diff --git a/src/engine/SynthEngine.ts b/src/engine/SynthEngine.ts index 99a4652f..be087f8d 100644 --- a/src/engine/SynthEngine.ts +++ b/src/engine/SynthEngine.ts @@ -12,7 +12,7 @@ import { getLegacySynthPresetFromInstrument, } from '../utils/trackInstrument'; -type SynthSource = TrackInstrument | SynthPreset; +export type SynthSource = TrackInstrument | SynthPreset; type RuntimeInstrument = SubtractiveTrackInstrument | FmTrackInstrument; type SynthVoiceType = 'mono' | 'fm'; type SynthModulationEffectType = 'tremolo' | 'autoPanner' | 'autoFilter' | 'vibrato'; @@ -23,15 +23,20 @@ const MAX_FAT_SPREAD_CENTS = 120; interface RuntimeModulationRack { node: Tone.ToneAudioNode; retriggerOnNote: boolean; - restart: () => void; + restart: (time?: number) => void; dispose: () => void; } -interface SynthInstance { +export interface SynthPlaybackChain { synth: Tone.PolySynth; - signature: string; gain: Tone.Gain; - modulation: RuntimeModulationRack | null; + restartModulation: (time?: number) => void; + dispose: () => void; +} + +interface SynthInstance { + playback: SynthPlaybackChain; + signature: string; } export interface SynthRuntimeSpec { @@ -50,6 +55,12 @@ export interface SynthModulationSpec { options: Record; } +interface CreateSynthPlaybackChainOptions { + gainScale?: number; + connectTo?: Tone.InputNode; + routeToDestination?: boolean; +} + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } @@ -318,11 +329,11 @@ function createRuntimeModulationRack(source: SynthSource): RuntimeModulationRack return { node, retriggerOnNote: spec.retrigger, - restart: () => { + restart: (time) => { if (!spec.retrigger) return; - const now = Tone.now(); - node.stop(now); - node.start(now + 0.001); + const restartAt = time ?? Tone.now(); + node.stop(restartAt); + node.start(restartAt + 0.001); }, dispose: () => node.dispose(), }; @@ -333,11 +344,11 @@ function createRuntimeModulationRack(source: SynthSource): RuntimeModulationRack return { node, retriggerOnNote: spec.retrigger, - restart: () => { + restart: (time) => { if (!spec.retrigger) return; - const now = Tone.now(); - node.stop(now); - node.start(now + 0.001); + const restartAt = time ?? Tone.now(); + node.stop(restartAt); + node.start(restartAt + 0.001); }, dispose: () => node.dispose(), }; @@ -348,11 +359,11 @@ function createRuntimeModulationRack(source: SynthSource): RuntimeModulationRack return { node, retriggerOnNote: spec.retrigger, - restart: () => { + restart: (time) => { if (!spec.retrigger) return; - const now = Tone.now(); - node.stop(now); - node.start(now + 0.001); + const restartAt = time ?? Tone.now(); + node.stop(restartAt); + node.start(restartAt + 0.001); }, dispose: () => node.dispose(), }; @@ -374,6 +385,7 @@ function connectSynthChain( modulation: RuntimeModulationRack | null, gain: Tone.Gain, connectTo?: Tone.InputNode, + routeToDestination: boolean = true, ) { if (modulation) { synth.connect(modulation.node); @@ -384,28 +396,49 @@ function connectSynthChain( if (connectTo) { gain.connect(connectTo); - } else { + } else if (routeToDestination) { gain.toDestination(); } } -function restartModulation(instance: SynthInstance | null | undefined) { - if (!instance?.modulation?.retriggerOnNote) return; - instance.modulation.restart(); +export function createSynthPlaybackChain( + source: SynthSource, + options: CreateSynthPlaybackChainOptions = {}, +): SynthPlaybackChain { + const { gainScale = 1, connectTo, routeToDestination = true } = options; + const spec = createSynthRuntimeSpec(source); + const synth = createSynthForSource(source); + const modulation = createRuntimeModulationRack(source); + const gain = new Tone.Gain(Math.max(0, Math.min(2, spec.gainLevel * gainScale))); + connectSynthChain(synth, modulation, gain, connectTo, routeToDestination); + + return { + synth, + gain, + restartModulation: (time) => { + if (!modulation?.retriggerOnNote) return; + modulation.restart(time); + }, + dispose: () => { + synth.releaseAll(); + synth.dispose(); + modulation?.dispose(); + gain.dispose(); + }, + }; +} + +function restartPlaybackModulation(instance: SynthInstance | null | undefined, time?: number) { + instance?.playback.restartModulation(time); } function disposeSynthInstance(instance: SynthInstance) { - instance.synth.releaseAll(); - instance.synth.dispose(); - instance.modulation?.dispose(); - instance.gain.dispose(); + instance.playback.dispose(); } class SynthEngine { private synths = new Map(); - private previewSynth: Tone.PolySynth | null = null; - private previewGain: Tone.Gain | null = null; - private previewModulation: RuntimeModulationRack | null = null; + private previewPlayback: SynthPlaybackChain | null = null; private previewSignature: string | null = null; async ensureStarted() { @@ -417,51 +450,42 @@ class SynthEngine { ensureTrackSynth(trackId: string, source: SynthSource, connectTo?: Tone.InputNode): Tone.PolySynth { const signature = getSynthSignature(source); const existing = this.synths.get(trackId); - if (existing && existing.signature === signature) return existing.synth; + if (existing && existing.signature === signature) return existing.playback.synth; if (existing) { disposeSynthInstance(existing); } - const spec = createSynthRuntimeSpec(source); - const synth = createSynthForSource(source); - const modulation = createRuntimeModulationRack(source); - const gain = new Tone.Gain(spec.gainLevel); - connectSynthChain(synth, modulation, gain, connectTo); - this.synths.set(trackId, { synth, signature, gain, modulation }); - return synth; + const playback = createSynthPlaybackChain(source, { connectTo, routeToDestination: true }); + this.synths.set(trackId, { playback, signature }); + return playback.synth; } getSynth(trackId: string): Tone.PolySynth | null { - return this.synths.get(trackId)?.synth ?? null; + return this.synths.get(trackId)?.playback.synth ?? null; } async previewNote(pitch: number, velocity = 100, duration = 0.3, source: SynthSource = 'piano') { await this.ensureStarted(); const signature = getSynthSignature(source); - if (!this.previewSynth || !this.previewGain || this.previewSignature !== signature) { - this.previewSynth?.dispose(); - this.previewModulation?.dispose(); - this.previewGain?.dispose(); - const spec = createSynthRuntimeSpec(source); - this.previewSynth = createSynthForSource(source); - this.previewModulation = createRuntimeModulationRack(source); - this.previewGain = new Tone.Gain(Math.max(0, Math.min(1, spec.gainLevel * 0.55))); - connectSynthChain(this.previewSynth, this.previewModulation, this.previewGain); + if (!this.previewPlayback || this.previewSignature !== signature) { + this.previewPlayback?.dispose(); + this.previewPlayback = createSynthPlaybackChain(source, { + gainScale: 0.55, + routeToDestination: true, + }); this.previewSignature = signature; } const freq = Tone.Frequency(pitch, 'midi').toFrequency(); - if (this.previewModulation?.retriggerOnNote) { - this.previewModulation.restart(); - } - this.previewSynth.triggerAttackRelease(freq, duration, undefined, velocity / 127); + this.previewPlayback.restartModulation(); + this.previewPlayback.synth.triggerAttackRelease(freq, duration, undefined, velocity / 127); } async playNote(trackId: string, pitch: number, velocity: number, duration: number, source: SynthSource) { await this.ensureStarted(); const synth = this.ensureTrackSynth(trackId, source); - restartModulation(this.synths.get(trackId)); + restartPlaybackModulation(this.synths.get(trackId)); const freq = Tone.Frequency(pitch, 'midi').toFrequency(); synth.triggerAttackRelease(freq, duration, undefined, velocity / 127); } @@ -481,7 +505,7 @@ class SynthEngine { triggerRelease: (note: number, time?: string | number) => void; triggerAttackRelease: (note: number, duration: number, time?: string | number, velocity?: number) => void; }; - restartModulation(this.synths.get(trackId)); + restartPlaybackModulation(this.synths.get(trackId)); const glideTime = Math.max(0.03, Math.min(0.12, duration * 0.35)); const fromFreq = Tone.Frequency(fromPitch, 'midi').toFrequency(); const toFreq = Tone.Frequency(toPitch, 'midi').toFrequency(); @@ -495,9 +519,9 @@ class SynthEngine { noteOn(trackId: string, pitch: number, velocity = 100) { const instance = this.synths.get(trackId); if (!instance) return; - restartModulation(instance); + restartPlaybackModulation(instance); const freq = Tone.Frequency(pitch, 'midi').toFrequency(); - instance.synth.triggerAttack(freq, undefined, velocity / 127); + instance.playback.synth.triggerAttack(freq, undefined, velocity / 127); } /** Trigger note off for a track synth. */ @@ -505,13 +529,13 @@ class SynthEngine { const instance = this.synths.get(trackId); if (!instance) return; const freq = Tone.Frequency(pitch, 'midi').toFrequency(); - instance.synth.triggerRelease(freq); + instance.playback.synth.triggerRelease(freq); } /** Release all currently sounding notes on all track synths. */ releaseAll() { for (const instance of this.synths.values()) { - instance.synth.releaseAll(); + instance.playback.synth.releaseAll(); } } @@ -526,12 +550,8 @@ class SynthEngine { for (const trackId of this.synths.keys()) { this.removeTrackSynth(trackId); } - this.previewSynth?.dispose(); - this.previewModulation?.dispose(); - this.previewGain?.dispose(); - this.previewSynth = null; - this.previewModulation = null; - this.previewGain = null; + this.previewPlayback?.dispose(); + this.previewPlayback = null; this.previewSignature = null; } } diff --git a/src/engine/__tests__/exportMix.test.ts b/src/engine/__tests__/exportMix.test.ts new file mode 100644 index 00000000..870c625a --- /dev/null +++ b/src/engine/__tests__/exportMix.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Project, Track } from '../../types/project'; +import { buildTrackExportClips } from '../exportMix'; +import { createDefaultFmInstrument, createDefaultSamplerInstrument } from '../../utils/trackInstrument'; + +const mockLoadAudioBlobByKey = vi.fn(); +const mockRenderMidiTrackOffline = vi.fn(); +const mockRenderSamplerTrackOffline = vi.fn(); +const mockRenderSequencerTrackOffline = vi.fn(); + +vi.mock('../../services/audioFileManager', () => ({ + loadAudioBlobByKey: (...args: unknown[]) => mockLoadAudioBlobByKey(...args), +})); + +vi.mock('../offlineRender', () => ({ + renderMidiTrackOffline: (...args: unknown[]) => mockRenderMidiTrackOffline(...args), + renderSamplerTrackOffline: (...args: unknown[]) => mockRenderSamplerTrackOffline(...args), + renderSequencerTrackOffline: (...args: unknown[]) => mockRenderSequencerTrackOffline(...args), +})); + +function createMockAudioBuffer(duration = 1, sample = 0.25): AudioBuffer { + const sampleRate = 48_000; + const length = Math.ceil(duration * sampleRate); + const left = new Float32Array(length).fill(sample); + const right = new Float32Array(length).fill(sample); + return { + duration, + sampleRate, + length, + numberOfChannels: 2, + getChannelData: (channelIndex: number) => (channelIndex === 0 ? left : right), + copyFromChannel: vi.fn(), + copyToChannel: vi.fn(), + } as unknown as AudioBuffer; +} + +function makeProject(track: Track): Project { + return { + id: 'project-1', + name: 'Render Test', + createdAt: Date.now(), + updatedAt: Date.now(), + bpm: 120, + keyScale: 'C major', + timeSignature: 4, + totalDuration: 8, + globalCaption: '', + generationDefaults: { inferenceSteps: 8, guidanceScale: 3, shift: 0, thinking: false, model: 'test' }, + tracks: [track], + markers: [], + assets: [], + trackPresets: [], + automationLanes: [], + returnTracks: [], + tempoMap: [], + timeSignatureMap: [], + mastering: undefined, + measures: 8, + masterVolume: 0.8, + } as Project; +} + +function makeAudioDecoder() { + return { + decodeAudioData: vi.fn(async () => createMockAudioBuffer(0.75, 0.2)), + }; +} + +describe('buildTrackExportClips', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadAudioBlobByKey.mockResolvedValue(new Blob(['audio'])); + mockRenderMidiTrackOffline.mockResolvedValue(createMockAudioBuffer(1.5, 0.18)); + mockRenderSamplerTrackOffline.mockResolvedValue(createMockAudioBuffer(1.5, 0.18)); + mockRenderSequencerTrackOffline.mockResolvedValue(createMockAudioBuffer(1.5, 0.18)); + }); + + it('passes canonical FM instruments through to offline midi rendering', async () => { + const instrument = createDefaultFmInstrument({ + name: 'FM Bell', + fallbackPreset: 'lead', + }); + const track = { + id: 'track-fm', + trackName: 'synth', + trackType: 'pianoRoll', + displayName: 'FM Lead', + color: '#60a5fa', + order: 1, + volume: 0.8, + pan: -0.1, + muted: false, + soloed: false, + instrument, + synthPreset: 'lead', + sampler: undefined, + samplerConfig: undefined, + effects: [], + clips: [{ + id: 'clip-1', + trackId: 'track-fm', + startTime: 0, + duration: 2, + prompt: 'Hook', + lyrics: '', + generationStatus: 'empty', + generationJobId: null, + cumulativeMixKey: null, + isolatedAudioKey: null, + waveformPeaks: null, + midiData: { + notes: [{ id: 'n1', pitch: 60, startBeat: 0, durationBeats: 1, velocity: 0.8 }], + grid: '1/16', + }, + }], + } as Track; + + await buildTrackExportClips(makeProject(track), track, makeAudioDecoder()); + + expect(mockRenderMidiTrackOffline).toHaveBeenCalledWith( + expect.any(Array), + 0, + 120, + expect.objectContaining({ + kind: 'fm', + name: 'FM Bell', + }), + 8, + ); + }); + + it('uses canonical sampler state even when legacy sampler mirrors are empty', async () => { + const instrument = createDefaultSamplerInstrument({ + audioKey: 'audio:test:sampler', + sampleName: 'Glass Vox', + rootNote: 48, + sampleDuration: 1.5, + trimEnd: 1.25, + loopEnd: 1.1, + }); + const track = { + id: 'track-sampler', + trackName: 'keyboard', + trackType: 'pianoRoll', + displayName: 'Quick Sampler', + color: '#34d399', + order: 1, + volume: 0.85, + pan: 0, + muted: false, + soloed: false, + instrument, + synthPreset: undefined, + sampler: undefined, + samplerConfig: undefined, + effects: [], + clips: [{ + id: 'clip-1', + trackId: 'track-sampler', + startTime: 0, + duration: 2, + prompt: 'Vox', + lyrics: '', + generationStatus: 'empty', + generationJobId: null, + cumulativeMixKey: null, + isolatedAudioKey: null, + waveformPeaks: null, + midiData: { + notes: [{ id: 'n1', pitch: 60, startBeat: 0, durationBeats: 1, velocity: 0.75 }], + grid: '1/16', + }, + }], + } as Track; + + await buildTrackExportClips(makeProject(track), track, makeAudioDecoder()); + + expect(mockLoadAudioBlobByKey).toHaveBeenCalledWith('audio:test:sampler'); + expect(mockRenderSamplerTrackOffline).toHaveBeenCalledWith( + expect.any(Array), + 0, + 120, + expect.any(Object), + expect.objectContaining({ + audioKey: 'audio:test:sampler', + rootNote: 48, + trimEnd: 1.25, + loopEnd: 1.1, + }), + 8, + ); + expect(mockRenderMidiTrackOffline).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engine/exportMix.ts b/src/engine/exportMix.ts index 83e2a0fb..481b61c7 100644 --- a/src/engine/exportMix.ts +++ b/src/engine/exportMix.ts @@ -6,6 +6,10 @@ import { ensureMasteringState } from '../utils/mastering'; import { loadAudioBlobByKey } from '../services/audioFileManager'; import { renderMidiTrackOffline, renderSamplerTrackOffline, renderSequencerTrackOffline } from './offlineRender'; import { createSamplerConfig } from './SamplerEngine'; +import { + getTrackInstrumentPlaybackSource, + getTrackSamplerPlaybackState, +} from '../utils/trackInstrument'; export interface ExportClip { startTime: number; @@ -106,13 +110,16 @@ export async function buildTrackExportClips( const clips: ExportClip[] = []; if (track.trackType === 'pianoRoll') { + const playbackSource = getTrackInstrumentPlaybackSource(track); + const samplerState = getTrackSamplerPlaybackState(track); + for (const clip of track.clips) { const notes = clip.midiData?.notes ?? []; if (notes.length === 0) continue; let buffer: AudioBuffer | null = null; - if (track.synthPreset === 'sampler' && track.sampler?.audioKey) { - const samplerBlob = await loadAudioBlobByKey(track.sampler.audioKey); + if (samplerState) { + const samplerBlob = await loadAudioBlobByKey(samplerState.audioKey); if (samplerBlob) { const sampleBuffer = await audioDecoder.decodeAudioData(samplerBlob); buffer = await renderSamplerTrackOffline( @@ -120,11 +127,7 @@ export async function buildTrackExportClips( clip.startTime, project.bpm, sampleBuffer, - track.samplerConfig ?? createSamplerConfig(track.sampler.audioKey, { - rootNote: track.sampler.rootNote, - trimEnd: track.sampler.sampleDuration, - loopEnd: track.sampler.sampleDuration, - }), + samplerState.config ?? createSamplerConfig(samplerState.audioKey), project.totalDuration, ); } @@ -133,7 +136,7 @@ export async function buildTrackExportClips( notes, clip.startTime, project.bpm, - track.synthPreset ?? 'piano', + playbackSource, project.totalDuration, ); } diff --git a/src/engine/offlineRender.ts b/src/engine/offlineRender.ts index e0ffd8b9..837d90e8 100644 --- a/src/engine/offlineRender.ts +++ b/src/engine/offlineRender.ts @@ -1,8 +1,12 @@ import * as Tone from 'tone'; import type { ToneAudioBuffer } from 'tone'; import { createDrumVoicesForKit } from './DrumEngine'; -import { createSynthForPreset } from './SynthEngine'; -import type { DrumKitName, MidiNote, SamplerConfig, SequencerPattern, SynthPreset } from '../types/project'; +import { + createSynthPlaybackChain, + type SynthPlaybackChain, + type SynthSource, +} from './SynthEngine'; +import type { DrumKitName, MidiNote, SamplerConfig, SequencerPattern } from '../types/project'; const DRUM_PAD_INDEX_BY_SAMPLE_KEY: Record = { kick: 0, @@ -40,14 +44,14 @@ export async function renderMidiTrackOffline( notes: MidiNote[], clipStartTime: number, bpm: number, - synthPreset: SynthPreset, + source: SynthSource, totalDuration: number, sampleRate: number = 48000, ): Promise { + let playback: SynthPlaybackChain | null = null; + const buffer = await Tone.Offline(({ transport }) => { - const synth = createSynthForPreset(synthPreset); - const gain = new Tone.Gain(0.55).toDestination(); - synth.connect(gain); + playback = createSynthPlaybackChain(source, { routeToDestination: true }); transport.bpm.value = bpm; const secondsPerBeat = 60 / bpm; @@ -61,7 +65,8 @@ export async function renderMidiTrackOffline( const velocity = Math.max(0, Math.min(1, note.velocity)); const frequency = Tone.Frequency(note.pitch, 'midi').toFrequency(); transport.schedule((time) => { - synth.triggerAttackRelease(frequency, noteDuration, time, velocity); + playback?.restartModulation(time); + playback?.synth.triggerAttackRelease(frequency, noteDuration, time, velocity); }, noteStart); } diff --git a/src/services/__tests__/freezeTrackService.test.ts b/src/services/__tests__/freezeTrackService.test.ts index bdba60ec..89323bf9 100644 --- a/src/services/__tests__/freezeTrackService.test.ts +++ b/src/services/__tests__/freezeTrackService.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useProjectStore } from '../../store/projectStore'; import { freezeTrackToAudio, flattenTrackToAudio } from '../freezeTrack'; +import { createDefaultFmInstrument } from '../../utils/trackInstrument'; // ─── Mocks ────────────────────────────────────────────────────────────────── @@ -116,6 +117,36 @@ describe('freezeTrackToAudio', () => { expect(track.frozenAudioKey).toBe('frozen-audio-key-123'); }); + it('passes canonical FM instruments into freeze rendering', async () => { + const trackId = useProjectStore.getState().project!.tracks[0].id; + useProjectStore.getState().updateTrack(trackId, { trackType: 'pianoRoll' }); + useProjectStore.getState().setTrackInstrument(trackId, createDefaultFmInstrument({ + name: 'FM Bell', + fallbackPreset: 'lead', + })); + + useProjectStore.getState().addClip(trackId, 0, 4); + const clipId = useProjectStore.getState().project!.tracks[0].clips[0].id; + useProjectStore.getState().addMidiNote(clipId, { + pitch: 64, + startBeat: 0, + durationBeats: 1, + velocity: 0.82, + }); + + const mockBuffer = createMockAudioBuffer(4); + mockRenderMidiTrackOffline.mockResolvedValueOnce(mockBuffer); + + await freezeTrackToAudio(trackId); + + expect(mockRenderMidiTrackOffline).toHaveBeenCalledOnce(); + const [, , , source] = mockRenderMidiTrackOffline.mock.calls[0] ?? []; + expect(source).toMatchObject({ + kind: 'fm', + name: 'FM Bell', + }); + }); + it('renders sequencer track offline when no ready clips exist', async () => { const trackId = useProjectStore.getState().project!.tracks[0].id; useProjectStore.getState().updateTrack(trackId, { trackType: 'sequencer' }); diff --git a/src/services/bounceInPlace.ts b/src/services/bounceInPlace.ts index 0427d109..a1f6ca08 100644 --- a/src/services/bounceInPlace.ts +++ b/src/services/bounceInPlace.ts @@ -19,6 +19,10 @@ import { type Project, type Track, } from '../types/project'; +import { + getTrackInstrumentPlaybackSource, + getTrackSamplerPlaybackState, +} from '../utils/trackInstrument'; const DEFAULT_SAMPLE_RATE = 48_000; const NORMALIZE_TARGET_PEAK = 0.99; @@ -333,8 +337,11 @@ async function renderTrackSourceBuffer( if (track.trackType === 'pianoRoll') { const notes = trimMidiNotesToRange(track, bpm, range); if (notes.length > 0) { - if (track.synthPreset === 'sampler' && track.sampler?.audioKey) { - const blob = await loadAudioBlobByKey(track.sampler.audioKey); + const playbackSource = getTrackInstrumentPlaybackSource(track); + const samplerState = getTrackSamplerPlaybackState(track); + + if (samplerState) { + const blob = await loadAudioBlobByKey(samplerState.audioKey); if (blob) { const sampleBuffer = await getAudioEngine().decodeAudioData(blob); const rendered = await renderSamplerTrackOffline( @@ -342,11 +349,7 @@ async function renderTrackSourceBuffer( 0, bpm, sampleBuffer, - track.samplerConfig ?? createSamplerConfig(track.sampler.audioKey, { - rootNote: track.sampler.rootNote, - trimEnd: track.sampler.sampleDuration, - loopEnd: track.sampler.sampleDuration, - }), + samplerState.config ?? createSamplerConfig(samplerState.audioKey), range.duration, DEFAULT_SAMPLE_RATE, ); @@ -357,7 +360,7 @@ async function renderTrackSourceBuffer( notes, 0, bpm, - track.synthPreset ?? 'piano', + playbackSource, range.duration, DEFAULT_SAMPLE_RATE, ); diff --git a/src/services/freezeTrack.ts b/src/services/freezeTrack.ts index 527ae1df..3a47e8aa 100644 --- a/src/services/freezeTrack.ts +++ b/src/services/freezeTrack.ts @@ -6,7 +6,11 @@ import { audioBufferToWavBlob } from '../utils/wav'; import { computeWaveformPeaks } from '../utils/waveformPeaks'; import { CLIP_WAVEFORM_PEAK_COUNT } from '../utils/clipAudio'; import { getAudioEngine } from '../hooks/useAudioEngine'; -import type { SynthPreset, DrumKitName } from '../types/project'; +import type { DrumKitName } from '../types/project'; +import { + getTrackInstrumentPlaybackSource, + getTrackSamplerPlaybackState, +} from '../utils/trackInstrument'; /** * Freeze a track by rendering its content to a single audio bounce. @@ -31,8 +35,11 @@ export async function freezeTrackToAudio(trackId: string): Promise { })), ); if (allNotes.length > 0) { - if (track.synthPreset === 'sampler' && track.sampler?.audioKey) { - const samplerBlob = await loadAudioBlobByKey(track.sampler.audioKey); + const playbackSource = getTrackInstrumentPlaybackSource(track); + const samplerState = getTrackSamplerPlaybackState(track); + + if (samplerState) { + const samplerBlob = await loadAudioBlobByKey(samplerState.audioKey); if (samplerBlob) { const engine = getAudioEngine(); const sampleBuffer = await engine.decodeAudioData(samplerBlob); @@ -41,11 +48,7 @@ export async function freezeTrackToAudio(trackId: string): Promise { 0, project.bpm, sampleBuffer, - track.samplerConfig ?? createSamplerConfig(track.sampler.audioKey, { - rootNote: track.sampler.rootNote, - trimEnd: track.sampler.sampleDuration, - loopEnd: track.sampler.sampleDuration, - }), + samplerState.config ?? createSamplerConfig(samplerState.audioKey), project.totalDuration, ); } @@ -54,7 +57,7 @@ export async function freezeTrackToAudio(trackId: string): Promise { allNotes, 0, project.bpm, - (track.synthPreset ?? 'piano') as SynthPreset, + playbackSource, project.totalDuration, ); } diff --git a/src/services/projectSharingService.ts b/src/services/projectSharingService.ts index 2b096e20..8fb71c42 100644 --- a/src/services/projectSharingService.ts +++ b/src/services/projectSharingService.ts @@ -6,6 +6,10 @@ import { loadAudioBlobByKey } from './audioFileManager'; import { cloudStorage, type SharedProjectRecord, type SharedStemAsset } from './cloudStorageService'; import { DEFAULT_EXPORT_OPTIONS } from '../utils/audioEncoders'; import type { Project, Track } from '../types/project'; +import { + getTrackInstrumentPlaybackSource, + getTrackSamplerPlaybackState, +} from '../utils/trackInstrument'; export interface ProjectShareProgress { completedTracks: number; @@ -40,6 +44,9 @@ async function renderTrackClips(project: Project, track: Track): Promise { expect(selectValue).toBe('fm'); }); + + it('returns the canonical playback source for fm instruments', () => { + const source = getTrackInstrumentPlaybackSource({ + trackName: 'synth', + trackType: 'pianoRoll', + instrument: createDefaultFmInstrument({ + name: 'FM Bell', + fallbackPreset: 'lead', + }), + synthPreset: 'lead', + sampler: undefined, + samplerConfig: undefined, + }); + + expect(source).toMatchObject({ + kind: 'fm', + name: 'FM Bell', + fallbackPreset: 'lead', + }); + }); + + it('derives sampler playback state from canonical sampler instruments without legacy mirrors', () => { + const samplerState = getTrackSamplerPlaybackState({ + trackName: 'keyboard', + trackType: 'pianoRoll', + instrument: createDefaultSamplerInstrument({ + audioKey: 'audio:test:vox', + sampleName: 'Glass Vox', + rootNote: 48, + sampleDuration: 1.5, + trimEnd: 1.25, + loopEnd: 1.1, + }), + synthPreset: undefined, + sampler: undefined, + samplerConfig: undefined, + }); + + expect(samplerState).toMatchObject({ + audioKey: 'audio:test:vox', + config: { + audioKey: 'audio:test:vox', + rootNote: 48, + trimEnd: 1.25, + loopEnd: 1.1, + }, + }); + }); }); diff --git a/src/utils/trackInstrument.ts b/src/utils/trackInstrument.ts index 5f83d350..d5e842e3 100644 --- a/src/utils/trackInstrument.ts +++ b/src/utils/trackInstrument.ts @@ -321,6 +321,29 @@ export function getTrackSamplerConfigFromInstrument(input: TrackInstrumentSyncIn return buildLegacySamplerState(instrument).samplerConfig ?? null; } +export function getTrackInstrumentPlaybackSource( + input: TrackInstrumentSyncInput, +): TrackInstrument | SynthPreset { + return resolveTrackInstrument(input) + ?? input.synthPreset + ?? getDefaultTrackInstrumentPreset(input.trackName); +} + +export function getTrackSamplerPlaybackState( + input: TrackInstrumentSyncInput, +): { audioKey: string; config: SamplerConfig } | null { + const instrument = resolveTrackInstrument(input); + if (instrument?.kind !== 'sampler') return null; + + const samplerConfig = buildLegacySamplerState(instrument).samplerConfig; + if (!samplerConfig?.audioKey) return null; + + return { + audioKey: samplerConfig.audioKey, + config: samplerConfig, + }; +} + function normalizeExistingInstrument( input: TrackInstrumentSyncInput, instrument: TrackInstrument,