Zoom with the wheel and set the loop on its own strip in music tabs
The ruler of the Pattern and Song tabs did two things with one drag,
and the wheel did three things with Alt and Shift. Both now do one
thing each, and the Song tab gets a few edits it was missing.
- The ruler has a loop strip along its top. A drag there sets the
loop, a press or drag below it moves only where playing starts.
- The wheel only zooms: the time over the grid, lanes and ruler, the
rows over the keys, the tracks over their heads. Alt and Shift no
longer change it. A middle-drag still pans.
- Tracks can be zoomed from 28 to 160 px. Below 46 px a head shows
only its name, M and S. An old saved view gets the old height.
- A click on nothing in the lanes picks the song itself.
- A right-click on an item opens a menu with Delete instead of
deleting it. The inspector has no delete buttons any more.
- A sound item goes up to +12 dB, a track up to +12 dB. A pattern
item stays at 0 dB, since a note is held to unity.
- An end of an item under the pointer lights up and shows a resize
cursor. A long item marks every point where it starts over.
- The Pattern tab has an Audition switch in its header, off at first.
Only with it on does a note sound when drawn, grabbed or moved.
The keys down the left sound either way.
- A looped sound item draws every round alike. Each round is drawn
from its own start, and each column shows the loudest part of the
sound it covers instead of one sampled bucket.
- A song is at least one bar long instead of four. An empty song
fits that bar across, and Play song ends after the last item.
- The ghost notes held back in #334 got their own issue, #339.
Finding: suite 542 for f97961c failed 5 game checks in e2e 64 and 65
while my runs on the same Windows machine overlapped it. Modules
60-65 alone without load passed, so this push's suite has to show it.
Tests:
- Unit: song-edit 11, piano-roll 17, song 14, sound-library 67.
Four of them are new, one in song changed.
- e2e 64+65 on melektaus-atx: ALL PASSED - 46 checks (+3).
- e2e 60-65 on melektaus-atx: ALL PASSED - 86 checks.
- Gate on melektaus-atx: 251 files, 3907 tests, CHECK EXIT 0.
- Audition, after the gate: e2e 64 alone ALL PASSED - 25 checks
(+1), transitions and piano-roll 30 tests, typecheck and ESLint
clean.
- Waveform and one bar, after the gate: song, song-edit, song-play
and the node catalog 438 tests. e2e 65 alone ALL PASSED - 25
checks (+1). A probe shot of a looped pop sound: all six rounds
pixel for pixel alike.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f97961c8ff
commit
aa097bd076
19 files changed
+627
-202
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -43,6 +43,8 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
/** Public for the check scripts. */
|
||||
readonly playButton: Button;
|
||||
readonly gridButton = new Dropdown('1/16', { width: GRID_WIDTH });
|
||||
/** Whether a note drawn, grabbed or moved sounds; off by default. A key down the left always does. */
|
||||
readonly auditionButton: Button;
|
||||
readonly music = new Music(() => this.state.project);
|
||||
/** The pattern playing, `''` while none is. */
|
||||
private playingId = '';
|
||||
@@ -59,6 +61,11 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
onClick: () => this.togglePlay(),
|
||||
});
|
||||
this.gridButton.onClick = () => this.openGridMenu();
|
||||
this.auditionButton = new Button('Audition', {
|
||||
toggle: true,
|
||||
tooltip: 'Let a note sound when it is drawn, grabbed or moved',
|
||||
onClick: () => this.auditionButton.setToggled(!this.auditionButton.toggled),
|
||||
});
|
||||
const roll = new PianoRoll(state);
|
||||
roll.onPreview = (note, held) => this.preview(note, held);
|
||||
roll.onRelease = (voice) => soundLibrary.release(voice);
|
||||
@@ -66,7 +73,12 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
roll.onStart = (tick) => {
|
||||
if (this.isPlaying) this.music.current?.seek(tick);
|
||||
};
|
||||
this.mount(roll, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
|
||||
this.mount(roll, [
|
||||
this.playButton,
|
||||
new Label('Grid', { style: 'muted' }),
|
||||
this.gridButton,
|
||||
this.auditionButton,
|
||||
]);
|
||||
state.on('load', () => this.stop());
|
||||
}
|
||||
|
||||
@@ -124,8 +136,9 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
/** A key of the roll is held until it is let go; a note drawn or moved sounds for a moment. */
|
||||
/** A key of the roll is held until it is let go; a note drawn or moved sounds for a moment with Audition on. */
|
||||
private preview(note: number, held: boolean): number {
|
||||
if (!held && !this.auditionButton.toggled) return 0;
|
||||
const asset = this.asset();
|
||||
const instrument = asset ? patternInstrument(asset.props) : '';
|
||||
if (!instrument) return 0;
|
||||
|
||||
@@ -24,8 +24,11 @@ import { crispText, drawBand, Menu, type Size, textStyle, Theme, ui, Widget, zoo
|
||||
import { TimeRuler } from '../music/TimeRuler';
|
||||
import {
|
||||
clampTimePan,
|
||||
draggedLoop,
|
||||
fitTime,
|
||||
LOOP_STRIP,
|
||||
RULER_HEIGHT,
|
||||
rulerPartAt,
|
||||
tickToX,
|
||||
timeLines,
|
||||
xToTick,
|
||||
@@ -83,15 +86,15 @@ type Gesture =
|
||||
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
|
||||
| { kind: 'key'; note: number; voice: number }
|
||||
| {
|
||||
kind: 'ruler';
|
||||
kind: 'loop';
|
||||
from: number;
|
||||
pointerX: number;
|
||||
active: boolean;
|
||||
loop: LoopRange | null;
|
||||
free: boolean;
|
||||
/** The end of the loop the press was at: a drag moves it, a click is a click all the same. */
|
||||
/** The end of the loop the press was at: a drag moves it. */
|
||||
edge: 'start' | 'end' | null;
|
||||
}
|
||||
| { kind: 'cursor'; tick: number }
|
||||
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean; free: boolean }
|
||||
| {
|
||||
kind: 'move';
|
||||
@@ -119,7 +122,8 @@ function mix(a: number, b: number, t: number): number {
|
||||
* The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid
|
||||
* between them and their velocities in a lane below. A click into the grid draws a note, a drag spans
|
||||
* a band that picks notes; a note is dragged by its body, lengthened by its right end, and goes with a
|
||||
* right-click or Delete. The ruler sets where playing starts, and a drag on it the loop.
|
||||
* right-click or Delete. A press or a drag on the ruler moves where playing starts, one on the strip
|
||||
* along its top sets the loop. The wheel zooms: the time over the grid, the rows over the keys.
|
||||
*/
|
||||
export class PianoRoll extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -386,6 +390,12 @@ export class PianoRoll extends Widget {
|
||||
return { x: at.x + KEYS_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
|
||||
}
|
||||
|
||||
/** A tick on the strip of the ruler that holds the loop. */
|
||||
loopPoint(tick: number): Vec2 {
|
||||
const at = this.getGlobalPosition();
|
||||
return { x: at.x + KEYS_WIDTH + tickToX(this.view, tick), y: at.y + LOOP_STRIP / 2 };
|
||||
}
|
||||
|
||||
keyPoint(note: number): Vec2 {
|
||||
const at = this.getGlobalPosition();
|
||||
return {
|
||||
@@ -480,19 +490,20 @@ export class PianoRoll extends Widget {
|
||||
if (area === 'keys') {
|
||||
const note = noteAtY(this.view, g.y);
|
||||
if (note !== null) this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) };
|
||||
} else if (area === 'ruler') {
|
||||
} else if (area === 'ruler' && rulerPartAt(p.y) === 'loop') {
|
||||
const loop = patternLoop(asset);
|
||||
const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH;
|
||||
const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null;
|
||||
this.gesture = {
|
||||
kind: 'ruler',
|
||||
kind: 'loop',
|
||||
from: Math.max(0, tick),
|
||||
pointerX: g.x,
|
||||
active: false,
|
||||
loop: null,
|
||||
free: e.altKey,
|
||||
edge,
|
||||
};
|
||||
} else if (area === 'ruler') {
|
||||
this.gesture = { kind: 'cursor', tick: this.cursorTick(tick, patternGrid(asset.props), e.altKey) };
|
||||
} else if (area === 'grid') {
|
||||
this.pressGrid(asset, e, g);
|
||||
} else if (area === 'lane') {
|
||||
@@ -552,21 +563,16 @@ export class PianoRoll extends Widget {
|
||||
this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) };
|
||||
break;
|
||||
}
|
||||
case 'ruler': {
|
||||
gesture.free = free;
|
||||
case 'loop': {
|
||||
if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return;
|
||||
gesture.active = true;
|
||||
const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid));
|
||||
const at = snap(tick);
|
||||
const { start, end } = patternLoop(asset);
|
||||
if (gesture.edge === 'start') gesture.loop = { start: Math.min(at, end - 1), end };
|
||||
else if (gesture.edge === 'end') gesture.loop = { start, end: Math.max(at, start + 1) };
|
||||
else {
|
||||
const from = snap(gesture.from);
|
||||
gesture.loop = at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) };
|
||||
}
|
||||
gesture.loop = draggedLoop(patternLoop(asset), gesture.edge, snap(gesture.from), snap(tick));
|
||||
break;
|
||||
}
|
||||
case 'cursor':
|
||||
gesture.tick = this.cursorTick(tick, grid, free);
|
||||
break;
|
||||
case 'empty': {
|
||||
gesture.current = g;
|
||||
gesture.free = free;
|
||||
@@ -613,11 +619,14 @@ export class PianoRoll extends Widget {
|
||||
case 'key':
|
||||
this.onRelease(gesture.voice);
|
||||
break;
|
||||
case 'ruler':
|
||||
if (gesture.active) {
|
||||
const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset));
|
||||
if (gesture.loop && !same) this.state.execute(new SetPatternLoopCommand(asset.id, gesture.loop));
|
||||
} else this.setStart(gesture.free ? Math.round(gesture.from) : snapDown(gesture.from, grid));
|
||||
case 'loop': {
|
||||
const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset));
|
||||
if (gesture.active && gesture.loop && !same)
|
||||
this.state.execute(new SetPatternLoopCommand(asset.id, gesture.loop));
|
||||
break;
|
||||
}
|
||||
case 'cursor':
|
||||
this.setStart(gesture.tick);
|
||||
break;
|
||||
case 'empty':
|
||||
if (gesture.active) {
|
||||
@@ -693,6 +702,11 @@ export class PianoRoll extends Widget {
|
||||
if (hit) this.gesture.erased.add(hit.id);
|
||||
}
|
||||
|
||||
/** Where a press or a drag on the ruler puts the start: on the grid line before it, with Alt on the tick. */
|
||||
private cursorTick(tick: number, grid: number, free: boolean): number {
|
||||
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid));
|
||||
}
|
||||
|
||||
private setStart(tick: number): void {
|
||||
this.startTick = Math.max(0, tick);
|
||||
this.onStart(this.startTick);
|
||||
@@ -725,12 +739,12 @@ export class PianoRoll extends Widget {
|
||||
const local = this.toLocal(e.global);
|
||||
const area = this.areaAt({ x: local.x, y: local.y });
|
||||
const g = this.inGrid({ x: local.x, y: local.y });
|
||||
let view: RollView = this.view;
|
||||
if (area === 'ruler' || (e.altKey && area !== 'keys'))
|
||||
view = { ...view, ...zoomTime(view, zoomFactor(e.deltaY), g.x) };
|
||||
else if (e.altKey) view = zoomRows(view, zoomFactor(e.deltaY), g.y);
|
||||
else if (e.shiftKey) view = { ...view, panX: view.panX - e.deltaY };
|
||||
else view = { ...view, panX: view.panX - e.deltaX, panY: view.panY - e.deltaY };
|
||||
const factor = zoomFactor(e.deltaY);
|
||||
let view: RollView;
|
||||
if (area === 'keys') view = zoomRows(this.view, factor, g.y);
|
||||
else if (area === 'ruler' || area === 'grid' || area === 'lane')
|
||||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||||
else return;
|
||||
this.view = clampRows(clampTimePan(view), this.gridHeight);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
@@ -753,10 +767,15 @@ export class PianoRoll extends Widget {
|
||||
const asset = this.asset();
|
||||
if (!asset) return null;
|
||||
const g = this.gesture;
|
||||
if (g?.kind === 'ruler' && g.active) return g.loop;
|
||||
if (g?.kind === 'loop' && g.active) return g.loop;
|
||||
return patternLoop(asset);
|
||||
}
|
||||
|
||||
/** Where playing starts, as a drag on the ruler would leave it. */
|
||||
private shownStart(): number {
|
||||
return this.gesture?.kind === 'cursor' ? this.gesture.tick : this.startTick;
|
||||
}
|
||||
|
||||
/** The notes whose rows are on screen, highest first. */
|
||||
private visibleNotes(): number[] {
|
||||
const top = noteAtY(this.view, 0) ?? HIGHEST_NOTE;
|
||||
@@ -832,12 +851,12 @@ export class PianoRoll extends Widget {
|
||||
this.ruler.draw(this.view, patternMeter(asset.props), gw, {
|
||||
length,
|
||||
loop: this.shownLoop(),
|
||||
start: this.startTick,
|
||||
start: this.shownStart(),
|
||||
playhead: this.playhead,
|
||||
});
|
||||
const g = this.gesture;
|
||||
if (g?.kind === 'empty' && g.active) drawBand(this.gridOverlay, g.start, g.current);
|
||||
const start = tickToX(this.view, this.startTick);
|
||||
const start = tickToX(this.view, this.shownStart());
|
||||
if (start >= 0 && start <= gw) this.gridOverlay.rect(start, 0, 1, gh).fill({ color: c.text, alpha: 0.3 });
|
||||
if (this.playhead !== null) {
|
||||
const x = tickToX(this.view, this.playhead);
|
||||
|
||||
+186
-98
@@ -23,7 +23,7 @@ import {
|
||||
TRACK_MAX_DB,
|
||||
TRACK_MIN_DB,
|
||||
} from '../../core/music/song';
|
||||
import { secondsOfTicks, ticksPerBar } from '../../core/music/time';
|
||||
import { ticksOfSeconds, ticksPerBar } from '../../core/music/time';
|
||||
import type { SongAsset } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||||
@@ -46,8 +46,11 @@ import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import { TimeRuler } from '../music/TimeRuler';
|
||||
import {
|
||||
clampTimePan,
|
||||
draggedLoop,
|
||||
fitTime,
|
||||
LOOP_STRIP,
|
||||
RULER_HEIGHT,
|
||||
rulerPartAt,
|
||||
tickToX,
|
||||
timeLines,
|
||||
xToTick,
|
||||
@@ -75,20 +78,27 @@ import {
|
||||
clampTracks,
|
||||
HEAD_BOXES,
|
||||
HEAD_WIDTH,
|
||||
EDGE_REACH,
|
||||
type HeadPart,
|
||||
headPartAt,
|
||||
heldSongView,
|
||||
indexOf,
|
||||
itemAt,
|
||||
itemBox,
|
||||
type ItemPart,
|
||||
itemsWithin,
|
||||
laneTick,
|
||||
loopSeams,
|
||||
nearestTrack,
|
||||
peakOver,
|
||||
SLIDERS_MIN_HEIGHT,
|
||||
sliderShare,
|
||||
sliderValue,
|
||||
type SongView,
|
||||
TRACK_HEIGHT,
|
||||
trackAtY,
|
||||
trackTop,
|
||||
zoomTracks,
|
||||
} from './songView';
|
||||
|
||||
const DRAG_THRESHOLD = 3;
|
||||
@@ -118,15 +128,15 @@ type Area = 'corner' | 'ruler' | 'heads' | 'lanes';
|
||||
type Gesture =
|
||||
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
|
||||
| {
|
||||
kind: 'ruler';
|
||||
kind: 'loop';
|
||||
from: number;
|
||||
pointerX: number;
|
||||
active: boolean;
|
||||
loop: LoopRange | null;
|
||||
free: boolean;
|
||||
edge: 'start' | 'end' | null;
|
||||
}
|
||||
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean; track: string }
|
||||
| { kind: 'cursor'; tick: number }
|
||||
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean }
|
||||
| {
|
||||
kind: 'move';
|
||||
anchor: SongItem;
|
||||
@@ -138,7 +148,6 @@ type Gesture =
|
||||
}
|
||||
| { kind: 'resize'; anchor: SongItem; by: number }
|
||||
| { kind: 'trim'; anchor: SongItem; by: number }
|
||||
| { kind: 'erase'; erased: Set<string> }
|
||||
| { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number };
|
||||
|
||||
/** An asset dragged in from the panel, where it would land. */
|
||||
@@ -153,9 +162,10 @@ interface Drop {
|
||||
/**
|
||||
* The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the
|
||||
* items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved
|
||||
* by its body, lengthened by its right end and cut back by its left one, and goes with a right-click
|
||||
* or Delete. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's
|
||||
* selection, so the inspector shows it.
|
||||
* by its body, lengthened by its right end and cut back by its left one, and goes with Delete or its
|
||||
* menu. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's
|
||||
* selection, so the inspector shows it; a click on nothing picks the song. The wheel zooms: the time
|
||||
* over the lanes, the tracks over their heads.
|
||||
*/
|
||||
export class SongTimeline extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -173,11 +183,13 @@ export class SongTimeline extends Widget {
|
||||
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
|
||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||
private readonly addLabel = crispText({ text: '+ Track', style: textStyle('muted') });
|
||||
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0 };
|
||||
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
|
||||
private needsFit = true;
|
||||
private assetId = '';
|
||||
private gesture: Gesture | null = null;
|
||||
private drop: Drop | null = null;
|
||||
/** The part of an item under the pointer while nothing is held. */
|
||||
private hover: { id: string; part: ItemPart } | null = null;
|
||||
/** Where playing starts and a paste lands, in ticks. */
|
||||
private startTick = 0;
|
||||
/** What Ctrl+C took, for any song of the project. */
|
||||
@@ -248,7 +260,7 @@ export class SongTimeline extends Widget {
|
||||
}
|
||||
|
||||
restoreView(view: unknown): void {
|
||||
this.view = { ...(view as SongView) };
|
||||
this.view = heldSongView(view as Partial<SongView>);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
}
|
||||
@@ -261,7 +273,11 @@ export class SongTimeline extends Widget {
|
||||
return;
|
||||
}
|
||||
this.needsFit = false;
|
||||
this.view = { ...fitTime(songLength(asset), this.lanesWidth), panY: 0 };
|
||||
this.view = {
|
||||
...fitTime(songLength(asset), this.lanesWidth),
|
||||
panY: 0,
|
||||
trackHeight: this.view.trackHeight,
|
||||
};
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
@@ -446,7 +462,7 @@ export class SongTimeline extends Widget {
|
||||
const at = this.getGlobalPosition();
|
||||
return {
|
||||
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
|
||||
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + TRACK_HEIGHT / 2,
|
||||
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + this.view.trackHeight / 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -485,11 +501,19 @@ export class SongTimeline extends Widget {
|
||||
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
|
||||
}
|
||||
|
||||
/** A tick on the strip of the ruler that holds the loop. */
|
||||
loopPoint(tick: number): Vec2 {
|
||||
const at = this.getGlobalPosition();
|
||||
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + LOOP_STRIP / 2 };
|
||||
}
|
||||
|
||||
viewInfo(): SongView & {
|
||||
start: number;
|
||||
playhead: number | null;
|
||||
loop: LoopRange | null;
|
||||
drop: Drop | null;
|
||||
hover: { id: string; part: ItemPart } | null;
|
||||
cursor: string;
|
||||
} {
|
||||
return {
|
||||
...this.view,
|
||||
@@ -497,11 +521,19 @@ export class SongTimeline extends Widget {
|
||||
playhead: this.playhead,
|
||||
loop: this.shownLoop(),
|
||||
drop: this.drop,
|
||||
hover: this.hover,
|
||||
cursor: String(this.cursor ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
/** The items as drawn: their name, whether they hold something to show, and their box on screen. */
|
||||
itemsDrawn(): { id: string; label: string; content: 'notes' | 'wave' | 'none'; box: Box }[] {
|
||||
/** The items as drawn: their name, what they show, their box on screen and where they start over. */
|
||||
itemsDrawn(): {
|
||||
id: string;
|
||||
label: string;
|
||||
content: 'notes' | 'wave' | 'none';
|
||||
box: Box;
|
||||
seams: number[];
|
||||
}[] {
|
||||
const asset = this.asset();
|
||||
if (!asset) return [];
|
||||
const rows = indexOf(asset.tracks);
|
||||
@@ -516,6 +548,7 @@ export class SongTimeline extends Widget {
|
||||
label: this.labelOf(item),
|
||||
content: this.contentOf(item),
|
||||
box: { x: at.x + HEAD_WIDTH + box.x, y: at.y + RULER_HEIGHT + box.y, w: box.w, h: box.h },
|
||||
seams: this.seamsOf(item),
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -563,27 +596,25 @@ export class SongTimeline extends Widget {
|
||||
if (area === 'heads') return this.pressHead(asset, e, g.y, p.x);
|
||||
if (e.button === 2) {
|
||||
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
||||
if (area === 'lanes') {
|
||||
this.gesture = { kind: 'erase', erased: new Set() };
|
||||
this.erase(asset, g);
|
||||
}
|
||||
if (area === 'lanes') this.openItemMenu(asset, g, e.global);
|
||||
this.invalidate();
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (area === 'ruler') {
|
||||
if (area === 'ruler' && rulerPartAt(p.y) === 'loop') {
|
||||
const loop = songLoop(asset);
|
||||
const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH;
|
||||
const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null;
|
||||
this.gesture = {
|
||||
kind: 'ruler',
|
||||
kind: 'loop',
|
||||
from: Math.max(0, tick),
|
||||
pointerX: g.x,
|
||||
active: false,
|
||||
loop: null,
|
||||
free: e.altKey,
|
||||
edge,
|
||||
};
|
||||
} else if (area === 'ruler') {
|
||||
this.gesture = { kind: 'cursor', tick: this.cursorTick(tick, songGrid(asset.props), e.altKey) };
|
||||
} else if (area === 'lanes') this.pressLanes(asset, e, g);
|
||||
this.invalidate();
|
||||
}
|
||||
@@ -601,7 +632,7 @@ export class SongTimeline extends Widget {
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
const part = headPartAt(x, y - trackTop(this.view, row));
|
||||
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
|
||||
if (part === 'mute' || part === 'solo') {
|
||||
const label =
|
||||
part === 'mute'
|
||||
@@ -634,16 +665,8 @@ export class SongTimeline extends Widget {
|
||||
private pressLanes(asset: SongAsset, e: FederatedPointerEvent, g: Vec2): void {
|
||||
const hit = itemAt(this.view, asset.items, asset.tracks, g);
|
||||
const anchor = hit ? asset.items.find((i) => i.id === hit.id) : undefined;
|
||||
const row = nearestTrack(this.view, asset.tracks.length, g.y);
|
||||
if (!hit || !anchor) {
|
||||
this.gesture = {
|
||||
kind: 'empty',
|
||||
start: g,
|
||||
current: g,
|
||||
active: false,
|
||||
add: e.shiftKey,
|
||||
track: asset.tracks[row]?.id ?? '',
|
||||
};
|
||||
this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey };
|
||||
return;
|
||||
}
|
||||
if (pressCount(e, `song-item:${anchor.id}`) === 2 && anchor.kind === 'pattern') {
|
||||
@@ -676,7 +699,11 @@ export class SongTimeline extends Widget {
|
||||
return;
|
||||
}
|
||||
const gesture = this.gesture;
|
||||
if (!gesture || !asset) return;
|
||||
if (!asset) return;
|
||||
if (!gesture) {
|
||||
this.followHover(asset, local, g);
|
||||
return;
|
||||
}
|
||||
const tick = xToTick(this.view, g.x);
|
||||
const grid = songGrid(asset.props);
|
||||
const free = e.altKey;
|
||||
@@ -693,21 +720,16 @@ export class SongTimeline extends Widget {
|
||||
this.lanesHeight,
|
||||
);
|
||||
break;
|
||||
case 'ruler': {
|
||||
gesture.free = free;
|
||||
case 'loop': {
|
||||
if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return;
|
||||
gesture.active = true;
|
||||
const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid));
|
||||
const at = snap(tick);
|
||||
const { start, end } = songLoop(asset);
|
||||
if (gesture.edge === 'start') gesture.loop = { start: Math.min(at, end - 1), end };
|
||||
else if (gesture.edge === 'end') gesture.loop = { start, end: Math.max(at, start + 1) };
|
||||
else {
|
||||
const from = snap(gesture.from);
|
||||
gesture.loop = at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) };
|
||||
}
|
||||
gesture.loop = draggedLoop(songLoop(asset), gesture.edge, snap(gesture.from), snap(tick));
|
||||
break;
|
||||
}
|
||||
case 'cursor':
|
||||
gesture.tick = this.cursorTick(tick, grid, free);
|
||||
break;
|
||||
case 'empty': {
|
||||
gesture.current = g;
|
||||
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
|
||||
@@ -741,9 +763,6 @@ export class SongTimeline extends Widget {
|
||||
case 'trim':
|
||||
gesture.by = trimDelta(asset.items, picked, gesture.anchor, tick, grid, free, songTempo(asset.props));
|
||||
break;
|
||||
case 'erase':
|
||||
this.erase(asset, g);
|
||||
break;
|
||||
case 'slider':
|
||||
gesture.value = this.sliderAt(gesture.part, local.x);
|
||||
this.onTracksPreview(this.shownTracks(asset));
|
||||
@@ -767,20 +786,25 @@ export class SongTimeline extends Widget {
|
||||
this.gesture = null;
|
||||
if (!gesture || !asset) return;
|
||||
const picked = new Set(this.pickedIds);
|
||||
const grid = songGrid(asset.props);
|
||||
switch (gesture.kind) {
|
||||
case 'ruler':
|
||||
if (gesture.active) {
|
||||
const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset));
|
||||
if (gesture.loop && !same) this.state.execute(new SetSongLoopCommand(asset.id, gesture.loop));
|
||||
} else this.setStart(gesture.free ? Math.round(gesture.from) : snapDown(gesture.from, grid));
|
||||
case 'loop': {
|
||||
const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset));
|
||||
if (gesture.active && gesture.loop && !same)
|
||||
this.state.execute(new SetSongLoopCommand(asset.id, gesture.loop));
|
||||
break;
|
||||
case 'empty':
|
||||
if (gesture.active) {
|
||||
const caught = itemsWithin(this.view, asset.items, asset.tracks, gesture.start, gesture.current);
|
||||
this.pick([...new Set([...(gesture.add ? this.pickedIds : []), ...caught])]);
|
||||
} else if (gesture.track) this.pick([], gesture.track);
|
||||
}
|
||||
case 'cursor':
|
||||
this.setStart(gesture.tick);
|
||||
break;
|
||||
case 'empty': {
|
||||
const caught = gesture.active
|
||||
? itemsWithin(this.view, asset.items, asset.tracks, gesture.start, gesture.current)
|
||||
: [];
|
||||
const kept = gesture.add ? this.pickedIds : [];
|
||||
if (caught.length > 0 || kept.length > 0) this.pick([...new Set([...kept, ...caught])]);
|
||||
else this.state.select({ kind: 'asset', assetId: asset.id });
|
||||
break;
|
||||
}
|
||||
case 'move':
|
||||
if (gesture.moved && (gesture.ticks !== 0 || gesture.tracks !== 0))
|
||||
this.commit(
|
||||
@@ -814,16 +838,6 @@ export class SongTimeline extends Widget {
|
||||
picked.size === 1 ? 'Trim item' : 'Trim items',
|
||||
);
|
||||
break;
|
||||
case 'erase':
|
||||
if (gesture.erased.size > 0) {
|
||||
this.commit(
|
||||
asset,
|
||||
{ items: asset.items.filter((i) => !gesture.erased.has(i.id)) },
|
||||
gesture.erased.size === 1 ? 'Delete item' : 'Delete items',
|
||||
);
|
||||
this.pick(this.pickedIds.filter((id) => !gesture.erased.has(id)));
|
||||
}
|
||||
break;
|
||||
case 'slider': {
|
||||
const tracks = this.shownTracksWith(asset, gesture);
|
||||
this.commit(asset, { tracks }, gesture.part === 'volume' ? 'Set track volume' : 'Set track pan');
|
||||
@@ -860,16 +874,20 @@ export class SongTimeline extends Widget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
private erase(asset: SongAsset, g: Vec2): void {
|
||||
if (this.gesture?.kind !== 'erase') return;
|
||||
const erased = this.gesture.erased;
|
||||
const hit = itemAt(
|
||||
this.view,
|
||||
asset.items.filter((i) => !erased.has(i.id)),
|
||||
asset.tracks,
|
||||
g,
|
||||
);
|
||||
if (hit) erased.add(hit.id);
|
||||
/** The part of an item under the pointer: an end it can be dragged by shows a resize cursor and lights up. */
|
||||
private followHover(asset: SongAsset, local: Vec2, g: Vec2): void {
|
||||
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
|
||||
const hit = inside ? itemAt(this.view, asset.items, asset.tracks, g) : null;
|
||||
const cursor = hit && hit.part !== 'body' ? 'ew-resize' : 'default';
|
||||
if (this.cursor !== cursor) this.cursor = cursor;
|
||||
if (JSON.stringify(hit) === JSON.stringify(this.hover)) return;
|
||||
this.hover = hit;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Where a press or a drag on the ruler puts the start: on the grid line before it, with Alt on the tick. */
|
||||
private cursorTick(tick: number, grid: number, free: boolean): number {
|
||||
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid));
|
||||
}
|
||||
|
||||
private setStart(tick: number): void {
|
||||
@@ -899,6 +917,14 @@ export class SongTimeline extends Widget {
|
||||
]).open(global.x, global.y);
|
||||
}
|
||||
|
||||
/** The menu of the item under a right-click, picked first unless it is already. */
|
||||
private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void {
|
||||
const hit = itemAt(this.view, asset.items, asset.tracks, g);
|
||||
if (!hit) return;
|
||||
if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]);
|
||||
new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).open(global.x, global.y);
|
||||
}
|
||||
|
||||
private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void {
|
||||
const track = asset.tracks[row]!;
|
||||
const swap = (other: number) => {
|
||||
@@ -937,10 +963,12 @@ export class SongTimeline extends Widget {
|
||||
const local = this.toLocal(e.global);
|
||||
const area = this.areaAt({ x: local.x, y: local.y });
|
||||
const g = this.inLanes({ x: local.x, y: local.y });
|
||||
let view: SongView = this.view;
|
||||
if (area === 'ruler' || e.altKey) view = { ...view, ...zoomTime(view, zoomFactor(e.deltaY), g.x) };
|
||||
else if (e.shiftKey) view = { ...view, panX: view.panX - e.deltaY };
|
||||
else view = { ...view, panX: view.panX - e.deltaX, panY: view.panY - e.deltaY };
|
||||
const factor = zoomFactor(e.deltaY);
|
||||
let view: SongView;
|
||||
if (area === 'heads') view = zoomTracks(this.view, factor, g.y);
|
||||
else if (area === 'ruler' || area === 'lanes')
|
||||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||||
else return;
|
||||
this.view = clampTracks(clampTimePan(view), asset.tracks.length, this.lanesHeight);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
@@ -963,7 +991,6 @@ export class SongTimeline extends Widget {
|
||||
);
|
||||
if (g?.kind === 'resize') return resized(asset.items, picked, g.by);
|
||||
if (g?.kind === 'trim') return trimmed(asset.items, picked, g.by, songTempo(asset.props));
|
||||
if (g?.kind === 'erase') return asset.items.filter((i) => !g.erased.has(i.id));
|
||||
return asset.items;
|
||||
}
|
||||
|
||||
@@ -980,10 +1007,29 @@ export class SongTimeline extends Widget {
|
||||
const asset = this.asset();
|
||||
if (!asset) return null;
|
||||
const g = this.gesture;
|
||||
if (g?.kind === 'ruler' && g.active) return g.loop;
|
||||
if (g?.kind === 'loop' && g.active) return g.loop;
|
||||
return songLoop(asset);
|
||||
}
|
||||
|
||||
/** Where playing starts, as a drag on the ruler would leave it. */
|
||||
private shownStart(): number {
|
||||
return this.gesture?.kind === 'cursor' ? this.gesture.tick : this.startTick;
|
||||
}
|
||||
|
||||
/** Where what an item plays starts over inside it: each round of its pattern, or of its sound once loaded. */
|
||||
private seamsOf(item: SongItem): number[] {
|
||||
if (item.kind === 'pattern') {
|
||||
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
|
||||
return pattern ? loopSeams(item, patternLength(pattern), item.offset) : [];
|
||||
}
|
||||
const sound = findAssetOf(this.state.project, item.asset, 'sound');
|
||||
const seconds = sound?.file ? soundLibrary.duration(sound.file) : null;
|
||||
const asset = this.asset();
|
||||
if (!seconds || !asset) return [];
|
||||
const tempo = songTempo(asset.props);
|
||||
return loopSeams(item, ticksOfSeconds(seconds, tempo), ticksOfSeconds(item.offset % seconds, tempo));
|
||||
}
|
||||
|
||||
private labelOf(item: SongItem): string {
|
||||
return findAsset(this.state.project, item.asset)?.name ?? 'missing';
|
||||
}
|
||||
@@ -1055,7 +1101,7 @@ export class SongTimeline extends Widget {
|
||||
this.ruler.draw(this.view, songMeter(asset.props), lw, {
|
||||
length,
|
||||
loop: this.shownLoop(),
|
||||
start: this.startTick,
|
||||
start: this.shownStart(),
|
||||
playhead: this.playhead,
|
||||
});
|
||||
const g = this.gesture;
|
||||
@@ -1070,7 +1116,7 @@ export class SongTimeline extends Widget {
|
||||
.stroke({ width: 1, color: c.accent });
|
||||
}
|
||||
}
|
||||
const start = tickToX(this.view, this.startTick);
|
||||
const start = tickToX(this.view, this.shownStart());
|
||||
if (start >= 0 && start <= lw) this.overlay.rect(start, 0, 1, lh).fill({ color: c.text, alpha: 0.3 });
|
||||
if (this.playhead !== null) {
|
||||
const x = tickToX(this.view, this.playhead);
|
||||
@@ -1098,12 +1144,13 @@ export class SongTimeline extends Widget {
|
||||
const c = Theme.colors;
|
||||
tracks.forEach((track, i) => {
|
||||
const y = trackTop(this.view, i);
|
||||
if (y > lh || y + TRACK_HEIGHT < 0) return;
|
||||
const th = this.view.trackHeight;
|
||||
if (y > lh || y + th < 0) return;
|
||||
const lit = track.id === this.pickedTrack && this.pickedIds.length === 0;
|
||||
this.lanesG
|
||||
.rect(0, y, lw, TRACK_HEIGHT)
|
||||
.rect(0, y, lw, th)
|
||||
.fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 });
|
||||
this.lanesG.rect(0, y + TRACK_HEIGHT - 1, lw, 1).fill({ color: c.border, alpha: 0.8 });
|
||||
this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: 0.8 });
|
||||
});
|
||||
const bottom = Math.min(lh, trackTop(this.view, tracks.length));
|
||||
const alpha = [0.9, 0.45, 0.2];
|
||||
@@ -1140,11 +1187,41 @@ export class SongTimeline extends Widget {
|
||||
});
|
||||
if (item.kind === 'pattern') this.drawNotes(item, box, lw);
|
||||
else this.drawWave(item, box, lw);
|
||||
this.drawSeams(item, box, lw);
|
||||
if (this.hover?.id === item.id && this.hover.part !== 'body') this.drawGrip(box, this.hover.part);
|
||||
if (box.w > 18) labels.push({ text: this.labelOf(item), x: box.x + 4, y: box.y + 1, w: box.w - 8 });
|
||||
}
|
||||
this.syncTexts(this.itemTexts, this.itemLabels, labels);
|
||||
}
|
||||
|
||||
/** A line where what the item plays starts over, the label strip left clear. */
|
||||
private drawSeams(item: SongItem, box: Box, lw: number): void {
|
||||
for (const tick of this.seamsOf(item)) {
|
||||
const x = Math.round(tickToX(this.view, tick));
|
||||
if (x <= box.x + 1 || x >= box.x + box.w - 1 || x < 0 || x > lw) continue;
|
||||
this.itemsG
|
||||
.rect(x - 1, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP)
|
||||
.fill({ color: 0x000000, alpha: 0.5 });
|
||||
this.itemsG.rect(x, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP).fill({ color: 0xffffff, alpha: 0.55 });
|
||||
this.itemsG
|
||||
.moveTo(x - 3, box.y + LABEL_STRIP)
|
||||
.lineTo(x + 4, box.y + LABEL_STRIP)
|
||||
.lineTo(x + 0.5, box.y + LABEL_STRIP + 4)
|
||||
.closePath()
|
||||
.fill({ color: 0xffffff, alpha: 0.8 });
|
||||
}
|
||||
}
|
||||
|
||||
/** The end of an item the pointer would drag, lit. */
|
||||
private drawGrip(box: Box, part: 'start' | 'end'): void {
|
||||
const reach = Math.min(EDGE_REACH, box.w / 3);
|
||||
const x = part === 'end' ? box.x + box.w - reach : box.x;
|
||||
this.itemsG.rect(x, box.y, reach, box.h).fill({ color: 0xffffff, alpha: 0.28 });
|
||||
this.itemsG
|
||||
.rect(part === 'end' ? box.x + box.w - 2 : box.x, box.y, 2, box.h)
|
||||
.fill(Theme.colors.accentText);
|
||||
}
|
||||
|
||||
/** The notes of a pattern item, small, across every round it plays. */
|
||||
private drawNotes(item: SongItem, box: Box, lw: number): void {
|
||||
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
|
||||
@@ -1174,7 +1251,10 @@ export class SongTimeline extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** The waveform of a sound item, round and round as it plays, from where it starts in it. */
|
||||
/**
|
||||
* The waveform of a sound item, round and round as it plays, from where it starts in it. Each
|
||||
* round is drawn from its own start in the same columns, so every repeat looks alike.
|
||||
*/
|
||||
private drawWave(item: SongItem, box: Box, lw: number): void {
|
||||
const sound = findAssetOf(this.state.project, item.asset, 'sound');
|
||||
if (!sound?.file) return;
|
||||
@@ -1186,16 +1266,24 @@ export class SongTimeline extends Widget {
|
||||
}
|
||||
const asset = this.asset()!;
|
||||
const tempo = songTempo(asset.props);
|
||||
const period = ticksOfSeconds(seconds, tempo);
|
||||
const width = tickToX(this.view, period) - tickToX(this.view, 0);
|
||||
if (!(width > 0)) return;
|
||||
const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2;
|
||||
const half = (box.h - LABEL_STRIP - 4) / 2;
|
||||
const from = Math.max(box.x, 0);
|
||||
const to = Math.min(box.x + box.w, lw);
|
||||
for (let x = from; x < to; x += 2) {
|
||||
const tick = xToTick(this.view, x) - item.tick;
|
||||
const at = (item.offset + secondsOfTicks(tick, tempo)) % seconds;
|
||||
const peak = peaks[Math.min(peaks.length - 1, Math.floor((at / seconds) * peaks.length))] ?? 0;
|
||||
const reach = Math.max(0.5, peak * half);
|
||||
this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 });
|
||||
const into = ticksOfSeconds(item.offset % seconds, tempo);
|
||||
const firstRound = Math.floor((xToTick(this.view, from) - item.tick + into) / period);
|
||||
for (let round = firstRound; ; round++) {
|
||||
const start = Math.round(tickToX(this.view, item.tick - into + round * period));
|
||||
if (start >= to) break;
|
||||
for (let dx = Math.max(0, Math.ceil((from - start) / 2) * 2); dx < width; dx += 2) {
|
||||
const x = start + dx;
|
||||
if (x >= to) break;
|
||||
const reach = Math.max(0.5, peakOver(peaks, dx / width, (dx + 2) / width) * half);
|
||||
this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1206,10 +1294,9 @@ export class SongTimeline extends Widget {
|
||||
tracks.forEach((track, i) => {
|
||||
const y = trackTop(this.view, i);
|
||||
const lit = track.id === this.pickedTrack;
|
||||
this.headsG
|
||||
.rect(0, y, HEAD_WIDTH - 1, TRACK_HEIGHT)
|
||||
.fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
|
||||
this.headsG.rect(0, y + TRACK_HEIGHT - 1, HEAD_WIDTH - 1, 1).fill(c.border);
|
||||
const th = this.view.trackHeight;
|
||||
this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
|
||||
this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border);
|
||||
const { name, mute, solo, volume, pan } = HEAD_BOXES;
|
||||
labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w });
|
||||
this.toggle(mute, y, track.mute, MUTE_COLOR);
|
||||
@@ -1218,6 +1305,7 @@ export class SongTimeline extends Widget {
|
||||
{ text: 'M', x: mute.x + 5, y: y + mute.y, w: mute.w, dark: track.mute },
|
||||
{ text: 'S', x: solo.x + 5, y: y + solo.y, w: solo.w, dark: track.solo },
|
||||
);
|
||||
if (th < SLIDERS_MIN_HEIGHT) return;
|
||||
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
|
||||
this.headsG
|
||||
.rect(volume.x, y + volume.y, volume.w, volume.h)
|
||||
|
||||
@@ -10,10 +10,15 @@ import { type TimeView, tickToX, xToTick } from '../music/timeView';
|
||||
export interface SongView extends TimeView {
|
||||
/** Pixels from the lanes' top edge to the first track. */
|
||||
panY: number;
|
||||
trackHeight: number;
|
||||
}
|
||||
|
||||
export const HEAD_WIDTH = 176;
|
||||
export const TRACK_HEIGHT = 52;
|
||||
export const MIN_TRACK_HEIGHT = 28;
|
||||
export const MAX_TRACK_HEIGHT = 160;
|
||||
/** A track lower than this has no room for the volume and pan sliders in its head. */
|
||||
export const SLIDERS_MIN_HEIGHT = 46;
|
||||
/** The row under the last track that adds one. */
|
||||
export const ADD_ROW_HEIGHT = 28;
|
||||
/** How close to an end of an item a press takes that end. */
|
||||
@@ -27,19 +32,38 @@ export interface Box {
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A view as saved before the tracks could be zoomed has their old height. */
|
||||
export function heldSongView(view: Partial<SongView>): SongView {
|
||||
const finite = (value: unknown, fallback: number) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
return {
|
||||
panX: finite(view.panX, 0),
|
||||
pxPerTick: finite(view.pxPerTick, 0.02),
|
||||
panY: finite(view.panY, 0),
|
||||
trackHeight: clamp(finite(view.trackHeight, TRACK_HEIGHT), MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
export function trackTop(view: SongView, index: number): number {
|
||||
return view.panY + index * TRACK_HEIGHT;
|
||||
return view.panY + index * view.trackHeight;
|
||||
}
|
||||
|
||||
/** The index of the track a height falls on, null below the last one. */
|
||||
export function trackAtY(view: SongView, count: number, y: number): number | null {
|
||||
const index = Math.floor((y - view.panY) / TRACK_HEIGHT);
|
||||
const index = Math.floor((y - view.panY) / view.trackHeight);
|
||||
return index >= 0 && index < count ? index : null;
|
||||
}
|
||||
|
||||
/** The track a height is nearest to, held to the tracks there are: where a dragged item goes. */
|
||||
export function nearestTrack(view: SongView, count: number, y: number): number {
|
||||
return clamp(Math.floor((y - view.panY) / TRACK_HEIGHT), 0, Math.max(0, count - 1));
|
||||
return clamp(Math.floor((y - view.panY) / view.trackHeight), 0, Math.max(0, count - 1));
|
||||
}
|
||||
|
||||
/** Zooms the tracks about `y`, so the track under the pointer stays under it. */
|
||||
export function zoomTracks(view: SongView, factor: number, y: number): SongView {
|
||||
const trackHeight = clamp(view.trackHeight * factor, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT);
|
||||
const at = (y - view.panY) / view.trackHeight;
|
||||
return { ...view, trackHeight, panY: y - at * trackHeight };
|
||||
}
|
||||
|
||||
export function itemBox(view: SongView, index: number, item: Pick<SongItem, 'tick' | 'length'>): Box {
|
||||
@@ -48,7 +72,7 @@ export function itemBox(view: SongView, index: number, item: Pick<SongItem, 'tic
|
||||
x,
|
||||
y: trackTop(view, index) + INSET,
|
||||
w: Math.max(1, tickToX(view, item.tick + item.length) - x),
|
||||
h: TRACK_HEIGHT - INSET * 2,
|
||||
h: view.trackHeight - INSET * 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,9 +123,35 @@ export function itemsWithin(
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where inside an item what it plays starts over, in ticks: every `period` from a start `into`
|
||||
* ticks before the item, the item's own start left out. None where it is no longer than one round.
|
||||
*/
|
||||
export function loopSeams(item: Pick<SongItem, 'tick' | 'length'>, period: number, into: number): number[] {
|
||||
if (!(period > 0)) return [];
|
||||
const seams: number[] = [];
|
||||
const end = item.tick + item.length;
|
||||
const first = item.tick - (((into % period) + period) % period) + period;
|
||||
for (let tick = first; tick < end - 1e-6; tick += period) seams.push(tick);
|
||||
return seams;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loudest of `peaks` between two shares of a sound (0 to 1): every bucket the stretch touches,
|
||||
* so a column of a waveform shows what it covers and every round of a loop looks the same.
|
||||
*/
|
||||
export function peakOver(peaks: ArrayLike<number>, from: number, to: number): number {
|
||||
const n = peaks.length;
|
||||
const first = clamp(Math.floor(from * n), 0, n - 1);
|
||||
const last = clamp(Math.ceil(to * n) - 1, first, n - 1);
|
||||
let peak = 0;
|
||||
for (let i = first; i <= last; i++) peak = Math.max(peak, peaks[i] ?? 0);
|
||||
return peak;
|
||||
}
|
||||
|
||||
/** Keeps the tracks and the row that adds one in reach: nothing to scroll to above or far below. */
|
||||
export function clampTracks<V extends SongView>(view: V, count: number, height: number): V {
|
||||
const content = count * TRACK_HEIGHT + ADD_ROW_HEIGHT;
|
||||
const content = count * view.trackHeight + ADD_ROW_HEIGHT;
|
||||
const panY = clamp(view.panY, Math.min(0, height - content), 0);
|
||||
return panY === view.panY ? view : { ...view, panY };
|
||||
}
|
||||
@@ -121,8 +171,10 @@ export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
|
||||
pan: { x: 116, y: 30, w: 52, h: 12 },
|
||||
};
|
||||
|
||||
export function headPartAt(x: number, yInTrack: number): HeadPart {
|
||||
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
|
||||
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
|
||||
for (const part of ['mute', 'solo', 'volume', 'pan'] as const) {
|
||||
if (!sliders && (part === 'volume' || part === 'pan')) continue;
|
||||
const box = HEAD_BOXES[part];
|
||||
if (x >= box.x - 2 && x <= box.x + box.w + 2 && yInTrack >= box.y - 3 && yInTrack <= box.y + box.h + 3)
|
||||
return part;
|
||||
|
||||
Reference in new issue
Block a user