Skip to content

Commit 4e518f4

Browse files
authored
Merge pull request #58 from boostcampwm-snu-2026-1/feat/50-arrangement-wav-export
feat: export arrangement as wav
2 parents b4aea8f + bb39e29 commit 4e518f4

15 files changed

Lines changed: 1066 additions & 173 deletions

PLANS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ M1 should include:
2929

3030
Use this section as the current execution order for agent work. Feature document numbering describes the planned product sequence, but actual issue order may change as dependencies, review feedback, or implementation risks become clearer.
3131

32-
1. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md`
33-
2. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md`
32+
1. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md`
3433

3534
## Planned Milestones
3635

docs/audio-engine.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,14 @@ Arrangement WAV export should use an offline audio rendering path, not the live
8181

8282
The first export target should be WAV because the browser can produce it from PCM data without a compressed-audio encoder dependency. A predictable first format is stereo 44.1 kHz 16-bit PCM WAV unless implementation constraints justify another choice in the PR.
8383

84-
Export rendering should:
85-
86-
- Render from arrangement tick 0 through the configured arrangement length.
87-
- Use the same tick-to-audio-time conversion rules as live playback.
88-
- Include the clip types, instruments, and mixer routing available at the time of implementation.
89-
- Block with a clear error when required sample data is missing.
90-
- Avoid mutating live transport state, active source nodes, or React component state during rendering.
84+
The current first export pass:
85+
86+
- Renders from arrangement tick 0 through the configured arrangement length.
87+
- Uses the same tick-to-seconds conversion rules as live playback.
88+
- Includes arranged hybrid clip drums, `Default Synth` notes, Iowa Piano sampled notes, imported audio clips, track volume, track mute/solo, and master gain.
89+
- Crops imported audio clip playback to the placed `ClipInstance.lengthTicks`; if the source ends first, the remaining placement renders silence.
90+
- Blocks with a clear error when required imported sample data is missing.
91+
- Avoids mutating live transport state, active source nodes, or decoded runtime caches during rendering.
9192

9293
WAV encoding can be a small utility that converts rendered PCM into a Blob. MP3, FLAC, stem export, cloud export, and mastering processors are separate features.
9394

docs/features/21-arrangement-wav-export.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Feature: 21 Arrangement WAV Export
22

33
## Status
4-
Planned
4+
In Review
55

66
## Goal
77

src/app/App.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
BUNDLED_DRUM_SAMPLES,
55
createAudioEngine,
66
expandClipInstancesForPlayback,
7+
renderArrangementToWav,
78
type BundledSampleMeta,
89
type MixerLevelSnapshot,
910
type NoteLoopEvent,
@@ -142,6 +143,31 @@ function createNextHybridClip(clips: readonly Clip[]): HybridClip {
142143
});
143144
}
144145

146+
function createArrangementExportFileName(projectName: string): string {
147+
const safeProjectName =
148+
projectName
149+
.trim()
150+
.toLowerCase()
151+
.replace(/[^a-z0-9]+/gu, "-")
152+
.replace(/^-|-$/gu, "") || "mini-daw";
153+
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
154+
155+
return `${safeProjectName}-arrangement-${timestamp}.wav`;
156+
}
157+
158+
function downloadBlob(blob: Blob, fileName: string): void {
159+
const objectUrl = URL.createObjectURL(blob);
160+
const link = document.createElement("a");
161+
162+
link.href = objectUrl;
163+
link.download = fileName;
164+
link.style.display = "none";
165+
document.body.append(link);
166+
link.click();
167+
link.remove();
168+
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
169+
}
170+
145171
function getPersistenceStatusLabel(status: PersistenceStatus): string {
146172
if (status === "loading") {
147173
return "Loading project";
@@ -200,6 +226,10 @@ export function App() {
200226
const sampleMetasRef = useRef<SampleMeta[]>(sampleMetas);
201227
const [isClipImporting, setIsClipImporting] = useState(false);
202228
const [clipImportError, setClipImportError] = useState<string | null>(null);
229+
const [isArrangementExporting, setIsArrangementExporting] = useState(false);
230+
const [arrangementExportError, setArrangementExportError] = useState<
231+
string | null
232+
>(null);
203233
const [isPersistenceReady, setIsPersistenceReady] = useState(false);
204234
const [persistenceStatus, setPersistenceStatus] =
205235
useState<PersistenceStatus>("loading");
@@ -723,6 +753,43 @@ export function App() {
723753
return true;
724754
}
725755

756+
async function getImportedSampleBlobsForArrangement(
757+
instances: readonly ClipInstance[],
758+
): Promise<ReadonlyMap<string, Blob>> {
759+
const importedSampleBlobs = new Map(importedSampleBlobsRef.current);
760+
const missingClipNames: string[] = [];
761+
762+
for (const instance of instances) {
763+
const clip = clipsRef.current.find(
764+
(candidate) => candidate.id === instance.clipId,
765+
);
766+
767+
if (!clip || !isAudioClip(clip) || importedSampleBlobs.has(clip.sampleId)) {
768+
continue;
769+
}
770+
771+
const blob = await projectStore.loadImportedSampleBlob(clip.sampleId);
772+
773+
if (blob) {
774+
importedSampleBlobs.set(clip.sampleId, blob);
775+
importedSampleBlobsRef.current.set(clip.sampleId, blob);
776+
continue;
777+
}
778+
779+
missingClipNames.push(clip.name);
780+
}
781+
782+
if (missingClipNames.length > 0) {
783+
throw new Error(
784+
`Imported audio data is missing for ${missingClipNames.join(
785+
", ",
786+
)}. Re-import the file before exporting.`,
787+
);
788+
}
789+
790+
return importedSampleBlobs;
791+
}
792+
726793
async function handleAudioClipPreviewPlay() {
727794
const clip = selectedClipRef.current;
728795

@@ -1033,6 +1100,41 @@ export function App() {
10331100
}
10341101
}
10351102

1103+
async function handleArrangementWavExport() {
1104+
if (isArrangementExporting) {
1105+
return;
1106+
}
1107+
1108+
setArrangementExportError(null);
1109+
setAudioError(null);
1110+
setIsArrangementExporting(true);
1111+
1112+
try {
1113+
const importedSampleBlobs = await getImportedSampleBlobsForArrangement(
1114+
clipInstancesRef.current,
1115+
);
1116+
const exportResult = await renderArrangementToWav({
1117+
arrangementLengthBars: arrangementLengthBarsRef.current,
1118+
clipInstances: clipInstancesRef.current,
1119+
clips: clipsRef.current,
1120+
importedSampleBlobs,
1121+
masterMixerState: masterMixerStateRef.current,
1122+
tempoBpm: bpmRef.current,
1123+
trackMixerStates: trackMixerStatesRef.current,
1124+
});
1125+
1126+
downloadBlob(exportResult.blob, createArrangementExportFileName(PROJECT_NAME));
1127+
} catch (error) {
1128+
const message =
1129+
error instanceof Error ? error.message : "Arrangement WAV export failed.";
1130+
1131+
setArrangementExportError(message);
1132+
setAudioError(message);
1133+
} finally {
1134+
setIsArrangementExporting(false);
1135+
}
1136+
}
1137+
10361138
function handleClipSelect(clipId: string) {
10371139
const clip = clipsRef.current.find((candidate) => candidate.id === clipId);
10381140

@@ -1674,9 +1776,12 @@ export function App() {
16741776

16751777
<div className={styles.mainLayout}>
16761778
<ProjectSidebar
1779+
arrangementExportError={arrangementExportError}
16771780
clipImportError={clipImportError}
16781781
clips={clips}
1782+
isArrangementExporting={isArrangementExporting}
16791783
isClipImporting={isClipImporting}
1784+
onArrangementExport={handleArrangementWavExport}
16801785
onClipAdd={handleClipAdd}
16811786
onClipDelete={handleClipDelete}
16821787
onClipImport={handleAudioClipImport}

src/audio/arrangement-events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ export function expandClipInstancesForPlayback({
3535
if (isAudioClip(clip)) {
3636
if (instance.lengthTicks > 0) {
3737
sampleEvents.push({
38+
durationTicks: instance.lengthTicks,
3839
id: `${instance.id}:audio`,
3940
sampleId: clip.sampleId,
41+
sourceOffsetSeconds: instance.sourceOffsetSeconds,
4042
startTick: instance.startTick,
4143
trackId: instance.trackId,
4244
});

src/audio/audio-buffer-decoder.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
interface DecodedPcmWav {
2+
channelData: Float32Array[];
3+
sampleRate: number;
4+
}
5+
6+
export async function decodeAudioBuffer({
7+
arrayBuffer,
8+
audioContext,
9+
errorMessage,
10+
}: {
11+
arrayBuffer: ArrayBuffer;
12+
audioContext: BaseAudioContext;
13+
errorMessage: string;
14+
}): Promise<AudioBuffer> {
15+
try {
16+
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
17+
} catch (decodeError) {
18+
const decodedPcmWav = decodePcmWav(arrayBuffer);
19+
20+
if (!decodedPcmWav) {
21+
throw decodeError instanceof Error
22+
? new Error(errorMessage, { cause: decodeError })
23+
: new Error(errorMessage);
24+
}
25+
26+
const audioBuffer = audioContext.createBuffer(
27+
decodedPcmWav.channelData.length,
28+
decodedPcmWav.channelData[0]?.length ?? 0,
29+
decodedPcmWav.sampleRate,
30+
);
31+
32+
decodedPcmWav.channelData.forEach((channelData, channelIndex) => {
33+
audioBuffer.copyToChannel(new Float32Array(channelData), channelIndex);
34+
});
35+
36+
return audioBuffer;
37+
}
38+
}
39+
40+
function decodePcmWav(arrayBuffer: ArrayBuffer): DecodedPcmWav | null {
41+
const view = new DataView(arrayBuffer);
42+
43+
if (
44+
arrayBuffer.byteLength < 44 ||
45+
readAscii(view, 0, 4) !== "RIFF" ||
46+
readAscii(view, 8, 4) !== "WAVE"
47+
) {
48+
return null;
49+
}
50+
51+
let audioFormat = 0;
52+
let bitsPerSample = 0;
53+
let blockAlign = 0;
54+
let channelCount = 0;
55+
let dataOffset = 0;
56+
let dataSize = 0;
57+
let sampleRate = 0;
58+
let offset = 12;
59+
60+
while (offset + 8 <= view.byteLength) {
61+
const chunkId = readAscii(view, offset, 4);
62+
const chunkSize = view.getUint32(offset + 4, true);
63+
const chunkStart = offset + 8;
64+
65+
if (chunkStart + chunkSize > view.byteLength) {
66+
return null;
67+
}
68+
69+
if (chunkId === "fmt ") {
70+
audioFormat = view.getUint16(chunkStart, true);
71+
channelCount = view.getUint16(chunkStart + 2, true);
72+
sampleRate = view.getUint32(chunkStart + 4, true);
73+
blockAlign = view.getUint16(chunkStart + 12, true);
74+
bitsPerSample = view.getUint16(chunkStart + 14, true);
75+
}
76+
77+
if (chunkId === "data") {
78+
dataOffset = chunkStart;
79+
dataSize = chunkSize;
80+
}
81+
82+
offset = chunkStart + chunkSize + (chunkSize % 2);
83+
}
84+
85+
const isPcm = audioFormat === 1 || audioFormat === 65534;
86+
const bytesPerSample = bitsPerSample / 8;
87+
88+
if (
89+
!isPcm ||
90+
!Number.isInteger(bytesPerSample) ||
91+
![2, 3, 4].includes(bytesPerSample) ||
92+
blockAlign <= 0 ||
93+
channelCount <= 0 ||
94+
dataOffset <= 0 ||
95+
dataSize <= 0 ||
96+
sampleRate <= 0
97+
) {
98+
return null;
99+
}
100+
101+
const frameCount = Math.floor(dataSize / blockAlign);
102+
const channelData = Array.from(
103+
{ length: channelCount },
104+
() => new Float32Array(frameCount),
105+
);
106+
107+
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
108+
const frameOffset = dataOffset + frameIndex * blockAlign;
109+
110+
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
111+
const sampleOffset = frameOffset + channelIndex * bytesPerSample;
112+
113+
channelData[channelIndex][frameIndex] = readPcmSample(
114+
view,
115+
sampleOffset,
116+
bytesPerSample,
117+
);
118+
}
119+
}
120+
121+
return {
122+
channelData,
123+
sampleRate,
124+
};
125+
}
126+
127+
function readPcmSample(
128+
view: DataView,
129+
sampleOffset: number,
130+
bytesPerSample: number,
131+
): number {
132+
if (bytesPerSample === 2) {
133+
return view.getInt16(sampleOffset, true) / 32768;
134+
}
135+
136+
if (bytesPerSample === 3) {
137+
let value =
138+
view.getUint8(sampleOffset) |
139+
(view.getUint8(sampleOffset + 1) << 8) |
140+
(view.getUint8(sampleOffset + 2) << 16);
141+
142+
if (value & 0x800000) {
143+
value |= 0xff000000;
144+
}
145+
146+
return value / 8388608;
147+
}
148+
149+
return view.getInt32(sampleOffset, true) / 2147483648;
150+
}
151+
152+
function readAscii(view: DataView, offset: number, length: number): string {
153+
let value = "";
154+
155+
for (let index = 0; index < length; index += 1) {
156+
value += String.fromCharCode(view.getUint8(offset + index));
157+
}
158+
159+
return value;
160+
}

0 commit comments

Comments
 (0)