Spaces:
Running on Zero
Running on Zero
Download app/editor.js from mimbres/spansynth-edit: direct link, hf CLI and curl.
- Browser
- Download file 45.8 kB
-
https://huggingface.co/spaces/mimbres/spansynth-edit/resolve/main/app/editor.js
- Command line
-
hf download hf://spaces/mimbres/spansynth-edit/app/editor.js
-
curl -L -o editor.js https://huggingface.co/spaces/mimbres/spansynth-edit/resolve/main/app/editor.js
45.8 kB
| 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<keys) { | |
| if(p.y>=scroll.scrollTop+header) { | |
| canvas.setPointerCapture(event.pointerId); drag={mode:"keys",pitch:p.pitch}; audition(p.pitch); | |
| } | |
| return; | |
| } | |
| canvas.setPointerCapture(event.pointerId); | |
| if (p.y<scroll.scrollTop+header) { | |
| selected.clear(); timeRange=null; cursorTime=p.time; | |
| drag={mode:"time",pointer:p,moved:false}; refresh(); draw(); return; | |
| } | |
| const n=hit(p), before=snapshot(); | |
| if (tool==="erase") { | |
| drag={mode:"erase",before,moved:!!n}; if(n) data.notes=data.notes.filter(item=>item.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<keys && p.pitch!==drag.pitch && p.y>=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.start<last && n.start+n.duration>first).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.start<end && n.start+n.duration>start && 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(); | |