TURBOSYNTHETIC PERFORMER v1.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/fb44b520bb91cf94/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>TURBOSYNTHETIC LIVE v1.0</title>
<style>
:root{color-scheme:dark;--bg:#1b1d21;--panel:#23262b;--soft:#30343b;--line:#4d525b;--grid:#33373e;--ring:#3f444c;--ink:#f4f5f7;--mut:#b4b9c2;--dim:#8f959f;--inv:#16181b;--hov:#555b65;--b:#646a74}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink)}
body{font:12px/1.4 -apple-system,"Helvetica Neue",Helvetica,Arial,sans-serif;-webkit-user-select:none;user-select:none}
.wrap{max-width:1480px;margin:0 auto;padding:0 18px 60px}
header{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:11px 0;border-bottom:1px solid var(--line)}
h1{font-size:13px;letter-spacing:.2em;font-weight:700;margin:0;flex:1;min-width:150px}
h1 small{font-weight:400;color:var(--mut);letter-spacing:.06em;margin-left:8px;font-size:11px}
#scope{width:120px;height:26px}
.meta{color:var(--mut)} .meta b{color:var(--ink);font-weight:600;display:inline-block;min-width:14px;text-align:right}
.vol{display:flex;align-items:center;gap:8px;color:var(--mut);width:150px}
button{font:inherit;background:transparent;color:var(--ink);border:1px solid var(--ink);padding:3px 11px;border-radius:999px;cursor:pointer;transition:background .12s,color .12s,border-color .12s;white-space:nowrap}
button:hover{background:var(--ink);color:var(--inv)}
button:disabled{opacity:.3;cursor:default;pointer-events:none}
button.on{background:var(--ink);color:var(--inv)}
button.q{border-color:var(--b);color:#e2e5ea}
button.q:hover{border-color:var(--ink);background:var(--ink);color:var(--inv)}
button.s{padding:1px 9px;font-size:11px}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:16px;background:transparent;margin:0;cursor:pointer}
input[type=range]::-webkit-slider-runnable-track{height:2px;background:var(--b)}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;border-radius:50%;background:var(--ink);margin-top:-4.5px}
input[type=range]::-moz-range-track{height:2px;background:var(--b)}
input[type=range]::-moz-range-thumb{width:11px;height:11px;border:0;border-radius:50%;background:var(--ink)}
input[type=text]{font:inherit;border:0;border-bottom:1px solid var(--b);padding:3px 0;outline:none;background:transparent;color:var(--ink);-webkit-user-select:text;user-select:text;min-width:0}
input[type=text]:focus{border-color:var(--ink)}
input[type=text]::placeholder{color:var(--dim)}

.main{display:grid;grid-template-columns:minmax(0,580px) minmax(0,1fr);gap:20px;padding:14px 0}
@media(max-width:900px){.main{grid-template-columns:1fr}}
h2{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut);font-weight:600;margin:0 0 6px}

/* Radial */
.radbox{display:flex;gap:6px;align-items:stretch}
#radwrap{position:relative;flex:1;min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--panel)}
#rad{width:100%;height:440px;display:block;touch-action:none;cursor:default}
.wcol{width:56px;display:flex;flex-direction:column;align-items:center;gap:6px;padding:8px 0;border:1px solid var(--line);border-radius:10px;background:var(--panel)}
.wcol .lbl{font-size:10px;letter-spacing:.16em;color:var(--mut);font-weight:600}
#wheel{position:relative;flex:1;width:26px;min-height:150px;border:1px solid var(--ink);border-radius:13px;cursor:ns-resize;touch-action:none;overflow:hidden;
  background:repeating-linear-gradient(0deg,#3d424a 0 1px,#1f2226 1px 6px)}
#wfill{position:absolute;left:0;right:0;bottom:0;height:0;background:rgba(255,255,255,.16)}
#wthumb{position:absolute;left:2px;right:2px;height:9px;border-radius:5px;background:var(--ink);bottom:0}
#wval{font-size:11px;font-weight:600;font-variant-numeric:tabular-nums}
#wword{font-size:9px;color:var(--mut);min-height:11px;text-align:center}
.tools{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px}
.tools .sp{flex:1}

/* Panels rechts */
.side{display:flex;flex-direction:column;gap:14px;min-width:0}
.sidetop{display:grid;grid-template-columns:minmax(0,1fr) auto 33%;gap:18px;align-items:start}
@media(max-width:1280px){.sidetop{grid-template-columns:minmax(0,1fr) auto}.libcol{grid-column:1/-1}}
@media(max-width:900px){.sidetop{grid-template-columns:1fr}}
.seedbar{display:flex;gap:6px;align-items:center}
.seedbar #nm{flex:1;font-size:14px;font-weight:600}
.dirty{color:var(--mut);font-size:16px;line-height:1;width:10px}
.code{display:flex;gap:8px;align-items:center;margin-top:8px;color:var(--mut)}
.code input{flex:1;font:10.5px ui-monospace,Menlo,monospace;color:#d3d7dd}
#status{min-height:15px;color:var(--mut);font-size:11px;margin-top:6px}
.dnacol canvas{display:block;width:133px;height:111px}
#cost{width:150px}
.cbar{height:4px;background:var(--grid);border-radius:2px;margin-top:9px}
.cbar i{display:block;height:100%;background:var(--ink);border-radius:2px;width:0;transition:width .2s}
#clabel{font-size:11px;font-weight:600;margin-top:4px}
#cdet{color:var(--mut);font-size:10px;line-height:1.35}
.libtools{display:flex;gap:5px;margin-bottom:6px;flex-wrap:wrap}
#lib{max-height:250px;overflow:auto;border-top:1px solid var(--line)}
.row{display:flex;align-items:center;gap:8px;padding:4px 4px;border-bottom:1px solid var(--line);cursor:pointer}
.row:hover{background:var(--soft)}
.row.on{background:var(--soft);box-shadow:inset 2px 0 0 var(--ink)}
.row canvas{width:44px;height:16px;flex:none}
.row .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
.row .x{color:var(--mut);padding:0 4px;font-size:13px}
.row .x:hover{color:var(--ink)}
.empty{color:var(--mut);padding:10px 4px}

/* Brücke / Kreuzung */
#bridge{border:1px solid var(--ink);border-radius:10px;padding:10px 12px;background:var(--panel)}
#bridge .bh{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
#bridge .bh b{font-size:10px;letter-spacing:.16em;text-transform:uppercase}
#bridge .x{cursor:pointer;color:var(--mut);padding:0 4px;font-size:14px}
.brow{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.brow .pa,.brow .pb{display:flex;gap:5px;align-items:center;width:34%;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.brow .pb{justify-content:flex-end}
.sq{display:inline-block;width:9px;height:9px;flex:none}
.kid{display:flex;align-items:center;gap:8px;padding:3px 4px;cursor:pointer;border-radius:4px}
.kid:hover{background:var(--soft)}
.kid.on{background:var(--soft);box-shadow:inset 2px 0 0 var(--ink)}
.kid canvas{width:198px;height:14px;flex:none}
.kid .s{color:#d3d7dd;font-size:11px}
.leg{color:var(--mut);font-size:10px;margin:6px 0 0}
.hint{color:var(--mut);font-size:11px;margin:6px 0 0}
.ghost{position:fixed;left:0;top:0;z-index:50;pointer-events:none;border:1px solid var(--ink);background:var(--panel);color:var(--ink);border-radius:999px;padding:2px 10px;font-size:11px}

/* Klaviatur */
.kb{display:flex;gap:6px;align-items:center;margin:2px 0 6px}
.kb .sp{flex:1}
#kbd{position:relative;height:84px;border:1px solid var(--b);border-radius:6px;overflow:hidden;touch-action:none;background:#0f1012}
.key{position:absolute;top:0}
.key.w{height:100%;border-right:1px solid #8a909a;background:#e6e8ec}
.key.b{height:60%;background:#14161a;border:1px solid #000;border-top:0;border-radius:0 0 3px 3px;z-index:2}
.key.w.lit{background:#9aa1ac}
.key.b.lit{background:#c9ced6}
.key i{position:absolute;bottom:4px;left:0;right:0;text-align:center;font-style:normal;font-size:9px;color:#5b616b;pointer-events:none}
.key.b i{bottom:3px;color:#a4aab4}
.key.lit i{color:var(--inv)}

/* Panels */
section{margin-top:14px}
details{border:1px solid var(--line);border-radius:8px;background:var(--panel)}
details>summary{list-style:none;cursor:pointer;padding:6px 10px;font-size:10px;letter-spacing:.15em;text-transform:uppercase;display:flex;justify-content:space-between;color:#e2e5ea;font-weight:600}
details>summary::-webkit-details-marker{display:none}
details>summary::after{content:'+';color:var(--mut)}
details[open]>summary::after{content:'–'}
details>.body{padding:2px 10px 10px}
#roll{width:100%;display:block;touch-action:none;border:1px solid var(--b);border-radius:6px;cursor:crosshair}
.rbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:6px 0 8px}
.rbar label{display:flex;align-items:center;gap:8px;color:var(--mut);width:150px}
.rbar .sp{flex:1}
.tools label{display:flex;align-items:center;gap:6px;color:var(--mut);width:128px}
.genes{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px;align-items:start}
.gene{margin-bottom:5px}
.gene .row2{display:flex;justify-content:space-between;color:#e2e5ea}
.gene .gl{cursor:pointer}
.gene .gl:hover{text-decoration:underline}
.gene.lock .gl{font-weight:700}
.gene .gv{color:var(--mut);font-variant-numeric:tabular-nums}

.main.live{grid-template-columns:330px minmax(0,1fr)}
@media(max-width:900px){.main.live{grid-template-columns:1fr}}
.armhint{color:var(--mut);font-size:11px;border:1px solid var(--b);border-radius:999px;padding:2px 10px}
.armhint.ok{display:none}
aside #bank{max-height:520px;overflow:auto;border-top:1px solid var(--line)}
aside .libtools{margin-bottom:8px}
aside #paste{width:100%;margin-bottom:10px;font:11px ui-monospace,Menlo,monospace}
.row .num{width:16px;color:var(--mut);font-size:10px;text-align:right;flex:none}
.row .n input{width:100%;font:inherit;font-weight:500}
.row canvas{width:56px}
#big{display:flex;align-items:baseline;gap:14px;padding:4px 0 16px}
#pnum{font-size:13px;color:var(--mut);letter-spacing:.1em}
#pname{font-size:34px;font-weight:700;letter-spacing:-.01em;line-height:1.1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.stagerow{display:grid;grid-template-columns:auto minmax(0,1fr);gap:34px;align-items:start;margin-bottom:18px}
.dnacol{width:150px}
.wk .wl{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
.wk .wl b{font-size:22px;font-variant-numeric:tabular-nums}
.wk input[type=range]{height:34px}
.wk input[type=range]::-webkit-slider-runnable-track{height:3px}
.wk input[type=range]::-webkit-slider-thumb{width:22px;height:22px;margin-top:-9.5px}
.wk input[type=range]::-moz-range-thumb{width:22px;height:22px}
.wk .ticks{display:flex;justify-content:space-between;color:var(--dim);font-size:10px;margin-top:2px}
.wk p{color:var(--mut);font-size:11px;margin:10px 0 0;max-width:46ch}
.empty b{color:var(--ink)}
</style>
</head>
<body>
<div class="wrap">
<header>
  <h1>TURBOSYNTHETIC<small>LIVE v1.0</small></h1>
  <span id="arm" class="armhint">▶ Taste drücken oder klicken, um den Ton zu aktivieren</span>
  <canvas id="scope" width="240" height="52"></canvas>
  <div class="meta">Stimmen <b id="vc">0</b>/16</div>
  <div class="meta" id="dspbox" hidden>DSP <b id="dsp">0</b>%</div>
  <label class="vol">Pegel <input id="vol" type="range" min="0" max="1" step="0.01" value="0.7"></label>
</header>

<div class="main live">
  <aside>
    <h2>Presets <span id="bn" style="letter-spacing:0"></span></h2>
    <div class="libtools">
      <button class="q s" id="bImport" title="JSON-Datei aus TURBOSYNTHETIC laden (auch per Drag &amp; Drop)">Import</button>
      <button class="q s" id="bExport">Export</button>
      <button class="q s" id="bEditor" title="Presets aus dem Editor übernehmen (gleicher Ort/Browser)" hidden>Aus Editor</button>
      <button class="q s" id="bPanic" title="Alle Töne sofort aus (Esc)">Panic</button>
    </div>
    <input type="text" id="paste" spellcheck="false" placeholder="Code, Link oder JSON einfügen + Enter">
    <div id="bank"></div>
    <div id="status"></div>
  </aside>

  <div class="stage">
    <div id="big"><span id="pnum"></span><span id="pname"></span></div>
    <div class="stagerow">
      <div class="dnacol">
        <h2>DNA-Sequenz</h2>
        <canvas id="dna" width="266" height="222"></canvas>
        <div id="cost"><div class="cbar"><i id="cfill"></i></div><div id="clabel"></div><div id="cdet"></div></div>
      </div>
      <div class="wk">
        <div class="wl"><h2 style="margin:0">Weird</h2><b id="wv">0</b></div>
        <input type="range" id="weird" min="0" max="100" step="1" value="0">
        <div class="ticks"><span>Original</span><span>subtil</span><span>deutlich</span><span>wild</span><span>extrem</span></div>
        <p>Schiebt das gewählte Preset entlang einer festen Zufallsrichtung – von leicht verändert bis völlig verbogen. MIDI-CC1 steuert den Regler.</p>
      </div>
    </div>
    <div class="kb">
      <button class="q" id="oDn">Okt −</button><button class="q" id="oUp">Okt +</button>
      <span class="meta" id="oLb"></span><span class="sp"></span>
      <button class="q" id="midi">MIDI</button>
    </div>
    <div id="kbd"></div>
    <p class="hint" id="khint"></p>
  </div>
</div>
<input type="file" id="fileIn" accept=".json,application/json" hidden>
</div>

<script>
/*!
 * TURBOSYNTHETIC engine v1.0 — polyphoner Web-Audio-Synth (FM + subtraktiv), 16 Stimmen, keine Abhängigkeiten.
 * Spielt die Presets („DNA-Codes“) aus TURBOSYNTHETIC. Eine Datei, ohne Framework; einbinden mit <script src="turbosynthetic_engine.js">.
 *
 *   TurboSynth.load('609f7caf…');            // DNA-Code (66 Hex-Zeichen) oder Array mit 33 Werten 0…1
 *   TurboSynth.noteOn(60, 0.8);              // MIDI-Note, Anschlag 0…1 (startet Audio beim ersten Aufruf – in einer Nutzeraktion!)
 *   TurboSynth.noteOff(60);
 *   TurboSynth.macro(0.5);                   // „Weird“-Regler 0…1 auf dem geladenen Preset
 *   TurboSynth.start({ctx, destination});    // optional: eigenen AudioContext / Zielknoten verwenden
 *   TurboSynth.presetsFromJSON(text)         // Export-Datei von TURBOSYNTHETIC → [{name, code}]
 *   TurboSynth.setVolume(0…1) · .allOff() · .panic() · .cost() · .voiceCount() · .dsp() · .analyser · .ctx · .schema
 * Es gibt genau eine Instanz pro Seite (eine Stimmen-Engine, ein Effektweg).
 */
(function(global){
'use strict';
const clamp=(x,a,b)=>Math.min(b,Math.max(a,x));
let baseG=null,curG=null,macroDir=null,macroAmt=0;
const playG=()=>curG||(curG=GENES.map(()=>0.5));

const WAVES=['Sinus','Dreieck','Säge','Rechteck'], WTYPE=['sine','triangle','sawtooth','square'];
const RATIOS=[0.5,1,1.5,2,2.5,3,3.5,4,5,7];
const FTYPES=['Tiefpass','Bandpass','Hochpass'], FT=['lowpass','bandpass','highpass'];
const pc=v=>Math.round(v*100)+'%', ms=v=>Math.round(v*1000)+' ms';
const f1=(v,u)=>(v<10?v.toFixed(2):v<100?v.toFixed(1):Math.round(v))+u;
const G=(k,l,grp,min,max,curve,fmt,r)=>({k,l,grp,min,max,curve,fmt,r});
const D=(k,l,grp,names,r,vals)=>({k,l,grp,disc:names,vals,r});
const GENES=[
  D('waveA','Welle A','Oszillator',WAVES,[0,1,2,2,3]),
  D('waveB','Welle B','Oszillator',WAVES,[0,1,2,2,3]),
  G('mixB','Mix B','Oszillator',0,1,'lin',pc,[0,0.8]),
  G('detB','Verstimmung B','Oszillator',0.5,40,'exp',v=>f1(v,' ct'),[2,25]),
  G('sub','Sub-Oszillator','Oszillator',0,0.7,'lin',pc,[0,0.4]),
  G('noise','Rauschen','Oszillator',0,0.35,'sq',pc,[0,0.06]),
  G('noiseDec','Rausch-Decay','Oszillator',0.01,0.5,'exp',ms,[0.02,0.2]),

  D('fmRatio','Verhältnis','FM',RATIOS.map(r=>'×'+r),[0,1,2,3,4,5,6,7,8,9],RATIOS),
  G('fmFine','Fein (inharmonisch)','FM',-0.03,0.03,'lin',v=>(v>=0?'+':'')+(v*100).toFixed(1)+'%',[-0.01,0.01]),
  G('fmIdx','Index','FM',0,8,'sq',v=>v.toFixed(2),[0,3]),
  G('fmDec','Decay','FM',0.03,1.5,'exp',ms,[0.08,0.8]),
  G('fmSus','Sustain','FM',0,1,'lin',pc,[0,0.6]),

  D('fType','Typ','Filter',FTYPES,[0,0,0,1,2]),
  G('cutoff','Cutoff','Filter',80,14000,'exp',v=>Math.round(v)+' Hz',[600,8000]),
  G('res','Resonanz','Filter',0.5,14,'exp',v=>v.toFixed(1),[0.7,6]),
  G('fEnv','Hüllkurven-Menge','Filter',0,6,'lin',v=>v.toFixed(1)+' Okt',[0,3.5]),
  G('fKey','Key-Tracking','Filter',0,1,'lin',pc,[0.2,0.9]),
  G('fDec','Hüllkurven-Decay','Filter',0.05,1.5,'exp',ms,[0.1,0.8]),

  G('aA','Attack','Amp-Hüllkurve',0.004,0.8,'exp',ms,[0.004,0.15]),
  G('aD','Decay','Amp-Hüllkurve',0.03,1.5,'exp',ms,[0.1,0.8]),
  G('aS','Sustain','Amp-Hüllkurve',0,1,'lin',pc,[0.2,0.9]),
  G('aR','Release','Amp-Hüllkurve',0.04,0.8,'exp',ms,[0.1,0.5]),

  G('lfoRate','LFO-Rate','Modulation',0.1,12,'exp',v=>v.toFixed(2)+' Hz',[0.3,7]),
  G('lfoPitch','LFO → Pitch','Modulation',0,40,'sq',v=>f1(v,' ct'),[0,12]),
  G('lfoFilt','LFO → Filter','Modulation',0,1200,'sq',v=>Math.round(v)+' ct',[0,400]),
  G('lfoFM','LFO → FM','Modulation',0,1,'sq',pc,[0,0.3]),
  G('pEnv','Pitch-Drop','Modulation',0,12,'sq',v=>v.toFixed(1)+' st',[0,2]),

  G('drive','Drive','Effekte',0,1,'lin',pc,[0,0.3]),
  G('dlMix','Delay Mix','Effekte',0,0.45,'lin',pc,[0,0.25]),
  G('dlTime','Delay Zeit','Effekte',0.06,0.5,'exp',ms,[0.1,0.4]),
  G('dlFb','Delay Feedback','Effekte',0,0.65,'lin',pc,[0.15,0.5]),
  G('rvMix','Hall Mix','Effekte',0,0.45,'lin',pc,[0.05,0.3]),
  G('spread','Stereobreite','Effekte',0,1,'lin',pc,[0.2,0.9])
];
function real(gn,v){
  if(gn.disc){const i=Math.min(gn.disc.length-1,Math.floor(v*gn.disc.length));return gn.vals?gn.vals[i]:i}
  if(gn.curve==='exp')return gn.min*Math.pow(gn.max/gn.min,v);
  if(gn.curve==='sq')return gn.min+(gn.max-gn.min)*v*v;
  return gn.min+(gn.max-gn.min)*v;
}
function inv(gn,r){
  let v;
  if(gn.curve==='exp')v=Math.log(r/gn.min)/Math.log(gn.max/gn.min);
  else if(gn.curve==='sq')v=Math.sqrt(Math.max(0,(r-gn.min)/(gn.max-gn.min)));
  else v=(r-gn.min)/(gn.max-gn.min);
  return clamp(v,0,1);
}
const discIdx=(gn,v)=>Math.min(gn.disc.length-1,Math.floor(v*gn.disc.length));
function computeParams(g){const p={};GENES.forEach((gn,i)=>p[gn.k]=real(gn,g[i]));return p}
const toHex=g=>g.map(v=>Math.round(v*255).toString(16).padStart(2,'0')).join('');
function fromHex(s){
  s=String(s).trim().toLowerCase();
  if(!/^[0-9a-f]+$/.test(s)||s.length!==GENES.length*2)return null;
  const g=[];for(let i=0;i<GENES.length;i++)g.push(parseInt(s.substr(i*2,2),16)/255);
  return g;
}
/* Kennung der Gen-Tabelle: Export-Dateien tragen sie, damit andere Instanzen Versionsabweichungen erkennen */
const SCHEMA_ID=(()=>{let h=5381;const s=GENES.map(g=>g.k).join(',');for(let i=0;i<s.length;i++)h=((h<<5)+h+s.charCodeAt(i))>>>0;return h.toString(36)})();
/* Grobe Lastschätzung: Kosten pro Stimme (Oszillatoren, Filter, Modulation) × Stimmen, die bei dichtem Spiel gleichzeitig
   klingen (hängt an Release/Sustain), plus feste Kosten der Effekte (Hall ist der größte Posten). */
function estimateCost(p){
  let u=1.3;
  if(p.mixB>0.01)u+=1;
  if(p.sub>0.01)u+=0.7;
  if(p.fmIdx>0.02){u+=1.4;if(p.lfoFM*p.fmIdx>0.01)u+=0.3}
  if(p.noise>0.002)u+=0.8;
  u+=2;
  if(p.lfoPitch>0.01)u+=0.6;
  if(p.lfoFilt>0.01)u+=0.3;
  const life=0.35+p.aR*1.9+(p.aS>0.5?0.5:0)+(p.aA>0.3?0.3:0);
  const nv=Math.min(16,Math.max(1,Math.ceil(5*life)));
  let g=0;const fx=[];
  if(p.rvMix>0.01){g+=5;fx.push('Hall')}
  if(p.dlMix>0.01){g+=1.5;fx.push('Delay')}
  if(p.drive>0.05){g+=1.5;fx.push('Drive')}
  const total=u*nv+g,level=total<45?0:total<85?1:total<125?2:3;
  const label=['leicht','mittel','schwer','sehr schwer'][level];
  return {units:u,nv,total,level,label,fill:Math.min(1,total/150),fx,
    text:'≈ '+u.toFixed(1)+' E/Stimme · bis ~'+nv+' Stimmen'+(fx.length?' · '+fx.join(', '):'')+(level>=2?' · kürzeres Release entlastet':'')};
}


const MAXV=16;
let ctx=null,bus,driveIn,driveOut,dlSend,dlFb,dly,rvSend,masterG,analyser,lfo,noiseBuf,P=null;
let vol=0.7,dspLoad=null;
const voices=[];
const softCurve=()=>{const n=4096,c=new Float32Array(n);for(let i=0;i<n;i++){const x=i/(n-1)*2-1;c[i]=x-x*x*x/3}return c};
function makeIR(){
  const sr=ctx.sampleRate,len=Math.floor(sr*1.9),b=ctx.createBuffer(2,len,sr),fade=sr*0.004;
  for(let c=0;c<2;c++){const d=b.getChannelData(c);let lp=0;
    for(let i=0;i<len;i++){const n=(Math.random()*2-1)*Math.pow(1-i/len,2.6);lp+=0.45*(n-lp);d[i]=lp*(i<fade?i/fade:1)}}
  return b;
}
/* opts.ctx / opts.destination: bestehenden AudioContext bzw. Zielknoten verwenden (z. B. in anderen Prototypen) */
function initAudio(opts){
  opts=opts||{};
  if(ctx){if(ctx.state!=='running')ctx.resume();return true}
  const AC=window.AudioContext||window.webkitAudioContext;
  if(!opts.ctx&&!AC)return false;
  ctx=opts.ctx||new AC({latencyHint:'interactive'});
  const sr=ctx.sampleRate;
  noiseBuf=ctx.createBuffer(1,sr*2,sr);
  {const d=noiseBuf.getChannelData(0);for(let i=0;i<d.length;i++)d[i]=Math.random()*2-1}
  bus=ctx.createGain();
  driveIn=ctx.createGain();driveOut=ctx.createGain();
  const sh=ctx.createWaveShaper();sh.curve=softCurve();sh.oversample='2x';
  bus.connect(driveIn);driveIn.connect(sh);sh.connect(driveOut);
  const mixer=ctx.createGain();driveOut.connect(mixer);
  dly=ctx.createDelay(1);dlSend=ctx.createGain();dlFb=ctx.createGain();
  const dlLp=ctx.createBiquadFilter();dlLp.type='lowpass';dlLp.frequency.value=3800;
  driveOut.connect(dlSend);dlSend.connect(dly);dly.connect(dlLp);dlLp.connect(dlFb);dlFb.connect(dly);dlLp.connect(mixer);
  rvSend=ctx.createGain();const conv=ctx.createConvolver();conv.buffer=makeIR();
  driveOut.connect(rvSend);rvSend.connect(conv);conv.connect(mixer);
  const hp=ctx.createBiquadFilter();hp.type='highpass';hp.frequency.value=22;hp.Q.value=0.5;
  masterG=ctx.createGain();masterG.gain.value=vol*vol;
  const comp=ctx.createDynamicsCompressor();
  comp.threshold.value=-12;comp.knee.value=10;comp.ratio.value=6;comp.attack.value=0.004;comp.release.value=0.15;
  const safe=ctx.createWaveShaper();safe.curve=softCurve();
  mixer.connect(hp);hp.connect(masterG);masterG.connect(comp);comp.connect(safe);safe.connect(opts.destination||ctx.destination);
  analyser=ctx.createAnalyser();analyser.fftSize=1024;safe.connect(analyser);
  lfo=ctx.createOscillator();lfo.frequency.value=2;lfo.start();
  try{if(ctx.renderCapacity){ctx.renderCapacity.addEventListener('update',e=>{dspLoad=e.averageLoad});ctx.renderCapacity.start({updateInterval:0.5})}}catch(_){}
  applyDNA(true);
  if(ctx.state!=='running')ctx.resume();
  return true;
}
function applyDNA(imm){
  P=computeParams(playG());
  if(!ctx)return;
  const t=ctx.currentTime,tc=imm?0.001:0.05;
  lfo.frequency.setTargetAtTime(P.lfoRate,t,tc*2);
  driveIn.gain.setTargetAtTime(1+P.drive*5,t,tc);
  driveOut.gain.setTargetAtTime(1/(1+P.drive*1.5),t,tc);
  dlSend.gain.setTargetAtTime(P.dlMix,t,tc);
  dlFb.gain.setTargetAtTime(P.dlFb,t,tc);
  dly.delayTime.setTargetAtTime(P.dlTime,t,0.12);
  rvSend.gain.setTargetAtTime(P.rvMix,t,tc);
}
function holdParam(p,at){
  if(p.cancelAndHoldAtTime){p.cancelAndHoldAtTime(at);return}
  p.cancelScheduledValues(at);
  if(at<=ctx.currentTime+0.03)p.setValueAtTime(p.value,at);
}
/* Klick-Schutz: Amp startet bei 0, Attack ≥ 4 ms; Release = exponentielle Annäherung, gestoppt erst nach ~1,9 Zeitkonstanten
   über dem Release (< −80 dB), startend vom aktuellen Hüllkurvenwert; Voice-Stealing blendet in ~12 ms aus;
   Release per Gen auf 0,8 s begrenzt; Master: Highpass → Kompressor → Soft-Clip.
   Sparsam: Oszillator B, Sub, FM-Modulator und Rauschen werden nur erzeugt, wenn ihr Gen sie hörbar macht. */
function startVoice(note,vel,t){
  const p=P,f=440*Math.pow(2,(note-69)/12);
  const act=voices.filter(v=>!v.dead&&v.endT>t);
  if(act.length>=MAXV){
    const rel=act.filter(v=>v.released).sort((a,b)=>a.endT-b.endT);
    (rel.length?rel[0]:act.sort((a,b)=>a.t0-b.t0)[0]).kill(t);
  }
  const nodes=[],lc=[];
  const mk=n=>(nodes.push(n),n);
  const osc=(type,fr)=>{const o=mk(ctx.createOscillator());o.type=type;o.frequency.value=fr;return o};
  const gn=val=>{const g=mk(ctx.createGain());g.gain.value=val;return g};
  const useB=p.mixB>0.01,useS=p.sub>0.01,useM=p.fmIdx>0.02,useN=p.noise>0.002;
  const oA=osc(WTYPE[p.waveA],f);
  const oB=useB?osc(WTYPE[p.waveB],f):null;
  const oS=useS?osc('sine',f/2):null;
  const oM=useM?osc('sine',f*p.fmRatio*(1+p.fmFine)):null;
  if(oB)oB.detune.value=p.detB;
  const gA=gn(1-0.5*p.mixB),amp=gn(0);
  const filt=mk(ctx.createBiquadFilter());filt.type=FT[p.fType];filt.Q.value=p.fType===1?0.5+p.res*0.5:p.res;
  const pan=mk(ctx.createStereoPanner());pan.pan.value=(Math.random()*2-1)*p.spread*0.7;
  oA.connect(gA);gA.connect(filt);
  if(useB){const g=gn(p.mixB);oB.connect(g);g.connect(filt)}
  if(useS){const g=gn(p.sub);oS.connect(g);g.connect(filt)}
  let nz=null,gNz=null,modG=null,idxPk=0;
  if(useN){nz=mk(ctx.createBufferSource());nz.buffer=noiseBuf;nz.loop=true;gNz=gn(0);nz.connect(gNz);gNz.connect(filt)}
  filt.connect(amp);amp.connect(pan);pan.connect(bus);
  if(useM){
    modG=gn(0);oM.connect(modG);modG.connect(oA.frequency);if(oB)modG.connect(oB.frequency);
    idxPk=p.fmIdx*oM.frequency.value*(0.4+0.6*vel)/(1+f/1500);
    modG.gain.setValueAtTime(idxPk,t);
    modG.gain.setTargetAtTime(idxPk*p.fmSus,t,p.fmDec/3);
  }
  const oscs=[oA,oB,oS,oM].filter(Boolean);
  const lf=(dests,depth)=>{if(depth<=0.01)return;const g=gn(depth);lfo.connect(g);lc.push(g);dests.forEach(d=>g.connect(d))};
  lf(oscs.map(o=>o.detune),p.lfoPitch);
  lf([filt.detune],p.lfoFilt);
  if(modG)lf([modG.gain],idxPk*p.lfoFM);
  if(p.pEnv>0.05)[[oA,0],[oB,p.detB],[oS,0],[oM,0]].filter(x=>x[0]).forEach(([o,b])=>{o.detune.setValueAtTime(b+p.pEnv*100,t);o.detune.setTargetAtTime(b,t,0.035)});
  const base=clamp(p.cutoff*Math.pow(2,(note-60)/12*p.fKey),30,16000);
  const envA=p.fEnv*(0.4+0.6*vel);
  filt.frequency.setValueAtTime(base,t);
  if(envA>0.05){
    const pk=Math.min(16000,base*Math.pow(2,envA)),sus=Math.min(16000,base*Math.pow(2,envA*p.aS*0.5));
    filt.frequency.setTargetAtTime(pk,t,Math.max(0.002,p.aA/3));
    filt.frequency.setTargetAtTime(sus,t+p.aA,p.fDec/3);
  }
  const pk=0.15*Math.pow(vel,1.3);
  amp.gain.setValueAtTime(0,t);
  amp.gain.linearRampToValueAtTime(pk,t+p.aA);
  amp.gain.setTargetAtTime(pk*p.aS,t+p.aA,p.aD/3);
  if(gNz){gNz.gain.setValueAtTime(p.noise,t);gNz.gain.setTargetAtTime(0,t,p.noiseDec/3)}
  oscs.forEach(o=>o.start(t));if(nz)nz.start(t,Math.random()*1.5);
  const v={t0:t,note,released:false,dead:false,endT:Infinity};
  const stopAll=e=>oscs.concat(nz?[nz]:[]).forEach(o=>{try{o.stop(e)}catch(_){}});
  v.release=at=>{
    if(v.released||v.dead)return;v.released=true;
    at=Math.max(at,t+0.012);
    holdParam(amp.gain,at);
    amp.gain.setTargetAtTime(0,at,p.aR/5);
    v.endT=at+p.aR*1.9+0.03;stopAll(v.endT);
  };
  v.kill=at=>{
    if(v.dead)return;
    at=Math.max(at,t+0.002);
    holdParam(amp.gain,at);
    amp.gain.setTargetAtTime(0,at,0.004);
    v.released=true;v.endT=at+0.05;stopAll(v.endT);
  };
  oA.onended=()=>{
    v.dead=true;
    nodes.forEach(n=>{try{n.disconnect()}catch(_){}});
    lc.forEach(g=>{try{lfo.disconnect(g)}catch(_){}});
    const i=voices.indexOf(v);if(i>=0)voices.splice(i,1);
  };
  voices.push(v);
  return v;
}

/* Weird-Makro: jedes Preset hat eine feste (vom Code abgeleitete) Zufallsrichtung; der Regler schiebt die Gene entlang dieser Richtung. */
function rng(str){let h=1779033703^str.length;for(let i=0;i<str.length;i++){h=Math.imul(h^str.charCodeAt(i),3432918353);h=h<<13|h>>>19}
  return function(){h=Math.imul(h^h>>>16,2246822507);h=Math.imul(h^h>>>13,3266489909);h^=h>>>16;return (h>>>0)/4294967296}}
function dirFor(code){const r=rng(code);return GENES.map(()=>clamp((r()+r()+r()-1.5)/0.5*0.65,-1,1))}
function macroG(g,a,dir){
  if(a<0.004)return g;
  const k=0.9*Math.pow(a,1.3);
  return g.map((v,i)=>{
    const gn=GENES[i];
    if(gn.disc){const N=gn.disc.length,n=Math.abs(dir[i]*a)>0.3?Math.round(dir[i]*a*N*0.7):0;return n?((((discIdx(gn,v)+n)%N)+N)%N+0.5)/N:v}
    return clamp(v+dir[i]*k,0,1);
  });
}
function recompute(){curG=macroG(baseG,macroAmt,macroDir);applyDNA(false)}
const held=new Map();
const api={
  schema:SCHEMA_ID,
  genes:GENES.map(g=>g.k),
  groups:(()=>{const o=[];GENES.forEach(g=>{const l=o[o.length-1];if(l&&l.n===g.grp)l.c++;else o.push({n:g.grp,c:1})});return o.map(x=>x.c)})(),
  toHex,fromHex,
  start(opts){return initAudio(opts)},
  load(code,amt){
    const g=Array.isArray(code)?code.slice():fromHex(code);
    if(!g||g.length!==GENES.length)return false;
    baseG=g;macroDir=dirFor(toHex(g));macroAmt=clamp(+amt||0,0,1);recompute();return true;
  },
  macro(a){macroAmt=clamp(+a||0,0,1);if(baseG)recompute()},
  current(){return playG().slice()},
  noteOn(note,vel,when){
    if(!initAudio())return null;
    const t=Math.max(ctx.currentTime+0.003,when||0),old=held.get(note);
    if(old)old.release(t);
    const v=startVoice(note,vel==null?0.8:vel,t);held.set(note,v);return v;
  },
  noteOff(note,when){const v=held.get(note);if(v){held.delete(note);v.release(Math.max(ctx.currentTime+0.003,when||0))}},
  allOff(){[...held.keys()].forEach(n=>api.noteOff(n))},
  panic(){api.allOff();if(ctx)voices.slice().forEach(v=>v.kill(ctx.currentTime))},
  setVolume(v){vol=clamp(+v||0,0,1);if(masterG)masterG.gain.setTargetAtTime(vol*vol,ctx.currentTime,0.03)},
  cost(){return estimateCost(computeParams(playG()))},
  voiceCount(){if(!ctx)return 0;const n=ctx.currentTime;return voices.filter(v=>!v.dead&&v.t0<=n&&v.endT>n).length},
  dsp(){return dspLoad},
  presetsFromJSON(txt){
    let d;try{d=JSON.parse(txt)}catch(_){return []}
    const l=Array.isArray(d)?d:(d&&Array.isArray(d.presets)?d.presets:[]);
    return l.filter(p=>p&&fromHex(String(p.code||''))).map(p=>({name:String(p.name||'Preset'),code:String(p.code).trim().toLowerCase()}));
  },
  get ctx(){return ctx},
  get analyser(){return analyser}
};
global.TurboSynth=api;
})(typeof window!=='undefined'?window:globalThis);

</script>
<script>
'use strict';
const $=s=>document.querySelector(s);
const clamp=(x,a,b)=>Math.min(b,Math.max(a,x));
const TS=TurboSynth,fromHex=TS.fromHex,toHex=TS.toHex;
const _cs=getComputedStyle(document.documentElement),css=n=>_cs.getPropertyValue(n).trim();
const TH={ink:css('--ink'),panel:css('--panel')};
const FACTORY=[{"name":"Keys","code":"20203ea10556aa597972ce3b2be3351588ac07c6336fb2256e13490f2fc354a875"},{"name":"Pad","code":"9f9fc0ce0a0765f27e2fbb7480974e27b049ccc7ccc53413815318287a819f78f9"},{"name":"Pluck","code":"9fdf4c9386403d5998831c0a2ba881a8ca2c157c057074021756520a4a7e915c8e"},{"name":"Lead","code":"df9fa78d76484e598d65954f80d2a9295d660da4e29bcda01904516271b69754bf"},{"name":"Bass","code":"9fdf0a91e85581f29920665980618b604b8018adb86952221721793603d97a1a4e"},{"name":"Bell","code":"206016790d3247d963d1b4142be91d1d65c801d918cfbb046f5118064c6871e891"},{"name":"Blips","code":"202047ad03470cbf5c750d072bf344117b520b0c021e916247351a054255995d66"},{"name":"Perc","code":"df9f58521fc69ed956cb35042b7f22a6c5b40534000a95851b8bc065778f3f833b"},{"name":"Drone","code":"9fdfdbd18c223240a079e28f2b486d0d489bf3f3fef62d3bc9411e299f3eb6b2be"}];
const LS_BANK='turbosynthetic.live.bank.v1',LS_ED='turbosynthetic.lib.v1';
let bank=[],cur=-1,weird=0;
const newId=()=>'l'+Date.now().toString(36)+Math.floor(Math.random()*1e5).toString(36);
const lsGet=k=>{try{return localStorage.getItem(k)}catch(_){return null}};
const lsSet=(k,v)=>{try{localStorage.setItem(k,v);return true}catch(_){return false}};
let statusT=0;
function status(t){const el=$('#status');el.textContent=t;clearTimeout(statusT);statusT=setTimeout(()=>{el.textContent=''},4200)}

/* ── Bank ── */
function loadBank(){
  try{
    const d=JSON.parse(lsGet(LS_BANK)||'null');
    if(d&&Array.isArray(d.presets)){bank=d.presets.filter(p=>fromHex(p.code));cur=bank.length?clamp(d.cur|0,0,bank.length-1):-1;return}
  }catch(_){}
  bank=FACTORY.map(p=>({id:newId(),name:p.name,code:p.code}));cur=bank.length?0:-1;
}
const saveBank=()=>lsSet(LS_BANK,JSON.stringify({presets:bank,cur}));
function drawBars(cv,g){
  const c=cv.getContext('2d'),n=g.length,w=cv.width/n,h=cv.height;
  c.clearRect(0,0,cv.width,h);
  g.forEach((v,i)=>{const bh=Math.max(3,v*h);c.fillStyle=TH.ink;c.fillRect(i*w+0.5,h-bh,w-1,bh)});
}
function renderBank(){
  const host=$('#bank');host.innerHTML='';
  $('#bn').textContent=bank.length?'('+bank.length+')':'';
  if(!bank.length){host.innerHTML='<div class="empty"><b>Noch keine Presets.</b><br>Import (JSON aus TURBOSYNTHETIC), Code/Link einfügen oder „Aus Editor“.</div>';return}
  bank.forEach((p,i)=>{
    const row=document.createElement('div');row.className='row'+(i===cur?' on':'');
    const num=document.createElement('span');num.className='num';num.textContent=i<9?i+1:i===9?0:'';
    const cv=document.createElement('canvas');cv.width=112;cv.height=32;drawBars(cv,fromHex(p.code));
    const n=document.createElement('span');n.className='n';n.textContent=p.name;
    n.ondblclick=e=>{
      e.stopPropagation();
      const inp=document.createElement('input');inp.type='text';inp.value=p.name;n.textContent='';n.append(inp);inp.focus();inp.select();
      const done=ok=>{if(ok&&inp.value.trim()){p.name=inp.value.trim().slice(0,60);saveBank()}renderBank();renderStage()};
      inp.onkeydown=ev=>{if(ev.key==='Enter')done(true);if(ev.key==='Escape')done(false);ev.stopPropagation()};
      inp.onblur=()=>done(true);inp.onclick=ev=>ev.stopPropagation();
    };
    const x=document.createElement('span');x.className='x';x.textContent='✕';x.title='Entfernen';
    x.onclick=e=>{e.stopPropagation();bank.splice(i,1);if(cur>=bank.length)cur=bank.length-1;saveBank();if(cur>=0)select(cur);else{renderBank();renderStage()}};
    row.append(num,cv,n,x);row.onclick=()=>{if(i!==cur)select(i)};host.append(row);
  });
}
function select(i){
  if(!bank[i])return;
  cur=i;TS.load(bank[i].code,weird/100);
  renderBank();renderStage();saveBank();
  const r=$('#bank .row.on');if(r&&r.scrollIntoView)r.scrollIntoView({block:'nearest'});
}
function renderStage(){
  const p=bank[cur];
  $('#pnum').textContent=p?String(cur+1).padStart(2,'0'):'';
  $('#pname').textContent=p?p.name:'—';
  dnaKey='';
}
function addPreset(name,code,sel){
  code=String(code).trim().toLowerCase();
  if(!fromHex(code))return -1;
  let i=bank.findIndex(q=>q.code===code&&q.name===name);
  if(i<0){bank.push({id:newId(),name:String(name).slice(0,60),code});i=bank.length-1}
  if(sel)select(i);else{renderBank();saveBank()}
  return i;
}

/* ── Import / Export / Einfügen / Editor ── */
function importText(txt){
  txt=String(txt).trim();
  if(!txt)return;
  let n=0;
  const hash=/#seed=([0-9a-fA-F]+)(?:~([^\s]*))?/.exec(txt);
  if(txt[0]==='{'||txt[0]==='['){
    const l=TS.presetsFromJSON(txt);
    if(!l.length){status('Keine gültigen Presets gefunden');return}
    let d;try{d=JSON.parse(txt)}catch(_){}
    const before=bank.length;l.forEach(p=>{if(!bank.some(q=>q.code===p.code&&q.name===p.name)){bank.push({id:newId(),name:p.name,code:p.code})}});
    n=bank.length-before;if(cur<0&&bank.length)cur=0;
    saveBank();if(cur>=0)select(cur);else renderBank();
    status(n+' importiert'+(l.length-n?' · '+(l.length-n)+' schon vorhanden':'')+(d&&d.schema&&d.schema!==TS.schema?' · Achtung: andere Gen-Version':''));
  }else if(hash||fromHex(txt)){
    const code=hash?hash[1]:txt;let nm='Einzelsound';
    if(hash&&hash[2]){try{nm=decodeURIComponent(hash[2])||nm}catch(_){}}
    if(addPreset(nm,code,true)>=0)status('„'+nm+'“ hinzugefügt');else status('Ungültiger Code');
  }else status('Weder Code, Link noch JSON erkannt');
}
$('#paste').addEventListener('keydown',e=>{if(e.key==='Enter'){importText(e.target.value);e.target.value=''}e.stopPropagation()});
$('#paste').addEventListener('paste',e=>{const t=(e.clipboardData||window.clipboardData).getData('text');if(t&&(t.length>40)){e.preventDefault();importText(t)}});
$('#bImport').onclick=()=>$('#fileIn').click();
$('#fileIn').addEventListener('change',e=>{const f=e.target.files[0];e.target.value='';if(f)f.text().then(importText)});
addEventListener('dragover',e=>{if(e.dataTransfer&&[...e.dataTransfer.types].includes('Files'))e.preventDefault()});
addEventListener('drop',e=>{const f=e.dataTransfer&&e.dataTransfer.files[0];if(!f)return;e.preventDefault();f.text().then(importText)});
$('#bExport').onclick=()=>{
  if(!bank.length){status('Nichts zu exportieren');return}
  const data={format:'turbosynthetic-presets',version:1,app:'TURBOSYNTHETIC LIVE v1.0',schema:TS.schema,genes:TS.genes,presets:bank.map(p=>({name:p.name,code:p.code}))};
  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,1)],{type:'application/json'}));
  a.download='turbosynthetic-live-bank-'+new Date().toISOString().slice(0,10)+'.json';document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(a.href),3000);
  status(bank.length+' Presets exportiert');
};
function editorLib(){try{const d=JSON.parse(lsGet(LS_ED)||'[]');return Array.isArray(d)?d.filter(p=>p&&fromHex(String(p.code||''))):[]}catch(_){return []}}
function checkEditor(){const l=editorLib(),fresh=l.filter(p=>!bank.some(q=>q.code===String(p.code).toLowerCase()&&q.name===p.name));
  $('#bEditor').hidden=!l.length;$('#bEditor').textContent=fresh.length?'Aus Editor (+'+fresh.length+')':'Aus Editor';return fresh}
$('#bEditor').onclick=()=>{
  const fresh=checkEditor();if(!fresh.length){status('Editor-Bibliothek ist bereits vollständig übernommen');return}
  fresh.slice().reverse().forEach(p=>bank.push({id:newId(),name:String(p.name||'Preset'),code:String(p.code).toLowerCase()}));
  if(cur<0)cur=0;saveBank();select(cur);checkEditor();status(fresh.length+' aus dem Editor übernommen');
};
addEventListener('storage',e=>{if(e.key===LS_ED){const f=checkEditor();if(f.length)status('Editor hat '+f.length+' neue Presets – „Aus Editor“ übernimmt sie')}});

/* ── Weird, Pegel, Panic ── */
function setWeird(v,fromUI){
  weird=clamp(Math.round(v),0,100);TS.macro(weird/100);$('#wv').textContent=weird;if(!fromUI)$('#weird').value=weird;dnaKey='';
}
$('#weird').addEventListener('input',e=>setWeird(+e.target.value,true));
$('#vol').addEventListener('input',e=>TS.setVolume(+e.target.value));
function panic(){TS.panic();held.clear();sustained.clear();refreshLit()}
$('#bPanic').onclick=panic;

/* ── Spielen: Tastatur, Maus/Touch, MIDI, Haltepedal ── */
const held=new Map(),sustained=new Set();let sustain=false;
let oct=3;const baseNote=()=>12*(oct+1);
const KM={KeyA:0,KeyW:1,KeyS:2,KeyE:3,KeyD:4,KeyF:5,KeyT:6,KeyG:7,KeyY:8,KeyH:9,KeyU:10,KeyJ:11,KeyK:12,KeyO:13,KeyL:14,KeyP:15,Semicolon:16};
const KOFF=Object.fromEntries(Object.entries(KM).map(([c,o])=>[o,c]));
const KLAB={};Object.keys(KM).forEach(c=>KLAB[c]=c==='Semicolon'?';':c.slice(3));KLAB.KeyZ='Z';KLAB.KeyX='X';
const kbd=$('#kbd');
function refreshLit(){
  const s=new Set([...held.values(),...[...sustained].map(k=>+k.split(':')[1])]);
  document.querySelectorAll('.key').forEach(k=>k.classList.toggle('lit',s.has(+k.dataset.n)));
}
function buildKeys(){
  kbd.innerHTML='';
  const N=37,base=baseNote(),isB=n=>[1,3,6,8,10].includes(n%12);
  let whites=0;for(let i=0;i<N;i++)if(!isB(base+i))whites++;
  const ww=100/whites;let wi=0;
  for(let i=0;i<N;i++){
    const n=base+i,el=document.createElement('div');el.dataset.n=n;
    if(isB(n)){el.className='key b';el.style.width=ww*0.6+'%';el.style.left=(wi*ww-ww*0.3)+'%'}
    else{el.className='key w';el.style.width=ww+'%';el.style.left=wi*ww+'%';wi++}
    const lab=KOFF[i];
    el.innerHTML='<i>'+(lab?KLAB[lab]:(n%12===0?'C'+(Math.floor(n/12)-1):''))+'</i>';
    kbd.append(el);
  }
  $('#oLb').textContent='C'+oct+' – C'+(oct+3);refreshLit();
}
function setOct(o){oct=clamp(o,1,5);buildKeys()}
$('#oDn').onclick=()=>setOct(oct-1);$('#oUp').onclick=()=>setOct(oct+1);
function noteOn(src,note,vel){
  const key=src+':'+note;sustained.delete(key);
  if(held.has(key))return;
  held.set(key,note);TS.noteOn(note,vel==null?0.8:vel);refreshLit();
}
function noteOff(src,note){
  const key=src+':'+note;if(!held.has(key))return;
  held.delete(key);
  if(sustain){sustained.add(key)}else TS.noteOff(note);
  refreshLit();
}
function setSustain(on){
  sustain=on;
  if(!on){[...sustained].forEach(k=>{const n=+k.split(':')[1];sustained.delete(k);if(![...held.values()].includes(n))TS.noteOff(n)});refreshLit()}
}
const ptr=new Map();
const keyAt=e=>{const el=document.elementFromPoint(e.clientX,e.clientY);return el&&el.closest?el.closest('.key'):null};
const velAt=(e,k)=>{const r=k.getBoundingClientRect();return clamp(0.35+0.65*((e.clientY-r.top)/r.height),0.3,1)};
kbd.addEventListener('pointerdown',e=>{
  const k=keyAt(e);if(!k)return;
  try{kbd.setPointerCapture(e.pointerId)}catch(_){}
  const n=+k.dataset.n;ptr.set(e.pointerId,n);noteOn('p'+e.pointerId,n,velAt(e,k));
});
kbd.addEventListener('pointermove',e=>{
  if(!ptr.has(e.pointerId))return;
  const k=keyAt(e);if(!k)return;
  const n=+k.dataset.n,old=ptr.get(e.pointerId);
  if(n!==old){noteOff('p'+e.pointerId,old);ptr.set(e.pointerId,n);noteOn('p'+e.pointerId,n,velAt(e,k))}
});
const pend=e=>{if(!ptr.has(e.pointerId))return;noteOff('p'+e.pointerId,ptr.get(e.pointerId));ptr.delete(e.pointerId)};
kbd.addEventListener('pointerup',pend);kbd.addEventListener('pointercancel',pend);
const down=new Map();
addEventListener('keydown',e=>{
  if(e.repeat||e.ctrlKey||e.metaKey||e.altKey)return;
  const tg=e.target;if(tg&&tg.tagName==='INPUT'&&tg.type==='text')return;
  TS.start();
  if(e.code==='Escape'){panic();return}
  if(e.code==='KeyZ'){setOct(oct-1);return}
  if(e.code==='KeyX'){setOct(oct+1);return}
  if(/^Digit[0-9]$/.test(e.code)){const i=e.code==='Digit0'?9:+e.code.slice(5)-1;if(bank[i])select(i);return}
  if(e.code==='ArrowDown'||e.code==='ArrowRight'){if(bank.length){e.preventDefault();select((cur+1)%bank.length)}return}
  if(e.code==='ArrowUp'||e.code==='ArrowLeft'){if(bank.length){e.preventDefault();select((cur-1+bank.length)%bank.length)}return}
  if(e.code in KM&&!down.has(e.code)){const n=baseNote()+KM[e.code];down.set(e.code,n);noteOn('k',n,0.8);e.preventDefault()}
});
addEventListener('keyup',e=>{if(down.has(e.code)){noteOff('k',down.get(e.code));down.delete(e.code)}});
addEventListener('blur',()=>{down.clear();for(const k of [...held.keys()]){const[s,n]=k.split(':');noteOff(s,+n)}});
addEventListener('pointerdown',()=>{TS.start()},{passive:true});
if(navigator.keyboard&&navigator.keyboard.getLayoutMap){
  navigator.keyboard.getLayoutMap().then(m=>{Object.keys(KM).concat(['KeyZ','KeyX']).forEach(c=>{const k=m.get(c);if(k)KLAB[c]=k.toUpperCase()});buildKeys();setHint()}).catch(()=>{});
}
function setHint(){
  $('#khint').textContent='Tasten: '+Object.keys(KM).map(c=>KLAB[c]).join(' ')+'  ·  Oktave '+KLAB.KeyZ+' / '+KLAB.KeyX+'  ·  1–9, 0 oder ←/→ = Preset wählen  ·  Esc = Panic  ·  MIDI: Noten, Program Change = Preset, CC1 = Weird, CC7 = Pegel, CC64 = Haltepedal';
}
$('#midi').onclick=async()=>{
  const b=$('#midi');
  if(!navigator.requestMIDIAccess){b.textContent='kein MIDI';return}
  try{
    const a=await navigator.requestMIDIAccess();
    const bind=()=>{for(const i of a.inputs.values())i.onmidimessage=m=>{
      const [s,d1,d2]=m.data,c=s&0xf0;
      if(c===0x90&&d2>0)noteOn('m',d1,d2/127);
      else if(c===0x80||(c===0x90&&d2===0))noteOff('m',d1);
      else if(c===0xB0){if(d1===1)setWeird(d2/127*100);else if(d1===7){$('#vol').value=d2/127;TS.setVolume(d2/127)}else if(d1===64)setSustain(d2>=64)}
      else if(c===0xC0&&bank[d1])select(d1);
    };b.textContent='MIDI: '+a.inputs.size};
    bind();a.onstatechange=bind;
  }catch(_){b.textContent='MIDI abgelehnt'}
};

/* ── DNA-Sequenz + Kosten ── */
const dnaCtx=$('#dna').getContext('2d');
const GROUPS=TS.groups;
let dnaKey='';
function drawDNA(){
  const g=TS.current(),key=toHex(g);if(key===dnaKey)return;dnaKey=key;
  const c=dnaCtx;c.setTransform(2,0,0,2,0,0);c.clearRect(0,0,140,120);
  let i=0;GROUPS.forEach((n,r)=>{for(let k=0;k<n;k++,i++){const L=Math.round(58+g[i]*185);c.fillStyle='rgb('+L+','+L+','+Math.min(255,L+5)+')';c.fillRect(k*19,r*19,16,16)}});
  const e=TS.cost();
  $('#cfill').style.width=(e.fill*100)+'%';$('#clabel').textContent='Last: '+e.label;$('#cdet').textContent=e.text;
}

/* ── Einbettung: postMessage-API für andere Seiten (iframe) ── */
addEventListener('message',e=>{
  const m=e.data;if(!m||m.tsynth!==1)return;
  const reply=o=>{try{e.source&&e.source.postMessage(Object.assign({tsynth:1},o),e.origin&&e.origin!=='null'?e.origin:'*')}catch(_){}};
  switch(m.cmd){
    case 'ping':reply({evt:'ready',schema:TS.schema,presets:bank.map(p=>p.name),cur});break;
    case 'load':{const i=addPreset(String(m.name||'Extern'),m.code,true);reply(i>=0?{evt:'loaded',index:i}:{evt:'error',msg:'ungültiger Code'});break}
    case 'select':if(bank[m.index|0]){select(m.index|0);reply({evt:'selected',index:cur})}break;
    case 'noteOn':TS.noteOn(+m.note,m.vel==null?0.8:+m.vel);break;
    case 'noteOff':TS.noteOff(+m.note);break;
    case 'weird':setWeird(+m.value*(m.value<=1?100:1));break;
    case 'volume':TS.setVolume(+m.value);$('#vol').value=+m.value;break;
    case 'panic':panic();break;
  }
});
if(window.parent!==window)window.parent.postMessage({tsynth:1,evt:'ready',schema:TS.schema},'*');

/* ── Frame-Loop ── */
const sc=$('#scope'),sctx=sc.getContext('2d'),sbuf=new Float32Array(1024);
function frame(){
  const ctx=TS.ctx;
  $('#arm').classList.toggle('ok',!!ctx&&ctx.state==='running');
  if(ctx&&TS.analyser){
    TS.analyser.getFloatTimeDomainData(sbuf);
    sctx.clearRect(0,0,sc.width,sc.height);sctx.strokeStyle=TH.ink;sctx.lineWidth=1.5;sctx.beginPath();
    for(let i=0;i<sbuf.length;i+=2){const x=i/sbuf.length*sc.width,y=sc.height/2-sbuf[i]*sc.height*0.9;i?sctx.lineTo(x,y):sctx.moveTo(x,y)}
    sctx.stroke();
    $('#vc').textContent=TS.voiceCount();
    const d=TS.dsp();if(d!=null){$('#dspbox').hidden=false;$('#dsp').textContent=Math.round(d*100)}
  }
  drawDNA();
  requestAnimationFrame(frame);
}

/* ── Start ── */
loadBank();
{
  const m=/^#seed=([0-9a-fA-F]+)(?:~(.*))?$/.exec(location.hash);
  if(m&&fromHex(m[1])){let nm='Link';try{nm=decodeURIComponent(m[2]||'')||nm}catch(_){}addPreset(nm,m[1],true);history.replaceState(null,'',location.href.split('#')[0]);status('Sound aus Link hinzugefügt')}
}
if(cur>=0)select(cur);else{renderBank();renderStage()}
buildKeys();setHint();checkEditor();
requestAnimationFrame(frame);
</script>
</body>
</html>