diff options
Diffstat (limited to 'docs/prototypes/2026-07-03-sound-panel-prototype.html')
| -rw-r--r-- | docs/prototypes/2026-07-03-sound-panel-prototype.html | 417 |
1 files changed, 417 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-03-sound-panel-prototype.html b/docs/prototypes/2026-07-03-sound-panel-prototype.html new file mode 100644 index 0000000..d75f566 --- /dev/null +++ b/docs/prototypes/2026-07-03-sound-panel-prototype.html @@ -0,0 +1,417 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Sound — instrument console (pulsemixer)</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.masthead{max-width:1280px;margin:0 auto 1.8rem} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch} +.masthead p b{color:var(--silver);font-weight:700} + +.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start} +.slot{width:400px} +.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem} + +.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;overflow:hidden} + +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto; + box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} + +.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px} +.b-id{display:flex;align-items:center;gap:9px} +.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em} +.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto} +.b-id .g{font-size:17px;color:var(--cream)} +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold); + border-radius:4px;padding:1px 6px;display:none} +.badge.show{display:inline-block} +.badge.red{background:var(--fail);color:var(--cream)} +.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem; + cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto} +.x-btn:hover{background:var(--wash);color:var(--silver)} +/* faceplate master quick-mute — same switch idiom as net wifi / bt power */ +.switch{width:38px;height:20px;border-radius:10px;background:var(--wash); + border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px; + border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:19px;background:var(--gold)} +.switch.muted{background:rgba(203,107,77,.2);border-color:var(--fail)} +.switch.muted::after{background:var(--fail);left:2px} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;margin:13px 0 6px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:12px} +.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px;text-transform:none;font-size:.62rem} + +/* device row — a FIXED grid so nothing can overflow the plate: + [signal] [name] [fader] [pct] [mute]. minmax(0,1fr) lets the name shrink + and ellipsis instead of forcing the row wider than the panel. */ +.dev{display:grid;grid-template-columns:15px minmax(0,1fr) 84px 32px 20px; + align-items:center;gap:8px;padding:6px 5px;border-radius:8px;cursor:default} +.dev:hover{background:var(--wash)} +.dev .who{min-width:0;display:flex;align-items:center;gap:6px;color:var(--silver)} +.dev .who .g{font-size:14px;color:var(--dim);flex:0 0 auto} +.dev.active .who .g{color:var(--gold)} +.dev .who .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.dev.active .who .nm{color:var(--cream);font-weight:700} +.dev.muted .who .nm{color:var(--dim)} +.dev .def{font-size:.5rem;letter-spacing:.14em;color:var(--panel);background:var(--gold); + border-radius:3px;padding:0 4px;flex:0 0 auto;display:none} +.dev.active .def{display:inline-block} +/* fader — machined slot + gold cap; width-based fill, bounded to its cell */ +.fader{height:16px;position:relative;cursor:pointer} +.fader .trk{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:var(--well); + border:1px solid #231f18;overflow:hidden} +.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold))} +.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; + background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)} +.dev.muted .fill{background:var(--wash)} +.dev.muted .cap{background:linear-gradient(180deg,#6a6a6a,#3f3f3f);border-color:#2a2a2a} +.pct{color:var(--cream);font-size:11.5px;font-variant-numeric:tabular-nums;text-align:right} +.dev.muted .pct{color:var(--fail)} +.mute-b{color:var(--dim);border:0;background:transparent;font:inherit;font-size:.9rem;cursor:pointer; + border-radius:5px;padding:0;justify-self:center;line-height:1} +.mute-b:hover{color:var(--silver)} +.dev.muted .mute-b{color:var(--fail)} +/* per-row signal mini-meter — 4 bars that light when THIS device has a live + stream, so the sink/source actually playing is visible before you pick it */ +.sig{display:flex;align-items:flex-end;gap:1.5px;height:14px;justify-self:center} +.sig i{width:2.5px;background:var(--wash);border-radius:1px} +.sig i:nth-child(1){height:4px}.sig i:nth-child(2){height:7px} +.sig i:nth-child(3){height:10px}.sig i:nth-child(4){height:13px} +.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} + +/* mic mode — three console keys */ +.modes{display:flex;gap:8px;margin-top:2px} +.mode{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11px;letter-spacing:.06em; + background:linear-gradient(180deg,#23211e,#191715);color:var(--silver); + border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.mode:hover{color:var(--gold);border-color:var(--gold)} +.mode.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.mode.on.mute{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream)} +.mode .k{display:block;font-size:.54rem;letter-spacing:.14em;color:var(--dim);margin-top:2px} +.mode.on .k{color:rgba(16,15,15,.7)} +.ptt-hint{color:var(--dim);font-size:10.5px;text-align:center;margin-top:6px;min-height:1.2em} +.ptt-hint.live{color:var(--gold)} + +/* meter row 1 — the volume dials you set (needles), OUT + IN */ +.meters{display:flex;gap:12px;margin-top:10px} +.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;position:relative} +.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)} +.meter .mode-tag.mut{color:var(--fail)} +.meter .dial{position:relative;height:50px;overflow:hidden;margin-top:13px} +.meter .arc{position:absolute;inset:0 0 -50px 0;border:2px solid var(--wash);border-radius:50%} +.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:9px;background:var(--steel);transform-origin:50% 50px} +.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:42px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(20deg);border-radius:2px; + box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .3s cubic-bezier(.3,1.3,.5,1)} +.meter .needle.mut{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)} +.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)} +.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums} +.meter .m-value small{color:var(--dim);font-weight:400} +.meter .m-label{color:var(--steel);font-size:.6rem;letter-spacing:.2em;text-align:center;margin-top:2px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* meter row 2 — the stereo VU pair: the live SIGNAL through the selected + output. Confirms which device is actually carrying the audio. */ +.vupair{margin-top:10px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 11px 8px} +.vuhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px} +.vuhead .t{color:var(--steel);font-size:.56rem;letter-spacing:.2em;text-transform:uppercase} +.vuhead .src{color:var(--cream);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%} +.vurow{display:flex;align-items:center;gap:8px;margin:4px 0} +.vurow .ch{color:var(--steel);font-size:.62rem;width:9px;flex:0 0 auto} +.vubar{flex:1;display:flex;gap:2px;height:9px} +.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.vubar i.on{opacity:1;background:var(--pass)} +.vubar i.hot{opacity:1;background:var(--gold)} +.vubar i.clip{opacity:1;background:var(--fail)} +.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} +.vupair.mute .vubar i{background:var(--wash);opacity:.22} +.vupair.mute .vuhead .src{color:var(--fail)} + +.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px; + padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em} +.toast.show{opacity:1} +.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)} + +.aside{flex:1 1 320px;min-width:300px} +.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem} +.aside h3:first-child{margin-top:.2rem} +.aside ul{list-style:none} +.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative} +.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.aside li b{color:var(--cream);font-weight:700} +.aside li em{color:var(--dim);font-style:normal} +.barbits{display:flex;gap:14px;margin:.4rem 0 .2rem;align-items:center;flex-wrap:wrap} +.barbits span{display:flex;align-items:center;gap:7px;color:var(--silver);font-size:.82rem} +.barbits .g{font-size:19px;color:var(--cream)} +.barbits .g.mut{color:var(--fail)} +.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem;color:var(--silver)} +.rec b{color:var(--gold)} +*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent} +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px} +@media (prefers-reduced-motion:reduce){.needle{transition:none}} +</style> +</head> +<body> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div> + <h1>Sound — the pulsemixer console</h1> + <p>Same faceplate as net + bluetooth. The bar's <b>sound glyph</b> opens this. Every sink and + source is a row: <b>click the row body to make it default</b>, drag the fader for its volume, + hit the glyph to mute just it. Each row has a <b>live-signal meter</b> — the device actually + carrying audio dances even when it isn't the default, so you can <b>find the one playing the + meeting</b> and click it. The faceplate switch is the <b>master quick-mute</b>; the mic carries + <b>live · muted · push-to-talk</b> (hold Space). Row 1 of gauges is the volume you set; row 2 is + the <b>stereo VU</b> of the selected output's live signal.</p> +</header> + +<div class="stage"> + <div class="slot"> + <div class="slot-label">snd·01 — pulsemixer in console form</div> + <div class="panel"> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="lamp"></span> + <span class="g" id="face-g"></span> + <span class="state-word" id="state">PLAYBACK</span> + <span class="badge red" id="mute-badge">MUTED</span> + <span class="unit">SND·01</span> + <span class="switch on" id="master" onclick="masterMute()" title="Master quick-mute (Super+Shift+M)"></span> + <button class="x-btn" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">outputs<span class="cnt" id="out-cnt"></span></div> + <div id="outputs"></div> + + <div class="engrave">inputs<span class="cnt" id="in-cnt"></span></div> + <div id="inputs"></div> + + <div class="engrave">mic mode</div> + <div class="modes"> + <button class="mode" id="md-toggle" onclick="micToggle()">LIVE<span class="k">Super+Shift+A</span></button> + <button class="mode" id="md-ptt" onclick="micPtt()">PUSH·TALK<span class="k">hold Space</span></button> + </div> + <div class="ptt-hint" id="ptt-hint"></div> + + <!-- meter row 1 — volume you set --> + <div class="meters"> + <div class="meter"> + <span class="mode-tag" id="vt-out">OUT VOL</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-out"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-out">68</span> <small>%</small></div> + <div class="m-label" id="l-out">SPEAKERS</div> + </div> + <div class="meter"> + <span class="mode-tag" id="vt-in">IN VOL</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-in"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-in">54</span> <small>%</small></div> + <div class="m-label" id="l-in">BUILT-IN MIC</div> + </div> + </div> + + <!-- meter row 2 — stereo VU of the selected output's live signal --> + <div class="vupair" id="vupair"> + <div class="vuhead"><span class="t">signal · VU peak</span><span class="src" id="vu-src">SPEAKERS</span></div> + <div class="vurow"><span class="ch">L</span><span class="vubar" id="vu-l"></span></div> + <div class="vurow"><span class="ch">R</span><span class="vubar" id="vu-r"></span></div> + </div> + + <div class="toast" id="toast"></div> + </div> + </div> + + <div class="aside"> + <h3>The bar glyph</h3> + <div class="barbits"> + <span><span class="g"></span> normal — speaker + arcs</span> + <span><span class="g mut"></span> muted — speaker ✕</span> + <span><span class="g" style="color:var(--gold)"></span> ptt armed</span> + </div> + <h3>Idiom map (same as net / bt)</h3> + <ul> + <li><b>Faceplate switch = master quick-mute</b> — the net wifi / bt power switch, here muting all output. Flip: state → MUTED, lamp red, bar glyph → speaker-✕.</li> + <li><b>Rows are devices</b> — every sink + source. <b>Click the row body</b> to set default (gold DEF moves); the fader sets that device's volume; the trailing glyph mutes just it.</li> + <li><b>Per-row signal meter</b> — the 4 bars at the left of each row read that device's <b>live</b> level. A sink can carry a stream without being default, so the one playing lights up — <em>demo: the music is on WH-1000XM4 while Speakers is still default. Click WH-1000XM4 to move to it.</em></li> + <li><b>Two meter rows</b> — row 1 = the volume you set (OUT + IN needles); row 2 = the <b>stereo VU</b> (L/R) of the selected output's live signal, red when muted.</li> + <li><b>Mic = two console keys</b> — one toggles LIVE↔MUTED (the label flips to show the state), the other is PUSH·TALK: the mic sits muted and un-mutes only while Space is held.</li> + <li><b>Verify-everything</b> — every action re-reads pactl/wpctl state after firing, like net/bt.</li> + </ul> + <h3>Push-to-talk — the one hard part</h3> + <div class="rec"> + Hold-to-talk needs a global key grab under Wayland. Two routes to spec: <b>(a)</b> a Hyprland + <b>bind pair</b> — <em>bindp</em> Space press → unmute, release → re-mute, armed only while PTT + mode is active (so it doesn't steal Space everywhere); or <b>(b)</b> an <b>evdev/libinput + listener</b> reading the key directly. (a) is lighter; (b) survives focus changes but needs + input-group permissions. Feasibility research is phase 1 of the spec. + </div> + </div> +</div> + +<script> +const $ = id => document.getElementById(id); +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + +/* sig = base live level 0..1 (what stream is on this device right now). + Note the demo: music is on WH-1000XM4 (sig .72) while Speakers is default. */ +let OUT = [ + {id:'spk', name:'Built-in Speakers', g:'', vol:68, mute:false, def:true, sig:0, ph:0.0}, + {id:'xm4', name:'WH-1000XM4', g:'', vol:80, mute:false, def:false, sig:0.72,ph:1.7}, + {id:'hdmi',name:'HDMI · Dell U2720', g:'', vol:100,mute:true, def:false, sig:0, ph:2.9}, +]; +let IN = [ + {id:'bmic', name:'Built-in Mic', g:'', vol:54, mute:false, def:true, sig:0.16,ph:0.6}, + {id:'yeti', name:'Blue Yeti USB', g:'', vol:70, mute:false, def:false, sig:0, ph:2.1}, + {id:'xm4m', name:'WH-1000XM4 Headset',g:'', vol:60, mute:true, def:false, sig:0, ph:3.3}, +]; +let master = false, micMode = 'live', pttHeld = false; +const pkL={v:0}, pkR={v:0}; + +function outMutedOf(d){ return master || d.mute; } +function inMutedOf(d){ return d.mute || (d.def && (micMode==='mute' || (micMode==='ptt' && !pttHeld))); } + +function fader(dev){ + const w=document.createElement('div'); w.className='fader'; + w.innerHTML=`<div class="trk"><div class="fill" style="width:${dev.vol}%"></div></div>`+ + `<div class="cap" style="left:${dev.vol}%"></div>`; + w.onclick=(e)=>{e.stopPropagation(); + const r=w.getBoundingClientRect(); + dev.vol=Math.max(0,Math.min(100,Math.round((e.clientX-r.left)/r.width*100))); + if(dev.vol>0) dev.mute=false; + render(); toast(`${dev.name} → ${dev.vol}%`); + }; + return w; +} +function row(dev,kind){ + const muted = kind==='out'?outMutedOf(dev):inMutedOf(dev); + const r=document.createElement('div'); + r.className='dev'+(dev.def?' active':'')+(muted?' muted':''); + r.title=dev.name; + const sig=document.createElement('span'); sig.className='sig'; sig.id='sig-'+dev.id; + sig.innerHTML='<i></i><i></i><i></i><i></i>'; + const who=document.createElement('div'); who.className='who'; + who.innerHTML=`<span class="g">${dev.g}</span><span class="nm">${dev.name}</span><span class="def">DEF</span>`; + const pct=document.createElement('span'); pct.className='pct'; pct.textContent=dev.mute?'mute':dev.vol+'%'; + const mb=document.createElement('button'); mb.className='mute-b'; mb.textContent=dev.mute?'':''; + mb.title='mute '+dev.name; + mb.onclick=(e)=>{e.stopPropagation();dev.mute=!dev.mute;render();toast(`${dev.name} ${dev.mute?'muted':'unmuted'}`);}; + r.append(sig,who,fader(dev),pct,mb); + r.onclick=()=>{ if(dev.def) return; + (kind==='out'?OUT:IN).forEach(d=>d.def=false); dev.def=true; render(); + toast(`default ${kind==='out'?'output':'input'} → ${dev.name}`); + }; + return r; +} +function render(){ + const o=$('outputs'); o.innerHTML=''; OUT.forEach(d=>o.appendChild(row(d,'out'))); + const i=$('inputs'); i.innerHTML=''; IN.forEach(d=>i.appendChild(row(d,'in'))); + $('out-cnt').textContent='· '+OUT.length; + $('in-cnt').textContent='· '+IN.length; + const od=OUT.find(d=>d.def), id=IN.find(d=>d.def); + const oM=outMutedOf(od), iM=inMutedOf(id); + $('master').className='switch'+(master?' muted':' on'); + $('state').textContent=oM?'MUTED':'PLAYBACK'; + $('lamp').className='lamp'+(oM?' red':''); + $('face-g').textContent=oM?'':''; + $('mute-badge').classList.toggle('show',oM); + setNeedle('out',oM?0:od.vol,oM); $('v-out').textContent=oM?0:od.vol; $('l-out').textContent=od.name.toUpperCase(); + setNeedle('in',iM?0:id.vol,iM); $('v-in').textContent=iM?0:id.vol; $('l-in').textContent=id.name.toUpperCase(); + $('vt-out').textContent=oM?'OUT·MUTE':'OUT VOL'; $('vt-out').className='mode-tag'+(oM?' mut':''); + $('vt-in').textContent=iM?'IN·MUTE':'IN VOL'; $('vt-in').className='mode-tag'+(iM?' mut':''); + $('vu-src').textContent=od.name.toUpperCase(); + $('vupair').classList.toggle('mute',oM); + // mic controls: one live/muted toggle + one push-to-talk key + const tg=$('md-toggle'); + tg.textContent = micMode==='mute' ? 'MUTED' : 'LIVE'; + tg.appendChild(Object.assign(document.createElement('span'),{className:'k',textContent:'Super+Shift+A'})); + tg.className = 'mode' + (micMode==='mute' ? ' on mute' : micMode==='live' ? ' on' : ''); + $('md-ptt').className='mode'+(micMode==='ptt'?' on':''); + $('ptt-hint').textContent = micMode==='ptt' + ? (pttHeld?'▸ transmitting — Space held':'mic muted — hold Space to talk') + : micMode==='mute' ? 'mic muted' : ''; + $('ptt-hint').className='ptt-hint'+(micMode==='ptt'&&pttHeld?' live':''); +} +function setNeedle(side,val,muted){ + const deg=-60+Math.max(0,Math.min(1,val/100))*120; + const n=$('n-'+side); n.style.transform=`rotate(${deg}deg)`; + n.className='needle'+(muted?' mut':''); +} + +/* live-signal animation — the per-row minis + the stereo VU pair */ +let phase=0; +function level(dev,muted){ + if(muted||!dev.sig) return 0; + const env=0.5+0.5*Math.abs(Math.sin(phase*1.25+dev.ph)); + const trans=Math.random()<0.14?Math.random()*0.4:0; + return Math.max(0,Math.min(1,dev.sig*env+trans-Math.random()*0.07)); +} +function paintMini(el,lvl){ + const b=el.children, lit=Math.round(lvl*4); + for(let k=0;k<4;k++){ b[k].className = k<lit ? (k>=3?'clip':k>=2?'hot':'on') : ''; } +} +function paintVU(el,lvl,pk){ + const b=el.children, n=b.length, lit=Math.round(lvl*n); + pk.v=Math.max(lit,pk.v-0.35); const p=Math.round(pk.v); + for(let k=0;k<n;k++){ let c = k<lit ? (k>=n-2?'clip':k>=n-4?'hot':'on') : ''; + if(p>0 && k===p-1) c=(c?c+' ':'')+'peak'; b[k].className=c; } +} +function buildVU(el,n){ el.innerHTML=''; for(let k=0;k<n;k++) el.appendChild(document.createElement('i')); } +function tick(){ + phase+=0.09; + OUT.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,outMutedOf(d)));}); + IN.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,inMutedOf(d)));}); + const od=OUT.find(d=>d.def), oM=outMutedOf(od); + const base=oM?0:level(od,false); + paintVU($('vu-l'),Math.min(1,base*(0.92+Math.random()*0.16)),pkL); + paintVU($('vu-r'),Math.min(1,base*(0.92+Math.random()*0.16)),pkR); +} + +function masterMute(){ master=!master; render(); toast(master?'ALL OUTPUT MUTED':'output unmuted'); } +/* two mic buttons: toggle flips live<->muted (and leaves ptt); ptt arms/disarms */ +function micToggle(){ micMode = micMode==='mute' ? 'live' : 'mute'; pttHeld=false; render(); + toast(micMode==='mute'?'mic muted':'mic live'); } +function micPtt(){ micMode = micMode==='ptt' ? 'live' : 'ptt'; pttHeld=false; render(); + toast(micMode==='ptt'?'push-to-talk armed — hold Space':'mic live'); } +let tT; +function toast(msg,err){ const t=$('toast'); t.textContent=msg; + t.className='toast show'+(err?' err':''); clearTimeout(tT); tT=setTimeout(()=>t.className='toast',2200); } +addEventListener('keydown',e=>{ if(e.code==='Space'&&micMode==='ptt'&&!e.repeat){e.preventDefault();pttHeld=true;render();}}); +addEventListener('keyup', e=>{ if(e.code==='Space'&&micMode==='ptt'){e.preventDefault();pttHeld=false;render();}}); + +buildVU($('vu-l'),16); buildVU($('vu-r'),16); +render(); +if(!reduced) setInterval(tick,70); else tick(); +</script> +</body> +</html> |
