Update Ableton export logic to use comprehensive playback state parameters

This commit is contained in:
2026-07-13 15:58:10 +02:00
parent cdbe72e775
commit e4ef0f97cd
8 changed files with 28 additions and 125 deletions

View File

@@ -389,7 +389,7 @@ async function buildMidiClip(
async function buildSimplerDevice(koTrack: AblTrack, abletonVersion: AbletonVersion) {
const simplerTemplate = await loadTemplate<ALSSimpler>('simpler', abletonVersion);
const device = structuredClone(simplerTemplate.OriginalSimpler);
const playbackState = getSimplerPlaybackState(koTrack.playMode, koTrack.oneShotOverride);
const playbackState = getSimplerPlaybackState(koTrack.playMode);
const samplePart = device.Player.MultiSampleMap.SampleParts.MultiSamplePart;
const loopModulators = device.Player.LoopModulators;
const simplerFilter = device.Filter.Slot.Value.SimplerFilter;
@@ -415,10 +415,10 @@ async function buildSimplerDevice(koTrack: AblTrack, abletonVersion: AbletonVers
device.VolumeAndPan.OneShotEnvelope.FadeOutTime.Manual['@Value'] = 0;
device.VolumeAndPan.OneShotEnvelope.SustainMode.Manual['@Value'] = 0;
device.Globals.PlaybackMode['@Value'] = playbackState.playbackMode;
if (playbackState.monophonic) {
device.Globals.NumVoices['@Value'] = 1;
device.Globals.RetriggerMode['@Value'] = 'false';
}
device.Globals.NumVoices['@Value'] = playbackState.voices;
device.Globals.RetriggerMode['@Value'] = playbackState.retrigger;
device.Globals.PortamentoMode.Manual['@Value'] = playbackState.portamentoMode;
device.Globals.PortamentoTime.Manual['@Value'] = playbackState.portamentoMode === 2 ? 0.1 : 50;
const sampleRef = device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleRef;
if (abletonVersion === '10') {
setLive10SampleReference(sampleRef.FileRef, koTrack.sampleName);

View File

@@ -1,18 +1,11 @@
import { PadCode } from '../../../types/types';
export type AbletonPlaybackMode = 'oneshot' | 'key' | 'legato';
export function hasOneShotOverride(padCode: PadCode, oneShotPads: PadCode[] = []) {
return oneShotPads.includes(padCode);
}
export function getSimplerPlaybackState(
projectMode: AbletonPlaybackMode,
oneShotOverride: boolean,
) {
export function getSimplerPlaybackState(projectMode: AbletonPlaybackMode) {
return {
playbackMode: projectMode === 'oneshot' || oneShotOverride ? 1 : 0,
monophonic: projectMode === 'legato',
playbackMode: 1,
voices: projectMode === 'key' ? 5 : 0,
retrigger: projectMode === 'oneshot',
portamentoMode: projectMode === 'legato' ? 2 : 0,
useOneShotEnvelope: projectMode === 'oneshot',
};
}

View File

@@ -10,7 +10,6 @@ import {
ProjectRawData,
TimeSignature,
} from '../../types/types';
import { hasOneShotOverride } from '../exporters/ableton/playback';
import { getSampleName } from '../exporters/utils';
import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
@@ -22,7 +21,6 @@ export type AblData = {
export type AblTrack = Omit<Pad, 'file' | 'rawData' | 'midiChannel'> & {
padCode: PadCode;
oneShotOverride: boolean;
group: string;
sampleName: string;
sampleChannels: number;
@@ -176,7 +174,6 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
...omit(pad, ['file', 'rawData']),
soundId,
padCode: pattern.pad,
oneShotOverride: hasOneShotOverride(pattern.pad, exporterParams.abletonOneShotPads),
name: sound?.meta?.name || pattern.pad,
volume: pad.volume / 200, // converting from 0-200 to 0.0-1.0
sampleName: getSampleName(sound?.meta?.name, soundId),
@@ -247,7 +244,6 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
...omit(pad, ['file', 'rawData']),
soundId: pad.soundId,
padCode,
oneShotOverride: hasOneShotOverride(padCode, exporterParams.abletonOneShotPads),
name: sound?.meta?.name || padCode,
volume: pad.volume / 200,
sampleName: getSampleName(sound?.meta?.name, pad.soundId),
@@ -278,7 +274,6 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
const drumTrack: AblTrack = {
padCode: `${group}0` as PadCode,
oneShotOverride: false,
group,
sampleName: '',
sampleChannels: 0,

View File

@@ -4,18 +4,14 @@ import { useFormContext, useWatch } from 'react-hook-form';
import { projectIdAtom } from '~/atoms/project';
import CheckboxField from '~/components/form/CheckboxField';
import SelectField from '~/components/form/SelectField';
import CheckBox from '~/components/ui/CheckBox';
import useProject from '~/hooks/useProject';
import { getPadDisplayName, hasMultipleNoteVariations } from '~/lib/utils';
import { PadCode } from '~/types/types';
import { hasMultipleNoteVariations } from '~/lib/utils';
import { ExportFormValues } from './exportFormSchema';
function ExportOptions({ disabled = false }: { disabled?: boolean }) {
const { control, setValue } = useFormContext<ExportFormValues>();
const { control } = useFormContext<ExportFormValues>();
const format = useWatch({ control, name: 'format' });
const includeArchivedSamples = useWatch({ control, name: 'includeArchivedSamples' });
const exportAllPadsWithSamples = useWatch({ control, name: 'exportAllPadsWithSamples' });
const abletonOneShotPads = useWatch({ control, name: 'abletonOneShotPads' });
const drumRackGroupA = useWatch({ control, name: 'drumRackGroupA' });
const drumRackGroupB = useWatch({ control, name: 'drumRackGroupB' });
const drumRackGroupC = useWatch({ control, name: 'drumRackGroupC' });
@@ -24,48 +20,6 @@ function ExportOptions({ disabled = false }: { disabled?: boolean }) {
const projectId = useAtomValue(projectIdAtom);
const { data: projectData } = useProject(projectId);
const playbackOverrides = useMemo(() => {
if (!projectData) {
return [];
}
const usedPads = new Set(
projectData.scenes.flatMap((scene) => scene.patterns.map((pattern) => pattern.pad)),
);
const result: Array<{ padCode: PadCode; title: string }> = [];
Object.entries(projectData.pads).forEach(([group, pads]) => {
pads.forEach((pad, index) => {
const padCode = `${group}${index}` as PadCode;
if (
pad.soundId <= 0 ||
pad.playMode === 'oneshot' ||
(!exportAllPadsWithSamples && !usedPads.has(padCode))
) {
return;
}
const soundName = projectData.sounds.find((sound) => sound.id === pad.soundId)?.meta.name;
const mode = pad.playMode === 'key' ? 'KEY' : 'LEGATO';
result.push({
padCode,
title: `${getPadDisplayName(group, index)} · ${soundName || pad.soundId} (EP: ${mode})`,
});
});
});
return result;
}, [projectData, exportAllPadsWithSamples]);
const setOneShotOverride = (padCode: PadCode, checked: boolean) => {
const current = abletonOneShotPads || [];
setValue(
'abletonOneShotPads',
checked ? [...new Set([...current, padCode])] : current.filter((code) => code !== padCode),
{ shouldDirty: true },
);
};
const notesVariationWarnings = useMemo(() => {
if (format === 'reaper') {
return [];
@@ -183,25 +137,6 @@ function ExportOptions({ disabled = false }: { disabled?: boolean }) {
disabled={disabled || !includeArchivedSamples}
helperText="Export all samples and create tracks for all pads with assigned samples, even if they are not used in patterns"
/>
{includeArchivedSamples && playbackOverrides.length > 0 && (
<div className="flex flex-col gap-1 mt-2">
<span className="text-sm font-semibold">Force One-Shot</span>
<span className="text-xs opacity-80">
Override selected EP KEY or LEGATO pads when Live Classic cuts them too early.
</span>
<div className="flex flex-col gap-1 max-h-44 overflow-y-auto border border-black p-2">
{playbackOverrides.map((item) => (
<CheckBox
key={item.padCode}
checked={(abletonOneShotPads || []).includes(item.padCode)}
onChange={(checked) => setOneShotOverride(item.padCode, checked)}
title={item.title}
disabled={disabled}
/>
))}
</div>
</div>
)}
<CheckboxField
name="clips"
title="Session clips instead of arrangements"

View File

@@ -43,7 +43,6 @@ function ExportProjectDialog({
defaultValues: {
format: EXPORT_FORMATS[0].value,
abletonVersion: '10',
abletonOneShotPads: [],
projectName: `Project${projectId}`,
includeArchivedSamples: true,
exportAllPadsWithSamples: false,

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { ExportFormatId, PadCode } from '../../../types/types';
import { ExportFormatId } from '../../../types/types';
const FORMAT_IDS: [ExportFormatId, ...ExportFormatId[]] = [
'ableton',
@@ -11,9 +11,6 @@ const FORMAT_IDS: [ExportFormatId, ...ExportFormatId[]] = [
export const exportFormSchema = z.object({
format: z.enum(FORMAT_IDS),
abletonVersion: z.enum(['10', '11']),
abletonOneShotPads: z.array(
z.custom<PadCode>((value) => typeof value === 'string' && /^[abcd]\d+$/.test(value)),
),
projectName: z.string(),
includeArchivedSamples: z.boolean(),
exportAllPadsWithSamples: z.boolean(),

View File

@@ -217,7 +217,6 @@ export type ExportFormat = {
export type ExporterParams = {
abletonVersion?: '10' | '11';
abletonOneShotPads?: PadCode[];
projectName?: string; // custom project name for exported files
includeArchivedSamples?: boolean;
exportAllPadsWithSamples?: boolean; // export all pads with assigned samples even if not used

View File

@@ -1,45 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getSimplerPlaybackState, hasOneShotOverride } from '../src/lib/exporters/ableton/playback';
import { PadCode } from '../src/types/types';
import { getSimplerPlaybackState } from '../src/lib/exporters/ableton/playback';
const project7Overrides = ['b0', 'c1', 'c8', 'c9', 'd3'] as PadCode[];
test('applies the selective Project 7 One-Shot overrides', () => {
for (const padCode of project7Overrides) {
assert.equal(hasOneShotOverride(padCode, project7Overrides), true);
}
assert.equal(hasOneShotOverride('b3', project7Overrides), false);
assert.equal(hasOneShotOverride('b7', project7Overrides), false);
});
test('preserves project playback modes when no override is selected', () => {
assert.equal(hasOneShotOverride('b0'), false);
});
test('changes only the active playback mode for forced KEY and LEGATO pads', () => {
assert.deepEqual(getSimplerPlaybackState('key', true), {
test('exports every EP playback mode as automatic One-Shot Trigger playback', () => {
assert.deepEqual(getSimplerPlaybackState('key'), {
playbackMode: 1,
monophonic: false,
voices: 5,
retrigger: false,
portamentoMode: 0,
useOneShotEnvelope: false,
});
assert.deepEqual(getSimplerPlaybackState('legato', true), {
assert.deepEqual(getSimplerPlaybackState('legato'), {
playbackMode: 1,
monophonic: true,
voices: 0,
retrigger: false,
portamentoMode: 2,
useOneShotEnvelope: false,
});
});
test('uses One-Shot envelopes only for native EP ONE pads', () => {
assert.deepEqual(getSimplerPlaybackState('oneshot', false), {
test('preserves native EP ONE envelope translation', () => {
assert.deepEqual(getSimplerPlaybackState('oneshot'), {
playbackMode: 1,
monophonic: false,
voices: 0,
retrigger: true,
portamentoMode: 0,
useOneShotEnvelope: true,
});
assert.deepEqual(getSimplerPlaybackState('legato', false), {
playbackMode: 0,
monophonic: true,
useOneShotEnvelope: false,
});
});