[Astro] #205 寝息を合成して WAV に書き出す(Web Audio・呼吸ごとのばらつき・ループ対応)
はじめに
添い寝アプリの中にあった寝息の生成を、単品のアプリ BREATH(/breath)として切り出して、作りこみました。ゲームやアプリで、ループ再生して使える WAV を作るのが目的です。
- 音は、ピンクノイズを、フィルターと音量の包絡で整えて作ります。ライブラリは使いません(Web Audio API だけ)
- リアルタイムの再生と、WAV の書き出しは、同じ関数
frame()で音を決めます - つまみは 21 個。吸う息と吐く息を、別々に調整できます
- 呼吸ごとの長さ・深さのばらつき、ステレオの広がり、息の中での音の高さの動き、山の偏りを足しました
- 書き出す WAV は、始点も終点も無音です。つなげると、ループになります
- A/B 比較、プリセット 10 個、自分の音声ファイルの追加があります
スクリーンショット
動画
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 秒 |
| Q | 0.1 秒 |
| ローパスの周波数 | 0.1 秒 |
| master | 0.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 |
|---|---|---|
| 0 | 1(完全に同じ) | 0.0054 |
| 0.5 | 0.50 | ほぼ同じ |
| 1 | 0.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 秒ごと(とページを閉じるとき)に保存するので、リロードしても残ります
プリセットと保存先
| 内容 | 保存先 | キー |
|---|---|---|
| いまの設定 | localStorage | breath-app-params |
| プリセット 10 個 | localStorage | breath-app-presets |
| プリセットの名前 | localStorage | breath-app-preset-names |
| A/B | localStorage | breath-app-ab |
| 自分の音声 | IndexedDB | breath-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 ガイド にまとめています