From 925e635e9b7042e163896104baf2d71b27a0cfe9 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Fri, 30 Oct 2026 15:20:27 +0000 Subject: [PATCH 1/3] Report each 900-series module's own reading and light its routing block from it (t-149) The 900 engine now reports, 25 times a second, each 921's wave and pitch, each 911's level and each 902's gain (MoogMeters), carried on the meter message as `moog`. The routing gives each 921/911/902 block a meter for its own slot, read off its output jack's signal name since slots are numbered in build order. routingActivity() lights the block from it with the wave / blur rules, a 902 never brighter than the output it carries; with no reading yet it falls back to the output's level as before. The full set is 23 numbers in reused arrays, so it is reported whole rather than filtered to the patched modules. Co-Authored-By: Claude Opus 5.5 --- components/app/synth/engine.ts | 1 + components/app/synth/routing-view.tsx | 1 + components/app/synth/stores.ts | 3 + lib/app/synths/audio/dsp-core.ts | 11 +- lib/app/synths/audio/moog-core.ts | 37 +++++- lib/app/synths/contract.ts | 13 +- lib/app/synths/lib/routing-activity.ts | 48 ++++++-- lib/app/synths/routing/moog900.ts | 33 ++++- .../lib/app/synths/audio/dsp-core.test.ts | 14 +++ .../lib/app/synths/audio/moog-core.test.ts | 116 ++++++++++++++++++ .../app/synths/lib/routing-activity.test.ts | 115 +++++++++++++++++ 11 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 tests/unit/lib/app/synths/audio/moog-core.test.ts diff --git a/components/app/synth/engine.ts b/components/app/synth/engine.ts index 0340f02bd..35228dfb6 100644 --- a/components/app/synth/engine.ts +++ b/components/app/synth/engine.ts @@ -33,6 +33,7 @@ export function toStores(e: SynthEvent): void { seq: e.seq == null ? -1 : e.seq, pulser: e.pulser || 0, eyes: e.eyes, + moog: e.moog, })); else if (e.type === 'note') notesStore.set((ns) => diff --git a/components/app/synth/routing-view.tsx b/components/app/synth/routing-view.tsx index 10f97800c..189f7acea 100644 --- a/components/app/synth/routing-view.tsx +++ b/components/app/synth/routing-view.tsx @@ -361,6 +361,7 @@ export function RoutingView({ lv: m.oscLv?.[k] ?? 0, })), out, + moog: m.moog, } : SILENT; const routing = routingRef.current; diff --git a/components/app/synth/stores.ts b/components/app/synth/stores.ts index 333bc048c..f605d0f9a 100644 --- a/components/app/synth/stores.ts +++ b/components/app/synth/stores.ts @@ -12,6 +12,7 @@ import { useSyncExternalStore } from 'react'; import type { ControlValue } from '@/lib/app/synths/contract'; +import type { MoogMeters } from '@/lib/app/synths/audio/moog-core'; /** A store: read it, replace it (or map it), and hear about changes. */ export interface Store { @@ -72,6 +73,8 @@ export interface Meters { pulser?: number; /** The SOLAR 42N's light on each voice's photosensor, 0..1. */ eyes?: number[]; + /** A 900-series modular's reading per module: the routing view lights each 921, 911 and 902 with its own. */ + moog?: MoogMeters; } /** A control, jack or area the pointer, the keyboard or a press is on. */ diff --git a/lib/app/synths/audio/dsp-core.ts b/lib/app/synths/audio/dsp-core.ts index b44e74cdf..f8cdfdd15 100644 --- a/lib/app/synths/audio/dsp-core.ts +++ b/lib/app/synths/audio/dsp-core.ts @@ -26,7 +26,12 @@ import type { TremoloParams, } from '@/lib/app/synths/contract'; import { createFx } from '@/lib/app/synths/audio/dm-fx'; -import { createMoog, MOOG_SIGNALS, MOOG_DESTS } from '@/lib/app/synths/audio/moog-core'; +import { + createMoog, + MOOG_SIGNALS, + MOOG_DESTS, + type MoogMeters, +} from '@/lib/app/synths/audio/moog-core'; import { createSolar, SOLAR_SIGNALS, SOLAR_DESTS } from '@/lib/app/synths/audio/solar-core'; import { DEFAULT_VELOCITY, noteEvents } from '@/lib/app/synths/audio/note-events'; import { @@ -80,6 +85,8 @@ export interface MeterEvent { pulser?: number; /** The SOLAR 42N's light on each voice's photosensor, 0..1. */ eyes?: number[]; + /** A 900-series modular's reading per module (`MoogMeters`), for the routing view. */ + moog?: MoogMeters; } /** What the synth reports back. */ @@ -2313,6 +2320,8 @@ function createVoice( ...(P.moog ? { gate: gate > 0 } : null), // the 960's lamps: its stage (0-based) while it has one ...(P.moog && moog.seqState() ? { seq: moog.seqState()!.stage } : null), + // each 921, 911 and 902 on its own, so the routing view lights a module with its own signal + ...(P.moog ? { moog: moog.meters() } : null), ...(P.solar ? { seq: solar.seqStage(), eyes: solar.eyes() } : null), }); overloadPeak = 0; diff --git a/lib/app/synths/audio/moog-core.ts b/lib/app/synths/audio/moog-core.ts index 9d637dd24..755659a1a 100644 --- a/lib/app/synths/audio/moog-core.ts +++ b/lib/app/synths/audio/moog-core.ts @@ -125,6 +125,18 @@ export const MOOG_DESTS: string[] = [ /** The 960 sequencer's settings, as EngineParams `moog.s960` carries them. */ type S960 = NonNullable; +/** + * Each module's reading, for the routing view: per oscillator slot its wave (−1..1, the sine) and pitch in Hz, per + * envelope slot its level (0..1 of the +6 V peak), per amplifier slot its gain (0..1). A slot that is not running + * reads 0. Slot k is index k − 1, as in the signal names (`v3sin` is `vco[2]`). + */ +export interface MoogMeters { + vco: number[]; + vcoHz: number[]; + env: number[]; + amp: number[]; +} + /** What `createMoog` hands back to the voice. */ export interface Moog { /** Take new settings: `m` = EngineParams `moog`, `used` = the signal names some cable reads. */ @@ -135,6 +147,8 @@ export interface Moog { envLevel: (i: number) => number; /** The 960's stage (0-based) and whether it runs, or null without a 960. */ seqState: () => { stage: number; run: boolean } | null; + /** Every module's reading now; the same object each call, rewritten, so the meter makes no garbage. */ + meters: () => MoogMeters; } /** Trigger levels. A V-trigger is +5 V while on. An S-trigger idles high and is pulled to 0 V while on. */ @@ -182,6 +196,7 @@ export function createMoog( syPrev: 0, tvPrev: 0, tsPrev: TRIG_HI, + hz: 0, o: ['sin', 'tri', 'saw', 'rect', 'aux', 'lft', 'lwt'].map((w) => sig(`v${n}${w}`)), d: ['lf', 'lw', 'dc', 'ac', 'sy', 'wc', 'tv', 'ts'].map((w) => dst(`v${n}${w}`)), p: null as MoogVco | null, @@ -200,6 +215,7 @@ export function createMoog( on: false, v: 0, vT: 0, + g: 0, exp: false, o: [sig(`a${n}p`), sig(`a${n}n`)], d: [dst(`a${n}ip`), dst(`a${n}in`), dst(`a${n}cv`)], @@ -578,6 +594,7 @@ export function createMoog( o.tsPrev = ts; } o.phase = ph; + o.hz = hz; let w; if (q.kind === '921') w = q.width! + ev(D[5]) * 0.15; else w = cabled[D[1]] ? lwIn * 0.16 : 0.5; @@ -650,6 +667,7 @@ export function createMoog( g = cv <= 0 ? 0 : Math.exp((cv - 6) * 1.38) * (cv < 1 ? cv : 1); // about 12 dB per volt else g = cv <= 0 ? 0 : cv / 6; if (g > 1.6) g = 1.6; + v.g = g; const y = (ev(v.d[0]) - ev(v.d[1])) * g; S[v.o[0]] = y; S[v.o[1]] = -y; @@ -816,5 +834,22 @@ export function createMoog( /** The 960's current stage (0-based) and whether it is running, for the panel lamps. */ const seqState = (): { stage: number; run: boolean } | null => sq.p ? { stage: sq.st, run: sq.run } : null; - return { set, step, envLevel, seqState }; + const out: MoogMeters = { + vco: vco.map(() => 0), + vcoHz: vco.map(() => 0), + env: env.map(() => 0), + amp: vca.map(() => 0), + }; + /** Each module's reading (`MoogMeters`), 25 times a second for the routing view. */ + function meters(): MoogMeters { + for (let i = 0; i < vco.length; i++) { + const o = vco[i]; + out.vco[i] = o.on ? Math.sin(TWO_PI * o.phase) : 0; + out.vcoHz[i] = o.on ? o.hz : 0; + } + for (let i = 0; i < env.length; i++) out.env[i] = env[i].on ? Math.min(env[i].level / 6, 1) : 0; + for (let i = 0; i < vca.length; i++) out.amp[i] = vca[i].on ? Math.min(vca[i].g, 1) : 0; + return out; + } + return { set, step, envLevel, seqState, meters }; } diff --git a/lib/app/synths/contract.ts b/lib/app/synths/contract.ts index b506669a0..74d665a63 100644 --- a/lib/app/synths/contract.ts +++ b/lib/app/synths/contract.ts @@ -1305,10 +1305,17 @@ export type RoutingSymbol = | 'util'; /** - * The engine meters a block can follow (`MeterEvent`): the gate, the first two envelopes, the LFO, and an oscillator - * by its place in the engine's (`osc1` is the first). + * The engine meters a block can follow (`MeterEvent`): the gate, the first two envelopes, the LFO, an oscillator by + * its place in the engine's (`osc1` is the first), and a 900-series module by its slot. */ -export type RoutingMeter = 'gate' | 'env1' | 'env2' | 'lfo' | 'osc1' | 'osc2' | 'osc3' | 'osc4'; +export type RoutingMeter = + 'gate' | 'env1' | 'env2' | 'lfo' | 'osc1' | 'osc2' | 'osc3' | 'osc4' | ModuleMeter; + +/** + * One 900-series module's own reading, by its engine slot (1-based, as in the signal names: `vco:3` is the oscillator + * whose outputs are `v3sin`…): an oscillator's wave, an envelope's level, an amplifier's gain (`MoogMeters`). + */ +export type ModuleMeter = `${'vco' | 'env' | 'amp'}:${number}`; /** What an edge carries. */ export type RoutingSignal = 'audio' | 'cv' | 'gate'; diff --git a/lib/app/synths/lib/routing-activity.ts b/lib/app/synths/lib/routing-activity.ts index ba344d8b9..5b23a1e02 100644 --- a/lib/app/synths/lib/routing-activity.ts +++ b/lib/app/synths/lib/routing-activity.ts @@ -13,14 +13,18 @@ // The audio path is the audio edges, and the live audio cables that carry audio on from a block that makes it or // is already on the path: a modular's patched line (it has no edges) and a block only a pin reaches glow the way a // wired path does, while a mixer or mult that only passes control voltage on stays off it. -// - On a modular (every path a cable), a live module that makes its own signal and has no meter (a 911 envelope, a -// 921 modulating another) is shaping the sound, so it too follows the output's level: the engine reports nothing -// per module, and what feeds it (the keyboard's gate) says nothing of its own release or rate. +// - A 900-series module follows its own engine slot (`MoogMeters`): a 921 its wave, as an oscillator above (pulsing at +// LO, steady at the output's level at audio pitch), a 911 its envelope, a 902 its gain, no brighter than the output +// while it carries audio. So two oscillators at different rates, or two envelopes on different contours, glow apart. +// - On a modular (every path a cable), a live module that makes its own signal and has no reading (the engine has not +// reported yet) is shaping the sound, so it too follows the output's level: what feeds it (the keyboard's gate) +// says nothing of its own release or rate. // - Any other block (the pitch and modulation buses) glows with the brightest live line into it. // - A live line glows with the block it leaves. // // Pure: the view reads the meters and the output's level once a frame and eases what it draws towards these targets // with `settle()`. +import type { MoogMeters } from '@/lib/app/synths/audio/moog-core'; import type { RoutingMeter, SynthRouting } from '@/lib/app/synths/contract'; import type { RoutingState } from '@/lib/app/synths/lib/routing'; @@ -36,6 +40,8 @@ export interface ActivityMeters { osc: { v: number; hz: number; lv: number }[]; /** The output's level, 0..1 (`levelOfRms()`). */ out: number; + /** A 900-series modular's reading per module, while the engine reports one. */ + moog?: MoogMeters; } export const SILENT: ActivityMeters = { @@ -69,6 +75,7 @@ export interface RoutingActivity { const clamp = (v: number) => (Number.isFinite(v) ? Math.min(Math.max(v, 0), 1) : 0); const OSC_METERS: Partial> = { osc1: 0, osc2: 1, osc3: 2, osc4: 3 }; +const MODULE_METER = /^(vco|env|amp):(\d+)$/; /** How far a wave at `hz` is drawn steady rather than as it moves: 0 at `SLOW_HZ` and below, 1 at `FAST_HZ`. */ const blur = (hz: number) => clamp((hz - SLOW_HZ) / (FAST_HZ - SLOW_HZ)); @@ -80,14 +87,40 @@ function wave(v: number, hz: number, steady: number): number { } /** - * A metered block's glow, and whether it is a moving wave; `loudest` is the highest oscillator mixer level. Null for - * an oscillator the engine did not report. + * A 900-series module's glow from its own slot, and whether it is a moving wave; `audio` = it is on the audio path. + * Null while the engine reports nothing for it. + */ +function moduleReading( + kind: string, + i: number, + m: ActivityMeters, + audio: boolean +): [number, boolean] | null { + const r = m.moog; + if (!r) return null; + if (kind === 'vco') { + const v = r.vco[i]; + const hz = r.vcoHz[i]; + if (v === undefined || !(hz > 0)) return null; + return [wave(v, hz, clamp(m.out)), blur(hz) < 1]; + } + const level = kind === 'env' ? r.env[i] : r.amp[i]; + if (level === undefined) return null; + return [kind === 'amp' && audio ? Math.min(clamp(level), clamp(m.out)) : clamp(level), false]; +} + +/** + * A metered block's glow, and whether it is a moving wave; `loudest` is the highest oscillator mixer level, `audio` + * whether the block is on the audio path. Null for an oscillator or module the engine did not report. */ function reading( meter: RoutingMeter, m: ActivityMeters, - loudest: number + loudest: number, + audio: boolean ): [number, boolean] | null { + const slot = MODULE_METER.exec(meter); + if (slot) return moduleReading(slot[1], Number(slot[2]) - 1, m, audio); if (meter === 'gate') return [m.gate ? 1 : 0, false]; if (meter === 'env1' || meter === 'env2') return [clamp(m[meter]), false]; // A free-running LFO is always working: steady, it glows at three quarters. @@ -129,7 +162,8 @@ export function routingActivity( const pulsing: Record = {}; const fedOnly: string[] = []; for (const b of routing.blocks) { - const metered = state.blocks[b.id] && b.meter ? reading(b.meter, meters, loudest) : null; + const metered = + state.blocks[b.id] && b.meter ? reading(b.meter, meters, loudest, audio.has(b.id)) : null; pulsing[b.id] = !!metered?.[1]; if (!state.blocks[b.id]) blocks[b.id] = 0; else if (metered) blocks[b.id] = metered[0]; diff --git a/lib/app/synths/routing/moog900.ts b/lib/app/synths/routing/moog900.ts index 0d57fda80..0763cc419 100644 --- a/lib/app/synths/routing/moog900.ts +++ b/lib/app/synths/routing/moog900.ts @@ -9,12 +9,13 @@ * app's output panel (`appout`, jacks `j.out.l` and `j.out.r`) is not on the hardware, so the spec has no module for * it; its block keeps the area's id. What each kind of module is, says and carries is written below, from the spec. * - * Only the CM1A has a `meter`: the engine reports the key being held as its gate on the 900 path. The engine's `env1` - * and `env2` are its own two envelopes, which the 900 sound path does not use (the 911s are separate slots in - * `moog-core.ts`), and its oscillator and LFO meters describe no 921, so a live 911 or 921 follows the output's level - * (`routingActivity()`). Each module following its own reading needs the engine to report one per module. + * The CM1A's `meter` is the gate: the engine reports the key being held as its gate on the 900 path. Each 921 and + * 921B, 911 and 902 follows its own engine slot (`MoogMeters`): its wave, its envelope, its gain. The engine's `env1` + * and `env2` are its own two envelopes, which the 900 sound path does not use, so no block reads them. The slot is + * read off the module's output jacks (`j.m911_2.out` carries `e2`), as the slots are numbered in build order. */ import type { + ModuleMeter, RoutingBlock, RoutingMeter, RoutingSymbol, @@ -209,6 +210,26 @@ const jackModule = (id: string) => { return m === 'out' ? OUT : m; }; +/** The output signal that numbers a module's engine slot, and the reading that slot reports. */ +const SLOT_SIGNALS: [RegExp, 'vco' | 'env' | 'amp'][] = [ + [/^v(\d+)sin$/, 'vco'], + [/^e(\d+)$/, 'env'], + [/^a(\d+)p$/, 'amp'], +]; + +/** Each module's own engine reading, by module id, from the signals its jacks carry. */ +function slotMeters(def: SynthDef): Map { + const meters = new Map(); + for (const j of def.jacks) { + if (!j.signal) continue; + for (const [re, kind] of SLOT_SIGNALS) { + const m = re.exec(j.signal); + if (m) meters.set(jackModule(j.id), `${kind}:${Number(m[1])}`); + } + } + return meters; +} + /** * The routing of a System 15, 35 or 55: one block per module of `def`, its cables the only paths. A module this file * does not know is left out rather than thrown on, since this runs as the synth page renders: `routingProblems` then @@ -216,6 +237,7 @@ const jackModule = (id: string) => { */ export function moog900Routing(def: SynthDef): SynthRouting { const areas = def.areas ?? []; + const slots = slotMeters(def); const blocks = areas.flatMap((a): RoutingBlock[] => { const kind = a.id.replace(/_\d+$/, ''); const k = KINDS[kind]; @@ -231,7 +253,8 @@ export function moog900Routing(def: SynthDef): SynthRouting { if (k.specs) block.specs = k.specs; if (k.passes) block.passes = true; if (k.symbol) block.symbol = k.symbol; - if (k.meter) block.meter = k.meter; + const meter = k.meter ?? slots.get(a.id); + if (meter) block.meter = meter; return [block]; }); const placed = new Set(blocks.map((b) => b.id)); diff --git a/tests/unit/lib/app/synths/audio/dsp-core.test.ts b/tests/unit/lib/app/synths/audio/dsp-core.test.ts index c7c4a1629..aa9a0ef3c 100644 --- a/tests/unit/lib/app/synths/audio/dsp-core.test.ts +++ b/tests/unit/lib/app/synths/audio/dsp-core.test.ts @@ -918,6 +918,20 @@ describe('Moog modular path', () => { expect(gates(drone, []).some(Boolean)).toBe(false); }); + it('meters each module on its own, for the routing view', () => { + const m = meters( + render(moog(), { len: 9600, events: [[0, { type: 'noteOn', n: 60 }]] }).events + ); + const r = m.at(-1)?.moog; + expect(r).toBeDefined(); + // Oscillators 1 and 2 are a fifth apart (semi 7), both played from the key. Envelope 2 hears the key's S-trigger + // and opens; envelope 1 hears its V-trigger, which holds an S-trigger input off; envelope 3 is unpatched. + expect(r!.vcoHz[1] / r!.vcoHz[0]).toBeCloseTo(2 ** (7 / 12), 2); + expect(r!.env[1]).toBeGreaterThan(0); + expect(r!.env[0]).toBe(0); + expect(r!.env[2]).toBe(0); + }); + it('is silent with nothing patched to the output', () => { const p = moog(); p.cables = []; diff --git a/tests/unit/lib/app/synths/audio/moog-core.test.ts b/tests/unit/lib/app/synths/audio/moog-core.test.ts new file mode 100644 index 000000000..8d55aa32f --- /dev/null +++ b/tests/unit/lib/app/synths/audio/moog-core.test.ts @@ -0,0 +1,116 @@ +/** + * The 900-series engine's reading per module (`meters()`), which the routing view lights each 921, 911 and 902 from: + * two of a kind set differently must read differently, and a slot the patch does not use reads nothing. + * + * The engine core runs on its own here, with nothing patched between the modules: every input reads 0 V, and an + * envelope's S-trigger is held on by marking its input cabled (an S-trigger is on while held low). + * + * @see lib/app/synths/audio/moog-core.ts + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { MoogParams } from '@/lib/app/synths/contract'; +import { createMoog, MOOG_DESTS, MOOG_SIGNALS } from '@/lib/app/synths/audio/moog-core'; + +const SR = 48000; + +/** A bare engine core: every destination reads 0 V; `held` marks those destinations cabled. */ +function core(held: string[] = []) { + const SI = Object.fromEntries(MOOG_SIGNALS.map((n, i) => [n, i])); + const DI = Object.fromEntries(MOOG_DESTS.map((n, i) => [n, i])); + const S = new Float64Array(MOOG_SIGNALS.length); + const cabled = new Uint8Array(MOOG_DESTS.length); + for (const d of held) cabled[DI[d]] = 1; + return createMoog(SR, S, SI, DI, () => 0, cabled); +} + +const run = (m: ReturnType, seconds: number) => { + for (let i = 0; i < Math.round(seconds * SR); i++) m.step(60, true); +}; + +afterEach(() => vi.restoreAllMocks()); + +describe('the 900-series engine’s reading per module', () => { + it('reports each oscillator’s own wave and pitch: two at different rates read differently', () => { + // Both start their cycle at 0, so only the rate tells them apart. + vi.spyOn(Math, 'random').mockReturnValue(0); + const m = core(); + // 0 V in: middle C, then 8 and 7 octaves down (about 1 Hz and 2 Hz, a 921B at LO). + const params: MoogParams = { + vco: [ + { kind: 'b', semi: -96, sync: 'off' }, + { kind: 'b', semi: -84, sync: 'off' }, + ], + }; + m.set(params, new Set(['v1sin', 'v2sin'])); + run(m, 0.125); + const r = m.meters(); + const hz = 261.6256 / 256; + expect(r.vcoHz[0]).toBeCloseTo(hz, 3); + expect(r.vcoHz[1]).toBeCloseTo(hz * 2, 3); + // An eighth of a second in: a slow wave an eighth of its cycle on, a twice-as-fast one a quarter. + expect(r.vco[0]).toBeCloseTo(Math.sin(2 * Math.PI * hz * 0.125), 2); + expect(r.vco[1]).toBeCloseTo(Math.sin(2 * Math.PI * hz * 2 * 0.125), 2); + expect(Math.abs(r.vco[1] - r.vco[0])).toBeGreaterThan(0.2); + }); + + it('reports each envelope’s own level: a fast contour at its peak while a slow one is still rising', () => { + const m = core(['e1st', 'e2st']); + m.set( + { + vco: [], + env: [ + { t1: 0.005, t2: 5, t3: 1, sus: 6 }, + { t1: 2, t2: 5, t3: 1, sus: 6 }, + ], + }, + new Set(['e1', 'e2']) + ); + run(m, 0.05); + const r = m.meters(); + expect(r.env[0]).toBeCloseTo(1, 2); + expect(r.env[1]).toBeGreaterThan(0); + expect(r.env[1]).toBeLessThan(0.3); + // The same reading as the panel lamps, on a 0..1 scale of the +6 V peak. + expect(r.env[1]).toBeCloseTo(m.envLevel(1) / 6, 6); + }); + + it('reports each amplifier’s gain, 6 V being unity', () => { + const m = core(); + m.set( + { + vco: [], + vca: [ + { v: 3, exp: false }, + { v: 6, exp: false }, + ], + }, + new Set(['a1p', 'a2p']) + ); + run(m, 0.05); + const r = m.meters(); + expect(r.amp[0]).toBeCloseTo(0.5, 3); + expect(r.amp[1]).toBeCloseTo(1, 3); + }); + + it('reads nothing from a slot the patch does not use, and has one place per slot', () => { + const m = core(['e1st', 'e2st']); + m.set( + { + vco: [{ kind: 'b', semi: 0, sync: 'off' }], + env: [ + { t1: 0.005, t2: 5, t3: 1, sus: 6 }, + { t1: 0.005, t2: 5, t3: 1, sus: 6 }, + ], + }, + // Only envelope 2 is read by a cable; oscillator 1 and envelope 1 are set but unpatched. + new Set(['e2']) + ); + run(m, 0.05); + const r = m.meters(); + expect(r.env[1]).toBeGreaterThan(0.9); + expect(r.env[0]).toBe(0); + expect(r.vco[0]).toBe(0); + expect(r.vcoHz[0]).toBe(0); + expect([r.vco.length, r.env.length, r.amp.length]).toEqual([7, 4, 5]); + }); +}); diff --git a/tests/unit/lib/app/synths/lib/routing-activity.test.ts b/tests/unit/lib/app/synths/lib/routing-activity.test.ts index a766a0e65..e9452facc 100644 --- a/tests/unit/lib/app/synths/lib/routing-activity.test.ts +++ b/tests/unit/lib/app/synths/lib/routing-activity.test.ts @@ -269,6 +269,121 @@ describe('routingActivity on a modular: the System 55, every path a cable', () = ); expect(mixed.blocks.cp3am_1).toBe(0.2); }); + + describe('with the engine reporting each module', () => { + /** The engine slot (0-based) a block reads, as the routing gives it. */ + const slot = (id: string, kind: string) => { + const meter = modular.blocks.find((b) => b.id === id)?.meter ?? ''; + const m = new RegExp(`^${kind}:(\\d+)$`).exec(meter); + if (!m) throw new Error(`${id} reads no ${kind} slot`); + return Number(m[1]) - 1; + }; + /** A reading for every slot of a System 55, all at rest but those `set` gives. */ + const moog = ( + set: Partial> + ): NonNullable => { + const r = { + vco: Array(7).fill(0), + vcoHz: Array(7).fill(0), + env: Array(4).fill(0), + amp: Array(5).fill(0), + }; + for (const [k, pairs] of Object.entries(set) as [keyof typeof r, [string, number][]][]) + for (const [id, v] of pairs) r[k][slot(id, k === 'vcoHz' ? 'vco' : k)] = v; + return r; + }; + // Two 921Bs at LO into the filter, and two envelopes: one opens the amplifier, the other sweeps the filter. + const TWO: CableLike[] = [ + ...VOICE, + { from: 'j.m921b_2.sawtooth', to: 'j.m904a.sig_in' }, + { from: 'j.cm1a.trig_upper', to: 'j.m911_2.strig' }, + { from: 'j.m911_2.out', to: 'j.m904a.cv_1' }, + ]; + + it('reads each 921, 911 and 902 from its own slot, and each from a different one', () => { + const ids: [string, string][] = [ + ['m921b_1', 'vco'], + ['m921b_2', 'vco'], + ['m921', 'vco'], + ['m911_1', 'env'], + ['m911_2', 'env'], + ['m902_1', 'amp'], + ['m902_2', 'amp'], + ]; + const slots = ids.map(([id, kind]) => `${kind}:${slot(id, kind)}`); + expect(new Set(slots).size).toBe(ids.length); + // The 921A driver makes no sound of its own, and the keyboard keeps its gate. + expect(modular.blocks.find((b) => b.id === 'cm1a')?.meter).toBe('gate'); + }); + + it('lights two oscillators at LO with their own waves: one at its peak, one at its trough', () => { + const a = run(TWO, { + ...PLAYING, + out: 0.5, + moog: moog({ + vco: [ + ['m921b_1', 1], + ['m921b_2', -1], + ], + vcoHz: [ + ['m921b_1', 1], + ['m921b_2', 2], + ], + }), + }); + expect(a.blocks.m921b_1).toBeCloseTo(1, 5); + expect(a.blocks.m921b_2).toBeCloseTo(0.12, 5); + expect(a.pulsing.m921b_1).toBe(true); + expect(a.pulsing.m921b_2).toBe(true); + // Their cables pulse with them, apart. + const s = state55(TWO); + const from = (id: string) => + a.cables[s.cables.findIndex((c) => c.fromBlock === id && c.live)]; + expect(from('m921b_1')).toBeCloseTo(1, 5); + expect(from('m921b_2')).toBeCloseTo(0.12, 5); + }); + + it('lights an oscillator at audio pitch with the output, steady', () => { + const a = run(TWO, { + ...PLAYING, + out: 0.4, + moog: moog({ vco: [['m921b_1', -1]], vcoHz: [['m921b_1', 220]] }), + }); + expect(a.blocks.m921b_1).toBeCloseTo(0.4, 5); + expect(a.pulsing.m921b_1).toBe(false); + }); + + it('lights two envelopes on different contours with their own levels', () => { + const a = run(TWO, { + ...PLAYING, + out: 0.5, + moog: moog({ + env: [ + ['m911_1', 0.9], + ['m911_2', 0.25], + ], + }), + }); + expect(a.blocks.m911_1).toBeCloseTo(0.9, 5); + expect(a.blocks.m911_2).toBeCloseTo(0.25, 5); + }); + + it('lights an amplifier with its gain, never brighter than the output it carries', () => { + const open = run(TWO, { ...PLAYING, out: 0.7, moog: moog({ amp: [['m902_1', 0.3]] }) }); + expect(open.blocks.m902_1).toBeCloseTo(0.3, 5); + const quiet = run(TWO, { ...PLAYING, out: 0.1, moog: moog({ amp: [['m902_1', 1]] }) }); + expect(quiet.blocks.m902_1).toBeCloseTo(0.1, 5); + }); + + it('leaves a module the patch does not use dark, whatever its slot reads', () => { + const a = run(VOICE, { + ...PLAYING, + moog: moog({ vco: [['m921b_2', 1]], vcoHz: [['m921b_2', 1]], env: [['m911_2', 1]] }), + }); + expect(a.blocks.m921b_2).toBe(0); + expect(a.blocks.m911_2).toBe(0); + }); + }); }); describe('settle', () => { From 51eaee1c9d0f933e024196a24e27edf99c5cff13 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Fri, 30 Oct 2026 15:51:07 +0000 Subject: [PATCH 2/3] Say how the routing view lights each 900-series module from its own reading (t-149) Co-Authored-By: Claude Opus 5.5 --- .context/app/synth-page.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.context/app/synth-page.md b/.context/app/synth-page.md index d5d4511d5..3c093ef77 100644 --- a/.context/app/synth-page.md +++ b/.context/app/synth-page.md @@ -262,9 +262,11 @@ BeatBreaker's drummer player is the precedent (`components/app/studio/player/` t feeds the mod bus. A glow is a soft ring whose opacity follows `--a`, not an animated filter. The rest of the audio path follows the output's level (read off the engine's analyser, as the scope does), and the buses the brightest line into them. The audio path is the audio edges and the live audio cables that carry audio on from a module that makes it, so a modular's patched line glows though it has no edges, and a - mixer passing only control stays off it. On a modular only the CM1A has a meter (the key held); the engine reports - nothing per module, so a live module that makes signal (a 911, a modulating 921) follows the output's level, through - its release (see `moog900.ts`). The view eases each part towards that once a frame + mixer passing only control stays off it. On a modular the CM1A's meter is the key held, and each 921, 911 and 902 + reads its own engine slot (`MoogMeters` in `moog-core.ts`, the meter's `moog`): a 921 its wave (pulsing at LO, + the output's level at audio pitch), a 911 its envelope, a 902 its gain, no brighter than the output it carries. So + two oscillators at different rates, or two envelopes on different contours, glow apart. Until the engine reports, + a live module that makes signal follows the output's level (see `moog900.ts`). The view eases each part towards that once a frame (`settle()`: up in ~40 ms, down over ~450 ms) and writes it to `--a` on the element, never to React state; with reduced motion the lines glow without moving. It is drawn from `sess` (the panel as shown, glides and undos included) and the mod wheel, so it follows the panel while it shows. The panel stays laid out underneath (`.is-away`, `inert`), so its fit and From 2dbd4c8e95eed4a52190275056d4e4ea3c99ceb9 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Fri, 30 Oct 2026 15:56:32 +0000 Subject: [PATCH 3/3] Light a 902 passing only control with what feeds it, not its gain (t-149) Review round 1: a 902's gain says how much it would let through, not that anything reaches it, so off the audio line a 902 at a fixed 6 V glowed full with silent inputs. Off the line it now falls back to the brightest live line into it, as a pass-through block does. Co-Authored-By: Claude Opus 5.5 --- .context/app/synth-page.md | 2 +- lib/app/synths/lib/routing-activity.ts | 12 +++++++----- .../lib/app/synths/lib/routing-activity.test.ts | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.context/app/synth-page.md b/.context/app/synth-page.md index 3c093ef77..ef6c79070 100644 --- a/.context/app/synth-page.md +++ b/.context/app/synth-page.md @@ -264,7 +264,7 @@ BeatBreaker's drummer player is the precedent (`components/app/studio/player/` t that carry audio on from a module that makes it, so a modular's patched line glows though it has no edges, and a mixer passing only control stays off it. On a modular the CM1A's meter is the key held, and each 921, 911 and 902 reads its own engine slot (`MoogMeters` in `moog-core.ts`, the meter's `moog`): a 921 its wave (pulsing at LO, - the output's level at audio pitch), a 911 its envelope, a 902 its gain, no brighter than the output it carries. So + the output's level at audio pitch), a 911 its envelope, a 902 on the audio line its gain, no brighter than the output. So two oscillators at different rates, or two envelopes on different contours, glow apart. Until the engine reports, a live module that makes signal follows the output's level (see `moog900.ts`). The view eases each part towards that once a frame (`settle()`: up in ~40 ms, down over ~450 ms) and writes it to `--a` on the element, never to React state; with diff --git a/lib/app/synths/lib/routing-activity.ts b/lib/app/synths/lib/routing-activity.ts index 5b23a1e02..5887cb242 100644 --- a/lib/app/synths/lib/routing-activity.ts +++ b/lib/app/synths/lib/routing-activity.ts @@ -14,8 +14,8 @@ // is already on the path: a modular's patched line (it has no edges) and a block only a pin reaches glow the way a // wired path does, while a mixer or mult that only passes control voltage on stays off it. // - A 900-series module follows its own engine slot (`MoogMeters`): a 921 its wave, as an oscillator above (pulsing at -// LO, steady at the output's level at audio pitch), a 911 its envelope, a 902 its gain, no brighter than the output -// while it carries audio. So two oscillators at different rates, or two envelopes on different contours, glow apart. +// LO, steady at the output's level at audio pitch), a 911 its envelope, a 902 on the audio path its gain, no brighter +// than the output. So two oscillators at different rates, or two envelopes on different contours, glow apart. // - On a modular (every path a cable), a live module that makes its own signal and has no reading (the engine has not // reported yet) is shaping the sound, so it too follows the output's level: what feeds it (the keyboard's gate) // says nothing of its own release or rate. @@ -104,9 +104,11 @@ function moduleReading( if (v === undefined || !(hz > 0)) return null; return [wave(v, hz, clamp(m.out)), blur(hz) < 1]; } - const level = kind === 'env' ? r.env[i] : r.amp[i]; - if (level === undefined) return null; - return [kind === 'amp' && audio ? Math.min(clamp(level), clamp(m.out)) : clamp(level), false]; + if (kind === 'env') return r.env[i] === undefined ? null : [clamp(r.env[i]), false]; + // An amplifier's gain says how much it lets through, not that anything reaches it: on the audio path the output + // bounds it; passing control only, it has nothing of its own to show, so it falls back like any other module. + if (!audio || r.amp[i] === undefined) return null; + return [Math.min(clamp(r.amp[i]), clamp(m.out)), false]; } /** diff --git a/tests/unit/lib/app/synths/lib/routing-activity.test.ts b/tests/unit/lib/app/synths/lib/routing-activity.test.ts index e9452facc..74618e64b 100644 --- a/tests/unit/lib/app/synths/lib/routing-activity.test.ts +++ b/tests/unit/lib/app/synths/lib/routing-activity.test.ts @@ -375,6 +375,23 @@ describe('routingActivity on a modular: the System 55, every path a cable', () = expect(quiet.blocks.m902_1).toBeCloseTo(0.1, 5); }); + it('lights an amplifier passing only control with what feeds it, not its gain', () => { + // An envelope through the second amplifier to the filter's cutoff: control, off the audio line. Its gain is + // full, which says nothing of what reaches it; it glows with the envelope. + const cv: CableLike[] = [ + ...VOICE, + { from: 'j.m911_1.out', to: 'j.m902_2.sig_in_pos' }, + { from: 'j.m902_2.out_pos', to: 'j.m904a.cv_1' }, + ]; + const meters = (env: number) => ({ + ...PLAYING, + out: 0.8, + moog: moog({ amp: [['m902_2', 1]], env: [['m911_1', env]] }), + }); + expect(run(cv, meters(0.4)).blocks.m902_2).toBeCloseTo(0.4, 5); + expect(run(cv, meters(0)).blocks.m902_2).toBe(0); + }); + it('leaves a module the patch does not use dark, whatever its slot reads', () => { const a = run(VOICE, { ...PLAYING,