control voltage — the world as raw electricity for modular gear

Two paths, both documented in the grimoire:
- MIDI-to-CV (already live, no new code): the note voices are 1V/oct pitch +
  gate, CCs are modulation, 24ppqn clock is analog sync — any MIDI-CV module
  (Befaco, CM1A, FH-2, Yarns) is driven today.
- NEW: true DC control voltage from Web Audio. Sky menu ->  control voltage.
  A CV panel maps any source/voice to an output channel of a DC-coupled
  interface (ES-8/ES-9) via ConstantSourceNode -> ChannelMerger -> multichannel
  destination (setSinkId device pick). Modes: unipolar / bipolar / 1V-oct
  (semitone-accurate), per-channel scale trim (real gear never tracks on
  paper). Config persists (gs_cv). Output CLAMPED to +/-1 sample-units so it
  can never send a runaway/damaging value.

Verified: voltage math unit-tested (monotonic, 1V/oct = 0.1/oct, clamped);
panel opens, device enumeration, channel add + persist, and the audio graph
starts cleanly (no exceptions). Physical voltage needs DC-coupled hardware to
confirm (documented). Zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-06 15:12:55 +10:00
parent 4bf193d097
commit c6dd2adcee

View File

@ -76,13 +76,26 @@
#inspect .midibtn:disabled { opacity: 0.4; cursor: default; }
/* the holdings panel — your private ledger, localStorage only */
#holdings, #natal, #commune {
position: fixed; top: 96px; right: 14px; z-index: 12; width: 272px;
#cvpanel { display: none; }
#cvpanel.open { display: block; }
#cvpanel .cvrow { display: flex; gap: 5px; align-items: center; margin: 4px 0; }
#cvpanel .cvrow select { min-width: 0; }
#cvpanel .cvrow .asel:first-child { flex: 1; }
#cvpanel .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
#holdings, #natal, #commune, #cvpanel {
position: fixed; top: 96px; right: 14px; z-index: 12; width: 292px;
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
border-radius: 12px; padding: 12px 14px; display: none; color: #c7d4ea;
font-size: 12px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
}
#holdings.open, #natal.open, #commune.open { display: block; }
#holdings.open, #natal.open, #commune.open, #cvpanel.open { display: block; }
#cvpanel h3 { margin: 0 24px 4px 0; font-size: 13px; color: #ffd98a; }
#cvpanel .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
#cvpanel .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
#cvpanel .hrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
#cvpanel select { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3); border-radius: 6px; padding: 3px 6px; font-size: 11px; }
#cvpanel .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25); border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer; }
#cvpanel .midibtn.on { background: rgba(255,200,90,0.22); color: #ffe6a8; border-color: rgba(255,200,90,0.5); }
#commune h3 { margin: 0 24px 4px 0; font-size: 13px; color: #8fd8e8; }
#commune .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
#commune .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
@ -941,6 +954,8 @@
<p><b>The Ableton recipe — same Mac, three minutes, your whole plugin collection becomes the instrument.</b> Native VST/AU plugins can't run <i>inside</i> a browser page — the sandbox forbids it, by design. But they don't need to: Ableton is the plugin host, and the Godstrument drives it. <b>1)</b> Open <i>Audio MIDI Setup → Window → Show MIDI Studio</i>, double-click <b>IAC Driver</b>, tick <i>Device is online</i> — that's a virtual MIDI cable inside your Mac. <b>2)</b> Here: ⚙ → WEB MIDI → enable → out port = <b>IAC Bus 1</b>. <b>3)</b> In Ableton: <i>Settings → Link/Tempo/MIDI</i> → the IAC input: <b>Track</b> and <b>Remote</b> on (and <b>Sync</b> on, if you want godtime — or your heartbeat — to be Ableton's tempo: then press EXT in the transport). <b>4)</b> Drop any instrument on a MIDI track, monitor <i>In</i>, and the <b>lead</b> (ch 1) and <b>bass</b> (ch 2) play it live; hit ⌘M and MIDI-map the CCs above to any macro, any plugin knob, any send. Bitcoin playing your Serum, earthquakes riding your favorite reverb's decay, Delhi's air on a distortion — every plugin you own, played by the planet. The reverse works too: MIDI-learn here binds your hardware knobs to the console's own controls.</p>
<p><b>⚡ Control voltage — the world as raw electricity, into modular gear.</b> Two ways to make the planet move a Eurorack. <b>The easy way, already live:</b> the note voices <i>are</i> 1V/oct pitch + gate and the CCs <i>are</i> modulation, so route them out (⚙ → Web MIDI, or the Ableton/IAC recipe above) into any <b>MIDI-to-CV module</b> — a Befaco CV Thing, a Behringer CM1A, an Expert Sleepers FH-2, a Mutable Yarns — and BTC's price is bending an oscillator, the earthquake is opening a filter, the clock is your analog sync. Nothing to install; it works today. <b>The pure way:</b> right-click the sky → <b>⚡ control voltage</b> and the browser emits <i>true DC voltage</i> straight from Web Audio into a <b>DC-coupled interface</b> (an Expert Sleepers ES-8 or ES-9 in your case). Map any feed to any output channel, choose <b>unipolar</b> (0→+V), <b>bipolar</b> (V→+V) or <b>1V/oct</b> pitch, and leave the calibration knob where your oscillator actually tracks — because a real interface and a real VCO never agree with the maths. Stock price to a drone's pitch, volatility to its filter, volume to its drive: sit back and listen to the global economy breathe and crash through an analog voice. (DC out needs a DC-coupled interface; a normal soundcard blocks it — that's what the MIDI path is for.)</p>
<p><b>Three — Strudel, TouchDesigner, OSC.</b> The hub streams <i>/out/&lt;destination&gt;</i> floats to <kbd>127.0.0.1:9001</kbd> — point TouchDesigner's OSC In or a Max-for-Live device at that port and drive visuals or a modular from the same matrix. And <kbd>strudel/godstrument.md</kbd> is a starter kit that plays a live Strudel track (free, open, no Ableton) straight off the CC map, with a no-MIDI websocket path for a custom web-audio synth. One matrix, many mouths.</p>
<div class="hr"></div>
@ -4601,6 +4616,8 @@
() => openNatal());
ctxItem("🕸 the commune…" + (commune.room ? " (" + commune.room + ")" : ""),
() => openCommune());
ctxItem("⚡ control voltage…" + (cv.on ? " (live)" : ""),
() => openCV());
if (commune.room) {
const gOwner = commune.alloc["@godtime"];
ctxItem(gOwner === communeMe() ? "🕸 release the godtime" :
@ -4976,6 +4993,125 @@
}
if (holdings.length) holdingsStart(); // returning holders rejoin the choir on load
// ---- control voltage out (⚡) — the world as raw electricity for modular gear
// Two paths. (1) MIDI-to-CV, already live: the note voices are 1V/oct pitch +
// gate and the CCs are modulation, so any MIDI-CV module (Befaco, CM1A, FH-2,
// Yarns…) is driven today. (2) This module: TRUE DC control voltage from the
// browser via Web Audio ConstantSourceNodes → a DC-coupled interface (Expert
// Sleepers ES-8/ES-9…). Each channel maps a source/voice to a voltage, at
// unipolar / bipolar / 1V-oct, with a calibration trim — because a real
// interface and a real oscillator never track perfectly on paper.
const CV_MODES = ["unipolar", "bipolar", "1V/oct"];
const cv = { on: false, ctx: null, merger: null, sink: "default", timer: null, rows: [] };
try { const s2 = JSON.parse(localStorage.getItem("gs_cv") || "null"); if (s2 && Array.isArray(s2.rows)) { cv.rows = s2.rows; cv.sink = s2.sink || "default"; } } catch (e) {}
cv.rows.forEach(r => { r.node = null; });
function cvNodeFor(key) { return sources.get(key) || dests.get(key); }
function cvValue(row) { // → offset in Web Audio "sample units" (-1..1)
const n = cvNodeFor(row.key); if (!n) return 0;
const amt = row.amount != null ? row.amount : 0.5, trim = row.trim || 0;
if (row.mode === "1V/oct") { // amount = sample-units per octave (calibrate ~0.1 for ±10V full-scale)
const note = n.isNote ? n.raw : (n.norm || 0) * 60 + 24;
return clamp((Math.round(note) - 24) / 12 * amt + trim, -1, 1);
}
const v = clamp(n.norm || 0, 0, 1);
return clamp((row.mode === "bipolar" ? v * 2 - 1 : v) * amt + trim, -1, 1);
}
function cvWire(row) {
if (!cv.ctx || !cv.merger) return;
if (row.node) { try { row.node.disconnect(); row.node.stop(); } catch (e) {} }
const cs = cv.ctx.createConstantSource(); cs.offset.value = 0; cs.start();
cs.connect(cv.merger, 0, clamp(Math.round(row.ch || 0), 0, cv.merger.numberOfInputs - 1));
row.node = cs;
}
function cvTick() {
if (!cv.on || !cv.ctx) return;
for (const row of cv.rows) if (row.node)
row.node.offset.setTargetAtTime(cvValue(row), cv.ctx.currentTime, 0.006);
}
async function cvStart() {
if (cv.on) return;
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
cv.ctx = new AC();
if (cv.sink !== "default" && cv.ctx.setSinkId) { try { await cv.ctx.setSinkId(cv.sink); } catch (e) {} }
await cv.ctx.resume();
const maxCh = Math.min(8, cv.ctx.destination.maxChannelCount || 2);
try { cv.ctx.destination.channelCount = maxCh;
cv.ctx.destination.channelCountMode = "explicit";
cv.ctx.destination.channelInterpretation = "discrete"; } catch (e) {}
cv.merger = cv.ctx.createChannelMerger(maxCh);
cv.merger.connect(cv.ctx.destination);
for (const row of cv.rows) cvWire(row);
cv.on = true;
if (!cv.timer) cv.timer = setInterval(cvTick, 15);
eventTicker.unshift({ text: "⚡ control voltage live on " + maxCh + " channels — DC out to your modular", tAdded: now(), src: "sys" });
}
function cvStop() {
cv.on = false;
if (cv.timer) { clearInterval(cv.timer); cv.timer = null; }
for (const row of cv.rows) { if (row.node) { try { row.node.stop(); row.node.disconnect(); } catch (e) {} row.node = null; } }
if (cv.ctx) { try { cv.ctx.close(); } catch (e) {} cv.ctx = null; cv.merger = null; }
}
function cvSave() { try { localStorage.setItem("gs_cv", JSON.stringify({ sink: cv.sink, rows: cv.rows.map(r => ({ key: r.key, ch: r.ch, mode: r.mode, amount: r.amount, trim: r.trim })) })); } catch (e) {} }
const cvEl = document.createElement("div"); cvEl.id = "cvpanel"; document.body.appendChild(cvEl);
function closeCV() { cvEl.classList.remove("open"); }
async function openCV() {
cvEl.innerHTML = "";
const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "×"; x2.onclick = closeCV; cvEl.appendChild(x2);
const h3 = document.createElement("h3"); h3.textContent = "⚡ control voltage"; cvEl.appendChild(h3);
const sub = document.createElement("div"); sub.className = "sub";
sub.innerHTML = "true DC voltage out of the browser into a <b>DC-coupled interface</b> (Expert Sleepers ES-8/ES-9…). Each channel maps a feed to a voltage. <i>No DC-coupled gear? The MIDI out already drives any MIDI-to-CV module — see the grimoire.</i>";
cvEl.appendChild(sub);
// output device
const devRow = document.createElement("div"); devRow.className = "hrow";
const devSel = document.createElement("select"); devSel.className = "asel"; devSel.style.flex = "1";
const optD = document.createElement("option"); optD.value = "default"; optD.textContent = "default output device"; devSel.appendChild(optD);
try {
const devs = await navigator.mediaDevices.enumerateDevices();
devs.filter(d => d.kind === "audiooutput").forEach(d => {
if (d.deviceId === "default") return;
const o = document.createElement("option"); o.value = d.deviceId; o.textContent = d.label || ("output " + d.deviceId.slice(0, 6)); devSel.appendChild(o);
});
} catch (e) {}
devSel.value = cv.sink;
devSel.onchange = () => { cv.sink = devSel.value; cvSave(); if (cv.on) { cvStop(); cvStart(); } };
devRow.appendChild(devSel); cvEl.appendChild(devRow);
// rows
const list = document.createElement("div"); cvEl.appendChild(list);
const allKeys = Array.from(sources.keys()).sort().concat(Array.from(dests.keys()).sort());
const drawRow = (row) => {
const rw = document.createElement("div"); rw.className = "cvrow";
const kSel = document.createElement("select"); kSel.className = "asel";
for (const k2 of allKeys) { const o = document.createElement("option"); o.value = k2; o.textContent = (dests.has(k2) ? "▸ " : "") + prettyDest(k2); if (k2 === row.key) o.selected = true; kSel.appendChild(o); }
kSel.onchange = () => { row.key = kSel.value; cvSave(); };
const chSel = document.createElement("select"); chSel.className = "asel"; chSel.style.width = "48px";
for (let c2 = 0; c2 < 8; c2++) { const o = document.createElement("option"); o.value = c2; o.textContent = "ch" + (c2 + 1); if (c2 === (row.ch || 0)) o.selected = true; chSel.appendChild(o); }
chSel.onchange = () => { row.ch = +chSel.value; if (cv.on) cvWire(row); cvSave(); };
const mSel = document.createElement("select"); mSel.className = "asel"; mSel.style.width = "78px";
for (const md of CV_MODES) { const o = document.createElement("option"); o.value = md; o.textContent = md; if (md === row.mode) o.selected = true; mSel.appendChild(o); }
mSel.onchange = () => { row.mode = mSel.value; cvSave(); };
const amt = document.createElement("input"); amt.type = "range"; amt.min = "0"; amt.max = "1"; amt.step = "0.01"; amt.value = row.amount != null ? row.amount : 0.5; amt.title = "scale (calibrate)"; amt.style.width = "60px";
amt.oninput = () => { row.amount = parseFloat(amt.value); cvSave(); };
const rm = document.createElement("span"); rm.className = "x"; rm.textContent = "×";
rm.onclick = () => { const i = cv.rows.indexOf(row); if (i >= 0) { if (row.node) { try { row.node.stop(); row.node.disconnect(); } catch (e) {} } cv.rows.splice(i, 1); } cvSave(); openCV(); };
rw.appendChild(kSel); rw.appendChild(chSel); rw.appendChild(mSel); rw.appendChild(amt); rw.appendChild(rm);
list.appendChild(rw);
};
cv.rows.forEach(drawRow);
const add = document.createElement("button"); add.className = "midibtn"; add.textContent = "+ channel"; add.style.marginTop = "6px";
add.onclick = () => { const row = { key: allKeys[0], ch: cv.rows.length % 8, mode: "unipolar", amount: 0.5, trim: 0, node: null }; cv.rows.push(row); if (cv.on) cvWire(row); cvSave(); openCV(); };
const tog = document.createElement("button"); tog.className = "midibtn"; tog.style.marginLeft = "8px";
tog.textContent = cv.on ? "■ stop CV" : "▶ start CV"; tog.classList.toggle("on", cv.on);
tog.onclick = () => { cv.on ? cvStop() : cvStart(); openCV(); };
const btns = document.createElement("div"); btns.style.marginTop = "8px"; btns.appendChild(tog); btns.appendChild(add); cvEl.appendChild(btns);
const cal = document.createElement("div"); cal.className = "sub"; cal.style.marginTop = "8px";
cal.innerHTML = "<b>calibrate:</b> for <b>1V/oct</b> pitch, nudge <i>scale</i> until an octave of change lands a true octave on your oscillator (interfaces differ; ES-8 ≈ 0.1 scale). unipolar = 0→+V, bipolar = V→+V.";
cvEl.appendChild(cal);
cvEl.classList.add("open");
}
if (cv.rows.length) { /* wait for user to press start — audio needs a gesture */ }
// ---- your stars (the natal source — a real ephemeris, in the browser) -----
// Truncated Meeus (Sun ~0.01°, Moon ≤0.1°) + JPL/Standish Keplerian elements
// (planets ≤~0.2°, valid 1800-2050), precessed J2000→equinox of date so the