=1?"Finalizing…":("~"+Math.ceil((tot-el))+"s remaining");
- $("#proc-gpu").textContent=`GPU ${Math.round(40+Math.random()*55)}% · RTX 4090`;
if(prog>=1){ clearInterval(procTimer); setTimeout(()=>{ if(STATE.scene==="processing") setScene("success"); },650); }
};
procTimer=setInterval(tickP,80); tickP();
@@ -499,9 +616,6 @@ function hideCtx(){ $("#ctx").classList.remove("show"); }
/* ---------------- COMMAND PALETTE ---------------- */
const CMDS=[
- {g:"Projects",t:"Open · Midnight Cartography",s:"band",ic:ICONS.synth,act:()=>loadProject("band")},
- {g:"Projects",t:"Open · Aurora Borealis Suite",s:"14 stems",ic:ICONS.strings,act:()=>loadProject("orchestra")},
- {g:"Projects",t:"Open · Gymnopédie No. 1",s:"solo piano",ic:ICONS.keys,act:()=>loadProject("piano")},
{g:"States",t:"Show empty state",ic:'',act:()=>setScene("empty")},
{g:"States",t:"Simulate new analysis (processing)",ic:'',act:()=>setScene("processing")},
{g:"States",t:"Show error state",ic:'',act:()=>setScene("error")},
@@ -531,17 +645,22 @@ function flash(msg){ $("#flash-t").textContent=msg; $("#flash").classList.add("s
/* ---------------- MIDI NOTE LOADER ---------------- */
async function _loadMidiNotes(sid, stems){
+ // Pre-init to [] so export knows "fetched but empty" vs "never attempted"
+ stems.forEach(s => { if(s.midi_url && s.clef!=="perc") s.midiNotes = s.midiNotes || []; });
await Promise.allSettled(stems.map(async s => {
if(!s.midi_url || s.clef==="perc") return;
try {
- const r = await fetch(`/api/midi/${sid}/${s.id}`);
+ const r = await fetch(_api(`/api/midi/${sid}/${s.id}`));
if(r.ok){ const d=await r.json(); s.midiNotes=d.notes||[]; }
+ // non-OK → keep [] so export still works (score will show rests)
} catch(_){}
}));
}
/* ---------------- LOCAL FILE UPLOAD ---------------- */
async function submitLocalFile(file){
+ if(STATE.scene==="processing") return;
+ clearInterval(_activePoll); _activePoll=null;
STATE.liveProject=null;
STATE.url="local:"+file.name;
setScene("processing");
@@ -552,7 +671,7 @@ async function submitLocalFile(file){
fd.append("file",file);
let sid;
try {
- const r=await fetch("/api/upload",{method:"POST",body:fd});
+ const r=await fetch(_api("/api/upload"),{method:"POST",body:fd});
if(!r.ok) throw new Error("Upload failed "+r.status);
({session_id:sid}=await r.json());
} catch(e) {
@@ -562,22 +681,31 @@ async function submitLocalFile(file){
}
flash("Uploading… session "+sid.slice(0,8));
- const poll=setInterval(async()=>{
- if(STATE.scene!=="processing"){clearInterval(poll);return;}
+ _activePoll=setInterval(async()=>{
+ if(STATE.scene!=="processing"){clearInterval(_activePoll);_activePoll=null;return;}
let job;
- try{job=await fetch(`/api/status/${sid}`).then(x=>x.json());}catch(_){return;}
+ try{job=await fetch(_api(`/api/status/${sid}`)).then(x=>x.json());}catch(_){return;}
+
+ if(job.status==="error"){
+ clearInterval(_activePoll);_activePoll=null;
+ fillErrorLog(job.error||"Unknown error");
+ setScene("error");
+ return;
+ }
+
const prog=job.progress||0, si=job.stage>=0?job.stage:0;
_renderProcStages(prog,si);
$("#proc-eta").textContent=prog>=1?"Finalizing…":"Processing locally…";
- $("#proc-gpu").textContent=job.meta?.gpu?`${job.meta.gpu.model}`:"CPU · local";
if(job.status==="done"){
- clearInterval(poll);
+ clearInterval(_activePoll);_activePoll=null;
const m=job.meta||{};
const stems=(job.stems||[]).map(s=>({
...s,
density:s.density||2,
seed:typeof s.seed==='number'?s.seed:(s.id.split('').reduce((a,c)=>a*31+c.charCodeAt(0),1)&0x7fffffff)%50000,
reg:s.reg||null,
+ audio_url: s.audio_url ? _api(s.audio_url) : s.audio_url,
+ midi_url: s.midi_url ? _api(s.midi_url) : s.midi_url,
}));
STATE.liveProject={
id:"live",kind:"live",
@@ -588,28 +716,146 @@ async function submitLocalFile(file){
sampleRate:m.sampleRate||44100,bitDepth:m.bitDepth||24,
sepModel:m.sepModel||"HT-Demucs v4",transModel:m.transModel||"librosa-pyin",
gpu:m.gpu||{model:"CPU",util:0,vram:0,vramTotal:0},
- bars:8,barOffset:0,stems,diagnostics:[],spectrum:[],
+ bars:8,barOffset:0,stems,diagnostics:[],spectrum:m.spectrum||[],
};
STATE.pkey="live";
STATE.liveSid=sid;
+ STATE.sel=stems.find(s=>!s.removed)?.id||stems[0]?.id||null;
if(stems.length){
- flash("Loading stems…");
- await Promise.all([
- AudioEngine.loadFromSession(stems,(p,id,ok)=>{if(!ok)flash("Failed: "+id);}),
- _loadMidiNotes(sid, STATE.liveProject.stems),
- ]);
- flash("Stems + MIDI loaded — press Space to play");
+ flash("Loading score…");
+ await _loadMidiNotes(sid, STATE.liveProject.stems);
}
+ MidiEngine.reload(STATE.liveProject.stems);
+ _invalidateStaffCache();
renderAll();
setTimeout(()=>{if(STATE.scene==="processing")setScene("success");},300);
- } else if(job.status==="error"){
- clearInterval(poll);
- fillErrorLog(job.error||"Unknown error");
- setScene("error");
+ if(stems.length){
+ AudioEngine.loadFromSession(stems,(p,id,ok)=>{if(!ok)flash("Failed: "+id);})
+ .then(()=>flash("Audio ready · Space to play"));
+ }
}
},800);
}
+/* ---------------- URL SUBMIT (global so opener + omnibox + empty input all use it) ---------------- */
+function submitUrl(url, force=false){
+ if(STATE.scene==="processing") return;
+ STATE.url=(url||"").trim();
+ STATE._forceReanalyze=force;
+ STATE.liveProject=null;
+ setScene("processing");
+}
+
+/* ---------------- SESSION PERSISTENCE ────────────────────── */
+const _LS_SESSION = "aegis_session_v2";
+
+function _saveSession() {
+ const p = P(), sid = STATE.liveSid;
+ if (!p || !sid) return;
+ const slim = {
+ ...p,
+ stems: p.stems.map(({ midiNotes, ...s }) => s),
+ spectrum: [], diagnostics: [],
+ };
+ try { localStorage.setItem(_LS_SESSION, JSON.stringify({ sid, project: slim, ts: Date.now() })); } catch(_){}
+}
+
+async function _tryRestoreSession() {
+ try {
+ const raw = localStorage.getItem(_LS_SESSION);
+ if (!raw) return false;
+ const { sid, project, ts } = JSON.parse(raw);
+ if (!sid || !project) return false;
+ if (Date.now() - ts > 7 * 24 * 3600 * 1000) return false; // 7-day TTL
+ const r = await fetch(_api(`/api/status/${sid}`));
+ if (!r.ok) return false;
+ const job = await r.json();
+ if (job.status !== "done") return false;
+ // Merge freshest stems from server (in case file paths changed)
+ const stems = (job.stems || project.stems || []).map(s => ({
+ ...s,
+ density: s.density || 2,
+ seed: typeof s.seed === "number" ? s.seed : 1,
+ reg: s.reg || null,
+ audio_url: s.audio_url ? _api(s.audio_url) : s.audio_url,
+ midi_url: s.midi_url ? _api(s.midi_url) : s.midi_url,
+ }));
+ STATE.liveProject = { ...project, stems };
+ STATE.pkey = "live";
+ STATE.liveSid = sid;
+ STATE.sel = stems.find(s => !s.removed)?.id || stems[0]?.id || null;
+ flash("Restoring session…");
+ if (stems.length) {
+ await _loadMidiNotes(sid, STATE.liveProject.stems);
+ MidiEngine.reload(STATE.liveProject.stems);
+ }
+ renderAll();
+ setScene("success");
+ flash("Session restored — audio loading…");
+ if (stems.length) {
+ AudioEngine.loadFromSession(stems, (_, id, ok) => { if (!ok) flash("Audio failed: " + id); })
+ .then(() => flash("Audio ready · Space to play"));
+ }
+ return true;
+ } catch(_) { return false; }
+}
+
+async function _openRecentSession(entry) {
+ if (!entry) return;
+ setPlaying(false);
+ clearInterval(_activePoll); _activePoll = null;
+ STATE.url = entry.url || "";
+ if (entry.sid) {
+ setScene("loading");
+ try {
+ const r = await fetch(_api(`/api/status/${entry.sid}`));
+ if (r.ok) {
+ const job = await r.json();
+ if (job.status === "done") {
+ const m = job.meta || {};
+ const stems = (job.stems || []).map(s => ({
+ ...s, density: s.density||2,
+ seed: typeof s.seed==="number"?s.seed:1, reg: s.reg||null,
+ audio_url: s.audio_url ? _api(s.audio_url) : s.audio_url,
+ midi_url: s.midi_url ? _api(s.midi_url) : s.midi_url,
+ }));
+ STATE.liveProject = {
+ id:"live", kind:"live",
+ title: m.title||entry.title||"Untitled",
+ artist: m.artist||entry.artist||"",
+ album:"", source: entry.url||"",
+ thumb: m.thumb?`url("${m.thumb}") center/cover`
+ :"linear-gradient(135deg,oklch(0.4 0.12 260),oklch(0.5 0.1 200))",
+ duration: m.duration||180, tempo: m.tempo||120,
+ tempoMap:[[0,m.tempo||120]],
+ key: m.key||"A minor", timeSig: m.timeSig||[4,4],
+ sampleRate: m.sampleRate||44100, bitDepth: m.bitDepth||24,
+ sepModel: m.sepModel||"HT-Demucs v4", transModel: m.transModel||"basic-pitch",
+ gpu: m.gpu||{model:"CPU",util:0,vram:0,vramTotal:0},
+ bars:_calcBars(), barOffset:0,
+ stems, diagnostics:[], spectrum: m.spectrum||[],
+ };
+ STATE.pkey="live"; STATE.liveSid=entry.sid;
+ STATE.sel=stems.find(s=>!s.removed)?.id||stems[0]?.id||null;
+ flash("Loading score…");
+ if (stems.length) {
+ await _loadMidiNotes(entry.sid, STATE.liveProject.stems);
+ MidiEngine.reload(STATE.liveProject.stems);
+ }
+ _invalidateStaffCache(); renderAll(); setScene("success");
+ flash("Restored — audio loading…");
+ AudioEngine.loadFromSession(stems, (_,id,ok)=>{ if(!ok) flash("Audio: "+id+" failed"); })
+ .then(()=>flash("Audio ready · Space to play"));
+ _saveSession();
+ return;
+ }
+ }
+ } catch(_) {}
+ }
+ if (entry.url) submitUrl(entry.url);
+ else flash("Cannot open: no URL stored");
+}
+
/* ---------------- EVENTS ---------------- */
function bind(){
// File picker wired to hidden input
@@ -638,6 +884,18 @@ function bind(){
document.addEventListener("click",e=>{
const t=e.target;
+ const mb=t.closest("[data-menubar]"); if(mb){
+ const act=mb.dataset.menubar;
+ if(act==="analyze"){
+ const p=P();
+ if(p && STATE.url){ submitUrl(STATE.url, true); } // re-run AI pipeline on same URL
+ else if(p){ flash("Drop a file or paste a URL to re-analyze"); $("#omni-input")?.focus(); }
+ else { setScene("empty"); }
+ }
+ else if(act==="score"){ document.querySelector('.tab[data-tab="score"]')?.click(); }
+ else if(act==="view"){ openCmdk(); }
+ return;
+ }
const load=t.closest("[data-load]"); if(load){ loadProject(load.dataset.load, load.dataset.load && SCENES[load.dataset.load]?undefined:undefined); return; }
const stemEl=t.closest("[data-stem]");
const mute=t.closest("[data-mute]"); if(mute){ e.stopPropagation(); toggleMute(mute.dataset.mute); return; }
@@ -672,6 +930,25 @@ function bind(){
const ci=t.closest(".cmdk-item"); if(ci&&ci.dataset.idx!=null){ runCmdk(+ci.dataset.idx); return; }
if(t.closest("#btn-cmdk")){ openCmdk(); return; }
if(t.id==="cmdk"){ closeCmdk(); return; }
+ // crumb title click → project actions menu
+ if(t.closest("#crumb-title")){
+ const p=P(); if(!p) return;
+ const r=t.closest("#crumb-title").getBoundingClientRect();
+ showCtx(r.left, r.bottom+4, [
+ { label: p.title },
+ { t:"Export stems as MIDI", ic:'',
+ act:"flash:proj-export" },
+ { t:"Export stems as WAV", ic:'',
+ act:"flash:proj-export-wav" },
+ { t:"Detection log", ic:'',
+ act:"proj:log" },
+ { sep:true },
+ { t:"Re-analyze", ic:'',
+ act:"proj:reanalyze" },
+ { t:"Close project", ic:'', danger:1,
+ act:"proj:close" },
+ ]); return;
+ }
// scene pill menu
if(t.closest("#scene-pill")){ const r=t.closest("#scene-pill").getBoundingClientRect(); showCtx(r.left,r.bottom+4,[
{label:"Application states"},
@@ -679,14 +956,11 @@ function bind(){
{t:"Empty",ic:'',act:"sc:empty"},
{t:"Processing",ic:'',act:"sc:processing"},
{t:"Error",ic:'',act:"sc:error"},
- {sep:1},{label:"Projects"},
- {t:"Solo piano (small)",ic:ICONS.keys,act:"pj:piano"},
- {t:"Orchestral (massive)",ic:ICONS.strings,act:"pj:orchestra"},
]); return; }
hideCtx();
// transport
if(t.closest("#tp-play")) setPlaying(!STATE.playing);
- if(t.closest("#tp-stop")){ setPlaying(false); STATE.pos=P().barOffset||0; positionPlayhead(); }
+ if(t.closest("#tp-stop")){ setPlaying(false); STATE.pos=P()?.barOffset||0; positionPlayhead(); }
if(t.closest("#tp-start")){ STATE.pos=0; positionPlayhead(); $("#lane-scroll").scrollLeft=0; }
if(t.closest("#tp-loop")) t.closest("#tp-loop").classList.toggle("on");
// toggles
@@ -701,12 +975,36 @@ function bind(){
if(t.closest(".er")){ const er=t.closest(".er"); loadProject(er.dataset.load,"processing"); return; }
if(t.closest("#empty-go")){ const v=$("#empty-input").value.trim(); if(v){ submitUrl(v); } return; }
});
- // lane seek
- $("#lane-scroll").addEventListener("mousedown",e=>{
- if(e.target.closest(".playhead"))return;
+ // double-click gutter track or lane → focus player
+ document.addEventListener("dblclick",e=>{
+ const gt=e.target.closest(".gutter-track, .lane");
+ if(!gt) return;
+ e.preventDefault(); e.stopPropagation();
+ const id=gt.dataset.stem; if(!id) return;
+ if(id===STATE.focusStem) closeFocusTrack();
+ else openFocusTrack(id);
+ });
+ // focus panel controls
+ document.getElementById("focus-close").addEventListener("click",closeFocusTrack);
+ document.getElementById("focus-wave-wrap").addEventListener("click",e=>{
+ const p=P(); if(!p) return;
+ const rect=e.currentTarget.getBoundingClientRect();
+ const secs=Math.max(0,(e.clientX-rect.left)/rect.width)*p.duration;
+ STATE.pos=secs*p.tempo/60/p.timeSig[0];
+ const fph=document.getElementById("focus-ph");
+ if(fph) fph.style.left=Math.min(100,secs/Math.max(1,p.duration)*100)+"%";
+ positionPlayhead();
+ if(STATE.playing){ const eng=_activeEngine(); eng.stop(); eng.start(secs); }
+ });
+
+ // lane seek — ignore native horizontal scrollbar (lives below clientHeight)
+ $("#lane-scroll").addEventListener("mousedown",ev=>{
+ if(ev.target.closest(".playhead"))return;
+ const sc=ev.currentTarget;
+ if(ev.offsetY >= sc.clientHeight) return; // click is in native scrollbar area
const wrap=$("#lanes-wrap"); const rect=wrap.getBoundingClientRect();
- const x=e.clientX-rect.left; STATE.pos=Math.max(0,x/STATE.barPx); positionPlayhead();
- if(STATE.playing){ AudioEngine.stop(); AudioEngine.start(barsToSecs(STATE.pos)); }
+ const x=ev.clientX-rect.left; STATE.pos=Math.max(0,x/STATE.barPx); positionPlayhead();
+ if(STATE.playing){ const eng=_activeEngine(); eng.stop(); eng.start(barsToSecs(STATE.pos)); }
});
// ruler seek
$("#ruler").addEventListener("mousedown",e=>{ const r=e.currentTarget.getBoundingClientRect(); STATE.pos=Math.max(0,(e.clientX-r.left)/STATE.barPx); positionPlayhead(); });
@@ -714,6 +1012,22 @@ function bind(){
const gv=$("#gutter-scroll"), lv=$("#lanes-vscroll"); let syncing=false;
lv.addEventListener("scroll",()=>{ if(syncing)return; syncing=true; gv.scrollTop=lv.scrollTop; syncing=false; });
gv.addEventListener("scroll",()=>{ if(syncing)return; syncing=true; lv.scrollTop=gv.scrollTop; syncing=false; });
+ // timeline horizontal scroll → sync score barOffset
+ // During playback tick() sets sc.scrollLeft programmatically — skip those events,
+ // only react to genuine user drags of the scrollbar.
+ let _scoreScrollRAF=null;
+ // _progScrollLeft is module-scoped (declared above tick) — shared with tick()
+ $("#lane-scroll").addEventListener("scroll",()=>{
+ const p=P(); if(!p) return;
+ const sl=$("#lane-scroll").scrollLeft;
+ if(Math.abs(sl-_progScrollLeft)<2) return; // tick()-driven scroll, ignore
+ if(_scoreScrollRAF) cancelAnimationFrame(_scoreScrollRAF);
+ _scoreScrollRAF=requestAnimationFrame(()=>{
+ const bar=Math.floor($("#lane-scroll").scrollLeft/STATE.barPx);
+ const newOff=Math.max(0,Math.min(totalBars()-p.bars,bar));
+ if(newOff!==p.barOffset){ p.barOffset=newOff; renderScore(); }
+ });
+ });
// context menus
document.addEventListener("contextmenu",e=>{
const g=e.target.closest("[data-stem]");
@@ -741,7 +1055,6 @@ function bind(){
]); return; }
});
// omnibox enter
- function submitUrl(url){ STATE.url=(url||"").trim(); STATE.liveProject=null; setScene("processing"); }
$("#omni-input").addEventListener("keydown",e=>{ if(e.key==="Enter"){ e.target.blur(); submitUrl(e.target.value); }});
$("#omni-input").addEventListener("blur",e=>{ STATE.url=(e.target.value||"").trim(); });
$("#empty-input").addEventListener("keydown",e=>{ if(e.key==="Enter") submitUrl(e.target.value); });
@@ -759,9 +1072,9 @@ function bind(){
// global keys
document.addEventListener("keydown",e=>{
if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="k"){ e.preventDefault(); openCmdk(); return; }
- if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="e"){ e.preventDefault(); flash("Exported "+P().stems.filter(s=>!s.removed).length+" stems → MIDI"); return; }
+ if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="e"){ e.preventDefault(); if(P()) flash("Exported "+P().stems.filter(s=>!s.removed).length+" stems → MIDI"); return; }
if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="l"){ e.preventDefault(); $("#omni-input").focus(); $("#omni-input").select(); return; }
- if(e.key==="Escape"){ closeCmdk(); hideCtx(); }
+ if(e.key==="Escape"){ closeCmdk(); hideCtx(); if(STATE.focusStem) closeFocusTrack(); }
if(e.target.tagName==="INPUT")return;
if(e.code==="Space"){ e.preventDefault(); setPlaying(!STATE.playing); }
if(e.key==="m"&&STATE.sel) toggleMute(STATE.sel);
@@ -769,7 +1082,7 @@ function bind(){
if(e.key==="+"||e.key==="="){ STATE.zoom=Math.min(2,STATE.zoom+0.1); applyZoom(); }
if(e.key==="-"){ STATE.zoom=Math.max(0.6,STATE.zoom-0.1); applyZoom(); }
// arrow select stem
- if(e.key==="ArrowDown"||e.key==="ArrowUp"){ const st=P().stems; let i=st.findIndex(s=>s.id===STATE.sel); i=(i+(e.key==="ArrowDown"?1:-1)+st.length)%st.length; selectStem(st[i].id); }
+ if(e.key==="ArrowDown"||e.key==="ArrowUp"){ if(!P()) return; const st=P().stems; let i=st.findIndex(s=>s.id===STATE.sel); i=(i+(e.key==="ArrowDown"?1:-1)+st.length)%st.length; selectStem(st[i].id); }
});
// hover hints on transport
document.addEventListener("mouseover",e=>{ const h=e.target.closest("[title]"); });
@@ -780,12 +1093,263 @@ function handleCtxAct(act){
else if(act.startsWith("pj:")) loadProject(act.slice(3));
else if(act.startsWith("solo:")) toggleSolo(act.slice(5));
else if(act.startsWith("mute:")) toggleMute(act.slice(5));
+ else if(act==="flash:proj-export"){
+ const p=P(); if(!p) return;
+ const activeSt=p.stems.filter(s=>!s.removed&&s.midi_url);
+ if(activeSt.length){ activeSt.forEach((s,i)=>setTimeout(()=>{const a=document.createElement("a");a.href=s.midi_url;a.download=s.id+".mid";document.body.appendChild(a);a.click();document.body.removeChild(a);},i*250)); flash("Downloading "+activeSt.length+" MIDI files…"); }
+ else flash("No MIDI data yet — analyze first");
+ }
+ else if(act==="flash:proj-export-wav"){
+ const p=P(); if(!p) return;
+ const activeSt=p.stems.filter(s=>!s.removed&&s.audio_url);
+ if(activeSt.length){ activeSt.forEach((s,i)=>setTimeout(()=>{const a=document.createElement("a");a.href=s.audio_url;a.download=s.id+".wav";document.body.appendChild(a);a.click();document.body.removeChild(a);},i*250)); flash("Downloading "+activeSt.length+" stems…"); }
+ else flash("No stem audio yet");
+ }
+ else if(act==="proj:log"){
+ const sid=STATE.liveSid; if(!sid){flash("No active session");return;}
+ fetch(_api(`/api/detection_log/${sid}`)).then(r=>r.ok?r.text():Promise.reject()).then(txt=>{
+ const w=window.open("","_blank","width=600,height=500");
+ w.document.write(`${txt.replace(/`);
+ }).catch(()=>flash("Detection log not found"));
+ }
+ else if(act==="proj:reanalyze"){
+ if(STATE.url) submitUrl(STATE.url, true);
+ else flash("No URL — drop a file to re-analyze");
+ }
+ else if(act==="proj:close"){
+ setPlaying(false);
+ STATE.liveProject=null; STATE.liveSid=null; STATE.pkey=null;
+ try{ localStorage.removeItem(_LS_SESSION); }catch(_){}
+ setScene("empty");
+ }
else if(act.startsWith("flash:")) flash(act.slice(6));
+ else if(act.startsWith("ri:")){
+ const [,op,idxStr] = act.split(":");
+ const idx = parseInt(idxStr);
+ const list = _recentSessions();
+ const entry = list[idx];
+ if(!entry) return;
+ if(op==="open"){
+ _openRecentSession(entry);
+ } else if(op==="delete"){
+ list.splice(idx, 1);
+ try{ localStorage.setItem(_LS_RECENT, JSON.stringify(list)); }catch(_){}
+ _renderOpenerRecent();
+ flash("Removed from recents");
+ } else if(op==="dupe"){
+ const copy = { ...entry, title: entry.title + " (copy)" };
+ list.splice(idx + 1, 0, copy);
+ try{ localStorage.setItem(_LS_RECENT, JSON.stringify(list)); }catch(_){}
+ _renderOpenerRecent();
+ flash("Duplicated project entry");
+ } else if(op==="export"){
+ // Re-open the project and go straight to export
+ const sid_raw = localStorage.getItem(_LS_SESSION);
+ if(sid_raw){
+ try{
+ const { sid, project } = JSON.parse(sid_raw);
+ if(sid && project){
+ const activeSt = (project.stems||[]).filter(s=>!s.removed&&s.midi_url);
+ if(activeSt.length){
+ activeSt.forEach((s,i)=>setTimeout(()=>{
+ const a=document.createElement("a"); a.href=s.midi_url; a.download=s.id+".mid";
+ document.body.appendChild(a); a.click(); document.body.removeChild(a);
+ }, i*250));
+ flash("Downloading "+activeSt.length+" MIDI files…");
+ return;
+ }
+ }
+ }catch(_){}
+ }
+ // Fallback: load the project then export
+ document.getElementById("po-url").value = entry.url;
+ _openerGo();
+ flash("Open the project first, then use Export");
+ }
+ }
+}
+// Light redraw: only update opacity + selection classes, no SVG rebuild
+function _lightRedrawScore() {
+ const p=P(); if(!p) return;
+ const solo=STATE.solo.size>0;
+ $$('#score-systems .staff-row').forEach(row=>{
+ const s=p.stems.find(x=>x.id===row.dataset.stem); if(!s) return;
+ const dim=s.removed||STATE.muted.has(s.id)||(solo&&!STATE.solo.has(s.id));
+ row.style.opacity=dim?.34:1;
+ row.classList.toggle('sel',s.id===STATE.sel);
+ });
+}
+
+// Light redraw: update waveform muted tint + M/S button states, no DOM rebuild
+function _lightRedrawTimeline() {
+ const p=P(); if(!p) return;
+ const beats=p.timeSig[0], bpx=STATE.barPx, tb=totalBars();
+ const W=tb*bpx, laneH=46, solo=STATE.solo.size>0;
+ $$('#lanes .lane').forEach(lane=>{
+ const s=p.stems.find(x=>x.id===lane.dataset.stem); if(!s) return;
+ const cv=lane.querySelector('canvas'); if(!cv) return;
+ if(STATE.showWave){
+ const peaks=AudioEngine.hasStem(s.id)?AudioEngine.getPeaks(s.id):null;
+ drawWaveform(cv,{...s,muted:STATE.muted.has(s.id)||(solo&&!STATE.solo.has(s.id))},{width:W,height:laneH,beatPx:bpx/beats,peaks});
+ }
+ });
+ $$('.gutter-track').forEach(gt=>{
+ const id=gt.dataset.stem;
+ gt.querySelector('[data-mute]')?.classList.toggle('on',STATE.muted.has(id));
+ gt.querySelector('[data-solo]')?.classList.toggle('on',STATE.solo.has(id));
+ gt.style.background=id===STATE.sel?'var(--bg-3)':'';
+ });
+}
+
+function toggleMute(id){
+ STATE.muted.has(id)?STATE.muted.delete(id):STATE.muted.add(id);
+ if(STATE.playMode==="midi"){
+ MidiEngine.setMute(id, STATE.muted.has(id));
+ if(STATE.playing){ MidiEngine.stop(); MidiEngine.start(barsToSecs(STATE.pos)); }
+ } else {
+ AudioEngine.setMute(id, STATE.muted.has(id));
+ }
+ _lightRedrawScore(); _lightRedrawTimeline(); selectStem(STATE.sel);
+}
+function toggleSolo(id){
+ STATE.solo.has(id)?STATE.solo.delete(id):STATE.solo.add(id);
+ if(STATE.playMode==="midi"){
+ MidiEngine.setSolo([...STATE.solo]);
+ if(STATE.playing){ MidiEngine.stop(); MidiEngine.start(barsToSecs(STATE.pos)); }
+ } else {
+ AudioEngine.setSolo([...STATE.solo]);
+ }
+ _lightRedrawScore(); _lightRedrawTimeline();
+}
+
+/* ---------------- PROJECT OPENER ────────────────────── */
+const _LS_RECENT = "aegis_recent_v1";
+
+function _recentSessions() {
+ try { return JSON.parse(localStorage.getItem(_LS_RECENT) || "[]"); } catch(_){ return []; }
+}
+function _pushRecent(entry) {
+ const list = _recentSessions().filter(x => x.url !== entry.url).slice(0, 9);
+ list.unshift(entry);
+ try { localStorage.setItem(_LS_RECENT, JSON.stringify(list)); } catch(_){}
+}
+
+function _renderOpenerRecent() {
+ const list = _recentSessions();
+ const el = document.getElementById("po-recent-list");
+ if (!el) return;
+ if (!list.length) { el.innerHTML = 'No recent sessions yet.
'; return; }
+ el.innerHTML = list.map((s, i) => `
+
+
+
+
${s.title||'Untitled'}
+
${s.artist||''} · ${s.url||''}
+
+
+
`).join("");
+}
+
+function _bindOpener() {
+ // nav tabs
+ document.querySelectorAll(".po-navitem").forEach(btn => {
+ btn.addEventListener("click", () => {
+ document.querySelectorAll(".po-navitem").forEach(b => b.classList.remove("active"));
+ btn.classList.add("active");
+ const tab = btn.dataset.po;
+ document.querySelectorAll(".po-panel").forEach(p => p.classList.remove("active"));
+ document.getElementById("po-panel-" + tab)?.classList.add("active");
+ if (tab === "recent") _renderOpenerRecent();
+ });
+ });
+
+ // URL input enter
+ const urlInp = document.getElementById("po-url");
+ urlInp?.addEventListener("keydown", e => {
+ if (e.key === "Enter") _openerGo();
+ });
+
+ // Go button
+ document.getElementById("po-go-btn")?.addEventListener("click", _openerGo);
+
+ // Drop zone inside opener
+ const dz = document.getElementById("po-drop-zone");
+ if (dz) {
+ dz.addEventListener("click", e => {
+ if (!e.target.classList.contains("po-browse-btn")) document.getElementById("file-picker").click();
+ });
+ dz.addEventListener("dragover", e => { e.preventDefault(); dz.classList.add("drag-over"); });
+ dz.addEventListener("dragleave", () => dz.classList.remove("drag-over"));
+ dz.addEventListener("drop", e => {
+ e.preventDefault(); dz.classList.remove("drag-over");
+ const f = e.dataTransfer.files?.[0];
+ if (f) submitLocalFile(f);
+ });
+ }
+
+ // recent list: click item = open directly; click ⋯ button = ctx menu
+ document.getElementById("po-recent-list")?.addEventListener("click", e => {
+ // ⋯ more button
+ const more = e.target.closest("[data-po-more]");
+ if (more) {
+ e.stopPropagation();
+ const idx = +more.dataset.poMore;
+ const entry = _recentSessions()[idx];
+ if (!entry) return;
+ const r = more.getBoundingClientRect();
+ showCtx(Math.max(0, r.right - 210), r.bottom + 4, [
+ { label: entry.title || "Project" },
+ { t:"Export MIDI", ic:'', act:`ri:export:${idx}` },
+ { t:"Duplicate", ic:'', act:`ri:dupe:${idx}` },
+ { sep:true },
+ { t:"Remove from list", ic:'', danger:1, act:`ri:delete:${idx}` },
+ ]);
+ return;
+ }
+ // click anywhere on item = open immediately
+ const item = e.target.closest("[data-po-recent]");
+ if (!item) return;
+ const idx = +item.dataset.poRecent;
+ const entry = _recentSessions()[idx];
+ if (!entry) return;
+ _openRecentSession(entry);
+ });
+}
+
+function _openerGo() {
+ const val = (document.getElementById("po-url")?.value || "").trim();
+ if (val) submitUrl(val);
}
-function toggleMute(id){ STATE.muted.has(id)?STATE.muted.delete(id):STATE.muted.add(id); AudioEngine.setMute(id, STATE.muted.has(id)); renderScore(); renderTimeline(); selectStem(STATE.sel); }
-function toggleSolo(id){ STATE.solo.has(id)?STATE.solo.delete(id):STATE.solo.add(id); AudioEngine.setSolo([...STATE.solo]); renderScore(); renderTimeline(); }
/* ---------------- INIT ---------------- */
-function boot(){ bind(); loadProject("band","success"); }
+function boot(){
+ bind();
+ _bindOpener();
+ setScene("empty");
+ // Show recent sessions tab by default if any exist
+ if(_recentSessions().length){
+ document.querySelector('.po-navitem[data-po="recent"]')?.click();
+ }
+ // Recompute visible bars on window resize
+ window.addEventListener("resize", ()=>{
+ const p=P(); if(!p) return;
+ p.bars=_calcBars();
+ _invalidateStaffCache(); renderScore();
+ });
+ // WAV / MIDI mode toggle
+ document.getElementById("tp-mode")?.addEventListener("click", ()=>{
+ if(STATE.playing) setPlaying(false);
+ STATE.playMode = STATE.playMode==="midi" ? "wav" : "midi";
+ const btn = document.getElementById("tp-mode");
+ const isMidi = STATE.playMode==="midi";
+ btn.textContent = isMidi ? "MIDI" : "WAV";
+ btn.classList.toggle("on", isMidi);
+ if(isMidi){ const p=P(); if(p) MidiEngine.load(p.stems); }
+ flash(isMidi ? "MIDI synth mode — plays note synthesis" : "WAV mode — plays separated stems");
+ });
+ // Auto-save on app close / page unload
+ window.addEventListener("beforeunload", _saveSession);
+}
+
document.fonts && document.fonts.ready.then(()=>{ renderScore(); });
boot();
diff --git a/js/data.js b/js/data.js
index c16ef99..e47c155 100644
--- a/js/data.js
+++ b/js/data.js
@@ -5,123 +5,25 @@
const DIA = { C:0, D:1, E:2, F:3, G:4, A:5, B:6 };
function dabs(step, oct){ return oct*7 + DIA[step]; }
-// key signatures -> which diatonic steps are sharp/flat (for accidental glyphs)
-const KEYSIG = {
- "F# minor": { sharps:["F","C","G"], flats:[] },
- "D major": { sharps:["F","C"], flats:[] },
- "A minor": { sharps:[], flats:[] },
- "Bb major": { sharps:[], flats:["B","E"] },
+// key signatures — generated from circle of fifths, covers all 30 standard keys
+// Sharp order (FCGDAEB) and flat order (BEADGCF) are music-theory constants
+const _SHARP_ORDER = ['F','C','G','D','A','E','B'];
+const _FLAT_ORDER = ['B','E','A','D','G','C','F'];
+const _FIFTHS = {
+ // major keys
+ 'C major':0,'G major':1,'D major':2,'A major':3,'E major':4,'B major':5,
+ 'F# major':6,'C# major':7,
+ 'F major':-1,'Bb major':-2,'Eb major':-3,'Ab major':-4,'Db major':-5,'Gb major':-6,'Cb major':-7,
+ // minor keys (same accidentals as relative major)
+ 'A minor':0,'E minor':1,'B minor':2,'F# minor':3,'C# minor':4,'G# minor':5,
+ 'D# minor':6,'A# minor':7,
+ 'D minor':-1,'G minor':-2,'C minor':-3,'F minor':-4,'Bb minor':-5,'Eb minor':-6,'Ab minor':-7,
};
+const KEYSIG = {};
+Object.entries(_FIFTHS).forEach(([key, n]) => {
+ KEYSIG[key] = n > 0
+ ? { sharps: _SHARP_ORDER.slice(0, n), flats: [] }
+ : { sharps: [], flats: _FLAT_ORDER.slice(0, -n) };
+});
-const PROJECTS = {
-
- /* ---------------- HERO: band track ---------------- */
- band: {
- id:"band", kind:"band",
- title:"Midnight Cartography",
- artist:"Neon Tide",
- album:"Atlas of Sleep · 2025",
- source:"youtube.com/watch?v=k7Qm2x9LpZ4",
- thumb:"linear-gradient(135deg, oklch(0.5 0.16 290), oklch(0.45 0.14 245) 60%, oklch(0.55 0.13 30))",
- duration:218.4, tempo:122, tempoMap:[[0,122],[64,122],[112,118],[160,122]],
- key:"F# minor", timeSig:[4,4], sampleRate:48000, bitDepth:24,
- sepModel:"HT-Demucs v4 · 6-stem", transModel:"OvertoneNet-MIDI 2.3",
- gpu:{ model:"RTX 4090", util:14, vram:6.2, vramTotal:24 },
- bars:8, barOffset:33,
- stems:[
- { id:"drums", name:"Drums", instrument:"Acoustic Kit", family:"Percussion · GM 0",
- clef:"perc", color:"--s-drums", confidence:99.1, midi:412, role:"Rhythm",
- quality:{ snr:31.4, bleed:2.1, artifacts:0.8, separation:98.7 },
- density:4, seed:11, note:"Tight pocket · ghost notes on snare" },
- { id:"bass", name:"Bass", instrument:"Electric Bass (finger)", family:"Bass · GM 33",
- clef:"bass", color:"--s-bass", confidence:97.4, midi:96, role:"Low end",
- reg:[dabs("E",1),dabs("D",3)], density:2, seed:7, note:"Root–fifth, syncopated" },
- { id:"guitar", name:"Elec. Guitar", instrument:"Clean Strat", family:"Guitar · GM 27",
- clef:"treble", color:"--s-guitar", confidence:91.2, midi:188, role:"Harmony",
- reg:[dabs("G",3),dabs("E",5)], density:3, seed:23, note:"Arpeggiated, light chorus" },
- { id:"synth", name:"Lead Synth", instrument:"Analog Saw Lead", family:"Synth Lead · GM 81",
- clef:"treble", color:"--s-synth", confidence:93.0, midi:142, role:"Melody",
- reg:[dabs("D",4),dabs("A",5)], density:3, seed:41, note:"Detuned, portamento" },
- { id:"keys", name:"Keys / Pad", instrument:"Warm Poly Pad", family:"Synth Pad · GM 89",
- clef:"treble", color:"--s-keys", confidence:88.6, midi:74, role:"Texture",
- reg:[dabs("F",3),dabs("C",5)], density:1, seed:5, note:"Sustained triads, slow swell" },
- { id:"vocal", name:"Vocals", instrument:"Lead Vocal", family:"Voice", removed:true,
- clef:"treble", color:"--s-vocal", confidence:95.7, midi:0, role:"Removed",
- reg:[dabs("A",3),dabs("E",5)], density:2, seed:13, note:"Isolated & muted by request" },
- ],
- diagnostics:[
- { sev:"ok", t:"Vocal isolation clean", s:"No residual lead vocal in instrumental sum.", v:"−42 dB" },
- { sev:"warn", t:"Guitar/keys spectral overlap", s:"Bands 2–4 kHz contend; minor bleed on bar 47.", v:"6.8%" },
- { sev:"ok", t:"Transient preservation high", s:"Drum attacks retained through separation.", v:"98.7%" },
- { sev:"info", t:"Tempo drift detected", s:"Ritardando across bars 28–30, mapped automatically.", v:"−4 BPM" },
- { sev:"ok", t:"MIDI quantize confidence", s:"Onset grid locked to 1/16 at 122 BPM.", v:"96.1%" },
- ],
- spectrum:[8,17,28,42,61,73,68,55,47,52,44,38,31,36,27,22,18,24,15,11,14,9,7,12,6,5,8,4,3,5,2,3],
- },
-
- /* ---------------- MASSIVE: orchestral ---------------- */
- orchestra: {
- id:"orchestra", kind:"orchestra",
- title:"Aurora Borealis Suite — Mvt. II",
- artist:"E. Halvorsen · Oslo Phil.",
- album:"Northern Lights · Live 2024",
- source:"youtube.com/watch?v=A3v8Nq1RtY0",
- thumb:"linear-gradient(135deg, oklch(0.5 0.13 200), oklch(0.55 0.13 150) 55%, oklch(0.6 0.12 80))",
- duration:512.0, tempo:88, tempoMap:[[0,72],[40,88],[300,96],[440,80]],
- key:"D major", timeSig:[3,4], sampleRate:96000, bitDepth:24,
- sepModel:"OrchSep-XL · 14-source", transModel:"OvertoneNet-MIDI 2.3 · poly",
- gpu:{ model:"RTX 4090", util:71, vram:18.9, vramTotal:24 },
- bars:6, barOffset:51,
- stems:[
- { id:"vln1", name:"Violin I", instrument:"Strings", family:"Strings · divisi a2", clef:"treble", color:"--s-strings", confidence:84.2, midi:266, reg:[dabs("G",4),dabs("E",6)], density:4, seed:3, note:"Soaring theme, legato" },
- { id:"vln2", name:"Violin II", instrument:"Strings", family:"Strings", clef:"treble", color:"--s-strings", confidence:81.5, midi:240, reg:[dabs("D",4),dabs("B",5)], density:3, seed:9, note:"Inner harmony" },
- { id:"vla", name:"Viola", instrument:"Strings", family:"Strings", clef:"alto", color:"--s-strings", confidence:78.9, midi:198, reg:[dabs("C",3),dabs("E",5)], density:3, seed:14, note:"Counter-melody" },
- { id:"vc", name:"Cello", instrument:"Strings", family:"Strings", clef:"bass", color:"--s-strings", confidence:86.1, midi:176, reg:[dabs("C",2),dabs("A",3)], density:2, seed:6, note:"Pedal + arco swells" },
- { id:"cb", name:"Contrabass", instrument:"Strings", family:"Strings · 8vb", clef:"bass", color:"--s-strings", confidence:88.4, midi:88, reg:[dabs("E",1),dabs("D",3)], density:1, seed:2, note:"Foundation" },
- { id:"fl", name:"Flute", instrument:"Woodwind", family:"Woodwind · a2", clef:"treble", color:"--s-wood", confidence:79.2, midi:154, reg:[dabs("D",5),dabs("C",7)], density:4, seed:21, note:"Doubling Vln I 8va" },
- { id:"ob", name:"Oboe", instrument:"Woodwind", family:"Woodwind", clef:"treble", color:"--s-wood", confidence:72.6, midi:120, reg:[dabs("B",4),dabs("F",6)], density:2, seed:33, note:"Plaintive solo, bars 12–18" },
- { id:"cl", name:"Clarinet", instrument:"Woodwind", family:"Woodwind · in Bb", clef:"treble", color:"--s-wood", confidence:74.8, midi:138, reg:[dabs("E",3),dabs("C",6)], density:3, seed:28, note:"Arpeggiated figures" },
- { id:"bsn", name:"Bassoon", instrument:"Woodwind", family:"Woodwind", clef:"bass", color:"--s-wood", confidence:76.0, midi:104, reg:[dabs("B",1),dabs("E",4)], density:2, seed:18, note:"Tenor line" },
- { id:"hn", name:"Horns", instrument:"Brass", family:"Brass · F, a4", clef:"treble", color:"--s-brass", confidence:69.3, midi:96, reg:[dabs("C",3),dabs("G",4)], density:1, seed:44, note:"Sustained pads, low conf." },
- { id:"tpt", name:"Trumpets", instrument:"Brass", family:"Brass · C, a2", clef:"treble", color:"--s-brass", confidence:71.1, midi:78, reg:[dabs("G",3),dabs("D",5)], density:1, seed:37, note:"Fanfare, bar 22" },
- { id:"tbn", name:"Trombones", instrument:"Brass", family:"Brass · a3", clef:"bass", color:"--s-brass", confidence:73.5, midi:64, reg:[dabs("E",2),dabs("C",4)], density:1, seed:12, note:"Chorale" },
- { id:"timp", name:"Timpani", instrument:"Percussion", family:"Perc · D–A", clef:"bass", color:"--s-perc", confidence:90.7, midi:42, reg:[dabs("D",2),dabs("A",2)], density:1, seed:8, note:"Rolls + accents" },
- { id:"harp", name:"Harp", instrument:"Plucked", family:"Plucked · pedal D", clef:"treble", color:"--s-keys", confidence:80.4, midi:212, reg:[dabs("C",3),dabs("F",6)], density:4, seed:50, note:"Glissandi, arpeggios" },
- ],
- diagnostics:[
- { sev:"warn", t:"Brass separation degraded", s:"Horn/trombone overlap in 200–500 Hz; 14-source limit.", v:"69.3%" },
- { sev:"warn", t:"Divisi strings under-resolved", s:"Vln I a2 collapsed to single source above 4 kHz.", v:"a2→1" },
- { sev:"ok", t:"Timpani transients isolated", s:"Pitched percussion cleanly extracted.", v:"90.7%" },
- { sev:"info", t:"96 kHz source", s:"High-res input improved upper-partial recovery.", v:"96 kHz" },
- { sev:"err", t:"Oboe solo masked", s:"Bars 14–16 below detection floor under tutti.", v:"−38 dB" },
- { sev:"info", t:"Reverb tail (hall)", s:"RT60 ≈ 2.4 s folded into wet residual stem.", v:"2.4 s" },
- ],
- spectrum:[14,26,41,58,72,79,74,68,71,64,58,61,53,48,55,44,49,38,42,33,37,29,31,24,27,19,22,15,12,9,7,5],
- },
-
- /* ---------------- SMALL: solo piano ---------------- */
- piano: {
- id:"piano", kind:"piano",
- title:"Gymnopédie No. 1",
- artist:"Erik Satie",
- album:"Trois Gymnopédies · 1888 · Public Domain",
- source:"youtube.com/watch?v=S-Xm7s9d0kE",
- thumb:"linear-gradient(135deg, oklch(0.5 0.06 80), oklch(0.45 0.05 60) 60%, oklch(0.4 0.04 40))",
- duration:198.0, tempo:70, tempoMap:[[0,70]],
- key:"D major", timeSig:[3,4], sampleRate:44100, bitDepth:16,
- sepModel:"Solo bypass · no separation", transModel:"OvertoneNet-MIDI 2.3 · piano",
- gpu:{ model:"RTX 4090", util:4, vram:1.8, vramTotal:24 },
- bars:6, barOffset:5, grand:true,
- stems:[
- { id:"pno_r", name:"Piano — R.H.", instrument:"Grand Piano", family:"Keyboard · GM 0", clef:"treble", color:"--s-keys", confidence:99.6, midi:96, reg:[dabs("F",4),dabs("A",5)], density:1, seed:1, note:"Lent et douloureux — melody" },
- { id:"pno_l", name:"Piano — L.H.", instrument:"Grand Piano", family:"Keyboard · GM 0", clef:"bass", color:"--s-keys", confidence:99.4, midi:148, reg:[dabs("D",2),dabs("B",3)], density:2, seed:4, note:"Bass note + chord, ¾ accompaniment" },
- ],
- diagnostics:[
- { sev:"ok", t:"Single-source — direct transcription", s:"No separation needed; raw-to-MIDI path used.", v:"100%" },
- { sev:"ok", t:"Pedal events recovered", s:"Sustain (CC64) inferred from decay envelopes.", v:"24 events" },
- { sev:"ok", t:"Voicing detected", s:"3-voice polyphony tracked across both hands.", v:"3 voices" },
- { sev:"info", t:"Dynamics: pp–mp", s:"Velocity range compressed; gentle reading.", v:"18–54" },
- ],
- spectrum:[6,12,22,34,28,20,15,18,11,9,13,7,6,10,5,4,7,3,5,2,4,2,3,1,2,1,2,1,1,1,1,1],
- },
-};
+const PROJECTS = {};
diff --git a/js/export.js b/js/export.js
new file mode 100644
index 0000000..45ae495
--- /dev/null
+++ b/js/export.js
@@ -0,0 +1,484 @@
+/* ============================================================
+ export.js — MusicXML / MuseScore (.mscz) / PDF score export + File menu
+ ============================================================ */
+
+function toggleFileMenu(e) {
+ e && e.stopPropagation();
+ const dd = document.getElementById('dd-file');
+ const open = dd.classList.toggle('open');
+ if (open) _populateExportStemSel();
+}
+
+document.addEventListener('click', () => {
+ document.getElementById('dd-file')?.classList.remove('open');
+});
+
+function _populateExportStemSel() {
+ const p = typeof P === 'function' ? P() : null;
+ const sel = document.getElementById('exp-stem-sel');
+ if (!sel) return;
+ sel.innerHTML = '';
+ if (!p) { sel.innerHTML = ''; return; }
+ // Drums have no pitched MIDI (pyin can't transcribe percussion) — skip them
+ const exportable = p.stems.filter(s => s.id !== 'drums' && s.name.toLowerCase() !== 'drums');
+ if (!exportable.length) { sel.innerHTML = ''; return; }
+ exportable.forEach(s => {
+ const o = document.createElement('option');
+ o.value = s.id;
+ o.textContent = s.name;
+ sel.appendChild(o);
+ });
+ if (typeof STATE !== 'undefined' && STATE.sel && STATE.sel !== 'drums') sel.value = STATE.sel;
+}
+
+// ── Key sig fifths table ──────────────────────────────────────────────────────
+const _KEY_5THS = {
+ 'C major':0,'G major':1,'D major':2,'A major':3,'E major':4,'B major':5,
+ 'F# major':6,'C# major':7,'F major':-1,'Bb major':-2,'Eb major':-3,
+ 'Ab major':-4,'Db major':-5,'Gb major':-6,'Cb major':-7,
+ 'A minor':0,'E minor':1,'B minor':2,'F# minor':3,'C# minor':4,'G# minor':5,
+ 'D# minor':6,'A# minor':7,'D minor':-1,'G minor':-2,'C minor':-3,
+ 'F minor':-4,'Bb minor':-5,'Eb minor':-6,'Ab minor':-7,
+};
+
+function _escXml(s) {
+ return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+// ── MusicXML 3.1 builder ──────────────────────────────────────────────────────
+function _buildMusicXML(stem, project) {
+ const tempo = project.tempo || 120;
+ const [beatsPerBar, beatUnit] = project.timeSig || [4, 4];
+ const secPerBeat = 60 / tempo;
+ const barSecs = beatsPerBar * secPerBeat;
+ const DIVS = 480;
+
+ const _STEPS = ['C','C','D','D','E','F','F','G','G','A','A','B'];
+ const _ALTER = [0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0];
+
+ function _pitch(midi) {
+ const pc = ((midi % 12) + 12) % 12;
+ return { step: _STEPS[pc], alter: _ALTER[pc], oct: Math.floor(midi / 12) - 1 };
+ }
+
+ function qz(secs) { return Math.round(secs / secPerBeat / 0.25) * 0.25; } // snap to 16th-beat grid
+
+ const notes = [...(stem.midiNotes || [])].sort((a, b) => a.start - b.start);
+ const last = notes[notes.length - 1];
+ const totalBarsXml = last ? Math.ceil(last.end / barSecs) + 1 : 1;
+ const fifths = _KEY_5THS[project.key] ?? 0;
+ const mode = (project.key || '').includes('minor') ? 'minor' : 'major';
+ const clefSign = stem.clef === 'bass' ? 'F' : 'G';
+ const clefLine = stem.clef === 'bass' ? 4 : 2;
+
+ let xml = `\n` +
+ `\n` +
+ `\n` +
+ ` ${_escXml(project.title)}\n` +
+ ` AegisScore` +
+ `${new Date().toISOString().slice(0,10)}\n` +
+ ` ${_escXml(stem.name)}` +
+ `${_escXml(stem.instrument || stem.name)}` +
+ `\n `;
+
+ for (let bar = 0; bar < totalBarsXml; bar++) {
+ const t0 = bar * barSecs, t1 = t0 + barSecs;
+ const bn = notes.filter(n => n.start < t1 && n.end > t0);
+
+ xml += `\n `;
+ if (bar === 0) {
+ xml += `\n ` +
+ `${DIVS}` +
+ `${fifths}${mode}` +
+ `` +
+ `${clefSign}${clefLine}`;
+ xml += `\n ` +
+ `quarter` +
+ `${tempo}` +
+ ``;
+ }
+
+ if (bn.length === 0) {
+ xml += `\n ${DIVS * beatsPerBar}whole`;
+ } else {
+ // Group simultaneous notes into chords (within one 16th of each other)
+ const evts = bn.map(n => ({
+ startB: Math.max(0, Math.min(qz(n.start - t0), beatsPerBar - 0.25)),
+ endB: Math.min(beatsPerBar, Math.max(qz(n.end - t0), 0.25)),
+ pitch: n.pitch,
+ })).filter(e => e.endB > e.startB).sort((a, b) => a.startB - b.startB);
+
+ const chords = [];
+ for (const e of evts) {
+ const prev = chords[chords.length - 1];
+ if (prev && e.startB < prev.startB + 0.125) {
+ prev.pitches.push(e.pitch);
+ prev.endB = Math.max(prev.endB, e.endB);
+ } else {
+ if (prev && prev.endB > e.startB) prev.endB = e.startB;
+ chords.push({ startB: e.startB, endB: e.endB, pitches: [e.pitch] });
+ }
+ }
+ chords.forEach(c => {
+ const uniq = [...new Set(c.pitches)];
+ c.pitches = stem.clef === 'bass' ? [Math.min(...uniq)] : uniq.slice(0, 4);
+ });
+
+ let pos = 0;
+ for (const c of chords) {
+ if (c.startB > pos + 0.124) {
+ const sn = _snapNote(c.startB - pos, beatsPerBar - pos);
+ const d = Math.round(sn.b * DIVS);
+ xml += `\n ${d}` +
+ (sn.dots ? `` : '') + `${sn.t}`;
+ pos = Math.round((pos + sn.b) * 1000) / 1000;
+ }
+ pos = c.startB;
+ const durB = Math.max(0.25, Math.min(c.endB - c.startB, beatsPerBar - pos));
+ const sn = _snapNote(durB, beatsPerBar - pos);
+ const nd = Math.round(sn.b * DIVS);
+ const firstPitch = c.pitches[0];
+ const pp = _pitch(firstPitch);
+ // First note in chord (no tag)
+ xml += `\n ${pp.step}` +
+ (pp.alter ? `${pp.alter}` : '') +
+ `${pp.oct}` +
+ `${nd}1` +
+ (sn.dots ? `` : '') + `${sn.t}`;
+ // Additional pitches in chord (with tag)
+ for (let i = 1; i < c.pitches.length; i++) {
+ const pp2 = _pitch(c.pitches[i]);
+ xml += `\n ${pp2.step}` +
+ (pp2.alter ? `${pp2.alter}` : '') +
+ `${pp2.oct}` +
+ `${nd}1` +
+ (sn.dots ? `` : '') + `${sn.t}`;
+ }
+ pos = Math.round((pos + sn.b) * 1000) / 1000;
+ }
+
+ if (pos < beatsPerBar - 0.124) {
+ const sn = _snapNote(beatsPerBar - pos, beatsPerBar - pos);
+ const d = Math.round(sn.b * DIVS);
+ xml += `\n ${d}` +
+ (sn.dots ? `` : '') + `${sn.t}`;
+ }
+ }
+ xml += '\n ';
+ }
+
+ xml += '\n \n';
+ return xml;
+}
+
+// ── MuseScore 3 native format (.mscz = zip containing .mscx) ─────────────────
+
+const _TPC_SHARP = [14, 21, 16, 23, 18, 13, 20, 15, 22, 17, 24, 19];
+const _TPC_FLAT = [14, 9, 16, 11, 18, 13, 8, 15, 10, 17, 12, 19];
+const _GM_PROG = { bass: 33, other: 40, vocals: 52, guitar: 25, piano: 0 };
+
+// Valid note durations in beats, largest first
+const _NOTE_VALS = [
+ {b:4, t:'whole', dots:0},
+ {b:3, t:'half', dots:1},
+ {b:2, t:'half', dots:0},
+ {b:1.5, t:'quarter', dots:1},
+ {b:1, t:'quarter', dots:0},
+ {b:0.75,t:'eighth', dots:1},
+ {b:0.5, t:'eighth', dots:0},
+ {b:0.375,t:'16th', dots:1},
+ {b:0.25,t:'16th', dots:0},
+ {b:0.125,t:'32nd', dots:0},
+];
+
+// Snap db beats to nearest valid note value ≤ maxB
+function _snapNote(db, maxB = 8) {
+ db = Math.max(0.125, db);
+ const cands = _NOTE_VALS.filter(v => v.b <= maxB + 0.001);
+ if (!cands.length) return _NOTE_VALS[_NOTE_VALS.length - 1];
+ return cands.reduce((best, v) => Math.abs(v.b - db) < Math.abs(best.b - db) ? v : best);
+}
+
+// Decompose a beat-duration into a sequence of valid note values
+function _decompose(db, maxFirstB = 8) {
+ const parts = [];
+ let rem = Math.max(0, Math.round(db * 1000) / 1000);
+ let first = true;
+ for (let guard = 0; rem >= 0.124 && guard < 24; guard++) {
+ const s = _snapNote(rem, first ? maxFirstB : rem);
+ parts.push(s);
+ rem = Math.round((rem - s.b) * 1000) / 1000;
+ first = false;
+ }
+ return parts;
+}
+
+function _buildMscx(stem, project) {
+ const tempo = project.tempo || 120;
+ const [sigN, sigD] = project.timeSig || [4, 4];
+ const BPS = tempo / 60; // beats per second
+ const barSecs = sigN / BPS;
+ const GRID = 0.25; // 16th-note quantisation grid (beats)
+ const fifths = _KEY_5THS[project.key] ?? 0;
+ const TPC = fifths < 0 ? _TPC_FLAT : _TPC_SHARP;
+ const prog = _GM_PROG[stem.id] ?? 0;
+ const clef = stem.clef === 'bass' ? 'F' : 'G';
+
+ function qz(beats) { return Math.round(beats / GRID) * GRID; }
+
+ function chordXml(type, dots, pitches) {
+ let s = ` ${type}`;
+ if (dots) s += `${dots}`;
+ pitches.forEach(p => {
+ const pc = ((p % 12) + 12) % 12;
+ s += `${p}${TPC[pc]}`;
+ });
+ return s + `\n`;
+ }
+
+ function restXml(type, dots) {
+ return ` ${type}${dots ? `${dots}` : ''}\n`;
+ }
+
+ const allNotes = [...(stem.midiNotes || [])].sort((a, b) => a.start - b.start);
+ const last = allNotes[allNotes.length - 1];
+ const totalMeasures = last ? Math.ceil(last.end / barSecs) + 1 : 1;
+
+ let x = `\n\n \n`;
+ x += ` 480\n \n`;
+ x += ` ${_escXml(project.title)}\n`;
+ x += ` \n \n ${_escXml(stem.name)}\n`;
+ x += ` \n ${_escXml(stem.name)}\n`;
+ x += ` \n \n \n`;
+ x += ` \n`;
+
+ for (let m = 0; m < totalMeasures; m++) {
+ const t0 = m * barSecs, t1 = t0 + barSecs;
+ const mn = allNotes.filter(n => n.start < t1 && n.end > t0);
+
+ x += ` \n \n`;
+ if (m === 0) {
+ x += ` ${fifths}\n`;
+ x += ` ${sigN}${sigD}\n`;
+ x += ` ${clef}\n`;
+ x += ` ${(tempo / 60).toFixed(6)}\n`;
+ }
+
+ if (mn.length === 0) {
+ x += ` measure${sigN}/${sigD}\n`;
+ } else {
+ // Convert to beat offsets relative to measure start, quantise to grid
+ const evts = mn.map(n => ({
+ startB: Math.max(0, Math.min(qz((n.start - t0) * BPS), sigN - GRID)),
+ endB: Math.min(sigN, Math.max(qz((n.end - t0) * BPS), GRID)),
+ pitch: n.pitch,
+ })).filter(e => e.endB > e.startB);
+
+ evts.sort((a, b) => a.startB - b.startB);
+
+ // Group simultaneous notes (within GRID/2) into chords, clip overlapping ends
+ const chords = [];
+ for (const e of evts) {
+ const prev = chords[chords.length - 1];
+ if (prev && e.startB < prev.startB + GRID / 2) {
+ prev.pitches.push(e.pitch);
+ prev.endB = Math.max(prev.endB, e.endB);
+ } else {
+ if (prev && prev.endB > e.startB) prev.endB = e.startB;
+ chords.push({ startB: e.startB, endB: e.endB, pitches: [e.pitch] });
+ }
+ }
+
+ // Reduce polyphony: bass → lowest pitch only; others → up to 4 unique pitches
+ chords.forEach(c => {
+ const uniq = [...new Set(c.pitches)];
+ c.pitches = stem.clef === 'bass' ? [Math.min(...uniq)] : uniq.slice(0, 4);
+ });
+
+ // Render chords with proper rests filling all gaps
+ let pos = 0;
+ for (const c of chords) {
+ if (c.startB > pos + GRID / 2) {
+ _decompose(c.startB - pos, sigN - pos).forEach(r => { x += restXml(r.t, r.dots); });
+ }
+ pos = c.startB;
+ const durB = Math.max(GRID, Math.min(c.endB - c.startB, sigN - pos));
+ const sn = _snapNote(durB, sigN - pos);
+ x += chordXml(sn.t, sn.dots, c.pitches);
+ pos += sn.b;
+ pos = Math.round(pos * 1000) / 1000;
+ }
+
+ // Fill tail of measure with rests
+ if (pos < sigN - GRID / 2) {
+ _decompose(sigN - pos, sigN - pos).forEach(r => { x += restXml(r.t, r.dots); });
+ }
+ }
+
+ x += ` \n \n`;
+ }
+
+ x += ` \n \n`;
+ return x;
+}
+
+// CRC-32 for ZIP
+function _crc32(u8arr) {
+ let crc = 0xFFFFFFFF;
+ for (let i = 0; i < u8arr.length; i++) {
+ crc ^= u8arr[i];
+ for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
+ }
+ return (crc ^ 0xFFFFFFFF) >>> 0;
+}
+
+// Pack a single file into a ZIP blob (stored, no compression)
+function _makeMscz(innerFilename, xmlStr) {
+ const enc = new TextEncoder();
+ const nameBytes = enc.encode(innerFilename);
+ const data = enc.encode(xmlStr);
+ const crc = _crc32(data);
+
+ const now = new Date();
+ const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2);
+ const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
+
+ const LFH = 30 + nameBytes.length; // local file header size
+ const CDH = 46 + nameBytes.length; // central directory header size
+ const total = LFH + data.length + CDH + 22;
+
+ const buf = new ArrayBuffer(total);
+ const v = new DataView(buf);
+ const u8 = new Uint8Array(buf);
+ let p = 0;
+
+ const w32 = n => { v.setUint32(p, n, true); p += 4; };
+ const w16 = n => { v.setUint16(p, n, true); p += 2; };
+ const wB = arr => { u8.set(arr, p); p += arr.length; };
+
+ // Local file header
+ w32(0x04034B50); w16(20); w16(0); w16(0); // sig, ver, flags, method=stored
+ w16(dosTime); w16(dosDate);
+ w32(crc); w32(data.length); w32(data.length);
+ w16(nameBytes.length); w16(0);
+ wB(nameBytes); wB(data);
+
+ const cdOff = p; // start of central directory
+
+ // Central directory header
+ w32(0x02014B50); w16(20); w16(20); w16(0); w16(0);
+ w16(dosTime); w16(dosDate);
+ w32(crc); w32(data.length); w32(data.length);
+ w16(nameBytes.length); w16(0); w16(0); w16(0); w16(0);
+ w32(0); w32(0); // external attrs, local header offset
+ wB(nameBytes);
+
+ const cdSize = p - cdOff;
+
+ // End of central directory
+ w32(0x06054B50); w16(0); w16(0); w16(1); w16(1);
+ w32(cdSize); w32(cdOff); w16(0);
+
+ return new Blob([buf], { type: 'application/octet-stream' });
+}
+
+// ── Download helper ───────────────────────────────────────────────────────────
+function _downloadBlob(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = filename;
+ document.body.appendChild(a); a.click();
+ setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 1000);
+}
+
+// ── Public export entry point ─────────────────────────────────────────────────
+async function exportScore(fmt) {
+ document.getElementById('dd-file')?.classList.remove('open');
+ const p = typeof P === 'function' ? P() : null;
+ if (!p) { alert('No project open.'); return; }
+ const stemId = document.getElementById('exp-stem-sel')?.value || p.stems.find(s => s.id !== 'drums')?.id;
+ const stem = p.stems.find(s => s.id === stemId);
+ if (!stem) return;
+
+ if (stem.id === 'drums' || stem.name.toLowerCase() === 'drums') {
+ alert('Drums track has no pitched MIDI data — choose a different track.'); return;
+ }
+
+ // Lazy-load if midiNotes was never initialized (undefined = never attempted)
+ if (stem.midiNotes === undefined) {
+ const sid = typeof STATE !== 'undefined' ? STATE.liveSid : null;
+ if (sid && stem.midi_url) {
+ try {
+ const r = await fetch(`/api/midi/${sid}/${stem.id}`);
+ if (r.ok) { const d = await r.json(); stem.midiNotes = d.notes || []; }
+ else stem.midiNotes = [];
+ } catch(_) { stem.midiNotes = []; }
+ } else {
+ stem.midiNotes = [];
+ }
+ }
+ // midiNotes is now always defined — [] means no detected notes (export with rests)
+ if (stem.midiNotes === undefined) {
+ alert(`Cannot export "${stem.name}" — process a song first.`); return;
+ }
+
+ if (fmt === 'musicxml') {
+ const xml = _buildMusicXML(stem, p);
+ _downloadBlob(
+ new Blob([xml], { type: 'application/vnd.recordare.musicxml+xml' }),
+ `${p.title} - ${stem.name}.musicxml`
+ );
+
+ } else if (fmt === 'musescore') {
+ const mscx = _buildMscx(stem, p);
+ const safeName = `${p.title} - ${stem.name}`.replace(/[/\\:*?"<>|]/g, '_');
+ const blob = _makeMscz(`${safeName}.mscx`, mscx);
+ _downloadBlob(blob, `${safeName}.mscz`);
+
+ } else if (fmt === 'pdf') {
+ const row = [...document.querySelectorAll('#score-systems .staff-row')]
+ .find(r => r.dataset.stem === stemId);
+ const liveSvg = row?.querySelector('svg');
+
+ let svgStr;
+ if (liveSvg) {
+ const clone = liveSvg.cloneNode(true);
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ bg.setAttribute('x', 0); bg.setAttribute('y', 0);
+ bg.setAttribute('width', clone.getAttribute('width'));
+ bg.setAttribute('height', clone.getAttribute('height'));
+ bg.setAttribute('fill', '#ffffff');
+ clone.insertBefore(bg, clone.firstChild);
+ svgStr = new XMLSerializer().serializeToString(clone);
+ }
+
+ const stemInfo = `${stem.name} · ${_escXml(p.key)} · ${p.tempo} BPM · ${(p.timeSig || [4,4]).join('/')}`;
+ const barLabel = `Bars ${(p.barOffset || 0) + 1}–${(p.barOffset || 0) + p.bars}`;
+ const imgTag = svgStr
+ ? `
`
+ : 'Score not rendered.
';
+
+ const pw = window.open('', '_blank', 'width=960,height=720');
+ if (!pw) { alert('Pop-up blocked — allow pop-ups for PDF export'); return; }
+ pw.document.write(`
+ ${_escXml(p.title)} — ${_escXml(stem.name)}
+
+
+ ${_escXml(p.title)}
+ ${stemInfo}
+ ${barLabel}
+ ${imgTag}
+
+ `);
+ pw.document.close();
+ }
+}
diff --git a/js/notation.js b/js/notation.js
index 2fb3074..c4445be 100644
--- a/js/notation.js
+++ b/js/notation.js
@@ -125,6 +125,11 @@ function drawNote(g, x, n, clef, color, beats){
const nhW = 5.4, nhH = 3.9;
// ledger lines
drawLedgers(g, x, n.dia, clef);
+ // accidental (sharp ♯ / flat ♭ / natural ♮) — drawn before notehead
+ if (n.acc) {
+ const accGlyph = n.acc === 'sharp' ? SMUFL.sharp : n.acc === 'flat' ? SMUFL.flat : SMUFL.natural;
+ el("text",{x:x-10,y:y+3.5,class:"smufl",fill:"var(--t-0)","font-size":17,"opacity":"0.9"},g).textContent = accGlyph;
+ }
if (n.v === 1){ // whole note
el("ellipse",{cx:x,cy:y,rx:nhW,ry:nhH,fill:"none",stroke:color,"stroke-width":1.7,transform:`rotate(-12 ${x} ${y})`},g);
} else {
@@ -183,8 +188,45 @@ function drawFlagOrBeam(g, group, color){
}
// ---- MIDI → notation helpers ----
-const _CHROM_DIA = [0,0,1,1,2,3,3,4,4,5,5,6]; // chromatic step → diatonic (C=0..B=6)
-function midiToDia(pitch){ return Math.floor(pitch/12)*7 + _CHROM_DIA[pitch%12]; }
+
+// [diaStep(0-6), alteration(-1/0/1)] for each pitch class — two spellings
+const _SPELL_SHARP = [[0,0],[0,1],[1,0],[1,1],[2,0],[3,0],[3,1],[4,0],[4,1],[5,0],[5,1],[6,0]];
+const _SPELL_FLAT = [[0,0],[1,-1],[1,0],[2,-1],[2,0],[3,0],[4,-1],[4,0],[5,-1],[5,0],[6,-1],[6,0]];
+const _STEPS = 'CDEFGAB';
+
+// Returns {dia, acc: 'sharp'|'flat'|'natural'|null}
+// Chooses enharmonic spelling (C#/Db etc.) based on key signature, then compares
+// the note's actual alteration to what the key sig implies for that diatonic step.
+function _midiNoteInfo(pitch, ks) {
+ const pc = ((pitch % 12) + 12) % 12;
+ const oct = Math.floor(pitch / 12) - 1;
+ const ksSharps = (ks && ks.sharps) || [];
+ const ksFlats = (ks && ks.flats) || [];
+ const isAltered = !!_SPELL_SHARP[pc][1]; // true for C#/Db, D#/Eb, F#/Gb, G#/Ab, A#/Bb
+
+ let diaStep, noteAcc;
+ if (!isAltered) {
+ diaStep = _SPELL_SHARP[pc][0]; noteAcc = 0;
+ } else {
+ const sharpBase = _STEPS[_SPELL_SHARP[pc][0]]; // e.g. 'C' for C#
+ const flatBase = _STEPS[_SPELL_FLAT[pc][0]]; // e.g. 'D' for Db
+ if (ksSharps.includes(sharpBase)) { diaStep=_SPELL_SHARP[pc][0]; noteAcc=1; }
+ else if (ksFlats.includes(flatBase)) { diaStep=_SPELL_FLAT[pc][0]; noteAcc=-1; }
+ else if (ksFlats.length > 0) { diaStep=_SPELL_FLAT[pc][0]; noteAcc=-1; }
+ else { diaStep=_SPELL_SHARP[pc][0]; noteAcc=1; }
+ }
+
+ const stepName = _STEPS[diaStep];
+ const keySigAcc = ksSharps.includes(stepName) ? 1 : ksFlats.includes(stepName) ? -1 : 0;
+ const displayAcc = noteAcc === keySigAcc ? null
+ : noteAcc === 0 ? 'natural' : noteAcc === 1 ? 'sharp' : 'flat';
+
+ return { dia: oct * 7 + diaStep, acc: displayAcc };
+}
+
+// Keep for any legacy callers
+function midiToDia(pitch){ return _midiNoteInfo(pitch, null).dia; }
+
function _qz(b, grid=0.25){ return Math.round(b/grid)*grid; }
function _beatDurToV(db){
if(db>=3.5) return {v:1,dot:false};
@@ -197,43 +239,65 @@ function _beatDurToV(db){
return {v:16,dot:false};
}
-function buildMeasuresFromMidi(midiNotes, beats, bars, tempo){
+function buildMeasuresFromMidi(midiNotes, beats, bars, tempo, barOffset, keySig){
const bps = tempo / 60;
- const SLOTS = beats * 4;
+ const off = (barOffset || 0) * beats;
+ // 8th-note grid: notes 0.5 beats apart minimum → ~17px spacing, no visual collision
+ const GRID = 0.5;
+ const SLOTS = beats * 2;
+ const MIN_REST = 1.0; // suppress rests shorter than a quarter note
+
const events = midiNotes
.filter(n => !n.drum)
- .map(n => ({
- beatStart: _qz(n.start * bps),
- beatDur: Math.max(0.25, _qz((n.end - n.start) * bps)),
- dia: midiToDia(n.pitch)
- }))
- .filter(n => n.beatStart < bars * beats);
+ .map(n => {
+ const info = _midiNoteInfo(n.pitch, keySig);
+ return {
+ beatStart: _qz(n.start * bps, GRID) - off,
+ beatDur: Math.max(GRID, _qz((n.end - n.start) * bps, GRID)),
+ dia: info.dia,
+ acc: info.acc,
+ };
+ })
+ .filter(n => n.beatStart >= 0 && n.beatStart < bars * beats);
const grid = Array.from({length:bars}, ()=>Array(SLOTS).fill(null));
events.forEach(n => {
const bar = Math.floor(n.beatStart / beats);
if(bar < 0 || bar >= bars) return;
- const slot = Math.round((n.beatStart - bar*beats) * 4);
+ const slot = Math.round((n.beatStart - bar*beats) / GRID);
if(slot < 0 || slot >= SLOTS) return;
+ // keep highest pitch per slot (most audible note wins)
if(!grid[bar][slot] || n.dia > grid[bar][slot].dia)
- grid[bar][slot] = { dia:n.dia, beatInBar:n.beatStart-bar*beats, beatDur:n.beatDur };
+ grid[bar][slot] = { dia:n.dia, acc:n.acc, beatDur:n.beatDur };
});
return grid.map(row => {
const notes = [];
let pos = 0;
+ const barAccShown = new Set();
for(let s=0; s bp+0.001) continue;
+ const bp = s * GRID;
+ if(pos > bp + 0.001) continue;
const evt = row[s];
if(evt){
- if(bp > pos+0.001){ const rv=_beatDurToV(bp-pos); notes.push({dia:34,v:rv.v,dot:rv.dot,beats:bp-pos,rest:true,onset:pos}); }
- const nv=_beatDurToV(evt.beatDur); const ad=Math.min(evt.beatDur,beats-bp);
- notes.push({dia:evt.dia,v:nv.v,dot:nv.dot,beats:ad,rest:false,onset:bp});
- pos=bp+ad;
+ // only insert rest if the gap is big enough to show without overlap
+ if(bp > pos + 0.001){
+ const gap = bp - pos;
+ if(gap >= MIN_REST){ const rv=_beatDurToV(gap); notes.push({dia:34,v:rv.v,dot:rv.dot,beats:gap,rest:true,onset:pos}); }
+ }
+ const nv=_beatDurToV(evt.beatDur); const ad=Math.min(evt.beatDur, beats-bp);
+ const accKey = evt.dia % 7;
+ const showAcc = evt.acc && !barAccShown.has(accKey) ? evt.acc : null;
+ if (evt.acc) barAccShown.add(accKey);
+ notes.push({dia:evt.dia, acc:showAcc, v:nv.v, dot:nv.dot, beats:ad, rest:false, onset:bp});
+ pos = bp + ad;
}
}
- if(pos < beats-0.001){ const rv=_beatDurToV(beats-pos); notes.push({dia:34,v:rv.v,dot:rv.dot,beats:beats-pos,rest:true,onset:pos}); }
+ // trailing rest: only if remainder >= 1 beat
+ if(pos < beats - 0.001){
+ const gap = beats - pos;
+ if(gap >= MIN_REST){ const rv=_beatDurToV(gap); notes.push({dia:34,v:rv.v,dot:rv.dot,beats:gap,rest:true,onset:pos}); }
+ }
return notes.length ? notes : [{dia:34,v:1,dot:false,beats:beats,rest:true,onset:0}];
});
}
@@ -244,10 +308,18 @@ function renderStaff(stem, project, opts){
const bars = project.bars;
const clef = stem.clef === "perc" ? "treble" : stem.clef;
const mW = beats === 3 ? 132 : 168;
- const firstPad = 78; // clef + key + time
const notePadL = 16, notePadR = 14;
+
+ // pre-compute header width so firstPad is always wide enough
+ // clef ends ~x42; key sig: 8.5px/sharp, 8px/flat; time sig: 24px + gap
+ const ks = KEYSIG[project.key] || {sharps:[],flats:[]};
+ const kEndX = 44 + ks.sharps.length * 8.5 + ks.flats.length * 8;
+ const tsX = Math.max(kEndX + 6, 60);
+ const firstPad = Math.max(78, Math.ceil(tsX + 28)); // 28 = time sig glyph width + gap
+
const totalW = firstPad + mW*bars + 8;
- const height = 96;
+ // bass clef needs extra vertical room (E1 open string lands at y≈107)
+ const height = clef === 'bass' ? 148 : 96;
const svg = el("svg",{ width: totalW, height, viewBox:`0 0 ${totalW} ${height}`, class:"staff-svg" });
const css = getComputedStyle(document.documentElement);
const color = css.getPropertyValue(stem.color).trim() || "#9ab";
@@ -262,12 +334,8 @@ function renderStaff(stem, project, opts){
el("text",{x:10,y:clefGy,class:"smufl",fill:"var(--t-1)","font-size":34},svg).textContent = clefGlyph;
// key signature (sharps/flats)
- const ks = KEYSIG[project.key] || {sharps:[],flats:[]};
let kx = 44;
- const sharpRowDia = { F: stem.clef==="bass"?dabs("F",3):dabs("F",5), C: stem.clef==="bass"?dabs("C",3):dabs("C",5),
- G: stem.clef==="bass"?dabs("G",3):dabs("G",5) };
const accDia = (name)=>{
- // place near top of staff in correct octave for clef
const base = clef==="bass"? {F:dabs("F",3),C:dabs("C",3),G:dabs("G",3),A:dabs("A",2),E:dabs("E",3),B:dabs("B",2),D:dabs("D",3)}
: clef==="alto"? {F:dabs("F",4),C:dabs("C",4),G:dabs("G",4),A:dabs("A",3),E:dabs("E",4),B:dabs("B",3),D:dabs("D",4)}
: {F:dabs("F",5),C:dabs("C",5),G:dabs("G",5),A:dabs("A",4),E:dabs("E",5),B:dabs("B",4),D:dabs("D",5)};
@@ -278,10 +346,9 @@ function renderStaff(stem, project, opts){
ks.flats.forEach(s=>{ const y=yForDia(accDia(s),clef); el("text",{x:kx,y:y+3.2,class:"smufl",fill:"var(--t-1)","font-size":22},svg).textContent=SMUFL.flat; kx+=8; });
}
- // time signature
- const tsx = Math.max(kx+6, 60);
- el("text",{x:tsx,y:STAFF_TOP+SP*1.05+1,class:"smufl ts",fill:"var(--t-0)","font-size":24,"text-anchor":"middle"},svg).textContent = tsNum(project.timeSig[0]);
- el("text",{x:tsx,y:STAFF_TOP+SP*3.05+1,class:"smufl ts",fill:"var(--t-0)","font-size":24,"text-anchor":"middle"},svg).textContent = tsNum(project.timeSig[1]);
+ // time signature (use pre-computed tsX so it always lands before firstPad)
+ el("text",{x:tsX,y:STAFF_TOP+SP*1.05+1,class:"smufl ts",fill:"var(--t-0)","font-size":24,"text-anchor":"middle"},svg).textContent = tsNum(project.timeSig[0]);
+ el("text",{x:tsX,y:STAFF_TOP+SP*3.05+1,class:"smufl ts",fill:"var(--t-0)","font-size":24,"text-anchor":"middle"},svg).textContent = tsNum(project.timeSig[1]);
// barlines
for (let b=0;b<=bars;b++){ const x=firstPad+b*mW; el("line",{x1:x,y1:STAFF_TOP,x2:x,y2:STAFF_TOP+4*SP,stroke:b===bars?"var(--line-strong)":"var(--line)","stroke-width":b===bars?2:1},svg); }
@@ -294,7 +361,7 @@ function renderStaff(stem, project, opts){
}
const measures = (stem.midiNotes && stem.midiNotes.length > 0)
- ? buildMeasuresFromMidi(stem.midiNotes, beats, bars, project.tempo || 120)
+ ? buildMeasuresFromMidi(stem.midiNotes, beats, bars, project.tempo || 120, project.barOffset || 0, ks)
: genNotes(stem, beats, bars, rnd);
const noteG = el("g",{},svg);
diff --git a/js/waveform.js b/js/waveform.js
index 629d0ae..9d8f44b 100644
--- a/js/waveform.js
+++ b/js/waveform.js
@@ -50,12 +50,22 @@ function drawWaveform(canvas, stem, opts){
}
samples[i] = Math.max(0.02, Math.min(1, v));
}
- // fill waveform
- ctx.globalAlpha = stem.muted ? 0.28 : 1;
+ // fill waveform — real peaks take priority over procedural
ctx.fillStyle = col;
ctx.beginPath();
- for (let i=0;i 0) {
+ const P = opts.peaks, Pn = P.length;
+ ctx.globalAlpha = stem.muted ? 0.28 : 0.92;
+ for (let i = 0; i < W; i++) {
+ const pi = Math.min(Pn - 1, Math.floor(i / W * Pn));
+ const a = P[pi] * maxA;
+ ctx.rect(i, mid - a, 1.4, a * 2);
+ }
+ } else {
+ ctx.globalAlpha = stem.muted ? 0.28 : 1;
+ for (let i=0;i {
+ const req = http.get(`http://127.0.0.1:${PORT}/`, res => { res.resume(); resolve(true); });
+ req.on('error', () => resolve(false));
+ req.setTimeout(500, () => { req.destroy(); resolve(false); });
+ });
+}
+
+async function startBackend() {
+ if (await portInUse()) {
+ console.log(`[main] port ${PORT} already in use — reusing existing server`);
+ return;
+ }
+ let spawnCmd, spawnArgs, spawnCwd;
+ if (app.isPackaged) {
+ // Use the bundled server_launch.exe produced by PyInstaller
+ spawnCmd = path.join(process.resourcesPath, 'server_dist', 'server_launch.exe');
+ spawnArgs = [];
+ spawnCwd = path.join(process.resourcesPath, 'server_dist');
+ } else {
+ spawnCmd = process.platform === 'win32' ? 'python' : 'python3';
+ spawnArgs = ['-m', 'uvicorn', 'server:app', `--port=${PORT}`, '--log-level=warning'];
+ spawnCwd = __dirname;
+ }
+ server = spawn(spawnCmd, spawnArgs, {
+ cwd: spawnCwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+ server.stdout.on('data', d => process.stdout.write('[server] ' + d));
+ server.stderr.on('data', d => process.stderr.write('[server] ' + d));
+ server.on('error', err => {
+ dialog.showErrorBox('Backend error',
+ `Could not start Python server:\n${err.message}\n\n` +
+ `Make sure Python is on PATH and run:\n pip install -r requirements.txt`);
+ app.quit();
+ });
+}
+
+function killBackend() {
+ if (!server) return;
+ const pid = server.pid;
+ // Destroy open pipes so Node stops tracking this child (prevents exit hang)
+ try { server.stdout.destroy(); } catch (_) {}
+ try { server.stderr.destroy(); } catch (_) {}
+ server.unref();
+ server = null;
+ if (process.platform === 'win32') {
+ const tk = spawn('taskkill', ['/pid', pid, '/f', '/t'], { windowsHide: true });
+ tk.unref();
+ } else {
+ try { process.kill(pid, 'SIGTERM'); } catch (_) {}
+ }
+}
+
+// Poll until the server responds (max 60 s)
+function waitForServer() {
+ return new Promise((resolve, reject) => {
+ let attempts = 0;
+ const check = () => {
+ const req = http.get(`http://127.0.0.1:${PORT}/`, res => {
+ res.resume();
+ resolve();
+ });
+ req.on('error', () => {
+ if (++attempts >= 60) return reject(new Error('Server did not start within 60 s'));
+ setTimeout(check, 1000);
+ });
+ req.setTimeout(800, () => req.destroy());
+ };
+ check();
+ });
+}
+
+// ── Window ────────────────────────────────────────────────────────────────────
+async function createWindow() {
+ await startBackend();
+
+ // Show a splash while waiting
+ const splash = new BrowserWindow({
+ width: 380, height: 220,
+ frame: false, alwaysOnTop: true, resizable: false,
+ backgroundColor: '#0a0a0c',
+ webPreferences: { nodeIntegration: false },
+ });
+ splash.loadFile(path.join(__dirname, 'assets', 'splash.html'));
+
+ try {
+ await waitForServer();
+ } catch (err) {
+ splash.close();
+ dialog.showErrorBox('Failed to start', err.message);
+ app.quit();
+ return;
+ }
+
+ win = new BrowserWindow({
+ width: 1440,
+ height: 900,
+ minWidth: 1100,
+ minHeight: 720,
+ frame: false, // frameless — top bar acts as drag region
+ backgroundColor: '#0a0a0c',
+ show: false,
+ webPreferences: {
+ nodeIntegration: false,
+ contextIsolation: true,
+ webSecurity: false,
+ preload: path.join(__dirname, 'preload.js'),
+ },
+ });
+
+ win.loadURL(`http://127.0.0.1:${PORT}/`);
+
+ win.once('ready-to-show', () => {
+ splash.close();
+ win.show();
+ // inject drag region + window controls CSS/JS after load
+ win.webContents.insertCSS(`
+ header.top { -webkit-app-region: drag; }
+ header.top button,
+ header.top input,
+ header.top .meter,
+ header.top .omnibox { -webkit-app-region: no-drag; }
+ .win-controls {
+ display: flex; align-items: center; gap: 6px;
+ -webkit-app-region: no-drag; padding: 0 8px;
+ }
+ .win-btn {
+ width: 12px; height: 12px; border-radius: 50%;
+ border: none; cursor: pointer; outline: none;
+ }
+ .win-btn.close { background: #ff5f57; }
+ .win-btn.min { background: #febc2e; }
+ .win-btn.max { background: #28c840; }
+ `);
+ win.webContents.executeJavaScript(`
+ (function() {
+ const ctrl = document.createElement('div');
+ ctrl.className = 'win-controls';
+ ctrl.innerHTML =
+ '