Zoom with the wheel and set the loop on its own strip in music tabs

The ruler of the Pattern and Song tabs did two things with one drag,
and the wheel did three things with Alt and Shift. Both now do one
thing each, and the Song tab gets a few edits it was missing.

- The ruler has a loop strip along its top. A drag there sets the
  loop, a press or drag below it moves only where playing starts.
- The wheel only zooms: the time over the grid, lanes and ruler, the
  rows over the keys, the tracks over their heads. Alt and Shift no
  longer change it. A middle-drag still pans.
- Tracks can be zoomed from 28 to 160 px. Below 46 px a head shows
  only its name, M and S. An old saved view gets the old height.
- A click on nothing in the lanes picks the song itself.
- A right-click on an item opens a menu with Delete instead of
  deleting it. The inspector has no delete buttons any more.
- A sound item goes up to +12 dB, a track up to +12 dB. A pattern
  item stays at 0 dB, since a note is held to unity.
- An end of an item under the pointer lights up and shows a resize
  cursor. A long item marks every point where it starts over.
- The Pattern tab has an Audition switch in its header, off at first.
  Only with it on does a note sound when drawn, grabbed or moved.
  The keys down the left sound either way.
- A looped sound item draws every round alike. Each round is drawn
  from its own start, and each column shows the loudest part of the
  sound it covers instead of one sampled bucket.
- A song is at least one bar long instead of four. An empty song
  fits that bar across, and Play song ends after the last item.
- The ghost notes held back in #334 got their own issue, #339.

Finding: suite 542 for f97961c failed 5 game checks in e2e 64 and 65
while my runs on the same Windows machine overlapped it. Modules
60-65 alone without load passed, so this push's suite has to show it.

Tests:
- Unit: song-edit 11, piano-roll 17, song 14, sound-library 67.
  Four of them are new, one in song changed.
- e2e 64+65 on melektaus-atx: ALL PASSED - 46 checks (+3).
- e2e 60-65 on melektaus-atx: ALL PASSED - 86 checks.
- Gate on melektaus-atx: 251 files, 3907 tests, CHECK EXIT 0.
- Audition, after the gate: e2e 64 alone ALL PASSED - 25 checks
  (+1), transitions and piano-roll 30 tests, typecheck and ESLint
  clean.
- Waveform and one bar, after the gate: song, song-edit, song-play
  and the node catalog 438 tests. e2e 65 alone ALL PASSED - 25
  checks (+1). A probe shot of a looped pop sound: all six rounds
  pixel for pixel alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 18:25:49 +02:00
1 parent f97961c8ff
commit aa097bd076
19 files changed
+627 -202

No files matched your search

+12 -5
View File
@@ -38,7 +38,7 @@ export interface SongItem {
length: number;
/** Where in what it plays it starts: ticks into a pattern, seconds into a sound. */
offset: number;
/** In decibels, from `ITEM_MIN_DB` to 0. */
/** In decibels, from `ITEM_MIN_DB` to `itemMaxDb` of its kind. */
volume: number;
/** Semitones a pattern's notes are moved by. */
transpose: number;
@@ -55,15 +55,22 @@ export const SONG_GRID_KEY = 'grid';
export const DEFAULT_SONG_GRID = 'bar';
export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS];
/** A new song has this many tracks, and its ruler shows at least this many bars. */
/** A new song has this many tracks. */
export const DEFAULT_TRACKS = 4;
export const MIN_BARS = 4;
/** A song is never shorter than this many bars, on the ruler or when it plays. */
export const MIN_BARS = 1;
export const TRACK_MIN_DB = -60;
export const TRACK_MAX_DB = 6;
export const TRACK_MAX_DB = 12;
export const ITEM_MIN_DB = -60;
/** A sound item can be turned up; a pattern item's notes are held to unity, as a note always is. */
export const SOUND_ITEM_MAX_DB = 12;
export const MAX_TRANSPOSE = 48;
export function itemMaxDb(kind: SongItemKind): number {
return kind === 'sound' ? SOUND_ITEM_MAX_DB : 0;
}
export function songMeter(props: PropertyBag): Meter {
const beats = props[SONG_BEATS_KEY];
return heldMeter({
@@ -243,7 +250,7 @@ export function heldSongItems(raw: unknown, tracks: readonly SongTrack[]): SongI
tick,
length,
offset: kind === 'pattern' ? Math.round(offset) : offset,
volume: clampTo(finite(i.volume, 0), ITEM_MIN_DB, 0),
volume: clampTo(finite(i.volume, 0), ITEM_MIN_DB, itemMaxDb(kind)),
transpose: Math.round(clampTo(finite(i.transpose, 0), -MAX_TRANSPOSE, MAX_TRANSPOSE)),
instrument: text(i.instrument),
fx: text(i.fx),
+5
View File
@@ -21,6 +21,7 @@ export function patternHooks({ state, editor }: HookContext) {
/** Where a tick of a note's row, a tick of the ruler, a key, a velocity of the lane are on screen. */
gridAt: (tick: number, note: number) => roll.gridPoint(tick, note),
rulerAt: (tick: number) => roll.rulerPoint(tick),
loopAt: (tick: number) => roll.loopPoint(tick),
keyAt: (note: number) => roll.keyPoint(note),
laneAt: (tick: number, velocity: number) => roll.lanePoint(tick, velocity),
view: () => roll.viewInfo(),
@@ -28,6 +29,10 @@ export function patternHooks({ state, editor }: HookContext) {
playAt: () => ({ ...centreOf(patternEditor.playButton), text: patternEditor.playButton.text }),
gridButton: () => ({ ...centreOf(patternEditor.gridButton), text: patternEditor.gridButton.text }),
fitAt: () => centreOf(patternEditor.fitButton),
auditionAt: () => ({
...centreOf(patternEditor.auditionButton),
toggled: patternEditor.auditionButton.toggled,
}),
playing: () => patternEditor.isPlaying,
/** The notes the scheduler handed to the player since Play, and where it stands. */
planned: () => [...patternEditor.music.planned],
+1
View File
@@ -29,6 +29,7 @@ export function songHooks({ state, editor }: HookContext) {
headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share),
addTrackAt: () => timeline.addTrackPoint(),
rulerAt: (tick: number) => timeline.rulerPoint(tick),
loopAt: (tick: number) => timeline.loopPoint(tick),
view: () => timeline.viewInfo(),
items: () => timeline.itemsDrawn(),
playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }),
+12 -22
View File
@@ -5,16 +5,15 @@ import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import {
ITEM_MIN_DB,
itemMaxDb,
MAX_TRANSPOSE,
type SongItem,
type SongTrack,
TRACK_MAX_DB,
TRACK_MIN_DB,
} from '../../core/music/song';
import { Button } from '../../ui';
import { removeSongItems, removeTrack } from '../song/songActions';
import { createFieldRow } from './fieldFactory';
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
const TRACK_ROWS: readonly PropertyDef[] = [
@@ -42,7 +41,15 @@ function itemRows(item: SongItem): PropertyDef[] {
default: '',
assetTypes: [item.kind],
},
{ key: 'volume', label: 'Volume (dB)', kind: 'number', default: 0, min: ITEM_MIN_DB, max: 0, step: 0.5 },
{
key: 'volume',
label: 'Volume (dB)',
kind: 'number',
default: 0,
min: ITEM_MIN_DB,
max: itemMaxDb(item.kind),
step: 0.5,
},
];
if (item.kind === 'pattern')
rows.push(
@@ -75,7 +82,7 @@ export function songSignature(
return asset?.tracks.some((t) => t.id === trackId) ? `track:${trackId}` : 'missing';
}
/** The item picked last (#334), else the track: its rows and the way to delete it. */
/** The item picked last (#334), else the track: its rows. */
export function buildSongSection(
host: InspectorHost,
assetId: string,
@@ -143,13 +150,6 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo
host.fields.set(def.key, row);
host.body.add(row.row);
}
addGap(host);
host.body.add(
new Button('Delete track', {
variant: 'danger',
onClick: () => removeTrack(state, asset.id, track.id),
}),
);
}
function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked: number): void {
@@ -176,16 +176,6 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
host.body.add(row.row);
}
if (item.kind === 'pattern') addNote(host, 'An empty Instrument plays the pattern with its own.', 'small');
addGap(host);
host.body.add(
new Button(picked > 1 ? 'Delete items' : 'Delete item', {
variant: 'danger',
onClick: () => {
const s = state.selection;
removeSongItems(state, asset.id, s?.kind === 'song' ? s.itemIds : [item.id]);
},
}),
);
}
function itemOf(asset: SongAsset | undefined, itemIds: readonly string[]): SongItem | undefined {
+8 -9
View File
@@ -2,10 +2,7 @@ import { Container, Graphics, type Text } from 'pixi.js';
import type { LoopRange } from '../../core/music/sequence';
import { type Meter, ticksPerBar } from '../../core/music/time';
import { crispText, textStyle, Theme } from '../../ui';
import { barLabelStep, RULER_HEIGHT, type TimeView, tickToX, timeLines } from './timeView';
/** The strip at the top of the ruler that shows the loop. */
export const LOOP_STRIP = 6;
import { barLabelStep, LOOP_STRIP, RULER_HEIGHT, type TimeView, tickToX, timeLines } from './timeView';
export interface RulerMarks {
/** Where the piece ends, in ticks: the time before it reads lighter. */
@@ -17,9 +14,9 @@ export interface RulerMarks {
}
/**
* The ruler above the piano roll (#333), meant for the song tab too (#334): bars numbered from 1,
* the beats as short ticks, the loop as a band along its top, where playing starts as a small
* triangle at its foot and the playhead as one at its head. Draws `width` pixels from its own x 0,
* The ruler above the piano roll (#333) and the song tab (#334): bars numbered from 1, the beats as
* short ticks, the loop as a band in a strip of its own along its top, where playing starts as a
* small triangle at its foot and the playhead as one at its head. Draws `width` pixels from its own x 0,
* with the view measured from there.
*/
export class TimeRuler extends Container {
@@ -37,6 +34,7 @@ export class TimeRuler extends Container {
const c = Theme.colors;
const g = this.g.clear();
g.rect(0, 0, width, RULER_HEIGHT).fill(c.panelHeader);
g.rect(0, 0, width, LOOP_STRIP).fill(c.viewport);
const end = Math.min(width, tickToX(view, marks.length));
const begin = Math.max(0, tickToX(view, 0));
if (end > begin)
@@ -44,8 +42,9 @@ export class TimeRuler extends Container {
if (marks.loop) {
const x0 = Math.max(0, tickToX(view, marks.loop.start));
const x1 = Math.min(width, tickToX(view, marks.loop.end));
if (x1 > x0) g.rect(x0, 0, x1 - x0, LOOP_STRIP).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 });
}
g.rect(0, LOOP_STRIP, width, 1).fill(c.border);
g.rect(0, RULER_HEIGHT - 1, width, 1).fill(c.border);
const bar = ticksPerBar(meter);
@@ -69,7 +68,7 @@ export class TimeRuler extends Container {
}
text.visible = true;
if (text.text !== String(number + 1)) text.text = String(number + 1);
text.position.set(line.x + 3, LOOP_STRIP);
text.position.set(line.x + 3, LOOP_STRIP + 2);
used += 1;
}
for (let i = used; i < this.texts.length; i++) this.texts[i]!.visible = false;
+24 -1
View File
@@ -12,7 +12,9 @@ export interface TimeView {
pxPerTick: number;
}
export const RULER_HEIGHT = 22;
export const RULER_HEIGHT = 30;
/** The strip along the top of the ruler that holds the loop; a drag there sets it, below moves the start. */
export const LOOP_STRIP = 10;
/** A bar of 4/4 from 8 pixels to a 1/32 of 60. */
export const MIN_PX_PER_TICK = 0.004;
export const MAX_PX_PER_TICK = 1;
@@ -75,6 +77,27 @@ export function timeLines(view: TimeView, meter: Meter, grid: number, width: num
.sort((a, b) => a.tick - b.tick);
}
export type RulerPart = 'loop' | 'time';
export function rulerPartAt(y: number): RulerPart {
return y < LOOP_STRIP ? 'loop' : 'time';
}
/**
* The loop a drag on the loop strip leaves: from where it was pressed to `at`, or, pressed at an end
* of `loop`, that end moved to `at`. Null where the drag spans nothing.
*/
export function draggedLoop(
loop: { start: number; end: number },
edge: 'start' | 'end' | null,
from: number,
at: number,
): { start: number; end: number } | null {
if (edge === 'start') return { start: Math.min(at, loop.end - 1), end: loop.end };
if (edge === 'end') return { start: loop.start, end: Math.max(at, loop.start + 1) };
return at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) };
}
/** Every `n`-th bar gets its number on the ruler, `n` a power of two so the numbers never overlap. */
export function barLabelStep(view: TimeView, meter: Meter): number {
const barPx = ticksPerBar(meter) * view.pxPerTick;
+15 -2
View File
@@ -43,6 +43,8 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly gridButton = new Dropdown('1/16', { width: GRID_WIDTH });
/** Whether a note drawn, grabbed or moved sounds; off by default. A key down the left always does. */
readonly auditionButton: Button;
readonly music = new Music(() => this.state.project);
/** The pattern playing, `''` while none is. */
private playingId = '';
@@ -59,6 +61,11 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
onClick: () => this.togglePlay(),
});
this.gridButton.onClick = () => this.openGridMenu();
this.auditionButton = new Button('Audition', {
toggle: true,
tooltip: 'Let a note sound when it is drawn, grabbed or moved',
onClick: () => this.auditionButton.setToggled(!this.auditionButton.toggled),
});
const roll = new PianoRoll(state);
roll.onPreview = (note, held) => this.preview(note, held);
roll.onRelease = (voice) => soundLibrary.release(voice);
@@ -66,7 +73,12 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
roll.onStart = (tick) => {
if (this.isPlaying) this.music.current?.seek(tick);
};
this.mount(roll, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
this.mount(roll, [
this.playButton,
new Label('Grid', { style: 'muted' }),
this.gridButton,
this.auditionButton,
]);
state.on('load', () => this.stop());
}
@@ -124,8 +136,9 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
super.destroy(options);
}
/** A key of the roll is held until it is let go; a note drawn or moved sounds for a moment. */
/** A key of the roll is held until it is let go; a note drawn or moved sounds for a moment with Audition on. */
private preview(note: number, held: boolean): number {
if (!held && !this.auditionButton.toggled) return 0;
const asset = this.asset();
const instrument = asset ? patternInstrument(asset.props) : '';
if (!instrument) return 0;
+50 -31
View File
@@ -24,8 +24,11 @@ import { crispText, drawBand, Menu, type Size, textStyle, Theme, ui, Widget, zoo
import { TimeRuler } from '../music/TimeRuler';
import {
clampTimePan,
draggedLoop,
fitTime,
LOOP_STRIP,
RULER_HEIGHT,
rulerPartAt,
tickToX,
timeLines,
xToTick,
@@ -83,15 +86,15 @@ type Gesture =
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
| { kind: 'key'; note: number; voice: number }
| {
kind: 'ruler';
kind: 'loop';
from: number;
pointerX: number;
active: boolean;
loop: LoopRange | null;
free: boolean;
/** The end of the loop the press was at: a drag moves it, a click is a click all the same. */
/** The end of the loop the press was at: a drag moves it. */
edge: 'start' | 'end' | null;
}
| { kind: 'cursor'; tick: number }
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean; free: boolean }
| {
kind: 'move';
@@ -119,7 +122,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
* 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
* right-click or Delete. The ruler sets where playing starts, and a drag on it the loop.
* 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.
*/
export class PianoRoll extends Widget {
private readonly bg = new Graphics();
@@ -386,6 +390,12 @@ export class PianoRoll extends Widget {
return { x: at.x + KEYS_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
}
/** A tick on the strip of the ruler that holds the loop. */
loopPoint(tick: number): Vec2 {
const at = this.getGlobalPosition();
return { x: at.x + KEYS_WIDTH + tickToX(this.view, tick), y: at.y + LOOP_STRIP / 2 };
}
keyPoint(note: number): Vec2 {
const at = this.getGlobalPosition();
return {
@@ -480,19 +490,20 @@ export class PianoRoll extends Widget {
if (area === 'keys') {
const note = noteAtY(this.view, g.y);
if (note !== null) this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) };
} else if (area === 'ruler') {
} else if (area === 'ruler' && rulerPartAt(p.y) === 'loop') {
const loop = patternLoop(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',
kind: 'loop',
from: Math.max(0, tick),
pointerX: g.x,
active: false,
loop: null,
free: e.altKey,
edge,
};
} else if (area === 'ruler') {
this.gesture = { kind: 'cursor', tick: this.cursorTick(tick, patternGrid(asset.props), e.altKey) };
} else if (area === 'grid') {
this.pressGrid(asset, e, g);
} else if (area === 'lane') {
@@ -552,21 +563,16 @@ export class PianoRoll extends Widget {
this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) };
break;
}
case 'ruler': {
gesture.free = free;
case 'loop': {
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 } = patternLoop(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) };
}
gesture.loop = draggedLoop(patternLoop(asset), gesture.edge, snap(gesture.from), snap(tick));
break;
}
case 'cursor':
gesture.tick = this.cursorTick(tick, grid, free);
break;
case 'empty': {
gesture.current = g;
gesture.free = free;
@@ -613,11 +619,14 @@ export class PianoRoll extends Widget {
case 'key':
this.onRelease(gesture.voice);
break;
case 'ruler':
if (gesture.active) {
const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset));
if (gesture.loop && !same) this.state.execute(new SetPatternLoopCommand(asset.id, gesture.loop));
} else this.setStart(gesture.free ? Math.round(gesture.from) : snapDown(gesture.from, grid));
case 'loop': {
const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset));
if (gesture.active && gesture.loop && !same)
this.state.execute(new SetPatternLoopCommand(asset.id, gesture.loop));
break;
}
case 'cursor':
this.setStart(gesture.tick);
break;
case 'empty':
if (gesture.active) {
@@ -693,6 +702,11 @@ export class PianoRoll extends Widget {
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. */
private cursorTick(tick: number, grid: number, free: boolean): number {
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid));
}
private setStart(tick: number): void {
this.startTick = Math.max(0, tick);
this.onStart(this.startTick);
@@ -725,12 +739,12 @@ export class PianoRoll extends Widget {
const local = this.toLocal(e.global);
const area = this.areaAt({ x: local.x, y: local.y });
const g = this.inGrid({ x: local.x, y: local.y });
let view: RollView = this.view;
if (area === 'ruler' || (e.altKey && area !== 'keys'))
view = { ...view, ...zoomTime(view, zoomFactor(e.deltaY), g.x) };
else if (e.altKey) view = zoomRows(view, zoomFactor(e.deltaY), g.y);
else if (e.shiftKey) view = { ...view, panX: view.panX - e.deltaY };
else view = { ...view, panX: view.panX - e.deltaX, panY: view.panY - e.deltaY };
const factor = zoomFactor(e.deltaY);
let view: RollView;
if (area === 'keys') view = zoomRows(this.view, factor, g.y);
else if (area === 'ruler' || area === 'grid' || area === 'lane')
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
else return;
this.view = clampRows(clampTimePan(view), this.gridHeight);
this.needsFit = false;
this.redraw();
@@ -753,10 +767,15 @@ export class PianoRoll extends Widget {
const asset = this.asset();
if (!asset) return null;
const g = this.gesture;
if (g?.kind === 'ruler' && g.active) return g.loop;
if (g?.kind === 'loop' && g.active) return g.loop;
return patternLoop(asset);
}
/** Where playing starts, as a drag on the ruler would leave it. */
private shownStart(): number {
return this.gesture?.kind === 'cursor' ? this.gesture.tick : this.startTick;
}
/** The notes whose rows are on screen, highest first. */
private visibleNotes(): number[] {
const top = noteAtY(this.view, 0) ?? HIGHEST_NOTE;
@@ -832,12 +851,12 @@ export class PianoRoll extends Widget {
this.ruler.draw(this.view, patternMeter(asset.props), gw, {
length,
loop: this.shownLoop(),
start: this.startTick,
start: this.shownStart(),
playhead: this.playhead,
});
const g = this.gesture;
if (g?.kind === 'empty' && g.active) drawBand(this.gridOverlay, g.start, g.current);
const start = tickToX(this.view, this.startTick);
const start = tickToX(this.view, this.shownStart());
if (start >= 0 && start <= gw) this.gridOverlay.rect(start, 0, 1, gh).fill({ color: c.text, alpha: 0.3 });
if (this.playhead !== null) {
const x = tickToX(this.view, this.playhead);
+186 -98
View File
@@ -23,7 +23,7 @@ import {
TRACK_MAX_DB,
TRACK_MIN_DB,
} from '../../core/music/song';
import { secondsOfTicks, ticksPerBar } from '../../core/music/time';
import { ticksOfSeconds, 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';
@@ -46,8 +46,11 @@ import { soundLibrary } from '../../viewport/SoundLibrary';
import { TimeRuler } from '../music/TimeRuler';
import {
clampTimePan,
draggedLoop,
fitTime,
LOOP_STRIP,
RULER_HEIGHT,
rulerPartAt,
tickToX,
timeLines,
xToTick,
@@ -75,20 +78,27 @@ import {
clampTracks,
HEAD_BOXES,
HEAD_WIDTH,
EDGE_REACH,
type HeadPart,
headPartAt,
heldSongView,
indexOf,
itemAt,
itemBox,
type ItemPart,
itemsWithin,
laneTick,
loopSeams,
nearestTrack,
peakOver,
SLIDERS_MIN_HEIGHT,
sliderShare,
sliderValue,
type SongView,
TRACK_HEIGHT,
trackAtY,
trackTop,
zoomTracks,
} from './songView';
const DRAG_THRESHOLD = 3;
@@ -118,15 +128,15 @@ type Area = 'corner' | 'ruler' | 'heads' | 'lanes';
type Gesture =
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
| {
kind: 'ruler';
kind: 'loop';
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: 'cursor'; tick: number }
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean }
| {
kind: 'move';
anchor: SongItem;
@@ -138,7 +148,6 @@ type Gesture =
}
| { 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. */
@@ -153,9 +162,10 @@ interface Drop {
/**
* 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.
* by its body, lengthened by its right end and cut back by its left one, and goes with Delete or its
* menu. 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; a click on nothing picks the song. The wheel zooms: the time
* over the lanes, the tracks over their heads.
*/
export class SongTimeline extends Widget {
private readonly bg = new Graphics();
@@ -173,11 +183,13 @@ export class SongTimeline extends Widget {
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 view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
private needsFit = true;
private assetId = '';
private gesture: Gesture | null = null;
private drop: Drop | null = null;
/** The part of an item under the pointer while nothing is held. */
private hover: { id: string; part: ItemPart } | null = null;
/** Where playing starts and a paste lands, in ticks. */
private startTick = 0;
/** What Ctrl+C took, for any song of the project. */
@@ -248,7 +260,7 @@ export class SongTimeline extends Widget {
}
restoreView(view: unknown): void {
this.view = { ...(view as SongView) };
this.view = heldSongView(view as Partial<SongView>);
this.needsFit = false;
this.redraw();
}
@@ -261,7 +273,11 @@ export class SongTimeline extends Widget {
return;
}
this.needsFit = false;
this.view = { ...fitTime(songLength(asset), this.lanesWidth), panY: 0 };
this.view = {
...fitTime(songLength(asset), this.lanesWidth),
panY: 0,
trackHeight: this.view.trackHeight,
};
this.redraw();
}
@@ -446,7 +462,7 @@ export class SongTimeline extends Widget {
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,
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + this.view.trackHeight / 2,
};
}
@@ -485,11 +501,19 @@ export class SongTimeline extends Widget {
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
}
/** A tick on the strip of the ruler that holds the loop. */
loopPoint(tick: number): Vec2 {
const at = this.getGlobalPosition();
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + LOOP_STRIP / 2 };
}
viewInfo(): SongView & {
start: number;
playhead: number | null;
loop: LoopRange | null;
drop: Drop | null;
hover: { id: string; part: ItemPart } | null;
cursor: string;
} {
return {
...this.view,
@@ -497,11 +521,19 @@ export class SongTimeline extends Widget {
playhead: this.playhead,
loop: this.shownLoop(),
drop: this.drop,
hover: this.hover,
cursor: String(this.cursor ?? ''),
};
}
/** 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 }[] {
/** The items as drawn: their name, what they show, their box on screen and where they start over. */
itemsDrawn(): {
id: string;
label: string;
content: 'notes' | 'wave' | 'none';
box: Box;
seams: number[];
}[] {
const asset = this.asset();
if (!asset) return [];
const rows = indexOf(asset.tracks);
@@ -516,6 +548,7 @@ export class SongTimeline extends Widget {
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 },
seams: this.seamsOf(item),
},
];
});
@@ -563,27 +596,25 @@ export class SongTimeline extends Widget {
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);
}
if (area === 'lanes') this.openItemMenu(asset, g, e.global);
this.invalidate();
return;
}
if (e.button !== 0) return;
if (area === 'ruler') {
if (area === 'ruler' && rulerPartAt(p.y) === 'loop') {
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',
kind: 'loop',
from: Math.max(0, tick),
pointerX: g.x,
active: false,
loop: null,
free: e.altKey,
edge,
};
} else if (area === 'ruler') {
this.gesture = { kind: 'cursor', tick: this.cursorTick(tick, songGrid(asset.props), e.altKey) };
} else if (area === 'lanes') this.pressLanes(asset, e, g);
this.invalidate();
}
@@ -601,7 +632,7 @@ export class SongTimeline extends Widget {
return;
}
if (e.button !== 0) return;
const part = headPartAt(x, y - trackTop(this.view, row));
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
if (part === 'mute' || part === 'solo') {
const label =
part === 'mute'
@@ -634,16 +665,8 @@ export class SongTimeline extends Widget {
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 ?? '',
};
this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey };
return;
}
if (pressCount(e, `song-item:${anchor.id}`) === 2 && anchor.kind === 'pattern') {
@@ -676,7 +699,11 @@ export class SongTimeline extends Widget {
return;
}
const gesture = this.gesture;
if (!gesture || !asset) return;
if (!asset) return;
if (!gesture) {
this.followHover(asset, local, g);
return;
}
const tick = xToTick(this.view, g.x);
const grid = songGrid(asset.props);
const free = e.altKey;
@@ -693,21 +720,16 @@ export class SongTimeline extends Widget {
this.lanesHeight,
);
break;
case 'ruler': {
gesture.free = free;
case 'loop': {
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) };
}
gesture.loop = draggedLoop(songLoop(asset), gesture.edge, snap(gesture.from), snap(tick));
break;
}
case 'cursor':
gesture.tick = this.cursorTick(tick, grid, free);
break;
case 'empty': {
gesture.current = g;
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
@@ -741,9 +763,6 @@ export class SongTimeline extends Widget {
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));
@@ -767,20 +786,25 @@ export class SongTimeline extends Widget {
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));
case 'loop': {
const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset));
if (gesture.active && gesture.loop && !same)
this.state.execute(new SetSongLoopCommand(asset.id, gesture.loop));
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);
}
case 'cursor':
this.setStart(gesture.tick);
break;
case 'empty': {
const caught = gesture.active
? itemsWithin(this.view, asset.items, asset.tracks, gesture.start, gesture.current)
: [];
const kept = gesture.add ? this.pickedIds : [];
if (caught.length > 0 || kept.length > 0) this.pick([...new Set([...kept, ...caught])]);
else this.state.select({ kind: 'asset', assetId: asset.id });
break;
}
case 'move':
if (gesture.moved && (gesture.ticks !== 0 || gesture.tracks !== 0))
this.commit(
@@ -814,16 +838,6 @@ export class SongTimeline extends Widget {
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');
@@ -860,16 +874,20 @@ export class SongTimeline extends Widget {
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);
/** The part of an item under the pointer: an end it can be dragged by shows a resize cursor and lights up. */
private followHover(asset: SongAsset, local: Vec2, g: Vec2): void {
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
const hit = inside ? itemAt(this.view, asset.items, asset.tracks, 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 grid line before it, with Alt on the tick. */
private cursorTick(tick: number, grid: number, free: boolean): number {
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid));
}
private setStart(tick: number): void {
@@ -899,6 +917,14 @@ export class SongTimeline extends Widget {
]).open(global.x, global.y);
}
/** The menu of the item under a right-click, picked first unless it is already. */
private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void {
const hit = itemAt(this.view, asset.items, asset.tracks, g);
if (!hit) return;
if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]);
new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).open(global.x, global.y);
}
private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void {
const track = asset.tracks[row]!;
const swap = (other: number) => {
@@ -937,10 +963,12 @@ export class SongTimeline extends Widget {
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 };
const factor = zoomFactor(e.deltaY);
let view: SongView;
if (area === 'heads') view = zoomTracks(this.view, factor, g.y);
else if (area === 'ruler' || area === 'lanes')
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
else return;
this.view = clampTracks(clampTimePan(view), asset.tracks.length, this.lanesHeight);
this.needsFit = false;
this.redraw();
@@ -963,7 +991,6 @@ export class SongTimeline extends Widget {
);
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;
}
@@ -980,10 +1007,29 @@ export class SongTimeline extends Widget {
const asset = this.asset();
if (!asset) return null;
const g = this.gesture;
if (g?.kind === 'ruler' && g.active) return g.loop;
if (g?.kind === 'loop' && g.active) return g.loop;
return songLoop(asset);
}
/** Where playing starts, as a drag on the ruler would leave it. */
private shownStart(): number {
return this.gesture?.kind === 'cursor' ? this.gesture.tick : this.startTick;
}
/** Where what an item plays starts over inside it: each round of its pattern, or of its sound once loaded. */
private seamsOf(item: SongItem): number[] {
if (item.kind === 'pattern') {
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
return pattern ? loopSeams(item, patternLength(pattern), item.offset) : [];
}
const sound = findAssetOf(this.state.project, item.asset, 'sound');
const seconds = sound?.file ? soundLibrary.duration(sound.file) : null;
const asset = this.asset();
if (!seconds || !asset) return [];
const tempo = songTempo(asset.props);
return loopSeams(item, ticksOfSeconds(seconds, tempo), ticksOfSeconds(item.offset % seconds, tempo));
}
private labelOf(item: SongItem): string {
return findAsset(this.state.project, item.asset)?.name ?? 'missing';
}
@@ -1055,7 +1101,7 @@ export class SongTimeline extends Widget {
this.ruler.draw(this.view, songMeter(asset.props), lw, {
length,
loop: this.shownLoop(),
start: this.startTick,
start: this.shownStart(),
playhead: this.playhead,
});
const g = this.gesture;
@@ -1070,7 +1116,7 @@ export class SongTimeline extends Widget {
.stroke({ width: 1, color: c.accent });
}
}
const start = tickToX(this.view, this.startTick);
const start = tickToX(this.view, this.shownStart());
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);
@@ -1098,12 +1144,13 @@ export class SongTimeline extends Widget {
const c = Theme.colors;
tracks.forEach((track, i) => {
const y = trackTop(this.view, i);
if (y > lh || y + TRACK_HEIGHT < 0) return;
const th = this.view.trackHeight;
if (y > lh || y + th < 0) return;
const lit = track.id === this.pickedTrack && this.pickedIds.length === 0;
this.lanesG
.rect(0, y, lw, TRACK_HEIGHT)
.rect(0, y, lw, th)
.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 });
this.lanesG.rect(0, y + th - 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];
@@ -1140,11 +1187,41 @@ export class SongTimeline extends Widget {
});
if (item.kind === 'pattern') this.drawNotes(item, box, lw);
else this.drawWave(item, box, lw);
this.drawSeams(item, box, lw);
if (this.hover?.id === item.id && this.hover.part !== 'body') this.drawGrip(box, this.hover.part);
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);
}
/** A line where what the item plays starts over, the label strip left clear. */
private drawSeams(item: SongItem, box: Box, lw: number): void {
for (const tick of this.seamsOf(item)) {
const x = Math.round(tickToX(this.view, tick));
if (x <= box.x + 1 || x >= box.x + box.w - 1 || x < 0 || x > lw) continue;
this.itemsG
.rect(x - 1, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP)
.fill({ color: 0x000000, alpha: 0.5 });
this.itemsG.rect(x, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP).fill({ color: 0xffffff, alpha: 0.55 });
this.itemsG
.moveTo(x - 3, box.y + LABEL_STRIP)
.lineTo(x + 4, box.y + LABEL_STRIP)
.lineTo(x + 0.5, box.y + LABEL_STRIP + 4)
.closePath()
.fill({ color: 0xffffff, alpha: 0.8 });
}
}
/** The end of an item the pointer would drag, lit. */
private drawGrip(box: Box, part: 'start' | 'end'): void {
const reach = Math.min(EDGE_REACH, box.w / 3);
const x = part === 'end' ? box.x + box.w - reach : box.x;
this.itemsG.rect(x, box.y, reach, box.h).fill({ color: 0xffffff, alpha: 0.28 });
this.itemsG
.rect(part === 'end' ? box.x + box.w - 2 : box.x, box.y, 2, box.h)
.fill(Theme.colors.accentText);
}
/** 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');
@@ -1174,7 +1251,10 @@ export class SongTimeline extends Widget {
}
}
/** The waveform of a sound item, round and round as it plays, from where it starts in it. */
/**
* The waveform of a sound item, round and round as it plays, from where it starts in it. Each
* round is drawn from its own start in the same columns, so every repeat looks alike.
*/
private drawWave(item: SongItem, box: Box, lw: number): void {
const sound = findAssetOf(this.state.project, item.asset, 'sound');
if (!sound?.file) return;
@@ -1186,16 +1266,24 @@ export class SongTimeline extends Widget {
}
const asset = this.asset()!;
const tempo = songTempo(asset.props);
const period = ticksOfSeconds(seconds, tempo);
const width = tickToX(this.view, period) - tickToX(this.view, 0);
if (!(width > 0)) return;
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 });
const into = ticksOfSeconds(item.offset % seconds, tempo);
const firstRound = Math.floor((xToTick(this.view, from) - item.tick + into) / period);
for (let round = firstRound; ; round++) {
const start = Math.round(tickToX(this.view, item.tick - into + round * period));
if (start >= to) break;
for (let dx = Math.max(0, Math.ceil((from - start) / 2) * 2); dx < width; dx += 2) {
const x = start + dx;
if (x >= to) break;
const reach = Math.max(0.5, peakOver(peaks, dx / width, (dx + 2) / width) * half);
this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 });
}
}
}
@@ -1206,10 +1294,9 @@ export class SongTimeline extends Widget {
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 th = this.view.trackHeight;
this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
this.headsG.rect(0, y + th - 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);
@@ -1218,6 +1305,7 @@ export class SongTimeline extends Widget {
{ 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 },
);
if (th < SLIDERS_MIN_HEIGHT) return;
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
this.headsG
.rect(volume.x, y + volume.y, volume.w, volume.h)
+58 -6
View File
@@ -10,10 +10,15 @@ import { type TimeView, tickToX, xToTick } from '../music/timeView';
export interface SongView extends TimeView {
/** Pixels from the lanes' top edge to the first track. */
panY: number;
trackHeight: number;
}
export const HEAD_WIDTH = 176;
export const TRACK_HEIGHT = 52;
export const MIN_TRACK_HEIGHT = 28;
export const MAX_TRACK_HEIGHT = 160;
/** A track lower than this has no room for the volume and pan sliders in its head. */
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. */
@@ -27,19 +32,38 @@ export interface Box {
h: number;
}
/** A view as saved before the tracks could be zoomed has their old height. */
export function heldSongView(view: Partial<SongView>): SongView {
const finite = (value: unknown, fallback: number) =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
return {
panX: finite(view.panX, 0),
pxPerTick: finite(view.pxPerTick, 0.02),
panY: finite(view.panY, 0),
trackHeight: clamp(finite(view.trackHeight, TRACK_HEIGHT), MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT),
};
}
export function trackTop(view: SongView, index: number): number {
return view.panY + index * TRACK_HEIGHT;
return view.panY + index * view.trackHeight;
}
/** The index of the track a height falls on, null below the last one. */
export function trackAtY(view: SongView, count: number, y: number): number | null {
const index = Math.floor((y - view.panY) / TRACK_HEIGHT);
const index = Math.floor((y - view.panY) / view.trackHeight);
return index >= 0 && index < count ? index : null;
}
/** The track a height is nearest to, held to the tracks there are: where a dragged item goes. */
export function nearestTrack(view: SongView, count: number, y: number): number {
return clamp(Math.floor((y - view.panY) / TRACK_HEIGHT), 0, Math.max(0, count - 1));
return clamp(Math.floor((y - view.panY) / view.trackHeight), 0, Math.max(0, count - 1));
}
/** Zooms the tracks about `y`, so the track under the pointer stays under it. */
export function zoomTracks(view: SongView, factor: number, y: number): SongView {
const trackHeight = clamp(view.trackHeight * factor, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT);
const at = (y - view.panY) / view.trackHeight;
return { ...view, trackHeight, panY: y - at * trackHeight };
}
export function itemBox(view: SongView, index: number, item: Pick<SongItem, 'tick' | 'length'>): Box {
@@ -48,7 +72,7 @@ export function itemBox(view: SongView, index: number, item: Pick<SongItem, 'tic
x,
y: trackTop(view, index) + INSET,
w: Math.max(1, tickToX(view, item.tick + item.length) - x),
h: TRACK_HEIGHT - INSET * 2,
h: view.trackHeight - INSET * 2,
};
}
@@ -99,9 +123,35 @@ export function itemsWithin(
.map((item) => item.id);
}
/**
* Where inside an item what it plays starts over, in ticks: every `period` from a start `into`
* ticks before the item, the item's own start left out. None where it is no longer than one round.
*/
export function loopSeams(item: Pick<SongItem, 'tick' | 'length'>, period: number, into: number): number[] {
if (!(period > 0)) return [];
const seams: number[] = [];
const end = item.tick + item.length;
const first = item.tick - (((into % period) + period) % period) + period;
for (let tick = first; tick < end - 1e-6; tick += period) seams.push(tick);
return seams;
}
/**
* The loudest of `peaks` between two shares of a sound (0 to 1): every bucket the stretch touches,
* so a column of a waveform shows what it covers and every round of a loop looks the same.
*/
export function peakOver(peaks: ArrayLike<number>, from: number, to: number): number {
const n = peaks.length;
const first = clamp(Math.floor(from * n), 0, n - 1);
const last = clamp(Math.ceil(to * n) - 1, first, n - 1);
let peak = 0;
for (let i = first; i <= last; i++) peak = Math.max(peak, peaks[i] ?? 0);
return peak;
}
/** Keeps the tracks and the row that adds one in reach: nothing to scroll to above or far below. */
export function clampTracks<V extends SongView>(view: V, count: number, height: number): V {
const content = count * TRACK_HEIGHT + ADD_ROW_HEIGHT;
const content = count * view.trackHeight + ADD_ROW_HEIGHT;
const panY = clamp(view.panY, Math.min(0, height - content), 0);
return panY === view.panY ? view : { ...view, panY };
}
@@ -121,8 +171,10 @@ export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
pan: { x: 116, y: 30, w: 52, h: 12 },
};
export function headPartAt(x: number, yInTrack: number): HeadPart {
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
for (const part of ['mute', 'solo', 'volume', 'pan'] as const) {
if (!sliders && (part === 'volume' || part === 'pan')) continue;
const box = HEAD_BOXES[part];
if (x >= box.x - 2 && x <= box.x + box.w + 2 && yInTrack >= box.y - 3 && yInTrack <= box.y + box.h + 3)
return part;
+13 -2
View File
@@ -22,7 +22,15 @@ import { heldNote } from '../../core/instrument/notes';
import { clipFx, sequenceNeeds } from '../../core/music/needs';
import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern';
import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler';
import { songBuses, songLength, songLoop, songSequence, songTempo } from '../../core/music/song';
import {
dbToGain,
songBuses,
songLength,
songLoop,
songSequence,
songTempo,
SOUND_ITEM_MAX_DB,
} from '../../core/music/song';
import { DRY_VOICE, type FxParams } from '../../core/soundfx/nodes';
import type { Entity } from '../Entity';
import { PhysicsSystem } from '../physics/PhysicsSystem';
@@ -391,7 +399,10 @@ export function playClip(
): number {
const asset = findAssetOf(project, clip.sound, 'sound');
if (!asset?.file) return 0;
const volume = Math.max(0, Math.min(1, volumeOf(resolveAssetProps(project, asset)) * clip.volume));
const volume = Math.max(
0,
Math.min(dbToGain(SOUND_ITEM_MAX_DB), volumeOf(resolveAssetProps(project, asset)) * clip.volume),
);
return audio.playClip(
asset.file,
{ at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus },
+5 -1
View File
@@ -19,12 +19,16 @@ import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument
import type { EaseShare } from '../core/model/animation';
import { type BusLevel, Playbacks, type SequencePlay } from '../core/music/playbacks';
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
import { DEFAULT_TEMPO } from '../core/music/time';
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import type { PitchOptions } from './pitchWorklet';
import pitchWorklet from './pitchWorklet.ts?worker&url';
import { wake } from './wake';
/** A voice is no louder than a sound item of a song turned all the way up. */
const MAX_VOLUME = dbToGain(SOUND_ITEM_MAX_DB);
const DEFAULT_BASE = '/game-maker/data/sounds';
/** Seconds a changed parameter takes to get most of the way there: quick, but no click (#315). */
const GLIDE = 0.03;
@@ -920,7 +924,7 @@ export class SoundLibrary implements AudioSink {
// A clip whose bytes came too late is over already, or starts as far in as it would be by now.
if (clip && clip.end <= when) return;
const gain = context.createGain();
gain.gain.value = Math.max(0, Math.min(1, volume));
gain.gain.value = Math.max(0, Math.min(MAX_VOLUME, volume));
const rings = fx.layers.some(fxRings);
const wiring: Wiring = {
context,
+1 -1
View File
@@ -251,7 +251,7 @@ describe('Play song', () => {
expect([asked?.name, asked?.owner]).toEqual([`song:${f.songId}`, logic.id]);
expect(asked?.play).toMatchObject({
tempo: 90,
length: 4 * BAR,
length: 2 * BAR,
loop: { start: BAR, end: 2 * BAR },
volume: 0.8,
fadeIn: 2,
+8 -5
View File
@@ -71,9 +71,11 @@ describe('the song asset (#334)', () => {
expect(songGrid(f.song().props)).toBe(160);
});
it('is at least four bars long, else up to the bar line after its last item, and loops all of it', () => {
it('is at least a bar long, else up to the bar line after its last item, and loops all of it', () => {
const f = makeSong();
expect(songLength(f.song())).toBe(4 * BAR);
expect(songLength(f.song())).toBe(BAR);
f.lay(f.item({ tick: 0, length: 100 }));
expect(songLength(f.song())).toBe(BAR);
f.lay(f.item({ tick: 4 * BAR, length: 100 }));
expect(songLength(f.song())).toBe(5 * BAR);
expect(songLoop(f.song())).toEqual({ start: 0, end: 5 * BAR });
@@ -86,7 +88,7 @@ describe('the song asset (#334)', () => {
it('keeps tracks and items to their rules on load, and a song survives the round trip', () => {
const tracks = heldSongTracks([{ id: 'a', name: '', volume: 99, pan: -4, mute: 1 }, 'x', { id: 'a' }]);
expect(tracks.map((t) => [t.name, t.volume, t.pan, t.mute])).toEqual([
['Track 1', 6, -1, false],
['Track 1', 12, -1, false],
['Track 2', 0, 0, false],
]);
expect(tracks[1]!.id).not.toBe('a');
@@ -107,12 +109,13 @@ describe('the song asset (#334)', () => {
{ track: 'gone', kind: 'pattern', tick: 0, length: 10 },
{ track: 'a', kind: 'video', tick: 0, length: 10 },
{ track: 'a', kind: 'sound', tick: -1, length: 10 },
{ track: 'a', kind: 'sound', tick: 0, length: 10, offset: 1.25 },
{ track: 'a', kind: 'sound', tick: 0, length: 10, offset: 1.25, volume: 30 },
],
tracks,
);
// A sound item goes up to +12 dB, a pattern item's notes no louder than they are.
expect(items.map((i) => [i.tick, i.length, i.offset, i.volume, i.transpose])).toEqual([
[0, 10, 1.25, 0, 0],
[0, 10, 1.25, 12, 0],
[10, 1, 4, 0, 48],
]);
+15
View File
@@ -3,7 +3,10 @@ import type { PatternNote } from '../../src/core/music/pattern';
import {
barLabelStep,
clampTimePan,
draggedLoop,
fitTime,
LOOP_STRIP,
rulerPartAt,
timeLines,
xToTick,
zoomTime,
@@ -72,6 +75,18 @@ describe('time view of the piano roll (#333)', () => {
expect(barLabelStep({ panX: 0, pxPerTick: 0.01 }, COMMON)).toBe(2);
expect(barLabelStep({ panX: 0, pxPerTick: 0.004 }, COMMON)).toBe(8);
});
it('sets the loop on the strip along the top of the ruler, and the start below it', () => {
expect(rulerPartAt(0)).toBe('loop');
expect(rulerPartAt(LOOP_STRIP - 1)).toBe('loop');
expect(rulerPartAt(LOOP_STRIP)).toBe('time');
const loop = { start: 1920, end: 3840 };
expect(draggedLoop(loop, null, 960, 480)).toEqual({ start: 480, end: 960 });
expect(draggedLoop(loop, null, 960, 960)).toBeNull();
expect(draggedLoop(loop, 'end', 3840, 5760)).toEqual({ start: 1920, end: 5760 });
expect(draggedLoop(loop, 'end', 3840, 0)).toEqual({ start: 1920, end: 1921 });
expect(draggedLoop(loop, 'start', 1920, 9999)).toEqual({ start: 3839, end: 3840 });
});
});
describe('rows of the piano roll (#333)', () => {
+44 -1
View File
@@ -17,10 +17,18 @@ import {
HEAD_BOXES,
headPartAt,
itemAt,
heldSongView,
itemsWithin,
loopSeams,
MAX_TRACK_HEIGHT,
MIN_TRACK_HEIGHT,
peakOver,
SLIDERS_MIN_HEIGHT,
sliderValue,
type SongView,
TRACK_HEIGHT,
trackAtY,
zoomTracks,
} from '../../src/editor/song/songView';
const BAR = 1920;
@@ -113,7 +121,7 @@ describe('moving and sizing items (#334)', () => {
});
describe('where things are in the Song tab (#334)', () => {
const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0 };
const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0, trackHeight: TRACK_HEIGHT };
const items = [item('a', 't1', 0, BAR), item('b', 't2', BAR, BAR)];
it('finds an item and which end of it is under the pointer', () => {
@@ -145,5 +153,40 @@ describe('where things are in the Song tab (#334)', () => {
expect(sliderValue(volume.x, volume, -60, 6, 0.5)).toBe(-60);
expect(sliderValue(volume.x + volume.w, volume, -60, 6, 0.5)).toBe(6);
expect(sliderValue(pan.x + pan.w / 2, pan, -1, 1, 0.05)).toBe(0);
// A head too low for its sliders has none to hit.
expect(headPartAt(volume.x + 10, volume.y + 4, SLIDERS_MIN_HEIGHT - 1)).toBe('name');
expect(headPartAt(mute.x + 4, mute.y + 4, MIN_TRACK_HEIGHT)).toBe('mute');
});
it('zooms the tracks about the pointer, within their bounds, and fills in an old saved view', () => {
const zoomed = zoomTracks({ ...view, panY: -20 }, 2, 150);
expect(zoomed.trackHeight).toBe(2 * TRACK_HEIGHT);
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, 0.01, 0).trackHeight).toBe(MIN_TRACK_HEIGHT);
expect(clampTracks({ ...zoomed, panY: -9999 }, 10, 400).panY).toBe(400 - 10 * 2 * TRACK_HEIGHT - 28);
expect(heldSongView({ panX: -5, pxPerTick: 0.3, panY: -10 })).toEqual({
panX: -5,
pxPerTick: 0.3,
panY: -10,
trackHeight: TRACK_HEIGHT,
});
});
it('marks where an item starts over inside it, from as far in as it starts', () => {
expect(loopSeams({ tick: BAR, length: 3 * BAR }, BAR, 0)).toEqual([2 * BAR, 3 * BAR]);
expect(loopSeams({ tick: BAR, length: 2 * BAR }, BAR, 480)).toEqual([2 * BAR - 480, 3 * BAR - 480]);
expect(loopSeams({ tick: 0, length: BAR }, BAR, 0)).toEqual([]);
expect(loopSeams({ tick: 0, length: 1000 }, 400.5, 0)).toEqual([400.5, 801]);
expect(loopSeams({ tick: 0, length: BAR }, 0, 0)).toEqual([]);
});
it('draws a waveform column as the loudest of every bucket it covers', () => {
const peaks = [0.1, 0.9, 0.2, 0.3];
expect(peakOver(peaks, 0, 0.25)).toBe(0.1);
expect(peakOver(peaks, 0.2, 0.3)).toBe(0.9);
expect(peakOver(peaks, 0.5, 1)).toBe(0.3);
expect(peakOver(peaks, 0.6, 0.61)).toBe(0.2);
expect(peakOver(peaks, 0.99, 1.2)).toBe(0.3);
});
});
+12
View File
@@ -1340,6 +1340,18 @@ describe('clips and tracks of a song (#334)', () => {
expect(library.stats().last).toMatchObject({ at: 2, volume: 0.5, loop: true });
});
it('plays a clip turned up past unity as loud as +12 dB, and no louder', async () => {
const library = new SoundLibrary();
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 2, bus: '' });
await settle();
await arrive();
expect(library.stats().last?.volume).toBe(2);
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 50, bus: '' });
await settle();
await arrive();
expect(library.stats().last?.volume).toBeCloseTo(10 ** (12 / 20), 6);
});
it('fades a clip out at its end and lets it go, a source only once', async () => {
const library = new SoundLibrary();
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 1, bus: '' });
+58 -12
View File
@@ -6,7 +6,8 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
* keys sound where the instrument plays. A click draws a note on the grid, its right end lengthens
* it, 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
* start set on the ruler, the lane sets a velocity, a drag on the ruler sets the loop. Space plays the
* 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
* pattern round its loop through the scheduler, and a note drawn meanwhile is planned without a
* restart. A game plays it through Play pattern, looped or once. Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and
* the sound counters, as in e2e 34.
@@ -92,7 +93,8 @@ export async function pattern(t) {
JSON.stringify(keyed),
);
// 64b. Zoomed in on the ruler, a click draws a 1/16 on the grid line before it, and sounds.
// 64b. Zoomed in on the ruler, a click draws a 1/16 on the grid line before it, and with Audition
// switched on it sounds.
const before = await json('__gm.pattern.view()');
const r0 = await json('__gm.pattern.rulerAt(0)');
await wheel(r0.x, r0.y, -600);
@@ -102,6 +104,14 @@ export async function pattern(t) {
zoomed.pxPerTick > before.pxPerTick * 2 && Math.abs(zoomed.panX) < 1.5,
JSON.stringify({ before, zoomed }),
);
const auditionOff = await json('__gm.pattern.auditionAt()');
await click(auditionOff.x, auditionOff.y);
const auditionOn = await json('__gm.pattern.auditionAt()');
check(
'Audition in the header is off at first and a click switches it on',
auditionOff.toggled === false && auditionOn.toggled === true,
JSON.stringify({ auditionOff, auditionOn }),
);
const heard = await stats();
let p = await json('__gm.pattern.gridAt(250, 62)');
await click(p.x, p.y);
@@ -138,16 +148,22 @@ export async function pattern(t) {
);
const second = lengths.find((n) => n.id !== first)?.id;
// 64d. Its body moves it onto the grid and to another key; with Alt it lands on the tick.
// 64d. Its body moves it onto the grid and to another key, silent with Audition off; with Alt it
// lands on the tick.
await sleep(500);
const audition = await json('__gm.pattern.auditionAt()');
await click(audition.x, audition.y);
const quiet = await stats();
p = await json('__gm.pattern.gridAt(300, 62)');
let to = await json('__gm.pattern.gridAt(780, 60)');
await drag(p.x, p.y, to.x, to.y);
await sleep(300);
const moved = (await notes()).find((n) => n.id === first);
const movedStats = await stats();
check(
'dragging a note moves it to the grid line and the key under the pointer',
moved?.tick === 720 && moved?.note === 60,
JSON.stringify(moved),
'dragging a note moves it to the grid line and the key under the pointer, silent with Audition off',
moved?.tick === 720 && moved?.note === 60 && movedStats.started === quiet.started,
JSON.stringify({ moved, quiet, movedStats }),
);
await sleep(500);
p = await json('__gm.pattern.gridAt(800, 60)');
@@ -237,9 +253,38 @@ export async function pattern(t) {
JSON.stringify({ count: erased.length, velocities: soft.map((n) => n.velocity) }),
);
// 64i. A drag on the ruler sets the loop; its menu takes it back to the whole pattern.
p = await json('__gm.pattern.rulerAt(1920)');
to = await json('__gm.pattern.rulerAt(2880)');
// 64i. A drag on the ruler moves only where playing starts; one on the strip above sets the loop,
// its menu takes it back to the whole pattern. The wheel zooms the time on the grid, the rows on the keys.
p = await json('__gm.pattern.rulerAt(960)');
to = await json('__gm.pattern.rulerAt(2400)');
await drag(p.x + 2, p.y, to.x + 2, to.y);
const scrubbed = await json(
`{ start: __gm.pattern.view().start, loop: __gm.pattern.loop(${JSON.stringify(id)}) }`,
);
const wheelFrom = await json('__gm.pattern.view()');
p = await json('__gm.pattern.gridAt(960, 62)');
await wheel(p.x, p.y, -200);
const gridWheel = await json('__gm.pattern.view()');
p = await json('__gm.pattern.keyAt(62)');
await wheel(p.x, p.y, -200);
const keysWheel = await json('__gm.pattern.view()');
await wheel(p.x, p.y, 200);
p = await json('__gm.pattern.gridAt(960, 62)');
await wheel(p.x, p.y, 200);
check(
'a drag on the ruler moves where playing starts and leaves the loop; the wheel zooms the time on the grid, the rows on the keys',
scrubbed.start === 2400 &&
scrubbed.loop === null &&
gridWheel.pxPerTick > wheelFrom.pxPerTick &&
gridWheel.rowHeight === wheelFrom.rowHeight &&
Math.abs(gridWheel.panY - wheelFrom.panY) < 1e-6 &&
keysWheel.rowHeight > wheelFrom.rowHeight &&
keysWheel.pxPerTick === gridWheel.pxPerTick &&
keysWheel.panX === gridWheel.panX,
JSON.stringify({ scrubbed, wheelFrom, gridWheel, keysWheel }),
);
p = await json('__gm.pattern.loopAt(1920)');
to = await json('__gm.pattern.loopAt(2880)');
await drag(p.x, p.y, to.x, to.y);
const looped = await json(`__gm.pattern.loop(${JSON.stringify(id)})`);
await click(p.x + 20, p.y, 'right');
@@ -259,11 +304,12 @@ export async function pattern(t) {
// 64j. Space plays bar 1 round its loop from the start, each note at its tick on the audio clock;
// a note drawn while it plays is planned in the next round, without starting over.
p = await json('__gm.pattern.rulerAt(1920)');
to = await json('__gm.pattern.rulerAt(0)');
p = await json('__gm.pattern.loopAt(1920)');
to = await json('__gm.pattern.loopAt(0)');
await drag(p.x, p.y, to.x, to.y);
const bar1 = await json(`__gm.pattern.loop(${JSON.stringify(id)})`);
// At the loop's start, but a click: it sets where playing starts and leaves the loop as it is.
// At the loop's start, but on the ruler below it: it sets where playing starts and leaves the loop as it is.
to = await json('__gm.pattern.rulerAt(0)');
await click(to.x + 2, to.y);
await press('Space');
const planned = await waitFor(
+100 -6
View File
@@ -5,8 +5,10 @@ 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
* 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
* copy behind it, Ctrl+E splits it where playing starts, Delete takes it away. A head mutes, solos
* and sets the volume of its track, "+ Track" adds one. Space plays the song through the scheduler:
* 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
* 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 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.
* Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips
@@ -129,7 +131,22 @@ export async function song(t) {
const model = () => json(`__gm.song.model(${JSON.stringify(id)})`);
const tracks = (await model()).tracks.map((track) => track.id);
// 65b. A pattern dragged onto the second track lands on the bar before the pointer, a bar long.
// 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars.
// A pattern dragged onto the second track lands on the bar before the pointer, a bar long.
const fitted = await json('__gm.song.view()');
const area = await json('__gm.song.rect()');
for (let i = 0; i < 30; i++) {
const far = await json(`__gm.song.laneAt(${8 * BAR}, 0)`);
if (far.x < area.x + area.w) break;
const near = await json(`__gm.song.laneAt(0, 0)`);
await t.wheel(near.x + 2, near.y, 200);
}
const zoomedOut = await json('__gm.song.view()');
check(
'an empty song fits a single bar across, and the wheel over the lanes takes the view out',
fitted.pxPerTick > zoomedOut.pxPerTick * 4,
JSON.stringify({ fitted, zoomedOut }),
);
const preview = await dragAsset(PATTERN, await json(`__gm.song.laneAt(${BAR + 400}, 1)`));
let items = (await model()).items;
const riff = items[0];
@@ -240,11 +257,88 @@ export async function song(t) {
p = await json(`__gm.song.itemAt(${JSON.stringify(parts[0].id)}, 'body')`);
await click(p.x, p.y, 'right');
await sleep(150);
const menuDelete = await json("__gm.locate('Delete', true)");
const beforeMenu = (await model()).items.length;
if (menuDelete) await click(menuDelete.x, menuDelete.y);
await sleep(150);
items = (await model()).items;
check(
'Delete and a right-click take items away',
items.length === 2 && items.every((item) => item.id === riff.id || item.id === clip.id),
JSON.stringify(items),
'Delete takes an item away, and a right-click opens a menu whose "Delete" does',
beforeMenu === 3 &&
Boolean(menuDelete) &&
items.length === 2 &&
items.every((item) => item.id === riff.id || item.id === clip.id),
JSON.stringify({ beforeMenu, menuDelete, items }),
);
// 65f-ii. A click on nothing picks the song; a sound item turns up to +12 dB in the inspector, which
// has no delete button any more. An end of an item under the pointer shows it can be dragged,
// and an item longer than what it plays marks where it starts over.
p = await json('__gm.song.laneAt(300, 3)');
await click(p.x, p.y);
const emptyPick = await json('__gm.state.selection');
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'body')`);
await click(p.x, p.y);
await sleep(150);
const volumeField = await json("__gm.inspectorField('Volume (dB)')");
await click(volumeField.x, volumeField.y);
await t.typeText('30');
await press('Enter');
await sleep(150);
const loud = (await model()).items.find((item) => item.id === clip.id);
const deleteButtons = await json("[__gm.locate('Delete item'), __gm.locate('Delete items')]");
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'end')`);
await mouse('mouseMoved', p.x - 1, p.y);
await mouse('mouseMoved', p.x, p.y);
const onEnd = await json('__gm.song.view()');
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'body')`);
await mouse('mouseMoved', p.x, p.y);
const onBody = await json('__gm.song.view()');
const seams = (await json('__gm.song.items()')).find((item) => item.id === riff.id)?.seams;
check(
'a click on nothing picks the song, a sound item goes to +12 dB, an end lights up under the pointer, and a long item marks its rounds',
emptyPick?.kind === 'asset' &&
emptyPick.assetId === id &&
loud?.volume === 12 &&
deleteButtons.every((b) => b === null) &&
onEnd.hover?.id === clip.id &&
onEnd.hover.part === 'end' &&
onEnd.cursor === 'ew-resize' &&
onBody.hover?.part === 'body' &&
onBody.cursor === 'default' &&
seams?.join() === [3 * BAR, 4 * BAR].join(),
JSON.stringify({ emptyPick, loud, deleteButtons, onEnd, onBody, seams }),
);
await shot('01b-hover');
// 65f-iii. The wheel zooms the tracks over their heads and the time over the lanes; a drag on the
// strip above the ruler sets the loop, Ctrl+Z takes it back.
const unzoomed = await json('__gm.song.view()');
p = await json("__gm.song.headAt(0, 'name')");
await t.wheel(p.x, p.y, -200);
const taller = await json('__gm.song.view()');
p = await json(`__gm.song.laneAt(${2 * BAR}, 0)`);
await t.wheel(p.x, p.y, -200);
const wider = await json('__gm.song.view()');
p = await json("__gm.song.headAt(0, 'name')");
await t.wheel(p.x, p.y, 200);
p = await json('__gm.song.fitAt()');
await click(p.x, p.y);
p = await json(`__gm.song.loopAt(${BAR})`);
to = await json(`__gm.song.loopAt(${3 * BAR})`);
await drag(p.x, p.y, to.x, to.y);
const songLoop = (await model()).loop;
await press('z', { ctrl: true });
check(
'the wheel zooms the tracks on their heads and the time on the lanes, and the strip above the ruler sets the loop',
taller.trackHeight > unzoomed.trackHeight &&
taller.pxPerTick === unzoomed.pxPerTick &&
wider.pxPerTick > taller.pxPerTick &&
wider.trackHeight === taller.trackHeight &&
songLoop?.start === BAR &&
songLoop.end === 3 * BAR &&
(await model()).loop === null,
JSON.stringify({ unzoomed, taller, wider, songLoop }),
);
// 65g. The heads: mute and solo toggle, the volume slider sets decibels, "+ Track" adds a track.