Skip to content
Merged
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
3 changes: 1 addition & 2 deletions PLANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ M1 should include:

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.

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

## Planned Milestones

Expand Down
15 changes: 8 additions & 7 deletions docs/audio-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ Arrangement WAV export should use an offline audio rendering path, not the live

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.

Export rendering should:

- Render from arrangement tick 0 through the configured arrangement length.
- Use the same tick-to-audio-time conversion rules as live playback.
- Include the clip types, instruments, and mixer routing available at the time of implementation.
- Block with a clear error when required sample data is missing.
- Avoid mutating live transport state, active source nodes, or React component state during rendering.
The current first export pass:

- Renders from arrangement tick 0 through the configured arrangement length.
- Uses the same tick-to-seconds conversion rules as live playback.
- Includes arranged hybrid clip drums, `Default Synth` notes, Iowa Piano sampled notes, imported audio clips, track volume, track mute/solo, and master gain.
- Crops imported audio clip playback to the placed `ClipInstance.lengthTicks`; if the source ends first, the remaining placement renders silence.
- Blocks with a clear error when required imported sample data is missing.
- Avoids mutating live transport state, active source nodes, or decoded runtime caches during rendering.

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.

Expand Down
2 changes: 1 addition & 1 deletion docs/features/21-arrangement-wav-export.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Feature: 21 Arrangement WAV Export

## Status
Planned
In Review

## Goal

Expand Down
105 changes: 105 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BUNDLED_DRUM_SAMPLES,
createAudioEngine,
expandClipInstancesForPlayback,
renderArrangementToWav,
type BundledSampleMeta,
type MixerLevelSnapshot,
type NoteLoopEvent,
Expand Down Expand Up @@ -142,6 +143,31 @@ function createNextHybridClip(clips: readonly Clip[]): HybridClip {
});
}

function createArrangementExportFileName(projectName: string): string {
const safeProjectName =
projectName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-|-$/gu, "") || "mini-daw";
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");

return `${safeProjectName}-arrangement-${timestamp}.wav`;
}

function downloadBlob(blob: Blob, fileName: string): void {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");

link.href = objectUrl;
link.download = fileName;
link.style.display = "none";
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}

function getPersistenceStatusLabel(status: PersistenceStatus): string {
if (status === "loading") {
return "Loading project";
Expand Down Expand Up @@ -200,6 +226,10 @@ export function App() {
const sampleMetasRef = useRef<SampleMeta[]>(sampleMetas);
const [isClipImporting, setIsClipImporting] = useState(false);
const [clipImportError, setClipImportError] = useState<string | null>(null);
const [isArrangementExporting, setIsArrangementExporting] = useState(false);
const [arrangementExportError, setArrangementExportError] = useState<
string | null
>(null);
const [isPersistenceReady, setIsPersistenceReady] = useState(false);
const [persistenceStatus, setPersistenceStatus] =
useState<PersistenceStatus>("loading");
Expand Down Expand Up @@ -723,6 +753,43 @@ export function App() {
return true;
}

async function getImportedSampleBlobsForArrangement(
instances: readonly ClipInstance[],
): Promise<ReadonlyMap<string, Blob>> {
const importedSampleBlobs = new Map(importedSampleBlobsRef.current);
const missingClipNames: string[] = [];

for (const instance of instances) {
const clip = clipsRef.current.find(
(candidate) => candidate.id === instance.clipId,
);

if (!clip || !isAudioClip(clip) || importedSampleBlobs.has(clip.sampleId)) {
continue;
}

const blob = await projectStore.loadImportedSampleBlob(clip.sampleId);

if (blob) {
importedSampleBlobs.set(clip.sampleId, blob);
importedSampleBlobsRef.current.set(clip.sampleId, blob);
continue;
}

missingClipNames.push(clip.name);
}

if (missingClipNames.length > 0) {
throw new Error(
`Imported audio data is missing for ${missingClipNames.join(
", ",
)}. Re-import the file before exporting.`,
);
}

return importedSampleBlobs;
}

async function handleAudioClipPreviewPlay() {
const clip = selectedClipRef.current;

Expand Down Expand Up @@ -1033,6 +1100,41 @@ export function App() {
}
}

async function handleArrangementWavExport() {
if (isArrangementExporting) {
return;
}

setArrangementExportError(null);
setAudioError(null);
setIsArrangementExporting(true);

try {
const importedSampleBlobs = await getImportedSampleBlobsForArrangement(
clipInstancesRef.current,
);
const exportResult = await renderArrangementToWav({
arrangementLengthBars: arrangementLengthBarsRef.current,
clipInstances: clipInstancesRef.current,
clips: clipsRef.current,
importedSampleBlobs,
masterMixerState: masterMixerStateRef.current,
tempoBpm: bpmRef.current,
trackMixerStates: trackMixerStatesRef.current,
});

downloadBlob(exportResult.blob, createArrangementExportFileName(PROJECT_NAME));
} catch (error) {
const message =
error instanceof Error ? error.message : "Arrangement WAV export failed.";

setArrangementExportError(message);
setAudioError(message);
} finally {
setIsArrangementExporting(false);
}
}

function handleClipSelect(clipId: string) {
const clip = clipsRef.current.find((candidate) => candidate.id === clipId);

Expand Down Expand Up @@ -1674,9 +1776,12 @@ export function App() {

<div className={styles.mainLayout}>
<ProjectSidebar
arrangementExportError={arrangementExportError}
clipImportError={clipImportError}
clips={clips}
isArrangementExporting={isArrangementExporting}
isClipImporting={isClipImporting}
onArrangementExport={handleArrangementWavExport}
onClipAdd={handleClipAdd}
onClipDelete={handleClipDelete}
onClipImport={handleAudioClipImport}
Expand Down
2 changes: 2 additions & 0 deletions src/audio/arrangement-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ export function expandClipInstancesForPlayback({
if (isAudioClip(clip)) {
if (instance.lengthTicks > 0) {
sampleEvents.push({
durationTicks: instance.lengthTicks,
id: `${instance.id}:audio`,
sampleId: clip.sampleId,
sourceOffsetSeconds: instance.sourceOffsetSeconds,
startTick: instance.startTick,
trackId: instance.trackId,
});
Expand Down
160 changes: 160 additions & 0 deletions src/audio/audio-buffer-decoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
interface DecodedPcmWav {
channelData: Float32Array[];
sampleRate: number;
}

export async function decodeAudioBuffer({
arrayBuffer,
audioContext,
errorMessage,
}: {
arrayBuffer: ArrayBuffer;
audioContext: BaseAudioContext;
errorMessage: string;
}): Promise<AudioBuffer> {
try {
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
} catch (decodeError) {
const decodedPcmWav = decodePcmWav(arrayBuffer);

if (!decodedPcmWav) {
throw decodeError instanceof Error
? new Error(errorMessage, { cause: decodeError })
: new Error(errorMessage);
}

const audioBuffer = audioContext.createBuffer(
decodedPcmWav.channelData.length,
decodedPcmWav.channelData[0]?.length ?? 0,
decodedPcmWav.sampleRate,
);

decodedPcmWav.channelData.forEach((channelData, channelIndex) => {
audioBuffer.copyToChannel(new Float32Array(channelData), channelIndex);
});

return audioBuffer;
}
}

function decodePcmWav(arrayBuffer: ArrayBuffer): DecodedPcmWav | null {
const view = new DataView(arrayBuffer);

if (
arrayBuffer.byteLength < 44 ||
readAscii(view, 0, 4) !== "RIFF" ||
readAscii(view, 8, 4) !== "WAVE"
) {
return null;
}

let audioFormat = 0;
let bitsPerSample = 0;
let blockAlign = 0;
let channelCount = 0;
let dataOffset = 0;
let dataSize = 0;
let sampleRate = 0;
let offset = 12;

while (offset + 8 <= view.byteLength) {
const chunkId = readAscii(view, offset, 4);
const chunkSize = view.getUint32(offset + 4, true);
const chunkStart = offset + 8;

if (chunkStart + chunkSize > view.byteLength) {
return null;
}

if (chunkId === "fmt ") {
audioFormat = view.getUint16(chunkStart, true);
channelCount = view.getUint16(chunkStart + 2, true);
sampleRate = view.getUint32(chunkStart + 4, true);
blockAlign = view.getUint16(chunkStart + 12, true);
bitsPerSample = view.getUint16(chunkStart + 14, true);
}

if (chunkId === "data") {
dataOffset = chunkStart;
dataSize = chunkSize;
}

offset = chunkStart + chunkSize + (chunkSize % 2);
}

const isPcm = audioFormat === 1 || audioFormat === 65534;
const bytesPerSample = bitsPerSample / 8;

if (
!isPcm ||
!Number.isInteger(bytesPerSample) ||
![2, 3, 4].includes(bytesPerSample) ||
blockAlign <= 0 ||
channelCount <= 0 ||
dataOffset <= 0 ||
dataSize <= 0 ||
sampleRate <= 0
) {
return null;
}

const frameCount = Math.floor(dataSize / blockAlign);
const channelData = Array.from(
{ length: channelCount },
() => new Float32Array(frameCount),
);

for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameOffset = dataOffset + frameIndex * blockAlign;

for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const sampleOffset = frameOffset + channelIndex * bytesPerSample;

channelData[channelIndex][frameIndex] = readPcmSample(
view,
sampleOffset,
bytesPerSample,
);
}
}

return {
channelData,
sampleRate,
};
}

function readPcmSample(
view: DataView,
sampleOffset: number,
bytesPerSample: number,
): number {
if (bytesPerSample === 2) {
return view.getInt16(sampleOffset, true) / 32768;
}

if (bytesPerSample === 3) {
let value =
view.getUint8(sampleOffset) |
(view.getUint8(sampleOffset + 1) << 8) |
(view.getUint8(sampleOffset + 2) << 16);

if (value & 0x800000) {
value |= 0xff000000;
}

return value / 8388608;
}

return view.getInt32(sampleOffset, true) / 2147483648;
}

function readAscii(view: DataView, offset: number, length: number): string {
let value = "";

for (let index = 0; index < length; index += 1) {
value += String.fromCharCode(view.getUint8(offset + index));
}

return value;
}
Loading
Loading