blog

録画の音が45ms遅れても直さなかった理由と、ブラウザの音の罠

つやっとで公開しているサービスのうち 5 つが音を扱っています。ノイズを 1 サンプルずつ合成するもの、効果音を鳴らすゲーム、動画に音を入れて書き出すもの、と使い方はばらばらです。

サービス音でやっていること主な API
ノイズダウンロード14 種のノイズを合成して試聴し、MP3 や WAV に書き出すScriptProcessorNode、lamejs
10秒だけ!効果音を合成して鳴らし、遊んでいる様子の録画に入れるAudioWorklet、WebCodecs の AAC、Mediabunny
ショート動画メーカー効果音や BGM を重ねて、動画の音声トラックにするOfflineAudioContext、AudioEncoder
チャットシアター通知音を合成して、会話の動画に入れるOfflineAudioContext、Mediabunny
Aim Training銃声や BGM を事前に展開して、遅れなく鳴らすdecodeAudioData、StereoPanner

どれも Claude Code で実装していて、私が仕様を決め、触って確かめ、おかしな所を見つけて直させる、という分担で作りました。並べてみると、別々に作ったのに同じ判断をしている所と、逆の判断をしている所がありました。この記事では、その二つを軸に、実際のコードと測った数字を書きます。音を MP4 のファイルにまとめる部分(マルチプレクサの使い方やコーデックの選び方)は、ブラウザで MP4 を書き出す記事に分けました。

試聴と書き出しで同じ関数を通す

音を作ってファイルに書き出すアプリには、「その場で聴く」経路と「ファイルにする」経路の二つがあります。この二つを別々に書くと、試聴では鳴っていた音が書き出すと違う、という食い違いが起きます。ショート動画メーカー、チャットシアター、ノイズダウンロードの 3 つは、どれも音を作る部分を一つの関数にまとめて、二つの経路から同じものを呼んでいます。

ショート動画メーカーでは、音声クリップ 1 本ぶんのノードのつなぎ方を scheduleAudioClip という関数にまとめています。先頭のコメントに、共用する理由がそのまま書いてあります。

// 音声クリップ 1 本のノードチェーンを組む。
// プレビュー再生(AudioContext)と書き出し(OfflineAudioContext)の両方から呼ぶ。
// ここを共用しないとプレビューと書き出しで音が食い違うため、必ずこの関数を通すこと。
//
//   BufferSource(speed / loop) → [highpass] → [lowpass] → gain(volume + fade) → panner → dest

// ...

export function scheduleAudioClip(
  ctx: BaseAudioContext,
  clip: AudioClip,
  buffer: AudioBuffer,
  opts: ScheduleOptions,
): AudioBufferSourceNode | null {

引数の型が AudioContext ではなく BaseAudioContext になっている点がこの設計の要です。BaseAudioContext は、リアルタイムで鳴らす AudioContext と、実時間を待たずに一気に描き出す OfflineAudioContext の共通の親です。ノードを作る API はどちらにも同じものがあるので、関数を一つ書けば両方で動きます。

チャットシアターの通知音も同じ形です。外部の音声ファイルを持たず、サイン波を 660Hz から 330Hz へ下げる「ポコッ」という音を、その場で合成しています。

// 通知音3種を Web Audio で合成する(外部アセットなし)。
// ctx に OfflineAudioContext を渡せば書き出し時の音声トラック合成にも使える。

export function scheduleSound(
  ctx: BaseAudioContext,
  preset: SoundPreset,
  when: number,
  destination?: AudioNode,
) {
  const dest = destination ?? ctx.destination;
  const t0 = Math.max(when, 0);

  if (preset === 'poko') {
    // 短い水滴風: サイン波 660→330Hz
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.type = 'sine';
    osc.frequency.setValueAtTime(660, t0);
    osc.frequency.exponentialRampToValueAtTime(330, t0 + 0.09);
    gain.gain.setValueAtTime(0.0001, t0);
    gain.gain.exponentialRampToValueAtTime(0.35, t0 + 0.008);
    gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.13);
    osc.connect(gain).connect(dest);
    osc.start(t0);
    osc.stop(t0 + 0.15);
  // ...

書き出すときは、この関数をメッセージが現れる時刻ごとに OfflineAudioContext へ並べ、startRendering() で 1 本の音声にします。

/** 通知音をタイムラインどおりに敷いた AudioBuffer を合成する */
async function renderAudioTrack(story: Story, timeline: Timeline): Promise<AudioBuffer> {
  const sampleRate = 48000;
  const length = Math.max(1, Math.ceil(timeline.duration * sampleRate));
  const ctx = new OfflineAudioContext(2, length, sampleRate);
  if (story.sound.enabled) {
    for (const entry of timeline.entries) {
      if (entry.message.notice) continue; // お知らせは通知音なし
      scheduleSound(ctx, story.sound.preset, entry.appearAt);
    }
  }
  return ctx.startRendering();
}

Math.max(1, ...) は、長さ 0 の OfflineAudioContext を作ると例外になるのを避けるためのものです。ショート動画メーカーにも同じ式があります。

同じ関数に、試聴では参照を、書き出しではコピーを渡す

ノイズダウンロードは Web Audio のノードを使わず、JavaScript で 1 サンプルずつ波形を計算しています。その本体の makeChannel を、試聴と書き出しの両方が呼びます。ここで効いているのが、設定値の渡し方の違いです。

試聴では、設定値のオブジェクトを参照のまま渡します。関数は 1 サンプルごとに p.xxx を読み直すので、スライダーを動かすと次のサンプルから音が変わり、ノードを作り直さないので途切れもしません。書き出しでは、最初にコピーしてから渡します。

export async function generate(state, onProgress) {
  const secs = state.dur * 60, total = Math.floor(secs * SR), ch = state.stereo ? 2 : 1;
  const amp = volAmp(state.vol), cut = warmthCutoff(state.warm);
  const fadeN = state.fade ? Math.min(SR * 3, Math.floor(total / 4)) : 0;
  const p = { ...state.params[state.type] };
  const genL = makeChannel(state.type, ch === 2 ? 'L' : 'mono', p), genR = makeChannel(state.type, 'R', p);

最長 2 時間の音声を作っている間に、ユーザーがスライダーを触っても、出力の途中から音が変わることはありません。同じ関数でも、渡すものを変えるだけで「すぐ反映したい」と「固定したい」を両立できます。

ただし、同じ関数を通っていても、出てくる音がビット単位で同じになるとは限りません。書き出しは 44.1kHz に固定していますが、試聴は端末の AudioContext のサンプルレートで動きます。減衰や仕上げのローパスは経過時間 dt を掛けて計算しているのでサンプルレートに左右されませんが、雨だれや焚き火のはぜる音は「1 サンプルごとに一定の確率で鳴らす」という作りです。そのため 48kHz の端末では、試聴のときだけ雨だれの数が 48000/44100、つまり約 1.09 倍になります。ピンクノイズを作るフィルターも係数が 1 サンプル単位なので、同じように少しずれます。聞き分けられる差ではなさそうですが、確かめてはいません。

時間の原点を 1 か所で決める

音と映像を一緒に扱うときは、「音の 0 秒」と「映像の 0 秒」を同じ瞬間にそろえる必要があります。ここでずれた例が、ショート動画メーカーの WebM の書き出しにありました。

ショート動画メーカーは、WebCodecs が使えないブラウザでは MediaRecorder で画面を実時間で録って WebM にします。最初の実装は、音声の原点を audioCtx.currentTime で取ってから効果音を並べ、そのあとで recorder.start() を呼び、映像の原点 performance.now() を取っていました。二つの原点の間に、音声のスケジュール処理と録画の開始処理が挟まるので、その時間だけ音が先に進みます。この問題は、公開前に Claude Code に全体の監査を頼んだときに見つかったもので、実際に音ずれを耳で確かめたわけではありません。

今のコードでは、待ち時間の出る処理(画像やフォントの読み込み、音声のデコード)をすべて recorder.start() の前に済ませ、映像の原点と同じ瞬間に音声の原点を取っています。

  await audioCtx?.resume()

  // デコードは録画開始「前」に済ませる。開始後に await すると、その待ち時間ぶん
  // 音声だけ時間原点がずれて A/V が同期しなくなる。
  const decodedClips: { clip: AudioClip; buf: AudioBuffer }[] = []
  if (audioCtx && dest && audioClips.length) {
    const buffers = await Promise.all(
      audioClips.map((c) => getDecodedAudio(c.src).catch(() => null)),
    )
    audioClips.forEach((clip, i) => {
      const buf = buffers[i]
      if (buf) decodedClips.push({ clip, buf })
    })
  }

  recorder.start()

  const duration = doc.project.duration
  const t0 = performance.now()

  // 映像の時間原点(t0)と同じ瞬間を音声の原点にする
  if (audioCtx && dest) {
    const origin = audioCtx.currentTime
    for (const { clip, buf } of decodedClips) {
      scheduleAudioClip(audioCtx, clip, buf, { destination: dest, timeOrigin: origin, startAt: 0 })
    }
  }

recorder.start() と const origin の間には await がありません。JavaScript はこの間に別の処理へ切り替わらないので、二つの原点はほぼ同じ瞬間に取られます。

録画の 0 秒を「最初のフレームを取り込んだ瞬間」にする

10秒だけ!は、遊んでいる最中の画面とゲーム音を録画して MP4 にします。ここでは、映像の 0 秒を「最初のフレームを取り込んだ瞬間」と決め、そのときの AudioContext.currentTime を音の 0 秒として覚えています。

    if (this.hasAudio && this._audioZero === null) {
      this._audioZero = this._audio.currentTime - timestampSeconds;

音のほうは、後で書く AudioWorklet から 1024 サンプルずつ、AudioContext の通し番号つきで届きます。その通し番号を、覚えておいた 0 秒からの秒数に直すのが次の関数です。

export function alignChunk({ frame, length, sampleRate, zero }) {
  const zeroFrame = zero * sampleRate;
  if (frame + length <= zeroFrame) return null;

  const offset = Math.max(0, Math.ceil(zeroFrame - frame));
  if (offset >= length) return null;

  // 浮動小数のずれで -0.0000001 のような値にならないよう、0 で止める
  const timestamp = Math.max(0, (frame + offset) / sampleRate - zero);
  return { offset, timestamp };
}

0 秒より前の音はかたまりごと捨て、0 秒をまたぐかたまりは頭を切り落とします。Web Audio にも Mediabunny にも触らない純粋な関数にしてあるので、境目のふるまいをテストで確かめられます。

カウントダウンの「ポッ」という音は、フレームを取り込むのと同じ requestAnimationFrame の中で、currentTime ちょうどに鳴るよう予約しています。そのため「ポッ」は動画の 0 秒に乗ります。原点を 1 か所で決めて、音も映像もそこから数える、という形です。

録画用に音を取り出す AudioWorklet

10秒だけ!では、録画に入れる音を AudioWorklet(音声処理専用のスレッドで JavaScript を動かす仕組み)で取り出しています。音の流れは、ソースのコメントにある図のとおりです。

 *   各音 ─→ master ─→ compressor ─┬─→ speaker(ミュートで 0)─→ silencer(?silent)─→ destination
 *                                  └─→ 録画の口(openRecordTap / openRecordStream)
 *   s.press ─→ uiBus ──────────────────↗ speaker(**動画に入れない音**)

録画の口は、ミュートの手前で枝分かれしています。画面の音を消して遊んでいても、動画には音が入ります。動画は SNS で別の人が見るものなので、遊んでいる本人のミュートとは切り離しました。

逆に、HTML のボタンを押したときの「コッ」という音(s.press)は、録画の口を通らない uiBus に流しています。ボタン自体は動画に映らないので、音だけ残ると何の音か分かりません。最初はこの音も動画に入っていて、プロジェクトの記録には「『動画に残していいか』で置き場所を決める」という方針として残っています。

取り出し口のワークレットは 50 行ほどです。

const BLOCK = 1024;

class RecordTap extends AudioWorkletProcessor {
  // ...
  process(inputs) {
    if (!this.open) return false;

    const channels = inputs[0] ?? [];
    const left = channels[0];
    // 入力が 1ch でも 2ch に揃えて送る(ノード側で explicit 2ch にしてあるが念のため)
    const right = channels[1] ?? left;
    const length = left ? left.length : 128;

    for (let i = 0; i < length; i += 1) {
      if (this.fill === 0) this.startFrame = currentFrame + i;
      // 何も繋がっていない瞬間は入力が空になる。無音として詰める
      this.data[this.fill] = left ? left[i] : 0;
      this.data[BLOCK + this.fill] = right ? right[i] : 0;
      this.fill += 1;
      if (this.fill === BLOCK) {
        this.port.postMessage({ frame: this.startFrame, length: BLOCK, data: this.data }, [this.data.buffer]);
        this.data = new Float32Array(BLOCK * 2);
        this.fill = 0;
      }
    }
    return true;
  }
}

currentFrame は AudioWorklet の中から読める、AudioContext が始まってからの通し番号です。これをかたまりの先頭に付けて送るので、受け取った側が前の節の alignChunk で時刻を付けられます。出力には何も書きませんが、destination につないでおかないと処理されない環境があるので、出力口だけは持たせています。

ワークレットのファイルは、Vite で次のように読み込んでいます。

// AudioWorklet は別ファイルで読ませる必要がある。ビルドでは URL だけが初回チャンクに入る。
// **`no-inline` を外さない。** 小さいファイルは data: URL に焼き込まれ、
// ブラウザによっては addModule が data: URL を受け付けない
import recordTapUrl from './recordTap.worklet.js?url&no-inline';

このワークレットは 2KB に満たないので、Vite の既定の設定では data: URL に埋め込まれてしまいます。ビルドの設定一つで録画の音だけが消える、という気づきにくい罠です。

ライブラリの手軽な口を使わなかった理由

動画にまとめるのに使っている Mediabunny には、MediaStreamAudioTrackSource という、MediaStream の音をそのまま録れる口があります。最初の仕様書でもこれを使う予定でした。使わなかった理由は二つあり、どちらも実装のときに Claude Code がライブラリの中を読んで見つけたものです。

一つは、音の 0 秒が「最初に届いた音」になり、動画の 0 秒とずれることです。もう一つは、MediaStreamTrackProcessor の無い Safari では、ライブラリが内部で AudioContext をもう 1 つ作り、resume() を待つことです。iOS の Safari はユーザー操作の中でしか AudioContext を起こさないので、ゲームの途中で録画を始めるこの使い方では、録画の開始そのものが止まるおそれがありました。

MediaRecorder の経路で、76 秒の映像に 18 秒の音しか入らなかった

WebCodecs の無いブラウザ向けに、10秒だけ!にも MediaRecorder の経路を残しています。こちらはキャンバスの captureStream() と、MediaStreamAudioDestinationNode の音をまとめて録るだけの仕組みです。

この経路を確かめるため、プレビューで VideoEncoder を消して録ったところ、76 秒の映像に対して音が 18 秒に縮んでいました。プレビューのペインが隠れていて、requestAnimationFrame が毎秒 0.25 回ほどに落ち、キャンバスのフレームがほとんど届かなかったのが原因です。キャンバスを毎秒 30 回塗り替えながら 4 秒録ると、1 秒おきの音が 0.05 秒、1.015 秒、2.01 秒、3.015 秒に入り、長さも間隔も合っていました。この経路では、映像のフレームが途切れると音も詰まります。WebCodecs の経路は音に自分で時刻を付けているので、同じことは起きません。

AAC の遅れは直さず、MP3 の無音は切る

10秒だけ!で録った MP4 の音を調べると、鳴らした時刻より 45〜52ms 遅れていました。カウントダウンや着地の音など 7 つの音すべてで遅れ、ばらつきは 7ms 以内でした。

原因は AAC のエンコーダーが先頭に入れる priming(エンコーダーの都合で先頭に足される無音のサンプル)です。2112 サンプルが入るので、48kHz なら 44ms になります。本来は MP4 の edit list(elst)で「先頭のこのぶんは飛ばして再生する」と書くものですが、Mediabunny は書いていません1。

この遅れは補正しないと決めました。理由は三つあります。音が映像より遅れる向きのずれは 125ms 程度まで気づかれにくく、先に鳴る向きは 45ms ほどで気づかれる、という放送の基準があります2。時刻を前へずらして補正すると、edit list を読まない再生環境では、今度は音が先に鳴る側へ外れます。そして priming の長さはエンコーダー、つまり端末ごとに違います。気づかれにくい向きに 45ms ずれたままにするほうが、端末ごとに逆向きへ外れる危険を負うより安全、という判断です。

一方、Aim Training では逆の判断をしています。効果音は MP3 で持っていて、MP3 もエンコーダーの遅延のせいで、デコードすると先頭に 5〜35ms の無音が付きます。仕様書に「先頭の無音は 2ms 以内」とあるので、読み込んだときに無音の長さを測り、再生の開始位置をそのぶん後ろにずらしています。

/**
 * MP3 のエンコーダ遅延で先頭に無音が付くので、読み込み時に測って
 * 再生の開始位置に使う(10 §8-2 の「先頭の無音は 2ms 以内」)。
 */
function leadingSilenceS(buffer: AudioBuffer): number {
  const data = buffer.getChannelData(0);
  const threshold = 0.001;
  const limit = Math.min(data.length, buffer.sampleRate);
  for (let i = 0; i < limit; i++) {
    if (Math.abs(data[i]!) >= threshold) return i / buffer.sampleRate;
  }
  return 0;
}

同じ「エンコーダーが足した先頭の無音」なのに、片方は直さず、片方は切っています。判断が分かれたのは、ずれを直す場所が自分の手の中にあるかどうかです。Aim Training の再生は自分のコードが行うので、測った値をそのまま使えます。録画した MP4 は SNS のアプリが再生するので、その再生環境が edit list を読むかどうかを、こちらは知りようがありません。エイムの練習では引き金と銃声の間の遅れが手応えに直結する、という事情も加わっています。

何 Hz で書き出すか

書き出しのサンプルレートも、アプリによって分かれています。

  • ショート動画メーカー:48kHz に固定しています。デコード用の AudioContext も { sampleRate: 48000 } で作り、素材はデコードの時点で 48kHz に変換されます
  • チャットシアター:48kHz に固定しています。合成した通知音しか鳴らさないので、変換も起きません
  • 10秒だけ!:固定せず、端末の AudioContext のレートをそのままエンコーダーに渡しています。遊んでいる最中の音を取り出すので、変換の手間を挟まない形です
  • ノイズダウンロード:44.1kHz に固定しています

どのアプリでも、サンプルレートの食い違いで壊れたという記録はありません。どこかで一度だけレートを決め、それ以外の場所で変換が起きないようにしています。

AAC を書けるかどうかは、使う前に必ず確かめています。WebCodecs の AudioEncoder は Safari では 26 からの対応です(MDN の互換性データ)。書けない端末では、10秒だけ!もショート動画メーカーも、音声トラックの無い MP4 を書き出します。10秒だけ!の仕様には「Opus には落とさない」と書いてあり、理由は「MP4 の Opus は SNS で再生できないことがある」です。音が無い動画と、どこかで再生できない動画なら、前者を選んだということです。

iOS で鳴らす

iOS の Safari は、ユーザー操作のイベントの中でしか AudioContext を動かしません。10秒だけ!では、音を起こす unlock() をゲーム画面のボタンの pointerdown と click の両方から呼んでいます。

    const wake = (ev) => {
      if (ev.target.closest?.('button')) this.sound.unlock();
    };
    // capture にしておくと、HtmlUI の click(document)より必ず先に起きる
    this.gameView.addEventListener('pointerdown', wake, { capture: true, passive: true });
    this.gameView.addEventListener('click', wake, { capture: true, passive: true });

両方にしたのは、iOS の版によって pointerdown をユーザー操作と数えないことがあるためです。タッチの場合、ユーザー操作に数えられるのは pointerup や touchend のほうで、pointerdown が数えられるのはマウスのときだけ、という整理も見かけます(解説記事、WebKit のバグ報告)。unlock() は何度呼んでもよい作りにしてあり、止まっていれば resume() し直します。

unlock() の中では、AudioContext を作る前に navigator.audioSession.type を設定しています。

      // マナーモードで鳴らさない・聞いている音楽を止めない(対応ブラウザだけ)。
      // **AudioContext を作る前に**設定する。
      try {
        if (navigator.audioSession) navigator.audioSession.type = 'ambient';
      } catch {
        // 未対応
      }

Audio Session API は、ページの音を他のアプリの音とどう混ぜるかを宣言する API で、Safari 16.4 から使えます(MDN)。ambient は「他の音と混ぜてよい、控えめな音」という宣言で、ゲームの効果音が聞いている音楽を止めないようにするためのものです。iOS では、消音スイッチ(マナーモード)が入っていると Web Audio の音が鳴らないことも知られています(WebKit bug 237322)。10秒だけ!はこれを逆手に取り、初期状態を「音あり」にしました。iPhone はマナーモードで Web の効果音が鳴らないので、急に鳴る心配が小さい、という理由です。

ここまでの iOS 向けの対策は、どれも鳴らなくて困った末に入れたものではありません。設計の段階で予防として決めたもので、iPhone では確かめていません。

逆に、ノイズダウンロードは audioSession を設定していません。睡眠用のノイズを流すアプリは、マナーモードでも鳴ってほしい側です。その場合は playback を指定する、という解説もあります(解説記事)。ただ、ノイズダウンロードをマナーモードの iPhone で試した記録はなく、実際に鳴らないかどうかはまだ確かめていません。

読み込みとメモリ

Aim Training では、公開前の総点検で、音のデコードが何重にも走っていたことが分かりました。キーを 5 回押しただけで decodeAudioData が 245 回呼ばれていて、本来は 49 回で済むはずでした。入力のたびに呼ばれる解錠の処理が、まだデコードしていない音をすべてデコードする関数を await していて、処理中かどうかを見ていなかったためです。さらに BGM 4 曲を常に展開していて、それだけで約 46MB を使っていました。iOS でタブが落ちる原因の最有力候補として挙がったので、デコードを 1 本の Promise の列に並べて直列にし、失敗した音は覚えて二度と試さないようにし、BGM はその回に使う 1 曲だけを展開するように直しました。

-  async decodePending(): Promise<void> {
-    const context = this.context;
-    const supply = this.supply;
-    if (!context || !supply) return;
-    const pending = SOUNDS.filter((def) => !this.buffers.has(def.id));
 // ...
+  decodePending(): Promise<void> {
+    this.decodeQueue = this.decodeQueue.then(() => this.decodeSounds());
+    return this.decodeQueue;
+  }
+
+  prepareMusic(track: number): Promise<void> {
+    this.decodeQueue = this.decodeQueue.then(() => this.decodeMusic(track));
+    return this.decodeQueue;
+  }

同じ総点検で、BGM を Ogg で入れていたことも見つかりました。仕様書には「Safari のために Ogg 系は使わない」と書いてあったのに、手元の素材をそのまま入れていたものです。Safari が Ogg を再生できるようになったのは 18.4 からです(Safari 18.4 のリリースノート)。古い iOS では BGM が鳴らなくてよいと私が判断し、別の形式は持たずに、失敗を覚えて再試行しない上の修正で害を「無音」にとどめています。

プチッという音を消す

音を急に止めると、波形が途中で切れて「プチッ」という雑音になります。10秒だけ!の合成部品には、それを避けるための定数が並んでいます。

/** 止めるときに音量を 0 へ落とす時間(秒)。ぶつ切りのプチッを避ける。 */
const FADE = 0.015;
/** 減衰の行き先。exponentialRamp は 0 に向かえないので、ほぼ無音の値で止める。 */
const SILENT = 0.0001;
/** 持続音の音量・音程を寄せる速さ(setTargetAtTime の時定数、秒)。毎フレーム値が変わってもプチッと言わない */
const SUSTAIN_GLIDE = 0.03;

exponentialRampToValueAtTime は 0 を目標にできない(指数で近づくので 0 には届かない)ので、0.0001 まで下げて止めます。チャットシアターの通知音が 0.0001 から始まって 0.0001 で終わっているのも同じ理由です。ゲームの状態で毎フレーム音量が変わる持続音は、値を直接書き換えず、setTargetAtTime で時定数 30ms をかけて寄せています。

耳で確かめられないので測る

Claude Code は音を聞けません。私も、修正のたびにすべての音を聴き比べるわけにはいきません。そのため、どのサービスでも「聞いて確かめる」代わりに「測って確かめる」方法を用意していました。

ノイズダウンロードは、もとになったプロトタイプのコードを分割し、設定項目を足す作業から始まりました。既定値のときにプロトタイプと同じ音が出ることを確かめるため、14 種類のノイズの左右、28 通りを同じ乱数列で生成して突き合わせ、24 通りがビット単位で一致しました。残りの 4 通り(風とドローン)は 1e-16〜1e-14 の丸めの差で、16 ビットに量子化すれば消える大きさです。

同じノイズダウンロードでは、私が「しとしととざあざあの差があまり感じられません」と伝えたのをきっかけに、雨だれの数を測りました。修正前は設定を最小から最大にしても毎秒 11.8 粒から 19.8 粒にしか増えていませんでした。雨だれ 1 粒の減衰が 45ms で固定されていて、前の粒が消えるまで次が鳴らない作りだったので、毎秒 20 粒あたりが天井になっていたのです。減衰を頻度に連動させたあとは、毎秒 3.3 粒、17.0 粒、71.5 粒と 22 倍の幅になりました。この測定では、検出器が波形の振動そのものを雨だれとして数えていたり、焚き火の地のブラウンノイズを数えていたりして、測る側を 2 回直しています。

音量の揃え方も、測った結果で決めました。ブラウンノイズの明るさや送風の風量を変えられるようにしたとき、Claude Code は音量の補正が要ると見込んで入れました。測ってみると、設定の範囲全体での音量の振れ幅は、補正なしの 0.6dB に対して補正ありでは 2.7dB と、かえって大きくなりました。±1 のクリップと後段の tanh が音量を自然に揃えていたので、公開前に補正は外しています。

ショート動画メーカーでは、OfflineAudioContext で描き出した波形を数値で確かめています。音量 25% で振幅がちょうど 4 分の 1 になること、パンを左いっぱいにすると右の成分が 0 になること、500Hz のローパスで高域が 98.5% 削れることなどです。

チャットシアターでは、量産のために 5 シリーズ 100 本の動画をヘッドレス Chrome で書き出したとき、すべての MP4 の音を decodeAudioData で読み、通知音の立ち上がりを数えました。240 サンプルごとのピークが 0.05 を超えた点を立ち上がりとみなし、メッセージの数(通知音の鳴らないお知らせを除く)と照らし合わせて、100 本すべてで一致しました。

10秒だけ!の AAC の 45ms も、同じように録った MP4 をデコードし、5ms 刻みで立ち上がりを拾って測った値です。「コッ」が動画に入っていないことも、その時刻の音の大きさ(RMS)が 0.0002 以下であることで確かめています。代わりの経路は、プレビューで AudioEncoder や VideoEncoder を消して動かし、音声トラックの無い MP4 が書き出されることや、MediaRecorder に切り替わることを確かめました。

測るときに音を鳴らさない工夫も要りました。10秒だけ!の開発中、Claude Code がプレビューで動作を確かめた際に、私の端末から効果音を鳴らしてしまったことがあります。それ以来、URL に ?silent を付けると、スピーカーの手前で音量を 0 にするようにしました。ミュートと違ってグラフは最後まで処理されるので、合成や間引き、ミュートの音量の変化は普段どおり走ります。

      // `silent` でも destination までは繋ぐ。繋がないとグラフが処理されず、
      // ミュートの音量の変化(speaker.gain)も進まなくなる
      this._silencer = ctx.createGain();
      this._silencer.gain.value = this.silent ? 0 : 1;

Aim Training の確認は、ヘッドレス Chrome を --mute-audio 付きで起動して行っています。

耳で聴く確認がいらなくなったわけではありません。10秒だけ!の録画の音は、最後に Android の端末で録って SNS に上げ、ちゃんと鳴ることを確かめました。測る方法を用意したのは、聴く回数を「最後の 1 回」まで減らすためです。

  1. 2112 サンプルという値と、edit list で先頭を飛ばす仕組みは、Apple の Technical Note TN2258 と QuickTime File Format の解説にあります。 ↩
  2. ITU-R BT.1359 という放送の勧告の数値として、10秒だけ!の設計資料に書いたものです。勧告の原文には当たっていません。 ↩

← 記事一覧へ