AI Dev Lab
RainLull

RainLull ができるまで — 雨音を 4 レイヤーで合成する

雨音ヒーリングサウンドの開発ログ。ノイズのレイヤー合成、しずく音の先読みスケジューラ、procedural な遠雷、畳み込み残響をWeb Audioで重ねた設計を記録します。

雨の音で眠りたい、集中したい——という需要は大きく、アプリもたくさんあります。ただ、その多くは録音した雨の音をループ再生しているだけです。

録音ループには 2 つの弱点があります。ひとつは、数十秒〜数分で一巡するので、集中して聴くと「あ、またここだ」と繰り返しに気づいてしまうこと。もうひとつは、音源ファイルが数 MB〜数十 MB あって、読み込みが重いこと。

RainLull は、雨の音を録音ではなく その場で合成します。ループの継ぎ目がなく、無限に続き、音源ファイルは 1 バイトもダウンロードしません。全部ブラウザの Web Audio が生成しています。


#🎯 なぜ合成にこだわったのか

「雨の音なんて録音で十分では?」と言われそうですが、合成にする理由は明確です。

  • 継ぎ目がない — 乱数から作るので、同じ瞬間は二度と来ない。何時間流しても繰り返しに気づかない
  • 軽い — 音源ファイルがないので、ページを開いた瞬間に鳴らせる
  • 雨脚を自在に変えられる — 小雨から土砂降りまで、パラメータを動かすだけで連続的に変わる

録音だと「小雨の音源」「本降りの音源」と別々に用意することになりますが、合成なら 1 つの仕組みで全部の強さを作れます。


#🏗️ 雨を「層」で組み立てる

現実の雨の音を分解すると、いくつかの成分が重なっています。RainLull はそれをレイヤーとして別々に作り、混ぜています。

bed … 雨脚そのもの (ピンクノイズ)
fine … きらめく高音の細かさ (白ノイズ + ハイパス)
rumble … 遠くの低いゴーッという轟き (ブラウンノイズ + ローパス)
droplets… 個別の「ぴちょん」という雫
thunder … 遠雷
reverb … 全体を包む残響

面白いのは、ノイズの「色」を使い分けているところです。

ts
if (type === "white") {
 for (let i = 0; i < length; i++) data[i] = Math.random * 2 - 1
} else if (type === "pink") {
 // Voss-McCartney 近似で 1/f 特性を作る
 let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0
 for (let i = 0; i < length; i++) {
 const w = Math.random * 2 - 1
 b0 = 0.99886 * b0 + w * 0.0555179
 // ... 複数の係数で低域を持ち上げる
 const pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362
 data[i] = Math.max(-1, Math.min(1, pink * 0.11))
 }
}
  • 白ノイズ は全周波数が均等で、シャーッという硬い音。細かいきらめき(fine 層)に使う
  • ピンクノイズ は低いほど強い自然な分布で、雨脚(bed 層)の芯になる
  • ブラウンノイズ はさらに低域寄りで、遠くの轟き(rumble 層)を作る

自然界の音の多くはピンクノイズに近い分布を持つと言われていて、雨の「地の音」をピンクノイズにしたのはそれが理由です。白ノイズだけだと、雨というより砂嵐みたいな耳障りな音になります。


#💧 「ぴちょん」を一粒ずつ作る

雨の質感を決めるのは、実は地の音より個別の雫です。RainLull は雫を録音ではなく、1 粒ずつ合成しています。しかも、シーンによって雫の作り方そのものを変えています。

ts
// patter = 普通 の 雨だれ (柔らかい ノイズ tick)
// glass = ガラス を 弾く 硬く 高い 短い click
// plop = 水面 の ぽちゃ (上昇 する 共鳴 bloop + 飛沫、 残響 たっぷり)
// metal = トタン の 叩きつけ (鋭い transient + 高 Q 金属 リング 2 倍音)
// thud = 傘 / テント の 鈍い こもった bot (低音 ローパス、 飛沫 なし)
// leaf = 森 の 葉 を 打つ 柔らかい 中音

同じ「雨」でも、当たる対象で音はまったく違います。ガラス窓なら硬い高音、傘なら鈍い低音、水面ならぽちゃんと跳ねる残響。これを「雫の芯の周波数」「共鳴の Q 値」「減衰時間」「飛沫ノイズの量」の組み合わせで作り分けています。

各シーンは、これらのパラメータの組として定義されています。

ts
{
 id: "window", jp: "夜の窓辺",
 dropType: "glass",
 dropPitchMin: ..., dropPitchMax: ..., // 芯の周波数の幅
 dropQ: ..., // 共鳴の鋭さ
 dropDecay: ..., // 余韻の長さ
 reverb: ..., // 残響
}

「静かな小雨」「本降り」「傘」「雷雨」「森の雨」「水面」「トタン屋根」「テント泊」「土砂降り」——11 のシーンは、この雫の作り方と各層のバランスの違いで生まれています。


#⏱️ 雫のタイミング:先読みスケジューリング

ここが Web Audio で一番の技術的ポイントです。

雫を「鳴らしたい瞬間に setTimeout で鳴らす」と、必ずガタつきます。JavaScript のタイマーは数十 ms 単位でずれるので、雨だれのような細かい音では、そのズレが不規則なもたつきとして聞こえてしまう。

そこで look-ahead スケジューリングを使っています。考え方はこうです。

  1. 定期的に「これから少し先まで」を見る
  2. その区間に鳴らすべき雫を、Web Audio の正確なタイムスタンプ付きで予約する
  3. 実際の発音は Web Audio のオーディオクロックが担当する

JavaScript は「いつ何を予約するか」だけを担当し、正確な発音タイミングはオーディオエンジンに任せる。この分業で、JS タイマーのブレとは無関係に、雫がサンプル精度で正しく鳴ります。密度(1 秒あたりの雫の数)を上げれば土砂降り、下げれば小雨になります。


#🌫️ 残響もファイルなしで作る

雨の音には、空間の広がり(残響)が欠かせません。普通、残響は「インパルス応答」という録音ファイルを畳み込んで作りますが、それだとまたファイルが必要になる。

RainLull は残響用のインパルス応答も合成しています。

ts
export function createImpulseResponse(ctx, seconds = 2.6, decay = 2.4): AudioBuffer {
 const rate = ctx.sampleRate
 const len = Math.max(1, Math.floor(rate * seconds))
 const ir = ctx.createBuffer(2, len, rate)
 for (let ch = 0; ch < 2; ch++) {
 const data = ir.getChannelData(ch)
 for (let i = 0; i < len; i++) {
 data[i] = (Math.random * 2 - 1) * Math.pow(1 - i / len, decay)
 }
 }
 return ir
}

ノイズを指数関数で減衰させただけのものを ConvolverNode に食わせています。物理的に正確な部屋の残響ではありませんが、雨を包む「空間っぽさ」を出すには十分で、しかもファイルが要らない。実用の質と「ゼロダウンロード」を両立させるための割り切りです。


#😓 苦労したところ

#ブラウザは勝手に音を鳴らせない

現代のブラウザは、ユーザーの操作なしに音を鳴らすことを禁じています(勝手に音が出る広告を防ぐため)。なので AudioContext は最初「停止」状態で作られ、再生ボタンのクリックで初めて起動する形にしています。ページを開いた瞬間に鳴らせないのは、この制約のためです。

#音の「大きすぎ」を防ぐ

複数のノイズ層と大量の雫を重ねると、ピークが積み上がって音が割れます。各層の生成時点で Math.max(-1, Math.min(1, ...)) とクリップを入れ、さらに全体のゲインを絞ってあります。合成音は「思ったより簡単に破綻する」ので、各段でレベルを抑えるのが地道に効きました。

#耳で確かめるしかない

これは正直に書いておきます。音は、コードが「動く」ことと「気持ちよく聞こえる」ことが別物です。パラメータが技術的に正しく反映されていても、実際に長時間聴くと耳障り、ということが起こる。数値のテストでは「鳴っている」ことしか確認できず、心地よさは実機で耳で聴くしかない。ここは自動化しきれない領域として残っています。


#🔭 今後の拡張

  • 雫の当たる対象のミックス — 「窓」と「傘」を同時に、のような合成
  • 風の層 — 雨に風のうなりを重ねる
  • イコライザー — 高音が苦手な人向けに全体のトーンを調整
  • プリセットの保存 — 自分好みに調整した雨を名前を付けて残す

#💡 このサービスから言えること

「録音してループ」で済ませていたものを、合成に置き換えると質が上がる。 継ぎ目のなさ、軽さ、連続的に変えられる自由度——これらは録音では原理的に得られないものです。雨の音のような「自然に揺らぎ続けるもの」ほど、合成の相性が良い。

技術的な核心は look-ahead スケジューリングでした。「JS タイマーは不正確だが、オーディオクロックは正確」という性質を理解して、役割を分ける。これは音を扱う Web アプリすべてに効く考え方で、メトロノームでも同じ手を使っています。

そして、音・映像といった感覚に訴えるものは、自動テストが通っても完成ではない。「鳴っている」と「心地よい」の間には、耳で確かめるしかない距離があります。数値で検証できる範囲と、人が確認するしかない範囲を分けて考えるようになったのは、このサービスからの一番の学びです。

[ ./next_action ]

読んだら、 RainLull を実際に動かす。

この開発ログは RainLull をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →