869 lines
31 KiB
TypeScript
869 lines
31 KiB
TypeScript
import * as Tone from 'tone';
|
|
import type { KGProject } from '../KGProject';
|
|
import type { KGMidiNote } from '../midi/KGMidiNote';
|
|
import type { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
|
import type { KGAudioRegion } from '../region/KGAudioRegion';
|
|
import type { InstrumentType } from '../track/KGMidiTrack';
|
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
|
import { clampMidiControllerValue, MIDI_PITCH_BEND_CENTER, midiPitchBendToNormalized } from '../../util/midiUtil';
|
|
import {
|
|
bakeMidiAutomationPointsInWindow,
|
|
collectRegionMidiAutomationPoints,
|
|
normalizeMidiAutomationPoints,
|
|
resolveMidiAutomationValueAtBeat,
|
|
resolveSustainExtendedEndBeat,
|
|
type BakedMidiAutomationPoint,
|
|
type MidiAutomationPoint,
|
|
} from '../../util/midiAutomationUtil';
|
|
import {
|
|
bakeTrackAutomationPointsInWindow,
|
|
getTrackAutomationDefaultValue,
|
|
resolveTrackAutomationValueAtBeat,
|
|
} from '../../util/trackAutomationUtil';
|
|
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
|
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
|
import { KGAudioInterface } from './KGAudioInterface';
|
|
import { KGAudioBus } from './KGAudioBus';
|
|
import { ConfigManager } from '../config/ConfigManager';
|
|
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 {
|
|
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 bounceStartsFromBeat1 = (ConfigManager.instance().get('audio.bounce_starts_from_beat_1') as boolean) ?? true;
|
|
|
|
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: InstrumentType;
|
|
volume: number;
|
|
muted: boolean;
|
|
solo: boolean;
|
|
volumeAutomation: MidiAutomationPoint[];
|
|
panAutomation: MidiAutomationPoint[];
|
|
regions: Array<{
|
|
startBeat: number;
|
|
notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
|
|
}>;
|
|
pitchBends: MidiAutomationPoint[];
|
|
controllerEventsByType: MidiAutomationPoint[][];
|
|
}> = [];
|
|
|
|
const audioTrackData: Array<{
|
|
trackId: string;
|
|
volume: number;
|
|
muted: boolean;
|
|
solo: boolean;
|
|
volumeAutomation: MidiAutomationPoint[];
|
|
panAutomation: MidiAutomationPoint[];
|
|
regions: Array<{
|
|
startBeat: number;
|
|
lengthBeats: number;
|
|
audioFileId: string;
|
|
clipStartOffsetSeconds: number;
|
|
audioDurationSeconds: number;
|
|
rawBuffer: AudioBuffer;
|
|
}>;
|
|
}> = [];
|
|
|
|
let hasSoloedTracks = false;
|
|
const interpolationIntervalMs = (ConfigManager.instance().get('audio.midi_automation_interpolation_interval_ms') as number) ?? 10;
|
|
|
|
for (const track of tracks) {
|
|
const trackId = track.getId().toString();
|
|
|
|
if (track.getType() === 'MIDI') {
|
|
const midiTrack = track as unknown as { getInstrument: () => InstrumentType };
|
|
const instrumentName = 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[]; getPitchBends: () => KGMidiPitchBend[] };
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
const pitchBends = collectRegionMidiAutomationPoints(
|
|
track.getRegions()
|
|
.filter(region => region.getCurrentType() === 'KGMidiRegion')
|
|
.map(region => {
|
|
const midiRegion = region as unknown as { getPitchBends: () => KGMidiPitchBend[] };
|
|
return {
|
|
startBeat: region.getStartFromBeat(),
|
|
points: midiRegion.getPitchBends().map(pitchBend => ({
|
|
beat: pitchBend.getBeat(),
|
|
value: pitchBend.getValue(),
|
|
})),
|
|
};
|
|
})
|
|
);
|
|
const controllerEventsByType = Array.from({ length: 128 }, (_, controller) => (
|
|
collectRegionMidiAutomationPoints(
|
|
track.getRegions()
|
|
.filter(region => region.getCurrentType() === 'KGMidiRegion')
|
|
.map(region => {
|
|
const midiRegion = region as unknown as { getControllerEvents: (controller: number) => Array<{ getBeat: () => number; getValue: () => number }> };
|
|
return {
|
|
startBeat: region.getStartFromBeat(),
|
|
points: midiRegion.getControllerEvents(controller).map(event => ({
|
|
beat: event.getBeat(),
|
|
value: event.getValue(),
|
|
})),
|
|
};
|
|
})
|
|
)
|
|
));
|
|
|
|
const volumeAutomation = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
|
const panAutomation = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
|
midiTrackData.push({ trackId, instrumentName, volume, muted, solo, volumeAutomation, panAutomation, regions, pitchBends, controllerEventsByType });
|
|
} 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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const volumeAutomation = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
|
const panAutomation = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
|
audioTrackData.push({ trackId, volume, muted, solo, volumeAutomation, panAutomation, 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 = bounceStartsFromBeat1 ? 0 : 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(String(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); },
|
|
});
|
|
});
|
|
|
|
// Track volumes are stored in dB across the app, with 0 meaning unity gain.
|
|
sampler.volume.value = 0;
|
|
const trackGain = new Tone.Gain(getOfflineTrackGain(trackInfo.volume, trackInfo.muted));
|
|
const trackPanner = new Tone.Panner(0);
|
|
sampler.connect(trackGain);
|
|
trackGain.connect(trackPanner);
|
|
trackPanner.connect(masterGain);
|
|
applyOfflineTrackAutomation(
|
|
trackGain,
|
|
trackPanner,
|
|
trackInfo.volumeAutomation,
|
|
trackInfo.panAutomation,
|
|
trackInfo.volume,
|
|
renderStartBeat,
|
|
renderEndBeat,
|
|
secondsPerBeat,
|
|
interpolationIntervalMs,
|
|
bpm
|
|
);
|
|
const mergedExpressionEvents = normalizeMidiAutomationPoints(
|
|
[1, 2, 7, 11].flatMap(controller => trackInfo.controllerEventsByType[controller])
|
|
);
|
|
const bakedTrackPitchBends = bakeMidiAutomationPointsInWindow(
|
|
trackInfo.pitchBends,
|
|
renderStartBeat,
|
|
renderEndBeat,
|
|
{
|
|
maxIntervalMs: interpolationIntervalMs,
|
|
bpm: project.getBpm(),
|
|
defaultValue: MIDI_PITCH_BEND_CENTER,
|
|
}
|
|
);
|
|
const bakedExpressionEvents = bakeMidiAutomationPointsInWindow(
|
|
mergedExpressionEvents,
|
|
renderStartBeat,
|
|
renderEndBeat,
|
|
{
|
|
maxIntervalMs: interpolationIntervalMs,
|
|
bpm: project.getBpm(),
|
|
defaultValue: 127,
|
|
interpolationMode: 'linear',
|
|
quantizeValue: clampMidiControllerValue,
|
|
}
|
|
);
|
|
|
|
// 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 sustainedEndBeat = resolveSustainExtendedEndBeat(
|
|
trackInfo.controllerEventsByType[64],
|
|
note.endBeat,
|
|
0
|
|
);
|
|
const noteDuration = Math.max(0, sustainedEndBeat - note.startBeat) * secondsPerBeat;
|
|
const velocity = note.velocity / 127;
|
|
const initialNormalizedPitchBend = midiPitchBendToNormalized(
|
|
resolveMidiAutomationValueAtBeat(trackInfo.pitchBends, note.startBeat, MIDI_PITCH_BEND_CENTER)
|
|
);
|
|
const initialExpression = clampMidiControllerValue(
|
|
resolveMidiAutomationValueAtBeat(mergedExpressionEvents, note.startBeat, 127, 'linear')
|
|
) / 127;
|
|
const offlineSource = createOfflinePitchBendAwareSource(
|
|
sampler,
|
|
audioBuffers,
|
|
trackInfo.instrumentName,
|
|
note.pitch,
|
|
initialNormalizedPitchBend,
|
|
initialExpression
|
|
);
|
|
if (!offlineSource) {
|
|
continue;
|
|
}
|
|
|
|
const { source, basePlaybackRate, gainNode } = offlineSource;
|
|
applyOfflinePitchBendAutomation(
|
|
source,
|
|
basePlaybackRate,
|
|
bakedTrackPitchBends.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat),
|
|
renderStartBeat,
|
|
secondsPerBeat
|
|
);
|
|
applyOfflineExpressionAutomation(
|
|
gainNode,
|
|
bakedExpressionEvents.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat),
|
|
renderStartBeat,
|
|
secondsPerBeat
|
|
);
|
|
|
|
context.transport.schedule((time) => {
|
|
source.start(time, 0, noteDuration, 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(getOfflineTrackGain(trackInfo.volume, trackInfo.muted));
|
|
const trackPanner = new Tone.Panner(0);
|
|
trackGain.connect(trackPanner);
|
|
trackPanner.connect(masterGain);
|
|
applyOfflineTrackAutomation(
|
|
trackGain,
|
|
trackPanner,
|
|
trackInfo.volumeAutomation,
|
|
trackInfo.panAutomation,
|
|
trackInfo.volume,
|
|
renderStartBeat,
|
|
renderEndBeat,
|
|
secondsPerBeat,
|
|
interpolationIntervalMs,
|
|
bpm
|
|
);
|
|
|
|
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;
|
|
}
|
|
}
|
|
/**
|
|
* 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 =====
|
|
|
|
function shouldPlay(trackInfo: { muted: boolean; solo: boolean }, hasSoloedTracks: boolean): boolean {
|
|
if (trackInfo.muted) return false;
|
|
if (hasSoloedTracks) return trackInfo.solo;
|
|
return true;
|
|
}
|
|
|
|
function setOfflinePlaybackRate(
|
|
source: Tone.ToneBufferSource,
|
|
value: number,
|
|
time: number
|
|
): void {
|
|
const playbackRate = source.playbackRate as unknown as {
|
|
value: number;
|
|
setValueAtTime?: (nextValue: number, nextTime: number) => void;
|
|
};
|
|
|
|
if (typeof playbackRate.setValueAtTime === 'function') {
|
|
playbackRate.setValueAtTime(value, time);
|
|
return;
|
|
}
|
|
|
|
playbackRate.value = value;
|
|
}
|
|
|
|
function setOfflineGainValue(gainNode: Tone.Gain, value: number, time: number): void {
|
|
if (typeof gainNode.gain.setValueAtTime === 'function') {
|
|
gainNode.gain.setValueAtTime(value, time);
|
|
return;
|
|
}
|
|
|
|
gainNode.gain.value = value;
|
|
}
|
|
|
|
function setOfflinePanValue(panner: Tone.Panner, value: number, time: number): void {
|
|
if (typeof panner.pan.setValueAtTime === 'function') {
|
|
panner.pan.setValueAtTime(value, time);
|
|
return;
|
|
}
|
|
|
|
panner.pan.value = value;
|
|
}
|
|
|
|
function createOfflinePitchBendAwareSource(
|
|
sampler: Tone.Sampler,
|
|
audioBuffers: Tone.ToneAudioBuffers,
|
|
instrumentName: InstrumentType,
|
|
pitch: number,
|
|
initialNormalizedPitchBend: number,
|
|
initialExpression: number
|
|
): { source: Tone.ToneBufferSource; gainNode: Tone.Gain; basePlaybackRate: number } | null {
|
|
const closestPitch = KGAudioBus.findClosestBufferedPitch(instrumentName, audioBuffers, pitch);
|
|
if (closestPitch === null) {
|
|
return null;
|
|
}
|
|
|
|
const bufferKey = KGAudioBus.midiPitchToBufferKey(closestPitch);
|
|
const buffer = audioBuffers.get(bufferKey);
|
|
if (!buffer) {
|
|
return null;
|
|
}
|
|
|
|
const basePlaybackRate = Math.pow(2, (pitch - closestPitch) / 12);
|
|
const gainNode = new Tone.Gain(initialExpression).connect(sampler.output);
|
|
const source = new Tone.ToneBufferSource({
|
|
url: buffer,
|
|
fadeIn: sampler.attack,
|
|
fadeOut: sampler.release,
|
|
curve: sampler.curve,
|
|
playbackRate: KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, initialNormalizedPitchBend),
|
|
}).connect(gainNode);
|
|
setOfflinePlaybackRate(
|
|
source,
|
|
KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, initialNormalizedPitchBend),
|
|
0
|
|
);
|
|
setOfflineGainValue(gainNode, initialExpression, 0);
|
|
|
|
return { source, gainNode, basePlaybackRate };
|
|
}
|
|
|
|
export function applyOfflinePitchBendAutomation(
|
|
source: Tone.ToneBufferSource,
|
|
basePlaybackRate: number,
|
|
bakedPitchBends: BakedMidiAutomationPoint[],
|
|
renderStartBeat: number,
|
|
secondsPerBeat: number
|
|
): void {
|
|
bakedPitchBends.forEach(point => {
|
|
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
|
setOfflinePlaybackRate(
|
|
source,
|
|
KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, midiPitchBendToNormalized(point.value)),
|
|
automationTime
|
|
);
|
|
});
|
|
}
|
|
|
|
export function applyOfflineExpressionAutomation(
|
|
gainNode: Tone.Gain,
|
|
bakedExpressionEvents: BakedMidiAutomationPoint[],
|
|
renderStartBeat: number,
|
|
secondsPerBeat: number
|
|
): void {
|
|
bakedExpressionEvents.forEach(point => {
|
|
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
|
setOfflineGainValue(gainNode, clampMidiControllerValue(point.value) / 127, automationTime);
|
|
});
|
|
}
|
|
|
|
function applyOfflineTrackAutomation(
|
|
gainNode: Tone.Gain,
|
|
pannerNode: Tone.Panner,
|
|
volumeAutomation: MidiAutomationPoint[],
|
|
panAutomation: MidiAutomationPoint[],
|
|
baseVolume: number,
|
|
renderStartBeat: number,
|
|
renderEndBeat: number,
|
|
secondsPerBeat: number,
|
|
interpolationIntervalMs: number,
|
|
bpm: number
|
|
): void {
|
|
if (volumeAutomation.length > 0) {
|
|
const initialVolume = resolveTrackAutomationValueAtBeat(
|
|
volumeAutomation,
|
|
'volume',
|
|
renderStartBeat,
|
|
getTrackAutomationDefaultValue('volume')
|
|
);
|
|
setOfflineGainValue(gainNode, getOfflineTrackGain(initialVolume, false), 0);
|
|
bakeTrackAutomationPointsInWindow(
|
|
volumeAutomation,
|
|
'volume',
|
|
renderStartBeat,
|
|
renderEndBeat,
|
|
interpolationIntervalMs,
|
|
bpm
|
|
).forEach(point => {
|
|
if (point.beat <= renderStartBeat) {
|
|
return;
|
|
}
|
|
|
|
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
|
setOfflineGainValue(gainNode, getOfflineTrackGain(point.value, false), automationTime);
|
|
});
|
|
} else {
|
|
setOfflineGainValue(gainNode, getOfflineTrackGain(baseVolume, false), 0);
|
|
}
|
|
|
|
if (panAutomation.length > 0) {
|
|
const initialPan = resolveTrackAutomationValueAtBeat(
|
|
panAutomation,
|
|
'pan',
|
|
renderStartBeat,
|
|
getTrackAutomationDefaultValue('pan')
|
|
);
|
|
setOfflinePanValue(pannerNode, initialPan, 0);
|
|
bakeTrackAutomationPointsInWindow(
|
|
panAutomation,
|
|
'pan',
|
|
renderStartBeat,
|
|
renderEndBeat,
|
|
interpolationIntervalMs,
|
|
bpm
|
|
).forEach(point => {
|
|
if (point.beat <= renderStartBeat) {
|
|
return;
|
|
}
|
|
|
|
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
|
setOfflinePanValue(pannerNode, point.value, automationTime);
|
|
});
|
|
} else {
|
|
setOfflinePanValue(pannerNode, 0, 0);
|
|
}
|
|
}
|
|
|
|
export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number {
|
|
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
|
return isSilent ? -Infinity : volumeDb;
|
|
}
|
|
|
|
export function getOfflineTrackGain(volumeDb: number, muted: boolean): number {
|
|
const effectiveVolumeDb = getOfflineTrackVolumeDb(volumeDb, muted);
|
|
return Number.isFinite(effectiveVolumeDb) ? Math.pow(10, effectiveVolumeDb / 20) : 0;
|
|
}
|
|
|
|
// ===== 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));
|
|
}
|
|
}
|
|
|
|
// ===== 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;
|
|
}
|