// 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();