diora-web/static/ambient/index.html
mrwnslz c1bbaccccf
Some checks failed
Build and push Docker image / build (push) Successful in 52s
Test / test (push) Has been cancelled
Stille überspringen im Browser/Desktop (Server findet die Pausen); Ambient-Rekorder im Audio-Thread; sw v86
- podcasts/silence.py: ffmpeg (imageio-ffmpeg) findet die Pausen einer Folge einmal
  (silencedetect -35 dB, ab 0,4 s), im Hintergrund, höchstens 2 zugleich; gespeichert in
  PodcastEpisode.silences (Migration 0018); GET episodes/<id>/silences/
- podcasts-web.js: skip_silence als Einstellung (Gerät, pro Podcast vom Server), springt
  beim Abspielen über die Pausen (0,15 s bleiben)
- Drift: Ringpuffer für die Körner als AudioWorklet statt ScriptProcessor im Hauptthread
  (gehäufte Aussetzer am Desktop, wenn die Seite kurz stockte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:38:06 +02:00

1374 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Drift – Ambient Synth</title>
<style>
:root { --bg:#0b0e14; --fg:#d7dde8; --dim:#7b8598; --acc:#7fb4ff; --acc2:#c99bff; }
* { box-sizing:border-box; }
html,body { margin:0; height:100%; background:var(--bg); color:var(--fg);
font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; overflow:hidden; }
body { position:relative; }
canvas { position:absolute; inset:0; width:100%; height:100%; display:block; }
main { position:relative; z-index:1; height:100%; display:flex; flex-direction:column; gap:18px;
justify-content:space-between; padding:28px 24px; pointer-events:none;
overflow-x:hidden; overflow-y:auto; }
.embed header { display:none; }
.embed #share { display:none; }
.embed .m-vol { display:none; } /* eingebettet regelt die Seite die Lautstärke (cmd "volume") */
.embed .state { padding-right:58px; } /* oben rechts liegt in diora ein Knopf */
.noise .m-music, .noise .meters { display:none !important; }
html:not(.noise) .m-noise { display:none; }
.noise:not(.rev) .m-hall { display:none; }
main > * { pointer-events:auto; width:100%; max-width:960px; margin-inline:auto; } /* auf breiten Fenstern zentriert statt über die ganze Breite */
h1 { margin:0; font-weight:300; letter-spacing:.35em; text-transform:uppercase; font-size:18px; }
.sub { color:var(--dim); font-size:13px; margin-top:4px; }
.state { font-size:13px; color:var(--dim); font-variant-numeric:tabular-nums; }
.state b { color:var(--fg); font-weight:500; }
.state .big { font-size:34px; font-weight:200; color:var(--fg); display:block; letter-spacing:.04em; }
.panel { display:flex; flex-wrap:wrap; gap:14px 24px; align-items:end;
background:rgba(11,14,20,.6); backdrop-filter:blur(10px); border:1px solid #ffffff14;
border-radius:14px; padding:14px 18px; }
label { display:flex; flex-direction:column; gap:6px; font-size:11px; color:var(--dim);
text-transform:uppercase; letter-spacing:.12em; }
input[type=range] { width:130px; accent-color:var(--acc); }
select, input[type=text] { font:inherit; font-size:14px; color:var(--fg); background:#ffffff0f;
border:1px solid #ffffff22; border-radius:8px; padding:6px 10px; }
input[type=text] { width:120px; }
select option { background:#141925; }
button { font:inherit; color:var(--fg); background:#ffffff0f; border:1px solid #ffffff22;
border-radius:999px; padding:8px 18px; cursor:pointer; transition:.2s; }
button:hover { background:#ffffff1c; }
button.primary { background:linear-gradient(135deg,var(--acc),var(--acc2)); color:#0b0e14;
border:0; font-weight:600; min-width:100px; }
.sep { flex-basis:100%; height:0; }
.meters { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); max-width:340px; gap:6px 12px; margin-top:10px; }
.meter { height:3px; background:#ffffff18; border-radius:2px; overflow:hidden; }
.meter i { display:block; height:100%; width:50%; background:var(--acc); transition:width 1s linear; }
@media (max-width:520px) {
main { padding:18px 16px; }
.state .big { font-size:28px; }
.panel { display:grid; grid-template-columns:1fr 1fr; gap:12px 14px; padding:12px 14px; }
.panel .sep { display:none; }
.panel > button.primary { grid-column:1 / -1; }
.panel label, .panel select, .panel input[type=text], .panel input[type=range] { width:100%; min-width:0; }
}
.mlabel { font-size:10px; text-transform:uppercase; letter-spacing:.1em; color:var(--dim); }
</style>
</head>
<body>
<canvas id="viz"></canvas>
<main>
<header>
<h1>Drift</h1>
<div class="sub">Generativer Ambient-Synthesizer · alles wird live im Browser erzeugt</div>
</header>
<div class="state">
<span class="big" id="key">–</span>
<span id="chord">Noch still.</span>
<div class="meters" id="meters"></div>
</div>
<div class="panel">
<button class="primary" id="toggle">Start</button>
<label>Modus
<select id="mode"></select></label>
<label class="m-music">Stimmung
<select id="preset"></select></label>
<label class="m-music">Seed
<input type="text" id="seed" spellcheck="false"></label>
<button id="reroll" class="m-music" title="Neuer Seed, neue Szene">Neu würfeln</button>
<button id="share" class="m-music" title="Link mit Seed und Stimmung kopieren">Link kopieren</button>
<label class="m-music">Tuning
<select id="tuning"><option value="just">Rein</option><option value="equal">Gleichstufig</option></select></label>
<div class="sep"></div>
<label class="m-vol">Lautstärke <input type="range" id="vol" min="0" max="1" step="0.01" value="0.7"></label>
<label class="m-noise"><span id="lla">A</span> <input type="range" id="la" min="0" max="1" step="0.01" value="0.5"></label>
<label class="m-noise"><span id="llb">B</span> <input type="range" id="lb" min="0" max="1" step="0.01" value="0.5"></label>
<label class="m-music">Dichte <input type="range" id="density" min="0" max="1" step="0.01" value="0.5"></label>
<label><span id="driftL">Wandel-Tempo</span> <input type="range" id="drift" min="0" max="1" step="0.01" value="0.5"></label>
<label class="m-hall">Hall <input type="range" id="verb" min="0" max="1" step="0.01" value="0.6"></label>
<label class="m-music">Spannungsbogen <input type="range" id="arc" min="0" max="1" step="0.01" value="0.6"></label>
</div>
</main>
<script>
"use strict";
const $ = id => document.getElementById(id);
// Telefone: leichter rechnen, damit der Ton nicht ruckelt (Bild in einfacher Auflösung, ~30 Bilder/s,
// kürzerer Hall, kein Oversampling, keine Körner-Aufnahme im Hauptthread).
const LITE = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent);
const DPR = () => LITE ? .5 : devicePixelRatio; // Telefone: das Bild in halber Auflösung, hochskaliert
const MAX_VOICES = 10; // Telefone: höchstens so viele Stimmen zugleich (der Grundton zählt nicht)
const QS = new URLSearchParams(location.search);
if (QS.get("embed") === "1") document.documentElement.classList.add("embed");
let BG = [11, 14, 20]; // Hintergrundfarbe, per ?bg=%23101010 überschreibbar
{
const m = /^#?([0-9a-f]{6})$/i.exec(QS.get("bg") || "");
if (m) {
BG = [0, 2, 4].map(i => parseInt(m[1].substr(i, 2), 16));
document.documentElement.style.setProperty("--bg", "#" + m[1]);
}
}
// ---------- Zufall (seedbar) ----------
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
// v = Stimmen/Details, s = Szene (Tonart, Klangfarbe), n = Sonstiges
const rng = { v: Math.random, s: Math.random, n: Math.random };
const rnd = (a, b, r = rng.v) => a + r() * (b - a);
const pick = (arr, r = rng.v) => arr[Math.floor(r() * arr.length)];
const mtof = m => 440 * Math.pow(2, (m - 69) / 12);
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
function toSeed(str) {
if (/^\d+$/.test(str)) return parseInt(str, 10) % 2147483647 || 1;
let h = 2166136261;
for (const ch of str) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
return (h >>> 0) % 2147483647 || 1;
}
function seedAll(seed) {
rng.v = mulberry32(seed); rng.s = mulberry32(seed ^ 0x9e3779b9); rng.n = mulberry32(seed ^ 0x85ebca6b);
}
// ---------- Musikalische Daten ----------
const NOTE = ["C","C♯","D","D♯","E","F","F♯","G","G♯","A","A♯","H"];
const SCALES = {
"Dorisch": [0,2,3,5,7,9,10],
"Lydisch": [0,2,4,6,7,9,11],
"Äolisch": [0,2,3,5,7,8,10],
"Mixolydisch": [0,2,4,5,7,9,10],
"Phrygisch": [0,1,3,5,7,8,10],
"Moll-Pentatonik": [0,3,5,7,10],
"Dur-Pentatonik": [0,2,4,7,9],
"Ganzton": [0,2,4,6,8,10],
};
// Reine Stimmung: Frequenzverhältnisse pro Halbton über dem Grundton
const JI = [1, 16/15, 9/8, 6/5, 5/4, 4/3, 45/32, 3/2, 8/5, 5/3, 9/5, 15/8];
const PRESETS = {
"Frei": { scales:Object.keys(SCALES), bright:[.1,.95], fm:[0,.8], saw:[0,.9], wt:[0,.7], noise:[.05,.5], grain:[0,.6], choir:.15, bells:1, swell:.3, dens:1 },
"Dunkel": { scales:["Phrygisch","Äolisch","Moll-Pentatonik"], bright:[.05,.35], fm:[0,.25], saw:[.3,.9], wt:[0,.3], noise:[.3,.8], grain:[.2,.6], choir:.1, bells:.4, swell:.5, dens:.8 },
"Schwebend": { scales:["Lydisch","Dur-Pentatonik","Dorisch"], bright:[.3,.7], fm:[.05,.4], saw:[0,.4], wt:[.2,.7], noise:[0,.25], grain:[0,.3], choir:.4, bells:1, swell:.4, dens:1 },
"Glasig": { scales:["Ganzton","Dur-Pentatonik","Lydisch"], bright:[.6,.95], fm:[.4,.9], saw:[0,.2], wt:[.4,.9], noise:[0,.1], grain:[0,.2], choir:0, bells:1.8, swell:.2, dens:1.1 },
"Wald": { scales:["Dorisch","Mixolydisch","Moll-Pentatonik"], bright:[.2,.55], fm:[0,.2], saw:[.2,.6], wt:[0,.4], noise:[.5,1], grain:[.3,.8], choir:.5, bells:.6, swell:.3, dens:.9 },
};
const VOWELS = { // Formanten F1,F2,F3 und relative Pegel
a:[800,1150,2900,1,.5,.25], o:[450,800,2830,1,.45,.1], u:[325,700,2700,1,.3,.05],
e:[400,1600,2700,1,.35,.15], i:[270,2140,2950,1,.25,.12],
};
const LOOP_PERIODS = [17.3, 23.1, 29.7, 37.9, 43.3, 53.1]; // Eno-Stil: unterschiedlich lange Zyklen
// ---------- Zustand ----------
const S = {
ctx: null, running: false, g: null,
seedText: "", seed: 1, presetName: "Frei", P: PRESETS["Frei"], tuning: "just",
root: 38, scaleName: "Dorisch", scale: SCALES["Dorisch"], degree: 0,
// Klangfarbe: aktuelle Werte (cur) wandern langsam zu Zielwerten (tgt)
cur: { bright:.35, fm:.15, saw:.3, wt:.2, noise:.2, grain:.2, detune:6, ratio:2, motion:.3 },
tgt: { bright:.35, fm:.15, saw:.3, wt:.2, noise:.2, grain:.2, detune:6, ratio:2, motion:.3 },
density:.5, drift:.5, verb:.6, arcDepth:.6, arcLen:700, t0:0,
timers: [], waves: [], loop: { deg:[], oct:[] }, vis: [],
ring: null, wp: 0, written: 0, grainBuf: null,
};
let rafId = 0; // Zeichenschleife (siehe wake/draw)
S.hostVol = null; // Lautstärke der einbettenden Seite (cmd "volume"), sonst gilt der eigene Regler
const outVol = () => S.hostVol != null ? S.hostVol : +$("vol").value;
const speed = () => 0.35 + S.drift * 2.2;
// Spannungsbogen: 0 = ruhig, 1 = Höhepunkt, über mehrere Minuten
function arcPhase() { return S.running ? (((S.ctx.currentTime - S.t0) / S.arcLen) % 1) : 0; }
function eff() {
const I = 0.5 - 0.5 * Math.cos(2 * Math.PI * arcPhase());
return (1 - S.arcDepth) * .6 + S.arcDepth * I;
}
const effDens = () => clamp(S.density * (.5 + .9 * eff()) * S.P.dens, 0, 1);
// ---------- Audio-Graph ----------
function makeImpulse(ctx, secs, decay) {
const len = Math.floor(ctx.sampleRate * secs), buf = ctx.createBuffer(2, len, ctx.sampleRate);
for (let c = 0; c < 2; c++) {
const d = buf.getChannelData(c); let lp = 0;
for (let i = 0; i < len; i++) {
const t = i / len;
lp += (Math.random() * 2 - 1 - lp) * (0.35 - 0.3 * t);
d[i] = lp * Math.pow(1 - t, decay) * 2.2;
}
}
return buf;
}
function pinkBuffer(ctx, secs) {
const len = ctx.sampleRate * secs, buf = ctx.createBuffer(1, len, ctx.sampleRate), d = buf.getChannelData(0);
let b0=0,b1=0,b2=0,b3=0,b4=0,b5=0,b6=0, peak = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
b0=.99886*b0+w*.0555179; b1=.99332*b1+w*.0750759; b2=.969*b2+w*.153852;
b3=.8665*b3+w*.3104856; b4=.55*b4+w*.5329522; b5=-.7616*b5-w*.016898;
d[i] = b0+b1+b2+b3+b4+b5+b6+w*.5362; b6 = w*.115926; peak = Math.max(peak, Math.abs(d[i]));
}
for (let i = 0; i < len; i++) d[i] = d[i] / peak * .5;
return buf;
}
function satCurve(k) {
const n = 2048, c = new Float32Array(n);
for (let i = 0; i < n; i++) { const x = i / (n - 1) * 2 - 1; c[i] = Math.tanh(k * x) / Math.tanh(k); }
return c;
}
function buildGraph() {
// "playback": the normal media path (on Android the low-latency one can stay silent, e.g. on Samsung)
const ctx = S.ctx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: "playback" });
const g = S.g = {};
g.voiceBus = ctx.createGain();
g.filter = ctx.createBiquadFilter();
g.filter.type = "lowpass"; g.filter.Q.value = .6; g.filter.frequency.value = 1800;
g.voiceBus.connect(g.filter);
g.lfo = ctx.createOscillator(); g.lfo.frequency.value = .04;
g.lfoAmt = ctx.createGain(); g.lfoAmt.gain.value = 500;
g.lfo.connect(g.lfoAmt).connect(g.filter.frequency); g.lfo.start();
// gemeinsamer Summenbus -> Tape-Sättigung -> Kompressor
g.mix = ctx.createGain();
g.drive = ctx.createGain(); g.drive.gain.value = 1.5;
g.shaper = ctx.createWaveShaper(); g.shaper.curve = satCurve(1.6); g.shaper.oversample = LITE ? "none" : "2x";
g.makeup = ctx.createGain(); g.makeup.gain.value = .72;
g.comp = ctx.createDynamicsCompressor();
g.comp.threshold.value = -18; g.comp.ratio.value = 3; g.comp.attack.value = .05; g.comp.release.value = .4;
g.master = ctx.createGain(); g.master.gain.value = 0;
g.mix.connect(g.drive).connect(g.shaper).connect(g.makeup).connect(g.comp).connect(g.master);
g.dry = ctx.createGain(); g.dry.gain.value = .8;
g.filter.connect(g.dry).connect(g.mix);
// Hall
g.conv = ctx.createConvolver(); g.conv.buffer = makeImpulse(ctx, LITE ? 3.5 : 7, 2.6);
g.send = ctx.createGain(); g.send.gain.value = S.verb;
g.filter.connect(g.send).connect(g.conv);
g.conv.connect(g.mix);
// Tape-Delay mit Hochpass im Feedback und Wow/Flutter
g.delay = ctx.createDelay(4); g.delay.delayTime.value = .62;
g.dlp = ctx.createBiquadFilter(); g.dlp.type = "lowpass"; g.dlp.frequency.value = 2400;
g.dhp = ctx.createBiquadFilter(); g.dhp.type = "highpass"; g.dhp.frequency.value = 180;
g.fb = ctx.createGain(); g.fb.gain.value = .42;
g.delaySend = ctx.createGain();
g.delaySend.connect(g.delay); g.delay.connect(g.dlp);
g.dlp.connect(g.dhp).connect(g.fb).connect(g.delay);
g.dlp.connect(g.conv); g.dlp.connect(g.mix);
g.wow = ctx.createOscillator(); g.wow.frequency.value = .37;
g.wowC = ctx.createGain(); g.wowC.gain.value = 7; // Cent
g.flut = ctx.createOscillator(); g.flut.frequency.value = 5.3;
g.flutC = ctx.createGain(); g.flutC.gain.value = 1.6;
g.pitchMod = ctx.createGain();
g.wow.connect(g.wowC).connect(g.pitchMod); g.flut.connect(g.flutC).connect(g.pitchMod);
g.wow.start(); g.flut.start();
g.dWow = ctx.createGain(); g.dWow.gain.value = .004; // Delaytime-Wackeln
g.wow.connect(g.dWow).connect(g.delay.delayTime);
// Rauschbett: Wind (Bandpass, wandernd) + Meer (Tiefpass, Swell)
const nb = pinkBuffer(ctx, 8);
g.noiseBus = ctx.createGain();
const nA = ctx.createBufferSource(); nA.buffer = nb; nA.loop = true;
g.bp = ctx.createBiquadFilter(); g.bp.type = "bandpass"; g.bp.Q.value = 1.4; g.bp.frequency.value = 700;
const wMod = ctx.createGain(); wMod.gain.value = .65;
g.windGain = ctx.createGain(); g.windGain.gain.value = 0;
const wl = ctx.createOscillator(); wl.frequency.value = .07; const wlg = ctx.createGain(); wlg.gain.value = .35;
wl.connect(wlg).connect(wMod.gain); wl.start();
const wf = ctx.createOscillator(); wf.frequency.value = .05; const wfg = ctx.createGain(); wfg.gain.value = 350;
wf.connect(wfg).connect(g.bp.frequency); wf.start();
nA.connect(g.bp).connect(wMod).connect(g.windGain).connect(g.noiseBus);
const nB = ctx.createBufferSource(); nB.buffer = nb; nB.loop = true;
const sl = ctx.createBiquadFilter(); sl.type = "lowpass"; sl.frequency.value = 420;
const sMod = ctx.createGain(); sMod.gain.value = .55;
g.seaGain = ctx.createGain(); g.seaGain.gain.value = 0;
const sw = ctx.createOscillator(); sw.frequency.value = .11; const swg = ctx.createGain(); swg.gain.value = .45;
sw.connect(swg).connect(sMod.gain); sw.start();
nB.connect(sl).connect(sMod).connect(g.seaGain).connect(g.noiseBus);
nA.start(); nB.start(0, 3.1);
g.noiseBus.connect(g.mix);
const nSend = g.nSend = ctx.createGain(); nSend.gain.value = .3; g.noiseBus.connect(nSend).connect(g.conv);
S.musicLinked = true; S.revLinked = true; // siehe applyRev (Telefone)
// Granular: Ringpuffer der Stimmen, daraus werden Körner geschnitten. Er läuft als AudioWorklet
// im Audio-Thread – ein ScriptProcessor im Hauptthread hielt bei jedem Stocken der Seite den
// ganzen Ton auf (gehäufte Aussetzer). Telefone: keine Körner.
if (!LITE && ctx.audioWorklet) {
const url = URL.createObjectURL(new Blob([RING_WORKLET], { type: "application/javascript" }));
ctx.audioWorklet.addModule(url).then(() => {
const node = new AudioWorkletNode(ctx, "drift-ring", { numberOfInputs: 1, numberOfOutputs: 0, processorOptions: { n: ctx.sampleRate * 12 } });
node.port.onmessage = e => {
const d = e.data;
if (!d || d.length < ctx.sampleRate * 6) return;
const b = ctx.createBuffer(1, d.length, ctx.sampleRate); b.copyToChannel(d, 0); S.grainBuf = b;
};
g.voiceBus.connect(node); S.ringNode = node;
}).catch(() => {}).finally(() => URL.revokeObjectURL(url));
}
g.grainBus = ctx.createGain(); g.grainBus.connect(g.filter);
g.modeBus = ctx.createGain(); g.modeBus.connect(g.mix); // Klangwelten (Rauschen, Natur, …)
g.modeRev = ctx.createGain(); g.modeRev.gain.value = 0; g.modeBus.connect(g.modeRev).connect(g.conv);
g.analyser = ctx.createAnalyser(); g.analyser.fftSize = 2048; g.analyser.smoothingTimeConstant = .88;
g.master.connect(g.analyser); g.master.connect(ctx.destination);
}
function makeWaves() { // zufällige Wavetable-Zyklen aus Obertonreihen
const out = [];
for (let w = 0; w < 8; w++) {
const re = new Float32Array(17), im = new Float32Array(17), p = rnd(.8, 2.2, rng.n);
for (let k = 1; k < 17; k++) {
if (rng.n() < .75) { const a = 1 / Math.pow(k, p), ph = rnd(0, 6.28, rng.n); re[k] = a * Math.cos(ph); im[k] = a * Math.sin(ph); }
}
im[1] = Math.max(im[1], .6);
out.push(S.ctx.createPeriodicWave(re, im));
}
return out;
}
// ---------- Frequenzen ----------
function freqFromDegree(deg) {
const n = S.scale.length, semi = S.scale[((deg % n) + n) % n], oct = Math.floor(deg / n);
const ratio = S.tuning === "just" ? JI[semi] : Math.pow(2, semi / 12);
return mtof(S.root) * ratio * Math.pow(2, oct);
}
const emit = (f, when, dur, kind) => { if (S.vis.length < 200) S.vis.push({ f, t: when, dur, kind }); };
// ---------- Stimmen ----------
function envelope(g, when, att, dur, rel, peak) {
g.gain.setValueAtTime(0, when);
g.gain.linearRampToValueAtTime(peak, when + att);
g.gain.setValueAtTime(peak, when + Math.max(att, dur - rel));
g.gain.linearRampToValueAtTime(0, when + dur + rel * .2);
return when + dur + rel * .2 + .1;
}
// Pad: detunte Säge/Dreieck + Sinus-Sub + Wavetable-Morph + FM, eigener Filter, Tape-Wow
// Telefone: zählt die klingenden Stimmen; false, wenn schon genug klingen.
function voiceSlot(when, end, always) {
if (!LITE) return true;
if (!always && S.voices >= MAX_VOICES) return false;
S.voices = (S.voices || 0) + 1;
setTimeout(() => { S.voices--; }, Math.max(0, end - S.ctx.currentTime) * 1000 + 200);
return true;
}
function padVoice(f, when, dur, vel = 1) {
const ctx = S.ctx, c = S.cur, G = S.g;
if (!voiceSlot(when, when + dur + 10, vel >= 1.4)) return;
const out = ctx.createGain(), flt = ctx.createBiquadFilter(), pan = ctx.createStereoPanner();
flt.type = "lowpass"; flt.Q.value = .5;
flt.frequency.value = clamp(f * (1.2 + c.bright * 7), 200, 9000);
pan.pan.value = rnd(-.7, .7);
const att = rnd(2.5, 6) / Math.sqrt(speed()), rel = rnd(5, 9);
const end = envelope(out, when, att, dur, rel, .07 * vel);
const oscs = [];
const add = (o, gain) => {
const g = ctx.createGain(); g.gain.value = gain;
o.connect(g).connect(flt); G.pitchMod.connect(o.detune);
o.start(when); o.stop(end); oscs.push(o); return g;
};
const mk = (type, mul, det, gain) => {
const o = ctx.createOscillator(); o.type = type; o.frequency.value = f * mul; o.detune.value = det;
return add(o, gain);
};
mk("sawtooth", 1, c.detune * rnd(.6, 1.2), (LITE ? .5 : .35) * c.saw);
if (!LITE) mk("sawtooth", 1, -c.detune * rnd(.6, 1.2), .35 * c.saw); // Telefone: eine Säge statt zwei
mk("triangle", 1, c.detune * .3, .5 * (1 - c.saw * .5) * (1 - c.wt * .6));
mk("sine", .5, 0, .35);
if (c.wt > .04) { // zwei Wavetable-Zyklen, über die Notendauer übergeblendet
const mkW = w => { const o = ctx.createOscillator(); o.setPeriodicWave(w); o.frequency.value = f; return o; };
const ga = add(mkW(pick(S.waves)), .45 * c.wt);
if (!LITE) { // Telefone: ein Zyklus ohne Überblendung
const gb = add(mkW(pick(S.waves)), 0);
ga.gain.setValueAtTime(.45 * c.wt, when); ga.gain.linearRampToValueAtTime(0, when + dur);
gb.gain.setValueAtTime(0, when); gb.gain.linearRampToValueAtTime(.45 * c.wt, when + dur);
}
}
if (c.fm > (LITE ? .25 : .03)) { // Telefone: FM nur, wo sie deutlich hörbar ist
const m = ctx.createOscillator(); m.type = "sine"; m.frequency.value = f * c.ratio;
const mg = ctx.createGain(); mg.gain.value = f * c.fm * 1.6;
const car = ctx.createOscillator(); car.type = "sine"; car.frequency.value = f;
const cg = ctx.createGain(); cg.gain.value = .3;
m.connect(mg).connect(car.frequency); car.connect(cg).connect(flt);
m.start(when); car.start(when); m.stop(end); car.stop(end); oscs.push(m, car);
}
const vib = ctx.createOscillator(); vib.frequency.value = rnd(.15, .6);
const vg = ctx.createGain(); vg.gain.value = flt.frequency.value * c.motion * .5;
vib.connect(vg).connect(flt.frequency); vib.start(when); vib.stop(end); oscs.push(vib);
flt.connect(out).connect(pan).connect(G.voiceBus);
oscs[0].onended = () => { try { pan.disconnect(); } catch (e) {} };
emit(f, when + att * .5, dur, "pad");
}
// Chor: Sägezahn durch drei Formantfilter, Vokal gleitet während der Note
function choirVoice(f, when, dur) {
const ctx = S.ctx, G = S.g;
if (!voiceSlot(when, when + dur + 8)) return;
const out = ctx.createGain(), pan = ctx.createStereoPanner(), src = ctx.createGain();
pan.pan.value = rnd(-.6, .6);
const att = rnd(3, 6), rel = rnd(5, 8), end = envelope(out, when, att, dur, rel, .16);
const A = VOWELS[pick(Object.keys(VOWELS))], B = VOWELS[pick(Object.keys(VOWELS))];
const oscs = [];
[-9, 9].forEach(det => {
const o = ctx.createOscillator(); o.type = "sawtooth"; o.frequency.value = f; o.detune.value = det;
G.pitchMod.connect(o.detune); o.connect(src); o.start(when); o.stop(end); oscs.push(o);
});
const vib = ctx.createOscillator(); vib.frequency.value = rnd(4.6, 5.8);
const vg = ctx.createGain(); vg.gain.setValueAtTime(0, when); vg.gain.linearRampToValueAtTime(11, when + att + 2);
vib.connect(vg); oscs.forEach(o => vg.connect(o.detune)); vib.start(when); vib.stop(end);
for (let i = 0; i < 3; i++) {
const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.Q.value = 9;
bp.frequency.setValueAtTime(A[i], when); bp.frequency.linearRampToValueAtTime(B[i], when + dur);
const fg = ctx.createGain(); fg.gain.value = (A[3 + i] + B[3 + i]) * 1.4;
src.connect(bp).connect(fg).connect(out);
}
out.connect(pan).connect(G.voiceBus);
oscs[0].onended = () => { try { pan.disconnect(); } catch (e) {} };
emit(f, when + att * .5, dur, "choir");
}
// Glocke: FM-Pluck in Delay + Hall
function bell(f, when) {
const ctx = S.ctx, c = S.cur;
const car = ctx.createOscillator(), mod = ctx.createOscillator();
const mg = ctx.createGain(), out = ctx.createGain(), pan = ctx.createStereoPanner();
car.frequency.value = f; mod.frequency.value = f * pick([2, 3, 3.5, 4, 5.04]);
const idx = f * (.4 + c.fm * 2.5 + c.bright);
mg.gain.setValueAtTime(idx, when); mg.gain.exponentialRampToValueAtTime(1, when + 2.5);
const dec = rnd(3, 6);
out.gain.setValueAtTime(0, when);
out.gain.linearRampToValueAtTime(rnd(.03, .06), when + .012);
out.gain.exponentialRampToValueAtTime(.0001, when + dec);
pan.pan.value = rnd(-.8, .8);
mod.connect(mg).connect(car.frequency);
car.connect(out).connect(pan).connect(S.g.voiceBus);
const ds = ctx.createGain(); ds.gain.value = .7; pan.connect(ds).connect(S.g.delaySend);
car.start(when); mod.start(when); car.stop(when + dec + .1); mod.stop(when + dec + .1);
emit(f, when, dec, "bell");
}
// Rückwärts-Swell: Ton samt Hallfahne offline rendern und umdrehen
async function renderSwell(f) {
const sr = S.ctx.sampleRate, oc = new OfflineAudioContext(2, Math.ceil(sr * 4.5), sr);
const lp = oc.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = clamp(f * 5, 400, 5000);
const env = oc.createGain();
env.gain.setValueAtTime(0, 0); env.gain.linearRampToValueAtTime(.3, .01); env.gain.exponentialRampToValueAtTime(.001, 1.6);
[["sawtooth", 1, 0], ["sawtooth", 1, 7], ["sine", .5, 0]].forEach(([t, m, d]) => {
const o = oc.createOscillator(); o.type = t; o.frequency.value = f * m; o.detune.value = d;
o.connect(lp); o.start(0);
});
lp.connect(env);
const conv = oc.createConvolver(); conv.buffer = makeImpulse(oc, 3.4, 2.2);
env.connect(conv).connect(oc.destination);
const dry = oc.createGain(); dry.gain.value = .4; env.connect(dry).connect(oc.destination);
const buf = await oc.startRendering();
for (let c = 0; c < buf.numberOfChannels; c++) buf.getChannelData(c).reverse();
return buf;
}
function playSwell(buf, t) {
const ctx = S.ctx, src = ctx.createBufferSource(), g = ctx.createGain();
src.buffer = buf; g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(.9, t + .6);
src.connect(g).connect(S.g.voiceBus); src.start(t);
}
// Granulare Wolke aus dem eigenen Output
// Der Ringpuffer im Audio-Thread: auf Anfrage eine Kopie der letzten Sekunden (siehe snapGrains).
const RING_WORKLET = `
class DriftRing extends AudioWorkletProcessor {
constructor(o) {
super();
this.n = o.processorOptions.n; this.buf = new Float32Array(this.n); this.wp = 0; this.written = 0;
this.port.onmessage = () => {
const len = Math.min(this.written, this.n), out = new Float32Array(len), start = (this.wp - len + this.n) % this.n;
if (start + len <= this.n) out.set(this.buf.subarray(start, start + len));
else { out.set(this.buf.subarray(start)); out.set(this.buf.subarray(0, len - (this.n - start)), this.n - start); }
this.port.postMessage(out, [out.buffer]);
};
}
process(inputs) {
const inp = inputs[0] && inputs[0][0];
if (inp) {
const n = this.n, k = inp.length, room = n - this.wp;
if (k <= room) this.buf.set(inp, this.wp); else { this.buf.set(inp.subarray(0, room), this.wp); this.buf.set(inp.subarray(room), 0); }
this.wp = (this.wp + k) % n; this.written += k;
}
return true;
}
}
registerProcessor("drift-ring", DriftRing);
`;
function snapGrains() {
if (S.ringNode) S.ringNode.port.postMessage("snap"); // die Antwort setzt S.grainBuf
}
function grainCloud() {
const amt = S.cur.grain * eff();
if (!S.running || !S.grainBuf || amt < .05) return;
const ctx = S.ctx, buf = S.grainBuf, n = Math.round(6 + rnd(0, 8) * (.5 + amt)), t0 = ctx.currentTime + .1;
const rate = pick([.5, 1, 1, 1.5, 2]);
const pos = rnd(0, buf.duration - 3);
for (let i = 0; i < n; i++) {
const when = t0 + rnd(0, 3.5), dur = rnd(.35, 1.5), src = ctx.createBufferSource(), g = ctx.createGain(), pn = ctx.createStereoPanner();
src.buffer = buf; src.playbackRate.value = rate * rnd(.995, 1.005);
g.gain.setValueAtTime(0, when); g.gain.linearRampToValueAtTime(.55 * amt, when + dur * .5); g.gain.linearRampToValueAtTime(0, when + dur);
pn.pan.value = rnd(-.9, .9);
src.connect(g).connect(pn).connect(S.g.grainBus);
src.start(when, clamp(pos + rnd(0, 2.5), 0, buf.duration - dur * rate - .05), dur * rate);
}
}
function drone() {
const f = mtof(S.root) * .5 * (rng.v() < .3 ? 1.5 : 1);
padVoice(f, S.ctx.currentTime + .1, rnd(25, 45), 1.4);
}
// ---------- Harmonie / Szene ----------
function spawnChord(t0) {
const n = S.scale.length, d = effDens();
const stack = [0, 2, 4];
if (rng.s() < .5) stack.push(pick([1, 6, 8], rng.s));
const count = Math.round(2 + d * 3);
const chosen = stack.sort(() => rng.s() - .5).slice(0, count);
const oct = pick([1, 1, 2], rng.s);
chosen.forEach((st, i) => {
const deg = S.degree + st + n * (oct + (rng.s() < .35 ? 1 : 0));
const f = freqFromDegree(deg), when = t0 + i * rnd(.5, 3.5), dur = rnd(11, 20) / Math.sqrt(speed());
if (rng.v() < S.P.choir && f < 700) choirVoice(f, when, dur); else padVoice(f, when, dur);
});
}
function playChord() {
if (!S.running) return;
// Rückwärts-Swell: offline gerendert, auf Telefonen zu teuer (eine Rechenspitze, der Ton stockt)
if (!LITE && rng.v() < S.P.swell) {
renderSwell(freqFromDegree(S.degree + S.scale.length)).then(buf => {
if (!S.running) return;
const t = S.ctx.currentTime + .1;
playSwell(buf, t); spawnChord(t + buf.duration - .3);
});
} else spawnChord(S.ctx.currentTime + .2);
}
function playBells() {
if (!S.running) return;
if (rng.v() < (.2 + effDens() * .55) * S.P.bells) {
const n = S.scale.length, now = S.ctx.currentTime;
const deg = S.degree + pick([0, 2, 4, 1, 3, 6]) + n * pick([3, 3, 4]);
bell(freqFromDegree(deg), now + .05);
if (rng.v() < .25) bell(freqFromDegree(deg + pick([2, 4, -2])), now + rnd(.3, 1.4));
}
}
function setupLoops() {
S.loop.deg = LOOP_PERIODS.map(() => pick([0, 2, 4, 1, 3, 5, 6], rng.s));
S.loop.oct = LOOP_PERIODS.map(() => pick([1, 1, 2, 2, 3], rng.s));
}
function startLoops() { // jede Schleife hat ihren festen Zyklus, nichts wiederholt sich hörbar
LOOP_PERIODS.forEach((p, i) => {
const slot = S.timers.length; S.timers.push(0);
const fire = () => {
if (!S.running) return;
if (i < 1 + Math.round(effDens() * 4)) {
const f = freqFromDegree(S.loop.deg[i] + S.scale.length * S.loop.oct[i]), when = S.ctx.currentTime + .1;
if (i % 2) bell(f, when); else padVoice(f, when, rnd(8, 14), .6);
}
S.timers[slot] = setTimeout(fire, p * 1000);
};
S.timers[slot] = setTimeout(fire, rnd(1, p, rng.n) * 1000);
});
}
function changeDegree() {
const n = S.scale.length;
S.degree = pick([0, 0, n - 1, 1, 3, 4, 5, 2, 4, n - 2].map(d => d % n), rng.s);
S.g.delay.delayTime.setTargetAtTime(pick([.45, .62, .75, .9], rng.s), S.ctx.currentTime, 3);
}
function changeKey(soft = true) {
let r = S.root + pick([7, 5, 2, -2, 3, -3, 0, 7, 5], rng.s);
while (r > 47) r -= 12; while (r < 36) r += 12;
S.root = r;
S.scaleName = pick(S.P.scales, rng.s); S.scale = SCALES[S.scaleName];
S.degree = 0;
if (!soft && S.running) drone();
}
function wanderTimbre() {
const P = S.P, r = rng.s, between = ([a, b]) => rnd(a, b, r);
S.tgt = {
bright: between(P.bright), fm: between(P.fm), saw: between(P.saw), wt: between(P.wt),
noise: between(P.noise), grain: between(P.grain),
detune: rnd(2, 18, r), ratio: pick([1, 2, 2, 3, 1.5, .5, 4, 2.01, 3.01], r), motion: rnd(.05, .6, r),
};
}
// ---------- Scheduler ----------
function later(fn, minS, maxS, salt, first = .3) {
const slot = S.timers.length; S.timers.push(0);
const r = mulberry32(S.seed * 31 + salt * 7919);
const loop = () => {
if (!S.running) return;
fn();
S.timers[slot] = setTimeout(loop, rnd(minS, maxS, r) * 1000 / speed());
};
S.timers[slot] = setTimeout(loop, rnd(minS, maxS, r) * 1000 / speed() * first);
}
function tick() {
if (!S.running) return;
if (!MODES[S.mode].music) { tickMode(); return; }
const e = eff(), k = .024 * speed(), t = S.ctx.currentTime, G = S.g;
for (const key in S.cur) S.cur[key] += (S.tgt[key] - S.cur[key]) * k;
G.filter.frequency.setTargetAtTime(500 + S.cur.bright * 4500 * (.6 + .6 * e), t, 2);
G.lfo.frequency.setTargetAtTime(.02 + S.cur.motion * .12, t, 4);
G.lfoAmt.gain.setTargetAtTime(150 + S.cur.bright * 1200, t, 4);
const nl = S.cur.noise * (1.3 - .6 * e);
G.windGain.gain.setTargetAtTime(.3 * nl, t, 3);
G.seaGain.gain.setTargetAtTime(.35 * nl, t, 3);
G.bp.frequency.setTargetAtTime(400 + S.cur.bright * 1400, t, 5);
renderMeters(); updateInfo();
}
function resetScene() {
S.t0 = S.ctx.currentTime;
seedAll(S.seed);
S.arcLen = rnd(540, 840, rng.n);
S.waves = makeWaves();
wanderTimbre(); S.cur = { ...S.tgt };
changeKey(true); setupLoops();
}
// ---------- Klangwelten (Modi) ----------
// Musik ist nur ein Modus von vielen. Alle anderen sind ebenfalls komplett synthetisiert.
S.mode = "ambient"; S.modeObj = null; S.mp = { a: .5, b: .5 }; S.flash = 0; S.tickId = 0;
const T = () => S.ctx.currentTime;
const sm = (p, v, tc = .6) => p.setTargetAtTime(v, T(), tc);
const R = (a, b) => a + Math.random() * (b - a);
function bq(type, f, Q = .7, gain = 0) {
const n = S.ctx.createBiquadFilter(); n.type = type; n.frequency.value = f; n.Q.value = Q; n.gain.value = gain; return n;
}
// Rauschfarben: 10 s Stereo-Loop pro Typ, Naht per Equal-Power-Crossfade, auf gleiche Lautheit normalisiert
const NB = {};
function noiseBuf(type) {
if (NB[type]) return NB[type];
const ctx = S.ctx, sr = ctx.sampleRate, len = sr * 10, fade = Math.floor(sr * .5), tot = len + fade;
const buf = ctx.createBuffer(2, len, sr);
for (let c = 0; c < 2; c++) {
const raw = new Float32Array(tot);
let b0=0,b1=0,b2=0,b3=0,b4=0,b5=0,b6=0, brown=0, prevP=0, prevW=0;
for (let i = 0; i < tot; i++) {
const w = Math.random() * 2 - 1;
if (type === "white") raw[i] = w;
else if (type === "violet") { raw[i] = w - prevW; prevW = w; }
else if (type === "brown") { brown = (brown + .02 * w) / 1.02; raw[i] = brown; }
else { // pink, blue (= Ableitung von pink)
b0=.99886*b0+w*.0555179; b1=.99332*b1+w*.0750759; b2=.969*b2+w*.153852;
b3=.8665*b3+w*.3104856; b4=.55*b4+w*.5329522; b5=-.7616*b5-w*.016898;
const p = b0+b1+b2+b3+b4+b5+b6+w*.5362; b6 = w*.115926;
if (type === "pink") raw[i] = p; else { raw[i] = p - prevP; prevP = p; }
}
}
let sum = 0; for (let i = 0; i < tot; i++) sum += raw[i] * raw[i];
const k = .2 / Math.sqrt(sum / tot + 1e-12), d = buf.getChannelData(c);
for (let i = 0; i < len; i++) d[i] = clamp(raw[i] * k, -1, 1);
for (let i = 0; i < fade; i++) {
const th = i / fade * Math.PI / 2;
d[i] = clamp((raw[i] * Math.sin(th) + raw[len + i] * Math.cos(th)) * k, -1, 1);
}
}
return NB[type] = buf;
}
// Einzelereignisse (Tropfen, Knistern, Blubbern), kreisförmig in 10 s geschrieben -> nahtloser Loop
const DB = {};
function dropBuf(kind, rate) {
const key = kind + rate; if (DB[key]) return DB[key];
const ctx = S.ctx, sr = ctx.sampleRate, len = sr * 10, buf = ctx.createBuffer(2, len, sr);
const target = { rain: .16, pop: .09, water: .14 }[kind];
for (let c = 0; c < 2; c++) {
const d = buf.getChannelData(c), N = Math.round(rate * 10);
if (kind === "water") fillWater(d, len, sr, rate);
else for (let e = 0; e < N; e++) {
const pos = Math.floor(Math.random() * len);
if (kind === "rain") {
const n = 40 + Math.floor(Math.random() * 220), amp = Math.pow(Math.random(), 2.2) * .9, k = .12 + Math.random() * .75;
let lp = 0;
for (let j = 0; j < n; j++) { lp += (Math.random() * 2 - 1 - lp) * k; d[(pos + j) % len] += lp * Math.exp(-j / (n * .25)) * amp; }
} else if (kind === "pop") {
const burst = Math.random() < .25 ? 2 + Math.floor(Math.random() * 5) : 1;
for (let b = 0; b < burst; b++) {
const p = (pos + b * Math.floor(sr * (.004 + Math.random() * .02))) % len;
const n = 10 + Math.floor(Math.random() * 70), amp = Math.pow(Math.random(), 3) * 1.2, k = .35 + Math.random() * .6;
let lp = 0;
for (let j = 0; j < n; j++) { lp += (Math.random() * 2 - 1 - lp) * k; d[(p + j) % len] += lp * Math.exp(-j / (n * .3)) * amp; }
}
}
}
let sum = 0; for (let i = 0; i < len; i++) sum += d[i] * d[i];
const k = target / Math.sqrt(sum / len + 1e-12);
for (let i = 0; i < len; i++) d[i] = clamp(d[i] * k, -1, 1);
}
return DB[key] = buf;
}
// Wasser: Luftbläschen als gedämpfte, ansteigende Sinus-Schwingungen (Minnaert-Modell: Frequenz ≈ 3 / Radius,
// Dämpfung wächst mit der Frequenz). Ein Bach "gluckst" in Gruppen: ähnlich große Bläschen platzen kurz hintereinander,
// jede Gruppe hat ihre eigene Lautstärke, und die Gruppenrate schwankt periodisch über die 10 s.
let SIN = null;
function fillWater(d, len, sr, rate) { // rate = Gruppen pro Sekunde
if (!SIN) { SIN = new Float32Array(4096); for (let i = 0; i < 4096; i++) SIN[i] = Math.sin(i / 4096 * 2 * Math.PI); }
const NC = Math.round(rate * 10), p1 = Math.random() * 6.28, p2 = Math.random() * 6.28, p3 = Math.random() * 6.28;
for (let c = 0; c < NC; c++) {
let pos;
do {
pos = Math.random();
var lam = 1 + .45 * Math.sin(2 * Math.PI * 3 * pos + p1) + .3 * Math.sin(2 * Math.PI * 7 * pos + p2) + .2 * Math.sin(2 * Math.PI * 11 * pos + p3);
} while (Math.random() * 1.95 > lam);
const rc = (1 + 4 * Math.pow(Math.random(), 1.5)) * 1e-3; // typischer Radius der Gruppe: 1–5 mm
const loud = Math.pow(Math.random(), 2) * 1.2 + .05, m = 2 + Math.floor(Math.random() * 7), span = (.04 + Math.random() * .16) * sr;
for (let b = 0; b < m; b++) {
const r = rc * (1 + (Math.random() - .5) * .3), f0 = 3 / r, dmp = .043 * f0 + .0014 * Math.pow(f0, 1.5), xi = .3 * dmp; // kräftiger Anstieg = Glucksen
const amp = loud * Math.pow(r / .003, 1.2) * (.5 + Math.random() * .5);
const n = Math.min(Math.floor(sr * 3.5 / dmp), 5000), start = Math.floor(pos * len + Math.random() * span), dec = Math.exp(-dmp / sr), att = sr * .0008;
let env = 1, phase = 0;
for (let j = 0; j < n; j++) {
phase += f0 * (1 + xi * j / sr) / sr;
d[(start + j) % len] += SIN[((phase - Math.floor(phase)) * 4096) | 0] * env * amp * (j < att ? j / att : 1);
env *= dec;
}
}
}
}
// Grillen: je eine Grille pro Buffer (Chirp aus 3–4 Pulsen), Periode teilt die Loop-Länge exakt
const CB = [], CRICKET_BASE = R(4400, 4800); // Chor: alle Tiere nahezu gleiche Tonhöhe (±1 %)
function cricketBuf(i) {
if (CB[i]) return CB[i];
const ctx = S.ctx, sr = ctx.sampleRate, len = sr * 8, buf = ctx.createBuffer(1, len, sr), d = buf.getChannelData(0);
const fc = CRICKET_BASE * (1 + R(-.012, .012)), nCh = Math.floor(8 / R(.36, .42)), P = len / nCh, pulses = 3 + Math.floor(Math.random() * 2), off = Math.random() * P;
for (let k = 0; k < nCh; k++) for (let p = 0; p < pulses; p++) {
const start = Math.floor(k * P + off + p * sr * .024), n = Math.floor(sr * .016);
for (let j = 0; j < n; j++) d[(start + j) % len] += Math.sin(2 * Math.PI * fc * j / sr) * Math.pow(Math.sin(Math.PI * j / n), 2) * (p === 0 ? .7 : 1);
}
let sum = 0; for (let j = 0; j < len; j++) sum += d[j] * d[j];
const k = .16 / Math.sqrt(sum / len + 1e-12);
for (let j = 0; j < len; j++) d[j] = clamp(d[j] * k, -1, 1);
return CB[i] = buf;
}
// Baukasten pro Modus: eigener Ausgang, Quellen, Timer – alles lässt sich sauber ausblenden
function newKit(gain) {
const ctx = S.ctx, K = { out: ctx.createGain(), srcs: [], timers: [], dead: false, a: .5, b: .5, set() {} };
K.out.gain.value = 0; K.out.gain.setTargetAtTime(gain, T(), 1.4);
K.out.connect(S.g.modeBus);
K.src = (buf, dest, rate = 1) => {
const s = ctx.createBufferSource(); s.buffer = buf; s.loop = true; s.playbackRate.value = rate;
s.connect(dest); s.start(0, Math.random() * buf.duration); K.srcs.push(s); return s;
};
K.osc = (type, f, dest) => { const o = ctx.createOscillator(); o.type = type; o.frequency.value = f; o.connect(dest); o.start(); K.srcs.push(o); return o; };
K.every = (fn, min, max, first = .1) => {
const slot = K.timers.length; K.timers.push(0);
const val = x => typeof x === "function" ? x() : x;
const loop = () => { if (K.dead) return; fn(); K.timers[slot] = setTimeout(loop, R(val(min), val(max)) * 1000); };
K.timers[slot] = setTimeout(loop, first * 1000);
};
K.apply = (a, b) => { K.a = a; K.b = b; K.set(a, b); };
K.stop = () => {
K.dead = true; K.timers.forEach(clearTimeout);
K.out.gain.cancelScheduledValues(T()); K.out.gain.setTargetAtTime(0, T(), .7);
setTimeout(() => { K.srcs.forEach(s => { try { s.stop(); } catch (e) {} }); try { K.out.disconnect(); } catch (e) {} }, 4000);
};
return K;
}
// ----- Bauteile der einzelnen Modi -----
function buildColor(K, type) {
const ctx = S.ctx, head = ctx.createGain();
K.src(noiseBuf(type === "grey" ? "white" : type), head);
let n = head;
if (type === "grey") for (const f of [bq("lowshelf", 200, .7, 12), bq("peaking", 3200, .7, -10), bq("highshelf", 9000, .7, 9)]) { n.connect(f); n = f; }
const lp = bq("lowpass", 20000, .5), am = ctx.createGain(), lfoG = ctx.createGain();
n.connect(lp); lp.connect(am).connect(K.out);
K.osc("sine", .11, lfoG); lfoG.connect(am.gain);
K.set = (a, b) => { sm(lp.frequency, 500 * Math.pow(40, a), .4); const d = b * .8; sm(am.gain, 1 - d / 2); sm(lfoG.gain, d / 2); };
}
function buildRain(K, storm) {
const ctx = S.ctx, gl = ctx.createGain(), gh = ctx.createGain(), lp = bq("lowpass", 9000, .5);
const wash = ctx.createGain(), hp = bq("highpass", 350, .5), wl = bq("lowpass", 7000, .5);
K.src(dropBuf("rain", 60), gl); K.src(dropBuf("rain", 320), gh); gl.connect(lp); gh.connect(lp);
K.src(noiseBuf("pink"), hp); hp.connect(wl).connect(wash).connect(lp); lp.connect(K.out);
K.set = (a, b) => {
sm(gl.gain, 1 - a); sm(gh.gain, a * 1.1); sm(wash.gain, .05 + .25 * a);
sm(lp.frequency, storm ? 7000 : 2500 * Math.pow(5.5, b), .6);
};
if (storm) K.every(() => thunder(K), () => 9 / (.3 + K.a), () => 32 / (.3 + K.a), 4);
}
function thunder(K) {
const ctx = S.ctx, near = K.b, d = (1 - near) * 5 + .3 + Math.random() * 1.2, t0 = T() + d;
S.flash = .4 + .6 * near; setTimeout(() => { if (!K.dead) S.flash = Math.max(S.flash, .3 + .5 * near); }, 140);
const dur = R(6, 10), lp = bq("lowpass", 100 + 220 * near, .7), g = ctx.createGain(), s = ctx.createBufferSource();
s.buffer = noiseBuf("brown"); s.connect(lp).connect(g).connect(K.out);
const peak = .9 * (.35 + .65 * near);
lp.frequency.setValueAtTime(100 + 220 * near, t0); lp.frequency.exponentialRampToValueAtTime(60, t0 + dur);
g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(peak, t0 + .35);
let tt = t0 + .35, amp = peak;
for (let i = 0; i < 4; i++) { tt += R(.8, 1.8); amp *= R(.45, .85); g.gain.linearRampToValueAtTime(amp * R(.6, 1), tt); }
g.gain.linearRampToValueAtTime(0, t0 + dur);
s.start(t0, R(0, .5), dur + .2);
const cb = bq("bandpass", R(900, 2500), .8), cg = ctx.createGain(), cs = ctx.createBufferSource();
cs.buffer = noiseBuf("white"); cs.connect(cb).connect(cg).connect(K.out);
cg.gain.setValueAtTime(0, t0); cg.gain.linearRampToValueAtTime(.7 * near * near, t0 + .01); cg.gain.exponentialRampToValueAtTime(.001, t0 + .4);
cs.start(t0, R(0, 3), .5);
}
function buildOcean(K) {
const ctx = S.ctx, lp = bq("lowpass", 220, .6), bg = ctx.createGain(), hp = bq("highpass", 1800, .5), fl = bq("lowpass", 7500, .5), fg = ctx.createGain();
K.src(noiseBuf("brown"), lp); lp.connect(bg).connect(K.out); bg.gain.value = .12;
K.src(noiseBuf("white"), hp); hp.connect(fl).connect(fg).connect(K.out); fg.gain.value = 0;
const bed = ctx.createGain(), bl = bq("lowpass", 900, .5); bed.gain.value = .06; K.src(noiseBuf("pink"), bl); bl.connect(bed).connect(K.out);
K.waveEnd = T() + .1;
const sched = () => {
if (K.dead) return;
const a = K.a, b = K.b, Tw = R(7, 12) * (1.25 - .35 * a), A = (.35 + .65 * a) * R(.6, 1), t0 = K.waveEnd, rise = Tw * .42;
bg.gain.linearRampToValueAtTime(.12, t0); bg.gain.linearRampToValueAtTime(A, t0 + rise); bg.gain.linearRampToValueAtTime(.12, t0 + Tw);
lp.frequency.linearRampToValueAtTime(220, t0); lp.frequency.linearRampToValueAtTime(350 + 900 * b + 500 * A, t0 + rise); lp.frequency.linearRampToValueAtTime(220, t0 + Tw);
fg.gain.linearRampToValueAtTime(0, t0 + rise * .6); fg.gain.linearRampToValueAtTime(A * .55 * (.25 + b), t0 + rise * 1.15); fg.gain.linearRampToValueAtTime(0, t0 + Tw * .98);
K.waveEnd = t0 + Tw;
K.timers[0] = setTimeout(sched, Math.max(300, (K.waveEnd - T() - 2.5) * 1000));
};
K.timers.push(0); sched();
}
function buildWind(K) {
const ctx = S.ctx, bp = bq("bandpass", 500, 1.2), g = ctx.createGain(), wb = bq("bandpass", 900, 22), wg = ctx.createGain(), low = bq("lowpass", 250, .5), lg = ctx.createGain();
g.gain.value = 0; wg.gain.value = 0; lg.gain.value = .3;
K.src(noiseBuf("pink"), bp); bp.connect(g).connect(K.out);
K.src(noiseBuf("white"), wb); wb.connect(wg).connect(K.out);
K.src(noiseBuf("brown"), low); low.connect(lg).connect(K.out);
K.set = a => sm(lg.gain, .15 + .6 * a, 1);
K.every(() => {
const a = K.a, b = K.b;
sm(bp.frequency, R(250, 1100) * (.6 + .8 * a), 2.5); sm(bp.Q, R(.7, 2.2), 2.5);
sm(g.gain, (.12 + R(0, .8) * a) * .9, 2.2);
sm(wb.frequency, R(600, 1800), 3); sm(wg.gain, b * R(.3, 1) * 10, 2.5);
}, 2.5, 6, .1);
}
function buildStream(K) {
const ctx = S.ctx, g1 = ctx.createGain(), g2 = ctx.createGain(), hp = bq("highpass", 250, .5);
const s1 = K.src(dropBuf("water", 12), g1), s2 = K.src(dropBuf("water", 45), g2); // ruhig / sprudelnd
g1.connect(hp); g2.connect(hp);
const sp = bq("highpass", 1800, .5), spl = bq("lowpass", 5000, .5), spg = ctx.createGain(); // Spritzer
K.src(dropBuf("rain", 30), sp); sp.connect(spl).connect(spg).connect(K.out);
const fl = bq("bandpass", 700, .7), fg = ctx.createGain(); // ganz leises, unregelmäßiges Rauschbett
fg.gain.value = .03; K.src(noiseBuf("pink"), fl); fl.connect(fg).connect(hp);
const low = bq("lowpass", 180, .5), lg = ctx.createGain(); lg.gain.value = .05; K.src(noiseBuf("brown"), low); low.connect(lg).connect(K.out);
hp.connect(K.out);
K.set = (a, b) => {
sm(g1.gain, 1 - a * .7); sm(g2.gain, a * 1.3); sm(spg.gain, .02 + .07 * a);
const rt = .8 + .5 * b; sm(s1.playbackRate, rt); sm(s2.playbackRate, rt * 1.03);
};
K.every(() => { sm(fl.frequency, R(500, 1800), .4); sm(fg.gain, R(.01, .07) * (.4 + K.a), .2); }, .4, 1.2);
}
function buildFire(K) {
const ctx = S.ctx, rl = bq("lowpass", 450, .6), rg = ctx.createGain(), hp = bq("highpass", 3500, .5), hg = ctx.createGain();
const g1 = ctx.createGain(), g2 = ctx.createGain(), ph = bq("highpass", 600, .5);
K.src(noiseBuf("brown"), rl); rl.connect(rg).connect(K.out); rg.gain.value = .5;
K.src(noiseBuf("pink"), hp); hp.connect(hg).connect(K.out); hg.gain.value = .03;
K.src(dropBuf("pop", 12), g1); K.src(dropBuf("pop", 45), g2); g1.connect(ph); g2.connect(ph); ph.connect(K.out);
K.set = (a, b) => { sm(g1.gain, (1 - b) * (.6 + .5 * a)); sm(g2.gain, b * (.6 + .5 * a)); };
K.every(() => sm(rg.gain, (.2 + .6 * K.a) * R(.55, 1), .35), .3, 1.2);
}
function buildCrickets(K) {
const ctx = S.ctx, cs = [];
for (let i = 0; i < 6; i++) {
const g = ctx.createGain(), pn = ctx.createStereoPanner(); g.gain.value = 0; pn.pan.value = R(-.85, .85);
const src = K.src(cricketBuf(i), g); g.connect(pn).connect(K.out); cs.push({ g, src });
}
const bed = ctx.createGain(), lp = bq("lowpass", 220, .5); bed.gain.value = .05; K.src(noiseBuf("brown"), lp); lp.connect(bed).connect(K.out);
K.set = (a, b) => { const n = 1 + a * 5; cs.forEach((c, i) => { sm(c.g.gain, clamp(n - i, 0, 1) * .8, 1.2); sm(c.src.playbackRate, .8 + .45 * b, 1); }); };
}
function buildFan(K, cabin) {
const ctx = S.ctx, lp = bq("lowpass", 900, .6), g = ctx.createGain(), hp = bq("highpass", 2000, .5), lp2 = bq("lowpass", 6000, .5), hg = ctx.createGain(), hg2 = ctx.createGain();
g.gain.value = 1; hg.gain.value = cabin ? .035 : .05; hg2.gain.value = 0;
K.src(noiseBuf("brown"), lp); lp.connect(g).connect(K.out);
K.src(noiseBuf("pink"), hp); hp.connect(lp2).connect(hg).connect(K.out);
const o1 = K.osc("sine", cabin ? 83 : 100, hg2), o2 = K.osc("sine", cabin ? 83.8 : 200, hg2), o3 = K.osc("sine", cabin ? 166 : 300, hg2);
hg2.connect(K.out);
K.set = (a, b) => {
if (cabin) { sm(lp.frequency, 400 + 1400 * a); sm(hg2.gain, .02 + .12 * b); }
else { sm(lp.frequency, 500 + 1500 * a); sm(o1.frequency, 70 + 90 * a); sm(o2.frequency, 140 + 180 * a); sm(o3.frequency, 210 + 270 * a); sm(hg2.gain, .01 + .1 * b); }
};
}
function buildBinaural(K) {
const ctx = S.ctx, l = ctx.createGain(), r = ctx.createGain(), l2 = ctx.createGain(), r2 = ctx.createGain();
const pl = ctx.createStereoPanner(), pr = ctx.createStereoPanner(); pl.pan.value = -1; pr.pan.value = 1;
l.gain.value = r.gain.value = .16; l2.gain.value = r2.gain.value = .04;
l.connect(pl); l2.connect(pl); r.connect(pr); r2.connect(pr); pl.connect(K.out); pr.connect(K.out);
const oL = K.osc("sine", 200, l), oR = K.osc("sine", 210, r), oL2 = K.osc("sine", 400, l2), oR2 = K.osc("sine", 410, r2);
const bed = ctx.createGain(), lp = bq("lowpass", 1800, .5); bed.gain.value = .22; K.src(noiseBuf("pink"), lp); lp.connect(bed).connect(K.out);
K.set = (a, b) => {
const beat = Math.pow(40, a), f0 = 100 * Math.pow(4, b);
sm(oL.frequency, f0 - beat / 2, .3); sm(oR.frequency, f0 + beat / 2, .3);
sm(oL2.frequency, 2 * f0 - beat / 2, .3); sm(oR2.frequency, 2 * f0 + beat / 2, .3);
};
}
function buildBowls(K) {
const ctx = S.ctx, ratios = [1, 2.71, 5.15, 8.2], amps = [1, .45, .22, .1], decs = [16, 10, 6, 3.5];
const strike = () => {
const t = T() + .05, midi = pick([48, 50, 52, 55, 57], Math.random) + 12 * Math.round(K.b * 1.5), f = mtof(midi);
const pn = ctx.createStereoPanner(); pn.pan.value = R(-.6, .6); pn.connect(K.out);
ratios.forEach((ra, i) => [-R(.3, 1.1), R(.3, 1.1)].forEach(det => {
const o = ctx.createOscillator(), g = ctx.createGain(), dec = decs[i] * R(.8, 1.2);
o.frequency.value = f * ra + det;
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(amps[i] * .07, t + .008); g.gain.exponentialRampToValueAtTime(.0001, t + dec);
o.connect(g).connect(pn); o.start(t); o.stop(t + dec + .1);
}));
};
K.every(strike, () => 4 / (.35 + K.a * 1.4), () => 9 / (.35 + K.a * 1.4), .3);
}
function buildWomb(K) {
const ctx = S.ctx, lp = bq("lowpass", 500, .5), g = ctx.createGain(), sw = bq("bandpass", 900, .4), swg = ctx.createGain(), tl = bq("lowpass", 160, .7);
g.gain.value = .55; swg.gain.value = .12;
K.src(noiseBuf("brown"), lp); lp.connect(g).connect(K.out);
K.src(noiseBuf("pink"), sw); sw.connect(swg).connect(K.out); tl.connect(K.out);
const thump = (t, f0, amp) => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f0 * .55, t + .14);
og.gain.setValueAtTime(0, t); og.gain.linearRampToValueAtTime(amp, t + .012); og.gain.exponentialRampToValueAtTime(.001, t + .25);
o.connect(og).connect(tl); o.start(t); o.stop(t + .3);
};
const per = () => 60 / (55 + K.a * 60);
// Schläge der nächsten Sekunden vorplanen: ein stockender Hauptthread lässt so keinen Schlag aus
K.nextBeat = T() + .3;
K.every(() => {
while (K.nextBeat < T() + 2.5) { const t = K.nextBeat; thump(t, 64, .9); thump(t + .27, 72, .6); K.nextBeat += per() * R(1, 1.01); }
}, .25, .25, .05);
K.set = (a, b) => { sm(tl.frequency, 100 + 300 * b); sm(lp.frequency, 300 + 500 * b); };
}
// Zugfahrt: Rollgeräusch + Schienenstöße. Jeder Stoß wird von zwei Achsen kurz nacheinander angeschlagen
// (Achsabstand 3,1 m, Schienenlänge 33 m) -> "klack-klack ......... klack-klack", Pause ≈ 11× Paarabstand, Takt folgt der Geschwindigkeit.
function railClack(K, t, amp, pitch = 1) {
const ctx = S.ctx, nb = noiseBuf("white");
const s1 = ctx.createBufferSource(), bp = bq("bandpass", R(300, 650) * (.7 + .8 * K.b) * pitch, .8), g1 = ctx.createGain(); // dumpfer Anschlag
s1.buffer = nb; s1.connect(bp).connect(g1).connect(K.out);
g1.gain.setValueAtTime(0, t); g1.gain.linearRampToValueAtTime(amp * .55, t + .004); g1.gain.exponentialRampToValueAtTime(.001, t + .08);
s1.start(t, R(0, 8), .1);
const o = ctx.createOscillator(), og = ctx.createGain(); // Schlag im Wagenboden
o.frequency.setValueAtTime(95 * pitch, t); o.frequency.exponentialRampToValueAtTime(48 * pitch, t + .12);
og.gain.setValueAtTime(0, t); og.gain.linearRampToValueAtTime(amp * .8, t + .005); og.gain.exponentialRampToValueAtTime(.001, t + .24);
o.connect(og).connect(K.out); o.start(t); o.stop(t + .3);
// Karosserie klingt nach: zwei Resonanzmoden, lang abklingend
const f1 = R(170, 220) * pitch;
[[f1, 1.5, .65], [f1 * 1.9, .7, .45]].forEach(([f, a, dec]) => {
const s2 = ctx.createBufferSource(), rb = bq("bandpass", f, 10), g2 = ctx.createGain();
s2.buffer = nb; s2.connect(rb).connect(g2).connect(K.out);
g2.gain.setValueAtTime(0, t); g2.gain.linearRampToValueAtTime(amp * a, t + .006); g2.gain.exponentialRampToValueAtTime(.001, t + dec);
s2.start(t, R(0, 8), dec + .1);
});
}
function buildTrain(K) {
const ctx = S.ctx, lp = bq("lowpass", 700, .6), g = ctx.createGain(), bp = bq("bandpass", 900, .5), bg = ctx.createGain();
const hum = ctx.createGain(), sway = ctx.createGain(), swayG = ctx.createGain();
g.gain.value = .45; bg.gain.value = .12; hum.gain.value = .02;
K.src(noiseBuf("brown"), lp); lp.connect(g).connect(sway).connect(K.out);
K.src(noiseBuf("pink"), bp); bp.connect(bg).connect(sway);
const ho = K.osc("sine", 120, hum); hum.connect(K.out);
sway.gain.value = .9; K.osc("sine", .23, swayG); swayG.gain.value = .08; swayG.connect(sway.gain); // leichtes Schaukeln
K.v = 25; K.nextJoint = T() + .3; K.nextPoints = T() + R(25, 50);
K.set = (a, b) => {
K.v = 14 + 36 * a; // 50 … 180 km/h
sm(lp.frequency, 350 + 450 * a + 700 * b, 1); sm(bg.gain, .03 + .1 * a * (.4 + b), 1); sm(ho.frequency, 70 + 120 * a, 1); sm(hum.gain, .015 + .03 * a, 1);
};
K.every(() => {
const now = T();
while (K.nextJoint < now + 2.5) { // alle Achsen der Schienenstöße der nächsten Sekunden vorplanen (ein stockender Hauptthread reißt so keine Lücke)
const v = K.v, t0 = K.nextJoint, amp = .8 * (.85 + Math.random() * .3);
[0, 3.1 / v].forEach((o, i) => railClack(K, t0 + o, amp * (i ? .9 : 1), i ? 1.12 : 1)); // zwei Achsen des Drehgestells, dann Pause bis zum nächsten Stoß
K.nextJoint += 33 / v;
}
if (now > K.nextPoints) { // Weiche: kurzes schnelles Klapperbündel
const n = 7 + Math.floor(Math.random() * 5);
for (let i = 0; i < n; i++) railClack(K, now + .2 + i * R(.07, .13), R(.5, .9));
K.nextPoints = now + R(40, 90);
}
}, .12, .12, .05);
}
// ----- Modus-Tabelle -----
const bandOf = hz => hz < 4 ? "Delta" : hz < 8 ? "Theta" : hz < 13 ? "Alpha" : hz < 30 ? "Beta" : "Gamma";
const binInfo = (a, b) => { const hz = Math.pow(40, a); return `${hz.toFixed(1)} Hz · ${bandOf(hz)} · Träger ${Math.round(100 * Math.pow(4, b))} Hz · Kopfhörer!`; };
const colorDesc = "Regler: Höhen-Dämpfung und langsames Atmen der Lautstärke.";
const MODES = {
ambient: { group: "Musik", name: "Ambient-Musik", music: true, hue: 220 },
white: { group: "Rauschfarben", name: "Weißes Rauschen", desc: "Gleiche Energie in allen Frequenzen – hell und rauschig, maskiert Geräusche gut. " + colorDesc, labels: ["Höhen-Dämpfung", "Atmen"], def: [.9, 0], gain: 0.3, hue: 215, vis: "float", build: K => buildColor(K, "white") },
pink: { group: "Rauschfarben", name: "Pinkes Rauschen", desc: "Nimmt pro Oktave um 3 dB ab – ausgewogen, wie Regen und Blätterrauschen. " + colorDesc, labels: ["Höhen-Dämpfung", "Atmen"], def: [.9, 0], gain: 0.42, hue: 330, vis: "float", build: K => buildColor(K, "pink") },
brown: { group: "Rauschfarben", name: "Braunes Rauschen", desc: "Tief und dumpf (−6 dB pro Oktave), wie Donnergrollen oder ein Wasserfall. " + colorDesc, labels: ["Höhen-Dämpfung", "Atmen"], def: [.9, 0], gain: 0.6, hue: 28, vis: "float", build: K => buildColor(K, "brown") },
blue: { group: "Rauschfarben", name: "Blaues Rauschen", desc: "Zu den Höhen ansteigend, zischend und klar. " + colorDesc, labels: ["Höhen-Dämpfung", "Atmen"], def: [.9, 0], gain: 0.25, hue: 205, vis: "float", build: K => buildColor(K, "blue") },
violet: { group: "Rauschfarben", name: "Violettes Rauschen", desc: "Noch stärker zu den Höhen – sehr hell, fast nur Zischen. " + colorDesc, labels: ["Höhen-Dämpfung", "Atmen"], def: [.8, 0], gain: 0.25, hue: 275, vis: "float", build: K => buildColor(K, "violet") },
grey: { group: "Rauschfarben", name: "Graues Rauschen", desc: "Weißes Rauschen, an die Ohrempfindlichkeit angepasst – wirkt über alle Höhen gleich laut. " + colorDesc, labels: ["Höhen-Dämpfung", "Atmen"], def: [.9, 0], gain: 0.2, hue: 190, vis: "float", build: K => buildColor(K, "grey") },
rain: { group: "Natur", name: "Regen", desc: "Tausende einzeln synthetisierte Tropfen auf einem Rauschteppich.", labels: ["Stärke", "Dumpf ↔ Hell"], def: [.5, .55], gain: 1.3, hue: 210, vis: "rain", build: K => buildRain(K, false) },
storm: { group: "Natur", name: "Gewitter", desc: "Starker Regen mit Blitz und rollendem Donner in unregelmäßigen Abständen.", labels: ["Häufigkeit & Regen", "Entfernung (fern ↔ nah)"], def: [.6, .5], gain: .8, hue: 245, vis: "rain", rev: .5, build: K => buildRain(K, true) },
ocean: { group: "Natur", name: "Meer", desc: "Wellen, die anrollen, brechen und im Schaum auslaufen.", labels: ["Wellengang", "Brandung dumpf ↔ hell"], def: [.5, .5], gain: .9, hue: 195, vis: "float", build: buildOcean },
wind: { group: "Natur", name: "Wind", desc: "Böen mit wandernder Farbe, auf Wunsch mit feinem Pfeifen.", labels: ["Stärke", "Pfeifen"], def: [.5, .25], gain: .9, hue: 180, vis: "wind", build: buildWind },
stream: { group: "Natur", name: "Bach", desc: "Gluckerndes, plätscherndes Wasser aus Gruppen einzeln berechneter Luftbläschen.", labels: ["Strömung", "Tonhöhe"], def: [.5, .5], gain: 0.5, hue: 170, vis: "float", build: buildStream },
fire: { group: "Natur", name: "Lagerfeuer", desc: "Rollendes Brummen der Flamme und Knistern.", labels: ["Flammenstärke", "Knistern selten ↔ dicht"], def: [.5, .45], gain: .8, hue: 25, vis: "rise", build: buildFire },
crickets: { group: "Natur", name: "Sommernacht", desc: "Bis zu sechs Grillen, jede mit eigenem Rhythmus und eigener Tonhöhe.", labels: ["Anzahl", "Tonhöhe / Tempo"], def: [.5, .45], gain: 0.2, hue: 75, vis: "fly", build: buildCrickets },
fan: { group: "Technik", name: "Ventilator", desc: "Gleichmäßiger Luftstrom mit tiefem Motorbrummen.", labels: ["Drehzahl", "Brummen"], def: [.4, .35], gain: 0.3, hue: 200, vis: "float", build: K => buildFan(K, false) },
cabin: { group: "Technik", name: "Flugzeugkabine", desc: "Dumpfes Rauschen und tiefes Triebwerksdröhnen – beliebt zum Einschlafen.", labels: ["Dumpf ↔ offen", "Triebwerk"], def: [.3, .5], gain: 0.27, hue: 215, vis: "float", build: K => buildFan(K, true) },
train: { group: "Technik", name: "Zugfahrt", desc: "Gleichmäßiges Rollen mit dem Tschuck-tschuck der Schienenstöße, ab und zu rattert eine Weiche.", labels: ["Geschwindigkeit", "Abteil dumpf ↔ Fenster offen"], def: [.4, .35], gain: .45, hue: 35, vis: "wind", info: a => Math.round((14 + 36 * a) * 3.6) + " km/h", build: buildTrain },
delta: { group: "Fokus & Schlaf", name: "Binaurale Beats: Delta", desc: "Zwei leicht verstimmte Sinustöne links und rechts, Differenz ≈ 2 Hz (Tiefschlaf).", labels: ["Beat-Frequenz", "Trägerton"], def: [.19, .35], gain: 0.45, hue: 250, vis: "float", info: binInfo, build: buildBinaural },
theta: { group: "Fokus & Schlaf", name: "Binaurale Beats: Theta", desc: "Differenz ≈ 6 Hz (Meditation, Dämmerzustand).", labels: ["Beat-Frequenz", "Trägerton"], def: [.49, .35], gain: 0.45, hue: 280, vis: "float", info: binInfo, build: buildBinaural },
alpha: { group: "Fokus & Schlaf", name: "Binaurale Beats: Alpha", desc: "Differenz ≈ 10 Hz (entspannte Wachheit).", labels: ["Beat-Frequenz", "Trägerton"], def: [.62, .35], gain: 0.45, hue: 160, vis: "float", info: binInfo, build: buildBinaural },
beta: { group: "Fokus & Schlaf", name: "Binaurale Beats: Beta", desc: "Differenz ≈ 18 Hz (Konzentration).", labels: ["Beat-Frequenz", "Trägerton"], def: [.78, .35], gain: 0.45, hue: 45, vis: "float", info: binInfo, build: buildBinaural },
bowls: { group: "Fokus & Schlaf", name: "Klangschalen", desc: "Angeschlagene Schalen mit schwebenden Obertönen und langem Nachklang.", labels: ["Häufigkeit", "Tonhöhe"], def: [.4, .35], gain: 1.4, hue: 40, rev: .9, vis: "float", build: buildBowls },
womb: { group: "Fokus & Schlaf", name: "Herzschlag", desc: "Gedämpfter Herzschlag in tiefem Rauschen – wie im Mutterleib.", labels: ["Herzschlag-Tempo", "Dumpf ↔ nah"], def: [.2, .4], gain: 0.25, hue: 5, rev: .15, vis: "float", build: buildWomb },
};
function applyRev() {
if (!S.g) return;
const M = MODES[S.mode];
S.g.modeRev.gain.setTargetAtTime(M.music ? 0 : (M.rev || 0) * S.verb * 1.2, S.ctx.currentTime, .3);
if (!LITE) return;
// Telefone: Was gerade nicht klingt, vom Ausgang trennen – der Browser rechnet es dann gar nicht
// (Musik-Bett, Delay, Hall ohne Hall-Modus). Musik geht nach dem Ausblenden weg, damit nichts knackt.
const music = !!M.music;
if (music) linkMusic(true);
else setTimeout(() => { if (!MODES[S.mode].music) linkMusic(false); }, 1600);
const rev = music || !!M.rev;
if (S.revLinked !== rev) {
S.revLinked = rev;
try { rev ? S.g.conv.connect(S.g.mix) : S.g.conv.disconnect(S.g.mix); } catch (e) {}
}
}
function linkMusic(on) {
if (S.musicLinked === on) return;
S.musicLinked = on;
const G = S.g;
for (const [a, b] of [[G.dry, G.mix], [G.send, G.conv], [G.noiseBus, G.mix], [G.nSend, G.conv], [G.dlp, G.conv], [G.dlp, G.mix]]) {
try { on ? a.connect(b) : a.disconnect(b); } catch (e) {}
}
}
function startSoundscape() {
const M = MODES[S.mode], K = newKit(M.gain || .7);
S.modeObj = K; M.build(K); K.apply(S.mp.a, S.mp.b); applyRev();
}
function tickMode() { // „Wandel": die beiden Regler wandern langsam von selbst
const K = S.modeObj; if (!K) return;
const t = S.ctx.currentTime, amp = S.drift * .3;
const w1 = Math.sin(t * .031 + 1) * .5 + Math.sin(t * .083 + 4) * .5, w2 = Math.sin(t * .027 + 2) * .5 + Math.sin(t * .067 + 5) * .5;
K.ea = clamp(S.mp.a + amp * w1, 0, 1); K.eb = clamp(S.mp.b + amp * w2, 0, 1);
K.apply(K.ea, K.eb); updateInfo();
}
function leaveMusic() {
S.timers.forEach(clearTimeout); S.timers = [];
const G = S.g, t = S.ctx.currentTime;
G.voiceBus.gain.setTargetAtTime(0, t, .7); G.grainBus.gain.setTargetAtTime(0, t, .7);
G.windGain.gain.setTargetAtTime(0, t, .7); G.seaGain.gain.setTargetAtTime(0, t, .7);
}
function enterMode() {
if (MODES[S.mode].music) {
const G = S.g, t = S.ctx.currentTime;
G.voiceBus.gain.setTargetAtTime(1, t, .3); G.grainBus.gain.setTargetAtTime(1, t, .3);
startMusic();
} else startSoundscape();
applyRev();
}
// Kommunikation mit der einbettenden Seite (diora): Radio/Podcast ↔ Ambient
function post(state) {
try { if (window.parent !== window) window.parent.postMessage({ type: "ambient", state }, "*"); } catch (e) {}
}
addEventListener("message", e => {
if (e.source !== window.parent) return;
const d = e.data;
if (!d || d.type !== "ambient") return;
if (d.cmd === "start" && !S.running) start();
else if (d.cmd === "stop" && S.running) stop();
else if (d.cmd === "mode" && MODES[d.mode]) setMode(d.mode);
else if (d.cmd === "volume" && isFinite(d.value)) { // die einbettende Seite (diora) gibt die Lautstärke vor
S.hostVol = clamp(+d.value, 0, 1);
if (S.running) S.g.master.gain.setTargetAtTime(outVol(), S.ctx.currentTime, .1);
}
});
function startMusic() {
resetScene();
drone(); playChord();
later(playChord, 10, 18, 1);
later(playBells, 2, 6, 2);
later(changeDegree, 18, 34, 3);
later(wanderTimbre, 15, 30, 4);
later(() => changeKey(true), 90, 180, 5);
later(drone, 40, 70, 6);
later(grainCloud, 3, 8, 7);
later(snapGrains, 20, 30, 8, .3);
startLoops();
}
function start() {
if (!S.ctx) buildGraph();
S.running = true; S.timers = [];
// Ohne Nutzergeste (z. B. Befehl von außen) kann der Browser den Ton blockieren -> dann ehrlich "stopped" melden
S.ctx.resume().then(() => { if (S.running && S.ctx.state !== "running") stop(); }, () => { if (S.running) stop(); });
const t = S.ctx.currentTime;
S.g.master.gain.cancelScheduledValues(t);
S.g.master.gain.setTargetAtTime(outVol(), t, 2.5);
S.tickId = setInterval(tick, 1000);
enterMode();
$("toggle").textContent = "Stopp";
wake(); post("playing");
}
function stop() {
S.running = false;
S.timers.forEach(t => { clearTimeout(t); clearInterval(t); }); S.timers = [];
clearInterval(S.tickId);
if (S.modeObj) { S.modeObj.stop(); S.modeObj = null; }
const t = S.ctx.currentTime;
S.g.master.gain.cancelScheduledValues(t);
S.g.master.gain.setTargetAtTime(0, t, 1.5);
setTimeout(() => { if (!S.running) S.ctx.suspend(); }, 12000);
$("toggle").textContent = "Start";
S.visUntil = performance.now() + 14000; // Partikel noch ausklingen lassen, danach Zeichnen stoppen
post("stopped");
}
function reroll() { // neue Szene, laufende Stimmen klingen aus
resetScene();
if (S.running && MODES[S.mode].music) { drone(); playChord(); }
}
// ---------- Seed / Link ----------
function writeHash() {
try { history.replaceState(null, "", "#seed=" + encodeURIComponent(S.seedText) + "&p=" + encodeURIComponent(S.presetName) + "&m=" + encodeURIComponent(S.mode)); } catch (e) {}
}
function applySeed(text) {
S.seedText = text.trim() || String(Math.floor(Math.random() * 900000) + 100000);
S.seed = toSeed(S.seedText); $("seed").value = S.seedText; writeHash();
if (S.ctx) reroll(); else seedAll(S.seed);
}
function applyPreset(name) {
if (!PRESETS[name]) name = "Frei";
S.presetName = name; S.P = PRESETS[name]; $("preset").value = name; writeHash();
if (S.running && MODES[S.mode].music) { wanderTimbre(); changeKey(false); }
}
// ---------- UI ----------
const METERS = [
["Helligkeit", () => S.cur.bright], ["FM", () => S.cur.fm], ["Säge", () => S.cur.saw], ["Wavetable", () => S.cur.wt],
["Rauschen", () => S.cur.noise], ["Körner", () => S.cur.grain], ["Bewegung", () => S.cur.motion * 1.6], ["Spannung", () => eff()],
];
$("meters").innerHTML = METERS.map(([l], i) =>
`<div><div class="mlabel">${l}</div><div class="meter"><i id="m${i}"></i></div></div>`).join("");
function renderMeters() { METERS.forEach(([, fn], i) => $("m" + i).style.width = clamp(fn() * 100, 2, 100) + "%"); }
const ROMAN = ["I","II","III","IV","V","VI","VII","VIII"];
function arcLabel() {
const p = arcPhase();
return p < .15 ? "Ruhe" : p < .4 ? "Aufbau" : p < .6 ? "Höhepunkt" : p < .85 ? "Rückzug" : "Ruhe";
}
function updateInfo() {
const M = MODES[S.mode];
if (!M.music) {
const K = S.modeObj;
$("key").textContent = M.name;
$("chord").innerHTML = M.desc + (M.info ? ` <b>${M.info(K && K.ea != null ? K.ea : S.mp.a, K && K.eb != null ? K.eb : S.mp.b)}</b>` : "");
return;
}
if (!S.running) { $("key").textContent = "–"; $("chord").textContent = "Noch still."; return; }
$("key").textContent = `${NOTE[S.root % 12]} ${S.scaleName}`;
$("chord").innerHTML = `Stufe <b>${ROMAN[S.degree] || S.degree + 1}</b> · ${S.presetName} · ${S.tuning === "just" ? "rein" : "gleichstufig"} · Bogen: <b>${arcLabel()}</b>`;
}
$("preset").innerHTML = Object.keys(PRESETS).map(n => `<option>${n}</option>`).join("");
$("toggle").onclick = () => S.running ? stop() : start();
$("vol").oninput = () => { if (S.running) S.g.master.gain.setTargetAtTime(outVol(), S.ctx.currentTime, .1); };
$("density").oninput = e => S.density = +e.target.value;
$("drift").oninput = e => S.drift = +e.target.value;
$("arc").oninput = e => S.arcDepth = +e.target.value;
$("verb").oninput = e => { S.verb = +e.target.value; if (S.g) { S.g.send.gain.setTargetAtTime(S.verb * 1.2, S.ctx.currentTime, .2); applyRev(); } };
$("tuning").onchange = e => { S.tuning = e.target.value; updateInfo(); };
$("preset").onchange = e => applyPreset(e.target.value);
$("seed").onchange = e => applySeed(e.target.value);
$("reroll").onclick = () => { applySeed(String(Math.floor(Math.random() * 900000) + 100000)); if (!S.running) start(); };
$("share").onclick = async () => {
writeHash();
try { await navigator.clipboard.writeText(location.href); $("share").textContent = "Kopiert ✓"; }
catch (e) { $("share").textContent = "Adresszeile kopieren"; }
setTimeout(() => $("share").textContent = "Link kopieren", 1800);
};
S.density = +$("density").value; S.drift = +$("drift").value; S.verb = +$("verb").value; S.arcDepth = +$("arc").value;
// Modus-Auswahl
$("mode").innerHTML = (() => {
const groups = {};
for (const [k, m] of Object.entries(MODES)) (groups[m.group] = groups[m.group] || []).push(`<option value="${k}">${m.name}</option>`);
return Object.entries(groups).map(([g, o]) => `<optgroup label="${g}">${o.join("")}</optgroup>`).join("");
})();
function applyModeUI() {
const M = MODES[S.mode], root = document.documentElement;
root.classList.toggle("noise", !M.music); root.classList.toggle("rev", !!M.rev);
if (!M.music) { $("lla").textContent = M.labels[0]; $("llb").textContent = M.labels[1]; $("la").value = S.mp.a; $("lb").value = S.mp.b; }
$("driftL").textContent = M.music ? "Wandel-Tempo" : "Schwankung";
applyRev();
}
function setMode(name) {
if (!MODES[name]) name = "ambient";
const was = MODES[S.mode], M = MODES[name];
S.mode = name; $("mode").value = name;
if (!M.music) S.mp = { a: M.def[0], b: M.def[1] };
applyModeUI(); writeHash();
if (S.running) {
if (was.music) leaveMusic();
if (S.modeObj) { S.modeObj.stop(); S.modeObj = null; }
enterMode();
}
updateInfo(); wake();
}
$("mode").onchange = e => setMode(e.target.value);
$("la").oninput = e => { S.mp.a = +e.target.value; if (S.modeObj) S.modeObj.apply(S.mp.a, S.mp.b); };
$("lb").oninput = e => { S.mp.b = +e.target.value; if (S.modeObj) S.modeObj.apply(S.mp.a, S.mp.b); };
// Startwerte aus dem Link (#seed=…&p=…&m=…)
{
const q = new URLSearchParams(location.hash.slice(1));
applyPreset(q.get("p") || "Frei");
applySeed(q.get("seed") || "");
setMode(q.get("m") || "ambient");
}
// ---------- Visualisierung ----------
const cv = $("viz"), cx = cv.getContext("2d");
S.visUntil = performance.now() + 1500;
function wake() { if (!rafId) rafId = requestAnimationFrame(draw); }
function resize() {
cv.width = Math.round(cv.clientWidth * DPR());
cv.height = Math.round(cv.clientHeight * DPR());
wake();
}
resize();
new ResizeObserver(resize).observe(document.documentElement); // auch beim Wieder-Einblenden eines versteckten iframes
document.addEventListener("visibilitychange", wake);
let hueV = 220; const parts = [];
function spawnParticles(ev) {
const W = cv.width, H = cv.height;
const x = W * clamp((Math.log2(ev.f) - 5) / 7.2, .02, .98);
const count = ev.kind === "bell" ? 6 : Math.round(3 + ev.dur / 5);
const keyHue = (S.root % 12) * 30 + 200;
for (let i = 0; i < count && parts.length < 450; i++) {
parts.push({
x: x + rnd(-40, 40, Math.random) * DPR(), y: H * rnd(.55, .85, Math.random),
vx: rnd(-.12, .12, Math.random), vy: -rnd(.08, .4, Math.random) * (ev.kind === "bell" ? 1.6 : 1),
r: (ev.kind === "bell" ? rnd(4, 9, Math.random) : rnd(10, 30, Math.random)) * DPR(),
life: 0, max: ev.kind === "bell" ? rnd(180, 320, Math.random) : Math.min(ev.dur * 40, 700),
h: keyHue + (ev.kind === "choir" ? 60 : ev.kind === "bell" ? 25 : 0) + rnd(-12, 12, Math.random),
});
}
}
// Partikel der Klangwelten: Regen, Funken, Windschlieren, Glühwürmchen, sonst treibende Lichter
function modeVis(W, H, dpr) {
const M = MODES[S.mode], K = S.modeObj;
if (M.music || !S.running || !K) return;
const a = K.a, add = p => { if (parts.length < 450) parts.push(Object.assign({ life: 0, vx: 0, vy: 0, h: M.hue }, p)); };
const n = x => Math.random() < x, r = (lo, hi) => lo + Math.random() * (hi - lo);
switch (M.vis) {
case "rain":
for (let i = 0; i < 1 + Math.floor(a * 5); i++) add({ x: Math.random() * W, y: -8 * dpr, vx: -1.2, vy: r(7, 11), r: r(1.6, 3) * dpr, max: r(40, 80) });
break;
case "rise":
if (n(.5 + a * .4)) add({ x: W * r(.35, .65), y: H * .9, vx: r(-.5, .5), vy: -r(.8, 2.4), r: r(3, 9) * dpr, max: r(70, 160), h: M.hue + r(-10, 25) });
break;
case "wind":
if (n(.4 + a * .5)) add({ x: -10 * dpr, y: Math.random() * H, vx: r(4, 9) * (.5 + a), vy: r(-.4, .4), r: r(2, 5) * dpr, max: r(60, 140) });
break;
case "fly":
if (n(.05)) add({ x: Math.random() * W, y: H * r(.3, .95), vx: r(-.3, .3), vy: r(-.3, .3), r: r(5, 10) * dpr, max: r(200, 420) });
break;
default:
if (n(.06 + a * .05)) add({ x: Math.random() * W, y: H * r(.5, .95), vx: r(-.2, .2), vy: -r(.1, .5), r: r(10, 28) * dpr, max: r(300, 600) });
}
}
let lastDraw = 0;
function draw(ts) {
rafId = 0;
const W = cv.width, H = cv.height, dpr = DPR();
// Nichts zeichnen (und nicht neu planen), wenn versteckt, 0 px groß oder die Musik steht
if (document.hidden || !W || !H || !cv.clientWidth) return;
if (!S.running && performance.now() > S.visUntil) return;
rafId = requestAnimationFrame(draw);
if (LITE && ts - lastDraw < 48) return; // Telefone: ~20 Bilder/s reichen
lastDraw = ts;
cx.globalCompositeOperation = "source-over";
cx.fillStyle = `rgba(${BG[0]},${BG[1]},${BG[2]},.16)`; cx.fillRect(0, 0, W, H);
if (!S.g) return;
const bins = new Uint8Array(S.g.analyser.frequencyBinCount);
S.g.analyser.getByteFrequencyData(bins);
let lvl = 0; for (let i = 0; i < 80; i++) lvl += bins[i]; lvl = lvl / 80 / 255;
const M0 = MODES[S.mode];
const target = M0.music ? (S.root % 12) * 30 + 200 + S.cur.bright * 40 : M0.hue;
hueV += (((target - hueV + 540) % 360) - 180) * .01;
cx.globalCompositeOperation = "lighter";
// Nebel
for (let i = 0; i < 4; i++) {
const x = W * (.5 + .38 * Math.sin(ts * .00006 * (i + 1) + i * 2)), y = H * (.5 + .3 * Math.cos(ts * .00005 * (i + 2) + i));
const r = Math.min(W, H) * (.4 + .12 * Math.sin(ts * .0001 + i));
const gr = cx.createRadialGradient(x, y, 0, x, y, r);
gr.addColorStop(0, `hsla(${hueV + i * 28},70%,50%,${.04 + lvl * .16})`); gr.addColorStop(1, "hsla(0,0%,0%,0)");
cx.fillStyle = gr; cx.fillRect(0, 0, W, H);
}
modeVis(W, H, dpr);
// Notenereignisse -> Partikel
const now = S.ctx.currentTime;
S.vis = S.vis.filter(ev => { if (ev.t <= now) { if (now - ev.t < 2) spawnParticles(ev); return false; } return ev.t - now < 120; });
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i]; p.life++; p.x += p.vx * dpr + Math.sin((ts + i * 97) * .0007) * .15; p.y += p.vy * dpr;
if (p.life > p.max) { parts.splice(i, 1); continue; }
const k = p.life / p.max, a = Math.sin(Math.PI * k) * .35;
if (LITE) cx.fillStyle = `hsla(${p.h},80%,65%,${a * .45})`; // Telefone: einfache Scheiben statt Verläufen
else {
const gr = cx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r);
gr.addColorStop(0, `hsla(${p.h},80%,65%,${a})`); gr.addColorStop(1, "hsla(0,0%,0%,0)");
cx.fillStyle = gr;
}
cx.beginPath(); cx.arc(p.x, p.y, p.r, 0, 6.3); cx.fill();
}
if (S.flash > .02) { cx.fillStyle = `rgba(190,205,255,${S.flash * .28})`; cx.fillRect(0, 0, W, H); S.flash *= .9; }
// Spektrumlinie
cx.lineWidth = 1.6 * dpr; const N = 90;
for (let layer = 0; layer < 2; layer++) {
cx.beginPath();
for (let i = 0; i <= N; i++) {
const v = bins[Math.floor(Math.pow(i / N, 1.8) * 420) + layer * 3] / 255;
const x = i / N * W, y = H * (.9 - layer * .03) - v * H * .22;
i ? cx.lineTo(x, y) : cx.moveTo(x, y);
}
cx.strokeStyle = `hsla(${hueV + layer * 40},80%,70%,${.4 - layer * .15})`; cx.stroke();
}
}
wake();
post("stopped");
</script>
</body>
</html>