From d16553e659006cea8b0df1177d4985e27e24cc71 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:30:15 -0700 Subject: [PATCH] feat: added bouncing to MP3 feature --- package-lock.json | 7 ++ package.json | 1 + src/App.tsx | 10 +- src/components/Toolbar.tsx | 20 +++- src/core/audio-interface/KGOfflineRenderer.ts | 91 +++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ebe900..fba7196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "K.G.Studio", "version": "0.9.0-build.20260406", "dependencies": { + "@breezystack/lamejs": "^1.2.7", "class-transformer": "^0.5.1", "idb": "^8.0.3", "jszip": "^3.10.1", @@ -391,6 +392,12 @@ "node": ">=18" } }, + "node_modules/@breezystack/lamejs": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@breezystack/lamejs/-/lamejs-1.2.7.tgz", + "integrity": "sha512-6wc7ck65ctA75Hq7FYHTtTvGnYs6msgdxiSUICQ+A01nVOWg6rqouZB8IdyteRlfpYYiFovkf67dIeOgWIUzTA==", + "license": "LGPL-3.0" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index 50c926f..d2611a1 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:run": "vitest run" }, "dependencies": { + "@breezystack/lamejs": "^1.2.7", "class-transformer": "^0.5.1", "idb": "^8.0.3", "jszip": "^3.10.1", diff --git a/src/App.tsx b/src/App.tsx index 9fea06f..365fb93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -238,14 +238,14 @@ const MigrationOverlayContainer: React.FC = () => { ); }; -// Bounce/render overlay — shown during offline WAV rendering +// Bounce/render overlay — shown during offline WAV/MP3 rendering const BounceOverlayContainer: React.FC = () => { - const [isRendering, setIsRendering] = useState(false); + const [renderMessage, setRenderMessage] = useState(null); useEffectReact(() => { const renderer = KGOfflineRenderer.instance(); const listener = (evt: RenderingEvent) => { - setIsRendering(evt.type === 'start'); + setRenderMessage(evt.type === 'start' ? (evt.message ?? 'Rendering...') : null); }; renderer.addRenderingListener(listener); return () => { @@ -255,8 +255,8 @@ const BounceOverlayContainer: React.FC = () => { return ( ); }; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 30e3831..f0fe8e8 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -83,7 +83,7 @@ const Toolbar: React.FC = () => { const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; // Export options - const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV"]; + const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"]; const handleProjectNameClick = () => { const newName = prompt("Enter project name:", projectName); @@ -205,6 +205,8 @@ const Toolbar: React.FC = () => { handleExportMIDI(); } else if (exportType === "Export to WAV") { handleBounceToWav(); + } else if (exportType === "Export to MP3") { + handleBounceToMp3(); } setShowExportDropdown(false); @@ -304,6 +306,22 @@ const Toolbar: React.FC = () => { } }; + const handleBounceToMp3 = async () => { + if (DEBUG_MODE.TOOLBAR) { + console.log("bouncing to MP3"); + } + + try { + const currentProject = KGCore.instance().getCurrentProject(); + await KGOfflineRenderer.instance().bounceToMp3(currentProject, projectName); + setStatus(`Project "${projectName}" exported as MP3 file`); + } catch (error) { + console.error("Error bouncing to MP3:", error); + setStatus(`Error exporting MP3: ${error}`); + window.alert(`Failed to export project as MP3: ${error}`); + } + }; + const handleImportProject = () => { if (DEBUG_MODE.TOOLBAR) { console.log("user clicked import button"); diff --git a/src/core/audio-interface/KGOfflineRenderer.ts b/src/core/audio-interface/KGOfflineRenderer.ts index 5269b6e..6f5af03 100644 --- a/src/core/audio-interface/KGOfflineRenderer.ts +++ b/src/core/audio-interface/KGOfflineRenderer.ts @@ -7,11 +7,13 @@ import { pitchToNoteNameString } from '../../util/midiUtil'; import { KGToneBuffersPool } from './KGToneBuffersPool'; import { KGToneSamplerFactory } from './KGToneSamplerFactory'; import { KGAudioInterface } from './KGAudioInterface'; +import { Mp3Encoder } from '@breezystack/lamejs'; export interface RenderOptions { sampleRate?: number; // default 44100 channels?: number; // default 2 (stereo) tailSeconds?: number; // extra seconds after last note for release/reverb (default 2) + mp3Kbps?: number; // MP3 bitrate in kbps (default 192) } export interface RenderingEvent { @@ -368,6 +370,44 @@ export class KGOfflineRenderer { this._isRendering = false; } } + /** + * Render the project and download as an MP3 file. + */ + public async bounceToMp3(project: KGProject, fileName?: string, options?: RenderOptions): Promise { + if (this._isRendering) { + console.warn('Already rendering, ignoring bounce request'); + return; + } + + this._isRendering = true; + this.emitRenderingEvent({ type: 'start', message: 'Bouncing to MP3...' }); + + try { + const toneBuffer = await this.renderToBuffer(project, options); + const audioBuffer = toneBuffer.get() as AudioBuffer; + const mp3Data = encodeMp3(audioBuffer, options?.mp3Kbps ?? 192); + + // Trigger download + const blob = new Blob(mp3Data, { type: 'audio/mp3' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${fileName ?? 'bounce'}.mp3`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + this.emitRenderingEvent({ type: 'end', message: 'Bounce complete' }); + console.log('MP3 bounce complete'); + } catch (error) { + console.error('Bounce to MP3 failed:', error); + this.emitRenderingEvent({ type: 'error', message: String(error) }); + throw error; + } finally { + this._isRendering = false; + } + } } // ===== HELPERS ===== @@ -444,3 +484,54 @@ function writeString(view: DataView, offset: number, str: string): void { view.setUint8(offset + i, str.charCodeAt(i)); } } + +// ===== MP3 ENCODER ===== + +/** + * Convert float32 sample to Int16. + */ +function floatToInt16(sample: number): number { + const clamped = Math.max(-1, Math.min(1, sample)); + return clamped < 0 ? clamped * 0x8000 : clamped * 0x7FFF; +} + +/** + * Encode an AudioBuffer as MP3 using lamejs. + * Returns an array of Int8Array chunks (suitable for Blob constructor). + */ +export function encodeMp3(audioBuffer: AudioBuffer, kbps: number = 192): Uint8Array[] { + const numChannels = audioBuffer.numberOfChannels; + const sampleRate = audioBuffer.sampleRate; + const numFrames = audioBuffer.length; + const encoder = new Mp3Encoder(numChannels, sampleRate, kbps); + + const chunkSize = 1152; // MPEG frame size + const mp3Chunks: Uint8Array[] = []; + + const leftFloat = audioBuffer.getChannelData(0); + const rightFloat = numChannels > 1 ? audioBuffer.getChannelData(1) : leftFloat; + + for (let i = 0; i < numFrames; i += chunkSize) { + const end = Math.min(i + chunkSize, numFrames); + const leftChunk = new Int16Array(end - i); + const rightChunk = new Int16Array(end - i); + + for (let j = 0; j < leftChunk.length; j++) { + leftChunk[j] = floatToInt16(leftFloat[i + j]); + rightChunk[j] = floatToInt16(rightFloat[i + j]); + } + + const mp3buf = encoder.encodeBuffer(leftChunk, rightChunk); + if (mp3buf.length > 0) { + mp3Chunks.push(mp3buf); + } + } + + // Flush remaining data + const tail = encoder.flush(); + if (tail.length > 0) { + mp3Chunks.push(tail); + } + + return mp3Chunks; +}