blog

「ざあざあ」が1秒20粒で頭打ちだった、録音ゼロの雨音ジェネレーター

ノイズダウンロードは、作業や睡眠のための環境音を作って保存するツールです。ホワイトノイズや雨、焚き火など 14 種類の音から選び、長さや音のやわらかさを決めて、MP3 か WAV で保存します。

このツールには、録音した音のファイルが 1 つも入っていません。雨の音も焚き火の音も、乱数と簡単な計算から、その場で 1 サンプルずつ作っています。この記事では、その作り方と、プロトタイプを本番にするまでにつまずいたところを書きます。

1 枚の HTML から始まった

出発点は、「しずかな時間」という名前の、1 枚の HTML でできたプロトタイプでした。815 行のファイルに、14 種類の音の合成、再生ボタンでの試聴、MP3 と WAV の書き出しまでが入っていて、MP3 エンコーダーの lamejs も丸ごと貼り込まれていました。きっかけは、熊本で大きな地震があったときの記事でした。避難所に避難した受験生が、人が多くて集中して勉強できずにいる、という内容です。それを読んで、私が別の AI に頼んで作ってもらったのがこのプロトタイプです。この記事で紹介する合成のコードは、パラメーターを足した部分と、後で書く雨だれの直しを除けば、このプロトタイプから受け継いだものです。

これを本番のサービスにする作業は、Claude Code に任せました。2026 年 8 月 2 日の夜に出した最初の依頼は、次の 4 つです。

・一つ上の階層にある、つやっとディレクトリのように、CloudFlareの無料枠で本番環境を実装

・html,js,cssを分けてファイルにする

・それぞれのノイズで特定の設定項目を増やす。例えば雨なら雨っぽい音のする頻度とか、心音もリズムを選べたりとか、それぞれの項目で選択できるように

・designの中のデザインファイルを参考にプロトタイプのデザインを変更

デザインの資料には、音の部分について「original_prototype.html の実装をそのまま流用すること」と書いてありました。デザイン案の側の音は、CSS のアニメーションで動いて見えるだけの飾りだったからです。

設定項目の足し方は、Claude Code から 2 つの案が出ました。推奨は、14 種すべてに最低 1 項目を足し、ホワイトノイズのように調整する数値が無い音には新しく音の処理を足す案です。もう 1 つは、元のコードにある数値を変えられるようにするだけの案です。私は後者を選びました。

この選択が、そのまま検証のやり方を決めました。数値を変えられるようにしただけなら、既定値を元の定数にそろえれば、プロトタイプとまったく同じ音が出るはずです。実際に、14 種類の音の左右 28 通りを同じ乱数列で生成して旧実装と突き合わせ、24 通りがビット単位で一致することを確かめています。残りの 4 通り(風とドローン)も、差は 1e-16〜1e-14 で、計算の順序が変わったことによる丸めの差でした1。

作業は翌 8 月 3 日の昼までの 1 回のセッションで終わり、そのあいだにサイト名を「ノイズダウンロード」に変え、英語版も足しています。ストレージのキーや Service Worker のキャッシュ名がいまも shizuka で始まるのは、このときの名残です(画面に出ない内部の名前は変えなくてよい、と指示しました)。

音は「1 サンプルを返す関数」として作る

音のデータは、1 秒あたり 44,100 個の数(サンプル)の並びです。ノイズダウンロードでは、音の種類ごとに「呼ぶたびに次の 1 サンプルを返す関数」を作り、それを必要な数だけ呼んでいます。

  return function (dt) {
    const w = Math.random() * 2 - 1;
    let s = 0, pink;
    switch (type) {
      case 'white': s = w; break;
      case 'pink':
        b0 = 0.99886 * b0 + w * 0.0555179; b1 = 0.99332 * b1 + w * 0.0750759; b2 = 0.96900 * b2 + w * 0.1538520;
        b3 = 0.86650 * b3 + w * 0.3104856; b4 = 0.55000 * b4 + w * 0.5329522; b5 = -0.7616 * b5 - w * 0.0168980;
        s = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.11; b6 = w * 0.115926; break;

w は -1 から 1 までの一様な乱数で、これをそのまま出したものがホワイトノイズです。すべての高さの音を同じ強さで含むので、「サー」という明るい音になります。

ピンクノイズは、高い音ほど弱くしたノイズで、ホワイトノイズより落ち着いて聞こえます。ここでは、ホワイトノイズを 6 つの簡単なフィルター(b0 から b5)に通し、1 サンプル前の乱数を少し混ぜた b6 と、今回の乱数そのもの(w * 0.5362)を足して、その形に近づけています2。b0 から b6 は関数の外側の変数で、呼び出しをまたいで値を持ち越します。

関数が受け取る dt は、1 サンプルぶんの秒数(44.1kHz なら 1/44100)です。雨粒の減衰や音の位相のような時間の経過は、この dt を掛けて進めています。

ただし、すべてがサンプルレートから切り離されているわけではありません。後で出てくる雨だれや薪のはぜる音は、「1 サンプルごとに一定の確率で発生する」作りなので、48kHz で動くと 1 秒あたりの回数が約 1.09 倍に増えます。ブラウンノイズのような乱数の足し込みも、1 サンプルあたりの量で決めています。書き出すファイルは 44.1kHz に固定しているので、ファイルの粒の頻度はどの端末でも同じです。一方、試聴は端末の AudioContext のサンプルレートで動くので、48kHz の端末では、試聴の音とファイルの音で粒の数がわずかに違います。

ブラウンノイズと、入れてすぐ外した音量補正

さらに低い音に寄ったブラウンノイズは、乱数を足し続けるだけで作れます。

      // 係数を変えても ±1 のクリップと後段の tanh が効くので、音量はほぼ変わらず
      // 音色だけが動く(実測で範囲全体の振れ幅 0.6dB)。ゲイン補正は要らない
      case 'brown':
        brown += p.bright * w; if (brown > 1) brown = 1; else if (brown < -1) brown = -1;
        s = brown * 3.2; break;

前の値に小さな乱数を足していくので、値はゆっくりとさまよいます(ランダムウォーク)。急に変わらないぶん高い音が少なく、「ゴー」という低い音になります。放っておくと値がどこまでも離れていくので、±1 で止めています。

足す乱数の大きさ(p.bright)は、今回新しく変えられるようにした数値です。大きくすると、さまよい方が速くなり、音が明るくなります。

Claude Code は実装の途中で、これを変えると音量も変わるはずだと考えて、音量を打ち消す補正を入れました。ところが、測ってみると補正を入れたほうが音量が 2.7dB も振れ、入れないほうは 0.6dB しか変わりませんでした。±1 で止める処理と、後で書く tanh が、もともと音量をそろえていたのです。補正は公開前に外し、上のコメントにその理由を残しています。

雨は、ノイズの上に「雨だれ」を落とす

自然の音は、ノイズに少しだけ構造を足して作っています。雨は、ザーッという雨音の土台に、ポツッという雨だれを不規則に重ねたものです。

      case 'rain': {
        // ...
        const bed = (w * 0.5 - pink * 0.3);
        // 雨だれは減衰中に次が出ない。減衰 22 固定だと 1 粒 45ms が上限を作り、
        // rate をいくら上げても 20粒/秒 で頭打ちになって「ざあざあ」が効かなかった。
        // 既定 .0016 では 22 のまま(=元の音)、それより速いときだけ粒を短くする
        if (p.rate !== dRateSeen) { dRateSeen = p.rate; dDecay = 22 * Math.max(1, Math.sqrt(p.rate / 0.0016)); }
        if (dEnv <= 0 && Math.random() < p.rate) { dEnv = 1; dFreq = p.tone + Math.random() * p.tone * 2.5; dPh = 0; }
        let drip = 0;
        if (dEnv > 0) { dPh += 6.283 * dFreq * dt; drip = Math.sin(dPh) * dEnv * 0.5; dEnv -= dt * dDecay; }
        s = bed * p.hiss + drip; break; }

土台は、ホワイトノイズからピンクノイズを少し引いたものです。低い音が減って高い音が残るので、細かい雨粒が当たる「サー」に近くなります。

雨だれは、サンプルごとに p.rate の確率で 1 粒発生させます。1 粒は、ランダムな高さのサイン波を、直線的に小さくしていく短い音です。

コメントに書いてあるとおり、このコードには一度直した跡があります。次の節で、その経緯を書きます。

雨あしを上げても、雨が強くならない

公開の直前、雨の設定を触っていて、「しとしと」と「ざあざあ」の差がほとんど感じられないことに気付きました。Claude Code には「雨足はもっとダイナミックに変更してください」と頼みました。

Claude Code はまず、選択肢ごとに 1 秒あたりの雨だれの数を数えました。すると、しとしと、ふつう、ざあざあの順に 11.8、16.8、19.8 粒で、いちばん強い設定でも弱い設定の 1.7 倍しかありませんでした。発生確率は 7.5 倍に開いていたのに、です。

原因は、雨だれの出し方にありました。if (dEnv <= 0 && …) という条件のとおり、前の 1 粒が消えきるまで次の 1 粒は出ません。1 粒の長さは減衰の速さ 22 で決まっていて、1/22 秒(約 45 ミリ秒)です。そのため、確率をどれだけ上げても、1 秒に 22 粒を超えられませんでした。

直し方は 2 つの組み合わせです。まず、選択肢の確率そのものを広げました。

+    // 実測 3.1 / 16.7 / 71.7 粒per秒。上端は noise.js 側で減衰も速める(下記コメント参照)
     { id: 'rate', label: '雨あし', kind: 'segment', def: 0.0016, options: [
-      { v: 0.0006, nm: 'しとしと' }, { v: 0.0016, nm: 'ふつう' }, { v: 0.0045, nm: 'ざあざあ' }] },
+      { v: 0.00008, nm: 'しとしと' }, { v: 0.0016, nm: 'ふつう' }, { v: 0.02, nm: 'ざあざあ' }] },

これだけでは、上限の 22 粒は変わりません。そこで、確率が既定値より高いときだけ、確率の比の平方根に比例して減衰を速め、1 粒を短くしています(上のコードの dDecay)。

-        if (dEnv > 0) { dPh += 6.283 * dFreq * dt; drip = Math.sin(dPh) * dEnv * 0.5; dEnv -= dt * 22; }
+        if (dEnv > 0) { dPh += 6.283 * dFreq * dt; drip = Math.sin(dPh) * dEnv * 0.5; dEnv -= dt * dDecay; }

直したあとの数え直しでは、1 秒あたり 3.1、16.7、71.7 粒になりました。弱い設定と強い設定の差は、1.7 倍から 20 倍以上に開いています。Math.max(1, …) があるので、既定の「ふつう」では減衰は 22 のままです。既定値ではプロトタイプとビット単位で同じ音が出ることも、直したあとに確かめ直しました。

数え方にも、一度つまずいています。最初の数え方は振幅のしきい値を超えた回数を数えるもので、1 粒のサイン波の山を 1 つずつ数えてしまい、粒の数より大きな値が出ました。雨だれが発生した瞬間だけを数えるように直してから、上の数字になっています。

焚き火は、低いうなりとパチッという音

焚き火も同じ考え方で作っています。

      case 'fire':
        brown += 0.02 * w; if (brown > 1) brown = 1; else if (brown < -1) brown = -1;
        if (crEnv <= 0 && Math.random() < p.rate) { crEnv = 1; crAmp = (0.4 + Math.random() * 0.7) * p.size; }
        if (crEnv > 0) { s = (brown * 1.6 + (Math.random() * 2 - 1) * crEnv * crAmp) * 0.4; crEnv -= dt * 70 / p.size; }
        else s = brown * 0.64; break;

炎のゴーという低い音はブラウンノイズで、薪がはぜるパチッという音は、ごく短いホワイトノイズの破裂です。雨だれと違ってサイン波ではなくノイズを使うので、音の高さを感じない、乾いた音になります。1 回のパチッは、薪の大きさが既定の「まき」のとき 1/70 秒(約 14 ミリ秒)で、大きさを毎回ランダムに変えています。

焚き火の「はぜる頻度」も、雨と同じときに「おだやかはもっともっと穏やかで良い」と頼んで広げました。こちらは 1 回が短く頭打ちの問題は無かったので、確率の値を変えただけです。

+    // 実測 1.6 / 25.3 / 50.0 回per秒
     { id: 'rate', label: 'はぜる頻度', kind: 'segment', def: 0.0009, options: [
-      { v: 0.0003, nm: 'おだやか' }, { v: 0.0009, nm: 'ふつう' }, { v: 0.0025, nm: 'にぎやか' }] },
+      { v: 0.000035, nm: 'おだやか' }, { v: 0.0009, nm: 'ふつう' }, { v: 0.004, nm: 'にぎやか' }] },

コメントの数字は、小さなものも含めたはぜる回数です。はっきり聞こえる大きさのものに絞ると、「おだやか」は 1 秒に 11 回から 0.4 回ほどに減りました。

焚き火の数え方では、別の落とし穴がありました。土台のブラウンノイズ自体が大きく振れるので、低いしきい値では「ゴー」の山まで数えてしまいます。しきい値を土台の振れ幅より上に置き、確率を 0 にしたときに 0 回と数えられることを確かめてから測っています。

「やわらかさ」は、高い音を削るフィルター 1 つ

画面の「やわらかさ」のつまみは、高い音を削るローパスフィルター 1 つで実現しています。

export function warmthCutoff(warm) { return 300 * Math.pow(16000 / 300, warm / 100); }
export function volAmp(vol) { return Math.pow(vol / 100, 1.6) * 0.9; }
export function lp(st, x, cut, dt) { const rc = 1 / (6.283 * cut); const a = dt / (rc + dt); st.y += a * (x - st.y); return st.y; }

lp() は、抵抗とコンデンサーで作る回路(RC フィルター)と同じ計算で、出力を入力に少しずつ近づけます。急な変化についていけないので、高い音が削られます。

つまみの 0〜100 は、削り始める周波数の 300Hz〜16kHz に対応させています。直線ではなく指数で対応させているのは、人の耳が周波数の「差」ではなく「比」で高さを感じるからです。音量も、つまみの値を 1.6 乗してから掛けています。

最後に、音量を掛けた値を Math.tanh() に通しています。tanh は、小さい値はほぼそのまま通し、大きい値は ±1 に向かってなだらかに抑える関数です。ノイズがたまたま大きく振れても、音が割れずに丸く収まります。

2 時間の音を、4 秒ずつ作って MP3 にする

書き出しは、Web Worker も OfflineAudioContext も使わず、メインスレッドの JavaScript だけで行っています。いちどに全部を作ると画面が固まるので、4 秒ぶんずつ作っては、画面に処理を返しています。

  let pos = 0;
  while (pos < total) {
    const n = Math.min(block, total - pos);
    const L = new Int16Array(n), R = ch === 2 ? new Int16Array(n) : null;
    for (let i = 0; i < n; i++) {
      const e = env(pos + i);
      const l = Math.tanh(lp(fl, genL(dt), cut, dt) * amp * 1.3 * e);
      L[i] = l < 0 ? l * 32768 : l * 32767;
      if (ch === 2) { const r = Math.tanh(lp(fr, genR(dt), cut, dt) * amp * 1.3 * e); R[i] = r < 0 ? r * 32768 : r * 32767; }
    }
    if (mp3) { const b = ch === 2 ? enc.encodeBuffer(L, R) : enc.encodeBuffer(L); if (b.length) parts.push(b); }
    else parts.push(ch === 2 ? interleave(L, R) : L);
    pos += n;
    onProgress(pos / total);
    await new Promise(r => setTimeout(r, 0));
  }

-1〜1 の小数を 16 ビットの整数に直すとき、負の側は 32768、正の側は 32767 を掛けています。16 ビットの整数は -32768 から 32767 までなので、両端をぴったり使うためです。

左右の音は、別々の関数(genL と genR)で作っています。左右で独立した乱数を使うと、音が頭の中央ではなく、まわり全体から聞こえるように広がります。

env() は、最初と最後の数秒で音量をなめらかに上げ下げするためのものです。音の生成関数は 4 秒の区切りをまたいで状態を持ち越すので、区切りの位置で波形が途切れることはありません。

MP3 への変換には lamejs(LAME を JavaScript に移植したライブラリ)を使っていて、4 秒ぶんができるたびに encodeBuffer() に渡します3。MP3 にした結果だけを溜めていくので、2 時間ぶんでも 128kbps なら約 115MB(画面の表示は 1MB を 1,048,576 バイトで数えるので 110MB)に収まります。

setTimeout(r, 0) で処理を返す方式には弱点もあります。動作確認の途中、非表示のタブではこの setTimeout が 1 秒に 1 回ほどに絞られ、書き出しがほとんど進まなくなりました。利用者が書き出しの途中で別のタブに移ったときも、同じように遅くなると考えられます。いまのところ対策は入れていません。

WAV のヘッダーは 44 バイト書くだけ

WAV のほうは、ライブラリを使わずに書いています。WAV は、44 バイトのヘッダーのあとに、16 ビットの整数を並べただけの単純な形式です。

function buildWav(parts, ch, totalFrames) {
  const dataLen = totalFrames * ch * 2, byteRate = SR * ch * 2;
  const head = new DataView(new ArrayBuffer(44));
  const wr = (o, s) => { for (let i = 0; i < s.length; i++) head.setUint8(o + i, s.charCodeAt(i)); };
  wr(0, 'RIFF'); head.setUint32(4, 36 + dataLen, true); wr(8, 'WAVE'); wr(12, 'fmt ');
  head.setUint32(16, 16, true); head.setUint16(20, 1, true); head.setUint16(22, ch, true);
  head.setUint32(24, SR, true); head.setUint32(28, byteRate, true); head.setUint16(32, ch * 2, true);
  head.setUint16(34, 16, true); wr(36, 'data'); head.setUint32(40, dataLen, true);
  return new Blob([head.buffer, ...parts], { type: 'audio/wav' });
}

setUint32() の最後の true は、リトルエンディアン(下位のバイトから並べる)で書くという指定です。WAV はリトルエンディアンと決まっているので、これを忘れると読めないファイルになります。

本体のデータは、4 秒ごとに作った配列をそのまま Blob に並べるだけで、1 本の大きな配列にはつなぎ直しません。ただ、圧縮しない WAV は大きく、2 時間のステレオで約 1.27GB になります。WAV で 120MB を超える長さを選ぶと、画面に注意を出すようにしています。

試聴は ScriptProcessorNode のまま

再生ボタンで聞く音も、書き出しと同じ生成関数とフィルターを通しています。

  const bufSize = 2048;
  node = actx.createScriptProcessor(bufSize, 0, 2);
  analyser = actx.createAnalyser(); analyser.fftSize = 1024;
  const dt = 1 / actx.sampleRate;
  node.onaudioprocess = e => {
    const L = e.outputBuffer.getChannelData(0), R = e.outputBuffer.getChannelData(1);
    const cut = warmthCutoff(state.warm), amp = volAmp(state.vol), st = state.stereo;
    for (let i = 0; i < L.length; i++) {
      let l = lp(wl, chL(dt), cut, dt); l = Math.tanh(l * amp * 1.3); L[i] = l;
      if (st) { let r = lp(wr, chR(dt), cut, dt); R[i] = Math.tanh(r * amp * 1.3); } else { R[i] = l; }
    }
  };

ScriptProcessorNode は、2048 サンプルぶんの音が必要になるたびに onaudioprocess を呼んでくれるしくみです。生成関数は設定値を参照のまま持っているので、試聴中につまみを動かすと、ノードを作り直さずに次のサンプルから音が変わります。書き出しのときは設定値のコピーを渡すので、生成の途中で画面を触っても、ファイルの中身は変わりません。

ScriptProcessorNode は非推奨の API で、後継は AudioWorklet です。これを使い続けている理由として書き残されているのは、プロトタイプの見出しのコメント ScriptProcessor: continuous, no loop clicks と、デザイン資料の「そのまま流用すること」の 2 つだけです。コメントは、作った音をループ再生するのではなく、鳴らしながら作り続けるので継ぎ目でクリック音が出ない、という意味だと読めます。試聴と書き出しで同じ処理を通す考え方や、ほかのサービスでの AudioWorklet の使い方は、ブラウザの音まわりをまとめた記事に書きました。

本番だけ、英語版が半分日本語になる

英語版は、本番を出した日の昼に足しました。「需要は世界中にあると思うので英語にも切り変えられる機能が欲しいです」と頼んだところ、Claude Code は、検索結果を言語ごとに出し分ける方法から調べてきました。Google は検索結果に OG タグを使わないこと、Googlebot は言語の指定を送ってこないので 1 つの URL で出し分けると英語版がインデックスされないこと、といった調べた結果をもとに、/ を日本語、/en/ を英語にし、hreflang で互いを指す形に決めました。日英 2 つの HTML を手で保つと片方だけ直し忘れるので、HTML はテンプレートと語彙ファイルから Python のスクリプトで生成しています。

ローカルで英語になることを確かめてからデプロイしたところ、本番の英語ページは、画面の枠や見出しは英語なのに、ノイズの名前だけが日本語のままでした。そのときの私の質問は「sw.jsのCACHEとか関係ある?」です。

Claude Code は、本番の HTML と JavaScript を順に取ってきて調べました。本番の HTML は正しく、JavaScript もローカルとハッシュまで一致していました。食い違っていたのはレスポンスのヘッダーです。_headers で全ファイルに no-cache を指定していたのに、本番の /js/ や /css/ のファイルには Cache-Control: max-age=14400(4 時間)が付いていました。同じ _headers に書いた CSP などは効いていて、HTML の no-cache も効いていました。

これで症状の説明がつきます。HTML は毎回取り直されるので新しい英語版が届きます。英語化のために新しく足した i18n.js は、ブラウザにまだキャッシュが無いので新しい版が届きます。ところが、ノイズの名前を持つ catalog.js は、日本語の名前を直接書いていた 4 時間以内の古い版が、ブラウザのキャッシュから出ていました。

Service Worker のキャッシュ名を上げれば直る、というのが私の想像でしたが、それでは直りません。元の Service Worker は、インストールのときに cache.addAll() でファイルを取り込んでいました。

self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)).then(() => self.skipWaiting()));
});

addAll() は普通の fetch と同じく、ブラウザの HTTP キャッシュを通ります。キャッシュ名を上げて新しいキャッシュを作っても、その中に 4 時間前の JavaScript が入ってしまいます。古いファイルが、Service Worker の下をくぐって入ってくる形です。

直し方は 3 つです。1 つ目は、インストール時の取得に cache: 'reload' を付けて、HTTP キャッシュを通らずに必ずネットワークから取ることです。

self.addEventListener('install', e => {
  e.waitUntil(
    caches.open(CACHE).then(cache => Promise.all(SHELL.map(url =>
      fetch(new Request(url, { cache: 'reload' })).then(res => {
        if (!res.ok) throw new Error(`${url} が ${res.status}`);
        return cache.put(url, res);
      })
    ))).then(() => self.skipWaiting())
  );
});

2 つ目は、キャッシュ名の版数を手で上げるのをやめ、ビルドのときに中身のハッシュから決めることです。それまでは「デプロイのたびに CACHE の版数を上げること」というコメントがあり、上げ忘れれば利用者に古い版が張り付く作りでした。

    h = hashlib.sha256()
    for rel in sorted(set(shell)):
        f = OUT / rel.lstrip("/")
        if f.is_dir():          # "/" や "/en/" はディレクトリなので index.html を読む
            f = f / "index.html"
        h.update(f.read_bytes())
    version = "shizuka-" + h.hexdigest()[:12]

3 つ目は、ページ本体(ナビゲーションのリクエスト)だけをネットワーク優先にすることです。キャッシュ優先のままだと、デプロイしても利用者が次に開くまで古い画面が出続けます。オフラインのときは、キャッシュしておいた / か /en/ を返します。

ついでに、キャッシュする一覧から /index.html を外しました。Cloudflare Pages は /index.html を / へ 308 でリダイレクトするので、リダイレクトを経たレスポンスがキャッシュに入り、それをページ本体の代わりに返すとブラウザに弾かれるためです。

max-age=14400 がどこから来たのかは、当時は「Pages が静的ファイルのヘッダーを上書きする」と結論づけていました。後になって調べ直すと、独自ドメイン側のゾーン設定(Browser Cache TTL、既定で 4 時間)が、Cloudflare がキャッシュの対象とみなす拡張子のファイルにだけ効いていた可能性が高そうです(ダッシュボードの設定値はまだ確かめていません)。wrangler pages dev ではこの設定を通らないので、ローカルでは再現しません。この話は Cloudflare Pages の運用をまとめた記事で詳しく書きました。本番の sw.js は、いまは上の修正が入った版です。

小さな罠

ほかにも、同じセッションの中でいくつか踏んでいます。

pages.dev のアドレスから独自ドメインへの 301 リダイレクトは、Claude Code が最初に _redirects へ次の 1 行を書いていました。

https://noise-download.pages.dev/* https://noise-download.tsuyatt.com/:splat 301

これは Netlify の書き方で、Cloudflare Pages の _redirects はホスト名で振り分けられず、この行は黙って無視されます。リダイレクトの設定方法を毎回忘れてしまうので、先に作ったチャットシアターの設定を調べてもらったときに見つかりました。いまは Pages Functions の _middleware.js で 301 を返しています。この手順は何度も忘れるので、のちに Claude Code のスキルにまとめました。

hidden 属性が効かない、というのもありました。WAV を選ぶとビットレートの選択肢は隠れるはずが、表示されたままでした。ボタンのクラスに display:flex を指定していたため、ブラウザが [hidden] に当てている display:none より優先されていました。.chip[hidden]{display:none} を明示して直しています。

波形の表示が、読み込み直後に出ないこともありました。タブが隠れた状態でページを読み込むと、Canvas の幅が 0 で返ってきます。プロトタイプから受け継いだコードは resize イベントでしか測り直さなかったので、幅 0 のまま固まり、波形が二度と描かれませんでした。

function sizeCanvas(){
  const r=cv.getBoundingClientRect();
  cv.width=r.width*dpr; cv.height=132*dpr; cx.setTransform(dpr,0,0,dpr,0,0);
}
addEventListener('resize', sizeCanvas);

いまは ResizeObserver で、実際の幅が付いた時点で測り直しています。

いまの状態

雨あしの修正までは最初の 2 つのコミットに入っています。英語版と Service Worker の直しはまだコミットしていませんが、本番には出ていて、/en/ で英語版が動いています。

  1. 差の 1e-14 は、16 ビットに量子化したときの 1 段(約 3e-5)よりずっと小さく、書き出したファイルには現れません。 ↩
  2. 係数はプロトタイプにあったものです。出どころはコードにも資料にも書かれていません。 ↩
  3. lamejs はライセンスが LGPL-3.0 なので、ページの下部に LAME と lamejs へのリンクを載せています。 ↩

← 記事一覧へ