Audioreaktive 3D-Szenen
Eine Telefon-KI als Orb: Web Audio zerlegt die Stimme in Bass, Mitten und Höhen, und die Kugel verformt sich Silbe für Silbe.
Eine 3D-Szene reagiert auf Ton. Ein AnalyserNode der Web Audio API liefert pro Frame das Frequenzspektrum (getByteFrequencyData). Daraus entstehen wenige Bänder, meist Bass, Mitten und Höhen, plus ein Gesamtpegel. Die Werte werden geglättet und als Uniforms an die Shader gegeben. Dort steuern sie Verformung der Geometrie, Partikelgröße und -tempo, Leuchtstärke oder Farbe. Ton startet erst nach einer Nutzergeste.
Bild und Ton bewegen sich im selben Takt. Das wirkt lebendig und zieht in die Szene. Bei Sprach-KI zeigt der Orb sichtbar, wer gerade spricht und dass das System zuhört.
Wann einsetzen
Musiker, Labels, Festivals und Events, Studios mit Sounddesign, Kunstprojekte. Sprach- und Telefon-KI, bei denen der Orb Teil der Oberfläche ist.
Wann nicht
Als Kernerlebnis auf Seiten, die meist stumm genutzt werden (Büro, unterwegs am Telefon). Als reine Deko hinter Lesetext. Auf Business-Seiten ohne Bezug zu Ton.
Varianten
- 3D-Orb: verformte Kugel, Bass verformt grob, Höhen fein
- Partikel synchron zum Ton (Igloo Inc)
- Musikreaktiv: Kick pumpt die Skala, Hi-Hats bringen Detail
- Musikreaktives Goo mit WebGPU (Codrops, Run Rob Run)
- Glas und Kaustiken mit Ton
Umsetzung
AudioContext erst nach Klick erzeugen bzw. resume(). Quelle → AnalyserNode → Ausgang, Lautstärke hinter dem Analyser regeln. fftSize 512 bis 2048, smoothingTimeConstant 0,7 bis 0,85. Bänder als Mittel über Bin-Bereiche bilden und je Signal auf 0 bis 1 normieren. Im JS zusätzlich dämpfen, schnell steigend und langsam fallend. Werte als Uniforms an Vertex- und Fragment-Shader. Locomotive summiert alle Bins in einer fps-gedrosselten Schleife, Trionn mittelt die Bins 2 bis frequencyBinCount/2.
Robust umsetzen
Telefon. Ton standardmäßig aus. Die Szene muss auch stumm sinnvoll sein, etwa mit einem berechneten Signal. Geometrie und Partikelzahl senken.
Ohne Bewegung. Verformung, Drehung und Pulsieren aus. Höchstens Licht oder Farbe folgen dem Pegel, stark gedämpft.
Barrierefrei. Ton-Schalter sichtbar und per Tastatur erreichbar, Zustand mit aria-pressed. Nie Autoplay mit Ton. Das Canvas ist aria-hidden; was gesprochen wird, steht als Text daneben (Transkript in einer Live-Region).
Code
<!DOCTYPE html>
<!--
Audioreaktive 3D-Szenen
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/audio-reaktiv-3d/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Audioreaktive 3D-Szenen · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
/* Rezept „saas-produkt-praezise“: Graustufen tragen alles, ein Markenton (Signalorange) */
:root {
--grund: #08090a; --panel: #0f1011; --karte: #161718; --linie: #242728; --linie-leise: #1b1c1e;
--text: #f7f8f8; --text-2: #d0d6e0; --meta: #8a8f98; --leise: #7a7f87;
--marke: #ff7a3d; --marke-15: rgba(255, 122, 61, .15); --ok: #59d499;
--ui: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* easeOutQuad wie Linear */
--kurz: 160ms;
--rand: clamp(16px, 4vw, 56px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Geist', system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.zahl { font-family: 'Geist Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.weite { max-width: 1240px; margin: 0 auto; padding: 0 var(--rand); }
/* ---------- Navigation ---------- */
.nav { display: flex; align-items: center; gap: 32px; height: 64px; border-bottom: 1px solid var(--linie-leise); }
.marke-wort { display: flex; align-items: center; gap: 9px; font-weight: 600; font-size: 16px; letter-spacing: -.012em; }
.marke-wort svg { width: 22px; height: 22px; }
.nav ul { display: flex; gap: 24px; list-style: none; font-size: 14px; color: var(--meta); }
.nav ul a { transition: color var(--kurz) var(--ui); }
.nav ul a:hover { color: var(--text); }
.nav .rechts { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 14px; border-radius: 8px; font-size: 14px; font-weight: 500; letter-spacing: -.006em; border: 1px solid var(--linie); background: transparent; cursor: pointer; transition: background-color var(--kurz) var(--ui), border-color var(--kurz) var(--ui), color var(--kurz) var(--ui); }
.knopf:hover { border-color: #34373a; background: var(--karte); }
.knopf.primaer { background: var(--marke); border-color: var(--marke); color: #160a04; }
.knopf.primaer:hover { background: #ff8b55; }
.knopf.still { border-color: transparent; color: var(--meta); }
.knopf[aria-disabled='true'] { cursor: default; }
.knopf:focus-visible, .modi button:focus-visible, .ton:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
/* ---------- Hero ---------- */
.hero { display: grid; grid-template-columns: 1.25fr 1fr; gap: 24px 56px; align-items: end; padding-top: clamp(36px, 6vh, 60px); padding-bottom: clamp(28px, 4.5vh, 44px); }
.hinweis { grid-column: 1 / -1; justify-self: start; display: inline-flex; align-items: center; gap: 10px; height: 28px; padding: 0 12px 0 4px; border: 1px solid var(--linie); border-radius: 999px; font-size: 13px; color: var(--text-2); }
.hinweis b { font: 500 10px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--marke); background: var(--marke-15); border-radius: 999px; padding: 5px 8px; }
h1 { font-size: clamp(2.5rem, 1.6rem + 3.6vw, 4.5rem); font-weight: 580; letter-spacing: -.022em; line-height: 1; text-wrap: balance; }
.hero .text p { font-size: 1.0625rem; line-height: 1.6; letter-spacing: -.006em; color: var(--meta); max-width: 46ch; }
.hero .text p b { color: var(--text-2); font-weight: 500; }
.ctas { display: flex; gap: 10px; margin-top: 22px; flex-wrap: wrap; align-items: center; }
.ctas .knopf { height: 40px; padding: 0 18px; font-size: 15px; }
.ctas .mono { margin-left: 6px; font-size: 10.5px; color: var(--leise); }
/* ---------- Produktfenster: Live-Anruf ---------- */
.app { padding-bottom: clamp(40px, 6vw, 72px); }
.fenster { border: 1px solid var(--linie); border-radius: 14px; background: var(--panel); overflow: hidden; box-shadow: 0 40px 80px -40px rgba(0, 0, 0, .9), 0 0 0 1px rgba(255, 255, 255, .02) inset; }
.leiste { display: flex; align-items: center; gap: 12px; height: 46px; padding: 0 12px 0 16px; border-bottom: 1px solid var(--linie); font-size: 13px; color: var(--meta); }
.leiste .pfad { display: flex; gap: 8px; align-items: center; white-space: nowrap; overflow: hidden; }
.leiste .pfad i { font-style: normal; color: var(--leise); }
.leiste .pfad b { color: var(--text); font-weight: 500; }
.leiste .rechts { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.ton { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 12px 0 10px; border-radius: 7px; border: 1px solid var(--linie); background: var(--karte); font-size: 13px; color: var(--text-2); cursor: pointer; transition: border-color var(--kurz) var(--ui), color var(--kurz) var(--ui), background-color var(--kurz) var(--ui); }
.ton:hover { border-color: #3a3d41; color: var(--text); }
.ton svg { width: 16px; height: 16px; }
.ton .an { display: none; }
.ton[aria-pressed='true'] { border-color: var(--marke); color: var(--text); background: var(--marke-15); }
.ton[aria-pressed='true'] .an { display: block; }
.ton[aria-pressed='true'] .aus { display: none; }
.leiste .knopf { height: 30px; font-size: 13px; padding: 0 12px; }
.koerper { display: grid; grid-template-columns: 236px minmax(0, 1fr) 316px; height: clamp(520px, 64vh, 600px); }
/* Seitenleiste: heutige Anrufe */
.liste { border-right: 1px solid var(--linie); padding: 16px 10px; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.liste > .mono { padding: 0 8px 10px; display: flex; justify-content: space-between; }
.anruf { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; padding: 9px 8px; border-radius: 7px; font-size: 13px; color: var(--text-2); }
.anruf small { grid-column: 1 / -1; font-size: 12px; color: var(--meta); }
.anruf .zahl { font-size: 11.5px; color: var(--leise); }
.anruf.aktiv { background: var(--karte); color: var(--text); box-shadow: 0 0 0 1px var(--linie) inset; }
.anruf.aktiv .zahl { color: var(--marke); }
.anruf .etikett { font: 500 10px/1 'Geist Mono', monospace; letter-spacing: .1em; text-transform: uppercase; padding: 3px 6px; border-radius: 4px; background: #1d1f21; color: var(--meta); align-self: center; }
.anruf .etikett.gruen { color: var(--ok); background: rgba(89, 212, 153, .1); }
.live { display: inline-flex; align-items: center; gap: 7px; }
.live::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--marke); box-shadow: 0 0 0 3px var(--marke-15); }
.liste .summe { margin-top: auto; padding: 12px 8px 0; border-top: 1px solid var(--linie); display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.liste .summe b { display: block; font: 500 20px/1.1 'Geist', sans-serif; letter-spacing: -.02em; color: var(--text); margin-top: 4px; font-variant-numeric: tabular-nums; }
/* Mitte: Orb und Analyse */
.mitte { position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; }
.status { display: flex; align-items: center; gap: 12px; padding: 16px 20px 0; }
.sprecher { display: inline-flex; align-items: center; gap: 8px; height: 26px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--linie); font-size: 13px; color: var(--text-2); transition: border-color var(--kurz) var(--ui), color var(--kurz) var(--ui); }
.sprecher::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--meta); transition: background-color var(--kurz) var(--ui); }
.sprecher[data-wer='ki'] { border-color: rgba(255, 122, 61, .45); color: var(--text); }
.sprecher[data-wer='ki']::before { background: var(--marke); }
.sprecher[data-wer='anrufer']::before { background: var(--text-2); }
.status .zahl { font-size: 12px; color: var(--meta); }
.status .quelle { margin-left: auto; font-size: 10px; color: var(--leise); }
.status .quelle b { color: var(--meta); font-weight: 500; }
.orb { position: relative; min-height: 0; }
.orb canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.orb [data-canvas] { opacity: 0; transition: opacity .6s var(--ui); }
.gl-bereit .orb [data-canvas] { opacity: 1; }
/* Halo hinter dem Orb folgt dem Pegel (CSS-Variable aus JS) */
.orb::before { content: ''; position: absolute; left: 50%; top: 50%; width: min(78%, 92vh); aspect-ratio: 1; translate: -50% -50%; border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 122, 61, .2), rgba(255, 122, 61, .05) 55%, transparent); opacity: calc(.15 + var(--pegel, 0) * 1.1); scale: calc(.8 + var(--pegel, 0) * .35); }
.orb[data-wer='anrufer']::before { background: radial-gradient(closest-side, rgba(208, 214, 224, .13), rgba(208, 214, 224, .03) 55%, transparent); }
/* Ersatz ohne WebGL: CSS-Kugel, Größe und Glanz nach Pegel */
.ersatz { display: none; position: absolute; left: 50%; top: 50%; width: min(46%, 300px); aspect-ratio: 1; border-radius: 50%; translate: -50% -50%;
background: radial-gradient(circle at 34% 28%, #3a3c40, #15171a 58%, #0b0c0d); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .05), inset -18px -24px 60px rgba(255, 122, 61, calc(.15 + var(--pegel, 0) * .6));
scale: calc(1 + var(--bass, 0) * .12); }
.ohne-webgl .ersatz { display: block; }
.analyse { display: grid; grid-template-columns: minmax(0, 1fr) 264px; gap: 24px; align-items: end; padding: 0 20px 18px; }
.spektrum { display: block; width: 100%; height: 56px; }
.spektrum-achse { display: flex; justify-content: space-between; margin-top: 6px; font-size: 9.5px; color: var(--leise); letter-spacing: .1em; }
.baender { display: grid; gap: 9px; }
.band { display: grid; grid-template-columns: 1fr 34px; gap: 4px 10px; align-items: center; }
.band dt { grid-column: 1 / -1; display: flex; justify-content: space-between; font-size: 10px; }
.band dt span { color: var(--leise); letter-spacing: .06em; }
.band .spur { height: 3px; border-radius: 2px; background: #1e2022; overflow: hidden; }
.band .spur i { display: block; height: 100%; background: var(--text-2); transform-origin: left; transform: scaleX(0); }
.band.b-bass .spur i { background: var(--marke); }
.band dd { font-size: 12px; color: var(--text); text-align: right; }
/* Rechts: Transkript und erfasste Daten */
.rechts { border-left: 1px solid var(--linie); display: grid; grid-template-rows: minmax(0, 1fr) auto; min-width: 0; }
.transkript { padding: 16px 18px 12px; overflow: hidden; display: flex; flex-direction: column; }
.transkript > .mono { margin-bottom: 12px; display: flex; justify-content: space-between; }
.zeilen.ueberlauf { -webkit-mask-image: linear-gradient(transparent, #000 48px); mask-image: linear-gradient(transparent, #000 48px); }
.zeilen { list-style: none; display: flex; flex-direction: column; justify-content: flex-start; gap: 14px; flex: 1; min-height: 0; overflow: hidden; }
.zeilen li { font-size: 14px; line-height: 1.5; letter-spacing: -.006em; color: var(--text-2); animation: rein 200ms var(--ui) both; }
.zeilen li .mono { display: block; font-size: 10px; margin-bottom: 3px; }
.zeilen li[data-wer='ki'] .mono { color: var(--marke); }
.zeilen li[data-wer='system'] { color: var(--meta); font-size: 13px; }
.zeilen li.laeuft::after { content: ''; display: inline-block; width: 7px; height: 14px; margin-left: 3px; vertical-align: -2px; background: var(--meta); opacity: .7; }
@keyframes rein { from { opacity: 0; translate: 0 6px; } }
.erfasst { border-top: 1px solid var(--linie); padding: 14px 18px 16px; }
.erfasst > .mono { margin-bottom: 10px; display: flex; justify-content: space-between; }
.felder { display: grid; gap: 7px; }
.feld { display: grid; grid-template-columns: 104px 1fr; gap: 10px; font-size: 13px; align-items: baseline; }
.feld dt { color: var(--meta); }
.feld dd { color: var(--leise); }
.feld dd.voll { color: var(--text); animation: rein 160ms var(--ui) both; }
.feld dd.voll.gruen { color: var(--ok); }
/* Varianten-Umschalter und Werte unter dem Fenster */
.steuer { display: flex; align-items: center; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; margin-top: 16px; }
.modi { display: flex; gap: 6px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--meta); background: transparent; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: border-color var(--kurz) var(--ui), color var(--kurz) var(--ui); }
.modi button:hover { color: var(--text); border-color: #3a3d41; }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; justify-content: flex-end; font-size: 10.5px; }
.werte b { color: var(--text-2); font-weight: 500; }
/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .nav, .vorschau .hero { display: none; }
.vorschau .app { padding: 16px; max-width: none; }
.vorschau .koerper { height: calc(100vh - 152px); }
.vorschau .steuer { flex-wrap: nowrap; }
.vorschau .werte { flex-wrap: nowrap; overflow: hidden; white-space: nowrap; justify-content: flex-start; min-width: 0; -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent); mask-image: linear-gradient(90deg, #000 85%, transparent); }
.vorschau .steuer { margin-top: 14px; }
.vorschau [data-halt] { display: none; }
@media (max-width: 380px) { .leiste { height: auto; min-height: 46px; flex-wrap: wrap; row-gap: 8px; padding-block: 8px; } }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
:root.reduziert .orb::before { scale: 1; }
@media (max-width: 1180px) {
.koerper { grid-template-columns: minmax(0, 1fr) 296px; }
.liste { display: none; }
}
@media (max-width: 900px) { .nav ul { display: none; } }
@media (max-width: 760px) {
.nav { gap: 12px; height: 56px; }
.nav .knopf.still { display: none; }
.hero { grid-template-columns: 1fr; gap: 18px; padding-top: 28px; padding-bottom: 24px; }
.hinweis { font-size: 12px; }
h1 { font-size: 2.5rem; }
.hero .text p { font-size: 1rem; }
.ctas .mono { margin: 4px 0 0; width: 100%; }
.leiste .pfad .ab-mobil { display: none; }
.leiste .knopf { display: none; }
.koerper { grid-template-columns: 1fr; height: auto; }
.mitte { grid-template-rows: auto 300px auto; }
.status { padding: 14px 14px 0; }
.status .quelle { display: none; }
.analyse { grid-template-columns: 1fr; gap: 16px; padding: 0 14px 16px; }
.rechts { border-left: 0; border-top: 1px solid var(--linie); }
.transkript { padding: 14px; }
.zeilen { height: 212px; flex: none; }
.erfasst { padding: 14px; }
.feld { grid-template-columns: 96px 1fr; }
.steuer { flex-direction: column; align-items: flex-start; }
.werte { justify-content: flex-start; }
}
@media (hover: none) {
/* Touch: kein Hover-Zustand nötig; Ton bleibt aus, bis man den Schalter antippt */
.ton { height: 36px; padding: 0 14px 0 12px; }
.modi button { height: 34px; padding: 0 14px; }
}
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="weite nav">
<a class="marke-wort" href="#" aria-label="Rufwerk Startseite">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="1" width="22" height="22" rx="6" fill="#ff7a3d"/><path d="M7 15.5V8.5M10.33 17.5v-11M13.67 14.5v-5M17 16V8" stroke="#160a04" stroke-width="2" stroke-linecap="round"/></svg>
Rufwerk
</a>
<ul>
<li><a href="#">Produkt</a></li>
<li><a href="#">Branchen</a></li>
<li><a href="#">Preise</a></li>
<li><a href="#">Changelog</a></li>
</ul>
<div class="rechts">
<a class="knopf still" href="#">Anmelden</a>
<a class="knopf primaer" href="#">Kostenlos testen</a>
</div>
</header>
<section class="weite hero">
<a class="hinweis" href="#"><b>Neu</b> Notdienst-Modus für Sonn- und Feiertage</a>
<h1>Kein Anruf geht mehr verloren.</h1>
<div class="text">
<p>Rufwerk nimmt ab, wenn Ihr Team auf der Baustelle steht. Die Telefon-KI <b>versteht das Anliegen</b>, trägt Termine in Ihren Kalender ein und schickt die Zusammenfassung aufs Handy.</p>
<div class="ctas">
<a class="knopf primaer" href="#">Kostenlos testen</a>
<a class="knopf" href="#">Demo vereinbaren</a>
<span class="mono">14 Tage frei · Ihre Nummer bleibt</span>
</div>
</div>
</section>
<section class="weite app" aria-label="Produktansicht: Rufwerk beantwortet einen Anruf">
<div class="fenster">
<div class="leiste">
<div class="pfad"><span class="ab-mobil">Brökelmann Haustechnik</span><i class="ab-mobil">/</i><span>Anrufe</span><i>/</i><b class="live">Live</b></div>
<div class="rechts">
<button class="ton" type="button" aria-pressed="false" data-ton>
<svg class="aus" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 6v4h2.5l3.5 3V3L5 6z"/><path d="M11 6l3 4M14 6l-3 4"/></svg>
<svg class="an" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 6v4h2.5l3.5 3V3L5 6z"/><path d="M11 5.5a3.5 3.5 0 0 1 0 5M12.8 3.6a6 6 0 0 1 0 8.8"/></svg>
<span data-ton-text>Mithören</span>
</button>
<button class="ton" type="button" aria-pressed="false" data-halt>
<svg class="aus" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true"><path d="M5.5 3.5v9M10.5 3.5v9"/></svg>
<svg class="an" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" aria-hidden="true"><path d="M5 3.5v9l7.5-4.5z"/></svg>
<span>Anhalten</span>
</button>
<button class="knopf" type="button" tabindex="-1" aria-disabled="true">Gespräch übernehmen</button>
</div>
</div>
<div class="koerper">
<aside class="liste" aria-label="Anrufe heute">
<p class="mono"><span>So, 01.11.</span><span class="zahl">5 Anrufe</span></p>
<div class="anruf aktiv"><span class="live">0521 4·· ·· 18</span><span class="zahl" data-dauer>00:00</span><small>Notdienst · läuft</small></div>
<div class="anruf"><span>Hausverwaltung Sennestadt</span><span class="etikett gruen">Termin</span><small>Wasserfleck Decke, Whg. 3</small></div>
<div class="anruf"><span>Unbekannt</span><span class="etikett">Rückruf</span><small>Fragt nach Angebot Wärmepumpe</small></div>
<div class="anruf"><span>Fam. Ostermann</span><span class="etikett gruen">Termin</span><small>Therme tropft, Mo 13 Uhr</small></div>
<div class="anruf"><span>Kleinekathöfer GmbH</span><span class="etikett">Info</span><small>Rechnung 2026-114 erhalten</small></div>
<div class="summe mono">
<div>Angenommen<b>100 %</b></div>
<div>Ø Dauer<b>1:48</b></div>
</div>
</aside>
<div class="mitte">
<div class="status">
<span class="sprecher" data-sprecher data-wer="still">Hört zu</span>
<span class="zahl" data-zeit>00:00</span>
<span class="quelle mono">Spektrum <b data-quelle>berechnet</b></span>
</div>
<div class="orb" data-orb>
<div class="ersatz" aria-hidden="true"></div>
<canvas data-canvas aria-hidden="true"></canvas>
</div>
<div class="analyse" aria-hidden="true">
<div>
<canvas class="spektrum" data-spektrum></canvas>
<div class="spektrum-achse mono"><span>50 Hz</span><span>500</span><span>2 k</span><span>8 kHz</span></div>
</div>
<dl class="baender mono">
<div class="band b-bass"><dt>Bass <span>47–281 Hz</span></dt><dd class="spur"><i data-band="0"></i></dd><dd class="zahl" data-band-zahl="0">0</dd></div>
<div class="band"><dt>Mitten <span>0,28–2 kHz</span></dt><dd class="spur"><i data-band="1"></i></dd><dd class="zahl" data-band-zahl="1">0</dd></div>
<div class="band"><dt>Höhen <span>2–8 kHz</span></dt><dd class="spur"><i data-band="2"></i></dd><dd class="zahl" data-band-zahl="2">0</dd></div>
</dl>
</div>
</div>
<aside class="rechts">
<div class="transkript">
<p class="mono"><span data-transkript-titel>Transkript</span><span class="zahl">DE</span></p>
<ol class="zeilen" data-zeilen aria-live="polite"></ol>
</div>
<div class="erfasst">
<p class="mono"><span>Erfasst</span><span data-erfasst-zahl class="zahl">0 / 4</span></p>
<dl class="felder" data-felder></dl>
</div>
</aside>
</div>
</div>
<div class="steuer">
<div class="modi mono" role="group" aria-label="Variante wählen" data-modi>
<button type="button" data-modus="orb" aria-pressed="true">Orb</button>
<button type="button" data-modus="partikel" aria-pressed="false">Partikel</button>
<button type="button" data-modus="musik" aria-pressed="false">Warteschleife</button>
</div>
<p class="werte mono" data-werte></p>
</div>
</section>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const $ = s => document.querySelector(s);
const orbEl = $('[data-orb]'), canvas = $('[data-canvas]'), spekCanvas = $('[data-spektrum]');
const tonKnopf = $('[data-ton]'), tonText = $('[data-ton-text]'), quelleEl = $('[data-quelle]');
const sprecherEl = $('[data-sprecher]'), zeitEl = $('[data-zeit]'), dauerEl = $('[data-dauer]');
const zeilenEl = $('[data-zeilen]'), felderEl = $('[data-felder]'), erfasstZahl = $('[data-erfasst-zahl]'), transTitel = $('[data-transkript-titel]');
const werteEl = $('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-modus]')];
const bandBalken = [...document.querySelectorAll('[data-band]')], bandZahlen = [...document.querySelectorAll('[data-band-zahl]')];
/* ---------- Werte (meta.json → messwerte: fftSize 512–2048, smoothingTimeConstant 0.7–0.85;
Pegel wie Trionn: Mittel über Bins 2 bis frequencyBinCount/2, normiert auf 255) ---------- */
const W = {
fftSize: 1024, // → 512 Bins à 46,9 Hz (48 kHz)
glaettung: 0.8, // AnalyserNode.smoothingTimeConstant
dB: [-100, -30], // min/maxDecibels (Standard), Byte 0…255
baender: [[1, 5], [6, 42], [43, 170]], // Bins: 47–281 Hz / 281–2 000 Hz / 2–8 kHz
boden: { // Byte-Mittel → 0…1 je Band (unten, oben), je Signal kalibriert
stimme: [[90, 200], [90, 200], [60, 190]],
musik: [[185, 245], [60, 170], [40, 180]],
},
pegelBins: [2, 256], // Trionn: 2 … frequencyBinCount / 2
pegelFaktor: 2.4, // Pegel × 2,4, gekappt bei 1
an: 26, ab: 5, // JS-Dämpfung in 1/s: Anstieg ≈ 38 ms, Abfall ≈ 200 ms
ruhe: 0.05, bass: 0.26, mitte: 0.1, hoehe: 0.04, // Verformung: Grundrauschen + Bänder × Gewicht
puls: 0.12, // Warteschleife: Kick skaliert den Orb um bis zu 12 %
musikVerformung: 0.45, // Warteschleife: Bass verformt nur × 0,45, der Kick wirkt über die Skala
drehung: 0.14, // rad/s
bpm: 112,
reduziert: { faktor: 0.25, k: 2 }, // Reduziert: nur Randlicht, Pegel × 0,25, sehr träge
};
const MARKE = [1.0, 0.478, 0.239], NEUTRAL = [0.82, 0.84, 0.88];
const zahlDe = n => String(n).replace('.', ',');
/* ---------- Drehbuch: Notdienst-Anruf bei Brökelmann Haustechnik, Bielefeld ---------- */
const GESPRAECH = {
laenge: 18.2,
saetze: [
{ wer: 'ki', von: 0.5, bis: 4.0, text: 'Brökelmann Haustechnik, Notdienst. Hier spricht der digitale Assistent. Wie kann ich helfen?' },
{ wer: 'anrufer', von: 4.6, bis: 7.6, text: 'Hallo, bei uns ist seit heute Morgen die Heizung komplett aus.' },
{ wer: 'ki', von: 8.1, bis: 12.0, text: 'Das klingt dringend. Morgen früh zwischen acht und zehn Uhr ist ein Monteur frei. Passt das?' },
{ wer: 'anrufer', von: 12.5, bis: 13.6, text: 'Ja, das passt gut.' },
{ wer: 'ki', von: 14.1, bis: 17.2, text: 'Ich habe Sie eingetragen. Die Bestätigung kommt gleich per SMS.' },
],
felder: [
{ name: 'Anliegen', wert: 'Heizung ausgefallen', ab: 7.6 },
{ name: 'Dringlichkeit', wert: 'Hoch · Notdienst', ab: 7.9 },
{ name: 'Termin', wert: 'Mo, 02.11. · 8–10 Uhr', ab: 13.6 },
{ name: 'Bestätigung', wert: 'SMS gesendet', ab: 17.2, gruen: true },
],
};
const WARTESCHLEIFE = {
laenge: 16 * 60 / W.bpm * 2, // 8 Takte = 17,1 s
saetze: [
{ wer: 'system', von: 0.2, text: 'Leitung 1 belegt. Zweiter Anrufer wird gehalten.' },
{ wer: 'system', von: 5.0, text: 'Haltemusik läuft. Rückruf wird nach 30 s angeboten.' },
{ wer: 'system', von: 12.0, text: 'Leitung 1 frei in ca. 10 s.' },
],
felder: [
{ name: 'Warteschlange', wert: 'Position 1', ab: 0.3 },
{ name: 'Musik', wert: 'Rufwerk Loop · 112 BPM', ab: 0.5 },
{ name: 'Wartezeit', wert: '', ab: 0.7, uhr: true },
{ name: 'Rückruf', wert: 'angeboten', ab: 12.0, gruen: true },
],
};
let modus = 'orb';
const drehbuch = () => modus === 'musik' ? WARTESCHLEIFE : GESPRAECH;
const fmt = s => { s = Math.max(0, Math.floor(s)); return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); };
/* ---------- Stimmmodell: Silben, Vokal-Formanten, Grundton, Zischlaute.
Treibt den Synthesizer (Ton an) und das berechnete Spektrum (Ton aus) gleich. ---------- */
const VOKALE = [[800, 1200, 2500], [400, 2000, 2600], [300, 2300, 3000], [450, 800, 2500], [325, 700, 2500], [550, 1700, 2450]];
const hash = x => { const s = Math.sin(x * 12.9898) * 43758.5453; return s - Math.floor(s); };
const glatt = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
function stimme(t) {
const S = GESPRAECH.saetze;
for (let i = 0; i < S.length; i++) {
const s = S[i];
if (t < s.von || t > s.bis) continue;
const lokal = t - s.von, dauer = s.bis - s.von, ki = s.wer === 'ki';
const rate = ki ? 4.9 : 4.3, sx = lokal * rate, si = Math.floor(sx), ph = sx - si;
const h = hash(si * 7.13 + i * 31.7), h2 = hash(si * 3.91 + i * 11.3), hv = hash((si - 1) * 7.13 + i * 31.7);
let env = Math.pow(Math.sin(Math.PI * ph), 0.7) * (0.62 + 0.38 * h2);
if (h2 < 0.15) env *= 0.06; // Wortpause
env *= glatt(0, 0.08, lokal) * glatt(0, 0.14, dauer - lokal);
let rausch = 0;
if (h > 0.6 && ph < 0.3) { rausch = (1 - ph / 0.3) * 0.9; env *= ph / 0.3; } // Konsonant vor dem Vokal
const a = VOKALE[Math.floor(h * 6)], b = VOKALE[Math.floor(hv * 6)], m = glatt(0, 0.35, ph);
const F = [0, 1, 2].map(k => b[k] + (a[k] - b[k]) * m);
const f0 = (ki ? 196 : 116) * (1 + 0.1 * Math.sin(lokal * 2.3 + i) - 0.12 * lokal / dauer + 0.05 * (h - 0.5));
return { wer: s.wer, env, rausch: rausch * glatt(0, 0.1, dauer - lokal), f0, F, satz: i };
}
return { wer: 'still', env: 0, rausch: 0, f0: 150, F: VOKALE[0], satz: -1 };
}
/* Warteschleife: Kick auf jedem Schlag, Hi-Hat in Sechzehnteln (Akzent auf der Offbeat-Achtel), Pad-Akkorde je Takt */
const AKKORDE = [[174.6, 220, 261.6, 329.6], [220, 261.6, 329.6, 392], [146.8, 174.6, 220, 329.6], [116.5, 146.8, 174.6, 220]];
const GRUND = [87.3, 110, 73.4, 58.3];
function musik(t) {
const schlag = 60 / W.bpm, b = t / schlag, tb = (b - Math.floor(b)) * schlag;
const sech = schlag / 4, ts = t % sech, idx = Math.floor(b * 4) % 4;
const takt = Math.floor(b / 4) % 4;
return { kick: Math.exp(-tb / 0.12), fk: 45 + 75 * Math.exp(-tb / 0.03), hat: (idx === 2 ? 1 : 0.35) * Math.exp(-ts / 0.035), takt, pad: 0.85 + 0.15 * Math.sin(t * 0.9) };
}
/* ---------- Berechnetes Spektrum (Ton aus): gleiche Bins, gleiche Byte-Skala wie getByteFrequencyData ---------- */
const BINS = W.fftSize / 2, BIN_HZ = 48000 / W.fftSize;
const bytes = new Uint8Array(BINS), mag = new Float32Array(BINS);
const rauschTab = new Float32Array(BINS).map((_, i) => 0.6 + 0.4 * hash(i * 1.77));
function berechne(t) {
const st = modus === 'musik' ? null : stimme(t), mu = modus === 'musik' ? musik(t) : null;
const [dMin, dMax] = W.dB;
for (let k = 0; k < 180; k++) {
const f = Math.max(k, 0.5) * BIN_HZ;
let a = 1e-5;
if (st && st.env > 0.001) {
const hh = f / st.f0, d = hh - Math.round(hh), kamm = Math.exp(-(d * d) / 0.0072);
let g = 0; const bw = [90, 120, 170], ga = [1, 0.5, 0.28];
for (let j = 0; j < 3; j++) g += ga[j] / (1 + ((f - st.F[j]) / bw[j]) ** 2);
let band = 1;
if (st.wer === 'anrufer') band = (f < 300 ? (f / 300) ** 2 : 1) * (f > 3400 ? (3400 / f) ** 4 : 1);
else band = f > 7000 ? (7000 / f) ** 4 : 1;
a += st.env * kamm * g * band * 2.2 / (1 + f / 900);
a += st.env * 0.004 * band; // Atem
a += st.rausch * 0.22 * band / (1 + ((f - 4200) / 1700) ** 2) * rauschTab[(k * 7 + Math.floor(t * 60)) % BINS];
}
if (mu) {
a += mu.kick * 2.4 / (1 + ((f - mu.fk) / 28) ** 2);
const r = GRUND[mu.takt]; a += 0.12 / (1 + ((f - r) / 12) ** 2);
for (const n of AKKORDE[mu.takt]) for (let h = 1; h <= 6; h++) a += mu.pad * 0.16 / h / (1 + ((f - n * h) / 16) ** 2);
a += mu.hat * 0.32 * (f > 5000 ? 1 : (f / 5000) ** 4) * rauschTab[(k * 5 + Math.floor(t * 60)) % BINS];
}
mag[k] = W.glaettung * mag[k] + (1 - W.glaettung) * a; // wie smoothingTimeConstant
const db = 20 * Math.log10(mag[k]) - 22;
bytes[k] = Math.max(0, Math.min(255, (db - dMin) / (dMax - dMin) * 255));
}
for (let k = 180; k < BINS; k++) bytes[k] = 0;
return st ? st.wer : 'musik';
}
/* ---------- Bänder aus den Bytes, dann im JS dämpfen ---------- */
const band = [0, 0, 0], ziel = [0, 0, 0, 0];
let pegel = 0;
function analysiere(dt) {
W.baender.forEach(([a, b], i) => {
let s = 0; for (let k = a; k <= b; k++) s += bytes[k];
const m = s / (b - a + 1), [lo, hi] = W.boden[modus === 'musik' ? 'musik' : 'stimme'][i];
ziel[i] = Math.min(1, Math.max(0, (m - lo) / (hi - lo)));
});
let s = 0; for (let k = W.pegelBins[0]; k < W.pegelBins[1]; k++) s += bytes[k];
ziel[3] = Math.min(1, s / (W.pegelBins[1] - W.pegelBins[0]) / 255 * W.pegelFaktor);
const red = reduziert();
for (let i = 0; i < 3; i++) {
const k = red ? W.reduziert.k : (ziel[i] > band[i] ? W.an : W.ab);
band[i] += (ziel[i] - band[i]) * (1 - Math.exp(-dt * k));
}
const k = red ? W.reduziert.k : (ziel[3] > pegel ? W.an : W.ab);
pegel += (ziel[3] - pegel) * (1 - Math.exp(-dt * k));
}
/* ---------- Ton: Web Audio, erst nach Klick (Autoplay-Regel) ---------- */
let audio = null, tonAn = false;
function baueAudio() {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
const ac = new AC();
const bus = ac.createGain(), laut = ac.createGain();
const analyser = ac.createAnalyser();
analyser.fftSize = W.fftSize; analyser.smoothingTimeConstant = W.glaettung;
analyser.minDecibels = W.dB[0]; analyser.maxDecibels = W.dB[1];
laut.gain.value = 0;
bus.connect(analyser); bus.connect(laut); laut.connect(ac.destination);
// Stimme: Sägezahn durch drei Formant-Bandpässe, Rauschen für Zischlaute, Bandbegrenzung je Sprecher
const quelle = ac.createOscillator(); quelle.type = 'sawtooth'; quelle.frequency.value = 150;
const huelle = ac.createGain(); huelle.gain.value = 0;
const formanten = [[6, 3.2], [9, 2.2], [12, 1.4]].map(([q, g]) => {
const f = ac.createBiquadFilter(); f.type = 'bandpass'; f.Q.value = q;
const v = ac.createGain(); v.gain.value = g;
quelle.connect(f); f.connect(v); v.connect(huelle); return f;
});
const nb = ac.createBuffer(1, ac.sampleRate * 2, ac.sampleRate), nd = nb.getChannelData(0);
for (let i = 0; i < nd.length; i++) nd[i] = Math.random() * 2 - 1;
const rausch = ac.createBufferSource(); rausch.buffer = nb; rausch.loop = true;
const rf = ac.createBiquadFilter(); rf.type = 'bandpass'; rf.frequency.value = 4200; rf.Q.value = 0.9;
const rg = ac.createGain(); rg.gain.value = 0;
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 80;
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 7000; lp.Q.value = 0.7;
rausch.connect(rf); rf.connect(rg); rg.connect(hp); huelle.connect(hp); hp.connect(lp); lp.connect(bus);
quelle.start(); rausch.start();
// Warteschleife: Ereignisse werden 150 ms im Voraus geplant
const mbus = ac.createGain(); mbus.gain.value = 0.9; mbus.connect(bus);
const padFilter = ac.createBiquadFilter(); padFilter.type = 'lowpass'; padFilter.frequency.value = 1800; padFilter.connect(mbus);
function kick(z) {
const o = ac.createOscillator(), g = ac.createGain();
o.frequency.setValueAtTime(120, z); o.frequency.exponentialRampToValueAtTime(45, z + 0.12);
g.gain.setValueAtTime(0.0001, z); g.gain.exponentialRampToValueAtTime(0.9, z + 0.005); g.gain.exponentialRampToValueAtTime(0.001, z + 0.4);
o.connect(g); g.connect(mbus); o.start(z); o.stop(z + 0.42);
}
function hat(z, v) {
const s = ac.createBufferSource(), f = ac.createBiquadFilter(), g = ac.createGain();
s.buffer = nb; f.type = 'highpass'; f.frequency.value = 5000;
g.gain.setValueAtTime(0.9 * v, z); g.gain.exponentialRampToValueAtTime(0.001, z + 0.06);
s.connect(f); f.connect(g); g.connect(mbus); s.start(z, Math.random()); s.stop(z + 0.07);
}
function takt(z, i, dauer) {
[...AKKORDE[i].map(n => [n, 'triangle', 0.13]), [GRUND[i], 'sine', 0.16]].forEach(([n, typ, v]) => {
const o = ac.createOscillator(), g = ac.createGain();
o.type = typ; o.frequency.value = n;
g.gain.setValueAtTime(0.0001, z); g.gain.linearRampToValueAtTime(v, z + 0.35); g.gain.setValueAtTime(v, z + dauer - 0.3); g.gain.linearRampToValueAtTime(0.0001, z + dauer);
o.connect(g); g.connect(typ === 'sine' ? mbus : padFilter); o.start(z); o.stop(z + dauer + 0.05);
});
}
let geplant = -1; // letzter geplanter Sechzehntel-Index
return {
ac, analyser, laut,
stimme(st) {
const z = ac.currentTime;
quelle.frequency.setTargetAtTime(st.f0, z, 0.015);
formanten.forEach((f, i) => f.frequency.setTargetAtTime(st.F[i], z, 0.025));
huelle.gain.setTargetAtTime(st.env * 0.55, z, 0.012);
rg.gain.setTargetAtTime(st.rausch * 0.5, z, 0.01);
const tel = st.wer === 'anrufer';
lp.frequency.setTargetAtTime(tel ? 3400 : 7000, z, 0.02); hp.frequency.setTargetAtTime(tel ? 300 : 80, z, 0.02);
},
musik(t, an) {
if (!an) { geplant = -1; return; }
const sech = 60 / W.bpm / 4, bis = t + 0.15;
if (geplant < 0) {
geplant = Math.ceil(t / sech) - 1;
const rest = (16 - (geplant + 1) % 16) % 16; // laufenden Takt mit Pad auffüllen
if (rest > 2) takt(ac.currentTime, Math.floor((geplant + 1) / 16) % 4, rest * sech);
}
while ((geplant + 1) * sech < bis) {
geplant++;
const z = ac.currentTime + Math.max(0, geplant * sech - t), i = geplant % 4;
if (i === 0) kick(z);
hat(z, i === 2 ? 1 : 0.35);
if (geplant % 16 === 0) takt(z, Math.floor(geplant / 16) % 4, sech * 16);
}
},
still() { const z = ac.currentTime; huelle.gain.setTargetAtTime(0, z, 0.02); rg.gain.setTargetAtTime(0, z, 0.02); },
neuPlanen() { geplant = -1; },
};
}
async function schalteTon(an) {
if (an && !audio) audio = baueAudio();
if (!audio) { an = false; }
tonAn = an;
tonKnopf.setAttribute('aria-pressed', String(an));
tonText.textContent = an ? 'Ton an' : 'Mithören';
if (audio) {
if (an) { await audio.ac.resume(); audio.neuPlanen(); audio.laut.gain.setTargetAtTime(0.5, audio.ac.currentTime, 0.05); }
else { audio.laut.gain.setTargetAtTime(0, audio.ac.currentTime, 0.03); setTimeout(() => { if (!tonAn) audio.ac.suspend(); }, 200); }
}
zeigeWerte();
}
tonKnopf.addEventListener('click', () => schalteTon(!tonAn));
/* ---------- Werte-Anzeige ---------- */
function zeigeWerte() {
const red = reduziert();
const q = tonAn ? 'analyser live' : 'berechnet';
quelleEl.textContent = tonAn ? 'live' : 'berechnet';
let s = `<span>fftSize <b>${W.fftSize}</b></span><span>smoothing <b>${zahlDe(W.glaettung)}</b></span>`;
if (red) s += `<span>verformung <b>aus</b></span><span>randlicht <b>pegel × ${zahlDe(W.reduziert.faktor)}</b></span>`;
else {
s += `<span>js-dämpfung <b>an ${W.an} · ab ${W.ab} 1/s</b></span>`;
s += modus === 'musik' ? `<span>kick → <b>skala + ${W.puls * 100} %</b></span><span>tempo <b>${W.bpm} bpm</b></span>`
: `<span>verformung <b>${zahlDe(W.ruhe)} + bass × ${zahlDe(W.bass)}</b></span>`;
if (modus === 'partikel') s += `<span>punkte <b>${zahlTrenn(PUNKTE)}</b></span>`;
}
s += `<span>quelle <b>${q}</b></span>`;
werteEl.innerHTML = s;
}
const zahlTrenn = n => n.toLocaleString('de-DE').replace(/\./g, ' ');
/* ---------- Transkript und erfasste Felder ---------- */
let zeilenDom = [], felderDom = [];
function baueListe() {
const D = drehbuch();
zeilenEl.innerHTML = ''; zeilenDom = []; zeilenEl.classList.remove('ueberlauf');
felderEl.innerHTML = '';
felderDom = D.felder.map(f => {
const w = document.createElement('div'); w.className = 'feld';
w.innerHTML = `<dt>${f.name}</dt><dd>–</dd>`; felderEl.appendChild(w);
return w.querySelector('dd');
});
transTitel.textContent = modus === 'musik' ? 'Ereignisse' : 'Transkript';
erfasstZahl.textContent = `0 / ${D.felder.length}`;
}
function aktualisiereText(t) {
const D = drehbuch(), red = reduziert();
let geaendert = false;
D.saetze.forEach((s, i) => {
if (t < s.von) return;
let z = zeilenDom[i];
if (!z) {
const li = document.createElement('li'); li.dataset.wer = s.wer;
li.innerHTML = s.wer === 'system' ? '<span class="mono">System</span><span></span>' : `<span class="mono">${s.wer === 'ki' ? 'Rufwerk' : 'Anrufer'}</span><span></span>`;
zeilenEl.appendChild(li);
z = zeilenDom[i] = { li, span: li.lastChild, woerter: s.text.split(' '), n: -1 };
}
const fertig = !s.bis || red || t >= s.bis;
const n = fertig ? z.woerter.length : Math.max(1, Math.ceil((t - s.von) / (s.bis - s.von) * z.woerter.length));
if (n !== z.n || fertig !== z.fertig) { z.n = n; z.fertig = fertig; z.span.textContent = z.woerter.slice(0, n).join(' '); z.li.classList.toggle('laeuft', !fertig); geaendert = true; }
});
if (geaendert) { // neueste Zeile sichtbar halten, oben weich ausblenden
const ueber = zeilenEl.scrollHeight > zeilenEl.clientHeight + 1;
zeilenEl.classList.toggle('ueberlauf', ueber);
zeilenEl.scrollTop = zeilenEl.scrollHeight;
}
let voll = 0;
D.felder.forEach((f, i) => {
const dd = felderDom[i];
if (t >= f.ab) {
voll++;
const w = f.uhr ? fmt(t + 9) : f.wert;
if (dd.textContent !== w) dd.textContent = w;
if (!dd.classList.contains('voll')) { dd.classList.add('voll'); dd.classList.toggle('gruen', !!f.gruen); }
}
});
const txt = `${voll} / ${D.felder.length}`;
if (erfasstZahl.textContent !== txt) erfasstZahl.textContent = txt;
}
/* ---------- Spektrum-Streifen (2D) ---------- */
const sg = spekCanvas.getContext('2d');
const STREIFEN = 56, spekKanten = Array.from({ length: STREIFEN + 1 }, (_, i) => Math.round(Math.exp(Math.log(1) + (Math.log(171) - Math.log(1)) * i / STREIFEN)));
let spekB = 0, spekH = 0;
function spekLayout() {
const dpr = Math.min(devicePixelRatio, 2);
spekB = spekCanvas.clientWidth; spekH = spekCanvas.clientHeight;
spekCanvas.width = spekB * dpr; spekCanvas.height = spekH * dpr; sg.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function maleSpektrum(wer) {
sg.clearRect(0, 0, spekB, spekH);
const w = spekB / STREIFEN, farbe = wer === 'ki' || wer === 'musik' ? '255, 122, 61' : '208, 214, 224';
for (let i = 0; i < STREIFEN; i++) {
let m = 0, a = spekKanten[i], b = Math.max(a + 1, spekKanten[i + 1]);
for (let k = a; k < b; k++) m = Math.max(m, bytes[k]);
const v = Math.max(0, (m - 70) / 185), h = Math.max(2, v * spekH);
sg.fillStyle = `rgba(${farbe}, ${0.18 + v * 0.8})`;
sg.fillRect(i * w + 1, spekH - h, Math.max(1, w - 2), h);
}
}
/* ---------- 3D: Orb (verformte Kugel) und Partikel-Kugel ---------- */
const GLSL_RAUSCHEN = `
uniform float uZeit, uBass, uMitte, uHoehe, uRed;
float h3(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
float rauschen(vec3 x) {
vec3 i = floor(x), f = fract(x); f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(h3(i), h3(i + vec3(1,0,0)), f.x), mix(h3(i + vec3(0,1,0)), h3(i + vec3(1,1,0)), f.x), f.y),
mix(mix(h3(i + vec3(0,0,1)), h3(i + vec3(1,0,1)), f.x), mix(h3(i + vec3(0,1,1)), h3(i + vec3(1,1,1)), f.x), f.y), f.z);
}
// Grundrauschen + Bass verformt grob, Mitten mittel, Höhen fein
float verschiebung(vec3 n) {
float g = rauschen(n * 1.3 + vec3(0.0, uZeit * 0.4, uZeit * 0.2)) - 0.5;
float m = rauschen(n * 2.6 - vec3(uZeit * 0.55)) - 0.5;
float f = rauschen(n * 5.2 + vec3(uZeit * 1.2, 0.0, 0.0)) - 0.5;
return (g * (${W.ruhe.toFixed(2)} + uBass * ${W.bass.toFixed(2)}) + m * uMitte * ${W.mitte.toFixed(2)} + f * uHoehe * ${W.hoehe.toFixed(2)}) * 2.0 * (1.0 - uRed);
}`;
const PUNKTE = beruehrung || innerWidth <= 760 ? 3200 : 9000;
let gl = null;
try {
const probe = document.createElement('canvas');
if (probe.getContext('webgl2')) gl = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' });
} catch (e) { gl = null; }
if (!gl) html.classList.add('ohne-webgl');
let szene, kamera, orb, punkte, uni;
if (gl) {
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
gl.setClearColor(0x000000, 0);
szene = new THREE.Scene();
kamera = new THREE.PerspectiveCamera(30, 1, 0.1, 30); kamera.position.set(0, 0, 6);
uni = { uZeit: { value: 0 }, uBass: { value: 0 }, uMitte: { value: 0 }, uHoehe: { value: 0 }, uPegel: { value: 0 }, uRed: { value: 0 },
uFarbe: { value: new THREE.Vector3(...MARKE) }, uPx: { value: 1 } };
const seg = beruehrung || innerWidth <= 760 ? [96, 72] : [144, 108];
orb = new THREE.Mesh(new THREE.SphereGeometry(1, seg[0], seg[1]), new THREE.ShaderMaterial({
uniforms: uni,
vertexShader: GLSL_RAUSCHEN + `
varying vec3 vN, vW; varying float vD;
vec3 verformt(vec3 n) { return n * (1.0 + verschiebung(n)); }
void main() {
vec3 n = normalize(position);
vec3 t1 = normalize(cross(n, abs(n.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
vec3 t2 = cross(n, t1);
vec3 p = verformt(n), p1 = verformt(normalize(n + t1 * 0.012)), p2 = verformt(normalize(n + t2 * 0.012));
vN = normalize(normalMatrix * cross(p1 - p, p2 - p));
vD = length(p) - 1.0;
vec4 w = modelMatrix * vec4(p, 1.0); vW = w.xyz;
gl_Position = projectionMatrix * viewMatrix * w;
}`,
fragmentShader: `
uniform vec3 uFarbe; uniform float uMitte, uPegel, uHoehe;
varying vec3 vN, vW; varying float vD;
void main() {
vec3 N = normalize(vN), V = normalize(cameraPosition - vW);
if (dot(N, V) < 0.0) N = -N;
vec3 L = normalize(vec3(-0.55, 0.7, 0.55));
float diff = max(dot(N, L), 0.0), unten = max(dot(N, normalize(vec3(0.5, -0.8, 0.3))), 0.0);
float spec = pow(max(dot(N, normalize(L + V)), 0.0), 120.0);
float fr = pow(1.0 - max(dot(N, V), 0.0), 2.4);
float softbox = smoothstep(0.55, 0.9, dot(reflect(-V, N), normalize(vec3(-0.35, 0.9, 0.25)))); // weiches Streiflicht oben
vec3 c = vec3(0.05, 0.053, 0.06) + diff * vec3(0.12, 0.125, 0.14) + unten * vec3(0.03, 0.035, 0.045) + spec * 0.55 + softbox * 0.07;
c += uFarbe * fr * (0.45 + uMitte * 1.3);
c += uFarbe * smoothstep(0.0, 0.16, vD) * (0.3 + uPegel * 0.8);
gl_FragColor = vec4(c, 1.0);
}`,
}));
szene.add(orb);
// Partikel: Fibonacci-Kugel in zwei Schalen
const pos = new Float32Array(PUNKTE * 3), zuf = new Float32Array(PUNKTE), schale = new Float32Array(PUNKTE);
const gold = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < PUNKTE; i++) {
const y = 1 - (i + 0.5) / PUNKTE * 2, r = Math.sqrt(1 - y * y), th = gold * i;
pos.set([Math.cos(th) * r, y, Math.sin(th) * r], i * 3);
zuf[i] = hash(i * 0.731); schale[i] = hash(i * 1.913) < 0.22 ? 1 : 0;
}
const pg = new THREE.BufferGeometry();
pg.setAttribute('position', new THREE.BufferAttribute(pos, 3));
pg.setAttribute('zuf', new THREE.BufferAttribute(zuf, 1));
pg.setAttribute('schale', new THREE.BufferAttribute(schale, 1));
punkte = new THREE.Points(pg, new THREE.ShaderMaterial({
uniforms: uni,
vertexShader: GLSL_RAUSCHEN + `
attribute float zuf, schale; uniform float uPx, uPegel;
varying float vA, vHell;
void main() {
vec3 n = normalize(position);
float d = verschiebung(n);
float r = schale > 0.5 ? 1.34 + d * 0.6 + uHoehe * 0.12 * (zuf - 0.5) * (1.0 - uRed) : 1.0 + d;
vec4 w = modelMatrix * vec4(n * r, 1.0);
vec4 mv = viewMatrix * w;
gl_Position = projectionMatrix * mv;
gl_PointSize = uPx * (2.0 + zuf * 2.6) * (1.0 + uHoehe * 0.9 * (1.0 - uRed)) * (schale > 0.5 ? 0.8 : 1.0) * 6.0 / -mv.z;
float vorn = smoothstep(-1.3, 1.1, w.z);
vA = (0.18 + 0.6 * vorn) * (schale > 0.5 ? 0.55 : 1.0);
vHell = smoothstep(0.0, 0.22, d) + uPegel * 0.4;
}`,
fragmentShader: `
uniform vec3 uFarbe; varying float vA, vHell;
void main() {
float d = length(gl_PointCoord - 0.5); if (d > 0.5) discard;
vec3 c = mix(vec3(0.78, 0.8, 0.84), uFarbe, clamp(0.35 + vHell, 0.0, 1.0));
gl_FragColor = vec4(c, pow(1.0 - d * 2.0, 1.3) * vA);
}`,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
}));
punkte.frustumCulled = false;
szene.add(punkte);
}
let skala = 1;
function layout() {
spekLayout();
if (!gl) return;
const b = orbEl.clientWidth, h = orbEl.clientHeight;
gl.setSize(b, h, false);
kamera.aspect = b / h; kamera.updateProjectionMatrix();
const halbH = Math.tan(THREE.MathUtils.degToRad(15)) * 6, halbB = halbH * kamera.aspect;
skala = Math.min(halbH, halbB) * 0.6;
uni.uPx.value = gl.getPixelRatio() * Math.max(h, 300) / 420;
}
addEventListener('resize', layout);
/* ---------- Hauptschleife ---------- */
let uhr = 0, letzt = performance.now(), pausiert = false, farbe = [...MARKE], drehY = 0.3, pulsWert = 0, letzteWer = '';
function setzeSprecher(wer) {
if (wer === letzteWer) return; letzteWer = wer;
const T = { ki: 'Rufwerk spricht', anrufer: 'Anrufer spricht', still: 'Hört zu', musik: 'Warteschleife' };
sprecherEl.dataset.wer = wer === 'musik' ? 'ki' : wer; sprecherEl.textContent = T[wer];
orbEl.dataset.wer = wer;
}
function frame(jetzt) {
requestAnimationFrame(frame);
const dt = Math.min((jetzt - letzt) / 1000, 1 / 20); letzt = jetzt;
if (pausiert) return;
const red = reduziert(), D = drehbuch();
uhr += dt;
if (uhr >= D.laenge) { uhr -= D.laenge; amEnde(); }
vorfuehrSchritt();
// 1. Signal: Analyser (Ton an) oder berechnetes Spektrum (Ton aus)
let wer;
if (tonAn && audio && audio.ac.state === 'running') {
if (modus === 'musik') { audio.still(); audio.musik(uhr, true); wer = 'musik'; }
else { const st = stimme(uhr); audio.stimme(st); audio.musik(uhr, false); wer = st.wer; }
audio.analyser.getByteFrequencyData(bytes);
} else wer = berechne(uhr);
// 2. Bänder glätten
analysiere(dt);
setzeSprecher(wer);
// 3. Oberfläche
for (let i = 0; i < 3; i++) { bandBalken[i].style.transform = `scaleX(${band[i].toFixed(3)})`; bandZahlen[i].textContent = Math.round(band[i] * 100); }
orbEl.style.setProperty('--pegel', (red ? pegel * W.reduziert.faktor : pegel).toFixed(3));
orbEl.style.setProperty('--bass', (red ? 0 : band[0]).toFixed(3));
zeitEl.textContent = fmt(uhr); dauerEl.textContent = fmt(uhr + 24);
aktualisiereText(uhr);
maleSpektrum(wer);
// 4. Uniforms
if (!gl) return;
const zielFarbe = wer === 'anrufer' ? NEUTRAL : MARKE;
for (let i = 0; i < 3; i++) farbe[i] += (zielFarbe[i] - farbe[i]) * (1 - Math.exp(-dt * 6));
uni.uFarbe.value.set(...farbe);
uni.uZeit.value += red ? 0 : dt;
uni.uRed.value = red ? 1 : 0;
uni.uBass.value = modus === 'musik' ? band[0] * W.musikVerformung : band[0]; uni.uMitte.value = red ? band[1] * W.reduziert.faktor : band[1]; uni.uHoehe.value = band[2];
uni.uPegel.value = red ? pegel * W.reduziert.faktor : pegel;
pulsWert = modus === 'musik' && !red ? band[0] : 0;
if (!red) drehY += dt * W.drehung;
const obj = modus === 'partikel' ? punkte : orb;
orb.visible = obj === orb; punkte.visible = obj === punkte;
obj.rotation.set(0.18, drehY, 0.05);
obj.scale.setScalar(skala * (1 + pulsWert * W.puls) * (modus === 'partikel' ? 0.92 : 1));
gl.render(szene, kamera);
}
/* ---------- Bedienung, Vorführung, API ---------- */
function setzeModus(m) {
modus = m; uhr = 0;
knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === m)));
if (audio) audio.neuPlanen();
baueListe(); zeigeWerte();
}
knoepfe.forEach(k => k.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(k.dataset.modus); }));
// Vorführung (Schleife): Gespräch als Orb, ab 10,5 s als Partikel, danach die halbe Warteschleife (8,6 s)
const VF = { partikelAb: 10.5, musikBis: WARTESCHLEIFE.laenge / 2 };
let vorfuehrend = false;
function vorfuehrSchritt() {
if (!vorfuehrend) return;
if (modus === 'orb' && uhr >= VF.partikelAb) {
modus = 'partikel'; knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === 'partikel'))); zeigeWerte();
} else if (modus === 'musik' && uhr >= VF.musikBis) setzeModus('orb');
}
function amEnde() { if (vorfuehrend) setzeModus(modus === 'musik' ? 'orb' : 'musik'); else baueListe(); }
function stoppeVorfuehrung() { vorfuehrend = false; html.classList.remove('vorfuehrung'); }
function neustart() { stoppeVorfuehrung(); setzeModus(modus); pausiert = false; letzt = performance.now(); zeigeHalt(); }
function vorfuehren() {
stoppeVorfuehrung();
vorfuehrend = true; html.classList.add('vorfuehrung');
setzeModus('orb');
pausiert = false; letzt = performance.now(); zeigeHalt();
}
function pause() { pausiert = true; if (audio && tonAn) audio.ac.suspend(); zeigeHalt(); }
function weiter() { pausiert = false; letzt = performance.now(); if (audio && tonAn) audio.ac.resume(); zeigeHalt(); }
// Sichtbarer Pause-Knopf (WCAG 2.2.2): dieselbe Pause wie FUNDUS_DEMO.pause()
const haltKnopf = $('[data-halt]');
function zeigeHalt() { haltKnopf.setAttribute('aria-pressed', String(pausiert)); }
haltKnopf.addEventListener('click', () => { if (pausiert) weiter(); else { stoppeVorfuehrung(); pause(); } });
layout(); baueListe(); zeigeWerte();
if (gl) html.classList.add('gl-bereit');
requestAnimationFrame(t => { letzt = t; frame(t); });
new MutationObserver(zeigeWerte).observe(html, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>