[Astro] #205 寝息を合成して WAV に書き出す(Web Audio・呼吸ごとのばらつき・ループ対応)

[Astro] #205 寝息を合成して WAV に書き出す(Web Audio・呼吸ごとのばらつき・ループ対応)

はじめに

添い寝アプリの中にあった寝息の生成を、単品のアプリ BREATH(/breath)として切り出して、作りこみました。ゲームやアプリで、ループ再生して使える WAV を作るのが目的です。

  • 音は、ピンクノイズを、フィルターと音量の包絡で整えて作ります。ライブラリは使いません(Web Audio API だけ)
  • リアルタイムの再生と、WAV の書き出しは、同じ関数 frame() で音を決めます
  • つまみは 21 個。吸う息と吐く息を、別々に調整できます
  • 呼吸ごとの長さ・深さのばらつき、ステレオの広がり、息の中での音の高さの動き、山の偏りを足しました
  • 書き出す WAV は、始点も終点も無音です。つなげると、ループになります
  • A/B 比較、プリセット 10 個、自分の音声ファイルの追加があります

スクリーンショット

BREATH — 画面全体(左: 再生・A/B・プリセット・書き出し、中央: 胸と波形、右: つまみ)

動画

BREATH — 動画

1. 信号経路

ノイズを、左右 2 系統の「バンドパス → ローパス → 音量」に通します。

ピンクノイズ ─ バンドパス ─ ローパス ─ 音量(env) ─┐
                                                    ├ ChannelMerger(2) ─ master ─ 出力
ピンクノイズ ─ バンドパス ─ ローパス ─ 音量(env) ─┘
  • ピンクノイズは、Paul Kellet の近似で、AudioBuffer に作ります。ループ再生の周期がそろわないように、長さの違う 3 本(6・7・5 秒)を使います
  • バンドパスの中心の周波数が、息の「音の高さ」です。吸うときは 1100 Hz、吐くときは 410 Hz が、既定値です
  • ローパス(tone)が、高域を削る「こもり」です
  • 音量(env)が、息の山を作ります

動かす値は、setTargetAtTime でなめらかに追従させます。つまみを動かしても、音が途切れません。

対象時定数
音量(env)0.03 秒
バンドパスの周波数0.08 秒
Q0.1 秒
ローパスの周波数0.1 秒
master0.15 秒

この経路を作る buildGraph(ctx, dest) は、AudioContext(リアルタイム)と OfflineAudioContext(書き出し)の、どちらでも使えます。

2. 音を決める関数 frame()

胸の上下を sin(phase) とすると、空気の流れは、その傾き cos(phase) です。cos > 0 が吸う息、cos < 0 が吐く息です。

export function frame(p, phase, t, depth = 1) {
  const ph = warpPhase(p, phase + p.sync * TAU);
  const c = Math.cos(ph) * (p.invert ? -1 : 1);
  const inhale = c > 0;
  // 吸う息・吐く息の、それぞれの中での位置 q(0〜1)
  let x2 = ((ph + Math.PI / 2) / TAU) % 1; if (x2 < 0) x2 += 1;
  const q = (x2 * 2) % 1;
  // ... 山の偏り・音の高さの動き(後述)
  const a = Math.max(0, absC - p.pause) / (1 - p.pause);   // 間(無音)を引いて 0〜1 に戻す
  const flow = Math.pow(a, p.shape);                         // 山の形
  const wob = 1 + p.wobble * (0.5 * Math.sin(t * 1.3) + 0.3 * Math.sin(t * 2.9 + 1) + 0.2 * Math.sin(t * 5.1 + 2));
  return { g: flow * (inhale ? p.inhaleLevel : p.exhaleLevel) * wob * depth, freq, inhale };
}
  • 入力は、パラメーター p、位相 phase、時刻 t(秒)、深さ depth
  • 出力は、音量 g、バンドパスの周波数 freq、吸う息かどうか
  • 状態を持たない純粋関数なので、リアルタイムの再生も、書き出しも、同じ結果になります
  • pause(間)は、流れが小さいところを 0 に切って、息と息の間を無音にします
  • wobble(ゆらぎ)は、3 つの低い周波数の sin を足して、音量をゆっくり揺らします

3. 吸う息の長さの割合

吸う息を、周期の r だけ占めるようにします。位相の進み方を、前半と後半で変えます(warpPhase)。

const x2 = x < r ? 0.5 * x / r : 0.5 + 0.5 * (x - r) / (1 - r);
  • r = 0.5 は、ゆがみなしで、元の式と一致します(最大差 4e-15)
  • 吸う側と吐く側を入れ替える invert のときは、r を 1 - r にします。吸う息の割合は、入れ替えても r のままです
  • 胸の動き(画面の円とグラフ)も、同じ関数でゆがめます

4. 山の偏り と 音の高さの動き

息の中の位置 q(0〜1)を使って、2 つを足しました。

山の偏り(skewIn / skewOut): |cos| の代わりに、次の式を使います。

const absC = skew === 0 ? Math.abs(c) : Math.sin(Math.PI * Math.pow(q, Math.pow(2, -skew * 1.2)));
  • 指数 γ = 2^(−skew × 1.2)。γ = 1(skew = 0)で、元の形です
  • skew > 0 は、山が早く来て、尾が長くなります。吐く息の尾を引く感じに使います
  • 山の頂点の位置を実測すると、skew = +0.6 で q = 0.32、−0.6 で q = 0.66、0 で 0.5 です
  • つまみを 0 に戻すと、以前と完全に同じ出力(差 0)です

音の高さの動き(sweepIn / sweepOut): 息の始めから終わりへ、中心の周波数を ±sweep オクターブ動かします。

const freq = sweep === 0 ? base : Math.min(12000, Math.max(60, base * Math.pow(2, sweep * (q * 2 - 1))));

息の始めは 1 / 2^sweep 倍、終わりは 2^sweep 倍です。

5. 呼吸ごとのばらつき

呼吸ごとに、長さと深さ(音の大きさ)を変えます。つまみは、長さのばらつき(varLen)、深さのばらつき(varDepth)、種(seed)です。

乱数は、Math.random() ではなく、種と呼吸の番号 k から決まる乱数にします。同じ種なら、再生でも書き出しでも、同じばらつきになります。

const hash01 = (seed, k, salt) => {
  let h = (Math.imul(seed | 0, 374761393) + Math.imul(k | 0, 668265263) + Math.imul(salt, 1013904223)) | 0;
  h = Math.imul(h ^ (h >>> 13), 1274126177); h ^= h >>> 16;
  return (h >>> 0) / 4294967296;
};
export const breathVar = (p, k) => ({
  len:   Math.max(0.5, 1 + p.varLen   * (hash01(p.seed, k, 1) * 2 - 1)),
  depth: Math.max(0.2, 1 + p.varDepth * (hash01(p.seed, k, 2) * 2 - 1)),
});

呼吸の時計 BreathClock が、呼吸の番号 k と、その中の位置 x(0〜1)を持ちます。

advance(p, dt) {
  let left = dt;
  for (let guard = 0; guard < 8 && left > 0; guard++) {
    const len = p.period * this.v.len, toEnd = (1 - this.x) * len;
    if (left >= toEnd) { left -= toEnd; this.k++; this.x = 0; this.v = breathVar(p, this.k); }
    else { this.x += left / len; left = 0; }
  }
}
  • 呼吸の境目(x = 0)は、流れが 0 の位置です。深さが切り替わるとき、音は鳴っていません
  • ばらつきが 0 のときは、以前と同じ(書き出しの長さも、呼吸の回数 × 呼吸の長さ)です
  • 時計は、BreathSound の中にあります。再生中(gain > 0)だけ進みます

6. ステレオの広がり

左右に、別のノイズを混ぜます。共通のノイズ 1 本と、左だけ・右だけのノイズを、w(広がり)で混ぜます。

左 = (1 - w)·共通 + w·左だけ
右 = (1 - w)·共通 + w·右だけ
const norm = 1 / Math.sqrt((1 - w) * (1 - w) + w * w);   // 全体の音の大きさをそろえる
w左右の相関RMS
01(完全に同じ)0.0054
0.50.50ほぼ同じ
10.008(ほぼ独立)ほぼ同じ
  • バンドパス・ローパス・音量は、左右の 2 系統に、同じ値を同時にかけます
  • 効果は、ヘッドホンで分かります。スピーカーでは、耳を近づけないと分かりません

7. 自分の音声ファイル

音源に、自分の音声ファイル(録音した寝息など)を選べます。

  • IndexedDB(breath-app-se)に保存します。上限は 15 MB、20 個までです
  • ファイルは、ループ再生します。合成のつまみは効かず、音量だけが効きます
  • 削除すると、選んでいた音源は、合成に戻ります

8. WAV の書き出し

OfflineAudioContext(2ch)で、同じ buildGraph と frame() を、10 ミリ秒きざみで走らせます。

const clock = new BreathClock(p, x0);
const step = 0.01;
for (let t = 0; t < sec; t += step) {
  const f = frame(p, clock.phase, t, clock.depth);
  g.chains.forEach((c) => { c.env.gain.setTargetAtTime(f.g, t, 0.03); c.bp.frequency.setTargetAtTime(f.freq, t, 0.08); });
  clock.advance(p, step);
}
const out = await ctx.startRendering();

ループできる長さ

  • 開始位置 x0 = ((r − sync) mod 1) は、流れが 0 になる位置(吸う息と吐く息の間)です。位相のずれ(sync)や、invert を入れても、そこが無音になるように補正します
  • 長さは、呼吸 N 回ぶんです。k = 0 の呼吸は (1 − x0) だけ、k = N の呼吸は x0 だけにして、始点と終点を、同じ位置にそろえます
  • 最後に、両端 10 ミリ秒を、0 に向けてフェードします。先頭と末尾のサンプルは、ちょうど 0 です(実測)
for (let i = 0; i < n; i++) { const k = i / n; d[i] *= k; d[d.length - 1 - i] *= k; }

つなげても、つなぎ目にプツッという音が出ない形です。ループ点のメタデータ(smpl チャンク)は、付けていません。

WAV のエンコード

16bit PCM の WAV を、DataView で書きます。

  • 左右が完全に同じ(広がり 0)のときは、モノラルで書き出します。ファイルが半分になります
  • 「音量をそろえる」にチェックすると、最大値を 0.89(−1 dB)に合わせます。左右の比は変えません
  • 書き出しの回数は、1〜200 回、サンプルレートは選べます

9. 画面の操作

A/B 比較

作業用の設定を、2 つ持ちます(breath-app-ab)。鳴らしたまま切り替えられます。

  • 切り替えると、いまの編集が、元の側に残ります
  • まだ空の側は、いまの設定から始まります
  • 「コピー」で、反対側へ複写します
  • 編集は、3 秒ごと(とページを閉じるとき)に保存するので、リロードしても残ります

プリセットと保存先

内容保存先キー
いまの設定localStoragebreath-app-params
プリセット 10 個localStoragebreath-app-presets
プリセットの名前localStoragebreath-app-preset-names
A/BlocalStoragebreath-app-ab
自分の音声IndexedDBbreath-app-se

添い寝アプリの保存名(sleep-*)とは分けています。同じサイトの中で混ざって、添い寝側の設定が壊れるのを防ぐためです。

プリセットの名前は、保存モードのときだけ入力欄が出ます。番号にマウスを載せると、名前が出ます。

つまみの並び

BREATH_SLIDERS の group で、「基本/吸う息/吐く息/質感/ばらつき」の見出しごとに並びます。吸う息と吐く息のつまみは、見出しで区別するので、ラベルには「吸う」「吐く」を入れていません。

10. 間違いの記録

  • 「音量をそろえる」を、既定でオンにした: 書き出した WAV が、再生中の音より、かなり大きくなりました(約 27 dB)。大きくすると、本物の寝息でも、波の音のように聞こえます。小さい音のほうが、実際の聞こえ方に近いので、必要なときだけ使う形にしました
  • 山の偏りの符号が逆だった: γ = 2^(+skew × 1.2) にしていたため、skew > 0 で山が遅くなりました。指数の符号を直して、山の頂点の位置を実測で確かめました
  • JS で作った要素に、CSS が効かなかった: Astro の <style> は、そのページの要素だけに効きます。つまみ・見出し・プリセット番号は JS で作るので、スタイルが当たりませんでした。<style is:global> にして直しました。素の HTML で試すと、この問題は出ません
  • ノイズが増えていないかの確認: 大音量で粗が目立ったので、書き出しで、ノイズが足されていないかを測りました。足されていません

11. 制約

項目内容
確認した環境PC の Chrome のみ。Safari・Firefox は、まだ
ループ点smpl チャンクは付けていない。ゲームエンジンが必要とするなら、足す
ゲームでの確認実際のゲームに入れて、つなぎ目を聴く確認は、まだ
自分の音声ループ再生と音量だけ。合成のつまみは効かない
人間の寝息本物の寝息の再現は難しい。つまみで、近づける
画面つまみが多く、右の列は縦にスクロールする

関連

  • ゲームやアプリで使う前の流れは、BREATH ガイド にまとめています