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
2 changes: 2 additions & 0 deletions src/Board.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ function Board() {
isSequencePlaying,
startTime,
BPM,
totalSteps,
totalBeats,
};

const playHeadProps = {
Expand Down
12 changes: 12 additions & 0 deletions src/components/Toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@
cursor: pointer;
}

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

.button_save:disabled {
cursor: progress;
opacity: 0.6;
}

.button_icon_path {
fill: var(--color-fg);
}
Expand Down
37 changes: 32 additions & 5 deletions src/components/Toolbar.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useContext, memo } from "react";
import { useContext, memo, useState } from "react";
import { sequenceList } from "../constants/config";
import { Context } from "../Provider";
import { renderSequenceToWav, downloadBlob } from "../utils/exportWav";
import "./Toolbar.css";

const ToolBar = ({
Expand All @@ -10,11 +11,12 @@ const ToolBar = ({
isSequencePlaying,
startTime,
BPM,
totalSteps,
totalBeats,
}) => {
const {
sequence: { id: selectedSequenceID },
selectSequence,
} = useContext(Context);
const { sequence, selectSequence } = useContext(Context);
const { id: selectedSequenceID, title: sequenceTitle } = sequence;
const [isSaving, setIsSaving] = useState(false);

function togglePlayback() {
if (isSequencePlaying) {
Expand All @@ -34,6 +36,22 @@ const ToolBar = ({
setBPM(e.target.value);
}

async function saveTrack() {
setIsSaving(true);
try {
const blob = await renderSequenceToWav({
sequence,
BPM: Number(BPM),
totalSteps,
totalBeats,
});
const safeName = String(sequenceTitle).replace(/\s+/g, "_").toLowerCase();
downloadBlob(blob, `${safeName}_${BPM}bpm.wav`);
} finally {
setIsSaving(false);
}
}

return (
<nav className="toolbar">
<button
Expand Down Expand Up @@ -85,6 +103,15 @@ const ToolBar = ({
<label className="label_bpm" htmlFor="bpm">
BPM
</label>
<button
className="form_element button_save"
onClick={saveTrack}
disabled={isSaving}
aria-label="Save track to WAV file"
title="Save track to WAV file"
>
{isSaving ? "..." : "SAVE"}
</button>
<select
className="form_element select_sequence"
value={selectedSequenceID}
Expand Down
91 changes: 91 additions & 0 deletions src/utils/exportWav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { soundFiles } from '../constants/config'

export async function renderSequenceToWav({ sequence, BPM, totalSteps, totalBeats }) {
const sampleRate = 44100
const numChannels = 2
const tailSeconds = 1
const durationSeconds = (60 / BPM) * totalBeats + tailSeconds
const secondsPerStep = ((60 / BPM) * totalBeats) / totalSteps

const offlineCtx = new OfflineAudioContext(
numChannels,
Math.ceil(durationSeconds * sampleRate),
sampleRate
)
Comment on lines +10 to +14
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.

OfflineAudioContext is referenced as a global constructor here, but the codebase already uses vendor-prefixed AudioContext constructors for Safari compatibility. Consider selecting the constructor via window.OfflineAudioContext || window.webkitOfflineAudioContext (and failing with a clear error if unavailable) so export works in the same set of supported browsers.

Copilot uses AI. Check for mistakes.

const uniquePaths = [...new Set(sequence.trackList.map(t => soundFiles[t.soundFile]))]
const decoded = {}
await Promise.all(
uniquePaths.map(async (path) => {
const res = await fetch(path)
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.

fetch(path) isn't checked for res.ok before decoding. If a sound file is missing or returns a non-audio response (404 HTML, etc.), decodeAudioData will fail with a less clear error. Consider throwing a descriptive error when !res.ok (including path and res.status).

Suggested change
const res = await fetch(path)
const res = await fetch(path)
if (!res.ok) {
throw new Error(`Failed to fetch audio file "${path}": ${res.status} ${res.statusText}`)
}

Copilot uses AI. Check for mistakes.
const arrayBuffer = await res.arrayBuffer()
decoded[path] = await offlineCtx.decodeAudioData(arrayBuffer)
})
Comment on lines +19 to +23
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.

This uses the promise-returning form of decodeAudioData, but elsewhere in the codebase audio decoding is wrapped with the callback form (likely for Safari/WebKit compatibility). To avoid export breaking on browsers where decodeAudioData doesn't return a promise, use a small wrapper (or reuse the existing pattern) that supports the callback signature.

Copilot uses AI. Check for mistakes.
)

for (const track of sequence.trackList) {
const buffer = decoded[soundFiles[track.soundFile]]
for (const stepID of track.onNotes) {
const src = offlineCtx.createBufferSource()
src.buffer = buffer
src.connect(offlineCtx.destination)
src.start(stepID * secondsPerStep)
}
}

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

function audioBufferToWavBlob(buffer) {
const numChannels = buffer.numberOfChannels
const sampleRate = buffer.sampleRate
const bytesPerSample = 2
const dataSize = buffer.length * numChannels * bytesPerSample
const ab = new ArrayBuffer(44 + dataSize)
const view = new DataView(ab)

let offset = 0
const writeStr = (s) => {
for (let i = 0; i < s.length; i++) view.setUint8(offset++, s.charCodeAt(i))
}
const writeU32 = (v) => { view.setUint32(offset, v, true); offset += 4 }
const writeU16 = (v) => { view.setUint16(offset, v, true); offset += 2 }

writeStr('RIFF')
writeU32(36 + dataSize)
writeStr('WAVE')
writeStr('fmt ')
writeU32(16)
writeU16(1)
writeU16(numChannels)
writeU32(sampleRate)
writeU32(sampleRate * numChannels * bytesPerSample)
writeU16(numChannels * bytesPerSample)
writeU16(16)
writeStr('data')
writeU32(dataSize)

const channels = []
for (let c = 0; c < numChannels; c++) channels.push(buffer.getChannelData(c))
for (let i = 0; i < buffer.length; i++) {
for (let c = 0; c < numChannels; c++) {
const s = Math.max(-1, Math.min(1, channels[c][i]))
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true)
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.

DataView#setInt16 will coerce the float value here, which can truncate inconsistently across engines. Convert to a proper int (e.g., Math.round(...)) before writing so PCM values are stable and match expected WAV encoding.

Suggested change
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true)
const pcmSample = Math.round(s < 0 ? s * 0x8000 : s * 0x7fff)
view.setInt16(offset, pcmSample, true)

Copilot uses AI. Check for mistakes.
offset += 2
}
}

return new Blob([ab], { type: 'audio/wav' })
}

export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}