Edit a Compressor in a tab of its own, with knobs and levels (#378)

A Compressor had only its number rows in the node. Its numbers are
easier to set by feel on a dial, and what it does is easier to judge
when the level going in and the level going out are seen side by side.

- Knob is a new widget in the toolkit: a slider bent into a circle,
  its name above and a number field below. A drag up or down turns
  it, Shift a tenth as far, a notch of the wheel a fiftieth of the
  turn, a double-click sets the default, the number takes a typed
  value. A taper gives the low end more of the turn.
- The Compressor's tab opens on a double-click, one per node, like
  the Parametric EQ's. It has a knob for each of the seven numbers,
  greyed while its input is linked.
- Its curve shows level in to level out from -60 to 0 dB. The point
  where it bends sets the threshold, the end of the curve sets the
  ratio that ends there.
- Its levels show the last 4 s on both sides of the node. Where it
  takes more than 0.5 dB away, the stretch below the level coming in
  is tinted. The makeup is left out of that.
- The Compressor is tapped like the EQ. SpectrumTaps listens to more
  than one node at once and reads the loudest sample of the last
  25 ms on each side.
- The node tabs share one map in the editor (nodeTabs), so a third
  kind needs no new plumbing.

Findings:

- CI task 614 for 23922ee was red on the memory check of the stream:
  the tab's renderer grew by 94 MB with nothing decoded. Play mode
  made garbage of its own and was read before a collection. Both
  readings in play now come after one, and the check compares the
  growth streamed with the growth decoded (3.7 MB against 59.8 MB).
- The ratio handle sat on the right edge of the picture, where a
  widget's hit area ends. The curve is inset by the grab radius.

Tests:

- Gate (melektaus-atx): 282 files, 4338 tests (17 new)
- e2e 34 (melektaus-atx): ALL PASSED, 116 checks (6 new)
- Counter-checks: an inverted ratio search, stop() letting go of
  every node and a drag without its least step each fail exactly
  their test.
- Looked at the tab in a screenshot: a blip at -4.4 dB came out at
  -16.7 dB, the gap tinted, the threshold line at -24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 22:40:12 +02:00
1 parent 23922ee4a2
commit 1df38bd430
20 files changed
+1471 -103

No files matched your search

+12 -7
View File
@@ -58,7 +58,7 @@ import {
releaseEnvelope,
stopProcessor,
} from './fxNodes';
import { type Spectrum, SpectrumTaps } from './spectrum';
import { type Levels, type Spectrum, SpectrumTaps } from './spectrum';
import { decodeSpan, StreamFeed, StreamFile } from './streaming';
import { wake } from './wake';
@@ -620,19 +620,24 @@ export class SoundLibrary implements AudioSink {
}
/**
* Listens to the sound in front of the node `id` (`tapId`) and behind it (#374): a Parametric
* EQ's tab while it is seen. `spectrum` reads it, `unlisten` lets it go.
* Listens to the sound in front of the node `id` (`tapId`) and behind it (#374): the tab of a
* Parametric EQ or a Compressor (#378) while it is seen. `spectrum` and `nodeLevels` read it,
* `unlisten` lets it go.
*/
listen(id: string): void {
this.spectra.listen(id);
}
unlisten(): void {
this.spectra.stop();
unlisten(id: string): void {
this.spectra.stop(id);
}
spectrum(): Spectrum | null {
return this.spectra.read();
spectrum(id: string): Spectrum | null {
return this.spectra.read(id);
}
nodeLevels(id: string, seconds: number): Levels | null {
return this.spectra.levels(id, seconds);
}
/** The node listened to, the taps playing, and how many of them feed an analyser (test hook). */
+71 -32
View File
@@ -1,7 +1,8 @@
/**
* The sound in front of a node and behind it, as an analyser hears it (#374). Every voice and bus
* passing a tap hands its node in for as long as it plays; while the node is listened to, they all
* feed one analyser for each side. Nothing is made before a tap of that node plays.
* feed one analyser for each side. Nothing is made before a tap of that node plays. More than one
* node can be listened to at once: a Parametric EQ and a Compressor (#378) each in a tab of their own.
*/
import type { FxTap } from '../core/soundfx/nodes';
@@ -15,6 +16,8 @@ interface Analysis {
sum: GainNode;
analyser: AnalyserNode;
data: Float32Array<ArrayBuffer>;
/** The latest samples, for `levels`. */
wave: Float32Array<ArrayBuffer>;
}
/** Both sides of the node in dB, one level every `binHz`. */
@@ -24,6 +27,12 @@ export interface Spectrum {
binHz: number;
}
/** The loudest sample on each side of the node over a short while, in dB; -Infinity for silence. */
export interface Levels {
in: number;
out: number;
}
const keyOf = (id: string, at: Side) => `${at}:${id}`;
/** Cuts one link; it may be gone with the rest of a voice already. */
@@ -38,9 +47,11 @@ function cut(from: AudioNode, to: AudioNode): void {
export class SpectrumTaps {
/** The nodes of the taps that play now, by node and side. */
private readonly held = new Map<string, Set<AudioNode>>();
private listened = '';
/** Made once a tap of the node listened to plays; kept while it is listened to. */
private sides: Record<Side, Analysis> | null = null;
/**
* The nodes listened to, each with its analysers: null until a tap of it plays, then kept while
* it is listened to.
*/
private readonly listened = new Map<string, Record<Side, Analysis> | null>();
/** A tap of a voice or a bus as it starts to play; what it hands back is called once it stops. */
hold(tap: FxTap, node: AudioNode): () => void {
@@ -48,42 +59,43 @@ export class SpectrumTaps {
const nodes = this.held.get(key) ?? new Set<AudioNode>();
this.held.set(key, nodes);
nodes.add(node);
if (tap.id === this.listened) node.connect(this.analysis(node.context)[tap.at].sum);
if (this.listened.has(tap.id)) node.connect(this.analysis(tap.id, node.context)[tap.at].sum);
return () => {
if (!nodes.delete(node)) return;
if (nodes.size === 0 && this.held.get(key) === nodes) this.held.delete(key);
if (tap.id === this.listened && this.sides) cut(node, this.sides[tap.at].sum);
const sides = this.listened.get(tap.id);
if (sides) cut(node, sides[tap.at].sum);
};
}
/** Listens to the node `id` (`tapId`) and to no other. */
/** Listens to the node `id` (`tapId`), besides those listened to already. */
listen(id: string): void {
if (id === this.listened) return;
this.stop();
this.listened = id;
if (this.listened.has(id)) return;
this.listened.set(id, null);
for (const at of SIDES)
for (const node of this.held.get(keyOf(id, at)) ?? [])
node.connect(this.analysis(node.context)[at].sum);
node.connect(this.analysis(id, node.context)[at].sum);
}
/** Lets the analysers go: no tap feeds one any more. */
stop(): void {
const { sides, listened } = this;
this.listened = '';
this.sides = null;
if (!sides) return;
for (const at of SIDES) {
for (const node of this.held.get(keyOf(listened, at)) ?? []) cut(node, sides[at].sum);
sides[at].sum.disconnect();
/** Lets the analysers of the node `id` go, or of every node: no tap feeds one any more. */
stop(id?: string): void {
for (const listened of id === undefined ? [...this.listened.keys()] : [id]) {
const sides = this.listened.get(listened);
this.listened.delete(listened);
if (!sides) continue;
for (const at of SIDES) {
for (const node of this.held.get(keyOf(listened, at)) ?? []) cut(node, sides[at].sum);
sides[at].sum.disconnect();
}
}
}
/**
* The spectrum on both sides of the node listened to, smoothed by the analyser: once its sound has
* gone it falls away. Null before anything has played through it.
* The spectrum on both sides of the node `id`, smoothed by the analyser: once its sound has gone
* it falls away. Null before anything has played through it, or while it is not listened to.
*/
read(): Spectrum | null {
const sides = this.sides;
read(id: string): Spectrum | null {
const sides = this.listened.get(id);
if (!sides) return null;
for (const at of SIDES) sides[at].analyser.getFloatFrequencyData(sides[at].data);
return {
@@ -93,18 +105,40 @@ export class SpectrumTaps {
};
}
/** The node listened to, the taps playing and how many of them feed an analyser (test hook). */
/**
* The loudest sample on both sides of the node `id` over the last `seconds` (#378). Null before
* anything has played through it, or while it is not listened to.
*/
levels(id: string, seconds: number): Levels | null {
const sides = this.listened.get(id);
if (!sides) return null;
const count = Math.max(
1,
Math.min(SPECTRUM_SIZE, Math.round(seconds * sides.in.analyser.context.sampleRate)),
);
const peakOf = ({ analyser, wave }: Analysis) => {
analyser.getFloatTimeDomainData(wave);
let peak = 0;
for (let i = wave.length - count; i < wave.length; i++) peak = Math.max(peak, Math.abs(wave[i]!));
return peak > 0 ? 20 * Math.log10(peak) : -Infinity;
};
return { in: peakOf(sides.in), out: peakOf(sides.out) };
}
/** The nodes listened to, the taps playing and how many of them feed an analyser (test hook). */
info(): { listened: string; held: number; feeding: number } {
let held = 0;
for (const nodes of this.held.values()) held += nodes.size;
const feeding = this.sides
? SIDES.reduce((sum, at) => sum + (this.held.get(keyOf(this.listened, at))?.size ?? 0), 0)
: 0;
return { listened: this.listened, held, feeding };
let feeding = 0;
for (const [id, sides] of this.listened)
if (sides) for (const at of SIDES) feeding += this.held.get(keyOf(id, at))?.size ?? 0;
return { listened: [...this.listened.keys()].join(' '), held, feeding };
}
private analysis(context: BaseAudioContext): Record<Side, Analysis> {
return (this.sides ??= { in: analysisOf(context), out: analysisOf(context) });
private analysis(id: string, context: BaseAudioContext): Record<Side, Analysis> {
const made = this.listened.get(id) ?? { in: analysisOf(context), out: analysisOf(context) };
this.listened.set(id, made);
return made;
}
}
@@ -117,5 +151,10 @@ function analysisOf(context: BaseAudioContext): Analysis {
const analyser = context.createAnalyser();
analyser.fftSize = SPECTRUM_SIZE;
sum.connect(analyser);
return { sum, analyser, data: new Float32Array(analyser.frequencyBinCount) };
return {
sum,
analyser,
data: new Float32Array(analyser.frequencyBinCount),
wave: new Float32Array(SPECTRUM_SIZE),
};
}