From c6dd2adceed58d50b0f877af3187715b190a790b Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 6 Jul 2026 15:12:55 +1000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20control=20voltage=20=E2=80=94=20the?= =?UTF-8?q?=20world=20as=20raw=20electricity=20for=20modular=20gear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- viz/index.html | 142 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 139 insertions(+), 3 deletions(-) diff --git a/viz/index.html b/viz/index.html index 13fc5ab..345c644 100644 --- a/viz/index.html +++ b/viz/index.html @@ -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 @@

The Ableton recipe — same Mac, three minutes, your whole plugin collection becomes the instrument. Native VST/AU plugins can't run inside 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. 1) Open Audio MIDI Setup → Window → Show MIDI Studio, double-click IAC Driver, tick Device is online — that's a virtual MIDI cable inside your Mac. 2) Here: ⚙ → WEB MIDI → enable → out port = IAC Bus 1. 3) In Ableton: Settings → Link/Tempo/MIDI → the IAC input: Track and Remote on (and Sync on, if you want godtime — or your heartbeat — to be Ableton's tempo: then press EXT in the transport). 4) Drop any instrument on a MIDI track, monitor In, and the lead (ch 1) and bass (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.

+

⚡ Control voltage — the world as raw electricity, into modular gear. Two ways to make the planet move a Eurorack. The easy way, already live: the note voices are 1V/oct pitch + gate and the CCs are modulation, so route them out (⚙ → Web MIDI, or the Ableton/IAC recipe above) into any MIDI-to-CV module — 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. The pure way: right-click the sky → ⚡ control voltage and the browser emits true DC voltage straight from Web Audio into a DC-coupled interface (an Expert Sleepers ES-8 or ES-9 in your case). Map any feed to any output channel, choose unipolar (0→+V), bipolar (−V→+V) or 1V/oct 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.)

+

Three — Strudel, TouchDesigner, OSC. The hub streams /out/<destination> floats to 127.0.0.1:9001 — 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 strudel/godstrument.md 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.

@@ -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 DC-coupled interface (Expert Sleepers ES-8/ES-9…). Each channel maps a feed to a voltage. No DC-coupled gear? The MIDI out already drives any MIDI-to-CV module — see the grimoire."; + 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 = "calibrate: for 1V/oct pitch, nudge scale 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