Files
feuerwerk/src/editor/music/timeView.ts
T
adminandClaude Opus 5.5 84cd36f902
game-maker / gate (push) Successful in 3m49s
game-maker / suite (push) Successful in 15m6s
Bake a pattern or song to Opus, played in its place by a game (#340)
A pattern or song played by a game runs every note through the
scheduler, the instruments and their FX, live. Bake works that out
once, keeps it as a file on the asset, and Play pattern and Play song
play the file instead, as one voice, for as long as nothing it is made
of has changed. The tabs always play live.

- Bake is an icon in the header of the Pattern and the Song tab, lit
  while the bake holds. A click bakes, cancels a bake that runs, or
  offers Bake again and Remove bake. The toolbar shows the progress.
  The info of the tab says "baked" or "bake outdated".
- The bake holds everything up to the output of the pattern or song.
  The sound FX of the node, fx params, volume, fade in, bus, Master
  routing and stopping with a fade stay live.
- A print of the worked-out plan decides whether it holds: notes and
  clips, tempo, length, loop, tracks, the build of each instrument for
  each note, sound FX, files, cuts and levels. A rename keeps it, an
  undo back to the baked state makes it hold again.
- The file holds the straight run and then one more round of the loop
  as heard the second time, tails included. Looped, it plays to the
  loop end and then round that round, to the sample. Once, it fades
  out over its last 10 ms. Let ring fades it out over 0.3 s.
- It is worked out by a second SoundLibrary on an OfflineAudioContext,
  its scheduler woken by suspends of the offline clock, and what
  reaches the master is taken. A worklet probe tells whether that was
  mono, so a mono bake goes on exactly as the notes did.
- Opus in Ogg, 160 kbit/s stereo or 96 kbit/s mono, encoded with
  WebCodecs, the Ogg pages written by hand. Normalized to full scale,
  the peak kept and played back.
- The file goes up into the sounds of the account as "[bake] name",
  which the sound picker leaves out. An old bake file is deleted once
  the project is saved without it. Only a bake that holds is loaded.
- Ogg files are decoded at 48 kHz: resampled to 44.1 kHz, the loop of
  a bake lost a sample a round.
- Baking and removing a bake are no undo steps, like the EQ's zoom.

Findings:
- Chrome's AudioEncoder refuses opus.format 'ogg' (NotSupportedError)
  but hands out the OpusHead with a pre-skip of 312 for 'opus'.
- The Song tab's header is full at 1280 px: a captioned Bake pushed
  Fit off the tab. Hence the icon.

Tests:
- Unit: tests/core/ogg.test.ts (6), tests/core/bake.test.ts (16),
  four new in sound-library.test.ts, one in song-play.test.ts. One
  test cuts, reverses and fades a clip on a song's track: each change
  outdates the bake, each undo makes it hold again.
- e2e 64 (melektaus-atx): ALL PASSED, 36 checks (6 new). Live and baked
  rendered offline differ by at most 0.06 dB over 114 windows of
  20 ms, two loop ends among them. e2e 65 had no red check with the
  icon in its header.
- Gate (melektaus-atx): CHECK EXIT 0, 277 files, 4281 tests.
- Counter-checks: print without instrument builds, print taking the
  sound's file instead of the clip's cut, round taken from the first
  pass, bake voice without its looping source, Play pattern or Play
  song ignoring the bake: one unit test red each. Pre-skip set to 0:
  the e2e comparison 4.8 dB off, only that check red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:13:11 +02:00

141 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type Meter, ticksPerBar, ticksPerBeat } from '../../core/music/time';
import { clamp } from '../../ui';
/**
* The time axis of the piano roll (#333), and of the song tab after it (#334), apart from Pixi so a
* test can drive it: ticks run left to right, `x` is measured from the left edge of the area that
* shows them. Only across; what is stacked above each other is up to the tab.
*/
export interface TimeView {
/** Pixels from the area's left edge to tick 0. */
panX: number;
pxPerTick: number;
}
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;
const BAR_LABEL_MIN_PX = 36;
const BEAT_MIN_PX = 6;
const GRID_MIN_PX = 5;
export function tickToX(view: TimeView, tick: number): number {
return view.panX + tick * view.pxPerTick;
}
export function xToTick(view: TimeView, x: number): number {
return (x - view.panX) / view.pxPerTick;
}
/** Zooms about `x`, so the tick under the pointer stays under it, up to `most` pixels a tick. */
export function zoomTime(view: TimeView, factor: number, x: number, most = MAX_PX_PER_TICK): TimeView {
const pxPerTick = clamp(view.pxPerTick * factor, MIN_PX_PER_TICK, most);
const tick = xToTick(view, x);
return { panX: x - tick * pxPerTick, pxPerTick };
}
/** How far right of the area's left edge tick 0 can be panned, as a share of the area's width. */
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. Where there is something to look at before 0 – a
* song around a pattern (#339) – `earliest` takes that place instead.
*/
export function clampTimePan<V extends TimeView>(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;
}
/** How far past the first or the last row (a note, a track) a pan can go, as a share of the area's height. */
const ROW_LEAD = 0.5;
/**
* Down the area as across it: the first row can be panned up to half the height down from the top
* edge and the last up to half the height up from the bottom, to look at, with nothing to place there.
* Rows that fit with room to spare are not pushed up off the top.
*/
export function clampRowPan(panY: number, content: number, height: number): number {
const lead = Math.max(0, height * ROW_LEAD);
return clamp(panY, Math.min(0, height - lead - content), lead);
}
/** `length` ticks across `width` pixels, from tick 0. */
export function fitTime(length: number, width: number): TimeView {
return { panX: 0, pxPerTick: clamp(width / Math.max(1, length), MIN_PX_PER_TICK, MAX_PX_PER_TICK) };
}
/** A line across the time: 0 a bar, 1 a beat, 2 a step of the grid. */
export interface TimeLine {
tick: number;
x: number;
level: 0 | 1 | 2;
}
/**
* 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,
earliest = 0,
): TimeLine[] {
const levels = new Map<number, 0 | 1 | 2>();
const from = Math.max(earliest, xToTick(view, 0));
const to = xToTick(view, width);
const spans: [number, 0 | 1 | 2, number][] = [
[ticksPerBar(meter), 0, 0],
[ticksPerBeat(meter), 1, BEAT_MIN_PX],
[grid, 2, GRID_MIN_PX],
];
for (const [step, level, minPx] of spans) {
if (step * view.pxPerTick < minPx) continue;
for (let tick = Math.ceil(from / step) * step; tick <= to; tick += step)
if (!levels.has(tick)) levels.set(tick, level);
}
return [...levels]
.map(([tick, level]) => ({ tick, x: tickToX(view, tick), level }))
.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;
let n = 1;
while (n * barPx < BAR_LABEL_MIN_PX && n < 1024) n *= 2;
return n;
}
/** "1:05" */
export function clockText(seconds: number): string {
const whole = Math.round(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
}