Files
feuerwerk/src/editor/song/SongTimeline.ts
T
adminandClaude Opus 5.5 8f9c250eb6
game-maker / gate (push) Successful in 3m29s
game-maker / suite (push) Successful in 16m29s
Lay patterns and sounds out on tracks and play them as a song #334
Patterns could only be heard one at a time, in their own tab or from
a behavior. A song lays patterns and sounds out on tracks along one
time axis and plays them through the scheduler of #332, the third
piece of the music tools of #330.

- New asset type song under the heading Songs, with four empty tracks,
  120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows
  in the inspector. Tracks, items and the loop are the asset's data,
  held to their rules on load and in every command.
- A track has a name, volume, pan, mute and solo. Each is heard on a
  bus of its own, so a change reaches what is sounding at once.
- A pattern item repeats its pattern for as long as it is, with a
  transpose and another instrument if wanted. A sound item plays its
  sound as a clip at its own speed, round and round, through a sound
  FX if wanted. Both have a volume on top of their track's.
- Cut back at the left end or split, an item starts as far into what
  it plays as the cut is: ticks into a pattern, seconds into a sound.
- The tab shows the heads with M, S, volume and pan, the ruler of the
  Pattern tab and the items, a pattern with its notes, a sound with
  its waveform, a missing asset red. Patterns and sounds are dragged
  in from the Assets panel. Body, right and left end move, lengthen
  and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits
  where playing starts, Delete, arrows. A double-click opens a
  pattern item in its tab.
- What is picked is a new selection of its own, so the inspector
  shows the item or the track, with a button to delete it.
- The scheduler plans clips beside notes. Where the playhead starts
  or jumps back inside a clip, it is taken up that far into its
  sound. A clip fades out at the end of its item or of the loop.
- The editor plays one piece at a time: a pattern started stops the
  song and the other way round.
- The rules are pure functions in core/music/song, editor/song/
  songView and songEdit.

Findings:
- The suites of 4ea752f and 2a3b90b were red at e2e 54: the Pattern
  pushed Struct below the fold of the + menu. The module now scrolls
  to it, as e2e 47 does for Vector art. It also left the menu open,
  which made e2e 55 fail behind it.
- The Assets panel only dragged assets a scene can place. Patterns
  and sounds are dragged now as well, and a scene says why it does
  not take one instead of calling every asset a scene.

Tests:
- song.test.ts (new, 14) and song-edit.test.ts (new, 9): model,
  round trip, sequence of patterns and clips, mute and solo, cuts,
  clips on the scheduler, moving, sizing, trimming, splitting,
  duplicating, hits, head parts. sound-library.test.ts +5: clip
  offset, end fade, pitched clip, track bus, peaks.
- e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and
  55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset
  types: ALL PASSED, 62 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests.
- Counter-checks: no taking up of a clip, no pattern offset, no clip
  end each fail 3, 1 and 1 tests.
- Probe on stein2: the tab with four tracks, before and while
  playing, looked at in two screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:28:25 +02:00

1276 lines
45 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 {
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
type Text,
} from 'pixi.js';
import { SetSongCommand, SetSongLoopCommand } from '../../core/commands/songCommands';
import { patternLength } from '../../core/music/pattern';
import type { LoopRange } from '../../core/music/sequence';
import {
itemEnd,
newItem,
newTrack,
type SongItem,
type SongItemKind,
songGrid,
songLength,
songLoop,
songMeter,
songTempo,
type SongTrack,
TRACK_MAX_DB,
TRACK_MIN_DB,
} from '../../core/music/song';
import { secondsOfTicks, ticksPerBar } from '../../core/music/time';
import type { SongAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, findAssetOf } from '../../core/model/Project';
import {
activeDrag,
crispText,
drawBand,
endDrag,
Menu,
onDragChange,
pressCount,
type Size,
textStyle,
Theme,
ui,
Widget,
zoomFactor,
} from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { TimeRuler } from '../music/TimeRuler';
import {
clampTimePan,
fitTime,
RULER_HEIGHT,
tickToX,
timeLines,
xToTick,
zoomTime,
} from '../music/timeView';
import { snapDown, snapNear } from '../pattern/rollEdit';
import type { EditorState } from '../state';
import { removeTrack } from './songActions';
import {
clampMove,
draggedLength,
droppedLength,
duplicateOffset,
moveDelta,
moved,
placedCopies,
resized,
split,
trimDelta,
trimmed,
} from './songEdit';
import {
ADD_ROW_HEIGHT,
type Box,
clampTracks,
HEAD_BOXES,
HEAD_WIDTH,
type HeadPart,
headPartAt,
indexOf,
itemAt,
itemBox,
itemsWithin,
laneTick,
nearestTrack,
sliderShare,
sliderValue,
type SongView,
TRACK_HEIGHT,
trackAtY,
trackTop,
} from './songView';
const DRAG_THRESHOLD = 3;
/** How close to an end of the loop a press on the ruler takes that end. */
const LOOP_EDGE_REACH = 4;
const PATTERN_COLOR = 0x35598f;
const SOUND_COLOR = 0x2f6f52;
const MISSING_COLOR = 0x6b3a3a;
const MUTE_COLOR = 0xd08a2c;
const SOLO_COLOR = 0xc9c23a;
/** Height of an item's name strip; what it plays is drawn below. */
const LABEL_STRIP = 15;
/** The letter on a lit mute or solo button. */
const DARK_LABEL = textStyle('small').clone();
DARK_LABEL.fill = 0x30343c;
interface Placed {
text: string;
x: number;
y: number;
w: number;
dark?: boolean;
}
type Area = 'corner' | 'ruler' | 'heads' | 'lanes';
type Gesture =
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
| {
kind: 'ruler';
from: number;
pointerX: number;
active: boolean;
loop: LoopRange | null;
free: boolean;
edge: 'start' | 'end' | null;
}
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean; track: string }
| {
kind: 'move';
anchor: SongItem;
grab: number;
ticks: number;
tracks: number;
moved: boolean;
pointer: Vec2;
}
| { kind: 'resize'; anchor: SongItem; by: number }
| { kind: 'trim'; anchor: SongItem; by: number }
| { kind: 'erase'; erased: Set<string> }
| { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number };
/** An asset dragged in from the panel, where it would land. */
interface Drop {
kind: SongItemKind;
asset: string;
track: string;
tick: number;
length: number;
}
/**
* The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the
* items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved
* by its body, lengthened by its right end and cut back by its left one, and goes with a right-click
* or Delete. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's
* selection, so the inspector shows it.
*/
export class SongTimeline extends Widget {
private readonly bg = new Graphics();
private readonly lanesLayer = new Container();
private readonly lanesG = new Graphics();
private readonly itemsG = new Graphics();
private readonly overlay = new Graphics();
private readonly itemLabels = new Container();
private readonly itemTexts: Text[] = [];
private readonly headsLayer = new Container();
private readonly headsG = new Graphics();
private readonly headLabels = new Container();
private readonly headTexts: Text[] = [];
private readonly ruler = new TimeRuler();
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
private readonly hint = crispText({ text: '', style: textStyle('muted') });
private readonly addLabel = crispText({ text: '+ Track', style: textStyle('muted') });
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0 };
private needsFit = true;
private assetId = '';
private gesture: Gesture | null = null;
private drop: Drop | null = null;
/** Where playing starts and a paste lands, in ticks. */
private startTick = 0;
/** What Ctrl+C took, for any song of the project. */
private clipboard: SongItem[] = [];
/** Sounds asked for, so a waveform is drawn once its bytes are there. */
private readonly loading = new Set<string>();
/** Where the playhead stands while the song plays, in ticks; null while it does not. */
playhead: number | null = null;
onTogglePlay: () => void = () => {};
/** Where playing starts was moved: a playing song jumps there. */
onStart: (tick: number) => void = () => {};
/** The tracks as a slider in a head would leave them: heard at once, written when it lets go. */
onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {};
/** A pattern item was double-clicked. */
onOpenPattern: (patternId: string) => void = () => {};
constructor(private readonly state: EditorState) {
super();
this.focusable = true;
this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay);
this.headsLayer.addChild(this.headsG, this.headLabels, this.addLabel);
this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint);
for (const clip of Object.values(this.clips)) this.addChild(clip);
this.lanesLayer.mask = this.clips.lanes;
this.headsLayer.mask = this.clips.heads;
this.ruler.mask = this.clips.ruler;
this.hint.eventMode = 'none';
this.itemLabels.eventMode = 'none';
this.headLabels.eventMode = 'none';
this.addLabel.eventMode = 'none';
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.onPointerRelease(() => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.invalidate());
state.on('selection', () => this.invalidate());
// A drag can end anywhere, and this view never hears about it: the ghost would stay without this.
onDragChange(() => {
if (!activeDrag() && this.drop) {
this.drop = null;
this.invalidate();
}
});
}
override measure(): Size {
return { w: Math.max(this.minWidth, 360), h: Math.max(this.minHeight, 200) };
}
get currentAssetId(): string {
return this.assetId;
}
/** Another song starts playing from its start, framed. */
show(assetId: string): void {
if (this.assetId === assetId) return;
this.gesture = null;
this.drop = null;
this.assetId = assetId;
this.startTick = 0;
this.needsFit = true;
this.redraw();
}
saveView(): SongView {
return { ...this.view };
}
restoreView(view: unknown): void {
this.view = { ...(view as SongView) };
this.needsFit = false;
this.redraw();
}
/** The whole song across, from its first track down. */
fit(): void {
const asset = this.asset();
if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) {
this.needsFit = true;
return;
}
this.needsFit = false;
this.view = { ...fitTime(songLength(asset), this.lanesWidth), panY: 0 };
this.redraw();
}
get start(): number {
return this.startTick;
}
/** The items picked, by id, when the selection is about this song. */
get pickedIds(): string[] {
const s = this.state.selection;
return s?.kind === 'song' && s.assetId === this.assetId ? s.itemIds : [];
}
/** The track the selection is on, `''` when it is about something else. */
get pickedTrack(): string {
const s = this.state.selection;
return s?.kind === 'song' && s.assetId === this.assetId ? s.trackId : '';
}
pick(itemIds: readonly string[], trackId?: string): void {
const asset = this.asset();
if (!asset) return;
const last = asset.items.find((i) => i.id === itemIds.at(-1));
const track = trackId ?? last?.track ?? (this.pickedTrack || asset.tracks[0]?.id || '');
this.state.select({ kind: 'song', assetId: asset.id, trackId: track, itemIds: [...itemIds] });
}
// --- what the keys and the menus do ----------------------------------------------------------
selectAll(): boolean {
const asset = this.asset();
if (!asset) return false;
this.pick(asset.items.map((i) => i.id));
return true;
}
deleteSelection(): boolean {
const asset = this.asset();
const picked = new Set(this.pickedIds);
if (!asset || picked.size === 0) return false;
this.commit(
asset,
{ items: asset.items.filter((i) => !picked.has(i.id)) },
picked.size === 1 ? 'Delete item' : 'Delete items',
);
this.pick([]);
return true;
}
copy(): boolean {
const asset = this.asset();
const picked = new Set(this.pickedIds);
if (!asset || picked.size === 0) return false;
this.clipboard = asset.items.filter((i) => picked.has(i.id)).map((i) => ({ ...i }));
const n = this.clipboard.length;
this.state.setNotice(`Copied ${n} ${n === 1 ? 'item' : 'items'}`, 'quiet');
return true;
}
/** Lands at the start of playing, on the tracks it came from; false with nothing copied. */
paste(): boolean {
const asset = this.asset();
if (!asset || this.clipboard.length === 0) return false;
const copies = placedCopies(
this.clipboard,
this.startTick,
asset.tracks.map((t) => t.id),
);
this.commit(
asset,
{ items: [...asset.items, ...copies] },
copies.length === 1 ? 'Paste item' : 'Paste items',
);
this.pick(copies.map((i) => i.id));
return true;
}
/** A copy of the picked items right behind them, picked in their place. */
duplicate(): boolean {
const asset = this.asset();
const picked = new Set(this.pickedIds);
if (!asset || picked.size === 0) return false;
const chosen = asset.items.filter((i) => picked.has(i.id));
const by = duplicateOffset(asset.items, picked, songGrid(asset.props));
const copies = placedCopies(
chosen,
Math.min(...chosen.map((i) => i.tick)) + by,
asset.tracks.map((t) => t.id),
);
this.commit(asset, { items: [...asset.items, ...copies] }, 'Duplicate items');
this.pick(copies.map((i) => i.id));
return true;
}
/** Cuts the picked items where playing starts; the right-hand parts are picked afterwards. */
splitAtStart(): boolean {
const asset = this.asset();
const picked = new Set(this.pickedIds);
if (!asset || picked.size === 0) return false;
const cut = split(asset.items, picked, this.startTick, songTempo(asset.props));
if (cut.right.length === 0) {
this.state.setNotice('Where playing starts is not inside a picked item');
return true;
}
this.commit(asset, { items: cut.items }, cut.right.length === 1 ? 'Split item' : 'Split items');
this.pick(cut.right);
return true;
}
/** Moves the picked items by ticks and tracks, as far as they can go. */
nudge(ticks: number, tracks: number): boolean {
const asset = this.asset();
const picked = new Set(this.pickedIds);
if (!asset || picked.size === 0) return false;
const by = clampMove(asset.items, picked, indexOf(asset.tracks), asset.tracks.length, ticks, tracks);
if (by.ticks !== 0 || by.tracks !== 0)
this.commit(
asset,
{
items: moved(
asset.items,
picked,
asset.tracks.map((t) => t.id),
by.ticks,
by.tracks,
),
},
picked.size === 1 ? 'Move item' : 'Move items',
);
return true;
}
/** A new track under the others, or at `index`. */
addTrack(index?: number): boolean {
const asset = this.asset();
if (!asset) return false;
const track = newTrack(asset.tracks.length + 1);
const tracks = [...asset.tracks];
tracks.splice(index ?? tracks.length, 0, track);
this.commit(asset, { tracks }, 'Add track');
this.pick([], track.id);
return true;
}
override onKeyDown(event: KeyboardEvent): boolean {
const asset = this.asset();
if (!asset) return false;
const mod = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (event.code === 'Space' && !mod) {
this.onTogglePlay();
return true;
}
if (mod && key === 'c') return this.copy();
if (mod && key === 'v') return this.paste();
if (mod && key === 'd') return this.duplicate() || true;
if (mod && key === 'e') return this.splitAtStart() || true;
if (mod && key === 'a') return this.selectAll();
if (mod) return false;
// Taken even with nothing picked: a Delete must not go on to ask about the song itself.
if (event.key === 'Delete' || event.key === 'Backspace') {
if (!this.deleteSelection() && this.pickedTrack) removeTrack(this.state, asset.id, this.pickedTrack);
return true;
}
if (event.key === 'Escape' && this.pickedIds.length > 0) {
this.pick([]);
return true;
}
const grid = songGrid(asset.props);
const bar = ticksPerBar(songMeter(asset.props));
if (event.key === 'ArrowUp') return this.nudge(0, -1);
if (event.key === 'ArrowDown') return this.nudge(0, 1);
if (event.key === 'ArrowRight') return this.nudge(event.shiftKey ? bar : grid, 0);
if (event.key === 'ArrowLeft') return this.nudge(event.shiftKey ? -bar : -grid, 0);
return false;
}
// --- for the check scripts -------------------------------------------------------------------
/** The middle of a track's lane at a tick, on screen. */
lanePoint(tick: number, track: number): Vec2 {
const at = this.getGlobalPosition();
return {
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + TRACK_HEIGHT / 2,
};
}
/** A point of an item: the middle of its body or of one of its ends. */
itemPoint(itemId: string, part: 'start' | 'body' | 'end' = 'body'): Vec2 | null {
const asset = this.asset();
const item = asset?.items.find((i) => i.id === itemId);
const row = item && indexOf(asset!.tracks).get(item.track);
if (!item || row === undefined) return null;
const box = itemBox(this.view, row, item);
const at = this.getGlobalPosition();
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 };
}
/** The middle of a part of a track's head, or a share across its slider. */
headPoint(track: number, part: HeadPart, share = 0.5): Vec2 {
const at = this.getGlobalPosition();
const box = HEAD_BOXES[part];
const slider = part === 'volume' || part === 'pan';
return {
x: at.x + box.x + (slider ? box.w * share : box.w / 2),
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + box.y + box.h / 2,
};
}
addTrackPoint(): Vec2 {
const asset = this.asset();
const at = this.getGlobalPosition();
const top = trackTop(this.view, asset?.tracks.length ?? 0);
return { x: at.x + HEAD_WIDTH / 2, y: at.y + RULER_HEIGHT + top + ADD_ROW_HEIGHT / 2 };
}
rulerPoint(tick: number): Vec2 {
const at = this.getGlobalPosition();
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
}
viewInfo(): SongView & {
start: number;
playhead: number | null;
loop: LoopRange | null;
drop: Drop | null;
} {
return {
...this.view,
start: this.startTick,
playhead: this.playhead,
loop: this.shownLoop(),
drop: this.drop,
};
}
/** The items as drawn: their name, whether they hold something to show, and their box on screen. */
itemsDrawn(): { id: string; label: string; content: 'notes' | 'wave' | 'none'; box: Box }[] {
const asset = this.asset();
if (!asset) return [];
const rows = indexOf(asset.tracks);
const at = this.getGlobalPosition();
return this.shownItems(asset).flatMap((item) => {
const row = rows.get(item.track);
if (row === undefined) return [];
const box = itemBox(this.view, row, item);
return [
{
id: item.id,
label: this.labelOf(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 },
},
];
});
}
// --- pointer ---------------------------------------------------------------------------------
private get lanesWidth(): number {
return Math.max(0, this.w - HEAD_WIDTH);
}
private get lanesHeight(): number {
return Math.max(0, this.h - RULER_HEIGHT);
}
private asset(): SongAsset | null {
return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'song') ?? null) : null;
}
private areaAt(p: Vec2): Area {
const left = p.x < HEAD_WIDTH;
if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler';
return left ? 'heads' : 'lanes';
}
/** A point of the widget in the lanes' own pixels. */
private inLanes(p: Vec2): Vec2 {
return { x: p.x - HEAD_WIDTH, y: p.y - RULER_HEIGHT };
}
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
if (!asset) return;
const local = this.toLocal(e.global);
const p = { x: local.x, y: local.y };
const area = this.areaAt(p);
e.stopPropagation();
ui().focus.focus(this);
if (e.button === 1) {
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
return;
}
const g = this.inLanes(p);
const tick = xToTick(this.view, g.x);
if (area === 'heads') return this.pressHead(asset, e, g.y, p.x);
if (e.button === 2) {
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
if (area === 'lanes') {
this.gesture = { kind: 'erase', erased: new Set() };
this.erase(asset, g);
}
this.invalidate();
return;
}
if (e.button !== 0) return;
if (area === 'ruler') {
const loop = songLoop(asset);
const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH;
const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null;
this.gesture = {
kind: 'ruler',
from: Math.max(0, tick),
pointerX: g.x,
active: false,
loop: null,
free: e.altKey,
edge,
};
} else if (area === 'lanes') this.pressLanes(asset, e, g);
this.invalidate();
}
private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void {
const row = trackAtY(this.view, asset.tracks.length, y);
if (row === null) {
const below = y - trackTop(this.view, asset.tracks.length);
if (e.button === 0 && below >= 0 && below < ADD_ROW_HEIGHT) this.addTrack();
return;
}
const track = asset.tracks[row]!;
if (e.button === 2) {
this.openTrackMenu(asset, row, e.global);
return;
}
if (e.button !== 0) return;
const part = headPartAt(x, y - trackTop(this.view, row));
if (part === 'mute' || part === 'solo') {
const label =
part === 'mute'
? track.mute
? 'Unmute track'
: 'Mute track'
: track.solo
? 'Unsolo track'
: 'Solo track';
this.commit(
asset,
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: !t[part] } : t)) },
label,
);
} else if (part === 'volume' || part === 'pan') {
const value = this.sliderAt(part, x);
this.gesture = { kind: 'slider', track: track.id, part, value };
this.onTracksPreview(this.shownTracks(asset));
}
if (this.pickedTrack !== track.id || this.pickedIds.length > 0) this.pick([], track.id);
this.invalidate();
}
private sliderAt(part: 'volume' | 'pan', x: number): number {
return part === 'volume'
? sliderValue(x, HEAD_BOXES.volume, TRACK_MIN_DB, TRACK_MAX_DB, 0.5)
: sliderValue(x, HEAD_BOXES.pan, -1, 1, 0.05);
}
private pressLanes(asset: SongAsset, e: FederatedPointerEvent, g: Vec2): void {
const hit = itemAt(this.view, asset.items, asset.tracks, g);
const anchor = hit ? asset.items.find((i) => i.id === hit.id) : undefined;
const row = nearestTrack(this.view, asset.tracks.length, g.y);
if (!hit || !anchor) {
this.gesture = {
kind: 'empty',
start: g,
current: g,
active: false,
add: e.shiftKey,
track: asset.tracks[row]?.id ?? '',
};
return;
}
if (pressCount(e, `song-item:${anchor.id}`) === 2 && anchor.kind === 'pattern') {
this.onOpenPattern(anchor.asset);
return;
}
const picked = this.pickedIds;
if (e.shiftKey) {
this.pick(
picked.includes(anchor.id) ? picked.filter((id) => id !== anchor.id) : [...picked, anchor.id],
);
return;
}
if (!picked.includes(anchor.id)) this.pick([anchor.id]);
if (hit.part === 'end') this.gesture = { kind: 'resize', anchor, by: 0 };
else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor, by: 0 };
else {
const grab = xToTick(this.view, g.x) - anchor.tick;
this.gesture = { kind: 'move', anchor, grab, ticks: 0, tracks: 0, moved: false, pointer: g };
}
}
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const g = this.inLanes({ x: local.x, y: local.y });
const asset = this.asset();
const dragged = activeDrag();
if (dragged?.kind === 'asset' && asset) {
this.trackDrop(asset, dragged.id, local, g);
return;
}
const gesture = this.gesture;
if (!gesture || !asset) return;
const tick = xToTick(this.view, g.x);
const grid = songGrid(asset.props);
const free = e.altKey;
const picked = new Set(this.pickedIds);
switch (gesture.kind) {
case 'pan':
this.view = clampTracks(
clampTimePan({
...this.view,
panX: gesture.panX + (local.x - gesture.pointer.x),
panY: gesture.panY + (local.y - gesture.pointer.y),
}),
asset.tracks.length,
this.lanesHeight,
);
break;
case 'ruler': {
gesture.free = free;
if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return;
gesture.active = true;
const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid));
const at = snap(tick);
const { start, end } = songLoop(asset);
if (gesture.edge === 'start') gesture.loop = { start: Math.min(at, end - 1), end };
else if (gesture.edge === 'end') gesture.loop = { start, end: Math.max(at, start + 1) };
else {
const from = snap(gesture.from);
gesture.loop = at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) };
}
break;
}
case 'empty': {
gesture.current = g;
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
if (!gesture.active && span < DRAG_THRESHOLD) return;
gesture.active = true;
break;
}
case 'move': {
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
return;
gesture.moved = true;
const row = nearestTrack(this.view, asset.tracks.length, g.y);
const by = moveDelta(
asset.items,
picked,
indexOf(asset.tracks),
asset.tracks.length,
gesture.anchor,
tick - gesture.grab,
row,
grid,
free,
);
gesture.ticks = by.ticks;
gesture.tracks = by.tracks;
break;
}
case 'resize':
gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length;
break;
case 'trim':
gesture.by = trimDelta(asset.items, picked, gesture.anchor, tick, grid, free, songTempo(asset.props));
break;
case 'erase':
this.erase(asset, g);
break;
case 'slider':
gesture.value = this.sliderAt(gesture.part, local.x);
this.onTracksPreview(this.shownTracks(asset));
break;
}
this.invalidate();
}
private onPointerUp(): void {
const drop = this.drop;
const asset = this.asset();
if (drop && asset) {
this.drop = null;
endDrag();
const item = newItem(drop);
this.commit(asset, { items: [...asset.items, item] }, 'Add item');
this.pick([item.id]);
return;
}
const gesture = this.gesture;
this.gesture = null;
if (!gesture || !asset) return;
const picked = new Set(this.pickedIds);
const grid = songGrid(asset.props);
switch (gesture.kind) {
case 'ruler':
if (gesture.active) {
const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset));
if (gesture.loop && !same) this.state.execute(new SetSongLoopCommand(asset.id, gesture.loop));
} else this.setStart(gesture.free ? Math.round(gesture.from) : snapDown(gesture.from, grid));
break;
case 'empty':
if (gesture.active) {
const caught = itemsWithin(this.view, asset.items, asset.tracks, gesture.start, gesture.current);
this.pick([...new Set([...(gesture.add ? this.pickedIds : []), ...caught])]);
} else if (gesture.track) this.pick([], gesture.track);
break;
case 'move':
if (gesture.moved && (gesture.ticks !== 0 || gesture.tracks !== 0))
this.commit(
asset,
{
items: moved(
asset.items,
picked,
asset.tracks.map((t) => t.id),
gesture.ticks,
gesture.tracks,
),
},
picked.size === 1 ? 'Move item' : 'Move items',
);
else if (!gesture.moved) this.pick([gesture.anchor.id]);
break;
case 'resize':
if (gesture.by !== 0)
this.commit(
asset,
{ items: resized(asset.items, picked, gesture.by) },
picked.size === 1 ? 'Resize item' : 'Resize items',
);
break;
case 'trim':
if (gesture.by !== 0)
this.commit(
asset,
{ items: trimmed(asset.items, picked, gesture.by, songTempo(asset.props)) },
picked.size === 1 ? 'Trim item' : 'Trim items',
);
break;
case 'erase':
if (gesture.erased.size > 0) {
this.commit(
asset,
{ items: asset.items.filter((i) => !gesture.erased.has(i.id)) },
gesture.erased.size === 1 ? 'Delete item' : 'Delete items',
);
this.pick(this.pickedIds.filter((id) => !gesture.erased.has(id)));
}
break;
case 'slider': {
const tracks = this.shownTracksWith(asset, gesture);
this.commit(asset, { tracks }, gesture.part === 'volume' ? 'Set track volume' : 'Set track pan');
this.onTracksPreview(this.asset()?.tracks ?? tracks);
break;
}
}
this.invalidate();
}
/** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */
private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void {
const dragged = findAsset(this.state.project, assetId);
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
const row = trackAtY(this.view, asset.tracks.length, g.y);
if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) {
if (this.drop) {
this.drop = null;
this.invalidate();
}
return;
}
const grid = songGrid(asset.props);
const tick = Math.max(0, snapDown(laneTick(this.view, g.x), grid));
const bar = ticksPerBar(songMeter(asset.props));
let length: number;
if (dragged.type === 'pattern')
length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar);
else {
this.ensureLoaded(dragged.file);
length = droppedLength(soundLibrary.duration(dragged.file), null, songTempo(asset.props), bar);
}
this.drop = { kind: dragged.type, asset: dragged.id, track: asset.tracks[row]!.id, tick, length };
this.invalidate();
}
private erase(asset: SongAsset, g: Vec2): void {
if (this.gesture?.kind !== 'erase') return;
const erased = this.gesture.erased;
const hit = itemAt(
this.view,
asset.items.filter((i) => !erased.has(i.id)),
asset.tracks,
g,
);
if (hit) erased.add(hit.id);
}
private setStart(tick: number): void {
this.startTick = Math.max(0, tick);
this.onStart(this.startTick);
this.invalidate();
}
private commit(asset: SongAsset, parts: { tracks?: SongTrack[]; items?: SongItem[] }, label: string): void {
if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label));
}
private openRulerMenu(asset: SongAsset, tick: number, global: Vec2): void {
const bar = ticksPerBar(songMeter(asset.props));
const first = snapDown(tick, bar);
new Menu([
{
label: `Loop bar ${first / bar + 1}`,
onSelect: () =>
this.state.execute(new SetSongLoopCommand(asset.id, { start: first, end: first + bar })),
},
{
label: 'Loop the whole song',
disabled: !asset.loop,
onSelect: () => this.state.execute(new SetSongLoopCommand(asset.id, null)),
},
]).open(global.x, global.y);
}
private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void {
const track = asset.tracks[row]!;
const swap = (other: number) => {
const tracks = [...asset.tracks];
[tracks[row], tracks[other]] = [tracks[other]!, tracks[row]!];
this.commit(asset, { tracks }, 'Move track');
};
new Menu([
{ label: 'Add track above', onSelect: () => this.addTrack(row) },
{ label: 'Add track below', onSelect: () => this.addTrack(row + 1) },
{ label: 'Move up', disabled: row === 0, onSelect: () => swap(row - 1) },
{ label: 'Move down', disabled: row === asset.tracks.length - 1, onSelect: () => swap(row + 1) },
{
label: 'Delete track',
disabled: asset.tracks.length <= 1,
onSelect: () => {
this.pick([], track.id);
this.state.execute(
new SetSongCommand(
asset.id,
{
tracks: asset.tracks.filter((t) => t.id !== track.id),
items: asset.items.filter((i) => i.track !== track.id),
},
'Delete track',
),
);
},
},
]).open(global.x, global.y);
}
private onWheel(e: FederatedWheelEvent): void {
const asset = this.asset();
if (!asset) return;
const local = this.toLocal(e.global);
const area = this.areaAt({ x: local.x, y: local.y });
const g = this.inLanes({ x: local.x, y: local.y });
let view: SongView = this.view;
if (area === 'ruler' || e.altKey) view = { ...view, ...zoomTime(view, zoomFactor(e.deltaY), g.x) };
else if (e.shiftKey) view = { ...view, panX: view.panX - e.deltaY };
else view = { ...view, panX: view.panX - e.deltaX, panY: view.panY - e.deltaY };
this.view = clampTracks(clampTimePan(view), asset.tracks.length, this.lanesHeight);
this.needsFit = false;
this.redraw();
e.preventDefault();
}
// --- drawing ---------------------------------------------------------------------------------
/** The items as the running gesture would leave them. */
private shownItems(asset: SongAsset): readonly SongItem[] {
const g = this.gesture;
const picked = new Set(this.pickedIds);
if (g?.kind === 'move' && g.moved)
return moved(
asset.items,
picked,
asset.tracks.map((t) => t.id),
g.ticks,
g.tracks,
);
if (g?.kind === 'resize') return resized(asset.items, picked, g.by);
if (g?.kind === 'trim') return trimmed(asset.items, picked, g.by, songTempo(asset.props));
if (g?.kind === 'erase') return asset.items.filter((i) => !g.erased.has(i.id));
return asset.items;
}
private shownTracks(asset: SongAsset): SongTrack[] {
const g = this.gesture;
return g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks;
}
private shownTracksWith(asset: SongAsset, g: Extract<Gesture, { kind: 'slider' }>): SongTrack[] {
return asset.tracks.map((t) => (t.id === g.track ? { ...t, [g.part]: g.value } : t));
}
private shownLoop(): LoopRange | null {
const asset = this.asset();
if (!asset) return null;
const g = this.gesture;
if (g?.kind === 'ruler' && g.active) return g.loop;
return songLoop(asset);
}
private labelOf(item: SongItem): string {
return findAsset(this.state.project, item.asset)?.name ?? 'missing';
}
private contentOf(item: SongItem): 'notes' | 'wave' | 'none' {
if (item.kind === 'pattern') {
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
return pattern && pattern.notes.length > 0 ? 'notes' : 'none';
}
const sound = findAssetOf(this.state.project, item.asset, 'sound');
return sound?.file && soundLibrary.peaks(sound.file) ? 'wave' : 'none';
}
private ensureLoaded(file: string): void {
if (!file || this.loading.has(file) || soundLibrary.duration(file) !== null) return;
this.loading.add(file);
void soundLibrary.load(file).then(() => this.invalidate());
}
override layout(): void {
if (this.needsFit) this.fit();
}
override redraw(): void {
if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) {
this.fit();
return;
}
const c = Theme.colors;
const w = this.w;
const h = this.h;
const lw = this.lanesWidth;
const lh = this.lanesHeight;
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, w, RULER_HEIGHT).fill(c.panelHeader);
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.heads
.clear()
.rect(0, RULER_HEIGHT, HEAD_WIDTH - 1, lh)
.fill(0xffffff);
this.clips.ruler.clear().rect(HEAD_WIDTH, 0, lw, RULER_HEIGHT).fill(0xffffff);
this.lanesLayer.position.set(HEAD_WIDTH, RULER_HEIGHT);
this.headsLayer.position.set(0, RULER_HEIGHT);
this.ruler.position.set(HEAD_WIDTH, 0);
const asset = this.asset();
this.lanesG.clear();
this.itemsG.clear();
this.overlay.clear();
this.headsG.clear();
if (!asset) {
this.syncTexts(this.itemTexts, this.itemLabels, []);
this.syncTexts(this.headTexts, this.headLabels, []);
this.addLabel.visible = false;
this.ruler.visible = false;
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.visible = true;
return;
}
this.ruler.visible = true;
const length = songLength(asset);
const tracks = this.shownTracks(asset);
this.drawLanes(asset, tracks, lw, lh, length);
this.drawItems(asset, lw, lh);
this.drawHeads(tracks);
this.ruler.draw(this.view, songMeter(asset.props), lw, {
length,
loop: this.shownLoop(),
start: this.startTick,
playhead: this.playhead,
});
const g = this.gesture;
if (g?.kind === 'empty' && g.active) drawBand(this.overlay, g.start, g.current);
if (this.drop) {
const row = indexOf(asset.tracks).get(this.drop.track);
if (row !== undefined) {
const box = itemBox(this.view, row, this.drop);
this.overlay
.rect(box.x, box.y, box.w, box.h)
.fill({ color: c.accent, alpha: 0.25 })
.stroke({ width: 1, color: c.accent });
}
}
const start = tickToX(this.view, this.startTick);
if (start >= 0 && start <= lw) this.overlay.rect(start, 0, 1, lh).fill({ color: c.text, alpha: 0.3 });
if (this.playhead !== null) {
const x = tickToX(this.view, this.playhead);
if (x >= 0 && x <= lw) this.overlay.rect(x - 0.5, 0, 1, lh).fill(c.hot);
}
this.hint.text =
asset.items.length === 0
? 'Drag a pattern or a sound from the Assets panel onto a track. Space plays.'
: '';
this.hint.visible = this.hint.text !== '';
this.hint.position.set(
HEAD_WIDTH + Theme.padding * 2,
RULER_HEIGHT + trackTop(this.view, tracks.length) + Theme.padding * 2,
);
}
/** A band per track, the picked one lit, and the bar, beat and grid lines across them. */
private drawLanes(
asset: SongAsset,
tracks: readonly SongTrack[],
lw: number,
lh: number,
length: number,
): void {
const c = Theme.colors;
tracks.forEach((track, i) => {
const y = trackTop(this.view, i);
if (y > lh || y + TRACK_HEIGHT < 0) return;
const lit = track.id === this.pickedTrack && this.pickedIds.length === 0;
this.lanesG
.rect(0, y, lw, TRACK_HEIGHT)
.fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 });
this.lanesG.rect(0, y + TRACK_HEIGHT - 1, lw, 1).fill({ color: c.border, alpha: 0.8 });
});
const bottom = Math.min(lh, trackTop(this.view, tracks.length));
const alpha = [0.9, 0.45, 0.2];
for (const line of timeLines(this.view, songMeter(asset.props), songGrid(asset.props), lw))
this.lanesG.rect(line.x, 0, 1, bottom).fill({ color: c.border, alpha: alpha[line.level] });
const end = tickToX(this.view, length);
if (end < lw)
this.lanesG
.rect(Math.max(0, end), 0, lw - Math.max(0, end), bottom)
.fill({ color: 0x000000, alpha: 0.2 });
}
private drawItems(asset: SongAsset, lw: number, lh: number): void {
const c = Theme.colors;
const rows = indexOf(asset.tracks);
const picked = new Set(this.pickedIds);
const labels: Placed[] = [];
for (const item of this.shownItems(asset)) {
const row = rows.get(item.track);
if (row === undefined) continue;
const box = itemBox(this.view, row, item);
if (box.x > lw || box.x + box.w < 0 || box.y > lh || box.y + box.h < 0) continue;
const missing = !findAsset(this.state.project, item.asset);
const color = missing ? MISSING_COLOR : item.kind === 'pattern' ? PATTERN_COLOR : SOUND_COLOR;
const lit = picked.has(item.id);
this.itemsG
.roundRect(box.x, box.y, box.w, box.h, 3)
.fill({ color, alpha: lit ? 1 : 0.85 })
.stroke({
width: lit ? 2 : 1,
color: lit ? c.accentText : 0x000000,
alpha: lit ? 1 : 0.45,
alignment: 1,
});
if (item.kind === 'pattern') this.drawNotes(item, box, lw);
else this.drawWave(item, box, lw);
if (box.w > 18) labels.push({ text: this.labelOf(item), x: box.x + 4, y: box.y + 1, w: box.w - 8 });
}
this.syncTexts(this.itemTexts, this.itemLabels, labels);
}
/** The notes of a pattern item, small, across every round it plays. */
private drawNotes(item: SongItem, box: Box, lw: number): void {
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
if (!pattern || pattern.notes.length === 0) return;
const span = patternLength(pattern);
const notes = pattern.notes.filter((n) => n.tick < span);
if (notes.length === 0) return;
const low = Math.min(...notes.map((n) => n.note));
const high = Math.max(...notes.map((n) => n.note));
const top = box.y + LABEL_STRIP;
const room = Math.max(4, box.h - LABEL_STRIP - 3);
const step = room / Math.max(1, high - low + 1);
const into = ((item.offset % span) + span) % span;
const end = itemEnd(item);
for (let round = item.tick - into; round < end; round += span)
for (const n of notes) {
const from = Math.max(item.tick, round + n.tick);
const to = Math.min(end, round + Math.min(n.tick + n.length, span));
if (to <= from) continue;
const x0 = tickToX(this.view, from);
const x1 = tickToX(this.view, to);
if (x1 < 0 || x0 > lw) continue;
const y = top + (high - n.note) * step;
this.itemsG
.rect(x0, y, Math.max(1, x1 - x0 - 0.5), Math.max(1, Math.min(3, step - 0.5)))
.fill({ color: 0xffffff, alpha: 0.8 });
}
}
/** The waveform of a sound item, round and round as it plays, from where it starts in it. */
private drawWave(item: SongItem, box: Box, lw: number): void {
const sound = findAssetOf(this.state.project, item.asset, 'sound');
if (!sound?.file) return;
const peaks = soundLibrary.peaks(sound.file);
const seconds = soundLibrary.duration(sound.file);
if (!peaks || !seconds) {
this.ensureLoaded(sound.file);
return;
}
const asset = this.asset()!;
const tempo = songTempo(asset.props);
const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2;
const half = (box.h - LABEL_STRIP - 4) / 2;
const from = Math.max(box.x, 0);
const to = Math.min(box.x + box.w, lw);
for (let x = from; x < to; x += 2) {
const tick = xToTick(this.view, x) - item.tick;
const at = (item.offset + secondsOfTicks(tick, tempo)) % seconds;
const peak = peaks[Math.min(peaks.length - 1, Math.floor((at / seconds) * peaks.length))] ?? 0;
const reach = Math.max(0.5, peak * half);
this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 });
}
}
/** The heads: name, mute and solo, the volume and pan sliders, and the row that adds a track. */
private drawHeads(tracks: readonly SongTrack[]): void {
const c = Theme.colors;
const labels: Placed[] = [];
tracks.forEach((track, i) => {
const y = trackTop(this.view, i);
const lit = track.id === this.pickedTrack;
this.headsG
.rect(0, y, HEAD_WIDTH - 1, TRACK_HEIGHT)
.fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
this.headsG.rect(0, y + TRACK_HEIGHT - 1, HEAD_WIDTH - 1, 1).fill(c.border);
const { name, mute, solo, volume, pan } = HEAD_BOXES;
labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w });
this.toggle(mute, y, track.mute, MUTE_COLOR);
this.toggle(solo, y, track.solo, SOLO_COLOR);
labels.push(
{ text: 'M', x: mute.x + 5, y: y + mute.y, w: mute.w, dark: track.mute },
{ text: 'S', x: solo.x + 5, y: y + solo.y, w: solo.w, dark: track.solo },
);
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
this.headsG
.rect(volume.x, y + volume.y, volume.w, volume.h)
.fill(c.viewport)
.stroke({ width: 1, color: c.border, alignment: 1 });
this.headsG
.rect(volume.x + 1, y + volume.y + 1, Math.max(0, (volume.w - 2) * share), volume.h - 2)
.fill({ color: c.accent, alpha: track.mute ? 0.35 : 0.8 });
const unity = volume.x + volume.w * sliderShare(0, TRACK_MIN_DB, TRACK_MAX_DB);
this.headsG.rect(unity, y + volume.y - 2, 1, volume.h + 4).fill({ color: c.text, alpha: 0.5 });
this.headsG
.rect(pan.x, y + pan.y, pan.w, pan.h)
.fill(c.viewport)
.stroke({ width: 1, color: c.border, alignment: 1 });
const centre = pan.x + pan.w / 2;
const knob = pan.x + pan.w * sliderShare(track.pan, -1, 1);
this.headsG
.rect(Math.min(centre, knob), y + pan.y + 2, Math.max(1, Math.abs(knob - centre)), pan.h - 4)
.fill({ color: c.accent, alpha: 0.8 });
this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 });
});
this.syncTexts(this.headTexts, this.headLabels, labels);
const addTop = trackTop(this.view, tracks.length);
this.headsG.rect(0, addTop, HEAD_WIDTH - 1, ADD_ROW_HEIGHT).fill({ color: 0xffffff, alpha: 0.02 });
this.addLabel.visible = true;
this.addLabel.position.set(Theme.padding, addTop + (ADD_ROW_HEIGHT - this.addLabel.height) / 2);
}
private toggle(box: Box, y: number, on: boolean, color: number): void {
const c = Theme.colors;
this.headsG
.roundRect(box.x, y + box.y, box.w, box.h, 3)
.fill(on ? color : c.viewport)
.stroke({ width: 1, color: c.border, alignment: 1 });
}
/** Puts texts at their places, cut short with "…" where they would run past their width. */
private syncTexts(pool: Text[], parent: Container, labels: readonly Placed[]): void {
labels.forEach((label, i) => {
let text = pool[i];
if (!text) {
text = crispText({ style: textStyle('small') });
pool.push(text);
parent.addChild(text);
}
text.style = label.dark ? DARK_LABEL : textStyle('small');
if (text.text !== label.text) text.text = label.text;
for (let cut = label.text.length; text.width > label.w && cut > 1; cut--)
text.text = `${label.text.slice(0, cut - 1)}…`;
text.visible = true;
text.position.set(label.x, label.y);
});
for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false;
}
}