diff --git a/src/lib/exporters/ableton/automation.ts b/src/lib/exporters/ableton/automation.ts index 3dfc853..a184289 100644 --- a/src/lib/exporters/ableton/automation.ts +++ b/src/lib/exporters/ableton/automation.ts @@ -52,6 +52,16 @@ export function getFaderValueAt( return value; } +export function getScaledNoteVelocity( + originalVelocity: number, + occurrence: AblGroupPatternOccurrence | undefined, + position: number, +) { + const faderValue = getFaderValueAt(occurrence, FaderParam.VEL, position); + const velocity = faderValue === undefined ? originalVelocity : originalVelocity * faderValue; + return Math.max(1, Math.min(127, Math.round(velocity))); +} + function getStaticValue(pattern: AblGroupPatternOccurrence, parameter: FaderParam) { const value = pattern.faderParams[parameter]; return value === -1 ? getFaderDefaultValue(parameter) : value; diff --git a/src/lib/exporters/ableton/builders.ts b/src/lib/exporters/ableton/builders.ts index a9e362e..9f7adbd 100644 --- a/src/lib/exporters/ableton/builders.ts +++ b/src/lib/exporters/ableton/builders.ts @@ -20,7 +20,7 @@ import { AbletonAutomationTargets, buildArrangementEnvelopes, buildClipEnvelopes, - getFaderValueAt, + getScaledNoteVelocity, } from './automation'; import { chorusEffectValues, @@ -30,7 +30,11 @@ import { filterEffectValues, reverbEffectValues, } from './effects'; -import { convertGroupFaderValue, getFaderDefaultValue } from './faderPolicies'; +import { + canonicalizeFilterFaderValue, + convertGroupFaderValue, + getFaderDefaultValue, +} from './faderPolicies'; import { AbletonMidiControllerTargets, buildMidiClipEnvelopes } from './midi'; import { getPadEnvelopeState, getPadSoundState } from './padSound'; import { getSimplerPlaybackState } from './playback'; @@ -295,15 +299,10 @@ async function buildMidiClip( dur = nextNote.position / 96 - noteData.position / 96; } - const automatedVelocity = koClip.groupPattern - ? getFaderValueAt(koClip.groupPattern, FaderParam.VEL, noteData.position) - : undefined; - const velocity = Math.max( - 1, - Math.min( - 127, - automatedVelocity === undefined ? noteData.velocity : automatedVelocity * 127, - ), + const velocity = getScaledNoteVelocity( + noteData.velocity, + koClip.groupPattern, + noteData.position, ); return { @@ -644,7 +643,10 @@ async function buildTrack( if (exporterParams.includeArchivedSamples) { for (const parameter of [FaderParam.LPF, FaderParam.HPF] as const) { - const staticValue = koTrack.faderParams[parameter]; + const staticValue = canonicalizeFilterFaderValue( + parameter, + koTrack.faderParams[parameter], + ); const defaultValue = getFaderDefaultValue(parameter); const hasAutomation = automatedParameters.has(parameter); if (hasAutomation || (staticValue !== -1 && staticValue !== defaultValue)) { diff --git a/src/lib/exporters/ableton/faderPolicies.ts b/src/lib/exporters/ableton/faderPolicies.ts index a6ed579..01bd627 100644 --- a/src/lib/exporters/ableton/faderPolicies.ts +++ b/src/lib/exporters/ableton/faderPolicies.ts @@ -93,7 +93,7 @@ export const FADER_POLICIES = { toLive: (value) => Math.max(-0.5, Math.min(0.5, value - 0.5)) * 100, }, [FaderParam.VEL]: { - defaultValue: 0, + defaultValue: 1, renderStrategy: 'note', targetScope: 'track', status: 'research', @@ -116,6 +116,14 @@ export function getFaderDefaultValue(parameter: FaderParam) { return getFaderPolicy(parameter).defaultValue; } +export function canonicalizeFilterFaderValue( + parameter: FaderParam.LPF | FaderParam.HPF, + value: number, +) { + const defaultValue = getFaderDefaultValue(parameter); + return Math.abs(value - defaultValue) <= 0.001 ? defaultValue : value; +} + export function convertGroupFaderValue( parameter: FaderParam, value: number, diff --git a/tests/ableton-automation.test.ts b/tests/ableton-automation.test.ts index 0a35c0e..24e126e 100644 --- a/tests/ableton-automation.test.ts +++ b/tests/ableton-automation.test.ts @@ -5,6 +5,7 @@ import { buildClipEnvelopes, convertFaderValue, getFaderValueAt, + getScaledNoteVelocity, } from '../src/lib/exporters/ableton/automation'; import { AblGroupPatternOccurrence, AblTrack } from '../src/lib/transformers/ableton'; import { FaderParam } from '../src/types/types'; @@ -49,6 +50,28 @@ const track = { test('uses original note velocity until a VEL point is reached', () => { assert.equal(getFaderValueAt(occurrence, FaderParam.VEL, 96), undefined); assert.equal(getFaderValueAt(occurrence, FaderParam.VEL, 192), 0.5); + assert.equal(getScaledNoteVelocity(101, occurrence, 96), 101); + assert.equal(getScaledNoteVelocity(101, occurrence, 192), 51); +}); + +test('scales P07-like note velocities with static and automated VEL values', () => { + const staticVelocity = { + ...occurrence, + faderParams: { + ...occurrence.faderParams, + [FaderParam.VEL]: 0.5325199961662292, + }, + faderAutomation: [ + { parameter: FaderParam.VEL, position: 192, value: 0.25, flags: 0 }, + ], + }; + + assert.deepEqual( + [127, 127, 48].map((velocity) => getScaledNoteVelocity(velocity, staticVelocity, 96)), + [68, 68, 26], + ); + assert.equal(getScaledNoteVelocity(127, staticVelocity, 192), 32); + assert.equal(getScaledNoteVelocity(0, staticVelocity, 192), 1); }); test('preserves each pad pitch offset while applying group PTC automation', () => { diff --git a/tests/ableton-fader-policies.test.ts b/tests/ableton-fader-policies.test.ts index 06e8e74..ff7e0aa 100644 --- a/tests/ableton-fader-policies.test.ts +++ b/tests/ableton-fader-policies.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + canonicalizeFilterFaderValue, convertGroupFaderValue, FADER_POLICIES, getFaderPolicy, @@ -30,3 +31,10 @@ test('uses one group-fader conversion path for mixer and pad targets', () => { assert.equal(convertGroupFaderValue(FaderParam.PAN, 0.75, { track }), 0.5); assert.equal(convertGroupFaderValue(FaderParam.PTC, 0.75, { track, valueOffset: 7 }), 9.5); }); + +test('canonicalizes only effectively-open filter fader values', () => { + assert.equal(canonicalizeFilterFaderValue(FaderParam.LPF, 0.9996299743652344), 1); + assert.equal(canonicalizeFilterFaderValue(FaderParam.HPF, 0.0003700256347656), 0); + assert.equal(canonicalizeFilterFaderValue(FaderParam.LPF, 0.998), 0.998); + assert.equal(canonicalizeFilterFaderValue(FaderParam.HPF, 0.002), 0.002); +}); diff --git a/tests/ableton-hybrid.test.ts b/tests/ableton-hybrid.test.ts index a4ce827..b6dd868 100644 --- a/tests/ableton-hybrid.test.ts +++ b/tests/ableton-hybrid.test.ts @@ -213,7 +213,10 @@ test('serializes nested Live 10 Simplers without artificial empty Session clips' }); data.settings.groupFaderParams.a[FaderParam.ATK] = 0.66; data.settings.groupFaderParams.a[FaderParam.REL] = 0.87; + data.settings.groupFaderParams.a[FaderParam.LPF] = 0.9996299743652344; + data.settings.groupFaderParams.a[FaderParam.VEL] = 0.5325199961662292; delete data.groupPatterns[0].notes[1]; + data.groupPatterns[0].notes[0] = [note(72, 0, 127), note(72, 96, 127), note(72, 192, 48)]; delete data.groupPatterns[1].notes[0]; data.groupPatterns[1].notes[1] = [note(67, 48), note(68, 96)]; data.songPositions = []; @@ -272,7 +275,10 @@ test('serializes nested Live 10 Simplers without artificial empty Session clips' assert.doesNotMatch(xml, /]*\sVelocity="68"/g)].length, 2); + assert.equal([...xml.matchAll(/]*\sVelocity="26"/g)].length, 1); } finally { await server.close(); }