ゼルダの伝説 鼻歌のオカリナ

Swift

鼻歌でオカリナ演奏が!!!発売までの練習にも最適🎶

どりー_ハックツ

推しアイデア

ゼルダの伝説 時のオカリナ リメイクの目玉新機能に鼻歌でオカリナ演奏できるものがあったため、それをiOSアプリで再現してみた!

作った背景

裏ハックツハッカソンのために作成。 テーマを時のオカリナリメイクから持ってきていたので、そのゲームそのものを題材に。

推し技術

- Swift - AudioKit - AudioKit EX

プロジェクト詳細

はじめに

開発経緯

ハックツハッカソン〜モサカップ〜2026〜 の裏で行われている 「裏ハックツハッカソン」 への提出作品。

テーマは表に倣って、 「伝説」「楽器」「時」「リメイク」のいずれか。

それらは、ゼルダの伝説 時のオカリナ リメイク を題材にしているため、今回はそのままゼルダの時のオカリナを題材に作ってみた。

前提知識

ゼルダの伝説 時のオカリナ リメイク では 目玉の新機能として、 「鼻歌でオカリナ演奏できる機能」 がある。 紹介動画

今回はその機能をiOSアプリで再現してみることにした。

アプリ概要 - フロル

鼻歌でオカリナ演奏ができるアプリ。

特徴

・鼻歌(音声入力)を認識し、ドレミ+オクターブを出力 ・入力された音階を8個まで記録し、特定のパターンとマッチした場合成功音と対応するbgmを再生する ・時のオカリナ原作にあったオカリナ演奏をカカシ以外全て実装

デモ動画

軽い動作とゼルダの子守歌

画面

デフォルト画面 スタートを押し、鼻歌(音声入力)をすると対応した音階が表示・記録される パターンマッチした場合「ごまだれ」と表示されて音声再生

実装 - ネール

鼻歌検出

オープンソースライブラリの AudioKit を用いて実装。

import Foundation import AudioKit import SoundpipeAudioKit import AudioKitEX internal import Combine class PitchDetector: ObservableObject { @Published var pitchString: String = "---" @Published var frequencyString: String = "0.0 Hz" @Published var confirmedNotesHistory: [Note] = [] private let engine = AudioEngine() private var mic: AudioEngine.InputNode? private var silence: Fader? private var pitchTap: PitchTap? init() { setupAudio() setupPatterns() } private func setupAudio() { // 1. マイク入力を取得 guard let input = engine.input else { print("マイク入力が見つかりません") return } self.mic = input // 2. ハウリング防止のため、マイク音声を消音(Fader)して出力に繋ぐ let silenceNode = Fader(input, gain: 0) self.silence = silenceNode engine.output = silenceNode // 3. PitchTapを設定(リアルタイムで周波数を取得) pitchTap = PitchTap(input) { [weak self] pitch, amplitude in guard let self = self else { return } // amplitude(音量)が一定以上の時だけ処理(ノイズ対策) guard amplitude.first ?? 0 > 0.2 else { DispatchQueue.main.async { self.pitchString = "---" self.frequencyString = "静かです" // 音が途切れたら履歴をクリアして表示をリセット self.noteHistory.removeAll() self.lastConfirmedNote = "---" } return } if let hz = pitch.first { // 周波数から現在の瞬間的な音階を取得 guard let currentNote = self.convertHzToNote(hz: hz) else { return } } } func start() { do { try engine.start() pitchTap?.start() } catch { print("AudioKitエンジンの起動に失敗: \(error)") } } func stop() { engine.stop() pitchTap?.stop() } // 周波数(Hz)を音階(ドレミ)に変換するロジック private func convertHzToNote(hz: Float) -> Note? { guard hz > 0 else { return nil } let noteNumber = 12 * log2(hz / 440.0) + 69 let roundedNote = Int(round(noteNumber)) let noteNames = ["ド", "ド#", "レ", "レ#", "ミ", "ファ", "ファ#", "ソ", "ソ#", "ラ", "ラ#", "シ"] let index = (roundedNote % 12 + 12) % 12 let octave = (roundedNote / 12) - 1 return Note(name: noteNames[index], octave: octave) } }

モデル

import Foundation struct Note: Equatable, Hashable { let name: String // "ド", "レ", "ミ" など let octave: Int // 2, 3, 4 などの数値 // 画面表示用(例: "ド(3)") var displayString: String { return "\(name)(\(octave))" } }

音階取得

↓でマイク入力があるとhzに周波数が格納され、convertHzToNote関数に引数として渡す

if let hz = pitch.first { // 周波数から現在の瞬間的な音階を取得 guard let currentNote = self.convertHzToNote(hz: hz) else { return } }

↓convertHzToNoteで周波数(Hz)データをドレミ&オクターブ情報に変換 12 * log2(hz / 440.0) でオクターブ情報を取得 440.0 は ラ(A4) を表しており、基準値として採用されている。 なぜ ラ(A4) なのかというと、音階の周波数で整数なのは ラ のみで、計算がしやすいからなのだとか。 また、 + 69 はMIDI規格への変換を表しており、ラ(A4) はMIDI規格では 69番目になっている。 hz = 440.0hz の時、69 = ラ(A4) になる計算式ということだ。 ただ音階(周波数)はグラデーションがある(周波数域の範囲で音階が決まる)ので、round関数を使って一番近い数値に丸めている。

あとはその丸めた数値を使って、 (roundedNote % 12 + 12) % 12 で ドレミを (roundedNote / 12) - 1 で オクターブを 算出している。

// 周波数(Hz)を音階(ドレミ)に変換するロジック private func convertHzToNote(hz: Float) -> Note? { guard hz > 0 else { return nil } let noteNumber = 12 * log2(hz / 440.0) + 69 let roundedNote = Int(round(noteNumber)) let noteNames = ["ド", "ド#", "レ", "レ#", "ミ", "ファ", "ファ#", "ソ", "ソ#", "ラ", "ラ#", "シ"] let index = (roundedNote % 12 + 12) % 12 let octave = (roundedNote / 12) - 1 return Note(name: noteNames[index], octave: octave) }

実際は鼻歌でなくても検出できるので、楽器でも原曲でも読み取ることができる。

音階を記録

原作に倣って8個まで記録できるように。

if let hz = pitch.first { // 周波数から現在の瞬間的な音階を取得 guard let currentNote = self.convertHzToNote(hz: hz) else { return } // 履歴に追加 self.noteHistory.append(currentNote.name) if self.noteHistory.count > self.requiredStabilityCount { self.noteHistory.removeFirst() // 古いデータを削除 } // 履歴が規定回数に達し、かつすべて同じ音階かチェック let isStable = self.noteHistory.count == self.requiredStabilityCount && self.noteHistory.allSatisfy { $0 == currentNote.name } DispatchQueue.main.async { self.frequencyString = String(format: "%.1f Hz", hz) if isStable { self.pitchString = currentNote.displayString // 💡 音が新しく確定した時の処理 if self.confirmedNotesHistory.last?.displayString != currentNote.displayString { self.addNoteToHistory(note: currentNote) } } else { self.pitchString = self.lastConfirmedNote } } }
// 音階を履歴に追加するロジック(最大8個・重複排除) private func addNoteToHistory(note: Note) { // 念のため、直前の音階と同じ場合は追加しない if confirmedNotesHistory.last == note { return } confirmedNotesHistory.append(note) // 最大8個を超えたらリセット if confirmedNotesHistory.count > 8 { clearHistory() } }

入力記録を安定させる工夫

下記変数により、連続で同じ音階を規定回数入力された時のみ記録されるように。 この値が小さすぎると少しの音やグラデーションの違いですぐ検出してしまうし、大きすぎると長く同じ音を出し続ける必要がある。 調整が非常に難しい。。。

private let requiredStabilityCount = 5

パターンマッチ

音階記録が原作通りの譜面になっていたらそれに対応した音声を再生する

classにパターンマッチのためのモデルを追加

init() { setupAudio() setupPatterns() } // 💡 別ファイルからプリセット配列を一括で読み込むように変更 private func setupPatterns() { // 自身の持つ audioService をプリセットに渡す self.melodyPatterns = MelodyPatternPreset.getAllPresets(audioService: self.audioService) }

モデル

import Foundation // 💡 判定で「期待する音」の条件を定義する構造体 struct ExpectedNote { let name: String // "ド", "レ", "ミ" など(Note.name と一致) let octaveOffset: Int // 最初の音の octave を基準とした高低差(0, 1, -1 など) } struct MelodyPattern { let id = UUID() let name: String let notes: [ExpectedNote] // 💡 期待する音の配列 let action: () -> Void }

パターンマッチ関数 原作準拠で1つでも間違えたら失敗

// 音階を履歴に追加するロジック(最大8個・重複排除) private func addNoteToHistory(note: Note) { //~~割愛~~ // パターン判定アクションを実行 self.checkMelodyPattern() //~~割愛~~ } // 特定の音階パターンになった際のアクション private func checkMelodyPattern() { for pattern in melodyPatterns { let length = pattern.notes.count let suffix = Array(confirmedNotesHistory.suffix(length)) // 履歴の数がパターンの長さに満たない場合は次のパターンへ guard suffix.count == length else { continue } // 基準となる最初の音のオクターブ(Note.octave)を取得 guard let baseOctave = suffix.first?.octave else { continue } // 💡 既存の Note モデルの命名に合わせて一括比較 let isPatternMatch = zip(suffix, pattern.notes).allSatisfy { note, target in let isNameMatch = note.name == target.name let isOctaveMatch = note.octave == baseOctave + target.octaveOffset return isNameMatch && isOctaveMatch } // 両方一致したらアクションを実行 if isPatternMatch { print("🎯 パターン合致: \(pattern.name)") pattern.action() clearHistory() self.pitchString = "ごまだれ" stop() break } else { print("❌ 【\(pattern.name)】不一致でした。") } } }

マッチング条件

Array(confirmedNotesHistory.suffix(length)) をすることで最新のlength文字から検索。 例:ドレミというパターンを検出したい時、8個まで保存されるので、途中でドレミがあればそれを検出する。 レラドレミ → ◯ レドラレミ → X

パターンマッチPreset

全文は長いので一部抜粋

import Foundation import AudioKit struct MelodyPatternPreset { static func getAllPresets(audioService: AudioPlayService) -> [MelodyPattern] { return [ MelodyPattern( name: "ゼルダの子守歌", notes: [ ExpectedNote(name: "シ", octaveOffset: 0), ExpectedNote(name: "レ", octaveOffset: 1), ExpectedNote(name: "ラ", octaveOffset: 0), ExpectedNote(name: "シ", octaveOffset: 0), ExpectedNote(name: "レ", octaveOffset: 1), ExpectedNote(name: "ラ", octaveOffset: 0) ], action: { print("ゼルダの子守歌") DispatchQueue.main.async { audioService.playWithIntro(next: "OcarinaZeldasLullaby.mp3") } } ), MelodyPattern( name: "サリアの歌", notes: [ ExpectedNote(name: "ファ", octaveOffset: 0), ExpectedNote(name: "ラ", octaveOffset: 0), ExpectedNote(name: "シ", octaveOffset: 0), ExpectedNote(name: "ファ", octaveOffset: 0), ExpectedNote(name: "ラ", octaveOffset: 0), ExpectedNote(name: "シ", octaveOffset: 0) ], action: { print("サリアの歌") DispatchQueue.main.async { audioService.playWithIntro(next: "OcarinaSariasSong.mp3") } } ), // ~~ 割愛 ~~ ] } }

音声再生

秘伝のタレ 1音目は必ず成功音になるので、固定再生→対応音声再生としている

import AVFoundation import Foundation final class AudioPlayService: NSObject, AVAudioPlayerDelegate { // 1つ目の固定音声ファイル名(拡張子付き) private let introductionFileName = "SongCorrect.wav" private var player: AVAudioPlayer? private var playbackQueue: [String] = [] /// 1つ目の固定音声を再生したあと、引数で渡された2つ目の可変音声を連続再生 func playWithIntro(next dynamicFileName: String) { stop() // キューに2つ目の可変ファイルをセット self.playbackQueue = [dynamicFileName] // 1つ目の固定ファイルを再生開始 play(fileName: introductionFileName) } func stop() { player?.stop() player = nil playbackQueue.removeAll() // 制御権を PitchDetector(AudioKit)側に完全に返却する do { try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } catch { print("AudioPlayService: セッションの解放に失敗:", error.localizedDescription) } } // play関数 func play(fileName: String) { let fileParts = fileName.split(separator: ".", maxSplits: 1).map(String.init) let resourceName = fileParts.first ?? fileName let fileExtension = fileParts.count > 1 ? fileParts[1] : "mp3" guard let url = Bundle.main.url( forResource: resourceName, withExtension: fileExtension ) else { print("Audio file not found:", fileName) playNextIfNeeded() return } do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try session.setActive(true) player = try AVAudioPlayer(contentsOf: url) player?.delegate = self player?.prepareToPlay() player?.play() } catch { print("Audio playback failed:", error.localizedDescription) playNextIfNeeded() } } private func playNextIfNeeded() { guard !playbackQueue.isEmpty else { print("All audio playbacks finished.") return } let nextFile = playbackQueue.removeFirst() play(fileName: nextFile) } // MARK: - AVAudioPlayerDelegate func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { playNextIfNeeded() } }

マイクとスピーカーの共存

そのまま実装するとマイク入力とスピーカー出力が同時にできない問題にぶち当たった。

そのため sessionのsetCategoryを .playAndRecordに設定し直した。 また、optionsを.defaultToSpeakerに変更し、内蔵スピーカー出力に固定した。

try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])

該当エラー

AURemoteIO.cpp:1710 AUIOClient_StartIO failed (2003329396) AVAEInternal.h:104 [AVAudioEngineGraph.mm:1549:Start: (err = PerformCommand(*ioNode, kAUStartIO, NULL, 0)): error 2003329396 AudioKitエンジンの起動に失敗: Error Domain=com.apple.coreaudio.avfaudio Code=2003329396 "(null)" UserInfo={failed call=err = PerformCommand(*ioNode, kAUStartIO, NULL, 0)}

元の設定

try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)

付録 - ディン

譜面一覧

使用音階:[レ, ファ, ラ, シ, レ↑(1オクターブ高)]

譜面一覧

ゼルダの子守歌

シ レ↑ ラ シ レ↑ ラ

サリアの歌

ファ ラ シ ファ ラ シ

エポナの歌

レ↑ シ ラ レ↑ シ ラ

太陽の歌

ラ ファ レ↑ ラ ファ レ↑

時の歌

ラ レ ファ ラ レ ファ

嵐の歌

レ ファ レ↑ レ ファ レ↑

森のメヌエット

レ レ↑ シ ラ シ ラ

炎のボレロ

ファ レ ファ レ ラ レ ラ レ

水のセレナーデ

レ ファ ラ シ

魂のレクイエム

レ ファ レ ラ ファ レ

闇のノクターン

シ ラ レ シ ラ ファ

光のプレリュード

レ↑ ラ レ↑ ラ シ レ↑

どうしても音が合わない時は

自分はこれで練習してました。 バーチャルピアノ

どりー_ハックツ

@Friedrich_buryu