speaking-midi / ui /player.js
k-l-lambda's picture
Replace diagnostic panels with an interactive MIDI piano roll
59e4843
Raw History Blame Contribute Delete
6.47 kB
// Interaction reference: klstudio/app/views/midi-player.vue.
// The renderer's WAV is the playback clock; notes come from the exported MIDI.
let dispose = () => {};
function mount() {
dispose();
const root = element.querySelector('.player');
if (!root) return;
const $ = (s) => root.querySelector(s);
const canvas = $('.roll'), ctx = canvas.getContext('2d');
const play = $('.play'), seek = $('.seek'), clock = $('.clock');
const audio = new Audio();
const listeners = new AbortController();
let frame = 0, stopped = false, data = null, offset = 0, zoom = 1;
let width = 600, height = 300, low = 36, high = 84;
try { data = props.value ? JSON.parse(props.value) : null; } catch (_) {}
const duration = data ? data.duration : 8;
const notes = data ? data.notes : [];
const left = 46, top = 23, bottom = 10;
const clamp = (n, a, b) => Math.min(b, Math.max(a, n));
const format = (n) => `${Math.floor(n / 60)}:${Math.floor(n % 60).toString().padStart(2, '0')}`;
const on = (target, type, fn) => target.addEventListener(type, fn, {signal: listeners.signal});
const span = () => Math.max(duration / zoom, .1);
function follow() {
const time = audio.currentTime;
if (time < offset + span() * .15 || time > offset + span() * .7)
offset = clamp(time - span() * .4, 0, Math.max(0, duration - span()));
}
function draw() {
if (stopped) return;
const time = audio.currentTime;
const scale = (width-left)/span(), row = (height-top-bottom)/(high-low+1);
ctx.clearRect(0,0,width,height);
ctx.fillStyle='#101b2d';ctx.fillRect(0,0,width,height);
const playing = new Set(notes.filter(n=>!audio.paused && n.start<=time && n.start+n.duration>time).map(n=>n.pitch));
for (let pitch=low;pitch<=high;pitch++) {
const y=top+(high-pitch)*row, black=[1,3,6,8,10].includes(pitch%12);
ctx.fillStyle=black?'#0c1626':'#142136';ctx.fillRect(left,y,width-left,row);
ctx.fillStyle=playing.has(pitch)?'#6ce2b6':black?'#23334a':'#e1e8f2';ctx.fillRect(0,y,left-2,Math.max(.5,row-1));
if (pitch%12===0) {
ctx.fillStyle=playing.has(pitch)?'#102d28':'#4f627a';ctx.font='10px system-ui';ctx.fillText(`C${Math.floor(pitch/12)-1}`,7,y+Math.max(8,row*.8));
ctx.strokeStyle='#33425b';ctx.beginPath();ctx.moveTo(left,y+row);ctx.lineTo(width,y+row);ctx.stroke();
}
}
const step=span()>6?1:.5;
for(let t=Math.ceil(offset/step)*step;t<=offset+span();t+=step) {
const x=left+(t-offset)*scale;
ctx.strokeStyle='#26364d';ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,height-bottom);ctx.stroke();
ctx.fillStyle='#9bb0ce';ctx.font='10px system-ui';ctx.fillText(`${t.toFixed(step===1?0:1)}s`,x+3,14);
}
ctx.save();ctx.beginPath();ctx.rect(left,top,width-left,height-top);ctx.clip();
for(const n of notes) {
const x=left+(n.start-offset)*scale,y=top+(high-n.pitch)*row;
const active=!audio.paused && n.start<=time && n.start+n.duration>time;
ctx.fillStyle=active?'#d2ffe6':`hsl(${200-n.velocity*.35} 70% ${42+n.velocity*.15}%)`;
ctx.fillRect(x,y+Math.min(1,row*.1),Math.max(2,n.duration*scale),Math.max(1,row-1));
if(active){ctx.strokeStyle='#6ce2b6';ctx.strokeRect(x,y,Math.max(2,n.duration*scale),row);}
}
const cursor=left+(time-offset)*scale;
ctx.strokeStyle='#f8bc68';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(cursor,top);ctx.lineTo(cursor,height);ctx.stroke();ctx.lineWidth=1;ctx.restore();
canvas.dataset.time=String(time);canvas.dataset.offset=String(offset);canvas.dataset.active=String(playing.size);
clock.textContent=`${format(time)} / ${format(duration)}`;
seek.value=String(time);
}
function animate() {
if(stopped || !element.isConnected) { dispose();return; }
follow();draw();
if(!audio.paused) frame=requestAnimationFrame(animate);
}
function seekTo(time) {if(!data)return;audio.currentTime=clamp(time,0,duration);follow();draw();}
function resize() {
width=Math.max(180,canvas.clientWidth);const dpr=window.devicePixelRatio||1;
canvas.width=width*dpr;canvas.height=height*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);draw();
}
play.textContent='▶ Play';play.setAttribute('aria-label','Play piano MIDI');
play.disabled=true;$('.restart').disabled=true;seek.disabled=true;seek.value='0';
$('.empty').hidden=false;$('.note-count').textContent='No MIDI loaded';
$('.speed').value='1';$('.zoom').value='1';$('.error').textContent='';
if(data) {
if(notes.length){low=Math.max(0,Math.min(...notes.map(n=>n.pitch))-2);high=Math.min(127,Math.max(...notes.map(n=>n.pitch))+2);}
audio.src=data.audio;audio.preload='auto';seek.max=String(duration);
$('.empty').hidden=true;play.disabled=false;$('.restart').disabled=false;seek.disabled=false;
$('.note-count').textContent=`${notes.length} notes`;
}
on(play,'click',async()=>{
if(!data)return;
if(audio.paused) {
if(audio.ended || audio.currentTime>=duration)seekTo(0);
try{await audio.play();$('.error').textContent='';}catch(_){$('.error').textContent='Playback could not start. Please press Play again.';}
} else audio.pause();
});
on(audio,'play',()=>{play.textContent='Ⅱ Pause';play.setAttribute('aria-label','Pause piano MIDI');cancelAnimationFrame(frame);animate();});
on(audio,'pause',()=>{play.textContent='▶ Play';play.setAttribute('aria-label','Play piano MIDI');cancelAnimationFrame(frame);draw();});
on(audio,'ended',()=>{cancelAnimationFrame(frame);draw();});
on(audio,'error',()=>{$('.error').textContent='Unable to load the piano audio.';});
on($('.restart'),'click',()=>seekTo(0));
on(seek,'input',()=>seekTo(Number(seek.value)));
on($('.speed'),'change',(e)=>{audio.playbackRate=Number(e.target.value);});
on($('.zoom'),'input',(e)=>{zoom=Number(e.target.value);offset=clamp(audio.currentTime-span()*.4,0,Math.max(0,duration-span()));draw();});
on(canvas,'click',(e)=>{const x=e.clientX-canvas.getBoundingClientRect().left;if(x>=left)seekTo(offset+(x-left)/(width-left)*span());});
on(canvas,'keydown',(e)=>{if(['ArrowLeft','ArrowRight'].includes(e.key)){e.preventDefault();seekTo(audio.currentTime+(e.key==='ArrowRight'?1:-1));}});
const observer=new ResizeObserver(resize);observer.observe(canvas);resize();
dispose=()=>{if(stopped)return;stopped=true;listeners.abort();cancelAnimationFrame(frame);observer.disconnect();audio.pause();audio.removeAttribute('src');audio.load();};
}
watch('value', mount);
mount();