<!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>