Skip to content

Commit 5620ad6

Browse files
committed
envelopes
1 parent e1c9ad2 commit 5620ad6

12 files changed

Lines changed: 411 additions & 6 deletions

File tree

src/components/viz/drawAdEnv.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { persist, beginGlow, endGlow, VIZ } from "./gridHelpers.js";
2+
import { DB_FLOOR } from "../../audio/constants.js";
3+
4+
// AD envelope visualiser. Two segments only — attack ramp up, decay ramp
5+
// down — so the shape fills the canvas. (Reusing drawEnv would squeeze
6+
// half the canvas into a synthetic sustain plateau that AD doesn't have.)
7+
//
8+
// data: { env: {a, d}, phase: "idle" | "ad", start: ms, playing: bool }
9+
export function drawAdEnv(ctx, w, h, data) {
10+
persist(ctx, w, h, "green");
11+
if (!data) return;
12+
13+
const { env: e, phase, start, playing } = data;
14+
const pad = 8, top = 14, bot = h - 10;
15+
const total = Math.max(e.a + e.d, 0.001);
16+
const sx = (w - pad * 2) / total;
17+
18+
const dbY = (db) => bot - (bot - top) * ((Math.max(db, DB_FLOOR) - DB_FLOOR) / (0 - DB_FLOOR));
19+
const xA = pad + e.a * sx;
20+
const xD = xA + e.d * sx;
21+
const yPk = dbY(0);
22+
const y0 = dbY(DB_FLOOR);
23+
24+
// 0 dB ceiling marker
25+
ctx.strokeStyle = "rgba(79,214,255,.22)";
26+
ctx.setLineDash([2, 4]);
27+
ctx.beginPath();
28+
ctx.moveTo(0, yPk);
29+
ctx.lineTo(w, yPk);
30+
ctx.stroke();
31+
ctx.setLineDash([]);
32+
ctx.fillStyle = "rgba(79,214,255,.5)";
33+
ctx.font = "9px 'Chakra Petch',sans-serif";
34+
ctx.textAlign = "left";
35+
ctx.fillText("0 dB", 4, yPk - 3);
36+
37+
// AD shape — control signal, cyan glow
38+
beginGlow(ctx, VIZ.CONTROL_COLOR);
39+
ctx.beginPath();
40+
ctx.moveTo(pad, y0);
41+
ctx.lineTo(xA, yPk);
42+
ctx.lineTo(xD, y0);
43+
ctx.stroke();
44+
endGlow(ctx);
45+
46+
// Live phase dot — only during the cycle (after a+d seconds the dot
47+
// simply stops rendering; no need for the engine to flip to "idle").
48+
if (phase === "ad" && playing) {
49+
const el = (performance.now() - start) / 1000;
50+
if (el <= total) {
51+
const px = pad + el * sx;
52+
const yy = px < xA
53+
? y0 - ((px - pad) / Math.max(xA - pad, 0.001)) * (y0 - yPk)
54+
: yPk + ((px - xA) / Math.max(xD - xA, 0.001)) * (y0 - yPk);
55+
ctx.fillStyle = "#fff";
56+
ctx.beginPath();
57+
ctx.arc(px, yy, 3.5, 0, 7);
58+
ctx.fill();
59+
}
60+
}
61+
}

src/modules/_registry.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { Oscillator } from "./oscillator/index.js";
77
import { Filter } from "./filter/index.js";
88
import { Amp } from "./amp/index.js";
99
import { Env } from "./env/index.js";
10+
import { ArEnv } from "./arenv/index.js";
11+
import { AdEnv } from "./adenv/index.js";
1012
import { Lfo } from "./lfo/index.js";
1113
import { Keyboard } from "./keyboard/index.js";
1214
import { Gate } from "./gate/index.js";
@@ -19,7 +21,7 @@ import { Offset } from "./offset/index.js";
1921
import { AudioMixer } from "./audiomixer/index.js";
2022

2123
export const MODULES = [
22-
Oscillator, Filter, Amp, Env, Lfo,
24+
Oscillator, Filter, Amp, Env, ArEnv, AdEnv, Lfo,
2325
Keyboard, Gate, Output,
2426
Inverter, CvMixer, Attenuator, Attenuverter, Offset, AudioMixer,
2527
];
@@ -28,7 +30,7 @@ export const MODULES = [
2830
// from this array don't appear in the palette. `output` is included so the
2931
// user can re-add it after deletion (every patch needs an Output to be heard).
3032
const PALETTE_ORDER = [
31-
"oscillator", "filter", "audiomixer", "amp", "env", "lfo",
33+
"oscillator", "filter", "audiomixer", "amp", "env", "arenv", "adenv", "lfo",
3234
"keyboard", "gate", "output",
3335
"inverter", "attenuator", "attenuverter", "cvmixer", "offset",
3436
];

src/modules/adenv/glyph.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// AD envelope: attack ramp up, decay ramp back to zero — no plateau in between.
2+
export const AdEnvGlyph = (
3+
<svg className="m-glyph" viewBox="0 0 34 22">
4+
<path d="M2 19 L13 4 L32 19" />
5+
</svg>
6+
);

src/modules/adenv/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { AdEnvelopeModule } from "./module.js";
2+
import { AdEnvelopePanel } from "./panel.jsx";
3+
import { AdEnvGlyph } from "./glyph.jsx";
4+
5+
export const AdEnv = {
6+
type: "adenv",
7+
Cls: AdEnvelopeModule,
8+
Panel: AdEnvelopePanel,
9+
meta: { title: "AD Envelope" },
10+
defaults: () => ({ a: 0.005, d: 0.4 }),
11+
placard:
12+
"A percussive <b>one-shot</b> envelope. Each gate rising edge fires a fixed-length attack-then-decay cycle, regardless of how long the gate stays high — perfect for drums, plucks, and short stabs where you want the sound to fade on its own.",
13+
glyph: AdEnvGlyph,
14+
};

src/modules/adenv/module.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { AudioModule } from "../../audio/AudioModule.js";
2+
import { dbToLin, ENV_PEAK_BOOST_DB } from "../../audio/constants.js";
3+
import {
4+
MODULE_KIND, PORT_TYPE, PORT_DIR, CV_POLARITY,
5+
CONTROL_KIND, CONTROL_CURVE,
6+
} from "../../audio/graph/types.js";
7+
8+
// AD envelope — percussive, one-shot. A gate transition 0 → 1 starts an
9+
// attack-then-decay cycle of fixed length (a + d); gate length is otherwise
10+
// ignored. Re-triggering during a running cycle restarts from the current
11+
// value (no click). Same dB-space CV-out contract as ADSR/AR: 0 = silence,
12+
// 1 = peak.
13+
export class AdEnvelopeModule extends AudioModule {
14+
static KIND = MODULE_KIND.CONTROL;
15+
static PORTS = [
16+
{ name: "trigger", dir: PORT_DIR.IN, type: PORT_TYPE.GATE },
17+
{ name: "env", dir: PORT_DIR.OUT, type: PORT_TYPE.CV, polarity: CV_POLARITY.UNIPOLAR },
18+
];
19+
static CONTROLS = [
20+
{ name: "a", kind: CONTROL_KIND.KNOB, range: [0.001, 4], curve: CONTROL_CURVE.EXP, cvRange: 4, cvPolarity: CV_POLARITY.UNIPOLAR },
21+
{ name: "d", kind: CONTROL_KIND.KNOB, range: [0.001, 4], curve: CONTROL_CURVE.EXP, cvRange: 4, cvPolarity: CV_POLARITY.UNIPOLAR },
22+
];
23+
24+
constructor(ctx, params) {
25+
super(ctx);
26+
this.params = { ...params };
27+
this.node = ctx.createGain();
28+
this.node.gain.value = 1;
29+
this.envPhase = "idle";
30+
this.envStart = 0;
31+
32+
this.cvOut = ctx.createConstantSource();
33+
this.cvOut.offset.value = 0;
34+
this.cvOut.start();
35+
this._gateSources = new Set();
36+
37+
this._registerCvOut("env", this.cvOut);
38+
this._registerGateInput("trigger", (sourceId, active) => this._handleGate(sourceId, active));
39+
this._makeCvInput("a", 4, null);
40+
this._makeCvInput("d", 4, null);
41+
}
42+
43+
setParams(partial) { this.params = { ...this.params, ...partial }; }
44+
getValue() { return this.node.gain.value; }
45+
getPhase() { return this.envPhase; }
46+
getStart() { return this.envStart; }
47+
48+
// Trigger: schedule the whole A→D cycle in one shot. Gate-down does
49+
// nothing — that's the whole point of "no sustain".
50+
trigger() {
51+
const t = this.ctx.currentTime;
52+
const e = this.params;
53+
54+
// Internal gain (meter readout): boost to peak then back to unity.
55+
const g = this.node.gain;
56+
g.cancelScheduledValues(t);
57+
g.setValueAtTime(Math.max(1e-5, g.value), t);
58+
g.exponentialRampToValueAtTime(dbToLin(ENV_PEAK_BOOST_DB), t + e.a);
59+
g.exponentialRampToValueAtTime(1.0, t + e.a + e.d);
60+
61+
// CV-out: 0 → peak (attack), then peak → 0 (decay).
62+
const cv = this.cvOut.offset;
63+
cv.cancelScheduledValues(t);
64+
cv.setValueAtTime(cv.value, t);
65+
cv.linearRampToValueAtTime(1.0, t + e.a);
66+
cv.linearRampToValueAtTime(0, t + e.a + e.d);
67+
68+
this.envStart = performance.now();
69+
this.envPhase = "ad";
70+
}
71+
72+
reset() {
73+
const t = this.ctx.currentTime;
74+
this.node.gain.cancelScheduledValues(t);
75+
this.node.gain.setValueAtTime(1, t);
76+
this.cvOut.offset.cancelScheduledValues(t);
77+
this.cvOut.offset.setValueAtTime(0, t);
78+
this.envPhase = "idle";
79+
this._gateSources.clear();
80+
}
81+
82+
_handleGate(sourceId, active) {
83+
// Track sources so concurrent gates don't double-trigger. Only the
84+
// rising edge of "any source held" fires the envelope.
85+
const wasOpen = this._gateSources.size > 0;
86+
if (active) this._gateSources.add(sourceId);
87+
else this._gateSources.delete(sourceId);
88+
const nowOpen = this._gateSources.size > 0;
89+
if (!wasOpen && nowOpen) this.trigger();
90+
}
91+
92+
setParam(name, value) {
93+
if (name === "a" || name === "d") {
94+
this.setParams({ [name]: value });
95+
}
96+
}
97+
98+
dispose() {
99+
try { this.cvOut.stop(); } catch {}
100+
try { this.cvOut.disconnect(); } catch {}
101+
try { this.node.disconnect(); } catch {}
102+
super.dispose();
103+
}
104+
}

src/modules/adenv/panel.jsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useSynthStore } from "../../store/useSynthStore.js";
2+
import { getEngine } from "../../audio/engineSingleton.js";
3+
import { Knob } from "../../components/controls/Knob.jsx";
4+
import { Canvas } from "../../components/viz/Canvas.jsx";
5+
import { drawAdEnv } from "../../components/viz/drawAdEnv.js";
6+
import { useModuleInstance } from "../../components/ModuleInstanceContext.js";
7+
8+
const DEFAULT_PARAMS = { a: 0.005, d: 0.4 };
9+
10+
export function AdEnvelopePanel() {
11+
const { instanceId: id } = useModuleInstance();
12+
13+
const params = useSynthStore((s) => s.modules.find((m) => m.id === id)?.params) || DEFAULT_PARAMS;
14+
const playing = useSynthStore((s) => s.playing);
15+
const setModuleParam = useSynthStore((s) => s.setModuleParam);
16+
17+
function applyPartial(partial) {
18+
for (const [k, v] of Object.entries(partial)) setModuleParam(id, k, v);
19+
}
20+
21+
const engine = getEngine();
22+
const data = {
23+
env: params,
24+
playing,
25+
get phase() { return engine.getInstanceEnvPhase(id); },
26+
get start() { return engine.getInstanceEnvStart(id); },
27+
};
28+
29+
return (
30+
<>
31+
<Canvas tag="Envelope · AD (dB)" draw={drawAdEnv} data={data} />
32+
<div className="ctrl-grid">
33+
<Knob label="Attack" value={params.a} min={0} max={2} step={0.005} unit="s" onChange={(v) => applyPartial({ a: v })} />
34+
<Knob label="Decay" value={params.d} min={0} max={4} step={0.005} unit="s" onChange={(v) => applyPartial({ d: v })} />
35+
</div>
36+
</>
37+
);
38+
}

src/modules/arenv/glyph.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// AR envelope: attack ramp, flat hold, release ramp — no sustain plateau in between.
2+
export const ArEnvGlyph = (
3+
<svg className="m-glyph" viewBox="0 0 34 22">
4+
<path d="M2 19 L11 4 L23 4 L32 19" />
5+
</svg>
6+
);

src/modules/arenv/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ArEnvelopeModule } from "./module.js";
2+
import { ArEnvelopePanel } from "./panel.jsx";
3+
import { ArEnvGlyph } from "./glyph.jsx";
4+
5+
export const ArEnv = {
6+
type: "arenv",
7+
Cls: ArEnvelopeModule,
8+
Panel: ArEnvelopePanel,
9+
meta: { title: "AR Envelope" },
10+
defaults: () => ({ a: 0.05, r: 0.3 }),
11+
placard:
12+
"A two-stage <b>control</b> envelope — just <b>attack</b> and <b>release</b>. While the gate is held, the envelope ramps up to peak and stays there; when the gate releases, it ramps back to silence. Useful for plucks, claps, and percussive bursts where you don't need a separate sustain level.",
13+
glyph: ArEnvGlyph,
14+
};

src/modules/arenv/module.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { AudioModule } from "../../audio/AudioModule.js";
2+
import { dbToLin, ENV_PEAK_BOOST_DB } from "../../audio/constants.js";
3+
import {
4+
MODULE_KIND, PORT_TYPE, PORT_DIR, CV_POLARITY,
5+
CONTROL_KIND, CONTROL_CURVE,
6+
} from "../../audio/graph/types.js";
7+
8+
// AR envelope — a simplified ADSR with no decay and an implicit sustain
9+
// at peak. Gate high → attack ramp 0 → peak, then hold; gate low →
10+
// release ramp from current value → 0. Useful for plucks, claps, and
11+
// percussive bursts where the sustain plateau is just "fully open".
12+
//
13+
// Mirrors EnvelopeModule's contract (cv-out shape, internal gain node for
14+
// meter readout, gate-source dedup) so it composes with the same drawEnv +
15+
// amp.level wiring as the ADSR envelope. The CV-out value uses the same
16+
// dB-space normalisation: 0 = release floor, 1 = peak (= 0 dB at the amp
17+
// with knob at -48).
18+
export class ArEnvelopeModule extends AudioModule {
19+
static KIND = MODULE_KIND.CONTROL;
20+
static PORTS = [
21+
{ name: "trigger", dir: PORT_DIR.IN, type: PORT_TYPE.GATE },
22+
{ name: "env", dir: PORT_DIR.OUT, type: PORT_TYPE.CV, polarity: CV_POLARITY.UNIPOLAR },
23+
];
24+
static CONTROLS = [
25+
{ name: "a", kind: CONTROL_KIND.KNOB, range: [0.001, 4], curve: CONTROL_CURVE.EXP, cvRange: 4, cvPolarity: CV_POLARITY.UNIPOLAR },
26+
{ name: "r", kind: CONTROL_KIND.KNOB, range: [0.001, 4], curve: CONTROL_CURVE.EXP, cvRange: 4, cvPolarity: CV_POLARITY.UNIPOLAR },
27+
];
28+
29+
constructor(ctx, params) {
30+
super(ctx);
31+
this.params = { ...params };
32+
this.node = ctx.createGain();
33+
this.node.gain.value = 1;
34+
this.envPhase = "idle";
35+
this.envStart = 0;
36+
37+
this.cvOut = ctx.createConstantSource();
38+
this.cvOut.offset.value = 0;
39+
this.cvOut.start();
40+
this._gateSources = new Set();
41+
42+
this._registerCvOut("env", this.cvOut);
43+
this._registerGateInput("trigger", (sourceId, active) => this._handleGate(sourceId, active));
44+
this._makeCvInput("a", 4, null);
45+
this._makeCvInput("r", 4, null);
46+
}
47+
48+
setParams(partial) { this.params = { ...this.params, ...partial }; }
49+
getValue() { return this.node.gain.value; }
50+
getPhase() { return this.envPhase; }
51+
getStart() { return this.envStart; }
52+
53+
noteOn() {
54+
const t = this.ctx.currentTime;
55+
const e = this.params;
56+
const g = this.node.gain;
57+
g.cancelScheduledValues(t);
58+
g.setValueAtTime(Math.max(1e-5, g.value), t);
59+
// Attack to peak boost; then hold at peak (no decay, no sustain knob).
60+
g.exponentialRampToValueAtTime(dbToLin(ENV_PEAK_BOOST_DB), t + e.a);
61+
62+
const cv = this.cvOut.offset;
63+
cv.cancelScheduledValues(t);
64+
cv.setValueAtTime(cv.value, t);
65+
cv.linearRampToValueAtTime(1.0, t + e.a);
66+
this.envStart = performance.now();
67+
// Reuse drawEnv's "ad" phase tag — with d=0 + sustainDb=0 it animates
68+
// the dot along the attack ramp and then across the flat top, which is
69+
// exactly the AR shape.
70+
this.envPhase = "ad";
71+
}
72+
noteOff() {
73+
const t = this.ctx.currentTime;
74+
const e = this.params;
75+
const g = this.node.gain;
76+
g.cancelScheduledValues(t);
77+
g.setValueAtTime(Math.max(1e-5, g.value), t);
78+
g.exponentialRampToValueAtTime(1.0, t + e.r);
79+
80+
const cv = this.cvOut.offset;
81+
cv.cancelScheduledValues(t);
82+
cv.setValueAtTime(cv.value, t);
83+
cv.linearRampToValueAtTime(0, t + e.r);
84+
this.envStart = performance.now();
85+
this.envPhase = "rel";
86+
}
87+
reset() {
88+
const t = this.ctx.currentTime;
89+
this.node.gain.cancelScheduledValues(t);
90+
this.node.gain.setValueAtTime(1, t);
91+
this.cvOut.offset.cancelScheduledValues(t);
92+
this.cvOut.offset.setValueAtTime(0, t);
93+
this.envPhase = "idle";
94+
this._gateSources.clear();
95+
}
96+
97+
_handleGate(sourceId, active) {
98+
const wasOpen = this._gateSources.size > 0;
99+
if (active) this._gateSources.add(sourceId);
100+
else this._gateSources.delete(sourceId);
101+
const nowOpen = this._gateSources.size > 0;
102+
if (!wasOpen && nowOpen) this.noteOn();
103+
else if (wasOpen && !nowOpen) this.noteOff();
104+
}
105+
106+
setParam(name, value) {
107+
if (name === "a" || name === "r") {
108+
this.setParams({ [name]: value });
109+
}
110+
}
111+
112+
dispose() {
113+
try { this.cvOut.stop(); } catch {}
114+
try { this.cvOut.disconnect(); } catch {}
115+
try { this.node.disconnect(); } catch {}
116+
super.dispose();
117+
}
118+
}
119+

0 commit comments

Comments
 (0)