したがって, まずは時間領域の波形描画から解説します. また, ここで, SVG と Canvas の API を理解しておきましょう.
また, 以降のセクションでは, 以下のような SVG または, Canvas の HTML タグが記述されている前提でコードを解説します.
時間領域の波形描画
時間領域の波形描画で関係のある, AnalyserNode のプロパティは fftSize プロパティのみです. この値は,
時間分解能と周波数分解能 に関わってくる値です.
時間領域の波形描画だけを実行するのであれば, この値はできるだけ小さいほうが, 時間分解能としては高くなります.
getFloatTimeDomainData メソッド
getFloatTimeDomainData メソッドは, Float32Array を引数にとり,
Float32Array のサイズだけ時間領域の振幅を格納します. その値の範囲は -1 ~ 1 です (つまり,
0 が無音となります). ただし, 引数に指定する Float32Array のサイズが fftSize プロパティより大きい場合,
超過した要素は 0 のままです. また, fftSize プロパティより小さい場合は, 不足した分の値は無視されます. したがって,
イディオム的には, fftSize プロパティのサイズの Float32Array を引数に指定します.
そして, getFloatTimeDomainData メソッドをアニメーションで繰り返し実行することで, 実行時における,
fftSize プロパティサイズのサンプル数だけ時間領域の波形を描画できることになります. 以下は,
requestAnimationFrame メソッドでアニメーションを実行する場合のコード例です.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const analyser = new AnalyserNode(context);
// OscillatorNode (Input) -> AnalyserNode (Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 5 sec
oscillator.stop(context.currentTime + 5);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
for (let n = 0; n < analyser.fftSize; n++) {
// TOOD: Draw time domain wave to SVG or Canvas
}
animationId = window.requestAnimationFrame(() => {
render();
});
};
render();
したがって, あとは, SVG や Canvas にアニメーションごとに取得して Float32Array のインデックスと値, そして, サンプリング周期から x
座標と y 座標の値を算出していくことで, 時間領域の波形や, 時間のテキストを描画していくことができます.
まずは, 波形描画だけを実装してみます. y 軸の値は, getFloatTimeDomainData メソッドで取得した Float32Array の値と, SVG
や Canvas の height の値から算出できます. 振幅と y 座標のマッピングを考えると,
振幅が 1 の場合, y 座標は 0
振幅が -1 の場合, y 座標は height
振幅が 0 の場合, y 座標は height の半分
振幅 (Float32Array) と height のマッピング
この対応関係から, y 座標の値は (1 - data[n]) * (height / 2) で算出することができます.
x 軸は, SVG や Canvas の width を fftSize プロパティのサイズだけ等分に描画するので,
n * (width / analyser.fftSize) で算出することができます.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const analyser = new AnalyserNode(context);
// OscillatorNode (Input) -> AnalyserNode (Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 5 sec
oscillator.stop(context.currentTime + 5);
// const svg = document.getElementById('svg');
//
// const width = Number(svg.getAttribute('width') ?? '0');
// const height = Number(svg.getAttribute('height') ?? '0');
// const canvas = document.getElementById('canvas');
//
// const width = canvas.width;
// const height = canvas.height;
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (width / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
}
animationId = window.requestAnimationFrame(() => {
render();
});
};
render();
x 座標と y 座標が算出できたので, あとは波形を描画していくだけですが, ここはグラフィックス API によって異なりまります.
SVG の場合, パスの描画は document.createElementNS('http://www.w3.org/2000/svg', 'path') で
SVGPathElement を生成して, その d 属性に, 座標を設定します. また, パスのスタイルを設定するには,
stroke 属性 (パスの色) や stroke-width 属性 (パスの幅) などいくつかあるので, このあたりは
SVG の仕様 などを参考にしてくださいなどを参考にしてください.
また, SVGPathElement の d 属性は, パスの開始座標のプレフィックスに M をつけて,
それ以降の座標のプレフィックスには L をつけます.
アニメーションごとの描画の最初に, 前回の描画されたパスを削除するために SVGPathElement の d 属性を削除しています.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const analyser = new AnalyserNode(context);
// OscillatorNode (Input) -> AnalyserNode (Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 5 sec
oscillator.stop(context.currentTime + 5);
const svg = document.getElementById('svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
svg.appendChild(path);
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
path.removeAttribute('d');
let d = '';
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (width / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() => {
render();
});
};
render();
次に, Canvas を利用する場合です.
まず, Canvas (HTMLCanvasElement) そのものは, 描画の API が定義されていないので, getContext メソッドの引数に
'2d' を指定して, CanvasRenderingContext2D インスタンスを取得します.
CanvasRenderingContext2D に, 描画や描画スタイルに関するメソッドやプロパティが定義されています.
SVG の場合と同様に, アニメーションごとの描画の最初に, clearRect メソッドで前回の描画をクリアしています. ただし, SVG
と異なるのは, Canvas の場合, 基本的にはすべての描画をクリアすることになります (引数を調整すれば, 部分的にクリアすることも可能ですが, SVG
のように描画オブジェクト (DOM) を指定して削除するような API はなく, 基本的にはできません (もちろん, 1px サイズの矩形を
clearRect していくことで擬似的には可能ですが, 実装が容易ではありません)).
次に, パスの描画を開始するために beginPath メソッドを実行します.
あとは, SVG のパスの描画の考え方と同じで, パスの最初は moveTo メソッドで開始座標を移動して, それ以降は,
lineTo メソッドでパスの座標を指定していきます (ちなみに, パスの描画の終わりに,
closePath メソッドを呼び出すことでパスの終点と始点を同一にすることも可能です)
パスのスタイルは, storkeStyle プロパティでパスの色を,
lineWidth プロパティでパスの幅を指定することが可能です.
Canvas においては, パスの描画というのは, 描画する座標を定義しているにすぎません. 最終的にそのパスを Canvas 上に描画するには,
stroke メソッドか fill メソッド (塗りつぶしの場合) を呼び出す必要があります.
const context = new AudioContext();
const oscillator = new OscillatorNode(context);
const analyser = new AnalyserNode(context);
// OscillatorNode (Input) -> AnalyserNode (Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 5 sec
oscillator.stop(context.currentTime + 5);
const canvas = document.getElementById('canvas');
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.beginPath();
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (width / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
if (n === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
// renderingContext.closePath();
renderingContext.storke();
animationId = window.requestAnimationFrame(() => {
render();
});
};
render();
以下のコードは, SVG でユーザーインタラクティブに時間領域の波形のパスのみを描画するコードです. また, 時間分解能を優先して,
fftSize プロパティを 128 サンプルに設定しています (サンプリング周波数 48000 Hz で, 約
2.5 msec のサンプル数を 1 フレームで描画することになります).
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 128 });
const svg = document.getElementById('svg-animation-time-domain-wave-path');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
path.removeAttribute('d');
let d = '';
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (width / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-svg-time-domain-wave-path');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
SVG による時間領域の波形描画
start
同様に, 以下のコードは, Canvas でユーザーインタラクティブに時間領域の波形のパスのみを描画するコードです.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 128 });
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.beginPath();
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (width / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
if (n === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
renderingContext.lineWidth = 1.5;
renderingContext.strokeStyle = 'rgb(0 0 255)';
renderingContext.stroke();
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-canvas-time-domain-wave-path');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
Canvas による時間領域の波形描画
start
座標とテキストの描画
x 座標に関しては, height / 2 の y 座標で, width いっぱいに矩形を描画すればよいでしょう.
y 座標に関しては, このあとに振幅の値のテキストを描画することを考慮して, 右方向に 24px ずらして, 高さは
height で矩形を描画しています (テキスト描画位置によって, サイズや方向は異なるので, 1 つの例として捉えてください).
以下のコードは, SVG でユーザーインタラクティブに, 時間領域の波形のパスに追加して, x 座標と y 座標を描画するコードです.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 128 });
const svg = document.getElementById('svg-animation-time-domain-wave-path-with-coordinate');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const innerWidth = width - 24;
const xRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
xRect.setAttribute('x', '0');
xRect.setAttribute('y', ((height / 2) - 1).toString(10));
xRect.setAttribute('width', width.toString(10));
xRect.setAttribute('height', '2');
xRect.setAttribute('stroke', 'none');
xRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(xRect);
const yRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
yRect.setAttribute('x', '24');
yRect.setAttribute('y', '0');
yRect.setAttribute('width', '2');
yRect.setAttribute('height', height.toString(10));
yRect.setAttribute('stroke', 'none');
yRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(yRect);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
path.removeAttribute('d');
let d = '';
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (innerWidth / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-svg-time-domain-wave-path-with-coordinate');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
SVG による時間領域の波形描画 (x 座標, y 座標表示)
start
同様に, 以下のコードは, Canvas でユーザーインタラクティブに, 時間領域の波形のパスに追加して, x 座標と y 座標を描画するコードです.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 128 });
const canvas = document.getElementById('canvas-animation-time-domain-wave-path-with-coordinate');
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const innerWidth = width - 24;
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.fillStyle = 'rgb(153 153 153)';
renderingContext.fillRect(0, ((height / 2) - 1), width, 2);
renderingContext.fillRect(24, 0, 2, height);
renderingContext.beginPath();
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (innerWidth / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
if (n === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
renderingContext.lineWidth = 1.5;
renderingContext.strokeStyle = 'rgb(0 0 255)';
renderingContext.stroke();
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-canvas-time-domain-wave-path-with-coordinate');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
Canvas による時間領域の波形描画 (x 座標, y 座標表示)
start
最後に, 振幅と時間のテキストと描画です.
振幅のテキスト描画は, Float32Array がそのまま振幅値に対応しているので,
振幅と height のマッピング をもとに, 対応する座標に,
振幅のテキスト描画するだけです.
時間のテキスト描画は, デジタルオーディオ信号処理の理解が必要といっても, サンプリングを理解していればそれほど難しくはありません.
サンプリングの仕組みから, 取得した時間領域の波形を格納している Float32Array の
$n$ 番目のインデックスと, その次のインデックスは, サンプリング周波数の逆数, つまり,
サンプリング周期だけ時間が進んでいることになります. つまり, Float32Array のインデックスを $n$ ,
サンプリング周期を $T_{s}$ とすると, インデックス $n$ における時間
$t_{n}$ は, $t_{n} = n \cdot T_{s}$ となるわけです.
もっとも, すべてのインデックスで時間のテキストを描画してしまうと, テキストが重なって可読できないので, 実際には,
ある程度間引いて描画するのがよいでしょう.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 128 });
const samplingPeriod = 1 / context.sampleRate;
const svg = document.getElementById('svg-animation-time-domain-wave-path-with-coordinate-and-texts');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const innerWidth = width - 24;
const xRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
xRect.setAttribute('x', '0');
xRect.setAttribute('y', ((height / 2) - 1).toString(10));
xRect.setAttribute('width', width.toString(10));
xRect.setAttribute('height', '2');
xRect.setAttribute('stroke', 'none');
xRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(xRect);
const yRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
yRect.setAttribute('x', '24');
yRect.setAttribute('y', '0');
yRect.setAttribute('width', '2');
yRect.setAttribute('height', height.toString(10));
yRect.setAttribute('stroke', 'none');
yRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(yRect);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
[1.0, 0.0, -1.0].forEach((amplitude, index) => {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = amplitude.toFixed(1);
let h = 0;
switch (amplitude) {
case 1.0: {
h = 12;
break;
}
case 0.0: {
h = -4;
break;
}
case -1.0: {
h = 0;
break;
}
}
text.setAttribute('x', '24');
text.setAttribute('y', ((1 - amplitude) * (height / 2) + h).toString(10));
text.setAttribute('text-anchor', 'end');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
});
for (let n = 0; n < analyser.fftSize; n++) {
if (n % 16 !== 0) {
continue;
}
const x = n * (innerWidth / analyser.fftSize) + 24 + 4;
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = `${(n * samplingPeriod * 1000).toFixed(2)} msec`;
text.setAttribute('x', x);
text.setAttribute('y', (height / 2 + 12).toString(10));
text.setAttribute('text-anchor', 'start');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
}
svg.appendChild(g);
const xLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
xLabel.textContent = 'Time';
xLabel.setAttribute('x', width.toString(10));
xLabel.setAttribute('y', (height / 2 - 8).toString(10));
xLabel.setAttribute('text-anchor', 'end');
xLabel.setAttribute('stroke', 'none');
xLabel.setAttribute('fill', 'rgb(153 153 153)');
xLabel.setAttribute('font-size', '14px');
const yLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
yLabel.textContent = 'Amplitude';
yLabel.setAttribute('x', '28');
yLabel.setAttribute('y', '12');
yLabel.setAttribute('text-anchor', 'start');
yLabel.setAttribute('stroke', 'none');
yLabel.setAttribute('fill', 'rgb(153 153 153)');
yLabel.setAttribute('font-size', '14px');
svg.appendChild(xLabel);
svg.appendChild(yLabel);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
path.removeAttribute('d');
let d = '';
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (innerWidth / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-svg-time-domain-wave-path-with-coordinate-and-texts');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
SVG による時間領域の波形描画 (x 座標, y 座標, および, 振幅・時間テキスト表示)
start
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 128 });
const samplingPeriod = 1 / context.sampleRate;
const canvas = document.getElementById('canvas-animation-time-domain-wave-path-with-coordinate-and-texts');
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const innerWidth = width - 24;
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.fillStyle = 'rgb(153 153 153)';
renderingContext.fillRect(0, ((height / 2) - 1), width, 2);
renderingContext.fillRect(24, 0, 2, height);
renderingContext.font = 'Roboto 12px';
renderingContext.fillStyle = 'rgb(153 153 153)';
[1.0, 0.0, -1.0].forEach((amplitude, index) => {
let h = 0;
switch (amplitude) {
case 1.0: {
h = 12;
break;
}
case 0.0: {
h = -4;
break;
}
case -1.0: {
h = 0;
break;
}
}
renderingContext.textAlign = 'end';
renderingContext.fillText(amplitude.toFixed(1), 24, (((1 - amplitude) * (height / 2)) + h));
});
for (let n = 0; n < analyser.fftSize; n++) {
if (n % 16 !== 0) {
continue;
}
const x = (n * (innerWidth / analyser.fftSize)) + 24 + 4;
renderingContext.textAlign = 'start';
renderingContext.fillText(`${(n * samplingPeriod * 1000).toFixed(2)} msec`, x, ((height / 2) + 12));
}
renderingContext.font = 'Roboto 14px';
renderingContext.textAlign = 'end';
renderingContext.fillText('Time', width, ((height / 2) - 8));
renderingContext.textAlign = 'start';
renderingContext.fillText('Amplitude', 28, 12);
renderingContext.beginPath();
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (innerWidth / analyser.fftSize);
const y = (1 - data[n]) * (height / 2);
if (n === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
renderingContext.lineWidth = 1.5;
renderingContext.strokeStyle = 'rgb(0 0 255)';
renderingContext.stroke();
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-canvas-time-domain-wave-path-with-coordinate-and-texts');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
Canvas による時間領域の波形描画 (x 座標, y 座標, および, 振幅・時間テキスト表示)
start
ところで, SVG の場合, 座標軸やテキストなどアニメーションごとに不変な (静的な) 描画は, アニメーション前に描画して,
波形のパスのみをアニメーションごとに描画しています. 一方で, Canvas の場合, 基本的にはアニメーションのたびに描画をクリアするので,
座標軸やテキストなど本来不変な描画もアニメーションごとに描画しています. したがって, この点においては SVG
のほうが描画パフォーマンスが優れていると言えます. 描画自体のパフォーマンスは Canvas が優れていますが,
描画するオブジェクトがほとんど不変であれば SVG のほうがパフォーマンスが優れるケースもあるかもしれません.
getByteTimeDomainData メソッド
getByteTimeDomainData メソッドは, Uint8Array を引数にとり,
Uint8Array のサイズだけ時間領域の振幅を格納します. その値の範囲は 0 ~ 255 です. ただし,
引数に指定する Uint8Array のサイズが fftSize プロパティより大きい場合, 超過した要素は 0 のままです. また,
fftSize プロパティより小さい場合は, 不足した分の値は無視されます. したがって, イディオム的には,
fftSize プロパティのサイズの Uint8Array を引数に指定します. サイズと格納される値の仕様は
getFloatTimeDomainData メソッドと同じです.
getFloatTimeDomainData メソッドと異なるのは, 格納される値の型が異なるので, 振幅と y 座標のマッピングのみです.
255 の場合, 振幅が 1 で, y 座標は 0
0 の場合, 振幅が -1 で, y 座標は height
128 の場合, 振幅が 0 で, y 座標は height の半分
つまり, getFloatTimeDomainData メソッドで取得できる Float32Array の値を
$x\left[n\right]$ , getByteTimeDomainData メソッで取得できる Uint8Array の値を
$b\left[n\right]$ とすると以下のような値の関係にあります.
$b\left[n\right] = \lfloor 128 \cdot \left(1 + x\left[n\right]\right) \rfloor$
振幅 (Uint8Array) と height のマッピング
この対応関係から, y 座標の値は (1 - (data[n] / 255)) * height で算出することができます.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 128 });
const samplingPeriod = 1 / context.sampleRate;
const svg = document.getElementById('svg-animation-time-domain-wave-path-with-coordinate-and-texts-in-uint8');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const innerWidth = width - 24;
const xRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
xRect.setAttribute('x', '0');
xRect.setAttribute('y', ((height / 2) - 1).toString(10));
xRect.setAttribute('width', width.toString(10));
xRect.setAttribute('height', '2');
xRect.setAttribute('stroke', 'none');
xRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(xRect);
const yRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
yRect.setAttribute('x', '24');
yRect.setAttribute('y', '0');
yRect.setAttribute('width', '2');
yRect.setAttribute('height', height.toString(10));
yRect.setAttribute('stroke', 'none');
yRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(yRect);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
[1.0, 0.0, -1.0].forEach((amplitude, index) => {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = amplitude.toFixed(1);
let h = 0;
switch (amplitude) {
case 1.0: {
h = 12;
break;
}
case 0.0: {
h = -4;
break;
}
case -1.0: {
h = 0;
break;
}
}
text.setAttribute('x', '24');
text.setAttribute('y', ((1 - amplitude) * (height / 2) + h).toString(10));
text.setAttribute('text-anchor', 'end');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
});
for (let n = 0; n < analyser.fftSize; n++) {
if (n % 16 !== 0) {
continue;
}
const x = n * (innerWidth / analyser.fftSize) + 24 + 4;
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = `${(n * samplingPeriod * 1000).toFixed(2)} msec`;
text.setAttribute('x', x);
text.setAttribute('y', (height / 2 + 12).toString(10));
text.setAttribute('text-anchor', 'start');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
}
svg.appendChild(g);
const xLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
xLabel.textContent = 'Time';
xLabel.setAttribute('x', width.toString(10));
xLabel.setAttribute('y', (height / 2 - 8).toString(10));
xLabel.setAttribute('text-anchor', 'end');
xLabel.setAttribute('stroke', 'none');
xLabel.setAttribute('fill', 'rgb(153 153 153)');
xLabel.setAttribute('font-size', '14px');
const yLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
yLabel.textContent = 'Amplitude';
yLabel.setAttribute('x', '28');
yLabel.setAttribute('y', '12');
yLabel.setAttribute('text-anchor', 'start');
yLabel.setAttribute('stroke', 'none');
yLabel.setAttribute('fill', 'rgb(153 153 153)');
yLabel.setAttribute('font-size', '14px');
svg.appendChild(xLabel);
svg.appendChild(yLabel);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
let animationId = null;
const render = () => {
const data = new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(data);
path.removeAttribute('d');
let d = '';
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (innerWidth / analyser.fftSize);
const y = (1 - data[n] / 255) * height;
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-svg-time-domain-wave-path-with-coordinate-and-texts-in-uint8');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
getByteTimeDomainData メソッドによる時間領域の波形描画 (SVG)
start
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 128 });
const canvas = document.getElementById('canvas-animation-time-domain-wave-path-with-coordinate-and-texts-in-uint8');
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const innerWidth = width - 24;
let animationId = null;
const render = () => {
const data = new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.fillStyle = 'rgb(153 153 153)';
renderingContext.fillRect(0, ((height / 2) - 1), width, 2);
renderingContext.fillRect(24, 0, 2, height);
renderingContext.font = 'Roboto 12px';
renderingContext.fillStyle = 'rgb(153 153 153)';
[1.0, 0.0, -1.0].forEach((amplitude, index) => {
let h = 0;
switch (amplitude) {
case 1.0: {
h = 12;
break;
}
case 0.0: {
h = -4;
break;
}
case -1.0: {
h = 0;
break;
}
}
renderingContext.textAlign = 'end';
renderingContext.fillText(amplitude.toFixed(1), 24, (((1 - amplitude) * (height / 2)) + h));
});
for (let n = 0; n < analyser.fftSize; n++) {
if (n % 16 !== 0) {
continue;
}
const x = (n * (innerWidth / analyser.fftSize)) + 24 + 4;
renderingContext.textAlign = 'start';
renderingContext.fillText(`${(n * samplingPeriod * 1000).toFixed(2)} msec`, x, ((height / 2) + 12));
}
renderingContext.font = 'Roboto 14px';
renderingContext.textAlign = 'end';
renderingContext.fillText('Time', width, ((height / 2) - 8));
renderingContext.textAlign = 'start';
renderingContext.fillText('Amplitude', 28, 12);
renderingContext.beginPath();
for (let n = 0; n < analyser.fftSize; n++) {
const x = n * (innerWidth / analyser.fftSize);
const y = (1 - (data[n] / 255)) * height;
if (n === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
renderingContext.lineWidth = 1.5;
renderingContext.strokeStyle = 'rgb(0 0 255)';
renderingContext.stroke();
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-canvas-time-domain-wave-path-with-coordinate-and-texts-in-uint8');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
getByteTimeDomainData メソッドによる時間領域の波形描画 (Canvas)
start
周波数領域領域の波形描画 (振幅スペクトルの描画)
周波数領域のスペクトル描画で関係のある, AnalyserNode のプロパティは, getFloatFrequencyData メソッドの場合,
fftSize プロパティ (その $\frac{1}{2}$ の値となる frequencyBinCount プロパティ) と
smoothingTimeConstant プロパティのみです. getByteFrequencyData メソッドの場合, これらのプロパティに追加して,
maxDecibels プロパティと minDecibels プロパティが関連してきます.
時間分解能と周波数分解能 で解説しましたが,
スペクトル描画のみ場合は, fftSize プロパティはある程度大きくして, 周波数分解能を高くするほうが精度のよい描画となります (もっとも,
デフォルトの 2048 サンプルでも十分ですが).
また, getFloatFrequencyData メソッド, getByteFrequencyData メソッドで利用される (FFT のプリプロセス処理となる)
窓関数はブラックマン窓 です.
smoothingTimeConstant プロパティ
smoothingTimeConstant プロパティに関しては, どちらのメソッドにも関係するのと,
振幅スペクトルの描画ロジックには関わらないので, 先に解説しておきます. smoothingTimeConstant プロパティは
0 ~ 1 までの値を設定可能で, この値が小さいほどスペクトルのアニメーションが速くなり, 大きいほど遅くなります. また,
1 に設定するとスペクトルが更新されなくなります. この設定によって, 時間領域の処理には影響しません (オーディオ API
一般的なパラメータではなく, Web Audio API 特有のパラメータと思われます).
getFloatFrequencyData メソッド
getFloatFrequencyData メソッドは, Float32Array を引数にとり, FFT
した結果の振幅スペクトルをデジベル単位で格納します . 可能性としては, 単精度浮動小数点数の値の範囲をとりうることになりますが,
この範囲をカバーする振幅スペクトルの描画は無理でしょう. しかし, 例えば, 音楽信号であれば, おおよそ -30 dB から
-60 dB の範囲の振幅になります. つまり, 現実的には, 対象となる音信号がどのような特性かを想定することで, ある程度範囲を限定して,
振幅スペクトルを描画することができます.
getFloatFrequencyData メソッドの引数に渡す Float32Array のサイズは, サンプリング定理より,
frequencyBinCount 以下のサイズである必要があります. もっとも, それより大きいサイズを指定してもエラーにはなりませんが,
サンプリング定理から, 振幅スペクトルはナイキスト周波数を軸に線対称 となるので (すなわち,
ナイキスト周波数までの振幅スペクトルが取得できればよいので), frequencyBinCount より大きい要素は
0 のままになる仕様となっています. イディオム的には, frequencyBinCount プロパティのサイズの
Float32Array を引数に指定します (もしくは, 例えば, 音楽信号を対象にする場合,
ナイキスト周波数付近の振幅スペクトルまで不要であるケースも多いので, frequencyBinCount のさらに
$\frac{1}{2}$ 程度のサイズでもよいかもしれません. この場合, サンプリング周波数を
48000 Hz とすると, 12000 Hz ぐらいまでの振幅スペクトルを描画できることになります).
そして, getFloatFrequencyData メソッドをアニメーションで繰り返し実行することで, アニメーションごとに,
fftSize プロパティで指定されたサイズで FFT を実行して, 結果となる複素数の配列から, 絶対値を取得して,
振幅スペクトルをデシベル単位で格納します. そして, サンプリング定理から,
frequencyBinCount のインデックスがナイキスト周波数に対応するインデックスとなるので, 引数の Float32Array のサイズを
frequencyBinCount にしていた場合, ナイキスト周波数のインデックスの 1 つ前のインデックス (frequencyBinCount - 1)
に対応するまでの周波数成分の振幅スペクトルがデシベル単位で格納されることになります. 以下は,
requestAnimationFrame メソッドでアニメーションを実行する場合のコード例です.
const context = new AudioContext();
const oscillator = new OscillatorNode(context, { type: 'sawtooth' });
const analyser = new AnalyserNode(context);
// OscillatorNode (Input) -> AnalyserNode (Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
// Start oscillator immediately
oscillator.start(0);
// Stop oscillator after 5 sec
oscillator.stop(context.currentTime + 5);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
for (let k = 0; k < analyser.frequencyBinCount; k++) {
// TOOD: Draw amplitude spectrum to SVG or Canvas
}
animationId = window.requestAnimationFrame(() => {
render();
});
};
render();
getFloatFrequencyData メソッドで振幅スペクトルを描画するためには, 振幅値の範囲をデシベルで決める必要があります. 今回は,
0 dB から -60 dB までの振幅を対象に描画することにします. この範囲と, SVG または Canvas の高さとのマッピングを考えます.
振幅スペクトルが 0 (dB) の場合, y 座標は 0
振幅スペクトルが -60 (dB) の場合, y 座標は height
振幅スペクトル (dB) と height のマッピング
この対応関係から, y 座標の値は,
(0 - data[k]) * (height / (max - min)) = (0 - data[k]) * (height / (0 - (-60))) = -data[k] * (height / 60)
で算出することができます.
x 軸は, SVG や Canvas の width を frequencyBinCount プロパティのサイズだけ等分に描画するので,
k * (width / analyser.frequencyBinCount) で算出することができます.
以下のコードは, SVG でユーザーインタラクティブに振幅スペクトルのパスのみを描画するコードです. 周波数分解能を優先して,
fftSize プロパティを 16384 サンプルに設定しています (サンプリング周波数 48000 Hz で,
2 Hz の精度で振幅スペクトルを描画することができます).
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384 });
const svg = document.getElementById('svg-animation-amplitude-spectrum-path');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const maxDecibels = 0;
const minDecibels = -60;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
path.removeAttribute('d');
let d = '';
for (let k = 0; k < analyser.frequencyBinCount; k++) {
// for Chrome
if (!Number.isFinite(data[k])) {
continue;
}
const x = k * (width / analyser.frequencyBinCount);
const y = (0 - data[k]) * (height / (maxDecibels - minDecibels));
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() =' {
render();
});
};
const buttonElement = document.getElementById('button-svg-animation-spectrum-path');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
// OscillatorNode (Input) -> AnalyserNode (Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
ところで, getFloatFrequencyData メソッドで取得したすべての振幅スペクトルの数値を
Number.isFinite で有限値かどうかを判定していますが, これは本来は不要なのですが, Chrome の実装のバグで,
Infinity が格納されてしまう場合があるので, その対策としてこの判定をしています (Firefox や Safari では
Infinity になることはないので, この判定は不要です). 将来のバージョンにおいて, 修正されて不要になる可能性はありますが,
現状はこの判定が必要です).
SVG による周波数領域 (振幅スペクトル) の波形描画
start
同様に, 以下のコードは, Canvas でユーザーインタラクティブに振幅スペクトルのパスのみを描画するコードです.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384 });
const canvas = document.getElementById('canvas-animation-amplitude-spectrum-path');
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const maxDecibels = 0;
const minDecibels = -60;
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.beginPath();
for (let k = 0; k < analyser.frequencyBinCount; k++) {
if (!Number.isFinite(data[k])) {
continue;
}
const x = k * (width / analyser.frequencyBinCount);
const y = (0 - data[k]) * (height / (maxDecibels - minDecibels));
if (k === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
renderingContext.lineWidth = 1.5;
renderingContext.strokeStyle = 'rgb(0 0 255)';
renderingContext.stroke();
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-canvas-animation-spectrum-path');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
// OscillatorNode (Input) -> AnalyserNode (Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
Canvas による周波数領域 (振幅スペクトル) の波形描画
start
座標とテキストの描画
x, y 座標にともに, このあとに振幅の値のテキストを描画することを考慮して, x 座標, y 座標をそれぞれテキストサイズを考慮して, 右方向に
48px ずらして, 上方向に 24px ずらして描画しておきます (テキスト描画位置によって, サイズや方向は異なるので, 1
つの例として捉えてください).
以下のコードは, SVG でユーザーインタラクティブに, 振幅スペクトルのパスに追加して, x 座標と y 座標を描画するコードです.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384 });
const svg = document.getElementById('svg-animation-amplitude-spectrum-path-with-coordinate');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const innerWidth = width - 48;
const innerHeight = height - 48;
const translateX = 48;
const translateY = 24;
const maxDecibels = 0;
const minDecibels = -60;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
const xRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
xRect.setAttribute('x', translateX.toString(10));
xRect.setAttribute('y', (height - translateY - 1).toString(10));
xRect.setAttribute('width', (width - translateX).toString(10));
xRect.setAttribute('height', '2');
xRect.setAttribute('stroke', 'none');
xRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(xRect);
const yRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
yRect.setAttribute('x', translateX.toString(10));
yRect.setAttribute('y', translateY.toString(10));
yRect.setAttribute('width', '2');
yRect.setAttribute('height', (height - translateX).toString(10));
yRect.setAttribute('stroke', 'none');
yRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(yRect);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
path.removeAttribute('d');
let d = '';
for (let k = 0; k < analyser.frequencyBinCount; k++) {
// for Chrome
if (!Number.isFinite(data[k])) {
continue;
}
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
const y = Math.min(((0 - data[k]) * (innerHeight / (maxDecibels - minDecibels)) - translateY), (height - translateY));
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() =' {
render();
});
};
const buttonElement = document.getElementById('button-svg-amplitude-spectrum-path-with-coordinate');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
// OscillatorNode (Input) -> AnalyserNode (Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
SVG による周波数領域 (振幅スペクトル) の波形描画 (x 座標, y 座標表示)
start
同様に, 以下のコードは, Canvas でユーザーインタラクティブに, 振幅スペクトルのパスに追加して, x 座標と y 座標を描画するコードです.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384 });
const canvas = document.getElementById('canvas-animation-amplitude-spectrum-path-with-coordinate');
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const innerWidth = width - 48;
const innerHeight = height - 48;
const translateX = 48;
const translateY = 24;
const maxDecibels = 0;
const minDecibels = -60;
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.beginPath();
for (let k = 0; k < analyser.frequencyBinCount; k++) {
if (!Number.isFinite(data[k])) {
continue;
}
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
const y = Math.min(((0 - data[k]) * (innerHeight / (maxDecibels - minDecibels)) - translateY), (height - translateY));
if (k === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
renderingContext.lineWidth = 1.5;
renderingContext.strokeStyle = 'rgb(0 0 255)';
renderingContext.stroke();
renderingContext.fillStyle = 'rgb(153 153 153)';
renderingContext.fillRect(translateX, (height - translateY - 1), innerWidth, 2);
renderingContext.fillRect(translateX, translateY, 2, innerHeight);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-canvas-amplitude-spectrum-path-with-coordinate');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
// OscillatorNode (Input) -> AnalyserNode (Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
Canvas による周波数領域 (振幅スペクトル) の波形描画 (x 座標, y 座標表示)
start
振幅スペクトの描画の y 座標の算出で, y 座標の加減と比較して, 小さい方の値を描画する y 座標としていますが, これは,
-60 dB はアプリケーション側で決定した描画対象の振幅スペクトルのデシベルの下限であり,
getFloatFrequencyData メソッドで取得できる値はそれを下回ることも十分にありえるからです.
最後に, 振幅と時間のテキストと描画です.
振幅のテキスト描画は,
描画対象のデシベル範囲と height のマッピング から,
1 dB 間は height / (max - min) となりますが, すべてを描画してもテキストが重なって可読できないので,
ここで記載するコード例では, 10 dB ごとに描画することにします. つまり, height を 6 等分した間隔で, 0 dB,
-10 dB, -20 dB, ... -50 dB, -60 dB と描画することになります.
周波数のテキスト描画は, 周波数分解能 と
Float32Array のインデックスから算出することができます. 取得した振幅スペクトルを格納している Float32Array の
$k$ 番目のインデックスに対応する周波数 $f_{k}$ は, サンプリング周波数を
$f_{s}$ , FFT サイズ (fftSize プロパティの値) を $N$ とすると,
$f_{k} = k \cdot \left(\frac{f_{s}}{N}\right)$ となります. 周波数軸も,
すべてのインデックスでテキストを描画してしまうと, テキストが重なって視認できないので, 実際には, ある程度間引いて描画したり,
frequencyBinCount の 1/2 や, 1/4 に対応するインデックスまで描画したりすればよいでしょう.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384 });
const frequencyResolution = context.sampleRate / analyser.fftSize;
const svg = document.getElementById('svg-animation-amplitude-spectrum-path-with-coordinate-and-texts');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const innerWidth = width - 48;
const innerHeight = height - 48;
const translateX = 48;
const translateY = 24;
const maxDecibels = 0;
const minDecibels = -60;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
const xRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
xRect.setAttribute('x', translateX.toString(10));
xRect.setAttribute('y', (height - translateY - 1).toString(10));
xRect.setAttribute('width', (width - translateX).toString(10));
xRect.setAttribute('height', '2');
xRect.setAttribute('stroke', 'none');
xRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(xRect);
const yRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
yRect.setAttribute('x', translateX.toString(10));
yRect.setAttribute('y', translateY.toString(10));
yRect.setAttribute('width', '2');
yRect.setAttribute('height', (height - translateX).toString(10));
yRect.setAttribute('stroke', 'none');
yRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(yRect);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
[0, -10, -20, -30, -40, -50, -60].forEach((dB, index) => {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = `${dB} dB`;
text.setAttribute('x', '44');
text.setAttribute('y', (index * (innerHeight / 6) + translateY).toString(10));
text.setAttribute('text-anchor', 'end');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
});
for (let k = 0; k < analyser.frequencyBinCount; k++) {
if (k % 1024 !== 0) {
continue;
}
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = `${Math.trunc(k * frequencyResolution)} Hz`;
text.setAttribute('x', x);
text.setAttribute('y', (height - 8).toString(10));
text.setAttribute('text-anchor', 'start');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
}
svg.appendChild(g);
const xLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
xLabel.textContent = 'Frequency (Hz)';
xLabel.setAttribute('x', width.toString(10));
xLabel.setAttribute('y', (height - translateY - 8).toString(10));
xLabel.setAttribute('text-anchor', 'end');
xLabel.setAttribute('stroke', 'none');
xLabel.setAttribute('fill', 'rgb(153 153 153)');
xLabel.setAttribute('font-size', '14px');
const yLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
yLabel.textContent = 'Amplitude (dB)';
yLabel.setAttribute('x', translateY.toString(10));
yLabel.setAttribute('y', '12');
yLabel.setAttribute('text-anchor', 'start');
yLabel.setAttribute('stroke', 'none');
yLabel.setAttribute('fill', 'rgb(153 153 153)');
yLabel.setAttribute('font-size', '14px');
svg.appendChild(xLabel);
svg.appendChild(yLabel);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
path.removeAttribute('d');
let d = '';
for (let k = 0; k < analyser.frequencyBinCount; k++) {
// for Chrome
if (!Number.isFinite(data[k])) {
continue;
}
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
const y = Math.min(((0 - data[k]) * (innerHeight / (maxDecibels - minDecibels)) - translateY), (height - translateY));
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() =' {
render();
});
};
const buttonElement = document.getElementById('button-svg-amplitude-spectrum-path-with-coordinate-and-texts');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
// OscillatorNode (Input) -> AnalyserNode (Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
SVG による周波数領域 (振幅スペクトル) の波形描画 (x 座標, y 座標, および, 振幅・周波数テキスト表示)
start
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384 });
const frequencyResolution = context.sampleRate / analyser.fftSize;
const canvas = document.getElementById('canvas-animation-amplitude-spectrum-path-with-coordinate-and-texts');
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const innerWidth = width - 48;
const innerHeight = height - 48;
const translateX = 48;
const translateY = 24;
const maxDecibels = 0;
const minDecibels = -60;
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.beginPath();
for (let k = 0; k < analyser.frequencyBinCount; k++) {
if (!Number.isFinite(data[k])) {
continue;
}
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
const y = Math.min(((0 - data[k]) * (innerHeight / (maxDecibels - minDecibels)) - translateY), (height - translateY));
if (k === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
renderingContext.lineWidth = 1.5;
renderingContext.strokeStyle = 'rgb(0 0 255)';
renderingContext.stroke();
renderingContext.fillStyle = 'rgb(153 153 153)';
renderingContext.fillRect(translateX, (height - translateY - 1), innerWidth, 2);
renderingContext.fillRect(translateX, translateY, 2, innerHeight);
renderingContext.font = 'Roboto 12px';
renderingContext.fillStyle = 'rgb(153 153 153)';
[0, -10, -20, -30, -40, -50, -60].forEach((dB, index) => {
renderingContext.textAlign = 'end';
renderingContext.fillText(`${dB} dB`, 44, (index * (innerHeight / 6) + translateY));
});
for (let k = 0; k < analyser.frequencyBinCount; k++) {
if (k % 1024 !== 0) {
continue;
}
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
renderingContext.textAlign = 'start';
renderingContext.fillText(`${k * frequencyResolution} Hz`, x, (height - 8));
}
renderingContext.font = 'Roboto 16px';
renderingContext.textAlign = 'end';
renderingContext.fillText('Frequency (Hz)', width, (height - translateY - 8));
renderingContext.textAlign = 'start';
renderingContext.fillText('Amplitude (dB)', translateY, 12);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-canvas-amplitude-spectrum-path-with-coordinate-and-texts');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
// OscillatorNode (Input) -> AnalyserNode (Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
Canvas による周波数領域 (振幅スペクトル) の波形描画 (x 座標, y 座標, および, 振幅・周波数テキスト表示)
start
getByteFrequencyData メソッド
getByteFrequencyData メソッドは, Uint8Array を引数にとり,
Uint8Array のサイズだけ振幅スペクトル格納します. 振幅スペクトルの範囲は 0 ~ 255 です. ただし,
引数に指定する Uint8Array のサイズが frequencyBinCount プロパティより大きい場合, 超過した要素は
0 のままです. frequencyBinCount プロパティより小さい場合は, 不足した分の値は無視されます. したがって,
イディオム的には, frequencyBinCount プロパティのサイズの Uint8Array を引数に指定します. これら仕様は
getFloatFrequencyData メソッドと同じです.
getFloatFrequencyData メソッドと異なるのは, 格納される値の型が異なるので, 振幅と y 座標のマッピングのみです.
255 の場合, 振幅が maxDecibels プロパティで指定されているデシベル (デフォルト値は, -30 dB) で, y
座標は 0
0 の場合, 振幅が minDecibels プロパティで指定されているデシベル (デフォルト値は, -100 dB) で, y
座標は height
つまり, getFloatTimeDomainData メソッドで取得できる Float32Array の値を
$X\left[k\right]$ , getByteTimeDomainData メソッで取得できる Uint8Array の値を
$B\left[k\right]$ , maxDecibels プロパティの値を
$\mathrm{dB_{max}}$ , minDecibels プロパティの値を
$\mathrm{dB_{min}}$ とすると以下のような値の関係にあります.
$B\left[k\right] = \lfloor \left(\frac{255}{\mathrm{dB_{max}} - \mathrm{dB_{min}}}\right) \cdot \left(X\left[k\right] - \mathrm{dB_{min}}\right)
\rfloor$
少し理解しにくい関係式ですが, 端的には, getFloatFrequencyData で取得できる振幅スペクトルのデシベル値を, 符号なし 8 bit の範囲
(0 ~ 255) にマッピングしているということです. そして, そのマッピングの対象となるデシベルの範囲を,
maxDecibels プロパティと minDecibels プロパティで決定しています.
getFloatFrequencyData メソッドと getByteFrequencyData メソッドの関係
振幅スペクトル, および, そのテキストは, 符号なし 8 bit の最大値の 255 で除算して 0 ~
1 の範囲で正規化して描画するのがよいでしょう (振幅スペクトルは絶対値なので, 負数はありません). また,
maxDecibels プロパティと minDecibels プロパティも多くのケースにおいてはデフォルト値のままで問題ないですが,
以下のコードとデモでは, あえて変更して, getFloatFrequencyData メソッドで描画したコード例と合わせるように,
maxDecibels プロパティを 0 (dB), minDecibels プロパティを -60 (dB)
に設定して実装します.
振幅スペクトルを正規化すると, y 座標の値は (1 - (data[n] / 255)) * height で算出することができます.
正規化した振幅スペクトルと height のマッピング
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384, maxDecibels: 0, minDecibels: -60 });
const frequencyResolution = context.sampleRate / analyser.fftSize;
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const innerWidth = width - 48;
const innerHeight = height - 48;
const translateX = 48;
const translateY = 24;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', lineWidth.toString(10));
path.setAttribute('stroke-linecap', lineCap);
path.setAttribute('stroke-linejoin', lineJoin);
svg.appendChild(path);
const xRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
xRect.setAttribute('x', translateX.toString(10));
xRect.setAttribute('y', (height - translateY - 1).toString(10));
xRect.setAttribute('width', innerWidth.toString(10));
xRect.setAttribute('height', '2');
xRect.setAttribute('stroke', 'none');
xRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(xRect);
const yRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
yRect.setAttribute('x', translateX.toString(10));
yRect.setAttribute('y', translateY.toString(10));
yRect.setAttribute('width', '2');
yRect.setAttribute('height', innerHeight.toString(10));
yRect.setAttribute('stroke', 'none');
yRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(yRect);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
[1.0, 0.5, 0.0].forEach((amplitude, index) => {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = amplitude.toFixed(1);
text.setAttribute('x', '44');
text.setAttribute('y', (index * (innerHeight / 2) + translateY).toString(10));
text.setAttribute('text-anchor', 'end');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
});
for (let k = 0; k < analyser.frequencyBinCount; k++) {
if (k % 1024 !== 0) {
continue;
}
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = `${Math.trunc(k * frequencyResolution)} Hz`;
text.setAttribute('x', x);
text.setAttribute('y', (height - 8).toString(10));
text.setAttribute('text-anchor', 'start');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
}
svg.appendChild(g);
const xLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
xLabel.textContent = 'Frequency (Hz)';
xLabel.setAttribute('x', width.toString(10));
xLabel.setAttribute('y', (height - translateY - 8).toString(10));
xLabel.setAttribute('text-anchor', 'end');
xLabel.setAttribute('stroke', 'none');
xLabel.setAttribute('fill', 'rgb(153 153 153)');
xLabel.setAttribute('font-size', '14px');
const yLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
yLabel.textContent = 'Amplitude';
yLabel.setAttribute('x', '28');
yLabel.setAttribute('y', '12');
yLabel.setAttribute('text-anchor', 'start');
yLabel.setAttribute('stroke', 'none');
yLabel.setAttribute('fill', 'rgb(153 153 153)');
yLabel.setAttribute('font-size', '14px');
svg.appendChild(xLabel);
svg.appendChild(yLabel);
let animationId = null;
const render = () => {
const data = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(data);
path.removeAttribute('d');
let d = '';
for (let k = 0; k < analyser.frequencyBinCount; k++) {
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
const y = (1 - data[k] / 255) * innerHeight + translateY;
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-svg-amplitude-spectrum-path-with-coordinate-and-texts-in-uint8');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
getByteFrequencyData メソッドによる周波数領域 (振幅スペクトル) の波形描画 (SVG)
start
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384, maxDecibels: 0, minDecibels: -60 });
const frequencyResolution = context.sampleRate / analyser.fftSize;
const renderingContext = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const innerWidth = width - 48;
const innerHeight = height - 48;
const translateX = 48;
const translateY = 24;
let animationId = null;
const render = () => {
const data = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(data);
renderingContext.clearRect(0, 0, width, height);
renderingContext.beginPath();
for (let k = 0; k < analyser.frequencyBinCount; k++) {
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
const y = (1 - data[k] / 255) * innerHeight + translateY;
if (k === 0) {
renderingContext.moveTo(x, y);
} else {
renderingContext.lineTo(x, y);
}
}
renderingContext.lineWidth = 1.5;
renderingContext.strokeStyle = 'rgb(0 0 255)';
renderingContext.stroke();
renderingContext.fillStyle = 'rgb(153 153 153)';
renderingContext.fillRect(translateX, height - translateY - 1, innerWidth, 2);
renderingContext.fillRect(translateX, translateY, 2, innerHeight);
renderingContext.font = 'Roboto 12px';
renderingContext.fillStyle = 'rgb(153 153 153)';
[1.0, 0.5, 0.0].forEach((amplitude, index) => {
renderingContext.textAlign = 'end';
renderingContext.fillText(amplitude.toFixed(1), 44, index * (innerHeight / 2) + translateY);
});
for (let k = 0; k < analyser.frequencyBinCount; k++) {
if (k % 1024 !== 0) {
continue;
}
const x = k * (innerWidth / analyser.frequencyBinCount) + translateX;
renderingContext.textAlign = 'start';
renderingContext.fillText(`${k * frequencyResolution} Hz`, x, height - 8);
}
renderingContext.font = 'Roboto 16px';
renderingContext.textAlign = 'end';
renderingContext.fillText('Frequency (Hz)', width, height - translateY - 8);
renderingContext.textAlign = 'start';
renderingContext.fillText('Amplitude', 28, 12);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-canvas-amplitude-spectrum-path-with-coordinate-and-texts-in-uint8');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
getByteFrequencyData メソッドによる周波数領域 (振幅スペクトル) の波形描画 (Canvas)
start
位相スペクトルの描画
AnalyserNode クラスには, getFloatFrequencyData メソッドのような,
位相スペクトルを取得するためのメソッドは仕様にありません (すでに解説していますが, 人間の聴覚は位相スペクトルの違いに鈍感という特性があり, 実際,
エフェクターの実装などにおいても, 位相スペクトルで演算をするということが非常に稀なケースであるからです. しかしながら, API
として定義されていなくても, 離散フーリエ変換 (高速フーリエ変換) を直接適用することができるので, FFT を適用した結果の複素数の配列から,
偏角の配列を取得して, 周波数を横軸に位相をプロットしていくことで位相スペクトルを視覚化することができます.
また, 位相スペクトルが必要なケースでも, BiquadFilterNode によるフィルタの位相特性を取得する場合は,
BiquadFilterNode インスタンスの getFrequencyResponse メソッドを利用することで可能です (All-Pass Filter の周波数特性 (位相スペクトル)
は, FFT を実装することなく, getFrequencyResponse メソッドで位相スペクトルを取得することで視覚化しています).
位相スペクトルの視覚化の解説として,
OverlapAddProcessor クラス を拡張子したクラスを利用して実装します (AudioWorkletProcessor クラスで実装すると, FFT サイズが
renderQuantumSize (128 サンプル) で固定されてしまい, 十分な周波数分解能が確保できないからです).
processOverlapAdd メソッドで呼び出している, FFT 関数は,
高速フーリエ変換の実装 セクションで記載している実装を
AudioWorkletGlobalScope 内で定義していると仮定してください.
窓関数は, 位相スペクトルを描画に利用するだけなので, AnalyserNode と同じく, ブラックマン窓を FFT の実行前に適用すればよいでしょう.
// Filename is './audio-worklets/phase-spectrum.js'
/**
* This class extends `OverlapAddProcessor`.
*/
class PhaseSpectrumOverlapAddProcessor extends OverlapAddProcessor {
constructor(options) {
super(options);
this.blackmanWindow = this.createBlackmanWindow(this.frameSize);
this.frequencyBinCount = this.frameSize / 2;
}
/** @overdrive */
processOverlapAdd(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
const numberOfChannels = input.length;
for (let channelNumber = 0; channelNumber < numberOfChannels; channelNumber++) {
// Bypass
output[channelNumber].set(input[channelNumber]);
const phases = new Float32Array(this.frequencyBinCount);
const reals = new Float32Array(this.frameSize);
const imags = new Float32Array(this.frameSize);
for (let n = 0; n < this.frameSize; n++) {
reals[n] = this.blackmanWindow[n] * input[channelNumber][n];
}
FFT(reals, imags, this.frameSize);
const plot = Math.trunc(440 * (this.frameSize / sampleRate));
for (let k = 0; k < this.frequencyBinCount; k++) {
if (k !== plot) {
continue;
}
if ((reals[k] !== 0) && (imags[k] !== 0)) {
phases[k] = Math.atan2(imags[k], reals[k]);
}
}
// Post to main thread
this.port.postMessage(phases);
}
}
createBlackmanWindow(size) {
const w = new Float32Array(size);
const alpha = 0.16;
const a0 = (1 - alpha) / 2;
const a1 = 1 / 2;
const a2 = alpha / 2;
for (let n = 0; n < size; n++) {
w[n] = a0 - a1 * Math.cos((2 * Math.PI * n) / size) + a2 * Math.cos((4 * Math.PI * n) / size);
}
return w;
}
}
registerProcessor('PhaseSpectrumOverlapAddProcessor', PhaseSpectrumOverlapAddProcessor);
位相スペクトルはナイキスト周波数を軸に点対称 となります. 線対称か点対称かの違いはありますが, 振幅スペクトと同じく,
ナイキスト周波数までの位相スペクトルが取得できればよいので, FFT サイズの値を 1/2 にして
frequencyBinCount プロパティとして算出しています. したがって, メインスレッドに postMessage される
Float32Array のサイズは, frequencyBinCount サイズとなります. また, 偏角はすでに解説していますが,
Math.atan2 メソッドに, 対応するインデックスの虚軸の値と実軸の値を引数に指定することで, ラジアンとして算出できます.
メインスレッドでは, MessagePort の onmessage イベントハンドラで取得した位相スペクトルを描画します. 位相のプロット範囲
(プロットの下限と上限) の設定はいくつか考えられるので ($0 \leq \theta \leq 2 \pi$ , 0° ~
360° など), ここでは, $- \pi \leq \theta \leq \pi$ の範囲で位相スペクトルを描画することにします.
const context = new AudioContext();
const fftSize = 2048;
const numberOfPlots = fftSize / 64;
const frequencyResolution = context.sampleRate / fftSize;
const svg = document.getElementById('svg-animation-phase-spectrum-path-with-coordinate-and-texts');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const innerWidth = width - 48;
const innerHeight = height - 48;
const translateX = 48;
const translateY = 24;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
const xRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
xRect.setAttribute('x', translateX.toString(10));
xRect.setAttribute('y', (height - translateY - 1).toString(10));
xRect.setAttribute('width', (width - translateX).toString(10));
xRect.setAttribute('height', '2');
xRect.setAttribute('stroke', 'none');
xRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(xRect);
const yRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
yRect.setAttribute('x', translateX.toString(10));
yRect.setAttribute('y', translateY.toString(10));
yRect.setAttribute('width', '2');
yRect.setAttribute('height', (height - translateX).toString(10));
yRect.setAttribute('stroke', 'none');
yRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(yRect);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
['π', 'π/2', '0', '-π/2', '-π'].forEach((radian, index) => {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = `${radian} rad`;
text.setAttribute('x', '44');
text.setAttribute('y', (index * (innerHeight / 4) + translateY).toString(10));
text.setAttribute('text-anchor', 'end');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
});
for (let k = 0; k < numberOfPlots; k++) {
if ((k % 2) !== 0) {
continue;
}
const x = k * (innerWidth / numberOfPlots) + translateX;
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = `${Math.trunc(k * frequencyResolution)} Hz`;
text.setAttribute('x', x);
text.setAttribute('y', (height - 8).toString(10));
text.setAttribute('text-anchor', 'start');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
}
svg.appendChild(g);
const xLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
xLabel.textContent = 'Frequency (Hz)';
xLabel.setAttribute('x', width.toString(10));
xLabel.setAttribute('y', (height - translateY - 8).toString(10));
xLabel.setAttribute('text-anchor', 'end');
xLabel.setAttribute('stroke', 'none');
xLabel.setAttribute('fill', 'rgb(153 153 153)');
xLabel.setAttribute('font-size', '14px');
const yLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
yLabel.textContent = 'Phase (radian)';
yLabel.setAttribute('x', translateY.toString(10));
yLabel.setAttribute('y', '12');
yLabel.setAttribute('text-anchor', 'start');
yLabel.setAttribute('stroke', 'none');
yLabel.setAttribute('fill', 'rgb(153 153 153)');
yLabel.setAttribute('font-size', '14px');
svg.appendChild(xLabel);
svg.appendChild(yLabel);
const render = (data) => {
path.removeAttribute('d');
let d = '';
for (let k = 0; k < numberOfPlots; k++) {
const x = k * (innerWidth / numberOfPlots) + translateX;
const y = ((Math.PI - data[k]) / Math.PI) * (innerHeight / 2) + translateY;
if (d === '') {
d += `M${x} ${y}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
};
const buttonElement = document.getElementById('button-svg-phase-spectrum-path-with-coordinate-and-texts');
let processor = null;
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (processor === null) {
await context.audioWorklet.addModule('./audio-worklets/phase-spectrum.js');
processor = new AudioWorkletNode(context, 'PhaseSpectrumOverlapAddProcessor', {
processorOptions: {
frameSize: fftSize
}
});
processor.port.onmessage = (event) => {
render(event.data);
};
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context);
// OscillatorNode (Input) -> AudioWorkletNode (Phase Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(processor);
processor.connect(context.destination);
oscillator.start(0);
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
周波数領域 (位相スペクトル) の波形描画 (440 Hz の正弦波. 段差成分あり)
start
しかしながら, リアルタイム処理では, 窓関数を適用しても完全に段差成分を除去することはできないので,
本来は存在しない周波数成分の位相もシフトしてしまいます. 特に, 振幅スペクトルと異なり, 段差成分も
$- \pi \leq \theta \leq \pi$ の範囲でシフトするので, 位相スペクトルの段差成分は,
より目立って視覚化されてしまいます (それでも, 440 Hz の成分が
$- \pi \leq \theta \leq \pi$ の範囲でシフトしていることも確認はできると思います).
440 Hz 正弦波の (本来の) 位相スペクトルは以下のようになります.
周波数領域 (位相スペクトル) の波形描画 (440 Hz の正弦波. 段差成分除去)
start
周波数軸の対数スケール表示
これまでの周波数軸でのスペクトル描画の, 周波数軸はすべて線形スケールで表示していました. しかしながら, スペクトルの場合,
低音域から高音域まで視覚化したいユースケースも多くあります. 線形スケールでは, 現実的なグラフィックス API の領域の幅
(デスクトップサイズで横スクロールが発生しない幅として, 13 インチで 1400 px ぐらい) では, 低音域を詳細に視覚化すると,
高音域が視覚化できなくなり (視認できなくなり), 小音域まで視覚化すると, 低音域の詳細が視覚化できなく (視認できなく) なります.
この問題を解決するために, 周波数軸を対数スケールで表示するケースがよくあります (すでに,
フィルタの周波数特性などでは対数スケールで表示していますが). このセクションでは, 対数スケールで, 振幅スペクトルを描画する実装を解説します.
対数スケールの尺度は様々ですが, このセクションでは, 10 を低とする常用対数 (Math.log10) を利用して, 10
帯域のグラフィックイコライザーの帯域を視覚化できるように, 32 Hz から, 約 2 倍ずつステップした, 32 Hz,
62.5 Hz, 125 Hz, 250 Hz, 500 Hz, 1000 Hz, 2000 Hz,
4000 Hz, 8000 Hz, 16000 Hz の 10 つの周波数 (帯域) を対数スケールで描画します.
周波数のテキストの座標は線形スケールと考え方は同じです. 10 帯域を描画対象の幅で, 等間隔に描画するので width / 10 で算出できます
(10 は帯域数です).
線形スケールと異なるのは, 振幅スペクトルのパスの x 座標の算出です. 描画範囲の幅を 1 として, 対数スケールでプロットする最小周波数を
$f_{\mathrm{min}}$ , 最大周波数を $f_{\mathrm{max}}$ とすると, インデックス
$k$ 番目に対応する対数スケールの周波数を $f_{k}$ の, プロットする x 座標
$x_{k}$ の算出は,
$x_{k} = \frac{\log_{10}\left(\frac{f_{k}}{f_{\mathrm{min}}}\right)}{\log_{10}\left(\frac{f_{\mathrm{max}}}{f_{\mathrm{min}}}\right)}$
対数の性質より, 真数の除算は, 減算に展開可能なので, 以下の式と等価です (分母の意味としては, こちらのほうが理解しやすいかもしれません).
$x_{k} = \frac{\log_{10}\left(f_{k}\right) - \log_{10}\left(f_{\mathrm{min}}\right)}{\log_{10}\left(f_{\mathrm{max}}\right) -
\log_{10}\left(f_{\mathrm{min}}\right)}$
描画範囲の幅を 1 としているので, $x_{k}$ に実際の描画範囲の幅を乗算すればよいでしょう.
$f_{\mathrm{min}}$ と $f_{\mathrm{max}}$ は, このセクションの実装では,
それぞれ 32 (Hz) と 16000 (Hz) となります.
const context = new AudioContext();
const analyser = new AnalyserNode(context, { fftSize: 16384 });
const frequencies = [32, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const minFrequency = frequencies[0];
const maxFrequency = frequencies[frequencies.length - 1];
const ratio = maxFrequency / minFrequency;
const log10Ratio = Math.log10(ratio);
const frequencyResolution = context.sampleRate / analyser.fftSize;
const svg = document.getElementById('svg-animation-logarithmic-scale-amplitude-spectrum');
const width = Number(svg.getAttribute('width') ?? '0');
const height = Number(svg.getAttribute('height') ?? '0');
const innerWidth = width - 48;
const innerHeight = height - 48;
const translateX = 48;
const translateY = 24;
const maxDecibels = 0;
const minDecibels = -60;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke', 'rgb(0 0 255)');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(path);
const xRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
xRect.setAttribute('x', translateX.toString(10));
xRect.setAttribute('y', (height - translateY - 1).toString(10));
xRect.setAttribute('width', (width - translateX).toString(10));
xRect.setAttribute('height', '2');
xRect.setAttribute('stroke', 'none');
xRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(xRect);
const yRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
yRect.setAttribute('x', translateX.toString(10));
yRect.setAttribute('y', translateY.toString(10));
yRect.setAttribute('width', '2');
yRect.setAttribute('height', (height - translateX).toString(10));
yRect.setAttribute('stroke', 'none');
yRect.setAttribute('fill', 'rgb(153 153 153)');
svg.appendChild(yRect);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
[0, -10, -20, -30, -40, -50, -60].forEach((dB, index) => {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = `${dB} dB`;
text.setAttribute('x', '44');
text.setAttribute('y', (index * (innerHeight / 6) + translateY).toString(10));
text.setAttribute('text-anchor', 'end');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
});
frequencies.forEach((frequency, index) => {
const x = (index * (innerWidth / frequencies.length)) + translateX;
const y = height - 8;
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
if (frequency >= 1000) {
text.textContent = `${Math.trunc(frequency / 1000)} kHz`;
} else {
text.textContent = `${frequency} Hz`;
}
text.setAttribute('x', x.toString(10));
text.setAttribute('y', y.toString(10));
text.setAttribute('text-anchor', 'start');
text.setAttribute('stroke', 'none');
text.setAttribute('fill', 'rgb(153 153 153)');
text.setAttribute('font-size', '12px');
g.appendChild(text);
});
svg.appendChild(g);
const xLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
xLabel.textContent = 'Frequency (Hz)';
xLabel.setAttribute('x', width.toString(10));
xLabel.setAttribute('y', (height - translateY - 8).toString(10));
xLabel.setAttribute('text-anchor', 'end');
xLabel.setAttribute('stroke', 'none');
xLabel.setAttribute('fill', 'rgb(153 153 153)');
xLabel.setAttribute('font-size', '14px');
const yLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
yLabel.textContent = 'Amplitude (dB)';
yLabel.setAttribute('x', translateY.toString(10));
yLabel.setAttribute('y', '12');
yLabel.setAttribute('text-anchor', 'start');
yLabel.setAttribute('stroke', 'none');
yLabel.setAttribute('fill', 'rgb(153 153 153)');
yLabel.setAttribute('font-size', '14px');
svg.appendChild(xLabel);
svg.appendChild(yLabel);
let animationId = null;
const render = () => {
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
path.removeAttribute('d');
let d = '';
for (let k = 0; k < analyser.frequencyBinCount; k++) {
if (k === 0) {
continue;
}
// for Chrome
if (!Number.isFinite(data[k])) {
continue;
}
const frequency = k * frequencyResolution;
const x = ((Math.log10(frequency / minFrequency) / log10Ratio) * innerWidth) + translateX;
const y = Math.min(((0 - data[k]) * (innerHeight / (maxDecibels - minDecibels)) - translateY), (height - translateY));
if (x < translateX) {
continue;
}
if (d === '') {
d += `M${translateX} ${translateY + innerHeight}`;
} else {
d += ` L${x} ${y}`;
}
}
path.setAttribute('d', d);
animationId = window.requestAnimationFrame(() => {
render();
});
};
const buttonElement = document.getElementById('button-svg-logarithmic-scale-amplitude-spectrum');
let oscillator = null;
const onDown = async () => {
if (context.state !== 'running') {
await context.resume();
}
if (oscillator !== null) {
return;
}
oscillator = new OscillatorNode(context, { type: 'sawtooth' });
// OscillatorNode (Input) -> AnalyserNode (Spectrum Analyser) -> AudioDestinationNode (Output)
oscillator.connect(analyser);
analyser.connect(context.destination);
oscillator.start(0);
render();
buttonElement.textContent = 'stop';
};
const onUp = () => {
if (oscillator === null) {
return;
}
oscillator.stop(0);
oscillator = null;
if (animationId) {
window.cancelAnimationFrame(animationId);
animationId = null;
}
buttonElement.textContent = 'start';
};
buttonElement.addEventListener('mousedown', onDown);
buttonElement.addEventListener('touchstart', onDown);
buttonElement.addEventListener('mouseup', onUp);
buttonElement.addEventListener('touchend', onUp);
getFloatFrequencyData メソッド取得した, 振幅スペクトルの Float32Array の 0 番目のインデックスは
0 Hz に対応しますが, 対数の定義より, 対数の真数は 0 より大きくなければならない ので,
振幅スペクトルの座標をプロットするループで, 0 番目のインデックスは continue していることに着目してください (これは,
getByteFrequencyData メソッドを利用した場合でも同じです).
対数スケールによる振幅スペクトル
start
ちなみに, 振幅スペクトルもデシベル単位でプロットしているので, 周波数軸も振幅軸も対数スケールで視覚化していることになります.
また, 周波数軸を対数スケールにするので, 位相スペクトルも同様の実装で対応できます.