0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザだけで動く耳トレツールを作った — 平均律周波数の計算 1 行と、Web Audio で踏みやすい罠 3 つ

0
Posted at

「絶対音感は無理だが、相対音感くらい鍛えたい」というニッチな欲求は、紙の本でやるよりブラウザに任せた方が早い。基準音 → 問題音 を Web Audio で鳴らして当てる 耳トレ (ear training) ツール を、純粋ロジック 200 行 + DOM 150 行 + 21 テストで書いた。AudioContext.state === "suspended" の罠 と、最初の音が欠ける罠 と、ADSR エンベロープを書かないと「プチッ」と鳴る罠の話。

pitch-trainer の画面: 暗色テーマで、上に「モード: メジャースケール (Do-Re-Mi) / 音色: sine」セレクタと「▶ 開始」ボタン。中央に問題プロンプト「基準音 C4 (Do) に続けて鳴る 問題音 はメジャースケールのどの音?」と、Do/Re/Mi/Fa/Sol/La/Si の 7 ボタン (Mi がハイライト)。下に統計 (22 / 16 / 75% / 連続 1) と Web Audio ready ステータス

🌐 デモ: 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);
}

semitoneA4 からの 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 と同じ値なので意味がない)。linearRampToValueAtTimeexponentialRampToValueAtTime を組み合わせると 「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 で、-7719 も同じ 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 一覧 から。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?