Meter each track and the song, and limit the output at -1 dBFS (#359)
A song could clip at the output without anyone noticing: a track and a sound item at +12 dB each add up to +24 dB. The Song tab now shows how loud each track and the whole song are, and a fixed limiter behind the master keeps every sample under -1 dBFS in the editor and in the game. - Each track head has a stereo meter up its right edge. The head is 190 px wide instead of 176. - Out sits in the corner above the heads, with the loudest peak since Play in dB. In the tab header it made the row too wide for a 1280 px window, and the inspector lost its fields. - The meters read AnalyserNodes beside the buses, one per side after a channel splitter, so a peak panned hard is not halved. They are tapped only while the song plays and its tab is shown. - A clip lamp lights past 0 dBFS and stays through Stop. The next Play or a click on the meter puts it out. - The meter of a track that mute or solo keeps from being heard is grey. - Music always plays through a bus of its own, with or without a sound FX. That bus is what Out measures. - The limiter is the one from #326 and needs the FX worklet. Until that is loaded the master goes straight out. - It is 5 ms late. latency() counts that in, so the playhead and the alignment of a recorded take follow it. - Unit tests build SoundLibrary without the limiter, as their wiring checks follow every node of a voice. - Out shows "-inf" while nothing was loud. The UI font has no infinity sign, which the font test found. Tests: - Unit meter-view 8 new, sound-library 112 (2 new), 3 counter-tests each red - e2e 65 (melektaus-atx): ALL PASSED, 55 checks (3 new). Out wrote +16.6 dB, behind the limiter the loudest sample was 0.8913 - Gate (melektaus-atx): 272 files, 4209 tests, one red in the font test. Fixed after it, then the font and meter tests ran green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc666016db
commit
490fcba88f
12 files changed
+810
-137
No files matched your search
@@ -1039,6 +1039,7 @@ export class Editor implements PlayHost {
|
||||
if (this.tab === 'curve') this.curveEditor.tick(dt);
|
||||
if (this.tab === 'pattern') this.patternEditor.tick();
|
||||
if (this.tab === 'song') this.songEditor.tick();
|
||||
else this.songEditor.hideMeters();
|
||||
if (this.tab === 'sound') this.soundEditor.tick();
|
||||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||||
// to do it, and its states move just the same.
|
||||
|
||||
@@ -44,6 +44,20 @@ export function songHooks({ state, editor }: HookContext) {
|
||||
clock: () => soundLibrary.clock(),
|
||||
stats: () => soundLibrary.stats(),
|
||||
buses: () => soundLibrary.busLevels(),
|
||||
/** The level meters (#359): each track's by its id and Out, in dB, and how many buses are tapped. */
|
||||
meters: () => {
|
||||
const asset = findAssetOf(state.project, songEditor.currentAssetId, 'song');
|
||||
return {
|
||||
tracks: Object.fromEntries((asset?.tracks ?? []).map((t) => [t.id, timeline.meterOf(t.id)])),
|
||||
out: timeline.outShown(),
|
||||
metered: soundLibrary.metered(),
|
||||
};
|
||||
},
|
||||
/** The loudest sample on each side behind the limiter at the output, null without it. */
|
||||
guard: () => soundLibrary.guardPeaks(),
|
||||
latency: () => soundLibrary.latency(),
|
||||
meterAt: (track: number) => timeline.meterPoint(track),
|
||||
outAt: () => timeline.outPoint(),
|
||||
/** Timeline or Routing (#350), and where their switches are. */
|
||||
shown: () => songEditor.view,
|
||||
viewAt: (view: 'timeline' | 'routing') =>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
songMeter,
|
||||
songSequence,
|
||||
songTempo,
|
||||
trackBus,
|
||||
} from '../../core/music/song';
|
||||
import { embeddedIn } from '../../core/music/embedded';
|
||||
import { roundAt } from '../../core/music/ghosts';
|
||||
@@ -82,6 +83,8 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
readonly recordButton: Button;
|
||||
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
||||
readonly meter = new TakeMeter();
|
||||
/** The page's clock in seconds when the meters were stepped last, null while they are not. */
|
||||
private meteredAt: number | null = null;
|
||||
/** Public for the check scripts. */
|
||||
readonly timelineButton: Button;
|
||||
readonly routingButton: Button;
|
||||
@@ -242,6 +245,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
claimTransport(this.halt);
|
||||
this.playingId = asset.id;
|
||||
this.told = this.settingsOf(asset);
|
||||
this.canvas.clearMeters();
|
||||
this.music.setBuses(songBuses(this.state.project, asset));
|
||||
void this.music.play(songSequence(this.state.project, asset), {
|
||||
tempo: songTempo(asset.props),
|
||||
@@ -261,6 +265,8 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
const take = this.take;
|
||||
this.take = null;
|
||||
this.meter.set(0);
|
||||
this.hideMeters();
|
||||
this.canvas.hushMeters();
|
||||
if (take && options.discard) {
|
||||
take.recorder.cancel();
|
||||
this.canvas.take = null;
|
||||
@@ -311,6 +317,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
this.told = this.settingsOf(asset);
|
||||
this.showTake(take, false);
|
||||
this.syncPlayButton();
|
||||
this.canvas.clearMeters();
|
||||
ui().focus.focus(this.canvas);
|
||||
this.music.setBuses(songBuses(this.state.project, asset));
|
||||
await this.music.play(songSequence(this.state.project, asset), {
|
||||
@@ -329,6 +336,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
/** Only called while the tab is visible: the playhead follows what is heard, a take grows. */
|
||||
tick(): void {
|
||||
if (!this.isPlaying) return;
|
||||
this.stepMeters();
|
||||
this.canvas.playhead = this.music.current?.position().ticks ?? null;
|
||||
const take = this.take;
|
||||
if (take) {
|
||||
@@ -342,6 +350,27 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
this.canvas.invalidate();
|
||||
}
|
||||
|
||||
/** The meters of the tracks and Out a frame on, from the loudest samples on their buses since. */
|
||||
private stepMeters(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset || asset.id !== this.playingId) return;
|
||||
const now = performance.now() / 1000;
|
||||
const dt = this.meteredAt === null ? 0 : now - this.meteredAt;
|
||||
this.meteredAt = now;
|
||||
const names = new Map(asset.tracks.map((track) => [trackBus(asset.id, track.id), track.id]));
|
||||
const peaks = soundLibrary.busPeaks([...names.keys(), this.music.outBus]);
|
||||
const tracks = new Map<string, [number, number]>();
|
||||
for (const [name, id] of names) tracks.set(id, peaks.get(name) ?? [0, 0]);
|
||||
this.canvas.stepMeters(tracks, peaks.get(this.music.outBus) ?? [0, 0], now, dt);
|
||||
}
|
||||
|
||||
/** Lets the taps of the meters go: the tab is not seen, or nothing plays. */
|
||||
hideMeters(): void {
|
||||
if (this.meteredAt === null) return;
|
||||
this.meteredAt = null;
|
||||
soundLibrary.unmeter();
|
||||
}
|
||||
|
||||
protected override closed(assetId: string): void {
|
||||
if (assetId === this.playingId) this.stop();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
songClockProps,
|
||||
} from '../../core/music/embedded';
|
||||
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
|
||||
import { songRoutes, trackGates } from '../../core/music/routing';
|
||||
import type { LoopRange } from '../../core/music/sequence';
|
||||
import {
|
||||
itemAsset,
|
||||
@@ -72,6 +73,16 @@ import {
|
||||
} from '../music/timeView';
|
||||
import { snapDown, snapNear } from '../pattern/rollEdit';
|
||||
import type { EditorState } from '../state';
|
||||
import { drawMeter } from './meterDraw';
|
||||
import {
|
||||
gainToDb,
|
||||
hushMeter,
|
||||
meterClipped,
|
||||
peakText,
|
||||
QUIET_METER,
|
||||
type StereoMeter,
|
||||
stepMeter,
|
||||
} from './meterView';
|
||||
import { ownFxEntries } from './ownFx';
|
||||
import { removeTrack } from './songActions';
|
||||
import {
|
||||
@@ -105,6 +116,7 @@ import {
|
||||
laneTick,
|
||||
loopSeams,
|
||||
nearestTrack,
|
||||
OUT_METER_BOX,
|
||||
peakOver,
|
||||
SLIDERS_MIN_HEIGHT,
|
||||
sliderShare,
|
||||
@@ -112,6 +124,7 @@ import {
|
||||
type SongView,
|
||||
TRACK_HEIGHT,
|
||||
trackAtY,
|
||||
trackMeterBox,
|
||||
trackTop,
|
||||
zoomTracks,
|
||||
} from './songView';
|
||||
@@ -244,6 +257,13 @@ export class SongTimeline extends Widget {
|
||||
playhead: number | null = null;
|
||||
/** While set, the lanes take no edits: only a pan, Space to stop and Esc to throw it away. */
|
||||
take: TakeView | null = null;
|
||||
/** The level meter of each track (#359), by its id. */
|
||||
private readonly meters = new Map<string, StereoMeter>();
|
||||
/** What the song gives after its sound FX, and the loudest peak of it since Play, in dB. */
|
||||
private out: StereoMeter = QUIET_METER;
|
||||
private loudest = -Infinity;
|
||||
private readonly outLabel = crispText({ text: 'Out', style: textStyle('small') });
|
||||
private readonly outPeak = crispText({ text: '', style: textStyle('small') });
|
||||
onTogglePlay: () => void = () => {};
|
||||
/** Where playing starts was moved: a playing song jumps there. */
|
||||
onStart: (tick: number) => void = () => {};
|
||||
@@ -258,12 +278,22 @@ export class SongTimeline extends Widget {
|
||||
this.focusable = true;
|
||||
this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay);
|
||||
this.headsLayer.addChild(this.headsG, this.headLabels);
|
||||
this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint);
|
||||
this.addChild(
|
||||
this.bg,
|
||||
this.lanesLayer,
|
||||
this.headsLayer,
|
||||
this.ruler,
|
||||
this.hint,
|
||||
this.outLabel,
|
||||
this.outPeak,
|
||||
);
|
||||
for (const clip of Object.values(this.clips)) this.addChild(clip);
|
||||
this.lanesLayer.mask = this.clips.lanes;
|
||||
this.headsLayer.mask = this.clips.heads;
|
||||
this.ruler.mask = this.clips.ruler;
|
||||
this.hint.eventMode = 'none';
|
||||
this.outLabel.eventMode = 'none';
|
||||
this.outPeak.eventMode = 'none';
|
||||
this.itemLabels.eventMode = 'none';
|
||||
this.headLabels.eventMode = 'none';
|
||||
|
||||
@@ -541,6 +571,65 @@ export class SongTimeline extends Widget {
|
||||
return { x: at.x + HEAD_WIDTH + x, y: at.y + RULER_HEIGHT + box.y + box.h / 2 };
|
||||
}
|
||||
|
||||
/** The meters of these tracks and Out a frame on (#359), with the loudest sample on each side since. */
|
||||
stepMeters(
|
||||
tracks: ReadonlyMap<string, readonly [number, number]>,
|
||||
out: readonly [number, number],
|
||||
now: number,
|
||||
dt: number,
|
||||
): void {
|
||||
for (const [id, both] of tracks)
|
||||
this.meters.set(id, stepMeter(this.meters.get(id) ?? QUIET_METER, both, now, dt));
|
||||
this.out = stepMeter(this.out, out, now, dt);
|
||||
this.loudest = Math.max(this.loudest, gainToDb(Math.max(out[0], out[1])));
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Nothing plays: the meters go dark, their clip lamps stay lit and so does the loudest peak. */
|
||||
hushMeters(): void {
|
||||
for (const [id, meter] of this.meters) this.meters.set(id, hushMeter(meter));
|
||||
this.out = hushMeter(this.out);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
clearMeters(): void {
|
||||
this.meters.clear();
|
||||
this.clearOut();
|
||||
}
|
||||
|
||||
private clearOut(): void {
|
||||
this.out = QUIET_METER;
|
||||
this.loudest = -Infinity;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** What Out shows (test hook). */
|
||||
outShown(): { meter: StereoMeter; loudest: number; clipped: boolean } {
|
||||
return { meter: this.out, loudest: this.loudest, clipped: meterClipped(this.out) };
|
||||
}
|
||||
|
||||
/** The middle of the Out meter (test hook). */
|
||||
outPoint(): Vec2 {
|
||||
const at = this.getGlobalPosition();
|
||||
const box = OUT_METER_BOX;
|
||||
return { x: at.x + box.x + box.w / 2, y: at.y + box.y + box.h / 2 };
|
||||
}
|
||||
|
||||
/** What the meter of a track shows (test hook). */
|
||||
meterOf(trackId: string): StereoMeter {
|
||||
return this.meters.get(trackId) ?? QUIET_METER;
|
||||
}
|
||||
|
||||
/** The middle of a track's meter (test hook). */
|
||||
meterPoint(track: number): Vec2 {
|
||||
const at = this.getGlobalPosition();
|
||||
const box = trackMeterBox(this.view.trackHeight);
|
||||
return {
|
||||
x: at.x + box.x + box.w / 2,
|
||||
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + box.y + box.h / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** The middle of a part of a track's head, or a share across its slider. */
|
||||
headPoint(track: number, part: HeadPart, share = 0.5): Vec2 {
|
||||
const at = this.getGlobalPosition();
|
||||
@@ -653,6 +742,11 @@ export class SongTimeline extends Widget {
|
||||
const g = this.inLanes(p);
|
||||
const tick = xToTick(this.view, g.x);
|
||||
if (area === 'heads') return this.pressHead(asset, e, g.y, p.x);
|
||||
// A click on the corner puts out the clip lamp of Out and its loudest peak.
|
||||
if (area === 'corner') {
|
||||
if (e.button === 0) this.clearOut();
|
||||
return;
|
||||
}
|
||||
if (e.button === 2) {
|
||||
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
||||
if (area === 'lanes') this.openItemMenu(asset, g, e.global);
|
||||
@@ -677,7 +771,7 @@ export class SongTimeline extends Widget {
|
||||
kind: 'cursor',
|
||||
tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey),
|
||||
};
|
||||
} else if (area === 'lanes') this.pressLanes(asset, e, g);
|
||||
} else this.pressLanes(asset, e, g);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
@@ -690,6 +784,17 @@ export class SongTimeline extends Widget {
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
// A click on the meter puts out its clip lamp.
|
||||
if (x >= trackMeterBox(this.view.trackHeight).x - 2) {
|
||||
const meter = this.meters.get(track.id);
|
||||
if (meter)
|
||||
this.meters.set(track.id, [
|
||||
{ ...meter[0], clipped: false },
|
||||
{ ...meter[1], clipped: false },
|
||||
]);
|
||||
this.invalidate();
|
||||
return;
|
||||
}
|
||||
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
|
||||
if (part === 'fx') this.openTrackFxMenu(asset, track, e.global);
|
||||
else if (part === 'mute' || part === 'solo') {
|
||||
@@ -1301,6 +1406,7 @@ export class SongTimeline extends Widget {
|
||||
this.itemsG.clear();
|
||||
this.overlay.clear();
|
||||
this.headsG.clear();
|
||||
this.outLabel.visible = this.outPeak.visible = asset !== null;
|
||||
if (!asset) {
|
||||
this.syncTexts(this.itemTexts, this.itemLabels, []);
|
||||
this.syncTexts(this.headTexts, this.headLabels, []);
|
||||
@@ -1315,7 +1421,8 @@ export class SongTimeline extends Widget {
|
||||
const tracks = this.shownTracks(asset);
|
||||
this.drawLanes(asset, tracks, lw, lh, length);
|
||||
this.drawItems(asset, lw, lh);
|
||||
this.drawHeads(tracks);
|
||||
this.drawHeads(asset, tracks);
|
||||
this.drawOut();
|
||||
this.ruler.draw(this.view, songMeter(asset.props), lw, {
|
||||
length,
|
||||
loop: this.shownLoop(),
|
||||
@@ -1538,10 +1645,17 @@ export class SongTimeline extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** The heads: name, mute and solo, the volume and pan sliders. A track is added from a head's menu. */
|
||||
private drawHeads(tracks: readonly SongTrack[]): void {
|
||||
/**
|
||||
* The heads: name, mute and solo, the volume and pan sliders, and the meter (#359), grey on a track
|
||||
* mute or solo silences. A track is added from a head's menu.
|
||||
*/
|
||||
private drawHeads(asset: SongAsset, tracks: readonly SongTrack[]): void {
|
||||
const c = Theme.colors;
|
||||
const labels: Placed[] = [];
|
||||
const routes = songRoutes({ tracks, graph: asset.graph });
|
||||
const gates = trackGates(tracks, routes);
|
||||
const heard = (id: string) =>
|
||||
routes.some((route) => route.from === id && gates.get(id)?.passes(route.to));
|
||||
tracks.forEach((track, i) => {
|
||||
const y = trackTop(this.view, i);
|
||||
const lit = track.id === this.pickedTrack;
|
||||
@@ -1560,6 +1674,8 @@ export class SongTimeline extends Widget {
|
||||
{ text: 'M', x: mute.x, y: y + mute.y, w: mute.w, h: mute.h, dark: track.mute },
|
||||
{ text: 'S', x: solo.x, y: y + solo.y, w: solo.w, h: solo.h, dark: track.solo },
|
||||
);
|
||||
const meter = trackMeterBox(th);
|
||||
drawMeter(this.headsG, { ...meter, y: y + meter.y }, this.meterOf(track.id), true, !heard(track.id));
|
||||
if (th < SLIDERS_MIN_HEIGHT) return;
|
||||
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
|
||||
this.headsG
|
||||
@@ -1585,6 +1701,18 @@ export class SongTimeline extends Widget {
|
||||
this.syncTexts(this.headTexts, this.headLabels, labels);
|
||||
}
|
||||
|
||||
/** Out in the corner (#359): its meter and the loudest peak since Play, red past 0 dB. */
|
||||
private drawOut(): void {
|
||||
const box = OUT_METER_BOX;
|
||||
drawMeter(this.bg, box, this.out, false);
|
||||
const middle = box.y + box.h / 2;
|
||||
this.outLabel.position.set(8, Math.round(middle - this.outLabel.height / 2));
|
||||
const text = `${peakText(this.loudest)} dB`;
|
||||
if (this.outPeak.text !== text) this.outPeak.text = text;
|
||||
this.outPeak.style = this.loudest > 0 ? textStyle('warning') : textStyle('small');
|
||||
this.outPeak.position.set(box.x + box.w + 4, Math.round(middle - this.outPeak.height / 2));
|
||||
}
|
||||
|
||||
private toggle(box: Box, y: number, on: boolean, color: number): void {
|
||||
const c = Theme.colors;
|
||||
this.headsG
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Graphics } from 'pixi.js';
|
||||
import { Theme } from '../../ui';
|
||||
import { METER_WARM_DB, meterShare, meterZone, type MeterZone, type StereoMeter } from './meterView';
|
||||
import type { Box } from './songView';
|
||||
|
||||
const ZONE_COLORS: Readonly<Record<MeterZone, number>> = { ok: 0x4fae6a, warm: 0xe0c040, hot: 0xd04040 };
|
||||
/** Pixels at the far end of a meter that light while it clipped. */
|
||||
const LAMP = 3;
|
||||
|
||||
/**
|
||||
* Draws a meter into `box` (#359): a bar for each side, side by side up a vertical one and one under
|
||||
* the other along a horizontal one; green, yellow from -6 dBFS, red past 0, a line where the peak
|
||||
* holds, and the far end red while it clipped. `grey` for a track that is not heard.
|
||||
*/
|
||||
export function drawMeter(g: Graphics, box: Box, meter: StereoMeter, vertical: boolean, grey = false): void {
|
||||
const c = Theme.colors;
|
||||
g.rect(box.x, box.y, box.w, box.h).fill(c.viewport).stroke({ width: 1, color: c.border, alignment: 1 });
|
||||
const across = vertical ? box.w - 2 : box.h - 2;
|
||||
const along = (vertical ? box.h : box.w) - 2 - LAMP - 1;
|
||||
const thick = Math.floor((across - 1) / 2);
|
||||
const warm = meterShare(METER_WARM_DB);
|
||||
const full = meterShare(0);
|
||||
const tint = (zone: MeterZone) => (grey ? c.textMuted : ZONE_COLORS[zone]);
|
||||
meter.forEach((side, i) => {
|
||||
const offset = 1 + i * (thick + 1);
|
||||
const bar = (from: number, to: number, color: number) => {
|
||||
const a = Math.round(from * along);
|
||||
const b = Math.round(to * along);
|
||||
if (b <= a) return;
|
||||
if (vertical) g.rect(box.x + offset, box.y + box.h - 1 - b, thick, b - a).fill(color);
|
||||
else g.rect(box.x + 1 + a, box.y + offset, b - a, thick).fill(color);
|
||||
};
|
||||
const share = meterShare(side.level);
|
||||
bar(0, Math.min(share, warm), tint('ok'));
|
||||
bar(warm, Math.min(share, full), tint('warm'));
|
||||
bar(full, share, tint('hot'));
|
||||
if (Number.isFinite(side.peak)) {
|
||||
const peak = meterShare(side.peak);
|
||||
bar(Math.max(0, peak - 1 / along), peak, tint(meterZone(side.peak)));
|
||||
}
|
||||
if (!side.clipped) return;
|
||||
if (vertical) g.rect(box.x + offset, box.y + 1, thick, LAMP).fill(ZONE_COLORS.hot);
|
||||
else g.rect(box.x + box.w - 1 - LAMP, box.y + offset, LAMP, thick).fill(ZONE_COLORS.hot);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { clamp } from '../../ui';
|
||||
|
||||
/** The scale of a level meter (#359) in dBFS: what is quieter shows nothing, past 0 it clips. */
|
||||
export const METER_MIN_DB = -48;
|
||||
export const METER_MAX_DB = 6;
|
||||
/** From here on a meter is yellow; past 0 dBFS it is red. */
|
||||
export const METER_WARM_DB = -6;
|
||||
/** Seconds the loudest peak stays put before it falls. */
|
||||
export const PEAK_HOLD = 1.5;
|
||||
/** dB a second a level and a peak fall. */
|
||||
export const METER_FALL = 24;
|
||||
|
||||
/** One side of a meter: what it shows now, the loudest peak and when it came, and whether it clipped. */
|
||||
export interface MeterSide {
|
||||
level: number;
|
||||
peak: number;
|
||||
peakAt: number;
|
||||
clipped: boolean;
|
||||
}
|
||||
|
||||
export const QUIET_SIDE: MeterSide = { level: -Infinity, peak: -Infinity, peakAt: 0, clipped: false };
|
||||
|
||||
export function gainToDb(gain: number): number {
|
||||
return gain > 0 ? 20 * Math.log10(gain) : -Infinity;
|
||||
}
|
||||
|
||||
/** How far up the meter a level is, 0 to 1. */
|
||||
export function meterShare(db: number): number {
|
||||
return clamp((db - METER_MIN_DB) / (METER_MAX_DB - METER_MIN_DB), 0, 1);
|
||||
}
|
||||
|
||||
export type MeterZone = 'ok' | 'warm' | 'hot';
|
||||
|
||||
export function meterZone(db: number): MeterZone {
|
||||
return db > 0 ? 'hot' : db >= METER_WARM_DB ? 'warm' : 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* A side `dt` seconds on, at `now`, with `gain` the loudest sample since: the level jumps up to it
|
||||
* and falls back slowly, the peak holds `PEAK_HOLD` seconds and then falls the same way. A sample
|
||||
* past full scale lights the clip lamp, and it stays lit until it is cleared.
|
||||
*/
|
||||
export function stepSide(side: MeterSide, gain: number, now: number, dt: number): MeterSide {
|
||||
const db = gainToDb(gain);
|
||||
const fall = (from: number) => {
|
||||
const to = from - METER_FALL * Math.max(0, dt);
|
||||
return to < METER_MIN_DB ? -Infinity : to;
|
||||
};
|
||||
const level = Math.max(db, fall(side.level));
|
||||
const fresh = db >= side.peak;
|
||||
const held = now - side.peakAt < PEAK_HOLD;
|
||||
return {
|
||||
level,
|
||||
peak: fresh ? db : held ? side.peak : Math.max(db, fall(side.peak)),
|
||||
peakAt: fresh ? now : side.peakAt,
|
||||
clipped: side.clipped || gain > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Both sides of a meter, left and right. */
|
||||
export type StereoMeter = readonly [MeterSide, MeterSide];
|
||||
|
||||
export const QUIET_METER: StereoMeter = [QUIET_SIDE, QUIET_SIDE];
|
||||
|
||||
export function stepMeter(
|
||||
meter: StereoMeter,
|
||||
gains: readonly [number, number],
|
||||
now: number,
|
||||
dt: number,
|
||||
): StereoMeter {
|
||||
return [stepSide(meter[0], gains[0], now, dt), stepSide(meter[1], gains[1], now, dt)];
|
||||
}
|
||||
|
||||
/** What it shows goes; only the clip lamp stays as it was. */
|
||||
export function hushMeter(meter: StereoMeter): StereoMeter {
|
||||
return [
|
||||
{ ...QUIET_SIDE, clipped: meter[0].clipped },
|
||||
{ ...QUIET_SIDE, clipped: meter[1].clipped },
|
||||
];
|
||||
}
|
||||
|
||||
export function meterClipped(meter: StereoMeter): boolean {
|
||||
return meter[0].clipped || meter[1].clipped;
|
||||
}
|
||||
|
||||
/** "-3.2", "+1.5", "-inf": a peak as the Out meter writes it, in letters the UI font has. */
|
||||
export function peakText(db: number): string {
|
||||
if (!Number.isFinite(db)) return '-inf';
|
||||
const shown = Math.round(db * 10) / 10;
|
||||
return `${shown > 0 ? '+' : ''}${shown.toFixed(1)}`;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export interface SongView extends TimeView {
|
||||
trackHeight: number;
|
||||
}
|
||||
|
||||
export const HEAD_WIDTH = 176;
|
||||
export const HEAD_WIDTH = 190;
|
||||
export const TRACK_HEIGHT = 52;
|
||||
export const MIN_TRACK_HEIGHT = 28;
|
||||
export const MAX_TRACK_HEIGHT = 320;
|
||||
@@ -169,6 +169,14 @@ export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
|
||||
pan: { x: 116, y: 30, w: 52, h: 12 },
|
||||
};
|
||||
|
||||
/** The Out meter of the song (#359), in the corner left of the ruler, above the heads. */
|
||||
export const OUT_METER_BOX: Box = { x: 34, y: 9, w: 100, h: 13 };
|
||||
|
||||
/** The level meter of a track (#359): up the right edge of its head, as high as the track. */
|
||||
export function trackMeterBox(trackHeight: number): Box {
|
||||
return { x: 177, y: 3, w: 9, h: Math.max(1, trackHeight - 6) };
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user