poke/html/strings.ejs
2025-09-02 22:52:54 +02:00

566 lines
26 KiB
Plaintext

<!--
This Source Code Form is subject to the terms of the GNU General Public License:
Copyright (C) 2021-2025 Poke (https://codeberg.org/ashley/poke)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see https://www.gnu.org/licenses/.
--->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Poke Strings — Guitar • Ukulele • Violin</title>
<meta name="theme-color" content="#111111" />
<meta content="website" property="og:type">
<meta content="PokeStrings" property="og:title">
<meta content="Guitar, Ukulele, Violin!" property="twitter:description">
<meta content="https://cdn.glitch.me/d68d17bb-f2c0-4bc3-993f-50902734f652/aa70111e-5bcd-4379-8b23-332a33012b78.image.png?v=1701898829884" property="og:image" />
<meta content="summary_large_image" name="twitter:card">
<style>
:root{
--bg:#0d0f12;--bg-soft:#141820;--fg:#f3f5f7;--muted:#9aa4b2;
--brand:#7a5cff;--accent:#00d3a7;--danger:#ff5577;
--key-outline:#1a2030;--radius:16px;
--shadow:0 12px 34px rgba(0,0,0,.40),0 2px 10px rgba(0,0,0,.25);
--glass:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,.02));
}
.light{
--bg:#f7f8fb;--bg-soft:#ffffff;--fg:#0e1116;--muted:#4a5568;
--brand:#6a5cff;--accent:#00b394;--key-outline:#cfd8e3;
--shadow:0 12px 30px rgba(0,0,0,.12),0 2px 10px rgba(0,0,0,.08);
--glass:linear-gradient(180deg,rgba(255,255,255,.7),rgba(255,255,255,.6));
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(1200px 600px at 15% -10%, rgba(122,92,255,.18), transparent 60%),
radial-gradient(1200px 700px at 100% 0%, rgba(0,211,167,.12), transparent 45%),
linear-gradient(180deg,var(--bg),var(--bg-soft));
color:var(--fg);
font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,"Noto Sans",Arial,"Apple Color Emoji","Segoe UI Emoji";
display:grid;grid-template-rows:auto auto 1fr auto;gap:14px;
}
/* compact header (matches Poke Piano/Drums vibe) */
header{
padding:10px 16px 0;
display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;
}
.brand{display:flex;align-items:center;gap:10px}
.logo{width:28px;height:28px;border-radius:10px;background:conic-gradient(from 220deg,var(--brand),var(--accent),var(--brand));box-shadow:0 6px 18px rgba(122,92,255,.28);border:1px solid var(--key-outline)}
h1{margin:0;font-size:18px;letter-spacing:.2px}
.header-actions{display:flex;gap:6px}
.toolbar{display:grid;gap:8px;grid-template-columns:repeat(12,1fr);padding:0 16px}
.card{
background:var(--bg-soft);border:1px solid var(--key-outline);border-radius:var(--radius);
box-shadow:var(--shadow);padding:12px;min-width:0;position:relative;overflow:hidden;
}
.card::after{content:"";position:absolute;inset:0;pointer-events:none;background:
radial-gradient(600px 160px at 10% 0%, rgba(255,255,255,.06), transparent 60%),
radial-gradient(500px 140px at 90% 0%, rgba(122,92,255,.12), transparent 50%);mix-blend-mode:overlay}
.title{font-size:12px;color:var(--muted);margin-bottom:8px;letter-spacing:.3px;text-transform:uppercase}
.group{display:grid;gap:10px}
.row{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:12px;align-items:end}
.pair{display:grid;grid-template-columns:max-content 1fr;gap:8px;align-items:center;min-width:0}
.pair>label{color:var(--muted);font-size:12px}
select,input[type="range"],input[type="number"],button{
width:100%;border-radius:12px;border:1px solid var(--key-outline);background:#0f131b;color:var(--fg);
padding:8px 10px;outline:none;transition:transform .06s ease,box-shadow .06s ease,border-color .12s ease;min-height:32px
}
.light select,.light input[type="range"],.light input[type="number"],.light button{background:#fff}
input[type="range"]{height:28px}
input[type="number"]{text-align:center}
button{cursor:pointer;background:linear-gradient(180deg,var(--brand),#4a3aff);border:none;color:#fff;font-weight:700;letter-spacing:.2px}
button.secondary{background:var(--bg-soft);color:var(--fg);border:1px solid var(--key-outline)}
button.danger{background:var(--danger)}
button:active{transform:translateY(1px)}
button:focus-visible,select:focus-visible,input:focus-visible{box-shadow:0 0 0 3px rgba(122,92,255,.35);border-color:var(--brand)}
/* strings panel */
.strings{display:grid;gap:8px}
.string-row{
display:grid;grid-template-columns:110px 1fr 92px 60px 68px;gap:8px;align-items:center;
background:#0f131b;border:1px solid var(--key-outline);border-radius:12px;padding:8px 10px;
}
.badge{display:inline-block;padding:4px 8px;border-radius:999px;border:1px solid var(--key-outline);background:linear-gradient(180deg,#111723,#0f131b);font-weight:700;letter-spacing:.2px;text-align:center}
.mutebtn{background:#171c26}
.mutebtn.on{background:#2a3242;border-color:transparent}
.playbtn{background:var(--accent);border:none;color:#00130f;font-weight:800}
.note-pill{font-family:ui-monospace, Menlo, Consolas, "Liberation Mono", monospace; font-size:12px; padding:4px 8px; border:1px solid var(--key-outline); border-radius:999px; background:#111723}
.grid-col-3{grid-column:span 3}.grid-col-4{grid-column:span 4}.grid-col-5{grid-column:span 5}
.grid-col-6{grid-column:span 6}.grid-col-7{grid-column:span 7}.grid-col-8{grid-column:span 8}.grid-col-12{grid-column:span 12}
.footer{display:grid;grid-template-columns:1fr auto;align-items:center;gap:8px;padding:0 16px 16px}
.chip{padding:8px 12px;border-radius:999px;border:1px solid var(--key-outline);background:var(--bg-soft);display:inline-flex;gap:8px;align-items:center}
canvas{width:100%;height:84px;border-radius:var(--radius);background:#0b0f16;border:1px solid var(--key-outline);box-shadow:var(--shadow)}
@media (max-width:1000px){
.toolbar{grid-template-columns:repeat(6,1fr)}
.grid-col-8,.grid-col-7,.grid-col-6,.grid-col-5{grid-column:span 6}
.grid-col-4{grid-column:span 3}.grid-col-3{grid-column:span 3}
.string-row{grid-template-columns:90px 1fr 78px 52px 64px}
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo"></div>
<h1>Poke Strings BETA</h1>
</div>
<div class="header-actions">
<button id="power">Start Audio</button>
<button id="theme" style="display:none" class="secondary">Theme</button>
</div>
</header>
<section class="toolbar">
<div class="card grid-col-5 group">
<div class="title">Instrument</div>
<div class="row">
<div class="pair"><label for="inst">Type</label>
<select id="inst">
<option selected>Guitar</option>
<option>Ukulele</option>
<option>Violin</option>
</select>
</div>
<div class="pair"><label for="tuning">Tuning</label><select id="tuning"></select></div>
</div>
<div class="row">
<div class="pair"><label for="capo">Capo</label><input id="capo" type="range" min="0" max="12" step="1" value="0"></div>
<div class="pair"><label for="a4">A4 (Hz)</label><input id="a4" type="number" min="400" max="480" step="0.1" value="440"></div>
</div>
<div class="row">
<div class="pair"><label for="chord">Chord (Guitar/Uke)</label>
<select id="chord">
<option selected>—</option>
<option>C</option><option>G</option><option>Am</option><option>F</option>
<option>D</option><option>Em</option><option>E</option><option>A</option><option>Dm</option><option>Bm</option>
</select>
</div>
<div class="pair"><label for="strings">Strings</label>
<div id="stringCount" class="badge">—</div>
</div>
</div>
</div>
<div class="card grid-col-4 group">
<div class="title">Play</div>
<div class="row">
<div class="pair"><label for="strum">Strum Spacing (ms)</label><input id="strum" type="range" min="0" max="120" step="1" value="30"></div>
<div class="pair"><label for="dur">Note Time (s)</label><input id="dur" type="range" min="0.1" max="4" step="0.01" value="1.2"></div>
</div>
<div class="row">
<div class="pair"><label>&nbsp;</label><button id="strumDown" class="secondary">Strum Down</button></div>
<div class="pair"><label>&nbsp;</label><button id="strumUp" class="secondary">Strum Up</button></div>
</div>
<div class="row">
<div class="pair"><label>&nbsp;</label><button id="arp" class="secondary">Arp</button></div>
<div class="pair"><label for="arpRate">Arp Rate (ms)</label><input id="arpRate" type="range" min="50" max="400" step="10" value="160"></div>
</div>
</div>
<div class="card grid-col-3 group">
<div class="title">Tone</div>
<div class="row">
<div class="pair"><label for="atk">Attack</label><input id="atk" type="range" min="0.001" max="0.3" step="0.001" value="0.01"></div>
<div class="pair"><label for="rel">Release</label><input id="rel" type="range" min="0.05" max="4" step="0.01" value="1.2"></div>
</div>
<div class="row">
<div class="pair"><label for="color">LP Cutoff (Hz)</label><input id="color" type="range" min="800" max="12000" step="1" value="4500"></div>
<div class="pair"><label for="body">Body Mix</label><input id="body" type="range" min="0" max="1" step="0.01" value="0.35"></div>
</div>
<div class="row">
<div class="pair"><label for="vRate">Vibrato Hz (violin)</label><input id="vRate" type="range" min="0" max="9" step="0.1" value="5"></div>
<div class="pair"><label for="vDepth">Vibrato Depth (cents)</label><input id="vDepth" type="range" min="0" max="80" step="1" value="18"></div>
</div>
</div>
<div class="card grid-col-12 group">
<div class="title">Strings</div>
<div id="strings" class="strings"></div>
</div>
<div class="card grid-col-12 group">
<div class="title">Visualizer</div>
<canvas id="viz" width="1000" height="120"></canvas>
</div>
</section>
<section class="footer">
<div></div>
<div class="chip"><span id="status">Ready</span></div>
</section>
<script>
;(() => {
const $ = s => document.querySelector(s)
const $$ = s => Array.from(document.querySelectorAll(s))
const els = {
power: $('#power'), theme: $('#theme'),
inst: $('#inst'), tuning: $('#tuning'), capo: $('#capo'), a4: $('#a4'), chord: $('#chord'),
stringCount: $('#stringCount'), strings: $('#strings'),
strum: $('#strum'), dur: $('#dur'),
strumDown: $('#strumDown'), strumUp: $('#strumUp'), arp: $('#arp'), arpRate: $('#arpRate'),
atk: $('#atk'), rel: $('#rel'), color: $('#color'), body: $('#body'),
vRate: $('#vRate'), vDepth: $('#vDepth'),
viz: $('#viz'), status: $('#status')
}
const storageKey = 'pokestrings-v1'
const defaults = {
theme:'dark',
inst:'Guitar',
tuningKey:'Guitar:Standard',
capo:0, a4:440,
atk:0.01, rel:1.2, color:4500, body:0.35,
vRate:5, vDepth:18,
strum:30, dur:1.2, arpRate:160,
chord:'—'
}
let conf = { ...defaults }
try { const saved = JSON.parse(localStorage.getItem(storageKey)||'{}'); conf = { ...conf, ...saved } } catch {}
function save(){ localStorage.setItem(storageKey, JSON.stringify(conf)) }
function setStatus(t){ els.status.textContent = t }
// ---------- Music utils ----------
function freqFromMidi(m, a4 = conf.a4){ return a4 * Math.pow(2, (m-69)/12) }
function midiName(m){
const n = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'][m%12]
const o = Math.floor(m/12)-1
return n+o
}
// Tunings (midi numbers), strings ordered low→high
const Tunings = {
Guitar: {
Standard: [40,45,50,55,59,64], // E2 A2 D3 G3 B3 E4
'Drop D': [38,45,50,55,59,64],
DADGAD: [38,45,50,55,57,62],
'Open G': [38,43,47,50,55,62],
'Open D': [38,45,50,54,57,62]
},
Ukulele: {
'Standard (re-entrant)': [67,60,64,69], // G4 C4 E4 A4
'Low G': [55,60,64,69]
},
Violin: {
Standard: [55,62,69,76] // G3 D4 A4 E5
}
}
// Chord shapes (low→high). 'x' = mute. Guitar 6 chars, Uke 4 chars.
const GuitarChords = {
C:'x32010', G:'320003', Am:'x02210', F:'133211', D:'xx0232',
Em:'022000', E:'022100', A:'x02220', Dm:'xx0231', Bm:'x24432'
}
const UkeChords = {
C:'0003', G:'0232', Am:'2000', F:'2010', D:'2220', Em:'0432', E:'4442',
A:'2100', Dm:'2210', Bm:'4222'
}
// ---------- Audio ----------
let ctx, analyser, master, lowpass
function initAudio(){
if (ctx) return
ctx = new (window.AudioContext||window.webkitAudioContext)()
master = ctx.createGain(); master.gain.value = 0.9
lowpass = ctx.createBiquadFilter(); lowpass.type='lowpass'; lowpass.frequency.value = conf.color
analyser = ctx.createAnalyser(); analyser.fftSize = 2048
lowpass.connect(master)
master.connect(analyser)
master.connect(ctx.destination)
drawViz()
setStatus('Audio ready')
}
function drawViz(){
const c = els.viz, g = c.getContext('2d')
const data = new Uint8Array(analyser.frequencyBinCount)
function loop(){
requestAnimationFrame(loop)
analyser.getByteFrequencyData(data)
g.clearRect(0,0,c.width,c.height)
const w = c.width/data.length
for(let i=0;i<data.length;i++){
const v=data[i], h=(v/255)*(c.height-8)
g.fillStyle='#4a70ff'; g.fillRect(i*w, c.height-h, Math.max(1,w-1), h)
}
}
loop()
}
// Simple pluck (guitar/uke): two oscs + quick noise click + LP filter, with ADSR
function pluck(freq, when=ctx.currentTime){
const t = when
const mix = conf.body
const filt = ctx.createBiquadFilter(); filt.type='lowpass'; filt.frequency.setValueAtTime(conf.color, t)
const out = ctx.createGain(); out.gain.setValueAtTime(0.0001, t)
const o1 = ctx.createOscillator()
o1.type='triangle'; o1.frequency.setValueAtTime(freq*(1+(Math.random()-0.5)*0.002), t)
const o2 = ctx.createOscillator()
o2.type='sine'; o2.frequency.setValueAtTime(freq*2, t)
const g1 = ctx.createGain(); g1.gain.value = 1-mix
const g2 = ctx.createGain(); g2.gain.value = mix*0.8
o1.connect(g1); o2.connect(g2); g1.connect(filt); g2.connect(filt)
// pluck click
const nb = ctx.createBuffer(1, 256, ctx.sampleRate)
const arr = nb.getChannelData(0); for(let i=0;i<arr.length;i++) arr[i]=(Math.random()*2-1)
const n = ctx.createBufferSource(); n.buffer = nb
const hp = ctx.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=3000
const ng = ctx.createGain(); ng.gain.setValueAtTime(0.4, t); ng.gain.exponentialRampToValueAtTime(0.0001, t+0.02)
n.connect(hp); hp.connect(ng); ng.connect(filt); n.start(t)
filt.connect(out); out.connect(lowpass)
const a=t, d=conf.rel
out.gain.setValueAtTime(0.0001,a)
out.gain.linearRampToValueAtTime(1, a+conf.atk)
out.gain.exponentialRampToValueAtTime(0.0001, a+d)
o1.start(t); o2.start(t)
o1.stop(t+d+0.05); o2.stop(t+d+0.05)
setTimeout(()=>{ try{ o1.disconnect(); o2.disconnect(); filt.disconnect(); out.disconnect() }catch{} }, (d+0.2)*1000)
}
// Bowed tone (violin): filtered saw + vibrato LFO
function bowed(freq, when=ctx.currentTime, hold=conf.dur){
const t = when
const o = ctx.createOscillator()
o.type='sawtooth'; o.frequency.setValueAtTime(freq, t)
const vLFO = ctx.createOscillator()
const vGain = ctx.createGain()
vLFO.type='sine'
vLFO.frequency.setValueAtTime(conf.vRate, t)
vGain.gain.setValueAtTime(freq * (Math.pow(2, conf.vDepth/1200)-1), t) // cents→Hz approx
vLFO.connect(vGain); vGain.connect(o.frequency)
const filt = ctx.createBiquadFilter(); filt.type='lowpass'; filt.frequency.setValueAtTime(conf.color, t)
const out = ctx.createGain(); out.gain.setValueAtTime(0.0001, t)
o.connect(filt); filt.connect(out); out.connect(lowpass)
const atk = Math.max(0.02, conf.atk)
out.gain.linearRampToValueAtTime(0.9, t+atk)
out.gain.setTargetAtTime(0.85, t+atk, 0.2)
const end = t + hold
out.gain.setValueAtTime(0.85, end)
out.gain.exponentialRampToValueAtTime(0.0001, end+Math.max(0.2, conf.rel*0.5))
o.start(t); vLFO.start(t)
o.stop(end+Math.max(0.3, conf.rel))
vLFO.stop(end+Math.max(0.3, conf.rel))
setTimeout(()=>{ try{ o.disconnect(); vLFO.disconnect(); vGain.disconnect(); filt.disconnect(); out.disconnect() }catch{} }, (hold+conf.rel+0.5)*1000)
}
// ---------- Strings UI ----------
const state = { strings: [] } // [{midi, name, fret, mute}]
function applyTuning(){
const t = Tunings[conf.inst]
const list = Object.keys(t)
els.tuning.innerHTML = list.map((k,i)=>`<option ${ (conf.tuningKey.split(':')[1]||'')===k?'selected':''}>${k}</option>`).join('')
if (!list.includes(conf.tuningKey.split(':')[1])) conf.tuningKey = conf.inst+':'+list[0]
const key = conf.tuningKey.split(':')[1]
const mids = t[key].slice()
state.strings = mids.map(m=>({ midi:m, name:midiName(m), fret:0, mute:false }))
els.stringCount.textContent = String(state.strings.length)
buildStringRows()
updateChordVisibility()
save()
}
function buildStringRows(){
els.strings.innerHTML = ''
// show low→high, but visually top→bottom
state.strings.forEach((s, idx)=>{
const row = document.createElement('div'); row.className='string-row'; row.dataset.index=String(idx)
const label = document.createElement('div'); label.innerHTML = `<span class="badge">${conf.inst} ${state.strings.length-idx}</span>`
const note = document.createElement('div'); note.innerHTML = `<span class="note-pill">${s.name}</span>`
const fretWrap = document.createElement('div'); fretWrap.innerHTML = `<input type="range" min="0" max="12" step="1" value="${s.fret}">`
const mute = document.createElement('button'); mute.className='mutebtn secondary'; mute.textContent = s.mute?'Muted':'Mute'; if(s.mute) mute.classList.add('on')
const play = document.createElement('button'); play.className='playbtn'; play.textContent='Play'
row.appendChild(label); row.appendChild(note); row.appendChild(fretWrap.firstChild); row.appendChild(mute); row.appendChild(play)
els.strings.appendChild(row)
// handlers
const slider = row.children[2]
slider.oninput = e => { s.fret = +e.target.value|0; updateNotePill(row, s) ; save() }
mute.onclick = () => { s.mute = !s.mute; mute.classList.toggle('on', s.mute); mute.textContent = s.mute?'Muted':'Mute'; save() }
play.onclick = () => { triggerString(idx) }
})
}
function updateNotePill(row, s){
const capo = conf.capo|0
const m = s.midi + s.fret + capo
row.querySelector('.note-pill').textContent = midiName(m)
}
function triggerString(i, when = (ctx?ctx.currentTime:0)+0.001){
if (!ctx) initAudio()
const s = state.strings[i]; if (!s || s.mute) return
const f = freqFromMidi(s.midi + s.fret + (conf.capo|0))
if (conf.inst === 'Violin') bowed(f, when, conf.dur)
else pluck(f, when)
}
function strumDown(){ // low → high
if (!ctx) initAudio()
let t = ctx.currentTime + 0.02
const step = (+els.strum.value|0)/1000
state.strings.forEach((_,i)=>{ if(!state.strings[i].mute) triggerString(i, t); t+=step })
}
function strumUp(){ // high → low
if (!ctx) initAudio()
let t = ctx.currentTime + 0.02
const step = (+els.strum.value|0)/1000
for(let i=state.strings.length-1;i>=0;i--){ if(!state.strings[i].mute) triggerString(i, t); t+=step }
}
let arpTimer=null, arpIdx=0, arpDir=1
function toggleArp(){
if (arpTimer){ clearInterval(arpTimer); arpTimer=null; els.arp.textContent='Arp'; setStatus('Arp off'); return }
if (!ctx) initAudio()
arpIdx=0; arpDir=1; els.arp.textContent='Stop Arp'
arpTimer = setInterval(()=>{
const playable = state.strings.map((s,i)=>({s,i})).filter(x=>!x.s.mute)
if (playable.length===0) return
const order = playable.map(x=>x.i)
triggerString(order[arpIdx])
arpIdx += arpDir
if (arpIdx>=order.length){ arpIdx=order.length-2; arpDir=-1 }
if (arpIdx<0){ arpIdx=1; arpDir=1 }
}, +els.arpRate.value|0)
setStatus('Arpeggiator running')
}
// ---------- Chords ----------
function updateChordVisibility(){
const isStrings = conf.inst==='Guitar' || conf.inst==='Ukulele'
els.chord.disabled = !isStrings
if (!isStrings){ els.chord.value='—'; conf.chord='—'; save() }
}
function applyChord(name){
if (conf.inst==='Guitar'){
const shape = GuitarChords[name]; if (!shape) return
// shape low→high 6 chars; state.strings length may be 6; if different, bail
const chars = shape.split('')
for(let i=0;i<state.strings.length;i++){
const ch = chars[i] || 'x'
if (ch==='x'){ state.strings[i].mute = true }
else { state.strings[i].mute=false; state.strings[i].fret = +ch|0 }
}
} else if (conf.inst==='Ukulele'){
const shape = UkeChords[name]; if (!shape) return
const chars = shape.split('') // 4 chars, low→high (G C E A)
for(let i=0;i<state.strings.length;i++){
const ch = chars[i] || '0'
state.strings[i].mute=false; state.strings[i].fret = +ch|0
}
}
buildStringRows()
save()
}
// ---------- Wiring UI ----------
function syncUI(){
if (conf.theme==='light') document.body.classList.add('light'); else document.body.classList.remove('light')
els.inst.value = conf.inst
const family = Tunings[conf.inst]; els.tuning.innerHTML = Object.keys(family).map(k=>`<option ${conf.tuningKey.endsWith(k)?'selected':''}>${k}</option>`).join('')
els.capo.value = conf.capo; els.a4.value = conf.a4
els.atk.value = conf.atk; els.rel.value = conf.rel; els.color.value = conf.color; els.body.value = conf.body
els.vRate.value = conf.vRate; els.vDepth.value = conf.vDepth
els.strum.value = conf.strum; els.dur.value = conf.dur; els.arpRate.value = conf.arpRate
els.chord.value = conf.chord
}
syncUI()
applyTuning()
// controls
els.power.onclick = () => { initAudio(); els.power.textContent='Audio On' }
els.theme.onclick = () => { conf.theme = conf.theme==='dark'?'light':'dark'; save(); syncUI() }
els.inst.oninput = () => { conf.inst = els.inst.value; conf.tuningKey = conf.inst+':'+Object.keys(Tunings[conf.inst])[0]; conf.chord='—'; save(); syncUI(); applyTuning() }
els.tuning.oninput = () => { conf.tuningKey = conf.inst+':'+els.tuning.value; save(); applyTuning() }
els.capo.oninput = e => { conf.capo = +e.target.value|0; save(); buildStringRows() }
els.a4.oninput = e => { conf.a4 = +e.target.value; save() }
els.atk.oninput = e => { conf.atk = +e.target.value; save() }
els.rel.oninput = e => { conf.rel = +e.target.value; save() }
els.color.oninput = e => { conf.color = +e.target.value; if (lowpass) lowpass.frequency.value = conf.color; save() }
els.body.oninput = e => { conf.body = +e.target.value; save() }
els.vRate.oninput = e => { conf.vRate = +e.target.value; save() }
els.vDepth.oninput = e => { conf.vDepth = +e.target.value; save() }
els.strum.oninput = e => { conf.strum = +e.target.value|0; save() }
els.dur.oninput = e => { conf.dur = +e.target.value; save() }
els.arpRate.oninput = e => { conf.arpRate = +e.target.value|0; if (arpTimer){ toggleArp(); toggleArp() } save() }
els.strumDown.onclick = () => strumDown()
els.strumUp.onclick = () => strumUp()
els.arp.onclick = () => toggleArp()
els.chord.oninput = () => {
conf.chord = els.chord.value; save()
if (conf.chord==='—') return
applyChord(conf.chord)
}
// keyboard helpers: 1..strings pluck / [ and ] strum up/down
window.addEventListener('keydown', e=>{
if (!ctx) return
if (e.key==='['){ e.preventDefault(); strumDown() }
else if (e.key===']'){ e.preventDefault(); strumUp() }
else {
const n = +e.key
if (Number.isInteger(n) && n>=1 && n<=state.strings.length) {
triggerString(n-1)
}
}
})
setStatus('Ready')
})()
</script>
</body>
</html>