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>
This commit is contained in:
1 parent
2099511695
commit
56425fca6a
13 files changed
+718
-30
No files matched your search
@@ -8,6 +8,7 @@ import type { EdgeSend, Graph } from '../graph/Graph';
|
|||||||
import type { Asset, SongAsset } from '../model/Asset';
|
import type { Asset, SongAsset } from '../model/Asset';
|
||||||
import { findGraphAsset, type Project } from '../model/Project';
|
import { findGraphAsset, type Project } from '../model/Project';
|
||||||
import { settleEmbedded } from '../music/embedded';
|
import { settleEmbedded } from '../music/embedded';
|
||||||
|
import { heldSongMarkers, type SongMarker } from '../music/markers';
|
||||||
import { routedKind, sendsTapped } from '../music/projectRouting';
|
import { routedKind, sendsTapped } from '../music/projectRouting';
|
||||||
import { heldSend, heldSongGraph } from '../music/routing';
|
import { heldSend, heldSongGraph } from '../music/routing';
|
||||||
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
|
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
|
||||||
@@ -75,6 +76,36 @@ export class SetSongCommand implements Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes the markers of a song whole (#338): one gesture, one step to undo. */
|
||||||
|
export class SetSongMarkersCommand implements Command {
|
||||||
|
private previous: SongMarker[] | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly markers: readonly SongMarker[],
|
||||||
|
readonly label = 'Edit markers',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Nothing to do when the song already has exactly these markers – no empty undo step. */
|
||||||
|
static changes(asset: SongAsset, markers: readonly SongMarker[]): boolean {
|
||||||
|
return JSON.stringify(asset.markers ?? []) !== JSON.stringify(heldSongMarkers(markers));
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const asset = requireSong(project, this.assetId);
|
||||||
|
this.previous = asset.markers;
|
||||||
|
const held = heldSongMarkers(this.markers);
|
||||||
|
if (held.length > 0) asset.markers = held;
|
||||||
|
else delete asset.markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const asset = requireSong(project, this.assetId);
|
||||||
|
if (this.previous) asset.markers = this.previous;
|
||||||
|
else delete asset.markers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How loud a link of a song's routing sends, and where it taps its track (#350); the same for a link
|
* How loud a link of a song's routing sends, and where it taps its track (#350); the same for a link
|
||||||
* of the master routing (#351), where only a bus and a song may tap anywhere but after their level.
|
* of the master routing (#351), where only a bus and a song may tap anywhere but after their level.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Graph } from '../graph/Graph';
|
import type { Graph } from '../graph/Graph';
|
||||||
import type { Bake } from '../music/bake';
|
import type { Bake } from '../music/bake';
|
||||||
|
import type { SongMarker } from '../music/markers';
|
||||||
import type { PatternNote } from '../music/pattern';
|
import type { PatternNote } from '../music/pattern';
|
||||||
import type { SongItem, SongTrack } from '../music/song';
|
import type { SongItem, SongTrack } from '../music/song';
|
||||||
import type { LoopRange } from '../music/sequence';
|
import type { LoopRange } from '../music/sequence';
|
||||||
@@ -490,6 +491,8 @@ export interface SongAsset extends AssetBase {
|
|||||||
tracks: SongTrack[];
|
tracks: SongTrack[];
|
||||||
items: SongItem[];
|
items: SongItem[];
|
||||||
loop?: LoopRange;
|
loop?: LoopRange;
|
||||||
|
/** Named spots on its time (#338); left out without one. */
|
||||||
|
markers?: SongMarker[];
|
||||||
graph: Graph;
|
graph: Graph;
|
||||||
bake?: Bake;
|
bake?: Bake;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Markers of a song (#338): named spots on its time, where an event fires in the game and where a
|
||||||
|
* switch to the song can start. A marker sits on a whole tick; two may share one, and so may their
|
||||||
|
* names – a node names a marker by its id.
|
||||||
|
*/
|
||||||
|
import { newId } from '../ids';
|
||||||
|
|
||||||
|
export interface SongMarker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tick: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finite = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
|
||||||
|
|
||||||
|
/** Held to what a marker may be: a whole tick from 0, a name, an id no other has; in the order of their ticks. */
|
||||||
|
export function heldSongMarkers(raw: unknown): SongMarker[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const markers: SongMarker[] = [];
|
||||||
|
for (const entry of Array.isArray(raw) ? (raw as unknown[]) : []) {
|
||||||
|
if (typeof entry !== 'object' || entry === null) continue;
|
||||||
|
const m = entry as Record<string, unknown>;
|
||||||
|
const tick = Math.round(finite(m.tick));
|
||||||
|
if (!(tick >= 0)) continue;
|
||||||
|
let id = typeof m.id === 'string' && m.id ? m.id : newId();
|
||||||
|
while (ids.has(id)) id = newId();
|
||||||
|
ids.add(id);
|
||||||
|
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||||
|
markers.push({ id, name: name || `Marker ${markers.length + 1}`, tick });
|
||||||
|
}
|
||||||
|
return markers.sort((a, b) => a.tick - b.tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Marker 3": the first number no marker is called by yet. */
|
||||||
|
export function nextMarkerName(markers: readonly SongMarker[]): string {
|
||||||
|
const taken = new Set(markers.map((m) => m.name));
|
||||||
|
let n = 1;
|
||||||
|
while (taken.has(`Marker ${n}`)) n++;
|
||||||
|
return `Marker ${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newMarker(markers: readonly SongMarker[], tick: number, name?: string): SongMarker {
|
||||||
|
return { id: newId(), name: name?.trim() || nextMarkerName(markers), tick: Math.max(0, Math.round(tick)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The markers with one of them changed; the others as they are. */
|
||||||
|
export function changedMarker(
|
||||||
|
markers: readonly SongMarker[],
|
||||||
|
id: string,
|
||||||
|
change: Partial<Pick<SongMarker, 'name' | 'tick'>>,
|
||||||
|
): SongMarker[] {
|
||||||
|
return markers.map((m) => (m.id === id ? { ...m, ...change } : m));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withoutMarker(markers: readonly SongMarker[], id: string): SongMarker[] {
|
||||||
|
return markers.filter((m) => m.id !== id);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { heldBake } from '../music/bake';
|
|||||||
import { heldStream } from '../music/streams';
|
import { heldStream } from '../music/streams';
|
||||||
import { heldPatternNotes } from '../music/pattern';
|
import { heldPatternNotes } from '../music/pattern';
|
||||||
import { isEmbeddable } from '../music/embedded';
|
import { isEmbeddable } from '../music/embedded';
|
||||||
|
import { heldSongMarkers } from '../music/markers';
|
||||||
import { heldSongItems, heldSongTracks } from '../music/song';
|
import { heldSongItems, heldSongTracks } from '../music/song';
|
||||||
import { heldSend, heldSongGraph } from '../music/routing';
|
import { heldSend, heldSongGraph } from '../music/routing';
|
||||||
import { isMasterRoutingDef, isSongRoutingDef } from '../music/routingNodes';
|
import { isMasterRoutingDef, isSongRoutingDef } from '../music/routingNodes';
|
||||||
@@ -348,6 +349,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
const loop = readLoop(a.loop);
|
const loop = readLoop(a.loop);
|
||||||
const bake = heldBake(a.bake);
|
const bake = heldBake(a.bake);
|
||||||
const tracks = heldSongTracks(a.tracks);
|
const tracks = heldSongTracks(a.tracks);
|
||||||
|
const markers = heldSongMarkers(a.markers);
|
||||||
assets.push({
|
assets.push({
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
@@ -356,6 +358,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
tracks,
|
tracks,
|
||||||
items: heldSongItems(a.items, tracks),
|
items: heldSongItems(a.items, tracks),
|
||||||
...(loop && { loop }),
|
...(loop && { loop }),
|
||||||
|
...(markers.length > 0 && { markers }),
|
||||||
graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks),
|
graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks),
|
||||||
...(bake && { bake }),
|
...(bake && { bake }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { oneStep } from '../../../core/commands/CompoundCommand';
|
|||||||
import { ReplaceGraphCommand } from '../../../core/commands/graphCommands';
|
import { ReplaceGraphCommand } from '../../../core/commands/graphCommands';
|
||||||
import { SetLoopCommand, SetPatternNotesCommand } from '../../../core/commands/patternCommands';
|
import { SetLoopCommand, SetPatternNotesCommand } from '../../../core/commands/patternCommands';
|
||||||
import { requireAsset, requireEdge, requirePattern, requireSong } from '../../../core/commands/require';
|
import { requireAsset, requireEdge, requirePattern, requireSong } from '../../../core/commands/require';
|
||||||
import { SetSendCommand, SetSongCommand } from '../../../core/commands/songCommands';
|
import { SetSendCommand, SetSongCommand, SetSongMarkersCommand } from '../../../core/commands/songCommands';
|
||||||
import type { SendTap } from '../../../core/graph/Graph';
|
import type { SendTap } from '../../../core/graph/Graph';
|
||||||
import { newId } from '../../../core/ids';
|
import { newId } from '../../../core/ids';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
laneRange,
|
laneRange,
|
||||||
paramOf,
|
paramOf,
|
||||||
} from '../../../core/music/automation';
|
} 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 { DEFAULT_VELOCITY, patternLength, type PatternNote } from '../../../core/music/pattern';
|
||||||
import { SEND_TAPS } from '../../../core/music/routing';
|
import { SEND_TAPS } from '../../../core/music/routing';
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +82,7 @@ import {
|
|||||||
const PATTERN = id('Id of the pattern asset.');
|
const PATTERN = id('Id of the pattern asset.');
|
||||||
const SONG = id('Id of the song asset.');
|
const SONG = id('Id of the song asset.');
|
||||||
const TRACK = id('Id of the track, as get_asset lists it under tracks.');
|
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 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 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 TICK = whole(`Ticks from the start; ${TICKS_PER_QUARTER} to a quarter note.`, 0);
|
||||||
@@ -397,6 +399,57 @@ export const MUSIC_COMMANDS: readonly CatalogCommand[] = [
|
|||||||
},
|
},
|
||||||
shows: theTab,
|
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',
|
name: 'add_song_item',
|
||||||
description:
|
description:
|
||||||
@@ -690,6 +743,12 @@ function requireTrack(song: SongAsset, trackId: string): SongTrack {
|
|||||||
return track;
|
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". */
|
/** A pitch the agent wrote as a MIDI number or a name like "C#4". */
|
||||||
export function pitchOf(value: unknown): number {
|
export function pitchOf(value: unknown): number {
|
||||||
const number =
|
const number =
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
{
|
{
|
||||||
"name": "get_asset",
|
"name": "get_asset",
|
||||||
"title": "Read an asset",
|
"title": "Read an asset",
|
||||||
"description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap, the pixels of small pixel art as rows of palette indices (colors as \"#rrggbb\"), the notes of a pattern with their names and how many ticks it is long, on each track of a song its automation lanes and the parameters a lane can move (params), the rows a clip of a sound or video has (clipProperties), how many seconds a loaded sound lasts, and under silent what of a pattern, song or composition is not heard: all of it without an instrument, or the notes its instrument does not sound. Under usedBy: every asset naming it or one of its frames or clips, with the objects, nodes, items or keys in there that do (none for a property of the asset itself), and the project's start scene, master FX or Master routing; empty when nothing uses it. A graph is only counted here; read it with get_graph.",
|
"description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap, the pixels of small pixel art as rows of palette indices (colors as \"#rrggbb\"), the notes of a pattern with their names and how many ticks it is long, on each track of a song its automation lanes and the parameters a lane can move (params), the markers of a song with their ids, names and ticks, the rows a clip of a sound or video has (clipProperties), how many seconds a loaded sound lasts, and under silent what of a pattern, song or composition is not heard: all of it without an instrument, or the notes its instrument does not sound. Under usedBy: every asset naming it or one of its frames or clips, with the objects, nodes, items or keys in there that do (none for a property of the asset itself), and the project's start scene, master FX or Master routing; empty when nothing uses it. A graph is only counted here; read it with get_graph.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": { "asset": { "type": "string", "description": "Id of the asset." } },
|
"properties": { "asset": { "type": "string", "description": "Id of the asset." } },
|
||||||
|
|||||||
@@ -13,12 +13,17 @@ export function songHooks({ state, editor }: HookContext) {
|
|||||||
current: () => songEditor.currentAssetId,
|
current: () => songEditor.currentAssetId,
|
||||||
info: () => songEditor.shownInfo,
|
info: () => songEditor.shownInfo,
|
||||||
rect: () => songEditor.canvasRect(),
|
rect: () => songEditor.canvasRect(),
|
||||||
/** The tracks, items and loop as the model holds them. */
|
/** The tracks, items, loop and markers as the model holds them. */
|
||||||
model: (assetId: string) => {
|
model: (assetId: string) => {
|
||||||
const asset = findAssetOf(state.project, assetId, 'song');
|
const asset = findAssetOf(state.project, assetId, 'song');
|
||||||
return asset
|
return asset
|
||||||
? (JSON.parse(
|
? (JSON.parse(
|
||||||
JSON.stringify({ tracks: asset.tracks, items: asset.items, loop: asset.loop ?? null }),
|
JSON.stringify({
|
||||||
|
tracks: asset.tracks,
|
||||||
|
items: asset.items,
|
||||||
|
loop: asset.loop ?? null,
|
||||||
|
markers: asset.markers ?? [],
|
||||||
|
}),
|
||||||
) as unknown)
|
) as unknown)
|
||||||
: null;
|
: null;
|
||||||
},
|
},
|
||||||
@@ -30,6 +35,9 @@ export function songHooks({ state, editor }: HookContext) {
|
|||||||
headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share),
|
headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share),
|
||||||
rulerAt: (tick: number) => timeline.rulerPoint(tick),
|
rulerAt: (tick: number) => timeline.rulerPoint(tick),
|
||||||
loopAt: (tick: number) => timeline.loopPoint(tick),
|
loopAt: (tick: number) => timeline.loopPoint(tick),
|
||||||
|
/** The markers (#338): where a tick of their strip is on screen, and each as drawn. */
|
||||||
|
markerAt: (tick: number) => timeline.markerPoint(tick),
|
||||||
|
markers: () => timeline.markersShown(),
|
||||||
/** The automation lanes (#337): where a value of one is drawn, its head, and the rows as shown. */
|
/** The automation lanes (#337): where a value of one is drawn, its head, and the rows as shown. */
|
||||||
automationAt: (track: number, target: AutomationTarget, tick: number, value: number) =>
|
automationAt: (track: number, target: AutomationTarget, tick: number, value: number) =>
|
||||||
timeline.automationPoint(track, target, tick, value),
|
timeline.automationPoint(track, target, tick, value),
|
||||||
|
|||||||
+230
-26
@@ -1,4 +1,10 @@
|
|||||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
|
import {
|
||||||
|
CanvasTextMetrics,
|
||||||
|
Container,
|
||||||
|
type FederatedPointerEvent,
|
||||||
|
type FederatedWheelEvent,
|
||||||
|
Graphics,
|
||||||
|
} from 'pixi.js';
|
||||||
import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands';
|
import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands';
|
||||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +23,7 @@ import {
|
|||||||
} from '../../core/music/automation';
|
} from '../../core/music/automation';
|
||||||
import { trackParams } from '../../core/music/trackParams';
|
import { trackParams } from '../../core/music/trackParams';
|
||||||
import { SetLoopCommand } from '../../core/commands/patternCommands';
|
import { SetLoopCommand } from '../../core/commands/patternCommands';
|
||||||
import { SetSongCommand, type SongParts } from '../../core/commands/songCommands';
|
import { SetSongCommand, SetSongMarkersCommand, type SongParts } from '../../core/commands/songCommands';
|
||||||
import {
|
import {
|
||||||
embeddedCopy,
|
embeddedCopy,
|
||||||
embeddedName,
|
embeddedName,
|
||||||
@@ -27,6 +33,7 @@ import {
|
|||||||
listedAssets,
|
listedAssets,
|
||||||
songClockProps,
|
songClockProps,
|
||||||
} from '../../core/music/embedded';
|
} from '../../core/music/embedded';
|
||||||
|
import { changedMarker, newMarker, type SongMarker, withoutMarker } from '../../core/music/markers';
|
||||||
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
|
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
|
||||||
import { songRoutes, trackGates } from '../../core/music/routing';
|
import { songRoutes, trackGates } from '../../core/music/routing';
|
||||||
import type { LoopRange } from '../../core/music/sequence';
|
import type { LoopRange } from '../../core/music/sequence';
|
||||||
@@ -114,6 +121,16 @@ import {
|
|||||||
trackLanes,
|
trackLanes,
|
||||||
trackRows,
|
trackRows,
|
||||||
} from './laneView';
|
} from './laneView';
|
||||||
|
import { GroupNameDialog } from '../panels/GroupNameDialog';
|
||||||
|
import {
|
||||||
|
MARKER_FLAG,
|
||||||
|
MARKER_STRIP,
|
||||||
|
markerAt,
|
||||||
|
markerNameX,
|
||||||
|
type MarkerSpan,
|
||||||
|
markerSpans,
|
||||||
|
SONG_TOP,
|
||||||
|
} from './markerView';
|
||||||
import { openParamLaneDialog, ParamLaneDialog } from './ParamLaneDialog';
|
import { openParamLaneDialog, ParamLaneDialog } from './ParamLaneDialog';
|
||||||
import { drawMeter } from './meterDraw';
|
import { drawMeter } from './meterDraw';
|
||||||
import {
|
import {
|
||||||
@@ -187,6 +204,8 @@ const SOLO_COLOR = 0xc9c23a;
|
|||||||
const FX_COLOR = 0x8fb8de;
|
const FX_COLOR = 0x8fb8de;
|
||||||
/** The way and the points of an automation lane (#337), and a fader its lane rules. */
|
/** The way and the points of an automation lane (#337), and a fader its lane rules. */
|
||||||
const AUTOMATION_COLOR = 0xe06c9f;
|
const AUTOMATION_COLOR = 0xe06c9f;
|
||||||
|
/** A marker of the song (#338): its flag on the strip and its line down the tracks. */
|
||||||
|
const MARKER_COLOR = 0x4fc3b0;
|
||||||
/** Height of an item's name strip; what it plays is drawn below. */
|
/** Height of an item's name strip; what it plays is drawn below. */
|
||||||
const LABEL_STRIP = 15;
|
const LABEL_STRIP = 15;
|
||||||
/** The letter on a lit mute, solo or FX button. */
|
/** The letter on a lit mute, solo or FX button. */
|
||||||
@@ -203,7 +222,7 @@ interface Placed {
|
|||||||
dark?: boolean;
|
dark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Area = 'corner' | 'ruler' | 'heads' | 'lanes';
|
type Area = 'corner' | 'ruler' | 'markers' | 'heads' | 'lanes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a press with Shift did to the picked items: `added` the item, or found it picked – then a
|
* What a press with Shift did to the picked items: `added` the item, or found it picked – then a
|
||||||
@@ -222,6 +241,8 @@ type Gesture =
|
|||||||
edge: 'start' | 'end' | null;
|
edge: 'start' | 'end' | null;
|
||||||
}
|
}
|
||||||
| { kind: 'cursor'; tick: number }
|
| { kind: 'cursor'; tick: number }
|
||||||
|
/** A marker (#338) dragged along the strip; a click that does not move it starts playing there. */
|
||||||
|
| { kind: 'marker'; id: string; tick: number; pointerX: number; moved: boolean }
|
||||||
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean }
|
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean }
|
||||||
| {
|
| {
|
||||||
kind: 'move';
|
kind: 'move';
|
||||||
@@ -304,7 +325,17 @@ export class SongTimeline extends Widget {
|
|||||||
private readonly headLabels = new Container();
|
private readonly headLabels = new Container();
|
||||||
private readonly headTexts = new TextPool(this.headLabels);
|
private readonly headTexts = new TextPool(this.headLabels);
|
||||||
private readonly ruler = new TimeRuler();
|
private readonly ruler = new TimeRuler();
|
||||||
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
|
/** The strip of the song's markers (#338), under the ruler. */
|
||||||
|
private readonly markersLayer = new Container();
|
||||||
|
private readonly markersG = new Graphics();
|
||||||
|
private readonly markerLabels = new Container();
|
||||||
|
private readonly markerTexts = new TextPool(this.markerLabels);
|
||||||
|
private readonly clips = {
|
||||||
|
lanes: new Graphics(),
|
||||||
|
heads: new Graphics(),
|
||||||
|
ruler: new Graphics(),
|
||||||
|
markers: new Graphics(),
|
||||||
|
};
|
||||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||||
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
|
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
|
||||||
/** A zoom or a Fit shown as a transition (#377), the time and the tracks each on their own. */
|
/** A zoom or a Fit shown as a transition (#377), the time and the tracks each on their own. */
|
||||||
@@ -363,11 +394,13 @@ export class SongTimeline extends Widget {
|
|||||||
this.focusable = true;
|
this.focusable = true;
|
||||||
this.lanesLayer.addChild(this.lanesG, this.automationG, this.itemsG, this.itemLabels, this.overlay);
|
this.lanesLayer.addChild(this.lanesG, this.automationG, this.itemsG, this.itemLabels, this.overlay);
|
||||||
this.headsLayer.addChild(this.headsG, this.headLabels);
|
this.headsLayer.addChild(this.headsG, this.headLabels);
|
||||||
|
this.markersLayer.addChild(this.markersG, this.markerLabels);
|
||||||
this.addChild(
|
this.addChild(
|
||||||
this.bg,
|
this.bg,
|
||||||
this.lanesLayer,
|
this.lanesLayer,
|
||||||
this.headsLayer,
|
this.headsLayer,
|
||||||
this.ruler,
|
this.ruler,
|
||||||
|
this.markersLayer,
|
||||||
this.hint,
|
this.hint,
|
||||||
this.outLabel,
|
this.outLabel,
|
||||||
this.outPeak,
|
this.outPeak,
|
||||||
@@ -376,6 +409,8 @@ export class SongTimeline extends Widget {
|
|||||||
this.lanesLayer.mask = this.clips.lanes;
|
this.lanesLayer.mask = this.clips.lanes;
|
||||||
this.headsLayer.mask = this.clips.heads;
|
this.headsLayer.mask = this.clips.heads;
|
||||||
this.ruler.mask = this.clips.ruler;
|
this.ruler.mask = this.clips.ruler;
|
||||||
|
this.markersLayer.mask = this.clips.markers;
|
||||||
|
this.markersLayer.eventMode = 'none';
|
||||||
this.hint.eventMode = 'none';
|
this.hint.eventMode = 'none';
|
||||||
this.outLabel.eventMode = 'none';
|
this.outLabel.eventMode = 'none';
|
||||||
this.outPeak.eventMode = 'none';
|
this.outPeak.eventMode = 'none';
|
||||||
@@ -663,7 +698,7 @@ export class SongTimeline extends Widget {
|
|||||||
const at = this.getGlobalPosition();
|
const at = this.getGlobalPosition();
|
||||||
return {
|
return {
|
||||||
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
|
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
|
||||||
y: at.y + RULER_HEIGHT + trackTop(this.view, this.rowOfIndex(track)) + this.view.trackHeight / 2,
|
y: at.y + SONG_TOP + trackTop(this.view, this.rowOfIndex(track)) + this.view.trackHeight / 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,7 +711,7 @@ export class SongTimeline extends Widget {
|
|||||||
const at = this.getGlobalPosition();
|
const at = this.getGlobalPosition();
|
||||||
return {
|
return {
|
||||||
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
|
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
|
||||||
y: at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(kind, value, this.view.trackHeight),
|
y: at.y + SONG_TOP + trackTop(this.view, row) + laneY(kind, value, this.view.trackHeight),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,7 +723,7 @@ export class SongTimeline extends Widget {
|
|||||||
const at = this.getGlobalPosition();
|
const at = this.getGlobalPosition();
|
||||||
return {
|
return {
|
||||||
x: at.x + HEAD_WIDTH / 2,
|
x: at.x + HEAD_WIDTH / 2,
|
||||||
y: at.y + RULER_HEIGHT + trackTop(this.view, row) + this.view.trackHeight / 2,
|
y: at.y + SONG_TOP + trackTop(this.view, row) + this.view.trackHeight / 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,7 +747,7 @@ export class SongTimeline extends Widget {
|
|||||||
const box = itemBox(this.view, row, item);
|
const box = itemBox(this.view, row, item);
|
||||||
const at = this.getGlobalPosition();
|
const at = this.getGlobalPosition();
|
||||||
const x = part === 'start' ? box.x + 2 : part === 'end' ? box.x + box.w - 2 : box.x + box.w / 2;
|
const x = part === 'start' ? box.x + 2 : part === 'end' ? box.x + box.w - 2 : box.x + box.w / 2;
|
||||||
return { x: at.x + HEAD_WIDTH + x, y: at.y + RULER_HEIGHT + box.y + box.h / 2 };
|
return { x: at.x + HEAD_WIDTH + x, y: at.y + SONG_TOP + box.y + box.h / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The meters of these tracks and Out a frame on (#359), with the loudest sample on each side since. */
|
/** The meters of these tracks and Out a frame on (#359), with the loudest sample on each side since. */
|
||||||
@@ -770,7 +805,7 @@ export class SongTimeline extends Widget {
|
|||||||
const box = trackMeterBox(this.view.trackHeight);
|
const box = trackMeterBox(this.view.trackHeight);
|
||||||
return {
|
return {
|
||||||
x: at.x + box.x + box.w / 2,
|
x: at.x + box.x + box.w / 2,
|
||||||
y: at.y + RULER_HEIGHT + trackTop(this.view, this.rowOfIndex(track)) + box.y + box.h / 2,
|
y: at.y + SONG_TOP + trackTop(this.view, this.rowOfIndex(track)) + box.y + box.h / 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,7 +816,7 @@ export class SongTimeline extends Widget {
|
|||||||
const slider = part === 'volume' || part === 'pan';
|
const slider = part === 'volume' || part === 'pan';
|
||||||
return {
|
return {
|
||||||
x: at.x + box.x + (slider ? box.w * share : box.w / 2),
|
x: at.x + box.x + (slider ? box.w * share : box.w / 2),
|
||||||
y: at.y + RULER_HEIGHT + trackTop(this.view, this.rowOfIndex(track)) + box.y + box.h / 2,
|
y: at.y + SONG_TOP + trackTop(this.view, this.rowOfIndex(track)) + box.y + box.h / 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +825,25 @@ export class SongTimeline extends Widget {
|
|||||||
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
|
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A tick on the strip of the song's markers (#338). */
|
||||||
|
markerPoint(tick: number): Vec2 {
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT + MARKER_STRIP / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The markers as drawn, a dragged one where it would land, with where each is on screen. */
|
||||||
|
markersShown(): (SongMarker & { x: number; w: number })[] {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) return [];
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
const markers = this.shownMarkers(asset);
|
||||||
|
return this.spansOf(markers).map((span, i) => ({
|
||||||
|
...markers[i]!,
|
||||||
|
x: at.x + HEAD_WIDTH + span.x,
|
||||||
|
w: span.w,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** A tick on the strip of the ruler that holds the loop. */
|
/** A tick on the strip of the ruler that holds the loop. */
|
||||||
loopPoint(tick: number): Vec2 {
|
loopPoint(tick: number): Vec2 {
|
||||||
const at = this.getGlobalPosition();
|
const at = this.getGlobalPosition();
|
||||||
@@ -840,7 +894,7 @@ export class SongTimeline extends Widget {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
label: this.labelOf(item),
|
label: this.labelOf(item),
|
||||||
content: this.contentOf(item),
|
content: this.contentOf(item),
|
||||||
box: { x: at.x + HEAD_WIDTH + box.x, y: at.y + RULER_HEIGHT + box.y, w: box.w, h: box.h },
|
box: { x: at.x + HEAD_WIDTH + box.x, y: at.y + SONG_TOP + box.y, w: box.w, h: box.h },
|
||||||
seams: this.seamsOf(item),
|
seams: this.seamsOf(item),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -854,7 +908,7 @@ export class SongTimeline extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get lanesHeight(): number {
|
private get lanesHeight(): number {
|
||||||
return Math.max(0, this.h - RULER_HEIGHT);
|
return Math.max(0, this.h - SONG_TOP);
|
||||||
}
|
}
|
||||||
|
|
||||||
private asset(): SongAsset | null {
|
private asset(): SongAsset | null {
|
||||||
@@ -966,12 +1020,13 @@ export class SongTimeline extends Widget {
|
|||||||
private areaAt(p: Vec2): Area {
|
private areaAt(p: Vec2): Area {
|
||||||
const left = p.x < HEAD_WIDTH;
|
const left = p.x < HEAD_WIDTH;
|
||||||
if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler';
|
if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler';
|
||||||
|
if (p.y < SONG_TOP) return left ? 'corner' : 'markers';
|
||||||
return left ? 'heads' : 'lanes';
|
return left ? 'heads' : 'lanes';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A point of the widget in the lanes' own pixels. */
|
/** A point of the widget in the lanes' own pixels. */
|
||||||
private inLanes(p: Vec2): Vec2 {
|
private inLanes(p: Vec2): Vec2 {
|
||||||
return { x: p.x - HEAD_WIDTH, y: p.y - RULER_HEIGHT };
|
return { x: p.x - HEAD_WIDTH, y: p.y - SONG_TOP };
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerDown(e: FederatedPointerEvent): void {
|
private onPointerDown(e: FederatedPointerEvent): void {
|
||||||
@@ -1001,6 +1056,7 @@ export class SongTimeline extends Widget {
|
|||||||
}
|
}
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
||||||
|
if (area === 'markers') this.openMarkerMenu(asset, g.x, tick, e.global);
|
||||||
const lane = area === 'lanes' ? this.laneAt(asset, g.y) : null;
|
const lane = area === 'lanes' ? this.laneAt(asset, g.y) : null;
|
||||||
if (lane) this.openLaneMenu(lane, g, e.global);
|
if (lane) this.openLaneMenu(lane, g, e.global);
|
||||||
else if (area === 'lanes') this.openItemMenu(asset, g, e.global);
|
else if (area === 'lanes') this.openItemMenu(asset, g, e.global);
|
||||||
@@ -1025,10 +1081,27 @@ export class SongTimeline extends Widget {
|
|||||||
kind: 'cursor',
|
kind: 'cursor',
|
||||||
tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey),
|
tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey),
|
||||||
};
|
};
|
||||||
} else this.pressLanes(asset, e, g);
|
} else if (area === 'markers') this.pressMarkers(asset, e, g.x, tick);
|
||||||
|
else this.pressLanes(asset, e, g);
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A press on the strip of the markers (#338): on a marker it takes it to drag, a second one names
|
||||||
|
* it; a second press on nothing adds one there – on the grid, with Alt or Shift on the tick.
|
||||||
|
*/
|
||||||
|
private pressMarkers(asset: SongAsset, e: FederatedPointerEvent, x: number, tick: number): void {
|
||||||
|
const id = markerAt(this.spansOf(asset.markers ?? []), x);
|
||||||
|
const marker = asset.markers?.find((m) => m.id === id);
|
||||||
|
if (!marker) {
|
||||||
|
if (pressCount(e, `song-markers:${asset.id}`) === 2)
|
||||||
|
this.addMarker(asset, this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pressCount(e, `song-marker:${marker.id}`) === 2) return this.renameMarker(marker);
|
||||||
|
this.gesture = { kind: 'marker', id: marker.id, tick: marker.tick, pointerX: x, moved: false };
|
||||||
|
}
|
||||||
|
|
||||||
private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void {
|
private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void {
|
||||||
const rows = this.rows(asset);
|
const rows = this.rows(asset);
|
||||||
const at = trackAtY(this.view, rows.length, y);
|
const at = trackAtY(this.view, rows.length, y);
|
||||||
@@ -1233,6 +1306,11 @@ export class SongTimeline extends Widget {
|
|||||||
case 'cursor':
|
case 'cursor':
|
||||||
gesture.tick = this.cursorTick(tick, grid, free);
|
gesture.tick = this.cursorTick(tick, grid, free);
|
||||||
break;
|
break;
|
||||||
|
case 'marker':
|
||||||
|
if (!gesture.moved && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return;
|
||||||
|
gesture.moved = true;
|
||||||
|
gesture.tick = this.cursorTick(tick, grid, free);
|
||||||
|
break;
|
||||||
case 'empty': {
|
case 'empty': {
|
||||||
gesture.current = g;
|
gesture.current = g;
|
||||||
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
|
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
|
||||||
@@ -1325,6 +1403,18 @@ export class SongTimeline extends Widget {
|
|||||||
case 'cursor':
|
case 'cursor':
|
||||||
this.setStart(gesture.tick);
|
this.setStart(gesture.tick);
|
||||||
break;
|
break;
|
||||||
|
case 'marker': {
|
||||||
|
const marker = asset.markers?.find((m) => m.id === gesture.id);
|
||||||
|
if (!marker) break;
|
||||||
|
if (!gesture.moved) this.setStart(marker.tick);
|
||||||
|
else if (gesture.tick !== marker.tick)
|
||||||
|
this.commitMarkers(
|
||||||
|
asset,
|
||||||
|
changedMarker(asset.markers!, marker.id, { tick: gesture.tick }),
|
||||||
|
'Move marker',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'empty': {
|
case 'empty': {
|
||||||
const caught = gesture.active
|
const caught = gesture.active
|
||||||
? itemsWithin(this.view, asset.items, this.rowsOfTracks(asset), gesture.start, gesture.current)
|
? itemsWithin(this.view, asset.items, this.rowsOfTracks(asset), gesture.start, gesture.current)
|
||||||
@@ -1404,7 +1494,7 @@ export class SongTimeline extends Widget {
|
|||||||
const inside =
|
const inside =
|
||||||
local.x >= HEAD_WIDTH &&
|
local.x >= HEAD_WIDTH &&
|
||||||
local.x <= this.w &&
|
local.x <= this.w &&
|
||||||
local.y >= RULER_HEIGHT &&
|
local.y >= SONG_TOP &&
|
||||||
local.y <= this.h &&
|
local.y <= this.h &&
|
||||||
xToTick(this.view, g.x) >= 0;
|
xToTick(this.view, g.x) >= 0;
|
||||||
const row = this.trackAt(asset, g.y);
|
const row = this.trackAt(asset, g.y);
|
||||||
@@ -1435,7 +1525,9 @@ export class SongTimeline extends Widget {
|
|||||||
* up. A point of a lane (#337) says its value.
|
* up. A point of a lane (#337) says its value.
|
||||||
*/
|
*/
|
||||||
private followHover(asset: SongAsset, local: Vec2, g: Vec2, global: Vec2): void {
|
private followHover(asset: SongAsset, local: Vec2, g: Vec2, global: Vec2): void {
|
||||||
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
|
if (local.x <= this.w && this.areaAt(local) === 'markers')
|
||||||
|
return this.followMarkerHover(asset, g, global);
|
||||||
|
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= SONG_TOP && local.y <= this.h;
|
||||||
const lane = inside ? this.laneAt(asset, g.y) : null;
|
const lane = inside ? this.laneAt(asset, g.y) : null;
|
||||||
const point =
|
const point =
|
||||||
lane &&
|
lane &&
|
||||||
@@ -1472,6 +1564,30 @@ export class SongTimeline extends Widget {
|
|||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A marker under the pointer says its whole name and can be dragged; the strip off one says how to add one. */
|
||||||
|
private followMarkerHover(asset: SongAsset, g: Vec2, global: Vec2): void {
|
||||||
|
const spans = this.spansOf(asset.markers ?? []);
|
||||||
|
const id = markerAt(spans, g.x);
|
||||||
|
const marker = asset.markers?.find((m) => m.id === id);
|
||||||
|
const span = spans.find((s) => s.id === id);
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
const anchor = span
|
||||||
|
? { x: at.x + HEAD_WIDTH + span.x, y: at.y + RULER_HEIGHT, w: span.w, h: MARKER_STRIP }
|
||||||
|
: { x: global.x - 4, y: global.y - 4, w: 8, h: 8 };
|
||||||
|
tooltips.point(
|
||||||
|
this,
|
||||||
|
marker ? `marker:${marker.id}` : 'markers',
|
||||||
|
() => ({ text: marker ? marker.name : 'Double-click to add a marker', anchor }),
|
||||||
|
{ x: global.x, y: global.y },
|
||||||
|
);
|
||||||
|
const cursor = marker ? 'ew-resize' : 'default';
|
||||||
|
if (this.cursor !== cursor) this.cursor = cursor;
|
||||||
|
if (this.hover) {
|
||||||
|
this.hover = null;
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Where a paste goes with the pointer over the lanes: the grid line before it and its track; else null. */
|
/** Where a paste goes with the pointer over the lanes: the grid line before it and its track; else null. */
|
||||||
private pasteSpot(asset: SongAsset): { tick: number; row: number } | null {
|
private pasteSpot(asset: SongAsset): { tick: number; row: number } | null {
|
||||||
const p = this.pointer;
|
const p = this.pointer;
|
||||||
@@ -1498,6 +1614,51 @@ export class SongTimeline extends Widget {
|
|||||||
if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label));
|
if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private commitMarkers(asset: SongAsset, markers: readonly SongMarker[], label: string): void {
|
||||||
|
if (SetSongMarkersCommand.changes(asset, markers))
|
||||||
|
this.state.execute(new SetSongMarkersCommand(asset.id, markers, label));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new marker at a tick, named at once. */
|
||||||
|
private addMarker(asset: SongAsset, tick: number): void {
|
||||||
|
const markers = asset.markers ?? [];
|
||||||
|
this.commitMarkers(asset, [...markers, newMarker(markers, tick)], 'Add marker');
|
||||||
|
}
|
||||||
|
|
||||||
|
private renameMarker(marker: SongMarker): void {
|
||||||
|
GroupNameDialog.open(
|
||||||
|
new GroupNameDialog('Rename marker', marker.name, 'Rename', (name) => {
|
||||||
|
const now = this.asset();
|
||||||
|
if (now?.markers?.some((m) => m.id === marker.id))
|
||||||
|
this.commitMarkers(now, changedMarker(now.markers, marker.id, { name }), 'Rename marker');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The menu of the marker under a right-click on the strip; off one, a marker to add there. */
|
||||||
|
private openMarkerMenu(asset: SongAsset, x: number, tick: number, global: Vec2): void {
|
||||||
|
const id = markerAt(this.spansOf(asset.markers ?? []), x);
|
||||||
|
const marker = asset.markers?.find((m) => m.id === id);
|
||||||
|
if (!marker) {
|
||||||
|
const at = this.cursorTick(tick, songGrid(asset.props), false);
|
||||||
|
new Menu([{ label: 'Add marker', onSelect: () => this.addMarker(asset, at) }]).open(global.x, global.y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new Menu([
|
||||||
|
{ label: 'Play from here', onSelect: () => this.setStart(marker.tick) },
|
||||||
|
{ label: 'Rename…', onSelect: () => this.renameMarker(marker) },
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: 'Delete marker',
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => {
|
||||||
|
const now = this.asset();
|
||||||
|
if (now) this.commitMarkers(now, withoutMarker(now.markers ?? [], marker.id), 'Delete marker');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).open(global.x, global.y);
|
||||||
|
}
|
||||||
|
|
||||||
private openRulerMenu(asset: SongAsset, tick: number, global: Vec2): void {
|
private openRulerMenu(asset: SongAsset, tick: number, global: Vec2): void {
|
||||||
const bar = ticksPerBar(songMeter(asset.props));
|
const bar = ticksPerBar(songMeter(asset.props));
|
||||||
const first = snapDown(tick, bar);
|
const first = snapDown(tick, bar);
|
||||||
@@ -1659,8 +1820,7 @@ export class SongTimeline extends Widget {
|
|||||||
const kind = laneKindOf(track, target);
|
const kind = laneKindOf(track, target);
|
||||||
const at = this.getGlobalPosition();
|
const at = this.getGlobalPosition();
|
||||||
const x = at.x + HEAD_WIDTH + tickToX(this.view, point.tick);
|
const x = at.x + HEAD_WIDTH + tickToX(this.view, point.tick);
|
||||||
const y =
|
const y = at.y + SONG_TOP + trackTop(this.view, row) + laneY(kind, point.value, this.view.trackHeight);
|
||||||
at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(kind, point.value, this.view.trackHeight);
|
|
||||||
return { text: laneValueText(kind, point.value), anchor: { x: x - 4, y: y - 4, w: 8, h: 8 } };
|
return { text: laneValueText(kind, point.value), anchor: { x: x - 4, y: y - 4, w: 8, h: 8 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1793,7 +1953,7 @@ export class SongTimeline extends Widget {
|
|||||||
const g = this.inLanes({ x: local.x, y: local.y });
|
const g = this.inLanes({ x: local.x, y: local.y });
|
||||||
const factor = zoomFactor(e.deltaY);
|
const factor = zoomFactor(e.deltaY);
|
||||||
const tracks = area === 'heads';
|
const tracks = area === 'heads';
|
||||||
if (!tracks && area !== 'ruler' && area !== 'lanes') return;
|
if (!tracks && area !== 'ruler' && area !== 'markers' && area !== 'lanes') return;
|
||||||
(tracks ? this.trackTween : this.timeTween).change(() => {
|
(tracks ? this.trackTween : this.timeTween).change(() => {
|
||||||
const view = tracks
|
const view = tracks
|
||||||
? zoomTracks(this.view, factor, g.y)
|
? zoomTracks(this.view, factor, g.y)
|
||||||
@@ -1874,6 +2034,19 @@ export class SongTimeline extends Widget {
|
|||||||
return songLoop(asset);
|
return songLoop(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The markers as a drag on the strip would leave them. */
|
||||||
|
private shownMarkers(asset: SongAsset): SongMarker[] {
|
||||||
|
const g = this.gesture;
|
||||||
|
const markers = asset.markers ?? [];
|
||||||
|
if (g?.kind !== 'marker' || !g.moved) return markers;
|
||||||
|
return changedMarker(markers, g.id, { tick: g.tick }).sort((a, b) => a.tick - b.tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spansOf(markers: readonly SongMarker[]): MarkerSpan[] {
|
||||||
|
const style = textStyle('small');
|
||||||
|
return markerSpans(this.view, markers, (name) => CanvasTextMetrics.measureText(name, style).width);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where playing starts, as a drag on the ruler would leave it. */
|
/** Where playing starts, as a drag on the ruler would leave it. */
|
||||||
private shownStart(): number {
|
private shownStart(): number {
|
||||||
return this.gesture?.kind === 'cursor' ? this.gesture.tick : this.startTick;
|
return this.gesture?.kind === 'cursor' ? this.gesture.tick : this.startTick;
|
||||||
@@ -1925,16 +2098,18 @@ export class SongTimeline extends Widget {
|
|||||||
const lh = this.lanesHeight;
|
const lh = this.lanesHeight;
|
||||||
this.bg.clear().rect(0, 0, w, h).fill(c.viewport);
|
this.bg.clear().rect(0, 0, w, h).fill(c.viewport);
|
||||||
this.bg.rect(0, 0, HEAD_WIDTH, h).fill(c.panel);
|
this.bg.rect(0, 0, HEAD_WIDTH, h).fill(c.panel);
|
||||||
this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader);
|
this.bg.rect(0, 0, w, SONG_TOP).fill(c.panelHeader);
|
||||||
this.bg.rect(HEAD_WIDTH - 1, 0, 1, h).fill(c.border);
|
this.bg.rect(HEAD_WIDTH - 1, 0, 1, h).fill(c.border);
|
||||||
this.clips.lanes.clear().rect(HEAD_WIDTH, RULER_HEIGHT, lw, lh).fill(0xffffff);
|
this.clips.lanes.clear().rect(HEAD_WIDTH, SONG_TOP, lw, lh).fill(0xffffff);
|
||||||
this.clips.heads
|
this.clips.heads
|
||||||
.clear()
|
.clear()
|
||||||
.rect(0, RULER_HEIGHT, HEAD_WIDTH - 1, lh)
|
.rect(0, SONG_TOP, HEAD_WIDTH - 1, lh)
|
||||||
.fill(0xffffff);
|
.fill(0xffffff);
|
||||||
this.clips.ruler.clear().rect(HEAD_WIDTH, 0, lw, RULER_HEIGHT).fill(0xffffff);
|
this.clips.ruler.clear().rect(HEAD_WIDTH, 0, lw, RULER_HEIGHT).fill(0xffffff);
|
||||||
this.lanesLayer.position.set(HEAD_WIDTH, RULER_HEIGHT);
|
this.clips.markers.clear().rect(HEAD_WIDTH, RULER_HEIGHT, lw, MARKER_STRIP).fill(0xffffff);
|
||||||
this.headsLayer.position.set(0, RULER_HEIGHT);
|
this.markersLayer.position.set(HEAD_WIDTH, RULER_HEIGHT);
|
||||||
|
this.lanesLayer.position.set(HEAD_WIDTH, SONG_TOP);
|
||||||
|
this.headsLayer.position.set(0, SONG_TOP);
|
||||||
this.ruler.position.set(HEAD_WIDTH, 0);
|
this.ruler.position.set(HEAD_WIDTH, 0);
|
||||||
|
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
@@ -1942,14 +2117,16 @@ export class SongTimeline extends Widget {
|
|||||||
this.automationG.clear();
|
this.automationG.clear();
|
||||||
this.itemsG.clear();
|
this.itemsG.clear();
|
||||||
this.overlay.clear();
|
this.overlay.clear();
|
||||||
|
this.markersG.clear();
|
||||||
this.headsG.clear();
|
this.headsG.clear();
|
||||||
this.outLabel.visible = this.outPeak.visible = asset !== null;
|
this.outLabel.visible = this.outPeak.visible = asset !== null;
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
this.itemTexts.show([]);
|
this.itemTexts.show([]);
|
||||||
this.headTexts.show([]);
|
this.headTexts.show([]);
|
||||||
this.ruler.visible = false;
|
this.ruler.visible = false;
|
||||||
|
this.markerTexts.show([]);
|
||||||
this.hint.text = 'No song selected – pick one in the Assets panel or create one.';
|
this.hint.text = 'No song selected – pick one in the Assets panel or create one.';
|
||||||
this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, SONG_TOP + Theme.padding * 2);
|
||||||
this.hint.visible = true;
|
this.hint.visible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1968,6 +2145,7 @@ export class SongTimeline extends Widget {
|
|||||||
start: this.shownStart(),
|
start: this.shownStart(),
|
||||||
playhead: this.playhead,
|
playhead: this.playhead,
|
||||||
});
|
});
|
||||||
|
this.drawMarkers(asset, lw, lh);
|
||||||
const g = this.gesture;
|
const g = this.gesture;
|
||||||
if (g?.kind === 'empty' && g.active) drawBand(this.overlay, g.start, g.current);
|
if (g?.kind === 'empty' && g.active) drawBand(this.overlay, g.start, g.current);
|
||||||
if (this.drop) {
|
if (this.drop) {
|
||||||
@@ -1993,10 +2171,36 @@ export class SongTimeline extends Widget {
|
|||||||
this.hint.visible = this.hint.text !== '';
|
this.hint.visible = this.hint.text !== '';
|
||||||
this.hint.position.set(
|
this.hint.position.set(
|
||||||
HEAD_WIDTH + Theme.padding * 2,
|
HEAD_WIDTH + Theme.padding * 2,
|
||||||
RULER_HEIGHT + trackTop(this.view, rows.length) + Theme.padding * 2,
|
SONG_TOP + trackTop(this.view, rows.length) + Theme.padding * 2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The markers (#338): a flag and the name on their strip, and a faint line down the tracks. */
|
||||||
|
private drawMarkers(asset: SongAsset, lw: number, lh: number): void {
|
||||||
|
const g = this.markersG;
|
||||||
|
g.rect(0, MARKER_STRIP - 1, lw, 1).fill(Theme.colors.border);
|
||||||
|
const markers = this.shownMarkers(asset);
|
||||||
|
const names = new Map(markers.map((m) => [m.id, m.name]));
|
||||||
|
const dragged = this.gesture?.kind === 'marker' && this.gesture.moved ? this.gesture.id : null;
|
||||||
|
const spans = this.spansOf(markers).filter((span) => span.x + span.w >= 0 && span.x - MARKER_FLAG <= lw);
|
||||||
|
for (const span of spans) {
|
||||||
|
const x = Math.round(span.x);
|
||||||
|
const alpha = span.id === dragged ? 1 : 0.85;
|
||||||
|
g.rect(x, 0, 1, MARKER_STRIP - 1).fill({ color: MARKER_COLOR, alpha });
|
||||||
|
g.moveTo(x, 1)
|
||||||
|
.lineTo(x + MARKER_FLAG, 5)
|
||||||
|
.lineTo(x, 9)
|
||||||
|
.closePath()
|
||||||
|
.fill({ color: MARKER_COLOR, alpha });
|
||||||
|
if (x >= 0 && x <= lw)
|
||||||
|
this.overlay.rect(x, 0, 1, lh).fill({ color: MARKER_COLOR, alpha: span.id === dragged ? 0.6 : 0.3 });
|
||||||
|
}
|
||||||
|
const style = textStyle('small');
|
||||||
|
this.markerTexts
|
||||||
|
.show(spans.map((span) => truncate(names.get(span.id)!, style, span.x + span.w - markerNameX(span))))
|
||||||
|
.forEach((text, i) => text.position.set(Math.round(markerNameX(spans[i]!)), 2));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A band per track, the picked one lit, a darker one for each of its open automation lanes (#337),
|
* A band per track, the picked one lit, a darker one for each of its open automation lanes (#337),
|
||||||
* and the bar, beat and grid lines across them.
|
* and the bar, beat and grid lines across them.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { SongMarker } from '../../core/music/markers';
|
||||||
|
import { RULER_HEIGHT, type TimeView, tickToX } from '../music/timeView';
|
||||||
|
|
||||||
|
/** The strip under the song's ruler that holds its markers (#338). */
|
||||||
|
export const MARKER_STRIP = 18;
|
||||||
|
/** Where the tracks of the Song tab start: below the ruler and its markers. */
|
||||||
|
export const SONG_TOP = RULER_HEIGHT + MARKER_STRIP;
|
||||||
|
/** How far either side of its line a press still takes a marker. */
|
||||||
|
export const MARKER_REACH = 5;
|
||||||
|
/** The flag at the head of a marker's line, and the room between it and the name. */
|
||||||
|
export const MARKER_FLAG = 8;
|
||||||
|
const NAME_GAP = 3;
|
||||||
|
|
||||||
|
/** Where a marker is drawn on the strip: its line at `x`, flag and name over `w` pixels right of it. */
|
||||||
|
export interface MarkerSpan {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
w: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each marker as drawn on the strip: its flag and as much of its name as there is room for before
|
||||||
|
* the next one. `measure` gives the width of a name in pixels.
|
||||||
|
*/
|
||||||
|
export function markerSpans(
|
||||||
|
view: TimeView,
|
||||||
|
markers: readonly SongMarker[],
|
||||||
|
measure: (name: string) => number,
|
||||||
|
): MarkerSpan[] {
|
||||||
|
return markers.map((marker, i) => {
|
||||||
|
const x = tickToX(view, marker.tick);
|
||||||
|
const next = markers[i + 1];
|
||||||
|
const room = next ? tickToX(view, next.tick) - x - 2 : Infinity;
|
||||||
|
const w = Math.max(MARKER_FLAG, Math.min(MARKER_FLAG + NAME_GAP + measure(marker.name), room));
|
||||||
|
return { id: marker.id, x, w };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a marker's name starts, right of its flag. */
|
||||||
|
export function markerNameX(span: MarkerSpan): number {
|
||||||
|
return span.x + MARKER_FLAG + NAME_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The marker under `x` on the strip, the last drawn first: near its line, or on its flag and name. */
|
||||||
|
export function markerAt(spans: readonly MarkerSpan[], x: number): string | null {
|
||||||
|
for (let i = spans.length - 1; i >= 0; i--) {
|
||||||
|
const span = spans[i]!;
|
||||||
|
if (x >= span.x - MARKER_REACH && x <= span.x + Math.max(span.w, MARKER_REACH)) return span.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { SetSongMarkersCommand } from '../../src/core/commands/songCommands';
|
||||||
|
import type { SongAsset } from '../../src/core/model/Asset';
|
||||||
|
import {
|
||||||
|
changedMarker,
|
||||||
|
heldSongMarkers,
|
||||||
|
newMarker,
|
||||||
|
nextMarkerName,
|
||||||
|
withoutMarker,
|
||||||
|
} from '../../src/core/music/markers';
|
||||||
|
import { makeProject, roundTrip } from '../helpers';
|
||||||
|
|
||||||
|
function makeSong() {
|
||||||
|
const f = makeProject();
|
||||||
|
const create = new CreateAssetCommand('song', 'Tune');
|
||||||
|
f.stack.execute(create);
|
||||||
|
const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
|
||||||
|
return { ...f, song };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('song markers (#338)', () => {
|
||||||
|
it('holds markers to whole ticks from 0, a name and ids of their own, in the order of their ticks', () => {
|
||||||
|
const held = heldSongMarkers([
|
||||||
|
{ id: 'b', name: ' Boss ', tick: 3840.4 },
|
||||||
|
{ id: 'a', name: 'Intro', tick: 0 },
|
||||||
|
{ id: 'a', name: '', tick: 1920 },
|
||||||
|
{ id: 'c', name: 'Before', tick: -1 },
|
||||||
|
{ id: 'd', name: 'Nowhere' },
|
||||||
|
'no marker',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
expect(held.map((m) => [m.name, m.tick])).toEqual([
|
||||||
|
['Intro', 0],
|
||||||
|
['Marker 3', 1920],
|
||||||
|
['Boss', 3840],
|
||||||
|
]);
|
||||||
|
expect(new Set(held.map((m) => m.id)).size).toBe(3);
|
||||||
|
expect(held.find((m) => m.tick === 1920)!.id).not.toBe('a');
|
||||||
|
expect(heldSongMarkers(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps markers on one tick in the order they came', () => {
|
||||||
|
const held = heldSongMarkers([
|
||||||
|
{ id: 'x', name: 'Second', tick: 960 },
|
||||||
|
{ id: 'y', name: 'First', tick: 0 },
|
||||||
|
{ id: 'z', name: 'Third', tick: 960 },
|
||||||
|
]);
|
||||||
|
expect(held.map((m) => m.name)).toEqual(['First', 'Second', 'Third']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a new marker by the first number not taken', () => {
|
||||||
|
expect(nextMarkerName([])).toBe('Marker 1');
|
||||||
|
const markers = heldSongMarkers([
|
||||||
|
{ name: 'Marker 1', tick: 0 },
|
||||||
|
{ name: 'Marker 3', tick: 10 },
|
||||||
|
]);
|
||||||
|
expect(nextMarkerName(markers)).toBe('Marker 2');
|
||||||
|
const made = newMarker(markers, 479.6);
|
||||||
|
expect(made).toMatchObject({ name: 'Marker 2', tick: 480 });
|
||||||
|
expect(newMarker(markers, -5, ' Drop ')).toMatchObject({ name: 'Drop', tick: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes or takes away one marker and leaves the others', () => {
|
||||||
|
const markers = heldSongMarkers([
|
||||||
|
{ id: 'a', name: 'A', tick: 0 },
|
||||||
|
{ id: 'b', name: 'B', tick: 960 },
|
||||||
|
]);
|
||||||
|
expect(changedMarker(markers, 'b', { tick: 1920 })).toEqual([markers[0], { ...markers[1], tick: 1920 }]);
|
||||||
|
expect(changedMarker(markers, 'a', { name: 'Start' })[0]).toEqual({ id: 'a', name: 'Start', tick: 0 });
|
||||||
|
expect(withoutMarker(markers, 'a')).toEqual([markers[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the markers as one step, leaves the key out without one and takes it back on undo', () => {
|
||||||
|
const f = makeSong();
|
||||||
|
expect(f.song().markers).toBeUndefined();
|
||||||
|
const one = [{ id: 'm', name: 'Drop', tick: 1920.2 }];
|
||||||
|
expect(SetSongMarkersCommand.changes(f.song(), one)).toBe(true);
|
||||||
|
f.stack.execute(new SetSongMarkersCommand(f.song().id, one, 'Add marker'));
|
||||||
|
expect(f.song().markers).toEqual([{ id: 'm', name: 'Drop', tick: 1920 }]);
|
||||||
|
expect(SetSongMarkersCommand.changes(f.song(), one)).toBe(false);
|
||||||
|
f.stack.execute(new SetSongMarkersCommand(f.song().id, [], 'Delete marker'));
|
||||||
|
expect('markers' in f.song()).toBe(false);
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.song().markers).toEqual([{ id: 'm', name: 'Drop', tick: 1920 }]);
|
||||||
|
f.stack.undo();
|
||||||
|
expect('markers' in f.song()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the markers through saving and loading', () => {
|
||||||
|
const f = makeSong();
|
||||||
|
const markers = [
|
||||||
|
{ id: 'a', name: 'Intro', tick: 0 },
|
||||||
|
{ id: 'b', name: 'Boss', tick: 7680 },
|
||||||
|
];
|
||||||
|
f.stack.execute(new SetSongMarkersCommand(f.song().id, markers));
|
||||||
|
const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset;
|
||||||
|
expect(back.markers).toEqual(markers);
|
||||||
|
const none = makeSong();
|
||||||
|
const plain = roundTrip(none.project).assets.find((a) => a.id === none.song().id) as SongAsset;
|
||||||
|
expect('markers' in plain).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -224,6 +224,45 @@ describe('songs', () => {
|
|||||||
expect(noFx.text).toContain('is no Sound FX asset');
|
expect(noFx.text).toContain('is no Sound FX asset');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds, renames, moves and removes markers of a song (#338), and get_asset lists them', async () => {
|
||||||
|
const fixture = makeSpriteScene();
|
||||||
|
const { Theme } = await made(fixture, { Theme: 'song' });
|
||||||
|
const song = () => byId<SongAsset>(fixture, Theme);
|
||||||
|
const done = await run(fixture, [
|
||||||
|
{ command: 'add_song_marker', args: { asset: Theme, tick: 7680, name: 'Boss' }, as: 'boss' },
|
||||||
|
{ command: 'add_song_marker', args: { asset: Theme, tick: 0 } },
|
||||||
|
{ command: 'change_song_marker', args: { asset: Theme, marker: '$boss', tick: 3840 } },
|
||||||
|
]);
|
||||||
|
expect(done.result.isError).toBeUndefined();
|
||||||
|
expect(song().markers!.map(({ name, tick }) => [name, tick])).toEqual([
|
||||||
|
['Marker 1', 0],
|
||||||
|
['Boss', 3840],
|
||||||
|
]);
|
||||||
|
const listed = (await read(fixture, Theme)).markers as { id: string; name: string; tick: number }[];
|
||||||
|
expect(listed.map((m) => m.name)).toEqual(['Marker 1', 'Boss']);
|
||||||
|
|
||||||
|
const boss = song().markers![1]!.id;
|
||||||
|
const blank = await run(fixture, [
|
||||||
|
{ command: 'change_song_marker', args: { asset: Theme, marker: boss, name: ' ' } },
|
||||||
|
]);
|
||||||
|
expect(blank.text).toContain('A marker needs a name');
|
||||||
|
const renamed = await run(fixture, [
|
||||||
|
{ command: 'change_song_marker', args: { asset: Theme, marker: boss, name: 'Fight' } },
|
||||||
|
]);
|
||||||
|
expect(renamed.result.isError).toBeUndefined();
|
||||||
|
expect(song().markers![1]).toEqual({ id: boss, name: 'Fight', tick: 3840 });
|
||||||
|
|
||||||
|
const gone = await run(fixture, [
|
||||||
|
{ command: 'remove_song_marker', args: { asset: Theme, marker: boss } },
|
||||||
|
]);
|
||||||
|
expect(gone.result.isError).toBeUndefined();
|
||||||
|
expect(song().markers!.map((m) => m.name)).toEqual(['Marker 1']);
|
||||||
|
const missing = await run(fixture, [
|
||||||
|
{ command: 'remove_song_marker', args: { asset: Theme, marker: boss } },
|
||||||
|
]);
|
||||||
|
expect(missing.text).toContain(`Theme has no marker ${boss}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('sets the volume lane of a track in tick order with its easings, refuses what is out of range, and clears it with []', async () => {
|
it('sets the volume lane of a track in tick order with its easings, refuses what is out of range, and clears it with []', async () => {
|
||||||
const fixture = makeSpriteScene();
|
const fixture = makeSpriteScene();
|
||||||
const { Theme, Bend } = await made(fixture, { Theme: 'song', Bend: 'curve' });
|
const { Theme, Bend } = await made(fixture, { Theme: 'song', Bend: 'curve' });
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { heldSongMarkers } from '../../src/core/music/markers';
|
||||||
|
import {
|
||||||
|
MARKER_FLAG,
|
||||||
|
MARKER_REACH,
|
||||||
|
MARKER_STRIP,
|
||||||
|
markerAt,
|
||||||
|
markerNameX,
|
||||||
|
markerSpans,
|
||||||
|
SONG_TOP,
|
||||||
|
} from '../../src/editor/song/markerView';
|
||||||
|
import { RULER_HEIGHT } from '../../src/editor/music/timeView';
|
||||||
|
|
||||||
|
/** A tick a pixel, from x 100; every name 6 pixels a letter. */
|
||||||
|
const VIEW = { panX: 100, pxPerTick: 1 };
|
||||||
|
const measure = (name: string) => name.length * 6;
|
||||||
|
|
||||||
|
describe('the marker strip of the song tab (#338)', () => {
|
||||||
|
it('lies between the ruler and the tracks', () => {
|
||||||
|
expect(SONG_TOP).toBe(RULER_HEIGHT + MARKER_STRIP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a marker at its tick, as wide as its flag and name, but not into the next one', () => {
|
||||||
|
const markers = heldSongMarkers([
|
||||||
|
{ id: 'a', name: 'Intro', tick: 0 },
|
||||||
|
{ id: 'b', name: 'A long name', tick: 40 },
|
||||||
|
{ id: 'c', name: '', tick: 300 },
|
||||||
|
]);
|
||||||
|
const spans = markerSpans(VIEW, markers, measure);
|
||||||
|
expect(spans.map((s) => s.x)).toEqual([100, 140, 400]);
|
||||||
|
// Room to the next one is 40 pixels, less 2: the name of "Intro" (30) and the flag fit just so.
|
||||||
|
expect(spans[0]!.w).toBe(38);
|
||||||
|
expect(spans[1]!.w).toBe(MARKER_FLAG + 3 + measure('A long name'));
|
||||||
|
expect(spans[2]!.w).toBe(MARKER_FLAG + 3 + measure('Marker 3'));
|
||||||
|
expect(markerNameX(spans[0]!)).toBe(100 + MARKER_FLAG + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the flag of a marker with another one close behind it', () => {
|
||||||
|
const markers = heldSongMarkers([
|
||||||
|
{ id: 'a', name: 'Close', tick: 0 },
|
||||||
|
{ id: 'b', name: 'By', tick: 2 },
|
||||||
|
]);
|
||||||
|
expect(markerSpans(VIEW, markers, measure)[0]!.w).toBe(MARKER_FLAG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the marker near its line or on its flag and name, the later one where they overlap', () => {
|
||||||
|
const markers = heldSongMarkers([
|
||||||
|
{ id: 'a', name: 'Intro', tick: 0 },
|
||||||
|
{ id: 'b', name: 'B', tick: 6 },
|
||||||
|
]);
|
||||||
|
const spans = markerSpans(VIEW, markers, measure);
|
||||||
|
expect(markerAt(spans, 100 - MARKER_REACH)).toBe('a');
|
||||||
|
expect(markerAt(spans, 100 - MARKER_REACH - 1)).toBeNull();
|
||||||
|
expect(markerAt(spans, 103)).toBe('b');
|
||||||
|
expect(markerAt(spans, 106 + MARKER_FLAG + 3 + 6)).toBe('b');
|
||||||
|
expect(markerAt(spans, 106 + MARKER_FLAG + 3 + 7)).toBeNull();
|
||||||
|
expect(markerAt([], 100)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -628,6 +628,77 @@ export async function song(t) {
|
|||||||
);
|
);
|
||||||
await evaluate("__gm.state.notice = ''");
|
await evaluate("__gm.state.notice = ''");
|
||||||
|
|
||||||
|
// 65g3. Markers (#338) on their strip under the ruler. A double-click there adds one on the grid,
|
||||||
|
// a drag moves it as one step, a click starts playing at it, a double-click names it, resting
|
||||||
|
// on it says its name, and its menu takes it away.
|
||||||
|
const songMarkers = async () => (await model()).markers;
|
||||||
|
const markerAt = (tick) => json(`__gm.song.markerAt(${tick})`);
|
||||||
|
p = await markerAt(BAR + 100);
|
||||||
|
await doubleClick(p.x, p.y);
|
||||||
|
await sleep(100);
|
||||||
|
const added = await songMarkers();
|
||||||
|
p = await markerAt(BAR);
|
||||||
|
to = await markerAt(3 * BAR + 50);
|
||||||
|
await drag(p.x, p.y, to.x, to.y);
|
||||||
|
await sleep(100);
|
||||||
|
const movedMarkers = await songMarkers();
|
||||||
|
const movedLabel = await json('__gm.state.commands.undoLabel');
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
await sleep(100);
|
||||||
|
const markerUndone = await songMarkers();
|
||||||
|
await clickToolbar('redo');
|
||||||
|
await sleep(100);
|
||||||
|
const markerDrawn = (await json('__gm.song.markers()'))[0];
|
||||||
|
const rulerX = (await json(`__gm.song.rulerAt(${3 * BAR})`)).x;
|
||||||
|
check(
|
||||||
|
'a double-click on the strip adds a marker on the bar, a drag moves it as one step, drawn at its tick',
|
||||||
|
added.length === 1 &&
|
||||||
|
added[0].name === 'Marker 1' &&
|
||||||
|
added[0].tick === BAR &&
|
||||||
|
movedMarkers[0].tick === 3 * BAR &&
|
||||||
|
movedLabel === 'Move marker' &&
|
||||||
|
markerUndone[0].tick === BAR &&
|
||||||
|
(await songMarkers())[0].tick === 3 * BAR &&
|
||||||
|
Math.abs(markerDrawn?.x - rulerX) < 1,
|
||||||
|
JSON.stringify({ added, movedMarkers, movedLabel, markerUndone, markerDrawn, rulerX }),
|
||||||
|
);
|
||||||
|
p = await markerAt(3 * BAR);
|
||||||
|
await click(p.x, p.y);
|
||||||
|
await sleep(100);
|
||||||
|
const startAtMarker = (await json('__gm.song.view()')).start;
|
||||||
|
await sleep(500);
|
||||||
|
await doubleClick(p.x, p.y);
|
||||||
|
await sleep(150);
|
||||||
|
const nameDialog = await json('__gm.groupDialogOpen()');
|
||||||
|
await t.typeText('Boss');
|
||||||
|
await press('Enter');
|
||||||
|
await sleep(100);
|
||||||
|
const named = (await songMarkers())[0];
|
||||||
|
// The presses left the marker's sign quiet: the pointer goes off the strip and back.
|
||||||
|
to = await json(`__gm.song.rulerAt(${3 * BAR})`);
|
||||||
|
await mouse('mouseMoved', to.x, to.y);
|
||||||
|
await mouse('mouseMoved', p.x + 5, p.y);
|
||||||
|
await mouse('mouseMoved', p.x + 6, p.y);
|
||||||
|
await t.waitUntil('__gm.tooltip() !== null', { label: 'the sign over a marker' });
|
||||||
|
const markerSign = await json('__gm.tooltip()');
|
||||||
|
check(
|
||||||
|
'a click on a marker starts playing there, a double-click names it, and resting on it says the name',
|
||||||
|
startAtMarker === 3 * BAR && nameDialog === true && named?.name === 'Boss' && markerSign?.text === 'Boss',
|
||||||
|
JSON.stringify({ startAtMarker, nameDialog, named, markerSign }),
|
||||||
|
);
|
||||||
|
await click(p.x, p.y, 'right');
|
||||||
|
await sleep(150);
|
||||||
|
const markerRows = await json('__gm.menuRows()');
|
||||||
|
await clickText('Delete marker');
|
||||||
|
await sleep(100);
|
||||||
|
const markersLeft = await model();
|
||||||
|
check(
|
||||||
|
"a marker's menu plays from it, renames it and takes it away",
|
||||||
|
['Play from here', 'Rename…', 'Delete marker'].every((row) => markerRows?.includes(row)) &&
|
||||||
|
markersLeft.markers.length === 0,
|
||||||
|
JSON.stringify({ markerRows, markers: markersLeft.markers }),
|
||||||
|
);
|
||||||
|
|
||||||
// 65h. Space plays from the start: the pattern a bar into itself at 2 s a bar, the sound as a
|
// 65h. Space plays from the start: the pattern a bar into itself at 2 s a bar, the sound as a
|
||||||
// clip round and round, each on the bus of its track.
|
// clip round and round, each on the bus of its track.
|
||||||
p = await json('__gm.song.rulerAt(0)');
|
p = await json('__gm.song.rulerAt(0)');
|
||||||
|
|||||||
Reference in new issue
Block a user