diff --git a/viz/index.html b/viz/index.html
index 345c644..92cbdb6 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -940,6 +940,8 @@
One — the built-in browser synth. Click ♪ and a full voice rack wakes inside the page: lead (bitcoin's price and your market's average price both quantize to it — minor pentatonic and G dorian), bass (quake depth chooses its notes in a low minor), pad (brightened by Tokyo's warmth, daylight, harmony, the birth rate, longer lives), drone (thickened by aircraft aloft, barometric pressure, Saturn's slow wheel, the weight of the national debt), and perc (BTC volatility and market activity drive the hats). Over them sit the effects the world modulates: reverb (earthquakes open the void, room light sizes it), delay (your hand's Y feeds it, Mercury retrograde smears it), saturation (Delhi's air becomes grit, hard aspects add dissonance, wildfires burn it, hunger grinds beneath), and glitch (every Wikipedia edit on Earth, every light flash, every new wildfire fires a burst). Nothing to install; the planet plays a synth in your tab.
+🌀 The Earth Echo — a Space Echo whose tape is the planet. Right-click the sky → 🌀 earth echo, press ♪, and the whole mix runs onto a virtual Roland tape delay — but its knobs are wired to the living world. Its repeat rate is the wind (Tokyo's gusts whip the tape speed, and because a tape motor has inertia the pitch glides as it changes — that dubby seasick bend); its intensity is volatility (bitcoin's fever climbs the feedback toward a screaming, self-oscillating howl); its tone is the air (Delhi's PM2.5 rolls the treble off until the echoes melt into a dark, suffocating soup); its wow & flutter is the Moon (the lunar cycle warbles the pitch with an eerie, drifting vibrato); its tape wear is the world's fire (every wildfire adds hiss and grit, the machine growing more fragile as the planet burns); and a quake throws the whole echo forward. Best of all, these six knobs are ordinary voices in the matrix — echo.time, echo.feedback, echo.tone, echo.flutter, echo.wear, echo.wet — so you can drag any feed onto any of them, groove them on the 16-step grid, or send them out as CV. Turn it on and the world doesn't just play the instrument; it plays the space the instrument lives in.
+
Two — Web MIDI, out into a DAW or hardware. In Chrome or Edge, open the ⚙ panel's WEB MIDI section, enable MIDI, and pick an out port. Every destination now streams as CC or notes straight to your rig — no Python, no IAC bus. The map is fixed and MIDI-learnable at the far end:
@@ -4618,6 +4620,8 @@
() => openCommune());
ctxItem("⚡ control voltage…" + (cv.on ? " (live)" : ""),
() => openCV());
+ ctxItem((earthEchoOn ? "🌀 earth echo: on" : "🌀 earth echo — the planet on tape"),
+ () => earthEchoSet(!earthEchoOn));
if (commune.room) {
const gOwner = commune.alloc["@godtime"];
ctxItem(gOwner === communeMe() ? "🕸 release the godtime" :
@@ -5112,6 +5116,31 @@
}
if (cv.rows.length) { /* wait for user to press start — audio needs a gesture */ }
+ // ---- the Earth Echo — a tape delay whose knobs ARE matrix voices, wired to
+ // the planet by default (any feed can be re-patched onto them like any voice).
+ let earthEchoOn = false;
+ try { earthEchoOn = localStorage.getItem("gs_echo") === "1"; } catch (e) {}
+ const ECHO_CABLES = [
+ ["weather.wind", "echo.time", 0.75], // wind whips the tape speed → pitch-glide
+ ["crypto.vel", "echo.feedback", 0.8], // volatility drives it toward self-oscillation
+ ["air.pm25", "echo.tone", 0.9], // pollution rolls the treble off, dark dub soup
+ ["astro.moon", "echo.flutter", 0.65], // the moon warbles the pitch (wow & flutter)
+ ["fire.count", "echo.wear", 0.7], // the world's fires wear the tape (hiss)
+ ["quake.event", "echo.wet", 0.6], // a quake throws the whole echo forward
+ ];
+ function earthEchoSet(on) {
+ earthEchoOn = on;
+ try { localStorage.setItem("gs_echo", on ? "1" : "0"); } catch (e) {}
+ if (on) {
+ for (const [s, d2, a] of ECHO_CABLES) addRouteLocal(s, d2, a); // installs cables + echo.* voices
+ eventTicker.unshift({ text: "🌀 earth echo on — the planet is on the tape: wind→time · volatility→feedback · air→tone · moon→wow · fire→wear. patch any of it like any voice.", tAdded: now(), src: "sys" });
+ } else {
+ for (const [s, d2] of ECHO_CABLES) removeRouteLocal(s, d2);
+ eventTicker.unshift({ text: "🌀 earth echo off", tAdded: now(), src: "sys" });
+ }
+ }
+ if (earthEchoOn) setTimeout(() => { for (const [s, d2, a] of ECHO_CABLES) addRouteLocal(s, d2, a); }, 1200);
+
// ---- 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
@@ -5690,6 +5719,30 @@
const delWet = ctx.createGain(); delWet.gain.value = 0.35;
delSend.connect(delay); delay.connect(delWet); delWet.connect(out);
+ // ---- Earth Echo — a Roland Space Echo whose tape is driven by the planet.
+ // Repeat rate glides (tape-motor inertia → pitch bend); feedback climbs to
+ // near self-oscillation; a lowpass in the loop is the treble roll (pollution
+ // darkens); a waveshaper is tape grit; an LFO on the delay time is wow &
+ // flutter (the moon warbles it); a noise bed is tape hiss (wear).
+ const echoIn = ctx.createGain(); echoIn.gain.value = 1;
+ const echoDelay = ctx.createDelay(1.2); echoDelay.delayTime.value = 0.28;
+ const echoFilt = ctx.createBiquadFilter(); echoFilt.type = "lowpass"; echoFilt.frequency.value = 6500;
+ const echoHP = ctx.createBiquadFilter(); echoHP.type = "highpass"; echoHP.frequency.value = 120;
+ const echoSat = ctx.createWaveShaper(); echoSat.curve = tanhCurve();
+ const echoFb = ctx.createGain(); echoFb.gain.value = 0.3;
+ const echoWet = ctx.createGain(); echoWet.gain.value = 0; // silent until enabled
+ echoDelay.connect(echoFilt); echoFilt.connect(echoHP); echoHP.connect(echoSat);
+ echoSat.connect(echoFb); echoFb.connect(echoDelay); // the tape loop
+ echoIn.connect(echoDelay); echoDelay.connect(echoWet); echoWet.connect(out);
+ const echoFlutLfo = ctx.createOscillator(); echoFlutLfo.type = "sine"; echoFlutLfo.frequency.value = 1.3;
+ const echoFlutAmt = ctx.createGain(); echoFlutAmt.gain.value = 0;
+ echoFlutLfo.connect(echoFlutAmt); echoFlutAmt.connect(echoDelay.delayTime); echoFlutLfo.start();
+ const hissBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
+ { const ch = hissBuf.getChannelData(0); for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * 0.5; }
+ const hiss = ctx.createBufferSource(); hiss.buffer = hissBuf; hiss.loop = true;
+ const hissGain = ctx.createGain(); hissGain.gain.value = 0;
+ hiss.connect(hissGain); hissGain.connect(echoDelay); hiss.start();
+
const preSat = ctx.createGain(); // all voices sum here
const shaper = ctx.createWaveShaper(); shaper.curve = tanhCurve();
const satDry = ctx.createGain(); satDry.gain.value = 1;
@@ -5699,6 +5752,7 @@
preSat.connect(shaper); shaper.connect(satWet); satWet.connect(post);
const glitch = ctx.createGain(); glitch.gain.value = 1; // stutter target
post.connect(glitch); glitch.connect(out); post.connect(delSend); post.connect(revSend);
+ post.connect(echoIn); // the mix onto the Earth Echo tape
lfo = ctx.createOscillator(); lfo.frequency.value = 0.2;
const lfoAmt = ctx.createGain(); lfoAmt.gain.value = 260; lfo.connect(lfoAmt);
@@ -5733,7 +5787,8 @@
lfo.start();
return { out, preSat, glitch, revSend, fb, satDry, satWet, leadOsc, leadFilt, leadAmp,
- bassOsc, bassAmp, padOscs, padFilt, padAmp, droneOscs, droneAmp };
+ bassOsc, bassAmp, padOscs, padFilt, padAmp, droneOscs, droneAmp,
+ echoDelay, echoFilt, echoFb, echoWet, echoFlutAmt, hissGain };
}
function hit(node, peak, dec) { // pluck envelope
@@ -5764,6 +5819,16 @@
const sat = d("saturation", 0);
S(N.satWet.gain, sat * 0.9, 0.2); S(N.satDry.gain, 1 - sat * 0.4, 0.2);
lfo.frequency.setTargetAtTime(0.1 + d("lfo.rate", 0) * 6, t, 0.3);
+
+ // ---- Earth Echo — the planet on the tape ----
+ // Repeat rate GLIDES (long time-constant) so a change pitch-bends the tail,
+ // the tape-motor artifact. Feedback climbs toward — never past — self-osc.
+ N.echoDelay.delayTime.setTargetAtTime(0.06 + c01(d("echo.time", 0)) * 0.44, t, 0.28);
+ N.echoFb.gain.setTargetAtTime(Math.min(0.97, c01(d("echo.feedback", 0)) * 0.99), t, 0.15);
+ N.echoFilt.frequency.setTargetAtTime(6800 - c01(d("echo.tone", 0)) * 6300, t, 0.3); // pollution darkens
+ N.echoFlutAmt.gain.setTargetAtTime(c01(d("echo.flutter", 0)) * 0.006, t, 0.3); // the moon warbles it
+ N.hissGain.gain.setTargetAtTime(c01(d("echo.wear", 0)) * 0.03, t, 0.4); // fire wears the tape
+ N.echoWet.gain.setTargetAtTime(earthEchoOn ? (0.16 + c01(d("echo.wet", 0)) * 0.5) : 0, t, 0.3);
// retriggers are rate-limited to the godtime grid — data jitter glides
// the pitch but only strikes on musical time, not machine-gun time
const minGap = 30 / Math.max(40, godtime.bpm); // one 8th note