Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/components/Toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,26 @@
}

.button_save {
margin-left: var(--spacer);
margin-left: calc(var(--spacer) / 2);
padding: 0 10px;
cursor: pointer;
letter-spacing: 0.05em;
}

.select_lofi {
margin-left: var(--spacer);
padding: 0 10px;
text-transform: uppercase;
border-radius: 0;
-webkit-appearance: none;
-webkit-border-radius: 0px;
}

.select_lofi:disabled {
opacity: 0.5;
cursor: not-allowed;
}

.button_save:disabled {
cursor: progress;
opacity: 0.6;
Expand Down
49 changes: 46 additions & 3 deletions src/components/Toolbar.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { useContext, memo, useState } from "react";
import { sequenceList } from "../constants/config";
import { Context } from "../Provider";
import { renderSequenceToWav, downloadBlob } from "../utils/exportWav";
import {
renderSequence,
audioBufferToWavBlob,
downloadBlob,
} from "../utils/exportWav";
import { applyOpusLofi, isWebCodecsSupported } from "../utils/lofiCodec";
import "./Toolbar.css";

const LOFI_OPTIONS = [
{ value: 0, label: "Hi-Fi" },
{ value: 64000, label: "64k Opus" },
{ value: 32000, label: "32k Opus" },
{ value: 16000, label: "16k Opus" },
{ value: 6000, label: "6k Opus" },
];

const ToolBar = ({
setStartTime,
setPastLapse,
Expand All @@ -17,6 +30,7 @@ const ToolBar = ({
const { sequence, selectSequence } = useContext(Context);
const { id: selectedSequenceID, title: sequenceTitle } = sequence;
const [isSaving, setIsSaving] = useState(false);
const [lofiBitrate, setLofiBitrate] = useState(0);

function togglePlayback() {
if (isSequencePlaying) {
Expand All @@ -39,14 +53,25 @@ const ToolBar = ({
async function saveTrack() {
setIsSaving(true);
try {
const blob = await renderSequenceToWav({
// Opus only natively supports 8/12/16/24/48kHz; render at 48k when lo-fi
// is enabled to avoid an extra resampling step before the encoder.
const sampleRate = lofiBitrate ? 48000 : 44100;
const rendered = await renderSequence({
sequence,
BPM: Number(BPM),
totalSteps,
totalBeats,
sampleRate,
});
const finalBuffer = lofiBitrate
? await applyOpusLofi(rendered, lofiBitrate)
: rendered;
const safeName = String(sequenceTitle).replace(/\s+/g, "_").toLowerCase();
downloadBlob(blob, `${safeName}_${BPM}bpm.wav`);
const suffix = lofiBitrate ? `_opus${lofiBitrate / 1000}k` : "";
downloadBlob(
audioBufferToWavBlob(finalBuffer),
`${safeName}_${BPM}bpm${suffix}.wav`,
);
} finally {
setIsSaving(false);
}
Expand Down Expand Up @@ -103,6 +128,24 @@ const ToolBar = ({
<label className="label_bpm" htmlFor="bpm">
BPM
</label>
<select
className="form_element select_lofi"
value={lofiBitrate}
onChange={(e) => setLofiBitrate(+e.target.value)}
disabled={!isWebCodecsSupported}
aria-label="Lo-fi bitrate"
title={
isWebCodecsSupported
? "Run the saved track through Opus at this bitrate"
: "WebCodecs not supported in this browser"
}
Comment on lines +135 to +141
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.
>
{LOFI_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
className="form_element button_save"
onClick={saveTrack}
Expand Down
8 changes: 3 additions & 5 deletions src/utils/exportWav.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { soundFiles } from '../constants/config'

export async function renderSequenceToWav({ sequence, BPM, totalSteps, totalBeats }) {
const sampleRate = 44100
export async function renderSequence({ sequence, BPM, totalSteps, totalBeats, sampleRate = 44100 }) {
const numChannels = 2
const tailSeconds = 1
const durationSeconds = (60 / BPM) * totalBeats + tailSeconds
Expand Down Expand Up @@ -33,11 +32,10 @@ export async function renderSequenceToWav({ sequence, BPM, totalSteps, totalBeat
}
}

const rendered = await offlineCtx.startRendering()
return audioBufferToWavBlob(rendered)
return await offlineCtx.startRendering()
}

function audioBufferToWavBlob(buffer) {
export function audioBufferToWavBlob(buffer) {
const numChannels = buffer.numberOfChannels
const sampleRate = buffer.sampleRate
const bytesPerSample = 2
Expand Down
95 changes: 95 additions & 0 deletions src/utils/lofiCodec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
export const isWebCodecsSupported =
typeof globalThis.AudioEncoder !== 'undefined' &&
typeof globalThis.AudioDecoder !== 'undefined' &&
typeof globalThis.AudioData !== 'undefined'

// Push the rendered AudioBuffer through Opus at the given bitrate, then decode
// it back to PCM. Lower bitrate = more audible codec artifacts.
export async function applyOpusLofi(audioBuffer, bitrate) {
if (!isWebCodecsSupported) {
throw new Error('WebCodecs AudioEncoder/AudioDecoder not available in this browser')
}

const sampleRate = audioBuffer.sampleRate
const numberOfChannels = audioBuffer.numberOfChannels

const encoderConfig = { codec: 'opus', sampleRate, numberOfChannels, bitrate }
const support = await AudioEncoder.isConfigSupported(encoderConfig)
if (!support.supported) {
throw new Error(`Opus config not supported: ${JSON.stringify(encoderConfig)}`)
}

// Per-channel buffers we'll fill from decoded AudioData frames.
const decodedChunks = Array.from({ length: numberOfChannels }, () => [])
let totalDecodedFrames = 0
let pipelineError = null

const decoder = new AudioDecoder({
output: (audioData) => {
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()

Comment on lines +29 to +81
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.
if (pipelineError) throw pipelineError

const result = new AudioBuffer({ sampleRate, length: totalDecodedFrames, numberOfChannels })
for (let c = 0; c < numberOfChannels; c++) {
const merged = new Float32Array(totalDecodedFrames)
let p = 0
for (const arr of decodedChunks[c]) {
merged.set(arr, p)
p += arr.length
}
result.copyToChannel(merged, c, 0)
}
return result
}