Files
feuerwerk/src/editor/song/songView.ts
T
adminandClaude Opus 5.5 b519cd192f Send a track, a pattern item or a whole song through a sound FX (#346)
A sound FX could only be put on a single sound. Now a track, a pattern
item, a Play pattern and a Play song send everything they play through
one chain of it, as an insert in a DAW. A Play note gets one of its own.

- Track: an FX box in the head next to M and S opens a menu of the
  project's sound FX, and the track's inspector has the row too.
  Order: items, track FX, volume, pan.
- Pattern item: Sound FX in its inspector. Its notes play on a bus of
  their own into the track's bus.
- Play pattern and Play song take fx and fx params. The chain sits on
  the master of the playing, after the tracks and before the level and
  the fades. Set sound FX parameters reaches it by the playback.
- Play note takes fx and fx params next to the instrument's params.
  The FX parameters go by "fx:" so the two sets stay apart. Set sound
  FX parameters on a note changes a name both have in both.
- A Pitch on a bus is a new PitchShifter: the phase vocoder of the
  PitchPlayer, fed while it plays. It cannot look ahead and is exactly
  one frame late at every pitch, 2048 samples.
- That latency is made up. The other way of a Mix is held back by a
  delay of the same length. Every note and clip starts as much earlier
  as the buses it passes are late. The scheduler plans that much
  further ahead, and the first note waits that much after Play.
- The Song tab waits for the chains before it plays and lets its buses
  go a moment after Stop. A playing in a game lets them go after its
  echoes and what the shifters still hold.
- songBuses moved to its own file. Kept in song.ts it pulled the FX
  compiler into the node registry through an import cycle.

Decisions of the user: one chain for the whole (1a), FX everywhere
asked for (2a), Set sound FX parameters on playbacks (3a), and a
pitch shifter of its own with exact latency compensation for a Pitch
on a bus.

Tests:
- Unit pitch-shifter (new): 11 green. At 0 semitones the output is
  the input 2048 samples later, off by at most 1.2e-7.
- Unit sound-library: 84 green, 8 new. Counter-checks (no early
  start, no Mix delay, chain made every time, tune misses the master)
  each turned their test red.
- Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1
  new, its counter-check red.
- e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run.
  Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started
  exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz
  and passed a click 2048 samples later.
- Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:15:52 +02:00

197 lines
7.4 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, sound FX (#346), mute, solo, volume and pan. */
export type HeadPart = 'name' | 'fx' | 'mute' | 'solo' | 'volume' | 'pan';
export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
name: { x: 8, y: 4, w: 88, h: 18 },
fx: { x: 102, y: 5, w: 22, h: 16 },
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 ['fx', '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));
}