diora-web/static/ambient/index.html
mrwnslz b7a81f3e26
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 6m55s
Neuer Tab „Ambient“: Drift, ein generativer Ambient-Synthesizer; sw v73
- static/ambient/index.html: Drift (aus der Session "Ambient-Synthesizer-Webapp",
  eine Datei, Web Audio, offline), eingebettet per iframe (?embed=1&bg=#000)
- app.js: Tab 'ambient', iframe lädt beim ersten Öffnen und füllt den Platz bis
  zur Leiste; Radio/Podcast und Ambient wechseln sich ab (postMessage
  {type:'ambient', cmd|state})
- sw.js: Ambient im Offline-Cache (ignoreSearch für die Shell-Dateien)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:08:57 +02:00

778 lines
36 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; }
main > * { pointer-events:auto; }
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>Stimmung
<select id="preset"></select></label>
<label>Seed
<input type="text" id="seed" spellcheck="false"></label>
<button id="reroll" title="Neuer Seed, neue Szene">Neu würfeln</button>
<button id="share" title="Link mit Seed und Stimmung kopieren">Link kopieren</button>
<label>Tuning
<select id="tuning"><option value="just">Rein</option><option value="equal">Gleichstufig</option></select></label>
<div class="sep"></div>
<label>Lautstärke <input type="range" id="vol" min="0" max="1" step="0.01" value="0.7"></label>
<label>Dichte <input type="range" id="density" min="0" max="1" step="0.01" value="0.5"></label>
<label>Wandel-Tempo <input type="range" id="drift" min="0" max="1" step="0.01" value="0.5"></label>
<label>Hall <input type="range" id="verb" min="0" max="1" step="0.01" value="0.6"></label>
<label>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);
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,
};
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() {
const ctx = S.ctx = new (window.AudioContext || window.webkitAudioContext)();
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 = "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, 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 = ctx.createGain(); nSend.gain.value = .3; g.noiseBus.connect(nSend).connect(g.conv);
// Granular: Ringpuffer der Stimmen, daraus werden Körner geschnitten
S.ring = new Float32Array(ctx.sampleRate * 12); S.wp = 0; S.written = 0;
const proc = ctx.createScriptProcessor(4096, 1, 1);
proc.onaudioprocess = e => {
const inp = e.inputBuffer.getChannelData(0), n = S.ring.length;
for (let i = 0; i < inp.length; i++) { S.ring[S.wp] = inp[i]; S.wp = (S.wp + 1) % n; }
S.written += inp.length;
};
const zero = ctx.createGain(); zero.gain.value = 0;
g.voiceBus.connect(proc); proc.connect(zero).connect(ctx.destination);
g.grainBus = ctx.createGain(); g.grainBus.connect(g.filter);
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
function padVoice(f, when, dur, vel = 1) {
const ctx = S.ctx, c = S.cur, G = S.g;
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), .35 * c.saw);
mk("sawtooth", 1, -c.detune * rnd(.6, 1.2), .35 * c.saw);
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), 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 > .03) {
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;
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
function snapGrains() {
const sr = S.ctx.sampleRate;
if (S.written < sr * 6) return;
const len = Math.min(S.written, S.ring.length), b = S.ctx.createBuffer(1, len, sr), d = b.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = S.ring[(S.wp - len + i + S.ring.length) % S.ring.length];
S.grainBuf = b;
}
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;
if (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;
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();
}
// 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();
});
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(); });
resetScene();
const t = S.ctx.currentTime;
S.g.master.gain.cancelScheduledValues(t);
S.g.master.gain.setTargetAtTime(+$("vol").value, t, 2.5);
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();
S.timers.push(setInterval(tick, 1000));
$("toggle").textContent = "Stopp";
wake(); post("playing");
}
function stop() {
S.running = false;
S.timers.forEach(t => { clearTimeout(t); clearInterval(t); }); S.timers = [];
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) { drone(); playChord(); }
}
// ---------- Seed / Link ----------
function writeHash() {
try { history.replaceState(null, "", "#seed=" + encodeURIComponent(S.seedText) + "&p=" + encodeURIComponent(S.presetName)); } 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) { 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() {
$("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 = e => { if (S.running) S.g.master.gain.setTargetAtTime(+e.target.value, 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); };
$("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;
// Startwerte aus dem Link (#seed=…&p=…)
{
const q = new URLSearchParams(location.hash.slice(1));
applyPreset(q.get("p") || "Frei");
applySeed(q.get("seed") || "");
}
// ---------- Visualisierung ----------
const cv = $("viz"), cx = cv.getContext("2d");
let rafId = 0;
S.visUntil = performance.now() + 1500;
function wake() { if (!rafId) rafId = requestAnimationFrame(draw); }
function resize() {
cv.width = Math.round(cv.clientWidth * devicePixelRatio);
cv.height = Math.round(cv.clientHeight * devicePixelRatio);
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) * devicePixelRatio, 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)) * devicePixelRatio,
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),
});
}
}
function draw(ts) {
rafId = 0;
const W = cv.width, H = cv.height, dpr = devicePixelRatio;
// 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);
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 target = (S.root % 12) * 30 + 200 + S.cur.bright * 40;
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);
}
// 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;
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();
}
// 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>