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:
1 parent
23922ee4a2
commit
1df38bd430
20 files changed
+1471
-103
No files matched your search
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user