今日のGitHubトレンド
spotify/portal-ai-plugins
Spotify PortalをClaude Code、Codex、Cursor、piなどのAIツールに統合するためのプラグイン集です。
Portal CLIを介して、認証設定、カタログ検索、サービス概要の作成、診断、アクションの実行といったワークフローをAI上で利用可能にします。
また、Claude Code向けに負荷の高い処理を安価なモデルに振り分けるshunt機能も提供しています。
Ported Spotify's "shunt" plugin to keep big files out of Claude Code's context. Notes from hardening it
Spotify open-sourced a Claude Code plugin called shunt (Apache-2.0, in their portal-ai-plugins repo). The idea is simple: when Claude tries to read a huge file whole, a hook blocks it and points it at a skill that sends the file to a cheaper model and returns only the answer. The file never lands in your main context.
The original calls Spotify's internal tooling, so I ported it to run on the local claude CLI with Haiku as the worker:
- A PreToolUse hook denies whole reads of files over 350 lines (Read with no offset/limit, or a bare cat/head/tail). Targeted reads, sed -n, pipes and redirects pass.
- bulk-reader: ask Haiku a question about big files, get bullets back.
- code-writer: Haiku drafts boilerplate copied from a reference file.
One real call on a 1,021-line file: ~19.6k input tokens went to Haiku for about $0.004, and my main session only saw a few lines of answer.
Things I learned hardening it today:
1. The hook has to fail open. Any parse error, missing dependency or crash must allow the call. Otherwise one bug blocks every Read and Bash. It also allows the read if the claude CLI isn't installed, since pointing at a tool that can't run just strands you.
2. Secret filtering is harder than it looks. Before sending a file to the worker it checks against a deny-list (.env*, keys, credential JSON). An independent review found that a path like link/../file could reach a secret through a symlink while the check looked at a different path, because it normalized ".." before following symlinks. Fixed, with a test.
3. head/tail parsing matters. "head -n 5 file" should be allowed. "tail +2 file" or "head -c 100 file" is effectively a whole-file read.
Honest caveat: on a normal day the hook barely fires, because most files are under the threshold. It earns its keep on big generated files, long diffs and questions across several files. It's not something you should feel every turn.
Upstream: github.com/spotify/portal-ai-plugins/tree/main/pl…
1 point
查服务还切 Portal?
Spotify 插件把 Portal 接进 Claude Code 等工具,搜目录、生成简报、调动作,动作有 dry-run 和确认。
适合已有 Portal 的团队,先跑 setup 和 doctor,别动生产。
先用它干什么,查服务还是简报?
#AI编程 #开发工具 @grok
spotify/portal-ai-plugins
5 likes · 222 views
用 TypeScript 开发 Spotify AI 插件,集成坑踩完,快速就能上手扩展。
spotify/portal-ai-plugins
2 likes · 25 views
PythonとAIでYouTubeの音声をMIDIに自動変換する方法【Google Colab・Basic Pitch】
はじめに
YouTubeで公開されている楽曲を聴いていて、その演奏をMIDIデータとして取り出せないかと考えたことがある。
MIDIデータがあれば、楽曲に含まれる音符をピアノロールで確認したり、DAWで編集したり、シンセサイザーの演奏データとして利用したりできる。
今回は、PythonとSpotifyが公開している音程推定AIのBasic Pitchを使い、YouTube動画の音声をMIDIファイルに変換するプログラムを作成する。
実行環境にはGoogle Colabを使用するため、ローカルPCにPythonの開発環境を構築する必要はない。
本記事では、以下の処理を自動化する。
YouTubeのURLを指定する
yt-dlpで動画から音声を取得する
FFmpegで音声をWAV形式に変換する
Basic Pitchで音声全体を解析する
音程・発音開始時刻・音の長さを推定する
MIDIファイルとCSVファイルを生成する
Google Colabからファイルをダウンロードする
ただし、音声からすべての音符を完全に復元できるわけではない。特に複数の楽器が同時に演奏される楽曲では、音程の検出漏れや誤検出が発生する。
1. 使用する技術
今回のプログラムでは、以下のライブラリとツールを使用する。
技術
役割
Python
プログラム全体の制御
Google Colab
Pythonの実行環境
yt-dlp
YouTube動画からの音声取得
FFmpeg
音声形式の変換
Spotify Basic Pitch
AIによる音程推定
TensorFlow Lite
学習済みモデルの推論
uv
Python実行環境とライブラリの管理
setuptools
一部の旧ライブラリとの互換性確保
1.1 yt-dlpとは
yt-dlpは、動画配信サイトなどのメディアを取得するためのオープンソースソフトウェアである。
今回のプログラムでは、YouTube動画から音声ストリームを取得するために使用する。
動画によってはWebMやM4A形式などで保存されるため、後続の解析処理に向けてFFmpegでWAV形式に変換する。
なお、YouTubeのアクセス制限などにより取得できない動画もある。利用する際は、ダウンロードと変換が許可されている音源を対象とする。
1.2 Spotify Basic Pitchとは
Basic PitchはSpotifyが公開している、音声をMIDIに変換するための機械学習モデルである。
通常の音声ファイルには、時間とともに変化する音圧に対応したサンプル値が格納されている。
一方、MIDIは音声の波形そのものではなく、音符に関する情報を記録する。
主な情報は以下のとおりである。
MIDIノート番号
音符の発音開始時刻
音符の終了時刻
ベロシティ
ピッチベンド
Basic Pitchは音声信号を解析し、こうした情報を推定する。
複数の音程が同時に鳴る場合にも対応しているが、特に単一楽器の録音を対象とした音程推定に適している。
1.3 TensorFlow Liteとは
TensorFlow Liteは、学習済みの機械学習モデルを比較的軽量な実行環境で推論するための仕組みである。
Basic PitchにはTensorFlow、TensorFlow Lite、ONNXなど複数のモデル形式が用意されている。
今回はGoogle ColabのPython環境との互換性を考慮し、Python 3.10とTensorFlow Liteを組み合わせる。
2. 音声からMIDIを生成する仕組み
ここでは、音声信号を音符データに変換する基本的な考え方を説明する。
2.1 音声信号を数値として扱う
デジタル音声は、一定の時間間隔で音声信号を標本化したデータである。
例えば、サンプリング周波数が22,050 Hzの場合、1秒間に22,050個のサンプルを記録する。
離散時間の音声信号を次のように表す。
x[n] = x\left(\frac{n}{f_s}\right)
ここで、
x[n] は離散時間信号
n はサンプル番号
f_s はサンプリング周波数
である。
音声の音程を解析するには、この時間領域の信号に含まれる周期的な特徴を調べる必要がある。
2.2 短時間フーリエ変換
音声の周波数成分を時間ごとに調べる代表的な手法が、短時間フーリエ変換(STFT)である。
X(m,k)=
\sum_{n=0}^{N-1}
x[n+mH]w[n]
e^{-j2\pi kn/N}
各記号は以下を表す。
x[n] は音声サンプル
w[n] は窓関数
N はFFTの窓長
H はホップ長
m は時間フレーム
k は周波数ビン
STFTによって、音声に含まれる周波数成分の時間変化を調べられる。
ただし、フーリエ変換によって得られた周波数成分を、そのままMIDIノートに変換できるわけではない。
実際の楽器音には基本周波数に加えて倍音が含まれ、複数の楽器音が重なる場合もある。
そのため、音程推定には複数の周波数成分の関係や、時間方向の特徴を考慮する必要がある。
なお、Basic Pitchの内部処理は単純なSTFTのピーク検出だけで構成されているわけではなく、学習済みニューラルネットワークを使用している。
2.3 周波数とMIDIノート番号
平均律では、音の周波数とMIDIノート番号の間に次の関係がある。
f=440\times 2^{\frac{n-69}{12}}
ここで、
f は周波数(Hz)
n はMIDIノート番号
440 HzはA4の基準周波数
である。
この式を変形すると、
n=69+12\log_2\left(\frac{f}{440}\right)
となる。
例えば、440 Hzの音に対応するMIDIノート番号は69である。
音名
周波数の目安
MIDIノート番号
C4
261.63 Hz
60
D4
293.66 Hz
62
E4
329.63 Hz
64
A4
440.00 Hz
69
C5
523.25 Hz
72
ただし、音声からMIDIを生成する場合には音高だけでは不十分である。
音符がいつ始まったのか、いつ終わったのかも推定しなければならない。
2.4 AIによる音符イベントの推定
Basic Pitchは音声から、音の立ち上がり、継続状態、ピッチ変化などを推定する。
これらの情報を利用して、音符イベントを生成する。
例えば、次のような音符情報が得られたとする。
開始時刻
終了時刻
音高
0.00秒
0.50秒
C4
0.50秒
1.00秒
E4
1.00秒
1.50秒
G4
これらをMIDIファイルに保存すれば、DAWなどでピアノロールとして表示できる。
3. Google Colabでプログラムを実行する
実行環境はGoogle Colabを使用する。
colab.research.google.com
新しいノートブックを作成し、次のコードを1つのコードセルに貼り付ける。
冒頭の YOUTUBE_URL に変換したいYouTube動画のリンクを指定する。
以下では、実装例としてYouTubeのURLを設定済みにしている。
4. Pythonプログラム全文
============================================================
YouTube to MIDI Converter
Google Colab用
#
YouTubeの音声をAIで解析してMIDIに変換する
#
使用ライブラリ
yt-dlp
FFmpeg
Spotify Basic Pitch
TensorFlow Lite
#
出力
MIDIファイル (.mid)
音符情報 (.csv)
============================================================
============================================================
0. YouTubeのリンクを設定
============================================================
YOUTUBE_URL = "youtu.be"
別の動画に変更する場合はURLを書き換える
============================================================
1. 必要なライブラリを準備
============================================================
import os
import sys
import subprocess
import tempfile
from pathlib import Path
from google.colab import files
def run_command(command):
"""コマンドを実行し、実行結果を表示する"""
result = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
print(result.stdout)
if result.returncode != 0:
raise RuntimeError(
f"コマンド実行失敗: {result.returncode}"
)
return result
print("ライブラリをインストール中...")
run_command([
sys.executable,
"-m", "pip",
"install",
"-q",
"-U",
"yt-dlp[default]",
"uv"
])
============================================================
2. Denoを準備
============================================================
print("YouTube音声取得環境を準備中...")
DENO_PATH = (
Path.home() / ".deno" / "bin" / "deno"
)
if not DENO_PATH.exists():
run_command([
"bash",
"-c",
"curl -fsSL deno.land | sh"
])
os.environ["PATH"] = (
str(DENO_PATH.parent)
- os.pathsep
- os.environ["PATH"]
)
print("Deno準備完了")
============================================================
3. URLの確認
============================================================
if not YOUTUBE_URL.startswith((
"youtube.com",
"youtube.com",
"youtu.be",
"music.youtube.com"
)):
raise ValueError(
"YouTubeのURLを確認してください"
)
============================================================
4. 作業フォルダを作成
============================================================
WORK_DIR = Path(
tempfile.mkdtemp(
prefix="youtube_midi_",
dir="/content"
)
)
OUTPUT_DIR = WORK_DIR / "output"
OUTPUT_DIR.mkdir(
parents=True,
exist_ok=True
)
print("作業フォルダ:", WORK_DIR)
============================================================
5. YouTubeから音声を取得
============================================================
print("YouTubeから音声を取得中...")
run_command([
sys.executable,
"-m", "yt_dlp",
"--no-playlist",
"-f", "bestaudio/best",
"-o",
str(WORK_DIR / "source.%(ext)s"),
YOUTUBE_URL
])
============================================================
6. ダウンロードした音声を確認
============================================================
audio_files = [
p for p in WORK_DIR.glob("source.*")
if p.is_file()
and p.suffix not in (
".part",
".ytdl"
)
]
if not audio_files:
raise RuntimeError(
"YouTube音声の取得に失敗しました"
)
AUDIO_PATH = audio_files[0]
print("音声取得完了")
print("ファイル:", AUDIO_PATH.name)
============================================================
7. 音声をWAV形式に変換
============================================================
print("WAV形式に変換中...")
WAV_PATH = WORK_DIR / "audio.wav"
run_command([
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", str(AUDIO_PATH),
"-vn",
"-ac", "1",
"-ar", "22050",
"-c:a", "pcm_s16le",
str(WAV_PATH)
])
if not WAV_PATH.exists():
raise RuntimeError(
"WAVファイルが生成されませんでした"
)
print("WAV変換完了")
============================================================
8. Basic Pitchの実行環境を設定
============================================================
print("AI音程推定環境を準備中...")
Python 3.10を使用
setuptoolsを固定してpkg_resourcesエラーを対策
TensorFlow Lite版を使用
BASIC_PITCH_COMMAND = [
"uv",
"run",
"--no-project",
"--python", "3.10",
"--with", "setuptools==80.9.0",
"--with", "basic-pitch==0.4.0",
"--with", "tflite-runtime==2.14.0",
"--with", "numpy<2",
"--",
"basic-pitch",
str(OUTPUT_DIR),
str(WAV_PATH),
"--model-serialization",
"tflite",
"--save-note-events"
]
============================================================
9. AIで音声全体を解析
============================================================
print("================================")
print("AIで音声全体を解析中...")
print("================================")
result = subprocess.run(
BASIC_PITCH_COMMAND,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
print(result.stdout)
if result.returncode != 0:
raise RuntimeError(
"Basic PitchによるMIDI変換に失敗しました。"
"上の実行ログを確認してください。"
)
print("AI音程解析完了")
============================================================
10. MIDIファイルを確認
============================================================
midi_files = sorted(
OUTPUT_DIR.glob("*.mid")
)
if not midi_files:
raise RuntimeError(
"MIDIファイルが生成されませんでした"
)
print("MIDI変換成功")
for midi in midi_files:
print(
"MIDIファイル:",
midi.name
)
============================================================
11. MIDIファイルをダウンロード
============================================================
print("MIDIファイルをダウンロード中...")
for midi in midi_files:
files.download(
str(midi)
)
============================================================
12. CSV形式の音符情報もダウンロード
============================================================
csv_files = sorted(
OUTPUT_DIR.glob("*.csv")
)
for csv_file in csv_files:
files.download(
str(csv_file)
)
============================================================
13. 完了
============================================================
print("================================")
print("YouTube to MIDI 変換完了")
print("================================")
print("保存先:", OUTPUT_DIR)
print("生成されたMIDI:")
for midi in midi_files:
print(midi.name)
print("すべての処理が終了しました")
このコードはGoogle Colab上での動作を想定しているが、全処理の実行検証は完了していない。PythonパッケージやYouTubeの仕様変更によって修正が必要になる可能性がある。
5. プログラムの解説
5.1 YouTubeのURLを設定する
プログラム冒頭の次の部分で、変換対象の動画を指定する。
YOUTUBE_URL = "youtu.be"
URLはPythonの文字列として指定する必要がある。
Markdownのリンク記法をそのままPythonコードに貼り付けると、SyntaxErrorが発生するので注意する。
5.2 yt-dlpで音声を取得する
run_command([
sys.executable,
"-m", "yt_dlp",
"--no-playlist",
"-f", "bestaudio/best",
"-o", str(WORK_DIR / "source.%(ext)s"),
YOUTUBE_URL
])
この部分では、yt-dlpを使ってYouTube動画の音声を取得している。
--no-playlist はプレイリスト全体をダウンロードしないための指定である。
-f bestaudio/best は音声を優先して取得するための指定である。
また、ファイル名の拡張子は取得したフォーマットによって決まる。
例えば、WebM形式の音声が取得された場合は、source.webmというファイルが生成される。
5.3 FFmpegでWAV形式に変換する
run_command([
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", str(AUDIO_PATH),
"-vn",
"-ac", "1",
"-ar", "22050",
"-c:a", "pcm_s16le",
str(WAV_PATH)
])
この処理では、取得した音声をWAV形式に変換する。
各オプションの意味は以下のとおりである。
オプション
意味
-i
入力ファイルの指定
-vn
動画ストリームを除外
-ac 1
モノラル音声へ変換
-ar 22050
サンプリング周波数22,050 Hz
-c:a pcm_s16le
16ビット符号付きPCMで保存
-y
既存ファイルを上書き
ここでサンプリング周波数を22,050 Hzにしているが、これだけで音程推定が完成するわけではない。
次のBasic Pitchによる推論処理が必要になる。
なお、Basic Pitch側では必要に応じて音声の再サンプリングなどが行われる。
5.4 Basic Pitchを実行する
MIDI変換の中心となるのが、次の部分である。
BASIC_PITCH_COMMAND = [
"uv", "run",
"--no-project",
"--python", "3.10",
"--with", "setuptools==80.9.0",
"--with", "basic-pitch==0.4.0",
"--with", "tflite-runtime==2.14.0",
"--with", "numpy<2",
"--",
"basic-pitch",
str(OUTPUT_DIR),
str(WAV_PATH),
"--model-serialization", "tflite",
"--save-note-events"
]
Google Colabの標準Python環境とBasic Pitchの対応バージョンが一致しない場合がある。
そのため、uvを使ってPython 3.10の独立した実行環境を用意している。
--model-serialization tflite は、TensorFlow Lite形式のモデルを使用するための指定である。
--save-note-events を指定すると、MIDIに加えて推定した音符イベントをCSV形式でも出力できる。
5.5 MIDIファイルをダウンロードする
midi_files = sorted(
OUTPUT_DIR.glob("*.mid")
)
for midi in midi_files:
files.download(str(midi))
Basic Pitchが生成したMIDIファイルを取得し、Google Colabのダウンロード機能で保存している。
生成されたファイルは、MIDI対応のDAWやピアノロール表示ソフトで読み込める。
6. 実行時に発生したエラーと対策
今回の実装では、Python環境とライブラリの互換性について注意が必要だった。
6.1 Pythonのバージョン問題
Google ColabのPythonバージョンは更新されることがある。
一方、今回使用するBasic Pitch 0.4.0は、Python 3.10や3.11を含む環境を対象としている。
対応していないPythonバージョンに直接インストールすると、依存ライブラリの解決で失敗する可能性がある。
この問題に対して、今回のコードでは次の指定を使用している。
"uv", "run",
"--no-project",
"--python", "3.10",
ColabのPython自体を変更せず、Basic Pitchだけを別のPython環境で実行する方法である。
6.2 pkg_resourcesが見つからない問題
実装中、次のエラーが発生した。
ModuleNotFoundError:
No module named 'pkg_resources'
これは、Basic Pitchが依存するresampyがpkg_resourcesを読み込もうとした際に発生したエラーである。
pkg_resourcesは従来setuptoolsに含まれていたが、新しいsetuptoolsでは削除されている。
そこで、今回のコードでは次のようにバージョンを固定した。
"--with", "setuptools==80.9.0",
この指定は旧APIとの互換性を確保するための対策である。将来的にはライブラリ側の更新に合わせた対応が望ましい。
6.3 TensorFlow関連の警告
実行時に次のような警告が表示されることがある。
Tensorflow is not installed.
Coremltools is not installed.
onnxruntime is not installed.
Basic Pitchが複数の推論エンジンに対応していることに関連する警告である。
今回のプログラムではTensorFlow Liteを明示的に使用しているため、これらの警告だけで必ずしも変換失敗を意味するわけではない。
ただし、TensorFlow Lite側のモデル読み込みに失敗している場合は、別途エラー内容の確認が必要である。
6.4 YouTubeの音声を取得できない場合
yt-dlpを使用していても、すべての動画を取得できるとは限らない。
例えば、動画のアクセス制限、YouTube側の仕様変更、Colabからのアクセス制限などが原因となることがある。
この場合は、yt-dlpの実行ログを確認する。
今回のコードではDenoをインストールし、YouTubeのJavaScript処理に対応する構成としているが、取得成功を保証するものではない。
7. 変換精度の限界
音声からMIDIへの変換は、音響信号から音符を推定する問題である。
そのため、元のMIDIファイルを完全に復元する処理とは異なる。
7.1 単一楽器の演奏
ピアノやギターなどの単一楽器を録音した音源では、音程の推定が比較的行いやすい。
特に、録音状態が良く、音符の立ち上がりが明確な場合は解析しやすくなる。
7.2 複数の楽器による演奏
バンド演奏では、ギター、ベース、ドラム、ボーカルなどが同時に鳴る。
この場合、各楽器の周波数成分が重なり、音程の推定が難しくなる。
例えば、エレキギターの倍音が別の音符として誤認識されることがある。
また、ドラムのような非調波的な音は、通常の音高を持つ楽器とは性質が異なるため、Basic Pitchだけで正確なドラムMIDIを生成することは難しい。
7.3 MIDIは元の音色を保存しない
MIDIファイルには、元の音声波形がそのまま保存されるわけではない。
そのため、エレキギターの音声をMIDIに変換しても、ディストーションの音色やアンプの特性まで再現されるわけではない。
MIDIから音を再生するには、別途シンセサイザーや音源が必要になる。
8. 今後の拡張
今回のプログラムは、YouTubeの音声を一括してMIDIに変換する構成である。
さらに精度を高めたい場合は、音源分離技術を組み合わせる方法が考えられる。
例えば、Demucsなどの音源分離モデルを使って楽曲をボーカル、ドラム、ベース、その他の成分に分離し、その後で音程推定を行う方法である。
音源分離そのものにも誤差はあるが、混合音源をそのまま解析する場合とは異なるアプローチが可能になる。
また、次のような拡張も考えられる。
楽器別MIDIファイルの生成
MIDIのピアノロール可視化
ノートイベントの重複除去
BPM推定と音符のクオンタイズ
MIDIノート番号と周波数の比較
FFT・スペクトログラム・MIDIの同時可視化
特に、音声のスペクトログラムとMIDIのピアノロールを比較できれば、デジタル信号処理の学習教材としても利用できる。
9. まとめ
今回は、PythonとSpotify Basic Pitchを使い、YouTube動画の音声全体をMIDIデータに変換するプログラムを実装した。
プログラムでは、yt-dlpによる音声取得、FFmpegによるWAV変換、Basic Pitchによる音程推定、MIDIとCSVの出力を組み合わせている。
また、Google ColabのPython環境との互換性を考慮し、uvを使ってPython 3.10の実行環境を用意した。
音声をMIDIに変換する技術は、単なる楽曲の採譜だけでなく、音響信号処理、機械学習、デジタルシンセサイザー、音楽情報検索などとも関係している。
今回のプログラムは、その仕組みを実際の音源を使って学ぶための出発点になる。
なお、コードはColabでの一連の動作確認が完了していないため、実際の利用時には生成結果と実行ログを確認する必要がある。
参考資料
Spotify Basic Pitch - GitHub
Basic Pitch - PyPI
yt-dlp - GitHub
FFmpeg Documentation
Google Colab
uv Documentation
TensorFlow Lite
மனதில் ஒலிக்கும் இசையை MIDI ஆக மாற்றுங்கள். Basic Pitch குரலையோ வாத்தியத்தையோ கேட்டு நோட்களை எழுதித் தருகிறது. எந்தப் பாடலை முதலில் மாற்றிப் பார்ப்பீர்கள் ?
spotify/basic-pitch
Instagram-லும் பின்தொடருங்கள்: instagram.com
2 likes · 470 views
@_anderberg basic pitch includes a core ml model that extracts notes across instruments. could be useful for comparing the same idea hummed vs played on guitar, alongside sparks' key and tempo matching
spotify/basic-pitch
2 likes · 16 views
@awilkinson @BrentBeshore Been doing this for a while. Hard to go back! 😀 spotify/save-to-spotify
2 likes · 202 views
I turned a hummed melody into MIDI without uploading anything
How I run Spotify's basic-pitch audio-to-MIDI model entirely in the browser
I built an audio-to-MIDI converter where your audio never leaves your machine. Drop in a guitar riff, a hummed melody, or a piano recording, and you get an editable MIDI file back — no upload, no server, no queue. It runs Spotify's open-source basic-pitch model locally with TensorFlow.js.
[DEMO GIF]
Try it: **tunenoodle.com
Two things pushed me client-side. Musicians are (rightly) protective of unreleased audio, and "upload your track to our servers" is a trust cost I didn't want to pay. The second reason is less noble: client-side inference means no GPU bill. The model is ~0.9 MB, modern browsers have everything else needed, so the tradeoff was easy.
Here's what the pipeline actually looks like.
Step 1 — Let the browser's audio graph do the decode and resample
basic-pitch expects mono audio at 22.05 kHz. Writing a resampler for that is unnecessary — the Web Audio API already has one. decodeAudioData handles any input format the browser can play (mp3, wav, ogg, m4a…), and an OfflineAudioContext with a 22,050 Hz sample rate does the downmix and resample in a single pass:
const decoded = await decodeFile(file) // decodeAudioData underneath
const offline = new OfflineAudioContext(1, Math.ceil(decoded.duration * 22050), 22050)
const source = offline.createBufferSource()
source.buffer = decoded
source.connect(offline.destination)
source.start()
// Float32Array, mono, 22.05 kHz — exactly what the model wants
const samples = (await offline.startRendering()).getChannelData(0)
Three lines of setup replaced what would have been a page of DSP code, and it reuses the browser's highly optimized resampler.
Step 2 — Hand the samples to a Web Worker without copying them
Inference takes seconds. That cannot run on the main thread, and a couple of minutes of audio is megabytes of Float32Array that I don't want to structured-clone. postMessage with a transfer list gives zero-copy handoff:
const worker = new Worker(
new URL('./audio-to-midi.worker.ts', import.meta.url),
{ type: 'module' },
)
worker.postMessage(
{ samples, threshold, minFrames, modelUrl: '/models/basic-pitch/model.json' },
[samples.buffer], // transferred, not copied
)
One bundler note: wrapping the worker path in new URL(..., import.meta.url) is what makes Vite emit it as a proper module worker instead of silently breaking on build.
Step 3 — Running basic-pitch in the worker
I self-host the model (~0.9 MB: model.json plus the weight shard) from my own origin rather than a CDN. Same-origin means it's cacheable, there's no third-party CORS or uptime dependency, and it keeps working under a strict CSP.
Inside the worker, the interesting part is short:
import { BasicPitch, noteFramesToTime, outputToNotesPoly } from '@spotify/basic-pitch'
import * as tf from '@tensorflow/tfjs'
await tf.setBackend('cpu')
await tf.ready()
const model = new BasicPitch(modelUrl)
const frames: number[][] = []
const onsets: number[][] = []
try {
await model.evaluateModel(
samples,
(f, o) => { frames.push(...f); onsets.push(...o) },
progress => self.postMessage({ progress }), // model reports progress per window
)
const notes = outputToNotesPoly(frames, onsets, threshold, 0.3, minFrames)
self.postMessage({ notes: noteFramesToTime(notes) })
} finally {
(await model.model).dispose()
}
A few deliberate choices:
- CPU backend, forced. This is a one-shot batch job, and in a worker the CPU backend is the predictable one — no GPU context setup, no graphics-memory pressure competing with the page. For a model this size, the speed difference is measured in seconds, not minutes.
- Progress comes free. evaluateModel calls back per analysis window, so I forward those numbers to the UI and the progress bar is honest.
- dispose() in a finally. TF.js tensors are not garbage-collected promptly. Skip this and the third or fourth conversion in a session starts eating hundreds of MB.
Step 4 — From frame activations to notes
The model outputs per-frame pitch activations and onset activations. outputToNotesPoly turns them into note events, and three parameters matter for perceived quality:
const notes = noteFramesToTime(outputToNotesPoly(frames, onsets, threshold, 0.3, minFrames))
- threshold is the confidence a pitch must reach — this is the sensitivity slider in the UI.
- 0.3 is the onset threshold, kept fixed.
- minFrames is how many consecutive frames a pitch must persist before it counts as a note. This is the parameter that kills flickering false positives on noisy input.
Then I map the notes to a MIDI document: round and clamp the pitch to the MIDI range, derive velocity from the note amplitude, and reject anything above 10,000 notes. The output lands in an in-browser MIDI editor where you can fix wrong notes and export a .mid file.
The unglamorous parts that matter in production
- Cancellation is terminate(). A running evaluateModel loop won't politely stop, so I wire an AbortSignal straight to worker.terminate(). When a user drops the wrong 10-minute file, they should not wait for it.
- "No notes found" is an outcome, not a bug. Silence, speech, and heavily percussive material legitimately produce nothing. It gets a specific error message and a hint ("try a clearer, more melodic recording"), not a silent failure.
- Cap everything. A 20 MB limit before decoding, and transcription results above 10,000 notes get rejected outright. Both exist because a user will absolutely feed it a two-hour podcast.
The same pattern runs MP3 export
The converter's sibling feature — exporting a selection as MP3 — uses the identical worker + transferable pattern with lamejs. Two details from that side that cost me an afternoon:
const encoder = new Mp3Encoder(channels, sampleRate, 192) // kbps
for (let at = from; at < to; at += 1152) { // 1152 samples = one MP3 frame
const pcm = Int16Array.from(chunk, v => {
const c = Math.max(-1, Math.min(1, v))
return Math.round(c * (c < 0 ? 32768 : 32767)) // asymmetric on purpose
})
out.push(encoder.encodeBuffer(pcm))
}
out.push(encoder.flush())
Feed encodeBuffer exactly 1152 samples at a time — that's the MP3 frame size, and the encoder's buffering gets weird if you guess wrong. And convert float to Int16 asymmetrically: −1.0 should map to −32768, but +1.0 must clamp to 32767, or your loudest frames overflow.
Try it
The tool is live at **tunenoodle.com — it also pairs with a MIDI editor, a sheet-music scanner, and a MusicXML player if you want to go from recording to notation.
If you build something similar, or if you've hit the WebGL-vs-CPU tradeoff on TF.js in workers, I'd like to hear how you handled it in the comments.
1 reaction
Humming to MIDI in the Browser with Basic Pitch and Web Workers
You can turn a short hummed recording into MIDI in the browser with Spotify's Basic Pitch. Decode the audio, resample it to mono at 22,050 Hz, run the model, then encode its detected notes. The result needs a listen and usually some editing.
I build SoundToMIDI around that workflow. One design decision turned out to matter as much as the model: moving inference into a Web Worker so the interface could respond while a conversion was running.
The use case is familiar. In this r/hardstyle thread, a producer describes recording a melody, converting it, then spending too long correcting the notes. That is the job here: get an idea into a DAW without entering every note by hand.
From recording to note events
Local file
→ Web Audio decoding
→ mono PCM at 22,050 Hz
→ Basic Pitch inference
→ onset/frame post-processing
→ note events in seconds
→ MIDI encoding
→ piano-roll inspection and download
Basic Pitch supports polyphonic notes, though Spotify recommends one instrument at a time. Start with a solo hum. MIDI records note events; it won't preserve the sound of your voice.
Let AudioContext decode at the device's supported rate, then use a mono OfflineAudioContext to prepare the model input. Forcing the decoding context to 22,050 Hz can cause problems on some devices.
async function decodeAndResample(file) {
const context = new AudioContext();
let decoded;
try {
decoded = await context.decodeAudioData(await file.arrayBuffer());
} finally {
await context.close();
}
const sampleRate = 22050;
const offline = new OfflineAudioContext(
1,
Math.max(1, Math.ceil(decoded.duration * sampleRate)),
sampleRate,
);
const source = offline.createBufferSource();
source.buffer = decoded;
source.connect(offline.destination);
source.start();
const rendered = await offline.startRendering();
return Float32Array.from(rendered.getChannelData(0));
}
This applies Web Audio's channel-mixing rules. Stereo phase cancellation can affect the mono result. A mono recording avoids that surprise. File extensions can be misleading too: a supported container may hold a codec the browser cannot decode.
For a prototype in a browser project with a bundler such as Vite, install:
npm install @spotify/basic-pitch@1.0.1 @tonejs/midi@2
Copy the model assets from basic-pitch-ts into public/model/, preserving the paths between model.json and its weight shards. Add this to the same module:
import {
BasicPitch,
outputToNotesPoly,
noteFramesToTime,
} from '@spotify/basic-pitch';
import { Midi } from '@tonejs/midi';
const engine = new BasicPitch('/model/model.json');
export async function fileToMidi(file, onProgress = () => {}) {
const pcm = await decodeAndResample(file);
const frames = [];
const onsets = [];
await engine.evaluateModel(
pcm,
(f, o) => {
frames.push(...f);
onsets.push(...o);
},
onProgress,
);
const minFrames = Math.max(1, Math.round(0.130 * (22050 / 256)));
const events = noteFramesToTime(
outputToNotesPoly(frames, onsets, 0.5, 0.3, minFrames),
);
if (!events.length) throw new Error('No pitched notes detected.');
const midi = new Midi();
midi.header.setTempo(120);
const track = midi.addTrack();
track.name = 'Transcribed melody';
for (const note of events) {
track.addNote({
midi: note.pitchMidi,
time: note.startTimeSeconds,
duration: Math.max(0.02, note.durationSeconds),
velocity: Math.max(0, Math.min(1, note.amplitude)),
});
}
return { bytes: midi.toArray(), events };
}
Pass a File from an <input type="file"> to fileToMidi. Turn its bytes into an audio/midi Blob for download, and revoke the object URL when finished.
The 0.5 onset threshold controls note starts; 0.3 controls sustained pitch activity. The minimum duration is in frames, so 130 ms becomes roughly 11 at 22050 / 256 frames per second. Raising these controls may remove breath-triggered blips, but can also lose quiet or short intended notes. Change one setting and compare the same phrase.
This prototype runs on the main thread. It is enough to inspect a short clip, but the application needs a different execution boundary.
Why an async conversion can still freeze Cancel
Awaiting a Promise doesn't move synchronous inference kernels off the main thread. SoundToMIDI keeps decoding and native resampling outside the Worker, and puts TensorFlow, inference, note extraction, and MIDI encoding inside it.
The boundary passes PCM and settings:
const worker = new Worker(
new URL('./transcription-worker.js', import.meta.url),
{ type: 'module' },
);
// After a separate model-load request has completed:
worker.postMessage(
{
id: 42,
type: 'transcribe',
mono: pcm,
options: { onsetThreshold: 0.5, frameThreshold: 0.3 },
},
[pcm.buffer],
);
Transferring pcm.buffer avoids a copy and detaches it from the sender. Keep the decoded audio if the user may retry.
Cancel terminates the Worker and rejects pending requests. Retry creates a new one, which must load the model again. A cancellation flag alone can't interrupt a kernel that hasn't returned.
There is a race to test here: cancel, retry immediately, then receive a late response from the old Worker. Check the Worker instance as well as the request ID before accepting a response. An obsolete result must not complete the new request.
The memory bill follows the recording
Basic Pitch 1.0.1 uses overlapping windows. The application's inference loop processes one window at a time and releases its temporary tensors after reading the values. Leading padding and overlap trimming must match the reference implementation, or window boundaries can alter the output.
Use tf.tidy for synchronous tensor work, then dispose returned tensors in finally after awaiting their arrays. An async callback inside tf.tidy won't manage tensor lifetime across an await.
The decoded audio and accumulated frame outputs still grow with clip length. Windowing doesn't make this a constant-memory streaming system. I prefer short selections for this workflow.
The model runs locally, but the browser downloads code and weights. Local audio processing doesn't make the page network-free.
Eight intended notes can become nine
On October 8, 2026, we reran the published synthetic fixtures in desktop Chrome using the live converter. These are eight-second WAVs at 22,050 Hz. Settings were onset 0.5, frame 0.3, minimum length 130 ms, tempo 120 BPM, and pitch bends off:
| Input | Intended pitched events | Raw output events | Extra events |
| --- | ---: | ---: | ---: |
| Isolated melody | 8 | 9 | 1 |
| Chords with noise percussion | 12 | 15 | 3 |
| Unpitched noise percussion | 0 | 0 | 0 |
Every pitched source event had a same-pitch onset match within 150 ms, used once. We didn't score duration or expression. These synthetic fixtures cannot establish accuracy on humming. The noise-only clip produced no downloadable MIDI.
The melody's extra D5 was an octave above a source D4. Harmonic confusion is a possible explanation; the octave relationship doesn't prove the cause. The source audio and original September 28 results are public. That earlier melody run produced ten events, which is another reason to record the test date and settings.
Check repeated notes and phrase endings when you try your own recording. Also remember that writing 120 BPM into a MIDI header doesn't detect the recording's beat grid. This code writes notes in seconds against a chosen tempo map.
I leave pitch bends out of the prototype. Ordinary MIDI bends affect a channel, so overlapping notes can't all bend independently on a single channel. Cleanup rules can shorten overlaps or remove blips, but they don't know the intended melody. Keep the raw events available.
Record 10–20 seconds without backing music, leave gaps between repeated notes, and try the humming-to-MIDI workflow. Raw MIDI downloads are free; optional paid exports are separate. Listen beside the piano roll before importing it into a DAW. That last check catches errors that a successful download won't.
References
- Spotify Basic Pitch: model, scope, and limitations
- Basic Pitch TypeScript implementation and model assets
- Web Workers and transferable objects
- TensorFlow.js: tensor management
3 comments
Mentions
今日のGitHubトレンド spotify/portal-ai-plugins Spotify PortalをClaude Code、Codex、Cursor、piなどのAIツールに統合するためのプラグイン集です。 Portal CLIを介して、認証設定、カタログ検索、サービス概要の作成、診断、アクションの実行といったワークフローをAI上で利用可能にします。 また、Claude Code向けに負荷の高い処理を安価なモデルに振り分けるshunt機能も提供しています。
Ported Spotify's "shunt" plugin to keep big files out of Claude Code's context. Notes from hardening it Spotify open-sourced a Claude Code plugin called shunt (Apache-2.0, in their portal-ai-plugins repo). The idea is simple: when Claude tries to read a huge file whole, a hook blocks it and points it at a skill that sends the file to a cheaper model and returns only the answer. The file never lands in your main context. The original calls Spotify's internal tooling, so I ported it to run on the local claude CLI with Haiku as the worker: - A PreToolUse hook denies whole reads of files over 350 lines (Read with no offset/limit, or a bare cat/head/tail). Targeted reads, sed -n, pipes and redirects pass. - bulk-reader: ask Haiku a question about big files, get bullets back. - code-writer: Haiku drafts boilerplate copied from a reference file. One real call on a 1,021-line file: ~19.6k input tokens went to Haiku for about $0.004, and my main session only saw a few lines of answer. Things I learned hardening it today: 1. The hook has to fail open. Any parse error, missing dependency or crash must allow the call. Otherwise one bug blocks every Read and Bash. It also allows the read if the claude CLI isn't installed, since pointing at a tool that can't run just strands you. 2. Secret filtering is harder than it looks. Before sending a file to the worker it checks against a deny-list (.env*, keys, credential JSON). An independent review found that a path like link/../file could reach a secret through a symlink while the check looked at a different path, because it normalized ".." before following symlinks. Fixed, with a test. 3. head/tail parsing matters. "head -n 5 file" should be allowed. "tail +2 file" or "head -c 100 file" is effectively a whole-file read. Honest caveat: on a normal day the hook barely fires, because most files are under the threshold. It earns its keep on big generated files, long diffs and questions across several files. It's not something you should feel every turn. Upstream: github.com/spotify/portal-ai-plugins/tree/main/pl…
查服务还切 Portal? Spotify 插件把 Portal 接进 Claude Code 等工具,搜目录、生成简报、调动作,动作有 dry-run 和确认。 适合已有 Portal 的团队,先跑 setup 和 doctor,别动生产。 先用它干什么,查服务还是简报? #AI编程 #开发工具 @grok spotify/portal-ai-plugins
用 TypeScript 开发 Spotify AI 插件,集成坑踩完,快速就能上手扩展。 spotify/portal-ai-plugins
PythonとAIでYouTubeの音声をMIDIに自動変換する方法【Google Colab・Basic Pitch】 はじめに YouTubeで公開されている楽曲を聴いていて、その演奏をMIDIデータとして取り出せないかと考えたことがある。 MIDIデータがあれば、楽曲に含まれる音符をピアノロールで確認したり、DAWで編集したり、シンセサイザーの演奏データとして利用したりできる。 今回は、PythonとSpotifyが公開している音程推定AIのBasic Pitchを使い、YouTube動画の音声をMIDIファイルに変換するプログラムを作成する。 実行環境にはGoogle Colabを使用するため、ローカルPCにPythonの開発環境を構築する必要はない。 本記事では、以下の処理を自動化する。 YouTubeのURLを指定する yt-dlpで動画から音声を取得する FFmpegで音声をWAV形式に変換する Basic Pitchで音声全体を解析する 音程・発音開始時刻・音の長さを推定する MIDIファイルとCSVファイルを生成する Google Colabからファイルをダウンロードする ただし、音声からすべての音符を完全に復元できるわけではない。特に複数の楽器が同時に演奏される楽曲では、音程の検出漏れや誤検出が発生する。 1. 使用する技術 今回のプログラムでは、以下のライブラリとツールを使用する。 技術 役割 Python プログラム全体の制御 Google Colab Pythonの実行環境 yt-dlp YouTube動画からの音声取得 FFmpeg 音声形式の変換 Spotify Basic Pitch AIによる音程推定 TensorFlow Lite 学習済みモデルの推論 uv Python実行環境とライブラリの管理 setuptools 一部の旧ライブラリとの互換性確保 1.1 yt-dlpとは yt-dlpは、動画配信サイトなどのメディアを取得するためのオープンソースソフトウェアである。 今回のプログラムでは、YouTube動画から音声ストリームを取得するために使用する。 動画によってはWebMやM4A形式などで保存されるため、後続の解析処理に向けてFFmpegでWAV形式に変換する。 なお、YouTubeのアクセス制限などにより取得できない動画もある。利用する際は、ダウンロードと変換が許可されている音源を対象とする。 1.2 Spotify Basic Pitchとは Basic PitchはSpotifyが公開している、音声をMIDIに変換するための機械学習モデルである。 通常の音声ファイルには、時間とともに変化する音圧に対応したサンプル値が格納されている。 一方、MIDIは音声の波形そのものではなく、音符に関する情報を記録する。 主な情報は以下のとおりである。 MIDIノート番号 音符の発音開始時刻 音符の終了時刻 ベロシティ ピッチベンド Basic Pitchは音声信号を解析し、こうした情報を推定する。 複数の音程が同時に鳴る場合にも対応しているが、特に単一楽器の録音を対象とした音程推定に適している。 1.3 TensorFlow Liteとは TensorFlow Liteは、学習済みの機械学習モデルを比較的軽量な実行環境で推論するための仕組みである。 Basic PitchにはTensorFlow、TensorFlow Lite、ONNXなど複数のモデル形式が用意されている。 今回はGoogle ColabのPython環境との互換性を考慮し、Python 3.10とTensorFlow Liteを組み合わせる。 2. 音声からMIDIを生成する仕組み ここでは、音声信号を音符データに変換する基本的な考え方を説明する。 2.1 音声信号を数値として扱う デジタル音声は、一定の時間間隔で音声信号を標本化したデータである。 例えば、サンプリング周波数が22,050 Hzの場合、1秒間に22,050個のサンプルを記録する。 離散時間の音声信号を次のように表す。 x[n] = x\left(\frac{n}{f_s}\right) ここで、 x[n] は離散時間信号 n はサンプル番号 f_s はサンプリング周波数 である。 音声の音程を解析するには、この時間領域の信号に含まれる周期的な特徴を調べる必要がある。 2.2 短時間フーリエ変換 音声の周波数成分を時間ごとに調べる代表的な手法が、短時間フーリエ変換(STFT)である。 X(m,k)= \sum_{n=0}^{N-1} x[n+mH]w[n] e^{-j2\pi kn/N} 各記号は以下を表す。 x[n] は音声サンプル w[n] は窓関数 N はFFTの窓長 H はホップ長 m は時間フレーム k は周波数ビン STFTによって、音声に含まれる周波数成分の時間変化を調べられる。 ただし、フーリエ変換によって得られた周波数成分を、そのままMIDIノートに変換できるわけではない。 実際の楽器音には基本周波数に加えて倍音が含まれ、複数の楽器音が重なる場合もある。 そのため、音程推定には複数の周波数成分の関係や、時間方向の特徴を考慮する必要がある。 なお、Basic Pitchの内部処理は単純なSTFTのピーク検出だけで構成されているわけではなく、学習済みニューラルネットワークを使用している。 2.3 周波数とMIDIノート番号 平均律では、音の周波数とMIDIノート番号の間に次の関係がある。 f=440\times 2^{\frac{n-69}{12}} ここで、 f は周波数(Hz) n はMIDIノート番号 440 HzはA4の基準周波数 である。 この式を変形すると、 n=69+12\log_2\left(\frac{f}{440}\right) となる。 例えば、440 Hzの音に対応するMIDIノート番号は69である。 音名 周波数の目安 MIDIノート番号 C4 261.63 Hz 60 D4 293.66 Hz 62 E4 329.63 Hz 64 A4 440.00 Hz 69 C5 523.25 Hz 72 ただし、音声からMIDIを生成する場合には音高だけでは不十分である。 音符がいつ始まったのか、いつ終わったのかも推定しなければならない。 2.4 AIによる音符イベントの推定 Basic Pitchは音声から、音の立ち上がり、継続状態、ピッチ変化などを推定する。 これらの情報を利用して、音符イベントを生成する。 例えば、次のような音符情報が得られたとする。 開始時刻 終了時刻 音高 0.00秒 0.50秒 C4 0.50秒 1.00秒 E4 1.00秒 1.50秒 G4 これらをMIDIファイルに保存すれば、DAWなどでピアノロールとして表示できる。 3. Google Colabでプログラムを実行する 実行環境はGoogle Colabを使用する。 colab.research.google.com 新しいノートブックを作成し、次のコードを1つのコードセルに貼り付ける。 冒頭の YOUTUBE_URL に変換したいYouTube動画のリンクを指定する。 以下では、実装例としてYouTubeのURLを設定済みにしている。 4. Pythonプログラム全文 ============================================================ YouTube to MIDI Converter Google Colab用 # YouTubeの音声をAIで解析してMIDIに変換する # 使用ライブラリ yt-dlp FFmpeg Spotify Basic Pitch TensorFlow Lite # 出力 MIDIファイル (.mid) 音符情報 (.csv) ============================================================ ============================================================ 0. YouTubeのリンクを設定 ============================================================ YOUTUBE_URL = "youtu.be" 別の動画に変更する場合はURLを書き換える ============================================================ 1. 必要なライブラリを準備 ============================================================ import os import sys import subprocess import tempfile from pathlib import Path from google.colab import files def run_command(command): """コマンドを実行し、実行結果を表示する""" result = subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) print(result.stdout) if result.returncode != 0: raise RuntimeError( f"コマンド実行失敗: {result.returncode}" ) return result print("ライブラリをインストール中...") run_command([ sys.executable, "-m", "pip", "install", "-q", "-U", "yt-dlp[default]", "uv" ]) ============================================================ 2. Denoを準備 ============================================================ print("YouTube音声取得環境を準備中...") DENO_PATH = ( Path.home() / ".deno" / "bin" / "deno" ) if not DENO_PATH.exists(): run_command([ "bash", "-c", "curl -fsSL deno.land | sh" ]) os.environ["PATH"] = ( str(DENO_PATH.parent) - os.pathsep - os.environ["PATH"] ) print("Deno準備完了") ============================================================ 3. URLの確認 ============================================================ if not YOUTUBE_URL.startswith(( "youtube.com", "youtube.com", "youtu.be", "music.youtube.com" )): raise ValueError( "YouTubeのURLを確認してください" ) ============================================================ 4. 作業フォルダを作成 ============================================================ WORK_DIR = Path( tempfile.mkdtemp( prefix="youtube_midi_", dir="/content" ) ) OUTPUT_DIR = WORK_DIR / "output" OUTPUT_DIR.mkdir( parents=True, exist_ok=True ) print("作業フォルダ:", WORK_DIR) ============================================================ 5. YouTubeから音声を取得 ============================================================ print("YouTubeから音声を取得中...") run_command([ sys.executable, "-m", "yt_dlp", "--no-playlist", "-f", "bestaudio/best", "-o", str(WORK_DIR / "source.%(ext)s"), YOUTUBE_URL ]) ============================================================ 6. ダウンロードした音声を確認 ============================================================ audio_files = [ p for p in WORK_DIR.glob("source.*") if p.is_file() and p.suffix not in ( ".part", ".ytdl" ) ] if not audio_files: raise RuntimeError( "YouTube音声の取得に失敗しました" ) AUDIO_PATH = audio_files[0] print("音声取得完了") print("ファイル:", AUDIO_PATH.name) ============================================================ 7. 音声をWAV形式に変換 ============================================================ print("WAV形式に変換中...") WAV_PATH = WORK_DIR / "audio.wav" run_command([ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(AUDIO_PATH), "-vn", "-ac", "1", "-ar", "22050", "-c:a", "pcm_s16le", str(WAV_PATH) ]) if not WAV_PATH.exists(): raise RuntimeError( "WAVファイルが生成されませんでした" ) print("WAV変換完了") ============================================================ 8. Basic Pitchの実行環境を設定 ============================================================ print("AI音程推定環境を準備中...") Python 3.10を使用 setuptoolsを固定してpkg_resourcesエラーを対策 TensorFlow Lite版を使用 BASIC_PITCH_COMMAND = [ "uv", "run", "--no-project", "--python", "3.10", "--with", "setuptools==80.9.0", "--with", "basic-pitch==0.4.0", "--with", "tflite-runtime==2.14.0", "--with", "numpy<2", "--", "basic-pitch", str(OUTPUT_DIR), str(WAV_PATH), "--model-serialization", "tflite", "--save-note-events" ] ============================================================ 9. AIで音声全体を解析 ============================================================ print("================================") print("AIで音声全体を解析中...") print("================================") result = subprocess.run( BASIC_PITCH_COMMAND, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) print(result.stdout) if result.returncode != 0: raise RuntimeError( "Basic PitchによるMIDI変換に失敗しました。" "上の実行ログを確認してください。" ) print("AI音程解析完了") ============================================================ 10. MIDIファイルを確認 ============================================================ midi_files = sorted( OUTPUT_DIR.glob("*.mid") ) if not midi_files: raise RuntimeError( "MIDIファイルが生成されませんでした" ) print("MIDI変換成功") for midi in midi_files: print( "MIDIファイル:", midi.name ) ============================================================ 11. MIDIファイルをダウンロード ============================================================ print("MIDIファイルをダウンロード中...") for midi in midi_files: files.download( str(midi) ) ============================================================ 12. CSV形式の音符情報もダウンロード ============================================================ csv_files = sorted( OUTPUT_DIR.glob("*.csv") ) for csv_file in csv_files: files.download( str(csv_file) ) ============================================================ 13. 完了 ============================================================ print("================================") print("YouTube to MIDI 変換完了") print("================================") print("保存先:", OUTPUT_DIR) print("生成されたMIDI:") for midi in midi_files: print(midi.name) print("すべての処理が終了しました") このコードはGoogle Colab上での動作を想定しているが、全処理の実行検証は完了していない。PythonパッケージやYouTubeの仕様変更によって修正が必要になる可能性がある。 5. プログラムの解説 5.1 YouTubeのURLを設定する プログラム冒頭の次の部分で、変換対象の動画を指定する。 YOUTUBE_URL = "youtu.be" URLはPythonの文字列として指定する必要がある。 Markdownのリンク記法をそのままPythonコードに貼り付けると、SyntaxErrorが発生するので注意する。 5.2 yt-dlpで音声を取得する run_command([ sys.executable, "-m", "yt_dlp", "--no-playlist", "-f", "bestaudio/best", "-o", str(WORK_DIR / "source.%(ext)s"), YOUTUBE_URL ]) この部分では、yt-dlpを使ってYouTube動画の音声を取得している。 --no-playlist はプレイリスト全体をダウンロードしないための指定である。 -f bestaudio/best は音声を優先して取得するための指定である。 また、ファイル名の拡張子は取得したフォーマットによって決まる。 例えば、WebM形式の音声が取得された場合は、source.webmというファイルが生成される。 5.3 FFmpegでWAV形式に変換する run_command([ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(AUDIO_PATH), "-vn", "-ac", "1", "-ar", "22050", "-c:a", "pcm_s16le", str(WAV_PATH) ]) この処理では、取得した音声をWAV形式に変換する。 各オプションの意味は以下のとおりである。 オプション 意味 -i 入力ファイルの指定 -vn 動画ストリームを除外 -ac 1 モノラル音声へ変換 -ar 22050 サンプリング周波数22,050 Hz -c:a pcm_s16le 16ビット符号付きPCMで保存 -y 既存ファイルを上書き ここでサンプリング周波数を22,050 Hzにしているが、これだけで音程推定が完成するわけではない。 次のBasic Pitchによる推論処理が必要になる。 なお、Basic Pitch側では必要に応じて音声の再サンプリングなどが行われる。 5.4 Basic Pitchを実行する MIDI変換の中心となるのが、次の部分である。 BASIC_PITCH_COMMAND = [ "uv", "run", "--no-project", "--python", "3.10", "--with", "setuptools==80.9.0", "--with", "basic-pitch==0.4.0", "--with", "tflite-runtime==2.14.0", "--with", "numpy<2", "--", "basic-pitch", str(OUTPUT_DIR), str(WAV_PATH), "--model-serialization", "tflite", "--save-note-events" ] Google Colabの標準Python環境とBasic Pitchの対応バージョンが一致しない場合がある。 そのため、uvを使ってPython 3.10の独立した実行環境を用意している。 --model-serialization tflite は、TensorFlow Lite形式のモデルを使用するための指定である。 --save-note-events を指定すると、MIDIに加えて推定した音符イベントをCSV形式でも出力できる。 5.5 MIDIファイルをダウンロードする midi_files = sorted( OUTPUT_DIR.glob("*.mid") ) for midi in midi_files: files.download(str(midi)) Basic Pitchが生成したMIDIファイルを取得し、Google Colabのダウンロード機能で保存している。 生成されたファイルは、MIDI対応のDAWやピアノロール表示ソフトで読み込める。 6. 実行時に発生したエラーと対策 今回の実装では、Python環境とライブラリの互換性について注意が必要だった。 6.1 Pythonのバージョン問題 Google ColabのPythonバージョンは更新されることがある。 一方、今回使用するBasic Pitch 0.4.0は、Python 3.10や3.11を含む環境を対象としている。 対応していないPythonバージョンに直接インストールすると、依存ライブラリの解決で失敗する可能性がある。 この問題に対して、今回のコードでは次の指定を使用している。 "uv", "run", "--no-project", "--python", "3.10", ColabのPython自体を変更せず、Basic Pitchだけを別のPython環境で実行する方法である。 6.2 pkg_resourcesが見つからない問題 実装中、次のエラーが発生した。 ModuleNotFoundError: No module named 'pkg_resources' これは、Basic Pitchが依存するresampyがpkg_resourcesを読み込もうとした際に発生したエラーである。 pkg_resourcesは従来setuptoolsに含まれていたが、新しいsetuptoolsでは削除されている。 そこで、今回のコードでは次のようにバージョンを固定した。 "--with", "setuptools==80.9.0", この指定は旧APIとの互換性を確保するための対策である。将来的にはライブラリ側の更新に合わせた対応が望ましい。 6.3 TensorFlow関連の警告 実行時に次のような警告が表示されることがある。 Tensorflow is not installed. Coremltools is not installed. onnxruntime is not installed. Basic Pitchが複数の推論エンジンに対応していることに関連する警告である。 今回のプログラムではTensorFlow Liteを明示的に使用しているため、これらの警告だけで必ずしも変換失敗を意味するわけではない。 ただし、TensorFlow Lite側のモデル読み込みに失敗している場合は、別途エラー内容の確認が必要である。 6.4 YouTubeの音声を取得できない場合 yt-dlpを使用していても、すべての動画を取得できるとは限らない。 例えば、動画のアクセス制限、YouTube側の仕様変更、Colabからのアクセス制限などが原因となることがある。 この場合は、yt-dlpの実行ログを確認する。 今回のコードではDenoをインストールし、YouTubeのJavaScript処理に対応する構成としているが、取得成功を保証するものではない。 7. 変換精度の限界 音声からMIDIへの変換は、音響信号から音符を推定する問題である。 そのため、元のMIDIファイルを完全に復元する処理とは異なる。 7.1 単一楽器の演奏 ピアノやギターなどの単一楽器を録音した音源では、音程の推定が比較的行いやすい。 特に、録音状態が良く、音符の立ち上がりが明確な場合は解析しやすくなる。 7.2 複数の楽器による演奏 バンド演奏では、ギター、ベース、ドラム、ボーカルなどが同時に鳴る。 この場合、各楽器の周波数成分が重なり、音程の推定が難しくなる。 例えば、エレキギターの倍音が別の音符として誤認識されることがある。 また、ドラムのような非調波的な音は、通常の音高を持つ楽器とは性質が異なるため、Basic Pitchだけで正確なドラムMIDIを生成することは難しい。 7.3 MIDIは元の音色を保存しない MIDIファイルには、元の音声波形がそのまま保存されるわけではない。 そのため、エレキギターの音声をMIDIに変換しても、ディストーションの音色やアンプの特性まで再現されるわけではない。 MIDIから音を再生するには、別途シンセサイザーや音源が必要になる。 8. 今後の拡張 今回のプログラムは、YouTubeの音声を一括してMIDIに変換する構成である。 さらに精度を高めたい場合は、音源分離技術を組み合わせる方法が考えられる。 例えば、Demucsなどの音源分離モデルを使って楽曲をボーカル、ドラム、ベース、その他の成分に分離し、その後で音程推定を行う方法である。 音源分離そのものにも誤差はあるが、混合音源をそのまま解析する場合とは異なるアプローチが可能になる。 また、次のような拡張も考えられる。 楽器別MIDIファイルの生成 MIDIのピアノロール可視化 ノートイベントの重複除去 BPM推定と音符のクオンタイズ MIDIノート番号と周波数の比較 FFT・スペクトログラム・MIDIの同時可視化 特に、音声のスペクトログラムとMIDIのピアノロールを比較できれば、デジタル信号処理の学習教材としても利用できる。 9. まとめ 今回は、PythonとSpotify Basic Pitchを使い、YouTube動画の音声全体をMIDIデータに変換するプログラムを実装した。 プログラムでは、yt-dlpによる音声取得、FFmpegによるWAV変換、Basic Pitchによる音程推定、MIDIとCSVの出力を組み合わせている。 また、Google ColabのPython環境との互換性を考慮し、uvを使ってPython 3.10の実行環境を用意した。 音声をMIDIに変換する技術は、単なる楽曲の採譜だけでなく、音響信号処理、機械学習、デジタルシンセサイザー、音楽情報検索などとも関係している。 今回のプログラムは、その仕組みを実際の音源を使って学ぶための出発点になる。 なお、コードはColabでの一連の動作確認が完了していないため、実際の利用時には生成結果と実行ログを確認する必要がある。 参考資料 Spotify Basic Pitch - GitHub Basic Pitch - PyPI yt-dlp - GitHub FFmpeg Documentation Google Colab uv Documentation TensorFlow Lite
மனதில் ஒலிக்கும் இசையை MIDI ஆக மாற்றுங்கள். Basic Pitch குரலையோ வாத்தியத்தையோ கேட்டு நோட்களை எழுதித் தருகிறது. எந்தப் பாடலை முதலில் மாற்றிப் பார்ப்பீர்கள் ? spotify/basic-pitch Instagram-லும் பின்தொடருங்கள்: instagram.com
@_anderberg basic pitch includes a core ml model that extracts notes across instruments. could be useful for comparing the same idea hummed vs played on guitar, alongside sparks' key and tempo matching spotify/basic-pitch
@awilkinson @BrentBeshore Been doing this for a while. Hard to go back! 😀 spotify/save-to-spotify
I turned a hummed melody into MIDI without uploading anything How I run Spotify's basic-pitch audio-to-MIDI model entirely in the browser I built an audio-to-MIDI converter where your audio never leaves your machine. Drop in a guitar riff, a hummed melody, or a piano recording, and you get an editable MIDI file back — no upload, no server, no queue. It runs Spotify's open-source basic-pitch model locally with TensorFlow.js. [DEMO GIF] Try it: **tunenoodle.com Two things pushed me client-side. Musicians are (rightly) protective of unreleased audio, and "upload your track to our servers" is a trust cost I didn't want to pay. The second reason is less noble: client-side inference means no GPU bill. The model is ~0.9 MB, modern browsers have everything else needed, so the tradeoff was easy. Here's what the pipeline actually looks like. Step 1 — Let the browser's audio graph do the decode and resample basic-pitch expects mono audio at 22.05 kHz. Writing a resampler for that is unnecessary — the Web Audio API already has one. decodeAudioData handles any input format the browser can play (mp3, wav, ogg, m4a…), and an OfflineAudioContext with a 22,050 Hz sample rate does the downmix and resample in a single pass: const decoded = await decodeFile(file) // decodeAudioData underneath const offline = new OfflineAudioContext(1, Math.ceil(decoded.duration * 22050), 22050) const source = offline.createBufferSource() source.buffer = decoded source.connect(offline.destination) source.start() // Float32Array, mono, 22.05 kHz — exactly what the model wants const samples = (await offline.startRendering()).getChannelData(0) Three lines of setup replaced what would have been a page of DSP code, and it reuses the browser's highly optimized resampler. Step 2 — Hand the samples to a Web Worker without copying them Inference takes seconds. That cannot run on the main thread, and a couple of minutes of audio is megabytes of Float32Array that I don't want to structured-clone. postMessage with a transfer list gives zero-copy handoff: const worker = new Worker( new URL('./audio-to-midi.worker.ts', import.meta.url), { type: 'module' }, ) worker.postMessage( { samples, threshold, minFrames, modelUrl: '/models/basic-pitch/model.json' }, [samples.buffer], // transferred, not copied ) One bundler note: wrapping the worker path in new URL(..., import.meta.url) is what makes Vite emit it as a proper module worker instead of silently breaking on build. Step 3 — Running basic-pitch in the worker I self-host the model (~0.9 MB: model.json plus the weight shard) from my own origin rather than a CDN. Same-origin means it's cacheable, there's no third-party CORS or uptime dependency, and it keeps working under a strict CSP. Inside the worker, the interesting part is short: import { BasicPitch, noteFramesToTime, outputToNotesPoly } from '@spotify/basic-pitch' import * as tf from '@tensorflow/tfjs' await tf.setBackend('cpu') await tf.ready() const model = new BasicPitch(modelUrl) const frames: number[][] = [] const onsets: number[][] = [] try { await model.evaluateModel( samples, (f, o) => { frames.push(...f); onsets.push(...o) }, progress => self.postMessage({ progress }), // model reports progress per window ) const notes = outputToNotesPoly(frames, onsets, threshold, 0.3, minFrames) self.postMessage({ notes: noteFramesToTime(notes) }) } finally { (await model.model).dispose() } A few deliberate choices: - CPU backend, forced. This is a one-shot batch job, and in a worker the CPU backend is the predictable one — no GPU context setup, no graphics-memory pressure competing with the page. For a model this size, the speed difference is measured in seconds, not minutes. - Progress comes free. evaluateModel calls back per analysis window, so I forward those numbers to the UI and the progress bar is honest. - dispose() in a finally. TF.js tensors are not garbage-collected promptly. Skip this and the third or fourth conversion in a session starts eating hundreds of MB. Step 4 — From frame activations to notes The model outputs per-frame pitch activations and onset activations. outputToNotesPoly turns them into note events, and three parameters matter for perceived quality: const notes = noteFramesToTime(outputToNotesPoly(frames, onsets, threshold, 0.3, minFrames)) - threshold is the confidence a pitch must reach — this is the sensitivity slider in the UI. - 0.3 is the onset threshold, kept fixed. - minFrames is how many consecutive frames a pitch must persist before it counts as a note. This is the parameter that kills flickering false positives on noisy input. Then I map the notes to a MIDI document: round and clamp the pitch to the MIDI range, derive velocity from the note amplitude, and reject anything above 10,000 notes. The output lands in an in-browser MIDI editor where you can fix wrong notes and export a .mid file. The unglamorous parts that matter in production - Cancellation is terminate(). A running evaluateModel loop won't politely stop, so I wire an AbortSignal straight to worker.terminate(). When a user drops the wrong 10-minute file, they should not wait for it. - "No notes found" is an outcome, not a bug. Silence, speech, and heavily percussive material legitimately produce nothing. It gets a specific error message and a hint ("try a clearer, more melodic recording"), not a silent failure. - Cap everything. A 20 MB limit before decoding, and transcription results above 10,000 notes get rejected outright. Both exist because a user will absolutely feed it a two-hour podcast. The same pattern runs MP3 export The converter's sibling feature — exporting a selection as MP3 — uses the identical worker + transferable pattern with lamejs. Two details from that side that cost me an afternoon: const encoder = new Mp3Encoder(channels, sampleRate, 192) // kbps for (let at = from; at < to; at += 1152) { // 1152 samples = one MP3 frame const pcm = Int16Array.from(chunk, v => { const c = Math.max(-1, Math.min(1, v)) return Math.round(c * (c < 0 ? 32768 : 32767)) // asymmetric on purpose }) out.push(encoder.encodeBuffer(pcm)) } out.push(encoder.flush()) Feed encodeBuffer exactly 1152 samples at a time — that's the MP3 frame size, and the encoder's buffering gets weird if you guess wrong. And convert float to Int16 asymmetrically: −1.0 should map to −32768, but +1.0 must clamp to 32767, or your loudest frames overflow. Try it The tool is live at **tunenoodle.com — it also pairs with a MIDI editor, a sheet-music scanner, and a MusicXML player if you want to go from recording to notation. If you build something similar, or if you've hit the WebGL-vs-CPU tradeoff on TF.js in workers, I'd like to hear how you handled it in the comments.
Humming to MIDI in the Browser with Basic Pitch and Web Workers You can turn a short hummed recording into MIDI in the browser with Spotify's Basic Pitch. Decode the audio, resample it to mono at 22,050 Hz, run the model, then encode its detected notes. The result needs a listen and usually some editing. I build SoundToMIDI around that workflow. One design decision turned out to matter as much as the model: moving inference into a Web Worker so the interface could respond while a conversion was running. The use case is familiar. In this r/hardstyle thread, a producer describes recording a melody, converting it, then spending too long correcting the notes. That is the job here: get an idea into a DAW without entering every note by hand. From recording to note events Local file → Web Audio decoding → mono PCM at 22,050 Hz → Basic Pitch inference → onset/frame post-processing → note events in seconds → MIDI encoding → piano-roll inspection and download Basic Pitch supports polyphonic notes, though Spotify recommends one instrument at a time. Start with a solo hum. MIDI records note events; it won't preserve the sound of your voice. Let AudioContext decode at the device's supported rate, then use a mono OfflineAudioContext to prepare the model input. Forcing the decoding context to 22,050 Hz can cause problems on some devices. async function decodeAndResample(file) { const context = new AudioContext(); let decoded; try { decoded = await context.decodeAudioData(await file.arrayBuffer()); } finally { await context.close(); } const sampleRate = 22050; const offline = new OfflineAudioContext( 1, Math.max(1, Math.ceil(decoded.duration * sampleRate)), sampleRate, ); const source = offline.createBufferSource(); source.buffer = decoded; source.connect(offline.destination); source.start(); const rendered = await offline.startRendering(); return Float32Array.from(rendered.getChannelData(0)); } This applies Web Audio's channel-mixing rules. Stereo phase cancellation can affect the mono result. A mono recording avoids that surprise. File extensions can be misleading too: a supported container may hold a codec the browser cannot decode. For a prototype in a browser project with a bundler such as Vite, install: npm install @spotify/basic-pitch@1.0.1 @tonejs/midi@2 Copy the model assets from basic-pitch-ts into public/model/, preserving the paths between model.json and its weight shards. Add this to the same module: import { BasicPitch, outputToNotesPoly, noteFramesToTime, } from '@spotify/basic-pitch'; import { Midi } from '@tonejs/midi'; const engine = new BasicPitch('/model/model.json'); export async function fileToMidi(file, onProgress = () => {}) { const pcm = await decodeAndResample(file); const frames = []; const onsets = []; await engine.evaluateModel( pcm, (f, o) => { frames.push(...f); onsets.push(...o); }, onProgress, ); const minFrames = Math.max(1, Math.round(0.130 * (22050 / 256))); const events = noteFramesToTime( outputToNotesPoly(frames, onsets, 0.5, 0.3, minFrames), ); if (!events.length) throw new Error('No pitched notes detected.'); const midi = new Midi(); midi.header.setTempo(120); const track = midi.addTrack(); track.name = 'Transcribed melody'; for (const note of events) { track.addNote({ midi: note.pitchMidi, time: note.startTimeSeconds, duration: Math.max(0.02, note.durationSeconds), velocity: Math.max(0, Math.min(1, note.amplitude)), }); } return { bytes: midi.toArray(), events }; } Pass a File from an <input type="file"> to fileToMidi. Turn its bytes into an audio/midi Blob for download, and revoke the object URL when finished. The 0.5 onset threshold controls note starts; 0.3 controls sustained pitch activity. The minimum duration is in frames, so 130 ms becomes roughly 11 at 22050 / 256 frames per second. Raising these controls may remove breath-triggered blips, but can also lose quiet or short intended notes. Change one setting and compare the same phrase. This prototype runs on the main thread. It is enough to inspect a short clip, but the application needs a different execution boundary. Why an async conversion can still freeze Cancel Awaiting a Promise doesn't move synchronous inference kernels off the main thread. SoundToMIDI keeps decoding and native resampling outside the Worker, and puts TensorFlow, inference, note extraction, and MIDI encoding inside it. The boundary passes PCM and settings: const worker = new Worker( new URL('./transcription-worker.js', import.meta.url), { type: 'module' }, ); // After a separate model-load request has completed: worker.postMessage( { id: 42, type: 'transcribe', mono: pcm, options: { onsetThreshold: 0.5, frameThreshold: 0.3 }, }, [pcm.buffer], ); Transferring pcm.buffer avoids a copy and detaches it from the sender. Keep the decoded audio if the user may retry. Cancel terminates the Worker and rejects pending requests. Retry creates a new one, which must load the model again. A cancellation flag alone can't interrupt a kernel that hasn't returned. There is a race to test here: cancel, retry immediately, then receive a late response from the old Worker. Check the Worker instance as well as the request ID before accepting a response. An obsolete result must not complete the new request. The memory bill follows the recording Basic Pitch 1.0.1 uses overlapping windows. The application's inference loop processes one window at a time and releases its temporary tensors after reading the values. Leading padding and overlap trimming must match the reference implementation, or window boundaries can alter the output. Use tf.tidy for synchronous tensor work, then dispose returned tensors in finally after awaiting their arrays. An async callback inside tf.tidy won't manage tensor lifetime across an await. The decoded audio and accumulated frame outputs still grow with clip length. Windowing doesn't make this a constant-memory streaming system. I prefer short selections for this workflow. The model runs locally, but the browser downloads code and weights. Local audio processing doesn't make the page network-free. Eight intended notes can become nine On October 8, 2026, we reran the published synthetic fixtures in desktop Chrome using the live converter. These are eight-second WAVs at 22,050 Hz. Settings were onset 0.5, frame 0.3, minimum length 130 ms, tempo 120 BPM, and pitch bends off: | Input | Intended pitched events | Raw output events | Extra events | | --- | ---: | ---: | ---: | | Isolated melody | 8 | 9 | 1 | | Chords with noise percussion | 12 | 15 | 3 | | Unpitched noise percussion | 0 | 0 | 0 | Every pitched source event had a same-pitch onset match within 150 ms, used once. We didn't score duration or expression. These synthetic fixtures cannot establish accuracy on humming. The noise-only clip produced no downloadable MIDI. The melody's extra D5 was an octave above a source D4. Harmonic confusion is a possible explanation; the octave relationship doesn't prove the cause. The source audio and original September 28 results are public. That earlier melody run produced ten events, which is another reason to record the test date and settings. Check repeated notes and phrase endings when you try your own recording. Also remember that writing 120 BPM into a MIDI header doesn't detect the recording's beat grid. This code writes notes in seconds against a chosen tempo map. I leave pitch bends out of the prototype. Ordinary MIDI bends affect a channel, so overlapping notes can't all bend independently on a single channel. Cleanup rules can shorten overlaps or remove blips, but they don't know the intended melody. Keep the raw events available. Record 10–20 seconds without backing music, leave gaps between repeated notes, and try the humming-to-MIDI workflow. Raw MIDI downloads are free; optional paid exports are separate. Listen beside the piano roll before importing it into a DAW. That last check catches errors that a successful download won't. References - Spotify Basic Pitch: model, scope, and limitations - Basic Pitch TypeScript implementation and model assets - Web Workers and transferable objects - TensorFlow.js: tensor management