feat: added bouncing to MP3 feature
This commit is contained in:
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "K.G.Studio",
|
"name": "K.G.Studio",
|
||||||
"version": "0.9.0-build.20260406",
|
"version": "0.9.0-build.20260406",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -391,6 +392,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@colors/colors": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
+5
-5
@@ -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 BounceOverlayContainer: React.FC = () => {
|
||||||
const [isRendering, setIsRendering] = useState<boolean>(false);
|
const [renderMessage, setRenderMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffectReact(() => {
|
useEffectReact(() => {
|
||||||
const renderer = KGOfflineRenderer.instance();
|
const renderer = KGOfflineRenderer.instance();
|
||||||
const listener = (evt: RenderingEvent) => {
|
const listener = (evt: RenderingEvent) => {
|
||||||
setIsRendering(evt.type === 'start');
|
setRenderMessage(evt.type === 'start' ? (evt.message ?? 'Rendering...') : null);
|
||||||
};
|
};
|
||||||
renderer.addRenderingListener(listener);
|
renderer.addRenderingListener(listener);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -255,8 +255,8 @@ const BounceOverlayContainer: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingOverlay
|
<LoadingOverlay
|
||||||
visible={isRendering}
|
visible={renderMessage !== null}
|
||||||
message="Bouncing to WAV..."
|
message={renderMessage ?? 'Rendering...'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const Toolbar: React.FC = () => {
|
|||||||
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
||||||
|
|
||||||
// Export options
|
// 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 handleProjectNameClick = () => {
|
||||||
const newName = prompt("Enter project name:", projectName);
|
const newName = prompt("Enter project name:", projectName);
|
||||||
@@ -205,6 +205,8 @@ const Toolbar: React.FC = () => {
|
|||||||
handleExportMIDI();
|
handleExportMIDI();
|
||||||
} else if (exportType === "Export to WAV") {
|
} else if (exportType === "Export to WAV") {
|
||||||
handleBounceToWav();
|
handleBounceToWav();
|
||||||
|
} else if (exportType === "Export to MP3") {
|
||||||
|
handleBounceToMp3();
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowExportDropdown(false);
|
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 = () => {
|
const handleImportProject = () => {
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("user clicked import button");
|
console.log("user clicked import button");
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { pitchToNoteNameString } from '../../util/midiUtil';
|
|||||||
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||||
import { KGAudioInterface } from './KGAudioInterface';
|
import { KGAudioInterface } from './KGAudioInterface';
|
||||||
|
import { Mp3Encoder } from '@breezystack/lamejs';
|
||||||
|
|
||||||
export interface RenderOptions {
|
export interface RenderOptions {
|
||||||
sampleRate?: number; // default 44100
|
sampleRate?: number; // default 44100
|
||||||
channels?: number; // default 2 (stereo)
|
channels?: number; // default 2 (stereo)
|
||||||
tailSeconds?: number; // extra seconds after last note for release/reverb (default 2)
|
tailSeconds?: number; // extra seconds after last note for release/reverb (default 2)
|
||||||
|
mp3Kbps?: number; // MP3 bitrate in kbps (default 192)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderingEvent {
|
export interface RenderingEvent {
|
||||||
@@ -368,6 +370,44 @@ export class KGOfflineRenderer {
|
|||||||
this._isRendering = false;
|
this._isRendering = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Render the project and download as an MP3 file.
|
||||||
|
*/
|
||||||
|
public async bounceToMp3(project: KGProject, fileName?: string, options?: RenderOptions): Promise<void> {
|
||||||
|
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 =====
|
// ===== HELPERS =====
|
||||||
@@ -444,3 +484,54 @@ function writeString(view: DataView, offset: number, str: string): void {
|
|||||||
view.setUint8(offset + i, str.charCodeAt(i));
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user