fix: round interpolated MIDI values to integers to deduplicate repeated consecutive values
This commit is contained in:
@@ -140,6 +140,30 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
expect(scheduledTimes).toContain(0.5);
|
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<string, unknown> }).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', () => {
|
it('computes the initial interpolated bend for non-zero playback starts', () => {
|
||||||
const region = createMockMidiRegion({
|
const region = createMockMidiRegion({
|
||||||
pitchBends: [
|
pitchBends: [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer';
|
import { applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer';
|
||||||
|
import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a minimal AudioBuffer-like object for testing.
|
* 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][1]).toBe(0.5);
|
||||||
expect(calls[0][0]).toBeCloseTo(Math.pow(2, -2 / 12), 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<typeof applyOfflinePitchBendAutomation>[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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,30 @@ describe('midiAutomationUtil', () => {
|
|||||||
expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 5 })).toHaveLength(100);
|
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', () => {
|
it('treats flat segments as holds without adding interior baked points', () => {
|
||||||
const points = [
|
const points = [
|
||||||
{ beat: 0, value: 4096 },
|
{ 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', () => {
|
it('adds a window anchor and preserves the correct loop boundary value', () => {
|
||||||
const points = [
|
const points = [
|
||||||
{ beat: 2, value: 0 },
|
{ beat: 2, value: 0 },
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { clampMidiPitchBendValue } from './midiUtil';
|
||||||
|
|
||||||
export interface MidiAutomationPoint {
|
export interface MidiAutomationPoint {
|
||||||
beat: number;
|
beat: number;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -16,6 +18,10 @@ export interface MidiAutomationBakeOptions {
|
|||||||
|
|
||||||
const BEAT_EPSILON = 1e-9;
|
const BEAT_EPSILON = 1e-9;
|
||||||
|
|
||||||
|
function quantizeBakedValue(value: number): number {
|
||||||
|
return clampMidiPitchBendValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
function appendPoint(points: BakedMidiAutomationPoint[], nextPoint: BakedMidiAutomationPoint): void {
|
function appendPoint(points: BakedMidiAutomationPoint[], nextPoint: BakedMidiAutomationPoint): void {
|
||||||
const lastPoint = points[points.length - 1];
|
const lastPoint = points[points.length - 1];
|
||||||
if (!lastPoint) {
|
if (!lastPoint) {
|
||||||
@@ -117,7 +123,7 @@ export function bakeMidiAutomationPointsInWindow(
|
|||||||
const normalizedPoints = normalizeMidiAutomationPoints(points);
|
const normalizedPoints = normalizeMidiAutomationPoints(points);
|
||||||
const anchorPoint = {
|
const anchorPoint = {
|
||||||
beat: windowStartBeat,
|
beat: windowStartBeat,
|
||||||
value: resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue),
|
value: quantizeBakedValue(resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (windowEndBeat <= windowStartBeat) {
|
if (windowEndBeat <= windowStartBeat) {
|
||||||
@@ -131,7 +137,7 @@ export function bakeMidiAutomationPointsInWindow(
|
|||||||
|
|
||||||
const firstPoint = normalizedPoints[0];
|
const firstPoint = normalizedPoints[0];
|
||||||
if (windowStartBeat < firstPoint.beat && firstPoint.beat < windowEndBeat) {
|
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);
|
const maxIntervalBeats = getMaxIntervalBeats(options);
|
||||||
@@ -145,6 +151,22 @@ export function bakeMidiAutomationPointsInWindow(
|
|||||||
continue;
|
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) {
|
if (startPoint.value === endPoint.value) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -162,7 +184,7 @@ export function bakeMidiAutomationPointsInWindow(
|
|||||||
|
|
||||||
appendPoint(bakedPoints, {
|
appendPoint(bakedPoints, {
|
||||||
beat,
|
beat,
|
||||||
value: interpolateBetweenPoints(startPoint, endPoint, beat),
|
value: quantizeBakedValue(interpolateBetweenPoints(startPoint, endPoint, beat)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user