<!doctype html>

<html lang="en">

<head>

  <meta charset="utf-8" />

  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

  <meta name="theme-color" content="#0b0f15" />

  <title>SilenceandSoundwaves — Multi-Tone Generator (−32k…32k Hz) + MIDI</title>

  <link rel="canonical" href="https://silenceandsoundwaves.com/">

  <meta name="description" content="Generate precise tones (−32k…32k Hz), stack up to 64 oscillators, control log/linear frequency, per-voice phase, pan, and MIDI — all in your browser.">

  <!-- Open Graph / Twitter -->

  <meta property="og:type" content="website">

  <meta property="og:url" content="https://silenceandsoundwaves.com/">

  <meta property="og:title" content="SilenceandSoundwaves — Multi-Tone Generator">

  <meta property="og:description" content="High-precision web tone generator with MIDI, scopes, vectorscope, and per-voice control.">

  <meta property="og:image" content="https://silenceandsoundwaves.com/og-cover.png">

  <meta name="twitter:card" content="summary_large_image">

  <!-- Perf hint -->

  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

  <style>

    :root{ --bg:#0b0f15; --panel:#121826; --card:#0f1522; --text:#e6eefc; --muted:#9fb0c7; --border:#20304e; --accent:#9cc1ff; --good:#27c46a; --warn:#ff6b6b; --track:#1a2436; --track2:#2a3954; --thumb:#ebf1ff; }

    *{box-sizing:border-box}

    html,body{height:100%}

    body{margin:0;background:linear-gradient(180deg,#0b0f15,#0a0d14 50%,#0b0f15);color:var(--text);font:14px/1.45 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif}

    .brandbar{position:sticky;top:0;z-index:40;display:flex;align-items:center;gap:10px;padding:10px 16px;background:linear-gradient(180deg,#10182a,#0d1424);border-bottom:1px solid var(--border)}

    .brand{font-weight:800;color:var(--accent)}

    .wrap{padding:14px}

    .panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;box-shadow:0 8px 24px rgba(0,0,0,.25)}

    .head{display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:12px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,#141b2b,#10192a)}

    .controls{display:grid;grid-template-columns:repeat(12,1fr);gap:12px;padding:12px}

    .group{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:10px}

    .half{grid-column:span 6}.full{grid-column:span 12}

    .row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;margin:6px 0}

    button,select,input[type="number"],input[type="text"],input[type="color"],input[type="range"]{background:#0f1524;color:var(--text);border:1px solid var(--border);border-radius:10px;padding:8px 10px;font:inherit}

    button{cursor:pointer}

    .btn{display:inline-flex;gap:6px;align-items:center}

    .btn.good{background:linear-gradient(180deg,#29c46d,#1ca95a);border-color:#1a8d4b}

    .btn.warn{background:linear-gradient(180deg,#ff6b6b,#e25555);border-color:#b64646}

    .btn.alt{background:linear-gradient(180deg,#2d3e61,#1c2841);border-color:#2a3b60}

    .chip{padding:2px 8px;border-radius:999px;background:#17213a;border:1px solid var(--border);font-size:11px}

    /* High-contrast sliders */

    input[type="range"]{appearance:none;width:100%;height:34px;background:transparent}

    input[type="range"]::-webkit-slider-runnable-track{height:8px;background:linear-gradient(90deg,var(--track),var(--track2));border-radius:999px;border:1px solid #31466f}

    input[type="range"]::-moz-range-track{height:8px;background:linear-gradient(90deg,var(--track),var(--track2));border-radius:999px;border:1px solid #31466f}

    input[type="range"]::-webkit-slider-thumb{appearance:none;margin-top:-10px;width:24px;height:24px;border-radius:50%;background:var(--thumb);border:2px solid #3a5ea8;box-shadow:0 1px 8px rgba(157,193,255,.35)}

    input[type="range"]::-moz-range-thumb{width:24px;height:24px;border-radius:50%;background:var(--thumb);border:2px solid #3a5ea8;box-shadow:0 1px 8px rgba(157,193,255,.35)}

    .osc-wrap{display:grid;grid-template-columns:1fr 380px;gap:12px;padding:12px;border-top:1px solid var(--border);align-items:start}

    canvas{width:100%;height:260px;background:#0a0f19;border:1px solid var(--border);border-radius:12px}

    .voices{padding:12px}

    .table-view{overflow-x:auto}

    table{width:100%;border-collapse:separate;border-spacing:0 10px;min-width:1120px}

    thead th{font-size:12px;text-align:left;color:#b7c2d6;padding:0 10px}

    tbody tr{background:var(--card);border:1px solid var(--border)}

    tbody td{padding:10px;vertical-align:middle}

    tbody tr>td:first-child{border-top-left-radius:10px;border-bottom-left-radius:10px}

    tbody tr>td:last-child{border-top-right-radius:10px;border-bottom-right-radius:10px}

    .mini{width:100%;height:60px;border-radius:8px;background:#0a0f19;border:1px solid var(--border)}

    .cell-vert{display:grid;grid-template-rows:auto auto;gap:8px}

    .cell-top{display:grid;grid-template-columns:118px 56px auto;gap:8px;align-items:center}

    .pair-vert{display:grid;grid-template-rows:auto auto;gap:8px}

    .pair-top{display:grid;grid-template-columns:90px 40px 100px;gap:8px;align-items:center}

    .pair-top-pan{display:grid;grid-template-columns:90px 40px;gap:8px;align-items:center}

    .cards-view{display:grid;gap:12px}

    .cards-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:12px}

    .vcard{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:10px;display:grid;gap:8px}

    .vhead{display:flex;gap:8px;align-items:center}

    .idx{font-weight:700;opacity:.9;width:2ch;text-align:right}

    .label{font-size:12px;color:#b8c6dd}

    @media (max-width: 920px){

      .controls{grid-template-columns:1fr}

      .half,.full{grid-column:1/-1}

      .osc-wrap{grid-template-columns:1fr}

      canvas{height:220px}

      .cards-grid{grid-template-columns:1fr}

    }

  </style>


  <!-- JSON-LD: WebApplication + Organization + FAQ -->

  <script type="application/ld+json">

  {

    "@context":"https://schema.org",

    "@type":["WebApplication","SoftwareApplication"],

    "name":"SilenceandSoundwaves",

    "url":"https://silenceandsoundwaves.com/",

    "applicationCategory":"MultimediaApplication",

    "operatingSystem":"WEB",

    "description":"Multi-tone generator (−32k…32k Hz) with up to 64 oscillators, MIDI input, log/linear scaling, per-voice phase, oscilloscopes, and vectorscope.",

    "offers":{"@type":"Offer","price":"0","priceCurrency":"USD"}

  }

  </script>

  <script type="application/ld+json">

  {

    "@context":"https://schema.org",

    "@type":"Organization",

    "name":"SilenceandSoundwaves",

    "url":"https://silenceandsoundwaves.com/",

    "logo":"https://silenceandsoundwaves.com/logo.png",

    "sameAs":[]

  }

  </script>

  <script type="application/ld+json">

  {

    "@context":"https://schema.org",

    "@type":"FAQPage",

    "mainEntity":[

      {"@type":"Question","name":"Can I generate ultrasonic or sub-audible tones?","acceptedAnswer":{"@type":"Answer","text":"Yes. The range is −32 kHz to +32 kHz; negative values behave as phase inversion."}},

      {"@type":"Question","name":"Is it safe for headphones?","acceptedAnswer":{"@type":"Answer","text":"Use moderate volume and avoid long exposure. High levels may cause hearing damage."}}

    ]

  }

  </script>

</head>

<body>

  <div class="brandbar"><div class="brand">SilenceandSoundwaves</div></div>


  <div class="wrap">

    <div class="panel">

      <div class="head">

        <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">

          <button id="audioToggle" class="btn good">▶ Enable Audio</button>

          <button id="masterStartStop" class="btn alt">Stop All</button>

          <button id="globalToggle" class="btn warn">Deactivate All</button>

          <button id="masterReset" class="btn alt">↺ Master Reset</button>

          <span class="chip" id="ctxState">Context: suspended</span>

        </div>

        <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">

          <span class="label">Voices</span>

          <input id="voiceCount" type="number" min="1" max="64" value="8" style="width:70px">

          <button id="applyCount" class="btn">Apply</button>

          <button id="add4" class="btn">+4</button>

          <button id="remove1" class="btn">−1</button>

          <span class="label" style="margin-left:8px">Layout</span>

          <select id="layoutMode"><option value="table" selected>Table</option><option value="cards">Cards</option></select>

        </div>

      </div>


      <div class="controls">

        <div class="group half">

          <h3 style="margin:0 0 6px">Master</h3>

          <div class="row"><label>Master Volume</label><input id="masterVolume" type="range" min="0" max="1" step="0.001" value="0.8"></div>

          <div class="row"><label>Master Mute</label><button id="masterMute" class="btn warn">Mute</button></div>

          <div class="row"><label>Frequency Scale</label>

            <select id="freqScale"><option value="log" selected>Logarithmic</option><option value="linear">Linear</option></select>

          </div>

        </div>


        <div class="group half">

          <h3 style="margin:0 0 6px">MIDI</h3>

          <div style="display:grid;grid-template-columns:repeat(12,1fr);gap:8px;align-items:center">

            <button id="midiToggle" class="btn good" style="grid-column:span 2">Enable MIDI</button>

            <span class="label" style="grid-column:span 1;justify-self:end">Input</span>

            <select id="midiIn" style="grid-column:span 4"><option value="">(no devices)</option></select>

            <span class="label" style="grid-column:span 1;justify-self:end">Base Vol</span>

            <input id="midiBaseVol" type="range" min="0" max="100" step="1" value="60" style="grid-column:span 2">

            <span class="label" style="grid-column:span 1;justify-self:end">Octave</span>

            <select id="midiTranspose" style="grid-column:span 1"><option value="-2">−2</option><option value="-1">−1</option><option value="0" selected>0</option><option value="1">+1</option><option value="2">+2</option></select>

            <span id="midiStatus" class="chip" style="grid-column:span 1;justify-self:end">MIDI: idle</span>

          </div>

          <details style="margin-top:8px">

            <summary>Advanced MIDI</summary>

            <div style="display:grid;grid-template-columns:repeat(12,1fr);gap:8px;align-items:center;margin-top:8px">

              <span class="label" style="grid-column:span 1;justify-self:end">Channel</span>

              <select id="midiChannel" style="grid-column:span 1"><option value="0" selected>All</option><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option><option>6</option><option>7</option><option>8</option><option>9</option><option>10</option><option>11</option><option>12</option><option>13</option><option>14</option><option>15</option><option>16</option></select>

              <span class="label" style="grid-column:span 1;justify-self:end">Bend ± (st)</span>

              <input id="midiBend" type="number" value="2" min="0" max="24" step="1" style="grid-column:span 1">

              <span class="label" style="grid-column:span 1;justify-self:end">Vel→Vol</span>

              <input id="midiVelToVol" type="checkbox" checked style="grid-column:span 1">

            </div>

          </details>

        </div>

      </div>


      <!-- Oscilloscope + Vectorscope row -->

      <div class="osc-wrap">

        <div>

          <div style="display:flex;align-items:center;gap:8px;justify-content:space-between;margin-bottom:8px">

            <div class="label"><strong>Oscilloscope</strong></div>

            <div style="display:flex;gap:8px;align-items:center">

              <span class="label">Scope FPS</span>

              <select id="masterFps"><option>24</option><option>30</option><option selected>60</option><option>50</option><option>120</option><option>144</option><option>240</option></select>

              <span class="label">Mini Scopes FPS</span>

              <select id="miniFps"><option>24</option><option selected>30</option><option>50</option><option>60</option><option>120</option><option>144</option><option>240</option></select>

              <span class="label">Color</span><input type="color" id="scopeColor" value="#9cc1ff">

            </div>

          </div>

          <canvas id="oscilloscope" height="260"></canvas>

        </div>

        <div>

          <div style="display:flex;align-items:center;gap:8px;justify-content:space-between;margin-bottom:8px">

            <div class="label"><strong>Vectorscope (XY)</strong></div>

            <div style="display:flex;gap:8px;align-items:center">

              <span class="label">Color</span><input type="color" id="vectorColor" value="#8bd0ff">

            </div>

          </div>

          <canvas id="vectorscope" height="260"></canvas>

        </div>

      </div>


      <!-- Table / Cards -->

      <div class="voices table-view" id="voicesTable">

        <table>

          <thead>

            <tr>

              <th>#</th><th>Mini Scope</th><th>Start/Stop</th><th>Wave</th>

              <th>Frequency (Slider / Hz)</th><th>Phase (°)</th>

              <th>Volume (0–100, Mute)</th><th>Pan (−100…100)</th>

              <th>Pulse %</th><th>State</th>

            </tr>

          </thead>

          <tbody id="voiceBody"></tbody>

        </table>

      </div>


      <div class="voices cards-view" id="voiceCards" hidden>

        <div class="cards-grid" id="cardsGrid"></div>

      </div>


      <!-- SEO content -->

      <div style="padding:14px" class="group full">

        <h2>What this tone generator does</h2>

        <ul>

          <li>Generate frequencies from −32,000 to +32,000 Hz, with <strong>log or linear</strong> control.</li>

          <li>Stack up to 64 oscillators with per-voice <strong>volume, pan, phase, waveform</strong> (sine/triangle/square/saw/pulse).</li>

          <li>Visualize audio using a <strong>master oscilloscope</strong>, per-voice <strong>mini scopes</strong>, and a <strong>vectorscope</strong> (XY).</li>

          <li>Connect a controller via <strong>Web MIDI</strong> (note on/off, velocity→volume, pitch bend, sustain, channel filter).</li>

        </ul>

        <h2>How to use it</h2>

        <ol>

          <li>Click <em>Enable Audio</em> or move any frequency slider.</li>

          <li>Voices start unmuted at 50% volume; voice #1 is pre-set to <strong>440 Hz</strong>.</li>

          <li>Switch waveforms or select <em>Pulse</em> and adjust <em>Pulse %</em>.</li>

          <li>Optional: Enable MIDI and play your controller.</li>

        </ol>

        <h2>FAQ</h2>

        <p><strong>Can I generate ultrasonic or sub-audible tones?</strong> Yes, the range includes negative/positive values; negative behaves as phase inversion.</p>

        <p><strong>Is it safe for headphones?</strong> Keep volumes moderate; high levels or long exposure can cause damage.</p>

      </div>

    </div>

  </div>


  <!-- Footer links -->

  <footer style="max-width:1100px;margin:24px auto 32px;padding:0 16px;color:#9fb0c7">

    <div style="display:flex;gap:14px;flex-wrap:wrap;justify-content:space-between">

      <div>© SilenceandSoundwaves</div>

      <nav style="display:flex;gap:14px">

        <a href="/learn/tone-generator/" style="color:#9cc1ff">What is a Tone Generator?</a>

        <a href="/learn/midi/" style="color:#9cc1ff">Using Web MIDI</a>

        <a href="/privacy/" style="color:#9cc1ff">Privacy</a>

      </nav>

    </div>

  </footer>


  <script>

  // ---------- UI refs ----------

  const UI = {

    tableWrap: document.getElementById('voicesTable'),

    cardsWrap: document.getElementById('voiceCards'),

    cardsGrid: document.getElementById('cardsGrid'),

    body: document.getElementById('voiceBody'),

    audioToggle: document.getElementById('audioToggle'),

    masterStartStop: document.getElementById('masterStartStop'),

    globalToggle: document.getElementById('globalToggle'),

    masterReset: document.getElementById('masterReset'),

    masterVolume: document.getElementById('masterVolume'),

    masterMute: document.getElementById('masterMute'),

    voiceCount: document.getElementById('voiceCount'),

    applyCount: document.getElementById('applyCount'),

    add4: document.getElementById('add4'),

    remove1: document.getElementById('remove1'),

    layoutMode: document.getElementById('layoutMode'),

    ctxState: document.getElementById('ctxState'),

    scope: document.getElementById('oscilloscope'),

    vectorscope: document.getElementById('vectorscope'),

    masterFps: document.getElementById('masterFps'),

    miniFps: document.getElementById('miniFps'),

    scopeColor: document.getElementById('scopeColor'),

    vectorColor: document.getElementById('vectorColor'),

    freqScale: document.getElementById('freqScale'),

    // MIDI

    midiToggle: document.getElementById('midiToggle'),

    midiIn: document.getElementById('midiIn'),

    midiBaseVol: document.getElementById('midiBaseVol'),

    midiTranspose: document.getElementById('midiTranspose'),

    midiStatus: document.getElementById('midiStatus'),

    midiChannel: document.getElementById('midiChannel'),

    midiBend: document.getElementById('midiBend'),

    midiVelToVol: document.getElementById('midiVelToVol'),

  };


  // ---------- Audio graph ----------

  let AC=null, masterGain=null;

  let analyserT=null, splitter=null, analyserL=null, analyserR=null;

  let globalActive=true;

  let voices=[];

  const MAX_VOICES=64;

  let currentLayout='table';

  let scopeIntervalId=null, miniIntervalId=null, vectorIntervalId=null;


  // Frequency scale

  const F_MIN=1, F_MAX=32000, LOG_SPAN=Math.log(F_MAX/F_MIN);

  let freqScaleMode='log';

  const clamp=(v,lo,hi)=> Math.min(hi,Math.max(lo,v));


  function sliderToFreqLog(sl){

    const s=Math.max(-100,Math.min(100,parseFloat(sl)));

    if(Math.abs(s)<1e-4) return 0;

    const t=Math.abs(s)/100, mag=F_MIN*Math.exp(LOG_SPAN*t);

    return Math.sign(s)*Math.min(mag,F_MAX);

  }

  function freqToSliderLog(hz){

    const f=Math.max(-F_MAX,Math.min(F_MAX,parseFloat(hz)||0));

    if(f===0) return 0;

    const t=Math.log(Math.max(F_MIN,Math.abs(f))/F_MIN)/LOG_SPAN;

    return Math.sign(f)*(t*100);

  }

  function sliderSpec(){

    return (freqScaleMode==='log')

      ? {min:-100,max:100,step:0.1,toHz:sliderToFreqLog,fromHz:freqToSliderLog}

      : {min:-F_MAX,max:F_MAX,step:1,toHz:(x)=>clamp(parseFloat(x)||0,-F_MAX,F_MAX),fromHz:(hz)=>clamp(parseFloat(hz)||0,-F_MAX,F_MAX)};

  }

  function applyFreqSliderSpec(input){ const sp=sliderSpec(); input.min=sp.min; input.max=sp.max; input.step=sp.step; }


  function handleResize(){

    const dpr=Math.max(1,window.devicePixelRatio||1);

    const r=UI.scope.getBoundingClientRect(); UI.scope.width=Math.max(600,Math.floor(r.width*dpr)); UI.scope.height=Math.floor(r.height*dpr);

    const rv=UI.vectorscope.getBoundingClientRect(); UI.vectorscope.width=Math.max(300,Math.floor(rv.width*dpr)); UI.vectorscope.height=Math.floor(rv.height*dpr);

    for(const v of voices){ if(!v.miniCanvas) continue; const rr=v.miniCanvas.getBoundingClientRect(); v.miniCanvas.width=Math.max(200,Math.floor(rr.width*dpr)); v.miniCanvas.height=Math.floor(rr.height*dpr); }

  }


  function drawStaticGrids(){

    const sctx=UI.scope.getContext('2d'); const W=UI.scope.width,H=UI.scope.height;

    sctx.clearRect(0,0,W,H); sctx.strokeStyle='#1f2c45'; sctx.lineWidth=1; sctx.beginPath();

    for(let x=0;x<=W;x+=W/10){sctx.moveTo(x,0);sctx.lineTo(x,H);} for(let y=0;y<=H;y+=H/4){sctx.moveTo(0,y);sctx.lineTo(W,y);} sctx.stroke();


    const vctx=UI.vectorscope.getContext('2d'); const WV=UI.vectorscope.width, HV=UI.vectorscope.height;

    vctx.clearRect(0,0,WV,HV); vctx.strokeStyle='#1f2c45'; vctx.lineWidth=1; vctx.beginPath();

    for(let x=0;x<=WV;x+=WV/4){vctx.moveTo(x,0);vctx.lineTo(x,HV);} for(let y=0;y<=HV;y+=HV/4){vctx.moveTo(0,y);vctx.lineTo(WV,y);} vctx.stroke();

  }


  function ensureAC({startViz=true}={}){

    if(AC) return;

    AC=new (window.AudioContext||window.webkitAudioContext)();

    masterGain=AC.createGain(); masterGain.gain.value=parseFloat(UI.masterVolume.value);


    analyserT=AC.createAnalyser(); analyserT.fftSize=2048; analyserT.smoothingTimeConstant=0.75;

    splitter=AC.createChannelSplitter(2);

    analyserL=AC.createAnalyser(); analyserL.fftSize=1024; analyserL.smoothingTimeConstant=0.7;

    analyserR=AC.createAnalyser(); analyserR.fftSize=1024; analyserR.smoothingTimeConstant=0.7;


    masterGain.connect(splitter);

    splitter.connect(analyserL,0);

    splitter.connect(analyserR,1);

    masterGain.connect(analyserT);

    masterGain.connect(AC.destination);


    AC.onstatechange=()=> UI.ctxState.textContent='Context: '+AC.state;

    UI.ctxState.textContent='Context: '+AC.state;


    if(!startViz){ drawStaticGrids(); } else { startVisualizers(); }

  }

  async function resumeIfSuspended(){

    if(!AC) ensureAC({startViz:true});

    if(AC.state!=='running'){

      await AC.resume();

      startVisualizers();

      UI.audioToggle.textContent=' Suspend Audio';

      UI.audioToggle.className='btn warn';

    }

  }


  function createPanner(){

    if(AC.createStereoPanner){

      const sp=AC.createStereoPanner(); sp.pan.value=0;

      return {node:sp, setPan:(v)=>sp.pan.setTargetAtTime(v,AC.currentTime,0.01)};

    }

    const pn=AC.createPanner(); pn.panningModel='equalpower'; pn.setPosition(0,0,1);

    return {node:pn, setPan:(v)=>pn.setPosition(v,0,1)};

  }


  // ---------- Voice ----------

  class Voice{

    constructor(index){

      this.index=index;

      this.inverter=AC.createGain(); this.inverter.gain.value=1;

      this.delay=AC.createDelay(1.0); this.delay.delayTime.value=0;

      this.gain=AC.createGain(); this.gain.gain.value=0.5;

      const p=createPanner(); this.panner=p.node; this._setPan=p.setPan;


      this.analyser=AC.createAnalyser(); this.analyser.fftSize=512; this.analyser.smoothingTimeConstant=0.7;

      this.scopeData=new Uint8Array(this.analyser.fftSize);


      this.inverter.connect(this.delay); this.delay.connect(this.gain); this.gain.connect(this.panner).connect(masterGain);

      this.gain.connect(this.analyser);


      this.osc=null; this.running=false;

      this.waveType='sine'; this.pulseWidth=0.5;

      this.freq=0; this.volPct=50; this.panPct=0; this.muted=false;

      this.phaseDeg=0; this._persistedVolPct=50;

      this.miniCanvas=null; this.ui={};

      this.midi={note:null,channel:null};


      this.startOsc(); this.applyAll();

    }

    _mapWave(t){ return ({sine:'sine', triangle:'triangle', square:'square', saw:'sawtooth'})[t]||'sine' }

    _applyPeriodic(){

      if(this.waveType!=='pulse'||!this.osc) return;

      const N=64,duty=clamp(this.pulseWidth,0.05,0.95);

      const re=new Float32Array(N),im=new Float32Array(N);

      for(let n=1;n<N;n++){ const k=n; re[n]=(Math.sin(Math.PI*k*duty)/(Math.PI*k))*2; }

      const pw=AC.createPeriodicWave(re,im,{disableNormalization:true});

      this.osc.setPeriodicWave(pw);

    }

    startOsc(){

      if(this.running) return;

      this.osc=AC.createOscillator();

      if(this.waveType==='pulse'){ this.osc.type='custom'; this._applyPeriodic(); } else { this.osc.type=this._mapWave(this.waveType); }

      this.osc.frequency.value=Math.max(1,Math.min(F_MAX,Math.abs(this.freq))||1);

      this.osc.connect(this.inverter);

      this.osc.start();

      this.inverter.gain.setValueAtTime(this.freq>=0?1:-1, AC.currentTime);

      this._updatePhaseDelay();

      this.running=true;

      this._applyGain();

    }

    stopOsc(){ if(!this.running||!this.osc) return; try{ this.osc.stop(); this.osc.disconnect(); }catch{} this.osc=null; this.running=false; this._applyGain(0); }

    setWave(t){ this.waveType=t; if(this.osc){ if(t==='pulse'){ this._applyPeriodic(); } else this.osc.type=this._mapWave(t); } }

    setPulseWidth(p){ this.pulseWidth=clamp(p,0.05,0.95); if(this.waveType==='pulse') this._applyPeriodic(); }

    setFreqHz(hz){ this.freq=clamp(hz,-F_MAX,F_MAX); const s=Math.sign(this.freq), mag=Math.max(1,Math.abs(this.freq)); if(!this.running) return; this.inverter.gain.setTargetAtTime(s>=0?1:-1,AC.currentTime,0.01); if(this.osc) this.osc.frequency.setTargetAtTime(mag,AC.currentTime,0.01); this._updatePhaseDelay(); this._applyGain(); }

    setVolPct(p){ this.volPct=clamp(p,0,100); if(!this.muted && globalActive) this._applyGain(); this._persistedVolPct=this.volPct; }

    _applyGain(val){ const target=(typeof val==='number')?val:((this.muted||!globalActive)?0:this.volPct/100); this.gain.gain.setTargetAtTime(target,AC.currentTime,0.01); }

    setMuted(m){ this.muted=m; this._applyGain(); }

    setPanPct(p){ this.panPct=clamp(p,-100,100); this._setPan(this.panPct/100); }

    setPhaseDeg(deg){ this.phaseDeg=((parseFloat(deg)||0)%360+360)%360; this._updatePhaseDelay(); }

    _updatePhaseDelay(){ const f=Math.max(1,Math.abs(this.freq)); const tau=(this.phaseDeg/360)/f; this.delay.delayTime.setTargetAtTime(tau,AC.currentTime,0.01); }

    applyAll(){ this.setMuted(this.muted); this.setVolPct(this.volPct); this.setPanPct(this.panPct); this.setPhaseDeg(this.phaseDeg); this.setFreqHz(this.freq); if(!this.running) this.startOsc(); }

  }


  function waveColor(w){ switch(w){ case 'triangle':return '#32d583'; case 'square':return '#ff6b6b'; case 'saw':return '#ffd24d'; case 'pulse':return '#a48cff'; default:return '#9cc1ff'; } }


  // ---------- Voice UI ----------

  function buildCommonControls(v){

    const waveSel=document.createElement('select'); ['sine','triangle','square','saw','pulse'].forEach(w=>{ const o=document.createElement('option'); o.textContent=w; waveSel.appendChild(o); }); waveSel.value=v.waveType;

    waveSel.oninput=()=>{ v.setWave(waveSel.value); if(v.ui?.pulseWrap) v.ui.pulseWrap.style.display=(waveSel.value==='pulse')?'grid':'none'; };


    const startBtn=document.createElement('button'); startBtn.className=v.running?'btn alt':'btn good'; startBtn.textContent=v.running?'Stop':'Start';

    startBtn.onclick=async ()=>{ await resumeIfSuspended(); if(v.running){ v.stopOsc(); startBtn.textContent='Start'; startBtn.className='btn good'; } else { v.startOsc(); startBtn.textContent='Stop'; startBtn.className='btn alt'; } updateMasterStartStopLabel(); renderState(); };


    // frequency

    const freqNum=document.createElement('input'); freqNum.type='number'; freqNum.step='any'; freqNum.min=-F_MAX; freqNum.max=F_MAX; freqNum.value=String(v.freq);

    const freqReset=document.createElement('button'); freqReset.className='btn'; freqReset.textContent='↺';

    const hzChip=document.createElement('div'); hzChip.className='chip'; hzChip.textContent=(v.freq%1? v.freq: Math.round(v.freq))+' Hz';

    const freqSlider=document.createElement('input'); freqSlider.type='range'; applyFreqSliderSpec(freqSlider); freqSlider.value=sliderSpec().fromHz(v.freq);


    const sp=sliderSpec();

    const syncFreqFromSlider=async ()=>{ await resumeIfSuspended(); const hz=sp.toHz(freqSlider.value); v.setFreqHz(hz); const disp=(String(freqNum.value).includes('.')? hz.toFixed(3): Math.round(hz)); freqNum.value=String(disp); hzChip.textContent=disp+' Hz'; renderState(); };

    const syncFreqFromNum=async ()=>{ await resumeIfSuspended(); const raw=parseFloat(freqNum.value||'0'); const hz=clamp(raw,-F_MAX,F_MAX); v.setFreqHz(hz); freqSlider.value=sp.fromHz(hz); const disp=(String(freqNum.value).includes('.')? hz: Math.round(hz)); hzChip.textContent=disp+' Hz'; renderState(); };

    const resetFreq=async ()=>{ await resumeIfSuspended(); freqSlider.value=0; freqNum.value='0'; v.setFreqHz(0); hzChip.textContent='0 Hz'; renderState(); };

    freqSlider.oninput=syncFreqFromSlider; freqNum.onchange=syncFreqFromNum; freqReset.onclick=resetFreq;

    freqNum.addEventListener('focus', ()=>resumeIfSuspended(), {once:true});


    // phase

    const phaseNum=document.createElement('input'); phaseNum.type='number'; phaseNum.step='1'; phaseNum.min=0; phaseNum.max=360; phaseNum.value=String(v.phaseDeg|0);

    const phaseReset=document.createElement('button'); phaseReset.className='btn'; phaseReset.textContent='↺';

    const phaseSlider=document.createElement('input'); phaseSlider.type='range'; phaseSlider.min=0; phaseSlider.max=360; phaseSlider.step=1; phaseSlider.value=String(v.phaseDeg|0);

    const syncPhase=async (from)=>{ await resumeIfSuspended(); const pv=clamp(parseFloat(from.value||'0')||0,0,360); phaseSlider.value=pv; phaseNum.value=pv; v.setPhaseDeg(pv); };

    phaseSlider.oninput=()=>syncPhase(phaseSlider); phaseNum.onchange=()=>syncPhase(phaseNum); phaseReset.onclick=()=>{ phaseSlider.value=0; phaseNum.value='0'; v.setPhaseDeg(0); };


    // volume 0..100

    const volNum=document.createElement('input'); volNum.type='number'; volNum.step='any'; volNum.min=0; volNum.max=100; volNum.value=String(v.volPct);

    const volReset=document.createElement('button'); volReset.className='btn'; volReset.textContent='↺';

    const muteChk=document.createElement('input'); muteChk.type='checkbox'; muteChk.checked=v.muted;

    const volSlider=document.createElement('input'); volSlider.type='range'; volSlider.min=0; volSlider.max=100; volSlider.step=1; volSlider.value=String(v.volPct);

    const syncVol=(src)=>{ const vv=clamp(parseFloat(src.value||'0')||0,0,100); volSlider.value=vv; volNum.value=String(src===volNum && String(src.value).includes('.')? vv: Math.round(vv)); v.setVolPct(vv); renderState(); };

    volSlider.oninput=()=>syncVol(volSlider); volNum.onchange=()=>syncVol(volNum); volReset.onclick=()=>{ volSlider.value=50; volNum.value='50'; v.setVolPct(50); };

    muteChk.onchange=()=>{ v.setMuted(muteChk.checked); renderState(); };


    // pan −100..100

    const panNum=document.createElement('input'); panNum.type='number'; panNum.step='any'; panNum.min=-100; panNum.max=100; panNum.value=String(v.panPct);

    const panReset=document.createElement('button'); panReset.className='btn'; panReset.textContent='↺';

    const panSlider=document.createElement('input'); panSlider.type='range'; panSlider.min=-100; panSlider.max=100; panSlider.step=1; panSlider.value=String(v.panPct);

    const syncPan=(src)=>{ const pv=clamp(parseFloat(src.value||'0')||0,-100,100); panSlider.value=pv; panNum.value=String(src===panNum && String(src.value).includes('.')? pv: Math.round(pv)); v.setPanPct(pv); };

    panSlider.oninput=()=>syncPan(panSlider); panNum.onchange=()=>syncPan(panNum); panReset.onclick=()=>{ panSlider.value=0; panNum.value='0'; v.setPanPct(0); };


    // Pulse width

    const pulseNum=document.createElement('input'); pulseNum.type='number'; pulseNum.step='1'; pulseNum.min=5; pulseNum.max=95; pulseNum.value=String(Math.round(v.pulseWidth*100));

    const pulseSlider=document.createElement('input'); pulseSlider.type='range'; pulseSlider.min=5; pulseSlider.max=95; pulseSlider.step=1; pulseSlider.value=pulseNum.value;

    const pulseWrap=document.createElement('div'); pulseWrap.className='pair-vert'; pulseWrap.style.display=(v.waveType==='pulse')?'grid':'none';

    const syncPulse=(src)=>{ const pct=clamp(parseFloat(src.value||'50')||50,5,95); pulseSlider.value=pct; pulseNum.value=pct; v.setPulseWidth(pct/100); };

    pulseSlider.oninput=()=>syncPulse(pulseSlider); pulseNum.onchange=()=>syncPulse(pulseNum);

    const pulseTop=document.createElement('div'); pulseTop.className='pair-top-pan'; pulseTop.append(pulseNum, document.createTextNode('')); pulseWrap.append(pulseTop,pulseSlider);


    // state renderer

    let renderState=()=>{};

    function renderFactory(labelEl){ return function(){ let label='Idle'; if(!v.running) label='Stopped'; else if(v.muted||v.volPct===0||v.freq===0) label=v.muted?'Muted':'Idle'; else label='Active'; labelEl.textContent=label; } }


    v.ui.pulseWrap = pulseWrap;


    return {waveSel,startBtn,freqNum,freqReset,hzChip,freqSlider,phaseNum,phaseReset,phaseSlider,volNum,volReset,muteChk,volSlider,panNum,panReset,panSlider,pulseWrap,pulseSlider,pulseNum,

      setStateRenderer(fn){ renderState=fn }, renderFactory};

  }


  function makeVoiceRow(v){

    const W=buildCommonControls(v);

    const tr=document.createElement('tr');


    const tdIdx=document.createElement('td'); tdIdx.dataset.rowIdx=''; tdIdx.textContent=String(v.index).padStart(2,'0'); tr.appendChild(tdIdx);

    const tdMini=document.createElement('td'); const mini=document.createElement('canvas'); mini.className='mini'; tdMini.appendChild(mini); tr.appendChild(tdMini); v.miniCanvas=mini;


    const tdStart=document.createElement('td'); tdStart.appendChild(W.startBtn); tr.appendChild(tdStart);

    const tdWave=document.createElement('td'); tdWave.appendChild(W.waveSel); tr.appendChild(tdWave);


    const tdFreq=document.createElement('td'); tdFreq.style.minWidth='520px';

    const freqWrap=document.createElement('div'); freqWrap.className='cell-vert';

    const freqTop=document.createElement('div'); freqTop.className='cell-top'; const hzChip=W.hzChip; hzChip.style.justifySelf='end';

    freqTop.append(W.freqNum, W.freqReset, hzChip); freqWrap.append(freqTop, W.freqSlider); tdFreq.appendChild(freqWrap); tr.appendChild(tdFreq);


    const tdPhase=document.createElement('td'); const phaseWrap=document.createElement('div'); phaseWrap.className='pair-vert'; const phaseTop=document.createElement('div'); phaseTop.className='pair-top-pan'; phaseTop.append(W.phaseNum, W.phaseReset); phaseWrap.append(phaseTop, W.phaseSlider); tdPhase.appendChild(phaseWrap); tr.appendChild(tdPhase);


    const tdVol=document.createElement('td'); const volWrap=document.createElement('div'); volWrap.className='pair-vert';

    const volTop=document.createElement('div'); volTop.className='pair-top'; const muteLbl=document.createElement('label'); muteLbl.className='label'; muteLbl.style.display='inline-flex'; muteLbl.style.alignItems='center'; muteLbl.style.gap='6px'; muteLbl.append(W.muteChk, document.createTextNode('Mute'));

    volTop.append(W.volNum, W.volReset, muteLbl); volWrap.append(volTop, W.volSlider); tdVol.appendChild(volWrap); tr.appendChild(tdVol);


    const tdPan=document.createElement('td'); const panWrap=document.createElement('div'); panWrap.className='pair-vert'; const panTop=document.createElement('div'); panTop.className='pair-top-pan'; panTop.append(W.panNum, W.panReset); panWrap.append(panTop, W.panSlider); tdPan.appendChild(panWrap); tr.appendChild(tdPan);


    const tdPulse=document.createElement('td'); tdPulse.appendChild(W.pulseWrap); tr.appendChild(tdPulse);


    const tdState=document.createElement('td'); const stateCell=document.createElement('span'); stateCell.textContent='Active'; tdState.appendChild(stateCell); tr.appendChild(tdState);

    const renderStateFn=W.renderFactory(stateCell); W.setStateRenderer(renderStateFn); renderStateFn();


    v.ui={...v.ui, freqSlider:W.freqSlider,freqNum:W.freqNum,hzChip:W.hzChip};

    return tr;

  }


  function makeVoiceCard(v){

    const W=buildCommonControls(v);

    const card=document.createElement('div'); card.className='vcard';

    const head=document.createElement('div'); head.className='vhead';

    const idx=document.createElement('div'); idx.className='idx'; idx.dataset.rowIdx=''; idx.textContent=String(v.index).padStart(2,'0');

    const mini=document.createElement('canvas'); mini.className='mini'; v.miniCanvas=mini;

    head.append(idx,mini);


    const grid=document.createElement('div'); grid.className='cards-grid';

    grid.append(labeled('Start / Wave', row(W.startBtn,W.waveSel)));

    grid.append(labeled('Frequency (Hz)', stack(topRow(W.freqNum, W.freqReset, W.hzChip), W.freqSlider)));

    grid.append(labeled('Phase (°)', stack(topRow(W.phaseNum, W.phaseReset), W.phaseSlider)));

    grid.append(labeled('Volume (0–100)', stack(topRow(W.volNum, W.volReset, labelRight('Mute', W.muteChk)), W.volSlider)));

    grid.append(labeled('Pan (−100..100)', stack(topRow(W.panNum, W.panReset), W.panSlider)));

    grid.append(labeled('Pulse %', W.pulseWrap));


    const state=document.createElement('div'); state.className='chip'; grid.append(state);

    const renderStateFn=W.renderFactory(state); W.setStateRenderer(renderStateFn); renderStateFn();


    card.append(head, grid); return card;


    function labeled(txt, node){ const c=document.createElement('div'); const l=document.createElement('div'); l.className='label'; l.textContent=txt; c.append(l,node); return c; }

    function row(a,b){ const r=document.createElement('div'); r.style.display='flex'; r.style.gap='8px'; r.append(a,b); return r; }

    function topRow(){ const r=document.createElement('div'); r.style.display='flex'; r.style.gap='8px'; r.style.justifyContent='space-between'; [...arguments].forEach(x=>r.append(x)); return r; }

    function stack(){ const s=document.createElement('div'); s.className='cell-vert'; [...arguments].forEach(x=>s.append(x)); return s; }

    function labelRight(txt, input){ const w=document.createElement('label'); w.className='label'; w.style.display='inline-flex'; w.style.alignItems='center'; w.style.gap='6px'; w.append(input, document.createTextNode(txt)); return w; }

  }


  function rebuildVoicesView(){

    UI.body.innerHTML=''; UI.cardsGrid.innerHTML='';

    const useCards=(currentLayout==='cards');

    UI.tableWrap.hidden=useCards; UI.cardsWrap.hidden=!useCards;

    for(const v of voices){ const node=useCards? makeVoiceCard(v): makeVoiceRow(v); (useCards? UI.cardsGrid: UI.body).appendChild(node); }

    refreshRowNumbers(); handleResize();

  }

  function refreshRowNumbers(){ [...document.querySelectorAll('[data-row-idx]')].forEach((el,i)=> el.textContent=String(i+1).padStart(2,'0')); }


  // ---------- Visualizers ----------

  function startVisualizers(){

    const scopeCtx = UI.scope.getContext('2d');

    const td = new Uint8Array(analyserT.frequencyBinCount);

    const drawGrid=(ctx,W,H)=>{ ctx.strokeStyle='#1f2c45'; ctx.lineWidth=1; ctx.beginPath(); for(let x=0;x<=W;x+=W/10){ctx.moveTo(x,0);ctx.lineTo(x,H);} for(let y=0;y<=H;y+=H/4){ctx.moveTo(0,y);ctx.lineTo(W,y);} ctx.stroke(); };

    const drawMaster=()=>{ const W=UI.scope.width,H=UI.scope.height; scopeCtx.clearRect(0,0,W,H); drawGrid(scopeCtx,W,H); analyserT.getByteTimeDomainData(td); scopeCtx.lineWidth=2; scopeCtx.strokeStyle=UI.scopeColor.value||'#9cc1ff'; scopeCtx.beginPath(); const slice=W/td.length; for(let i=0,x=0;i<td.length;i++,x+=slice){ const v=(td[i]-128)/128; const y=H/2 + v*(H/2-6); if(i===0) scopeCtx.moveTo(x,y); else scopeCtx.lineTo(x,y); } scopeCtx.stroke(); };

    const startMaster=()=>{ if(scopeIntervalId) clearInterval(scopeIntervalId); const fps=parseInt(UI.masterFps.value,10)||60; scopeIntervalId=setInterval(drawMaster, Math.max(8,Math.floor(1000/fps))); };


    function drawMini(){

      for(const v of voices){

        if(!v.miniCanvas) continue;

        const c=v.miniCanvas, ctx=c.getContext('2d'), W=c.width, H=c.height;

        ctx.clearRect(0,0,W,H);

        ctx.fillStyle='#081120'; ctx.fillRect(0,0,W,H);

        ctx.strokeStyle='#1f2c45'; ctx.lineWidth=1; ctx.beginPath(); for(let y=0;y<=H;y+=H/2){ ctx.moveTo(0,y); ctx.lineTo(W,y);} ctx.stroke();

        v.analyser.getByteTimeDomainData(v.scopeData);

        ctx.lineWidth=1.5; ctx.strokeStyle=waveColor(v.waveType); ctx.globalAlpha = (!v.running?0.35 : v.muted?0.55:1);

        ctx.beginPath(); const slice=W/v.scopeData.length;

        for(let i=0,x=0;i<v.scopeData.length;i++,x+=slice){ const val=(v.scopeData[i]-128)/128; const y=H/2 + val*(H/2-4); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); }

        ctx.stroke(); ctx.globalAlpha=1;

      }

    }

    const startMini=()=>{ if(miniIntervalId) clearInterval(miniIntervalId); const fps=parseInt(UI.miniFps.value,10)||30; miniIntervalId=setInterval(drawMini, Math.max(8,Math.floor(1000/fps))); };


    // vectorscope (XY)

    const vCtx=UI.vectorscope.getContext('2d'); const left=new Uint8Array(analyserL.frequencyBinCount); const right=new Uint8Array(analyserR.frequencyBinCount);

    const drawVector=()=>{ const W=UI.vectorscope.width,H=UI.vectorscope.height; vCtx.clearRect(0,0,W,H);

      vCtx.strokeStyle='#1f2c45'; vCtx.lineWidth=1; vCtx.beginPath(); for(let x=0;x<=W;x+=W/4){vCtx.moveTo(x,0);vCtx.lineTo(x,H);} for(let y=0;y<=H;y+=H/4){vCtx.moveTo(0,y);vCtx.lineTo(W,y);} vCtx.stroke();

      analyserL.getByteTimeDomainData(left); analyserR.getByteTimeDomainData(right);

      vCtx.lineWidth=1.5; vCtx.strokeStyle=UI.vectorColor.value||'#8bd0ff'; vCtx.beginPath();

      const N=Math.min(left.length,right.length); for(let i=0;i<N;i++){ const lx=(left[i]-128)/128, rx=(right[i]-128)/128; const x= W/2 + lx*(W/2-6); const y= H/2 - rx*(H/2-6); if(i===0) vCtx.moveTo(x,y); else vCtx.lineTo(x,y); }

      vCtx.stroke();

    };

    const startVector=()=>{ if(vectorIntervalId) clearInterval(vectorIntervalId); const fps=Math.max(24, parseInt(UI.masterFps.value,10)||60); vectorIntervalId=setInterval(drawVector, Math.max(8, Math.floor(1000/fps))); };


    startMaster(); startMini(); startVector();

    UI.masterFps.addEventListener('change', ()=>{ startMaster(); startVector(); });

    UI.miniFps.addEventListener('change', startMini);

  }


  function updateMasterStartStopLabel(){

    const allRunning=voices.length>0 && voices.every(v=>v.running);

    UI.masterStartStop.textContent = allRunning ? 'Stop All' : 'Start All';

    UI.masterStartStop.className = 'btn '+(allRunning?'alt':'good');

  }


  // ---------- Voice table/cards ----------

  function setVoiceCount(n){

    n=Math.max(1,Math.min(MAX_VOICES,n|0));

    while(voices.length<n) addVoice();

    while(voices.length>n) removeVoice();

    UI.voiceCount.value=n;

  }

  function addVoice(){

    if(!AC) ensureAC({startViz:false});

    if(voices.length>=MAX_VOICES) return;

    const v=new Voice(voices.length+1);

    voices.push(v);

    rebuildVoicesView();

    updateMasterStartStopLabel();

  }

  function removeVoice(){

    const v=voices.pop();

    if(v){ try{ v.stopOsc(); v.gain.disconnect(); v.panner.disconnect(); v.inverter.disconnect(); v.analyser.disconnect(); }catch{} }

    rebuildVoicesView(); updateMasterStartStopLabel();

  }


  // ---------- Wiring ----------

  UI.audioToggle.addEventListener('click', async ()=>{

    if(!AC) ensureAC({startViz:true});

    if(AC.state!=='running'){

      await AC.resume();

      startVisualizers();

      UI.audioToggle.textContent=' Suspend Audio';

      UI.audioToggle.className='btn warn';

    } else {

      await AC.suspend();

      [scopeIntervalId,miniIntervalId,vectorIntervalId].forEach(id=> id && clearInterval(id));

      UI.audioToggle.textContent='▶ Enable Audio';

      UI.audioToggle.className='btn good';

    }

  });

  UI.masterStartStop.addEventListener('click', async ()=>{

    await resumeIfSuspended();

    const allRunning=voices.length>0 && voices.every(v=>v.running);

    voices.forEach(v=> allRunning? v.stopOsc(): v.startOsc());

    updateMasterStartStopLabel();

  });

  UI.globalToggle.addEventListener('click', ()=>{

    globalActive=!globalActive;

    UI.globalToggle.textContent=globalActive?'Deactivate All':'Activate All';

    UI.globalToggle.className='btn '+(globalActive?'warn':'good');

    voices.forEach(v=> v._applyGain());

  });

  UI.masterVolume.addEventListener('input', ()=>{ if(!AC) return; masterGain.gain.setTargetAtTime(parseFloat(UI.masterVolume.value), AC.currentTime, 0.01); });

  let masterIsMuted=false, masterPrev=parseFloat(UI.masterVolume.value);

  UI.masterMute.addEventListener('click', ()=>{ if(!AC) return; masterIsMuted=!masterIsMuted; if(masterIsMuted){ masterPrev=parseFloat(UI.masterVolume.value); masterGain.gain.setTargetAtTime(0,AC.currentTime,0.01); UI.masterMute.textContent='Unmute'; UI.masterMute.className='btn good'; } else { masterGain.gain.setTargetAtTime(masterPrev,AC.currentTime,0.01); UI.masterMute.textContent='Mute'; UI.masterMute.className='btn warn'; }});

  UI.masterReset.addEventListener('click', ()=>{

    UI.masterVolume.value=0.8; if(AC) masterGain.gain.setTargetAtTime(0.8,AC.currentTime,0.01);

    masterIsMuted=false; UI.masterMute.textContent='Mute'; UI.masterMute.className='btn warn';

    globalActive=true; UI.globalToggle.textContent='Deactivate All'; UI.globalToggle.className='btn warn';

    UI.freqScale.value='log'; freqScaleMode='log';

    voices.forEach(v=>{ v.setWave('sine'); v.setPulseWidth(0.5); v.setFreqHz(0); v.setVolPct(50); v.setPanPct(0); v.setPhaseDeg(0); v.setMuted(false); if(!v.running) v.startOsc(); });

    updateMasterStartStopLabel();

  });

  UI.freqScale.addEventListener('change', ()=>{

    freqScaleMode=UI.freqScale.value;

    for(const v of voices){ if(!v.ui?.freqSlider) continue; applyFreqSliderSpec(v.ui.freqSlider); v.ui.freqSlider.value=sliderSpec().fromHz(v.freq); }

  });

  UI.layoutMode.addEventListener('change', ()=>{ currentLayout=UI.layoutMode.value; rebuildVoicesView(); });

  UI.applyCount.addEventListener('click', ()=> setVoiceCount(parseInt(UI.voiceCount.value||'1',10)));

  UI.add4.addEventListener('click', ()=> setVoiceCount(voices.length+4));

  UI.remove1.addEventListener('click', ()=> setVoiceCount(voices.length-1));


  // ---------- MIDI ----------

  let midiEnabled=false, midiAccess=null, midiInput=null;

  const midiState={ sustain:false, pendingOff:new Map(), pitchBend:new Array(16).fill(0) };


  function noteToHz(note, transposeOct=0, bendSemis=0){ const n=note + transposeOct*12 + bendSemis; return 440*Math.pow(2,(n-69)/12); }

  function allocateVoice(note, ch){

    let v = voices.find(v=> v.running && (v.muted || v.volPct===0 || v.freq===0));

    if(!v) v = voices[0];

    if(!v.running) v.startOsc();

    v.midi.note=note; v.midi.channel=ch; v.setMuted(false);

    return v;

  }

  function voicesForNote(ch,note){ return voices.filter(v=> v.midi.note===note && v.midi.channel===ch && v.running); }


  function handleNoteOn(ch, note, vel){

    const transpose=parseInt(UI.midiTranspose.value,10)||0;

    const bendRange=parseFloat(UI.midiBend?.value||0)||0;

    const bend=(midiState.pitchBend[ch-1]||0)*bendRange;

    const hz = noteToHz(note, transpose, bend);

    const v=allocateVoice(note,ch);

    const base=parseFloat(UI.midiBaseVol.value)||60;

    const scaled = (UI.midiVelToVol?.checked ?? true) ? base*(vel/127) : base;

    v.setVolPct(clamp(scaled,0,100));

    v.setFreqHz(Math.abs(hz));

  }

  function handleNoteOff(ch, note){

    if(midiState.sustain){ const k=`${ch}:${note}`; midiState.pendingOff.set(k,(midiState.pendingOff.get(k)||0)+1); return; }

    for(const v of voicesForNote(ch,note)){ v.setVolPct(0); v.setMuted(true); v.midi.note=null; v.midi.channel=null; }

  }

  function flushSustain(){

    for(const [k,count] of midiState.pendingOff.entries()){ const [chS,nS]=k.split(':').map(Number); for(let i=0;i<count;i++) handleNoteOff(chS,nS); midiState.pendingOff.delete(k); }

  }

  function onMIDIMessage(ev){

    const [status,d1,d2]=ev.data; const type=status & 0xF0; const ch=(status & 0x0F)+1;

    const filter=parseInt(UI.midiChannel.value,10)||0; if(filter!==0 && ch!==filter) return;

    if(type===0x90){ const vel=d2; if(vel===0){ handleNoteOff(ch,d1); return; } resumeIfSuspended(); handleNoteOn(ch,d1,vel); }

    else if(type===0x80){ handleNoteOff(ch,d1); }

    else if(type===0xE0){ const val14=(d2<<7)|d1; midiState.pitchBend[ch-1]=(val14-8192)/8192;

      const bendRange=parseFloat(UI.midiBend?.value||0)||0; const transpose=parseInt(UI.midiTranspose.value,10)||0;

      for(const v of voices.filter(v=>v.midi.channel===ch)){ if(v.midi.note!=null){ const hz=noteToHz(v.midi.note, transpose, midiState.pitchBend[ch-1]*bendRange); v.setFreqHz(Math.abs(hz)); } }

    } else if(type===0xB0){ if(d1===64){ const down=d2>=64; midiState.sustain=down; if(!down) flushSustain(); UI.midiStatus.textContent=down?'MIDI: sustain on':'MIDI: sustain off'; } }

  }


  async function enableMIDI(){

    const secure = (location.protocol === 'https:') || (location.hostname === 'localhost');

    if (!secure){ UI.midiStatus.textContent='MIDI: requires HTTPS (or localhost)'; return; }

    if (!('requestMIDIAccess' in navigator)){ UI.midiStatus.textContent='MIDI: not supported in this browser'; return; }

    try{

      midiAccess = await navigator.requestMIDIAccess({sysex:false});

      UI.midiStatus.textContent='MIDI: ready';

      function refresh(){

        UI.midiIn.innerHTML=''; const none=document.createElement('option'); none.value=''; none.textContent='(select device)'; UI.midiIn.appendChild(none);

        let first=null;

        midiAccess.inputs.forEach(input=>{ const o=document.createElement('option'); o.value=input.id; o.textContent=input.name + (input.manufacturer? ' — '+input.manufacturer:''); UI.midiIn.appendChild(o); if(!first) first=input; });

        if(first){ UI.midiIn.value=first.id; attachSelectedInput(); } else { UI.midiStatus.textContent='MIDI: no inputs found'; }

      }

      refresh(); midiAccess.onstatechange=refresh;

    }catch(e){ console.error('MIDI error',e); UI.midiStatus.textContent='MIDI: error (see console)'; }

  }

  function attachSelectedInput(){

    if(!midiAccess) return;

    if(midiInput){ midiInput.onmidimessage=null; midiInput=null; }

    const id=UI.midiIn.value; if(!id){ UI.midiStatus.textContent='MIDI: device not selected'; return; }

    midiAccess.inputs.forEach(inp=>{ if(inp.id===id) midiInput=inp; });

    if(midiInput){ midiInput.onmidimessage=onMIDIMessage; UI.midiStatus.textContent='MIDI: listening ('+midiInput.name+')'; }

  }

  UI.midiToggle.addEventListener('click', async ()=>{

    const was=midiEnabled; midiEnabled=!midiEnabled;

    if(!was && midiEnabled){ UI.midiToggle.textContent='Disable MIDI'; UI.midiToggle.className='btn warn'; await enableMIDI(); }

    else { UI.midiToggle.textContent='Enable MIDI'; UI.midiToggle.className='btn good'; if(midiInput) midiInput.onmidimessage=null; UI.midiStatus.textContent='MIDI: idle'; }

  });

  UI.midiIn.addEventListener('change', attachSelectedInput);


  // ---------- Init / lifecycle ----------

  window.addEventListener('resize', ()=>{ handleResize(); drawStaticGrids(); }, {passive:true});

  window.addEventListener('orientationchange', ()=> setTimeout(()=>{ handleResize(); drawStaticGrids(); }, 250), {passive:true});

  document.addEventListener('visibilitychange', ()=>{

    const hidden=document.hidden;

    [scopeIntervalId, miniIntervalId, vectorIntervalId].forEach(id=> hidden && id && clearInterval(id));

    if(!hidden && AC && AC.state==='running') startVisualizers();

  });


  function bootstrap(){

    ensureAC({startViz:false}); // create context, no timers yet

    AC.suspend(); UI.audioToggle.textContent='▶ Enable Audio'; UI.audioToggle.className='btn good';


    // Build voices (unmuted @ 50%), pre-populate voice #1 to 440 Hz

    setVoiceCount(8); // change to 1 if you want a single-voice default

    voices.forEach(v=>{ v.setVolPct(50); v.setMuted(false); v.setPanPct(0); if(!v.running) v.startOsc(); });

    const v0=voices[0]; const sp=sliderSpec(); v0.setFreqHz(440);

    if(v0.ui?.freqSlider) v0.ui.freqSlider.value=sp.fromHz(440);

    if(v0.ui?.freqNum) v0.ui.freqNum.value='440';

    if(v0.ui?.hzChip) v0.ui.hzChip.textContent='440 Hz';


    updateMasterStartStopLabel();

    handleResize(); drawStaticGrids(); // still until user resumes audio

  }

  document.addEventListener('DOMContentLoaded', bootstrap);

  </script>

</body>

</html>