diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts index d6d373e..8574722 100644 --- a/src/core/audio-interface/KGAudioInterface.test.ts +++ b/src/core/audio-interface/KGAudioInterface.test.ts @@ -140,6 +140,30 @@ describe('KGAudioInterface preroll playback', () => { expect(scheduledTimes).toContain(0.5); }); + it('skips redundant scheduled pitch bend events when interpolated values round to the same MIDI value', () => { + const region = createMockMidiRegion({ + pitchBends: [ + createMockMidiPitchBend({ id: 'bend-1', beat: 0, value: MIDI_PITCH_BEND_CENTER }), + createMockMidiPitchBend({ id: 'bend-2', beat: 1, value: MIDI_PITCH_BEND_CENTER + 1 }), + ], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + const project = createMockProject({ tracks: [track] }); + const audio = KGAudioInterface.instance(); + const audioBus = { + resetLiveMidiPitchBend: vi.fn(), + setLiveMidiPitchBend: vi.fn(), + scheduleLiveMidiPitchBend: vi.fn(), + shouldPlayWithSolo: vi.fn().mockReturnValue(true), + }; + + ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); + audio.preparePlayback(project, 0); + + const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time); + expect(scheduledTimes).toEqual([0.25]); + }); + it('computes the initial interpolated bend for non-zero playback starts', () => { const region = createMockMidiRegion({ pitchBends: [ diff --git a/src/core/audio-interface/KGOfflineRenderer.test.ts b/src/core/audio-interface/KGOfflineRenderer.test.ts index eeee333..c184320 100644 --- a/src/core/audio-interface/KGOfflineRenderer.test.ts +++ b/src/core/audio-interface/KGOfflineRenderer.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer'; +import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil'; /** * Create a minimal AudioBuffer-like object for testing. @@ -192,4 +193,38 @@ describe('offline pitch bend automation', () => { expect(calls[0][1]).toBe(0.5); expect(calls[0][0]).toBeCloseTo(Math.pow(2, -2 / 12), 5); }); + + it('applies only distinct quantized pitch bend transitions from a shallow ramp', () => { + const source = { + playbackRate: { + value: 1, + setValueAtTime: (..._args: unknown[]) => undefined, + }, + } as unknown as Parameters[0]; + + const calls: Array<[number, number]> = []; + source.playbackRate.setValueAtTime = ((value: number, time: number) => { + calls.push([value, time]); + return source.playbackRate as never; + }) as typeof source.playbackRate.setValueAtTime; + + const baked = bakeMidiAutomationPointsInWindow( + [ + { beat: 0, value: 8192 }, + { beat: 1, value: 8193 }, + ], + 0, + 1, + { + maxIntervalMs: 20, + bpm: 120, + defaultValue: 8192, + } + ); + + applyOfflinePitchBendAutomation(source, 1, baked.filter(point => point.beat > 0), 0, 0.5); + + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(0.26); + }); }); diff --git a/src/util/midiAutomationUtil.test.ts b/src/util/midiAutomationUtil.test.ts index 50da516..9835574 100644 --- a/src/util/midiAutomationUtil.test.ts +++ b/src/util/midiAutomationUtil.test.ts @@ -71,6 +71,30 @@ describe('midiAutomationUtil', () => { expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 5 })).toHaveLength(100); }); + it('quantizes the window anchor to an integer MIDI pitch-bend value', () => { + const points = [ + { beat: 0, value: 8192 }, + { beat: 1, value: 8193 }, + ]; + + expect(bakeMidiAutomationPointsInWindow(points, 0.5, 1, { ...defaultOptions, maxIntervalMs: 20 })[0]).toEqual({ + beat: 0.5, + value: 8193, + }); + }); + + it('drops adjacent interpolated points that round to the same MIDI value', () => { + const points = [ + { beat: 0, value: 8192 }, + { beat: 1, value: 8193 }, + ]; + + expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 20 })).toEqual([ + { beat: 0, value: 8192 }, + { beat: 0.52, value: 8193 }, + ]); + }); + it('treats flat segments as holds without adding interior baked points', () => { const points = [ { beat: 0, value: 4096 }, @@ -112,6 +136,17 @@ describe('midiAutomationUtil', () => { ]); }); + it('stores baked interpolated values as integers', () => { + const points = [ + { beat: 0, value: 8192 }, + { beat: 3, value: 8195 }, + ]; + + const baked = bakeMidiAutomationPointsInWindow(points, 0, 3, { ...defaultOptions, maxIntervalMs: 500 }); + + expect(baked.every(point => Number.isInteger(point.value))).toBe(true); + }); + it('adds a window anchor and preserves the correct loop boundary value', () => { const points = [ { beat: 2, value: 0 }, diff --git a/src/util/midiAutomationUtil.ts b/src/util/midiAutomationUtil.ts index dbddec3..81ee0bb 100644 --- a/src/util/midiAutomationUtil.ts +++ b/src/util/midiAutomationUtil.ts @@ -1,3 +1,5 @@ +import { clampMidiPitchBendValue } from './midiUtil'; + export interface MidiAutomationPoint { beat: number; value: number; @@ -16,6 +18,10 @@ export interface MidiAutomationBakeOptions { const BEAT_EPSILON = 1e-9; +function quantizeBakedValue(value: number): number { + return clampMidiPitchBendValue(value); +} + function appendPoint(points: BakedMidiAutomationPoint[], nextPoint: BakedMidiAutomationPoint): void { const lastPoint = points[points.length - 1]; if (!lastPoint) { @@ -117,7 +123,7 @@ export function bakeMidiAutomationPointsInWindow( const normalizedPoints = normalizeMidiAutomationPoints(points); const anchorPoint = { beat: windowStartBeat, - value: resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue), + value: quantizeBakedValue(resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue)), }; if (windowEndBeat <= windowStartBeat) { @@ -131,7 +137,7 @@ export function bakeMidiAutomationPointsInWindow( const firstPoint = normalizedPoints[0]; if (windowStartBeat < firstPoint.beat && firstPoint.beat < windowEndBeat) { - appendPoint(bakedPoints, { beat: firstPoint.beat, value: firstPoint.value }); + appendPoint(bakedPoints, { beat: firstPoint.beat, value: quantizeBakedValue(firstPoint.value) }); } const maxIntervalBeats = getMaxIntervalBeats(options); @@ -145,6 +151,22 @@ export function bakeMidiAutomationPointsInWindow( continue; } + // MIDI pitch bend playback here is event-based, not continuous on its own. + // The last emitted value is held until a later baked event changes it. + // + // That means a flat authored span such as: + // beat 1 -> value 0 + // beat 2 -> value 0 + // beat 3 -> value 100 + // should *not* generate any intermediate events between beats 1 and 2. + // The value from beat 1 is simply held through beat 2, and the bend only + // begins once we emit the first baked point from the changing 2 -> 3 segment. + // + // In practice that first changing baked point may land slightly after beat 2 + // depending on the bake interval (for example 10 ms), but it still does not + // cause an earlier gradual bend from beat 1. Skipping flat segments preserves + // the intended "hold, then change" behavior while also avoiding redundant + // scheduled pitch-bend events. if (startPoint.value === endPoint.value) { continue; } @@ -162,7 +184,7 @@ export function bakeMidiAutomationPointsInWindow( appendPoint(bakedPoints, { beat, - value: interpolateBetweenPoints(startPoint, endPoint, beat), + value: quantizeBakedValue(interpolateBetweenPoints(startPoint, endPoint, beat)), }); } }