add lo-fi Opus effect via WebCodecs#44
add lo-fi Opus effect via WebCodecs#44joeshub wants to merge 1 commit intoclaude/save-track-to-file-GAtTXfrom
Conversation
Adds a bitrate dropdown next to the SAVE button. Selecting a non-zero value runs the rendered AudioBuffer through AudioEncoder (Opus) at the chosen bitrate and immediately back through AudioDecoder. The decoded PCM is then written to WAV and downloaded, so the saved file audibly contains Opus codec artifacts (more pronounced at lower bitrates).
There was a problem hiding this comment.
Pull request overview
Adds an optional “lo‑fi” export path by round‑tripping the rendered AudioBuffer through Opus using WebCodecs before writing the final WAV.
Changes:
- Added
applyOpusLofi()(Opus encode → decode) plus a WebCodecs feature-detect helper. - Refactored WAV export to separate “render sequence” (returns
AudioBuffer) from “buffer → WAV blob”. - Added a bitrate dropdown in the toolbar and adjusted styling to support lo‑fi export.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/utils/lofiCodec.js | Implements WebCodecs Opus encode/decode pipeline and feature detection. |
| src/utils/exportWav.js | Refactors export flow to expose renderSequence() and audioBufferToWavBlob(). |
| src/components/Toolbar.jsx | Adds lo‑fi bitrate selection and routes SAVE through Opus when enabled. |
| src/components/Toolbar.css | Styles the new lo‑fi dropdown and tweaks spacing around SAVE. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| disabled={!isWebCodecsSupported} | ||
| aria-label="Lo-fi bitrate" | ||
| title={ | ||
| isWebCodecsSupported | ||
| ? "Run the saved track through Opus at this bitrate" | ||
| : "WebCodecs not supported in this browser" | ||
| } |
There was a problem hiding this comment.
The lo‑fi dropdown is enabled solely based on WebCodecs constructors existing. Browsers can expose AudioEncoder/AudioDecoder but still not support the opus codec, which will cause SAVE to throw from applyOpusLofi. Consider feature-detecting Opus support (e.g., AudioEncoder.isConfigSupported({ codec: 'opus', sampleRate: 48000, numberOfChannels: 2, bitrate: ... })) and disabling the dropdown (or unsupported options) when Opus isn’t supported.
| const frames = audioData.numberOfFrames | ||
| for (let c = 0; c < audioData.numberOfChannels; c++) { | ||
| const chunk = new Float32Array(frames) | ||
| audioData.copyTo(chunk, { planeIndex: c, format: 'f32-planar' }) | ||
| decodedChunks[c].push(chunk) | ||
| } | ||
| totalDecodedFrames += frames | ||
| audioData.close() | ||
| }, | ||
| error: (e) => { pipelineError = e }, | ||
| }) | ||
| decoder.configure({ codec: 'opus', sampleRate, numberOfChannels }) | ||
|
|
||
| const encoder = new AudioEncoder({ | ||
| output: (chunk) => decoder.decode(chunk), | ||
| error: (e) => { pipelineError = e }, | ||
| }) | ||
| encoder.configure(encoderConfig) | ||
|
|
||
| // Feed the rendered buffer into the encoder in 20ms slices. WebCodecs is | ||
| // happy with any size, but matching Opus' native frame size keeps things | ||
| // tidy and timestamps clean. | ||
| const framesPerChunk = Math.floor(sampleRate * 0.02) | ||
| const totalFrames = audioBuffer.length | ||
| const channelData = [] | ||
| for (let c = 0; c < numberOfChannels; c++) channelData.push(audioBuffer.getChannelData(c)) | ||
|
|
||
| for (let offset = 0; offset < totalFrames; offset += framesPerChunk) { | ||
| const frames = Math.min(framesPerChunk, totalFrames - offset) | ||
| // f32-planar = each channel laid out contiguously, back-to-back. | ||
| const planar = new Float32Array(frames * numberOfChannels) | ||
| for (let c = 0; c < numberOfChannels; c++) { | ||
| planar.set(channelData[c].subarray(offset, offset + frames), c * frames) | ||
| } | ||
| // Timestamps are in microseconds. | ||
| const timestamp = Math.round((offset / sampleRate) * 1_000_000) | ||
| const data = new AudioData({ | ||
| format: 'f32-planar', | ||
| sampleRate, | ||
| numberOfFrames: frames, | ||
| numberOfChannels, | ||
| timestamp, | ||
| data: planar, | ||
| }) | ||
| encoder.encode(data) | ||
| data.close() | ||
| } | ||
|
|
||
| await encoder.flush() | ||
| encoder.close() | ||
| await decoder.flush() | ||
| decoder.close() | ||
|
|
There was a problem hiding this comment.
applyOpusLofi creates an AudioEncoder/AudioDecoder pair but doesn’t ensure they’re closed if an exception is thrown during encode()/flush() or if decoder.decode() throws from the encoder output callback. Wrap the pipeline in try/finally so both encoder and decoder are reliably close()d (and avoid leaking WebCodecs resources on failure).
| const frames = audioData.numberOfFrames | |
| for (let c = 0; c < audioData.numberOfChannels; c++) { | |
| const chunk = new Float32Array(frames) | |
| audioData.copyTo(chunk, { planeIndex: c, format: 'f32-planar' }) | |
| decodedChunks[c].push(chunk) | |
| } | |
| totalDecodedFrames += frames | |
| audioData.close() | |
| }, | |
| error: (e) => { pipelineError = e }, | |
| }) | |
| decoder.configure({ codec: 'opus', sampleRate, numberOfChannels }) | |
| const encoder = new AudioEncoder({ | |
| output: (chunk) => decoder.decode(chunk), | |
| error: (e) => { pipelineError = e }, | |
| }) | |
| encoder.configure(encoderConfig) | |
| // Feed the rendered buffer into the encoder in 20ms slices. WebCodecs is | |
| // happy with any size, but matching Opus' native frame size keeps things | |
| // tidy and timestamps clean. | |
| const framesPerChunk = Math.floor(sampleRate * 0.02) | |
| const totalFrames = audioBuffer.length | |
| const channelData = [] | |
| for (let c = 0; c < numberOfChannels; c++) channelData.push(audioBuffer.getChannelData(c)) | |
| for (let offset = 0; offset < totalFrames; offset += framesPerChunk) { | |
| const frames = Math.min(framesPerChunk, totalFrames - offset) | |
| // f32-planar = each channel laid out contiguously, back-to-back. | |
| const planar = new Float32Array(frames * numberOfChannels) | |
| for (let c = 0; c < numberOfChannels; c++) { | |
| planar.set(channelData[c].subarray(offset, offset + frames), c * frames) | |
| } | |
| // Timestamps are in microseconds. | |
| const timestamp = Math.round((offset / sampleRate) * 1_000_000) | |
| const data = new AudioData({ | |
| format: 'f32-planar', | |
| sampleRate, | |
| numberOfFrames: frames, | |
| numberOfChannels, | |
| timestamp, | |
| data: planar, | |
| }) | |
| encoder.encode(data) | |
| data.close() | |
| } | |
| await encoder.flush() | |
| encoder.close() | |
| await decoder.flush() | |
| decoder.close() | |
| try { | |
| const frames = audioData.numberOfFrames | |
| for (let c = 0; c < audioData.numberOfChannels; c++) { | |
| const chunk = new Float32Array(frames) | |
| audioData.copyTo(chunk, { planeIndex: c, format: 'f32-planar' }) | |
| decodedChunks[c].push(chunk) | |
| } | |
| totalDecodedFrames += frames | |
| } finally { | |
| audioData.close() | |
| } | |
| }, | |
| error: (e) => { pipelineError = e }, | |
| }) | |
| const encoder = new AudioEncoder({ | |
| output: (chunk) => decoder.decode(chunk), | |
| error: (e) => { pipelineError = e }, | |
| }) | |
| try { | |
| decoder.configure({ codec: 'opus', sampleRate, numberOfChannels }) | |
| encoder.configure(encoderConfig) | |
| // Feed the rendered buffer into the encoder in 20ms slices. WebCodecs is | |
| // happy with any size, but matching Opus' native frame size keeps things | |
| // tidy and timestamps clean. | |
| const framesPerChunk = Math.floor(sampleRate * 0.02) | |
| const totalFrames = audioBuffer.length | |
| const channelData = [] | |
| for (let c = 0; c < numberOfChannels; c++) channelData.push(audioBuffer.getChannelData(c)) | |
| for (let offset = 0; offset < totalFrames; offset += framesPerChunk) { | |
| const frames = Math.min(framesPerChunk, totalFrames - offset) | |
| // f32-planar = each channel laid out contiguously, back-to-back. | |
| const planar = new Float32Array(frames * numberOfChannels) | |
| for (let c = 0; c < numberOfChannels; c++) { | |
| planar.set(channelData[c].subarray(offset, offset + frames), c * frames) | |
| } | |
| // Timestamps are in microseconds. | |
| const timestamp = Math.round((offset / sampleRate) * 1_000_000) | |
| const data = new AudioData({ | |
| format: 'f32-planar', | |
| sampleRate, | |
| numberOfFrames: frames, | |
| numberOfChannels, | |
| timestamp, | |
| data: planar, | |
| }) | |
| try { | |
| encoder.encode(data) | |
| } finally { | |
| data.close() | |
| } | |
| } | |
| await encoder.flush() | |
| await decoder.flush() | |
| } finally { | |
| if (encoder.state !== 'closed') { | |
| encoder.close() | |
| } | |
| if (decoder.state !== 'closed') { | |
| decoder.close() | |
| } | |
| } |
Stacked on top of #43.
Adds a bitrate dropdown next to the SAVE button. Selecting a non-zero
value runs the rendered AudioBuffer through
AudioEncoder(Opus) atthe chosen bitrate and immediately back through
AudioDecoder. Thedecoded PCM is then written to WAV and downloaded, so the saved file
audibly contains Opus codec artifacts (more pronounced at lower
bitrates).
WebCodecs surface area touched
AudioEncoder.isConfigSupportedfor capability checkAudioEncoderconfigure / encode / flush / closeAudioDataconstructor withf32-planarformat + microsecond timestampsEncodedAudioChunk(passed straight from encoder output to decoder input)AudioDecoderconfigure / decode / flush / close, withcopyTo({ planeIndex })to pull each channel outNotes
isWebCodecsSupportedfeature-detects and disables the dropdown in unsupported browsers.Test plan
AudioEncoder(e.g. older Safari).Generated by Claude Code