From bb39e29bcc92eeaf109fd6596e8857c897d7e990 Mon Sep 17 00:00:00 2001 From: Jaewan Park Date: Sun, 14 Jun 2026 18:44:40 +0900 Subject: [PATCH] feat: export arrangement as wav --- PLANS.md | 3 +- docs/audio-engine.md | 15 +- docs/features/21-arrangement-wav-export.md | 2 +- src/app/App.tsx | 105 ++++ src/audio/arrangement-events.ts | 2 + src/audio/audio-buffer-decoder.ts | 160 +++++ src/audio/browser-audio-engine.ts | 162 +---- src/audio/index.ts | 9 + src/audio/offline-arrangement-renderer.ts | 587 ++++++++++++++++++ src/audio/types.ts | 2 + src/audio/wav-encoder.ts | 98 +++ .../project-sidebar/ProjectSidebar.module.css | 20 + .../project-sidebar/ProjectSidebar.tsx | 19 +- tests/unit/audio/arrangement-events.test.ts | 3 + tests/unit/audio/wav-encoder.test.ts | 52 ++ 15 files changed, 1066 insertions(+), 173 deletions(-) create mode 100644 src/audio/audio-buffer-decoder.ts create mode 100644 src/audio/offline-arrangement-renderer.ts create mode 100644 src/audio/wav-encoder.ts create mode 100644 tests/unit/audio/wav-encoder.test.ts diff --git a/PLANS.md b/PLANS.md index 6c3b621..0a77a36 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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 diff --git a/docs/audio-engine.md b/docs/audio-engine.md index 490c2e7..a0b3b3d 100644 --- a/docs/audio-engine.md +++ b/docs/audio-engine.md @@ -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. diff --git a/docs/features/21-arrangement-wav-export.md b/docs/features/21-arrangement-wav-export.md index c1ec68e..36063e2 100644 --- a/docs/features/21-arrangement-wav-export.md +++ b/docs/features/21-arrangement-wav-export.md @@ -1,7 +1,7 @@ # Feature: 21 Arrangement WAV Export ## Status -Planned +In Review ## Goal diff --git a/src/app/App.tsx b/src/app/App.tsx index 501897f..f54eeae 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,6 +4,7 @@ import { BUNDLED_DRUM_SAMPLES, createAudioEngine, expandClipInstancesForPlayback, + renderArrangementToWav, type BundledSampleMeta, type MixerLevelSnapshot, type NoteLoopEvent, @@ -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"; @@ -200,6 +226,10 @@ export function App() { const sampleMetasRef = useRef(sampleMetas); const [isClipImporting, setIsClipImporting] = useState(false); const [clipImportError, setClipImportError] = useState(null); + const [isArrangementExporting, setIsArrangementExporting] = useState(false); + const [arrangementExportError, setArrangementExportError] = useState< + string | null + >(null); const [isPersistenceReady, setIsPersistenceReady] = useState(false); const [persistenceStatus, setPersistenceStatus] = useState("loading"); @@ -723,6 +753,43 @@ export function App() { return true; } + async function getImportedSampleBlobsForArrangement( + instances: readonly ClipInstance[], + ): Promise> { + 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; @@ -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); @@ -1674,9 +1776,12 @@ export function App() {
0) { sampleEvents.push({ + durationTicks: instance.lengthTicks, id: `${instance.id}:audio`, sampleId: clip.sampleId, + sourceOffsetSeconds: instance.sourceOffsetSeconds, startTick: instance.startTick, trackId: instance.trackId, }); diff --git a/src/audio/audio-buffer-decoder.ts b/src/audio/audio-buffer-decoder.ts new file mode 100644 index 0000000..2f71013 --- /dev/null +++ b/src/audio/audio-buffer-decoder.ts @@ -0,0 +1,160 @@ +interface DecodedPcmWav { + channelData: Float32Array[]; + sampleRate: number; +} + +export async function decodeAudioBuffer({ + arrayBuffer, + audioContext, + errorMessage, +}: { + arrayBuffer: ArrayBuffer; + audioContext: BaseAudioContext; + errorMessage: string; +}): Promise { + 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; +} diff --git a/src/audio/browser-audio-engine.ts b/src/audio/browser-audio-engine.ts index 62c4d8f..ded518a 100644 --- a/src/audio/browser-audio-engine.ts +++ b/src/audio/browser-audio-engine.ts @@ -1,4 +1,5 @@ import { BUNDLED_SAMPLES } from "./bundled-samples"; +import { decodeAudioBuffer } from "./audio-buffer-decoder"; import { LookaheadScheduler } from "./lookahead-scheduler"; import { resolveSamplerPlaybackPlan, @@ -46,11 +47,6 @@ type ClipLoopEvent = | ({ kind: "note" } & NoteLoopEvent) | ({ kind: "sample" } & SampleLoopEvent); -interface DecodedPcmWav { - channelData: Float32Array[]; - sampleRate: number; -} - interface ActiveNoteVoice { gainNode: GainNode; isReleasing: boolean; @@ -886,40 +882,6 @@ function createClipLoopEvents({ ]; } -async function decodeAudioBuffer({ - arrayBuffer, - audioContext, - errorMessage, -}: { - arrayBuffer: ArrayBuffer; - audioContext: AudioContext; - errorMessage: string; -}): Promise { - 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 midiNoteToFrequency(midiNote: number): number { return 440 * 2 ** ((midiNote - 69) / 12); } @@ -928,128 +890,6 @@ function midiNoteToPlaybackRate(midiNote: number, rootMidiNote: number): number return 2 ** ((midiNote - rootMidiNote) / 12); } -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; -} - function disconnectAudioNode(audioNode: AudioNode): void { try { audioNode.disconnect(); diff --git a/src/audio/index.ts b/src/audio/index.ts index e614e64..77034cd 100644 --- a/src/audio/index.ts +++ b/src/audio/index.ts @@ -1,5 +1,10 @@ export { BrowserAudioEngine, createAudioEngine } from "./browser-audio-engine"; export { expandClipInstancesForPlayback } from "./arrangement-events"; +export { renderArrangementToWav } from "./offline-arrangement-renderer"; +export { + encodePcm16WavArrayBuffer, + encodePcm16WavBlob, +} from "./wav-encoder"; export { BUNDLED_DRUM_SAMPLES, BUNDLED_PIANO_SAMPLES, @@ -19,6 +24,10 @@ export { export type { ArrangementPlaybackEvents, } from "./arrangement-events"; +export type { + ArrangementWavExportOptions, + ArrangementWavExportResult, +} from "./offline-arrangement-renderer"; export type { LookaheadSchedulerOptions, ScheduledTickEvent, diff --git a/src/audio/offline-arrangement-renderer.ts b/src/audio/offline-arrangement-renderer.ts new file mode 100644 index 0000000..e744c08 --- /dev/null +++ b/src/audio/offline-arrangement-renderer.ts @@ -0,0 +1,587 @@ +import { BUNDLED_SAMPLES } from "./bundled-samples"; +import { decodeAudioBuffer } from "./audio-buffer-decoder"; +import { expandClipInstancesForPlayback } from "./arrangement-events"; +import { + resolveSamplerPlaybackPlan, +} from "./sampler-sustain"; +import { + encodePcm16WavBlob, +} from "./wav-encoder"; +import type { + BundledSampleMeta, + NoteLoopEvent, + SampleId, + SampleLoopEvent, +} from "./types"; +import { + decibelsToLinearGain, + getArrangementLengthTicks, + getPitchedInstrument, + getSampleZoneForMidiNote, + getTrackEffectiveGain, + getTrackMixerState, + type Clip, + type ClipInstance, + type MasterMixerState, + type SampleZone, + type TrackMixerState, +} from "../model"; +import { + clampTempoBpm, + ticksToSeconds, +} from "../utils"; + +export interface ArrangementWavExportOptions { + arrangementLengthBars: number; + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; + importedSampleBlobs?: ReadonlyMap; + masterMixerState: MasterMixerState; + sampleRate?: number; + samples?: readonly BundledSampleMeta[]; + tempoBpm: number; + trackMixerStates: readonly TrackMixerState[]; +} + +export interface ArrangementWavExportResult { + blob: Blob; + durationSeconds: number; + format: { + bitDepth: 16; + channelCount: 2; + sampleRate: number; + }; +} + +const DEFAULT_EXPORT_SAMPLE_RATE = 44100; +const EXPORT_CHANNEL_COUNT = 2; +const DEFAULT_SAMPLE_GAIN = 0.9; +const DEFAULT_SYNTH_GAIN = 0.22; +const DEFAULT_SAMPLER_GAIN = 0.72; + +export async function renderArrangementToWav({ + arrangementLengthBars, + clipInstances, + clips, + importedSampleBlobs = new Map(), + masterMixerState, + sampleRate = DEFAULT_EXPORT_SAMPLE_RATE, + samples = BUNDLED_SAMPLES, + tempoBpm, + trackMixerStates, +}: ArrangementWavExportOptions): Promise { + const normalizedTempoBpm = clampTempoBpm(tempoBpm); + const arrangementLengthTicks = getArrangementLengthTicks(arrangementLengthBars); + const durationSeconds = ticksToSeconds(arrangementLengthTicks, { + tempoBpm: normalizedTempoBpm, + }); + const frameCount = Math.max(1, Math.ceil(durationSeconds * sampleRate)); + const offlineAudioContext = createOfflineAudioContext({ + channelCount: EXPORT_CHANNEL_COUNT, + frameCount, + sampleRate, + }); + const playbackEvents = expandClipInstancesForPlayback({ + clipInstances, + clips, + }); + + if (playbackEvents.missingClipIds.length > 0) { + throw new Error( + `Arrangement contains missing source clips: ${playbackEvents.missingClipIds.join( + ", ", + )}.`, + ); + } + + const sampleBuffers = await loadSamplesForOfflineRender({ + audioContext: offlineAudioContext, + importedSampleBlobs, + noteEvents: playbackEvents.noteEvents, + sampleEvents: playbackEvents.sampleEvents, + samples, + }); + const mixerOptions = { + masterMixerState, + trackMixerStates, + }; + + for (const event of playbackEvents.sampleEvents) { + scheduleOfflineSampleEvent({ + arrangementLengthTicks, + audioContext: offlineAudioContext, + event, + mixerOptions, + sampleBuffers, + tempoBpm: normalizedTempoBpm, + }); + } + + for (const event of playbackEvents.noteEvents) { + scheduleOfflineNoteEvent({ + arrangementLengthTicks, + audioContext: offlineAudioContext, + event, + mixerOptions, + sampleBuffers, + tempoBpm: normalizedTempoBpm, + }); + } + + const renderedBuffer = await offlineAudioContext.startRendering(); + const channelData = Array.from( + { length: EXPORT_CHANNEL_COUNT }, + (_, channelIndex) => + renderedBuffer.getChannelData( + Math.min(channelIndex, renderedBuffer.numberOfChannels - 1), + ), + ); + + return { + blob: encodePcm16WavBlob({ + channelData, + sampleRate: renderedBuffer.sampleRate, + }), + durationSeconds: renderedBuffer.duration, + format: { + bitDepth: 16, + channelCount: EXPORT_CHANNEL_COUNT, + sampleRate: renderedBuffer.sampleRate, + }, + }; +} + +async function loadSamplesForOfflineRender({ + audioContext, + importedSampleBlobs, + noteEvents, + sampleEvents, + samples, +}: { + audioContext: BaseAudioContext; + importedSampleBlobs: ReadonlyMap; + noteEvents: readonly NoteLoopEvent[]; + sampleEvents: readonly SampleLoopEvent[]; + samples: readonly BundledSampleMeta[]; +}): Promise> { + const sampleIds = new Set(); + + for (const event of sampleEvents) { + sampleIds.add(event.sampleId); + } + + for (const event of noteEvents) { + const sampleZone = getSampleZoneForNoteEvent(event); + + if (sampleZone) { + sampleIds.add(sampleZone.sampleId); + } + } + + const sampleBuffers = new Map(); + + await Promise.all( + Array.from(sampleIds, async (sampleId) => { + sampleBuffers.set( + sampleId, + await loadOfflineSampleBuffer({ + audioContext, + importedSampleBlobs, + sampleId, + samples, + }), + ); + }), + ); + + return sampleBuffers; +} + +async function loadOfflineSampleBuffer({ + audioContext, + importedSampleBlobs, + sampleId, + samples, +}: { + audioContext: BaseAudioContext; + importedSampleBlobs: ReadonlyMap; + sampleId: SampleId; + samples: readonly BundledSampleMeta[]; +}): Promise { + const bundledSample = samples.find((sample) => sample.id === sampleId); + + if (bundledSample) { + const response = await fetch(bundledSample.path); + + if (!response.ok) { + throw new Error( + `Failed to load sample "${sampleId}" from ${bundledSample.path}.`, + ); + } + + return decodeAudioBuffer({ + arrayBuffer: await response.arrayBuffer(), + audioContext, + errorMessage: `Failed to decode bundled sample "${sampleId}".`, + }); + } + + const importedSampleBlob = importedSampleBlobs.get(sampleId); + + if (!importedSampleBlob) { + throw new Error( + `Sample data is missing for "${sampleId}". Re-import the file before exporting.`, + ); + } + + return decodeAudioBuffer({ + arrayBuffer: await importedSampleBlob.arrayBuffer(), + audioContext, + errorMessage: `Failed to decode imported sample "${sampleId}".`, + }); +} + +function scheduleOfflineSampleEvent({ + arrangementLengthTicks, + audioContext, + event, + mixerOptions, + sampleBuffers, + tempoBpm, +}: { + arrangementLengthTicks: number; + audioContext: OfflineAudioContext; + event: SampleLoopEvent; + mixerOptions: MixerGainOptions; + sampleBuffers: ReadonlyMap; + tempoBpm: number; +}): void { + if (event.startTick >= arrangementLengthTicks) { + return; + } + + const audioBuffer = sampleBuffers.get(event.sampleId); + + if (!audioBuffer) { + throw new Error(`Sample "${event.sampleId}" must be loaded before rendering.`); + } + + const startSeconds = ticksToSeconds(event.startTick, { tempoBpm }); + const sourceOffsetSeconds = Math.max(event.sourceOffsetSeconds ?? 0, 0); + const mixerGain = getMixerGain({ + ...mixerOptions, + trackId: event.trackId, + }); + const gainValue = DEFAULT_SAMPLE_GAIN * (event.gain ?? 1) * mixerGain; + + if (gainValue <= 0 || sourceOffsetSeconds >= audioBuffer.duration) { + return; + } + + const sourceNode = audioContext.createBufferSource(); + const gainNode = audioContext.createGain(); + const eventDurationSeconds = + event.durationTicks === undefined + ? undefined + : ticksToSeconds( + Math.min(event.durationTicks, arrangementLengthTicks - event.startTick), + { tempoBpm }, + ); + const bufferDurationSeconds = audioBuffer.duration - sourceOffsetSeconds; + const durationSeconds = + eventDurationSeconds === undefined + ? bufferDurationSeconds + : Math.min(eventDurationSeconds, bufferDurationSeconds); + + if (durationSeconds <= 0) { + return; + } + + sourceNode.buffer = audioBuffer; + gainNode.gain.value = gainValue; + sourceNode.connect(gainNode); + gainNode.connect(audioContext.destination); + + if (event.durationTicks === undefined) { + sourceNode.start(startSeconds, sourceOffsetSeconds); + } else { + sourceNode.start(startSeconds, sourceOffsetSeconds, durationSeconds); + } +} + +function scheduleOfflineNoteEvent({ + arrangementLengthTicks, + audioContext, + event, + mixerOptions, + sampleBuffers, + tempoBpm, +}: { + arrangementLengthTicks: number; + audioContext: OfflineAudioContext; + event: NoteLoopEvent; + mixerOptions: MixerGainOptions; + sampleBuffers: ReadonlyMap; + tempoBpm: number; +}): void { + if (event.startTick >= arrangementLengthTicks) { + return; + } + + const durationTicks = Math.min( + event.durationTicks, + arrangementLengthTicks - event.startTick, + ); + + if (durationTicks <= 0) { + return; + } + + const instrument = getPitchedInstrument(event.instrumentId); + + if (instrument.kind !== "sample") { + scheduleOfflineSynthNote({ + audioContext, + durationTicks, + event, + mixerOptions, + tempoBpm, + }); + return; + } + + const sampleZone = getSampleZoneForMidiNote({ + instrument, + midiNote: event.midiNote, + }); + + if (!sampleZone) { + scheduleOfflineSynthNote({ + audioContext, + durationTicks, + event, + mixerOptions, + tempoBpm, + }); + return; + } + + scheduleOfflineSampledNote({ + audioContext, + durationTicks, + event, + mixerOptions, + sampleBuffers, + sampleZone, + tempoBpm, + }); +} + +function scheduleOfflineSynthNote({ + audioContext, + durationTicks, + event, + mixerOptions, + tempoBpm, +}: { + audioContext: OfflineAudioContext; + durationTicks: number; + event: NoteLoopEvent; + mixerOptions: MixerGainOptions; + tempoBpm: number; +}): void { + const startTime = ticksToSeconds(event.startTick, { tempoBpm }); + const durationSeconds = Math.max(ticksToSeconds(durationTicks, { tempoBpm }), 0.01); + const stopTime = startTime + durationSeconds; + const attackSeconds = Math.min(0.01, durationSeconds / 4); + const releaseSeconds = Math.min(0.04, durationSeconds / 3); + const sustainEndTime = Math.max( + startTime + attackSeconds, + stopTime - releaseSeconds, + ); + const gainValue = + DEFAULT_SYNTH_GAIN * + (event.gain ?? 1) * + getMixerGain({ + ...mixerOptions, + trackId: event.trackId, + }); + + if (gainValue <= 0) { + return; + } + + const sourceNode = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + sourceNode.type = "triangle"; + sourceNode.frequency.setValueAtTime( + midiNoteToFrequency(event.midiNote), + startTime, + ); + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(gainValue, startTime + attackSeconds); + gainNode.gain.setValueAtTime(gainValue, sustainEndTime); + gainNode.gain.linearRampToValueAtTime(0, stopTime); + sourceNode.connect(gainNode); + gainNode.connect(audioContext.destination); + sourceNode.start(startTime); + sourceNode.stop(stopTime); +} + +function scheduleOfflineSampledNote({ + audioContext, + durationTicks, + event, + mixerOptions, + sampleBuffers, + sampleZone, + tempoBpm, +}: { + audioContext: OfflineAudioContext; + durationTicks: number; + event: NoteLoopEvent; + mixerOptions: MixerGainOptions; + sampleBuffers: ReadonlyMap; + sampleZone: SampleZone; + tempoBpm: number; +}): void { + const audioBuffer = sampleBuffers.get(sampleZone.sampleId); + + if (!audioBuffer) { + throw new Error( + `Sample "${sampleZone.sampleId}" must be loaded before rendering.`, + ); + } + + const startTime = ticksToSeconds(event.startTick, { tempoBpm }); + const durationSeconds = Math.max(ticksToSeconds(durationTicks, { tempoBpm }), 0.01); + const playbackRate = midiNoteToPlaybackRate( + event.midiNote, + sampleZone.rootMidiNote, + ); + const playbackPlan = resolveSamplerPlaybackPlan({ + bufferDurationSeconds: audioBuffer.duration, + noteDurationSeconds: durationSeconds, + playbackRate, + sampleZone, + }); + const noteStopTime = startTime + durationSeconds; + const unloopedSampleStopTime = + startTime + playbackPlan.unloopedPlaybackDurationSeconds; + const stopTime = playbackPlan.sustainLoopRegion + ? noteStopTime + : Math.min(noteStopTime, unloopedSampleStopTime); + const voiceDurationSeconds = Math.max(stopTime - startTime, 0.01); + const attackSeconds = Math.min( + playbackPlan.envelope.attackSeconds, + voiceDurationSeconds / 4, + ); + const releaseSeconds = Math.min( + playbackPlan.envelope.releaseSeconds, + voiceDurationSeconds / 2, + Math.max(voiceDurationSeconds - attackSeconds, 0), + ); + const sustainEndTime = Math.max( + startTime + attackSeconds, + stopTime - releaseSeconds, + ); + const gainValue = + DEFAULT_SAMPLER_GAIN * + (event.gain ?? 1) * + getMixerGain({ + ...mixerOptions, + trackId: event.trackId, + }); + + if (gainValue <= 0) { + return; + } + + const sourceNode = audioContext.createBufferSource(); + const gainNode = audioContext.createGain(); + + sourceNode.buffer = audioBuffer; + sourceNode.playbackRate.setValueAtTime(playbackRate, startTime); + + if (playbackPlan.sustainLoopRegion) { + sourceNode.loop = true; + sourceNode.loopStart = playbackPlan.sustainLoopRegion.loopStartSeconds; + sourceNode.loopEnd = playbackPlan.sustainLoopRegion.loopEndSeconds; + } + + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(gainValue, startTime + attackSeconds); + gainNode.gain.setValueAtTime(gainValue, sustainEndTime); + gainNode.gain.linearRampToValueAtTime(0, stopTime); + sourceNode.connect(gainNode); + gainNode.connect(audioContext.destination); + sourceNode.start(startTime, playbackPlan.sampleOffsetSeconds); + sourceNode.stop(stopTime); +} + +interface MixerGainOptions { + masterMixerState: MasterMixerState; + trackMixerStates: readonly TrackMixerState[]; +} + +function getMixerGain({ + masterMixerState, + trackId, + trackMixerStates, +}: MixerGainOptions & { + trackId?: string; +}): number { + const masterGain = decibelsToLinearGain(masterMixerState.volumeDb); + + if (!trackId) { + return masterGain; + } + + const trackState = getTrackMixerState(trackMixerStates, trackId); + + return ( + masterGain * + getTrackEffectiveGain({ + allTrackStates: trackMixerStates, + trackState, + }) + ); +} + +function getSampleZoneForNoteEvent(event: NoteLoopEvent): SampleZone | null { + const instrument = getPitchedInstrument(event.instrumentId); + + if (instrument.kind !== "sample") { + return null; + } + + return getSampleZoneForMidiNote({ + instrument, + midiNote: event.midiNote, + }) ?? null; +} + +function createOfflineAudioContext({ + channelCount, + frameCount, + sampleRate, +}: { + channelCount: number; + frameCount: number; + sampleRate: number; +}): OfflineAudioContext { + if (!globalThis.OfflineAudioContext) { + throw new Error("Offline audio rendering is not supported in this browser."); + } + + return new OfflineAudioContext(channelCount, frameCount, sampleRate); +} + +function midiNoteToFrequency(midiNote: number): number { + return 440 * 2 ** ((midiNote - 69) / 12); +} + +function midiNoteToPlaybackRate(midiNote: number, rootMidiNote: number): number { + return 2 ** ((midiNote - rootMidiNote) / 12); +} diff --git a/src/audio/types.ts b/src/audio/types.ts index 6e92b25..aadb974 100644 --- a/src/audio/types.ts +++ b/src/audio/types.ts @@ -22,8 +22,10 @@ export interface PlaySampleOptions { } export interface SampleLoopEvent { + durationTicks?: Tick; id: string; sampleId: SampleId; + sourceOffsetSeconds?: number; startTick: Tick; gain?: number; trackId?: TrackId; diff --git a/src/audio/wav-encoder.ts b/src/audio/wav-encoder.ts new file mode 100644 index 0000000..00d35ff --- /dev/null +++ b/src/audio/wav-encoder.ts @@ -0,0 +1,98 @@ +export interface PcmWavEncodingOptions { + channelData: readonly Float32Array[]; + sampleRate: number; +} + +const WAV_HEADER_BYTES = 44; +const PCM_FORMAT = 1; +const PCM_16_BIT_BYTES_PER_SAMPLE = 2; + +export function encodePcm16WavBlob(options: PcmWavEncodingOptions): Blob { + return new Blob([encodePcm16WavArrayBuffer(options)], { + type: "audio/wav", + }); +} + +export function encodePcm16WavArrayBuffer({ + channelData, + sampleRate, +}: PcmWavEncodingOptions): ArrayBuffer { + validateEncodingOptions({ channelData, sampleRate }); + + const channelCount = channelData.length; + const frameCount = channelData[0]?.length ?? 0; + const blockAlign = channelCount * PCM_16_BIT_BYTES_PER_SAMPLE; + const byteRate = sampleRate * blockAlign; + const dataByteLength = frameCount * blockAlign; + const arrayBuffer = new ArrayBuffer(WAV_HEADER_BYTES + dataByteLength); + const view = new DataView(arrayBuffer); + + writeAscii(view, 0, "RIFF"); + view.setUint32(4, 36 + dataByteLength, true); + writeAscii(view, 8, "WAVE"); + writeAscii(view, 12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, PCM_FORMAT, true); + view.setUint16(22, channelCount, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, 16, true); + writeAscii(view, 36, "data"); + view.setUint32(40, dataByteLength, true); + + let byteOffset = WAV_HEADER_BYTES; + + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) { + const sample = clampPcmSample(channelData[channelIndex]?.[frameIndex] ?? 0); + const pcmValue = sample < 0 ? sample * 32768 : sample * 32767; + + view.setInt16(byteOffset, Math.round(pcmValue), true); + byteOffset += PCM_16_BIT_BYTES_PER_SAMPLE; + } + } + + return arrayBuffer; +} + +function validateEncodingOptions({ + channelData, + sampleRate, +}: PcmWavEncodingOptions): void { + if (!Number.isInteger(sampleRate) || sampleRate <= 0) { + throw new Error(`sampleRate must be a positive integer. Received ${sampleRate}.`); + } + + if (channelData.length <= 0) { + throw new Error("At least one PCM channel is required."); + } + + const frameCount = channelData[0]?.length; + + if (frameCount === undefined) { + throw new Error("At least one PCM channel is required."); + } + + for (const [channelIndex, channel] of channelData.entries()) { + if (channel.length !== frameCount) { + throw new Error( + `All PCM channels must have the same frame count. Channel 0 has ${frameCount}, channel ${channelIndex} has ${channel.length}.`, + ); + } + } +} + +function clampPcmSample(sample: number): number { + if (!Number.isFinite(sample)) { + return 0; + } + + return Math.min(1, Math.max(-1, sample)); +} + +function writeAscii(view: DataView, offset: number, value: string): void { + for (let index = 0; index < value.length; index += 1) { + view.setUint8(offset + index, value.charCodeAt(index)); + } +} diff --git a/src/features/project-sidebar/ProjectSidebar.module.css b/src/features/project-sidebar/ProjectSidebar.module.css index dd9f999..4673c9a 100644 --- a/src/features/project-sidebar/ProjectSidebar.module.css +++ b/src/features/project-sidebar/ProjectSidebar.module.css @@ -317,3 +317,23 @@ gap: var(--space-unit); padding: var(--space-panel); } + +.footerButton:disabled { + color: var(--color-text-dim); + cursor: wait; + opacity: 0.6; +} + +.footerButton:disabled:hover { + background: transparent; + color: var(--color-text-dim); +} + +.exportError { + border: 1px solid var(--color-status-error); + border-radius: var(--radius-sm); + color: var(--color-status-error); + font-size: var(--font-size-caption); + margin: var(--space-unit) 0 0; + padding: var(--space-gutter); +} diff --git a/src/features/project-sidebar/ProjectSidebar.tsx b/src/features/project-sidebar/ProjectSidebar.tsx index 75f9fcd..68c84e3 100644 --- a/src/features/project-sidebar/ProjectSidebar.tsx +++ b/src/features/project-sidebar/ProjectSidebar.tsx @@ -21,11 +21,14 @@ import styles from "./ProjectSidebar.module.css"; export type InstrumentId = "audio" | "drums" | PitchedInstrumentId; interface ProjectSidebarProps { + arrangementExportError?: string | null; clips: readonly Clip[]; clipImportError?: string | null; + isArrangementExporting?: boolean; isClipImporting?: boolean; selectedClipId: string; selectedInstrumentId: InstrumentId; + onArrangementExport: () => void; onClipAdd: () => void; onClipDelete: (clipId: string) => void; onClipImport: (file: File) => void; @@ -37,11 +40,14 @@ interface ProjectSidebarProps { } export function ProjectSidebar({ + arrangementExportError = null, clips, clipImportError = null, + isArrangementExporting = false, isClipImporting = false, selectedClipId, selectedInstrumentId, + onArrangementExport, onClipAdd, onClipDelete, onClipImport, @@ -420,10 +426,19 @@ export function ProjectSidebar({ Settings - + {arrangementExportError ? ( +

{arrangementExportError}

+ ) : null}
); diff --git a/tests/unit/audio/arrangement-events.test.ts b/tests/unit/audio/arrangement-events.test.ts index 3c86900..1375cc3 100644 --- a/tests/unit/audio/arrangement-events.test.ts +++ b/tests/unit/audio/arrangement-events.test.ts @@ -77,6 +77,7 @@ describe("arrangement playback event expansion", () => { clipId: clip.id, id: "audio-instance-1", lengthTicks: 1920, + sourceOffsetSeconds: 0.25, startTick: 960, trackId: "track-2", }; @@ -88,8 +89,10 @@ describe("arrangement playback event expansion", () => { }).sampleEvents, ).toEqual([ { + durationTicks: 1920, id: "audio-instance-1:audio", sampleId: "imported-audio-loop", + sourceOffsetSeconds: 0.25, startTick: 960, trackId: "track-2", }, diff --git a/tests/unit/audio/wav-encoder.test.ts b/tests/unit/audio/wav-encoder.test.ts new file mode 100644 index 0000000..6ea830d --- /dev/null +++ b/tests/unit/audio/wav-encoder.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { encodePcm16WavArrayBuffer } from "../../../src/audio"; + +describe("wav encoder", () => { + it("encodes stereo float PCM as 16-bit little-endian WAV", () => { + const wavBuffer = encodePcm16WavArrayBuffer({ + channelData: [ + new Float32Array([0, 1]), + new Float32Array([-1, 0.5]), + ], + sampleRate: 44100, + }); + const view = new DataView(wavBuffer); + + expect(readAscii(view, 0, 4)).toBe("RIFF"); + expect(readAscii(view, 8, 4)).toBe("WAVE"); + expect(readAscii(view, 12, 4)).toBe("fmt "); + expect(view.getUint16(20, true)).toBe(1); + expect(view.getUint16(22, true)).toBe(2); + expect(view.getUint32(24, true)).toBe(44100); + expect(view.getUint16(34, true)).toBe(16); + expect(readAscii(view, 36, 4)).toBe("data"); + expect(view.getUint32(40, true)).toBe(8); + expect(view.getInt16(44, true)).toBe(0); + expect(view.getInt16(46, true)).toBe(-32768); + expect(view.getInt16(48, true)).toBe(32767); + expect(view.getInt16(50, true)).toBe(16384); + }); + + it("rejects mismatched channel lengths", () => { + expect(() => + encodePcm16WavArrayBuffer({ + channelData: [ + new Float32Array([0, 1]), + new Float32Array([0]), + ], + sampleRate: 44100, + }), + ).toThrow("All PCM channels must have the same frame count"); + }); +}); + +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; +}