「絶対音感は無理だが、相対音感くらい鍛えたい」というニッチな欲求は、紙の本でやるよりブラウザに任せた方が早い。基準音 → 問題音 を Web Audio で鳴らして当てる 耳トレ (ear training) ツール を、純粋ロジック 200 行 + DOM 150 行 + 21 テストで書いた。
AudioContext.state === "suspended"の罠 と、最初の音が欠ける罠 と、ADSR エンベロープを書かないと「プチッ」と鳴る罠の話。
🌐 デモ: https://sen.ltd/portfolio/pitch-trainer/
📦 GitHub: https://github.com/sen-ltd/pitch-trainer
3 モード:
| モード | 出題 | 回答 |
|---|---|---|
| メジャースケール | C4 → メジャースケールの 1 音 | Do / Re / Mi / Fa / Sol / La / Si |
| クロマチック | C4 → 12 音中ランダム 1 音 | C / C# / D / D# / E / F / F# / G / G# / A / A# / B |
| 音程 (Interval) | 2 音 連続 → 同時 | P1 / m2 / M2 / m3 / M3 / P4 / TT / P5 / m6 / M6 / m7 / M7 |
平均律周波数の計算は 1 行
12-TET (12-tone equal temperament) で A4 = 440 Hz をアンカーに置くと、任意のセミトーン offset の周波数は:
export function semitoneToHz(semitone) {
if (!Number.isFinite(semitone)) return 0;
return 440 * Math.pow(2, semitone / 12);
}
semitone は A4 からの semitone 差。
-
semitoneToHz(0)= 440 (A4) -
semitoneToHz(12)= 880 (A5、1 オクターブ上) -
semitoneToHz(-9)= 261.63 (C4、ミドル C)
MIDI 番号で考えたければ midi - 69 で semitone offset になる (A4 が MIDI 69 だから)。テストで小数の検算:
test("semitoneToHz computes equal-temperament semitones to within 1e-9", () => {
const expected = 440 / Math.pow(2, 9 / 12); // C4
assert.ok(Math.abs(semitoneToHz(-9) - expected) < 1e-9);
});
!Number.isFinite(semitone) → 0 のガードは NaN / Infinity が来たときに OscillatorNode.frequency.value = NaN を渡してしまうのを防ぐため。Web Audio で NaN を周波数に渡すと audio スレッドが silently 止まる、というデバッグしにくい挙動になる。
Web Audio の罠 3 つ
罠 1: AudioContext.state === "suspended" で起動する
ブラウザは ユーザージェスチャー (click / tap) が来るまで AudioContext を suspend する。これ自体は仕様 (自動再生を防ぐためのポリシー) なので回避ではなく従う:
function ensureAudioContext() {
if (!state.audioCtx) {
state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (state.audioCtx.state === "suspended") {
state.audioCtx.resume().catch(() => {});
}
}
「開始」ボタンの click ハンドラ内で ensureAudioContext() を呼べばいい。AudioContext.resume() は Promise を返す ので、エラーは catch。scheduler.tick() が回ってオーディオは予約されているのに何も鳴らない、を経験するとこの罠を意識するようになる。
加えて タブがバックグラウンドに行くと再び suspend される ことがあるので、再生のたびに state をチェックして必要なら再 resume()。
罠 2: osc.start(ctx.currentTime) で最初の音が欠ける
OscillatorNode.start(time) に ctx.currentTime を直接渡すと、最初のクリックがしばしば失われる — audio スレッドが拾うときには既に過去になっているから。数 ms 未来にずらす:
function playNote(semitoneFromA4, startOffset = 0) {
const ctx = state.audioCtx;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = semitoneToHz(semitoneFromA4);
const t0 = ctx.currentTime + startOffset;
const t1 = t0 + 0.012; // attack
const t2 = t0 + 0.82; // sustain end
const t3 = t0 + 0.9; // release end
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(0.35, t1);
gain.gain.setValueAtTime(0.35, t2);
gain.gain.exponentialRampToValueAtTime(0.0001, t3);
osc.connect(gain).connect(ctx.destination);
osc.start(t0);
osc.stop(t3 + 0.01);
}
startOffset = 0 でも t0 = ctx.currentTime には数 ms の "head room" が暗黙にある (上記 t1 = t0 + 0.012 の attack で安全に立ち上げる)。連続再生 (基準音 → 問題音) のときは startOffset を渡してスケジュール:
playNote(rootSemitone, 0);
playNote(targetSemitone, NOTE_DURATION_SEC + GAP_BETWEEN_NOTES_SEC);
これで「基準音 0.9 秒 + 無音 0.25 秒 + 問題音 0.9 秒」のシーケンスが、JS タイマに頼らず audio スレッド上で正確にスケジュールされる。
罠 3: ADSR エンベロープ無しだと「プチッ」と鳴る
OscillatorNode.start(t0) で start、osc.stop(t1) で stop だけだと、振幅がいきなり 0 → 1 → 0 になる。これは波形上の 不連続点 で、人間の耳には「プチッ」というクリック音として聞こえる。
ADSR (Attack / Decay / Sustain / Release) の最小版を GainNode でかける:
| Phase | 時間 | gain |
|---|---|---|
| Attack | 12 ms | 0 → 0.35 (linear) |
| Sustain | 残り | 0.35 |
| Release | 80 ms | 0.35 → 0.0001 (exponential) |
Decay は省略 (Sustain と同じ値なので意味がない)。linearRampToValueAtTime と exponentialRampToValueAtTime を組み合わせると 「Attack は早く滑らかに、Release は耳に自然な指数減衰で」 という典型形になる。Release を 0 にすると exponentialRampToValueAtTime がエラーになる ので 0.0001 にしている (これは Web Audio API の制約)。
ランダム出題の重複回避
ナイーブに Math.random() で出題すると 同じ音が 3 連続 することがある。recent-N exclude で防ぐ:
export function pickFromScale(scaleOffsets, recent = [], excludeN = 2, rng = Math.random) {
if (!scaleOffsets.length) return null;
const tail = recent.slice(-excludeN);
const tailSet = new Set(tail);
const candidates = scaleOffsets.filter((s) => !tailSet.has(s));
const pool = candidates.length ? candidates : scaleOffsets;
return pool[Math.floor(rng() * pool.length)];
}
recent には今までの出題履歴 (直近の targetOffset 配列) を渡す。直近 2 問と同じ音は除外される。もし全候補が除外されたら (= scale が短くて recent が長すぎたら) フォールバックで全 scale から選ぶ — null を返して進めなくなるよりは「たまに重複する」方が UX として軽い。
rng は引数化してあるので、テストで rngFrom([0, 0, 0]) のような決定論的な fake を渡せる:
test("pickFromScale excludes the last `excludeN` recent picks", () => {
const rng = rngFrom([0, 0, 0]);
// Recent = [0, 2]. Major scale without [0, 2] starts at 4.
const picked = pickFromScale(SCALES.major, [0, 2], 2, rng);
assert.equal(picked, 4);
});
Interval ラベリングは「絶対距離 mod 12」で十分
音楽理論的には、上行 P5 と下行 P5 は別物 (前者は P5、後者は P4 の inversion とも言える)。が、ear training アプリの観点では 「聞こえた 2 音の距離は?」 が問いなので、distance ベースで OK:
export function intervalName(semitones) {
if (!Number.isFinite(semitones)) return "?";
const folded = Math.abs(semitones) % 12;
return INTERVAL_NAMES[folded];
}
abs() % 12 で、-7 も 7 も 19 も同じ P5 ラベルになる。下行 fifth が「P5」と表示されてユーザーは「fifth だ、わかる」と理解できる。
最初は ((semitones % 12) + 12) % 12 という signed mod の典型実装で書いていたが、これだと intervalName(-7) === "P4" になってしまい、音楽的にも UI 的にも違和感 (テストが落ちて気付いた)。Math.abs() ベースに切り替え。
まとめ
- 平均律周波数は
440 * 2^(semitone/12)の 1 行 -
AudioContext.state === "suspended"で起動する → 「開始」click でresume() -
osc.start(ctx.currentTime)は最初の音が欠けやすい → 数 ms 未来にずらす - ADSR (
linearRampToValueAtTimeの attack +exponentialRampToValueAtTimeの release) でクリック音回避 - 出題重複回避は recent-N exclude、全候補除外時は全 scale にフォールバック
- interval ラベルは絶対距離 mod 12 で耳トレ用には十分
ソース: https://github.com/sen-ltd/pitch-trainer — MIT、合計 ~350 行 (JS)、21 ユニットテスト、ビルド不要、依存ゼロ。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。
