|
4 | 4 | BUNDLED_DRUM_SAMPLES, |
5 | 5 | createAudioEngine, |
6 | 6 | expandClipInstancesForPlayback, |
| 7 | + renderArrangementToWav, |
7 | 8 | type BundledSampleMeta, |
8 | 9 | type MixerLevelSnapshot, |
9 | 10 | type NoteLoopEvent, |
@@ -142,6 +143,31 @@ function createNextHybridClip(clips: readonly Clip[]): HybridClip { |
142 | 143 | }); |
143 | 144 | } |
144 | 145 |
|
| 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 | + |
145 | 171 | function getPersistenceStatusLabel(status: PersistenceStatus): string { |
146 | 172 | if (status === "loading") { |
147 | 173 | return "Loading project"; |
@@ -200,6 +226,10 @@ export function App() { |
200 | 226 | const sampleMetasRef = useRef<SampleMeta[]>(sampleMetas); |
201 | 227 | const [isClipImporting, setIsClipImporting] = useState(false); |
202 | 228 | const [clipImportError, setClipImportError] = useState<string | null>(null); |
| 229 | + const [isArrangementExporting, setIsArrangementExporting] = useState(false); |
| 230 | + const [arrangementExportError, setArrangementExportError] = useState< |
| 231 | + string | null |
| 232 | + >(null); |
203 | 233 | const [isPersistenceReady, setIsPersistenceReady] = useState(false); |
204 | 234 | const [persistenceStatus, setPersistenceStatus] = |
205 | 235 | useState<PersistenceStatus>("loading"); |
@@ -723,6 +753,43 @@ export function App() { |
723 | 753 | return true; |
724 | 754 | } |
725 | 755 |
|
| 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 | + |
726 | 793 | async function handleAudioClipPreviewPlay() { |
727 | 794 | const clip = selectedClipRef.current; |
728 | 795 |
|
@@ -1033,6 +1100,41 @@ export function App() { |
1033 | 1100 | } |
1034 | 1101 | } |
1035 | 1102 |
|
| 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 | + |
1036 | 1138 | function handleClipSelect(clipId: string) { |
1037 | 1139 | const clip = clipsRef.current.find((candidate) => candidate.id === clipId); |
1038 | 1140 |
|
@@ -1674,9 +1776,12 @@ export function App() { |
1674 | 1776 |
|
1675 | 1777 | <div className={styles.mainLayout}> |
1676 | 1778 | <ProjectSidebar |
| 1779 | + arrangementExportError={arrangementExportError} |
1677 | 1780 | clipImportError={clipImportError} |
1678 | 1781 | clips={clips} |
| 1782 | + isArrangementExporting={isArrangementExporting} |
1679 | 1783 | isClipImporting={isClipImporting} |
| 1784 | + onArrangementExport={handleArrangementWavExport} |
1680 | 1785 | onClipAdd={handleClipAdd} |
1681 | 1786 | onClipDelete={handleClipDelete} |
1682 | 1787 | onClipImport={handleAudioClipImport} |
|
0 commit comments