Size notes by either end and pan before bar one in music tabs

A round of small fixes to the Pattern and Song tabs: what the ruler
and a pan do in both, and a few things the tabs carried that were in
the way.

- A middle-drag can take bar 1 up to half the grid or lanes in. The
  time before it is drawn dark on the ruler, the grid and the lanes.
  Nothing lands there: a click draws no note, a drop shows no frame.
- A press or drag on the ruler puts the start on the nearest grid
  line, no longer on the one before the pointer.
- A new song has one track instead of four. The "+ Track" row under
  the heads is gone, a head's menu adds tracks above or below. A song
  still keeps at least one, so that menu is always there.
- A note can be sized by its left end too: the start goes to the
  nearest grid line and the end stays put, never before tick 0.
- An end of a note under the pointer lights up and shows a resize
  cursor, as on an item of the Song tab.

Tests:
- Unit: piano-roll, song-edit, song, song-play 52 tests. Two new
  cases for the left end and the pan, the song fixtures add their
  tracks themselves now.
- e2e 64+65 on melektaus-atx: ALL PASSED - 50 checks (+2).
  e2e 65 adds its tracks through the head's menu and clicks the
  ruler three quarters into a bar, which only the nearest line
  passes.
- Gate on melektaus-atx: 251 files, 3909 tests, CHECK EXIT 0.
- A probe shot of the piano roll panned in: the time before bar 1
  dark, the left end of a note lit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 18:40:13 +02:00
1 parent aa097bd076
commit a3101c590c
15 files changed
+260 -89

No files matched your search

+1 -1
View File
@@ -56,7 +56,7 @@ export const SONG_GRID_KEY = 'grid';
export const DEFAULT_SONG_GRID = 'bar'; export const DEFAULT_SONG_GRID = 'bar';
export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS]; export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS];
/** A new song has this many tracks. */ /** A new song has this many tracks. */
export const DEFAULT_TRACKS = 4; export const DEFAULT_TRACKS = 1;
/** A song is never shorter than this many bars, on the ruler or when it plays. */ /** A song is never shorter than this many bars, on the ruler or when it plays. */
export const MIN_BARS = 1; export const MIN_BARS = 1;
-1
View File
@@ -27,7 +27,6 @@ export function songHooks({ state, editor }: HookContext) {
laneAt: (tick: number, track: number) => timeline.lanePoint(tick, track), laneAt: (tick: number, track: number) => timeline.lanePoint(tick, track),
itemAt: (itemId: string, part?: 'start' | 'body' | 'end') => timeline.itemPoint(itemId, part), itemAt: (itemId: string, part?: 'start' | 'body' | 'end') => timeline.itemPoint(itemId, part),
headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share), headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share),
addTrackAt: () => timeline.addTrackPoint(),
rulerAt: (tick: number) => timeline.rulerPoint(tick), rulerAt: (tick: number) => timeline.rulerPoint(tick),
loopAt: (tick: number) => timeline.loopPoint(tick), loopAt: (tick: number) => timeline.loopPoint(tick),
view: () => timeline.viewInfo(), view: () => timeline.viewInfo(),
+3
View File
@@ -44,6 +44,9 @@ export class TimeRuler extends Container {
const x1 = Math.min(width, tickToX(view, marks.loop.end)); const x1 = Math.min(width, tickToX(view, marks.loop.end));
if (x1 > x0) g.rect(x0, 1, x1 - x0, LOOP_STRIP - 2).fill({ color: c.accent, alpha: 0.75 }); if (x1 > x0) g.rect(x0, 1, x1 - x0, LOOP_STRIP - 2).fill({ color: c.accent, alpha: 0.75 });
} }
// Before tick 0 there is nothing to play: it reads darker.
const zero = Math.min(width, tickToX(view, 0));
if (zero > 0) g.rect(0, 0, zero, RULER_HEIGHT).fill({ color: 0x000000, alpha: 0.3 });
g.rect(0, LOOP_STRIP, width, 1).fill(c.border); g.rect(0, LOOP_STRIP, width, 1).fill(c.border);
g.rect(0, RULER_HEIGHT - 1, width, 1).fill(c.border); g.rect(0, RULER_HEIGHT - 1, width, 1).fill(c.border);
+10 -3
View File
@@ -37,9 +37,16 @@ export function zoomTime(view: TimeView, factor: number, x: number): TimeView {
return { panX: x - tick * pxPerTick, pxPerTick }; return { panX: x - tick * pxPerTick, pxPerTick };
} }
/** Time starts at 0: nothing to look at left of it. */ /** How far right of the area's left edge tick 0 can be panned, as a share of the area's width. */
export function clampTimePan<V extends TimeView>(view: V): V { export const TIME_LEAD = 0.5;
return view.panX > 0 ? { ...view, panX: 0 } : view;
/**
* Time starts at 0, but it need not stand at the left edge: a pan can take it up to half the area in,
* to look at, with nothing to place left of it.
*/
export function clampTimePan<V extends TimeView>(view: V, width: number): V {
const most = Math.max(0, width * TIME_LEAD);
return view.panX > most ? { ...view, panX: most } : view;
} }
/** `length` ticks across `width` pixels, from tick 0. */ /** `length` ticks across `width` pixels, from tick 0. */
+79 -12
View File
@@ -38,6 +38,7 @@ import type { EditorState } from '../state';
import { import {
clampShift, clampShift,
draggedLength, draggedLength,
draggedStart,
drawnNote, drawnNote,
duplicateOffset, duplicateOffset,
moveDelta, moveDelta,
@@ -46,12 +47,16 @@ import {
shifted, shifted,
snapDown, snapDown,
snapNear, snapNear,
trimmed,
withVelocity, withVelocity,
} from './rollEdit'; } from './rollEdit';
import { import {
centreRows, centreRows,
clampRows, clampRows,
DEFAULT_ROW_HEIGHT, DEFAULT_ROW_HEIGHT,
END_GRIP,
type NoteBox,
type NoteHit,
noteAt, noteAt,
noteAtY, noteAtY,
noteBox, noteBox,
@@ -106,6 +111,7 @@ type Gesture =
pointer: Vec2; pointer: Vec2;
} }
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean } | { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean }
| { kind: 'trim'; anchor: PatternNote; by: number }
| { kind: 'erase'; erased: Set<string> } | { kind: 'erase'; erased: Set<string> }
| { kind: 'velocity'; anchor: PatternNote; value: number }; | { kind: 'velocity'; anchor: PatternNote; value: number };
@@ -121,8 +127,8 @@ function mix(a: number, b: number, t: number): number {
/** /**
* The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid * The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid
* between them and their velocities in a lane below. A click into the grid draws a note, a drag spans * between them and their velocities in a lane below. A click into the grid draws a note, a drag spans
* a band that picks notes; a note is dragged by its body, lengthened by its right end, and goes with a * a band that picks notes; a note is dragged by its body, sized by either end, which lights up under
* right-click or Delete. A press or a drag on the ruler moves where playing starts, one on the strip * the pointer, and goes with a right-click or Delete. A press or a drag on the ruler moves where playing starts, one on the strip
* along its top sets the loop. The wheel zooms: the time over the grid, the rows over the keys. * along its top sets the loop. The wheel zooms: the time over the grid, the rows over the keys.
*/ */
export class PianoRoll extends Widget { export class PianoRoll extends Widget {
@@ -149,6 +155,8 @@ export class PianoRoll extends Widget {
private needsFit = true; private needsFit = true;
private assetId = ''; private assetId = '';
private gesture: Gesture | null = null; private gesture: Gesture | null = null;
/** The part of a note under the pointer while nothing is held. */
private hover: NoteHit | null = null;
/** The notes picked, by id; only this tab knows them. */ /** The notes picked, by id; only this tab knows them. */
private picked = new Set<string>(); private picked = new Set<string>();
/** Where playing starts and a paste lands, in ticks. */ /** Where playing starts and a paste lands, in ticks. */
@@ -217,6 +225,7 @@ export class PianoRoll extends Widget {
if (this.assetId === assetId) return; if (this.assetId === assetId) return;
this.endGesture(); this.endGesture();
this.assetId = assetId; this.assetId = assetId;
this.hover = null;
this.picked.clear(); this.picked.clear();
this.startTick = 0; this.startTick = 0;
this.needsFit = true; this.needsFit = true;
@@ -419,6 +428,8 @@ export class PianoRoll extends Widget {
playhead: number | null; playhead: number | null;
loop: LoopRange | null; loop: LoopRange | null;
keys: number | null; keys: number | null;
hover: NoteHit | null;
cursor: string;
} { } {
const g = this.gesture; const g = this.gesture;
return { return {
@@ -427,6 +438,8 @@ export class PianoRoll extends Widget {
playhead: this.playhead, playhead: this.playhead,
loop: this.shownLoop(), loop: this.shownLoop(),
keys: g?.kind === 'key' ? g.note : null, keys: g?.kind === 'key' ? g.note : null,
hover: this.hover,
cursor: String(this.cursor ?? ''),
}; };
} }
@@ -531,6 +544,10 @@ export class PianoRoll extends Widget {
this.gesture = { kind: 'resize', anchor, by: 0, moved: false }; this.gesture = { kind: 'resize', anchor, by: 0, moved: false };
return; return;
} }
if (hit.part === 'start') {
this.gesture = { kind: 'trim', anchor, by: 0 };
return;
}
const grab = xToTick(this.view, g.x) - anchor.tick; const grab = xToTick(this.view, g.x) - anchor.tick;
this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g }; this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g };
this.onPreview(anchor.note, false); this.onPreview(anchor.note, false);
@@ -539,20 +556,27 @@ export class PianoRoll extends Widget {
private onPointerMove(e: FederatedPointerEvent): void { private onPointerMove(e: FederatedPointerEvent): void {
const gesture = this.gesture; const gesture = this.gesture;
const asset = this.asset(); const asset = this.asset();
if (!gesture || !asset) return; if (!asset) return;
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const g = this.inGrid({ x: local.x, y: local.y }); const g = this.inGrid({ x: local.x, y: local.y });
if (!gesture) {
this.followHover(asset, local, g);
return;
}
const tick = xToTick(this.view, g.x); const tick = xToTick(this.view, g.x);
const grid = patternGrid(asset.props); const grid = patternGrid(asset.props);
const free = e.altKey; const free = e.altKey;
switch (gesture.kind) { switch (gesture.kind) {
case 'pan': case 'pan':
this.view = clampRows( this.view = clampRows(
clampTimePan({ clampTimePan(
...this.view, {
panX: gesture.panX + (local.x - gesture.pointer.x), ...this.view,
panY: gesture.panY + (local.y - gesture.pointer.y), panX: gesture.panX + (local.x - gesture.pointer.x),
}), panY: gesture.panY + (local.y - gesture.pointer.y),
},
this.gridWidth,
),
this.gridHeight, this.gridHeight,
); );
break; break;
@@ -596,6 +620,9 @@ export class PianoRoll extends Widget {
gesture.moved = true; gesture.moved = true;
gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length; gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length;
break; break;
case 'trim':
gesture.by = draggedStart(gesture.anchor, tick, grid, free) - gesture.anchor.tick;
break;
case 'erase': case 'erase':
this.erase(asset, g); this.erase(asset, g);
break; break;
@@ -650,6 +677,16 @@ export class PianoRoll extends Widget {
this.lastLength = gesture.anchor.length + gesture.by; this.lastLength = gesture.anchor.length + gesture.by;
} }
break; break;
case 'trim':
if (gesture.by !== 0) {
this.commit(
asset,
trimmed(asset.notes, this.picked, gesture.by),
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
);
this.lastLength = gesture.anchor.length - gesture.by;
}
break;
case 'erase': case 'erase':
if (gesture.erased.size > 0) { if (gesture.erased.size > 0) {
this.commit( this.commit(
@@ -680,7 +717,8 @@ export class PianoRoll extends Widget {
private draw(asset: PatternAsset, at: Vec2, grid: number, free: boolean): void { private draw(asset: PatternAsset, at: Vec2, grid: number, free: boolean): void {
const note = noteAtY(this.view, at.y); const note = noteAtY(this.view, at.y);
if (note === null) return; // Left of tick 0 is there to look at, not to draw on.
if (note === null || xToTick(this.view, at.x) < 0) return;
const drawn = drawnNote( const drawn = drawnNote(
xToTick(this.view, at.x), xToTick(this.view, at.x),
note, note,
@@ -702,9 +740,19 @@ export class PianoRoll extends Widget {
if (hit) this.gesture.erased.add(hit.id); if (hit) this.gesture.erased.add(hit.id);
} }
/** Where a press or a drag on the ruler puts the start: on the grid line before it, with Alt on the tick. */ /** The part of a note under the pointer: an end it can be dragged by shows a resize cursor and lights up. */
private followHover(asset: PatternAsset, local: Vec2, g: Vec2): void {
const hit = this.areaAt(local) === 'grid' && local.x <= this.w ? noteAt(this.view, asset.notes, g) : null;
const cursor = hit && hit.part !== 'body' ? 'ew-resize' : 'default';
if (this.cursor !== cursor) this.cursor = cursor;
if (JSON.stringify(hit) === JSON.stringify(this.hover)) return;
this.hover = hit;
this.invalidate();
}
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt on the tick. */
private cursorTick(tick: number, grid: number, free: boolean): number { private cursorTick(tick: number, grid: number, free: boolean): number {
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid)); return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
} }
private setStart(tick: number): void { private setStart(tick: number): void {
@@ -745,7 +793,7 @@ export class PianoRoll extends Widget {
else if (area === 'ruler' || area === 'grid' || area === 'lane') else if (area === 'ruler' || area === 'grid' || area === 'lane')
view = { ...this.view, ...zoomTime(this.view, factor, g.x) }; view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
else return; else return;
this.view = clampRows(clampTimePan(view), this.gridHeight); this.view = clampRows(clampTimePan(view, this.gridWidth), this.gridHeight);
this.needsFit = false; this.needsFit = false;
this.redraw(); this.redraw();
e.preventDefault(); e.preventDefault();
@@ -758,6 +806,7 @@ export class PianoRoll extends Widget {
const g = this.gesture; const g = this.gesture;
if (g?.kind === 'move' && g.moved) return shifted(asset.notes, this.picked, g.ticks, g.notes); if (g?.kind === 'move' && g.moved) return shifted(asset.notes, this.picked, g.ticks, g.notes);
if (g?.kind === 'resize') return resized(asset.notes, this.picked, g.by); if (g?.kind === 'resize') return resized(asset.notes, this.picked, g.by);
if (g?.kind === 'trim') return trimmed(asset.notes, this.picked, g.by);
if (g?.kind === 'velocity') return withVelocity(asset.notes, this.picked, g.anchor, g.value); if (g?.kind === 'velocity') return withVelocity(asset.notes, this.picked, g.anchor, g.value);
if (g?.kind === 'erase') return asset.notes.filter((n) => !g.erased.has(n.id)); if (g?.kind === 'erase') return asset.notes.filter((n) => !g.erased.has(n.id));
return asset.notes; return asset.notes;
@@ -898,6 +947,11 @@ export class PianoRoll extends Widget {
.rect(Math.max(0, end), 0, gw - Math.max(0, end), VELOCITY_HEIGHT) .rect(Math.max(0, end), 0, gw - Math.max(0, end), VELOCITY_HEIGHT)
.fill({ color: 0x000000, alpha: 0.25 }); .fill({ color: 0x000000, alpha: 0.25 });
} }
const zero = Math.min(gw, tickToX(this.view, 0));
if (zero > 0) {
this.gridG.rect(0, 0, zero, gh).fill({ color: 0x000000, alpha: 0.5 });
this.laneG.rect(0, 0, zero, VELOCITY_HEIGHT).fill({ color: 0x000000, alpha: 0.35 });
}
} }
private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void { private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void {
@@ -912,9 +966,22 @@ export class PianoRoll extends Widget {
.rect(box.x, box.y + 1, w, Math.max(1, box.h - 1)) .rect(box.x, box.y + 1, w, Math.max(1, box.h - 1))
.fill({ color: picked ? mix(HARD, 0xffffff, 0.35) : mix(SOFT, HARD, note.velocity), alpha }) .fill({ color: picked ? mix(HARD, 0xffffff, 0.35) : mix(SOFT, HARD, note.velocity), alpha })
.stroke({ width: 1, color: picked ? c.accentText : c.viewport, alpha, alignment: 1 }); .stroke({ width: 1, color: picked ? c.accentText : c.viewport, alpha, alignment: 1 });
if (this.hover?.id === note.id && this.hover.part !== 'body')
this.drawGrip({ ...box, w: Math.max(4, box.w) }, this.hover.part);
} }
} }
/** The end of a note the pointer would drag, lit, as on an item of the Song tab. */
private drawGrip(box: NoteBox, part: 'start' | 'end'): void {
const reach = Math.min(END_GRIP, box.w / 3);
const y = box.y + 1;
const h = Math.max(1, box.h - 1);
this.gridG
.rect(part === 'end' ? box.x + box.w - reach : box.x, y, reach, h)
.fill({ color: 0xffffff, alpha: 0.35 });
this.gridG.rect(part === 'end' ? box.x + box.w - 2 : box.x, y, 2, h).fill(Theme.colors.accentText);
}
private drawLane(notes: readonly PatternNote[], length: number, gw: number): void { private drawLane(notes: readonly PatternNote[], length: number, gw: number): void {
const c = Theme.colors; const c = Theme.colors;
this.laneG.rect(0, VELOCITY_HEIGHT / 2, gw, 1).fill({ color: c.border, alpha: 0.5 }); this.laneG.rect(0, VELOCITY_HEIGHT / 2, gw, 1).fill({ color: c.border, alpha: 0.5 });
+26
View File
@@ -95,6 +95,32 @@ export function resized(
return notes.map((n) => (picked.has(n.id) ? { ...n, length: Math.max(1, n.length + by) } : n)); return notes.map((n) => (picked.has(n.id) ? { ...n, length: Math.max(1, n.length + by) } : n));
} }
/**
* Where the grabbed note starts when its start is dragged to a tick: on the grid line nearest the
* pointer, its end staying put. Never up to its end – then on the last grid line before it – and
* never before tick 0.
*/
export function draggedStart(anchor: PatternNote, pointerTick: number, grid: number, free: boolean): number {
const end = anchor.tick + anchor.length;
if (free) return clamp(Math.round(pointerTick), 0, end - 1);
const start = snapNear(pointerTick, grid);
return Math.max(0, start < end ? start : snapDown(end - 1, grid));
}
/** Every picked note's start moved by what the grabbed one's was, its end kept, none shorter than a tick. */
export function trimmed(
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
by: number,
): PatternNote[] {
return notes.map((n) => {
if (!picked.has(n.id)) return n;
const end = n.tick + n.length;
const tick = clamp(n.tick + by, 0, end - 1);
return { ...n, tick, length: end - tick };
});
}
/** How far right a copy of the picked notes goes so it stands right behind them: whole grid steps. */ /** How far right a copy of the picked notes goes so it stands right behind them: whole grid steps. */
export function duplicateOffset( export function duplicateOffset(
notes: readonly PatternNote[], notes: readonly PatternNote[],
+8 -5
View File
@@ -19,8 +19,8 @@ export const ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1;
export const MIN_ROW_HEIGHT = 6; export const MIN_ROW_HEIGHT = 6;
export const MAX_ROW_HEIGHT = 28; export const MAX_ROW_HEIGHT = 28;
export const DEFAULT_ROW_HEIGHT = 14; export const DEFAULT_ROW_HEIGHT = 14;
/** How far in from a note's right end a press takes its length rather than the note. */ /** How far in from an end of a note a press takes that end rather than the note. */
const END_GRIP = 6; export const END_GRIP = 6;
/** A note narrower than this is still this wide to the pointer. */ /** A note narrower than this is still this wide to the pointer. */
const MIN_HIT_WIDTH = 4; const MIN_HIT_WIDTH = 4;
/** How close to a bar of the velocity lane a press must be to take it. */ /** How close to a bar of the velocity lane a press must be to take it. */
@@ -73,10 +73,12 @@ export function noteBox(view: RollView, note: Pick<PatternNote, 'tick' | 'length
export interface NoteHit { export interface NoteHit {
id: string; id: string;
/** The body moves the note, the end changes its length. */ /** The body moves the note, an end moves that end and so changes its length. */
part: 'body' | 'end'; part: NotePart;
} }
export type NotePart = 'start' | 'body' | 'end';
/** The note under a point; where notes lie over each other, the one drawn last. */ /** The note under a point; where notes lie over each other, the one drawn last. */
export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec2): NoteHit | null { export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec2): NoteHit | null {
for (let i = notes.length - 1; i >= 0; i--) { for (let i = notes.length - 1; i >= 0; i--) {
@@ -85,7 +87,8 @@ export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec
const w = Math.max(box.w, MIN_HIT_WIDTH); const w = Math.max(box.w, MIN_HIT_WIDTH);
if (point.x < box.x || point.x >= box.x + w || point.y < box.y || point.y >= box.y + box.h) continue; if (point.x < box.x || point.x >= box.x + w || point.y < box.y || point.y >= box.y + box.h) continue;
const grip = Math.min(END_GRIP, w / 3); const grip = Math.min(END_GRIP, w / 3);
return { id: note.id, part: point.x >= box.x + w - grip ? 'end' : 'body' }; const part = point.x >= box.x + w - grip ? 'end' : point.x < box.x + grip ? 'start' : 'body';
return { id: note.id, part };
} }
return null; return null;
} }
+23 -31
View File
@@ -73,7 +73,6 @@ import {
trimmed, trimmed,
} from './songEdit'; } from './songEdit';
import { import {
ADD_ROW_HEIGHT,
type Box, type Box,
clampTracks, clampTracks,
HEAD_BOXES, HEAD_BOXES,
@@ -182,7 +181,6 @@ export class SongTimeline extends Widget {
private readonly ruler = new TimeRuler(); private readonly ruler = new TimeRuler();
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() }; private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
private readonly hint = crispText({ text: '', style: textStyle('muted') }); 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, trackHeight: TRACK_HEIGHT }; private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
private needsFit = true; private needsFit = true;
private assetId = ''; private assetId = '';
@@ -210,7 +208,7 @@ export class SongTimeline extends Widget {
super(); super();
this.focusable = true; this.focusable = true;
this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay); this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay);
this.headsLayer.addChild(this.headsG, this.headLabels, this.addLabel); this.headsLayer.addChild(this.headsG, this.headLabels);
this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint); this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint);
for (const clip of Object.values(this.clips)) this.addChild(clip); for (const clip of Object.values(this.clips)) this.addChild(clip);
this.lanesLayer.mask = this.clips.lanes; this.lanesLayer.mask = this.clips.lanes;
@@ -219,7 +217,6 @@ export class SongTimeline extends Widget {
this.hint.eventMode = 'none'; this.hint.eventMode = 'none';
this.itemLabels.eventMode = 'none'; this.itemLabels.eventMode = 'none';
this.headLabels.eventMode = 'none'; this.headLabels.eventMode = 'none';
this.addLabel.eventMode = 'none';
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
@@ -489,13 +486,6 @@ export class SongTimeline extends Widget {
}; };
} }
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 { rulerPoint(tick: number): Vec2 {
const at = this.getGlobalPosition(); const at = this.getGlobalPosition();
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 };
@@ -621,11 +611,7 @@ export class SongTimeline extends Widget {
private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void { private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void {
const row = trackAtY(this.view, asset.tracks.length, y); const row = trackAtY(this.view, asset.tracks.length, y);
if (row === null) { if (row === null) return;
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]!; const track = asset.tracks[row]!;
if (e.button === 2) { if (e.button === 2) {
this.openTrackMenu(asset, row, e.global); this.openTrackMenu(asset, row, e.global);
@@ -711,11 +697,14 @@ export class SongTimeline extends Widget {
switch (gesture.kind) { switch (gesture.kind) {
case 'pan': case 'pan':
this.view = clampTracks( this.view = clampTracks(
clampTimePan({ clampTimePan(
...this.view, {
panX: gesture.panX + (local.x - gesture.pointer.x), ...this.view,
panY: gesture.panY + (local.y - gesture.pointer.y), panX: gesture.panX + (local.x - gesture.pointer.x),
}), panY: gesture.panY + (local.y - gesture.pointer.y),
},
this.lanesWidth,
),
asset.tracks.length, asset.tracks.length,
this.lanesHeight, this.lanesHeight,
); );
@@ -851,7 +840,13 @@ export class SongTimeline extends Widget {
/** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */ /** 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 { private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void {
const dragged = findAsset(this.state.project, assetId); 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; // Left of tick 0 is there to look at: nothing lands there.
const inside =
local.x >= HEAD_WIDTH &&
local.x <= this.w &&
local.y >= RULER_HEIGHT &&
local.y <= this.h &&
xToTick(this.view, g.x) >= 0;
const row = trackAtY(this.view, asset.tracks.length, g.y); const row = trackAtY(this.view, asset.tracks.length, g.y);
if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) { if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) {
if (this.drop) { if (this.drop) {
@@ -885,9 +880,9 @@ export class SongTimeline extends Widget {
this.invalidate(); this.invalidate();
} }
/** Where a press or a drag on the ruler puts the start: on the grid line before it, with Alt on the tick. */ /** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt on the tick. */
private cursorTick(tick: number, grid: number, free: boolean): number { private cursorTick(tick: number, grid: number, free: boolean): number {
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid)); return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
} }
private setStart(tick: number): void { private setStart(tick: number): void {
@@ -969,7 +964,7 @@ export class SongTimeline extends Widget {
else if (area === 'ruler' || area === 'lanes') else if (area === 'ruler' || area === 'lanes')
view = { ...this.view, ...zoomTime(this.view, factor, g.x) }; view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
else return; else return;
this.view = clampTracks(clampTimePan(view), asset.tracks.length, this.lanesHeight); this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight);
this.needsFit = false; this.needsFit = false;
this.redraw(); this.redraw();
e.preventDefault(); e.preventDefault();
@@ -1085,7 +1080,6 @@ export class SongTimeline extends Widget {
if (!asset) { if (!asset) {
this.syncTexts(this.itemTexts, this.itemLabels, []); this.syncTexts(this.itemTexts, this.itemLabels, []);
this.syncTexts(this.headTexts, this.headLabels, []); this.syncTexts(this.headTexts, this.headLabels, []);
this.addLabel.visible = false;
this.ruler.visible = false; this.ruler.visible = false;
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, RULER_HEIGHT + Theme.padding * 2);
@@ -1161,6 +1155,8 @@ export class SongTimeline extends Widget {
this.lanesG this.lanesG
.rect(Math.max(0, end), 0, lw - Math.max(0, end), bottom) .rect(Math.max(0, end), 0, lw - Math.max(0, end), bottom)
.fill({ color: 0x000000, alpha: 0.2 }); .fill({ color: 0x000000, alpha: 0.2 });
const zero = Math.min(lw, tickToX(this.view, 0));
if (zero > 0) this.lanesG.rect(0, 0, zero, bottom).fill({ color: 0x000000, alpha: 0.4 });
} }
private drawItems(asset: SongAsset, lw: number, lh: number): void { private drawItems(asset: SongAsset, lw: number, lh: number): void {
@@ -1287,7 +1283,7 @@ export class SongTimeline extends Widget {
} }
} }
/** The heads: name, mute and solo, the volume and pan sliders, and the row that adds a track. */ /** The heads: name, mute and solo, the volume and pan sliders. A track is added from a head's menu. */
private drawHeads(tracks: readonly SongTrack[]): void { private drawHeads(tracks: readonly SongTrack[]): void {
const c = Theme.colors; const c = Theme.colors;
const labels: Placed[] = []; const labels: Placed[] = [];
@@ -1328,10 +1324,6 @@ export class SongTimeline extends Widget {
this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 }); 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); 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 { private toggle(box: Box, y: number, on: boolean, color: number): void {
+2 -4
View File
@@ -19,8 +19,6 @@ export const MIN_TRACK_HEIGHT = 28;
export const MAX_TRACK_HEIGHT = 160; export const MAX_TRACK_HEIGHT = 160;
/** A track lower than this has no room for the volume and pan sliders in its head. */ /** A track lower than this has no room for the volume and pan sliders in its head. */
export const SLIDERS_MIN_HEIGHT = 46; export const SLIDERS_MIN_HEIGHT = 46;
/** The row under the last track that adds one. */
export const ADD_ROW_HEIGHT = 28;
/** How close to an end of an item a press takes that end. */ /** How close to an end of an item a press takes that end. */
export const EDGE_REACH = 6; export const EDGE_REACH = 6;
const INSET = 2; const INSET = 2;
@@ -149,9 +147,9 @@ export function peakOver(peaks: ArrayLike<number>, from: number, to: number): nu
return peak; return peak;
} }
/** Keeps the tracks and the row that adds one in reach: nothing to scroll to above or far below. */ /** Keeps the tracks in reach: nothing to scroll to above the first or below the last. */
export function clampTracks<V extends SongView>(view: V, count: number, height: number): V { export function clampTracks<V extends SongView>(view: V, count: number, height: number): V {
const content = count * view.trackHeight + ADD_ROW_HEIGHT; const content = count * view.trackHeight;
const panY = clamp(view.panY, Math.min(0, height - content), 0); const panY = clamp(view.panY, Math.min(0, height - content), 0);
return panY === view.panY ? view : { ...view, panY }; return panY === view.panY ? view : { ...view, panY };
} }
+7 -7
View File
@@ -13,7 +13,7 @@ import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/song
import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset'; import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset';
import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks'; import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler'; import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler';
import { newItem, trackBus } from '../../src/core/music/song'; import { newItem, newTrack, trackBus } from '../../src/core/music/song';
import { type AudioSink, type ClipTiming, SILENT_AUDIO } from '../../src/runtime/systems/Audio'; import { type AudioSink, type ClipTiming, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
@@ -197,13 +197,14 @@ function playingSong(params: Record<string, string | number | boolean> = {}) {
const id = create.asset.id; const id = create.asset.id;
const asset = () => f.project.assets.find((a) => a.id === id) as SongAsset; const asset = () => f.project.assets.find((a) => a.id === id) as SongAsset;
f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90)); f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90));
const [one, two, three] = asset().tracks; // A new song has one track: two more to lay things out on.
const [one, two, three] = [...asset().tracks, newTrack(2), newTrack(3)];
f.stack.execute( f.stack.execute(
new SetSongCommand(id, { new SetSongCommand(id, {
tracks: asset().tracks.map((t, i) => (i === 1 ? { ...t, volume: -6, pan: -1 } : t)), tracks: [one, { ...two, volume: -6, pan: -1 }, three!],
items: [ items: [
newItem({ track: one!.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }), newItem({ track: one.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }),
newItem({ track: two!.id, kind: 'sound', asset: sound.asset.id, tick: BAR, length: BAR }), newItem({ track: two.id, kind: 'sound', asset: sound.asset.id, tick: BAR, length: BAR }),
], ],
}), }),
); );
@@ -215,7 +216,7 @@ function playingSong(params: Record<string, string | number | boolean> = {}) {
const start = edit.addNode('OnStart'); const start = edit.addNode('OnStart');
const play = edit.addNode('PlaySong', { song: id, volume: 0.8, fadeIn: 2, ...params }); const play = edit.addNode('PlaySong', { song: id, volume: 0.8, fadeIn: 2, ...params });
edit.connect(start, 'exec', play, 'exec'); edit.connect(start, 'exec', play, 'exec');
return { ...f, songId: id, tracks: [one!.id, two!.id, three!.id], lead, edit, play }; return { ...f, songId: id, tracks: [one.id, two.id, three!.id], lead, edit, play };
} }
function recordingAudio() { function recordingAudio() {
@@ -265,7 +266,6 @@ describe('Play song', () => {
[trackBus(f.songId, f.tracks[0]!), 1, 0], [trackBus(f.songId, f.tracks[0]!), 1, 0],
[trackBus(f.songId, f.tracks[1]!), 0.501, -1], [trackBus(f.songId, f.tracks[1]!), 0.501, -1],
[trackBus(f.songId, f.tracks[2]!), 1, 0], [trackBus(f.songId, f.tracks[2]!), 1, 0],
[expect.any(String), 1, 0],
]); ]);
}); });
+9 -5
View File
@@ -8,6 +8,7 @@ import {
heldSongTracks, heldSongTracks,
itemFrom, itemFrom,
newItem, newItem,
newTrack,
type SongItem, type SongItem,
songGrid, songGrid,
songLength, songLength,
@@ -24,14 +25,17 @@ import { makeProject, roundTrip } from '../helpers';
const BAR = 1920; const BAR = 1920;
/** A song with its four tracks, a one-bar pattern of two notes played by "Piano", and a sound. */ /** A song with four tracks, a one-bar pattern of two notes played by "Piano", and a sound. */
function makeSong() { function makeSong(tracks = 4) {
const f = makeProject(); const f = makeProject();
const create = new CreateAssetCommand('song', 'Tune'); const create = new CreateAssetCommand('song', 'Tune');
const pattern = new CreateAssetCommand('pattern', 'Riff'); const pattern = new CreateAssetCommand('pattern', 'Riff');
const piano = new CreateAssetCommand('instrument', 'Piano'); const piano = new CreateAssetCommand('instrument', 'Piano');
const organ = new CreateAssetCommand('instrument', 'Organ'); const organ = new CreateAssetCommand('instrument', 'Organ');
for (const command of [create, pattern, piano, organ]) f.stack.execute(command); for (const command of [create, pattern, piano, organ]) f.stack.execute(command);
// A new song has one track; the tests lay things out on more, without an undo step of their own.
const created = f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
for (let i = created.tracks.length; i < tracks; i++) created.tracks.push(newTrack(i + 1));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1)); f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', piano.asset.id)); f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', piano.asset.id));
f.stack.execute( f.stack.execute(
@@ -61,9 +65,9 @@ function makeSong() {
} }
describe('the song asset (#334)', () => { describe('the song asset (#334)', () => {
it('starts with four tracks, nothing on them, at 120 BPM in 4/4 on a grid of a bar', () => { it('starts with one track, nothing on it, at 120 BPM in 4/4 on a grid of a bar', () => {
const f = makeSong(); const f = makeSong(1);
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']); expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']);
expect(f.song().items).toEqual([]); expect(f.song().items).toEqual([]);
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'beats', 'beat', 'grid']); expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'beats', 'beat', 'grid']);
expect(songGrid(f.song().props)).toBe(BAR); expect(songGrid(f.song().props)).toBe(BAR);
+23 -3
View File
@@ -14,12 +14,14 @@ import {
import { import {
clampShift, clampShift,
draggedLength, draggedLength,
draggedStart,
drawnNote, drawnNote,
duplicateOffset, duplicateOffset,
moveDelta, moveDelta,
placedCopies, placedCopies,
resized, resized,
shifted, shifted,
trimmed,
withVelocity, withVelocity,
} from '../../src/editor/pattern/rollEdit'; } from '../../src/editor/pattern/rollEdit';
import { import {
@@ -48,10 +50,12 @@ const VIEW: RollView = { panX: 0, pxPerTick: 0.1, panY: -(127 - 60) * 10, rowHei
const COMMON = { beats: 4, unit: 4 }; const COMMON = { beats: 4, unit: 4 };
describe('time view of the piano roll (#333)', () => { describe('time view of the piano roll (#333)', () => {
it('zooms about the pointer and never shows time before 0', () => { it('zooms about the pointer and pans tick 0 at most half the width in', () => {
const zoomed = zoomTime({ panX: -100, pxPerTick: 0.1 }, 2, 300); const zoomed = zoomTime({ panX: -100, pxPerTick: 0.1 }, 2, 300);
expect(xToTick(zoomed, 300)).toBeCloseTo(4000); expect(xToTick(zoomed, 300)).toBeCloseTo(4000);
expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }).panX).toBe(0); expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }, 600).panX).toBe(40);
expect(clampTimePan({ panX: 400, pxPerTick: 0.1 }, 600).panX).toBe(300);
expect(clampTimePan({ panX: -50, pxPerTick: 0.1 }, 600).panX).toBe(-50);
expect(fitTime(7680, 768)).toEqual({ panX: 0, pxPerTick: 0.1 }); expect(fitTime(7680, 768)).toEqual({ panX: 0, pxPerTick: 0.1 });
}); });
@@ -117,11 +121,12 @@ describe('rows of the piano roll (#333)', () => {
expect(centreRows(0, 127, 200).rowHeight).toBe(6); expect(centreRows(0, 127, 200).rowHeight).toBe(6);
}); });
it('tells a note body from its right end, and takes the one drawn last', () => { it('tells a note body from its ends, and takes the one drawn last', () => {
const notes = [note('a', 0, 480), note('b', 240, 480)]; const notes = [note('a', 0, 480), note('b', 240, 480)];
expect(noteAt(VIEW, notes, { x: 10, y: 5 })).toEqual({ id: 'a', part: 'body' }); expect(noteAt(VIEW, notes, { x: 10, y: 5 })).toEqual({ id: 'a', part: 'body' });
expect(noteAt(VIEW, notes, { x: 30, y: 5 })).toEqual({ id: 'b', part: 'body' }); expect(noteAt(VIEW, notes, { x: 30, y: 5 })).toEqual({ id: 'b', part: 'body' });
expect(noteAt(VIEW, notes, { x: 70, y: 5 })).toEqual({ id: 'b', part: 'end' }); expect(noteAt(VIEW, notes, { x: 70, y: 5 })).toEqual({ id: 'b', part: 'end' });
expect(noteAt(VIEW, notes, { x: 25, y: 5 })).toEqual({ id: 'b', part: 'start' });
expect(noteAt(VIEW, notes, { x: 10, y: 15 })).toBeNull(); expect(noteAt(VIEW, notes, { x: 10, y: 15 })).toBeNull();
// A note of a tick is still 4 px to the pointer. // A note of a tick is still 4 px to the pointer.
expect(noteAt(VIEW, [note('c', 1000, 1)], { x: 102, y: 5 })?.id).toBe('c'); expect(noteAt(VIEW, [note('c', 1000, 1)], { x: 102, y: 5 })?.id).toBe('c');
@@ -185,6 +190,21 @@ describe('editing in the piano roll (#333)', () => {
]); ]);
}); });
it('drags a start to the nearest grid line, its end kept, never up to the end or before 0', () => {
const anchor = note('a', 240, 240);
expect(draggedStart(anchor, 130, 120, false)).toBe(120);
expect(draggedStart(anchor, 470, 120, false)).toBe(360);
expect(draggedStart(anchor, -300, 120, false)).toBe(0);
expect(draggedStart(anchor, 479.6, 120, true)).toBe(479);
const notes = trimmed([anchor, note('b', 60, 60), note('c', 0, 60)], new Set(['a', 'b']), -120);
expect(notes.map((n) => [n.tick, n.length])).toEqual([
[120, 360],
[0, 120],
[0, 60],
]);
expect(trimmed([anchor], new Set(['a']), 999).map((n) => [n.tick, n.length])).toEqual([[479, 1]]);
});
it('puts a duplicate right behind the picked notes, in whole grid steps', () => { it('puts a duplicate right behind the picked notes, in whole grid steps', () => {
const notes = [note('a', 0, 120), note('b', 240, 60), note('c', 960, 120)]; const notes = [note('a', 0, 120), note('b', 240, 60), note('c', 960, 120)];
expect(duplicateOffset(notes, new Set(['a', 'b']), 120)).toBe(360); expect(duplicateOffset(notes, new Set(['a', 'b']), 120)).toBe(360);
+3 -3
View File
@@ -138,9 +138,9 @@ describe('where things are in the Song tab (#334)', () => {
]); ]);
}); });
it('scrolls no further than the tracks and the row that adds one', () => { it('scrolls no further than the tracks', () => {
expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(0); expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(0);
expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT - 28); expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT);
}); });
it('tells the parts of a head apart and reads a slider from left to right', () => { it('tells the parts of a head apart and reads a slider from left to right', () => {
@@ -164,7 +164,7 @@ describe('where things are in the Song tab (#334)', () => {
expect(trackAtY(zoomed, 10, 150)).toBe(trackAtY({ ...view, panY: -20 }, 10, 150)); expect(trackAtY(zoomed, 10, 150)).toBe(trackAtY({ ...view, panY: -20 }, 10, 150));
expect(zoomTracks(view, 100, 0).trackHeight).toBe(MAX_TRACK_HEIGHT); expect(zoomTracks(view, 100, 0).trackHeight).toBe(MAX_TRACK_HEIGHT);
expect(zoomTracks(view, 0.01, 0).trackHeight).toBe(MIN_TRACK_HEIGHT); expect(zoomTracks(view, 0.01, 0).trackHeight).toBe(MIN_TRACK_HEIGHT);
expect(clampTracks({ ...zoomed, panY: -9999 }, 10, 400).panY).toBe(400 - 10 * 2 * TRACK_HEIGHT - 28); expect(clampTracks({ ...zoomed, panY: -9999 }, 10, 400).panY).toBe(400 - 10 * 2 * TRACK_HEIGHT);
expect(heldSongView({ panX: -5, pxPerTick: 0.3, panY: -10 })).toEqual({ expect(heldSongView({ panX: -5, pxPerTick: 0.3, panY: -10 })).toEqual({
panX: -5, panX: -5,
pxPerTick: 0.3, pxPerTick: 0.3,
+43 -2
View File
@@ -3,8 +3,8 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
/** /**
* Patterns and their piano roll (#333). "+ → Pattern" opens the tab; with an instrument picked its * Patterns and their piano roll (#333). "+ → Pattern" opens the tab; with an instrument picked its
* keys sound where the instrument plays. A click draws a note on the grid, its right end lengthens * keys sound where the instrument plays. A click draws a note on the grid, either end sizes it and
* it, its body moves it – with Alt off the grid –, a band picks notes, Ctrl+D puts a copy behind * lights up under the pointer, its body moves it – with Alt off the grid –, a band picks notes, Ctrl+D puts a copy behind
* them, the arrows transpose, Delete and a right-click take notes away, Ctrl+C/V copies them to the * them, the arrows transpose, Delete and a right-click take notes away, Ctrl+C/V copies them to the
* start set on the ruler, the lane sets a velocity, a drag on the ruler moves the start and one on * start set on the ruler, the lane sets a velocity, a drag on the ruler moves the start and one on
* the strip above it sets the loop, the wheel zooms the time or the rows. Space plays the * the strip above it sets the loop, the wheel zooms the time or the rows. Space plays the
@@ -148,6 +148,30 @@ export async function pattern(t) {
); );
const second = lengths.find((n) => n.id !== first)?.id; const second = lengths.find((n) => n.id !== first)?.id;
// 64c'. Its left end lights up under the pointer and takes its start to the nearest grid line, the
// end staying put; Ctrl+Z takes it back.
const startEdge = await json('__gm.pattern.gridAt(960, 64)');
await t.mouse('mouseMoved', startEdge.x + 2, startEdge.y);
await sleep(100);
const hovered = await json('__gm.pattern.view()');
const earlier = await json('__gm.pattern.gridAt(850, 64)');
await drag(startEdge.x + 2, startEdge.y, earlier.x, earlier.y);
await sleep(300);
const trimmedNote = (await notes()).find((n) => n.id === second);
await press('z', { ctrl: true });
const untrimmed = (await notes()).find((n) => n.id === second);
check(
'the left end of a note lights up with a resize cursor and takes its start to the nearest grid line, the end kept',
hovered.hover?.id === second &&
hovered.hover?.part === 'start' &&
hovered.cursor === 'ew-resize' &&
trimmedNote?.tick === 840 &&
trimmedNote?.length === 600 &&
untrimmed?.tick === 960 &&
untrimmed?.length === 480,
JSON.stringify({ hover: hovered.hover, cursor: hovered.cursor, trimmedNote, untrimmed }),
);
// 64d. Its body moves it onto the grid and to another key, silent with Audition off; with Alt it // 64d. Its body moves it onto the grid and to another key, silent with Audition off; with Alt it
// lands on the tick. // lands on the tick.
await sleep(500); await sleep(500);
@@ -283,6 +307,23 @@ export async function pattern(t) {
keysWheel.panX === gridWheel.panX, keysWheel.panX === gridWheel.panX,
JSON.stringify({ scrubbed, wheelFrom, gridWheel, keysWheel }), JSON.stringify({ scrubbed, wheelFrom, gridWheel, keysWheel }),
); );
// A middle-drag far to the right pans tick 0 in from the left edge, but only half the grid in; a
// click left of it draws nothing. Fit takes the view back.
const area = await json('__gm.pattern.rect()');
p = await json('__gm.pattern.gridAt(960, 62)');
for (let i = 0; i < 4; i++) await drag(area.x + 120, p.y, area.x + area.w - 20, p.y, { button: 'middle' });
const panned = await json('__gm.pattern.view()');
const zero = await json('__gm.pattern.gridAt(0, 62)');
const countPanned = (await notes()).length;
await click(zero.x - 20, zero.y);
const countLeft = (await notes()).length;
p = await json('__gm.pattern.fitAt()');
await click(p.x, p.y);
check(
'a middle-drag pans bar 1 in from the left edge, half the grid at most, and a click left of it draws nothing',
Math.abs(panned.panX - (area.w - 84) / 2) < 1 && countLeft === countPanned,
JSON.stringify({ panned, area, countPanned, countLeft }),
);
p = await json('__gm.pattern.loopAt(1920)'); p = await json('__gm.pattern.loopAt(1920)');
to = await json('__gm.pattern.loopAt(2880)'); to = await json('__gm.pattern.loopAt(2880)');
await drag(p.x, p.y, to.x, to.y); await drag(p.x, p.y, to.x, to.y);
+23 -12
View File
@@ -2,13 +2,14 @@ import { edge, node } from '../lib/builders.mjs';
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
/** /**
* Songs and their tracks (#334). "+ → Song" opens the tab on four empty tracks. A pattern and a * Songs and their tracks (#334). "+ → Song" opens the tab on one empty track, a head's menu adds
* three more. A pattern and a
* sound dragged in from the Assets panel land as items where the preview showed them; an item's * sound dragged in from the Assets panel land as items where the preview showed them; an item's
* right end lengthens it, its body moves it across tracks, its left end cuts it back, Ctrl+D puts a * right end lengthens it, its body moves it across tracks, its left end cuts it back, Ctrl+D puts a
* copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click * copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click
* on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the * on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the
* wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets * wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets
* the volume of its track, "+ Track" adds one. Space plays the song through the scheduler: * the volume of its track, its menu adds one. Space plays the song through the scheduler:
* the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus. * the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus.
* A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. * A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab.
* Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips * Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips
@@ -109,7 +110,7 @@ export async function song(t) {
);`); );`);
const seconds = await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`); const seconds = await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`);
// 65a. "+ → Song" makes a song of four empty tracks and opens its tab. // 65a. "+ → Song" makes a song of one empty track and opens its tab; a head's menu adds the rest.
await clickText('+'); await clickText('+');
await pickAssetType(t, 'Song'); await pickAssetType(t, 'Song');
const created = await json(`{ const created = await json(`{
@@ -119,16 +120,26 @@ export async function song(t) {
songs: __gm.state.project.assets.filter((a) => a.type === 'song').map((a) => ({ id: a.id, tracks: a.tracks.length })), songs: __gm.state.project.assets.filter((a) => a.type === 'song').map((a) => ({ id: a.id, tracks: a.tracks.length })),
}`); }`);
check( check(
'the + menu creates a song of four empty tracks and opens its tab', 'the + menu creates a song of one empty track and opens its tab',
created.tab === 'song' && created.tab === 'song' &&
created.songs.length === 1 && created.songs.length === 1 &&
created.current === created.songs[0].id && created.current === created.songs[0].id &&
created.songs[0].tracks === 4 && created.songs[0].tracks === 1 &&
created.info === '4 tracks · 0 items', created.info === '1 track · 0 items',
JSON.stringify(created), JSON.stringify(created),
); );
const id = created.songs[0]?.id; const id = created.songs[0]?.id;
const model = () => json(`__gm.song.model(${JSON.stringify(id)})`); const model = () => json(`__gm.song.model(${JSON.stringify(id)})`);
/** Adds a track under the last one through its head's menu: there is no other way to. */
const addTrackBelowLast = async () => {
const count = (await model()).tracks.length;
const head = await json(`__gm.song.headAt(${count - 1}, 'name')`);
await click(head.x, head.y, 'right');
await sleep(150);
await clickText('Add track below');
await sleep(100);
};
for (let i = 0; i < 3; i++) await addTrackBelowLast();
const tracks = (await model()).tracks.map((track) => track.id); const tracks = (await model()).tracks.map((track) => track.id);
// 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars. // 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars.
@@ -237,13 +248,14 @@ export async function song(t) {
); );
p = await json('__gm.song.fitAt()'); p = await json('__gm.song.fitAt()');
await click(p.x, p.y); await click(p.x, p.y);
p = await json(`__gm.song.rulerAt(${6 * BAR + 300})`); // Three quarters into a bar: the start goes to the nearest bar line, the next one, not back.
p = await json(`__gm.song.rulerAt(${5 * BAR + 1500})`);
await click(p.x, p.y); await click(p.x, p.y);
await press('e', { ctrl: true }); await press('e', { ctrl: true });
items = (await model()).items; items = (await model()).items;
const parts = items.filter((item) => item.tick >= 5 * BAR).sort((a, b) => a.tick - b.tick); const parts = items.filter((item) => item.tick >= 5 * BAR).sort((a, b) => a.tick - b.tick);
check( check(
'a click on the ruler sets where playing starts, and Ctrl+E splits the picked item there', 'a click on the ruler sets the start on the nearest bar line, and Ctrl+E splits the picked item there',
(await json('__gm.song.view()')).start === 6 * BAR && (await json('__gm.song.view()')).start === 6 * BAR &&
parts.length === 2 && parts.length === 2 &&
parts[0].length === BAR && parts[0].length === BAR &&
@@ -341,18 +353,17 @@ export async function song(t) {
JSON.stringify({ unzoomed, taller, wider, songLoop }), JSON.stringify({ unzoomed, taller, wider, songLoop }),
); );
// 65g. The heads: mute and solo toggle, the volume slider sets decibels, "+ Track" adds a track. // 65g. The heads: mute and solo toggle, the volume slider sets decibels, the menu adds a track.
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')"))); await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
await click(...Object.values(await json("__gm.song.headAt(2, 'solo')"))); await click(...Object.values(await json("__gm.song.headAt(2, 'solo')")));
p = await json("__gm.song.headAt(2, 'volume', 0.5)"); p = await json("__gm.song.headAt(2, 'volume', 0.5)");
to = await json("__gm.song.headAt(2, 'volume', 0)"); to = await json("__gm.song.headAt(2, 'volume', 0)");
await drag(p.x, p.y, to.x + 1, to.y); await drag(p.x, p.y, to.x + 1, to.y);
await sleep(150); await sleep(150);
p = await json('__gm.song.addTrackAt()'); await addTrackBelowLast();
await click(p.x, p.y);
const heads = await model(); const heads = await model();
check( check(
'a head mutes, solos and turns down its track, "+ Track" adds a fifth and picks it', 'a head mutes, solos and turns down its track, its menu adds a fifth below and picks it',
heads.tracks.length === 5 && heads.tracks.length === 5 &&
heads.tracks[0].mute && heads.tracks[0].mute &&
heads.tracks[2].solo && heads.tracks[2].solo &&