feat: add bounce-to-WAV export with offline rendering via Tone.Offline
This commit is contained in:
+28
@@ -12,6 +12,8 @@ import { SettingsPanel } from './components/settings';
|
|||||||
import LoadingOverlay from './components/common/LoadingOverlay';
|
import LoadingOverlay from './components/common/LoadingOverlay';
|
||||||
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
||||||
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
||||||
|
import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer';
|
||||||
|
import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
|
||||||
import { KGCore } from './core/KGCore';
|
import { KGCore } from './core/KGCore';
|
||||||
import { ConfigManager } from './core/config/ConfigManager';
|
import { ConfigManager } from './core/config/ConfigManager';
|
||||||
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
||||||
@@ -144,6 +146,9 @@ function App() {
|
|||||||
|
|
||||||
{/* Migration Loading Overlay */}
|
{/* Migration Loading Overlay */}
|
||||||
<MigrationOverlayContainer />
|
<MigrationOverlayContainer />
|
||||||
|
|
||||||
|
{/* Bounce/Render Overlay */}
|
||||||
|
<BounceOverlayContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,3 +237,26 @@ const MigrationOverlayContainer: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bounce/render overlay — shown during offline WAV rendering
|
||||||
|
const BounceOverlayContainer: React.FC = () => {
|
||||||
|
const [isRendering, setIsRendering] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffectReact(() => {
|
||||||
|
const renderer = KGOfflineRenderer.instance();
|
||||||
|
const listener = (evt: RenderingEvent) => {
|
||||||
|
setIsRendering(evt.type === 'start');
|
||||||
|
};
|
||||||
|
renderer.addRenderingListener(listener);
|
||||||
|
return () => {
|
||||||
|
renderer.removeRenderingListener(listener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={isRendering}
|
||||||
|
message="Bouncing to WAV..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { regionDeleteManager } from '../util/regionDeleteUtil';
|
|||||||
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
||||||
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
||||||
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||||
|
import { KGOfflineRenderer } from '../core/audio-interface/KGOfflineRenderer';
|
||||||
import KGDropdown from './common/KGDropdown';
|
import KGDropdown from './common/KGDropdown';
|
||||||
import FileImportModal from './common/FileImportModal';
|
import FileImportModal from './common/FileImportModal';
|
||||||
import OpenProjectModal from './common/OpenProjectModal';
|
import OpenProjectModal from './common/OpenProjectModal';
|
||||||
@@ -82,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"];
|
const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV"];
|
||||||
|
|
||||||
const handleProjectNameClick = () => {
|
const handleProjectNameClick = () => {
|
||||||
const newName = prompt("Enter project name:", projectName);
|
const newName = prompt("Enter project name:", projectName);
|
||||||
@@ -202,6 +203,8 @@ const Toolbar: React.FC = () => {
|
|||||||
handleExportKGStudio();
|
handleExportKGStudio();
|
||||||
} else if (exportType === "Export to MIDI file") {
|
} else if (exportType === "Export to MIDI file") {
|
||||||
handleExportMIDI();
|
handleExportMIDI();
|
||||||
|
} else if (exportType === "Export to WAV") {
|
||||||
|
handleBounceToWav();
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowExportDropdown(false);
|
setShowExportDropdown(false);
|
||||||
@@ -285,6 +288,22 @@ const Toolbar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBounceToWav = async () => {
|
||||||
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
|
console.log("bouncing to WAV");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentProject = KGCore.instance().getCurrentProject();
|
||||||
|
await KGOfflineRenderer.instance().bounceToWav(currentProject, projectName);
|
||||||
|
setStatus(`Project "${projectName}" exported as WAV file`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error bouncing to WAV:", error);
|
||||||
|
setStatus(`Error exporting WAV: ${error}`);
|
||||||
|
window.alert(`Failed to export project as WAV: ${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");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
z-index: 20000;
|
z-index: 20000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { encodeWav } from './KGOfflineRenderer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a minimal AudioBuffer-like object for testing.
|
||||||
|
* In the jsdom test environment, AudioBuffer is not available,
|
||||||
|
* so we create a plain object that matches the interface used by encodeWav.
|
||||||
|
*/
|
||||||
|
function createMockAudioBuffer(
|
||||||
|
options: { numberOfChannels: number; sampleRate: number; length: number },
|
||||||
|
channelData?: Float32Array[]
|
||||||
|
): AudioBuffer {
|
||||||
|
const channels = channelData ?? Array.from({ length: options.numberOfChannels }, () =>
|
||||||
|
new Float32Array(options.length)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
numberOfChannels: options.numberOfChannels,
|
||||||
|
sampleRate: options.sampleRate,
|
||||||
|
length: options.length,
|
||||||
|
duration: options.length / options.sampleRate,
|
||||||
|
getChannelData: (ch: number) => channels[ch],
|
||||||
|
} as unknown as AudioBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('encodeWav', () => {
|
||||||
|
it('should produce a valid RIFF/WAV header for stereo 44100Hz', () => {
|
||||||
|
const audioBuffer = createMockAudioBuffer({
|
||||||
|
numberOfChannels: 2,
|
||||||
|
sampleRate: 44100,
|
||||||
|
length: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = encodeWav(audioBuffer);
|
||||||
|
const view = new DataView(result);
|
||||||
|
|
||||||
|
// RIFF header
|
||||||
|
expect(String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3))).toBe('RIFF');
|
||||||
|
expect(String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11))).toBe('WAVE');
|
||||||
|
|
||||||
|
// File size field: total - 8
|
||||||
|
const dataSize = 100 * 2 * 2; // 100 frames * 2 channels * 2 bytes
|
||||||
|
expect(view.getUint32(4, true)).toBe(44 + dataSize - 8);
|
||||||
|
|
||||||
|
// fmt sub-chunk
|
||||||
|
expect(String.fromCharCode(view.getUint8(12), view.getUint8(13), view.getUint8(14), view.getUint8(15))).toBe('fmt ');
|
||||||
|
expect(view.getUint32(16, true)).toBe(16); // PCM sub-chunk size
|
||||||
|
expect(view.getUint16(20, true)).toBe(1); // audio format = PCM
|
||||||
|
expect(view.getUint16(22, true)).toBe(2); // channels
|
||||||
|
expect(view.getUint32(24, true)).toBe(44100); // sample rate
|
||||||
|
expect(view.getUint32(28, true)).toBe(44100 * 4); // byte rate (sampleRate * blockAlign)
|
||||||
|
expect(view.getUint16(32, true)).toBe(4); // block align (channels * bytesPerSample)
|
||||||
|
expect(view.getUint16(34, true)).toBe(16); // bits per sample
|
||||||
|
|
||||||
|
// data sub-chunk
|
||||||
|
expect(String.fromCharCode(view.getUint8(36), view.getUint8(37), view.getUint8(38), view.getUint8(39))).toBe('data');
|
||||||
|
expect(view.getUint32(40, true)).toBe(dataSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce correct header for mono 48000Hz', () => {
|
||||||
|
const audioBuffer = createMockAudioBuffer({
|
||||||
|
numberOfChannels: 1,
|
||||||
|
sampleRate: 48000,
|
||||||
|
length: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = encodeWav(audioBuffer);
|
||||||
|
const view = new DataView(result);
|
||||||
|
|
||||||
|
expect(view.getUint16(22, true)).toBe(1); // 1 channel
|
||||||
|
expect(view.getUint32(24, true)).toBe(48000); // sample rate
|
||||||
|
expect(view.getUint16(32, true)).toBe(2); // block align (1 * 2)
|
||||||
|
expect(view.getUint32(28, true)).toBe(48000 * 2); // byte rate
|
||||||
|
expect(view.getUint32(40, true)).toBe(50 * 2); // data size
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct total buffer size', () => {
|
||||||
|
const audioBuffer = createMockAudioBuffer({
|
||||||
|
numberOfChannels: 2,
|
||||||
|
sampleRate: 44100,
|
||||||
|
length: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = encodeWav(audioBuffer);
|
||||||
|
// 44 header + 200 frames * 2 channels * 2 bytes
|
||||||
|
expect(result.byteLength).toBe(44 + 200 * 2 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly convert float32 samples to int16', () => {
|
||||||
|
const left = new Float32Array([0, 1, -1, 0.5, -0.5]);
|
||||||
|
const right = new Float32Array([0, -1, 1, -0.5, 0.5]);
|
||||||
|
|
||||||
|
const audioBuffer = createMockAudioBuffer(
|
||||||
|
{ numberOfChannels: 2, sampleRate: 44100, length: 5 },
|
||||||
|
[left, right]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = encodeWav(audioBuffer);
|
||||||
|
const view = new DataView(result);
|
||||||
|
|
||||||
|
// Sample data starts at offset 44, interleaved L/R as int16 LE
|
||||||
|
// Sample 0: L=0 → 0, R=0 → 0
|
||||||
|
expect(view.getInt16(44, true)).toBe(0);
|
||||||
|
expect(view.getInt16(46, true)).toBe(0);
|
||||||
|
|
||||||
|
// Sample 1: L=1.0 → 32767, R=-1.0 → -32768
|
||||||
|
expect(view.getInt16(48, true)).toBe(32767);
|
||||||
|
expect(view.getInt16(50, true)).toBe(-32768);
|
||||||
|
|
||||||
|
// Sample 2: L=-1.0 → -32768, R=1.0 → 32767
|
||||||
|
expect(view.getInt16(52, true)).toBe(-32768);
|
||||||
|
expect(view.getInt16(54, true)).toBe(32767);
|
||||||
|
|
||||||
|
// Sample 3: L=0.5 → ~16383, R=-0.5 → ~-16384
|
||||||
|
expect(view.getInt16(56, true)).toBeCloseTo(16383, -1);
|
||||||
|
expect(view.getInt16(58, true)).toBeCloseTo(-16384, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp values outside [-1, 1]', () => {
|
||||||
|
const data = new Float32Array([1.5, -1.5]);
|
||||||
|
|
||||||
|
const audioBuffer = createMockAudioBuffer(
|
||||||
|
{ numberOfChannels: 1, sampleRate: 44100, length: 2 },
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = encodeWav(audioBuffer);
|
||||||
|
const view = new DataView(result);
|
||||||
|
|
||||||
|
// 1.5 clamped to 1.0 → 32767
|
||||||
|
expect(view.getInt16(44, true)).toBe(32767);
|
||||||
|
// -1.5 clamped to -1.0 → -32768
|
||||||
|
expect(view.getInt16(46, true)).toBe(-32768);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero-length audio', () => {
|
||||||
|
const audioBuffer = createMockAudioBuffer({
|
||||||
|
numberOfChannels: 2,
|
||||||
|
sampleRate: 44100,
|
||||||
|
length: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = encodeWav(audioBuffer);
|
||||||
|
expect(result.byteLength).toBe(44); // header only
|
||||||
|
const view = new DataView(result);
|
||||||
|
expect(view.getUint32(40, true)).toBe(0); // data size = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import * as Tone from 'tone';
|
||||||
|
import type { KGProject } from '../KGProject';
|
||||||
|
import type { KGMidiNote } from '../midi/KGMidiNote';
|
||||||
|
import type { KGAudioRegion } from '../region/KGAudioRegion';
|
||||||
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||||
|
import { pitchToNoteNameString } from '../../util/midiUtil';
|
||||||
|
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||||
|
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||||
|
import { KGAudioInterface } from './KGAudioInterface';
|
||||||
|
|
||||||
|
export interface RenderOptions {
|
||||||
|
sampleRate?: number; // default 44100
|
||||||
|
channels?: number; // default 2 (stereo)
|
||||||
|
tailSeconds?: number; // extra seconds after last note for release/reverb (default 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderingEvent {
|
||||||
|
type: 'start' | 'end' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KGOfflineRenderer - Singleton for bouncing/rendering a project to audio.
|
||||||
|
* Uses Tone.Offline() to render faster-than-realtime via OfflineAudioContext.
|
||||||
|
*/
|
||||||
|
export class KGOfflineRenderer {
|
||||||
|
private static _instance: KGOfflineRenderer | null = null;
|
||||||
|
|
||||||
|
private _isRendering = false;
|
||||||
|
private renderingListeners: Array<(_evt: RenderingEvent) => void> = [];
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
console.log('KGOfflineRenderer initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static instance(): KGOfflineRenderer {
|
||||||
|
if (!KGOfflineRenderer._instance) {
|
||||||
|
KGOfflineRenderer._instance = new KGOfflineRenderer();
|
||||||
|
}
|
||||||
|
return KGOfflineRenderer._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== EVENT LISTENERS =====
|
||||||
|
|
||||||
|
public addRenderingListener(listener: (_evt: RenderingEvent) => void): void {
|
||||||
|
this.renderingListeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeRenderingListener(listener: (_evt: RenderingEvent) => void): void {
|
||||||
|
this.renderingListeners = this.renderingListeners.filter(l => l !== listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRenderingEvent(evt: RenderingEvent): void {
|
||||||
|
for (const listener of this.renderingListeners) {
|
||||||
|
try { listener(evt); } catch { /* swallow */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isRendering(): boolean {
|
||||||
|
return this._isRendering;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PUBLIC API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the project to a ToneAudioBuffer using Tone.Offline.
|
||||||
|
*/
|
||||||
|
public async renderToBuffer(project: KGProject, options?: RenderOptions): Promise<Tone.ToneAudioBuffer> {
|
||||||
|
const sampleRate = options?.sampleRate ?? 44100;
|
||||||
|
const channels = options?.channels ?? 2;
|
||||||
|
const tailSeconds = options?.tailSeconds ?? 2;
|
||||||
|
|
||||||
|
// Calculate render duration in seconds
|
||||||
|
const bpm = project.getBpm();
|
||||||
|
const secondsPerBeat = 60 / bpm;
|
||||||
|
const timeSignature = project.getTimeSignature();
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
|
||||||
|
let renderStartBeat = 0;
|
||||||
|
let renderEndBeat: number;
|
||||||
|
|
||||||
|
const isLooping = project.getIsLooping();
|
||||||
|
// Looping range is determined up-front; non-looping range is computed
|
||||||
|
// after collecting track data (see below).
|
||||||
|
if (isLooping) {
|
||||||
|
const [startBar, endBarOriginal] = project.getLoopingRange();
|
||||||
|
const endBar = (startBar === 0 && endBarOriginal === 0) ? project.getMaxBars() : endBarOriginal;
|
||||||
|
renderStartBeat = startBar * beatsPerBar;
|
||||||
|
renderEndBeat = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
|
||||||
|
} else {
|
||||||
|
// Placeholder — will be refined after track data collection
|
||||||
|
renderEndBeat = project.getMaxBars() * beatsPerBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine solo state from the live audio buses
|
||||||
|
const audioInterface = KGAudioInterface.instance();
|
||||||
|
|
||||||
|
// Collect track info we'll need inside the offline callback
|
||||||
|
const tracks = project.getTracks();
|
||||||
|
|
||||||
|
// Pre-collect all the data we need before entering the offline context
|
||||||
|
const midiTrackData: Array<{
|
||||||
|
trackId: string;
|
||||||
|
instrumentName: string;
|
||||||
|
volume: number;
|
||||||
|
muted: boolean;
|
||||||
|
solo: boolean;
|
||||||
|
regions: Array<{
|
||||||
|
startBeat: number;
|
||||||
|
notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
|
||||||
|
}>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const audioTrackData: Array<{
|
||||||
|
trackId: string;
|
||||||
|
volume: number;
|
||||||
|
muted: boolean;
|
||||||
|
solo: boolean;
|
||||||
|
regions: Array<{
|
||||||
|
startBeat: number;
|
||||||
|
lengthBeats: number;
|
||||||
|
audioFileId: string;
|
||||||
|
clipStartOffsetSeconds: number;
|
||||||
|
audioDurationSeconds: number;
|
||||||
|
rawBuffer: AudioBuffer;
|
||||||
|
}>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let hasSoloedTracks = false;
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
const trackId = track.getId().toString();
|
||||||
|
|
||||||
|
if (track.getType() === 'MIDI') {
|
||||||
|
const midiTrack = track as unknown as { getInstrument: () => string };
|
||||||
|
const instrumentName = String(midiTrack.getInstrument());
|
||||||
|
|
||||||
|
// Get live bus state for volume/mute/solo via public getters
|
||||||
|
const volume = audioInterface.getTrackVolume(trackId);
|
||||||
|
const muted = audioInterface.getTrackMuted(trackId);
|
||||||
|
const solo = audioInterface.getTrackSolo(trackId);
|
||||||
|
if (solo) hasSoloedTracks = true;
|
||||||
|
|
||||||
|
const regions: typeof midiTrackData[0]['regions'] = [];
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (region.getCurrentType() === 'KGMidiRegion') {
|
||||||
|
const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] };
|
||||||
|
if (midiRegion.getNotes) {
|
||||||
|
const notes = midiRegion.getNotes().map(note => ({
|
||||||
|
startBeat: note.getStartBeat() + region.getStartFromBeat(),
|
||||||
|
endBeat: note.getEndBeat() + region.getStartFromBeat(),
|
||||||
|
durationBeats: note.getEndBeat() - note.getStartBeat(),
|
||||||
|
pitch: note.getPitch(),
|
||||||
|
velocity: note.getVelocity(),
|
||||||
|
}));
|
||||||
|
regions.push({ startBeat: region.getStartFromBeat(), notes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions });
|
||||||
|
} else if (track.getType() === 'Wave') {
|
||||||
|
const volume = audioInterface.getTrackVolume(trackId);
|
||||||
|
const muted = audioInterface.getTrackMuted(trackId);
|
||||||
|
const solo = audioInterface.getTrackSolo(trackId);
|
||||||
|
if (solo) hasSoloedTracks = true;
|
||||||
|
|
||||||
|
const regions: typeof audioTrackData[0]['regions'] = [];
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (region.getCurrentType() === 'KGAudioRegion') {
|
||||||
|
const audioRegion = region as unknown as KGAudioRegion;
|
||||||
|
const audioFileId = audioRegion.getAudioFileId();
|
||||||
|
const rawBuffer = audioInterface.getAudioBuffer(trackId, audioFileId);
|
||||||
|
if (rawBuffer) {
|
||||||
|
regions.push({
|
||||||
|
startBeat: region.getStartFromBeat(),
|
||||||
|
lengthBeats: region.getLength(),
|
||||||
|
audioFileId,
|
||||||
|
clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(),
|
||||||
|
audioDurationSeconds: audioRegion.getAudioDurationSeconds(),
|
||||||
|
rawBuffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioTrackData.push({ trackId, volume, muted, solo, regions });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-looping mode, tighten the render range to the actual content
|
||||||
|
if (!isLooping) {
|
||||||
|
let contentStart = Infinity;
|
||||||
|
let contentEnd = 0;
|
||||||
|
|
||||||
|
for (const t of midiTrackData) {
|
||||||
|
for (const r of t.regions) {
|
||||||
|
for (const n of r.notes) {
|
||||||
|
if (n.startBeat < contentStart) contentStart = n.startBeat;
|
||||||
|
if (n.endBeat > contentEnd) contentEnd = n.endBeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of audioTrackData) {
|
||||||
|
for (const r of t.regions) {
|
||||||
|
if (r.startBeat < contentStart) contentStart = r.startBeat;
|
||||||
|
const regionEnd = r.startBeat + r.lengthBeats;
|
||||||
|
if (regionEnd > contentEnd) contentEnd = regionEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentEnd > 0) {
|
||||||
|
renderStartBeat = contentStart;
|
||||||
|
renderEndBeat = contentEnd;
|
||||||
|
}
|
||||||
|
// else: no content found, keep the full project range as fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationSeconds = (renderEndBeat - renderStartBeat) * secondsPerBeat + tailSeconds;
|
||||||
|
|
||||||
|
console.log(`Offline render: ${durationSeconds}s (beats ${renderStartBeat}-${renderEndBeat}), ${sampleRate}Hz, ${channels}ch`);
|
||||||
|
|
||||||
|
// Run offline render
|
||||||
|
const buffer = await Tone.Offline(async (context) => {
|
||||||
|
// Master gain routed to offline destination
|
||||||
|
const masterGain = new Tone.Gain(1).toDestination();
|
||||||
|
|
||||||
|
// Set BPM and time signature on offline transport
|
||||||
|
context.transport.bpm.value = bpm;
|
||||||
|
context.transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
||||||
|
|
||||||
|
// ---- Create MIDI track samplers ----
|
||||||
|
const samplerPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const trackInfo of midiTrackData) {
|
||||||
|
if (!shouldPlay(trackInfo, hasSoloedTracks)) continue;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
// Get cached buffers from pool
|
||||||
|
const audioBuffers = await KGToneBuffersPool.instance().getToneAudioBuffers(trackInfo.instrumentName);
|
||||||
|
const pitchRange = FLUIDR3_INSTRUMENT_MAP[trackInfo.instrumentName]?.pitchRange || [21, 108];
|
||||||
|
const urlMap = KGToneSamplerFactory.instance().convertBuffersToUrls(audioBuffers, pitchRange);
|
||||||
|
|
||||||
|
// Create sampler inside offline context
|
||||||
|
const sampler = await new Promise<Tone.Sampler>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error(`Offline sampler timeout: ${trackInfo.instrumentName}`)), 30000);
|
||||||
|
const s = new Tone.Sampler({
|
||||||
|
urls: urlMap,
|
||||||
|
onload: () => { clearTimeout(timeout); resolve(s); },
|
||||||
|
onerror: (err) => { clearTimeout(timeout); reject(err); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply volume
|
||||||
|
const volumeDb = trackInfo.volume > 0 ? 20 * Math.log10(trackInfo.volume) : -Infinity;
|
||||||
|
sampler.volume.value = volumeDb;
|
||||||
|
sampler.connect(masterGain);
|
||||||
|
|
||||||
|
// Schedule all notes for this track
|
||||||
|
for (const regionInfo of trackInfo.regions) {
|
||||||
|
for (const note of regionInfo.notes) {
|
||||||
|
// Skip notes outside render range
|
||||||
|
if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue;
|
||||||
|
|
||||||
|
const offsetBeat = note.startBeat - renderStartBeat;
|
||||||
|
const noteStartTime = offsetBeat * secondsPerBeat;
|
||||||
|
const noteDuration = note.durationBeats * secondsPerBeat;
|
||||||
|
const noteName = pitchToNoteNameString(note.pitch);
|
||||||
|
const velocity = note.velocity / 127;
|
||||||
|
|
||||||
|
context.transport.schedule((time) => {
|
||||||
|
sampler.triggerAttackRelease(noteName, noteDuration, time, velocity);
|
||||||
|
}, noteStartTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Offline render: failed to create sampler for ${trackInfo.instrumentName}:`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
samplerPromises.push(promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Create audio track gain nodes and schedule regions ----
|
||||||
|
for (const trackInfo of audioTrackData) {
|
||||||
|
if (!shouldPlay(trackInfo, hasSoloedTracks)) continue;
|
||||||
|
|
||||||
|
const trackGain = new Tone.Gain(trackInfo.volume);
|
||||||
|
trackGain.connect(masterGain);
|
||||||
|
|
||||||
|
for (const regionInfo of trackInfo.regions) {
|
||||||
|
const regionStartBeat = regionInfo.startBeat;
|
||||||
|
const regionEndBeat = regionStartBeat + regionInfo.lengthBeats;
|
||||||
|
|
||||||
|
// Skip regions outside render range
|
||||||
|
if (regionStartBeat >= renderEndBeat || regionEndBeat <= renderStartBeat) continue;
|
||||||
|
|
||||||
|
const clipStartOffsetSeconds = regionInfo.clipStartOffsetSeconds;
|
||||||
|
const audioDurationSeconds = regionInfo.audioDurationSeconds;
|
||||||
|
const regionLengthSeconds = regionInfo.lengthBeats * secondsPerBeat;
|
||||||
|
const effectiveDurationSeconds = Math.min(regionLengthSeconds, audioDurationSeconds - clipStartOffsetSeconds);
|
||||||
|
|
||||||
|
if (effectiveDurationSeconds <= 0) continue;
|
||||||
|
|
||||||
|
const offsetBeat = regionStartBeat - renderStartBeat;
|
||||||
|
const regionStartTime = Math.max(0, offsetBeat * secondsPerBeat);
|
||||||
|
|
||||||
|
// Create buffer source NOW while the offline context is still active.
|
||||||
|
// Schedule callbacks fire during rendering after Tone.js restores the
|
||||||
|
// main context, so creating nodes there would bind them to the wrong context.
|
||||||
|
const toneBuffer = new Tone.ToneAudioBuffer(regionInfo.rawBuffer);
|
||||||
|
const source = new Tone.ToneBufferSource(toneBuffer);
|
||||||
|
source.connect(trackGain);
|
||||||
|
|
||||||
|
context.transport.schedule((time) => {
|
||||||
|
source.start(time, clipStartOffsetSeconds, effectiveDurationSeconds);
|
||||||
|
}, regionStartTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all samplers to load
|
||||||
|
await Promise.all(samplerPromises);
|
||||||
|
|
||||||
|
// Start offline transport
|
||||||
|
context.transport.start(0);
|
||||||
|
}, durationSeconds, channels, sampleRate);
|
||||||
|
|
||||||
|
console.log(`Offline render complete: ${buffer.duration}s, ${buffer.numberOfChannels}ch`);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the project and download as a WAV file.
|
||||||
|
*/
|
||||||
|
public async bounceToWav(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 WAV...' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const toneBuffer = await this.renderToBuffer(project, options);
|
||||||
|
const audioBuffer = toneBuffer.get() as AudioBuffer;
|
||||||
|
const wavData = encodeWav(audioBuffer);
|
||||||
|
|
||||||
|
// Trigger download
|
||||||
|
const blob = new Blob([wavData], { type: 'audio/wav' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${fileName ?? 'bounce'}.wav`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
this.emitRenderingEvent({ type: 'end', message: 'Bounce complete' });
|
||||||
|
console.log('WAV bounce complete');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bounce to WAV failed:', error);
|
||||||
|
this.emitRenderingEvent({ type: 'error', message: String(error) });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this._isRendering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HELPERS =====
|
||||||
|
|
||||||
|
function shouldPlay(trackInfo: { muted: boolean; solo: boolean }, hasSoloedTracks: boolean): boolean {
|
||||||
|
if (trackInfo.muted) return false;
|
||||||
|
if (hasSoloedTracks) return trackInfo.solo;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== WAV ENCODER =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode an AudioBuffer as a 16-bit PCM WAV file.
|
||||||
|
* Returns the complete WAV file as an ArrayBuffer.
|
||||||
|
*/
|
||||||
|
export function encodeWav(audioBuffer: AudioBuffer): ArrayBuffer {
|
||||||
|
const numChannels = audioBuffer.numberOfChannels;
|
||||||
|
const sampleRate = audioBuffer.sampleRate;
|
||||||
|
const numFrames = audioBuffer.length;
|
||||||
|
const bitsPerSample = 16;
|
||||||
|
const bytesPerSample = bitsPerSample / 8;
|
||||||
|
const blockAlign = numChannels * bytesPerSample;
|
||||||
|
const dataSize = numFrames * blockAlign;
|
||||||
|
const headerSize = 44;
|
||||||
|
const totalSize = headerSize + dataSize;
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(totalSize);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
// Collect channel data
|
||||||
|
const channels: Float32Array[] = [];
|
||||||
|
for (let ch = 0; ch < numChannels; ch++) {
|
||||||
|
channels.push(audioBuffer.getChannelData(ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
// RIFF header
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
view.setUint32(4, totalSize - 8, true); // file size - 8
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
|
||||||
|
// fmt sub-chunk
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true); // sub-chunk size (16 for PCM)
|
||||||
|
view.setUint16(20, 1, true); // audio format (1 = PCM)
|
||||||
|
view.setUint16(22, numChannels, true);
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
view.setUint16(34, bitsPerSample, true);
|
||||||
|
|
||||||
|
// data sub-chunk
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
view.setUint32(40, dataSize, true);
|
||||||
|
|
||||||
|
// Interleave and convert float32 [-1, 1] to int16
|
||||||
|
let offset = headerSize;
|
||||||
|
for (let i = 0; i < numFrames; i++) {
|
||||||
|
for (let ch = 0; ch < numChannels; ch++) {
|
||||||
|
const sample = channels[ch][i];
|
||||||
|
// Clamp to [-1, 1] then scale to int16 range
|
||||||
|
const clamped = Math.max(-1, Math.min(1, sample));
|
||||||
|
const int16 = clamped < 0 ? clamped * 0x8000 : clamped * 0x7FFF;
|
||||||
|
view.setInt16(offset, int16, true);
|
||||||
|
offset += bytesPerSample;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeString(view: DataView, offset: number, str: string): void {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
view.setUint8(offset + i, str.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ export class KGToneSamplerFactory {
|
|||||||
* Convert ToneAudioBuffers to the URL format expected by Tone.Sampler
|
* Convert ToneAudioBuffers to the URL format expected by Tone.Sampler
|
||||||
* This creates a mapping from note names to the actual audio buffers
|
* This creates a mapping from note names to the actual audio buffers
|
||||||
*/
|
*/
|
||||||
private convertBuffersToUrls(audioBuffers: Tone.ToneAudioBuffers, range: number[] = [21, 118]): { [key: string]: Tone.ToneAudioBuffer } {
|
public convertBuffersToUrls(audioBuffers: Tone.ToneAudioBuffers, range: number[] = [21, 118]): { [key: string]: Tone.ToneAudioBuffer } {
|
||||||
const urls: { [key: string]: Tone.ToneAudioBuffer } = {};
|
const urls: { [key: string]: Tone.ToneAudioBuffer } = {};
|
||||||
|
|
||||||
// Note names in order (using flats instead of sharps where applicable)
|
// Note names in order (using flats instead of sharps where applicable)
|
||||||
|
|||||||
Reference in New Issue
Block a user