diff --git a/src/core/music/song.ts b/src/core/music/song.ts index 37785510..a6d417ec 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -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), diff --git a/src/editor/debug/patternHooks.ts b/src/editor/debug/patternHooks.ts index 2e717501..22e1fc6e 100644 --- a/src/editor/debug/patternHooks.ts +++ b/src/editor/debug/patternHooks.ts @@ -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], diff --git a/src/editor/debug/songHooks.ts b/src/editor/debug/songHooks.ts index 4cb0b5f6..0950e276 100644 --- a/src/editor/debug/songHooks.ts +++ b/src/editor/debug/songHooks.ts @@ -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 }), diff --git a/src/editor/inspector/songSection.ts b/src/editor/inspector/songSection.ts index 801f78f3..6fb98ada 100644 --- a/src/editor/inspector/songSection.ts +++ b/src/editor/inspector/songSection.ts @@ -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 { diff --git a/src/editor/music/TimeRuler.ts b/src/editor/music/TimeRuler.ts index dceb4f37..14b34de6 100644 --- a/src/editor/music/TimeRuler.ts +++ b/src/editor/music/TimeRuler.ts @@ -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; diff --git a/src/editor/music/timeView.ts b/src/editor/music/timeView.ts index 35a2cb02..31c479d4 100644 --- a/src/editor/music/timeView.ts +++ b/src/editor/music/timeView.ts @@ -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; diff --git a/src/editor/pattern/PatternEditor.ts b/src/editor/pattern/PatternEditor.ts index 35102188..e5646383 100644 --- a/src/editor/pattern/PatternEditor.ts +++ b/src/editor/pattern/PatternEditor.ts @@ -43,6 +43,8 @@ export class PatternEditor extends AssetTabEditor { /** 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 { 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 { 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 { 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; diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts index d7f29f99..5eb32fdf 100644 --- a/src/editor/pattern/PianoRoll.ts +++ b/src/editor/pattern/PianoRoll.ts @@ -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); diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index f172f66d..02846e26 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -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 } | { 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); 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) diff --git a/src/editor/song/songView.ts b/src/editor/song/songView.ts index b677e837..f156c14d 100644 --- a/src/editor/song/songView.ts +++ b/src/editor/song/songView.ts @@ -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 { + 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): Box { @@ -48,7 +72,7 @@ export function itemBox(view: SongView, index: number, item: Pick 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, 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, 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(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> = { 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; diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 25db7646..aa6fb0a8 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -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 }, diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 49ad2499..cbde3b10 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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, diff --git a/tests/core/song-play.test.ts b/tests/core/song-play.test.ts index 3aa40be3..c0faaeaa 100644 --- a/tests/core/song-play.test.ts +++ b/tests/core/song-play.test.ts @@ -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, diff --git a/tests/core/song.test.ts b/tests/core/song.test.ts index 27e90726..682f2f18 100644 --- a/tests/core/song.test.ts +++ b/tests/core/song.test.ts @@ -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], ]); diff --git a/tests/editor/piano-roll.test.ts b/tests/editor/piano-roll.test.ts index 9c5401d5..94244ef5 100644 --- a/tests/editor/piano-roll.test.ts +++ b/tests/editor/piano-roll.test.ts @@ -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)', () => { diff --git a/tests/editor/song-edit.test.ts b/tests/editor/song-edit.test.ts index 54936020..ffbc8616 100644 --- a/tests/editor/song-edit.test.ts +++ b/tests/editor/song-edit.test.ts @@ -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); }); }); diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index fdc83616..80beae70 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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: '' }); diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs index d095a0e0..90b4dea9 100644 --- a/tools/e2e/64-pattern.mjs +++ b/tools/e2e/64-pattern.mjs @@ -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( diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 5c1818c7..c9d07dea 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -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.