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>
141 lines
5.2 KiB
TypeScript
141 lines
5.2 KiB
TypeScript
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')}`;
|
||
}
|