A round of fixes to the Pattern and Song tabs: where a paste lands, what Shift does on a drag, how far the rows zoom, and how an item of a song is sized at its ends. - Shift does what Alt does on a drag: off the grid, on the tick. A Shift click on a note or an item still adds or unpicks it. A Shift press only decides on letting go: no drag toggles, a drag moves. - Ctrl+V lands under the pointer. The earliest copy goes on the grid line before it, the highest note on its row or the topmost item on its track, held to the keyboard or the tracks there are. Off the grid or left of bar 1 it lands at the start of playing as before. - The rows of the piano roll zoom to 64 px instead of 28, the tracks of a song to 320 px instead of 160. - Fit in the Pattern tab zooms the rows as well, so the notes fill the height, no longer held to the default of 14 px. - An item of a song can be pulled out by its left end past the start of what it plays, which then repeats. The offset goes round to the end of a round. A sound not loaded yet still stops at its start. - Both ends of an item snap to the start of a round of what it plays as well as to the grid, whichever is nearer. The end of a round between two ticks is cut to the tick before. A start put within half a tick of a round goes right on it, so it plays from the very beginning. - An offset past a whole round is not folded back to 0, so a pattern that grows later still plays from where the item said. - A middle-drag pans past the first and the last row, as it pans left of bar 1: the highest note or the first track up to half the height down, the other end up to half the height up. The room past them is dark in the piano roll and empty in the song. Tests: - Unit: song-edit 14, piano-roll 19, song 14 tests. New cases for the paste spot, pulling out left, the round snap and Fit. - e2e 64+65 on melektaus-atx: ALL PASSED - 54 checks (+4). New: Shift drag and Shift click, Ctrl+V under the pointer in both tabs, Fit zooming the rows, pulling a pattern out left and snapping a sound to the end of its second round. e2e 64 zooms the rows out again after Fit, so the keys it plays later are on screen. - Gate on melektaus-atx: 251 files, 3912 tests, CHECK EXIT 0. - Then the pan down the rows: e2e 64 on melektaus-atx ALL PASSED - 30 checks. Its pan check now also pans the top row down, half the grid's height, and a click above it draws nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
7.3 KiB
TypeScript
196 lines
7.3 KiB
TypeScript
import type { SongItem, SongTrack } from '../../core/music/song';
|
|
import type { Vec2 } from '../../core/model/Properties';
|
|
import { clamp } from '../../ui';
|
|
import { type TimeView, clampRowPan, tickToX, xToTick } from '../music/timeView';
|
|
|
|
/**
|
|
* The geometry of the Song tab (#334), apart from Pixi so a test can drive it: the tracks stacked
|
|
* down the lanes, `y` measured from the top of the lanes, `x` from their left edge as on the ruler.
|
|
*/
|
|
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 = 320;
|
|
/** A track lower than this has no room for the volume and pan sliders in its head. */
|
|
export const SLIDERS_MIN_HEIGHT = 46;
|
|
/** How close to an end of an item a press takes that end. */
|
|
export const EDGE_REACH = 6;
|
|
const INSET = 2;
|
|
|
|
export interface Box {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
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 * 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) / 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) / 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 {
|
|
const x = tickToX(view, item.tick);
|
|
return {
|
|
x,
|
|
y: trackTop(view, index) + INSET,
|
|
w: Math.max(1, tickToX(view, item.tick + item.length) - x),
|
|
h: view.trackHeight - INSET * 2,
|
|
};
|
|
}
|
|
|
|
export type ItemPart = 'start' | 'body' | 'end';
|
|
|
|
/** The item under a point, the last drawn first, and which part of it: an end within reach, else the body. */
|
|
export function itemAt(
|
|
view: SongView,
|
|
items: readonly SongItem[],
|
|
tracks: readonly SongTrack[],
|
|
p: Vec2,
|
|
): { id: string; part: ItemPart } | null {
|
|
const index = indexOf(tracks);
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
const item = items[i]!;
|
|
const row = index.get(item.track);
|
|
if (row === undefined) continue;
|
|
const box = itemBox(view, row, item);
|
|
if (p.y < box.y || p.y > box.y + box.h || p.x < box.x || p.x > box.x + box.w) continue;
|
|
const reach = Math.min(EDGE_REACH, box.w / 3);
|
|
if (p.x >= box.x + box.w - reach) return { id: item.id, part: 'end' };
|
|
if (p.x <= box.x + reach) return { id: item.id, part: 'start' };
|
|
return { id: item.id, part: 'body' };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** The items a band from `a` to `b` touches. */
|
|
export function itemsWithin(
|
|
view: SongView,
|
|
items: readonly SongItem[],
|
|
tracks: readonly SongTrack[],
|
|
a: Vec2,
|
|
b: Vec2,
|
|
): string[] {
|
|
const index = indexOf(tracks);
|
|
const x0 = Math.min(a.x, b.x);
|
|
const x1 = Math.max(a.x, b.x);
|
|
const y0 = Math.min(a.y, b.y);
|
|
const y1 = Math.max(a.y, b.y);
|
|
return items
|
|
.filter((item) => {
|
|
const row = index.get(item.track);
|
|
if (row === undefined) return false;
|
|
const box = itemBox(view, row, item);
|
|
return box.x <= x1 && box.x + box.w >= x0 && box.y <= y1 && box.y + box.h >= y0;
|
|
})
|
|
.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 in reach: half the height past the first or the last at most. */
|
|
export function clampTracks<V extends SongView>(view: V, count: number, height: number): V {
|
|
const panY = clampRowPan(view.panY, count * view.trackHeight, height);
|
|
return panY === view.panY ? view : { ...view, panY };
|
|
}
|
|
|
|
export function indexOf(tracks: readonly SongTrack[]): Map<string, number> {
|
|
return new Map(tracks.map((t, i) => [t.id, i]));
|
|
}
|
|
|
|
/** The parts of a track's head, in its own pixels: name, mute, solo, volume and pan. */
|
|
export type HeadPart = 'name' | 'mute' | 'solo' | 'volume' | 'pan';
|
|
|
|
export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
|
|
name: { x: 8, y: 4, w: 112, h: 18 },
|
|
mute: { x: 128, y: 5, w: 18, h: 16 },
|
|
solo: { x: 150, y: 5, w: 18, h: 16 },
|
|
volume: { x: 8, y: 30, w: 100, h: 12 },
|
|
pan: { x: 116, y: 30, w: 52, h: 12 },
|
|
};
|
|
|
|
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;
|
|
}
|
|
return 'name';
|
|
}
|
|
|
|
/** A share of a slider's width for a value from `min` to `max`, and back. */
|
|
export function sliderShare(value: number, min: number, max: number): number {
|
|
return clamp((value - min) / (max - min), 0, 1);
|
|
}
|
|
|
|
export function sliderValue(x: number, box: Box, min: number, max: number, step: number): number {
|
|
const share = clamp((x - box.x) / box.w, 0, 1);
|
|
return clamp(Math.round((min + share * (max - min)) / step) * step, min, max);
|
|
}
|
|
|
|
/** The tick under a point of the lanes, never before 0. */
|
|
export function laneTick(view: SongView, x: number): number {
|
|
return Math.max(0, xToTick(view, x));
|
|
}
|