Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .context/app/synth-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
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
Expand Down
1 change: 1 addition & 0 deletions components/app/synth/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
1 change: 1 addition & 0 deletions components/app/synth/routing-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ export function RoutingView({
lv: m.oscLv?.[k] ?? 0,
})),
out,
moog: m.moog,
}
: SILENT;
const routing = routingRef.current;
Expand Down
3 changes: 3 additions & 0 deletions components/app/synth/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
Expand Down Expand Up @@ -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. */
Expand Down
11 changes: 10 additions & 1 deletion lib/app/synths/audio/dsp-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 36 additions & 1 deletion lib/app/synths/audio/moog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ export const MOOG_DESTS: string[] = [
/** The 960 sequencer's settings, as EngineParams `moog.s960` carries them. */
type S960 = NonNullable<MoogParams['s960']>;

/**
* 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. */
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
Expand All @@ -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`)],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}
13 changes: 10 additions & 3 deletions lib/app/synths/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 43 additions & 7 deletions lib/app/synths/lib/routing-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
// - 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';

Expand All @@ -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 = {
Expand Down Expand Up @@ -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<Record<RoutingMeter, number>> = { 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));
Expand All @@ -80,14 +87,42 @@ 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];
}
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];
}

/**
* 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.
Expand Down Expand Up @@ -129,7 +164,8 @@ export function routingActivity(
const pulsing: Record<string, boolean> = {};
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];
Expand Down
33 changes: 28 additions & 5 deletions lib/app/synths/routing/moog900.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -209,13 +210,34 @@ 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<string, ModuleMeter> {
const meters = new Map<string, ModuleMeter>();
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
* names its controls and jacks as on no block, which fails the routing test.
*/
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];
Expand All @@ -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));
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/lib/app/synths/audio/dsp-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
Loading
Loading