Add html/strings.ejs
This commit is contained in:
parent
4b80e831fd
commit
d9fbe7ffb4
565
html/strings.ejs
Normal file
565
html/strings.ejs
Normal file
@ -0,0 +1,565 @@
|
||||
<!--
|
||||
|
||||
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> </label><button id="strumDown" class="secondary">Strum Down</button></div>
|
||||
<div class="pair"><label> </label><button id="strumUp" class="secondary">Strum Up</button></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="pair"><label> </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>
|
||||
Loading…
x
Reference in New Issue
Block a user