TURBOSYNTHETIC v1.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/7676659354084493/" 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 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}
</style>
</head>
<body>
<div class="wrap">

<header>
  <h1>TURBOSYNTHETIC<small>v1.0</small></h1>
  <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">
  <div>
    <div class="radbox">
      <div id="radwrap"><canvas id="rad"></canvas></div>
      <div class="wcol">
        <div class="lbl">WEIRD</div>
        <div id="wheel"><div id="wfill"></div><div id="wthumb"></div></div>
        <div id="wval">0</div><div id="wword"></div>
      </div>
    </div>
    <div class="tools">
      <button id="bAdopt" disabled title="Den angespielten Sound zum neuen Seed machen (oder Doppelklick)">Als Seed</button>
      <button class="q" id="bBack" disabled title="Zum vorherigen Seed zurück">← Zurück</button>
      <button class="q" id="bReroll" title="Neue Evolutionen um denselben Seed würfeln">Neu würfeln</button>
      <span class="sp"></span>
      <button id="bPick" class="q" title="Danach einen Sound anklicken, der mit dem Seed gekreuzt wird">Kreuzen …</button>
    </div>
    <p class="hint" id="rhint"></p>
  </div>

  <div class="side">
    <div class="sidetop">
      <div class="seedcol">
        <h2>Seed</h2>
        <div class="seedbar"><span class="dirty" id="dirty"></span><input type="text" id="nm" spellcheck="false" placeholder="Name"><button id="bSave">Speichern</button><button class="q" id="bCopy" title="Unter neuem Namen als Kopie ablegen">Kopie</button></div>
        <div class="code"><span>Code</span><input type="text" id="code" spellcheck="false"><button class="q s" id="bCode">kopieren</button><button class="q s" id="bLink" title="Link kopieren, der diesen Sound in einer anderen Instanz öffnet">Link</button></div>
        <div id="status"></div>
      </div>
      <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="libcol">
        <h2>Bibliothek <span id="libn" style="letter-spacing:0"></span></h2>
        <div class="libtools"><button class="q s" id="bExport" title="Alle Presets als JSON-Datei speichern">Export</button><button class="q s" id="bImport" title="JSON-Datei einer anderen Instanz laden (auch per Drag &amp; Drop)">Import</button></div>
        <div id="lib"></div>
      </div>
    </div>
    <div id="bridge" hidden></div>
  </div>
</div>
<input type="file" id="fileIn" accept=".json,application/json" hidden>

<section>
  <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>
</section>

<section>
  <details id="rollbox">
    <summary>Piano Roll</summary>
    <div class="body">
      <div class="rbar">
        <button id="bPlay">Play</button>
        <label>Tempo <input id="bpm" type="range" min="60" max="180" step="1" value="96"></label>
        <span class="meta" id="bpmLb">96</span>
        <span class="sp"></span>
        <button class="q" id="bDice">Würfeln</button>
        <button class="q" id="bClear">Leeren</button>
      </div>
      <canvas id="roll"></canvas>
    </div>
  </details>
</section>

<section>
  <h2>Parameter <span style="letter-spacing:0;text-transform:none">· Gen-Name anklicken sperrt das Gen gegen Mutation/Kreuzung/Zufall</span></h2>
  <div class="genes" id="genes"></div>
</section>

</div>

<script>
'use strict';
const $=s=>document.querySelector(s);
const clamp=(x,a,b)=>Math.min(b,Math.max(a,x));
const sm=(x,a,b)=>clamp((x-a)/(b-a),0,1);
const tri=(x,a,b,c)=>x<=a||x>=c?0:x<b?(x-a)/(b-a):(c-x)/(c-b);
const _cs=getComputedStyle(document.documentElement),css=n=>_cs.getPropertyValue(n).trim();
const TH={bg:css('--bg'),panel:css('--panel'),soft:css('--soft'),line:css('--line'),grid:css('--grid'),ring:css('--ring'),ink:css('--ink'),mut:css('--mut'),dim:css('--dim'),inv:css('--inv'),hov:css('--hov')};

/* ───────── Gen-Definitionen ───────── */
/*@ENGINE:GENES*/
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':'')};
}
/*@/ENGINE:GENES*/

/* ───────── Archetypen ───────── */
const ARCH={
  Pad:{waveA:[1,2],waveB:[2],mixB:[0.4,0.9],detB:[8,28],sub:[0,0.3],fmIdx:[0,1.2],fmDec:[0.5,1.5],cutoff:[500,3500],res:[0.7,4],fEnv:[0,2],aA:[0.25,0.7],aD:[0.6,1.5],aS:[0.6,0.95],aR:[0.35,0.8],lfoRate:[0.15,1.5],lfoFilt:[100,700],pEnv:[0,0.2],dlMix:[0.1,0.3],rvMix:[0.2,0.45],spread:[0.6,1]},
  Pluck:{waveA:[2,3,1],mixB:[0,0.5],fmRatio:[1,3,5],fmIdx:[0.5,3],fmDec:[0.04,0.25],fmSus:[0,0.15],cutoff:[500,2500],res:[1,7],fEnv:[2.5,5],aA:[0.004,0.007],aD:[0.12,0.5],aS:[0,0.12],aR:[0.08,0.22],fDec:[0.08,0.35],noise:[0,0.1],noiseDec:[0.01,0.05],dlMix:[0.1,0.3],rvMix:[0.1,0.3],lfoPitch:[0,4]},
  Bass:{waveA:[2,3],waveB:[2,3],mixB:[0,0.5],detB:[2,10],sub:[0.3,0.7],fmIdx:[0,1.5],fmDec:[0.1,0.4],cutoff:[120,700],res:[2,10],fEnv:[1.5,4],aA:[0.004,0.01],aD:[0.2,0.6],aS:[0.5,0.9],aR:[0.06,0.15],fDec:[0.1,0.4],drive:[0.15,0.6],dlMix:[0,0.05],rvMix:[0,0.08],pEnv:[0,3]},
  Bell:{waveA:[0],waveB:[0,1],mixB:[0,0.3],sub:[0,0.1],fmRatio:[4,5,6,8,9],fmFine:[-0.02,0.02],fmIdx:[1.5,6],fmDec:[0.4,1.3],fmSus:[0,0.3],fType:[0],cutoff:[4000,13000],res:[0.5,2],fEnv:[0,1],aA:[0.004,0.006],aD:[0.6,1.5],aS:[0,0.25],aR:[0.3,0.8],rvMix:[0.25,0.45],dlMix:[0.1,0.3],lfoPitch:[0,3]},
  Lead:{waveA:[2,3],waveB:[2,3],mixB:[0.4,0.8],detB:[4,16],fmRatio:[1,3,5],fmIdx:[0,1.5],cutoff:[1500,6500],res:[1,5],fEnv:[0.5,2.5],aA:[0.005,0.02],aD:[0.15,0.5],aS:[0.7,0.95],aR:[0.1,0.3],lfoRate:[4.5,6.5],lfoPitch:[4,16],dlMix:[0.12,0.3],dlFb:[0.25,0.5],rvMix:[0.1,0.25],drive:[0,0.4]},
  Keys:{waveA:[0,1],waveB:[0],mixB:[0,0.3],fmRatio:[1,3],fmFine:[-0.005,0.005],fmIdx:[1,3.5],fmDec:[0.25,0.8],fmSus:[0.05,0.3],cutoff:[3000,9000],res:[0.5,2],fEnv:[0,1.5],aA:[0.004,0.01],aD:[0.5,1.2],aS:[0.05,0.4],aR:[0.12,0.3],rvMix:[0.15,0.35],spread:[0.3,0.8],lfoPitch:[0,3]},
  Perc:{waveA:[0,1,3],fmRatio:[0,3,7,8],fmIdx:[2,7],fmDec:[0.03,0.15],fmSus:[0,0.05],cutoff:[800,6000],fEnv:[1,4],aA:[0.004,0.006],aD:[0.06,0.22],aS:[0,0.03],aR:[0.04,0.1],noise:[0.08,0.3],noiseDec:[0.02,0.15],pEnv:[5,12],drive:[0.1,0.5],rvMix:[0.1,0.3]},
  Drone:{waveA:[2,3,2],waveB:[2,3],mixB:[0.6,1],detB:[10,30],sub:[0.2,0.6],noise:[0,0.04],fmRatio:[0,1,2,3],fmFine:[-0.02,0.02],fmIdx:[0.4,3],fmDec:[0.8,1.5],fmSus:[0.4,0.9],cutoff:[250,1600],res:[1,5],fEnv:[0,1],aA:[0.4,0.8],aD:[1,1.5],aS:[0.9,1],aR:[0.5,0.8],lfoRate:[0.1,0.5],lfoFilt:[400,1100],lfoPitch:[0,10],dlMix:[0.15,0.35],dlFb:[0.4,0.6],rvMix:[0.3,0.45],spread:[0.7,1]},
  Blips:{waveA:[0,3,0],waveB:[0,3],mixB:[0,0.4],sub:[0,0.1],noise:[0,0.2],noiseDec:[0.01,0.03],fmRatio:[3,5,7,8,9],fmIdx:[1,5],fmDec:[0.03,0.09],fmSus:[0,0.03],cutoff:[3500,13000],res:[0.5,3],fEnv:[0,2],aA:[0.004,0.006],aD:[0.03,0.09],aS:[0,0.05],aR:[0.04,0.09],pEnv:[0,5],dlMix:[0.1,0.35],dlTime:[0.1,0.3],dlFb:[0.3,0.55],rvMix:[0.05,0.2]}
};
const ARCH_KEYS=['Drone','Pad','Keys','Lead','Pluck','Bell','Blips','Perc','Bass'];

/* ───────── Klangtyp-Kreis ─────────
   9 Sektoren à 40°, im Uhrzeigersinn ab 12 Uhr. Winkel eines Sounds = gewichtete Mischung seiner
   besten (benachbarten) Klangtypen, abgeleitet aus den Gen-Werten. */
const STY=['Drone','Pad','Keys','Lead','Pluck','Bell','Blips & Clicks','Perc','Bass'];
const STS=['Drone','Pad','Keys','Lead','Pluck','Bell','Blips','Perc','Bass'];
const SA=STY.map((_,i)=>(-90+i*40)*Math.PI/180);
function scoreStyles(p){
  const lc=Math.log2(p.cutoff);
  const fast=1-sm(p.aA,0.008,0.06);
  const bright=sm(lc,9,12.5);
  const noiseA=sm(p.noise,0.03,0.2), drop=sm(p.pEnv,2.5,8), fm=sm(p.fmIdx,0.7,3);
  const R=p.fmRatio, bellR=(R===4||R===5||R===7)?1:(R===3||R===3.5||R===2.5)?0.6:0.3;
  const hiR=bellR>0.9?1:0;
  const sine=p.waveA<=1?1:0.55, saw=p.waveA>=2?1:0.6;
  const short1=1-sm(p.aD,0.04,0.12), short2=1-sm(p.aD,0.08,0.3);
  const susLo=1-sm(p.aS,0.1,0.4);
  const s=[];
  s[0]=sm(p.aA,0.2,0.5)*sm(p.aS,0.8,0.97)*sm(p.aR,0.35,0.65)*(0.45+0.55*(1-bright));
  s[1]=sm(p.aA,0.04,0.2)*sm(p.aS,0.35,0.8)*sm(p.aR,0.12,0.4)*(1-0.7*s[0]);
  s[2]=fast*tri(p.aS,0,0.2,0.55)*sm(p.aD,0.25,0.6)*(1-0.8*fm*hiR);
  s[3]=fast*sm(p.aS,0.5,0.85)*sm(lc,10,11.8)*saw*(0.7+0.3*sm(p.lfoPitch,2,10));
  s[4]=fast*(1-sm(p.aS,0.05,0.3))*tri(p.aD,0.06,0.2,0.65)*(0.4+0.6*sm(p.fEnv,1.5,3.5))*(1-0.5*noiseA)*(1-0.7*fm*hiR);
  s[5]=fm*bellR*(0.4+0.6*bright)*(0.4+0.6*sm(p.aD,0.3,0.8))*(0.3+0.7*susLo)*sine;
  s[6]=Math.max(short1*(1-sm(p.aS,0.02,0.1))*(0.4+0.6*bright)*(1-0.5*Math.max(noiseA,drop)),noiseA*(1-sm(p.noiseDec,0.02,0.1))*short1);
  s[7]=short2*Math.max(noiseA,drop)*(1-sm(p.aS,0,0.1));
  s[8]=sm(p.sub,0.12,0.5)*(1-sm(lc,9.3,11.3))*(0.5+0.5*fast);
  return s.map(v=>clamp(v,0,1)+0.02);
}
function classify(g){
  const s=scoreStyles(computeParams(g));
  const order=s.map((v,i)=>i).sort((a,b)=>s[b]-s[a]);
  const a=order[0],b=order[1];
  let ang=SA[a];
  const step=Math.min((a-b+9)%9,(b-a+9)%9);
  if(step<=2&&s[b]>0.35*s[a]){
    const w1=s[a]*s[a],w2=s[b]*s[b];
    ang=Math.atan2(w1*Math.sin(SA[a])+w2*Math.sin(SA[b]),w1*Math.cos(SA[a])+w2*Math.cos(SA[b]));
  }
  let h=0;for(let i=0;i<g.length;i++)h+=g[i]*(i*7+3);
  ang+=((h%1)-0.5)*0.44;
  const mix=step<=2&&s[b]>0.6*s[a];
  return {s,top:a,sec:b,ang,conf:clamp(s[a],0.15,1),mix};
}
function styleLabel(c){return mix2(c)}
function mix2(c){return c.mix?STS[c.top]+' / '+STS[c.sec]:STS[c.top]}
function gdist(a,b){
  let t=0;
  for(let i=0;i<GENES.length;i++){
    const gn=GENES[i],d=gn.disc?(discIdx(gn,a[i])!==discIdx(gn,b[i])?0.5:0):Math.abs(a[i]-b[i]);
    t+=d*d;
  }
  return Math.sqrt(t/GENES.length);
}

/* ───────── DNA-Operationen ───────── */
let cur=null,prev=null;
let pop=[];                         // Nachbarschaft im Radial
const locked=new Set();
function randomDNA(tag){
  const A=ARCH[tag]||{};
  const g=GENES.map((gn,i)=>{
    if(locked.has(gn.k)&&cur)return cur.g[i];
    const r=A[gn.k]||gn.r;
    if(gn.disc)return (r[Math.floor(Math.random()*r.length)]+0.5)/gn.disc.length;
    const a=inv(gn,r[0]),b=inv(gn,r[1]);
    return clamp(a+Math.random()*(b-a),0,1);
  });
  return {g,tag,gen:0};
}
const gauss=()=>(Math.random()+Math.random()+Math.random()-1.5)/0.5;
function mutate(d,m){
  const g=d.g.slice();let changed=0;const p=0.2+0.6*m;
  GENES.forEach((gn,i)=>{
    if(locked.has(gn.k)||Math.random()>p)return;
    if(gn.disc){if(Math.random()<0.25+m*0.6){g[i]=(Math.floor(Math.random()*gn.disc.length)+0.5)/gn.disc.length;changed++}}
    else{g[i]=clamp(g[i]+gauss()*(0.03+0.3*m),0,1);changed++}
  });
  if(!changed){const i=GENES.findIndex(gn=>!gn.disc&&!locked.has(gn.k));if(i>=0)g[i]=clamp(g[i]+gauss()*0.1,0,1)}
  return {g,tag:d.tag,gen:d.gen+1};
}
/* Kreuzung: pro Gen entscheidet t (0 = ganz A, 1 = ganz B). origin merkt sich die Herkunft (A/B/M=gemischt). */
function crossover(a,b,t,m){
  const origin=[];
  const g=a.g.map((v,i)=>{
    const gn=GENES[i];
    if(locked.has(gn.k)){origin.push('A');return v}
    if(!gn.disc&&Math.random()<0.35){origin.push('M');return v+(b.g[i]-v)*t}
    const fromB=Math.random()<t;origin.push(fromB?'B':'A');return fromB?b.g[i]:v;
  });
  const c=mutate({g,tag:'Mix',gen:Math.max(a.gen,b.gen)},m);
  c.origin=origin;return c;
}

/* ───────── Weirdness & Hörprobe ─────────
   weird (Mod-Wheel, 0…1) bestimmt, wie weit sich ALLE Sounds im Radial vom Seed entfernen dürfen:
   0 = kaum hörbare Variation des Seeds, 1 = extremste Auswüchse in der Peripherie.
   heard = der gerade angespielte (noch nicht übernommene) Sound; ihn spielen auch Tastatur und Sequenzer. */
let weird=0.4,heard=null;
const playG=()=>heard?heard.g:cur.g;

/* ───────── Audio ───────── */
/*@ENGINE:AUDIO*/
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;
}
/*@/ENGINE:AUDIO*/

/* ───────── Spielen ───────── */
const held=new Map();
function refreshLit(){
  const s=new Set([...held.keys()].map(k=>+k.split(':')[1]));
  document.querySelectorAll('.key').forEach(k=>k.classList.toggle('lit',s.has(+k.dataset.n)));
}
function noteOn(src,note,vel){
  if(!initAudio())return;
  const key=src+':'+note;if(held.has(key))return;
  held.set(key,startVoice(note,vel==null?0.8:vel,ctx.currentTime+0.005));
  refreshLit();
}
function noteOff(src,note){
  const key=src+':'+note,v=held.get(key);if(!v)return;
  held.delete(key);v.release(ctx.currentTime+0.003);refreshLit();
}
function releaseAll(){[...held.keys()].forEach(k=>{const [s,n]=k.split(':');noteOff(s,+n)})}
function audition(note,dur,vel){
  if(!initAudio())return;
  const t=ctx.currentTime+0.005,v=startVoice(note,vel||0.8,t);v.release(t+(dur||0.25));
}
function preview(){
  if(!initAudio())return;
  const t=ctx.currentTime+0.01;
  [48,55,64].forEach(n=>startVoice(n,0.8,t).release(t+0.7));
}

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 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);
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;
  if(e.code==='KeyZ'){setOct(oct-1);return}
  if(e.code==='KeyX'){setOct(oct+1);return}
  if(e.code==='Space'){if(tg&&(tg.tagName==='BUTTON'||tg.tagName==='INPUT'||tg.tagName==='SUMMARY'))return;e.preventDefault();toggleSeq();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();releaseAll()});
addEventListener('pointerdown',()=>{if(ctx&&ctx.state!=='running')ctx.resume()},{passive:true});
function setHint(){
  $('#khint').textContent='Tasten: '+['KeyA','KeyW','KeyS','KeyE','KeyD','KeyF','KeyT','KeyG','KeyY','KeyH','KeyU','KeyJ','KeyK','KeyO','KeyL','KeyP','Semicolon'].map(c=>KLAB[c]).join(' ')+'  ·  Oktave '+KLAB.KeyZ+' / '+KLAB.KeyX+'  ·  Leertaste: Sequenzer  ·  Maus: Höhe auf der Taste = Anschlagstärke  ·  MIDI-CC1 = Weirdness';
}
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(()=>{});
}
$('#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&&d1===1)setWeird(d2/127);
    };b.textContent='MIDI: '+a.inputs.size};
    bind();a.onstatechange=bind;
  }catch(_){b.textContent='MIDI abgelehnt'}
};

/* ───────── Piano Roll + Sequenzer ───────── */
const ROWS=24,STEPS=16,RBASE=48,RH=10,GUT=34;
let notes=[
  {n:48,s:0,l:4},{n:55,s:0,l:4},{n:63,s:0,l:4},{n:67,s:2,l:2},
  {n:51,s:4,l:4},{n:58,s:4,l:4},{n:63,s:4,l:4},{n:70,s:6,l:2},
  {n:53,s:8,l:4},{n:60,s:8,l:4},{n:68,s:8,l:2},{n:65,s:10,l:2},
  {n:55,s:12,l:4},{n:58,s:12,l:4},{n:62,s:12,l:4},{n:67,s:14,l:2}
];
const roll=$('#roll');let rctx,rW=0,rollDirty=true;
function fitRoll(){
  const w=roll.parentElement.clientWidth;if(!w)return;
  const d=devicePixelRatio||1;rW=w;
  roll.width=rW*d;roll.height=ROWS*RH*d;roll.style.height=ROWS*RH+'px';
  rctx=roll.getContext('2d');rctx.setTransform(d,0,0,d,0,0);rollDirty=true;
}
let curStep=-1;
function drawRoll(){
  if(!rctx||!rW)return;
  const c=rctx,W=rW,H=ROWS*RH,cw=(W-GUT)/STEPS;
  c.clearRect(0,0,W,H);
  for(let r=0;r<ROWS;r++){
    const n=RBASE+ROWS-1-r,y=r*RH;
    if([1,3,6,8,10].includes(n%12)){c.fillStyle=TH.bg;c.fillRect(GUT,y,W-GUT,RH)}
    c.fillStyle=TH.grid;c.fillRect(GUT,y+RH-1,W-GUT,1);
    if(n%12===0){c.fillStyle=TH.mut;c.font='9px -apple-system,Helvetica,Arial,sans-serif';c.textBaseline='middle';c.fillText('C'+(Math.floor(n/12)-1),6,y+RH/2)}
  }
  for(let i=0;i<=STEPS;i++){c.fillStyle=i%4===0?TH.ring:TH.grid;c.fillRect(GUT+i*cw,0,1,H)}
  if(curStep>=0){c.fillStyle='rgba(255,255,255,0.10)';c.fillRect(GUT+curStep*cw,0,cw,H)}
  c.fillStyle=TH.ink;
  notes.forEach(o=>{
    const r=RBASE+ROWS-1-o.n;if(r<0||r>=ROWS)return;
    const x=GUT+o.s*cw+1,y=(ROWS-1-(o.n-RBASE))*RH+1,w=o.l*cw-2,h=RH-2;
    c.beginPath();if(c.roundRect)c.roundRect(x,y,w,h,2);else c.rect(x,y,w,h);c.fill();
  });
  rollDirty=false;
}
let rdrag=null;
function rpos(e){
  const r=roll.getBoundingClientRect(),cw=(rW-GUT)/STEPS;
  return {col:Math.floor((e.clientX-r.left-GUT)/cw),n:RBASE+ROWS-1-Math.floor((e.clientY-r.top)/RH)};
}
const noteAt=(n,col)=>notes.find(o=>o.n===n&&col>=o.s&&col<o.s+o.l);
roll.addEventListener('pointerdown',e=>{
  const p=rpos(e);if(p.col<0||p.col>=STEPS||p.n<RBASE||p.n>=RBASE+ROWS)return;
  roll.setPointerCapture(e.pointerId);
  const ex=noteAt(p.n,p.col);
  if(ex){notes=notes.filter(o=>o!==ex);rdrag={mode:'erase'}}
  else{const o={n:p.n,s:p.col,l:1};notes.push(o);rdrag={mode:'extend',o};audition(p.n,0.3)}
  rollDirty=true;
});
roll.addEventListener('pointermove',e=>{
  if(!rdrag)return;const p=rpos(e);
  if(rdrag.mode==='extend'){
    const o=rdrag.o;
    const nxt=notes.filter(x=>x!==o&&x.n===o.n&&x.s>o.s).reduce((m,x)=>Math.min(m,x.s),STEPS);
    const l=clamp(p.col-o.s+1,1,nxt-o.s);
    if(l!==o.l){o.l=l;rollDirty=true}
  }else{
    const ex=noteAt(p.n,p.col);if(ex){notes=notes.filter(o=>o!==ex);rollDirty=true}
  }
});
const rup=()=>{rdrag=null};
roll.addEventListener('pointerup',rup);roll.addEventListener('pointercancel',rup);
$('#bClear').onclick=()=>{notes=[];rollDirty=true};
$('#bDice').onclick=()=>{
  const deg=[0,3,5,7,10],out=[];
  out.push({n:RBASE+deg[Math.floor(Math.random()*3)],s:0,l:4});
  const cnt=6+Math.floor(Math.random()*6);
  for(let i=0;i<cnt;i++){
    const n=RBASE+12*Math.floor(Math.random()*2)+deg[Math.floor(Math.random()*5)]+(Math.random()<0.3?12:0);
    const s=Math.floor(Math.random()*STEPS),l=1+Math.floor(Math.random()*4);
    if(n>=RBASE+ROWS)continue;
    const L=Math.min(l,STEPS-s);
    if(out.some(o=>o.n===n&&s<o.s+o.l&&s+L>o.s))continue;
    out.push({n,s,l:L});
  }
  notes=out;rollDirty=true;
};
let seqOn=false,nextT=0,seqStep=0;const stepQ=[];
const stepDur=()=>60/(+$('#bpm').value)/4;
$('#bpm').addEventListener('input',()=>{$('#bpmLb').textContent=$('#bpm').value});
function toggleSeq(){
  if(seqOn){seqOn=false}
  else{if(!initAudio())return;seqOn=true;seqStep=0;nextT=ctx.currentTime+0.06}
  $('#bPlay').textContent=seqOn?'Stop':'Play';$('#bPlay').classList.toggle('on',seqOn);
}
$('#bPlay').onclick=toggleSeq;
function tickSeq(){
  if(!seqOn||!ctx)return;
  while(nextT<ctx.currentTime+0.12){
    const sd=stepDur();
    notes.forEach(o=>{if(o.s===seqStep){const v=startVoice(o.n,0.8,nextT);v.release(nextT+o.l*sd*0.96)}});
    stepQ.push({s:seqStep,t:nextT});
    nextT+=sd;seqStep=(seqStep+1)%STEPS;
  }
}
setInterval(tickSeq,25);
$('#rollbox').addEventListener('toggle',()=>setTimeout(fitRoll,0));
if(window.ResizeObserver)new ResizeObserver(()=>fitRoll()).observe(roll.parentElement);

/* ───────── Speicher (localStorage, mit Fallback) ───────── */
const LS_LIB='turbosynthetic.lib.v1',LS_N='turbosynthetic.n',LS_OLD='dnasynth.lib.v1',LS_OLDN='dnasynth.n';
let lib=[],lsOK=true,nameN=0;
function loadStore(){
  try{
    let raw=localStorage.getItem(LS_LIB);
    if(raw===null)raw=localStorage.getItem(LS_OLD);          // Übernahme aus der Vorversion DNA SYNTH
    lib=JSON.parse(raw||'[]');if(!Array.isArray(lib))lib=[];
  }catch(_){lib=[];lsOK=false}
  try{const n=localStorage.getItem(LS_N);nameN=+(n!==null?n:localStorage.getItem(LS_OLDN))||0}catch(_){lsOK=false}
}
function persist(){
  try{localStorage.setItem(LS_LIB,JSON.stringify(lib));localStorage.setItem(LS_N,String(nameN));return true}
  catch(_){lsOK=false;return false}
}
function autoName(d){nameN++;try{localStorage.setItem(LS_N,String(nameN))}catch(_){}return STS[classify(d.g).top]+' '+nameN}
const nameOf=d=>d.name||(d.name=autoName(d));
let statusT=0;
function status(t){const el=$('#status');el.textContent=t;clearTimeout(statusT);statusT=setTimeout(()=>{el.textContent=''},3200)}

/* ───────── Seed, Spektrum, Hörprobe ─────────
   Der SEED sitzt in der Mitte. Um ihn öffnet sich ein Spektrum aus Evolutionen: Winkel = Richtung (zu welchem Klangtyp
   der Seed gezogen wird), Abstand = Intensität – außen am extremsten. Die Weirdness (Mod-Wheel) skaliert, wie weit sich
   alle Knoten vom Seed entfernen. Klick auf einen Knoten = nur anspielen. Erst „Als Seed“ / Doppelklick macht ihn zum
   neuen Seed, um den sich dann ein neues Spektrum öffnet. */
let bridge=null,pickMode=false,hist=[],nameDraft=null,lastClick=null;
let spec=[];
const RING=['nah','mittel','fern','extrem'];
const rndArr=n=>Array.from({length:n},()=>Math.random());
function makeSpectrum(){
  spec=[];const K=4,N=GENES.length;
  for(let si=0;si<9;si++)for(let k=0;k<K;k++){
    const cnt=k===K-1?2:1;
    for(let j=0;j<cnt;j++){
      const off=(cnt===2?(j?1:-1)*0.2:(k%2?1:-1)*0.13)+(Math.random()-.5)*0.08;
      spec.push({si,k,f:(k+1)/K,ang:SA[si]+off,u:rndArr(N),v:rndArr(N),z:Array.from({length:N},()=>clamp(gauss()*0.55,-1,1))});
    }
  }
}
/* Sound eines Knotens = Seed, gezogen in Richtung des Klangtyps (Ziel-Bereiche aus dem Archetyp), verstärkt durch
   Ring (f) und Weirdness. Außen werden die Zielwerte zusätzlich an die Bereichsenden gedrückt (extremer) und
   mit Zufallsabweichung auf allen Genen überlagert. Deterministisch pro Knoten → stetig beim Drehen am Wheel. */
function nodeG(n){
  const Wp=0.06+0.94*Math.pow(weird,1.15),e=Wp*(0.3+0.7*n.f),A=ARCH[ARCH_KEYS[n.si]];
  return cur.g.map((v,i)=>{
    const gn=GENES[i];if(locked.has(gn.k))return v;
    const r=A[gn.k];
    if(gn.disc){
      const N=gn.disc.length;
      if(r)return n.v[i]<e?(r[Math.floor(n.u[i]*r.length)]+0.5)/N:v;
      return n.v[i]<e*e*0.22?(Math.floor(n.u[i]*N)+0.5)/N:v;
    }
    let x=v;
    if(r){
      const lo=inv(gn,r[0]),hi=inv(gn,r[1]);
      let t=lo+(hi-lo)*n.u[i];
      t+=((Math.abs(lo-v)>Math.abs(hi-v)?lo:hi)-t)*Math.min(1,e*e*0.9);
      x=v+(t-v)*Math.min(1,e*1.15);
    }
    x+=n.z[i]*(r?0.35:1)*(0.03*e+0.38*e*e);
    return clamp(x,0,1);
  });
}
const nodeLabel=n=>'→ '+STS[n.si]+' · '+RING[n.k];
const nodeDNA=n=>({g:nodeG(n),tag:'Mix',gen:cur.gen+1,label:nodeLabel(n)});
const HINT0='Klick = anhören (und auf der Tastatur spielen) · Doppelklick oder „Als Seed“ = übernehmen · Sound auf die Mitte ziehen = kreuzen';
function updateHeardUI(){
  $('#bAdopt').disabled=!heard;$('#bBack').disabled=!hist.length;
  $('#rhint').textContent=pickMode?'Jetzt einen Sound im Radial oder in der Bibliothek anklicken – er wird mit dem Seed gekreuzt.'
    :heard?'Hörprobe: '+(heard.label||'Kind')+' – Doppelklick oder „Als Seed“ übernimmt sie · Klick auf die Mitte = zurück zum Seed':HINT0;
  $('#code').value=toHex(playG());
  refreshDirty();
  if(bridge)renderBridge();
}
function hear(dna,label,node){
  heard={g:dna.g,label,n:node||null,src:dna};nameDraft=null;
  applyDNA(false);updateHeardUI();radDirty=true;preview();
}
function unhear(audio){heard=null;nameDraft=null;applyDNA(false);updateHeardUI();radDirty=true;if(audio)preview()}
function select(d,o){
  o=o||{};
  if(cur&&cur!==d){hist.push(cur);if(hist.length>40)hist.shift()}
  cur=d;nameOf(cur);heard=null;nameDraft=null;lastClick=null;
  makeSpectrum();applyDNA(false);refreshAll();updateHeardUI();radDirty=true;
  if(o.audio!==false)preview();
}
function back(){
  const d=hist.pop();if(!d)return;
  cur=d;heard=null;nameDraft=null;makeSpectrum();applyDNA(false);refreshAll();updateHeardUI();radDirty=true;preview();
}
function adoptHeard(){
  if(!heard)return;
  const typed=nameDraft&&nameDraft.trim();
  select({g:heard.g.slice(),tag:cur.tag,gen:cur.gen+1},{audio:false});
  if(typed){cur.name=typed;$('#nm').value=typed}
  status('Neuer Seed: '+nameOf(cur));
}
$('#bAdopt').onclick=adoptHeard;
$('#bBack').onclick=back;
$('#bReroll').onclick=()=>{makeSpectrum();unhear(false);status('Neue Evolutionen gewürfelt')};

/* ───────── Weirdness-Wheel ───────── */
const wheel=$('#wheel');
function syncWheel(){
  $('#wfill').style.height=(weird*100)+'%';
  $('#wthumb').style.bottom='calc('+weird+' * (100% - 9px))';
  $('#wval').textContent=Math.round(weird*100);
  $('#wword').textContent=weird<0.15?'subtil':weird<0.4?'leicht':weird<0.65?'deutlich':weird<0.85?'wild':'extrem';
}
function setWeird(a){
  weird=clamp(a,0,1);syncWheel();
  if(heard&&heard.n){heard.g=nodeG(heard.n);applyDNA(false);$('#code').value=toHex(playG())}
  radDirty=true;
}
let wdrag=false;
const wy=e=>{const r=wheel.getBoundingClientRect();return 1-(e.clientY-r.top-4)/(r.height-8)};
wheel.addEventListener('pointerdown',e=>{wdrag=true;wheel.setPointerCapture(e.pointerId);setWeird(wy(e))});
wheel.addEventListener('pointermove',e=>{if(wdrag)setWeird(wy(e))});
wheel.addEventListener('pointerup',()=>{wdrag=false;if(heard)holdPreview()});
wheel.addEventListener('pointercancel',()=>{wdrag=false});
wheel.addEventListener('wheel',e=>{e.preventDefault();setWeird(weird-e.deltaY*0.0012)},{passive:false});
let hpT=0;function holdPreview(){clearTimeout(hpT);hpT=setTimeout(preview,60)}

/* ───────── Radial ───────── */
const rc=$('#rad');let radCtx,radW=0,radH=440,radDirty=true;
let hover=null,dragOver=false;
function fitRad(){
  const w=rc.clientWidth;if(!w)return;
  const d=devicePixelRatio||1;radW=w;radH=rc.clientHeight;
  rc.width=radW*d;rc.height=radH*d;radCtx=rc.getContext('2d');radCtx.setTransform(d,0,0,d,0,0);
  radDirty=true;
}
if(window.ResizeObserver)new ResizeObserver(fitRad).observe(rc);
addEventListener('resize',fitRad);
function geom(){return {cx:radW/2,cy:radH/2,r0:46,R:Math.max(90,Math.min(radW/2-66,radH/2-26))}}
function nodePos(n,gm){const r=gm.r0+(gm.R-gm.r0)*n.f;return {x:Math.cos(n.ang)*r,y:Math.sin(n.ang)*r}}
const nodeR=n=>4.5+3.5*n.f;
function drawRadial(){
  if(!radCtx||!radW||!cur)return;
  const c=radCtx,gm=geom(),{cx,cy,r0,R}=gm,FONT='-apple-system,Helvetica,Arial,sans-serif';
  c.clearRect(0,0,radW,radH);
  const hs=heard&&heard.n?heard.n.si:(hover?hover.si:-1);
  if(hs>=0){
    const a0=SA[hs]-20*Math.PI/180,a1=SA[hs]+20*Math.PI/180;
    c.beginPath();c.arc(cx,cy,R+4,a0,a1);c.arc(cx,cy,r0,a1,a0,true);c.closePath();c.fillStyle=TH.soft;c.fill();
  }
  c.lineWidth=1;c.strokeStyle=TH.ring;
  [0.25,0.5,0.75,1].forEach(f=>{c.beginPath();c.arc(cx,cy,r0+(R-r0)*f,0,7);c.stroke()});
  c.strokeStyle=TH.grid;
  for(let i=0;i<9;i++){const a=SA[i]+20*Math.PI/180;c.beginPath();c.moveTo(cx+Math.cos(a)*r0,cy+Math.sin(a)*r0);c.lineTo(cx+Math.cos(a)*(R+4),cy+Math.sin(a)*(R+4));c.stroke()}
  // Ring-Beschriftung (Intensität)
  c.font='8.5px '+FONT;c.textAlign='center';c.textBaseline='middle';
  {const a=SA[0]-20*Math.PI/180;
   RING.forEach((t,k)=>{const r=r0+(R-r0)*(k+1)/4,x=cx+Math.cos(a)*r,y=cy+Math.sin(a)*r;c.lineWidth=3;c.strokeStyle=TH.panel;c.strokeText(t,x,y);c.fillStyle=TH.dim;c.fillText(t,x,y)})}
  // Richtungs-Beschriftung
  for(let i=0;i<9;i++){
    const cs=Math.cos(SA[i]),sn=Math.sin(SA[i]),x=cx+cs*(R+14),y=cy+sn*(R+14);
    c.textAlign=cs>0.35?'left':cs<-0.35?'right':'center';
    c.fillStyle=i===hs?TH.ink:TH.mut;c.font=(i===hs?'600 ':'')+'10px '+FONT;
    c.fillText(STY[i].toUpperCase(),x+(cs>0.35?2:cs<-0.35?-2:0),y+(Math.abs(cs)<=0.35?sn*6:0));
  }
  // Verbindung zur Hörprobe
  if(heard&&heard.n){
    const p=nodePos(heard.n,gm);c.setLineDash([3,3]);c.strokeStyle=TH.ink;c.lineWidth=1;
    c.beginPath();c.moveTo(cx,cy);c.lineTo(cx+p.x,cy+p.y);c.stroke();c.setLineDash([]);
  }
  // Knoten
  spec.forEach(n=>{
    const p=nodePos(n,gm),x=cx+p.x,y=cy+p.y,s=nodeR(n),isHeard=heard&&heard.n===n,isH=hover===n;
    c.beginPath();c.arc(x,y,s,0,7);
    c.fillStyle=isHeard?TH.ink:isH?TH.hov:TH.panel;c.fill();
    c.lineWidth=0.9+0.9*n.f;c.strokeStyle=TH.ink;c.stroke();
  });
  // Seed
  c.beginPath();c.arc(cx,cy,r0-14+(dragOver?8:0),0,7);c.fillStyle=TH.ink;c.fill();
  if(dragOver||(heard&&!heard.n)){c.setLineDash(dragOver?[]:[3,3]);c.strokeStyle=TH.ink;c.lineWidth=1;c.beginPath();c.arc(cx,cy,r0+4,0,7);c.stroke();c.setLineDash([])}
  c.textAlign='center';c.fillStyle=TH.inv;
  if(dragOver){c.font='600 11px '+FONT;c.fillText('× kreuzen',cx,cy)}
  else{
    c.font='600 10px '+FONT;c.fillText('SEED',cx,cy-8);
    c.font='10px '+FONT;c.fillStyle='#3a3f47';c.fillText(STS[classify(cur.g).top],cx,cy+5);
    c.font='9px '+FONT;c.fillStyle='#585e68';c.fillText('G'+cur.gen,cx,cy+17);
  }
  radDirty=false;
}
function hitTest(e){
  const r=rc.getBoundingClientRect(),gm=geom(),x=e.clientX-r.left-gm.cx,y=e.clientY-r.top-gm.cy;
  let best=null,bd=1e9;
  spec.forEach(n=>{const p=nodePos(n,gm),d=Math.hypot(p.x-x,p.y-y);if(d<nodeR(n)+5&&d<bd){best=n;bd=d}});
  return best;
}
const overCenter=e=>{const r=rc.getBoundingClientRect();return Math.hypot(e.clientX-(r.left+r.width/2),e.clientY-(r.top+r.height/2))<62};
/* Klick = anhören, Ziehen auf die Mitte = kreuzen. Gilt für Radial-Knoten und Bibliothekszeilen. */
function beginPress(d,e,onClick){
  const x0=e.clientX,y0=e.clientY;let dragging=false,ghost=null;
  const mv=ev=>{
    if(!dragging&&Math.hypot(ev.clientX-x0,ev.clientY-y0)>6){
      dragging=true;ghost=document.createElement('div');ghost.className='ghost';ghost.textContent=d.name||d.label||'Sound';document.body.append(ghost);
    }
    if(dragging){
      ghost.style.transform='translate('+(ev.clientX+12)+'px,'+(ev.clientY+12)+'px)';
      const o=overCenter(ev);if(o!==dragOver){dragOver=o;radDirty=true}
    }
  };
  const up=ev=>{
    removeEventListener('pointermove',mv);removeEventListener('pointerup',up);removeEventListener('pointercancel',up);
    if(ghost)ghost.remove();
    if(dragging){const o=overCenter(ev)&&ev.type==='pointerup';dragOver=false;radDirty=true;if(o)setPartner(d)}
    else onClick();
  };
  addEventListener('pointermove',mv);addEventListener('pointerup',up);addEventListener('pointercancel',up);
}
function nodeClick(n,dna){
  if(pickMode){setPartner(dna);return}
  const now=performance.now();
  if(lastClick&&lastClick.n===n&&now-lastClick.t<450){lastClick=null;adoptHeard();return}
  lastClick={n,t:now};
  hear(dna,nodeLabel(n),n);
}
rc.addEventListener('pointerdown',e=>{
  const n=hitTest(e);
  if(n){const dna=nodeDNA(n);beginPress(dna,e,()=>nodeClick(n,dna));return}
  const r=rc.getBoundingClientRect();
  if(Math.hypot(e.clientX-(r.left+r.width/2),e.clientY-(r.top+r.height/2))<34)unhear(true);
});
rc.addEventListener('pointermove',e=>{
  const n=hitTest(e);
  if(n!==hover){hover=n;radDirty=true}
  rc.style.cursor=n?'pointer':'default';
  rc.title=n?nodeLabel(n):'';
});
rc.addEventListener('pointerleave',()=>{if(hover){hover=null;radDirty=true}});

/* ───────── Kreuzung: Brücke ───────── */
function setPartner(b){
  pickMode=false;$('#bPick').classList.remove('on');
  if(!b||b===cur){updateHeardUI();return}
  bridge={a:cur,b,t:0.5,kids:[]};
  makeKids();updateHeardUI();
  status('Kreuzung: '+nameOf(cur)+' × '+nameOf(b));
}
function makeKids(){
  const br=bridge,m=0.06;
  br.kids=[];
  for(let i=0;i<6;i++){
    const tt=clamp(br.t+(i-2.5)*0.14,0.04,0.96);
    const k=crossover(br.a,br.b,tt,m);k.bias=tt;br.kids.push(k);
  }
  br.kids.sort((x,y)=>x.bias-y.bias);
}
function drawChromo(cv,d){
  const c=cv.getContext('2d'),n=GENES.length,w=cv.width/n;
  c.clearRect(0,0,cv.width,cv.height);
  for(let i=0;i<n;i++){
    const o=d.origin[i];
    c.fillStyle=o==='A'?TH.ink:o==='B'?'#575d67':'#9399a3';
    c.fillRect(i*w+0.5,0,w-1,cv.height);
  }
}
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function renderBridge(){
  const el=$('#bridge');
  if(!bridge){el.hidden=true;el.innerHTML='';return}
  el.hidden=false;
  const br=bridge;
  el.innerHTML='<div class="bh"><b>Kreuzung</b><span class="x" id="bx" title="Schließen">✕</span></div>'+
   '<div class="brow"><span class="pa"><i class="sq" style="background:var(--ink)"></i>'+esc(nameOf(br.a))+'</span><input type="range" id="bt" min="0" max="1" step="0.01" value="'+br.t+'"><span class="pb">'+esc(nameOf(br.b))+'<i class="sq" style="background:#575d67"></i></span></div>'+
   '<div id="kids"></div>'+
   '<div class="tools" style="margin-top:8px"><button id="bRe">Neu mischen</button><span class="meta">Kind anklicken = anhören · „Als Seed“ übernimmt</span></div>'+
   '<p class="leg">Jeder Streifen ist ein Kind, ein Kästchen pro Gen: hell = von A · dunkelgrau = von B · mittelgrau = gemischt. Der Regler verschiebt den Anteil von A↔B.</p>';
  const host=$('#kids');
  br.kids.forEach((k,i)=>{
    const row=document.createElement('div');row.className='kid'+(heard&&heard.src===k?' on':'');
    const cv=document.createElement('canvas');cv.width=198;cv.height=14;drawChromo(cv,k);
    const s=document.createElement('span');s.className='s';s.textContent=mix2(classify(k.g));
    row.append(cv,s);row.onclick=()=>hear(k,'Kind '+(i+1)+' ('+nameOf(br.a)+' × '+nameOf(br.b)+')',null);host.append(row);
  });
  $('#bx').onclick=()=>{bridge=null;renderBridge()};
  $('#bt').addEventListener('change',e=>{br.t=+e.target.value;makeKids();renderBridge()});
  $('#bRe').onclick=()=>{makeKids();renderBridge()};
}
$('#bPick').onclick=()=>{pickMode=!pickMode;$('#bPick').classList.toggle('on',pickMode);updateHeardUI()};

/* ───────── Bibliothek ───────── */
function drawBars(cv,g){
  const c=cv.getContext('2d'),n=GENES.length,w=cv.width/n,h=cv.height;
  c.clearRect(0,0,cv.width,h);
  GENES.forEach((gn,i)=>{const bh=Math.max(3,g[i]*h);c.fillStyle=gn.disc?'#737984':TH.ink;c.fillRect(i*w+0.5,h-bh,w-1,bh)});
}
function renderLib(){
  const host=$('#lib');host.innerHTML='';
  $('#libn').textContent=lib.length?'('+lib.length+')':'';
  if(!lib.length){host.innerHTML='<div class="empty">Noch nichts gespeichert. Benenne einen Seed und drücke „Speichern“.</div>';return}
  lib.forEach(p=>{
    const g=fromHex(p.code);if(!g)return;
    const row=document.createElement('div');row.className='row'+(cur&&cur.id===p.id?' on':'');
    const cv=document.createElement('canvas');cv.width=88;cv.height=32;drawBars(cv,g);
    const n=document.createElement('span');n.className='n';n.textContent=p.name;
    row.title=p.name+' · '+mix2(classify(g));
    const x=document.createElement('span');x.className='x';x.textContent='✕';x.title='Löschen';
    x.addEventListener('pointerdown',e=>e.stopPropagation());
    x.onclick=e=>{e.stopPropagation();lib=lib.filter(q=>q.id!==p.id);persist();renderLib();refreshDirty();status('Gelöscht')};
    row.append(cv,n,x);
    row.addEventListener('pointerdown',e=>{
      if(e.button!==0)return;
      const d={g,tag:'Preset',gen:0,name:p.name,id:p.id};
      beginPress(d,e,()=>loadPreset(p));
    });
    host.append(row);
  });
}
function loadPreset(p){
  const g=fromHex(p.code);if(!g)return;
  select({g,tag:'Preset',gen:0,name:p.name,id:p.id});
  status('Geladen: '+p.name);
}
function savePreset(asCopy){
  let typed=$('#nm').value.trim();
  if(heard){const draft=nameDraft&&nameDraft.trim();adoptHeard();typed=draft||''}
  const name=typed||nameOf(cur);
  cur.name=name;nameDraft=null;
  const code=toHex(cur.g);
  let p=(!asCopy&&cur.id)?lib.find(q=>q.id===cur.id):null;
  if(p){p.name=name;p.code=code;p.t=Date.now()}
  else{
    p={id:'s'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36),name,code,t:Date.now()};
    if(asCopy&&cur.id){cur={g:cur.g.slice(),tag:cur.tag,gen:cur.gen,name};}
    cur.id=p.id;lib.unshift(p);
  }
  const ok=persist();
  renderLib();refreshAll();updateHeardUI();
  status(ok?'Gespeichert · '+lib.length+' Seeds':'Gespeichert, aber localStorage ist nicht verfügbar – nur in dieser Sitzung');
}
$('#bSave').onclick=()=>savePreset(false);
$('#bCopy').onclick=()=>savePreset(true);
$('#nm').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();savePreset(false);e.target.blur()}});
$('#nm').addEventListener('input',e=>{if(heard)nameDraft=e.target.value;else cur.name=e.target.value;refreshDirty()});
$('#code').addEventListener('change',e=>{
  const g=fromHex(e.target.value);
  if(!g){e.target.value=toHex(playG());return}
  heard=null;cur.g=g;makeSpectrum();applyDNA(false);refreshAll();updateHeardUI();radDirty=true;preview();
});
$('#bCode').onclick=()=>copyText(toHex(playG()),'Code kopiert');

/* ───────── Export / Import / Link (zwischen Instanzen) ───────── */
const newId=()=>'s'+Date.now().toString(36)+Math.floor(Math.random()*1e5).toString(36);
function exportLib(){
  if(!lib.length){status('Bibliothek ist leer');return}
  const data={format:'turbosynthetic-presets',version:1,app:'TURBOSYNTHETIC v1.0',schema:SCHEMA_ID,genes:GENES.map(g=>g.k),
    presets:lib.map(p=>({name:p.name,code:p.code,t:p.t}))};
  const a=document.createElement('a');
  a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,1)],{type:'application/json'}));
  a.download='turbosynthetic-presets-'+new Date().toISOString().slice(0,10)+'.json';
  document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(a.href),3000);
  status(lib.length+' Presets exportiert');
}
function importData(txt){
  let d;try{d=JSON.parse(txt)}catch(_){status('Keine gültige JSON-Datei');return}
  const list=Array.isArray(d)?d:(d&&Array.isArray(d.presets)?d.presets:null);
  if(!list){status('Keine Presets in dieser Datei');return}
  let add=0,dup=0,bad=0;
  list.forEach(p=>{
    const code=String(p&&p.code||'').trim().toLowerCase();
    if(!fromHex(code)){bad++;return}
    const name=String(p.name||'Import').slice(0,60);
    if(lib.some(q=>q.code===code&&q.name===name)){dup++;return}
    lib.push({id:newId(),name,code,t:+p.t||Date.now()});add++;
  });
  if(add)persist();renderLib();refreshDirty();
  const warn=d&&d.schema&&d.schema!==SCHEMA_ID?' · Achtung: andere Gen-Version, Klang kann abweichen':'';
  status(add+' importiert'+(dup?' · '+dup+' schon vorhanden':'')+(bad?' · '+bad+' nicht lesbar':'')+warn);
}
$('#bExport').onclick=exportLib;
$('#bImport').onclick=()=>$('#fileIn').click();
$('#fileIn').addEventListener('change',e=>{const f=e.target.files[0];e.target.value='';if(f)f.text().then(importData)});
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(importData)});
function copyText(t,okMsg){
  const fb=()=>{$('#code').value=t;$('#code').select();status('Zum Kopieren markiert')};
  try{navigator.clipboard.writeText(t).then(()=>status(okMsg),fb)}catch(_){fb()}
}
$('#bLink').onclick=()=>{
  const nm=(heard?nameDraft:$('#nm').value.trim())||nameOf(cur);
  copyText(location.href.split('#')[0]+'#seed='+toHex(playG())+'~'+encodeURIComponent(nm),'Link kopiert – öffnet den Sound in jeder Instanz');
};
function loadFromHash(){
  const m=/^#seed=([0-9a-fA-F]+)(?:~(.*))?$/.exec(location.hash);if(!m)return false;
  const g=fromHex(m[1]);if(!g){status('Link enthält keinen gültigen Sound');return false}
  let name='';try{name=decodeURIComponent(m[2]||'')}catch(_){}
  select({g,tag:'Preset',gen:0,name:name||undefined});
  status('Sound aus Link geladen – „Speichern“ übernimmt ihn in die Bibliothek');
  history.replaceState(null,'',location.href.split('#')[0]);
  return true;
}
addEventListener('hashchange',loadFromHash);
/* Andere Tabs/Instanzen im selben Browser: Bibliothek bleibt synchron */
addEventListener('storage',e=>{if(e.key===LS_LIB){loadStore();renderLib();refreshDirty();status('Bibliothek aktualisiert (andere Instanz)')}});
$('#vol').addEventListener('input',e=>{vol=+e.target.value;if(ctx)masterG.gain.setTargetAtTime(vol*vol,ctx.currentTime,0.03)});

/* ───────── Gen-Regler (einklappbare Gruppen) ───────── */
const sliders=[],gvals=[];
(function buildGenes(){
  const host=$('#genes'),groups=[];
  GENES.forEach(gn=>{if(!groups.includes(gn.grp))groups.push(gn.grp)});
  groups.forEach(gr=>{
    const box=document.createElement('details');
    box.innerHTML='<summary>'+gr+'</summary>';
    const body=document.createElement('div');body.className='body';
    GENES.forEach((gn,i)=>{
      if(gn.grp!==gr)return;
      const row=document.createElement('div');row.className='gene';
      const top=document.createElement('div');top.className='row2';
      const gl=document.createElement('span');gl.className='gl';gl.textContent=gn.l;
      gl.onclick=()=>{locked.has(gn.k)?locked.delete(gn.k):locked.add(gn.k);row.classList.toggle('lock',locked.has(gn.k));gl.textContent=(locked.has(gn.k)?'● ':'')+gn.l};
      const gv=document.createElement('span');gv.className='gv';
      top.append(gl,gv);
      const inp=document.createElement('input');inp.type='range';
      if(gn.disc){inp.min=0;inp.max=gn.disc.length-1;inp.step=1}else{inp.min=0;inp.max=1000;inp.step=1}
      inp.addEventListener('input',()=>{
        cur.g[i]=gn.disc?(+inp.value+0.5)/gn.disc.length:(+inp.value)/1000;
        gv.textContent=fmtGene(gn,cur.g[i]);
        if(heard)heard=null;
        applyDNA(false);updateHeardUI();radDirty=true;
      });
      row.append(top,inp);body.append(row);
      sliders[i]=inp;gvals[i]=gv;
    });
    box.append(body);host.append(box);
  });
})();
function fmtGene(gn,v){return gn.disc?gn.disc[discIdx(gn,v)]:gn.fmt(real(gn,v))}
function refreshSliders(){
  GENES.forEach((gn,i)=>{
    sliders[i].value=gn.disc?discIdx(gn,cur.g[i]):Math.round(cur.g[i]*1000);
    gvals[i].textContent=fmtGene(gn,cur.g[i]);
  });
}
function refreshDirty(){
  const saved=cur&&cur.id?lib.find(p=>p.id===cur.id):null;
  const dirty=!!heard||!saved||saved.code!==toHex(cur.g)||saved.name!==(cur.name||'');
  $('#dirty').textContent=dirty?'●':'';
  $('#dirty').title=dirty?(heard?'Hörprobe – noch kein Seed':saved?'Ungespeicherte Änderungen':'Noch nicht gespeichert'):'';
}
function refreshAll(){
  $('#nm').value=cur.name||'';
  $('#code').value=toHex(playG());
  refreshSliders();refreshDirty();renderLib();
}

/* ───────── DNA-Sequenz + Kosten ───────── */
const dnaCv=$('#dna'),dnaCtx=dnaCv.getContext('2d');
const DGROUPS=[];GENES.forEach((gn,i)=>{let q=DGROUPS.find(x=>x.n===gn.grp);if(!q)DGROUPS.push(q={n:gn.grp,ix:[]});q.ix.push(i)});
const CELL=19,BLK=16;let dnaKey='';
function drawDNA(){
  if(!cur)return;
  const g=playG(),key=toHex(g)+toHex(cur.g);if(key===dnaKey)return;dnaKey=key;
  const c=dnaCtx;c.setTransform(2,0,0,2,0,0);c.clearRect(0,0,140,120);
  DGROUPS.forEach((gr,r)=>gr.ix.forEach((i,k)=>{
    const L=Math.round(58+g[i]*185);
    c.fillStyle='rgb('+L+','+L+','+Math.min(255,L+5)+')';c.fillRect(k*CELL,r*CELL,BLK,BLK);
    if(heard&&Math.abs(g[i]-cur.g[i])>0.06){c.fillStyle=L>150?'#16181b':'#f4f5f7';c.fillRect(k*CELL+6,r*CELL+6,4,4)}
  }));
  const e=estimateCost(computeParams(g));
  $('#cfill').style.width=(e.fill*100)+'%';$('#clabel').textContent='Last: '+e.label;$('#cdet').textContent=e.text;
}
dnaCv.addEventListener('mousemove',e=>{
  const r=dnaCv.getBoundingClientRect(),k=Math.floor((e.clientX-r.left)/CELL),row=Math.floor((e.clientY-r.top)/CELL);
  const gr=DGROUPS[row],i=gr&&gr.ix[k];
  dnaCv.title=i==null?'':gr.n+' · '+GENES[i].l+': '+fmtGene(GENES[i],playG()[i]);
});

/* ───────── Frame-Loop ───────── */
const sc=$('#scope'),sctx=sc.getContext('2d'),sbuf=new Float32Array(1024);
function frame(){
  if(ctx){
    const now=ctx.currentTime;
    let ch=false;
    while(stepQ.length&&stepQ[0].t<=now){curStep=stepQ.shift().s;ch=true}
    if(!seqOn&&!stepQ.length&&curStep!==-1){curStep=-1;ch=true}
    if(ch)rollDirty=true;
    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=voices.filter(v=>!v.dead&&v.t0<=now&&v.endT>now).length;
  }
  if(rollDirty)drawRoll();
  if(radDirty)drawRadial();
  drawDNA();
  if(dspLoad!=null){$('#dspbox').hidden=false;$('#dsp').textContent=Math.round(dspLoad*100)}
  requestAnimationFrame(frame);
}

/* ───────── Start ───────── */
loadStore();
{
  const start=['Pad','Keys','Pluck','Bell'][Math.floor(Math.random()*4)];
  cur=randomDNA(start);nameOf(cur);
  makeSpectrum();applyDNA(true);
  refreshAll();buildKeys();setHint();syncWheel();updateHeardUI();
  fitRad();fitRoll();
  loadFromHash();
  requestAnimationFrame(frame);
}
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}