Stage 1 of #338. Game sync needs named places in a song: an event when the playhead passes one, and a switch that jumps to one. This stage adds the markers themselves. Games do nothing with them yet. - A song has markers, each with id, name and tick. A song without markers stores nothing for them. Two markers may share tick and name, since nodes will name them by id. - Markers change neither the sound nor the bake fingerprint. - The song tab draws a strip under the ruler. A marker has a green flag with its name and a faint line through the tracks. Tracks and lanes sit 18 px lower. The ruler of the pattern tab is unchanged. - Double click on an empty place adds a marker on the grid, with Alt or Shift on the exact tick. Dragging moves it in one undo step. - A click on a marker sets the start of playback there. A double click renames it in the name dialog of the hierarchy groups. - The context menu offers Play from here, Rename and Delete marker, on an empty place Add marker. A tooltip shows the full name. The wheel over the strip zooms time like the ruler. - New agent commands add_song_marker, change_song_marker and remove_song_marker. get_asset lists them under markers. Tests: - song-markers in core 6 and editor 4, 1 agent test, with the touched song, agent and MCP tests 175/175 - 9 counter-probes, all compile, all red - e2e 65 on atx: ALL PASSED, 70 checks, 3 new in 65g3. Counter-probe where a dragged marker is not taken over: 65g3 red - gate on atx: CHECK EXIT 0, 332 files, 5093 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
793 lines
34 KiB
TypeScript
793 lines
34 KiB
TypeScript
import {
|
||
AddClipCommand,
|
||
RemoveClipCommand,
|
||
RenameClipCommand,
|
||
SetClipPropsCommand,
|
||
} from '../../../core/commands/clipCommands';
|
||
import { type Command, CommandError } from '../../../core/commands/Command';
|
||
import { oneStep } from '../../../core/commands/CompoundCommand';
|
||
import { ReplaceGraphCommand } from '../../../core/commands/graphCommands';
|
||
import { SetLoopCommand, SetPatternNotesCommand } from '../../../core/commands/patternCommands';
|
||
import { requireAsset, requireEdge, requirePattern, requireSong } from '../../../core/commands/require';
|
||
import { SetSendCommand, SetSongCommand, SetSongMarkersCommand } from '../../../core/commands/songCommands';
|
||
import type { SendTap } from '../../../core/graph/Graph';
|
||
import { newId } from '../../../core/ids';
|
||
import {
|
||
CHIP_PRESETS,
|
||
type ChipPreset,
|
||
chipPresetGraph,
|
||
EFFECT_PRESETS,
|
||
TONE_PRESETS,
|
||
} from '../../../core/instrument/chipPresets';
|
||
import { HIGHEST_NOTE, LOWEST_NOTE, noteName } from '../../../core/instrument/notes';
|
||
import { easingOf, isKeyInterpolation } from '../../../core/model/animation';
|
||
import type { PatternAsset, SongAsset } from '../../../core/model/Asset';
|
||
import { audioSeconds } from '../../../core/model/composition';
|
||
import type { PropertyBag } from '../../../core/model/Properties';
|
||
import {
|
||
findAsset,
|
||
findAssetOf,
|
||
findGraphAsset,
|
||
type Project,
|
||
ROUTING_ID,
|
||
} from '../../../core/model/Project';
|
||
import { clipsOf, findSound, hasClips } from '../../../core/model/sound';
|
||
import {
|
||
type AutomationLane,
|
||
type AutomationPoint,
|
||
heldRange,
|
||
isAutomationTarget,
|
||
type LaneKind,
|
||
laneRange,
|
||
paramOf,
|
||
} from '../../../core/music/automation';
|
||
import { changedMarker, newMarker, type SongMarker, withoutMarker } from '../../../core/music/markers';
|
||
import { DEFAULT_VELOCITY, patternLength, type PatternNote } from '../../../core/music/pattern';
|
||
import { SEND_TAPS } from '../../../core/music/routing';
|
||
import {
|
||
itemMaxDb,
|
||
ITEM_MIN_DB,
|
||
MAX_TRANSPOSE,
|
||
newItem,
|
||
newTrack,
|
||
type SongItem,
|
||
type SongTrack,
|
||
songMeter,
|
||
songTempo,
|
||
TRACK_MAX_DB,
|
||
TRACK_MIN_DB,
|
||
} from '../../../core/music/song';
|
||
import { TICKS_PER_QUARTER, ticksOfSeconds, ticksPerBar } from '../../../core/music/time';
|
||
import { defaultParamRange, trackParams } from '../../../core/music/trackParams';
|
||
import { NO_FX } from '../../../core/properties/assetFx';
|
||
import { withLanePoints, withoutLane, withParamLane } from '../../song/laneEdit';
|
||
import type { ArgSchema } from '../argCheck';
|
||
import { EASINGS } from './animation';
|
||
import {
|
||
type Args,
|
||
type CatalogCommand,
|
||
flag,
|
||
id,
|
||
ids,
|
||
listOf,
|
||
number,
|
||
numberOf,
|
||
object,
|
||
oneOf,
|
||
text,
|
||
theTab,
|
||
whole,
|
||
} from './shared';
|
||
|
||
const PATTERN = id('Id of the pattern asset.');
|
||
const SONG = id('Id of the song asset.');
|
||
const TRACK = id('Id of the track, as get_asset lists it under tracks.');
|
||
const MARKER = id('Id of the marker, as get_asset lists it under markers.');
|
||
const ITEM = id('Id of the item, as get_asset lists it under items.');
|
||
const INDEX = whole('Place in the list of tracks, from 0 (the top).', 0);
|
||
const TICK = whole(`Ticks from the start; ${TICKS_PER_QUARTER} to a quarter note.`, 0);
|
||
const NOTE: ArgSchema = {
|
||
description: 'The pitch: a MIDI number (60 is C4) or a name with sharps only, like "C4", "F#3", "A-1".',
|
||
};
|
||
const VELOCITY: ArgSchema = { type: 'number', minimum: 0, maximum: 1, description: 'How hard, 0 to 1.' };
|
||
const FX: ArgSchema = { type: 'string', description: 'Id of a Sound FX asset, or "" for none.' };
|
||
const DB = (description: string): ArgSchema => ({ type: 'number', description: `${description} In dB.` });
|
||
|
||
/** Notes of patterns, loops, the tracks and items of songs, sends of a routing, clips of sounds and videos. */
|
||
export const MUSIC_COMMANDS: readonly CatalogCommand[] = [
|
||
{
|
||
name: 'add_notes',
|
||
description:
|
||
`Adds notes to a pattern; with replace, they take the place of all its notes. Time is in ticks, ${TICKS_PER_QUARTER} ` +
|
||
'to a quarter note; how many bars long the pattern is, its meter, grid, tempo and instrument are properties. ' +
|
||
'A note has to start before the end of the pattern. A pattern plays nothing until its property instrument ' +
|
||
'names an Instrument asset, here or wherever it plays, and of its notes only those the instrument sounds: ' +
|
||
'inside the range of a Notes node, C4 to B4 in a new one, after the transpose of a song item. ' +
|
||
'get_asset says under silent what is not heard.',
|
||
args: object(
|
||
{
|
||
asset: PATTERN,
|
||
notes: {
|
||
type: 'array',
|
||
minItems: 1,
|
||
items: object(
|
||
{ tick: TICK, length: whole('Ticks it is held.', 1), note: NOTE, velocity: VELOCITY },
|
||
['tick', 'length', 'note'],
|
||
),
|
||
description: 'The notes.',
|
||
},
|
||
replace: flag('Take away the notes it had.'),
|
||
},
|
||
['asset', 'notes'],
|
||
),
|
||
build: (args, { project }) => {
|
||
const pattern = requirePattern(project, text(args, 'asset'));
|
||
const added = (args.notes as Args[]).map((n): PatternNote => ({
|
||
id: newId(),
|
||
tick: n.tick as number,
|
||
length: n.length as number,
|
||
note: pitchOf(n.note),
|
||
velocity: (n.velocity as number | undefined) ?? DEFAULT_VELOCITY,
|
||
}));
|
||
for (const note of added) requireInside(pattern, note);
|
||
const notes = [...(args.replace === true ? [] : pattern.notes), ...added];
|
||
const command = new SetPatternNotesCommand(pattern.id, notes, 'Add notes');
|
||
return added.length === 1 ? { command, made: { id: added[0]!.id } } : { command };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'change_notes',
|
||
description:
|
||
'Changes notes of a pattern by their ids: where they start, how long, the pitch, the velocity.',
|
||
args: object(
|
||
{
|
||
asset: PATTERN,
|
||
notes: {
|
||
type: 'array',
|
||
minItems: 1,
|
||
items: object(
|
||
{
|
||
id: id('Id of the note.'),
|
||
tick: TICK,
|
||
length: whole('Ticks it is held.', 1),
|
||
note: NOTE,
|
||
velocity: VELOCITY,
|
||
},
|
||
['id'],
|
||
),
|
||
description: 'Each with only what changes.',
|
||
},
|
||
},
|
||
['asset', 'notes'],
|
||
),
|
||
build: (args, { project }) => {
|
||
const pattern = requirePattern(project, text(args, 'asset'));
|
||
const changes = new Map((args.notes as Args[]).map((n) => [n.id as string, n]));
|
||
for (const noteId of changes.keys())
|
||
if (!pattern.notes.some((n) => n.id === noteId))
|
||
throw new CommandError(`${pattern.name} has no note ${noteId}`);
|
||
const notes = pattern.notes.map((note) => {
|
||
const change = changes.get(note.id);
|
||
if (!change) return note;
|
||
const changed: PatternNote = {
|
||
...note,
|
||
...(typeof change.tick === 'number' ? { tick: change.tick } : {}),
|
||
...(typeof change.length === 'number' ? { length: change.length } : {}),
|
||
...(change.note === undefined ? {} : { note: pitchOf(change.note) }),
|
||
...(typeof change.velocity === 'number' ? { velocity: change.velocity } : {}),
|
||
};
|
||
requireInside(pattern, changed);
|
||
return changed;
|
||
});
|
||
return { command: new SetPatternNotesCommand(pattern.id, notes, 'Change notes') };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'remove_notes',
|
||
description: 'Removes notes of a pattern by their ids.',
|
||
args: object({ asset: PATTERN, notes: ids('Ids of the notes.') }, ['asset', 'notes']),
|
||
build: (args, { project }) => {
|
||
const pattern = requirePattern(project, text(args, 'asset'));
|
||
const going = new Set(listOf(args, 'notes'));
|
||
for (const noteId of going)
|
||
if (!pattern.notes.some((n) => n.id === noteId))
|
||
throw new CommandError(`${pattern.name} has no note ${noteId}`);
|
||
const notes = pattern.notes.filter((n) => !going.has(n.id));
|
||
return { command: new SetPatternNotesCommand(pattern.id, notes, 'Delete notes') };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'set_loop',
|
||
description:
|
||
'Sets the loop the playhead of a pattern or a song runs round, in ticks; leave start and end out to loop the whole.',
|
||
args: object({ asset: id('Id of the pattern or song asset.'), start: TICK, end: whole('Ticks.', 1) }, [
|
||
'asset',
|
||
]),
|
||
build: (args, { project }) => {
|
||
const asset = requireAsset(project, text(args, 'asset'));
|
||
if (asset.type !== 'pattern' && asset.type !== 'song')
|
||
throw new CommandError(`${asset.name} is no pattern or song`);
|
||
const given = [args.start, args.end].filter((value) => value !== undefined).length;
|
||
if (given === 1) throw new CommandError('Give start and end, or neither for the whole');
|
||
const loop = given === 0 ? null : { start: numberOf(args, 'start'), end: numberOf(args, 'end') };
|
||
if (loop && loop.end <= loop.start) throw new CommandError('The loop has to end after it starts');
|
||
return { command: new SetLoopCommand(asset.type, asset.id, loop) };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'apply_preset',
|
||
description:
|
||
'Replaces the graph of an Instrument asset with a preset, as the Preset menu of its tab does: the quickest way to an instrument that sounds. ' +
|
||
`To play tunes on, every note at its own pitch from ${noteName(LOWEST_NOTE)} to ${noteName(HIGHEST_NOTE)}, the same each time: ` +
|
||
`${TONE_PRESETS.map((p) => p.value).join(', ')} (drumKit has its drums on single notes). ` +
|
||
`Sounds after sfxr, one sound on every key, drawn anew each time: ${EFFECT_PRESETS.map((p) => p.value).join(', ')}.`,
|
||
args: object(
|
||
{
|
||
asset: id('Id of the instrument asset.'),
|
||
preset: oneOf(
|
||
CHIP_PRESETS.map((p) => p.value),
|
||
'The preset.',
|
||
),
|
||
},
|
||
['asset', 'preset'],
|
||
),
|
||
build: (args, { project }) => {
|
||
const instrument = findAssetOf(project, text(args, 'asset'), 'instrument');
|
||
if (!instrument)
|
||
throw new CommandError(`${requireAsset(project, text(args, 'asset')).name} is no instrument`);
|
||
const preset = text(args, 'preset') as ChipPreset;
|
||
const label = CHIP_PRESETS.find((p) => p.value === preset)?.label ?? preset;
|
||
return { command: new ReplaceGraphCommand(instrument.id, chipPresetGraph(preset), `Preset ${label}`) };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'add_song_track',
|
||
description:
|
||
'Adds a track to a song, at the bottom or at an index. Its routing gets a node for it, going to the output.',
|
||
args: object({ asset: SONG, name: id('Its name.'), at: INDEX }, ['asset']),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const track = newTrack(song.tracks.length + 1);
|
||
if (typeof args.name === 'string') track.name = args.name;
|
||
const tracks = [...song.tracks];
|
||
tracks.splice(Math.min((args.at as number | undefined) ?? tracks.length, tracks.length), 0, track);
|
||
return { command: new SetSongCommand(song.id, { tracks }, 'Add track'), made: { id: track.id } };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'change_song_track',
|
||
description: 'Changes a track of a song. Give only what changes.',
|
||
args: object(
|
||
{
|
||
asset: SONG,
|
||
track: TRACK,
|
||
name: id('Its name.'),
|
||
volume: DB(`From ${TRACK_MIN_DB} to ${TRACK_MAX_DB}.`),
|
||
pan: number('-1 all left, 1 all right.'),
|
||
mute: flag('Muted.'),
|
||
solo: flag('Soloed.'),
|
||
fx: {
|
||
...FX,
|
||
description: 'Id of the Sound FX everything on the track goes through, or "" for none.',
|
||
},
|
||
},
|
||
['asset', 'track'],
|
||
),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const track = requireTrack(song, text(args, 'track'));
|
||
const changed: SongTrack = { ...track };
|
||
if (typeof args.name === 'string') changed.name = args.name;
|
||
if (typeof args.volume === 'number')
|
||
changed.volume = within(args.volume, TRACK_MIN_DB, TRACK_MAX_DB, 'volume');
|
||
if (typeof args.pan === 'number') changed.pan = within(args.pan, -1, 1, 'pan');
|
||
if (typeof args.mute === 'boolean') changed.mute = args.mute;
|
||
if (typeof args.solo === 'boolean') changed.solo = args.solo;
|
||
if (typeof args.fx === 'string') changed.fx = soundFxOf(project, args.fx);
|
||
const tracks = song.tracks.map((t) => (t.id === track.id ? changed : t));
|
||
return { command: new SetSongCommand(song.id, { tracks }, 'Change track') };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'set_song_lane',
|
||
description:
|
||
'Sets an automation lane of a song track: its volume (in dB), its pan (-1 left to 1 right) or a parameter ' +
|
||
'changing over the song along points; the points given replace those the lane had. From each point the ' +
|
||
'value runs to the next along its ease; before the first point it holds the first value, after the last ' +
|
||
'the last one; two points on one tick jump there. While the lane of the volume or the pan has a point, it ' +
|
||
"rules that value in place of the track's own; with no point that lane goes and the track's value rules " +
|
||
'again. A parameter lane moves every parameter of that name on the track: of its sound FX, of the sound FX ' +
|
||
'of its items, of the instruments its patterns play and of their sound FX (get_asset lists them under ' +
|
||
"params of the track). It keeps a range its points lie in, round the parameter's default where none is " +
|
||
'given, and stays without a point until remove. A note or clip starts at the value there, so what a ' +
|
||
'voice reads only when it starts changes from the next one.',
|
||
args: object(
|
||
{
|
||
asset: SONG,
|
||
track: TRACK,
|
||
target: id(
|
||
'"volume", "pan", or "param:<name>" for a parameter, as get_asset lists lanes under automation.',
|
||
),
|
||
points: {
|
||
type: 'array',
|
||
items: object(
|
||
{
|
||
tick: TICK,
|
||
value: number(
|
||
'In the units of the lane: dB, -1 to 1 for the pan, within the range of a parameter.',
|
||
),
|
||
ease: id(
|
||
`How it runs to the next point: ${EASINGS.join(', ')}, or "curve:<id of a Curve asset>"; ` +
|
||
'linear where left out.',
|
||
),
|
||
},
|
||
['tick', 'value'],
|
||
),
|
||
description: 'All points of the lane, in any order; those on one tick in the order given.',
|
||
},
|
||
min: number('A parameter lane: the lowest value it holds.'),
|
||
max: number('A parameter lane: the highest value it holds.'),
|
||
remove: flag("Take the lane away, a parameter's range with it."),
|
||
},
|
||
['asset', 'track', 'target'],
|
||
),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const track = requireTrack(song, text(args, 'track'));
|
||
const target = args.target;
|
||
if (!isAutomationTarget(target))
|
||
throw new CommandError(
|
||
`${JSON.stringify(target)} is no lane – give "volume", "pan" or "param:<name>"`,
|
||
);
|
||
const had = track.automation?.find((lane) => lane.target === target);
|
||
const given = [args.points, args.min, args.max].some((value) => value !== undefined);
|
||
let changed: SongTrack;
|
||
let label: string;
|
||
if (args.remove === true) {
|
||
if (given) throw new CommandError('remove takes no points and no range');
|
||
if (!had) throw new CommandError(`${track.name} has no ${target} lane`);
|
||
changed = withoutLane(track, target);
|
||
label = 'Remove automation lane';
|
||
} else {
|
||
const name = paramOf(target);
|
||
const range = name === null ? noRange(args) : paramRange(project, song, track, name, had, args);
|
||
const kind: LaneKind = { target, ...range };
|
||
const points = args.points === undefined ? null : lanePointsOf(project, kind, args.points as Args[]);
|
||
if (name === null && !points) throw new CommandError('Give the points, [] to take the lane away');
|
||
changed = range ? withParamLane(track, name!, range) : track;
|
||
if (points) changed = withLanePoints(changed, target, points);
|
||
label = had ? 'Set automation lane' : 'Add automation lane';
|
||
}
|
||
const tracks = song.tracks.map((t) => (t.id === track.id ? changed : t));
|
||
return { command: new SetSongCommand(song.id, { tracks }, label) };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'move_song_track',
|
||
description: 'Moves a track of a song to another place in its list.',
|
||
args: object({ asset: SONG, track: TRACK, to: INDEX }, ['asset', 'track', 'to']),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const track = requireTrack(song, text(args, 'track'));
|
||
const tracks = song.tracks.filter((t) => t.id !== track.id);
|
||
tracks.splice(Math.min(numberOf(args, 'to'), tracks.length), 0, track);
|
||
return { command: new SetSongCommand(song.id, { tracks }, 'Move track') };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'remove_song_track',
|
||
description: 'Removes a track of a song with its items; the last track stays.',
|
||
args: object({ asset: SONG, track: TRACK }, ['asset', 'track']),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const track = requireTrack(song, text(args, 'track'));
|
||
if (song.tracks.length <= 1) throw new CommandError('A song keeps at least one track');
|
||
const parts = {
|
||
tracks: song.tracks.filter((t) => t.id !== track.id),
|
||
items: song.items.filter((i) => i.track !== track.id),
|
||
};
|
||
return { command: new SetSongCommand(song.id, parts, 'Delete track') };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'add_song_marker',
|
||
description:
|
||
'Adds a marker to a song: a named spot on its time, drawn on the strip under the ruler of the song tab. ' +
|
||
'Markers may share a tick and a name; a marker is known by its id.',
|
||
args: object({ asset: SONG, tick: TICK, name: id('Its name; "Marker <n>" where left out.') }, [
|
||
'asset',
|
||
'tick',
|
||
]),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const markers = song.markers ?? [];
|
||
const name = typeof args.name === 'string' ? args.name : undefined;
|
||
const marker = newMarker(markers, numberOf(args, 'tick'), name);
|
||
return {
|
||
command: new SetSongMarkersCommand(song.id, [...markers, marker], 'Add marker'),
|
||
made: { id: marker.id },
|
||
};
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'change_song_marker',
|
||
description: 'Renames a marker of a song or moves it to another tick. Give only what changes.',
|
||
args: object({ asset: SONG, marker: MARKER, name: id('Its name.'), tick: TICK }, ['asset', 'marker']),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const marker = requireMarker(song, text(args, 'marker'));
|
||
const change: { name?: string; tick?: number } = {};
|
||
if (typeof args.name === 'string') {
|
||
if (!args.name.trim()) throw new CommandError('A marker needs a name');
|
||
change.name = args.name.trim();
|
||
}
|
||
if (typeof args.tick === 'number') change.tick = numberOf(args, 'tick');
|
||
const markers = changedMarker(song.markers ?? [], marker.id, change);
|
||
return { command: new SetSongMarkersCommand(song.id, markers, 'Change marker') };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'remove_song_marker',
|
||
description: 'Removes a marker of a song.',
|
||
args: object({ asset: SONG, marker: MARKER }, ['asset', 'marker']),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const marker = requireMarker(song, text(args, 'marker'));
|
||
const markers = withoutMarker(song.markers ?? [], marker.id);
|
||
return { command: new SetSongMarkersCommand(song.id, markers, 'Delete marker') };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'add_song_item',
|
||
description:
|
||
`Lays a pattern, a sound or a clip of a sound on a track of a song. Time is in ticks of the song, ${TICKS_PER_QUARTER} ` +
|
||
'to a quarter note at its tempo. Without a length it is as long as its pattern, or its sound at the tempo of the ' +
|
||
'song; a pattern item repeats its pattern for as long as it lasts.',
|
||
args: object(
|
||
{
|
||
asset: SONG,
|
||
track: TRACK,
|
||
play: id('Id of the pattern, sound or sound clip.'),
|
||
tick: TICK,
|
||
...itemFields(),
|
||
},
|
||
['asset', 'track', 'play', 'tick'],
|
||
),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
requireTrack(song, text(args, 'track'));
|
||
const play = text(args, 'play');
|
||
const kind = findAssetOf(project, play, 'pattern')
|
||
? 'pattern'
|
||
: findSound(project, play)
|
||
? 'sound'
|
||
: null;
|
||
if (!kind) throw new CommandError(`${play} is no pattern, sound or sound clip`);
|
||
const length = (args.length as number | undefined) ?? itemLength(project, song, kind, play);
|
||
const item = changedItem(
|
||
project,
|
||
newItem({ track: text(args, 'track'), kind, asset: play, tick: numberOf(args, 'tick'), length }),
|
||
args,
|
||
);
|
||
return {
|
||
command: new SetSongCommand(song.id, { items: [...song.items, item] }, 'Add item'),
|
||
made: { id: item.id },
|
||
};
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'change_song_item',
|
||
description:
|
||
'Changes an item of a song: its track, where it starts, how long, and how it plays. Give only what changes.',
|
||
args: object({ asset: SONG, item: ITEM, track: TRACK, tick: TICK, ...itemFields() }, ['asset', 'item']),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const item = song.items.find((i) => i.id === text(args, 'item'));
|
||
if (!item) throw new CommandError(`${song.name} has no item ${text(args, 'item')}`);
|
||
if (typeof args.track === 'string') requireTrack(song, args.track);
|
||
const changed = changedItem(project, item, args);
|
||
const items = song.items.map((i) => (i.id === item.id ? changed : i));
|
||
return { command: new SetSongCommand(song.id, { items }, 'Change item') };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'remove_song_items',
|
||
description: 'Removes items of a song.',
|
||
args: object({ asset: SONG, items: ids('Ids of the items.') }, ['asset', 'items']),
|
||
build: (args, { project }) => {
|
||
const song = requireSong(project, text(args, 'asset'));
|
||
const going = new Set(listOf(args, 'items'));
|
||
for (const itemId of going)
|
||
if (!song.items.some((i) => i.id === itemId))
|
||
throw new CommandError(`${song.name} has no item ${itemId}`);
|
||
const items = song.items.filter((i) => !going.has(i.id));
|
||
return {
|
||
command: new SetSongCommand(song.id, { items }, going.size === 1 ? 'Delete item' : 'Delete items'),
|
||
};
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'set_send',
|
||
description:
|
||
`How loud a link of a song's routing or of the master routing (asset "${ROUTING_ID}") sends, and where it taps: ` +
|
||
'post (after volume and pan), postFx (after the sound FX, before volume and pan) or preFx (before the sound FX). ' +
|
||
'In the master routing only a bus or a song taps anywhere but post. Links are drawn with connect.',
|
||
args: object(
|
||
{
|
||
asset: id(`Id of the song, or "${ROUTING_ID}".`),
|
||
edge: id('Id of the link, as get_graph lists it.'),
|
||
volume: DB(`From ${TRACK_MIN_DB} to ${TRACK_MAX_DB}.`),
|
||
tap: oneOf(
|
||
SEND_TAPS.map((t) => t.value),
|
||
'Where it taps.',
|
||
),
|
||
},
|
||
['asset', 'edge'],
|
||
),
|
||
build: (args, { project }) => {
|
||
const holder = findGraphAsset(project, text(args, 'asset'));
|
||
if (holder?.type !== 'song' && holder?.type !== 'routing')
|
||
throw new CommandError(`${text(args, 'asset')} is no song and no master routing`);
|
||
const edge = requireEdge(project, holder.id, text(args, 'edge'));
|
||
const volume =
|
||
typeof args.volume === 'number'
|
||
? within(args.volume, TRACK_MIN_DB, TRACK_MAX_DB, 'volume')
|
||
: (edge.send?.volume ?? 0);
|
||
const tap = (args.tap as SendTap | undefined) ?? edge.send?.tap ?? 'post';
|
||
return { command: new SetSendCommand(holder.id, edge.id, { volume, tap }) };
|
||
},
|
||
shows: (args) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: [] }),
|
||
},
|
||
{
|
||
name: 'add_clip',
|
||
description:
|
||
'Adds a clip to a sound or a video: a stretch of its file with a name, which a song, a composition, a Play ' +
|
||
'sound node or a sprite can play instead of the whole. props are its rows by key (get_asset lists them as ' +
|
||
'clipProperties): start and end in seconds (end 0 is the end of the file), and on a sound reverse and its fades.',
|
||
args: object(
|
||
{
|
||
asset: id('Id of the sound or video asset.'),
|
||
name: id('Its name; else Clip 1, Clip 2, ...'),
|
||
props: { type: 'object', description: 'Its rows by key.' },
|
||
},
|
||
['asset'],
|
||
),
|
||
build: (args) => {
|
||
const add = new AddClipCommand(text(args, 'asset'), (args.props ?? {}) as PropertyBag);
|
||
const steps: Command[] = [add];
|
||
if (typeof args.name === 'string')
|
||
steps.push(new RenameClipCommand(text(args, 'asset'), add.clipId, args.name));
|
||
return { command: steps.length === 1 ? add : oneStep('Add clip', steps), made: { id: add.clipId } };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'change_clip',
|
||
description:
|
||
'Changes a clip of a sound or a video: its name, and its rows by key. Give only what changes.',
|
||
args: object(
|
||
{
|
||
asset: id('Id of the sound or video asset.'),
|
||
clip: id('Id of the clip.'),
|
||
name: id('Its name.'),
|
||
props: { type: 'object', description: 'Rows by key.' },
|
||
},
|
||
['asset', 'clip'],
|
||
),
|
||
build: (args) => {
|
||
const steps: Command[] = [];
|
||
if (typeof args.name === 'string')
|
||
steps.push(new RenameClipCommand(text(args, 'asset'), text(args, 'clip'), args.name));
|
||
const props = (args.props ?? {}) as PropertyBag;
|
||
if (Object.keys(props).length > 0)
|
||
steps.push(new SetClipPropsCommand(text(args, 'asset'), text(args, 'clip'), props));
|
||
if (steps.length === 0) throw new CommandError('Name what to change: name or props');
|
||
return { command: steps.length === 1 ? steps[0]! : oneStep('Change clip', steps) };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
{
|
||
name: 'remove_clip',
|
||
description:
|
||
'Removes a clip of a sound or a video. What plays it is left naming it and reads as missing until an undo.',
|
||
args: object({ asset: id('Id of the sound or video asset.'), clip: id('Id of the clip.') }, [
|
||
'asset',
|
||
'clip',
|
||
]),
|
||
build: (args, { project }) => {
|
||
const asset = requireAsset(project, text(args, 'asset'));
|
||
if (!hasClips(asset) || !clipsOf(asset).some((c) => c.id === text(args, 'clip')))
|
||
throw new CommandError(`${asset.name} has no clip ${text(args, 'clip')}`);
|
||
return { command: new RemoveClipCommand(asset.id, text(args, 'clip')) };
|
||
},
|
||
shows: theTab,
|
||
},
|
||
];
|
||
|
||
/** The fields an item of a song has besides its place. */
|
||
function itemFields(): Record<string, ArgSchema> {
|
||
return {
|
||
length: whole('Ticks it lasts.', 1),
|
||
offset: number('Where in what it plays it starts: ticks into a pattern, seconds into a sound.'),
|
||
volume: DB(`From ${ITEM_MIN_DB} to 0 for a pattern, to ${itemMaxDb('sound')} for a sound.`),
|
||
transpose: whole(`Semitones a pattern's notes are moved by, -${MAX_TRANSPOSE} to ${MAX_TRANSPOSE}.`),
|
||
instrument: {
|
||
type: 'string',
|
||
description: "Id of an instrument a pattern plays with instead of its own; '' for its own.",
|
||
},
|
||
fx: {
|
||
type: 'string',
|
||
description: `Id of a Sound FX it goes through; "" for the one its asset names, "${NO_FX}" for none.`,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** The item with what `args` change, refused where a value is out of range or names the wrong kind of asset. */
|
||
function changedItem(project: Project, item: SongItem, args: Args): SongItem {
|
||
const out: SongItem = { ...item };
|
||
if (typeof args.track === 'string') out.track = args.track;
|
||
if (typeof args.tick === 'number') out.tick = args.tick;
|
||
if (typeof args.length === 'number') out.length = args.length;
|
||
if (typeof args.offset === 'number') {
|
||
if (args.offset < 0) throw new CommandError('The offset has to be 0 or more');
|
||
out.offset = out.kind === 'pattern' ? Math.round(args.offset) : args.offset;
|
||
}
|
||
if (typeof args.volume === 'number')
|
||
out.volume = within(args.volume, ITEM_MIN_DB, itemMaxDb(out.kind), 'volume');
|
||
if (typeof args.transpose === 'number') {
|
||
if (out.kind !== 'pattern') throw new CommandError('Only a pattern item is transposed');
|
||
out.transpose = within(args.transpose, -MAX_TRANSPOSE, MAX_TRANSPOSE, 'transpose');
|
||
}
|
||
if (typeof args.instrument === 'string') {
|
||
if (out.kind !== 'pattern') throw new CommandError('Only a pattern item plays with an instrument');
|
||
if (args.instrument !== '' && !findAssetOf(project, args.instrument, 'instrument'))
|
||
throw new CommandError(`${args.instrument} is no instrument asset`);
|
||
out.instrument = args.instrument;
|
||
}
|
||
if (typeof args.fx === 'string')
|
||
out.fx = args.fx === NO_FX || args.fx === '' ? args.fx : soundFxOf(project, args.fx);
|
||
return out;
|
||
}
|
||
|
||
/** How long an item laid in without a length is: its pattern, or its sound at the song's tempo. */
|
||
function itemLength(project: Project, song: SongAsset, kind: SongItem['kind'], play: string): number {
|
||
const pattern = kind === 'pattern' ? findAssetOf(project, play, 'pattern') : undefined;
|
||
if (pattern) return Math.max(1, patternLength(pattern));
|
||
const seconds = audioSeconds(project, { kind: 'sound', asset: play, volume: 0, bus: '' });
|
||
if (seconds <= 0)
|
||
throw new CommandError(
|
||
`The length of ${findSound(project, play)?.asset.name ?? play} is not known yet – give a length ` +
|
||
`(a bar of this song is ${ticksPerBar(songMeter(song.props))} ticks)`,
|
||
);
|
||
return Math.max(1, Math.round(ticksOfSeconds(seconds, songTempo(song.props))));
|
||
}
|
||
|
||
/** Refuses a range given to the lane of the volume or the pan: theirs is fixed. */
|
||
function noRange(args: Args): null {
|
||
if (args.min !== undefined || args.max !== undefined)
|
||
throw new CommandError('Only a parameter lane has a range – the volume and the pan have theirs');
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The range of a parameter's lane: the one given, else the one it has, else one round the default of
|
||
* the parameter. A new lane is refused for a parameter nothing on the track has.
|
||
*/
|
||
function paramRange(
|
||
project: Project,
|
||
song: SongAsset,
|
||
track: SongTrack,
|
||
name: string,
|
||
had: AutomationLane | undefined,
|
||
args: Args,
|
||
): { min: number; max: number } {
|
||
const offered = trackParams(project, song, track);
|
||
const param = offered.find((p) => p.name === name);
|
||
if (!had && !param)
|
||
throw new CommandError(
|
||
offered.length === 0
|
||
? `Nothing on ${track.name} has a parameter to move`
|
||
: `Nothing on ${track.name} has a parameter ${name} – it has ${offered.map((p) => p.name).join(', ')}`,
|
||
);
|
||
const given = [args.min, args.max].filter((value) => value !== undefined).length;
|
||
if (given === 1) throw new CommandError('Give min and max, or neither');
|
||
if (given === 2) {
|
||
const range = heldRange(args.min, args.max);
|
||
if (!range) throw new CommandError('max has to be above min');
|
||
return range;
|
||
}
|
||
return had ? laneRange(had) : defaultParamRange(param!.default);
|
||
}
|
||
|
||
/**
|
||
* The points the agent wrote, refused where a value or an easing is wrong. The song puts them in the
|
||
* order of their ticks, those on one tick as they came.
|
||
*/
|
||
function lanePointsOf(project: Project, kind: LaneKind, raw: readonly Args[]): AutomationPoint[] {
|
||
const { min, max } = laneRange(kind);
|
||
return raw.map((point): AutomationPoint => {
|
||
const tick = point.tick as number;
|
||
const value = within(point.value as number, min, max, `value at tick ${tick}`);
|
||
const ease = point.ease;
|
||
if (ease === undefined || ease === 'linear') return { tick, value };
|
||
if (!isKeyInterpolation(ease))
|
||
throw new CommandError(
|
||
`${JSON.stringify(ease)} is no easing – give ${EASINGS.join(', ')} or "curve:<id>"`,
|
||
);
|
||
const { curve } = easingOf(ease);
|
||
if (curve !== undefined && !findAssetOf(project, curve, 'curve'))
|
||
throw new CommandError(`${curve} is no Curve asset`);
|
||
return { tick, value, ease };
|
||
});
|
||
}
|
||
|
||
function requireTrack(song: SongAsset, trackId: string): SongTrack {
|
||
const track = song.tracks.find((t) => t.id === trackId);
|
||
if (!track) throw new CommandError(`${song.name} has no track ${trackId}`);
|
||
return track;
|
||
}
|
||
|
||
function requireMarker(song: SongAsset, markerId: string): SongMarker {
|
||
const marker = song.markers?.find((m) => m.id === markerId);
|
||
if (!marker) throw new CommandError(`${song.name} has no marker ${markerId}`);
|
||
return marker;
|
||
}
|
||
|
||
/** A pitch the agent wrote as a MIDI number or a name like "C#4". */
|
||
export function pitchOf(value: unknown): number {
|
||
const number =
|
||
typeof value === 'number'
|
||
? value
|
||
: typeof value === 'string'
|
||
? NOTE_BY_NAME.get(value.trim().toUpperCase())
|
||
: undefined;
|
||
if (number === undefined || !Number.isInteger(number) || number < LOWEST_NOTE || number > HIGHEST_NOTE)
|
||
throw new CommandError(
|
||
`${JSON.stringify(value)} is no note – give ${LOWEST_NOTE} to ${HIGHEST_NOTE} or a name like "C4" or "F#3"`,
|
||
);
|
||
return number;
|
||
}
|
||
|
||
const NOTE_BY_NAME = new Map(
|
||
Array.from({ length: HIGHEST_NOTE - LOWEST_NOTE + 1 }, (_, i) => [
|
||
noteName(LOWEST_NOTE + i),
|
||
LOWEST_NOTE + i,
|
||
]),
|
||
);
|
||
|
||
function requireInside(pattern: PatternAsset, note: PatternNote): void {
|
||
const end = patternLength(pattern);
|
||
if (note.tick >= end)
|
||
throw new CommandError(
|
||
`A note at tick ${note.tick} lies past the end of ${pattern.name}, which is ${end} ticks long – ` +
|
||
'give it more bars first',
|
||
);
|
||
}
|
||
|
||
function within(value: number, min: number, max: number, what: string): number {
|
||
if (value < min || value > max) throw new CommandError(`The ${what} has to be from ${min} to ${max}`);
|
||
return value;
|
||
}
|
||
|
||
function soundFxOf(project: Project, fx: string): string {
|
||
if (fx !== '' && findAsset(project, fx)?.type !== 'soundfx')
|
||
throw new CommandError(`${fx} is no Sound FX asset`);
|
||
return fx;
|
||
}
|