Update Ableton export logic to use comprehensive playback state parameters
This commit is contained in:
@@ -389,7 +389,7 @@ async function buildMidiClip(
|
|||||||
async function buildSimplerDevice(koTrack: AblTrack, abletonVersion: AbletonVersion) {
|
async function buildSimplerDevice(koTrack: AblTrack, abletonVersion: AbletonVersion) {
|
||||||
const simplerTemplate = await loadTemplate<ALSSimpler>('simpler', abletonVersion);
|
const simplerTemplate = await loadTemplate<ALSSimpler>('simpler', abletonVersion);
|
||||||
const device = structuredClone(simplerTemplate.OriginalSimpler);
|
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 samplePart = device.Player.MultiSampleMap.SampleParts.MultiSamplePart;
|
||||||
const loopModulators = device.Player.LoopModulators;
|
const loopModulators = device.Player.LoopModulators;
|
||||||
const simplerFilter = device.Filter.Slot.Value.SimplerFilter;
|
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.FadeOutTime.Manual['@Value'] = 0;
|
||||||
device.VolumeAndPan.OneShotEnvelope.SustainMode.Manual['@Value'] = 0;
|
device.VolumeAndPan.OneShotEnvelope.SustainMode.Manual['@Value'] = 0;
|
||||||
device.Globals.PlaybackMode['@Value'] = playbackState.playbackMode;
|
device.Globals.PlaybackMode['@Value'] = playbackState.playbackMode;
|
||||||
if (playbackState.monophonic) {
|
device.Globals.NumVoices['@Value'] = playbackState.voices;
|
||||||
device.Globals.NumVoices['@Value'] = 1;
|
device.Globals.RetriggerMode['@Value'] = playbackState.retrigger;
|
||||||
device.Globals.RetriggerMode['@Value'] = 'false';
|
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;
|
const sampleRef = device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleRef;
|
||||||
if (abletonVersion === '10') {
|
if (abletonVersion === '10') {
|
||||||
setLive10SampleReference(sampleRef.FileRef, koTrack.sampleName);
|
setLive10SampleReference(sampleRef.FileRef, koTrack.sampleName);
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { PadCode } from '../../../types/types';
|
|
||||||
|
|
||||||
export type AbletonPlaybackMode = 'oneshot' | 'key' | 'legato';
|
export type AbletonPlaybackMode = 'oneshot' | 'key' | 'legato';
|
||||||
|
|
||||||
export function hasOneShotOverride(padCode: PadCode, oneShotPads: PadCode[] = []) {
|
export function getSimplerPlaybackState(projectMode: AbletonPlaybackMode) {
|
||||||
return oneShotPads.includes(padCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSimplerPlaybackState(
|
|
||||||
projectMode: AbletonPlaybackMode,
|
|
||||||
oneShotOverride: boolean,
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
playbackMode: projectMode === 'oneshot' || oneShotOverride ? 1 : 0,
|
playbackMode: 1,
|
||||||
monophonic: projectMode === 'legato',
|
voices: projectMode === 'key' ? 5 : 0,
|
||||||
|
retrigger: projectMode === 'oneshot',
|
||||||
|
portamentoMode: projectMode === 'legato' ? 2 : 0,
|
||||||
useOneShotEnvelope: projectMode === 'oneshot',
|
useOneShotEnvelope: projectMode === 'oneshot',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
ProjectRawData,
|
ProjectRawData,
|
||||||
TimeSignature,
|
TimeSignature,
|
||||||
} from '../../types/types';
|
} from '../../types/types';
|
||||||
import { hasOneShotOverride } from '../exporters/ableton/playback';
|
|
||||||
import { getSampleName } from '../exporters/utils';
|
import { getSampleName } from '../exporters/utils';
|
||||||
import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
|
import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ export type AblData = {
|
|||||||
|
|
||||||
export type AblTrack = Omit<Pad, 'file' | 'rawData' | 'midiChannel'> & {
|
export type AblTrack = Omit<Pad, 'file' | 'rawData' | 'midiChannel'> & {
|
||||||
padCode: PadCode;
|
padCode: PadCode;
|
||||||
oneShotOverride: boolean;
|
|
||||||
group: string;
|
group: string;
|
||||||
sampleName: string;
|
sampleName: string;
|
||||||
sampleChannels: number;
|
sampleChannels: number;
|
||||||
@@ -176,7 +174,6 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
|
|||||||
...omit(pad, ['file', 'rawData']),
|
...omit(pad, ['file', 'rawData']),
|
||||||
soundId,
|
soundId,
|
||||||
padCode: pattern.pad,
|
padCode: pattern.pad,
|
||||||
oneShotOverride: hasOneShotOverride(pattern.pad, exporterParams.abletonOneShotPads),
|
|
||||||
name: sound?.meta?.name || pattern.pad,
|
name: sound?.meta?.name || pattern.pad,
|
||||||
volume: pad.volume / 200, // converting from 0-200 to 0.0-1.0
|
volume: pad.volume / 200, // converting from 0-200 to 0.0-1.0
|
||||||
sampleName: getSampleName(sound?.meta?.name, soundId),
|
sampleName: getSampleName(sound?.meta?.name, soundId),
|
||||||
@@ -247,7 +244,6 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
|
|||||||
...omit(pad, ['file', 'rawData']),
|
...omit(pad, ['file', 'rawData']),
|
||||||
soundId: pad.soundId,
|
soundId: pad.soundId,
|
||||||
padCode,
|
padCode,
|
||||||
oneShotOverride: hasOneShotOverride(padCode, exporterParams.abletonOneShotPads),
|
|
||||||
name: sound?.meta?.name || padCode,
|
name: sound?.meta?.name || padCode,
|
||||||
volume: pad.volume / 200,
|
volume: pad.volume / 200,
|
||||||
sampleName: getSampleName(sound?.meta?.name, pad.soundId),
|
sampleName: getSampleName(sound?.meta?.name, pad.soundId),
|
||||||
@@ -278,7 +274,6 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
|
|||||||
|
|
||||||
const drumTrack: AblTrack = {
|
const drumTrack: AblTrack = {
|
||||||
padCode: `${group}0` as PadCode,
|
padCode: `${group}0` as PadCode,
|
||||||
oneShotOverride: false,
|
|
||||||
group,
|
group,
|
||||||
sampleName: '',
|
sampleName: '',
|
||||||
sampleChannels: 0,
|
sampleChannels: 0,
|
||||||
|
|||||||
@@ -4,18 +4,14 @@ import { useFormContext, useWatch } from 'react-hook-form';
|
|||||||
import { projectIdAtom } from '~/atoms/project';
|
import { projectIdAtom } from '~/atoms/project';
|
||||||
import CheckboxField from '~/components/form/CheckboxField';
|
import CheckboxField from '~/components/form/CheckboxField';
|
||||||
import SelectField from '~/components/form/SelectField';
|
import SelectField from '~/components/form/SelectField';
|
||||||
import CheckBox from '~/components/ui/CheckBox';
|
|
||||||
import useProject from '~/hooks/useProject';
|
import useProject from '~/hooks/useProject';
|
||||||
import { getPadDisplayName, hasMultipleNoteVariations } from '~/lib/utils';
|
import { hasMultipleNoteVariations } from '~/lib/utils';
|
||||||
import { PadCode } from '~/types/types';
|
|
||||||
import { ExportFormValues } from './exportFormSchema';
|
import { ExportFormValues } from './exportFormSchema';
|
||||||
|
|
||||||
function ExportOptions({ disabled = false }: { disabled?: boolean }) {
|
function ExportOptions({ disabled = false }: { disabled?: boolean }) {
|
||||||
const { control, setValue } = useFormContext<ExportFormValues>();
|
const { control } = useFormContext<ExportFormValues>();
|
||||||
const format = useWatch({ control, name: 'format' });
|
const format = useWatch({ control, name: 'format' });
|
||||||
const includeArchivedSamples = useWatch({ control, name: 'includeArchivedSamples' });
|
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 drumRackGroupA = useWatch({ control, name: 'drumRackGroupA' });
|
||||||
const drumRackGroupB = useWatch({ control, name: 'drumRackGroupB' });
|
const drumRackGroupB = useWatch({ control, name: 'drumRackGroupB' });
|
||||||
const drumRackGroupC = useWatch({ control, name: 'drumRackGroupC' });
|
const drumRackGroupC = useWatch({ control, name: 'drumRackGroupC' });
|
||||||
@@ -24,48 +20,6 @@ function ExportOptions({ disabled = false }: { disabled?: boolean }) {
|
|||||||
const projectId = useAtomValue(projectIdAtom);
|
const projectId = useAtomValue(projectIdAtom);
|
||||||
const { data: projectData } = useProject(projectId);
|
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(() => {
|
const notesVariationWarnings = useMemo(() => {
|
||||||
if (format === 'reaper') {
|
if (format === 'reaper') {
|
||||||
return [];
|
return [];
|
||||||
@@ -183,25 +137,6 @@ function ExportOptions({ disabled = false }: { disabled?: boolean }) {
|
|||||||
disabled={disabled || !includeArchivedSamples}
|
disabled={disabled || !includeArchivedSamples}
|
||||||
helperText="Export all samples and create tracks for all pads with assigned samples, even if they are not used in patterns"
|
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
|
<CheckboxField
|
||||||
name="clips"
|
name="clips"
|
||||||
title="Session clips instead of arrangements"
|
title="Session clips instead of arrangements"
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ function ExportProjectDialog({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
format: EXPORT_FORMATS[0].value,
|
format: EXPORT_FORMATS[0].value,
|
||||||
abletonVersion: '10',
|
abletonVersion: '10',
|
||||||
abletonOneShotPads: [],
|
|
||||||
projectName: `Project${projectId}`,
|
projectName: `Project${projectId}`,
|
||||||
includeArchivedSamples: true,
|
includeArchivedSamples: true,
|
||||||
exportAllPadsWithSamples: false,
|
exportAllPadsWithSamples: false,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ExportFormatId, PadCode } from '../../../types/types';
|
import { ExportFormatId } from '../../../types/types';
|
||||||
|
|
||||||
const FORMAT_IDS: [ExportFormatId, ...ExportFormatId[]] = [
|
const FORMAT_IDS: [ExportFormatId, ...ExportFormatId[]] = [
|
||||||
'ableton',
|
'ableton',
|
||||||
@@ -11,9 +11,6 @@ const FORMAT_IDS: [ExportFormatId, ...ExportFormatId[]] = [
|
|||||||
export const exportFormSchema = z.object({
|
export const exportFormSchema = z.object({
|
||||||
format: z.enum(FORMAT_IDS),
|
format: z.enum(FORMAT_IDS),
|
||||||
abletonVersion: z.enum(['10', '11']),
|
abletonVersion: z.enum(['10', '11']),
|
||||||
abletonOneShotPads: z.array(
|
|
||||||
z.custom<PadCode>((value) => typeof value === 'string' && /^[abcd]\d+$/.test(value)),
|
|
||||||
),
|
|
||||||
projectName: z.string(),
|
projectName: z.string(),
|
||||||
includeArchivedSamples: z.boolean(),
|
includeArchivedSamples: z.boolean(),
|
||||||
exportAllPadsWithSamples: z.boolean(),
|
exportAllPadsWithSamples: z.boolean(),
|
||||||
|
|||||||
@@ -217,7 +217,6 @@ export type ExportFormat = {
|
|||||||
|
|
||||||
export type ExporterParams = {
|
export type ExporterParams = {
|
||||||
abletonVersion?: '10' | '11';
|
abletonVersion?: '10' | '11';
|
||||||
abletonOneShotPads?: PadCode[];
|
|
||||||
projectName?: string; // custom project name for exported files
|
projectName?: string; // custom project name for exported files
|
||||||
includeArchivedSamples?: boolean;
|
includeArchivedSamples?: boolean;
|
||||||
exportAllPadsWithSamples?: boolean; // export all pads with assigned samples even if not used
|
exportAllPadsWithSamples?: boolean; // export all pads with assigned samples even if not used
|
||||||
|
|||||||
@@ -1,45 +1,30 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { getSimplerPlaybackState, hasOneShotOverride } from '../src/lib/exporters/ableton/playback';
|
import { getSimplerPlaybackState } from '../src/lib/exporters/ableton/playback';
|
||||||
import { PadCode } from '../src/types/types';
|
|
||||||
|
|
||||||
const project7Overrides = ['b0', 'c1', 'c8', 'c9', 'd3'] as PadCode[];
|
test('exports every EP playback mode as automatic One-Shot Trigger playback', () => {
|
||||||
|
assert.deepEqual(getSimplerPlaybackState('key'), {
|
||||||
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), {
|
|
||||||
playbackMode: 1,
|
playbackMode: 1,
|
||||||
monophonic: false,
|
voices: 5,
|
||||||
|
retrigger: false,
|
||||||
|
portamentoMode: 0,
|
||||||
useOneShotEnvelope: false,
|
useOneShotEnvelope: false,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getSimplerPlaybackState('legato', true), {
|
assert.deepEqual(getSimplerPlaybackState('legato'), {
|
||||||
playbackMode: 1,
|
playbackMode: 1,
|
||||||
monophonic: true,
|
voices: 0,
|
||||||
|
retrigger: false,
|
||||||
|
portamentoMode: 2,
|
||||||
useOneShotEnvelope: false,
|
useOneShotEnvelope: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses One-Shot envelopes only for native EP ONE pads', () => {
|
test('preserves native EP ONE envelope translation', () => {
|
||||||
assert.deepEqual(getSimplerPlaybackState('oneshot', false), {
|
assert.deepEqual(getSimplerPlaybackState('oneshot'), {
|
||||||
playbackMode: 1,
|
playbackMode: 1,
|
||||||
monophonic: false,
|
voices: 0,
|
||||||
|
retrigger: true,
|
||||||
|
portamentoMode: 0,
|
||||||
useOneShotEnvelope: true,
|
useOneShotEnvelope: true,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getSimplerPlaybackState('legato', false), {
|
|
||||||
playbackMode: 0,
|
|
||||||
monophonic: true,
|
|
||||||
useOneShotEnvelope: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user