Last active
June 21, 2026 23:35
-
-
Save marcoonroad/a0acdd5fa329d6a9d0f2ea46428ec1cb to your computer and use it in GitHub Desktop.
Chrome (inspect -> console) WebAudio recorder script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (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").'); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (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").'); | |
| })(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
inspect_console_webaudio_recorder.js-> Graphical Main-thread Recorder (prone to clicks)webaudio_recorder_using_worklet.js-> Background thread Recorder (better quality)