Both were asked for in #367 "for later". The node of a Parametric EQ now shows its curve in small, and its tab shows what passes the node while it is seen. #375, the curve in the node: - NodeDef has an optional `picture`: a height, and shapes made from the settings as plain point lists (guide, area, line). It is general, the Parametric EQ is its first user. - NodeView draws it in a box below the rows, again only when the box or the settings change. A drag in the EQ tab changes them, so the node follows it live. - Without a band the box stays, flat on its 0 dB line, so the node does not grow at the first band. - The axes of the curve, `eqCurvePoints` and `fillShapes` move from editor/soundfx/eqView to core/soundfx/parametricEq. Tab and node draw the sum from the same function. #374, the spectrum: - A node marked `tapped` gets a `tap` stage in front of its own stages and one behind them when compiled, in a sound FX and in an instrument alike. Its id is asset and node, since a copied asset keeps its node ids. - A tap is a gain of 1. SpectrumTaps (new, viewport/spectrum.ts) holds the tap of every voice and bus while it plays. - While the EQ tab is seen, in the tab row or in a window, the taps of that node feed one analyser per side (8192 points). Nothing is made before a tap of the node plays, so no AudioContext is opened early. - The tab draws both behind the curve, on a scale of 0 to -96 dBFS: the sound coming in faint and grey, the sound going on in the colour of a sound port. The analyser smooths it, and once the sound has gone the sum still hands it silence, so it falls away. - Once the tab is not seen, Editor.tick lets the analysers go, like the meters of the Song tab. - A tap does nothing to the sound, so `stats().last.stages` leaves it out, and a bypassed EQ keeps its taps. Decided on my own: - Every sound passing the node counts: Play of the sound FX tab, an instrument's keys, a song, a run. The issue proposed that. - The spectrum has no labels of its own. Its scale is in the wiki. Tests: Gate (melektaus-atx) 275 files, 4247 tests (11 new), EXIT 0 e2e 34 (melektaus-atx) ALL PASSED, 98 checks (2 new), twice Counter-checks, each put back by copy and compared with cmp: - The node's curve drawn without its bands: 2 unit tests fail. - The spectrum without the loudest bin between two points: 1 fails. - SpectrumTaps.stop leaving the taps linked: 1 fails. - Taps that never feed the analyser: e2e 34 fails in exactly the spectrum check (frames 0). Run in the container only. The screenshots of the second run were looked at: the blip as one peak behind the curve, and the dip at 4 kHz in the node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25084f8576
commit
687377682c
19 files changed
+802
-99
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1919 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1921 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ export interface DerivedShape {
|
|||||||
inOrder?: true;
|
inOrder?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small picture a node draws below its rows (#375), as wide as the node and `height` tall. Its
|
||||||
|
* shapes are flat point lists `[x0, y0, x1, y1, …]` in that box, y down: a `guide` is a faint line, an
|
||||||
|
* `area` is filled, a `line` is drawn strong.
|
||||||
|
*/
|
||||||
|
export interface NodePicture {
|
||||||
|
height: number;
|
||||||
|
shapes(params: PropertyBag, w: number): PictureShape[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PictureShape {
|
||||||
|
kind: 'guide' | 'area' | 'line';
|
||||||
|
points: number[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
||||||
export interface NodeDef {
|
export interface NodeDef {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -135,6 +150,8 @@ export interface NodeDef {
|
|||||||
idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[];
|
idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[];
|
||||||
/** Whether the node does nothing at this value set and is drawn faded, like a bypassed effect (#354). */
|
/** Whether the node does nothing at this value set and is drawn faded, like a bypassed effect (#354). */
|
||||||
dimmed?(params: PropertyBag): boolean;
|
dimmed?(params: PropertyBag): boolean;
|
||||||
|
/** What the node shows of its settings below its rows, drawn rather than written (#375). */
|
||||||
|
picture?: NodePicture;
|
||||||
/**
|
/**
|
||||||
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
|
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
|
||||||
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an
|
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ import {
|
|||||||
type SoundFxNodeDef,
|
type SoundFxNodeDef,
|
||||||
type SoundFxProject,
|
type SoundFxProject,
|
||||||
SoundFxReverb,
|
SoundFxReverb,
|
||||||
|
tapId,
|
||||||
withPitch,
|
withPitch,
|
||||||
withStages,
|
withStages,
|
||||||
|
withTaps,
|
||||||
} from '../soundfx/nodes';
|
} from '../soundfx/nodes';
|
||||||
import {
|
import {
|
||||||
CHIP_ROWS,
|
CHIP_ROWS,
|
||||||
@@ -129,9 +131,15 @@ export function instrumentParams(graph: Graph): readonly DeclaredPort[] {
|
|||||||
* One note of an instrument (#331): the graph walked back from the Output, with the triggers that
|
* One note of an instrument (#331): the graph walked back from the Output, with the triggers that
|
||||||
* stand for this note on and every other one off. A source whose trigger is off is silence, and so
|
* stand for this note on and every other one off. A source whose trigger is off is silence, and so
|
||||||
* is everything it alone feeds; a Mix with one silent side is its other side. `note` and `frequency`
|
* is everything it alone feeds; a Mix with one silent side is its other side. `note` and `frequency`
|
||||||
* are numbers here, `velocity` and the parameters stay open for every voice to fill in.
|
* are numbers here, `velocity` and the parameters stay open for every voice to fill in. `assetId` is
|
||||||
|
* the instrument, which the taps of a node are named by (#374).
|
||||||
*/
|
*/
|
||||||
export function compileInstrument(graph: Graph, note: number, sources: InstrumentSources): InstrumentBuild {
|
export function compileInstrument(
|
||||||
|
graph: Graph,
|
||||||
|
note: number,
|
||||||
|
sources: InstrumentSources,
|
||||||
|
assetId = '',
|
||||||
|
): InstrumentBuild {
|
||||||
const project: SoundFxProject = { soundFile: (assetId) => sources.sound(assetId)?.file ?? '' };
|
const project: SoundFxProject = { soundFile: (assetId) => sources.sound(assetId)?.file ?? '' };
|
||||||
const walk = graphEmitter<Def, Value>(graph, {
|
const walk = graphEmitter<Def, Value>(graph, {
|
||||||
isDef: isInstrumentDef,
|
isDef: isInstrumentDef,
|
||||||
@@ -230,9 +238,9 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
|
|||||||
return { out: { plans: [def.emit({ a, b }, amounts, node.params, project)] } };
|
return { out: { plans: [def.emit({ a, b }, amounts, node.params, project)] } };
|
||||||
}
|
}
|
||||||
const [arriving] = plansAt('in');
|
const [arriving] = plansAt('in');
|
||||||
return {
|
if (!arriving) return { out: SILENT };
|
||||||
out: arriving ? { plans: [def.emit({ in: arriving }, amounts, node.params, project)] } : SILENT,
|
const plan = def.emit({ in: arriving }, amounts, node.params, project);
|
||||||
};
|
return { out: { plans: [def.tapped ? withTaps(arriving, plan, tapId(assetId, node.id)) : plan] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A Sample's sound, or null after saying why it has none – also where its trigger is off. */
|
/** A Sample's sound, or null after saying why it has none – also where its trigger is off. */
|
||||||
@@ -416,7 +424,7 @@ export function instrumentBuildFor(
|
|||||||
}
|
}
|
||||||
let build = cached.notes.get(note);
|
let build = cached.notes.get(note);
|
||||||
if (!build) {
|
if (!build) {
|
||||||
build = compileInstrument(asset.graph, note, sources);
|
build = compileInstrument(asset.graph, note, sources, asset.id);
|
||||||
cached.notes.set(note, build);
|
cached.notes.set(note, build);
|
||||||
}
|
}
|
||||||
return build;
|
return build;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
SOUND_FX_OUTPUT,
|
SOUND_FX_OUTPUT,
|
||||||
type SoundFxNodeDef,
|
type SoundFxNodeDef,
|
||||||
type SoundFxProject,
|
type SoundFxProject,
|
||||||
|
tapId,
|
||||||
|
withTaps,
|
||||||
} from './nodes';
|
} from './nodes';
|
||||||
|
|
||||||
export interface SoundFxBuild {
|
export interface SoundFxBuild {
|
||||||
@@ -49,9 +51,10 @@ function inputOutputs(node: GraphNode): Record<string, FxValue> {
|
|||||||
* A sound FX graph to the way a voice is sent: walked back from the Output to the Input, every node
|
* A sound FX graph to the way a voice is sent: walked back from the Output to the Input, every node
|
||||||
* on the way adding what it does. An input takes one edge, so only a Mix joins two ways into one
|
* on the way adding what it does. An input takes one edge, so only a Mix joins two ways into one
|
||||||
* (#316); a node fed from nowhere is a problem rather than silence. A number fed from a parameter
|
* (#316); a node fed from nowhere is a problem rather than silence. A number fed from a parameter
|
||||||
* stays open in the plan until a voice is started (#311).
|
* stays open in the plan until a voice is started (#311). `assetId` is the graph's asset, which the
|
||||||
|
* taps of a node are named by (#374).
|
||||||
*/
|
*/
|
||||||
export function compileSoundFx(graph: Graph, project?: SoundFxProject): SoundFxBuild {
|
export function compileSoundFx(graph: Graph, project?: SoundFxProject, assetId = ''): SoundFxBuild {
|
||||||
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
|
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
|
||||||
isDef: isSoundFxNodeDef,
|
isDef: isSoundFxNodeDef,
|
||||||
noun: 'sound FX',
|
noun: 'sound FX',
|
||||||
@@ -64,7 +67,9 @@ export function compileSoundFx(graph: Graph, project?: SoundFxProject): SoundFxB
|
|||||||
for (const [key, value] of Object.entries(inputs))
|
for (const [key, value] of Object.entries(inputs))
|
||||||
if ('amount' in value) amounts[key] = value.amount;
|
if ('amount' in value) amounts[key] = value.amount;
|
||||||
else sounds[key] = value.plan;
|
else sounds[key] = value.plan;
|
||||||
return { out: { plan: def.emit(sounds, amounts, node.params, project) } };
|
const plan = def.emit(sounds, amounts, node.params, project);
|
||||||
|
if (!def.tapped) return { out: { plan } };
|
||||||
|
return { out: { plan: withTaps(sounds.in ?? EMPTY_PLAN, plan, tapId(assetId, node.id)) } };
|
||||||
},
|
},
|
||||||
unfed: (node, port) => {
|
unfed: (node, port) => {
|
||||||
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
|
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
|
||||||
@@ -124,6 +129,7 @@ export function soundFxBuildFor(asset: SoundFxAsset, project?: Project): SoundFx
|
|||||||
const build = compileSoundFx(
|
const build = compileSoundFx(
|
||||||
asset.graph,
|
asset.graph,
|
||||||
project && { soundFile: (assetId) => playedSoundById(project, assetId) },
|
project && { soundFile: (assetId) => playedSoundById(project, assetId) },
|
||||||
|
asset.id,
|
||||||
);
|
);
|
||||||
builds.set(asset.id, { fingerprint, build });
|
builds.set(asset.id, { fingerprint, build });
|
||||||
return build;
|
return build;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
toneCutoff,
|
toneCutoff,
|
||||||
} from './saturation';
|
} from './saturation';
|
||||||
import { PHASER_STAGES } from './phaser';
|
import { PHASER_STAGES } from './phaser';
|
||||||
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
|
import { EQ_PICTURE, eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
|
||||||
|
|
||||||
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||||||
|
|
||||||
@@ -48,7 +48,18 @@ export type FxStage =
|
|||||||
| FxLfo<'tremolo'>
|
| FxLfo<'tremolo'>
|
||||||
| FxLfo<'vibrato'>
|
| FxLfo<'vibrato'>
|
||||||
| FxReverb
|
| FxReverb
|
||||||
| FxWet;
|
| FxWet
|
||||||
|
| FxTap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the sound in front of a node or behind it can be listened to (#374); nothing is done to it.
|
||||||
|
* `id` names the node in the project (`tapId`), `at` the side.
|
||||||
|
*/
|
||||||
|
export interface FxTap {
|
||||||
|
kind: 'tap';
|
||||||
|
id: string;
|
||||||
|
at: 'in' | 'out';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What one node adds, blended with what arrives at it (#354): `wet` of its stages and the rest of the
|
* What one node adds, blended with what arrives at it (#354): `wet` of its stages and the rest of the
|
||||||
@@ -319,7 +330,8 @@ export type FxStagePlan =
|
|||||||
| { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
|
| { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
|
||||||
| { kind: 'vibrato'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
|
| { kind: 'vibrato'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
|
||||||
| ({ kind: 'reverb'; impulse?: string } & Record<'decay' | 'damping' | 'predelay' | 'mix', FxAmount>)
|
| ({ kind: 'reverb'; impulse?: string } & Record<'decay' | 'damping' | 'predelay' | 'mix', FxAmount>)
|
||||||
| { kind: 'wet'; wet: FxAmount; stages: readonly FxStagePlan[] };
|
| { kind: 'wet'; wet: FxAmount; stages: readonly FxStagePlan[] }
|
||||||
|
| FxTap;
|
||||||
|
|
||||||
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
||||||
export type FxPlan =
|
export type FxPlan =
|
||||||
@@ -350,6 +362,8 @@ export const DRY_VOICE: FxVoice = { plan: EMPTY_PLAN, params: {} };
|
|||||||
*/
|
*/
|
||||||
export interface SoundFxNodeDef extends NodeDef {
|
export interface SoundFxNodeDef extends NodeDef {
|
||||||
category: 'sound';
|
category: 'sound';
|
||||||
|
/** The sound in front of the node and behind it can be listened to (#374), see `withTaps`. */
|
||||||
|
tapped?: true;
|
||||||
emit(
|
emit(
|
||||||
sounds: Readonly<Record<string, FxPlan>>,
|
sounds: Readonly<Record<string, FxPlan>>,
|
||||||
amounts: Readonly<Record<string, FxAmount>>,
|
amounts: Readonly<Record<string, FxAmount>>,
|
||||||
@@ -417,6 +431,26 @@ export function withStages(plan: FxPlan, stages: readonly FxStagePlan[]): FxPlan
|
|||||||
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
|
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A node by the asset its graph is in: node ids alone repeat in a copied asset. */
|
||||||
|
export const tapId = (assetId: string, nodeId: string): string => `${assetId}:${nodeId}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a tapped node made of `before`, with a tap where its own stages begin and one behind them
|
||||||
|
* (#374): the sound as it came in, and as it goes on – blended by Wet, or not touched when bypassed.
|
||||||
|
*/
|
||||||
|
export function withTaps(before: FxPlan, after: FxPlan, id: string): FxPlan {
|
||||||
|
const at = before.stages.length;
|
||||||
|
return {
|
||||||
|
...after,
|
||||||
|
stages: [
|
||||||
|
...after.stages.slice(0, at),
|
||||||
|
{ kind: 'tap', id, at: 'in' },
|
||||||
|
...after.stages.slice(at),
|
||||||
|
{ kind: 'tap', id, at: 'out' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const BYPASS = { key: 'bypass', label: 'Bypass', kind: 'boolean' as const, default: false };
|
const BYPASS = { key: 'bypass', label: 'Bypass', kind: 'boolean' as const, default: false };
|
||||||
const WET = { ...numberRow('wet', 'Wet', 0, 1, 0.01), default: 1 };
|
const WET = { ...numberRow('wet', 'Wet', 0, 1, 0.01), default: 1 };
|
||||||
|
|
||||||
@@ -596,10 +630,12 @@ export const SoundFxParametricEq: SoundFxNodeDef = effect(
|
|||||||
type: 'SoundFxParametricEq',
|
type: 'SoundFxParametricEq',
|
||||||
label: 'Parametric EQ',
|
label: 'Parametric EQ',
|
||||||
category: 'sound',
|
category: 'sound',
|
||||||
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve. Double-click the node to open its editor: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope.${BLENDS}`,
|
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve, here in small. Double-click the node to open its editor: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope. While the editor is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`,
|
||||||
inputs: [audioIn],
|
inputs: [audioIn],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [],
|
||||||
|
picture: EQ_PICTURE,
|
||||||
|
tapped: true,
|
||||||
emit: (sounds, _amounts, settings) =>
|
emit: (sounds, _amounts, settings) =>
|
||||||
withStages(
|
withStages(
|
||||||
arriving(sounds),
|
arriving(sounds),
|
||||||
@@ -1287,6 +1323,10 @@ const dbToGain = (db: number) => 10 ** (db / 20);
|
|||||||
function stagesOf(plan: readonly FxStagePlan[], params: FxParams, tempo: number): FxStage[] {
|
function stagesOf(plan: readonly FxStagePlan[], params: FxParams, tempo: number): FxStage[] {
|
||||||
const stages: FxStage[] = [];
|
const stages: FxStage[] = [];
|
||||||
for (const stage of plan) {
|
for (const stage of plan) {
|
||||||
|
if (stage.kind === 'tap') {
|
||||||
|
stages.push(stage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (stage.kind === 'wet') {
|
if (stage.kind === 'wet') {
|
||||||
const wetMod = modOf(stage.wet, params);
|
const wetMod = modOf(stage.wet, params);
|
||||||
stages.push({
|
stages.push({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* node, and the curve they make together – worked out as Web Audio's BiquadFilterNode does, so the
|
* node, and the curve they make together – worked out as Web Audio's BiquadFilterNode does, so the
|
||||||
* editor draws what is heard.
|
* editor draws what is heard.
|
||||||
*/
|
*/
|
||||||
|
import type { NodePicture, PictureShape } from '../graph/NodeRegistry';
|
||||||
import { isList, type PropertyBag, type PropertyValue, type StructValue } from '../model/Properties';
|
import { isList, type PropertyBag, type PropertyValue, type StructValue } from '../model/Properties';
|
||||||
import type { FxPassFilter } from './nodes';
|
import type { FxPassFilter } from './nodes';
|
||||||
|
|
||||||
@@ -224,3 +225,89 @@ export function eqCurves(
|
|||||||
});
|
});
|
||||||
return { bands: each, sum };
|
return { bands: each, sum };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A picture of the curve, in the EQ's tab and in its node: frequency to the right in octaves, from
|
||||||
|
* 20 Hz to 20 kHz, and dB up, the same distance either side of 0.
|
||||||
|
*/
|
||||||
|
export interface EqArea {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dB a picture of the curve shows above and below 0. */
|
||||||
|
const EQ_VIEW_DB = EQ_GAIN.max;
|
||||||
|
|
||||||
|
const SPAN = Math.log(EQ_FREQUENCY.max / EQ_FREQUENCY.min);
|
||||||
|
|
||||||
|
export const frequencyToX = (frequency: number, area: EqArea): number =>
|
||||||
|
(Math.log(frequency / EQ_FREQUENCY.min) / SPAN) * area.w;
|
||||||
|
|
||||||
|
export const xToFrequency = (x: number, area: EqArea): number =>
|
||||||
|
EQ_FREQUENCY.min * Math.exp((Math.max(0, Math.min(area.w, x)) / area.w) * SPAN);
|
||||||
|
|
||||||
|
export const dbToY = (db: number, area: EqArea): number => area.h / 2 - (db / EQ_VIEW_DB) * (area.h / 2);
|
||||||
|
|
||||||
|
export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (area.h / 2)) * EQ_VIEW_DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The curves of `eqCurves` laid onto an area through `steps` + 1 points, evenly in octaves. A point
|
||||||
|
* stays within `overflow` of the area, so a deep cut runs out of sight rather than down to -300 dB.
|
||||||
|
*/
|
||||||
|
export function eqCurvePoints(
|
||||||
|
bands: readonly EqBand[],
|
||||||
|
area: EqArea,
|
||||||
|
steps: number,
|
||||||
|
overflow = 0,
|
||||||
|
): { xs: number[]; bands: number[][]; sum: number[] } {
|
||||||
|
const xs = Array.from({ length: steps + 1 }, (_, i) => (i / steps) * area.w);
|
||||||
|
const yOf = (db: number) => Math.max(-overflow, Math.min(area.h + overflow, dbToY(db, area)));
|
||||||
|
const curves = eqCurves(
|
||||||
|
bands,
|
||||||
|
xs.map((x) => xToFrequency(x, area)),
|
||||||
|
);
|
||||||
|
return { xs, bands: curves.bands.map((dbs) => dbs.map(yOf)), sum: curves.sum.map(yOf) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The area between a curve and the 0 dB line at `zero`, as flat point lists: one shape for every
|
||||||
|
* stretch on one side of the line. A single shape would cross itself where the curve crosses the
|
||||||
|
* line, and its fill would come apart.
|
||||||
|
*/
|
||||||
|
export function fillShapes(xs: readonly number[], ys: readonly number[], zero: number): number[][] {
|
||||||
|
const shapes: number[][] = [];
|
||||||
|
let shape = [xs[0]!, zero];
|
||||||
|
for (let i = 0; i < xs.length; i++) {
|
||||||
|
const after = ys[i]! - zero;
|
||||||
|
const before = i > 0 ? ys[i - 1]! - zero : 0;
|
||||||
|
if (before * after < 0) {
|
||||||
|
const x = xs[i - 1]! + ((xs[i]! - xs[i - 1]!) * before) / (before - after);
|
||||||
|
shapes.push([...shape, x, zero]);
|
||||||
|
shape = [x, zero];
|
||||||
|
}
|
||||||
|
shape.push(xs[i]!, ys[i]!);
|
||||||
|
}
|
||||||
|
shapes.push([...shape, xs.at(-1)!, zero]);
|
||||||
|
return shapes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How tall the curve is in the node, and through how many points it is drawn there (#375). */
|
||||||
|
const PICTURE_HEIGHT = 60;
|
||||||
|
const PICTURE_STEPS = 96;
|
||||||
|
|
||||||
|
/** The sum of the bands in the node (#375): the 0 dB line, the area to the curve, and the curve. */
|
||||||
|
export function eqPictureShapes(bands: readonly EqBand[], area: EqArea): PictureShape[] {
|
||||||
|
const { xs, sum } = eqCurvePoints(bands, area, PICTURE_STEPS);
|
||||||
|
const zero = dbToY(0, area);
|
||||||
|
return [
|
||||||
|
{ kind: 'guide', points: [0, zero, area.w, zero] },
|
||||||
|
...fillShapes(xs, sum, zero).map((points) => ({ kind: 'area' as const, points })),
|
||||||
|
{ kind: 'line', points: xs.flatMap((x, i) => [x, sum[i]!]) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Without a band the box stays, with its 0 dB line: the node does not jump at the first one. */
|
||||||
|
export const EQ_PICTURE: NodePicture = {
|
||||||
|
height: PICTURE_HEIGHT,
|
||||||
|
shapes: (params, w) => eqPictureShapes(eqBandsOf(params), { w, h: PICTURE_HEIGHT }),
|
||||||
|
};
|
||||||
@@ -1299,6 +1299,8 @@ export class Editor implements PlayHost {
|
|||||||
if (this.shows('song')) this.songEditor.tick();
|
if (this.shows('song')) this.songEditor.tick();
|
||||||
else this.songEditor.hideMeters();
|
else this.songEditor.hideMeters();
|
||||||
if (this.shows('sound')) this.soundEditor.tick();
|
if (this.shows('sound')) this.soundEditor.tick();
|
||||||
|
if (this.shows('eq')) this.eqEditor.tick();
|
||||||
|
else this.eqEditor.hideSpectrum();
|
||||||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
// 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.
|
// to do it, and its states move just the same.
|
||||||
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||||||
@@ -1334,7 +1336,8 @@ export class Editor implements PlayHost {
|
|||||||
(this.shows('curve') && this.curveEditor.isPlaying) ||
|
(this.shows('curve') && this.curveEditor.isPlaying) ||
|
||||||
(this.shows('pattern') && this.patternEditor.isPlaying) ||
|
(this.shows('pattern') && this.patternEditor.isPlaying) ||
|
||||||
(this.shows('song') && this.songEditor.isPlaying) ||
|
(this.shows('song') && this.songEditor.isPlaying) ||
|
||||||
(this.shows('sound') && this.soundEditor.isPlaying)
|
(this.shows('sound') && this.soundEditor.isPlaying) ||
|
||||||
|
(this.shows('eq') && this.eqEditor.hasSpectrum)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||||
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import { currentNodePalette } from '../graph/NodePalette';
|
import { currentNodePalette } from '../graph/NodePalette';
|
||||||
import { rectOf } from '../textProbe';
|
import { rectOf } from '../textProbe';
|
||||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||||
@@ -111,6 +112,8 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
}),
|
}),
|
||||||
playAt: () => centreOf(editor.soundFxEditor.playButton),
|
playAt: () => centreOf(editor.soundFxEditor.playButton),
|
||||||
stopAt: () => centreOf(editor.soundFxEditor.stopButton),
|
stopAt: () => centreOf(editor.soundFxEditor.stopButton),
|
||||||
|
/** As the header's Play does, also while the tab is behind another one. */
|
||||||
|
play: () => editor.soundFxEditor.play(),
|
||||||
/** The header's parameter values, and setting one as its field would. */
|
/** The header's parameter values, and setting one as its field would. */
|
||||||
params: () => editor.soundFxEditor.previewValues,
|
params: () => editor.soundFxEditor.previewValues,
|
||||||
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
|
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
|
||||||
@@ -134,6 +137,12 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
typeAt: () => centreOf(editor.eqEditor.typeButton),
|
typeAt: () => centreOf(editor.eqEditor.typeButton),
|
||||||
|
/**
|
||||||
|
* The spectra drawn behind the curve (#374): how many, the loudest bin in front of the node and
|
||||||
|
* behind it in the last one, and the taps – which node is listened to, how many play, how many
|
||||||
|
* of them feed an analyser.
|
||||||
|
*/
|
||||||
|
spectrum: () => ({ ...editor.eqEditor.curve.seen, taps: soundLibrary.spectrumTaps() }),
|
||||||
},
|
},
|
||||||
instrument: {
|
instrument: {
|
||||||
current: () => editor.instrumentEditor.currentAssetId,
|
current: () => editor.instrumentEditor.currentAssetId,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type NodeCategory,
|
type NodeCategory,
|
||||||
type NodeDef,
|
type NodeDef,
|
||||||
nodeDef,
|
nodeDef,
|
||||||
|
type NodePicture,
|
||||||
type PortDef,
|
type PortDef,
|
||||||
type PortType,
|
type PortType,
|
||||||
type Value,
|
type Value,
|
||||||
@@ -125,6 +126,8 @@ export interface PortHit {
|
|||||||
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
|
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
|
||||||
export class NodeView extends Widget {
|
export class NodeView extends Widget {
|
||||||
private readonly box = new Graphics();
|
private readonly box = new Graphics();
|
||||||
|
/** What the node draws of its settings below its rows (#375). */
|
||||||
|
private readonly picture = new Graphics();
|
||||||
private readonly ports = new Graphics();
|
private readonly ports = new Graphics();
|
||||||
private readonly texts: Text[] = [];
|
private readonly texts: Text[] = [];
|
||||||
/** The input and output text of each port row, among `texts`. */
|
/** The input and output text of each port row, among `texts`. */
|
||||||
@@ -159,6 +162,7 @@ export class NodeView extends Widget {
|
|||||||
*/
|
*/
|
||||||
private textsOf = '';
|
private textsOf = '';
|
||||||
private drawnOf = '';
|
private drawnOf = '';
|
||||||
|
private pictureOf = '';
|
||||||
/** How often the texts and the box were really made, for the checks. */
|
/** How often the texts and the box were really made, for the checks. */
|
||||||
readonly made = { texts: 0, box: 0 };
|
readonly made = { texts: 0, box: 0 };
|
||||||
|
|
||||||
@@ -178,8 +182,9 @@ export class NodeView extends Widget {
|
|||||||
// Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred
|
// Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred
|
||||||
// nodes were a few hundred draw calls. In the batch they share one.
|
// nodes were a few hundred draw calls. In the batch they share one.
|
||||||
this.box.context.batchMode = 'batch';
|
this.box.context.batchMode = 'batch';
|
||||||
|
this.picture.context.batchMode = 'batch';
|
||||||
this.ports.context.batchMode = 'batch';
|
this.ports.context.batchMode = 'batch';
|
||||||
this.addChild(this.box, this.ports);
|
this.addChild(this.box, this.picture, this.ports);
|
||||||
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +255,10 @@ export class NodeView extends Widget {
|
|||||||
inputs: graph.edges.some((e) => e.to.node === node.id),
|
inputs: graph.edges.some((e) => e.to.node === node.id),
|
||||||
outputs: graph.edges.some((e) => e.from.node === node.id),
|
outputs: graph.edges.some((e) => e.from.node === node.id),
|
||||||
};
|
};
|
||||||
const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
|
const picture = this.shownPicture();
|
||||||
|
const height = this.collapsed
|
||||||
|
? TITLE_HEIGHT
|
||||||
|
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
|
||||||
// Sized before the texts: output labels are placed against the right edge.
|
// Sized before the texts: output labels are placed against the right edge.
|
||||||
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
|
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
|
||||||
const texts = this.textSignature();
|
const texts = this.textSignature();
|
||||||
@@ -387,6 +395,47 @@ export class NodeView extends Widget {
|
|||||||
return TITLE_HEIGHT + PAD + lines * ROW_HEIGHT;
|
return TITLE_HEIGHT + PAD + lines * ROW_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Below the last port row. */
|
||||||
|
private rowsEnd(): number {
|
||||||
|
return this.headerHeight() + this.rowCount() * ROW_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node's picture, while the node is open. */
|
||||||
|
private shownPicture(): NodePicture | null {
|
||||||
|
return (!this.collapsed && this.def.picture) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the picture sits, in node units. */
|
||||||
|
private pictureBox(): { x: number; y: number; w: number; h: number } | null {
|
||||||
|
const picture = this.shownPicture();
|
||||||
|
return picture && { x: PAD, y: this.rowsEnd() + PAD / 2, w: this.w - PAD * 2, h: picture.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drawn again when the settings or the size have changed: a drag in the EQ's tab changes them. */
|
||||||
|
private drawPicture(): void {
|
||||||
|
const picture = this.shownPicture();
|
||||||
|
const box = this.pictureBox();
|
||||||
|
const drawn = picture && box ? JSON.stringify([box, this.node.params]) : '';
|
||||||
|
if (drawn === this.pictureOf) return;
|
||||||
|
this.pictureOf = drawn;
|
||||||
|
this.picture.clear();
|
||||||
|
if (!picture || !box) return;
|
||||||
|
const c = Theme.colors;
|
||||||
|
this.picture.position.set(box.x, box.y);
|
||||||
|
this.picture.rect(0, 0, box.w, box.h).fill(c.graphBackground).stroke({ color: c.nodeBorder, width: 1 });
|
||||||
|
for (const { kind, points } of picture.shapes(this.node.params, box.w)) {
|
||||||
|
if (kind === 'area') {
|
||||||
|
this.picture.poly(points).fill({ color: c.accent, alpha: 0.18 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.picture.moveTo(points[0]!, points[1]!);
|
||||||
|
for (let i = 2; i < points.length; i += 2) this.picture.lineTo(points[i]!, points[i + 1]!);
|
||||||
|
this.picture.stroke(
|
||||||
|
kind === 'guide' ? { color: c.textMuted, width: 1, alpha: 0.5 } : { color: c.accent, width: 1.5 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Local position of a port's centre. */
|
/** Local position of a port's centre. */
|
||||||
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
|
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
|
||||||
const list = isOutput ? this.def.outputs : this.def.inputs;
|
const list = isOutput ? this.def.outputs : this.def.inputs;
|
||||||
@@ -490,6 +539,7 @@ export class NodeView extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
|
this.drawPicture();
|
||||||
const drawn = this.drawSignature();
|
const drawn = this.drawSignature();
|
||||||
if (drawn === this.drawnOf) return;
|
if (drawn === this.drawnOf) return;
|
||||||
this.drawnOf = drawn;
|
this.drawnOf = drawn;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* The editor of a Parametric EQ node (#367), in a tab of its own (#370). The curve is the sum of the
|
* The editor of a Parametric EQ node (#367), in a tab of its own (#370). The curve is the sum of the
|
||||||
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
|
* bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and
|
||||||
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
|
* gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one
|
||||||
* undo step.
|
* undo step. While the tab is seen, the spectrum of what passes the node is drawn behind the curve
|
||||||
|
* (#374).
|
||||||
*/
|
*/
|
||||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
|
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js';
|
||||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||||
@@ -10,6 +11,8 @@ import type { GraphNode } from '../../core/graph';
|
|||||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||||
import type { PropertyValue } from '../../core/model/Properties';
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
import {
|
import {
|
||||||
|
dbToY,
|
||||||
|
type EqArea,
|
||||||
type EqBand,
|
type EqBand,
|
||||||
EQ_BAND_TYPES,
|
EQ_BAND_TYPES,
|
||||||
EQ_BANDS_PARAM,
|
EQ_BANDS_PARAM,
|
||||||
@@ -19,15 +22,21 @@ import {
|
|||||||
EQ_SLOPES,
|
EQ_SLOPES,
|
||||||
eqBandsOf,
|
eqBandsOf,
|
||||||
eqBandsValue,
|
eqBandsValue,
|
||||||
eqCurves,
|
eqCurvePoints,
|
||||||
|
fillShapes,
|
||||||
|
frequencyToX,
|
||||||
hasGain,
|
hasGain,
|
||||||
hasQ,
|
hasQ,
|
||||||
hasSlope,
|
hasSlope,
|
||||||
MAX_EQ_BANDS,
|
MAX_EQ_BANDS,
|
||||||
newEqBand,
|
newEqBand,
|
||||||
withBandSet,
|
withBandSet,
|
||||||
|
xToFrequency,
|
||||||
|
yToDb,
|
||||||
} from '../../core/soundfx/parametricEq';
|
} from '../../core/soundfx/parametricEq';
|
||||||
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
|
import { SoundFxParametricEq, tapId } from '../../core/soundfx/nodes';
|
||||||
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
|
import type { Spectrum } from '../../viewport/spectrum';
|
||||||
import {
|
import {
|
||||||
crispText,
|
crispText,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
@@ -47,16 +56,13 @@ import type { EditorState } from '../state';
|
|||||||
import { TabHeader } from '../TabHeader';
|
import { TabHeader } from '../TabHeader';
|
||||||
import {
|
import {
|
||||||
bandAt,
|
bandAt,
|
||||||
dbToY,
|
|
||||||
type EqArea,
|
|
||||||
EQ_GRID_DB,
|
EQ_GRID_DB,
|
||||||
EQ_GRID_FREQUENCIES,
|
EQ_GRID_FREQUENCIES,
|
||||||
fillShapes,
|
|
||||||
frequencyToX,
|
|
||||||
handleOf,
|
handleOf,
|
||||||
|
SPECTRUM_FLOOR_DB,
|
||||||
|
spectrumPeak,
|
||||||
|
spectrumYs,
|
||||||
wheeledQ,
|
wheeledQ,
|
||||||
xToFrequency,
|
|
||||||
yToDb,
|
|
||||||
} from './eqView';
|
} from './eqView';
|
||||||
|
|
||||||
/** The least the curve shrinks to; otherwise it takes the tab. */
|
/** The least the curve shrinks to; otherwise it takes the tab. */
|
||||||
@@ -71,6 +77,8 @@ const CURVE_STEPS = 320;
|
|||||||
const BAND_COLORS = [
|
const BAND_COLORS = [
|
||||||
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
|
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
|
||||||
];
|
];
|
||||||
|
/** Pixels between the points the spectrum is drawn through (#374). */
|
||||||
|
const SPECTRUM_STEP = 2;
|
||||||
|
|
||||||
/** What the curve asks of the tab around it. */
|
/** What the curve asks of the tab around it. */
|
||||||
interface EqHost {
|
interface EqHost {
|
||||||
@@ -85,18 +93,24 @@ interface EqHost {
|
|||||||
class EqCurve extends Widget {
|
class EqCurve extends Widget {
|
||||||
private readonly bg = new Graphics();
|
private readonly bg = new Graphics();
|
||||||
private readonly grid = new Graphics();
|
private readonly grid = new Graphics();
|
||||||
|
/** What passes the node, behind the curve (#374): faint as it comes in, strong as it goes on. */
|
||||||
|
private readonly spectrum = new Graphics();
|
||||||
private readonly lines = new Graphics();
|
private readonly lines = new Graphics();
|
||||||
private readonly handles = new Graphics();
|
private readonly handles = new Graphics();
|
||||||
private readonly labels: Text[] = [];
|
private readonly labels: Text[] = [];
|
||||||
private readonly numbers: Text[] = [];
|
private readonly numbers: Text[] = [];
|
||||||
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
||||||
|
/** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */
|
||||||
|
private heard: Spectrum | null = null;
|
||||||
|
/** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */
|
||||||
|
seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 };
|
||||||
|
|
||||||
constructor(private readonly host: EqHost) {
|
constructor(private readonly host: EqHost) {
|
||||||
super();
|
super();
|
||||||
this.minWidth = MIN_CURVE_WIDTH;
|
this.minWidth = MIN_CURVE_WIDTH;
|
||||||
this.minHeight = MIN_CURVE_HEIGHT;
|
this.minHeight = MIN_CURVE_HEIGHT;
|
||||||
this.cursor = 'crosshair';
|
this.cursor = 'crosshair';
|
||||||
this.addChild(this.bg, this.grid, this.lines, this.handles);
|
this.addChild(this.bg, this.grid, this.spectrum, this.lines, this.handles);
|
||||||
const muted = textStyle('small').clone();
|
const muted = textStyle('small').clone();
|
||||||
muted.fill = Theme.colors.textMuted;
|
muted.fill = Theme.colors.textMuted;
|
||||||
const onHandle = textStyle('small').clone();
|
const onHandle = textStyle('small').clone();
|
||||||
@@ -231,20 +245,15 @@ class EqCurve extends Widget {
|
|||||||
|
|
||||||
const bands = this.host.bands();
|
const bands = this.host.bands();
|
||||||
this.lines.clear();
|
this.lines.clear();
|
||||||
const xs = Array.from({ length: CURVE_STEPS + 1 }, (_, i) => (i / CURVE_STEPS) * this.w);
|
const curves = eqCurvePoints(bands, area, CURVE_STEPS, 2);
|
||||||
const yOf = (db: number) => Math.max(-2, Math.min(this.h + 2, dbToY(db, area)));
|
const xs = curves.xs;
|
||||||
const curves = eqCurves(
|
curves.bands.forEach((ys, i) => {
|
||||||
bands,
|
trace(this.lines, xs, ys);
|
||||||
xs.map((x) => xToFrequency(x, area)),
|
|
||||||
);
|
|
||||||
curves.bands.forEach((dbs, i) => {
|
|
||||||
this.trace(xs, dbs.map(yOf));
|
|
||||||
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
|
this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 });
|
||||||
});
|
});
|
||||||
const ys = curves.sum.map(yOf);
|
for (const shape of fillShapes(xs, curves.sum, zero))
|
||||||
for (const shape of fillShapes(xs, ys, zero))
|
|
||||||
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
|
this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 });
|
||||||
this.trace(xs, ys);
|
trace(this.lines, xs, curves.sum);
|
||||||
this.lines.stroke({ color: c.accent, width: 2 });
|
this.lines.stroke({ color: c.accent, width: 2 });
|
||||||
|
|
||||||
this.handles.clear();
|
this.handles.clear();
|
||||||
@@ -258,13 +267,58 @@ class EqCurve extends Widget {
|
|||||||
this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 });
|
this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 });
|
||||||
text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2));
|
text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2));
|
||||||
});
|
});
|
||||||
|
this.drawSpectrum();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A path through the points. */
|
/** The spectrum a frame on (#374); one all below the floor of its scale counts as none. */
|
||||||
private trace(xs: readonly number[], ys: readonly number[]): void {
|
showSpectrum(spectrum: Spectrum | null): void {
|
||||||
this.lines.moveTo(xs[0]!, ys[0]!);
|
const peakIn = spectrum && spectrumPeak(spectrum.in, spectrum.binHz);
|
||||||
for (let i = 1; i < xs.length; i++) this.lines.lineTo(xs[i]!, ys[i]!);
|
const peakOut = spectrum && spectrumPeak(spectrum.out, spectrum.binHz);
|
||||||
|
const loud = Math.max(peakIn?.db ?? -Infinity, peakOut?.db ?? -Infinity) > SPECTRUM_FLOOR_DB;
|
||||||
|
const shown = loud ? spectrum : null;
|
||||||
|
if (!shown && !this.heard) return;
|
||||||
|
this.heard = shown;
|
||||||
|
if (shown)
|
||||||
|
this.seen = {
|
||||||
|
frames: this.seen.frames + 1,
|
||||||
|
peakIn: peakIn?.hz ?? 0,
|
||||||
|
peakOut: peakOut?.hz ?? 0,
|
||||||
|
levelOut: peakOut?.db ?? -Infinity,
|
||||||
|
binHz: shown.binHz,
|
||||||
|
};
|
||||||
|
this.drawSpectrum();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasSpectrum(): boolean {
|
||||||
|
return this.heard !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawSpectrum(): void {
|
||||||
|
this.spectrum.clear();
|
||||||
|
const heard = this.heard;
|
||||||
|
if (!heard) return;
|
||||||
|
const c = Theme.colors;
|
||||||
|
const count = Math.floor(this.w / SPECTRUM_STEP);
|
||||||
|
const xs = Array.from({ length: count + 1 }, (_, i) => (i / count) * this.w);
|
||||||
|
for (const [dbs, color, alpha, line] of [
|
||||||
|
[heard.in, c.textMuted, 0.12, false],
|
||||||
|
[heard.out, c.portAudio, 0.18, true],
|
||||||
|
] as const) {
|
||||||
|
const ys = spectrumYs(dbs, heard.binHz, xs, this.area);
|
||||||
|
this.spectrum
|
||||||
|
.poly([0, this.h, ...xs.flatMap((x, i) => [x, ys[i]!]), this.w, this.h])
|
||||||
|
.fill({ color, alpha });
|
||||||
|
if (!line) continue;
|
||||||
|
trace(this.spectrum, xs, ys);
|
||||||
|
this.spectrum.stroke({ color, width: 1, alpha: 0.6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path through the points. */
|
||||||
|
function trace(g: Graphics, xs: readonly number[], ys: readonly number[]): void {
|
||||||
|
g.moveTo(xs[0]!, ys[0]!);
|
||||||
|
for (let i = 1; i < xs.length; i++) g.lineTo(xs[i]!, ys[i]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,6 +413,7 @@ export class EqEditor extends VStack implements EqHost {
|
|||||||
show(assetId: string, nodeId: string): void {
|
show(assetId: string, nodeId: string): void {
|
||||||
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
||||||
this.perFrame.cancel();
|
this.perFrame.cancel();
|
||||||
|
this.curve.showSpectrum(null);
|
||||||
this.before = null;
|
this.before = null;
|
||||||
this.assetId = assetId;
|
this.assetId = assetId;
|
||||||
this.nodeId = nodeId;
|
this.nodeId = nodeId;
|
||||||
@@ -382,6 +437,24 @@ export class EqEditor extends VStack implements EqHost {
|
|||||||
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
|
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only called while the tab is seen: the spectrum of what passes the node, a frame on (#374). */
|
||||||
|
tick(): void {
|
||||||
|
if (!this.nodeId) return;
|
||||||
|
soundLibrary.listen(tapId(this.assetId, this.nodeId));
|
||||||
|
this.curve.showSpectrum(soundLibrary.spectrum());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tab is not seen: no analyser is fed for it. */
|
||||||
|
hideSpectrum(): void {
|
||||||
|
soundLibrary.unlisten();
|
||||||
|
this.curve.showSpectrum(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the spectrum is moving, so the tab has to be drawn every frame. */
|
||||||
|
get hasSpectrum(): boolean {
|
||||||
|
return this.curve.hasSpectrum;
|
||||||
|
}
|
||||||
|
|
||||||
private node(): GraphNode | null {
|
private node(): GraphNode | null {
|
||||||
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
|
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* How the Parametric EQ editor (#367) lays its curve onto a picture: frequency to the right in
|
* What the Parametric EQ's tab (#367) lays onto its picture beside the curve, whose axes are in
|
||||||
* octaves, from 20 Hz to 20 kHz, and dB up, the same distance either side of 0.
|
* `parametricEq.ts`: the handles of the bands, and the spectrum of what passes the node (#374).
|
||||||
*/
|
*/
|
||||||
import { type EqBand, EQ_FREQUENCY, EQ_GAIN, EQ_Q, hasGain } from '../../core/soundfx/parametricEq';
|
import {
|
||||||
|
type EqArea,
|
||||||
export interface EqArea {
|
type EqBand,
|
||||||
w: number;
|
dbToY,
|
||||||
h: number;
|
EQ_Q,
|
||||||
}
|
frequencyToX,
|
||||||
|
hasGain,
|
||||||
/** The dB the picture shows above and below 0. */
|
xToFrequency,
|
||||||
const EQ_VIEW_DB = EQ_GAIN.max;
|
} from '../../core/soundfx/parametricEq';
|
||||||
|
|
||||||
const SPAN = Math.log(EQ_FREQUENCY.max / EQ_FREQUENCY.min);
|
|
||||||
|
|
||||||
export const frequencyToX = (frequency: number, area: EqArea): number =>
|
|
||||||
(Math.log(frequency / EQ_FREQUENCY.min) / SPAN) * area.w;
|
|
||||||
|
|
||||||
export const xToFrequency = (x: number, area: EqArea): number =>
|
|
||||||
EQ_FREQUENCY.min * Math.exp((Math.max(0, Math.min(area.w, x)) / area.w) * SPAN);
|
|
||||||
|
|
||||||
export const dbToY = (db: number, area: EqArea): number => area.h / 2 - (db / EQ_VIEW_DB) * (area.h / 2);
|
|
||||||
|
|
||||||
export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (area.h / 2)) * EQ_VIEW_DB;
|
|
||||||
|
|
||||||
/** Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line. */
|
/** Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line. */
|
||||||
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
|
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
|
||||||
@@ -50,28 +38,6 @@ export function wheeledQ(q: number, deltaY: number): number {
|
|||||||
return Math.max(EQ_Q.min, Math.min(EQ_Q.max, Math.round(turned * 1000) / 1000));
|
return Math.max(EQ_Q.min, Math.min(EQ_Q.max, Math.round(turned * 1000) / 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The area between a curve and the 0 dB line at `zero`, as flat point lists: one shape for every
|
|
||||||
* stretch on one side of the line. A single shape would cross itself where the curve crosses the
|
|
||||||
* line, and its fill would come apart.
|
|
||||||
*/
|
|
||||||
export function fillShapes(xs: readonly number[], ys: readonly number[], zero: number): number[][] {
|
|
||||||
const shapes: number[][] = [];
|
|
||||||
let shape = [xs[0]!, zero];
|
|
||||||
for (let i = 0; i < xs.length; i++) {
|
|
||||||
const after = ys[i]! - zero;
|
|
||||||
const before = i > 0 ? ys[i - 1]! - zero : 0;
|
|
||||||
if (before * after < 0) {
|
|
||||||
const x = xs[i - 1]! + ((xs[i]! - xs[i - 1]!) * before) / (before - after);
|
|
||||||
shapes.push([...shape, x, zero]);
|
|
||||||
shape = [x, zero];
|
|
||||||
}
|
|
||||||
shape.push(xs[i]!, ys[i]!);
|
|
||||||
}
|
|
||||||
shapes.push([...shape, xs.at(-1)!, zero]);
|
|
||||||
return shapes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The lines the picture is ruled with, and what they say. */
|
/** The lines the picture is ruled with, and what they say. */
|
||||||
export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
|
export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
|
||||||
{ hz: 20, label: '20' },
|
{ hz: 20, label: '20' },
|
||||||
@@ -86,3 +52,47 @@ export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
|
|||||||
{ hz: 20000, label: '20k' },
|
{ hz: 20000, label: '20k' },
|
||||||
];
|
];
|
||||||
export const EQ_GRID_DB: readonly number[] = [-18, -12, -6, 0, 6, 12, 18];
|
export const EQ_GRID_DB: readonly number[] = [-18, -12, -6, 0, 6, 12, 18];
|
||||||
|
|
||||||
|
/** The spectrum's own scale (#374): full scale at the top of the picture, this many dB at its foot. */
|
||||||
|
export const SPECTRUM_FLOOR_DB = -96;
|
||||||
|
|
||||||
|
const spectrumY = (db: number, area: EqArea): number =>
|
||||||
|
(Math.min(0, Math.max(SPECTRUM_FLOOR_DB, db)) / SPECTRUM_FLOOR_DB) * area.h;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A spectrum, one level in dB every `binHz`, laid onto the picture at `xs`: each point takes the
|
||||||
|
* loudest bin from halfway to the point before to halfway to the next, or – where the bins lie
|
||||||
|
* further apart than the points, down in the bass – the level between the two nearest.
|
||||||
|
*/
|
||||||
|
export function spectrumYs(
|
||||||
|
dbs: ArrayLike<number>,
|
||||||
|
binHz: number,
|
||||||
|
xs: readonly number[],
|
||||||
|
area: EqArea,
|
||||||
|
): number[] {
|
||||||
|
const last = dbs.length - 1;
|
||||||
|
const level = (bin: number) => {
|
||||||
|
const db = dbs[Math.max(0, Math.min(last, bin))]!;
|
||||||
|
return Number.isFinite(db) ? db : SPECTRUM_FLOOR_DB;
|
||||||
|
};
|
||||||
|
return xs.map((x, i) => {
|
||||||
|
const from = Math.ceil(xToFrequency(i > 0 ? (xs[i - 1]! + x) / 2 : x, area) / binHz);
|
||||||
|
const to = Math.floor(xToFrequency(i < xs.length - 1 ? (x + xs[i + 1]!) / 2 : x, area) / binHz);
|
||||||
|
let db = -Infinity;
|
||||||
|
for (let bin = from; bin <= Math.min(to, last); bin++) db = Math.max(db, level(bin));
|
||||||
|
if (db === -Infinity) {
|
||||||
|
const at = xToFrequency(x, area) / binHz;
|
||||||
|
const below = Math.floor(at);
|
||||||
|
db = level(below) + (level(below + 1) - level(below)) * (at - below);
|
||||||
|
}
|
||||||
|
return spectrumY(db, area);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The frequency of the loudest bin, and how loud it is; null for a spectrum that is all silence. */
|
||||||
|
export function spectrumPeak(dbs: ArrayLike<number>, binHz: number): { hz: number; db: number } | null {
|
||||||
|
let best = -1;
|
||||||
|
for (let bin = 1; bin < dbs.length; bin++)
|
||||||
|
if (Number.isFinite(dbs[bin]) && (best < 0 || dbs[bin]! > dbs[best]!)) best = bin;
|
||||||
|
return best < 0 ? null : { hz: best * binHz, db: dbs[best]! };
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
releaseEnvelope,
|
releaseEnvelope,
|
||||||
stopProcessor,
|
stopProcessor,
|
||||||
} from './fxNodes';
|
} from './fxNodes';
|
||||||
|
import { type Spectrum, SpectrumTaps } from './spectrum';
|
||||||
import { wake } from './wake';
|
import { wake } from './wake';
|
||||||
|
|
||||||
/** A voice is no louder than a sound item of a song turned all the way up. */
|
/** A voice is no louder than a sound item of a song turned all the way up. */
|
||||||
@@ -305,6 +306,8 @@ export class SoundLibrary implements AudioSink {
|
|||||||
private readonly ownerStops = new Map<string, number>();
|
private readonly ownerStops = new Map<string, number>();
|
||||||
/** The tracks of the songs played so far (#334), by name. */
|
/** The tracks of the songs played so far (#334), by name. */
|
||||||
private readonly buses = new Map<string, Bus>();
|
private readonly buses = new Map<string, Bus>();
|
||||||
|
/** The taps of the voices and buses playing now, and the node listened to (#374). */
|
||||||
|
private readonly spectra = new SpectrumTaps();
|
||||||
private readonly peaked = new Map<string, Float32Array>();
|
private readonly peaked = new Map<string, Float32Array>();
|
||||||
/**
|
/**
|
||||||
* The patterns and songs a game plays, each on a scheduler of its own. What it asks after are its
|
* The patterns and songs a game plays, each on a scheduler of its own. What it asks after are its
|
||||||
@@ -531,6 +534,27 @@ export class SoundLibrary implements AudioSink {
|
|||||||
for (const bus of this.buses.values()) this.dropMeter(bus);
|
for (const bus of this.buses.values()) this.dropMeter(bus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
listen(id: string): void {
|
||||||
|
this.spectra.listen(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlisten(): void {
|
||||||
|
this.spectra.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
spectrum(): Spectrum | null {
|
||||||
|
return this.spectra.read();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node listened to, the taps playing, and how many of them feed an analyser (test hook). */
|
||||||
|
spectrumTaps(): { listened: string; held: number; feeding: number } {
|
||||||
|
return this.spectra.info();
|
||||||
|
}
|
||||||
|
|
||||||
/** How many buses are tapped for a meter (test hook). */
|
/** How many buses are tapped for a meter (test hook). */
|
||||||
metered(): number {
|
metered(): number {
|
||||||
return [...this.buses.values()].filter((bus) => bus.meter).length;
|
return [...this.buses.values()].filter((bus) => bus.meter).length;
|
||||||
@@ -865,6 +889,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
tail = stageMade.output;
|
tail = stageMade.output;
|
||||||
knobs.push(...stageMade.knobs);
|
knobs.push(...stageMade.knobs);
|
||||||
if (stageMade.stop) made.stops.push(stageMade.stop);
|
if (stageMade.stop) made.stops.push(stageMade.stop);
|
||||||
|
if (stage.kind === 'tap') made.stops.push(this.spectra.hold(stage, stageMade.output));
|
||||||
nodes.push(stageMade.input, stageMade.output);
|
nodes.push(stageMade.input, stageMade.output);
|
||||||
}
|
}
|
||||||
return { out: tail, latency };
|
return { out: tail, latency };
|
||||||
@@ -1763,8 +1788,11 @@ export class SoundLibrary implements AudioSink {
|
|||||||
tail = made.output;
|
tail = made.output;
|
||||||
knobs.push(...made.knobs);
|
knobs.push(...made.knobs);
|
||||||
if (made.stop) wiring.drivers.push({ start: () => {}, stop: made.stop });
|
if (made.stop) wiring.drivers.push({ start: () => {}, stop: made.stop });
|
||||||
|
if (stage.kind === 'tap')
|
||||||
|
wiring.drivers.push({ start: () => {}, stop: this.spectra.hold(stage, made.output) });
|
||||||
}
|
}
|
||||||
wiring.stages += chain.stages.length;
|
// A tap does nothing to the sound: the stages counted are those that do.
|
||||||
|
wiring.stages += chain.stages.filter((stage) => stage.kind !== 'tap').length;
|
||||||
return tail;
|
return tail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ export function stageNodes(
|
|||||||
follow?.({ envelope: stage.envelope, from: 0, to: 1 }, gain.gain, false);
|
follow?.({ envelope: stage.envelope, from: 0, to: 1 }, gain.gain, false);
|
||||||
return { input: gain, output: gain, knobs: [] };
|
return { input: gain, output: gain, knobs: [] };
|
||||||
}
|
}
|
||||||
|
if (stage.kind === 'tap') {
|
||||||
|
const gain = gainNode(context, 1);
|
||||||
|
return { input: gain, output: gain, knobs: [] };
|
||||||
|
}
|
||||||
if (stage.kind === 'pass') return passNodes(context, stage, follow);
|
if (stage.kind === 'pass') return passNodes(context, stage, follow);
|
||||||
if (stage.kind === 'delay') return delayNodes(context, stage);
|
if (stage.kind === 'delay') return delayNodes(context, stage);
|
||||||
if (stage.kind === 'crush') return crushNodes(context, stage, follow);
|
if (stage.kind === 'crush') return crushNodes(context, stage, follow);
|
||||||
@@ -739,5 +743,5 @@ function stageLevels(stage: FxStage, into: number[]): void {
|
|||||||
into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix);
|
into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix);
|
||||||
else if (stage.kind === 'saturate')
|
else if (stage.kind === 'saturate')
|
||||||
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
|
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
|
||||||
else if (stage.kind !== 'envelope') into.push(stage.gain);
|
else if (stage.kind !== 'envelope' && stage.kind !== 'tap') into.push(stage.gain);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import type { FxTap } from '../core/soundfx/nodes';
|
||||||
|
|
||||||
|
/** Points of one analysis: about 6 Hz apart at 48 kHz, close enough for a bass. */
|
||||||
|
export const SPECTRUM_SIZE = 8192;
|
||||||
|
|
||||||
|
type Side = FxTap['at'];
|
||||||
|
const SIDES: readonly Side[] = ['in', 'out'];
|
||||||
|
|
||||||
|
interface Analysis {
|
||||||
|
sum: GainNode;
|
||||||
|
analyser: AnalyserNode;
|
||||||
|
data: Float32Array<ArrayBuffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both sides of the node in dB, one level every `binHz`. */
|
||||||
|
export interface Spectrum {
|
||||||
|
in: Float32Array<ArrayBuffer>;
|
||||||
|
out: Float32Array<ArrayBuffer>;
|
||||||
|
binHz: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyOf = (id: string, at: Side) => `${at}:${id}`;
|
||||||
|
|
||||||
|
/** Cuts one link; it may be gone with the rest of a voice already. */
|
||||||
|
function cut(from: AudioNode, to: AudioNode): void {
|
||||||
|
try {
|
||||||
|
from.disconnect(to);
|
||||||
|
} catch {
|
||||||
|
// Not linked any more.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
const key = keyOf(tap.id, tap.at);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listens to the node `id` (`tapId`) and to no other. */
|
||||||
|
listen(id: string): void {
|
||||||
|
if (id === this.listened) return;
|
||||||
|
this.stop();
|
||||||
|
this.listened = id;
|
||||||
|
for (const at of SIDES)
|
||||||
|
for (const node of this.held.get(keyOf(id, at)) ?? [])
|
||||||
|
node.connect(this.analysis(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
read(): Spectrum | null {
|
||||||
|
const sides = this.sides;
|
||||||
|
if (!sides) return null;
|
||||||
|
for (const at of SIDES) sides[at].analyser.getFloatFrequencyData(sides[at].data);
|
||||||
|
return {
|
||||||
|
in: sides.in.data,
|
||||||
|
out: sides.out.data,
|
||||||
|
binHz: sides.in.analyser.context.sampleRate / SPECTRUM_SIZE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private analysis(context: BaseAudioContext): Record<Side, Analysis> {
|
||||||
|
return (this.sides ??= { in: analysisOf(context), out: analysisOf(context) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sum the taps of one side go into, and the analyser behind it. With nothing coming in the sum
|
||||||
|
* still hands the analyser silence, so the picture falls away instead of standing still.
|
||||||
|
*/
|
||||||
|
function analysisOf(context: BaseAudioContext): Analysis {
|
||||||
|
const sum = context.createGain();
|
||||||
|
const analyser = context.createAnalyser();
|
||||||
|
analyser.fftSize = SPECTRUM_SIZE;
|
||||||
|
sum.connect(analyser);
|
||||||
|
return { sum, analyser, data: new Float32Array(analyser.frequencyBinCount) };
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import { heldNote, noteFrequency, noteName } from '../../src/core/instrument/not
|
|||||||
import type { BehaviorAsset, InstrumentAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
import type { BehaviorAsset, InstrumentAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||||
import { fxChainOf, type FxPlan } from '../../src/core/soundfx/nodes';
|
import { fxChainOf, type FxPlan, tapId } from '../../src/core/soundfx/nodes';
|
||||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||||
import { keyboardLayout, noteAt, trackerNote } from '../../src/editor/instrument/keys';
|
import { keyboardLayout, noteAt, trackerNote } from '../../src/editor/instrument/keys';
|
||||||
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
||||||
@@ -515,6 +515,24 @@ describe('ports of an instrument', () => {
|
|||||||
f.edit.connect(other, 'out', gain, 'in');
|
f.edit.connect(other, 'out', gain, 'in');
|
||||||
expect(f.instrument.graph.edges.filter((e) => e.to.node === gain.id)).toHaveLength(1);
|
expect(f.instrument.graph.edges.filter((e) => e.to.node === gain.id)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('taps a Parametric EQ in front and behind, named by the instrument and the node (#374)', () => {
|
||||||
|
const f = makeInstrument();
|
||||||
|
const sample = nodeOf(f, 'InstrumentSample');
|
||||||
|
sample.params.sound = f.sound('Piano', KICK).id;
|
||||||
|
const eq = f.edit.addNode('SoundFxParametricEq', { bands: [{ type: 'peaking', gain: 6 }] });
|
||||||
|
f.edit.connect(sample, 'out', eq, 'in');
|
||||||
|
f.edit.connect(eq, 'out', nodeOf(f, 'InstrumentOutput'), 'in');
|
||||||
|
const id = tapId(f.instrument.id, eq.id);
|
||||||
|
const stages = way(
|
||||||
|
instrumentBuildFor(f.instrument, 60, instrumentSourcesOf(f.project)).layers[0],
|
||||||
|
)?.stages;
|
||||||
|
expect(stages?.map((s) => (s.kind === 'tap' ? `${s.at}:${s.id}` : s.kind))).toEqual([
|
||||||
|
`in:${id}`,
|
||||||
|
'pass',
|
||||||
|
`out:${id}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Play note', () => {
|
describe('Play note', () => {
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, and the curve
|
* The Parametric EQ (#367): up to ten bands on one node, compiled to filters in a row, the curve the
|
||||||
* the editor draws from them.
|
* editor draws from them, the curve in the node (#375), and the taps the spectrum is heard at (#374).
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||||
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||||
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
||||||
import { fxChainOf } from '../../src/core/soundfx/nodes';
|
import { fxChainOf, SoundFxParametricEq, tapId } from '../../src/core/soundfx/nodes';
|
||||||
import {
|
import {
|
||||||
|
dbToY,
|
||||||
type EqBand,
|
type EqBand,
|
||||||
eqBandDb,
|
eqBandDb,
|
||||||
eqBandsOf,
|
eqBandsOf,
|
||||||
eqCurves,
|
eqCurves,
|
||||||
|
fillShapes,
|
||||||
|
frequencyToX,
|
||||||
MAX_EQ_BANDS,
|
MAX_EQ_BANDS,
|
||||||
withBandSet,
|
withBandSet,
|
||||||
} from '../../src/core/soundfx/parametricEq';
|
} from '../../src/core/soundfx/parametricEq';
|
||||||
import { fillShapes } from '../../src/editor/soundfx/eqView';
|
import { SPECTRUM_FLOOR_DB, spectrumPeak, spectrumYs } from '../../src/editor/soundfx/eqView';
|
||||||
import { graphEditor, makeProject, roundTrip } from '../helpers';
|
import { graphEditor, makeProject, roundTrip } from '../helpers';
|
||||||
|
|
||||||
const band = (change: Partial<EqBand>): EqBand => ({
|
const band = (change: Partial<EqBand>): EqBand => ({
|
||||||
@@ -39,7 +42,7 @@ function eqLine(bands: unknown) {
|
|||||||
const output = edit.addNode('SoundFxOutput');
|
const output = edit.addNode('SoundFxOutput');
|
||||||
edit.connect(input, 'out', eq, 'in');
|
edit.connect(input, 'out', eq, 'in');
|
||||||
edit.connect(eq, 'out', output, 'in');
|
edit.connect(eq, 'out', output, 'in');
|
||||||
return { ...f, fx };
|
return { ...f, fx, eq };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('the bands of a Parametric EQ', () => {
|
describe('the bands of a Parametric EQ', () => {
|
||||||
@@ -65,15 +68,31 @@ describe('the bands of a Parametric EQ', () => {
|
|||||||
band({ type: 'highpass', frequency: 80, q: Math.SQRT1_2, slope: 24 }),
|
band({ type: 'highpass', frequency: 80, q: Math.SQRT1_2, slope: 24 }),
|
||||||
band({ type: 'lowshelf', frequency: 200, gain: 0 }),
|
band({ type: 'lowshelf', frequency: 200, gain: 0 }),
|
||||||
]);
|
]);
|
||||||
const build = compileSoundFx(f.fx.graph);
|
const build = compileSoundFx(f.fx.graph, undefined, f.fx.id);
|
||||||
|
const id = tapId(f.fx.id, f.eq.id);
|
||||||
expect(build.effects).toBe(1);
|
expect(build.effects).toBe(1);
|
||||||
expect(fxChainOf(build.compiled!).stages).toEqual([
|
expect(fxChainOf(build.compiled!).stages).toEqual([
|
||||||
|
{ kind: 'tap', id, at: 'in' },
|
||||||
{ kind: 'pass', filter: 'peaking', stages: 1, cutoff: 1000, q: 1, gain: 6 },
|
{ kind: 'pass', filter: 'peaking', stages: 1, cutoff: 1000, q: 1, gain: 6 },
|
||||||
{ kind: 'pass', filter: 'highpass', stages: 2, cutoff: 80, q: 20 * Math.log10(Math.SQRT1_2), gain: 0 },
|
{ kind: 'pass', filter: 'highpass', stages: 2, cutoff: 80, q: 20 * Math.log10(Math.SQRT1_2), gain: 0 },
|
||||||
|
{ kind: 'tap', id, at: 'out' },
|
||||||
]);
|
]);
|
||||||
expect(roundTrip(f.project)).toEqual(f.project);
|
expect(roundTrip(f.project)).toEqual(f.project);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('are tapped in front of the node and behind it, behind its Wet and when bypassed too', () => {
|
||||||
|
const f = eqLine([band({ gain: 6 })]);
|
||||||
|
const id = tapId(f.fx.id, f.eq.id);
|
||||||
|
const kinds = () =>
|
||||||
|
compileSoundFx(f.fx.graph, undefined, f.fx.id).compiled!.stages.map((s) =>
|
||||||
|
s.kind === 'tap' ? `${s.at}:${s.id}` : s.kind,
|
||||||
|
);
|
||||||
|
f.eq.params.wet = 0.5;
|
||||||
|
expect(kinds()).toEqual([`in:${id}`, 'wet', `out:${id}`]);
|
||||||
|
f.eq.params.bypass = true;
|
||||||
|
expect(kinds()).toEqual([`in:${id}`, `out:${id}`]);
|
||||||
|
});
|
||||||
|
|
||||||
it('draw the curve Web Audio plays', () => {
|
it('draw the curve Web Audio plays', () => {
|
||||||
const peak = band({ gain: 6 });
|
const peak = band({ gain: 6 });
|
||||||
expect(eqBandDb(peak, 1000)).toBeCloseTo(6, 3);
|
expect(eqBandDb(peak, 1000)).toBeCloseTo(6, 3);
|
||||||
@@ -112,3 +131,70 @@ describe('the bands of a Parametric EQ', () => {
|
|||||||
expect(fillShapes([0, 10, 20], [40, 30, 40], 50)).toEqual([[0, 50, 0, 40, 10, 30, 20, 40, 20, 50]]);
|
expect(fillShapes([0, 10, 20], [40, 30, 40], 50)).toEqual([[0, 50, 0, 40, 10, 30, 20, 40, 20, 50]]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the curve in the node (#375)', () => {
|
||||||
|
const picture = SoundFxParametricEq.picture!;
|
||||||
|
const W = 164;
|
||||||
|
const area = { w: W, h: picture.height };
|
||||||
|
const lineOf = (bands: unknown) => picture.shapes({ bands } as never, W).find((s) => s.kind === 'line')!;
|
||||||
|
|
||||||
|
it('has its highest point where the peak is, as high as its gain', () => {
|
||||||
|
const line = lineOf([band({ gain: 6 })]);
|
||||||
|
let top = 1;
|
||||||
|
for (let i = 3; i < line.points.length; i += 2) if (line.points[i]! < line.points[top]!) top = i;
|
||||||
|
expect(Math.abs(line.points[top - 1]! - frequencyToX(1000, area))).toBeLessThanOrEqual(W / 96);
|
||||||
|
expect(line.points[top]).toBeCloseTo(dbToY(6, area), 1);
|
||||||
|
expect(line.points.length).toBe(97 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its box without a band, flat on the 0 dB line', () => {
|
||||||
|
const shapes = picture.shapes({}, W);
|
||||||
|
const zero = dbToY(0, area);
|
||||||
|
expect(picture.height).toBe(60);
|
||||||
|
expect(shapes[0]).toEqual({ kind: 'guide', points: [0, zero, W, zero] });
|
||||||
|
const line = shapes.find((s) => s.kind === 'line')!;
|
||||||
|
expect(line.points.filter((_, i) => i % 2 === 1).every((y) => y === zero)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays inside its box however deep a cut goes', () => {
|
||||||
|
const line = lineOf([band({ type: 'highpass', frequency: 2000, slope: 48 })]);
|
||||||
|
const ys = line.points.filter((_, i) => i % 2 === 1);
|
||||||
|
expect(Math.max(...ys)).toBe(picture.height);
|
||||||
|
expect(Math.min(...ys)).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the spectrum behind the curve (#374)', () => {
|
||||||
|
const BIN = 48000 / 8192;
|
||||||
|
const area = { w: 600, h: 300 };
|
||||||
|
const xs = Array.from({ length: 301 }, (_, i) => i * 2);
|
||||||
|
/** All silence but one loud bin at about 1 kHz. */
|
||||||
|
const sine = () => {
|
||||||
|
const dbs = new Float32Array(4096).fill(-Infinity);
|
||||||
|
dbs[Math.round(1000 / BIN)] = -12;
|
||||||
|
return dbs;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('finds the loudest bin, and nothing in silence', () => {
|
||||||
|
expect(spectrumPeak(sine(), BIN)!.hz).toBeCloseTo(Math.round(1000 / BIN) * BIN, 6);
|
||||||
|
expect(spectrumPeak(sine(), BIN)!.db).toBe(-12);
|
||||||
|
expect(spectrumPeak(new Float32Array(4096).fill(-Infinity), BIN)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the loud bin where 1 kHz is, at its level, and silence on the floor', () => {
|
||||||
|
const ys = spectrumYs(sine(), BIN, xs, area);
|
||||||
|
const top = ys.indexOf(Math.min(...ys));
|
||||||
|
expect(Math.abs(xs[top]! - frequencyToX(1000, area))).toBeLessThanOrEqual(2);
|
||||||
|
expect(ys[top]).toBeCloseTo((-12 / SPECTRUM_FLOOR_DB) * area.h, 6);
|
||||||
|
expect(ys.filter((y) => y === area.h).length).toBeGreaterThan(xs.length - 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('down in the bass, where bins lie further apart than the points, goes between them', () => {
|
||||||
|
const dbs = new Float32Array(4096).fill(-60);
|
||||||
|
dbs[4] = -20;
|
||||||
|
const at = (hz: number) => spectrumYs(dbs, BIN, [frequencyToX(hz, area)], area)[0]!;
|
||||||
|
// Bin 4 is 23.4 Hz, bin 5 29.3 Hz: halfway the level is halfway.
|
||||||
|
expect(at(4 * BIN)).toBeCloseTo((-20 / SPECTRUM_FLOOR_DB) * area.h, 6);
|
||||||
|
expect(at(4.5 * BIN)).toBeCloseTo((-40 / SPECTRUM_FLOOR_DB) * area.h, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -638,10 +638,12 @@ describe('Bypass and Wet (#354)', () => {
|
|||||||
for (const type of Object.keys(EFFECTS)) {
|
for (const type of Object.keys(EFFECTS)) {
|
||||||
const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50, bands: [{ gain: 6 }] };
|
const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50, bands: [{ gain: 6 }] };
|
||||||
expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]);
|
expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]);
|
||||||
expect([
|
// The taps a Parametric EQ keeps do nothing to the sound (#374).
|
||||||
|
const bypassed = compileSoundFx(line([{ type, params: { ...on, bypass: true } }]).fx.graph).compiled!;
|
||||||
|
expect([type, { ...bypassed, stages: bypassed.stages.filter((s) => s.kind !== 'tap') }]).toEqual([
|
||||||
type,
|
type,
|
||||||
compileSoundFx(line([{ type, params: { ...on, bypass: true } }]).fx.graph).compiled,
|
EMPTY_PLAN,
|
||||||
]).toEqual([type, EMPTY_PLAN]);
|
]);
|
||||||
const def = SOUND_FX_NODES.find((d) => d.type === type)!;
|
const def = SOUND_FX_NODES.find((d) => d.type === type)!;
|
||||||
expect([def.dimmed?.({ bypass: true }), def.dimmed?.({})]).toEqual([true, false]);
|
expect([def.dimmed?.({ bypass: true }), def.dimmed?.({})]).toEqual([true, false]);
|
||||||
expect([def.hiddenParams?.({ bypass: true }), def.hiddenParams?.({})]).toEqual([[], ['bypass']]);
|
expect([def.hiddenParams?.({ bypass: true }), def.hiddenParams?.({})]).toEqual([[], ['bypass']]);
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* The taps of the spectrum (#374): a voice's tap feeds the analyser of its side only while its node
|
||||||
|
* is listened to, and once that stops – or the voice does – nothing feeds one any more.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { FxTap } from '../../src/core/soundfx/nodes';
|
||||||
|
import { SpectrumTaps } from '../../src/viewport/spectrum';
|
||||||
|
|
||||||
|
/** A node that remembers what it is linked to. */
|
||||||
|
class FakeNode {
|
||||||
|
readonly links = new Set<FakeNode>();
|
||||||
|
fftSize = 0;
|
||||||
|
frequencyBinCount = 4;
|
||||||
|
constructor(readonly context: FakeContext) {}
|
||||||
|
connect(to: FakeNode): FakeNode {
|
||||||
|
this.links.add(to);
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
disconnect(to?: FakeNode): void {
|
||||||
|
if (!to) this.links.clear();
|
||||||
|
else if (!this.links.delete(to)) throw new Error('not linked');
|
||||||
|
}
|
||||||
|
getFloatFrequencyData(data: Float32Array): void {
|
||||||
|
data.fill(this.context.made.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeContext {
|
||||||
|
readonly sampleRate = 48000;
|
||||||
|
readonly made: FakeNode[] = [];
|
||||||
|
private make(): FakeNode {
|
||||||
|
const node = new FakeNode(this);
|
||||||
|
this.made.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
createGain(): FakeNode {
|
||||||
|
return this.make();
|
||||||
|
}
|
||||||
|
createAnalyser(): FakeNode {
|
||||||
|
return this.make();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tap = (id: string, at: FxTap['at']): FxTap => ({ kind: 'tap', id, at });
|
||||||
|
|
||||||
|
describe('the taps of the spectrum', () => {
|
||||||
|
it('feed nothing and make nothing before their node is listened to', () => {
|
||||||
|
const context = new FakeContext();
|
||||||
|
const taps = new SpectrumTaps();
|
||||||
|
taps.hold(tap('fx:eq', 'in'), new FakeNode(context) as never);
|
||||||
|
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
|
||||||
|
expect(context.made).toHaveLength(0);
|
||||||
|
expect(taps.read()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feed the analyser of their side while listened to, a voice started before as well', () => {
|
||||||
|
const context = new FakeContext();
|
||||||
|
const taps = new SpectrumTaps();
|
||||||
|
const before = new FakeNode(context);
|
||||||
|
taps.hold(tap('fx:eq', 'in'), before as never);
|
||||||
|
taps.listen('fx:eq');
|
||||||
|
const after = new FakeNode(context);
|
||||||
|
taps.hold(tap('fx:eq', 'out'), after as never);
|
||||||
|
const other = new FakeNode(context);
|
||||||
|
taps.hold(tap('fx:other', 'out'), other as never);
|
||||||
|
expect(taps.info()).toEqual({ listened: 'fx:eq', held: 3, feeding: 2 });
|
||||||
|
// Two sums, each into its analyser: the in side made first.
|
||||||
|
const [sumIn, , sumOut] = context.made;
|
||||||
|
expect([...before.links]).toEqual([sumIn]);
|
||||||
|
expect([...after.links]).toEqual([sumOut]);
|
||||||
|
expect(other.links.size).toBe(0);
|
||||||
|
expect(taps.read()?.binHz).toBeCloseTo(48000 / 8192, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('let go of the analysers once nobody listens, or once the voice stops', () => {
|
||||||
|
const context = new FakeContext();
|
||||||
|
const taps = new SpectrumTaps();
|
||||||
|
taps.listen('fx:eq');
|
||||||
|
const voice = new FakeNode(context);
|
||||||
|
const stopVoice = taps.hold(tap('fx:eq', 'in'), voice as never);
|
||||||
|
const bus = new FakeNode(context);
|
||||||
|
taps.hold(tap('fx:eq', 'out'), bus as never);
|
||||||
|
stopVoice();
|
||||||
|
stopVoice();
|
||||||
|
expect(voice.links.size).toBe(0);
|
||||||
|
expect(taps.info()).toEqual({ listened: 'fx:eq', held: 1, feeding: 1 });
|
||||||
|
taps.stop();
|
||||||
|
expect(bus.links.size).toBe(0);
|
||||||
|
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
|
||||||
|
expect(taps.read()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -572,7 +572,56 @@ export async function sound(t) {
|
|||||||
JSON.stringify({ eqDragged, eqUndone, eqWheeled }),
|
JSON.stringify({ eqDragged, eqUndone, eqWheeled }),
|
||||||
);
|
);
|
||||||
await shot('34-parametric-eq');
|
await shot('34-parametric-eq');
|
||||||
|
// #374: floated beside the Sound FX tab, the EQ's tab draws the spectrum of what passes the node:
|
||||||
|
// the blip's 880 Hz is the loudest bin in front of the EQ and behind it. Once the tab is not
|
||||||
|
// seen, no tap feeds an analyser any more, though the voice still plays.
|
||||||
|
await evaluate(`__gm.floatDocument(${JSON.stringify(`eq:${fxId}:fx-eq`)})`);
|
||||||
|
await evaluate('__gm.soundfx.setParam("depth", 0)');
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
|
const framesBefore = (await json('__gm.eq.spectrum()')).frames;
|
||||||
|
await evaluate('__gm.soundfx.play()');
|
||||||
|
const spectrum = await waitFor(
|
||||||
|
() => json('__gm.eq.spectrum()'),
|
||||||
|
(s) => s.frames >= framesBefore + 3,
|
||||||
|
);
|
||||||
|
await shot('34-eq-spectrum');
|
||||||
|
const nearBlip = (hz) => Math.abs(hz - 880) <= spectrum.binHz;
|
||||||
|
check(
|
||||||
|
'floated beside the Sound FX tab, the EQ draws the blip’s 880 Hz as the loudest bin on both sides',
|
||||||
|
spectrum.taps.listened === `${fxId}:fx-eq` &&
|
||||||
|
spectrum.frames >= framesBefore + 3 &&
|
||||||
|
nearBlip(spectrum.peakIn) &&
|
||||||
|
nearBlip(spectrum.peakOut) &&
|
||||||
|
spectrum.levelOut > -60,
|
||||||
|
JSON.stringify(spectrum),
|
||||||
|
);
|
||||||
|
const eqWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'eq');
|
||||||
|
await click(eqWindow.dock.x, eqWindow.dock.y);
|
||||||
|
const unseen = await evaluate(`(async () => {
|
||||||
|
const frame = () => new Promise((done) => requestAnimationFrame(() => done()));
|
||||||
|
__gm.sound.stopAll();
|
||||||
|
const before = __gm.sound.stats().started;
|
||||||
|
__gm.soundfx.play();
|
||||||
|
for (let i = 0; i < 120 && __gm.sound.stats().started === before; i++) await frame();
|
||||||
|
await frame();
|
||||||
|
const seen = __gm.eq.spectrum().taps;
|
||||||
|
__gm.openDocument(${JSON.stringify(`asset:${fxId}`)});
|
||||||
|
await frame();
|
||||||
|
await frame();
|
||||||
|
return { seen, unseen: __gm.eq.spectrum().taps, playing: __gm.sound.stats().playing };
|
||||||
|
})()`);
|
||||||
|
check(
|
||||||
|
'once the EQ’s tab is not seen, no tap feeds an analyser, though the voice still plays',
|
||||||
|
unseen.seen.held >= 2 &&
|
||||||
|
unseen.seen.feeding === unseen.seen.held &&
|
||||||
|
unseen.unseen.listened === '' &&
|
||||||
|
unseen.unseen.held >= 2 &&
|
||||||
|
unseen.unseen.feeding === 0 &&
|
||||||
|
unseen.playing === 1,
|
||||||
|
JSON.stringify(unseen),
|
||||||
|
);
|
||||||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||||||
|
await shot('34-eq-node');
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
const eqBefore = await stats();
|
const eqBefore = await stats();
|
||||||
await click(fxPlayAt.x, fxPlayAt.y);
|
await click(fxPlayAt.x, fxPlayAt.y);
|
||||||
|
|||||||
Reference in new issue
Block a user