- 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>
902 lines
44 KiB
HTML
902 lines
44 KiB
HTML
<!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>
|