779 lines
36 KiB
HTML
779 lines
36 KiB
HTML
|
|
<!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>
|