Files
homeclaw/web-ui/midi-editor.html
T
kimandClaude Sonnet 4.6 1bfadd6060 Add MIDI editor + fix external synth instrument mapping
- web-ui/midi-editor.html: full piano roll MIDI editor (new tab)
  - all tracks simultaneously, color-coded per channel
  - note add/delete/move/resize, multi-select, copy/paste
  - velocity lane, track panel (mute/solo/instrument), undo/redo
  - GM reset + bank select on playback, MIDI output device select
  - export to .mid, open via ?url= param or file picker

- web-ui/index.html: add ✏ 편집 button to MIDI widget (opens editor)
  - GM SysEx reset + bank select before note playback
  - 200ms note delay for synth init time
  - getRenderFile: prefer original.midi over LilyPond output.midi

- src/gateway/music-renderer.ts: getRenderFile returns original.midi first
  - LilyPond SVG render overwrites output.midi (no Program Changes)
  - original.midi preserved from upload, served via /api/music/file/:id/midi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:54:15 +09:00

902 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MIDI Editor — Homeclaw</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1e1e2e; color: #cdd6f4; font-family: 'Segoe UI', system-ui, sans-serif; font-size: 13px; height: 100vh; display: flex; flex-direction: column; overflow: hidden; user-select: none; }
/* ── Toolbar ── */
#toolbar { display: flex; align-items: center; gap: 3px; padding: 4px 8px; background: #181825; border-bottom: 1px solid #313244; flex-shrink: 0; flex-wrap: wrap; min-height: 36px; }
.tb-sep { width: 1px; height: 22px; background: #45475a; margin: 0 4px; }
.tb-btn { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 3px 8px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.tb-btn:hover { background: #45475a; }
.tb-btn.active { background: #89b4fa; color: #1e1e2e; border-color: #89b4fa; }
.tb-lbl { color: #6c7086; font-size: 11px; }
select.tb-sel { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 2px 4px; font-size: 12px; }
input.tb-num { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 2px 6px; width: 58px; font-size: 12px; text-align: center; }
#midi-out-sel { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 2px 4px; font-size: 11px; max-width: 160px; }
/* ── Main ── */
#main { display: flex; flex: 1; overflow: hidden; }
/* ── Track Panel ── */
#track-panel { width: 162px; flex-shrink: 0; background: #181825; border-right: 1px solid #313244; display: flex; flex-direction: column; overflow: hidden; }
#tp-head { padding: 4px 8px; color: #6c7086; font-size: 11px; border-bottom: 1px solid #313244; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
#track-list { flex: 1; overflow-y: auto; }
.ti { padding: 4px 6px 3px; border-bottom: 1px solid #252535; cursor: pointer; }
.ti:hover { background: #252535; }
.ti.sel { background: #313244; }
.ti-top { display: flex; align-items: center; gap: 5px; }
.ti-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; cursor: pointer; }
.ti-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.ti-ch { color: #6c7086; font-size: 10px; flex-shrink: 0; }
.ti-btns { display: flex; gap: 2px; margin-top: 3px; }
.ti-btn { background: #252535; border: 1px solid #45475a; color: #6c7086; border-radius: 3px; padding: 1px 5px; cursor: pointer; font-size: 10px; }
.ti-btn:hover { background: #45475a; color: #cdd6f4; }
.ti-btn.m-on { background: #f38ba8; color: #1e1e2e; border-color: #f38ba8; }
.ti-btn.s-on { background: #a6e3a1; color: #1e1e2e; border-color: #a6e3a1; }
.ti-prog { font-size: 9px; color: #585b70; margin-top: 1px; }
/* ── Roll Container ── */
#roll-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
#ruler-row { display: flex; flex-shrink: 0; border-bottom: 1px solid #313244; }
#corner { width: 56px; flex-shrink: 0; background: #1a1a2a; border-right: 1px solid #313244; height: 24px; display: flex; align-items: center; justify-content: center; }
#corner span { color: #45475a; font-size: 9px; }
#ruler-canvas { flex: 1; display: block; background: #1a1a2a; height: 24px; cursor: pointer; }
#roll-row { flex: 1; display: flex; overflow: hidden; min-height: 0; }
#keys-canvas { display: block; flex-shrink: 0; width: 56px; background: #1a1a2a; border-right: 1px solid #313244; cursor: pointer; }
#roll-canvas { display: block; flex: 1; min-width: 0; cursor: default; }
#vel-row { height: 70px; flex-shrink: 0; display: flex; border-top: 1px solid #313244; }
#vel-label { width: 56px; flex-shrink: 0; background: #1a1a2a; border-right: 1px solid #313244; display: flex; align-items: center; justify-content: center; color: #45475a; font-size: 9px; }
#vel-canvas { display: block; flex: 1; min-width: 0; cursor: ns-resize; background: #16161e; }
/* ── Transport ── */
#transport { display: flex; align-items: center; gap: 6px; padding: 4px 10px; background: #181825; border-top: 1px solid #313244; flex-shrink: 0; }
.tr-btn { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 2px 10px; cursor: pointer; font-size: 14px; }
.tr-btn:hover { background: #45475a; }
#pos-disp { font-family: monospace; color: #a6e3a1; font-size: 12px; min-width: 90px; }
</style>
</head>
<body>
<div id="toolbar">
<button class="tb-btn" onclick="openFileDialog()">📂 열기</button>
<button class="tb-btn" onclick="doSave()">💾 저장</button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="doUndo()" title="Ctrl+Z">↩</button>
<button class="tb-btn" onclick="doRedo()" title="Ctrl+Y">↪</button>
<div class="tb-sep"></div>
<button class="tb-btn" id="t-sel" onclick="setTool('sel')" title="V">▢ 선택</button>
<button class="tb-btn" id="t-draw" onclick="setTool('draw')" title="P">✏ 그리기</button>
<button class="tb-btn" id="t-erase" onclick="setTool('erase')" title="E">✗ 지우개</button>
<div class="tb-sep"></div>
<span class="tb-lbl">양자화</span>
<select class="tb-sel" id="q-sel" onchange="setQ(this.value)">
<option value="0">없음</option>
<option value="30">1/64</option>
<option value="60">1/32</option>
<option value="120" selected>1/16</option>
<option value="240">1/8</option>
<option value="480">1/4</option>
<option value="960">1/2</option>
<option value="1920">1/1</option>
</select>
<div class="tb-sep"></div>
<span class="tb-lbl">BPM</span>
<input class="tb-num" id="bpm-in" type="number" value="120" min="20" max="300" onchange="setBpm(+this.value)">
<div class="tb-sep"></div>
<button class="tb-btn" onclick="zoomX(1.3)">X+</button>
<button class="tb-btn" onclick="zoomX(1/1.3)">X−</button>
<button class="tb-btn" onclick="zoomY(1.3)">Y+</button>
<button class="tb-btn" onclick="zoomY(1/1.3)">Y−</button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="doAddTrack()">+ 트랙</button>
<div class="tb-sep"></div>
<span class="tb-lbl">MIDI 출력</span>
<select id="midi-out-sel"><option value="">-- 없음 --</option></select>
<button class="tb-btn" onclick="refreshMidiOuts()">↻</button>
</div>
<div id="main">
<div id="track-panel">
<div id="tp-head"><span>트랙</span></div>
<div id="track-list"></div>
</div>
<div id="roll-wrap">
<div id="ruler-row">
<div id="corner"><span>bar</span></div>
<canvas id="ruler-canvas" height="24"></canvas>
</div>
<div id="roll-row">
<canvas id="keys-canvas" width="56"></canvas>
<canvas id="roll-canvas"></canvas>
</div>
<div id="vel-row">
<div id="vel-label">vel</div>
<canvas id="vel-canvas" height="70"></canvas>
</div>
</div>
</div>
<div id="transport">
<button class="tr-btn" onclick="doRewind()">⏮</button>
<button class="tr-btn" id="play-btn" onclick="doTogglePlay()">▶</button>
<button class="tr-btn" onclick="doStop()">■</button>
<span id="pos-disp">1:1:000</span>
<span class="tb-lbl">루프</span>
<input type="checkbox" id="loop-en">
<span class="tb-lbl">In</span><input class="tb-num" id="loop-in" type="number" value="1" min="1" style="width:42px">
<span class="tb-lbl">Out</span><input class="tb-num" id="loop-out" type="number" value="5" min="1" style="width:42px">
<span style="flex:1"></span>
<span class="tb-lbl" id="status-txt"></span>
</div>
<input type="file" id="file-in" accept=".mid,.midi" style="display:none" onchange="handleOpen(this)">
<script>
// ═══════════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════════
const KEY_W = 56;
const RULE_H = 24;
const VEL_H = 70;
const COLORS = ['#e06c75','#98c379','#61afef','#c678dd','#e5c07b','#56b6c2','#d19a66','#be5046','#a6e3a1','#89dceb','#cba6f7','#f9e2af','#74c7ec','#fab387'];
const GM_NAMES = ['Piano','Bright Piano','E.Grand','Honky-Tonk','E.Piano1','E.Piano2','Harpsichord','Clavinet','Celesta','Glockenspiel','Music Box','Vibraphone','Marimba','Xylophone','Tubular Bells','Dulcimer','Hammond Organ','Perc.Organ','Rock Organ','Church Organ','Reed Organ','Accordion','Harmonica','Bandoneon','Nylon Guitar','Steel Guitar','Jazz Guitar','Clean Guitar','Muted Guitar','Overd.Guitar','Dist.Guitar','Harmonics','Acoustic Bass','Finger Bass','Pick Bass','Fretless Bass','Slap Bass1','Slap Bass2','Synth Bass1','Synth Bass2','Violin','Viola','Cello','Contrabass','Tremolo Str','Pizzicato','Harp','Timpani','Str Ensemble1','Str Ensemble2','Synth Str1','Synth Str2','Choir Aahs','Voice Oohs','Synth Choir','Orch.Hit','Trumpet','Trombone','Tuba','Muted Trumpet','French Horn','Brass Section','Synth Brass1','Synth Brass2','Soprano Sax','Alto Sax','Tenor Sax','Baritone Sax','Oboe','English Horn','Bassoon','Clarinet','Piccolo','Flute','Recorder','Pan Flute','Bottle Blow','Shakuhachi','Whistle','Ocarina','Square Lead','Sawtooth Lead','Calliope Lead','Chiffer Lead','Charang Lead','Solo Vox','5th Sawtooth','Bass+Lead','Fantasia','Warm Pad','Polysynth','Choir Pad','Bowed Pad','Metallic Pad','Halo Pad','Sweep Pad','Rain','Soundtrack','Crystal','Atmosphere','Brightness','Goblins','Echoes','Sci-Fi','Sitar','Banjo','Shamisen','Koto','Kalimba','Bag Pipe','Fiddle','Shanai','Tinkle Bell','Agogo','Steel Drums','Woodblock','Taiko Drum','Melodic Tom','Synth Drum','Rev.Cymbal','Guitar Fret','Breath Noise','Seashore','Bird Tweet','Telephone','Helicopter','Applause','Gunshot'];
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const BLACK_K = new Set([1,3,6,8,10]);
// ═══════════════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════════════
let midi = mkMidi();
let view = { scrollX: 0, scrollY: 57 * 8, pxPerTick: 0.15, noteH: 8 };
let tool = 'sel';
let qTicks = 120;
let activeTi = 0;
let sel = new Set();
let hist = [], histIdx = -1;
let drag = null;
let ph = 0; // playhead ticks
let playing = false;
let midiAccess = null, midiOut = null;
let animId = null;
let playT0 = 0, playTick0 = 0;
let velDrag = null;
let clip = [];
function mkMidi() {
return { format:1, tpq:480, tracks:[], tempos:[{tick:0,uspb:500000}], timeSigs:[{tick:0,num:4,den:4}] };
}
// ═══════════════════════════════════════════════════════════════════
// Canvases
// ═══════════════════════════════════════════════════════════════════
const rulerC = document.getElementById('ruler-canvas');
const keysC = document.getElementById('keys-canvas');
const rollC = document.getElementById('roll-canvas');
const velC = document.getElementById('vel-canvas');
const rCtx = rulerC.getContext('2d');
const kCtx = keysC.getContext('2d');
const pCtx = rollC.getContext('2d');
const vCtx = velC.getContext('2d');
function resize() {
const wrap = document.getElementById('roll-wrap');
const rr = document.getElementById('roll-row');
const W = wrap.clientWidth - KEY_W;
const H = rr.clientHeight;
rulerC.width = W; rulerC.height = RULE_H;
keysC.height = H;
rollC.width = W; rollC.height = H;
velC.width = W; velC.height = VEL_H;
renderAll();
}
window.addEventListener('resize', resize);
// ═══════════════════════════════════════════════════════════════════
// MIDI Parser (tick-based)
// ═══════════════════════════════════════════════════════════════════
function parseMidi(buf) {
const u8 = new Uint8Array(buf), dv = new DataView(buf);
if (dv.getUint32(0) !== 0x4D546864) throw new Error('Not a MIDI file');
const format = dv.getUint16(8), ntrk = dv.getUint16(10), tpq = dv.getUint16(12);
const res = { format, tpq, tracks:[], tempos:[], timeSigs:[] };
const readVL = (pos) => {
let val=0,b; do { b=u8[pos++]; val=(val<<7)|(b&0x7F); } while(b&0x80);
return { val, pos };
};
let p = 14;
for (let t = 0; t < ntrk; t++) {
if (p+8 > u8.length || dv.getUint32(p) !== 0x4D54726B) break;
const tlen = dv.getUint32(p+4); p+=8; const end = p+tlen;
let tick=0, rs=0;
const open={}, notes=[], pcs=[];
let name=`Track ${t+1}`, ch=-1;
while (p < end) {
const dl=readVL(p); p=dl.pos; tick+=dl.val;
let b=u8[p]; if(b&0x80){rs=b;p++;} else {b=rs;}
const typ=b&0xF0, c=b&0x0F;
if (typ===0x90) {
const n=u8[p++],v=u8[p++]; if(ch<0)ch=c;
if(v>0) open[`${c}_${n}`]={tick,vel:v};
else { const o=open[`${c}_${n}`]; if(o){notes.push({tick:o.tick,dur:Math.max(1,tick-o.tick),pitch:n,vel:o.vel});delete open[`${c}_${n}`];} }
} else if (typ===0x80) {
const n=u8[p++]; p++; if(ch<0)ch=c;
const o=open[`${c}_${n}`]; if(o){notes.push({tick:o.tick,dur:Math.max(1,tick-o.tick),pitch:n,vel:o.vel});delete open[`${c}_${n}`];}
} else if (typ===0xA0) { p+=2; }
else if (typ===0xB0) { p+=2; }
else if (typ===0xC0) { pcs.push({tick,prog:u8[p++]}); if(ch<0)ch=c; }
else if (typ===0xD0) { p++; }
else if (typ===0xE0) { p+=2; }
else if (b===0xFF) {
const mt=u8[p++]; const ll=readVL(p); p=ll.pos;
if(mt===0x03) try{name=new TextDecoder().decode(u8.slice(p,p+ll.val));}catch{}
else if(mt===0x51&&ll.val>=3) res.tempos.push({tick,uspb:(u8[p]<<16)|(u8[p+1]<<8)|u8[p+2]});
else if(mt===0x58&&ll.val>=4) res.timeSigs.push({tick,num:u8[p],den:1<<u8[p+1]});
p+=ll.val;
} else if (b===0xF0||b===0xF7) { const ll=readVL(p); p=ll.pos+ll.val; }
else { p++; }
p = Math.min(p, end);
}
// Close orphaned notes
for(const k in open){const[,np]=k.split('_').map(Number);const o=open[k];notes.push({tick:o.tick,dur:tpq,pitch:np,vel:o.vel});}
if(notes.length>0||pcs.length>0) {
res.tracks.push({name,channel:ch>=0?ch:t%16,notes,pcs,muted:false,solo:false,color:COLORS[res.tracks.length%COLORS.length]});
}
p=end;
}
if(!res.tempos.length) res.tempos.push({tick:0,uspb:500000});
if(!res.timeSigs.length) res.timeSigs.push({tick:0,num:4,den:4});
return res;
}
// ═══════════════════════════════════════════════════════════════════
// MIDI Writer
// ═══════════════════════════════════════════════════════════════════
function writeVL(n) {
if(n===0)return[0];
const b=[]; while(n>0){b.unshift(n&0x7F);n>>>=7;} for(let i=0;i<b.length-1;i++)b[i]|=0x80; return b;
}
function writeMidi(state) {
const trks=[];
// Tempo track
const tevs=[];
for(const ts of [...state.timeSigs].sort((a,b)=>a.tick-b.tick))
tevs.push({tick:ts.tick,bytes:[0xFF,0x58,0x04,ts.num,Math.round(Math.log2(ts.den)),24,8]});
for(const t of [...state.tempos].sort((a,b)=>a.tick-b.tick))
tevs.push({tick:t.tick,bytes:[0xFF,0x51,0x03,(t.uspb>>16)&0xFF,(t.uspb>>8)&0xFF,t.uspb&0xFF]});
tevs.sort((a,b)=>a.tick-b.tick);
const tb=[]; let pv=0;
for(const e of tevs){tb.push(...writeVL(e.tick-pv),...e.bytes);pv=e.tick;}
tb.push(0x00,0xFF,0x2F,0x00); trks.push(tb);
for(const trk of state.tracks){
const evs=[];
for(const pc of (trk.pcs||[])) evs.push({tick:pc.tick,bytes:[0xC0|trk.channel,pc.prog]});
for(const n of trk.notes){
evs.push({tick:n.tick, bytes:[0x90|trk.channel,n.pitch,n.vel]});
evs.push({tick:n.tick+n.dur,bytes:[0x80|trk.channel,n.pitch,0]});
}
evs.sort((a,b)=>a.tick-b.tick);
const bytes=[]; pv=0;
for(const e of evs){bytes.push(...writeVL(e.tick-pv),...e.bytes);pv=e.tick;}
bytes.push(0x00,0xFF,0x2F,0x00); trks.push(bytes);
}
let total=14; for(const t of trks) total+=8+t.length;
const buf=new ArrayBuffer(total), dv=new DataView(buf), u8=new Uint8Array(buf);
let p=0;
dv.setUint32(p,0x4D546864);p+=4; dv.setUint32(p,6);p+=4;
dv.setUint16(p,1);p+=2; dv.setUint16(p,trks.length);p+=2; dv.setUint16(p,state.tpq);p+=2;
for(const t of trks){dv.setUint32(p,0x4D54726B);p+=4;dv.setUint32(p,t.length);p+=4;u8.set(t,p);p+=t.length;}
return buf;
}
// ═══════════════════════════════════════════════════════════════════
// Coordinate helpers
// ═══════════════════════════════════════════════════════════════════
const tX = tick => tick * view.pxPerTick - view.scrollX;
const pY = pitch => (127-pitch)*view.noteH - view.scrollY;
const xT = x => (x + view.scrollX) / view.pxPerTick;
const yP = y => 127 - Math.floor((y + view.scrollY) / view.noteH);
const qz = tick => qTicks>0 ? Math.round(tick/qTicks)*qTicks : Math.round(tick);
const bpm = () => Math.round(60000000 / (midi.tempos[0]?.uspb||500000));
const mspT= () => (midi.tempos[0]?.uspb||500000) / midi.tpq / 1000;
// ═══════════════════════════════════════════════════════════════════
// Rendering
// ═══════════════════════════════════════════════════════════════════
function renderAll() { renderRuler(); renderKeys(); renderRoll(); renderVel(); }
function renderRuler() {
const W=rulerC.width, H=RULE_H;
rCtx.fillStyle='#1a1a2a'; rCtx.fillRect(0,0,W,H);
const tpb=midi.tpq*(midi.timeSigs[0]?.num||4);
const startBar=Math.floor(view.scrollX/(tpb*view.pxPerTick));
const endBar=startBar+Math.ceil(W/(tpb*view.pxPerTick))+2;
rCtx.font='10px monospace'; rCtx.textBaseline='middle';
for(let bar=startBar; bar<=endBar; bar++){
const x=bar*tpb*view.pxPerTick-view.scrollX;
rCtx.strokeStyle='#45475a'; rCtx.lineWidth=1;
rCtx.beginPath(); rCtx.moveTo(x+.5,0); rCtx.lineTo(x+.5,H); rCtx.stroke();
if(x>=0&&x<W){rCtx.fillStyle='#a6adc8'; rCtx.fillText(bar+1,x+3,H/2);}
if(tpb*view.pxPerTick>40){
for(let b=1;b<(midi.timeSigs[0]?.num||4);b++){
const bx=x+b*midi.tpq*view.pxPerTick;
rCtx.strokeStyle='#313244'; rCtx.lineWidth=.5;
rCtx.beginPath(); rCtx.moveTo(bx+.5,H/2); rCtx.lineTo(bx+.5,H); rCtx.stroke();
}
}
}
const phx=tX(ph); if(phx>=0&&phx<=W){rCtx.fillStyle='#f38ba8'; rCtx.fillRect(phx-1,0,2,H);}
}
function renderKeys() {
const W=KEY_W, H=keysC.height;
kCtx.fillStyle='#1a1a2a'; kCtx.fillRect(0,0,W,H);
for(let p=0; p<=127; p++){
const y=pY(p); if(y+view.noteH<0||y>H) continue;
const isB=BLACK_K.has(p%12), isC=p%12===0;
kCtx.fillStyle=isB?'#2a2a3a':(isC?'#3a3a5a':'#e8e8f0');
kCtx.fillRect(0,y,W-(isB?14:0),view.noteH);
kCtx.strokeStyle='#45475a'; kCtx.lineWidth=.5;
kCtx.strokeRect(.5,y+.5,W-(isB?14:0)-1,view.noteH-1);
if(!isB&&view.noteH>=7){
kCtx.fillStyle=isC?'#89b4fa':'#6c7086';
kCtx.font=`${Math.min(9,view.noteH-1)}px monospace`;
kCtx.textAlign='right'; kCtx.textBaseline='middle';
kCtx.fillText(NOTE_NAMES[p%12]+(isC?Math.floor(p/12)-1:''),W-16,y+view.noteH/2);
}
}
}
function renderRoll() {
const W=rollC.width, H=rollC.height;
pCtx.fillStyle='#1e1e2e'; pCtx.fillRect(0,0,W,H);
const nh=view.noteH;
// Pitch rows
for(let p=127;p>=0;p--){
const y=pY(p); if(y+nh<0||y>H) continue;
const isB=BLACK_K.has(p%12), isC=p%12===0;
pCtx.fillStyle=isB?'#191926':(isC?'#1e1e38':'#1e1e2e');
pCtx.fillRect(0,y,W,nh);
if(isC){pCtx.strokeStyle='#2e2e50';pCtx.lineWidth=.5;pCtx.beginPath();pCtx.moveTo(0,y+.5);pCtx.lineTo(W,y+.5);pCtx.stroke();}
}
// Bar/beat grid
const tpb=midi.tpq*(midi.timeSigs[0]?.num||4);
const s0=Math.floor(view.scrollX/(tpb*view.pxPerTick));
const s1=s0+Math.ceil(W/(tpb*view.pxPerTick))+2;
for(let bar=s0;bar<=s1;bar++){
const x=bar*tpb*view.pxPerTick-view.scrollX;
pCtx.strokeStyle='#313244'; pCtx.lineWidth=1;
pCtx.beginPath(); pCtx.moveTo(x+.5,0); pCtx.lineTo(x+.5,H); pCtx.stroke();
if(view.pxPerTick*midi.tpq>16){
for(let b=1;b<(midi.timeSigs[0]?.num||4);b++){
const bx=x+b*midi.tpq*view.pxPerTick;
pCtx.strokeStyle='#252535'; pCtx.lineWidth=.5;
pCtx.beginPath(); pCtx.moveTo(bx+.5,0); pCtx.lineTo(bx+.5,H); pCtx.stroke();
}
}
}
// Sub-beat grid (1/16) when zoomed in
if(view.pxPerTick*midi.tpq>60){
const subT=midi.tpq/4;
const subS=Math.floor(view.scrollX/(subT*view.pxPerTick));
const subE=subS+Math.ceil(W/(subT*view.pxPerTick))+2;
for(let i=subS;i<=subE;i++){
if(i%(4*(midi.timeSigs[0]?.num||4))===0) continue;
if(i%4===0) continue;
const x=i*subT*view.pxPerTick-view.scrollX;
pCtx.strokeStyle='#232333'; pCtx.lineWidth=.5;
pCtx.beginPath(); pCtx.moveTo(x+.5,0); pCtx.lineTo(x+.5,H); pCtx.stroke();
}
}
const anySolo=midi.tracks.some(t=>t.solo);
// Notes
for(let ti=0;ti<midi.tracks.length;ti++){
const trk=midi.tracks[ti];
if(trk.muted||(anySolo&&!trk.solo)) continue;
for(const n of trk.notes){
const x=tX(n.tick), w=Math.max(2,n.dur*view.pxPerTick-1), y=pY(n.pitch);
if(x+w<0||x>W||y+nh<0||y>H) continue;
const isSel=sel.has(n);
pCtx.fillStyle=isSel?'#fff':trk.color;
pCtx.fillRect(x,y+1,w,nh-2);
if(isSel){pCtx.strokeStyle=trk.color;pCtx.lineWidth=1;pCtx.strokeRect(x+.5,y+1.5,w-1,nh-3);}
else if(w>5){pCtx.fillStyle='rgba(0,0,0,.25)';pCtx.fillRect(x+w-3,y+1,3,nh-2);}
}
}
// Drag select box
if(drag?.type==='box'){
const {x0,y0,x1,y1}=drag;
pCtx.strokeStyle='#89b4fa'; pCtx.lineWidth=1; pCtx.setLineDash([4,2]);
pCtx.strokeRect(Math.min(x0,x1),Math.min(y0,y1),Math.abs(x1-x0),Math.abs(y1-y0));
pCtx.setLineDash([]);
}
// Playhead
const phx=tX(ph); if(phx>=0&&phx<=W){pCtx.fillStyle='#f38ba8';pCtx.fillRect(phx-1,0,2,H);}
}
function renderVel() {
const W=velC.width, H=VEL_H;
vCtx.fillStyle='#16161e'; vCtx.fillRect(0,0,W,H);
vCtx.strokeStyle='#252535'; vCtx.lineWidth=.5;
[.25,.5,.75].forEach(f=>{vCtx.beginPath();vCtx.moveTo(0,H*f);vCtx.lineTo(W,H*f);vCtx.stroke();});
const anySolo=midi.tracks.some(t=>t.solo);
for(const trk of midi.tracks){
if(trk.muted||(anySolo&&!trk.solo)) continue;
for(const n of trk.notes){
const x=tX(n.tick); if(x<-4||x>W+4) continue;
const bH=Math.max(2,Math.round((n.vel/127)*(H-4)));
vCtx.fillStyle=sel.has(n)?'#fff':trk.color+'bb';
vCtx.fillRect(x-1,H-bH-2,3,bH);
}
}
}
// ═══════════════════════════════════════════════════════════════════
// Track Panel
// ═══════════════════════════════════════════════════════════════════
function renderTracks() {
const list=document.getElementById('track-list');
list.innerHTML='';
midi.tracks.forEach((trk,i)=>{
const prog=trk.pcs?.[0]?.prog;
const progName=prog!==undefined?(trk.channel===9?'Drums':GM_NAMES[prog]||`Prog ${prog}`):'';
const div=document.createElement('div');
div.className='ti'+(i===activeTi?' sel':'');
div.onclick=()=>{activeTi=i;renderTracks();};
div.innerHTML=`<div class="ti-top">
<div class="ti-dot" style="background:${trk.color}" title="색상변경" onclick="event.stopPropagation();cycleColor(${i})"></div>
<div class="ti-name" title="${trk.name}">${trk.name}</div>
<div class="ti-ch">ch${trk.channel+1}</div>
</div>
<div class="ti-btns">
<button class="ti-btn${trk.muted?' m-on':''}" onclick="event.stopPropagation();toggleMute(${i})">M</button>
<button class="ti-btn${trk.solo?' s-on':''}" onclick="event.stopPropagation();toggleSolo(${i})">S</button>
<select style="font-size:9px;background:#252535;color:#cdd6f4;border:1px solid #45475a;border-radius:2px;padding:1px;max-width:70px" onchange="event.stopPropagation();setTrackProg(${i},+this.value)" onclick="event.stopPropagation()">
${Array.from({length:128},(_,k)=>`<option value="${k}"${k===(prog??0)?' selected':''}>${k}:${GM_NAMES[k]||k}</option>`).join('')}
</select>
<button class="ti-btn" style="margin-left:auto" onclick="event.stopPropagation();delTrack(${i})">✕</button>
</div>
${progName?`<div class="ti-prog">${progName}</div>`:''}`;
list.appendChild(div);
});
}
function cycleColor(i) {
const idx=COLORS.indexOf(midi.tracks[i].color);
midi.tracks[i].color=COLORS[(idx+1)%COLORS.length];
renderAll(); renderTracks();
}
function toggleMute(i) { pushH(); midi.tracks[i].muted=!midi.tracks[i].muted; renderAll(); renderTracks(); }
function toggleSolo(i) { pushH(); midi.tracks[i].solo=!midi.tracks[i].solo; renderAll(); renderTracks(); }
function delTrack(i) { pushH(); midi.tracks.splice(i,1); if(activeTi>=midi.tracks.length)activeTi=Math.max(0,midi.tracks.length-1); renderAll(); renderTracks(); }
function setTrackProg(i, prog) { pushH(); if(!midi.tracks[i].pcs.length)midi.tracks[i].pcs.push({tick:0,prog}); else midi.tracks[i].pcs[0].prog=prog; renderTracks(); }
function doAddTrack() {
pushH();
const ch=midi.tracks.length%16;
midi.tracks.push({name:`Track ${midi.tracks.length+1}`,channel:ch,notes:[],pcs:[{tick:0,prog:0}],muted:false,solo:false,color:COLORS[midi.tracks.length%COLORS.length]});
activeTi=midi.tracks.length-1;
renderAll(); renderTracks();
}
// ═══════════════════════════════════════════════════════════════════
// Tools
// ═══════════════════════════════════════════════════════════════════
function setTool(t) {
tool=t;
['sel','draw','erase'].forEach(n=>document.getElementById('t-'+n).classList.toggle('active',n===t));
}
setTool('sel');
function setQ(v) { qTicks=+v; }
function setBpm(v) { pushH(); const b=Math.max(20,Math.min(300,v||120)); midi.tempos=[{tick:0,uspb:Math.round(60000000/b)}]; document.getElementById('bpm-in').value=b; }
function zoomX(f) { const mid=view.scrollX+rollC.width/2; view.pxPerTick=Math.max(.02,Math.min(4,view.pxPerTick*f)); view.scrollX=Math.max(0,mid-rollC.width/2); renderAll(); }
function zoomY(f) { view.noteH=Math.max(3,Math.min(24,Math.round(view.noteH*f))); renderAll(); }
// ═══════════════════════════════════════════════════════════════════
// Hit Test
// ═══════════════════════════════════════════════════════════════════
function hitNote(cx,cy) {
const tick=xT(cx),pitch=yP(cy);
const anySolo=midi.tracks.some(t=>t.solo);
for(let i=midi.tracks.length-1;i>=0;i--){
const trk=midi.tracks[i]; if(trk.muted||(anySolo&&!trk.solo)) continue;
for(const n of trk.notes){
if(n.pitch===pitch&&tick>=n.tick&&tick<n.tick+n.dur) return {n,trk,ti:i};
}
}
return null;
}
function hitResize(cx,cy) {
const RZ=6/view.pxPerTick, pitch=yP(cy);
const anySolo=midi.tracks.some(t=>t.solo);
for(let i=midi.tracks.length-1;i>=0;i--){
const trk=midi.tracks[i]; if(trk.muted||(anySolo&&!trk.solo)) continue;
for(const n of trk.notes){
if(n.pitch!==pitch) continue;
const endTick=n.tick+n.dur;
if(Math.abs(xT(cx)-endTick)<=RZ) return {n,trk,ti:i};
}
}
return null;
}
// ═══════════════════════════════════════════════════════════════════
// Mouse Events (Roll)
// ═══════════════════════════════════════════════════════════════════
let mdown=false;
rollC.addEventListener('contextmenu',e=>e.preventDefault());
rollC.addEventListener('mousedown',e=>{
if(e.button!==0&&e.button!==2) return;
mdown=true; e.preventDefault();
const cx=e.offsetX, cy=e.offsetY;
if(e.button===2||tool==='erase'){
const h=hitNote(cx,cy);
if(h){pushH();h.trk.notes.splice(h.trk.notes.indexOf(h.n),1);sel.delete(h.n);renderAll();renderVel();}
drag={type:'erase'}; return;
}
if(tool==='draw'){
const pitch=yP(cy); if(pitch<0||pitch>127) return;
if(activeTi>=midi.tracks.length) doAddTrack();
pushH();
const tick=Math.max(0,qz(xT(cx)));
const dur=qTicks>0?qTicks:midi.tpq/4;
const n={tick,dur,pitch,vel:80};
midi.tracks[activeTi].notes.push(n);
sel=new Set([n]);
drag={type:'draw',n,startTick:tick};
renderAll(); return;
}
// Select tool
const rh=hitResize(cx,cy);
if(rh){
if(!sel.has(rh.n)){if(!e.shiftKey)sel.clear();sel.add(rh.n);}
pushH();
drag={type:'resize',n:rh.n,startX:cx,origDur:rh.n.dur,origTick:rh.n.tick};
return;
}
const h=hitNote(cx,cy);
if(h){
if(!e.shiftKey&&!sel.has(h.n)) sel.clear();
sel.add(h.n); pushH();
drag={type:'move',startX:cx,startY:cy,origNotes:[...sel].map(n=>({n,tick:n.tick,pitch:n.pitch}))};
renderAll(); return;
}
if(!e.shiftKey) sel.clear();
drag={type:'box',x0:cx,y0:cy,x1:cx,y1:cy};
renderAll();
});
rollC.addEventListener('mousemove',e=>{
const cx=e.offsetX,cy=e.offsetY;
if(!mdown||!drag){
if(tool==='sel'){
const rh=hitResize(cx,cy);
rollC.style.cursor=rh?'ew-resize':(hitNote(cx,cy)?'grab':'default');
} else {
rollC.style.cursor=tool==='draw'?'crosshair':'cell';
}
return;
}
if(drag.type==='erase'){
const h=hitNote(cx,cy);
if(h){h.trk.notes.splice(h.trk.notes.indexOf(h.n),1);sel.delete(h.n);renderAll();renderVel();}
return;
}
if(drag.type==='draw'){
const minDur=Math.max(1,qTicks>0?qTicks:1);
const endTick=Math.max(drag.startTick+minDur,qz(xT(cx)));
drag.n.dur=endTick-drag.startTick;
renderAll(); return;
}
if(drag.type==='resize'){
const minDur=Math.max(1,qTicks>0?qTicks:1);
drag.n.dur=Math.max(minDur,qz(xT(cx))-drag.n.tick||minDur);
renderAll(); renderVel(); return;
}
if(drag.type==='move'){
const dtick=qz(xT(cx))-qz(xT(drag.startX));
const dpitch=yP(cy)-yP(drag.startY);
for(const {n,tick:ot,pitch:op} of drag.origNotes){
n.tick=Math.max(0,ot+dtick);
n.pitch=Math.max(0,Math.min(127,op+dpitch));
}
renderAll(); return;
}
if(drag.type==='box'){
drag.x1=cx; drag.y1=cy; renderRoll(); return;
}
});
rollC.addEventListener('mouseup',e=>{
mdown=false;
if(drag?.type==='box'){
const {x0,y0,x1,y1}=drag;
const t0=xT(Math.min(x0,x1)),t1=xT(Math.max(x0,x1));
const p0=yP(Math.max(y0,y1)),p1=yP(Math.min(y0,y1));
if(!e.shiftKey) sel.clear();
for(const trk of midi.tracks) for(const n of trk.notes)
if(n.tick<t1&&n.tick+n.dur>t0&&n.pitch>=p0&&n.pitch<=p1) sel.add(n);
renderAll();
}
drag=null; renderVel();
});
rollC.addEventListener('wheel',e=>{
e.preventDefault();
if(e.ctrlKey){
const f=e.deltaY<0?1.2:.8, bt=xT(e.offsetX);
view.pxPerTick=Math.max(.02,Math.min(4,view.pxPerTick*f));
view.scrollX=Math.max(0,bt*view.pxPerTick-e.offsetX);
} else if(e.shiftKey){
view.scrollX=Math.max(0,view.scrollX+e.deltaY);
} else {
view.scrollY=Math.max(0,Math.min(127*view.noteH-rollC.height*.5,view.scrollY+e.deltaY));
}
renderAll();
},{passive:false});
rulerC.addEventListener('click',e=>{
ph=Math.max(0,qz(xT(e.offsetX)));
renderRuler(); renderRoll(); updPos();
});
// Velocity lane drag
velC.addEventListener('mousedown',e=>{
velDrag={startY:e.offsetY}; e.preventDefault();
});
velC.addEventListener('mousemove',e=>{
if(!velDrag) return;
const x=e.offsetX;
const anySolo=midi.tracks.some(t=>t.solo);
for(const trk of midi.tracks){
if(trk.muted||(anySolo&&!trk.solo)) continue;
for(const n of trk.notes){
if(Math.abs(tX(n.tick)-x)<4){
n.vel=Math.max(1,Math.min(127,Math.round((1-(e.offsetY/(VEL_H-4)))*127)));
}
}
}
renderVel();
});
velC.addEventListener('mouseup',()=>{if(velDrag){pushH();velDrag=null;}});
// Keys: click to preview note
keysC.addEventListener('click',e=>{
const pitch=yP(e.offsetY);
if(pitch<0||pitch>127||!midiOut) return;
const ch=midi.tracks[activeTi]?.channel??0;
try{midiOut.send(new Uint8Array([0x90|ch,pitch,80]));}catch{}
setTimeout(()=>{try{midiOut.send(new Uint8Array([0x80|ch,pitch,0]));}catch{}},300);
});
// ═══════════════════════════════════════════════════════════════════
// Undo / Redo
// ═══════════════════════════════════════════════════════════════════
function pushH() {
const snap=JSON.stringify(midi);
hist=hist.slice(0,histIdx+1); hist.push(snap);
if(hist.length>60) hist.shift();
histIdx=hist.length-1;
}
function doUndo() {
if(histIdx<=0) return;
histIdx--; midi=JSON.parse(hist[histIdx]); sel.clear();
document.getElementById('bpm-in').value=bpm();
renderAll(); renderTracks();
}
function doRedo() {
if(histIdx>=hist.length-1) return;
histIdx++; midi=JSON.parse(hist[histIdx]); sel.clear();
document.getElementById('bpm-in').value=bpm();
renderAll(); renderTracks();
}
// ═══════════════════════════════════════════════════════════════════
// File I/O
// ═══════════════════════════════════════════════════════════════════
function openFileDialog() { document.getElementById('file-in').click(); }
async function handleOpen(inp) {
const f=inp.files[0]; if(!f) return;
try {
const buf=await f.arrayBuffer();
midi=parseMidi(buf); sel.clear(); hist=[]; histIdx=-1; pushH();
document.getElementById('bpm-in').value=bpm();
view.scrollX=0; view.scrollY=57*view.noteH;
document.title=`MIDI Editor — ${f.name}`;
renderAll(); renderTracks(); status('로드: '+f.name);
} catch(e) { alert('읽기 실패: '+e.message); }
inp.value='';
}
async function loadUrl(url) {
try {
const r=await fetch(url); if(!r.ok) throw new Error(r.status);
const buf=await r.arrayBuffer();
midi=parseMidi(buf); sel.clear(); hist=[]; histIdx=-1; pushH();
document.getElementById('bpm-in').value=bpm();
view.scrollX=0; view.scrollY=57*view.noteH;
const name=decodeURIComponent(url.split('/').pop());
document.title=`MIDI Editor — ${name}`;
renderAll(); renderTracks(); status('로드 완료');
} catch(e) { alert('로드 실패: '+e.message); }
}
function doSave() {
const buf=writeMidi(midi);
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([buf],{type:'audio/midi'}));
a.download=(document.title.replace('MIDI Editor — ','').replace(/\.midi?$/,'')||'edited')+'.mid';
document.body.appendChild(a); a.click();
setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000);
status('저장 완료');
}
// ═══════════════════════════════════════════════════════════════════
// MIDI Output / Playback
// ═══════════════════════════════════════════════════════════════════
async function initMidiOut() {
if(!navigator.requestMIDIAccess) return;
try {
midiAccess=await navigator.requestMIDIAccess();
midiAccess.onstatechange=()=>refreshMidiOuts();
refreshMidiOuts();
} catch {}
}
function refreshMidiOuts() {
if(!midiAccess) { initMidiOut(); return; }
const sel=document.getElementById('midi-out-sel');
const cur=sel.value;
sel.innerHTML='<option value="">-- 없음 --</option>';
midiAccess.outputs.forEach((out,id)=>{
const o=document.createElement('option');
o.value=id; o.textContent=out.name;
if(id===cur) o.selected=true;
sel.appendChild(o);
});
midiOut=cur?midiAccess.outputs.get(cur):null;
if(!midiOut&&midiAccess.outputs.size>0){
const first=midiAccess.outputs.values().next().value;
midiOut=first; sel.value=first.id;
}
}
document.getElementById('midi-out-sel').addEventListener('change',function(){
midiOut=midiAccess?.outputs.get(this.value)||null;
});
function doTogglePlay() { playing?doStop():doPlay(); }
function doRewind() { doStop(); ph=0; renderRuler(); renderRoll(); updPos(); }
function doStop() {
playing=false;
document.getElementById('play-btn').textContent='▶';
if(animId){cancelAnimationFrame(animId);animId=null;}
if(midiOut) for(let c=0;c<16;c++) try{midiOut.send(new Uint8Array([0xB0|c,123,0]));}catch{}
}
function doPlay() {
if(!midiOut){alert('MIDI 출력 기기를 선택하세요.');return;}
playing=true;
document.getElementById('play-btn').textContent='⏸';
playTick0=ph; playT0=performance.now();
const mspt=mspT();
const anySolo=midi.tracks.some(t=>t.solo);
const loopEn=document.getElementById('loop-en').checked;
const loopIn=(+document.getElementById('loop-in').value-1)*midi.tpq*(midi.timeSigs[0]?.num||4);
const loopOut=(+document.getElementById('loop-out').value)*midi.tpq*(midi.timeSigs[0]?.num||4);
// GM Reset + Program Changes
try{midiOut.send(new Uint8Array([0xF0,0x7E,0x7F,0x09,0x01,0xF7]));}catch{}
for(const trk of midi.tracks){
if(trk.pcs?.length){const c=trk.channel;
try{midiOut.send(new Uint8Array([0xB0|c,0,0]));}catch{}
try{midiOut.send(new Uint8Array([0xB0|c,32,0]));}catch{}
try{midiOut.send(new Uint8Array([0xC0|c,trk.pcs[0].prog]));}catch{}
}
}
// Schedule notes
const t0=performance.now()+200;
for(const trk of midi.tracks){
if(trk.muted||(anySolo&&!trk.solo)) continue;
for(const n of trk.notes){
if(n.tick<playTick0) continue;
if(loopEn&&n.tick>=loopOut) continue;
const on =(n.tick-playTick0)*mspt+t0;
const off=(n.tick+n.dur-playTick0)*mspt+t0;
try{midiOut.send(new Uint8Array([0x90|trk.channel,n.pitch,n.vel]),on);}catch{}
try{midiOut.send(new Uint8Array([0x80|trk.channel,n.pitch,0]),off);}catch{}
}
}
const maxTick=Math.max(0,...midi.tracks.flatMap(t=>t.notes.map(n=>n.tick+n.dur)));
function loop(){
if(!playing) return;
const elapsed=(performance.now()-playT0-200);
ph=playTick0+elapsed/mspt;
if(loopEn&&ph>=loopOut){ doStop(); ph=loopIn; doPlay(); return; }
if(ph>=maxTick){ doStop(); return; }
updPos();
// Auto-scroll playhead into view
const phx=tX(ph);
if(phx>rollC.width*.8) view.scrollX+=rollC.width*.4;
else if(phx<20&&view.scrollX>0) view.scrollX=Math.max(0,view.scrollX-rollC.width*.4);
renderRuler(); renderRoll();
animId=requestAnimationFrame(loop);
}
animId=requestAnimationFrame(loop);
}
// ═══════════════════════════════════════════════════════════════════
// Keyboard Shortcuts
// ═══════════════════════════════════════════════════════════════════
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT') return;
const ctrl=e.ctrlKey||e.metaKey;
if(ctrl&&e.key==='z'){e.preventDefault();doUndo();return;}
if(ctrl&&(e.key==='y'||(e.shiftKey&&e.key==='Z'))){e.preventDefault();doRedo();return;}
if(ctrl&&e.key==='a'){e.preventDefault();sel=new Set(midi.tracks.flatMap(t=>t.notes));renderAll();return;}
if(ctrl&&e.key==='c'){clip=[...sel].map(n=>({...n}));status(`${clip.length}개 복사`);return;}
if(ctrl&&e.key==='d'){e.preventDefault();sel=new Set([...sel].map(n=>({...n})));if(activeTi<midi.tracks.length){pushH();midi.tracks[activeTi].notes.push(...sel);renderAll();renderVel();}return;}
if(ctrl&&e.key==='v'){
if(!clip.length) return; e.preventDefault(); pushH();
const minT=Math.min(...clip.map(n=>n.tick));
const ns=clip.map(n=>({...n,tick:n.tick-minT+ph}));
if(activeTi<midi.tracks.length) midi.tracks[activeTi].notes.push(...ns);
sel=new Set(ns); renderAll(); renderVel(); status(`${ns.length}개 붙여넣기`); return;
}
if((e.key==='Delete'||e.key==='Backspace')&&!ctrl){
if(!sel.size) return; pushH();
for(const trk of midi.tracks) trk.notes=trk.notes.filter(n=>!sel.has(n));
sel.clear(); renderAll(); renderVel(); return;
}
if(e.key==='v'||e.key==='V') setTool('sel');
if(e.key==='p'||e.key==='P') setTool('draw');
if(e.key==='e'||e.key==='E') setTool('erase');
if(e.key===' '){e.preventDefault();doTogglePlay();}
if(e.key==='Home'){doRewind();}
// Arrow keys: move selected notes
if(e.key==='ArrowLeft'||e.key==='ArrowRight'||e.key==='ArrowUp'||e.key==='ArrowDown'){
if(!sel.size) return; e.preventDefault(); pushH();
const dt=e.key==='ArrowLeft'?-(qTicks||midi.tpq/4):e.key==='ArrowRight'?(qTicks||midi.tpq/4):0;
const dp=e.key==='ArrowUp'?1:e.key==='ArrowDown'?-1:0;
for(const n of sel){n.tick=Math.max(0,n.tick+dt);n.pitch=Math.max(0,Math.min(127,n.pitch+dp));}
renderAll();
}
});
// ═══════════════════════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════════════════════
function updPos() {
const tpb=midi.tpq*(midi.timeSigs[0]?.num||4);
const bar=Math.floor(ph/tpb)+1, beat=Math.floor((ph%tpb)/midi.tpq)+1, tick=Math.floor(ph%midi.tpq);
document.getElementById('pos-disp').textContent=`${bar}:${beat}:${String(tick).padStart(3,'0')}`;
}
function status(msg) { document.getElementById('status-txt').textContent=msg; setTimeout(()=>document.getElementById('status-txt').textContent='',3000); }
// ═══════════════════════════════════════════════════════════════════
// Init
// ═══════════════════════════════════════════════════════════════════
initMidiOut();
window.addEventListener('load',()=>{
resize(); pushH();
const url=new URLSearchParams(location.search).get('url');
if(url) loadUrl(url);
updPos();
});
</script>
</body>
</html>