Files
feuerwerk/src/editor/agent/catalog/music.ts
T
adminandClaude Opus 5.5 56425fca6a
feuerwerk / gate (push) Successful in 5m22s
feuerwerk / suite (push) Failing after 19m54s
Let a song carry named markers on a strip in the song tab (#338)
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>
2026-10-19 18:44:58 +02:00

793 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}