Enhance Ableton export logic with scaled note velocity and filter fader canonicalization

This commit is contained in:
2026-07-14 11:49:08 +02:00
parent b74c13621d
commit 82a4c73295
6 changed files with 70 additions and 13 deletions

View File

@@ -52,6 +52,16 @@ export function getFaderValueAt(
return value; 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) { function getStaticValue(pattern: AblGroupPatternOccurrence, parameter: FaderParam) {
const value = pattern.faderParams[parameter]; const value = pattern.faderParams[parameter];
return value === -1 ? getFaderDefaultValue(parameter) : value; return value === -1 ? getFaderDefaultValue(parameter) : value;

View File

@@ -20,7 +20,7 @@ import {
AbletonAutomationTargets, AbletonAutomationTargets,
buildArrangementEnvelopes, buildArrangementEnvelopes,
buildClipEnvelopes, buildClipEnvelopes,
getFaderValueAt, getScaledNoteVelocity,
} from './automation'; } from './automation';
import { import {
chorusEffectValues, chorusEffectValues,
@@ -30,7 +30,11 @@ import {
filterEffectValues, filterEffectValues,
reverbEffectValues, reverbEffectValues,
} from './effects'; } from './effects';
import { convertGroupFaderValue, getFaderDefaultValue } from './faderPolicies'; import {
canonicalizeFilterFaderValue,
convertGroupFaderValue,
getFaderDefaultValue,
} from './faderPolicies';
import { AbletonMidiControllerTargets, buildMidiClipEnvelopes } from './midi'; import { AbletonMidiControllerTargets, buildMidiClipEnvelopes } from './midi';
import { getPadEnvelopeState, getPadSoundState } from './padSound'; import { getPadEnvelopeState, getPadSoundState } from './padSound';
import { getSimplerPlaybackState } from './playback'; import { getSimplerPlaybackState } from './playback';
@@ -295,15 +299,10 @@ async function buildMidiClip(
dur = nextNote.position / 96 - noteData.position / 96; dur = nextNote.position / 96 - noteData.position / 96;
} }
const automatedVelocity = koClip.groupPattern const velocity = getScaledNoteVelocity(
? getFaderValueAt(koClip.groupPattern, FaderParam.VEL, noteData.position) noteData.velocity,
: undefined; koClip.groupPattern,
const velocity = Math.max( noteData.position,
1,
Math.min(
127,
automatedVelocity === undefined ? noteData.velocity : automatedVelocity * 127,
),
); );
return { return {
@@ -644,7 +643,10 @@ async function buildTrack(
if (exporterParams.includeArchivedSamples) { if (exporterParams.includeArchivedSamples) {
for (const parameter of [FaderParam.LPF, FaderParam.HPF] as const) { 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 defaultValue = getFaderDefaultValue(parameter);
const hasAutomation = automatedParameters.has(parameter); const hasAutomation = automatedParameters.has(parameter);
if (hasAutomation || (staticValue !== -1 && staticValue !== defaultValue)) { if (hasAutomation || (staticValue !== -1 && staticValue !== defaultValue)) {

View File

@@ -93,7 +93,7 @@ export const FADER_POLICIES = {
toLive: (value) => Math.max(-0.5, Math.min(0.5, value - 0.5)) * 100, toLive: (value) => Math.max(-0.5, Math.min(0.5, value - 0.5)) * 100,
}, },
[FaderParam.VEL]: { [FaderParam.VEL]: {
defaultValue: 0, defaultValue: 1,
renderStrategy: 'note', renderStrategy: 'note',
targetScope: 'track', targetScope: 'track',
status: 'research', status: 'research',
@@ -116,6 +116,14 @@ export function getFaderDefaultValue(parameter: FaderParam) {
return getFaderPolicy(parameter).defaultValue; 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( export function convertGroupFaderValue(
parameter: FaderParam, parameter: FaderParam,
value: number, value: number,

View File

@@ -5,6 +5,7 @@ import {
buildClipEnvelopes, buildClipEnvelopes,
convertFaderValue, convertFaderValue,
getFaderValueAt, getFaderValueAt,
getScaledNoteVelocity,
} from '../src/lib/exporters/ableton/automation'; } from '../src/lib/exporters/ableton/automation';
import { AblGroupPatternOccurrence, AblTrack } from '../src/lib/transformers/ableton'; import { AblGroupPatternOccurrence, AblTrack } from '../src/lib/transformers/ableton';
import { FaderParam } from '../src/types/types'; import { FaderParam } from '../src/types/types';
@@ -49,6 +50,28 @@ const track = {
test('uses original note velocity until a VEL point is reached', () => { 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, 96), undefined);
assert.equal(getFaderValueAt(occurrence, FaderParam.VEL, 192), 0.5); 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', () => { test('preserves each pad pitch offset while applying group PTC automation', () => {

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
canonicalizeFilterFaderValue,
convertGroupFaderValue, convertGroupFaderValue,
FADER_POLICIES, FADER_POLICIES,
getFaderPolicy, 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.PAN, 0.75, { track }), 0.5);
assert.equal(convertGroupFaderValue(FaderParam.PTC, 0.75, { track, valueOffset: 7 }), 9.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);
});

View File

@@ -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.ATK] = 0.66;
data.settings.groupFaderParams.a[FaderParam.REL] = 0.87; 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]; 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]; delete data.groupPatterns[1].notes[0];
data.groupPatterns[1].notes[1] = [note(67, 48), note(68, 96)]; data.groupPatterns[1].notes[1] = [note(67, 48), note(68, 96)];
data.songPositions = []; data.songPositions = [];
@@ -272,7 +275,10 @@ test('serializes nested Live 10 Simplers without artificial empty Session clips'
assert.doesNotMatch(xml, /<ReturnBranch\s/); assert.doesNotMatch(xml, /<ReturnBranch\s/);
assert.doesNotMatch(xml, /<AudioBranchSendInfo\s/); assert.doesNotMatch(xml, /<AudioBranchSendInfo\s/);
assert.doesNotMatch(xml, /Big Break Kit/); assert.doesNotMatch(xml, /Big Break Kit/);
assert.doesNotMatch(xml, /<AutoFilter\b/);
assert.doesNotMatch(xml, /<ClipEnvelope\b/); assert.doesNotMatch(xml, /<ClipEnvelope\b/);
assert.equal([...xml.matchAll(/<MidiNoteEvent\b[^>]*\sVelocity="68"/g)].length, 2);
assert.equal([...xml.matchAll(/<MidiNoteEvent\b[^>]*\sVelocity="26"/g)].length, 1);
} finally { } finally {
await server.close(); await server.close();
} }