Skip to content

Commit f1271df

Browse files
committed
add save-to-WAV button using OfflineAudioContext
Renders the current sequence with OfflineAudioContext, encodes the result as a 16-bit PCM WAV, and downloads it. WAV is simpler than WebCodecs for uncompressed export and has no dependencies.
1 parent 7f160f5 commit f1271df

4 files changed

Lines changed: 137 additions & 5 deletions

File tree

src/Board.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ function Board() {
4747
isSequencePlaying,
4848
startTime,
4949
BPM,
50+
totalSteps,
51+
totalBeats,
5052
};
5153

5254
const playHeadProps = {

src/components/Toolbar.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@
2323
cursor: pointer;
2424
}
2525

26+
.button_save {
27+
margin-left: var(--spacer);
28+
padding: 0 10px;
29+
cursor: pointer;
30+
letter-spacing: 0.05em;
31+
}
32+
33+
.button_save:disabled {
34+
cursor: progress;
35+
opacity: 0.6;
36+
}
37+
2638
.button_icon_path {
2739
fill: var(--color-fg);
2840
}

src/components/Toolbar.jsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useContext, memo } from "react";
1+
import { useContext, memo, useState } from "react";
22
import { sequenceList } from "../constants/config";
33
import { Context } from "../Provider";
4+
import { renderSequenceToWav, downloadBlob } from "../utils/exportWav";
45
import "./Toolbar.css";
56

67
const ToolBar = ({
@@ -10,11 +11,12 @@ const ToolBar = ({
1011
isSequencePlaying,
1112
startTime,
1213
BPM,
14+
totalSteps,
15+
totalBeats,
1316
}) => {
14-
const {
15-
sequence: { id: selectedSequenceID },
16-
selectSequence,
17-
} = useContext(Context);
17+
const { sequence, selectSequence } = useContext(Context);
18+
const { id: selectedSequenceID, title: sequenceTitle } = sequence;
19+
const [isSaving, setIsSaving] = useState(false);
1820

1921
function togglePlayback() {
2022
if (isSequencePlaying) {
@@ -34,6 +36,22 @@ const ToolBar = ({
3436
setBPM(e.target.value);
3537
}
3638

39+
async function saveTrack() {
40+
setIsSaving(true);
41+
try {
42+
const blob = await renderSequenceToWav({
43+
sequence,
44+
BPM: Number(BPM),
45+
totalSteps,
46+
totalBeats,
47+
});
48+
const safeName = String(sequenceTitle).replace(/\s+/g, "_").toLowerCase();
49+
downloadBlob(blob, `${safeName}_${BPM}bpm.wav`);
50+
} finally {
51+
setIsSaving(false);
52+
}
53+
}
54+
3755
return (
3856
<nav className="toolbar">
3957
<button
@@ -85,6 +103,15 @@ const ToolBar = ({
85103
<label className="label_bpm" htmlFor="bpm">
86104
BPM
87105
</label>
106+
<button
107+
className="form_element button_save"
108+
onClick={saveTrack}
109+
disabled={isSaving}
110+
aria-label="Save track to WAV file"
111+
title="Save track to WAV file"
112+
>
113+
{isSaving ? "..." : "SAVE"}
114+
</button>
88115
<select
89116
className="form_element select_sequence"
90117
value={selectedSequenceID}

src/utils/exportWav.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { soundFiles } from '../constants/config'
2+
3+
export async function renderSequenceToWav({ sequence, BPM, totalSteps, totalBeats }) {
4+
const sampleRate = 44100
5+
const numChannels = 2
6+
const tailSeconds = 1
7+
const durationSeconds = (60 / BPM) * totalBeats + tailSeconds
8+
const secondsPerStep = ((60 / BPM) * totalBeats) / totalSteps
9+
10+
const offlineCtx = new OfflineAudioContext(
11+
numChannels,
12+
Math.ceil(durationSeconds * sampleRate),
13+
sampleRate
14+
)
15+
16+
const uniquePaths = [...new Set(sequence.trackList.map(t => soundFiles[t.soundFile]))]
17+
const decoded = {}
18+
await Promise.all(
19+
uniquePaths.map(async (path) => {
20+
const res = await fetch(path)
21+
const arrayBuffer = await res.arrayBuffer()
22+
decoded[path] = await offlineCtx.decodeAudioData(arrayBuffer)
23+
})
24+
)
25+
26+
for (const track of sequence.trackList) {
27+
const buffer = decoded[soundFiles[track.soundFile]]
28+
for (const stepID of track.onNotes) {
29+
const src = offlineCtx.createBufferSource()
30+
src.buffer = buffer
31+
src.connect(offlineCtx.destination)
32+
src.start(stepID * secondsPerStep)
33+
}
34+
}
35+
36+
const rendered = await offlineCtx.startRendering()
37+
return audioBufferToWavBlob(rendered)
38+
}
39+
40+
function audioBufferToWavBlob(buffer) {
41+
const numChannels = buffer.numberOfChannels
42+
const sampleRate = buffer.sampleRate
43+
const bytesPerSample = 2
44+
const dataSize = buffer.length * numChannels * bytesPerSample
45+
const ab = new ArrayBuffer(44 + dataSize)
46+
const view = new DataView(ab)
47+
48+
let offset = 0
49+
const writeStr = (s) => {
50+
for (let i = 0; i < s.length; i++) view.setUint8(offset++, s.charCodeAt(i))
51+
}
52+
const writeU32 = (v) => { view.setUint32(offset, v, true); offset += 4 }
53+
const writeU16 = (v) => { view.setUint16(offset, v, true); offset += 2 }
54+
55+
writeStr('RIFF')
56+
writeU32(36 + dataSize)
57+
writeStr('WAVE')
58+
writeStr('fmt ')
59+
writeU32(16)
60+
writeU16(1)
61+
writeU16(numChannels)
62+
writeU32(sampleRate)
63+
writeU32(sampleRate * numChannels * bytesPerSample)
64+
writeU16(numChannels * bytesPerSample)
65+
writeU16(16)
66+
writeStr('data')
67+
writeU32(dataSize)
68+
69+
const channels = []
70+
for (let c = 0; c < numChannels; c++) channels.push(buffer.getChannelData(c))
71+
for (let i = 0; i < buffer.length; i++) {
72+
for (let c = 0; c < numChannels; c++) {
73+
const s = Math.max(-1, Math.min(1, channels[c][i]))
74+
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true)
75+
offset += 2
76+
}
77+
}
78+
79+
return new Blob([ab], { type: 'audio/wav' })
80+
}
81+
82+
export function downloadBlob(blob, filename) {
83+
const url = URL.createObjectURL(blob)
84+
const a = document.createElement('a')
85+
a.href = url
86+
a.download = filename
87+
document.body.appendChild(a)
88+
a.click()
89+
a.remove()
90+
URL.revokeObjectURL(url)
91+
}

0 commit comments

Comments
 (0)