ヘッドホンで歌うヒトカラを自作する――Mac の再生音からボーカルを消す自作アプリ「HeyaKara」の中身
導入:カラオケは、本当は自己満足のためにある
管理人は、カラオケは結局のところ自己満足のためにするものだと思っている。
もちろん、みんなでワイワイ楽しむのがメインの楽しみ方だろう。それでも、自分の好きな曲を、人の評価を気にせずに歌いたい――本当は誰もがそう思っているのではないだろうか。ヒトカラ(一人カラオケ)が当たり前になってきたのも、そういう理由だと思う。
しかし、普通のカラオケ店は音漏れがひどすぎる。気にしなければいいと言われるだろうが、自分は嫌なのだ。何を歌っているかを知られたくないし、音程を外したりうまくいかなかったりしたときに、つい独り言を言ってしまう。それも筒抜けになる。
ワンカラのように、ヘッドホンで歌うことで音漏れを気にせずに済む店もある。ただ、いかんせん店が少なすぎる。もっと近場で、同じことを実現したい。
そこで、ワンカラと同じ仕組みを自分の環境だけで作ることにして、実装は AI に任せた。この記事は、その実装内容の紹介である。実際には、このアプリとオーディオインターフェース、マイク、ヘッドホンを組み合わせて、一人カラオケをしている。
ただし、歌う場所は自分の部屋ではない。この一式を普通のカラオケ店の個室に持ち込んで使おうとしている。部屋で歌うと、声が騒音になりかねないからだ。店のスピーカーは使わず、曲はヘッドホンの中でしか鳴らない。自分の部屋でも歌えるように、別途環境を整えることも検討している。
何を作ったのか
アプリの名前は HeyaKara(ヘヤカラ)。「部屋でやるカラオケ」から取った。今はカラオケ店に持ち込んでいるが、いずれは名前のとおり自分の部屋で、という願いも込めている。Mac のメニューバーに常駐し、次のことをリアルタイムで行う。
- Mac で鳴っている特定のアプリの音(ミュージック、iPhone からの AirPlay、Safari や Chrome の YouTube など)を横取りする
- AI の音源分離でボーカルを消す(伴奏だけ、あるいはボーカルを少しだけ残すこともできる)
- キーを ±12 半音で変える。ボーカルと伴奏を 1 オクターブ違いにすることもできる
- 加工した音を、選んだ出力デバイス(オーディオインターフェース)から鳴らす
- ついでに、分離したボーカルから曲ごとの最高音を記録し、自分の音域に合う「おすすめキー」を出す
つまり、カラオケ用の音源(オフボーカル音源)を用意しなくても、手持ちの曲や配信の曲がそのままカラオケになる。Apple Music の「Apple Music Sing」も似たことができるが、非対応の曲があるため、分離は自前で行うことにした。
図:HeyaKara の音の流れ。曲はアプリが加工し、マイクの声はオーディオインターフェースが直接ヘッドホンへ返す。
機材の構成は図の上段のとおりだ。アプリが担当するのは曲の側だけで、マイクの声はアプリを通らない。声はオーディオインターフェースに入り、そこで伴奏と混ぜてヘッドホンへ返す。後で書くとおり、アプリの中では約 7 秒の遅れが出るが、自分の声には遅れが乗らないので、歌ううえでは困らない。
作り方:仕様書を書き、AI に段階的に実装させた
実装したのは AI(Claude Code)だ。管理人が書いたのは、SPEC.md という実装指示書である。最初に決めたことは次のとおり。
- 2 段階で作る。 Phase 1 は Python のプロトタイプで、分離品質・遅延・処理速度が実用になるかを最短で確かめる。Phase 2 で Swift のネイティブアプリにする。
- 各 Phase で止まる。 実装して受け入れ条件を検証し、報告して止まる。次に進むのは了承してから。
- 絶対要件を 3 つ置く。
- 音声をディスクに保存しない。 録音、書き出し、キャッシュ、デバッグ用のダンプも禁止。処理はすべてメモリ上で行う。配信コンテンツの利用規約を踏まえた要件だ。
- フィードバックループを作らない。 入力に使う仮想デバイスを、出力先に選べないようにする。
- モデルの重みやライブラリのライセンスを確認し、README に書く。
- 遅延より分離の品質を優先する。 聴いて歌うための再生なので、数秒の遅れは許容する。
このあと、実装 → 検証 → 管理人が実機で使って不満を伝える → 修正、というやり取りを何度も繰り返した。git の履歴には「Review round 1〜4」というレビュー修正の回が 4 回ある。以下の話の多くは、そのやり取りの中で分かったことだ。
仕組み 1:アプリの音だけを横取りする(Process Tap)
最初の関門は、「Mac で鳴っている曲を、どうやってアプリに取り込むか」だった。
Phase 1 のプロトタイプでは、仮想オーディオデバイスの BlackHole を使った。Mac のサウンド出力を BlackHole に切り替えると、Mac の音がすべてそこへ流れ込む。それを Python で読み、加工して本物のスピーカーへ出す。確実に動くが、使うたびにサウンド設定を切り替えて、終わったら戻す必要がある。
Phase 2 のネイティブアプリでは、macOS 14.2 から使える Core Audio の Process Tap に切り替えた。これは、特定のプロセスが出している音だけを横取りできる仕組みだ。
let desc = CATapDescription(stereoMixdownOfProcesses: processes.map(\.id))
desc.muteBehavior = .mutedWhenTapped
desc.isPrivate = true
try check(AudioHardwareCreateProcessTap(desc, &tap), "AudioHardwareCreateProcessTap")
ポイントは muteBehavior だ。.mutedWhenTapped にすると、アプリが音を読み取っている間だけ、元の音がミュートされる。だからシステムのサウンド出力を切り替える必要がない。スイッチをオンにすると、元の曲が消えて、加工後の曲だけが聞こえる。逆に、アプリがクラッシュするなどして読み取りが止まれば、元の音はすぐに戻る。
作ったタップと出力デバイスをまとめた「Aggregate Device」(複数のデバイスを 1 つに束ねる仮想デバイス)を作り、出力デバイスのクロックに揃える。どちらも private にしてあり、作ったプロセスと一緒に破棄される。実際に kill -9 でアプリを強制終了させても、元の音は即座に戻り、デバイスは残らなかった。
横取りする相手は、実機で 1 つずつ確かめた。
- ミュージックアプリ:そのまま選べる
- iPhone からの AirPlay:音を出しているのは「コントロールセンター」(
com.apple.controlcenter)だった。アプリの一覧では「AirPlay 受信(コントロールセンター)」として先頭側に出す - Safari:「Safari Graphics and Media」(
com.apple.WebKit.GPU)をタップする - Chrome:「Google Chrome Helper」をタップする。ほかのタブの音もまとめて対象になる
仕組み 2:AI でボーカルを消す(Demucs を Core ML で)
音源分離には、Meta の研究者が公開した Demucs(MIT ライセンス)を使った。曲をボーカル・ドラム・ベースなどに分けるモデルだ。
Python のプロトタイプでは PyTorch で動かしていた。ネイティブアプリでは PyTorch を抱え込みたくないので、ネットワーク部分だけを Core ML に変換し、前後の STFT(音を周波数成分に分解する処理)とその逆変換、正規化は Swift 側(Apple の vDSP)で書いた。実行は CPU と GPU。Neural Engine では出力が壊れたので使っていない。
変換して終わりではなく、Swift 版と PyTorch 版に同じ合成信号を通して結果を突き合わせた。既定のモデル(htdemucs_ft のボーカル用サブモデル)では、ボーカルの誤差は信号に対して約 −113dB。実用上、同じ出力と言っていい。
別の候補として、MDX-Net を ONNX Runtime で動かす方式も試作した。技術的には動いたが、重みのライセンスが明記されていなかったので採用しなかった。仕様書の絶対要件 3 が効いた場面だ。なお Demucs の重み自体も、モデルカードにライセンスの記載はなく、学習データには非商用向けのデータセットも含まれる。HeyaKara はモデルを同梱せず、各自がスクリプトで変換して使う前提にしてある。
6 秒ずつ切って、つなぐ
Demucs は曲全体を一度に処理する想定のモデルだ。リアルタイムで使うため、入力を 6 秒ずつ(1 秒ずつ重ねて) 切り出して分離し、重なった 1 秒をクロスフェードでつなぐ。
ここで一つ、実際に使って分かった問題があった。当初はクロスフェードに、よく使われる「等パワー」の曲線(cos と sin)を使っていた。ところが管理人から「5 秒ごとに音量が膨らむ」と報告が出た。
原因はこうだ。等パワーのクロスフェードは、つなぐ 2 つの信号が無関係な場合に音量が一定になるよう設計されている。ところが隣り合うチャンクの分離結果は、重なり区間ではほぼ同じ信号になる。同じ信号を cos と sin で重ねると、振幅が最大 √2 倍、つまり +3dB 膨らむ。実際の出力で測ると、境界で平均 +2.6dB 膨らんでいた。
曲線を cos² と sin²(足すと常に 1)に変えると、膨らみは −0.15dB に収まった。これを既定にした。仕様書では「等パワー」と指定していたので、仕様のほうが間違っていたことになる。
速さ
Mac Studio(M4 Max)で、6 秒ぶんの分離にかかる時間は約 0.26 秒。実時間の 4〜5% で処理できる。1 時間の連続運転(ソークテスト)でも、音切れ 0、メモリの増え続けもなかった。
途中、Core ML の出力の読み出しが遅いことにも気づいた。Core ML の出力はメモリ上で行ごとに少し隙間(パディング)が空いており、それを 1 要素ずつ読んでいたのが原因だった。行単位でまとめてコピーするように直し、6 ステム分離では 0.74 秒が 0.24 秒になった。
6 ステム分離
ボーカルだけでなく、ドラム・ベース・ギター・ピアノ・その他の 6 つに分けるモード(htdemucs_6s)もある。「ドラムだけ消す」「ベースだけ聴く」といった使い方ができる。ただしカラオケ用途では、伴奏へのボーカルの残り方が、ボーカル専用モデルのほうが少なかった。普段はボーカル分離モードを使う。
仕組み 3:キーを変える
キー変更は、最初は AI が位相ボコーダ(音の高さを変える古典的な手法)を自作した。GPL のライブラリ(Rubber Band)を避けるためだ。ところが管理人から「シャリシャリする」「音量が小さい」と苦情が出た。
手持ちの曲から分離したボーカルで測ると、キー −2 で音量が −2.2dB 下がっていた。原因は、スペクトルのピークを動かす量を整数に丸めていたのに、位相は正確な周波数で進めていたことだった。その食い違いで、重なり合う処理単位どうしが打ち消し合っていた。
最終的な構成はこうなった。
- キー変更そのものは、Apple 標準の
AVAudioUnitTimePitchに任せる - ボーカルだけ、フォルマント補正をかける。 キーを下げると声の響き(フォルマント)まで一緒に下がり、こもった声になる。それを元の響きに戻す後処理を足した
- 音量キーパーを足す。位相ボコーダは密な音で音量が落ちやすいので、入力と出力の音量を比べて、なめらかに補正する
ボーカルと伴奏を分離した後にキーを変えるので、ボーカルだけを別の高さにすることもできる。普通のカラオケでは、ボーカルと伴奏のキーをずらすことはまずない。想定しているのは、ボーカルと伴奏を 1 オクターブ違いにする使い方だ。たとえば女性の曲を男性が歌うとき、伴奏は原曲キーのまま、ガイドとして薄く残すボーカルだけを 1 オクターブ下げれば、自分が歌う高さでお手本を聴ける。修正後は、キーを変えても音量の変化は ±0.4dB 程度に収まった。
仕組み 4:曲ごとの音域を記録する
カラオケに行くと毎回悩むのが「この曲、何キー下げればいいか」だ。そこで、せっかく分離したボーカルを使い、曲の最高音を自動で記録するようにした。
- 分離したボーカルから、YIN 法(音の高さを推定する定番の方法)で 10ms ごとに音程を推定する
- しゃくりや一瞬の誤検出を除くため、0.15 秒以上続いた音だけを数え、合計 0.3 秒以上出てきた音のうち最も高いものを「最高音」とする
- 表記は国際式(A4 = 440Hz)と、カラオケでおなじみの「hiA」「mid2G」式を並べる
- 自分の最高音(地声と裏声)を登録しておくと、曲ごとに「おすすめキー」が出る。パネルの「適用」を押すと、そのままキーに反映される
保存するのは曲名と音の高さだけで、音声は保存しない。
地声と裏声を自動で見分ける機能も一度作った。倍音の構造から判定する方式だったが、実際の曲では精度が足りず、管理人の判断でやめた。地声・裏声の最高音は手で入力する。AI に作らせると何でも作れてしまうが、使い物にならないものは捨てる判断も必要になる。
曲名は、ミュージックアプリなら公開されている通知から取れる。AirPlay の曲名は、macOS 15.4 以降は非公開 API の MediaRemote が制限されていて取りにくい。オープンソースの mediaremote-adapter(BSD-3-Clause)を経由して読むことで、iPhone から AirPlay で流した曲の名前も記録できるようになった。
ハマったところ:サンプルレートの「申告」を信じてはいけない
一番厄介だったのは、キーを 0 にしているのに、曲のテンポと音程が約 8.8% 上がる不具合だった。
原因は、タップが申告するサンプルレートだった。出力デバイスが 44.1kHz で動いていて、実際に毎秒約 44,100 個のサンプルが届いているのに、タップとストリームは「48kHz」と申告していた。アプリはそれを信じて 48kHz として変換していたため、48 ÷ 44.1 ≒ 1.088 倍、つまり 8.8% 速く再生されていた。
対処は、タップの申告ではなく、Aggregate Device の実際の動作レートを使うこと。1kHz のテスト音を通すと、どの出力デバイスでも 999.9〜1000.1Hz に収まることを確かめた。
使ってみての制約
正直に書いておくべき制約もある。
- 約 7 秒遅れる。 6 秒ずつ分離するため、元の音から約 7 秒遅れて聞こえる。音だけを聴いて歌う分には問題ないが、YouTube のカラオケ動画のように画面に歌詞が出るものは、映像と音がずれる。
- キーの変更は数秒後に効く。 先読みしている分があるので、ボタンを押してから聞こえ方が変わるまでに時間がかかる。
- ブラウザはまるごと対象になる。 Chrome をタップすると、ほかのタブの音も一緒に加工・ミュートされる。
- ボーカルが残る曲もある。 曲によっては、分離しきれなかったボーカルがかすかに残る。
- Apple Silicon の Mac と macOS 14.2 以降が必要。 Process Tap を使うため。
まとめ
ワンカラの仕組みは、要するに「伴奏と自分の声をヘッドホンだけに流す」ことだ。HeyaKara は、そのうち「伴奏を作る」部分を AI の音源分離で置き換え、「ヘッドホンに流す」部分をオーディオインターフェースに任せた。
実装は AI にほぼ任せたが、「5 秒ごとに音量が膨らむ」「シャリシャリする」「テンポが速い」といった不具合は、実際に歌ってみて初めて分かったものばかりだった。AI は測って直すのは速いが、何がおかしいかに気づくのは、使う人間の耳だった。
近くにワンカラがなくても、普通のカラオケ店の個室にこの一式を持ち込めば、曲を外に漏らさずに、好きな曲を好きなキーで歌える。次は名前のとおり、自分の部屋でも歌える環境を整えたい。
キーワード:#HeyaKara#Process Tap#Demucs
コメント
まだコメントはありません。