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 & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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>