diff --git a/static/ambient/index.html b/static/ambient/index.html
index f8c2767..3118c64 100644
--- a/static/ambient/index.html
+++ b/static/ambient/index.html
@@ -100,7 +100,8 @@ 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 ? 1 : devicePixelRatio;
+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
@@ -353,8 +354,18 @@ function envelope(g, when, att, dur, rel, peak) {
}
// 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);
@@ -372,18 +383,21 @@ function padVoice(f, when, dur, vel = 1) {
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("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), 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);
+ 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 > .03) {
+ 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;
@@ -403,6 +417,7 @@ function padVoice(f, when, dur, vel = 1) {
// 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);
@@ -515,7 +530,8 @@ function spawnChord(t0) {
}
function playChord() {
if (!S.running) return;
- if (rng.v() < S.P.swell) {
+ // 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;
@@ -1279,7 +1295,7 @@ function draw(ts) {
if (document.hidden || !W || !H || !cv.clientWidth) return;
if (!S.running && performance.now() > S.visUntil) return;
rafId = requestAnimationFrame(draw);
- if (LITE && ts - lastDraw < 32) return; // Telefone: ~30 Bilder/s reichen
+ 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);
@@ -1308,9 +1324,13 @@ function draw(ts) {
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();
+ 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
diff --git a/static/js/sw.js b/static/js/sw.js
index a4305f9..9eb39fc 100644
--- a/static/js/sw.js
+++ b/static/js/sw.js
@@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
-const CACHE = 'diora-v83';
+const CACHE = 'diora-v84';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',