Web Audio: リサンプル

 現在のオーディオシステムはサンプリング周波数が44.1kHzか48kHzのものが多い。 中にはハイレゾと称して96kHzや192kHzのものもあるが、音声のみならもっと低いサンプリング周波数で十分である。 電話は長いこと8kHz・8bitだったはずだ(8kHz×8bitで64kbps、64、64、128の64なわけだ)。 AM放送はもう少し狭く、アマチュア無線ではもっと帯域が狭くて3kHz程度である。

 サンプリング周波数を下げる目的はふたつあり、ひとつは通信帯域の節約である。 まぁこれはMP3なんかで圧縮してしまった方が効果が高い。 もうひとつは処理負荷の軽減である。 48kHzを8kHzにリサンプルすれば後の処理は全部8kHzで済むから、処理負荷は単純に1/6になる。 \(N^2\) に比例した処理があったりするとそれ以上の効果がある。

もくじ

リサンプル

 サンプリング周波数の変換にもいろいろあり、一番簡単なのが整数比になるサンプリング周波数への変換、次が有理数比、そして無理数比である。有理数比でも分子・分母が大きくなると無理数比と同じように処理した方がいい、というか、そう処理するしかなくなったりするので、比が簡単かどうかに着目すると思った方がいいだろう。

 よく使われる有理数比変換のうち、比較的面倒な比なのは147:160である。 これは44.1kHzと48kHzの周波数比で、これくらいが有理数とみなすか無理数とみなすかの境目だと思っていいと思う。 原理的には44.1kHzを48kHzにするなら、160倍アップサンプリングして、147倍ダウンサンプリングすればいい。

 間に補間フィルタが挟まるが、これはFIRを使うことが多く、ということは畳み込みになるから、FFTを使った巡回畳み込みを使うことが多い。 クソ真面目にサンプリング周波数を160倍にしてしまうと処理負荷が大変なことになるので、FFTの行きと帰りを最適化して、アップサンプリングが表に出てこないように実装するのが普通だろう。

 ここではとりあえず整数比のダウンサンプリング、いわゆるデシメーションを中心に説明する。 同時に、データ量を減らす手段として8ビット精度での量子化も実装してみる。 例によって音声信号処理の基本は分かっていることが前提。 リンサプルの詳しい理論については他サイトを参照されたし。

デシメーションフィルタ

 基本、ローパスフィルタである。 前回更新したローパスフィルタがそのまま使える。 ここではサンプリング周波数を整数で割り、8kHz以上のなるべく低い周波数に落とすことを考える。 FS=48kHzの場合は1/6デシメーションである。 44.1kHzの場合は1/5デシメーションで8820Hzになる。 デシメーションフィルタは通過域上限がナイキスト周波数を超えないように作る。 FS=44.1kHzの場合はナイキスト周波数は4410Hzだが、48kHzと特性をそろえるため、どちらも4000Hzを通過域の上限で設計する。

 最終的には精度8bitにするのでakamoz 8bit窓を使うと、通過帯域は±2Lf。 遅延を5msに設定すると、係数の長さは倍の10msで、2Lfは200Hzに相当する。 したがって、カットオフ周波数は3800Hzに設定すればよく、3600Hz以下はほぼそのまま出力される。 あとで実際に音を出すスクリプトを示すが、エイリアスの影響を肌で感じでもらうため、カットオフを変更できるようにしてある。

 Web Audioでの実装だが、フィルタに使えそうなクラスを探すと、まずIIRFilterNodeというクラスが見つかる。 これを使えばIIRは作れそうだ。 が、実際にはBiquadFilterNodeを直列につないだ方が楽なことが多いだろう。 FIRに関してはFIRFilterNodeというクラスはない。 じゃぁ自力で作らなければならないかというとそんなことはなくて、要するに畳み込みなのでConvolverNodeがFIR実装に使うWeb Audioクラスになる。 畳み込みのことを英語でconvolutionといい、動詞はconvolveである。

 ConvolverNodeは係数値をAudioBufferで設定する。 AudioBufferはデータを配列のように直接設定することはできなくて、Float32Arrayにデータを作ってcopyToChannelメソッドで設定する形になる。

 ConvolverNodenormalize(振幅の正規化)というプロパティがあるが、この正規化、なんか我々が考えているものとは違うようで、仕様書を見るとGainCalibrationと称して800で割られていたりするので、コンストラクタを呼び出すときにオプションのdisableNormalizationtrueに設定して、自力で正規化した方がいい。

デシメーション

 デシメーションフィルタはただのローパスフィルタなのでサンプリング周波数は変わらない。 実際にサンプリング周波数を下げるのがデシメーションである。 デシメーションフィルタをきちんとかけていれば、あとは単純に間引くだけ。

 ただ、Web Audioではサンプリング周波数がコンテキストで決まってしまうため、いくつかの例外をのぞいて後からサンプリング周波数を自由に設定することができない。 とりあえずデシメーションした結果を拾うだけ(拾ってWebSocketやPOSTで送りつけるような用途)ならばAudioWorkletが使える。 メッセージを使って結果をメインスレッドに放り投げればいい。 これで今までの話がいくつかつながるわけだ。 FFTは結局使ってないんだけれども。

 AudioWorkletは今のところ128サンプル単位でデータを扱うため、1/6や1/5のデシメーションだと1度の処理で出力するサンプル数がぴったりの値にならないことに注意。 余ったサンプル数を次回に繰り越して処理する必要がある。 逆に、毎回デシメーションした結果をメッセージで送りつける必要もないので、たとえば50msごとに放り投げるようにすれば、メッセージを投げるためのオーバーヘッドが減らせる。

アップサンプリング

 今回はダウンサンプリングした結果をもう一度音としてWeb Audioで再生してみことにする。 そうすると、デシメートした結果をもう一度アップサンプリングして元に戻さないといけない。 これは逆の手順で実行できる。 つまり、AudioWorkletにデシメートされたデータをメッセージで送りつけ、0データを挟み込んで5倍なり6倍なりのデータ量にしてAudioWorkletNodeの出力にし、それをConvolverNodeにつないでインターポレーションフィルタ処理(補間処理)をすればいい。

 原理的にはこれでいいのだが、Web AudioにはAudioBufferSourceNodeというのがあり、AudioBufferに設定したデータを再生できる。 実はAudioBuffersampleRateというプロパティがあり、これがコンテキストのFSと違うとリサンプルしてくれる。 さっき書いた、サンプリング周波数を自由に設定できる例外のひとつである。 「なーんだ、リサンプルできるじゃないか」と思うかもしれないが、できるのはコンテキストのFSに変換することだけである。 逆はできない。 まぁ、どうせリサンプルなんて周波数比で処理が決まるので、ごまかして処理させてもいいのかもしれないけど、リアルタイムに処理したい場合はいろいろと問題があるはず。

実装

 例によって「いつの間にか立派な子に成長してました(てへぺろ)」といういい例になっている。

とりあえず音を鳴らす

 とにかく音を鳴らす。

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
const audio = new AudioContext();
$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;
	const srcnode = new OscillatorNode(audio, { frequency: 440 });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 0.5 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);
	CLK("source", _ => {
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
});
</script>
<style>
#stop {
	font-weight: bold;
	border-width: 2px;
}
</style>
<p><button id="stop">stop</button>
<button id="source">source</button>

カスタムオシレータを使う

 OscillatorNodetypecustomにする。

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
const audio = new AudioContext();
$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;
	const numHarmonics = 50;
	const real = new Float32Array(numHarmonics);
	const imag = new Float32Array(numHarmonics);
	real[0] = imag[0] = 0;
	for (let i = 1; i < real.length; i++) {
		const theta = 2 * Math.PI * Math.random();
		real[i] = Math.cos(theta) / numHarmonics;
		imag[i] = Math.sin(theta) / numHarmonics;
	}
	const periodicWave = new PeriodicWave(audio, { real, imag, disableNormalization: true });
	const srcnode = new OscillatorNode(audio, { frequency: 440, type: "custom", periodicWave });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 1 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);
	CLK("source", _ => {
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
});
</script>
<style>
#stop {
	font-weight: bold;
	border-width: 2px;
}
</style>
<p><button id="stop">stop</button>
<button id="source">source</button>

FIRフィルタの追加

 係数を作って、ConvolverNodeに流し込み、再生してみる。 これでローパスフィルタの実際の出力を聞くことができる。

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
function coswin(t, coef) {
	t -= 0.5;
	return coef.reduce((sum, v, idx) => {
		return sum + v * Math.cos(Math.PI * idx * t);
	}, 0);
}
function akamoz48win(t) {
	return coswin(t, [ 0.427, 0.148, 0.398, 0.01155, 0.01475 ]);
}
function generateWindowFunction(f, sampletime, adjustEdge) {
	let win = [];
	const n = Math.floor(sampletime / 2);
	for (let i = -n; i <= n; i++)
		win.push(f(i / sampletime + 0.5));
	if (n == sampletime / 2 && adjustEdge) {
		win[0] /= 2;
		win[sampletime] /= 2;
	}
	return win;
}
function lpfresp(t, fc) {
	if (t == 0)
		return 2 * fc;
	return Math.sin(2 * Math.PI * fc * t) / Math.PI / t;
}
function generateFilterCoef(fs, respfunc, win) {
	const T = 1 / fs;
	const n = (win.length - 1) / 2;
	return win.map((v, i) => v * respfunc((i - n) * T));
}
function normalizeResponse(resp) {
	const amp = resp.reduce((sum, v) => sum + v);
	return resp.map(v => v / amp);
}

const audio = new AudioContext();
const FS = audio.sampleRate;

function createDecimationCoefBuffer(freq) {
	const delay = Math.floor(FS * 0.005); // 5ms, 240samples@48k
	const winlen = delay * 2;
	const coef = new AudioBuffer({
		length: winlen + 1, numberOfChannels: 1, sampleRate: FS
	});
	coef.copyToChannel(Float32Array.from(normalizeResponse(generateFilterCoef(
		FS, t => lpfresp(t, freq),
		generateWindowFunction(akamoz48win, winlen, true)
	))), 0);
	return coef;
}

$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;

	const numHarmonics = 50;
	const real = new Float32Array(numHarmonics);
	const imag = new Float32Array(numHarmonics);
	real[0] = imag[0] = 0;
	for (let i = 1; i < real.length; i++) {
		const theta = 2 * Math.PI * Math.random();
		real[i] = Math.cos(theta) / numHarmonics;
		imag[i] = Math.sin(theta) / numHarmonics;
	}
	const periodicWave = new PeriodicWave(audio, { real, imag, disableNormalization: true });
	const srcnode = new OscillatorNode(audio, { frequency: 440, type: "custom", periodicWave });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 0 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);

	const firnode = new ConvolverNode(audio, { disableNormalization: true });
	srcnode.connect(firnode);
	const gain1node = new GainNode(audio, { gain: 0 });
	firnode.connect(gain1node);
	gain1node.connect(dstnode);

	CLK("source", _ => {
		gain0node.gain.setTargetAtTime(1, 0, 0.005);
		gain1node.gain.setTargetAtTime(0, 0, 0.005);
		audio.resume();
	});
	CLK("decimated", _ => {
		gain0node.gain.setTargetAtTime(0, 0, 0.005);
		gain1node.gain.setTargetAtTime(1, 0, 0.005);
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
	function updateValues() {
		firnode.buffer = createDecimationCoefBuffer(parseFloat($ID("freq").value));
		const e = $ID("value-info");
		e.textContent = "";
		[
			`FS=${FS}[Hz],`,
			`cutoff-freq=${$ID("freq").value}[Hz]`
		].forEach(v => e.append($ELEM("span").text(v).fin));
	}
	AEL("freq", "change", updateValues);
	updateValues();
});
</script>
<style>
label {
	width: 100%;
	display: grid;
	grid-template-columns: max-content 1fr max-content;
}
#stop {
	font-weight: bold;
	border-width: 2px;
}
#value-info {
	font-size: 90%;
	font-family: monospace;
	display: flex;
	flex-wrap: wrap;
	column-gap: 1ex;
	span {
		white-space: nowrap;
	}
}
</style>
<label>Cutoff Frequency <input id="freq" type="range" value="3000" min="200" max="16000" step="100"></label>
<p><span id="value-info"></span>
<p><button id="stop">stop</button>
<button id="source">source</button>
<button id="decimated">decimated</button>

デシメーションの追加

 デシメーター本体はAudioWorkletなので、まずAudioWorkletProcessorのモジュールを作る。 Web Audioエンジンから受け取った音声データを指定された比率で間引いたものをメッセージで投げるようになっている。 したがって、このAudioWorkletは入力が1で、出力はない。 デシメートする比率はAudioWorkletNodeを作るときにオプションratioとして渡す。 メッセージを投げる単位をblockSapmlesで指定できる。

class DecimatorUint8 extends AudioWorkletProcessor {
	constructor(opt) {
		super(opt);
		const procopt = opt.processorOptions;
		this.ratio = Math.floor(procopt.ratio);
		this.bs = Math.floor(procopt.blockSamples ?? 128);
		this.rqs = globalThis.renderQuantumSize ?? 128;
		this.ratiocnt = 0;
		this.tmp = [];
		this.tmppos = 0;
	}
	process(inputs, outputs, parameters) {
		const inp = inputs[0];
		const nch = inp.length;
		if (nch == null)
			return false;
		const tmp = this.tmp;
		while (tmp.length < nch)
			tmp.push(Array(this.bs));
		let cnt = this.ratiocnt;
		let pos = this.tmppos;
		for (; cnt < this.rqs; cnt += this.ratio) {
			tmp.forEach((vec, ch) => {
				const v = inp[ch]?.[cnt] ?? 0;
				vec[pos] = v;
			});
			if (++pos >= this.bs) {
				const buf = new Float32Array(nch * this.bs);
				let i = 0;
				tmp.forEach(vec => vec.forEach(v => buf[i++] = v));
				this.port.postMessage({
					kind: "pull", buf, nch: tmp.length, bs: this.bs
				}, [ buf.buffer ]);
				pos = 0;
			}
		}
		this.ratiocnt = cnt - this.rqs;
		this.tmppos = pos;
		return true;
	}
}
registerProcessor("decimator-uint8", DecimatorUint8);

ノードのチャンネル数について

 まず、各オーディオノードは複数の入出力を持てる。 各入出力はひとつあるいは複数のチャンネルから構成されている。 オーディオノードをAudioNode.connectでつなぐとき、本来は入出力の関係を明示的に指定する必要がある。 指定しなかった場合は送り側ノードの最初の出力を、受け側ノードの最初の入力につなぐだけであり、入力を順番に選んだりとかはしてくれない。

 あるひとつの出力を複数の入力に接続することもでき、「ファンアウト(fan-out)を実現できる」と書いてあるが、要するに同じ音声データを複数の入力に同時に流すことができる、ということだ。 この「ファンアウト」という言葉は元々はロジックICで「ひとつの出力を何本の入力端子につないでも動作が保証されるか」という値だった。 昔のTTLロジックICは入出力に電流を流す必要があったため、出力側が16mAまで流せて、入力側が1.6mA必要とするなら、接続が保証されるのは入力10本までなわけだ。 これを「ファンアウト10」と言っていた。 先ほどの例ならsrcnodegain0nodefirnodeに接続している部分が相当する。

 あるひとつの入力に複数の出力をつなぐこともでき、この場合はミックス動作になる。 やはり先ほどの例だとgain0nodegain1nodedstnodeに接続している部分が相当する。 ただし、ある出力とある入力の間に作れる接続は1本だけである。 ある出力をさっきつないだのと同じ入力に接続しても無視される。 ここまではいい。

 問題は、あるノードの入力チャンネル数はそのノードに接続したノードの出力チャンネル数で決まる点である。 つまり、ノードを作った時点では入力のチャンネル数ははっきりしないのだな。 そこにどこからかのノードの出力をつないだ時点ではじめて入力のチャンネル数が決まる。 先ほど書いたように、ひとつの入力には複数の出力を接続できる。 この場合はそれぞれの出力のチャンネル数から一定のルールにしたがって入力チャンネル数が決まる。 ということは、接続の変更によって入力チャンネル数が変わることがある。 これ、AudioWorkletProcessorの実装に影響します、しまくりです。 面倒くさい。

 実際の入力チャンネル数の決定の方法はAudioNodechannelCountchannelCountModechannelInterpretationプロパティで指定できる。 特に、channelCountの値はchannelCountModeの値によって解釈が違ってくる。 入力チャンネル数がchannelCountと同じになっているとは限らないことに注意。

 これを踏まえて先ほどのAudioWorkletProcessorのコードを見てみると、コンストラクタではtmpバッファを空にしている。 これは入力に何か接続されないと必要なチャンネル数が分からないから。 つまり、processが呼ばれるまで正確な入力チャンネル数が分からない。 入力の数は1固定で扱っていて、processではinput[0]だけを処理している。 このlengthを見ているが、これがこの入力のチャンネル数になる。 チャンネルは増えたり減ったりする可能性があるが、増えた場合は書き込み先のバッファが足りなくなるので、tmpバッファを増やしている。

本体側コード

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
function coswin(t, coef) {
	t -= 0.5;
	return coef.reduce((sum, v, idx) => {
		return sum + v * Math.cos(Math.PI * idx * t);
	}, 0);
}
function akamoz48win(t) {
	return coswin(t, [ 0.427, 0.148, 0.398, 0.01155, 0.01475 ]);
}
function generateWindowFunction(f, sampletime, adjustEdge) {
	let win = [];
	const n = Math.floor(sampletime / 2);
	for (let i = -n; i <= n; i++)
		win.push(f(i / sampletime + 0.5));
	if (n == sampletime / 2 && adjustEdge) {
		win[0] /= 2;
		win[sampletime] /= 2;
	}
	return win;
}
function lpfresp(t, fc) {
	if (t == 0)
		return 2 * fc;
	return Math.sin(2 * Math.PI * fc * t) / Math.PI / t;
}
function generateFilterCoef(fs, respfunc, win) {
	const T = 1 / fs;
	const n = (win.length - 1) / 2;
	return win.map((v, i) => v * respfunc((i - n) * T));
}
function normalizeResponse(resp) {
	const amp = resp.reduce((sum, v) => sum + v);
	return resp.map(v => v / amp);
}

const audio = new AudioContext();
const FS = audio.sampleRate;
const RATIO = Math.floor(FS / 8000);
const decimatedFS = FS / RATIO;
const blockSamples = Math.floor(0.05 * decimatedFS);

function createDecimationCoefBuffer(freq) {
	const delay = Math.floor(FS * 0.005); // 5ms, 240samples@48k
	const winlen = delay * 2;
	const coef = new AudioBuffer({
		length: winlen + 1, numberOfChannels: 1, sampleRate: FS
	});
	coef.copyToChannel(Float32Array.from(normalizeResponse(generateFilterCoef(
		FS, t => lpfresp(t, freq),
		generateWindowFunction(akamoz48win, winlen, true)
	))), 0);
	return coef;
}

const promises = [];
promises.push(audio.audioWorklet.addModule("decimator-rev4.js"));
$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;

	const numHarmonics = 50;
	const real = new Float32Array(numHarmonics);
	const imag = new Float32Array(numHarmonics);
	real[0] = imag[0] = 0;
	for (let i = 1; i < real.length; i++) {
		const theta = 2 * Math.PI * Math.random();
		real[i] = Math.cos(theta) / numHarmonics;
		imag[i] = Math.sin(theta) / numHarmonics;
	}
	const periodicWave = new PeriodicWave(audio, { real, imag, disableNormalization: true });
	const srcnode = new OscillatorNode(audio, { frequency: 440, type: "custom", periodicWave });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 0 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);

	const firnode = new ConvolverNode(audio, { disableNormalization: true });
	srcnode.connect(firnode);
	await Promise.all(promises);
	const decinode = new AudioWorkletNode(audio, "decimator-uint8", {
		numberOfInputs: 1,
		numberOfOutputs: 0,
		processorOptions: { ratio: RATIO, blockSamples }
	});
	firnode.connect(decinode);
	decinode.port.onmessage = ev => {
		switch (ev.data.kind) {
		case "pull":
			console.log(ev);
			break;
		}
	};
	const gain1node = new GainNode(audio, { gain: 0 });
	firnode.connect(gain1node);
	gain1node.connect(dstnode);

	CLK("source", _ => {
		gain0node.gain.setTargetAtTime(1, 0, 0.005);
		gain1node.gain.setTargetAtTime(0, 0, 0.005);
		audio.resume();
	});
	CLK("decimated", _ => {
		gain0node.gain.setTargetAtTime(0, 0, 0.005);
		gain1node.gain.setTargetAtTime(1, 0, 0.005);
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
	function updateValues() {
		firnode.buffer = createDecimationCoefBuffer(parseFloat($ID("freq").value));
		const e = $ID("value-info");
		e.textContent = "";
		[
			`FS=${FS}[Hz],`,
			`ratio=${RATIO},`,
			`decimated-fs=${decimatedFS}[Hz],`,
			`Nyquist-freq=${decimatedFS / 2}[Hz],`,
			`cutoff-freq=${$ID("freq").value}[Hz]`
		].forEach(v => e.append($ELEM("span").text(v).fin));
	}
	AEL("freq", "change", updateValues);
	updateValues();
});
</script>
<style>
label {
	width: 100%;
	display: grid;
	grid-template-columns: max-content 1fr max-content;
}
#stop {
	font-weight: bold;
	border-width: 2px;
}
#value-info {
	font-size: 90%;
	font-family: monospace;
	display: flex;
	flex-wrap: wrap;
	column-gap: 1ex;
	span {
		white-space: nowrap;
	}
}
</style>
<label>Cutoff Frequency <input id="freq" type="range" value="3000" min="200" max="16000" step="100"></label>
<p><span id="value-info"></span>
<p><button id="stop">stop</button>
<button id="source">source</button>
<button id="decimated">decimated</button>

AudioBufferSourceNodeによる連続再生

 受け取ったデータをAudioBufferSourceNodeで連続再生する。 ダブルバッファなどのことは考えられていないので、自力でなんとかする必要がある。

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
function coswin(t, coef) {
	t -= 0.5;
	return coef.reduce((sum, v, idx) => {
		return sum + v * Math.cos(Math.PI * idx * t);
	}, 0);
}
function akamoz48win(t) {
	return coswin(t, [ 0.427, 0.148, 0.398, 0.01155, 0.01475 ]);
}
function generateWindowFunction(f, sampletime, adjustEdge) {
	let win = [];
	const n = Math.floor(sampletime / 2);
	for (let i = -n; i <= n; i++)
		win.push(f(i / sampletime + 0.5));
	if (n == sampletime / 2 && adjustEdge) {
		win[0] /= 2;
		win[sampletime] /= 2;
	}
	return win;
}
function lpfresp(t, fc) {
	if (t == 0)
		return 2 * fc;
	return Math.sin(2 * Math.PI * fc * t) / Math.PI / t;
}
function generateFilterCoef(fs, respfunc, win) {
	const T = 1 / fs;
	const n = (win.length - 1) / 2;
	return win.map((v, i) => v * respfunc((i - n) * T));
}
function normalizeResponse(resp) {
	const amp = resp.reduce((sum, v) => sum + v);
	return resp.map(v => v / amp);
}

const audio = new AudioContext();
const FS = audio.sampleRate;
const RATIO = Math.floor(FS / 8000);
const decimatedFS = FS / RATIO;
const blockSamples = Math.floor(0.05 * decimatedFS);
const blockSeconds = blockSamples / decimatedFS;
const latencySeconds = 0.01;

function createDecimationCoefBuffer(freq) {
	const delay = Math.floor(FS * 0.005); // 5ms, 240samples@48k
	const winlen = delay * 2;
	const coef = new AudioBuffer({
		length: winlen + 1, numberOfChannels: 1, sampleRate: FS
	});
	coef.copyToChannel(Float32Array.from(normalizeResponse(generateFilterCoef(
		FS, t => lpfresp(t, freq),
		generateWindowFunction(akamoz48win, winlen, true)
	))), 0);
	return coef;
}

const promises = [];
promises.push(audio.audioWorklet.addModule("decimator-rev4.js"));
$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;

	const numHarmonics = 50;
	const real = new Float32Array(numHarmonics);
	const imag = new Float32Array(numHarmonics);
	real[0] = imag[0] = 0;
	for (let i = 1; i < real.length; i++) {
		const theta = 2 * Math.PI * Math.random();
		real[i] = Math.cos(theta) / numHarmonics;
		imag[i] = Math.sin(theta) / numHarmonics;
	}
	const periodicWave = new PeriodicWave(audio, { real, imag, disableNormalization: true });
	const srcnode = new OscillatorNode(audio, { frequency: 440, type: "custom", periodicWave });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 0 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);

	const firnode = new ConvolverNode(audio, { disableNormalization: true });
	srcnode.connect(firnode);
	await Promise.all(promises);
	const decinode = new AudioWorkletNode(audio, "decimator-uint8", {
		numberOfInputs: 1,
		numberOfOutputs: 0,
		processorOptions: { ratio: RATIO, blockSamples }
	});
	firnode.connect(decinode);
	decinode.port.onmessage = ev => {
		switch (ev.data.kind) {
		case "pull":
			pullBuffer(ev);
			break;
		}
	};
	const gain1node = new GainNode(audio, { gain: 0 });
	gain1node.connect(dstnode);

	let audioEpoch = null;
	let blockCount = 0;
	function pullBuffer(ev) {
		const ibuf = ev.data.buf;
		const { nch, bs } = ev.data;
		const obuf = new AudioBuffer({
			sampleRate: decimatedFS,
			numberOfChannels: nch,
			length: blockSamples
		});
		for (let ch = 0; ch < nch; ch++)
			obuf.copyToChannel(ibuf.subarray(ch * bs, ch * bs + bs), ch);
		const bufnode = new AudioBufferSourceNode(audio, { buffer: obuf });
		bufnode.connect(gain1node);
		AEL(bufnode, "ended", _ => {
			bufnode.disconnect();
		}, { once: true });
		audioEpoch ??= audio.currentTime + latencySeconds;
		bufnode.start(audioEpoch + blockCount++ * blockSeconds);
	}

	CLK("source", _ => {
		gain0node.gain.setTargetAtTime(1, 0, 0.005);
		gain1node.gain.setTargetAtTime(0, 0, 0.005);
		audio.resume();
	});
	CLK("decimated", _ => {
		gain0node.gain.setTargetAtTime(0, 0, 0.005);
		gain1node.gain.setTargetAtTime(1, 0, 0.005);
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
	function updateValues() {
		firnode.buffer = createDecimationCoefBuffer(parseFloat($ID("freq").value));
		const e = $ID("value-info");
		e.textContent = "";
		[
			`FS=${FS}[Hz],`,
			`ratio=${RATIO},`,
			`decimated-fs=${decimatedFS}[Hz],`,
			`Nyquist-freq=${decimatedFS / 2}[Hz],`,
			`cutoff-freq=${$ID("freq").value}[Hz]`
		].forEach(v => e.append($ELEM("span").text(v).fin));
	}
	AEL("freq", "change", updateValues);
	updateValues();
});
</script>
<style>
label {
	width: 100%;
	display: grid;
	grid-template-columns: max-content 1fr max-content;
}
#stop {
	font-weight: bold;
	border-width: 2px;
}
#value-info {
	font-size: 90%;
	font-family: monospace;
	display: flex;
	flex-wrap: wrap;
	column-gap: 1ex;
	span {
		white-space: nowrap;
	}
}
</style>
<label>Cutoff Frequency <input id="freq" type="range" value="3000" min="200" max="16000" step="100"></label>
<p><span id="value-info"></span>
<p><button id="stop">stop</button>
<button id="source">source</button>
<button id="decimated">decimated</button>

 これで実際にデシメートした音を聞ける。 フィルタのサンプリング周波数を上げていけば、実際にエイリアシングを体感できる。

 が、エイリアシングと関係なく、プチプチとノイズが鳴ることがある。 AudioBufferSourceNodeがリサンプルアップを担当しているが、これが使い捨てなのが原因で、新しいノードを作ると補間フィルタの状態が受け継がれないためノイズになる。 リロードするとプチプチの大きさが変わるが、これはOscillatorNodeでランダムに位相を指定しているからで、周波数成分の振幅はいつも同じだから聞こえる音色は同じだが、位相はリロードごとに変わり、したがって波形の形も毎回違うからである。

 対策としてはループ再生して再生が終わった部分を書き換える、という方法も考えられるが、Web Audio設計者はそういう想定はしていなかったらしく、それ向けのイベントはなさそうだ。 とりあえず、ふたつのメッセージをつなげてひとつのAudioBufferSourceNodeで再生し、コサイン窓でオーバラップ加算する方針で行く。 AudioBufferSourceNodeはメッセージが来るごとに作るので、同時にふたつのAudioBufferSourceNodeが鳴っていることになり、加算はシステムに任せている。

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
function coswin(t, coef) {
	t -= 0.5;
	return coef.reduce((sum, v, idx) => {
		return sum + v * Math.cos(Math.PI * idx * t);
	}, 0);
}
function akamoz48win(t) {
	return coswin(t, [ 0.427, 0.148, 0.398, 0.01155, 0.01475 ]);
}
function generateWindowFunction(f, sampletime, adjustEdge) {
	let win = [];
	const n = Math.floor(sampletime / 2);
	for (let i = -n; i <= n; i++)
		win.push(f(i / sampletime + 0.5));
	if (n == sampletime / 2 && adjustEdge) {
		win[0] /= 2;
		win[sampletime] /= 2;
	}
	return win;
}
function lpfresp(t, fc) {
	if (t == 0)
		return 2 * fc;
	return Math.sin(2 * Math.PI * fc * t) / Math.PI / t;
}
function generateFilterCoef(fs, respfunc, win) {
	const T = 1 / fs;
	const n = (win.length - 1) / 2;
	return win.map((v, i) => v * respfunc((i - n) * T));
}
function normalizeResponse(resp) {
	const amp = resp.reduce((sum, v) => sum + v);
	return resp.map(v => v / amp);
}

const audio = new AudioContext();
const FS = audio.sampleRate;
const RATIO = Math.floor(FS / 8000);
const decimatedFS = FS / RATIO;
const blockSamples = Math.floor(0.05 * decimatedFS);
const blockSeconds = blockSamples / decimatedFS;
const latencySeconds = 0.01;

function createDecimationCoefBuffer(freq) {
	const delay = Math.floor(FS * 0.005); // 5ms, 240samples@48k
	const winlen = delay * 2;
	const coef = new AudioBuffer({
		length: winlen + 1, numberOfChannels: 1, sampleRate: FS
	});
	coef.copyToChannel(Float32Array.from(normalizeResponse(generateFilterCoef(
		FS, t => lpfresp(t, freq),
		generateWindowFunction(akamoz48win, winlen, true)
	))), 0);
	return coef;
}

const promises = [];
promises.push(audio.audioWorklet.addModule("decimator-rev4.js"));
$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;

	const numHarmonics = 50;
	const real = new Float32Array(numHarmonics);
	const imag = new Float32Array(numHarmonics);
	real[0] = imag[0] = 0;
	for (let i = 1; i < real.length; i++) {
		const theta = 2 * Math.PI * Math.random();
		real[i] = Math.cos(theta) / numHarmonics;
		imag[i] = Math.sin(theta) / numHarmonics;
	}
	const periodicWave = new PeriodicWave(audio, { real, imag, disableNormalization: true });
	const srcnode = new OscillatorNode(audio, { frequency: 440, type: "custom", periodicWave });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 0 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);

	const firnode = new ConvolverNode(audio, { disableNormalization: true });
	srcnode.connect(firnode);
	await Promise.all(promises);
	const decinode = new AudioWorkletNode(audio, "decimator-uint8", {
		numberOfInputs: 1,
		numberOfOutputs: 0,
		processorOptions: { ratio: RATIO, blockSamples }
	});
	firnode.connect(decinode);
	decinode.port.onmessage = ev => {
		switch (ev.data.kind) {
		case "pull":
			pullBuffer(ev);
			break;
		}
	};
	const gain1node = new GainNode(audio, { gain: 0 });
	gain1node.connect(dstnode);

	const overlapwin = Array(blockSamples);
	for (let i = 0; i < blockSamples; i++)
		overlapwin[i] = 0.5 - Math.cos(Math.PI * (i + 0.5) / blockSamples) * 0.5;
	const tbuf = new Float32Array(blockSamples);
	const fbuf = [];
	let audioEpoch = null;
	let blockCount = 0;
	function pullBuffer(ev) {
		const ibuf = ev.data.buf;
		const { nch, bs } = ev.data;
		while (fbuf.length < nch)
			fbuf.push(new Float32Array(bs));
		const obuf = new AudioBuffer({
			sampleRate: decimatedFS,
			numberOfChannels: nch,
			length: blockSamples * 2
		});
		let pos = 0;
		for (let ch = 0; ch < nch; ch++) {
			obuf.copyToChannel(fbuf[ch], ch);
			for (let i = 0; i < bs; i++) {
				const v = ibuf[pos++];
				const u = v * overlapwin[i];
				fbuf[ch][i] = u;
				tbuf[i] = v - u;
			}
			obuf.copyToChannel(tbuf, ch, bs);
		}
		const bufnode = new AudioBufferSourceNode(audio, { buffer: obuf });
		bufnode.connect(gain1node);
		AEL(bufnode, "ended", _ => {
			bufnode.disconnect();
		}, { once: true });
		audioEpoch ??= audio.currentTime + latencySeconds;
		bufnode.start(audioEpoch + blockCount++ * blockSeconds);
	}

	CLK("source", _ => {
		gain0node.gain.setTargetAtTime(1, 0, 0.005);
		gain1node.gain.setTargetAtTime(0, 0, 0.005);
		audio.resume();
	});
	CLK("decimated", _ => {
		gain0node.gain.setTargetAtTime(0, 0, 0.005);
		gain1node.gain.setTargetAtTime(1, 0, 0.005);
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
	function updateValues() {
		firnode.buffer = createDecimationCoefBuffer(parseFloat($ID("freq").value));
		const e = $ID("value-info");
		e.textContent = "";
		[
			`FS=${FS}[Hz],`,
			`ratio=${RATIO},`,
			`decimated-fs=${decimatedFS}[Hz],`,
			`Nyquist-freq=${decimatedFS / 2}[Hz],`,
			`cutoff-freq=${$ID("freq").value}[Hz]`
		].forEach(v => e.append($ELEM("span").text(v).fin));
	}
	AEL("freq", "change", updateValues);
	updateValues();
});
</script>
<style>
label {
	width: 100%;
	display: grid;
	grid-template-columns: max-content 1fr max-content;
}
#stop {
	font-weight: bold;
	border-width: 2px;
}
#value-info {
	font-size: 90%;
	font-family: monospace;
	display: flex;
	flex-wrap: wrap;
	column-gap: 1ex;
	span {
		white-space: nowrap;
	}
}
</style>
<label>Cutoff Frequency <input id="freq" type="range" value="3000" min="200" max="16000" step="100"></label>
<p><span id="value-info"></span>
<p><button id="stop">stop</button>
<button id="source">source</button>
<button id="decimated">decimated</button>

8ビット化とバッファの再利用

 やり取りするデータを浮動小数から8ビット整数に変更する。 メッセージでやり取りするのはInt8Arrayにする。 この中に各チャンネルが順に並んでおり、チャンネル内では最初の1バイトがスケーリング量、その後にそのチャンネルのサンプルが1バイト1サンプルで続く形にする。 したがって、メッセージでやり取りするバッファのサイズはバイト単位で(1+サンプル数)×チャンネル数になる。 変数で書けば(1+blockSamples)*nchである。

 スケーリング量については、スケーリング量をsとすると、各バイトを(1 << s)で割ると浮動小数の±1フルスケールになるように値を決める。 まずはAudioWorkletProcessorの方に手を入れる。

function roundedShift(v, n) {
	return Math.floor((v * (2 << n) + 1) / 2);
}

class DecimatorUint8 extends AudioWorkletProcessor {
	constructor(opt) {
		super(opt);
		const procopt = opt.processorOptions;
		this.ratio = Math.floor(procopt.ratio);
		this.bs = Math.floor(procopt.blockSamples ?? 128);
		this.rqs = globalThis.renderQuantumSize ?? 128;
		this.ratiocnt = 0;
		this.tmp = [];
		this.tmppos = 0;
		this.absmax = [];
	}
	process(inputs, outputs, parameters) {
		const inp = inputs[0];
		const nch = inp.length;
		if (nch == null)
			return false;
		const { tmp, absmax } = this;
		while (tmp.length < nch) {
			tmp.push(Array(this.bs));
			absmax.push(0);
		}
		let cnt = this.ratiocnt;
		let pos = this.tmppos;
		for (; cnt < this.rqs; cnt += this.ratio) {
			tmp.forEach((vec, ch) => {
				const v = inp[ch]?.[cnt] ?? 0;
				vec[pos] = v;
				absmax[ch] = Math.max(absmax[ch], Math.abs(v));
			});
			if (++pos >= this.bs) {
				const buf = new Int8Array(nch * (1 + this.bs));
				let i = 0;
				absmax.forEach((m, ch) => {
					let s = 5 + Math.clz32(m * (1 << 30));
					if (roundedShift(m, s) > 127)
						s--;
					buf[i++] = s;
					tmp[ch].forEach(v => buf[i++] = roundedShift(v, s));
					absmax[ch] = 0;
				});
				this.port.postMessage({
					kind: "pull", buf, nch: tmp.length, bs: this.bs
				}, [ buf.buffer ]);
				pos = 0;
			}
		}
		this.ratiocnt = cnt - this.rqs;
		this.tmppos = pos;
		return true;
	}
}
registerProcessor("decimator-uint8", DecimatorUint8);

 続いてHTML側だが、こちらはスケーリングを復元する程度であまり変更がない。

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
function coswin(t, coef) {
	t -= 0.5;
	return coef.reduce((sum, v, idx) => {
		return sum + v * Math.cos(Math.PI * idx * t);
	}, 0);
}
function akamoz48win(t) {
	return coswin(t, [ 0.427, 0.148, 0.398, 0.01155, 0.01475 ]);
}
function generateWindowFunction(f, sampletime, adjustEdge) {
	let win = [];
	const n = Math.floor(sampletime / 2);
	for (let i = -n; i <= n; i++)
		win.push(f(i / sampletime + 0.5));
	if (n == sampletime / 2 && adjustEdge) {
		win[0] /= 2;
		win[sampletime] /= 2;
	}
	return win;
}
function lpfresp(t, fc) {
	if (t == 0)
		return 2 * fc;
	return Math.sin(2 * Math.PI * fc * t) / Math.PI / t;
}
function generateFilterCoef(fs, respfunc, win) {
	const T = 1 / fs;
	const n = (win.length - 1) / 2;
	return win.map((v, i) => v * respfunc((i - n) * T));
}
function normalizeResponse(resp) {
	const amp = resp.reduce((sum, v) => sum + v);
	return resp.map(v => v / amp);
}

const audio = new AudioContext();
const FS = audio.sampleRate;
const RATIO = Math.floor(FS / 8000);
const decimatedFS = FS / RATIO;
const blockSamples = Math.floor(0.05 * decimatedFS);
const blockSeconds = blockSamples / decimatedFS;
const latencySeconds = 0.01;

function createDecimationCoefBuffer(freq) {
	const delay = Math.floor(FS * 0.005); // 5ms, 240samples@48k
	const winlen = delay * 2;
	const coef = new AudioBuffer({
		length: winlen + 1, numberOfChannels: 1, sampleRate: FS
	});
	coef.copyToChannel(Float32Array.from(normalizeResponse(generateFilterCoef(
		FS, t => lpfresp(t, freq),
		generateWindowFunction(akamoz48win, winlen, true)
	))), 0);
	return coef;
}

const promises = [];
promises.push(audio.audioWorklet.addModule("decimator-rev7.js"));
$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;

	const numHarmonics = 50;
	const real = new Float32Array(numHarmonics);
	const imag = new Float32Array(numHarmonics);
	real[0] = imag[0] = 0;
	for (let i = 1; i < real.length; i++) {
		const theta = 2 * Math.PI * Math.random();
		real[i] = Math.cos(theta) / numHarmonics;
		imag[i] = Math.sin(theta) / numHarmonics;
	}
	const periodicWave = new PeriodicWave(audio, { real, imag, disableNormalization: true });
	const srcnode = new OscillatorNode(audio, { frequency: 440, type: "custom", periodicWave });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 0 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);

	const firnode = new ConvolverNode(audio, { disableNormalization: true });
	srcnode.connect(firnode);
	await Promise.all(promises);
	const decinode = new AudioWorkletNode(audio, "decimator-uint8", {
		numberOfInputs: 1,
		numberOfOutputs: 0,
		processorOptions: { ratio: RATIO, blockSamples }
	});
	firnode.connect(decinode);
	decinode.port.onmessage = ev => {
		switch (ev.data.kind) {
		case "pull":
			pullBuffer(ev);
			break;
		}
	};
	const gain1node = new GainNode(audio, { gain: 0 });
	gain1node.connect(dstnode);

	const overlapwin = Array(blockSamples);
	for (let i = 0; i < blockSamples; i++)
		overlapwin[i] = 0.5 - Math.cos(Math.PI * (i + 0.5) / blockSamples) * 0.5;
	const tbuf = new Float32Array(blockSamples);
	const fbuf = [];
	let audioEpoch = null;
	let blockCount = 0;
	function pullBuffer(ev) {
		const ibuf = ev.data.buf;
		const { nch, bs } = ev.data;
		while (fbuf.length < nch)
			fbuf.push(new Float32Array(bs));
		const obuf = new AudioBuffer({
			sampleRate: decimatedFS,
			numberOfChannels: nch,
			length: blockSamples * 2
		});
		let pos = 0;
		for (let ch = 0; ch < nch; ch++) {
			const s = ibuf[pos++];
			obuf.copyToChannel(fbuf[ch], ch);
			for (let i = 0; i < bs; i++) {
				const v = ibuf[pos++] / (1 << s);
				const u = v * overlapwin[i];
				fbuf[ch][i] = u;
				tbuf[i] = v - u;
			}
			obuf.copyToChannel(tbuf, ch, bs);
		}
		const bufnode = new AudioBufferSourceNode(audio, { buffer: obuf });
		bufnode.connect(gain1node);
		AEL(bufnode, "ended", _ => {
			bufnode.disconnect();
		}, { once: true });
		audioEpoch ??= audio.currentTime + latencySeconds;
		bufnode.start(audioEpoch + blockCount++ * blockSeconds);
	}

	CLK("source", _ => {
		gain0node.gain.setTargetAtTime(1, 0, 0.005);
		gain1node.gain.setTargetAtTime(0, 0, 0.005);
		audio.resume();
	});
	CLK("decimated", _ => {
		gain0node.gain.setTargetAtTime(0, 0, 0.005);
		gain1node.gain.setTargetAtTime(1, 0, 0.005);
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
	function updateValues() {
		firnode.buffer = createDecimationCoefBuffer(parseFloat($ID("freq").value));
		const e = $ID("value-info");
		e.textContent = "";
		[
			`FS=${FS}[Hz],`,
			`ratio=${RATIO},`,
			`decimated-fs=${decimatedFS}[Hz],`,
			`Nyquist-freq=${decimatedFS / 2}[Hz],`,
			`cutoff-freq=${$ID("freq").value}[Hz]`
		].forEach(v => e.append($ELEM("span").text(v).fin));
	}
	AEL("freq", "change", updateValues);
	updateValues();
});
</script>
<style>
label {
	width: 100%;
	display: grid;
	grid-template-columns: max-content 1fr max-content;
}
#stop {
	font-weight: bold;
	border-width: 2px;
}
#value-info {
	font-size: 90%;
	font-family: monospace;
	display: flex;
	flex-wrap: wrap;
	column-gap: 1ex;
	span {
		white-space: nowrap;
	}
}
</style>
<label>Cutoff Frequency <input id="freq" type="range" value="3000" min="200" max="16000" step="100"></label>
<p><span id="value-info"></span>
<p><button id="stop">stop</button>
<button id="source">source</button>
<button id="decimated">decimated</button>

 実際に実行して、カットオフ周波数を下げて正弦波が鳴っている状態にして、音量を上げて前回のものと聞き比べると、今回の方は若干量子化ノイズが載っているのが分かる。 ただし、本当によく聞かないと分からない程度である。 音楽のようなシビアな用途ではない限り十分な性能だろう。


 あとはバッファの再利用。 今までAudio­Workletでnewして放り投げておしまいだったのを、バッファプールに入れて管理する。 あまりないとは思うが、途中でチャンネル数が増えた場合は容量が足りなくなるので、プールから引っ張り出したバッファの容量が必要な容量よりも小さい場合、そのバッファは捨てている。 バッファプールが空ならば今まで通りnewする。

function roundedShift(v, n) {
	return Math.floor((v * (2 << n) + 1) / 2);
}

class DecimatorUint8 extends AudioWorkletProcessor {
	constructor(opt) {
		super(opt);
		const procopt = opt.processorOptions;
		this.ratio = Math.floor(procopt.ratio);
		this.bs = Math.floor(procopt.blockSamples ?? 128);
		this.rqs = globalThis.renderQuantumSize ?? 128;
		this.ratiocnt = 0;
		this.buffers = [];
		this.tmp = [];
		this.tmppos = 0;
		this.absmax = [];
		this.port.onmessage = ev => {
			switch (ev.data.kind) {
			case "returnBuffer":
				ev.data.buf.forEach(buf => this.buffers.push(new Int16Array(buf)));
				break;
			}
		}
	}
	getBuffer() {
		const nch = this.tmp.length;
		const bufbytes = nch * (1 + this.bs);
		while (this.buffers.length > 0) {
			const buf = this.buffers.shift();
			if (buf.length >= bufbytes)
				return buf;
		}
		return new Int8Array(bufbytes);
	}
	process(inputs, outputs, parameters) {
		const inp = inputs[0];
		const nch = inp.length;
		if (nch == null)
			return false;
		const { tmp, absmax } = this;
		while (tmp.length < nch) {
			tmp.push(Array(this.bs));
			absmax.push(0);
		}
		let cnt = this.ratiocnt;
		let pos = this.tmppos;
		for (; cnt < this.rqs; cnt += this.ratio) {
			tmp.forEach((vec, ch) => {
				const v = inp[ch]?.[cnt] ?? 0;
				vec[pos] = v;
				absmax[ch] = Math.max(absmax[ch], Math.abs(v));
			});
			if (++pos >= this.bs) {
				const buf = this.getBuffer();
				let i = 0;
				absmax.forEach((m, ch) => {
					let s = 5 + Math.clz32(m * (1 << 30));
					if (roundedShift(m, s) > 127)
						s--;
					buf[i++] = s;
					tmp[ch].forEach(v => buf[i++] = roundedShift(v, s));
					absmax[ch] = 0;
				});
				this.port.postMessage({
					kind: "pull", buf, nch: tmp.length, bs: this.bs
				}, [ buf.buffer ]);
				pos = 0;
			}
		}
		this.ratiocnt = cnt - this.rqs;
		this.tmppos = pos;
		return true;
	}
}
registerProcessor("decimator-uint8", DecimatorUint8);

 受け取った側はバッファを使い終わったらreturnBufferメッセージを使ってバッファをAudio­Workletに返す。 使い終わるたびに返してもよいが、ここでは10回分まとめて返している。 所有権を渡すのはInt8ArrayではなくArrayBufferなので、mapArrayBufferの配列をサラッと作り直している。

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
function coswin(t, coef) {
	t -= 0.5;
	return coef.reduce((sum, v, idx) => {
		return sum + v * Math.cos(Math.PI * idx * t);
	}, 0);
}
function akamoz48win(t) {
	return coswin(t, [ 0.427, 0.148, 0.398, 0.01155, 0.01475 ]);
}
function generateWindowFunction(f, sampletime, adjustEdge) {
	let win = [];
	const n = Math.floor(sampletime / 2);
	for (let i = -n; i <= n; i++)
		win.push(f(i / sampletime + 0.5));
	if (n == sampletime / 2 && adjustEdge) {
		win[0] /= 2;
		win[sampletime] /= 2;
	}
	return win;
}
function lpfresp(t, fc) {
	if (t == 0)
		return 2 * fc;
	return Math.sin(2 * Math.PI * fc * t) / Math.PI / t;
}
function generateFilterCoef(fs, respfunc, win) {
	const T = 1 / fs;
	const n = (win.length - 1) / 2;
	return win.map((v, i) => v * respfunc((i - n) * T));
}
function normalizeResponse(resp) {
	const amp = resp.reduce((sum, v) => sum + v);
	return resp.map(v => v / amp);
}

const audio = new AudioContext();
const FS = audio.sampleRate;
const RATIO = Math.floor(FS / 8000);
const decimatedFS = FS / RATIO;
const blockSamples = Math.floor(0.05 * decimatedFS);
const blockSeconds = blockSamples / decimatedFS;
const latencySeconds = 0.01;

function createDecimationCoefBuffer(freq) {
	const delay = Math.floor(FS * 0.005); // 5ms, 240samples@48k
	const winlen = delay * 2;
	const coef = new AudioBuffer({
		length: winlen + 1, numberOfChannels: 1, sampleRate: FS
	});
	coef.copyToChannel(Float32Array.from(normalizeResponse(generateFilterCoef(
		FS, t => lpfresp(t, freq),
		generateWindowFunction(akamoz48win, winlen, true)
	))), 0);
	return coef;
}

const promises = [];
promises.push(audio.audioWorklet.addModule("decimator-rev8.js"));
$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;

	const numHarmonics = 50;
	const real = new Float32Array(numHarmonics);
	const imag = new Float32Array(numHarmonics);
	real[0] = imag[0] = 0;
	for (let i = 1; i < real.length; i++) {
		const theta = 2 * Math.PI * Math.random();
		real[i] = Math.cos(theta) / numHarmonics;
		imag[i] = Math.sin(theta) / numHarmonics;
	}
	const periodicWave = new PeriodicWave(audio, { real, imag, disableNormalization: true });
	const srcnode = new OscillatorNode(audio, { frequency: 440, type: "custom", periodicWave });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 0 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);

	const firnode = new ConvolverNode(audio, { disableNormalization: true });
	srcnode.connect(firnode);
	await Promise.all(promises);
	const decinode = new AudioWorkletNode(audio, "decimator-uint8", {
		numberOfInputs: 1,
		numberOfOutputs: 0,
		processorOptions: { ratio: RATIO, blockSamples }
	});
	firnode.connect(decinode);
	decinode.port.onmessage = ev => {
		switch (ev.data.kind) {
		case "pull":
			pullBuffer(ev);
			break;
		}
	};
	const gain1node = new GainNode(audio, { gain: 0 });
	gain1node.connect(dstnode);

	const overlapwin = Array(blockSamples);
	for (let i = 0; i < blockSamples; i++)
		overlapwin[i] = 0.5 - Math.cos(Math.PI * (i + 0.5) / blockSamples) * 0.5;
	const tbuf = new Float32Array(blockSamples);
	const fbuf = [];
	const buffersToReturn = [];
	let audioEpoch = null;
	let blockCount = 0;
	function pullBuffer(ev) {
		const ibuf = ev.data.buf;
		const { nch, bs } = ev.data;
		while (fbuf.length < nch)
			fbuf.push(new Float32Array(bs));
		const obuf = new AudioBuffer({
			sampleRate: decimatedFS,
			numberOfChannels: nch,
			length: blockSamples * 2
		});
		let pos = 0;
		for (let ch = 0; ch < nch; ch++) {
			const s = ibuf[pos++];
			obuf.copyToChannel(fbuf[ch], ch);
			for (let i = 0; i < bs; i++) {
				const v = ibuf[pos++] / (1 << s);
				const u = v * overlapwin[i];
				fbuf[ch][i] = u;
				tbuf[i] = v - u;
			}
			obuf.copyToChannel(tbuf, ch, bs);
		}
		const bufnode = new AudioBufferSourceNode(audio, { buffer: obuf });
		bufnode.connect(gain1node);
		AEL(bufnode, "ended", _ => {
			bufnode.disconnect();
		}, { once: true });
		audioEpoch ??= audio.currentTime + latencySeconds;
		bufnode.start(audioEpoch + blockCount++ * blockSeconds);
		buffersToReturn.push(ibuf);
		if (buffersToReturn.length >= 10) {
			decinode.port.postMessage({
				kind: "returnBuffer", buf: buffersToReturn
			}, buffersToReturn.map(buf => buf.buffer));
			buffersToReturn.length = 0;
		}
	}

	CLK("source", _ => {
		gain0node.gain.setTargetAtTime(1, 0, 0.005);
		gain1node.gain.setTargetAtTime(0, 0, 0.005);
		audio.resume();
	});
	CLK("decimated", _ => {
		gain0node.gain.setTargetAtTime(0, 0, 0.005);
		gain1node.gain.setTargetAtTime(1, 0, 0.005);
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
	function updateValues() {
		firnode.buffer = createDecimationCoefBuffer(parseFloat($ID("freq").value));
		const e = $ID("value-info");
		e.textContent = "";
		[
			`FS=${FS}[Hz],`,
			`ratio=${RATIO},`,
			`decimated-fs=${decimatedFS}[Hz],`,
			`Nyquist-freq=${decimatedFS / 2}[Hz],`,
			`cutoff-freq=${$ID("freq").value}[Hz]`
		].forEach(v => e.append($ELEM("span").text(v).fin));
	}
	AEL("freq", "change", updateValues);
	updateValues();
});
</script>
<style>
label {
	width: 100%;
	display: grid;
	grid-template-columns: max-content 1fr max-content;
}
#stop {
	font-weight: bold;
	border-width: 2px;
}
#value-info {
	font-size: 90%;
	font-family: monospace;
	display: flex;
	flex-wrap: wrap;
	column-gap: 1ex;
	span {
		white-space: nowrap;
	}
}
</style>
<label>Cutoff Frequency <input id="freq" type="range" value="3000" min="200" max="16000" step="100"></label>
<p><span id="value-info"></span>
<p><button id="stop">stop</button>
<button id="source">source</button>
<button id="decimated">decimated</button>

 リサンプルアップで使っているAudioBufferも再利用する。 こちらはHTML側で閉じており、endedイベントが発生したときにプールに戻している。 使い回せるのはAudioBufferだけで、AudioBufferSourceNodeは生涯に1回しか再生できないので使い回しはできず、常に新しく確保する必要がある。 つまり、新しく確保したAudioBufferSourceNodeに、リサイクルしたAudioBufferを設定している。 チャンネル数が増えた時の対応はAudio­Workletのときと大体同じである。

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>resample - you / uni / webaudio</title>
<script src="../../akamoz-v6.js"></script>
<script>
// Copyright (C) 2026 akamoz.jp
function coswin(t, coef) {
	t -= 0.5;
	return coef.reduce((sum, v, idx) => {
		return sum + v * Math.cos(Math.PI * idx * t);
	}, 0);
}
function akamoz48win(t) {
	return coswin(t, [ 0.427, 0.148, 0.398, 0.01155, 0.01475 ]);
}
function generateWindowFunction(f, sampletime, adjustEdge) {
	let win = [];
	const n = Math.floor(sampletime / 2);
	for (let i = -n; i <= n; i++)
		win.push(f(i / sampletime + 0.5));
	if (n == sampletime / 2 && adjustEdge) {
		win[0] /= 2;
		win[sampletime] /= 2;
	}
	return win;
}
function lpfresp(t, fc) {
	if (t == 0)
		return 2 * fc;
	return Math.sin(2 * Math.PI * fc * t) / Math.PI / t;
}
function generateFilterCoef(fs, respfunc, win) {
	const T = 1 / fs;
	const n = (win.length - 1) / 2;
	return win.map((v, i) => v * respfunc((i - n) * T));
}
function normalizeResponse(resp) {
	const amp = resp.reduce((sum, v) => sum + v);
	return resp.map(v => v / amp);
}

const audio = new AudioContext();
const FS = audio.sampleRate;
const RATIO = Math.floor(FS / 8000);
const decimatedFS = FS / RATIO;
const blockSamples = Math.floor(0.05 * decimatedFS);
const blockSeconds = blockSamples / decimatedFS;
const latencySeconds = 0.01;

function createDecimationCoefBuffer(freq) {
	const delay = Math.floor(FS * 0.005); // 5ms, 240samples@48k
	const winlen = delay * 2;
	const coef = new AudioBuffer({
		length: winlen + 1, numberOfChannels: 1, sampleRate: FS
	});
	coef.copyToChannel(Float32Array.from(normalizeResponse(generateFilterCoef(
		FS, t => lpfresp(t, freq),
		generateWindowFunction(akamoz48win, winlen, true)
	))), 0);
	return coef;
}

const promises = [];
promises.push(audio.audioWorklet.addModule("decimator-rev8.js"));
$DCL(async _ => {
	audio.suspend();
	const dstnode = audio.destination;

	const numHarmonics = 50;
	const real = new Float32Array(numHarmonics);
	const imag = new Float32Array(numHarmonics);
	real[0] = imag[0] = 0;
	for (let i = 1; i < real.length; i++) {
		const theta = 2 * Math.PI * Math.random();
		real[i] = Math.cos(theta) / numHarmonics;
		imag[i] = Math.sin(theta) / numHarmonics;
	}
	const periodicWave = new PeriodicWave(audio, { real, imag, disableNormalization: true });
	const srcnode = new OscillatorNode(audio, { frequency: 440, type: "custom", periodicWave });
	srcnode.start();
	const gain0node = new GainNode(audio, { gain: 0 });
	srcnode.connect(gain0node);
	gain0node.connect(dstnode);

	const firnode = new ConvolverNode(audio, { disableNormalization: true });
	srcnode.connect(firnode);
	await Promise.all(promises);
	const decinode = new AudioWorkletNode(audio, "decimator-uint8", {
		numberOfInputs: 1,
		numberOfOutputs: 0,
		processorOptions: { ratio: RATIO, blockSamples }
	});
	firnode.connect(decinode);
	decinode.port.onmessage = ev => {
		switch (ev.data.kind) {
		case "pull":
			pullBuffer(ev);
			break;
		}
	};
	const gain1node = new GainNode(audio, { gain: 0 });
	gain1node.connect(dstnode);

	const overlapwin = Array(blockSamples);
	for (let i = 0; i < blockSamples; i++)
		overlapwin[i] = 0.5 - Math.cos(Math.PI * (i + 0.5) / blockSamples) * 0.5;
	const tbuf = new Float32Array(blockSamples);
	const fbuf = [];
	const buffersToReturn = [];
	let audioEpoch = null;
	let blockCount = 0;
	const freeBuffers = [];
	function getBuffer(nch) {
		while (freeBuffers.length > 0) {
			const buf = freeBuffers.shift();
			if (buf.numberOfChannels == nch)
				return buf;
		}
		return new AudioBuffer({
			sampleRate: decimatedFS,
			numberOfChannels: nch,
			length: blockSamples * 2
		});
	}
	function pullBuffer(ev) {
		const ibuf = ev.data.buf;
		const { nch, bs } = ev.data;
		while (fbuf.length < nch)
			fbuf.push(new Float32Array(bs));
		const obuf = getBuffer(nch);
		let pos = 0;
		for (let ch = 0; ch < nch; ch++) {
			const s = ibuf[pos++];
			obuf.copyToChannel(fbuf[ch], ch);
			for (let i = 0; i < bs; i++) {
				const v = ibuf[pos++] / (1 << s);
				const u = v * overlapwin[i];
				fbuf[ch][i] = u;
				tbuf[i] = v - u;
			}
			obuf.copyToChannel(tbuf, ch, bs);
		}
		const bufnode = new AudioBufferSourceNode(audio, { buffer: obuf });
		bufnode.connect(gain1node);
		AEL(bufnode, "ended", _ => {
			bufnode.disconnect();
			freeBuffers.push(bufnode.buffer);
		}, { once: true });
		audioEpoch ??= audio.currentTime + latencySeconds;
		bufnode.start(audioEpoch + blockCount++ * blockSeconds);
		buffersToReturn.push(ibuf);
		if (buffersToReturn.length >= 10) {
			decinode.port.postMessage({
				kind: "returnBuffer", buf: buffersToReturn
			}, buffersToReturn.map(buf => buf.buffer));
			buffersToReturn.length = 0;
		}
	}

	CLK("source", _ => {
		gain0node.gain.setTargetAtTime(1, 0, 0.005);
		gain1node.gain.setTargetAtTime(0, 0, 0.005);
		audio.resume();
	});
	CLK("decimated", _ => {
		gain0node.gain.setTargetAtTime(0, 0, 0.005);
		gain1node.gain.setTargetAtTime(1, 0, 0.005);
		audio.resume();
	});
	CLK("stop", _ => {
		audio.suspend();
	});
	function updateValues() {
		firnode.buffer = createDecimationCoefBuffer(parseFloat($ID("freq").value));
		const e = $ID("value-info");
		e.textContent = "";
		[
			`FS=${FS}[Hz],`,
			`ratio=${RATIO},`,
			`decimated-fs=${decimatedFS}[Hz],`,
			`Nyquist-freq=${decimatedFS / 2}[Hz],`,
			`cutoff-freq=${$ID("freq").value}[Hz]`
		].forEach(v => e.append($ELEM("span").text(v).fin));
	}
	AEL("freq", "change", updateValues);
	updateValues();
});
</script>
<style>
label {
	width: 100%;
	display: grid;
	grid-template-columns: max-content 1fr max-content;
}
#stop {
	font-weight: bold;
	border-width: 2px;
}
#value-info {
	font-size: 90%;
	font-family: monospace;
	display: flex;
	flex-wrap: wrap;
	column-gap: 1ex;
	span {
		white-space: nowrap;
	}
}
</style>
<label>Cutoff Frequency <input id="freq" type="range" value="3000" min="200" max="16000" step="100"></label>
<p><span id="value-info"></span>
<p><button id="stop">stop</button>
<button id="source">source</button>
<button id="decimated">decimated</button>

20 Apr 2026: 新規作成

ご意見・ご要望の送り先は あかもず仮店舗 の末尾をご覧ください。

Copyright (C) 2026 akamoz.jp