Skip to content

Instantly share code, notes, and snippets.

@marcoonroad
Last active June 21, 2026 23:35
Show Gist options
  • Select an option

  • Save marcoonroad/a0acdd5fa329d6a9d0f2ea46428ec1cb to your computer and use it in GitHub Desktop.

Select an option

Save marcoonroad/a0acdd5fa329d6a9d0f2ea46428ec1cb to your computer and use it in GitHub Desktop.
Chrome (inspect -> console) WebAudio recorder script
(function () {
if (window.__waRecorder) {
console.warn('[waRecorder] Already installed.');
return;
}
const state = {
contexts: new Set(),
recording: false,
buffers: [], // array of [Float32Array, Float32Array, ...] per channel, per block
sampleRate: 44100,
numChannels: 2,
};
function instrumentContext(ctx) {
if (state.contexts.has(ctx)) return;
state.contexts.add(ctx);
const channels = ctx.destination.channelCount || 2;
state.sampleRate = ctx.sampleRate;
state.numChannels = channels;
const tap = ctx.createGain(); // receives a copy of anything connected to destination
const processor = ctx.createScriptProcessor(8192, channels, channels);
const mute = ctx.createGain();
mute.gain.value = 0; // prevents double playback from the processor's required output
tap.connect(processor);
processor.connect(mute);
mute.connect(ctx.destination);
processor.onaudioprocess = (e) => {
if (!state.recording) return;
const block = new Array(channels);
for (let c = 0; c < channels; c++) {
block[c] = new Float32Array(e.inputBuffer.getChannelData(c));
}
state.buffers.push(block);
};
ctx.__waTap = tap;
ctx.__waTapConnectedNodes = new Set();
}
// Patch connect() once, globally, on the AudioNode prototype
const originalConnect = AudioNode.prototype.connect;
AudioNode.prototype.connect = function (destination, ...args) {
const result = originalConnect.call(this, destination, ...args);
try {
if (destination instanceof AudioDestinationNode) {
const ctx = destination.context;
if (!state.contexts.has(ctx)) instrumentContext(ctx);
if (ctx.__waTap && this !== ctx.__waTap && !ctx.__waTapConnectedNodes.has(this)) {
originalConnect.call(this, ctx.__waTap); // duplicate signal into the recorder tap
ctx.__waTapConnectedNodes.add(this);
}
}
} catch (err) {
console.error('[waRecorder] tap error:', err);
}
return result;
};
function encodeWAV(buffers, sampleRate, numChannels) {
let totalLength = 0;
for (const b of buffers) totalLength += b[0].length;
const channelData = [];
for (let c = 0; c < numChannels; c++) channelData.push(new Float32Array(totalLength));
let offset = 0;
for (const block of buffers) {
for (let c = 0; c < numChannels; c++) channelData[c].set(block[c], offset);
offset += block[0].length;
}
const interleaved = new Float32Array(totalLength * numChannels);
for (let i = 0; i < totalLength; i++) {
for (let c = 0; c < numChannels; c++) interleaved[i * numChannels + c] = channelData[c][i];
}
const bytesPerSample = 2;
const blockAlign = numChannels * bytesPerSample;
const dataSize = interleaved.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
const writeStr = (off, str) => { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)); };
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bytesPerSample * 8, true);
writeStr(36, 'data');
view.setUint32(40, dataSize, true);
let idx = 44;
for (let i = 0; i < interleaved.length; i++, idx += 2) {
const s = Math.max(-1, Math.min(1, interleaved[i]));
view.setInt16(idx, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return new Blob([view], { type: 'audio/wav' });
}
globalThis.startRecord = function () {
state.buffers = [];
state.recording = true;
console.log('[waRecorder] Recording started.');
};
globalThis.stopRecord = function () {
state.recording = false;
console.log('[waRecorder] Recording stopped.', state.buffers.length, 'blocks captured.');
};
globalThis.saveRecord = function (fileName = 'recording.wav') {
if (!state.buffers.length) {
console.warn('[waRecorder] No audio recorded yet.');
return;
}
const blob = encodeWAV(state.buffers, state.sampleRate, state.numChannels);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName.endsWith('.wav') ? fileName : `${fileName}.wav`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
console.log('[waRecorder] Saved as', a.download);
};
window.__waRecorder = true;
console.log('[waRecorder] Installed. Use startRecord(), stopRecord(), saveRecord("file.wav").');
})();
class RecorderProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.recording = false;
this.chunkSize = 16384;
this.buffers = null;
this.writeIndex = 0;
this.numChannels = 2;
this.port.onmessage = (e) => {
if (e.data === 'start') {
this.recording = true;
this._reset(this.numChannels);
} else if (e.data === 'stop') {
this.recording = false;
this._flush();
this.port.postMessage({ type: 'stopped' });
}
};
}
_reset(n) {
this.numChannels = n;
this.buffers = [];
for (let c = 0; c < n; c++) this.buffers.push(new Float32Array(this.chunkSize));
this.writeIndex = 0;
}
_flush() {
if (this.buffers && this.writeIndex > 0) {
const buffers = this.buffers.map(b => b.slice(0, this.writeIndex));
this.port.postMessage({ type: 'chunk', buffers }, buffers.map(b => b.buffer));
}
this.writeIndex = 0;
}
process(inputs) {
const input = inputs[0];
if (!this.recording || !input || !input.length || !input[0] || !input[0].length) return true;
if (!this.buffers || this.numChannels !== input.length) {
this._flush();
this._reset(input.length);
}
const frames = input[0].length;
for (let i = 0; i < frames; i++) {
for (let c = 0; c < this.numChannels; c++) this.buffers[c][this.writeIndex] = input[c][i];
this.writeIndex++;
if (this.writeIndex >= this.chunkSize) {
this._flush();
this._reset(this.numChannels);
}
}
return true;
}
}
registerProcessor('recorder-processor', RecorderProcessor);
(function () {
if (window.__waRecorder) {
console.warn('[waRecorder] Already installed.');
return;
}
const state = {
recording: false,
buffers: [],
sampleRate: 44100,
numChannels: 2,
pendingStops: 0,
};
const ctxStates = new Map();
const workletCode = `
class RecorderProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.recording = false;
this.chunkSize = 16384;
this.buffers = null;
this.writeIndex = 0;
this.numChannels = 2;
this.port.onmessage = (e) => {
if (e.data === 'start') {
this.recording = true;
this._reset(this.numChannels);
} else if (e.data === 'stop') {
this.recording = false;
this._flush();
this.port.postMessage({ type: 'stopped' });
}
};
}
_reset(n) {
this.numChannels = n;
this.buffers = [];
for (let c = 0; c < n; c++) this.buffers.push(new Float32Array(this.chunkSize));
this.writeIndex = 0;
}
_flush() {
if (this.buffers && this.writeIndex > 0) {
const buffers = this.buffers.map(b => b.slice(0, this.writeIndex));
this.port.postMessage({ type: 'chunk', buffers }, buffers.map(b => b.buffer));
}
this.writeIndex = 0;
}
process(inputs) {
const input = inputs[0];
if (!this.recording || !input || !input.length || !input[0] || !input[0].length) return true;
if (!this.buffers || this.numChannels !== input.length) {
this._flush();
this._reset(input.length);
}
const frames = input[0].length;
for (let i = 0; i < frames; i++) {
for (let c = 0; c < this.numChannels; c++) this.buffers[c][this.writeIndex] = input[c][i];
this.writeIndex++;
if (this.writeIndex >= this.chunkSize) {
this._flush();
this._reset(this.numChannels);
}
}
return true;
}
}
registerProcessor('recorder-processor', RecorderProcessor);
`;
const workletBlobUrl = URL.createObjectURL(new Blob([workletCode], { type: 'application/javascript' }));
async function instrumentContext(ctx) {
const cs = { ready: false, tapNode: null, pendingNodes: new Map(), connectedNodes: new Set() };
ctxStates.set(ctx, cs);
try {
await ctx.audioWorklet.addModule(workletBlobUrl);
const channels = ctx.destination.channelCount || 2;
const processor = new AudioWorkletNode(ctx, 'recorder-processor', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [channels],
});
const mute = ctx.createGain();
mute.gain.value = 0;
processor.connect(mute);
mute.connect(ctx.destination);
processor.onprocessorerror = (e) => console.error('[waRecorder] processor error:', e);
processor.port.onmessage = (e) => {
if (e.data && e.data.type === 'chunk') {
state.sampleRate = ctx.sampleRate;
state.numChannels = e.data.buffers.length;
state.buffers.push(e.data.buffers);
} else if (e.data && e.data.type === 'stopped') {
state.pendingStops = Math.max(0, state.pendingStops - 1);
}
};
cs.tapNode = processor;
cs.ready = true;
for (const [n, args] of cs.pendingNodes) connectToTap(cs, n, args);
cs.pendingNodes.clear();
if (state.recording) processor.port.postMessage('start');
} catch (err) {
console.error('[waRecorder] Failed to initialize AudioWorklet:', err);
}
}
function connectToTap(cs, node, args = []) {
if (!cs.ready || !cs.tapNode || node === cs.tapNode || cs.connectedNodes.has(node)) return;
originalConnect.call(node, cs.tapNode, ...args);
cs.connectedNodes.add(node);
}
const originalConnect = AudioNode.prototype.connect;
AudioNode.prototype.connect = function (destination, ...args) {
const result = originalConnect.call(this, destination, ...args);
try {
if (destination instanceof AudioDestinationNode) {
const ctx = destination.context;
let cs = ctxStates.get(ctx);
if (!cs) {
instrumentContext(ctx);
cs = ctxStates.get(ctx);
}
if (cs.ready) connectToTap(cs, this, args);
else cs.pendingNodes.set(this, args);
}
} catch (err) {
console.error('[waRecorder] tap error:', err);
}
return result;
};
function encodeWAV(buffers, sampleRate, numChannels) {
let totalLength = 0;
for (const b of buffers) totalLength += b[0].length;
const channelData = [];
for (let c = 0; c < numChannels; c++) channelData.push(new Float32Array(totalLength));
let offset = 0;
for (const block of buffers) {
for (let c = 0; c < numChannels; c++) {
const sourceChannel = block[c] || block[0];
if (sourceChannel) channelData[c].set(sourceChannel, offset);
}
offset += block[0].length;
}
const interleaved = new Float32Array(totalLength * numChannels);
for (let i = 0; i < totalLength; i++) {
for (let c = 0; c < numChannels; c++) interleaved[i * numChannels + c] = channelData[c][i];
}
const bytesPerSample = 2;
const blockAlign = numChannels * bytesPerSample;
const dataSize = interleaved.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
const writeStr = (off, str) => { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)); };
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bytesPerSample * 8, true);
writeStr(36, 'data');
view.setUint32(40, dataSize, true);
let idx = 44;
for (let i = 0; i < interleaved.length; i++, idx += 2) {
const s = Math.max(-1, Math.min(1, interleaved[i]));
view.setInt16(idx, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return new Blob([view], { type: 'audio/wav' });
}
globalThis.startRecord = function () {
state.buffers = [];
state.pendingStops = 0;
state.recording = true;
for (const cs of ctxStates.values()) if (cs.ready) cs.tapNode.port.postMessage('start');
console.log('[waRecorder] Recording started.');
};
globalThis.stopRecord = function () {
state.recording = false;
state.pendingStops = 0;
for (const cs of ctxStates.values()) {
if (cs.ready) {
state.pendingStops++;
cs.tapNode.port.postMessage('stop');
}
}
console.log('[waRecorder] Recording stopped.', state.buffers.length, 'chunks captured.');
};
globalThis.saveRecord = function saveRecord(fileName = 'recording.wav') {
if (state.pendingStops > 0) {
setTimeout(() => saveRecord(fileName), 50);
return;
}
if (!state.buffers.length) {
console.warn('[waRecorder] No audio recorded yet. Install this snippet before starting Strudel playback.');
return;
}
const blob = encodeWAV(state.buffers, state.sampleRate, state.numChannels);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName.endsWith('.wav') ? fileName : `${fileName}.wav`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
console.log('[waRecorder] Saved as', a.download);
};
window.__waRecorder = true;
console.log('[waRecorder] Installed. Use startRecord(), stopRecord(), saveRecord("file.wav").');
})();
@marcoonroad

Copy link
Copy Markdown
Author
  • inspect_console_webaudio_recorder.js -> Graphical Main-thread Recorder (prone to clicks)
  • webaudio_recorder_using_worklet.js -> Background thread Recorder (better quality)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment