LoginSignup
8
3

More than 1 year has passed since last update.

AudioBuffer(WebAudioAPI)をwavファイルに変換(0.4kB)

Last updated at Posted at 2023-01-24

AudioBuffer?

JSではWebAudioAPIを使って音を出すことができる
WebAudioAPIで音源を定義する方法は大体4通り

  • OscillatorNode
    • 正弦波とか三角波とか矩形波とか
    • 任意の周波数で行ける
    • ビープ音とか出したいならこれ
    • 意外と音質良い
  • AudioBufferSourceNode
    • AudioBufferの再生
      • 実態はバイナリデータ
      • 入手方法
        • 音源ファイルのArrayBufferを.decodeAudioData()
        • JSで頑張って波形生成
    • 短めの音声が得意
    • 効果音とかによく使う
  • MediaElementAudioSourceNode
    • <audio>タグとかを再生
    • 長めの音声が得意
    • iOSだと端末の再生中判定になったりしてちょっと不便
  • MediaStreamAudioSourceNode
    • マイクのリアルタイム再生
  • 詳しくは https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API#音源の定義

つまりAudioBufferは生の波形データを保持できるオブジェクト
Float32Arrayを各チャンネル毎に持っている

ネット上の音源ファイルから波形の型付き配列を得るサンプル

const
actx=new(window.AudioContext||webkitAudioContext)(),
abuf=await new Promise(async(ok,err)=>actx.decodeAudioData(
    await(
        await fetch('https://example.com/audio/nyan.mp3')
    ).arrayBuffer(),
    ok,
    err
));

console.log(
    [...Array(abuf.numberOfChannels)].map((_,i)=>abuf.getChannelData(i))
);

wav?

非圧縮で音声データを詰め込むときに便利なファイル形式
実はコンテナでmp3のデータを詰めることもできるらしい

構造

理解するにあたってお世話になったサイト様

wav自体がRIFFという汎用ファイル形式に沿ってできている模様
値を入れる際はリトルエンディアン

オフセット サイズ 説明
0 4 52,49,46,46 チャンク識別子 常に"RIFF"(0x52494646)
4 4 xx,xx,xx,xx チャンクサイズ これ以降のサイズ =ファイルサイズ-8=波形データサイズ+36
8 4 57,41,56,45 ファイルフォーマット wavファイルは常に"WAVE"(0x57415645)
12 4 66,6d,74,20 チャンク識別子 常に"fmt%20"(0x666d7420)
16 4 10,00,00,00 チャンクサイズ 例はPCM
20 2 01,00 音声フォーマット 例はPCM
22 2 02,00 チャンネル数 例は2ch
24 4 44,ac,00,00 サンプリング周波数 例は44.1k(=0x44ac0000)Hz
28 4 10,b1,02,00 byte/sec =サンプリング周波数*ブロックサイズ 例は44.1kHz、16bit、2ch(4410022=0x2b110)
32 2 04,00 ブロックサイズ =チャンネル数*ビット数/8 例は16bit、2ch(2*16/8=0x4)
34 2 10,00 ビット数 例は16bit
(2) 拡張パラメタサイズ PCMでは未使用
(*) 拡張パラメタ PCMでは未使用
36 4 64,61,74,61 チャンク識別子 常に"data"(0x64617461)
40 4 xx,xx,xx,xx チャンクサイズ =波形データサイズ
44 * xx,…… 波形データ PCMでは時間順に格納

ビット数に関して一般的に
8bit,16bit,24bit,32bit float, 64bit float
があるようで一般的に使われているのは
8bit,16bit の2つで
float系は音声フォーマットが3になる
今回はファイルサイズの肥大化を防ぐ目的から16bitを選択

波形データは
8bit: 0~255
16bit: -32768~32767
float: -1~1
で格納するらしい
複数チャンネルある場合はインターリブ配置
つまり
1ch,2ch,3ch,1ch,2ch,3ch,……
ちなみにステレオで1chは左

先駆者様

とりあえずjs audiobuffer to wavで調べるとこれが出てくる

先にArrayBufferのサイズを計算してDataViewを使ってデータを入れている
32bit floatにも対応している

あまりに長いので以前に人力minifyしたものがこれ
16bitで固定して計算できる定数は置き換えている

toWav=x=>{//https://github.com/Jam3/audiobuffer-to-wav
    let ch=x.numberOfChannels,rate=x.sampleRate;
    if(ch==1)x=x.getChannelData(0);else{const l=x.getChannelData(0),r=x.getChannelData(1);x=new Float32Array(l.length+r.length);for(let i=0;i<x.length;i++){x[i*2]=l[i];x[i*2+1]=r[i];}};
    const b=new ArrayBuffer(44+x.length*2),v=new DataView(b),str=(c,s)=>{for(let i=0;i<s.length;i++)v.setUint8(c+i,s.charCodeAt(i))};
    str(0,'RIFF');v.setUint32(4,36+x.length*2,true);str(8,'WAVE');
    str(12,'fmt ');v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,ch,true);v.setUint32(24,rate,true);ch*=2;v.setUint32(28,rate*ch,true);v.setUint16(32,ch,true);v.setUint16(34,16,true);
    str(36,'data');v.setUint32(40,x.length*2,true);for(let i=0;i<x.length;i++){const s=Math.max(-1,Math.min(1,x[i]));v.setInt16(44+i*2,s<0?s*0x8000:s*0x7FFF,true);}
    return b;
}

実装

筆者は以前にzipファイル生成を1kBで実装している
同じノリで書いた結果完成したものがこちら

toWav=w=>((
    {numberOfChannels:c,sampleRate:r},
    l4=x=>[x,x>>>8,x>>>16,x>>>24],
    l2=x=>[x,x>>>8],
    x=(
        x=>[...Array(x[0].length)].flatMap((_,i)=>x.flatMap(y=>l2(y[i]*0x7fff)))
    )([...Array(c)].map((_,i)=>w.getChannelData(i)))
)=>new Uint8Array([
    82,73,70,70,// RIFF
    l4(36+x.length),
    87,65,86,69,
    102,109,116,32,// fmt
    16,0,0,0,
    1,0,
    l2(c),
    l4(r),
    l4(r*(c*=2)),
    l2(c),
    16,0,
    100,97,116,97,// data
    l4(x.length),
    x
].flat()).buffer)(w),

数をリトルエンディアンにする関数l4 l2はもともとzip生成から借りてきた

le=(x,l=4)=>new Uint8Array(l).map((_,i)=>x>>>(i*8))

だったがパフォーマンスの関係で直書きした
x&0xffを付けずともUint8Arrayにする際に下位8bitだけになるので省いてある

x=(x=>[...Array(x[0].length)].flatMap((_,i)=>x.flatMap(y=>l2(y[i]*0x7fff))))([...Array(c)].map((_,i)=>w.getChannelData(i)))
AudioBufferから波形を取得してインターリブ配置にして16bit化するワンライナーである
二次元配列はmapを二つ組み合わせることで転置することができる
今回は一次元に並べたいのでflatMapを用いている
同時に16bit化するにあたってUint8Arrayに詰めるために16bitを8*2bitにする必要があるのでここでもflatMapを用いている

16bit化について
2の補数表現で負数を表すには絶対値の二進数を論理反転するだけで良い
つまり符号がついている正規化された数から変換するにはそのまま0x7fffを掛けて下位8bitをとるだけで良い
下位8bit問題はUint8Arrayに渡すので何も考えないで良い
これをそのままl2に渡している

変換後の値域は-32767~32767になる
許せない場合は先駆者さんと同じように三項演算子で符号に応じて0x7fffと0x8000を切り替えれば解決する
が、-1~1のデータを変換しているからこれは-32768を含めないほうが正しいのかも

コメントを消して折りたたむと

toWav=w=>((
    {numberOfChannels:c,sampleRate:r},l4=x=>[x,x>>>8,x>>>16,x>>>24],l2=x=>[x,x>>>8],
    x=(x=>[...Array(x[0].length)].flatMap((_,i)=>x.flatMap(y=>l2(y[i]*0x7fff))))([...Array(c)].map((_,i)=>w.getChannelData(i)))
)=>new Uint8Array([82,73,70,70,l4(36+x.length),87,65,86,69,102,109,116,32,16,0,0,0,1,0,l2(c),l4(r),l4(r*(c*=2)),l2(c),16,0,100,97,116,97,l4(x.length),x].flat()).buffer)(w),

先駆者様の人力minify版とこれの50秒程度のデータに対する10回の実行時間の比較

あんまり速くない?
タシカニ

使い道

恐らく一番便利なのはMediaRecorderの保存をwavにする用途

mr.ondataavailable=e=>save(e.data);

だとwebmなどで保存されるものが

mr.ondataavailable=async e=>save(new Blob([
    toWav(await new Promise(async(f,r)=>actx.decodeAudioData(
        await new Response(e.data).arrayBuffer(),f,r
    )))
]));

でwavにすることができる

あんまり使い道ない?
タシカニ

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3