const root = element; const host = root.spansynthHost; const find = (role) => root.querySelector(`[data-role="${role}"]`); const canvas = find("roll"), scroll = root.querySelector(".roll-scroll"), ctx = canvas.getContext("2d"); const track = find("track"), instrument = find("instrument"), detail = find("detail"), trackMenu = find("track-options"); const row = 13, keys = 54, header = 52; const clone = (value) => JSON.parse(JSON.stringify(value)); const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); const noteName = (pitch) => ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"][pitch % 12] + (Math.floor(pitch / 12) - 1); const trackPalette = ["#79a8e8", "#e68c93", "#69c3a6", "#b091dc", "#e5b05f", "#68bed5", "#d68ec1", "#a9ba66", "#df9a70", "#7b9ab7", "#9894e2", "#d9c669", "#66a58e", "#b57f9b", "#aba5be", "#afa293"]; let palette = {}, data = {notes: [], instruments: [], duration: 20.48, waveform: []}, trackColors = new Map(); let selected = new Set(), program = 0, extraTracks = [], undo = [], redo = [], drag = null, tool = "select"; let lastSent = null, width = 900, timeline = 846, cursorTime = 0, timeRange = null; let playing = null, audioContext = null, previewStarted = 0, previewOffset = 0, playbackEnd = 0, playbackFrame = null; let viewFrame = null, auditionPitch = null, previewing = false, soundRequest = 0, voiceNumber = 0; let soundLibrary = null, gmNames = null; let midiAccess = null, midiInput = null, midiConnecting = false, recording = null; const midiNotes = new Map(), midiSustain = new Set(); let recordingButtons = []; const soundfonts = new Map(), loadingSounds = new Map(), voiceStops = new Set(); const gmBase = host?.gmBase || "https://gleitz.github.io/midi-js-soundfonts/FluidR3_GM/"; const drumUrl = host?.drumUrl || "https://cdn.jsdelivr.net/gh/henrikvilhelmberglund/midi-js-compat-soundfonts@gh-pages/GM-soundfonts/FluidR3_GM/drumkits/Standard-mp3.js"; const name = (p) => data.instruments.find(i => i.members.includes(p))?.name || `Program ${p}`; const trackColor = (p) => trackColors.get(p) || trackPalette[0]; const snap = (time) => {const unit = Number(find("snap").value); return unit ? Math.round(time / unit) * unit : time;}; const addingTrack = () => !find("add-panel").hidden; const chosen = () => data.notes.filter(n => n.program === program && selected.has(n.id)); const snapshot = () => ({notes: clone(data.notes), program, extraTracks: [...extraTracks], colors: [...trackColors]}); function remember(before) {undo.push(before); if (undo.length > 50) undo.shift(); redo = [];} function syncValue() { if (!data.clip || recording) return; data.view = {program, colors: [...trackColors], extraTracks: [...extraTracks], zoom: Number(find("zoom").value), snap: Number(find("snap").value), scrollTop: scroll.scrollTop, scrollLeft: scroll.scrollLeft}; const value = JSON.stringify(data); if (value !== lastSent) {lastSent = value; props.value = value;} } function publish() {refresh(); draw(); syncValue();} function change(fn) {if (!data.clip || addingTrack() || recording) return; finishDrag(); stop(); const before=snapshot(); if(fn()!==false)remember(before); publish();} function options(select, entries, value) { select.replaceChildren(...entries.map(([v, label]) => {const item = document.createElement("option"); item.value = v; item.textContent = label; return item;})); select.value = String(value); } function selectionRange() { if (timeRange) return timeRange; const notes = chosen(); return notes.length ? [Math.min(...notes.map(n => n.start)), Math.max(...notes.map(n => n.start+n.duration))] : null; } function refresh() { const programs = [...new Set([...data.notes.map(n => n.program), ...extraTracks, program])].sort((a,b) => a-b); for (const p of trackColors.keys()) if (!programs.includes(p)) trackColors.delete(p); for (const p of programs) if (!trackColors.has(p)) { const used = new Set(trackColors.values()); trackColors.set(p, trackPalette.find(color => !used.has(color)) || trackPalette[programs.indexOf(p) % trackPalette.length]); } find("track-name").textContent = `${name(program)} · ${data.notes.filter(n => n.program === program).length}`; track.style.setProperty("--track-color", trackColor(program)); trackMenu.replaceChildren(...programs.map(p => { const item = document.createElement("button"); item.type = "button"; item.tabIndex = -1; item.dataset.program = p; item.setAttribute("role", "option"); item.setAttribute("aria-selected", String(p === program)); item.style.setProperty("--track-color", trackColor(p)); const swatch = document.createElement("span"); swatch.className = "track-swatch"; swatch.setAttribute("aria-hidden", "true"); const label = document.createElement("span"); label.textContent = `${name(p)} · ${data.notes.filter(n => n.program === p).length}`; item.append(swatch, label); return item; })); const choices = data.instruments.map(i => [i.program, i.name]); const entry = data.instruments.find(i => i.members.includes(program)); if (!entry) choices.push([program, `Imported program ${program} · choose an instrument`]); options(instrument, choices, entry ? entry.program : program); find("count").textContent = `${data.notes.length} notes · ${data.duration.toFixed(2)} s`; const locked = addingTrack() || !!recording; root.dataset.adding = String(addingTrack()); root.dataset.recording = String(!!recording); scroll.setAttribute("aria-disabled", String(locked)); find("edit-lock").hidden = !addingTrack(); for (const control of [track, instrument, find("velocity"), ...root.querySelectorAll("[data-tool], [data-action=add-track]")]) control.disabled = locked; for (const control of [find("snap"), find("audio-source"), find("listen"), ...root.querySelectorAll('[data-action=play], [data-action=preview], [data-action=restart]')]) control.disabled = !!recording; midiControls(); for (const action of ["undo", "redo"]) root.querySelector(`[data-action="${action}"]`).disabled = locked || !(action === "undo" ? undo : redo).length; const notes = chosen(); root.querySelector('[data-action="delete"]').disabled = locked || !notes.length; if (notes.length) find("velocity").value = notes[0].velocity; const range = selectionRange(); find("listen-range").textContent = range ? `${range[0].toFixed(2)}–${range[1].toFixed(2)} s` : `Cursor · ${cursorTime.toFixed(2)} s`; if (!data.clip) detail.textContent = "Load a clip to begin."; else if (recording) detail.textContent = `Recording into ${name(recording.program)}. Press Stop to finish.`; else if (locked) detail.textContent = "Choose Add instrument or Cancel to continue editing."; else if (notes.length === 1) {const n = notes[0]; detail.textContent = `${name(n.program)} · ${noteName(n.pitch)} · ${n.start.toFixed(2)}–${(n.start+n.duration).toFixed(2)} s · velocity ${n.velocity}`;} else if (notes.length) detail.textContent = `${notes.length} notes selected · drag to move together, ↑ ↓ to transpose, Del to remove.`; else detail.textContent = `${name(program)} · ${tool === "pencil" ? "Click or drag to draw a note." : tool === "erase" ? "Click or drag over notes to erase." : "Only this track is editable. Drag empty space to select its notes."}`; } function region() {return host ? host.region() : ["edit-start", "edit-end"].map(id => Number(document.querySelector(`#${id} input`)?.value || 0));} function draw(playhead = cursorTime) { if (!scroll.clientWidth) return; const scale = window.devicePixelRatio || 1; const soundingPitches=new Set([...midiNotes.values()].map(n=>n.pitch)); width = Math.max(620, scroll.clientWidth) * Number(find("zoom").value); timeline = width-keys; const height = header+row*128; if (canvas.width !== Math.round(width*scale) || canvas.height !== Math.round(height*scale)) { canvas.width = Math.round(width*scale); canvas.height = Math.round(height*scale); canvas.style.width = width+"px"; canvas.style.height = height+"px"; } ctx.setTransform(scale,0,0,scale,0,0); ctx.clearRect(0,0,width,height); ctx.font = "10px system-ui"; for (let pitch=127; pitch>=0; pitch--) { const y = header+(127-pitch)*row, black = [1,3,6,8,10].includes(pitch%12); ctx.fillStyle = black ? palette["grid-black"] : palette["grid-white"]; ctx.fillRect(keys,y,timeline,row); ctx.fillStyle = pitch===auditionPitch || soundingPitches.has(pitch) ? trackColor(program) : black ? palette["piano-black"] : palette["piano-white"]; ctx.fillRect(0,y,keys,row); ctx.strokeStyle = pitch%12 === 0 ? palette["octave-line"] : palette["grid-line"]; ctx.beginPath(); ctx.moveTo(0,y+row); ctx.lineTo(width,y+row); ctx.stroke(); if (pitch%12 === 0) {ctx.fillStyle = palette.muted; ctx.fillText(noteName(pitch),10,y+10);} } const [start,end] = region(), range = selectionRange(); if (end>start) {ctx.fillStyle = palette.region; ctx.fillRect(keys+start/data.duration*timeline,header,(end-start)/data.duration*timeline,row*128);} const tick = timeline/data.duration<60 ? 2 : 1; for (let t=0; t<=data.duration; t+=tick) {const x=keys+t/data.duration*timeline; ctx.strokeStyle=palette["grid-line"]; ctx.beginPath(); ctx.moveTo(x,header); ctx.lineTo(x,height); ctx.stroke();} [...data.notes].sort((a,b) => Number(a.program===program)-Number(b.program===program)).forEach(n => { const x=keys+n.start/data.duration*timeline, y=header+(127-n.pitch)*row+1, w=Math.max(3,n.duration/data.duration*timeline); ctx.globalAlpha = n.program===program || selected.has(n.id) ? 1 : .35; ctx.fillStyle=trackColor(n.program); ctx.fillRect(x,y,w,row-2); ctx.strokeStyle=selected.has(n.id) ? palette["note-outline"] : "#7f91b077"; ctx.lineWidth=selected.has(n.id) ? 2 : 1; ctx.strokeRect(x,y,w,row-2); if ((n.program===program || selected.has(n.id)) && w>25) {ctx.fillStyle="#283747"; ctx.fillText(noteName(n.pitch),x+4,y+9);} }); ctx.globalAlpha=1; ctx.lineWidth=1; if (drag?.mode === "box") { ctx.fillStyle="#79a8e82b"; ctx.strokeStyle="#79a8e8"; const x=Math.min(drag.pointer.x,drag.current.x), y=Math.min(drag.pointer.y,drag.current.y); const w=Math.abs(drag.current.x-drag.pointer.x), h=Math.abs(drag.current.y-drag.pointer.y); ctx.fillRect(x,y,w,h); ctx.strokeRect(x,y,w,h); } const top=scroll.scrollTop; ctx.fillStyle=palette.soft; ctx.fillRect(0,top,width,header); const wave=data.waveform || []; ctx.strokeStyle=palette.wave; ctx.beginPath(); wave.forEach((pair,i) => {const x=keys+i/wave.length*timeline; ctx.moveTo(x,top+28+pair[0]*19); ctx.lineTo(x,top+28+pair[1]*19);}); ctx.stroke(); if (end>start) {ctx.fillStyle=palette["region-head"]; ctx.fillRect(keys+start/data.duration*timeline,top,(end-start)/data.duration*timeline,header);} if (range) {ctx.fillStyle="#79a8e830"; ctx.fillRect(keys+range[0]/data.duration*timeline,top,(range[1]-range[0])/data.duration*timeline,header); ctx.strokeStyle="#79a8e8"; ctx.strokeRect(keys+range[0]/data.duration*timeline,top+1,(range[1]-range[0])/data.duration*timeline,header-2);} for (let t=0;t<=data.duration;t+=tick) {ctx.fillStyle=palette.muted; ctx.fillText(`${t}s`,keys+t/data.duration*timeline+4,top+13);} const x=keys+playhead/data.duration*timeline; ctx.strokeStyle=palette.playhead; ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,height); ctx.stroke(); } function updatePalette() { const style=getComputedStyle(root); for (const key of ["text","muted","soft","grid-white","grid-black","piano-white","piano-black","grid-line","octave-line","wave","region","region-head","note-outline","playhead"]) palette[key]=style.getPropertyValue(`--ss-${key}`).trim(); draw(); } function centerTrack() {const pitches=data.notes.filter(n=>n.program===program).map(n=>n.pitch); const pitch=pitches.length ? pitches.reduce((a,b)=>a+b,0)/pitches.length : 60; scroll.scrollTop=Math.max(0,header+(127-pitch)*row-scroll.clientHeight/2);} function position(event) {const box=canvas.getBoundingClientRect(), x=event.clientX-box.left, y=event.clientY-box.top; return {x,y,time:clamp((x-keys)/timeline*data.duration,0,data.duration),pitch:clamp(127-Math.floor((y-header)/row),0,127)};} function hit(p) { return data.notes.findLast(n=>n.program===program && n.pitch===p.pitch && p.time>=n.start && p.time<=n.start+n.duration); } function setTool(next) {tool=next; root.dataset.tool=next; for (const button of root.querySelectorAll("[data-tool]")) button.setAttribute("aria-pressed", String(button.dataset.tool===next)); refresh();} function createNote(p) { const start=clamp(snap(p.time),0,data.duration-.01), id=Math.max(-1,...data.notes.map(n=>n.id))+1; const note={id,start,duration:Math.min(.4,data.duration-start),pitch:p.pitch,velocity:Math.round(clamp(Number(find("velocity").value)||90,1,127)),program}; data.notes.push(note); selected=new Set([id]); timeRange=null; return note; } function moveNotes(originals, seconds, semitones) { const dt=clamp(seconds,-Math.min(...originals.map(n=>n.start)),data.duration-Math.max(...originals.map(n=>n.start+n.duration))); const dp=clamp(semitones,-Math.min(...originals.map(n=>n.pitch)),127-Math.max(...originals.map(n=>n.pitch))); const currentNotes=new Map(data.notes.map(n=>[n.id,n])); let moved=false; for (const n of originals) {const current=currentNotes.get(n.id); if(current) { moved ||= current.start!==n.start+dt || current.pitch!==n.pitch+dp; current.start=n.start+dt; current.pitch=n.pitch+dp; }} timeRange=null; return moved; } function previewMovedNote(movement, finish=false) { const note=data.notes.find(n=>n.id===movement.id), previous=movement.preview, now=performance.now(); // Always hear a new pitch; limit repeated attacks while sliding along the timeline. if(note && (note.pitch!==previous.pitch || (note.start!==previous.start && (finish || now-previous.at>=100)))) { movement.preview={pitch:note.pitch,start:note.start,at:now}; audition(note.pitch,note.velocity); } } canvas.addEventListener("pointerdown", event => { if (!data.clip || addingTrack() || recording || event.button !== 0) return; event.preventDefault(); stop(); scroll.focus({preventScroll:true}); const p=position(event); if(p.x=scroll.scrollTop+header) { canvas.setPointerCapture(event.pointerId); drag={mode:"keys",pitch:p.pitch}; audition(p.pitch); } return; } canvas.setPointerCapture(event.pointerId); if (p.yitem.id!==n.id); selected.clear(); timeRange=null; } else if (tool==="pencil" && !n) { const note=createNote(p); audition(note.pitch,note.velocity); drag={mode:"draw",id:note.id,before,pointer:p,moved:true}; } else if (n) { timeRange=null; audition(n.pitch,n.velocity); if (event.shiftKey) {selected.has(n.id) ? selected.delete(n.id) : selected.add(n.id);} else if (!selected.has(n.id)) selected=new Set([n.id]); if (selected.has(n.id)) drag={mode:selected.size===1 && n.duration/data.duration*timeline>14 && Math.abs(p.x-(keys+(n.start+n.duration)/data.duration*timeline))<7 ? "resize" : "move",id:n.id,before,originals:clone(chosen()),pointer:p,moved:false,preview:{pitch:n.pitch,start:n.start,at:performance.now()}}; } else { const base=event.shiftKey ? [...selected] : []; selected=new Set(base); timeRange=null; drag={mode:"box",pointer:p,current:p,base,moved:false}; } refresh(); draw(); }); canvas.addEventListener("pointermove", event => { if (addingTrack() || recording) {canvas.style.cursor="not-allowed"; return;} const p=position(event); if (!drag) {const n=hit(p); canvas.style.cursor=tool==="select" ? (n ? "grab" : "crosshair") : ""; return;} if(drag.mode==="keys") {if(p.x=scroll.scrollTop+header) {drag.pitch=p.pitch; audition(p.pitch);} return;} if (drag.pointer && Math.abs(p.x-drag.pointer.x)+Math.abs(p.y-drag.pointer.y)<3 && !drag.moved) return; if (drag.mode==="erase") {const n=hit(p); if(n) {data.notes=data.notes.filter(item=>item.id!==n.id); drag.moved=true;}} else { drag.moved=true; if (drag.mode==="time") {timeRange=[Math.min(p.time,drag.pointer.time),Math.max(p.time,drag.pointer.time)];} if (drag.mode==="box") { drag.current=p; const low=Math.min(p.pitch,drag.pointer.pitch), high=Math.max(p.pitch,drag.pointer.pitch), first=Math.min(p.time,drag.pointer.time), last=Math.max(p.time,drag.pointer.time); selected=new Set([...drag.base,...data.notes.filter(n=>n.program===program && n.pitch>=low && n.pitch<=high && n.startfirst).map(n=>n.id)]); } if (drag.mode==="move") { moveNotes(drag.originals,snap(p.time-drag.pointer.time),p.pitch-drag.pointer.pitch); previewMovedNote(drag); } if (drag.mode==="draw" || drag.mode==="resize") {const n=data.notes.find(n=>n.id===drag.id); if(n) n.duration=clamp(snap(p.time)-n.start,.01,data.duration-n.start); timeRange=null;} } refresh(); draw(); }); function finishDrag(event) { if (!drag) return; const previous=drag; drag=null; if(event?.type==="pointerup" && previous.mode==="move" && previous.moved)previewMovedNote(previous,true); if (previous.moved && previous.before && JSON.stringify(previous.before.notes)!==JSON.stringify(data.notes))remember(previous.before); publish(); } for (const event of ["pointerup","pointercancel","lostpointercapture"]) canvas.addEventListener(event,finishDrag); canvas.addEventListener("dblclick", event=>{const p=position(event); if(data.clip && !addingTrack() && tool==="select" && p.x>=keys && p.y>=scroll.scrollTop+header && !hit(p)) change(()=>{const note=createNote(p);audition(note.pitch,note.velocity);});}); function closeTrackMenu(focus=false) {trackMenu.hidden=true; track.setAttribute("aria-expanded","false"); if(focus) track.focus({preventScroll:true});} function openTrackMenu() {if(addingTrack() || recording)return;trackMenu.hidden=false; track.setAttribute("aria-expanded","true"); const item=trackMenu.querySelector('[aria-selected="true"]'); item?.focus({preventScroll:true}); item?.scrollIntoView({block:"nearest"});} track.addEventListener("click",()=>trackMenu.hidden ? openTrackMenu() : closeTrackMenu(true)); track.addEventListener("keydown",event=>{if(["ArrowDown","ArrowUp"].includes(event.key)) {event.preventDefault(); openTrackMenu();}}); trackMenu.addEventListener("click",event=>{const item=event.target.closest('[role="option"]'); if(!item || addingTrack())return; finishDrag(); stop(); program=Number(item.dataset.program); selected.clear(); timeRange=null; closeTrackMenu(true); refresh(); centerTrack(); draw(); syncValue(); warmInstrument();}); trackMenu.addEventListener("keydown",event=>{ if(["Escape","Tab"].includes(event.key)) {closeTrackMenu(true); return;} const items=[...trackMenu.querySelectorAll('[role="option"]')], index=items.indexOf(document.activeElement); const next={ArrowDown:(index+1)%items.length,ArrowUp:(index+items.length-1)%items.length,Home:0,End:items.length-1}[event.key]; if(next!==undefined) {event.preventDefault(); items[next]?.focus({preventScroll:true}); items[next]?.scrollIntoView({block:"nearest"});} }); instrument.addEventListener("change",()=>{const next=Number(instrument.value);change(()=>{stop(); if(!trackColors.has(next))trackColors.set(next,trackColor(program)); data.notes.filter(n=>n.program===program).forEach(n=>n.program=next); extraTracks=extraTracks.filter(p=>p!==program); program=next; extraTracks.push(next);});warmInstrument();}); find("zoom").addEventListener("input",()=>{draw(); syncValue();}); find("snap").addEventListener("change",syncValue); find("listen").addEventListener("change",()=>{stop(); refresh(); draw();}); find("audio-source").addEventListener("change",stop); find("velocity").addEventListener("change",()=>{const velocity=Math.round(clamp(Number(find("velocity").value)||90,1,127));if(selected.size)change(()=>{for(const n of chosen())n.velocity=velocity;});}); function travel(backward=true) {if(addingTrack() || recording)return; finishDrag(); const from=backward?undo:redo, to=backward?redo:undo; if(!from.length)return; stop(); to.push(snapshot()); const previous=from.pop(); data.notes=previous.notes; program=previous.program; extraTracks=previous.extraTracks; trackColors=new Map(previous.colors); selected.clear(); timeRange=null; publish();} function remove() {if(selected.size)change(()=>{data.notes=data.notes.filter(n=>!selected.has(n.id)); selected.clear(); timeRange=null;});} function soundStatus(message="GM preview") {find("sound-status").textContent=message;} function previewProgram(p) {return p===100 ? 52 : p===101 ? 53 : p;} function previewPitch(p,pitch) {return p!==128 || (pitch>=24 && pitch<=84);} function soundContext() { if(!audioContext)audioContext=new AudioContext(); return audioContext; } async function loadInstrument(p) { const key=previewProgram(p), context=soundContext(); if(soundfonts.has(key))return soundfonts.get(key); if(loadingSounds.has(key))return loadingSounds.get(key); const pending=(async()=>{ let url=drumUrl; if(key!==128) { if(!gmNames)gmNames=fetch(gmBase+"names.json",{signal:AbortSignal.timeout(15000)}).then(response=>{ if(!response.ok)throw Error("GM instrument list unavailable");return response.json(); }).catch(error=>{gmNames=null;throw error;}); const names=await gmNames; if(!Array.isArray(names) || !/^[a-z0-9_]+$/.test(names[key]||""))throw Error("Unknown GM program"); url=gmBase+names[key]+"-mp3.js"; } if(!soundLibrary)soundLibrary=import(host?.soundLibrary || "https://cdn.jsdelivr.net/npm/smplr@1.0.0/dist/index.mjs").catch(error=>{soundLibrary=null;throw error;}); const {Soundfont}=await soundLibrary; if(!root.isConnected)throw Error("Editor closed"); const player=Soundfont(context,{instrumentUrl:url,volume:75,extraGain:3}); try { await player.ready; if(!root.isConnected)throw Error("Editor closed"); soundfonts.set(key,player);return player; } catch(error) {player.dispose();throw error;} })(); loadingSounds.set(key,pending); try {return await pending;} finally {loadingSounds.delete(key);} } function warmInstrument() { if(!data.clip || addingTrack())return; const p=program, request=soundRequest; soundStatus(soundfonts.has(previewProgram(p)) ? "GM preview ready" : "Loading GM sound…"); loadInstrument(p).then(()=>{if(request===soundRequest && p===program)soundStatus("GM preview ready");}) .catch(()=>{if(request===soundRequest && p===program)soundStatus("GM sound unavailable. Click a key to retry.");}); } function startNote(player,p,pitch,velocity,time,duration,onEnded) { if(!previewPitch(p,pitch))return; let cancel, releaseTimer; // smplr cannot interrupt a voice after scheduling its own future release. // Trigger the release when it is due so Stop can still cut off held notes. const stopVoice=player.start({note:pitch,velocity,time,duration:null,ampRelease:.08,stopId:++voiceNumber, onEnded:()=>{clearTimeout(releaseTimer);voiceStops.delete(cancel);onEnded?.();}}); cancel=()=>{clearTimeout(releaseTimer);voiceStops.delete(cancel);stopVoice();}; voiceStops.add(cancel); if(p!==128 && duration!==null)releaseTimer=setTimeout(cancel,Math.max(0,(time+duration-audioContext.currentTime)*1000)); return cancel; } async function audition(pitch, velocity=Number(find("velocity").value)||90) { stop(); const request=soundRequest, p=program, context=soundContext(); if(!previewPitch(p,pitch)) {soundStatus("GM drums use MIDI keys 24–84.");return;} try { await context.resume(); soundStatus(soundfonts.has(previewProgram(p)) ? "GM preview ready" : "Loading GM sound…"); const player=await loadInstrument(p); if(request!==soundRequest || !root.isConnected)return; soundStatus("GM preview ready");auditionPitch=pitch;draw(); startNote(player,p,pitch,velocity,context.currentTime,.45,()=>{ if(request===soundRequest){auditionPitch=null;draw();} }); } catch {if(request===soundRequest)soundStatus("GM sound unavailable. Click a key to retry.");} } function midiStatus(message) {find("midi-status").textContent=message;} function midiControls() { const connected=midiInput?.state==="connected" && midiInput.onmidimessage===midiMessage; const connect=root.querySelector('[data-action="connect-midi"]'), record=root.querySelector('[data-action="record"]'); connect.textContent=midiAccess ? "Disconnect MIDI" : "Connect MIDI"; connect.disabled=midiConnecting || !!recording; find("midi-input").disabled=!midiAccess || !!recording; find("record-snap").disabled=!!recording; record.disabled=!connected || !data.clip || addingTrack() || !!recording; record.setAttribute("aria-pressed",String(!!recording)); root.querySelector('[data-action="stop-recording"]').disabled=!recording; } function releaseMidiNote(key, time=recordingTime()) { const held=midiNotes.get(key);if(!held)return; midiNotes.delete(key);if(held.program!==128)held.stop?.(); if(held.note && recording) { const end=recording.unit ? Math.round(time/recording.unit)*recording.unit : time; held.note.duration=clamp(end-held.note.start,.01,recording.end-held.note.start); } } function recordingTime(eventTime) { if(!recording?.ready)return recording?.start || 0; const time=playing ? playing.currentTime : audioContext.currentTime-recording.clock+recording.start; const delay=Number.isFinite(eventTime) ? Math.min(0,(eventTime-performance.now())/1000) : 0; return clamp(time+delay,recording.start,recording.end); } function midiMessage(event) { const [status,pitch,velocity]=event.data, kind=status&0xf0, channel=status&0x0f, key=channel*128+pitch; const time=recordingTime(event.timeStamp); if(kind===0xb0) { if(pitch===64 && velocity>=64)midiSustain.add(channel); if((pitch===64 && velocity<64) || pitch===121) { midiSustain.delete(channel); for(const [id,held] of midiNotes)if(held.channel===channel && !held.down)releaseMidiNote(id,time); } if(pitch===120 || pitch===123) { midiSustain.delete(channel); for(const [id,held] of midiNotes)if(held.channel===channel)releaseMidiNote(id,time); } draw();return; } if(kind===0x80 || (kind===0x90 && velocity===0)) { const held=midiNotes.get(key); if(held && midiSustain.has(channel) && held.program!==128)held.down=false; else releaseMidiNote(key,time); refresh();draw();return; } if(kind!==0x90 || !data.clip || addingTrack() || (recording && !recording.ready))return; if(recording && time>=recording.end-.01)return; releaseMidiNote(key,time); const held={pitch,velocity,channel,program,down:true};midiNotes.set(key,held); if(recording) { const start=clamp(recording.unit ? Math.round(time/recording.unit)*recording.unit : time,recording.start,recording.end-.01); held.note={id:recording.nextId++,start,duration:.01,pitch,velocity,program:recording.program}; data.notes.push(held.note);recording.ids.push(held.note.id);selected.add(held.note.id); } const request=soundRequest, context=soundContext(); context.resume().then(()=>loadInstrument(held.program)).then(player=>{ if(request!==soundRequest || midiNotes.get(key)!==held || !root.isConnected)return; held.stop=startNote(player,held.program,pitch,velocity,context.currentTime,null); }).catch(()=>{if(request===soundRequest)soundStatus("GM sound unavailable; MIDI notes are still recorded.");}); refresh();draw(); } async function selectMidiInput(id) { stop(); if(midiInput) {midiInput.onmidimessage=null;midiInput.close().catch(()=>{});} const input=midiAccess?.inputs.get(id);midiInput=input || null; midiControls(); if(!input)return; try { await input.open(); if(midiInput!==input || !root.isConnected){input.close().catch(()=>{});return;} input.onmidimessage=midiMessage; midiStatus(`${input.name || "MIDI input"} ready. Play to preview; Record adds notes.`); } catch {if(midiInput===input){midiInput=null;midiStatus("Could not open this MIDI input. Reconnect and try again.");}} midiControls(); } function midiInputsChanged() { if(!midiAccess)return; const inputs=[...midiAccess.inputs.values()].filter(input=>input.state==="connected"); const lost=midiInput && !inputs.includes(midiInput), current=inputs.includes(midiInput) ? midiInput : inputs[0]; if(lost) {stop();midiInput.onmidimessage=null;midiInput.close().catch(()=>{});midiInput=null;} options(find("midi-input"),inputs.length ? inputs.map(input=>[input.id,input.name || "MIDI input"]) : [["","No input connected"]],current?.id || ""); if(current && current!==midiInput)selectMidiInput(current.id); if(!inputs.length)midiStatus(lost ? "Keyboard disconnected. Recording kept; reconnect to continue." : "No MIDI input found. Connect a keyboard."); midiControls(); } function disconnectMidi() { stop(); if(midiAccess)midiAccess.onstatechange=null; if(midiInput){midiInput.onmidimessage=null;midiInput.close().catch(()=>{});} midiInput=null;midiAccess=null; options(find("midi-input"),[["","No input connected"]],""); midiStatus("MIDI disconnected. Your recorded notes are kept.");midiControls(); } async function connectMidi() { if(midiAccess){disconnectMidi();return;} if(midiConnecting)return; const policy=document.permissionsPolicy || document.featurePolicy; if(!window.isSecureContext){midiStatus("MIDI needs HTTPS or localhost. For a remote server, use an SSH tunnel.");return;} if(!navigator.requestMIDIAccess){midiStatus("Web MIDI is unavailable in this browser. Try Chrome or Edge.");return;} if(policy?.allowsFeature && !policy.allowsFeature("midi")) { midiStatus("MIDI is blocked in this embedded page. Open the demo directly."); const link=find("midi-standalone");link.href=location.href;link.hidden=false;return; } midiConnecting=true;midiControls();midiStatus("Allow MIDI access in your browser to connect."); try { await soundContext().resume(); const access=await navigator.requestMIDIAccess({sysex:false}); if(!root.isConnected || events.signal.aborted)return; midiAccess=access;access.onstatechange=midiInputsChanged;midiInputsChanged(); } catch {midiStatus("MIDI access was not granted. Allow it in the browser's site settings, then retry.");} finally {midiConnecting=false;midiControls();} } function recordingControls(active) { host?.recording?.(active); if(active) { recordingButtons=[...document.querySelectorAll('#apply-edits button, button#apply-edits, #generate-audio button, button#generate-audio')].map(button=>[button,button.disabled]); for(const [button] of recordingButtons)button.disabled=true; } else { for(const [button,disabled] of recordingButtons)button.disabled=disabled; recordingButtons=[]; } refresh(); } async function recordMidi() { if(recording || !data.clip || addingTrack() || midiInput?.state!=="connected" || midiInput.onmidimessage!==midiMessage)return; finishDrag();stop();closeTrackMenu(); const [start,end]=timeRange || [cursorTime>=data.duration ? 0 : cursorTime,data.duration]; if(end-start<.01){midiStatus("Choose a longer range on the waveform.");return;} const take={before:snapshot(),start,end,program,unit:find("record-snap").checked ? Number(find("snap").value) : 0, ids:[],nextId:Math.max(-1,...data.notes.map(n=>n.id))+1,ready:false}; recording=take;selected.clear();timeRange=null;cursorTime=start; recordingControls(true);midiStatus("Preparing recording…"); try { const context=soundContext();await context.resume(); // Load the monitor before the take, so first notes are not delayed by a download. await loadInstrument(program).catch(()=>soundStatus("GM sound unavailable; recording without monitoring.")); if(recording!==take || !root.isConnected)return; pauseOtherAudio(); const id=find("audio-source").value==="generated" ? "result-audio" : "source-audio"; const url=host ? host.audio(id) : document.querySelector(`#${id} a[download]`)?.href; if(url) { const audio=find("player");if(audio.src!==url)audio.src=url; audio.currentTime=start;playing=audio;await audio.play(); if(recording!==take || !root.isConnected)return; } else if(find("audio-source").value==="generated")throw Error("Generate audio first, or choose Original."); take.clock=context.currentTime;take.ready=true; midiStatus(`Recording into ${name(program)} · press Stop to finish.`);animate(); } catch(error) {if(recording===take){stop();midiStatus(`Recording could not start. ${error.message || "Try again."}`);}} } function finishRecording() { if(!recording)return; const take=recording, time=recordingTime(); for(const key of [...midiNotes.keys()])releaseMidiNote(key,time); recording=null;cursorTime=time; if(take.ids.length) { // MIDI note-offs cannot identify overlapping voices at the same pitch/channel. // Keep the new performance and the non-overlapping portions of earlier notes. const ids=new Set(take.ids), added=data.notes.filter(n=>ids.has(n.id)); let notes=data.notes.filter(n=>!ids.has(n.id)); for(const fresh of added) { const end=fresh.start+fresh.duration; notes=notes.flatMap(n=>{ const oldEnd=n.start+n.duration; if(n.program!==fresh.program || n.pitch!==fresh.pitch || n.start>=end || oldEnd<=fresh.start)return [n]; const parts=[]; if(fresh.start-n.start>=.01)parts.push({...n,duration:fresh.start-n.start}); if(oldEnd-end>=.01) { const tail={...n,id:take.nextId++,start:end,duration:oldEnd-end}; delete tail.source_onset;parts.push(tail); } return parts; }); notes.push(fresh); } data.notes=notes;selected=new Set(notes.filter(n=>ids.has(n.id)).map(n=>n.id)); remember(take.before); } recordingControls(false);publish(); midiStatus(take.ids.length ? `${take.ids.length} notes recorded. Undo restores the previous score; Apply edits saves it.` : "No notes recorded. Click Record to try again."); } find("midi-input").addEventListener("change",()=>selectMidiInput(find("midi-input").value)); function transportState(action) {for(const name of ["play","preview"])root.querySelector(`[data-action="${name}"]`).setAttribute("aria-pressed",String(name===action));} function stop() { finishRecording(); for(const key of [...midiNotes.keys()])releaseMidiNote(key); midiSustain.clear(); soundRequest++; if (playing) cursorTime=clamp(playing.currentTime,0,data.duration); if (previewing) cursorTime=clamp(audioContext.currentTime-previewStarted+previewOffset,0,data.duration); previewing=false;auditionPitch=null; for(const cancel of voiceStops)cancel();voiceStops.clear(); cancelAnimationFrame(playbackFrame); playbackFrame=null; playing?.pause(); playing=null; soundStatus();transportState(null); draw(); } function animate() { if(!root.isConnected){stop();return;} if(recording?.ready) { const time=recordingTime(); if(time>=recording.end || playing?.ended) {stop();refresh();return;} for(const held of midiNotes.values())if(held.note)held.note.duration=Math.max(.01,time-held.note.start); find("listen-range").textContent=`Recording · ${time.toFixed(2)} s`; draw(time);playbackFrame=requestAnimationFrame(animate);return; } const time=previewing ? audioContext.currentTime-previewStarted+previewOffset : playing?.currentTime; if(time===undefined)return; if(time>=playbackEnd || playing?.ended) { // Let sampled drum hits and note releases decay after the playback range. if(previewing){previewing=false;transportState(null);}else stop(); playbackFrame=null;cursorTime=playbackEnd;refresh();draw();return; } find("listen-range").textContent=`Playing · ${Math.max(0,time).toFixed(2)} s`; draw(Math.max(0,time)); playbackFrame=requestAnimationFrame(animate); } function playbackRange() { const range=selectionRange(); if(range)return range; return [cursorTime>=data.duration ? 0 : cursorTime,data.duration]; } function pauseOtherAudio(except=null) { const owner=except?.closest("#upload-audio, #source-audio, #result-audio"); document.querySelectorAll("audio").forEach(audio=>{if(audio!==except && !owner?.contains(audio))audio.pause();}); for(const id of ["upload-audio","source-audio","result-audio"]) { const player=document.getElementById(id); if(player!==owner)player?.querySelector('button[aria-label="Pause"]')?.click(); } } async function play(action) { stop(); if(!data.clip)return; const [start,end]=playbackRange(); if(end<=start)return; playbackEnd=end; pauseOtherAudio(); if(action==="play") { const request=soundRequest; const id=find("audio-source").value==="generated" ? "result-audio" : "source-audio"; const url=host ? host.audio(id) : document.querySelector(`#${id} a[download]`)?.href; const audio=find("player"); if(!url) {detail.textContent="Generate audio first, or choose Original.";return;} if(audio.src!==url)audio.src=url; playing=audio; audio.currentTime=start; try {await audio.play(); if(request!==soundRequest || playing!==audio)return; transportState("play"); animate();} catch {if(request===soundRequest){stop();detail.textContent="Playback could not start. Try the audio player below.";}} } else { const request=soundRequest, context=soundContext(), onlyTrack=find("listen").value==="selected"; const notes=data.notes.filter(n=>(!onlyTrack || n.program===program) && n.startstart && previewPitch(n.program,n.pitch)); if(!notes.length){soundStatus("No notes to preview in this range.");return;} await context.resume(); if(request!==soundRequest || !root.isConnected)return; const programs=[...new Set(notes.map(n=>n.program))]; soundStatus(programs.every(p=>soundfonts.has(previewProgram(p))) ? "GM preview ready" : "Loading GM sounds…"); let players; try {players=new Map(await Promise.all(programs.map(async p=>[p,await loadInstrument(p)])));} catch {if(request===soundRequest)soundStatus("GM sound unavailable. Press Preview notes to retry.");return;} if(request!==soundRequest || !root.isConnected)return; soundStatus("GM preview ready");previewStarted=context.currentTime+.03;previewOffset=start;previewing=true; for(const n of notes) { const onset=previewStarted+Math.max(0,n.start-start), finish=previewStarted+Math.min(end,n.start+n.duration)-start; startNote(players.get(n.program),n.program,n.pitch,n.velocity,onset,Math.max(.01,finish-onset)); } transportState("preview"); animate(); } } root.addEventListener("click",event=>{ const button=event.target.closest("button"); if(!button)return; if(button.disabled)return; if(button.dataset.tool) {setTool(button.dataset.tool); canvas.style.cursor=""; return;} const action=button.dataset.action; if(action==="connect-midi")connectMidi(); if(action==="record")recordMidi(); if(action==="undo")travel(); if(action==="redo")travel(false); if(action==="delete")remove(); if(action==="add-track") { stop(); finishDrag(); closeTrackMenu(); const used=[...data.notes.map(n=>n.program),...extraTracks]; const available=data.instruments.filter(i=>!i.members.some(p=>used.includes(p))); options(find("new-instrument"),available.map(i=>[i.program,i.name]),available[0]?.program); find("add-panel").hidden=false; refresh(); canvas.style.cursor="not-allowed"; find("new-instrument").focus(); root.querySelector('[data-action="confirm-track"]').disabled=!available.length || !data.clip; } if(action==="cancel-track") {find("add-panel").hidden=true; canvas.style.cursor=""; refresh(); draw();} if(action==="confirm-track" && find("new-instrument").value!=="") { find("add-panel").hidden=true; change(()=>{program=Number(find("new-instrument").value); extraTracks.push(program); selected.clear(); timeRange=null; setTool("pencil"); canvas.style.cursor=""; centerTrack();});warmInstrument(); } if(action==="stop" || action==="stop-recording") {stop();refresh();} if(action==="restart") {stop();cursorTime=0;timeRange=null;selected.clear();for(const id of ["source-audio","result-audio"]){const a=document.querySelector(`#${id} audio`);if(a)a.currentTime=0;}refresh();draw();} if(action==="play" || action==="preview")play(action).catch(()=>{stop();detail.textContent="Playback could not start. Please try again.";}); }); scroll.addEventListener("keydown",event=>{ if(recording) {if([" ","Escape"].includes(event.key)){event.preventDefault();stop();refresh();}return;} if(addingTrack()) {if(event.key!=="Tab")event.preventDefault(); return;} const key=event.key.toLowerCase(), modifier=event.metaKey||event.ctrlKey; if(modifier && key==="z") {event.preventDefault();event.shiftKey?travel(false):travel();return;} if(modifier && key==="a") {event.preventDefault();selected=new Set(data.notes.filter(n=>n.program===program).map(n=>n.id));timeRange=null;refresh();draw();return;} if(event.key==="Escape") {selected.clear();timeRange=null;refresh();draw();} if(["Delete","Backspace"].includes(event.key)) {event.preventDefault();remove();} if(["v","d","e"].includes(key) && !modifier) {setTool({v:"select",d:"pencil",e:"erase"}[key]);canvas.style.cursor="";} if(event.key===" ") {event.preventDefault();playing||previewing?stop():play("play");} if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(event.key) && selected.size) { event.preventDefault(); const unit=Number(find("snap").value)||.04; change(()=>{ const moved=moveNotes(clone(chosen()),event.key==="ArrowLeft"?-unit:event.key==="ArrowRight"?unit:0,event.key==="ArrowUp"?(event.shiftKey?12:1):event.key==="ArrowDown"?-(event.shiftKey?12:1):0); const id=[...selected].at(-1), note=data.notes.find(n=>n.id===id); if(moved && note)audition(note.pitch,note.velocity); return moved; }); } }); const events=new AbortController(); window.addEventListener("spansynth-stop",()=>{stop();refresh();},{signal:events.signal}); window.addEventListener("pagehide",disconnectMidi,{signal:events.signal}); document.addEventListener("visibilitychange",()=>{if(document.hidden){stop();refresh();}},{signal:events.signal}); document.addEventListener("pointerdown",event=>{if(!track.parentElement.contains(event.target))closeTrackMenu();},{signal:events.signal}); document.addEventListener("input",event=>{if(event.target.closest?.("#edit-start, #edit-end"))draw();},{signal:events.signal}); document.addEventListener("click",event=>{ const button=event.target.closest("#upload-audio button, #source-audio button, #result-audio button"); const label=button?.getAttribute("aria-label"); if(label==="Play" || label==="Go to start") {stop();if(label==="Play")pauseOtherAudio(button);else{cursorTime=0;timeRange=null;selected.clear();}refresh();draw();} },{capture:true,signal:events.signal}); document.addEventListener("play",event=>{if(event.target.tagName==="AUDIO" && !event.target.paused){if(event.target!==playing)stop();pauseOtherAudio(event.target);}},{capture:true,signal:events.signal}); document.addEventListener("pause",event=>{if(event.target===playing && event.target.paused){stop();refresh();}},{capture:true,signal:events.signal}); window.addEventListener("spansynth-generated",()=>{stop();find("audio-source").value=(host ? host.audio("result-audio") : document.querySelector("#result-audio a[download]"))?"generated":"original";refresh();draw();},{signal:events.signal}); window.addEventListener("spansynth-region",()=>requestAnimationFrame(()=>draw()),{signal:events.signal}); const observer=new ResizeObserver(()=>draw());observer.observe(scroll); const themeObserver=new MutationObserver(updatePalette); for(let node=root;node;node=node.parentElement)themeObserver.observe(node,{attributes:true,attributeFilter:["class"]}); scroll.addEventListener("scroll",()=>{draw();cancelAnimationFrame(viewFrame);viewFrame=requestAnimationFrame(syncValue);}); const lifetime=new MutationObserver(()=>{if(!root.isConnected){disconnectMidi();events.abort();observer.disconnect();themeObserver.disconnect();lifetime.disconnect();cancelAnimationFrame(viewFrame);for(const player of soundfonts.values())player.dispose();soundfonts.clear();if(audioContext)audioContext.close().catch(()=>{});}}); lifetime.observe(root.parentElement,{childList:true}); function receive() { if(props.value===lastSent)return; try { const next=JSON.parse(props.value||"{}"); if(!next.clip){refresh();draw();centerTrack();return;} stop();closeTrackMenu();find("add-panel").hidden=true;drag=null;timeRange=null; if(next.clip!==data.clip){trackColors.clear();cursorTime=0;timeRange=null;} data=next;data.notes=data.notes.map((n,i)=>({...n,id:i})); const view=data.view||{};if(view.colors)trackColors=new Map(view.colors); program=view.program??data.notes[0]?.program??0;extraTracks=view.extraTracks||[]; find("zoom").value=view.zoom??1;find("snap").value=view.snap??.04; selected=new Set(Array.isArray(view.selected) ? view.selected.filter(id=>data.notes.some(n=>n.id===id && n.program===program)) : []); undo=[];redo=[];setTool("select");refresh();draw();centerTrack(); if(view.scrollTop!==undefined)scroll.scrollTop=view.scrollTop;scroll.scrollLeft=view.scrollLeft??0; syncValue();warmInstrument(); } catch {detail.textContent="Unable to read MIDI. Please load the clip again.";} } watch("value",receive);updatePalette();setTool("select");receive();