diff --git a/src/core/music/ghosts.ts b/src/core/music/ghosts.ts new file mode 100644 index 00000000..ed67a07c --- /dev/null +++ b/src/core/music/ghosts.ts @@ -0,0 +1,115 @@ +/** + * Ghost notes (#339): what else sounds in a song around a pattern item, shown faint in the piano roll + * of its pattern. The item is the one a double-click in the song opened the pattern from, and of it + * the round clicked into; the notes are counted from where that round starts. + */ +import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes'; +import type { SongAsset } from '../model/Asset'; +import { findAssetOf, type Project } from '../model/Project'; +import { patternLength } from './pattern'; +import { firstRound, itemEnd, itemNotes, type SongItem, songMeter } from './song'; +import { ticksPerBar } from './time'; + +/** Which item of which song a pattern is seen in, and which of its rounds. */ +export interface PatternPlace { + songId: string; + itemId: string; + round: number; +} + +export interface GhostNote { + /** From the start of the round, in ticks: before it is below 0. */ + tick: number; + length: number; + /** The row it is drawn on: as it sounds, less the transpose of the item the pattern is seen in. */ + note: number; + track: string; + pattern: string; +} + +export interface Ghosts { + song: string; + track: string; + /** The bar of the song the round is heard from, counted from 1. */ + bar: number; + /** Where the round starts in the song, in ticks. */ + origin: number; + notes: GhostNote[]; +} + +/** How many rounds of its pattern an item plays, the cut ones at its ends included. */ +export function roundsOf(item: SongItem, span: number): number { + return Math.max(1, Math.ceil((itemEnd(item) - firstRound(item, span)) / span)); +} + +/** The round of an item a song tick falls into, held to the rounds it has. */ +export function roundAt(item: SongItem, span: number, tick: number): number { + const round = Math.floor((tick - firstRound(item, span)) / span); + return Math.max(0, Math.min(roundsOf(item, span) - 1, round)); +} + +function patternItem(song: SongAsset, item: SongItem | undefined, patternId: string): item is SongItem { + return ( + item !== undefined && + item.kind === 'pattern' && + item.asset === patternId && + song.tracks.some((t) => t.id === item.track) + ); +} + +/** + * Where a pattern is seen: the place remembered, while its item is still there and still plays the + * pattern; else the one item that plays it, if there is exactly one in all the songs; else nowhere. + */ +export function placeOf(project: Project, patternId: string, remembered?: PatternPlace): PatternPlace | null { + const pattern = findAssetOf(project, patternId, 'pattern'); + if (!pattern) return null; + const span = patternLength(pattern); + if (remembered) { + const song = findAssetOf(project, remembered.songId, 'song'); + const item = song?.items.find((i) => i.id === remembered.itemId); + if (song && patternItem(song, item, patternId)) + return { ...remembered, round: Math.max(0, Math.min(roundsOf(item, span) - 1, remembered.round)) }; + } + const found: PatternPlace[] = []; + for (const song of project.assets) + if (song.type === 'song') + for (const item of song.items) + if (patternItem(song, item, patternId)) found.push({ songId: song.id, itemId: item.id, round: 0 }); + return found.length === 1 ? found[0]! : null; +} + +/** + * The notes of every pattern item of the song but the round itself: the other tracks all along the + * song, on the item's own track the items before and after and its other rounds. Sound items have + * no notes. Null where the place is gone. + */ +export function ghostNotes(project: Project, patternId: string, place: PatternPlace): Ghosts | null { + const pattern = findAssetOf(project, patternId, 'pattern'); + const song = findAssetOf(project, place.songId, 'song'); + const item = song?.items.find((i) => i.id === place.itemId); + if (!pattern || !song || !patternItem(song, item, patternId)) return null; + const span = patternLength(pattern); + const origin = firstRound(item, span) + place.round * span; + const tracks = new Map(song.tracks.map((t) => [t.id, t.name])); + const notes: GhostNote[] = []; + for (const other of song.items) { + const track = tracks.get(other.track); + const source = other.kind === 'pattern' ? findAssetOf(project, other.asset, 'pattern') : undefined; + if (track === undefined || !source) continue; + for (const n of itemNotes(source, other)) { + if (other.id === item.id && n.tick >= origin && n.tick < origin + span) continue; + const note = n.note - item.transpose; + if (note < LOWEST_NOTE || note > HIGHEST_NOTE) continue; + notes.push({ tick: n.tick - origin, length: n.length, note, track, pattern: source.name }); + } + } + notes.sort((a, b) => a.tick - b.tick || a.note - b.note); + return { + song: song.name, + track: tracks.get(item.track) ?? '', + bar: Math.floor(Math.max(origin, item.tick) / ticksPerBar(songMeter(song.props))) + 1, + origin, + notes, + }; +} diff --git a/src/core/music/song.ts b/src/core/music/song.ts index 3ab350da..9356f88f 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -6,7 +6,7 @@ */ import { newId } from '../ids'; import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes'; -import type { SongAsset } from '../model/Asset'; +import type { PatternAsset, SongAsset } from '../model/Asset'; import { type PropertyBag, textOf } from '../model/Properties'; import { findAssetOf, type Project } from '../model/Project'; import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern'; @@ -159,6 +159,33 @@ export function itemFrom(item: SongItem, tick: number, tempo: number): SongItem return { ...item, tick: item.tick + by, length: item.length - by, offset }; } +/** Where the first round of a pattern item starts in the song: before the item, by its offset. */ +export function firstRound(item: SongItem, span: number): number { + return item.tick - (((item.offset % span) + span) % span); +} + +/** + * The notes a pattern item plays, in song ticks: its pattern over and over for as long as the item + * is, moved by its transpose, cut at the item's ends and at the end of each round. + */ +export function itemNotes( + pattern: PatternAsset, + item: SongItem, +): { tick: number; length: number; note: number; velocity: number }[] { + const span = patternLength(pattern); + const end = itemEnd(item); + const out: { tick: number; length: number; note: number; velocity: number }[] = []; + for (let round = firstRound(item, span); round < end; round += span) + for (const n of pattern.notes) { + const tick = round + n.tick; + const note = n.note + item.transpose; + if (n.tick >= span || tick < item.tick || tick >= end || note < LOWEST_NOTE || note > HIGHEST_NOTE) + continue; + out.push({ tick, length: Math.min(n.length, span - n.tick, end - tick), note, velocity: n.velocity }); + } + return out; +} + /** What the scheduler plays of a song: every item that has its asset, on the bus of its track. */ export function songSequence(project: Project, song: SongAsset): Sequence { const notes: SequenceNote[] = []; @@ -184,18 +211,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence { const pattern = findAssetOf(project, item.asset, 'pattern'); const instrument = pattern && (item.instrument || patternInstrument(pattern.props)); if (!pattern || !instrument) continue; - const span = patternLength(pattern); - const end = itemEnd(item); - const into = ((item.offset % span) + span) % span; - for (let round = item.tick - into; round < end; round += span) - for (const n of pattern.notes) { - const tick = round + n.tick; - const note = n.note + item.transpose; - if (n.tick >= span || tick < item.tick || tick >= end || note < LOWEST_NOTE || note > HIGHEST_NOTE) - continue; - const length = Math.min(n.length, span - n.tick, end - tick); - notes.push({ tick, length, note, velocity: n.velocity, instrument, volume, bus }); - } + for (const n of itemNotes(pattern, item)) notes.push({ ...n, instrument, volume, bus }); } notes.sort((a, b) => a.tick - b.tick || a.note - b.note); clips.sort((a, b) => a.tick - b.tick); diff --git a/src/editor/debug/patternHooks.ts b/src/editor/debug/patternHooks.ts index 22e1fc6e..b2705725 100644 --- a/src/editor/debug/patternHooks.ts +++ b/src/editor/debug/patternHooks.ts @@ -33,6 +33,15 @@ export function patternHooks({ state, editor }: HookContext) { ...centreOf(patternEditor.auditionButton), toggled: patternEditor.auditionButton.toggled, }), + /** The ghost notes drawn (#339), where their round starts in the song, and the switch. */ + ghosts: () => ({ + notes: roll.ghostsShown?.notes ?? [], + origin: roll.ghostsShown?.origin ?? null, + source: patternEditor.ghostSource.text, + ...centreOf(patternEditor.ghostsButton), + toggled: patternEditor.ghostsButton.toggled, + enabled: patternEditor.ghostsButton.enabled, + }), 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/music/timeView.ts b/src/editor/music/timeView.ts index 41c168ab..abdaec6c 100644 --- a/src/editor/music/timeView.ts +++ b/src/editor/music/timeView.ts @@ -42,10 +42,11 @@ export const TIME_LEAD = 0.5; /** * Time starts at 0, but it need not stand at the left edge: a pan can take it up to half the area in, - * to look at, with nothing to place left of it. + * to look at, with nothing to place left of it. Where there is something to look at before 0 – a + * song around a pattern (#339) – `earliest` takes that place instead. */ -export function clampTimePan(view: V, width: number): V { - const most = Math.max(0, width * TIME_LEAD); +export function clampTimePan(view: V, width: number, earliest = 0): V { + const most = Math.max(0, width * TIME_LEAD) - Math.min(0, earliest) * view.pxPerTick; return view.panX > most ? { ...view, panX: most } : view; } @@ -75,12 +76,18 @@ export interface TimeLine { } /** - * The lines from `0` to `width` pixels, each tick only at its strongest level. Beats and the grid - * are left out where they would stand closer than a few pixels. + * The lines from `0` to `width` pixels, each tick only at its strongest level, none before tick + * `earliest`. Beats and the grid are left out where they would stand closer than a few pixels. */ -export function timeLines(view: TimeView, meter: Meter, grid: number, width: number): TimeLine[] { +export function timeLines( + view: TimeView, + meter: Meter, + grid: number, + width: number, + earliest = 0, +): TimeLine[] { const levels = new Map(); - const from = Math.max(0, xToTick(view, 0)); + const from = Math.max(earliest, xToTick(view, 0)); const to = xToTick(view, width); const spans: [number, 0 | 1 | 2, number][] = [ [ticksPerBar(meter), 0, 0], diff --git a/src/editor/pattern/PatternEditor.ts b/src/editor/pattern/PatternEditor.ts index e5646383..332f0d49 100644 --- a/src/editor/pattern/PatternEditor.ts +++ b/src/editor/pattern/PatternEditor.ts @@ -5,6 +5,7 @@ import { namedNotes, playedNotes, } from '../../core/instrument/compile'; +import { ghostNotes, placeOf } from '../../core/music/ghosts'; import { GRID_OPTIONS, PATTERN_GRID_KEY, @@ -18,7 +19,7 @@ import type { Asset, PatternAsset } from '../../core/model/Asset'; import { textOf } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; import { playNote } from '../../runtime/systems/behaviorContext'; -import { Button, Dropdown, Label, Menu } from '../../ui'; +import { Button, Dropdown, Label, Menu, type Widget } from '../../ui'; import { Music } from '../../viewport/music'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { AssetTabEditor } from '../AssetTabEditor'; @@ -45,6 +46,10 @@ export class PatternEditor extends AssetTabEditor { 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; + /** Whether the notes the song plays around the pattern are shown (#339); on by default. */ + readonly ghostsButton: Button; + /** Where the ghost notes come from: song, track and bar. */ + readonly ghostSource = new Label('', { style: 'muted' }); readonly music = new Music(() => this.state.project); /** The pattern playing, `''` while none is. */ private playingId = ''; @@ -66,6 +71,18 @@ export class PatternEditor extends AssetTabEditor { tooltip: 'Let a note sound when it is drawn, grabbed or moved', onClick: () => this.auditionButton.setToggled(!this.auditionButton.toggled), }); + this.ghostsButton = new Button('Ghosts', { + toggle: true, + tooltip: () => + this.ghostsButton.enabled + ? 'Show what the song plays around this pattern, faint' + : 'Double-click an item of this pattern in a song to see what plays around it', + onClick: () => { + this.ghostsButton.setToggled(!this.ghostsButton.toggled); + this.refresh(); + }, + }); + this.ghostsButton.setToggled(true); const roll = new PianoRoll(state); roll.onPreview = (note, held) => this.preview(note, held); roll.onRelease = (voice) => soundLibrary.release(voice); @@ -78,6 +95,8 @@ export class PatternEditor extends AssetTabEditor { new Label('Grid', { style: 'muted' }), this.gridButton, this.auditionButton, + this.ghostsButton, + this.ghostSource, ]); state.on('load', () => this.stop()); } @@ -214,6 +233,20 @@ export class PatternEditor extends AssetTabEditor { ); this.canvas.setInstrument(new Set(sounding), namedNotes(instrument.graph)); } else this.canvas.setInstrument(new Set(), new Map()); + this.syncGhosts(asset); this.follow(asset); } + + /** The song item the pattern was opened from – or the only one it lies in – and what plays around it. */ + private syncGhosts(asset: PatternAsset | null): void { + const project = this.state.project; + const place = asset && placeOf(project, asset.id, this.state.patternPlaces.get(asset.id)); + const ghosts = asset && place ? ghostNotes(project, asset.id, place) : null; + this.ghostsButton.setEnabled(ghosts !== null); + this.canvas.setGhosts(this.ghostsButton.toggled ? ghosts : null); + const source = ghosts ? `${ghosts.song} · ${ghosts.track} · bar ${ghosts.bar}` : ''; + if (source === this.ghostSource.text) return; + this.ghostSource.setText(source); + (this.ghostSource.parent as Widget | null)?.layout(); + } } diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts index 00b49007..07ab93ec 100644 --- a/src/editor/pattern/PianoRoll.ts +++ b/src/editor/pattern/PianoRoll.ts @@ -6,6 +6,7 @@ import { type Text, } from 'pixi.js'; import { SetPatternLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands'; +import type { GhostNote, Ghosts } from '../../core/music/ghosts'; import { HIGHEST_NOTE, isSharp, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes'; import { DEFAULT_VELOCITY, @@ -20,7 +21,18 @@ import { ticksPerBar } from '../../core/music/time'; import type { PatternAsset } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; -import { crispText, drawBand, Menu, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui'; +import { + crispText, + drawBand, + Menu, + type Size, + textStyle, + Theme, + tooltips, + ui, + Widget, + zoomFactor, +} from '../../ui'; import { TimeRuler } from '../music/TimeRuler'; import { clampTimePan, @@ -55,6 +67,7 @@ import { clampRows, DEFAULT_ROW_HEIGHT, END_GRIP, + ghostAt, type NoteBox, type NoteHit, noteAt, @@ -82,6 +95,9 @@ const SOFT = 0x2a4a80; const HARD = 0x8fb8ff; /** Notes behind the end of the pattern are kept, but not played: drawn faint. */ const BEYOND_ALPHA = 0.35; +/** What else sounds in the song around the pattern (#339): grey, without an edge, under the notes. */ +const GHOST = 0xa4a9b3; +const GHOST_ALPHA = 0.4; const DARK_LABEL = textStyle('small').clone(); DARK_LABEL.fill = 0x30343c; @@ -177,6 +193,7 @@ export class PianoRoll extends Widget { private clipboard: PatternNote[] = []; private sounding: ReadonlySet = new Set(); private names: ReadonlyMap = new Map(); + private ghosts: Ghosts | null = null; /** Where the playhead stands while the pattern plays, in ticks; null while it does not. */ playhead: number | null = null; /** Starts a note for the keys or for a note drawn or moved; `held` until `onRelease`. Gives back its voice. */ @@ -215,7 +232,10 @@ export class PianoRoll extends Widget { const p = this.toLocal(e.global); this.pointer = { x: p.x, y: p.y }; }); - this.on('pointerleave', () => (this.pointer = null)); + this.on('pointerleave', () => { + this.pointer = null; + tooltips.leave(this); + }); this.onPointerRelease(() => this.onPointerUp()); this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); state.on('project', () => { @@ -281,6 +301,18 @@ export class PianoRoll extends Widget { this.invalidate(); } + /** The notes of the song around the pattern (#339); null for none. */ + setGhosts(ghosts: Ghosts | null): void { + if (JSON.stringify(ghosts) === JSON.stringify(this.ghosts)) return; + this.ghosts = ghosts; + this.view = clampTimePan(this.view, this.gridWidth, this.earliest); + this.invalidate(); + } + + get ghostsShown(): Ghosts | null { + return this.ghosts; + } + get start(): number { return this.startTick; } @@ -478,6 +510,11 @@ export class PianoRoll extends Widget { return Math.max(0, this.w - KEYS_WIDTH); } + /** The first tick there is anything to look at: the start of the song around the pattern, else 0. */ + private get earliest(): number { + return Math.min(0, -(this.ghosts?.origin ?? 0)); + } + private get gridHeight(): number { return Math.max(0, this.h - RULER_HEIGHT - VELOCITY_HEIGHT); } @@ -591,6 +628,7 @@ export class PianoRoll extends Widget { const g = this.inGrid({ x: local.x, y: local.y }); if (!gesture) { this.followHover(asset, local, g); + this.followGhost(local, g, e.global); return; } const tick = xToTick(this.view, g.x); @@ -606,6 +644,7 @@ export class PianoRoll extends Widget { panY: gesture.panY + (local.y - gesture.pointer.y), }, this.gridWidth, + this.earliest, ), this.gridHeight, ); @@ -750,8 +789,8 @@ export class PianoRoll extends Widget { private draw(asset: PatternAsset, at: Vec2, grid: number, free: boolean): void { const note = noteAtY(this.view, at.y); - // Left of tick 0 is there to look at, not to draw on. - if (note === null || xToTick(this.view, at.x) < 0) return; + // Outside the pattern is there to look at, not to draw on. + if (note === null || !this.inside(asset, xToTick(this.view, at.x))) return; const drawn = drawnNote( xToTick(this.view, at.x), note, @@ -783,6 +822,33 @@ export class PianoRoll extends Widget { this.invalidate(); } + private inside(asset: PatternAsset, tick: number): boolean { + return tick >= 0 && tick < patternLength(asset); + } + + /** A ghost note under the pointer, with no note of the pattern over it, tells where it comes from. */ + private followGhost(local: Vec2, g: Vec2, global: Vec2): void { + const asset = this.asset(); + const over = this.ghosts && asset && this.areaAt(local) === 'grid' && !noteAt(this.view, asset.notes, g); + const ghost = over ? ghostAt(this.view, this.ghosts!.notes, g) : null; + if (!ghost) return tooltips.leave(this); + tooltips.point(this, `${ghost.tick}:${ghost.note}`, () => this.ghostTooltip(ghost), global); + } + + private ghostTooltip(ghost: GhostNote) { + const at = this.getGlobalPosition(); + const box = noteBox(this.view, ghost); + return { + text: `${ghost.track} · ${ghost.pattern}`, + anchor: { + x: at.x + KEYS_WIDTH + box.x, + y: at.y + RULER_HEIGHT + box.y, + w: Math.max(2, box.w), + h: box.h, + }, + }; + } + /** Where a paste goes with the pointer over the grid: the grid line before it and its row; else null. */ private pasteSpot(asset: PatternAsset): { tick: number; note: number } | null { const p = this.pointer; @@ -790,8 +856,8 @@ export class PianoRoll extends Widget { const g = this.inGrid(p); const tick = xToTick(this.view, g.x); const note = noteAtY(this.view, g.y); - // Left of tick 0 nothing lands, as nothing is drawn there. - if (note === null || tick < 0) return null; + // Outside the pattern nothing lands, as nothing is drawn there. + if (note === null || !this.inside(asset, tick)) return null; return { tick: snapDown(tick, patternGrid(asset.props)), note }; } @@ -838,7 +904,7 @@ export class PianoRoll extends Widget { 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.gridWidth), this.gridHeight); + this.view = clampRows(clampTimePan(view, this.gridWidth, this.earliest), this.gridHeight); this.needsFit = false; this.redraw(); e.preventDefault(); @@ -939,6 +1005,7 @@ export class PianoRoll extends Widget { const rows = this.visibleNotes(); this.drawRows(rows, gw); this.drawLines(asset, gw, gh, length); + this.drawGhosts(gw, gh); this.drawNotes(notes, length, gw, gh); this.drawLane(notes, length, gw); this.drawKeys(rows); @@ -980,7 +1047,8 @@ export class PianoRoll extends Widget { private drawLines(asset: PatternAsset, gw: number, gh: number, length: number): void { const c = Theme.colors; const alpha = [1, 0.55, 0.25]; - for (const line of timeLines(this.view, patternMeter(asset.props), patternGrid(asset.props), gw)) { + const grid = patternGrid(asset.props); + for (const line of timeLines(this.view, patternMeter(asset.props), grid, gw, this.earliest)) { this.gridG.rect(line.x, 0, 1, gh).fill({ color: c.border, alpha: alpha[line.level] }); if (line.level === 0) this.laneG.rect(line.x, 0, 1, VELOCITY_HEIGHT).fill({ color: c.border, alpha: 0.6 }); @@ -1004,6 +1072,16 @@ export class PianoRoll extends Widget { if (bottom < gh) this.gridG.rect(0, bottom, gw, gh - bottom).fill({ color: 0x000000, alpha: 0.5 }); } + private drawGhosts(gw: number, gh: number): void { + for (const ghost of this.ghosts?.notes ?? []) { + const box = noteBox(this.view, ghost); + if (box.x > gw || box.x + box.w < 0 || box.y > gh || box.y + box.h < 0) continue; + this.gridG + .rect(box.x, box.y + 1, Math.max(2, box.w), Math.max(1, box.h - 1)) + .fill({ color: GHOST, alpha: GHOST_ALPHA }); + } + } + private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void { const c = Theme.colors; for (const note of notes) { diff --git a/src/editor/pattern/rollView.ts b/src/editor/pattern/rollView.ts index df331ebe..d3a2ebc7 100644 --- a/src/editor/pattern/rollView.ts +++ b/src/editor/pattern/rollView.ts @@ -92,6 +92,21 @@ export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec return null; } +/** The ghost note under a point (#339), for its tooltip; where they lie over each other, the one drawn last. */ +export function ghostAt>( + view: RollView, + notes: readonly N[], + point: Vec2, +): N | null { + for (let i = notes.length - 1; i >= 0; i--) { + const box = noteBox(view, notes[i]!); + const w = Math.max(box.w, MIN_HIT_WIDTH); + if (point.x >= box.x && point.x < box.x + w && point.y >= box.y && point.y < box.y + box.h) + return notes[i]!; + } + return null; +} + /** The notes a band between two points touches. */ export function notesWithin(view: RollView, notes: readonly PatternNote[], a: Vec2, b: Vec2): string[] { const left = Math.min(a.x, b.x); diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index 4b3af343..640e590c 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -10,6 +10,8 @@ import { songSequence, songTempo, } from '../../core/music/song'; +import { roundAt } from '../../core/music/ghosts'; +import { patternLength } from '../../core/music/pattern'; import { songBuses } from '../../core/music/songBuses'; import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take'; import { ticksOfSeconds } from '../../core/music/time'; @@ -106,7 +108,17 @@ export class SongEditor extends AssetTabEditor { const asset = this.asset(); if (asset && this.isPlaying) this.music.setBuses(songBuses(this.state.project, { ...asset, tracks })); }; - timeline.onOpenPattern = (patternId) => activateAsset(state, patternId); + timeline.onOpenPattern = (item, tick) => { + const song = this.asset(); + const pattern = findAssetOf(state.project, item.asset, 'pattern'); + if (song && pattern) + state.patternPlaces.set(pattern.id, { + songId: song.id, + itemId: item.id, + round: roundAt(item, patternLength(pattern), tick), + }); + activateAsset(state, item.asset); + }; timeline.onDiscardTake = () => this.stop({ discard: true }); this.mount(timeline, [ this.playButton, diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index ec7c1026..c01fb3b7 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -233,8 +233,8 @@ export class SongTimeline extends Widget { onStart: (tick: number) => void = () => {}; /** The tracks as a slider in a head would leave them: heard at once, written when it lets go. */ onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {}; - /** A pattern item was double-clicked. */ - onOpenPattern: (patternId: string) => void = () => {}; + /** A pattern item was double-clicked, at a tick of the song. */ + onOpenPattern: (item: SongItem, tick: number) => void = () => {}; onDiscardTake: () => void = () => {}; constructor(private readonly state: EditorState) { @@ -723,7 +723,7 @@ export class SongTimeline extends Widget { return; } if (pressCount(e, `song-item:${anchor.id}`) === 2 && anchor.kind === 'pattern') { - this.onOpenPattern(anchor.asset); + this.onOpenPattern(anchor, xToTick(this.view, g.x)); return; } const picked = this.pickedIds; diff --git a/src/editor/state.ts b/src/editor/state.ts index f7a1470b..ff9e2b3e 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -1,4 +1,5 @@ import type { KeyClipboard } from '../core/commands/animationCommands'; +import type { PatternPlace } from '../core/music/ghosts'; import type { EditorClipboard } from './clipboard'; import type { ObjectPick, PickEnd } from './objectPick'; import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation'; @@ -261,6 +262,11 @@ export class EditorState { keyClipboard: KeyClipboard | null = null; /** Objects or graph pieces copied with Ctrl+C (#240). Gone with the project, whose ids it holds. */ clipboard: EditorClipboard | null = null; + /** + * The song item a pattern was last opened from by a double-click, by pattern (#339): where its + * ghost notes come from. For the session only, gone with the project. + */ + readonly patternPlaces = new Map(); /** * What the scene view paints a tilemap with (#128): the tool, the tile and the layer it goes * on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of @@ -305,6 +311,7 @@ export class EditorState { this.selection = null; this.openedValues = null; this.clipboard = null; + this.patternPlaces.clear(); this.saveStatus = 'saved'; this.emit('load'); this.emit('project'); diff --git a/tests/core/ghosts.test.ts b/tests/core/ghosts.test.ts new file mode 100644 index 00000000..ea7f8d37 --- /dev/null +++ b/tests/core/ghosts.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import { SetSongCommand } from '../../src/core/commands/songCommands'; +import { ghostNotes, placeOf, roundAt, roundsOf } from '../../src/core/music/ghosts'; +import { newItem, newTrack, type SongItem } from '../../src/core/music/song'; +import type { SongAsset } from '../../src/core/model/Asset'; +import { makeProject } from '../helpers'; + +const BAR = 1920; + +/** A song with two tracks; "Riff" plays C and E in a bar, "Bass" a whole bar of C2. */ +function makeSong() { + const f = makeProject(); + const create = new CreateAssetCommand('song', 'Tune'); + const riff = new CreateAssetCommand('pattern', 'Riff'); + const bass = new CreateAssetCommand('pattern', 'Bass'); + for (const command of [create, riff, bass]) f.stack.execute(command); + const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset; + song().tracks.push(newTrack(2)); + for (const id of [riff.asset.id, bass.asset.id]) + f.stack.execute(new SetAssetPropertyCommand(id, 'bars', 1)); + f.stack.execute( + new SetPatternNotesCommand(riff.asset.id, [ + { id: 'n1', tick: 0, length: 480, note: 60, velocity: 1 }, + { id: 'n2', tick: 960, length: 1200, note: 64, velocity: 0.5 }, + ]), + ); + f.stack.execute( + new SetPatternNotesCommand(bass.asset.id, [{ id: 'b1', tick: 0, length: BAR, note: 36, velocity: 1 }]), + ); + f.project.assets.push({ id: 'snd', type: 'sound', name: 'Drum loop', props: {}, file: 'loop.wav' }); + const track = (i: number) => song().tracks[i]!.id; + const item = (fields: Partial & Pick): SongItem => ({ + ...newItem({ track: track(0), kind: 'pattern', asset: riff.asset.id, tick: 0, length: BAR }), + ...fields, + }); + const lay = (...items: SongItem[]) => f.stack.execute(new SetSongCommand(song().id, { items })); + return { ...f, song, track, item, lay, riff: riff.asset.id, bass: bass.asset.id }; +} + +/** Riff twice from bar 2 and once more in bar 5 on track 1, a sound in bar 1; Bass for six bars on track 2. */ +function laidOut( + f: ReturnType, + riffFields: Partial = {}, + laterFields: Partial = {}, +) { + const a = f.item({ id: 'a', tick: BAR, length: 2 * BAR, ...riffFields }); + const later = f.item({ id: 'c', tick: 4 * BAR, length: BAR, ...laterFields }); + const sound = f.item({ id: 's', kind: 'sound', asset: 'snd', tick: 0, length: BAR }); + const bass = f.item({ id: 'b', track: f.track(1), asset: f.bass, tick: 0, length: 6 * BAR }); + f.lay(a, later, sound, bass); + return { a, later, bass }; +} + +const brief = (notes: { tick: number; note: number; length: number; track: string; pattern: string }[]) => + notes.map((n) => `${n.tick}:${n.note}:${n.length}:${n.track}:${n.pattern}`); + +describe('ghost notes of a pattern in a song (#339)', () => { + it('shows the other tracks all along the song and the own track around the round, counted from the round', () => { + const f = makeSong(); + laidOut(f); + const ghosts = ghostNotes(f.project, f.riff, { songId: f.song().id, itemId: 'a', round: 1 })!; + expect(ghosts.origin).toBe(2 * BAR); + expect(ghosts.song).toBe('Tune'); + expect(ghosts.track).toBe('Track 1'); + expect(ghosts.bar).toBe(3); + // The round edited (bar 3) is not there; its first round (bar 2) and the item in bar 5 are. + expect(brief(ghosts.notes)).toEqual([ + `${-2 * BAR}:36:${BAR}:Track 2:Bass`, + `${-BAR}:36:${BAR}:Track 2:Bass`, + `${-BAR}:60:480:Track 1:Riff`, + `${-BAR + 960}:64:960:Track 1:Riff`, + `0:36:${BAR}:Track 2:Bass`, + `${BAR}:36:${BAR}:Track 2:Bass`, + `${2 * BAR}:36:${BAR}:Track 2:Bass`, + `${2 * BAR}:60:480:Track 1:Riff`, + `${2 * BAR + 960}:64:960:Track 1:Riff`, + `${3 * BAR}:36:${BAR}:Track 2:Bass`, + ]); + }); + + it('puts a note on the row it sounds with: its own transpose, less that of the item the pattern is seen in', () => { + const f = makeSong(); + laidOut(f, { transpose: 5 }, { transpose: 2 }); + const ghosts = ghostNotes(f.project, f.riff, { songId: f.song().id, itemId: 'a', round: 0 })!; + expect(ghosts.notes.filter((n) => n.pattern === 'Bass').map((n) => n.note)).toEqual(Array(6).fill(31)); + // The item's own second round is moved as far as the round edited: on the pattern's own rows. + expect(ghosts.notes.filter((n) => n.pattern === 'Riff').map((n) => n.note)).toEqual([60, 64, 57, 61]); + }); + + it('counts the rounds of an item from before its start, by its offset', () => { + const item = { + ...newItem({ track: 't', kind: 'pattern', asset: 'p', tick: BAR, length: 2 * BAR }), + offset: 480, + }; + // Rounds start at bar 2 less 480, then a bar on: three of them touch the item. + expect(roundsOf(item, BAR)).toBe(3); + expect(roundAt(item, BAR, BAR + 100)).toBe(0); + expect(roundAt(item, BAR, 2 * BAR - 481)).toBe(0); + expect(roundAt(item, BAR, 2 * BAR - 480)).toBe(1); + expect(roundAt(item, BAR, 9 * BAR)).toBe(2); + expect(roundAt(item, BAR, 0)).toBe(0); + }); + + it('keeps the place remembered while its item plays the pattern, at most at its last round', () => { + const f = makeSong(); + laidOut(f); + const songId = f.song().id; + expect(placeOf(f.project, f.riff, { songId, itemId: 'a', round: 9 })).toEqual({ + songId, + itemId: 'a', + round: 1, + }); + // Without a place remembered: Riff lies twice in the song, so nowhere; Bass once, so there. + expect(placeOf(f.project, f.riff)).toBeNull(); + expect(placeOf(f.project, f.bass)).toEqual({ songId, itemId: 'b', round: 0 }); + }); + + it('falls back to the only item of the pattern once the remembered one is gone or plays another', () => { + const f = makeSong(); + const { later, bass } = laidOut(f); + const songId = f.song().id; + const remembered = { songId, itemId: 'a', round: 1 }; + f.lay(f.item({ id: 'a', tick: BAR, length: 2 * BAR, asset: f.bass }), later, { ...bass, tick: 8 * BAR }); + expect(placeOf(f.project, f.riff, remembered)).toEqual({ songId, itemId: 'c', round: 0 }); + f.lay(later, f.item({ id: 'x', tick: 6 * BAR, length: BAR })); + expect(placeOf(f.project, f.riff, remembered)).toBeNull(); + expect(ghostNotes(f.project, f.riff, remembered)).toBeNull(); + }); +}); diff --git a/tests/editor/piano-roll.test.ts b/tests/editor/piano-roll.test.ts index 8f8b0faa..1114999c 100644 --- a/tests/editor/piano-roll.test.ts +++ b/tests/editor/piano-roll.test.ts @@ -59,6 +59,20 @@ describe('time view of the piano roll (#333)', () => { expect(fitTime(7680, 768)).toEqual({ panX: 0, pxPerTick: 0.1 }); }); + it('pans as far left as a song before the pattern goes, with its lines (#339)', () => { + // The song starts two bars before the pattern: 384 px more than half the width. + expect(clampTimePan({ panX: 5000, pxPerTick: 0.1 }, 600, -3840).panX).toBeCloseTo(684); + expect(clampTimePan({ panX: 500, pxPerTick: 0.1 }, 600, -3840).panX).toBe(500); + expect(clampTimePan({ panX: 5000, pxPerTick: 0.1 }, 600, 960).panX).toBe(300); + const before = { panX: 384, pxPerTick: 0.1 }; + const bars = (earliest?: number) => + timeLines(before, COMMON, 120, 600, earliest) + .filter((l) => l.level === 0) + .map((l) => l.tick); + expect(bars()).toEqual([0, 1920]); + expect(bars(-3840)).toEqual([-3840, -1920, 0, 1920]); + }); + it('draws bars, beats and the grid, each tick at its strongest, the grid only with room', () => { const lines = timeLines(VIEW, COMMON, 120, 200); expect(lines.filter((l) => l.level === 0).map((l) => l.tick)).toEqual([0, 1920]); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 0c2a58d8..0682b0ab 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -493,6 +493,10 @@ export async function song(t) { swapped.pattern && !swapped.song, JSON.stringify(swapped), ); + // The riff a tone up for four bars on the second track: what its ghost notes (#339) show. + await mutate(` + const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + song.items.push({ ...song.items.find((i) => i.kind === 'pattern'), id: 'sg65-tone', track: song.tracks[1].id, tick: 0, length: ${4 * BAR}, offset: 0, transpose: 2 });`); await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); @@ -504,6 +508,53 @@ export async function song(t) { opened.tab === 'pattern' && opened.pattern === PATTERN, JSON.stringify(opened), ); + const songNow = { + ...(await model()), + name: await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`), + }; + const riffNow = songNow.items.find((i) => i.id === riff.id); + const trackName = (trackId) => songNow.tracks.find((tr) => tr.id === trackId).name; + const ghosts = await json('__gm.pattern.ghosts()'); + const tone = ghosts.notes.filter((n) => n.track === trackName(songNow.tracks[1].id)); + const toneTicks = tone.map((n) => n.tick + ghosts.origin); + check( + 'the pattern opened from the song shows the other track faint: each note where it sounds, a tone up, on by default', + ghosts.toggled && + ghosts.enabled && + tone.length === 8 && + toneTicks.every((tick, i) => tick === Math.floor(i / 2) * BAR + (i % 2) * 960) && + tone.every((n, i) => n.note === (i % 2 ? 64 : 60) + 2 - riffNow.transpose && n.pattern === 'Riff 65') && + (ghosts.origin - riffNow.tick + (riffNow.offset % BAR)) % BAR === 0 && + ghosts.source === + `${songNow.name} · ${trackName(riffNow.track)} · bar ${Math.floor(Math.max(ghosts.origin, riffNow.tick) / BAR) + 1}`, + JSON.stringify({ ghosts, riff: riffNow }), + ); + const inside = tone.find((n) => n.tick >= 0 && n.tick < BAR); + p = await json(`__gm.pattern.gridAt(${inside.tick + 100}, ${inside.note})`); + await mouse('mouseMoved', p.x - 2, p.y); + await mouse('mouseMoved', p.x, p.y); + const sign = await waitFor( + () => json('__gm.tooltip()'), + (s) => s !== null, + ); + check( + 'resting on a ghost note tells its track and its pattern', + sign?.text === `${trackName(songNow.tracks[1].id)} · Riff 65`, + JSON.stringify(sign), + ); + await shot('03-ghosts'); + await click(ghosts.x, ghosts.y); + await sleep(150); + const hidden = await json('__gm.pattern.ghosts()'); + await click(ghosts.x, ghosts.y); + await sleep(150); + check( + 'Ghosts hides them and shows them again', + !hidden.toggled && + hidden.notes.length === 0 && + (await json('__gm.pattern.ghosts()')).notes.length === ghosts.notes.length, + JSON.stringify({ hidden: hidden.notes.length, toggled: hidden.toggled }), + ); // 65k. A game plays the song through Play song (#336): the clip once as the intro, then round the // loop of its ruler, each track on a bus inside a master of the playing. The pause of the