Skip to content

add lo-fi Opus effect via WebCodecs#44

Open
joeshub wants to merge 1 commit intoclaude/save-track-to-file-GAtTXfrom
claude/lofi-opus-effect
Open

add lo-fi Opus effect via WebCodecs#44
joeshub wants to merge 1 commit intoclaude/save-track-to-file-GAtTXfrom
claude/lofi-opus-effect

Conversation

@joeshub
Copy link
Copy Markdown
Collaborator

@joeshub joeshub commented Apr 30, 2026

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) 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).

WebCodecs surface area touched

  • AudioEncoder.isConfigSupported for capability check
  • AudioEncoder configure / encode / flush / close
  • AudioData constructor with f32-planar format + microsecond timestamps
  • EncodedAudioChunk (passed straight from encoder output to decoder input)
  • AudioDecoder configure / decode / flush / close, with copyTo({ planeIndex }) to pull each channel out

Notes

  • Renders at 48 kHz when lo-fi is enabled (Opus' native rate); 44.1 kHz otherwise.
  • 20 ms encode chunks (960 frames at 48 kHz) match Opus' native frame size.
  • isWebCodecsSupported feature-detects and disables the dropdown in unsupported browsers.

Test plan

  • In Chromium: select 6k Opus, hit SAVE, confirm downloaded WAV plays back with audible codec artifacts.
  • Hi-Fi option still produces a clean WAV (regression check on add save-to-WAV button using OfflineAudioContext #43).
  • Dropdown is disabled in a browser without AudioEncoder (e.g. older Safari).

Generated by Claude Code

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).
Copilot AI review requested due to automatic review settings April 30, 2026 22:44
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +135 to +141
disabled={!isWebCodecsSupported}
aria-label="Lo-fi bitrate"
title={
isWebCodecsSupported
? "Run the saved track through Opus at this bitrate"
: "WebCodecs not supported in this browser"
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/utils/lofiCodec.js
Comment on lines +29 to +81
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()

Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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()
}
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants