From fbe17e5f8dcdb609bcaba71b027c6f85f4b30587 Mon Sep 17 00:00:00 2001 From: ARNAV AGGARWAL Date: Tue, 9 Jun 2026 04:33:08 +0800 Subject: [PATCH 1/6] feat: scaffold PySide6/QML desktop app structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.py: PySide6 entry, embedded FastAPI on background thread - backend/api/: FastAPI app factory, routers split by concern - backend/models/schemas.py: Pydantic v2 typed contracts - backend/bridge.py: QObject bridge QML↔FastAPI via Qt signals/slots - backend/services/pipeline.py: extracted pipeline from monolithic server.py - frontend/qml/: MainWindow, ScoreView, ProcessingView, Sidebar, Inspector, OmniBar - pyproject.toml: project manifest + dev deps Web prototype preserved on master branch. Co-Authored-By: Claude Sonnet 4.6 --- assets/fonts/.gitkeep | 0 assets/icons/.gitkeep | 0 backend/__init__.py | 0 backend/api/__init__.py | 0 backend/api/app.py | 31 +++++ backend/api/routers/.gitkeep | 0 backend/api/routers/__init__.py | 0 backend/api/routers/analyze.py | 20 +++ backend/api/routers/midi_notes.py | 28 ++++ backend/api/routers/status.py | 15 +++ backend/api/routers/upload.py | 22 ++++ backend/bridge.py | 118 +++++++++++++++++ backend/models/.gitkeep | 0 backend/models/__init__.py | 0 backend/models/schemas.py | 84 ++++++++++++ backend/services/.gitkeep | 0 backend/services/__init__.py | 0 backend/services/pipeline.py | 183 ++++++++++++++++++++++++++ core/__init__.py | 0 core/audio/.gitkeep | 0 core/midi/.gitkeep | 0 core/notation/.gitkeep | 0 frontend/qml/components/.gitkeep | 0 frontend/qml/components/Inspector.qml | 58 ++++++++ frontend/qml/components/OmniBar.qml | 43 ++++++ frontend/qml/components/Sidebar.qml | 40 ++++++ frontend/qml/dialogs/.gitkeep | 0 frontend/qml/views/.gitkeep | 0 frontend/qml/views/EmptyView.qml | 24 ++++ frontend/qml/views/ErrorView.qml | 13 ++ frontend/qml/views/MainWindow.qml | 114 ++++++++++++++++ frontend/qml/views/ProcessingView.qml | 68 ++++++++++ frontend/qml/views/ScoreView.qml | 69 ++++++++++ main.py | 52 ++++++++ models/.gitkeep | 0 plugins/.gitkeep | 0 pyproject.toml | 47 +++++++ scripts/.gitkeep | 0 tests/integration/.gitkeep | 0 tests/unit/.gitkeep | 0 40 files changed, 1029 insertions(+) create mode 100644 assets/fonts/.gitkeep create mode 100644 assets/icons/.gitkeep create mode 100644 backend/__init__.py create mode 100644 backend/api/__init__.py create mode 100644 backend/api/app.py create mode 100644 backend/api/routers/.gitkeep create mode 100644 backend/api/routers/__init__.py create mode 100644 backend/api/routers/analyze.py create mode 100644 backend/api/routers/midi_notes.py create mode 100644 backend/api/routers/status.py create mode 100644 backend/api/routers/upload.py create mode 100644 backend/bridge.py create mode 100644 backend/models/.gitkeep create mode 100644 backend/models/__init__.py create mode 100644 backend/models/schemas.py create mode 100644 backend/services/.gitkeep create mode 100644 backend/services/__init__.py create mode 100644 backend/services/pipeline.py create mode 100644 core/__init__.py create mode 100644 core/audio/.gitkeep create mode 100644 core/midi/.gitkeep create mode 100644 core/notation/.gitkeep create mode 100644 frontend/qml/components/.gitkeep create mode 100644 frontend/qml/components/Inspector.qml create mode 100644 frontend/qml/components/OmniBar.qml create mode 100644 frontend/qml/components/Sidebar.qml create mode 100644 frontend/qml/dialogs/.gitkeep create mode 100644 frontend/qml/views/.gitkeep create mode 100644 frontend/qml/views/EmptyView.qml create mode 100644 frontend/qml/views/ErrorView.qml create mode 100644 frontend/qml/views/MainWindow.qml create mode 100644 frontend/qml/views/ProcessingView.qml create mode 100644 frontend/qml/views/ScoreView.qml create mode 100644 main.py create mode 100644 models/.gitkeep create mode 100644 plugins/.gitkeep create mode 100644 pyproject.toml create mode 100644 scripts/.gitkeep create mode 100644 tests/integration/.gitkeep create mode 100644 tests/unit/.gitkeep diff --git a/assets/fonts/.gitkeep b/assets/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/icons/.gitkeep b/assets/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/app.py b/backend/api/app.py new file mode 100644 index 0000000..e78a341 --- /dev/null +++ b/backend/api/app.py @@ -0,0 +1,31 @@ +"""FastAPI app factory — same pipeline logic as web prototype, now IPC-only.""" +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +from backend.api.routers import analyze, upload, status, midi_notes + + +def create_app() -> FastAPI: + app = FastAPI(title="AegisScore-Desktop", version="0.1.0") + + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:7471", "app://aegis"], + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(analyze.router, prefix="/api") + app.include_router(upload.router, prefix="/api") + app.include_router(status.router, prefix="/api") + app.include_router(midi_notes.router, prefix="/api") + + work_dir = Path("work") + work_dir.mkdir(exist_ok=True) + app.mount("/work", StaticFiles(directory=str(work_dir)), name="work") + + return app diff --git a/backend/api/routers/.gitkeep b/backend/api/routers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/routers/__init__.py b/backend/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/routers/analyze.py b/backend/api/routers/analyze.py new file mode 100644 index 0000000..fbd7c53 --- /dev/null +++ b/backend/api/routers/analyze.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import uuid +from fastapi import APIRouter, BackgroundTasks +from backend.models.schemas import AnalyzeRequest, JobState, JobStatus +from backend.services.pipeline import JOBS, URL_CACHE, pipeline_url + +router = APIRouter() + + +@router.post("/analyze") +async def analyze(req: AnalyzeRequest, bg: BackgroundTasks): + url = req.url.strip() + cached = URL_CACHE.get(url) + if cached and JOBS.get(cached, {}).get("status") == JobStatus.DONE: + return {"session_id": cached, "cached": True} + sid = str(uuid.uuid4()) + JOBS[sid] = JobState().model_dump() + bg.add_task(pipeline_url, sid, url) + return {"session_id": sid} diff --git a/backend/api/routers/midi_notes.py b/backend/api/routers/midi_notes.py new file mode 100644 index 0000000..bfdbbcb --- /dev/null +++ b/backend/api/routers/midi_notes.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from backend.models.schemas import MidiResponse +from backend.services.pipeline import WORK_DIR + +router = APIRouter() + + +@router.get("/midi/{sid}/{stem_name}", response_model=MidiResponse) +async def get_midi_notes(sid: str, stem_name: str): + midi_path = WORK_DIR / sid / "midi" / f"{stem_name}.mid" + if not midi_path.exists(): + raise HTTPException(404, "MIDI not found") + import pretty_midi + pm = pretty_midi.PrettyMIDI(str(midi_path)) + notes = [] + for inst in pm.instruments: + for note in inst.notes: + notes.append({ + "pitch": note.pitch, + "start": round(float(note.start), 4), + "end": round(float(note.end), 4), + "velocity": note.velocity, + "drum": bool(inst.is_drum), + }) + notes.sort(key=lambda n: n["start"]) + return {"notes": notes, "end_time": round(float(pm.get_end_time()), 2)} diff --git a/backend/api/routers/status.py b/backend/api/routers/status.py new file mode 100644 index 0000000..eedc7ab --- /dev/null +++ b/backend/api/routers/status.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse +from backend.services.pipeline import JOBS + +router = APIRouter() + + +@router.get("/status/{sid}") +async def status(sid: str): + job = JOBS.get(sid) + if not job: + raise HTTPException(404, "session not found") + return JSONResponse(job) diff --git a/backend/api/routers/upload.py b/backend/api/routers/upload.py new file mode 100644 index 0000000..1d9048f --- /dev/null +++ b/backend/api/routers/upload.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import uuid +from pathlib import Path +from fastapi import APIRouter, BackgroundTasks, UploadFile, File +from backend.models.schemas import JobState +from backend.services.pipeline import JOBS, WORK_DIR, pipeline_file + +router = APIRouter() + + +@router.post("/upload") +async def upload(bg: BackgroundTasks, file: UploadFile = File(...)): + sid = str(uuid.uuid4()) + job_dir = WORK_DIR / sid + job_dir.mkdir(parents=True, exist_ok=True) + suffix = Path(file.filename or "audio.wav").suffix or ".wav" + dest = job_dir / f"audio{suffix}" + dest.write_bytes(await file.read()) + JOBS[sid] = JobState().model_dump() + bg.add_task(pipeline_file, sid, dest, file.filename or "audio") + return {"session_id": sid} diff --git a/backend/bridge.py b/backend/bridge.py new file mode 100644 index 0000000..4944362 --- /dev/null +++ b/backend/bridge.py @@ -0,0 +1,118 @@ +""" +AppBridge — Qt object exposed to QML via setContextProperty. +QML calls methods here; signals fire back to QML reactively. +All heavy work delegated to FastAPI backend via HTTP (localhost). +""" +from __future__ import annotations + +import json +import urllib.request +import urllib.parse +from PySide6.QtCore import QObject, Signal, Slot, Property, QTimer + + +BACKEND = "http://127.0.0.1:7471" + + +def _post(path: str, payload: dict) -> dict: + data = json.dumps(payload).encode() + req = urllib.request.Request( + BACKEND + path, data=data, + headers={"Content-Type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()) + + +def _get(path: str) -> dict: + with urllib.request.urlopen(BACKEND + path, timeout=10) as r: + return json.loads(r.read()) + + +class AppBridge(QObject): + # Signals → QML + sessionStarted = Signal(str) # session_id + progressChanged = Signal(float, int, str) # progress, stage, status + analysisReady = Signal("QVariant") # full job dict + errorOccurred = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._sid: str | None = None + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(800) + self._poll_timer.timeout.connect(self._poll) + + # ── QML-callable slots ──────────────────────────────────────────────────── + + @Slot(str) + def analyzeUrl(self, url: str) -> None: + try: + resp = _post("/api/analyze", {"url": url.strip()}) + self._sid = resp["session_id"] + self.sessionStarted.emit(self._sid) + self._poll_timer.start() + except Exception as e: + self.errorOccurred.emit(str(e)) + + @Slot(str) + def openLocalFile(self, path: str) -> None: + """QML passes native file path; bridge uploads to backend.""" + import urllib.request + # multipart upload + boundary = "AegisBoundary42" + with open(path, "rb") as f: + file_data = f.read() + filename = path.split("/")[-1].split("\\")[-1] + body = ( + f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; " + f"filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n" + ).encode() + file_data + f"\r\n--{boundary}--\r\n".encode() + req = urllib.request.Request( + BACKEND + "/api/upload", data=body, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as r: + resp = json.loads(r.read()) + self._sid = resp["session_id"] + self.sessionStarted.emit(self._sid) + self._poll_timer.start() + except Exception as e: + self.errorOccurred.emit(str(e)) + + @Slot(str, str, result="QVariant") + def getMidiNotes(self, sid: str, stem_id: str) -> dict: + try: + return _get(f"/api/midi/{sid}/{stem_id}") + except Exception: + return {"notes": [], "end_time": 0.0} + + @Slot() + def cancelSession(self) -> None: + self._poll_timer.stop() + self._sid = None + + # ── Polling ─────────────────────────────────────────────────────────────── + + def _poll(self) -> None: + if not self._sid: + self._poll_timer.stop() + return + try: + job = _get(f"/api/status/{self._sid}") + except Exception: + return + self.progressChanged.emit( + job.get("progress", 0.0), + job.get("stage", -1), + job.get("status", ""), + ) + if job.get("status") in ("done", "error"): + self._poll_timer.stop() + if job["status"] == "done": + job["session_id"] = self._sid + self.analysisReady.emit(job) + else: + self.errorOccurred.emit(job.get("error") or "Unknown error") diff --git a/backend/models/.gitkeep b/backend/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/schemas.py b/backend/models/schemas.py new file mode 100644 index 0000000..e7bac27 --- /dev/null +++ b/backend/models/schemas.py @@ -0,0 +1,84 @@ +"""Pydantic v2 schemas — shared contracts between FastAPI and QML bridge.""" +from __future__ import annotations + +from enum import StrEnum +from typing import Any +from pydantic import BaseModel, Field + + +class JobStatus(StrEnum): + QUEUED = "queued" + DOWNLOADING = "downloading" + CONVERTING = "converting" + SEPARATING = "separating" + IDENTIFYING = "identifying" + TRANSCRIBING= "transcribing" + ENGRAVING = "engraving" + DONE = "done" + ERROR = "error" + + +class GpuInfo(BaseModel): + model: str = "CPU" + util: float = 0.0 + vram: float = 0.0 + vram_total: float = 0.0 + + +class StemSchema(BaseModel): + id: str + name: str + instrument: str + family: str + clef: str + color: str + role: str + confidence: float + midi: int + audio_url: str + midi_url: str | None + density: int = 2 + seed: int = 0 + note: str = "" + + +class JobMeta(BaseModel): + title: str = "" + artist: str = "" + duration: float = 0.0 + thumb: str = "" + source: str = "" + tempo: int = 120 + key: str = "A minor" + time_sig: list[int] = Field(default_factory=lambda: [4, 4]) + sample_rate: int = 44100 + bit_depth: int = 24 + sep_model: str = "" + trans_model: str = "" + gpu: GpuInfo = Field(default_factory=GpuInfo) + + +class JobState(BaseModel): + status: JobStatus = JobStatus.QUEUED + progress: float = 0.0 + stage: int = -1 + meta: JobMeta = Field(default_factory=JobMeta) + stems: list[StemSchema] = Field(default_factory=list) + error: str | None = None + + +class AnalyzeRequest(BaseModel): + url: str + + +class MidiNote(BaseModel): + pitch: int + start: float + end: float + velocity: int + drum: bool + + +class MidiResponse(BaseModel): + notes: list[MidiNote] + end_time: float diff --git a/backend/services/.gitkeep b/backend/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py new file mode 100644 index 0000000..221ae17 --- /dev/null +++ b/backend/services/pipeline.py @@ -0,0 +1,183 @@ +""" +Core audio pipeline — same logic as web prototype server.py, +now as a proper service module with typed state. +""" +from __future__ import annotations + +import asyncio +import os +import shutil +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +WORK_DIR = Path("work") +WORK_DIR.mkdir(exist_ok=True) + +URL_CACHE: dict[str, str] = {} +JOBS: dict[str, dict] = {} + +DEMUCS_SOURCES = ["drums", "bass", "other", "vocals", "guitar", "piano"] + +STEM_META = { + "drums": {"name": "Drums", "instrument": "Acoustic Kit", "family": "Percussion · GM 0", "clef": "perc", "color": "--s-drums", "role": "Rhythm"}, + "bass": {"name": "Bass", "instrument": "Electric Bass (finger)", "family": "Bass · GM 33", "clef": "bass", "color": "--s-bass", "role": "Low end"}, + "guitar": {"name": "Guitar", "instrument": "Electric Guitar", "family": "Guitar · GM 27", "clef": "treble", "color": "--s-guitar", "role": "Harmony"}, + "piano": {"name": "Piano", "instrument": "Grand Piano", "family": "Piano · GM 0", "clef": "treble", "color": "--s-keys", "role": "Keys"}, + "vocals": {"name": "Vocals", "instrument": "Lead Vocal", "family": "Voice", "clef": "treble", "color": "--s-vocal", "role": "Melody"}, + "other": {"name": "Other", "instrument": "Mixed / Other", "family": "Synth", "clef": "treble", "color": "--s-synth", "role": "Texture"}, +} + +_POOL = ThreadPoolExecutor(max_workers=min(6, (os.cpu_count() or 2))) +_DEVICE = "cuda" if __import__("torch").cuda.is_available() else "cpu" + + +def _set(sid: str, **kwargs: object) -> None: + JOBS[sid].update(kwargs) + + +async def _run_async(cmd: list[str]) -> None: + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + detail = (stderr or b"no output").decode(errors="replace")[-2000:] + raise RuntimeError(f"Command failed (exit {proc.returncode}):\n{detail}") + + +def _transcribe_stem_sync(wav_path: Path, midi_out: Path, is_drums: bool) -> int: + import librosa, numpy as np, pretty_midi + SR, HOP = 22050, 1024 + y, sr = librosa.load(str(wav_path), sr=SR, mono=True, duration=300) + pm = pretty_midi.PrettyMIDI() + if is_drums: + drum = pretty_midi.Instrument(program=0, is_drum=True) + onset_frames = librosa.onset.onset_detect(y=y, sr=sr, units="frames", backtrack=True, hop_length=HOP) + for i, t in enumerate(librosa.frames_to_time(onset_frames, sr=sr, hop_length=HOP)): + drum.notes.append(pretty_midi.Note(velocity=90, pitch=36 if i%2==0 else 38, start=float(t), end=float(t)+0.05)) + pm.instruments.append(drum); pm.write(str(midi_out)); return len(drum.notes) + f0, voiced_flag, _ = librosa.pyin(y, fmin=librosa.note_to_hz("C2"), fmax=librosa.note_to_hz("C7"), sr=sr, frame_length=2048, hop_length=HOP) + times = librosa.times_like(f0, sr=sr, hop_length=HOP) + inst = pretty_midi.Instrument(program=0); in_note = False; note_start = 0.0; note_pitch = 0 + for t, freq, voiced in zip(times, f0, voiced_flag): + if voiced and not np.isnan(freq): + mp = max(0, min(127, int(round(librosa.hz_to_midi(freq))))) + if not in_note: in_note, note_start, note_pitch = True, float(t), mp + elif mp != note_pitch: + if float(t)-note_start >= 0.05: inst.notes.append(pretty_midi.Note(velocity=80, pitch=note_pitch, start=note_start, end=float(t))) + note_start, note_pitch = float(t), mp + elif in_note: + if float(t)-note_start >= 0.05: inst.notes.append(pretty_midi.Note(velocity=80, pitch=note_pitch, start=note_start, end=float(t))) + in_note = False + pm.instruments.append(inst); pm.write(str(midi_out)); return len(inst.notes) + + +def _gpu_info() -> dict: + try: + import torch + if torch.cuda.is_available(): + name = torch.cuda.get_device_name(0) + vram = torch.cuda.get_device_properties(0).total_memory / 1024**3 + return {"model": name, "util": 0, "vram": round(vram, 1), "vramTotal": round(vram, 1)} + except Exception: + pass + return {"model": "CPU", "util": 0, "vram": 0, "vramTotal": 0} + + +async def _separate_and_transcribe(sid: str, audio_path: Path, meta: dict) -> None: + job_dir = audio_path.parent + stems_dir = job_dir / "stems"; stems_dir.mkdir(exist_ok=True) + midi_dir = job_dir / "midi"; midi_dir.mkdir(exist_ok=True) + + _set(sid, stage=1, status="separating", progress=0.20) + await _run_async([sys.executable, "-m", "demucs", "--name", "htdemucs_6s", "--device", _DEVICE, "--out", str(job_dir / "demucs_out"), str(audio_path)]) + demucs_root = next((job_dir / "demucs_out" / "htdemucs_6s").iterdir()) + for stem_wav in demucs_root.glob("*.wav"): + shutil.copy(stem_wav, stems_dir / stem_wav.name) + _set(sid, progress=0.62) + + _set(sid, stage=2, status="identifying", progress=0.64) + _set(sid, progress=0.70) + + _set(sid, stage=3, status="transcribing", progress=0.72) + loop = asyncio.get_event_loop() + tasks, stem_names = [], [] + for stem_name in DEMUCS_SOURCES: + stem_file = stems_dir / f"{stem_name}.wav" + if not stem_file.exists(): continue + tasks.append(loop.run_in_executor(_POOL, _transcribe_stem_sync, stem_file, midi_dir / f"{stem_name}.mid", stem_name == "drums")) + stem_names.append(stem_name) + results = await asyncio.gather(*tasks, return_exceptions=True) + midi_counts = {n: (r if isinstance(r, int) else 0) for n, r in zip(stem_names, results)} + _set(sid, progress=0.90) + + _set(sid, stage=4, status="engraving", progress=0.95) + + stems = [] + for name in DEMUCS_SOURCES: + if not (stems_dir / f"{name}.wav").exists(): continue + m = STEM_META.get(name, {}) + stems.append({ + "id": name, "name": m.get("name", name.title()), + "instrument": m.get("instrument", "Unknown"), + "family": m.get("family", ""), "clef": m.get("clef", "treble"), + "color": m.get("color", "--s-synth"), "role": m.get("role", ""), + "confidence": 90.0, "midi": midi_counts.get(name, 0), + "audio_url": f"/work/{sid}/stems/{name}.wav", + "midi_url": f"/work/{sid}/midi/{name}.mid" if (midi_dir / f"{name}.mid").exists() else None, + "density": 2, "seed": abs(hash(name)) % 50000, "note": "", + }) + + _set(sid, status="done", progress=1.0, stage=4, stems=stems, + meta={**meta, "tempo": 120, "key": "A minor", "timeSig": [4, 4], + "sampleRate": 44100, "bitDepth": 24, + "sepModel": "HT-Demucs v4 · 6-stem", "transModel": "librosa-pyin", + "gpu": _gpu_info()}) + + +async def pipeline_url(sid: str, url: str) -> None: + job_dir = WORK_DIR / sid; job_dir.mkdir(parents=True, exist_ok=True) + audio_path = job_dir / "audio.wav" + try: + _set(sid, stage=0, status="downloading", progress=0.0) + import yt_dlp + ydl_opts = { + "format": "bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio/best", + "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "wav", "preferredquality": "0"}], + "outtmpl": str(job_dir / "audio"), + "quiet": True, "no_warnings": True, + "extractor_args": {"youtube": {"player_client": ["android", "web"]}}, + } + loop = asyncio.get_event_loop() + def _dl(): + with yt_dlp.YoutubeDL(ydl_opts) as ydl: return ydl.extract_info(url, download=True) + info = await loop.run_in_executor(None, _dl) + meta = {"title": info.get("title","Unknown"), "artist": info.get("uploader","Unknown"), + "duration": info.get("duration",0), "thumb": info.get("thumbnail",""), "source": url} + candidates = [audio_path]+sorted(job_dir.glob("audio.*"), key=lambda p: p.stat().st_mtime, reverse=True) + resolved = next((p for p in candidates if p.exists() and p.suffix==".wav"), None) + if resolved and resolved != audio_path: resolved.rename(audio_path) + elif not resolved: raise FileNotFoundError("yt-dlp produced no WAV") + _set(sid, meta=meta, progress=0.18) + await _separate_and_transcribe(sid, audio_path, meta) + URL_CACHE[url] = sid + except Exception as exc: + _set(sid, status="error", error=str(exc), progress=1.0) + + +async def pipeline_file(sid: str, audio_path: Path, filename: str) -> None: + try: + _set(sid, stage=0, status="converting", progress=0.05) + if audio_path.suffix.lower() != ".wav": + wav_path = audio_path.with_suffix(".wav") + await _run_async(["ffmpeg", "-y", "-i", str(audio_path), "-ac", "2", "-ar", "44100", str(wav_path)]) + audio_path.unlink(missing_ok=True) + audio_path = wav_path + import soundfile as sf + info = sf.info(str(audio_path)) + meta = {"title": Path(filename).stem, "artist": "Local file", "duration": int(info.duration), "thumb": "", "source": filename} + _set(sid, meta=meta, progress=0.18) + await _separate_and_transcribe(sid, audio_path, meta) + except Exception as exc: + _set(sid, status="error", error=str(exc), progress=1.0) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/audio/.gitkeep b/core/audio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/midi/.gitkeep b/core/midi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/notation/.gitkeep b/core/notation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/qml/components/.gitkeep b/frontend/qml/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/qml/components/Inspector.qml b/frontend/qml/components/Inspector.qml new file mode 100644 index 0000000..8b05801 --- /dev/null +++ b/frontend/qml/components/Inspector.qml @@ -0,0 +1,58 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Rectangle { + property var job: null + property string sessionId: "" + color: "#0d1220" + + Column { + anchors { top: parent.top; left: parent.left; right: parent.right; topMargin: 52; leftMargin: 12; rightMargin: 12 } + spacing: 16 + + // Song meta + Label { text: job ? job.meta.title : "—"; color: "#e2e8f0"; font.pixelSize: 14; font.weight: Font.Medium; wrapMode: Text.WordWrap; width: parent.width } + Label { text: job ? job.meta.artist : "—"; color: "#64748b"; font.pixelSize: 12 } + + Rectangle { width: parent.width; height: 1; color: "#1e2535" } + + // GPU info + Label { text: "GPU / CPU"; color: "#475569"; font.pixelSize: 10; font.letterSpacing: 1 } + Label { text: job ? (job.meta.gpu.model + " · " + job.meta.gpu.util + "%") : "—"; color: "#94a3b8"; font.pixelSize: 12 } + + Rectangle { width: parent.width; height: 1; color: "#1e2535" } + + // Export + Label { text: "EXPORT"; color: "#475569"; font.pixelSize: 10; font.letterSpacing: 1 } + + Column { + width: parent.width + spacing: 8 + + Button { + width: parent.width + text: "Download stems (WAV)" + enabled: job !== null && sessionId !== "" + onClicked: { + if (!job) return + job.stems.forEach(function(s) { + if (s.audio_url) Qt.openUrlExternally("http://127.0.0.1:7471" + s.audio_url) + }) + } + } + + Button { + width: parent.width + text: "Download MIDI" + enabled: job !== null && sessionId !== "" + onClicked: { + if (!job) return + job.stems.forEach(function(s) { + if (s.midi_url) Qt.openUrlExternally("http://127.0.0.1:7471" + s.midi_url) + }) + } + } + } + } +} diff --git a/frontend/qml/components/OmniBar.qml b/frontend/qml/components/OmniBar.qml new file mode 100644 index 0000000..6bb8131 --- /dev/null +++ b/frontend/qml/components/OmniBar.qml @@ -0,0 +1,43 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Dialogs 1.3 + +Item { + id: root + signal submitUrl(string url) + signal openFile(string path) + + Rectangle { + anchors.fill: parent + color: "#161b27" + + Row { + anchors { verticalCenter: parent.verticalCenter; left: parent.left; right: parent.right; leftMargin: 12; rightMargin: 12 } + spacing: 8 + + TextField { + id: urlInput + width: parent.width - openBtn.width - 20 + height: 32 + placeholderText: "YouTube URL or search…" + color: "#e2e8f0" + background: Rectangle { color: "#1e2535"; radius: 6 } + Keys.onReturnPressed: root.submitUrl(urlInput.text.trim()) + } + + Button { + id: openBtn + text: "Open file" + height: 32 + onClicked: fileDialog.open() + } + } + } + + FileDialog { + id: fileDialog + title: "Open audio file" + nameFilters: ["Audio files (*.mp3 *.wav *.flac *.aac *.ogg *.m4a *.opus *.webm *.mp4)", "All files (*)"] + onAccepted: root.openFile(fileDialog.fileUrl.toString().replace("file:///", "")) + } +} diff --git a/frontend/qml/components/Sidebar.qml b/frontend/qml/components/Sidebar.qml new file mode 100644 index 0000000..7ff85f7 --- /dev/null +++ b/frontend/qml/components/Sidebar.qml @@ -0,0 +1,40 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +Rectangle { + property var job: null + color: "#0b0f1a" + + Column { + anchors { top: parent.top; left: parent.left; right: parent.right; topMargin: 52 } + spacing: 0 + + Label { + text: "STEMS" + color: "#475569" + font.pixelSize: 10 + font.letterSpacing: 1.5 + leftPadding: 16 + topPadding: 12 + bottomPadding: 8 + } + + Repeater { + model: job ? job.stems : [] + Rectangle { + width: parent.width; height: 44 + color: "transparent" + Row { + anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } + spacing: 10 + Rectangle { width: 8; height: 8; radius: 2; color: "#60a5fa"; anchors.verticalCenter: parent.verticalCenter } + Column { + spacing: 2 + Label { text: modelData.name; color: "#e2e8f0"; font.pixelSize: 12 } + Label { text: Math.round(modelData.confidence) + "% · " + modelData.midi + " notes"; color: "#64748b"; font.pixelSize: 10 } + } + } + } + } + } +} diff --git a/frontend/qml/dialogs/.gitkeep b/frontend/qml/dialogs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/qml/views/.gitkeep b/frontend/qml/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/qml/views/EmptyView.qml b/frontend/qml/views/EmptyView.qml new file mode 100644 index 0000000..1ce00a3 --- /dev/null +++ b/frontend/qml/views/EmptyView.qml @@ -0,0 +1,24 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +Item { + Column { + anchors.centerIn: parent + spacing: 16 + + Label { + text: "Aegis Score Studio" + font.pixelSize: 28 + font.weight: Font.Light + color: "#e2e8f0" + anchors.horizontalCenter: parent.horizontalCenter + } + + Label { + text: "Paste a YouTube URL or drop an audio file to begin." + color: "#64748b" + font.pixelSize: 14 + anchors.horizontalCenter: parent.horizontalCenter + } + } +} diff --git a/frontend/qml/views/ErrorView.qml b/frontend/qml/views/ErrorView.qml new file mode 100644 index 0000000..bf2dcc4 --- /dev/null +++ b/frontend/qml/views/ErrorView.qml @@ -0,0 +1,13 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +Item { + property string message: "" + Column { + anchors.centerIn: parent + spacing: 16 + Label { text: "Analysis failed"; color: "#f87171"; font.pixelSize: 18; anchors.horizontalCenter: parent.horizontalCenter } + Label { text: message; color: "#94a3b8"; font.pixelSize: 13; anchors.horizontalCenter: parent.horizontalCenter; wrapMode: Text.WordWrap; width: 400 } + Button { text: "Retry"; anchors.horizontalCenter: parent.horizontalCenter } + } +} diff --git a/frontend/qml/views/MainWindow.qml b/frontend/qml/views/MainWindow.qml new file mode 100644 index 0000000..d2bf079 --- /dev/null +++ b/frontend/qml/views/MainWindow.qml @@ -0,0 +1,114 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 +import "../components" + +ApplicationWindow { + id: root + visible: true + width: 1440 + height: 900 + minimumWidth: 1100 + minimumHeight: 700 + title: "Aegis Score Studio" + + // ── State ────────────────────────────────────────────────────────────── + property string scene: "empty" // empty | processing | success | error + property var liveJob: null + property string sessionId: "" + property real progress: 0.0 + property int stage: -1 + + // ── Bridge connections ───────────────────────────────────────────────── + Connections { + target: AppBridge + + function onSessionStarted(sid) { + root.sessionId = sid + root.scene = "processing" + root.progress = 0.0 + } + + function onProgressChanged(prog, stg, status) { + root.progress = prog + root.stage = stg + } + + function onAnalysisReady(job) { + root.liveJob = job + root.scene = "success" + } + + function onErrorOccurred(msg) { + root.scene = "error" + errorMessage.text = msg + } + } + + // ── Layout ───────────────────────────────────────────────────────────── + RowLayout { + anchors.fill: parent + spacing: 0 + + // Left sidebar + Sidebar { + Layout.preferredWidth: 220 + Layout.fillHeight: true + job: root.liveJob + } + + // Center: score / processing / empty + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "#0f1117" + + Loader { + anchors.fill: parent + source: { + if (root.scene === "processing") return "../views/ProcessingView.qml" + if (root.scene === "success") return "../views/ScoreView.qml" + if (root.scene === "error") return "../views/ErrorView.qml" + return "../views/EmptyView.qml" + } + onLoaded: { + if (root.scene === "processing") item.progress = Qt.binding(()=> root.progress) + if (root.scene === "success") item.job = Qt.binding(()=> root.liveJob) + } + } + } + + // Right inspector + Inspector { + Layout.preferredWidth: 260 + Layout.fillHeight: true + job: root.liveJob + sessionId: root.sessionId + } + } + + // ── Omnibar (top) — future: promote to real toolbar ─────────────────── + OmniBar { + id: omni + anchors { top: parent.top; left: parent.left; right: parent.right } + height: 46 + onSubmitUrl: (url) => AppBridge.analyzeUrl(url) + onOpenFile: (path) => AppBridge.openLocalFile(path) + } + + // Nudge content down below omnibar + Item { + id: omniSpacer + anchors { top: omni.bottom; left: parent.left; right: parent.right } + height: 0 + } + + // ── Error label (temp) ──────────────────────────────────────────────── + Label { + id: errorMessage + visible: root.scene === "error" + anchors.centerIn: parent + color: "#f87171" + } +} diff --git a/frontend/qml/views/ProcessingView.qml b/frontend/qml/views/ProcessingView.qml new file mode 100644 index 0000000..2c517da --- /dev/null +++ b/frontend/qml/views/ProcessingView.qml @@ -0,0 +1,68 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Item { + property real progress: 0.0 + + readonly property var stages: [ + "Fetch & decode audio", + "Source separation (Demucs)", + "Instrument identification", + "MIDI transcription", + "Notation engraving", + ] + + Column { + anchors.centerIn: parent + spacing: 24 + width: 420 + + Label { + text: "Analyzing…" + font.pixelSize: 22 + font.weight: Font.Medium + color: "#e2e8f0" + anchors.horizontalCenter: parent.horizontalCenter + } + + // Progress bar + Rectangle { + width: parent.width + height: 6 + radius: 3 + color: "#1e2535" + Rectangle { + width: parent.width * progress + height: parent.height + radius: 3 + color: "#60a5fa" + Behavior on width { SmoothedAnimation { duration: 300 } } + } + } + + Label { + text: Math.round(progress * 100) + "%" + color: "#94a3b8" + anchors.horizontalCenter: parent.horizontalCenter + } + + // Stage list + Repeater { + model: stages + Row { + spacing: 10 + Rectangle { + width: 8; height: 8; radius: 4 + color: index < Math.round(progress * 5) ? "#34d399" : "#334155" + anchors.verticalCenter: parent.verticalCenter + } + Label { + text: modelData + color: index < Math.round(progress * 5) ? "#e2e8f0" : "#64748b" + font.pixelSize: 13 + } + } + } + } +} diff --git a/frontend/qml/views/ScoreView.qml b/frontend/qml/views/ScoreView.qml new file mode 100644 index 0000000..bdb6de7 --- /dev/null +++ b/frontend/qml/views/ScoreView.qml @@ -0,0 +1,69 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +/* + ScoreView: shows stems as staff rows + playback timeline. + Notation rendering: QtQuick Canvas (port of notation.js logic). + Phase 1 stub — full Canvas renderer TBD. +*/ +Item { + property var job: null + + ScrollView { + anchors.fill: parent + clip: true + + Column { + width: parent.width + spacing: 0 + + Repeater { + model: job ? job.stems : [] + + Rectangle { + width: parent.width + height: 90 + color: index % 2 === 0 ? "#0f1117" : "#111520" + + Row { + anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } + spacing: 12 + + Rectangle { + width: 4; height: 60; radius: 2 + color: "#60a5fa" // TODO: map modelData.color + } + + Column { + spacing: 4 + anchors.verticalCenter: parent.verticalCenter + Label { text: modelData.name; color: "#e2e8f0"; font.pixelSize: 13; font.weight: Font.Medium } + Label { text: modelData.instrument; color: "#64748b"; font.pixelSize: 11 } + Label { text: modelData.midi + " notes"; color: "#475569"; font.pixelSize: 10 } + } + + // Staff canvas — notation rendered here + Canvas { + id: staffCanvas + width: 600 + height: 80 + anchors.verticalCenter: parent.verticalCenter + // TODO: port buildMeasuresFromMidi + drawNote to QtQuick Canvas + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.strokeStyle = "#334155" + ctx.lineWidth = 1 + for (let i = 0; i < 5; i++) { + const y = 20 + i * 9 + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke() + } + } + } + } + } + } + } + } +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..617d22b --- /dev/null +++ b/main.py @@ -0,0 +1,52 @@ +""" +Aegis Score Studio — desktop entry point +Launches PySide6 window + embedded FastAPI backend on a background thread. +""" +from __future__ import annotations + +import sys +import threading +import uvicorn +from PySide6.QtWidgets import QApplication +from PySide6.QtQml import QQmlApplicationEngine +from PySide6.QtCore import QUrl, Qt + +from backend.api.app import create_app +from backend.bridge import AppBridge + + +def _run_backend(host: str = "127.0.0.1", port: int = 7471) -> None: + fastapi_app = create_app() + uvicorn.run(fastapi_app, host=host, port=port, log_level="warning") + + +def main() -> int: + # High-DPI + QML style + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + app = QApplication(sys.argv) + app.setApplicationName("Aegis Score Studio") + app.setOrganizationName("AegisAudio") + app.setApplicationVersion("0.1.0") + + # Start backend + backend_thread = threading.Thread( + target=_run_backend, daemon=True, name="aegis-backend" + ) + backend_thread.start() + + # QML engine + engine = QQmlApplicationEngine() + bridge = AppBridge(parent=app) + engine.rootContext().setContextProperty("AppBridge", bridge) + engine.load(QUrl("frontend/qml/views/MainWindow.qml")) + + if not engine.rootObjects(): + return 1 + + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/models/.gitkeep b/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/.gitkeep b/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ff9d5e6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["setuptools>=72", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "aegis-score-studio" +version = "0.1.0" +description = "AI music deconstruction workstation" +requires-python = ">=3.13" +dependencies = [ + "PySide6>=6.9", + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "pydantic>=2.7", + "yt-dlp>=2024.8", + "demucs>=4.0", + "librosa>=0.10", + "pretty_midi>=0.2", + "soundfile>=0.12", + "numpy>=2.0", + "scipy>=1.13", + "sqlalchemy>=2.0", + "alembic>=1.13", + "aiofiles>=23.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "pytest-qt>=4.4", + "ruff>=0.5", + "mypy>=1.10", + "black>=24", +] + +[tool.ruff] +target-version = "py313" +line-length = 100 + +[tool.mypy] +python_version = "3.13" +strict = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..e69de29 From 6c06d8f51f429b8e67256e8e4b462da506af6172 Mon Sep 17 00:00:00 2001 From: ARNAV AGGARWAL Date: Tue, 9 Jun 2026 04:40:07 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20complete=20desktop=20app=20?= =?UTF-8?q?=E2=80=94=20DB,=20notation=20renderer,=20tests,=20CI,=20build?= =?UTF-8?q?=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite / SQLAlchemy: - backend/db/models.py: Session + Stem ORM models - backend/db/database.py: engine, SessionLocal, init_db - backend/services/session_store.py: persist completed jobs - backend/api/routers/history.py: GET/DELETE /api/history QML UI (complete): - StaffCanvas.qml: QtQuick Canvas notation renderer - real MIDI notes via /api/midi/{sid}/{stem} - seeded fake notes fallback for demo projects - treble/bass/perc/alto clef, barlines, noteheads, stems, ledger lines - ScoreView.qml: scroll view with per-stem staff rows + transport bar - MainWindow.qml: full layout with status bar, flash toast, download wiring - Sidebar.qml: stem list + history from /api/history - Inspector.qml: meta, per-stem confidence bars, GPU vram bar, export buttons - FlashToast.qml: animated notification Tests (16 total, 16 passing): - tests/unit/: STEM_META, GPU info, DB init, Pydantic schemas - tests/integration/: API routes mocked (analyze, status, midi, history, upload) CI/CD: - .github/workflows/ci.yml: lint + unit + integration on push; build exe on tag - scripts/build_windows.py: PyInstaller one-file Windows exe Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 64 + .gitignore | 1 + AegisScore.vbs | 4 + backend/api/app.py | 12 +- backend/api/routers/history.py | 28 + backend/db/__init__.py | 0 backend/db/database.py | 23 + backend/db/models.py | 48 + backend/services/pipeline.py | 4 + backend/services/session_store.py | 42 + frontend/qml/components/FlashToast.qml | 32 + frontend/qml/components/Inspector.qml | 182 +- frontend/qml/components/Sidebar.qml | 130 +- frontend/qml/components/StaffCanvas.qml | 281 ++ frontend/qml/views/MainWindow.qml | 151 +- frontend/qml/views/ScoreView.qml | 212 +- main.js | 171 + package-lock.json | 4112 +++++++++++++++++++++++ package.json | 38 + preload.js | 7 + scripts/build_windows.py | 40 + tests/__init__.py | 0 tests/integration/__init__.py | 0 tests/integration/test_api.py | 51 + tests/unit/__init__.py | 0 tests/unit/test_pipeline_helpers.py | 46 + tests/unit/test_schemas.py | 41 + 27 files changed, 5574 insertions(+), 146 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 AegisScore.vbs create mode 100644 backend/api/routers/history.py create mode 100644 backend/db/__init__.py create mode 100644 backend/db/database.py create mode 100644 backend/db/models.py create mode 100644 backend/services/session_store.py create mode 100644 frontend/qml/components/FlashToast.qml create mode 100644 frontend/qml/components/StaffCanvas.qml create mode 100644 main.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 preload.js create mode 100644 scripts/build_windows.py create mode 100644 tests/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_api.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_pipeline_helpers.py create mode 100644 tests/unit/test_schemas.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f7333c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [master, "desktop/**", "feature/**"] + pull_request: + branches: [master] + +jobs: + test: + runs-on: windows-latest + strategy: + matrix: + python-version: ["3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies (no heavy ML) + run: | + pip install fastapi pydantic httpx pytest pytest-asyncio sqlalchemy aiofiles + + - name: Lint (ruff) + run: | + pip install ruff + ruff check backend/ tests/ --ignore E501 + + - name: Run unit tests + run: pytest tests/unit/ -v + + - name: Run integration tests (mock pipeline) + run: pytest tests/integration/ -v + + build-windows: + runs-on: windows-latest + needs: test + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install build deps + run: pip install pyinstaller PySide6 + + - name: Build executable + run: python scripts/build_windows.py + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: AegisScoreStudio-Windows + path: dist/AegisScoreStudio.exe diff --git a/.gitignore b/.gitignore index af91fbb..f31be3e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ build/ venv/ *.log test_sf.wav +aegis.db diff --git a/AegisScore.vbs b/AegisScore.vbs new file mode 100644 index 0000000..39982ca --- /dev/null +++ b/AegisScore.vbs @@ -0,0 +1,4 @@ +Set objShell = CreateObject("WScript.Shell") +Dim dir : dir = Left(WScript.ScriptFullName, InStrRev(WScript.ScriptFullName, "\") - 1) +objShell.CurrentDirectory = dir +objShell.Run "cmd /c npm start", 0, False diff --git a/backend/api/app.py b/backend/api/app.py index e78a341..bd2b02f 100644 --- a/backend/api/app.py +++ b/backend/api/app.py @@ -6,7 +6,8 @@ from fastapi.staticfiles import StaticFiles from pathlib import Path -from backend.api.routers import analyze, upload, status, midi_notes +from backend.api.routers import analyze, upload, status, midi_notes, history +from backend.db.database import init_db def create_app() -> FastAPI: @@ -19,10 +20,13 @@ def create_app() -> FastAPI: allow_headers=["*"], ) - app.include_router(analyze.router, prefix="/api") - app.include_router(upload.router, prefix="/api") - app.include_router(status.router, prefix="/api") + init_db() + + app.include_router(analyze.router, prefix="/api") + app.include_router(upload.router, prefix="/api") + app.include_router(status.router, prefix="/api") app.include_router(midi_notes.router, prefix="/api") + app.include_router(history.router, prefix="/api") work_dir = Path("work") work_dir.mkdir(exist_ok=True) diff --git a/backend/api/routers/history.py b/backend/api/routers/history.py new file mode 100644 index 0000000..48a01aa --- /dev/null +++ b/backend/api/routers/history.py @@ -0,0 +1,28 @@ +"""Session history — CRUD over SQLite.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from backend.db.database import get_db +from backend.db.models import Session as DbSession + +router = APIRouter() + + +@router.get("/history") +def list_history(limit: int = 50, db: Session = Depends(get_db)): + rows = db.query(DbSession).order_by(DbSession.created_at.desc()).limit(limit).all() + return [ + {"id": r.id, "title": r.title, "artist": r.artist, "source": r.source, + "duration": r.duration, "created_at": r.created_at.isoformat(), + "stem_count": len(r.stems)} + for r in rows + ] + + +@router.delete("/history/{sid}") +def delete_session(sid: str, db: Session = Depends(get_db)): + row = db.query(DbSession).filter(DbSession.id == sid).first() + if row: + db.delete(row); db.commit() + return {"ok": True} diff --git a/backend/db/__init__.py b/backend/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/db/database.py b/backend/db/database.py new file mode 100644 index 0000000..d90c3da --- /dev/null +++ b/backend/db/database.py @@ -0,0 +1,23 @@ +"""DB engine + session factory.""" +from __future__ import annotations + +from pathlib import Path +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from backend.db.models import Base + +DB_PATH = Path("aegis.db") +_ENGINE = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=_ENGINE, autocommit=False, autoflush=False) + + +def init_db() -> None: + Base.metadata.create_all(bind=_ENGINE) + + +def get_db(): + db: Session = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/db/models.py b/backend/db/models.py new file mode 100644 index 0000000..e903210 --- /dev/null +++ b/backend/db/models.py @@ -0,0 +1,48 @@ +"""SQLAlchemy 2.x ORM models.""" +from __future__ import annotations + +from datetime import datetime +from sqlalchemy import String, Float, Integer, Boolean, ForeignKey, DateTime, JSON +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class Session(Base): + __tablename__ = "sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + source: Mapped[str] = mapped_column(String(2048)) + title: Mapped[str] = mapped_column(String(512), default="") + artist: Mapped[str] = mapped_column(String(512), default="") + duration: Mapped[float] = mapped_column(Float, default=0.0) + thumb: Mapped[str] = mapped_column(String(2048), default="") + tempo: Mapped[int] = mapped_column(Integer, default=120) + key: Mapped[str] = mapped_column(String(32), default="A minor") + time_sig: Mapped[list] = mapped_column(JSON, default=lambda: [4, 4]) + status: Mapped[str] = mapped_column(String(32), default="done") + + stems: Mapped[list[Stem]] = relationship("Stem", back_populates="session", cascade="all, delete-orphan") + + +class Stem(Base): + __tablename__ = "stems" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + session_id: Mapped[str] = mapped_column(String(36), ForeignKey("sessions.id")) + stem_id: Mapped[str] = mapped_column(String(32)) # "drums", "bass", … + name: Mapped[str] = mapped_column(String(128)) + instrument: Mapped[str] = mapped_column(String(256)) + clef: Mapped[str] = mapped_column(String(16)) + color: Mapped[str] = mapped_column(String(32)) + role: Mapped[str] = mapped_column(String(64)) + confidence: Mapped[float] = mapped_column(Float) + midi_count: Mapped[int] = mapped_column(Integer, default=0) + audio_path: Mapped[str] = mapped_column(String(1024), default="") + midi_path: Mapped[str] = mapped_column(String(1024), default="") + muted: Mapped[bool] = mapped_column(Boolean, default=False) + + session: Mapped[Session] = relationship("Session", back_populates="stems") diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index 221ae17..6ad7337 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -162,6 +162,8 @@ def _dl(): _set(sid, meta=meta, progress=0.18) await _separate_and_transcribe(sid, audio_path, meta) URL_CACHE[url] = sid + from backend.services.session_store import save_session + save_session(sid, JOBS[sid]) except Exception as exc: _set(sid, status="error", error=str(exc), progress=1.0) @@ -179,5 +181,7 @@ async def pipeline_file(sid: str, audio_path: Path, filename: str) -> None: meta = {"title": Path(filename).stem, "artist": "Local file", "duration": int(info.duration), "thumb": "", "source": filename} _set(sid, meta=meta, progress=0.18) await _separate_and_transcribe(sid, audio_path, meta) + from backend.services.session_store import save_session + save_session(sid, JOBS[sid]) except Exception as exc: _set(sid, status="error", error=str(exc), progress=1.0) diff --git a/backend/services/session_store.py b/backend/services/session_store.py new file mode 100644 index 0000000..c192979 --- /dev/null +++ b/backend/services/session_store.py @@ -0,0 +1,42 @@ +"""Persist a completed pipeline job to SQLite.""" +from __future__ import annotations + +from backend.db.database import SessionLocal +from backend.db.models import Session as DbSession, Stem as DbStem + + +def save_session(sid: str, job: dict) -> None: + meta = job.get("meta", {}) + db = SessionLocal() + try: + existing = db.query(DbSession).filter(DbSession.id == sid).first() + if existing: + return + row = DbSession( + id=sid, + source=meta.get("source", ""), + title=meta.get("title", ""), + artist=meta.get("artist", ""), + duration=meta.get("duration", 0.0), + thumb=meta.get("thumb", ""), + tempo=meta.get("tempo", 120), + key=meta.get("key", "A minor"), + time_sig=meta.get("timeSig", [4, 4]), + status="done", + ) + for s in job.get("stems", []): + row.stems.append(DbStem( + stem_id=s.get("id", ""), + name=s.get("name", ""), + instrument=s.get("instrument", ""), + clef=s.get("clef", "treble"), + color=s.get("color", ""), + role=s.get("role", ""), + confidence=s.get("confidence", 0.0), + midi_count=s.get("midi", 0), + audio_path=s.get("audio_url", ""), + midi_path=s.get("midi_url", "") or "", + )) + db.add(row); db.commit() + finally: + db.close() diff --git a/frontend/qml/components/FlashToast.qml b/frontend/qml/components/FlashToast.qml new file mode 100644 index 0000000..8b94ec7 --- /dev/null +++ b/frontend/qml/components/FlashToast.qml @@ -0,0 +1,32 @@ +import QtQuick 2.15 + +Rectangle { + id: root + property string message: "" + + width: toastText.implicitWidth + 32 + height: 36; radius: 8 + color: "#1e2d4a" + border.color: "#2d4a7a" + opacity: 0 + visible: opacity > 0 + + Text { + id: toastText + anchors.centerIn: parent + text: root.message + color: "#e2e8f0"; font.pixelSize: 13 + } + + function show(msg) { + message = msg + showAnim.restart() + } + + SequentialAnimation { + id: showAnim + NumberAnimation { target: root; property: "opacity"; to: 1.0; duration: 160; easing.type: Easing.OutQuad } + PauseAnimation { duration: 2000 } + NumberAnimation { target: root; property: "opacity"; to: 0.0; duration: 300; easing.type: Easing.InQuad } + } +} diff --git a/frontend/qml/components/Inspector.qml b/frontend/qml/components/Inspector.qml index 8b05801..3eeae48 100644 --- a/frontend/qml/components/Inspector.qml +++ b/frontend/qml/components/Inspector.qml @@ -3,56 +3,168 @@ import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Rectangle { + id: root property var job: null property string sessionId: "" - color: "#0d1220" + signal exportMidi() + signal exportWav() + color: "#090d18" Column { - anchors { top: parent.top; left: parent.left; right: parent.right; topMargin: 52; leftMargin: 12; rightMargin: 12 } - spacing: 16 + anchors { top: parent.top; left: parent.left; right: parent.right; topMargin: 0 } + spacing: 0 - // Song meta - Label { text: job ? job.meta.title : "—"; color: "#e2e8f0"; font.pixelSize: 14; font.weight: Font.Medium; wrapMode: Text.WordWrap; width: parent.width } - Label { text: job ? job.meta.artist : "—"; color: "#64748b"; font.pixelSize: 12 } + // Header + Rectangle { + width: parent.width; height: 36; color: "#0d1220" + Text { anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } + text: "INSPECTOR"; color: "#334155"; font.pixelSize: 10; font.letterSpacing: 1.5 } + } - Rectangle { width: parent.width; height: 1; color: "#1e2535" } + // Song info + Rectangle { + width: parent.width + height: infoCol.implicitHeight + 24 + color: "transparent" + Column { + id: infoCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 } + spacing: 6 + Text { text: job ? job.meta.title : "No project"; color: "#e2e8f0"; font.pixelSize: 15; font.weight: Font.Medium; wrapMode: Text.WordWrap; width: parent.width } + Text { text: job ? job.meta.artist : ""; color: "#64748b"; font.pixelSize: 12 } + Text { text: job ? job.meta.sepModel : ""; color: "#334155"; font.pixelSize: 10 } + } + } - // GPU info - Label { text: "GPU / CPU"; color: "#475569"; font.pixelSize: 10; font.letterSpacing: 1 } - Label { text: job ? (job.meta.gpu.model + " · " + job.meta.gpu.util + "%") : "—"; color: "#94a3b8"; font.pixelSize: 12 } + Rectangle { width: parent.width; height: 1; color: "#131c2e" } - Rectangle { width: parent.width; height: 1; color: "#1e2535" } + // Metrics + Rectangle { + width: parent.width; height: metricsCol.implicitHeight + 20; color: "transparent" + Column { + id: metricsCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 } + spacing: 10 + _MetricRow { label: "Tempo"; value: job ? job.meta.tempo + " BPM" : "—" } + _MetricRow { label: "Key"; value: job ? job.meta.key : "—" } + _MetricRow { label: "Time"; value: job ? (job.meta.timeSig ? job.meta.timeSig[0]+"/"+job.meta.timeSig[1] : "4/4") : "—" } + _MetricRow { label: "Duration";value: job ? _fmtTime(job.meta.duration) : "—" } + _MetricRow { label: "Stems"; value: job ? job.stems.length : "0" } + _MetricRow { label: "MIDI"; value: job ? job.stems.reduce(function(a,s){return a+s.midi},0).toLocaleString() : "0" } + } + } - // Export - Label { text: "EXPORT"; color: "#475569"; font.pixelSize: 10; font.letterSpacing: 1 } + Rectangle { width: parent.width; height: 1; color: "#131c2e" } - Column { - width: parent.width - spacing: 8 - - Button { - width: parent.width - text: "Download stems (WAV)" - enabled: job !== null && sessionId !== "" - onClicked: { - if (!job) return - job.stems.forEach(function(s) { - if (s.audio_url) Qt.openUrlExternally("http://127.0.0.1:7471" + s.audio_url) - }) + // GPU + Rectangle { + width: parent.width; height: gpuCol.implicitHeight + 20; color: "transparent" + Column { + id: gpuCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 } + spacing: 10 + Text { text: "GPU / CPU"; color: "#475569"; font.pixelSize: 10; font.letterSpacing: 1 } + Text { text: job ? job.meta.gpu.model : "—"; color: "#94a3b8"; font.pixelSize: 12 } + // VRAM bar + Rectangle { + width: parent.width; height: 4; radius: 2; color: "#1e2535" + visible: job && job.meta.gpu.vramTotal > 0 + Rectangle { + width: job ? (job.meta.gpu.vramTotal > 0 ? parent.width * job.meta.gpu.vram / job.meta.gpu.vramTotal : 0) : 0 + height: parent.height; radius: 2; color: "#3b82f6" + } } + Text { text: job && job.meta.gpu.vramTotal > 0 ? job.meta.gpu.vram.toFixed(1) + " / " + job.meta.gpu.vramTotal.toFixed(1) + " GB" : "CPU-only"; color: "#64748b"; font.pixelSize: 10 } } + } + + Rectangle { width: parent.width; height: 1; color: "#131c2e" } - Button { - width: parent.width - text: "Download MIDI" - enabled: job !== null && sessionId !== "" - onClicked: { - if (!job) return - job.stems.forEach(function(s) { - if (s.midi_url) Qt.openUrlExternally("http://127.0.0.1:7471" + s.midi_url) - }) + // Per-stem confidence + Rectangle { + width: parent.width + height: stemConfCol.implicitHeight + 20 + color: "transparent" + visible: job !== null + Column { + id: stemConfCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 } + spacing: 8 + Text { text: "CONFIDENCE"; color: "#475569"; font.pixelSize: 10; font.letterSpacing: 1 } + Repeater { + model: job ? job.stems : [] + Column { + width: parent.width; spacing: 3 + Row { + width: parent.width + Text { text: modelData.name; color: "#94a3b8"; font.pixelSize: 11; width: parent.width * 0.5 } + Text { + text: modelData.confidence.toFixed(1) + "%" + color: modelData.confidence>=90?"#34d399":modelData.confidence>=78?"#fbbf24":"#f87171" + font.pixelSize: 11; width: parent.width*0.5; horizontalAlignment: Text.AlignRight + } + } + Rectangle { + width: parent.width; height: 3; radius: 2; color: "#1e2535" + Rectangle { + width: parent.width * modelData.confidence / 100 + height: parent.height; radius: 2 + color: modelData.confidence>=90?"#34d399":modelData.confidence>=78?"#fbbf24":"#f87171" + } + } + } } } } + + Rectangle { width: parent.width; height: 1; color: "#131c2e" } + + // Export + Rectangle { + width: parent.width + height: exportCol.implicitHeight + 20 + color: "transparent" + Column { + id: exportCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 } + spacing: 10 + + Text { text: "EXPORT"; color: "#475569"; font.pixelSize: 10; font.letterSpacing: 1 } + + _ExportBtn { label: "MIDI stems (.mid)"; icon: "♩"; enabled: job !== null && sessionId !== ""; onClicked: root.exportMidi() } + _ExportBtn { label: "Audio stems (.wav)"; icon: "🔊"; enabled: job !== null && sessionId !== ""; onClicked: root.exportWav() } + } + } + } + + function _fmtTime(sec) { + const m = Math.floor(sec/60), s = Math.floor(sec%60) + return m + ":" + ("0"+s).slice(-2) + } + + // ── Sub-components ──────────────────────────────────────────────────── + component _MetricRow: Row { + property string label: "" + property string value: "" + width: parent.width; spacing: 0 + Text { text: label; color: "#475569"; font.pixelSize: 11; width: parent.width * 0.45 } + Text { text: value; color: "#94a3b8"; font.pixelSize: 11; width: parent.width * 0.55; horizontalAlignment: Text.AlignRight } + } + + component _ExportBtn: Rectangle { + property string label: "" + property string icon: "" + property bool enabled: true + signal clicked() + width: parent.width; height: 34; radius: 6 + color: btnMouse.containsMouse ? "#1e2d4a" : "#131c2e" + border.color: "#2d3a52" + opacity: enabled ? 1.0 : 0.4 + MouseArea { id: btnMouse; anchors.fill: parent; hoverEnabled: true; onClicked: if (parent.enabled) parent.clicked() } + Row { + anchors.centerIn: parent; spacing: 8 + Text { text: icon; color: "#60a5fa"; font.pixelSize: 13 } + Text { text: label; color: "#cbd5e1"; font.pixelSize: 12 } + } } } diff --git a/frontend/qml/components/Sidebar.qml b/frontend/qml/components/Sidebar.qml index 7ff85f7..d3a1572 100644 --- a/frontend/qml/components/Sidebar.qml +++ b/frontend/qml/components/Sidebar.qml @@ -2,39 +2,135 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 Rectangle { + id: root property var job: null - color: "#0b0f1a" + signal loadSession(string sid) + color: "#090d18" Column { - anchors { top: parent.top; left: parent.left; right: parent.right; topMargin: 52 } + anchors { top: parent.top; left: parent.left; right: parent.right; topMargin: 0 } spacing: 0 - Label { - text: "STEMS" - color: "#475569" - font.pixelSize: 10 - font.letterSpacing: 1.5 - leftPadding: 16 - topPadding: 12 - bottomPadding: 8 + // Section: current stems + Rectangle { + width: parent.width; height: 36 + color: "#0d1220" + Text { + anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } + text: "STEMS"; color: "#334155"; font.pixelSize: 10; font.letterSpacing: 1.5 + } + Text { + anchors { verticalCenter: parent.verticalCenter; right: parent.right; rightMargin: 16 } + text: job ? job.stems.length : "0"; color: "#475569"; font.pixelSize: 11 + } } Repeater { model: job ? job.stems : [] + Rectangle { - width: parent.width; height: 44 - color: "transparent" + width: root.width; height: 52 + color: stemMouse.containsMouse ? "#111a2e" : "transparent" + + MouseArea { id: stemMouse; anchors.fill: parent; hoverEnabled: true } + Row { - anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } + anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 12 } spacing: 10 - Rectangle { width: 8; height: 8; radius: 2; color: "#60a5fa"; anchors.verticalCenter: parent.verticalCenter } + + // Color swatch + Rectangle { + width: 3; height: 36; radius: 2 + color: _stemColor(modelData.color) + anchors.verticalCenter: parent.verticalCenter + } + + // Icon placeholder + Rectangle { + width: 32; height: 32; radius: 6 + color: Qt.rgba(Qt.lighter(_stemColor(modelData.color), 1.2).r, + Qt.lighter(_stemColor(modelData.color), 1.2).g, + Qt.lighter(_stemColor(modelData.color), 1.2).b, 0.12) + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: _stemIcon(modelData.id) + font.pixelSize: 14 + } + } + Column { - spacing: 2 - Label { text: modelData.name; color: "#e2e8f0"; font.pixelSize: 12 } - Label { text: Math.round(modelData.confidence) + "% · " + modelData.midi + " notes"; color: "#64748b"; font.pixelSize: 10 } + spacing: 3; anchors.verticalCenter: parent.verticalCenter + Text { text: modelData.name; color: "#cbd5e1"; font.pixelSize: 12; font.weight: Font.Medium } + Text { text: modelData.instrument; color: "#475569"; font.pixelSize: 10 } + Text { + text: modelData.midi + " notes · " + Math.round(modelData.confidence) + "%" + color: modelData.confidence >= 90 ? "#34d399" : modelData.confidence >= 78 ? "#fbbf24" : "#f87171" + font.pixelSize: 10 + } } } } } + + // Divider + Rectangle { width: parent.width; height: 1; color: "#131c2e"; visible: job !== null } + + // Section: history (fetched from backend) + Rectangle { + width: parent.width; height: 36 + color: "#0d1220" + Text { + anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } + text: "RECENT"; color: "#334155"; font.pixelSize: 10; font.letterSpacing: 1.5 + } + } + + ListView { + width: parent.width + height: Math.min(contentHeight, 200) + clip: true + model: histModel + delegate: Rectangle { + width: root.width; height: 46 + color: hm.containsMouse ? "#111a2e" : "transparent" + MouseArea { id: hm; anchors.fill: parent; hoverEnabled: true; onClicked: root.loadSession(model.id) } + Column { + anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter } + spacing: 3 + Text { text: model.title||"Untitled"; color: "#94a3b8"; font.pixelSize: 12; elide: Text.ElideRight; width: root.width-32 } + Text { text: model.artist + " · " + model.stem_count + " stems"; color: "#475569"; font.pixelSize: 10 } + } + } + } + } + + // Fetch history on load + ListModel { id: histModel } + + Component.onCompleted: _fetchHistory() + + function _fetchHistory() { + const xhr = new XMLHttpRequest() + xhr.open("GET", "http://127.0.0.1:7471/api/history?limit=20") + xhr.onreadystatechange = function() { + if (xhr.readyState === 4 && xhr.status === 200) { + const items = JSON.parse(xhr.responseText) + histModel.clear() + for (let i = 0; i < items.length; i++) histModel.append(items[i]) + } + } + xhr.send() + } + + function _stemColor(cssVar) { + const map = { "--s-drums":"#f97316","--s-bass":"#a855f7","--s-guitar":"#22c55e", + "--s-keys":"#3b82f6","--s-vocal":"#ec4899","--s-synth":"#06b6d4", + "--s-strings":"#84cc16","--s-brass":"#eab308","--s-wood":"#14b8a6","--s-perc":"#f59e0b" } + return map[cssVar] || "#60a5fa" + } + function _stemIcon(id) { + const map = { drums:"🥁", bass:"🎸", guitar:"🎸", piano:"🎹", vocals:"🎤", other:"🎵" } + return map[id] || "🎵" } } diff --git a/frontend/qml/components/StaffCanvas.qml b/frontend/qml/components/StaffCanvas.qml new file mode 100644 index 0000000..534a4b8 --- /dev/null +++ b/frontend/qml/components/StaffCanvas.qml @@ -0,0 +1,281 @@ +/** + * StaffCanvas — QtQuick Canvas port of notation.js + * Renders a 5-line staff with real MIDI notes or seeded fake notes. + * + * Required properties: + * stemData : object { id, name, clef, color, midiNotes[], density, seed } + * tempo : int + * timeSig : int[2] + * bars : int + */ +import QtQuick 2.15 + +Canvas { + id: root + + required property var stemData + required property int tempo + required property var timeSig + required property int bars + + property real stemColor: "#60a5fa" + + readonly property int SP : 9 // staff space px + readonly property int STAFF_TOP : 28 // y of top line + readonly property int FIRST_PAD : 72 // clef + keysig + timesig width + readonly property int NOTE_PAD_L : 14 + readonly property int NOTE_PAD_R : 12 + readonly property int BAR_W : timeSig[0] === 3 ? 128 : 162 + + implicitWidth: FIRST_PAD + BAR_W * bars + 8 + implicitHeight: 90 + + onStemDataChanged: requestPaint() + onTempoChanged: requestPaint() + onTimeSigChanged: requestPaint() + + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + _drawStaff(ctx) + _drawClef(ctx) + _drawTimeSig(ctx) + _drawBarlines(ctx) + + if (stemData && stemData.midiNotes && stemData.midiNotes.length > 0) + _drawRealNotes(ctx, stemData.midiNotes) + else + _drawFakeNotes(ctx) + } + + // ── Staff lines ────────────────────────────────────────────────────────── + function _drawStaff(ctx) { + ctx.strokeStyle = "#2d3a52" + ctx.lineWidth = 1 + for (let i = 0; i < 5; i++) { + const y = STAFF_TOP + i * SP + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width - 4, y); ctx.stroke() + } + } + + // ── Barlines ───────────────────────────────────────────────────────────── + function _drawBarlines(ctx) { + for (let b = 0; b <= bars; b++) { + const x = FIRST_PAD + b * BAR_W + ctx.strokeStyle = b === bars ? "#4a5568" : "#2d3a52" + ctx.lineWidth = b === bars ? 2 : 1 + ctx.beginPath(); ctx.moveTo(x, STAFF_TOP); ctx.lineTo(x, STAFF_TOP + 4*SP); ctx.stroke() + } + } + + // ── Clef (simplified rects — Bravura font not guaranteed on all OSes) ─── + function _drawClef(ctx) { + const clef = stemData ? stemData.clef : "treble" + ctx.fillStyle = "#94a3b8" + ctx.font = "bold 11px monospace" + ctx.textAlign = "left" + if (clef === "bass") { + ctx.fillText("𝄢", 6, STAFF_TOP + SP * 1.5 + 4) + } else if (clef === "perc") { + ctx.fillRect(8, STAFF_TOP + SP, 4, SP * 2) + ctx.fillRect(14, STAFF_TOP + SP, 4, SP * 2) + } else { + ctx.fillText("𝄞", 4, STAFF_TOP + SP * 3 + 4) + } + } + + // ── Time signature digits ──────────────────────────────────────────────── + function _drawTimeSig(ctx) { + ctx.fillStyle = "#94a3b8" + ctx.font = "bold 16px monospace" + ctx.textAlign = "center" + const tx = 58 + ctx.fillText(timeSig[0], tx, STAFF_TOP + SP * 1.2) + ctx.fillText(timeSig[1], tx, STAFF_TOP + SP * 3.2) + } + + // ── MIDI → diatonic position helpers ───────────────────────────────────── + readonly property var CHROM_DIA: [0,0,1,1,2,3,3,4,4,5,5,6] + + function midiToDia(pitch) { + return Math.floor(pitch / 12) * 7 + CHROM_DIA[pitch % 12] + } + + // clef bottom-line diatonic reference + function _clefBottom() { + const clef = stemData ? stemData.clef : "treble" + if (clef === "bass") return 18 // G2 + if (clef === "alto") return 24 // C3 + return 30 // E4 + } + function _clefMid() { + const clef = stemData ? stemData.clef : "treble" + if (clef === "bass") return 22 + if (clef === "alto") return 28 + return 34 + } + + function _yForDia(dia) { + const bottom = _clefBottom() + return STAFF_TOP + 4 * SP - (dia - bottom) * (SP / 2) + } + + // ── Draw one notehead ──────────────────────────────────────────────────── + function _drawNote(ctx, x, dia, filled, color) { + const y = _yForDia(dia) + const up = dia < _clefMid() + ctx.strokeStyle = color + ctx.fillStyle = color + ctx.lineWidth = 1.6 + + // notehead ellipse + ctx.save() + ctx.translate(x, y) + ctx.rotate(-0.35) + ctx.beginPath() + ctx.ellipse(0, 0, 5.2, 3.8, 0, 0, 2 * Math.PI) + if (filled) ctx.fill(); else ctx.stroke() + ctx.restore() + + // stem + const stemLen = SP * 3.2 + const sx = up ? x + 4.6 : x - 4.6 + const sy1 = up ? y - 1 : y + 1 + const sy2 = up ? y - stemLen : y + stemLen + ctx.lineWidth = 1.4 + ctx.beginPath(); ctx.moveTo(sx, sy1); ctx.lineTo(sx, sy2); ctx.stroke() + + // ledger lines + const top = _clefBottom() + 8 + const bot = _clefBottom() + if (dia > top) { + for (let d = top + 2; d <= dia; d += 2) { + const ly = _yForDia(d) + ctx.lineWidth = 1.2 + ctx.beginPath(); ctx.moveTo(x-8, ly); ctx.lineTo(x+8, ly); ctx.stroke() + } + } else if (dia < bot) { + for (let d = bot - 2; d >= dia; d -= 2) { + const ly = _yForDia(d) + ctx.lineWidth = 1.2 + ctx.beginPath(); ctx.moveTo(x-8, ly); ctx.lineTo(x+8, ly); ctx.stroke() + } + } + } + + // ── Draw rest symbol ───────────────────────────────────────────────────── + function _drawRest(ctx, x, v, color) { + ctx.fillStyle = color + ctx.strokeStyle = color + const my = STAFF_TOP + 2 * SP + if (v <= 1) { + ctx.fillRect(x - 5, my - SP - 3, 10, 4) + } else if (v === 2) { + ctx.fillRect(x - 5, my + 1, 10, 4) + } else if (v === 4) { + ctx.lineWidth = 1.5 + ctx.beginPath(); ctx.moveTo(x, my - 8); ctx.lineTo(x+5, my+2); ctx.lineTo(x-2, my+7); ctx.stroke() + } else { + ctx.lineWidth = 1.3 + ctx.beginPath(); ctx.arc(x, my, 3, 0, Math.PI * 2); ctx.stroke() + } + } + + // ── Real MIDI rendering ────────────────────────────────────────────────── + function _drawRealNotes(ctx, midiNotes) { + const color = stemColor + const bps = tempo / 60 + const beats = timeSig[0] + + // Quantize to 1/16 grid + function qz(b) { return Math.round(b * 4) / 4 } + + const events = [] + for (let i = 0; i < midiNotes.length; i++) { + const n = midiNotes[i] + if (n.drum) continue + const bs = qz(n.start * bps) + if (bs >= bars * beats) continue + events.push({ bs, dia: midiToDia(n.pitch) }) + } + + // Sort, deduplicate per slot (highest pitch wins) + const slotMap = {} + for (let i = 0; i < events.length; i++) { + const e = events[i] + const bar = Math.floor(e.bs / beats) + const slot = Math.round((e.bs - bar * beats) * 4) + const key = bar + ":" + slot + if (!slotMap[key] || e.dia > slotMap[key].dia) + slotMap[key] = { bar, slot, dia: e.dia } + } + + ctx.lineWidth = 1.4 + for (const key in slotMap) { + const e = slotMap[key] + const usable = BAR_W - NOTE_PAD_L - NOTE_PAD_R + const mx = FIRST_PAD + e.bar * BAR_W + NOTE_PAD_L + const x = mx + (e.slot / (beats * 4)) * usable + _drawNote(ctx, x, e.dia, true, color) + } + } + + // ── Seeded fake notes (demo projects without MIDI) ─────────────────────── + function _rng(seed) { + let s = (seed * 2654435761) % 2147483647 + return function() { s = (s * 16807) % 2147483647; return s / 2147483647 } + } + + function _drawFakeNotes(ctx) { + if (!stemData) return + const color = stemColor + const beats = timeSig[0] + const rnd = _rng((stemData.seed || 1) + 0) + const clef = stemData.clef || "treble" + if (clef === "perc") { _drawFakeDrums(ctx, beats, rnd, color); return } + + const lo = _clefBottom() + 2 + const hi = _clefBottom() + 12 + let cur = Math.round((lo + hi) / 2) + + for (let m = 0; m < bars; m++) { + const usable = BAR_W - NOTE_PAD_L - NOTE_PAD_R + const mx = FIRST_PAD + m * BAR_W + NOTE_PAD_L + const density = stemData.density || 2 + const noteCount = density + Math.floor(rnd() * 2) + for (let n = 0; n < noteCount; n++) { + const x = mx + (n / noteCount) * usable + 8 + const step = rnd() < 0.6 ? (rnd()<0.5?1:-1) : (rnd()<0.5?2:-2) + cur = Math.max(lo, Math.min(hi, cur + step)) + _drawNote(ctx, x, cur, true, color) + } + } + } + + function _drawFakeDrums(ctx, beats, rnd, color) { + const yHat = _yForDia(_clefBottom() + 10) + const ySnare = _yForDia(_clefBottom() + 4) + const yKick = _yForDia(_clefBottom()) + for (let m = 0; m < bars; m++) { + const usable = BAR_W - NOTE_PAD_L - NOTE_PAD_R + const mx = FIRST_PAD + m * BAR_W + NOTE_PAD_L + for (let b = 0; b < beats * 2; b++) { + const x = mx + (b / (beats * 2)) * usable + 8 + // hihat X + ctx.strokeStyle = color; ctx.lineWidth = 1.6 + ctx.beginPath(); ctx.moveTo(x-3,yHat-3); ctx.lineTo(x+3,yHat+3); ctx.stroke() + ctx.beginPath(); ctx.moveTo(x-3,yHat+3); ctx.lineTo(x+3,yHat-3); ctx.stroke() + // snare on backbeats + if (beats>=4 && (b===2||b===6)) { + ctx.fillStyle=color; ctx.beginPath() + ctx.ellipse(x,ySnare,4.8,3.5,-0.4,0,2*Math.PI); ctx.fill() + } + // kick + if (b===0||(rnd()<0.15&&b%2===0)) { + ctx.fillStyle=color; ctx.beginPath() + ctx.ellipse(x,yKick,4.8,3.5,-0.4,0,2*Math.PI); ctx.fill() + } + } + } + } +} diff --git a/frontend/qml/views/MainWindow.qml b/frontend/qml/views/MainWindow.qml index d2bf079..4e81ab5 100644 --- a/frontend/qml/views/MainWindow.qml +++ b/frontend/qml/views/MainWindow.qml @@ -7,18 +7,18 @@ import "../components" ApplicationWindow { id: root visible: true - width: 1440 - height: 900 - minimumWidth: 1100 - minimumHeight: 700 + width: 1440; height: 900 + minimumWidth: 1100; minimumHeight: 700 title: "Aegis Score Studio" + color: "#0b0e17" - // ── State ────────────────────────────────────────────────────────────── - property string scene: "empty" // empty | processing | success | error - property var liveJob: null + // ── Global state ─────────────────────────────────────────────────────── + property string scene: "empty" + property var liveJob: null property string sessionId: "" - property real progress: 0.0 - property int stage: -1 + property real progress: 0.0 + property int stage: -1 + property string errorMsg: "" // ── Bridge connections ───────────────────────────────────────────────── Connections { @@ -26,8 +26,9 @@ ApplicationWindow { function onSessionStarted(sid) { root.sessionId = sid - root.scene = "processing" - root.progress = 0.0 + root.scene = "processing" + root.progress = 0.0 + root.liveJob = null } function onProgressChanged(prog, stg, status) { @@ -41,14 +42,29 @@ ApplicationWindow { } function onErrorOccurred(msg) { - root.scene = "error" - errorMessage.text = msg + root.errorMsg = msg + root.scene = "error" } } + // ── Flash toast ──────────────────────────────────────────────────────── + property alias flashText: flashToast.message + function flash(msg) { flashToast.show(msg) } + + FlashToast { id: flashToast; anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter; bottomMargin: 24 } } + // ── Layout ───────────────────────────────────────────────────────────── + // Omnibar fixed at top + OmniBar { + id: omni + anchors { top: parent.top; left: parent.left; right: parent.right } + height: 46 + onSubmitUrl: (url) => AppBridge.analyzeUrl(url) + onOpenFile: (path) => AppBridge.openLocalFile(path) + } + RowLayout { - anchors.fill: parent + anchors { top: omni.bottom; left: parent.left; right: parent.right; bottom: statusBar.top } spacing: 0 // Left sidebar @@ -56,59 +72,104 @@ ApplicationWindow { Layout.preferredWidth: 220 Layout.fillHeight: true job: root.liveJob + onLoadSession: (sid) => { /* TODO: restore session */ } } - // Center: score / processing / empty - Rectangle { + // Divider + Rectangle { Layout.preferredWidth: 1; Layout.fillHeight: true; color: "#1a2035" } + + // Center content + Item { Layout.fillWidth: true Layout.fillHeight: true - color: "#0f1117" + // Scene switcher Loader { + id: centerLoader anchors.fill: parent - source: { - if (root.scene === "processing") return "../views/ProcessingView.qml" - if (root.scene === "success") return "../views/ScoreView.qml" - if (root.scene === "error") return "../views/ErrorView.qml" - return "../views/EmptyView.qml" - } + + states: [ + State { name: "empty"; when: root.scene === "empty"; PropertyChanges { target: centerLoader; source: "../views/EmptyView.qml" } }, + State { name: "processing"; when: root.scene === "processing"; PropertyChanges { target: centerLoader; source: "../views/ProcessingView.qml" } }, + State { name: "success"; when: root.scene === "success"; PropertyChanges { target: centerLoader; source: "../views/ScoreView.qml" } }, + State { name: "error"; when: root.scene === "error"; PropertyChanges { target: centerLoader; source: "../views/ErrorView.qml" } } + ] + onLoaded: { - if (root.scene === "processing") item.progress = Qt.binding(()=> root.progress) - if (root.scene === "success") item.job = Qt.binding(()=> root.liveJob) + if (root.scene === "processing") { + item.progress = Qt.binding(() => root.progress) + item.stage = Qt.binding(() => root.stage) + } + if (root.scene === "success") { + item.job = Qt.binding(() => root.liveJob) + item.sessionId = Qt.binding(() => root.sessionId) + } + if (root.scene === "error") { + item.message = Qt.binding(() => root.errorMsg) + } } } } + // Divider + Rectangle { Layout.preferredWidth: 1; Layout.fillHeight: true; color: "#1a2035" } + // Right inspector Inspector { - Layout.preferredWidth: 260 + Layout.preferredWidth: 270 Layout.fillHeight: true - job: root.liveJob + job: root.liveJob sessionId: root.sessionId + onExportMidi: _downloadStems("midi") + onExportWav: _downloadStems("wav") } } - // ── Omnibar (top) — future: promote to real toolbar ─────────────────── - OmniBar { - id: omni - anchors { top: parent.top; left: parent.left; right: parent.right } - height: 46 - onSubmitUrl: (url) => AppBridge.analyzeUrl(url) - onOpenFile: (path) => AppBridge.openLocalFile(path) + // Status bar + Rectangle { + id: statusBar + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 24 + color: "#070b13" + border { width: 0; color: "transparent" } + + Row { + anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 12 } + spacing: 20 + Text { text: _sceneLabel(); color: "#475569"; font.pixelSize: 11 } + Text { text: _gpuLabel(); color: "#334155"; font.pixelSize: 11 } + Text { text: _audioLabel(); color: "#334155"; font.pixelSize: 11 } + } + + Text { + anchors { verticalCenter: parent.verticalCenter; right: parent.right; rightMargin: 12 } + text: "Aegis Score Studio v0.1" + color: "#1e2d42"; font.pixelSize: 10 + } } - // Nudge content down below omnibar - Item { - id: omniSpacer - anchors { top: omni.bottom; left: parent.left; right: parent.right } - height: 0 + function _sceneLabel() { + const map = { empty:"Idle", processing:"Analyzing…", success:"Ready", error:"Error" } + return map[root.scene] || "" + } + function _gpuLabel() { + if (!root.liveJob) return "" + const g = root.liveJob.meta.gpu + return g.model + (g.vramTotal > 0 ? " · " + g.vram.toFixed(1) + "/" + g.vramTotal.toFixed(1) + " GB" : "") + } + function _audioLabel() { + if (!root.liveJob) return "" + const m = root.liveJob.meta + return (m.sampleRate/1000).toFixed(0) + " kHz · " + m.bitDepth + "-bit" } - // ── Error label (temp) ──────────────────────────────────────────────── - Label { - id: errorMessage - visible: root.scene === "error" - anchors.centerIn: parent - color: "#f87171" + function _downloadStems(type) { + if (!root.liveJob) return + const sid = root.sessionId + root.liveJob.stems.forEach(function(s) { + const url = type === "midi" ? s.midi_url : s.audio_url + if (url) Qt.openUrlExternally("http://127.0.0.1:7471" + url) + }) + root.flash("Downloading " + type.toUpperCase() + " files…") } } diff --git a/frontend/qml/views/ScoreView.qml b/frontend/qml/views/ScoreView.qml index bdb6de7..fb483d8 100644 --- a/frontend/qml/views/ScoreView.qml +++ b/frontend/qml/views/ScoreView.qml @@ -1,69 +1,191 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import "../components" -/* - ScoreView: shows stems as staff rows + playback timeline. - Notation rendering: QtQuick Canvas (port of notation.js logic). - Phase 1 stub — full Canvas renderer TBD. -*/ Item { - property var job: null + id: root + property var job: null + property string sessionId: "" - ScrollView { + readonly property var stems: job ? (job.stems || []) : [] + readonly property int tempo: job ? (job.meta.tempo || 120) : 120 + readonly property var timeSig: job ? (job.meta.timeSig || [4,4]) : [4,4] + readonly property int bars: 8 + + // Load MIDI notes from backend for each stem when job arrives + onJobChanged: { + if (!job || !sessionId) return + for (let i = 0; i < stems.length; i++) { + const s = stems[i] + if (s.midi_url && s.clef !== "perc") { + const xhr = new XMLHttpRequest() + xhr.open("GET", "http://127.0.0.1:7471/api/midi/" + sessionId + "/" + s.id) + xhr.onreadystatechange = (function(stemObj) { + return function() { + if (xhr.readyState === 4 && xhr.status === 200) { + const d = JSON.parse(xhr.responseText) + stemObj.midiNotes = d.notes || [] + staffRepeater.itemAt(stems.indexOf(stemObj))?.reload() + } + } + })(s) + xhr.send() + } + } + } + + ColumnLayout { anchors.fill: parent - clip: true + spacing: 0 + + // ── Transport bar ────────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 44 + color: "#0d1220" - Column { - width: parent.width - spacing: 0 + Row { + anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } + spacing: 12 - Repeater { - model: job ? job.stems : [] + // Play/pause + Rectangle { + width: 32; height: 32; radius: 6 + color: playBtn.containsMouse ? "#1e2d4a" : "#141c2e" + border.color: "#2d3a52" + MouseArea { id: playBtn; anchors.fill: parent; hoverEnabled: true } + Text { anchors.centerIn: parent; text: "▶"; color: "#60a5fa"; font.pixelSize: 14 } + } + // Timecode Rectangle { - width: parent.width - height: 90 - color: index % 2 === 0 ? "#0f1117" : "#111520" + height: 32; width: 120; radius: 6 + color: "#0a0f1a" + border.color: "#1e2535" + Text { anchors.centerIn: parent; text: "001 : 1 : 00"; color: "#60a5fa"; font.family: "monospace"; font.pixelSize: 13 } + } - Row { - anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } - spacing: 12 + // Tempo + Text { text: tempo + " BPM"; color: "#94a3b8"; font.pixelSize: 13; anchors.verticalCenter: parent.verticalCenter } + Text { text: timeSig[0]+"/"+timeSig[1]; color: "#64748b"; font.pixelSize: 13; anchors.verticalCenter: parent.verticalCenter } + Text { text: job ? job.meta.key : ""; color: "#64748b"; font.pixelSize: 13; anchors.verticalCenter: parent.verticalCenter } + } - Rectangle { - width: 4; height: 60; radius: 2 - color: "#60a5fa" // TODO: map modelData.color - } + // Right: stem count + Text { + anchors { verticalCenter: parent.verticalCenter; right: parent.right; rightMargin: 16 } + text: stems.length + " stems · " + (stems.reduce((a,s)=>a+s.midi,0)).toLocaleString() + " MIDI events" + color: "#475569"; font.pixelSize: 12 + } + } - Column { - spacing: 4 - anchors.verticalCenter: parent.verticalCenter - Label { text: modelData.name; color: "#e2e8f0"; font.pixelSize: 13; font.weight: Font.Medium } - Label { text: modelData.instrument; color: "#64748b"; font.pixelSize: 11 } - Label { text: modelData.midi + " notes"; color: "#475569"; font.pixelSize: 10 } - } + Rectangle { Layout.fillWidth: true; height: 1; color: "#1e2535" } - // Staff canvas — notation rendered here - Canvas { - id: staffCanvas - width: 600 - height: 80 - anchors.verticalCenter: parent.verticalCenter - // TODO: port buildMeasuresFromMidi + drawNote to QtQuick Canvas - onPaint: { - const ctx = getContext("2d") - ctx.clearRect(0, 0, width, height) - ctx.strokeStyle = "#334155" - ctx.lineWidth = 1 - for (let i = 0; i < 5; i++) { - const y = 20 + i * 9 - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke() + // ── Score body ───────────────────────────────────────────────────── + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ScrollBar.horizontal.policy: ScrollBar.AsNeeded + ScrollBar.vertical.policy: ScrollBar.AsNeeded + + Column { + width: implicitWidth + spacing: 0 + + // Song title header + Rectangle { + width: Math.max(parent.width, 600) + height: 56 + color: "#0b0f1a" + Column { + anchors { left: parent.left; leftMargin: 72; verticalCenter: parent.verticalCenter } + spacing: 3 + Text { text: job ? job.meta.title : ""; color: "#e2e8f0"; font.pixelSize: 18; font.weight: Font.Light } + Text { text: job ? (job.meta.artist + " · " + job.meta.key + " · ♩ = " + tempo) : ""; color: "#64748b"; font.pixelSize: 12 } + } + } + + // Staff rows + Repeater { + id: staffRepeater + model: stems + + Rectangle { + id: staffRow + width: Math.max(staffCanvas.implicitWidth + 68 + 8, 600) + height: 90 + color: index % 2 === 0 ? "#0f1117" : "#0b0e17" + + // Left label + Item { + id: labelCol + width: 68 + height: parent.height + + Column { + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + spacing: 3 + + Text { + text: modelData.name + color: "#cbd5e1"; font.pixelSize: 11; font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + width: 60; elide: Text.ElideRight + } + Text { + text: Math.round(modelData.confidence) + "%" + color: modelData.confidence >= 90 ? "#34d399" : modelData.confidence >= 78 ? "#fbbf24" : "#f87171" + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight + width: 60 } } } + + // Staff canvas + StaffCanvas { + id: staffCanvas + x: 68 + y: 0 + height: parent.height + stemData: modelData + tempo: root.tempo + timeSig: root.timeSig + bars: root.bars + stemColor: _stemColor(modelData.color) + + function reload() { requestPaint() } + } + + // Stem color bar + Rectangle { + x: 64; y: 16; width: 3; height: 58; radius: 2 + color: _stemColor(modelData.color) + } } } + + // Bottom padding + Item { height: 40; width: parent.width } } } } + + function _stemColor(cssVar) { + const map = { + "--s-drums": "#f97316", + "--s-bass": "#a855f7", + "--s-guitar": "#22c55e", + "--s-keys": "#3b82f6", + "--s-vocal": "#ec4899", + "--s-synth": "#06b6d4", + "--s-strings":"#84cc16", + "--s-brass": "#eab308", + "--s-wood": "#14b8a6", + "--s-perc": "#f59e0b", + } + return map[cssVar] || "#60a5fa" + } } diff --git a/main.js b/main.js new file mode 100644 index 0000000..dace595 --- /dev/null +++ b/main.js @@ -0,0 +1,171 @@ +const { app, BrowserWindow, dialog, shell, ipcMain } = require('electron'); +const { spawn } = require('child_process'); +const http = require('http'); +const path = require('path'); + +const PORT = 7471; +let win; +let server; + +// ── Python backend ──────────────────────────────────────────────────────────── +function portInUse() { + return new Promise(resolve => { + 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; + } + const py = process.platform === 'win32' ? 'python' : 'python3'; + server = spawn(py, ['-m', 'uvicorn', 'server:app', `--port=${PORT}`, '--log-level=warning'], { + cwd: __dirname, + stdio: ['ignore', 'pipe', 'pipe'], + }); + 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; + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', server.pid, '/f', '/t']); + } else { + server.kill('SIGTERM'); + } + server = null; +} + +// 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.loadURL(`data:text/html,
AEGISSCORE
Starting local engine…
`); + + 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, + 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 = + ' +
+ +
+
Export Score
+
+ + +
+
+ + + +
+
+
- + ⌘L
-
GPU14%
-
VRAM6.2G
JD
@@ -51,9 +189,9 @@
- Workspace/ - Atlas of Sleep/ - Midnight Cartography + Workspace + +
Analysis complete
@@ -62,19 +200,9 @@ @@ -103,6 +231,11 @@
+
+ + + +
@@ -118,11 +251,7 @@

Deconstruct any track

-
- Midnight Cartography - Aurora Borealis Suite - Gymnopédie No. 1 -
+
@@ -131,12 +260,25 @@

Deconstruct any track

-
Midnight Cartography
Neon Tide · 3:38 · 48 kHz/24-bit
+
Fetching…
0%
+
+ + + + + + + + + + + +
-
Estimating…GPU · RTX 4090
+
Estimating…
@@ -178,12 +320,13 @@

Deconstruct any track

+ -
009:1:000:16.2
+
001:1:000:00.0
-
Tempo122 BPM
-
Sig4/4
-
KeyF# min
+
Tempo—
+
Sig—
+
Key—
@@ -224,27 +367,114 @@

Deconstruct any track

-
HT-Demucs v4
-
6 stems · 912 MIDI events
-
Lead Synth selected
-
48 kHz · 24-bit
+
—
+
—
+
—
+
100%
-
Ready
+
Idle
+ +
+
+ +
+
+
+ + + +
+
AEGISSCORE
+
Studio · v1.0
+
+ +
Audio is processed locally.
Nothing leaves your machine.
+
+ + +
+ + +
+
Open Audio Source
+
Paste a YouTube link or drag in a local audio file to separate stems, transcribe MIDI, and engrave notation.
+ + +
+ + +
+ +
or
+ +
+
+ + + +
+
Drop an audio file here
+
MP3 · WAV · FLAC · AAC · OGG · M4A · MP4 · WEBM
+ +
+ +
+ +
+
+ + +
+
Recent Sessions
+
Sessions from this browser. Reopen to skip reprocessing if the server still has the data.
+
No recent sessions yet.
+
+ +
+
+
+
+ +
+
+
+
+ Stem + + +
+
+ +
+
+
+
+ + diff --git a/js/audio.js b/js/audio.js index 4ed9ad7..7f11580 100644 --- a/js/audio.js +++ b/js/audio.js @@ -52,11 +52,17 @@ const AudioEngine = (() => { } // Start playback from offsetSecs into the audio - function start(offsetSecs = 0) { + async function start(offsetSecs = 0) { const ac = _ensureCtx(); + // Set offset immediately so currentSecs() returns correct position during any async gap + _startOffset = offsetSecs; + // Await resume — critical: sources started in a suspended context may replay + // when context finally resumes, making stop() appear to have no effect + if (ac.state !== 'running') { + try { await ac.resume(); } catch (_) {} + } _stop_sources(); _startCtxTime = ac.currentTime; - _startOffset = offsetSecs; _playing = true; for (const [id, stem] of Object.entries(stems)) { @@ -72,8 +78,11 @@ const AudioEngine = (() => { } function stop() { + if (_playing && ctx) _startOffset = _startOffset + (ctx.currentTime - _startCtxTime); _playing = false; _stop_sources(); + // Suspend context — guarantees silence even if browser hasn't fully processed source.stop() + if (ctx && ctx.state === 'running') ctx.suspend().catch(() => {}); } function _stop_sources() { @@ -109,9 +118,176 @@ const AudioEngine = (() => { if (stems[id]) stems[id].gain.gain.setTargetAtTime(value, ctx?.currentTime || 0, 0.02); } + // Compute peak amplitudes at PEAK_RES points (cached on stem object) + const PEAK_RES = 512; + function getPeaks(id) { + const stem = stems[id]; + if (!stem?.buffer) return null; + if (stem._peaks) return stem._peaks; + const buf = stem.buffer; + const total = buf.length; + const nCh = buf.numberOfChannels; + const blockSize = Math.ceil(total / PEAK_RES); + const peaks = new Float32Array(PEAK_RES); + for (let c = 0; c < nCh; c++) { + const data = buf.getChannelData(c); + for (let i = 0; i < PEAK_RES; i++) { + const start = i * blockSize; + const end = Math.min(start + blockSize, total); + let max = 0; + for (let j = start; j < end; j += 4) { // sample every 4th — 4× faster, negligible quality loss + const a = Math.abs(data[j]); + if (a > max) max = a; + } + if (max > peaks[i]) peaks[i] = max; + } + } + stem._peaks = peaks; + return peaks; + } + function isLoaded() { return Object.values(stems).some(s => s.buffer); } function hasStem(id) { return !!(stems[id]?.buffer); } return { loadStem, loadFromSession, start, stop, currentSecs, - setMute, setSolo, setGain, isLoaded, hasStem }; + setMute, setSolo, setGain, isLoaded, hasStem, getPeaks }; +})(); + +/* ============================================================ + MidiEngine — Web Audio API synthesiser for MIDI note playback + Schedules notes ~800 ms ahead in a rolling setTimeout loop. + ============================================================ */ +const MidiEngine = (() => { + let ctx = null; + let _notes = []; // [{start,end,pitch,velocity,drum,stemId}] sorted by start + let _nodes = []; // active AudioNodes for cleanup + let _startCtxTime = 0; + let _startOffset = 0; + let _playing = false; + let _nextIdx = 0; + let _schedTimer = null; + const AHEAD = 0.8; // schedule up to 800 ms ahead + const TICK_MS = 200; // re-check every 200 ms + const _mutedStems = new Set(); + const _soloedStems = new Set(); + + function _ensureCtx(){ + if(!ctx) ctx = new AudioContext(); + if(ctx.state==='suspended') ctx.resume(); + return ctx; + } + + function load(stems){ + _notes = []; + for(const s of stems){ + if(!s.midiNotes?.length) continue; + for(const n of s.midiNotes) _notes.push({...n, stemId:s.id}); + } + _notes.sort((a,b)=>a.start-b.start); + } + + function _noteOn(ac, n, atTime){ + const hasSolo = _soloedStems.size > 0; + if(_mutedStems.has(n.stemId)) return; + if(hasSolo && !_soloedStems.has(n.stemId)) return; + + const dur = Math.max(0.04, n.end - n.start); + const vel = (n.velocity || 70) / 127; + + if(n.drum){ + const bufSize = Math.ceil(ac.sampleRate * Math.min(dur, 0.14)); + const buf = ac.createBuffer(1, bufSize, ac.sampleRate); + const d = buf.getChannelData(0); + for(let i=0;i{}); + } + + function _stop_nodes(){ + const t = ctx?.currentTime || 0; + for(const n of _nodes){ + try{ if(n.stop) n.stop(t+0.01); }catch(_){} + try{ n.disconnect(); }catch(_){} + } + _nodes = []; + } + + function currentSecs(){ + if(!_playing || !ctx) return _startOffset; + return _startOffset + (ctx.currentTime - _startCtxTime); + } + + function isLoaded(){ return _notes.length > 0; } + + function setMute(id, muted){ + if(muted) _mutedStems.add(id); else _mutedStems.delete(id); + } + function setSolo(soloSet){ + _soloedStems.clear(); + for(const id of soloSet) _soloedStems.add(id); + } + // Reload stems into engine (called after midiNotes fetch completes) + function reload(stems){ if(stems) load(stems); } + + return { load, reload, start, stop, currentSecs, isLoaded, setMute, setSolo }; })(); diff --git a/js/controller.js b/js/controller.js index 937a81d..bb323b9 100644 --- a/js/controller.js +++ b/js/controller.js @@ -34,27 +34,46 @@ function iconFor(stem){ return ICONS.synth; } function svgIco(path,cls){ return `${path}`; } +const _api = p => (window.__API__ || '') + p; /* ---------------- STATE ---------------- */ const STATE = { - pkey:"band", scene:"success", sel:"synth", + pkey:null, scene:"empty", sel:null, playing:false, pos:0 /*bars*/, muted:new Set(), solo:new Set(), itab:"instrument", zoom:1, barPx:16, showWave:true, showGrid:true, url:"", liveProject:null, liveSid:null, + focusStem:null, + playMode:"wav", // "wav" | "midi" }; -let raf=null, lastT=0; -const P = ()=>STATE.liveProject || PROJECTS[STATE.pkey]; -function totalBars(){ const p=P(); return Math.ceil(p.duration*p.tempo/60/p.timeSig[0]); } +let raf=null, lastT=0, _progScrollLeft=-1; +const P = ()=>STATE.liveProject || PROJECTS[STATE.pkey] || null; +function totalBars(){ const p=P(); if(!p) return 0; return Math.ceil(p.duration*p.tempo/60/p.timeSig[0]); } +function _calcBars(){ return Math.max(4, Math.min(32, Math.floor((document.getElementById("score-systems")?.clientWidth||800)/120))); } + +// ── SVG staff cache — avoids re-rendering notation on scroll/mute/zoom ────── +let _staffCacheVer = 0; +const _staffCache = new Map(); // "${ver}|${stemId}|${barOffset}|${key}" → SVGElement +function _cachedStaff(s, p) { + const key = `${_staffCacheVer}|${s.id}|${p.barOffset}|${p.key}`; + if (_staffCache.has(key)) return _staffCache.get(key).cloneNode(true); + const svg = renderStaff(s, p, {salt:0}); + _staffCache.set(key, svg); + if (_staffCache.size > 300) _staffCache.delete(_staffCache.keys().next().value); // LRU evict + return svg.cloneNode(true); +} +function _invalidateStaffCache() { _staffCacheVer++; _staffCache.clear(); } /* ---------------- TOP / SUB META ---------------- */ function renderMeta(){ - const p=P(); - if(document.activeElement!==$("#omni-input")) $("#omni-input").value = p.source?.startsWith("http") ? p.source : "https://"+p.source; - $("#crumb-title").textContent = p.title; - $("#gpu-bar").style.width=p.gpu.util+"%"; $("#gpu-val").textContent=p.gpu.util+"%"; - const vr=p.gpu.vramTotal>0?Math.round(p.gpu.vram/p.gpu.vramTotal*100):0; - $("#vram-bar").style.width=vr+"%"; $("#vram-val").textContent=p.gpu.vramTotal>0?p.gpu.vram.toFixed(1)+"G":"—"; - $("#gpu-bar").style.background = p.gpu.util>60?"linear-gradient(90deg,var(--warn),var(--play))":"linear-gradient(90deg,var(--acc),var(--acc-bright))"; + const p=P(); if(!p) return; + if(document.activeElement!==$("#omni-input")) $("#omni-input").value = p.source?.startsWith("http") ? p.source : ""; + $("#crumb-title").textContent=p.title; + $("#crumb-sep").style.display=""; + $("#sbar-dot").style.background="var(--good)"; + $("#sbar-model").textContent=p.sepModel.split(" · ")[0]; + const midiTot=p.stems.reduce((a,s)=>a+s.midi,0); + $("#sbar-stems").textContent=p.stems.length+" stems · "+midiTot.toLocaleString()+" MIDI events"; + $("#sbar-fmt").textContent=`${p.sampleRate/1000} kHz · ${p.bitDepth}-bit`; $("#sub-meta").innerHTML = `tempo ${p.tempo}`+ `key ${p.key}`+ @@ -64,55 +83,32 @@ function renderMeta(){ $("#st-tempo").innerHTML=`${p.tempo} BPM`; $("#st-sig").textContent=p.timeSig.join("/"); $("#st-key").textContent=p.key.replace(" minor"," min").replace(" major"," maj"); - $("#sbar-model").textContent=p.sepModel.split(" · ")[0]; - const midiTot=p.stems.reduce((a,s)=>a+s.midi,0); - $("#sbar-stems").textContent=p.stems.length+" stems"; - $("#sbar-midi").textContent=midiTot.toLocaleString()+" MIDI events"; - $(".status .sb.right").textContent=`${(p.sampleRate/1000)} kHz · ${p.bitDepth}-bit`; } /* ---------------- LEFT ---------------- */ function renderLeft(){ const p=P(); - // navigator: projects as expandable; current expanded showing stems - const projs=Object.values(PROJECTS); - $("#nav-count").textContent=projs.length; - $("#nav-tree").innerHTML = projs.map(pr=>{ - const open=pr.id===STATE.pkey; - const head=`
- ${svgIco(open?'':'')} - ${pr.title}${pr.stems.length}
`; - let kids=""; - if(open){ kids=pr.stems.map(s=>`
- - ${s.name}${Math.round(s.confidence)}%
`).join(""); } - return head+kids; - }).join(""); - // files - $("#file-tree").innerHTML = [ - {t:"📁 source.wav",m:fmtSize(p),f:1}, - {t:"📁 stems/",m:p.stems.length,f:1}, - ].map(()=>"").join("") + - `
${svgIco('')}source_master.wav${fmtSize(p)}
`+ - p.stems.map(s=>`
${svgIco('')}${s.id}.mid${s.midi||'—'}
`).join("")+ - `
${svgIco('')}${p.id}_score.musicxmlXML
`; - // history - const hist=[ - {p:"band",t:"2m ago"},{p:"orchestra",t:"1h ago"},{p:"piano",t:"3h ago"}, - {p:"band",t:"yesterday",alt:"Saffron Light — Loom"},{p:"orchestra",t:"2d ago",alt:"Concerto in E — Aria"}, - ]; - $("#hist-count").textContent=hist.length; - $("#hist-list").innerHTML=hist.map(h=>{const pr=PROJECTS[h.p];return `
-
-
${h.alt||pr.title}
${pr.stems.length} stems · ${pr.key}
-
${h.t}
`;}).join(""); + if(!p){ + $("#nav-count").textContent=""; + $("#nav-tree").innerHTML=''; + return; + } + $("#nav-count").textContent=p.stems.length; + $("#nav-tree").innerHTML= + `
${p.title}
`+ + p.stems.map(s=>`
+ + ${s.name} + ${Math.round(s.confidence)}% +
`).join(""); } /* ---------------- SCORE ---------------- */ function renderScore(){ - const p=P(); + const p=P(); if(!p) return; + const tb=totalBars(); $("#score-title").innerHTML=`

${p.title}

-
${p.artist}${p.key}♩ = ${p.tempo}${p.timeSig.join("/")}bars ${p.barOffset}–${p.barOffset+p.bars-1}${p.transModel}
`; +
${p.artist}${p.key}♩ = ${p.tempo}${p.timeSig.join("/")}${p.transModel}
`; const wrap=$("#score-systems"); wrap.innerHTML=""; const solo=STATE.solo.size>0; p.stems.forEach(s=>{ @@ -126,17 +122,32 @@ function renderScore(){
${s.name}
${clefName(s.clef)}${s.confidence.toFixed(1)}%${s.removed?'muted':''}
`; - const svg=renderStaff(s,p,{salt:0}); + const svg=_cachedStaff(s,p); row.querySelector(".staff-svg-wrap").appendChild(svg); wrap.appendChild(row); }); + // bar nav + const nav=document.getElementById("score-nav"); + const lbl=document.getElementById("score-nav-label"); + const prev=document.getElementById("score-prev"); + const next=document.getElementById("score-next"); + if(nav){ nav.style.display=tb>p.bars?"flex":"none"; } + if(lbl){ lbl.textContent=`Bars ${p.barOffset+1}–${Math.min(p.barOffset+p.bars,tb)}`; } + if(prev){ prev.disabled=p.barOffset<=0; } + if(next){ next.disabled=p.barOffset+p.bars>=tb; } applyZoom(); } +function scoreNavStep(dir){ + const p=P(); if(!p) return; + const tb=totalBars(); + p.barOffset=Math.max(0,Math.min(tb-p.bars, p.barOffset+dir*p.bars)); + renderScore(); +} function clefName(c){return {treble:"Treble",bass:"Bass",alto:"Alto",perc:"Perc"}[c]||c;} /* ---------------- TIMELINE ---------------- */ function renderTimeline(){ - const p=P(); const beats=p.timeSig[0]; const tb=totalBars(); const bpx=STATE.barPx; + const p=P(); if(!p) return; const beats=p.timeSig[0]; const tb=totalBars(); const bpx=STATE.barPx; // gutter const solo=STATE.solo.size>0; $("#gutter").innerHTML=p.stems.map(s=>{ @@ -154,28 +165,42 @@ function renderTimeline(){ const grid=$("#grid-canvas"); drawGrid(grid, tb, bpx, beats, p.stems.length*laneH); grid.style.display=STATE.showGrid?"block":"none"; - // lanes - const lanes=$("#lanes"); lanes.innerHTML=""; + // lanes — reuse existing canvas elements when stem list + dimensions unchanged + const lanes=$("#lanes"); const W=tb*bpx; $("#lanes-wrap").style.width=W+"px"; $("#ruler").style.width=W+"px"; $("#lane-col").style.width=W+"px"; - // reserve gutter footer = horizontal scrollbar height so rows stay aligned at bottom const sb=$("#lane-scroll").offsetHeight-$("#lane-scroll").clientHeight; $("#gutter-foot").style.height=Math.max(0,sb)+"px"; - p.stems.forEach(s=>{ - const lane=document.createElement("div"); lane.className="lane"; lane.dataset.stem=s.id; - const cv=document.createElement("canvas"); lane.appendChild(cv); - lanes.appendChild(lane); - if(STATE.showWave) drawWaveform(cv, {...s, muted:STATE.muted.has(s.id)||(solo&&!STATE.solo.has(s.id))}, {width:W,height:laneH,beatPx:bpx/beats}); - else cv.style.display="none"; + const existingLanes=[...lanes.children]; + const stemIds=p.stems.map(s=>s.id).join(","); + const existingIds=existingLanes.map(l=>l.dataset.stem).join(","); + if(stemIds!==existingIds) lanes.innerHTML=""; // stem structure changed — full rebuild + p.stems.forEach((s,i)=>{ + let lane=lanes.children[i]; + let cv; + if(!lane){ + lane=document.createElement("div"); lane.className="lane"; lane.dataset.stem=s.id; + cv=document.createElement("canvas"); lane.appendChild(cv); + lanes.appendChild(lane); + } else { + cv=lane.querySelector("canvas"); + } + 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}); + cv.style.display=""; + } else { + cv.style.display="none"; + } }); positionPlayhead(); } /* ---------------- INSPECTOR ---------------- */ function renderInspector(){ - const p=P(); const s=p.stems.find(x=>x.id===STATE.sel)||p.stems[0]; + const p=P(); if(!p) return; const s=p.stems.find(x=>x.id===STATE.sel)||p.stems[0]; const body=$("#insp-body"); if(STATE.itab==="instrument") body.innerHTML=inspInstrument(p,s); else if(STATE.itab==="quality") body.innerHTML=inspQuality(p,s); @@ -209,10 +234,6 @@ function inspInstrument(p,s){
Register${regName(s)}
Polyphony${/key|pno|pad|harp|guitar/.test(s.id)?'poly':'mono'}
Role${s.role||'—'}
- -
-
${svgIco('')}AI Diagnostics${p.stems.length} sources
-
${p.diagnostics.map(d=>diagRow(d)).join("")}
`; } function inspQuality(p,s){ @@ -227,8 +248,6 @@ function inspQuality(p,s){
${svgIco('')}Separation summary
${metric("Overall confidence",overall,overall.toFixed(1)+"%",confColor(overall))} - ${metric("Phase coherence",94.2,"94.2%",'var(--good)')} - ${metric("Reconstruction error",8,"−24.1 dB",'var(--acc)',true)}
${svgIco('')}Per-stem confidence
@@ -265,13 +284,6 @@ function metric(k,pct,v,col,inverse){ return `
${k}${v}
`; } -function diagRow(d){ - const ic={ok:'',warn:'',err:'',info:''}[d.sev]; - const col={ok:'var(--good)',warn:'var(--warn)',err:'var(--bad)',info:'var(--acc-bright)'}[d.sev]; - return `
${svgIco(ic)} -
${d.t}
${d.s}
-
${d.v}
`; -} /* ---------------- helpers ---------------- */ function confColor(c){ return c>=90?'var(--good)':c>=78?'var(--warn)':'var(--bad)'; } @@ -292,8 +304,13 @@ function applyZoom(){ function positionPlayhead(){ const bpx=STATE.barPx; $("#playhead").style.left=(STATE.pos*bpx)+"px"; - // timecode - const p=P(); const beats=p.timeSig[0]; + const p=P(); + if(!p){ + $("#tc-bars").textContent="001"; $("#tc-beat").textContent="1"; + $("#tc-tick").textContent="00"; $("#tc-time").textContent="0:00.0"; + return; + } + const beats=p.timeSig[0]; const bar=Math.floor(STATE.pos)+1, beat=Math.floor((STATE.pos%1)*beats)+1, tick=Math.floor(((STATE.pos%1)*beats%1)*100); $("#tc-bars").textContent=String(bar).padStart(3,"0"); $("#tc-beat").textContent=beat; @@ -303,27 +320,78 @@ function positionPlayhead(){ } function tick(t){ if(!STATE.playing){ raf=null; return; } - if(!lastT)lastT=t; const dt=(t-lastT)/1000; lastT=t; - const p=P(); const barsPerSec=p.tempo/60/p.timeSig[0]; - STATE.pos+=barsPerSec*dt; + const p=P(); if(!p){ STATE.playing=false; raf=null; return; } + const beats=p.timeSig[0], bps=p.tempo/60/beats; + const eng = _activeEngine(); + if(eng.isLoaded()){ + STATE.pos=eng.currentSecs()*bps; + } else { + if(!lastT) lastT=t; + STATE.pos+=bps*(t-lastT)/1000; + } + lastT=t; if(STATE.pos>=totalBars()){ STATE.pos=0; } positionPlayhead(); - // autoscroll lane - const sc=$("#lane-scroll"); const x=STATE.pos*STATE.barPx; - if(x>sc.scrollLeft+sc.clientWidth-120||x= p.barOffset + p.bars){ + p.barOffset=Math.max(0, Math.min(totalBars()-p.bars, Math.floor(bar/p.bars)*p.bars)); + renderScore(); + } + } + if(STATE.focusStem){ + const secs=eng.isLoaded()?eng.currentSecs():barsToSecs(STATE.pos); + const fph=document.getElementById("focus-ph"); + if(fph) fph.style.left=Math.min(100,secs/Math.max(1,p.duration)*100)+"%"; + } + const sc=$("#lane-scroll"), x=STATE.pos*STATE.barPx; + if(x>sc.scrollLeft+sc.clientWidth-120||x':''; - if(v){ if(AudioEngine.isLoaded()) AudioEngine.start(barsToSecs(STATE.pos)); if(!raf) raf=requestAnimationFrame(tick); } - else { AudioEngine.stop(); } + const eng = _activeEngine(); + if(v){ if(eng.isLoaded()) eng.start(barsToSecs(STATE.pos)); if(!raf) raf=requestAnimationFrame(tick); } + else { eng.stop(); } +} + +/* ---------------- FOCUS TRACK ---------------- */ +function openFocusTrack(stemId){ + const p=P(); if(!p) return; + const s=p.stems.find(x=>x.id===stemId); if(!s) return; + STATE.focusStem=stemId; + STATE.solo=new Set([stemId]); + _activeEngine().setSolo([stemId]); + document.getElementById("focus-dot").style.background="var("+s.color+")"; + document.getElementById("focus-name").textContent=s.name; + document.getElementById("focus-info").textContent=s.instrument+(s.midi?" · "+s.midi+" notes":""); + const wrap=document.getElementById("focus-wave-wrap"); + const cv=document.getElementById("focus-wave-cv"); + const W=wrap.clientWidth||Math.max(600,window.innerWidth-32); + cv.width=W; cv.height=80; + cv.style.width=W+"px"; cv.style.height="80px"; + const focusPeaks = AudioEngine.hasStem(stemId) ? AudioEngine.getPeaks(stemId) : null; + drawWaveform(cv,{...s,muted:false},{width:W,height:80,beatPx:STATE.barPx/p.timeSig[0],peaks:focusPeaks}); + document.getElementById("ov-focus").classList.add("show"); + renderTimeline(); +} +function closeFocusTrack(){ + STATE.focusStem=null; + STATE.solo=new Set(); + _activeEngine().setSolo([]); + document.getElementById("ov-focus").classList.remove("show"); + renderTimeline(); } /* ---------------- SELECTION ---------------- */ function selectStem(id){ - STATE.sel=id; const s=P().stems.find(x=>x.id===id); + const p=P(); if(!p) return; STATE.sel=id; const s=p.stems.find(x=>x.id===id); $$(".staff-row").forEach(r=>r.classList.toggle("sel",r.dataset.stem===id)); $$(".gutter-track").forEach(r=>r.style.background=r.dataset.stem===id?"var(--bg-3)":""); $$('#nav-tree .trow.indent').forEach(r=>r.classList.toggle("active",r.dataset.stem===id)); @@ -341,12 +409,23 @@ const SCENES={ }; function setScene(scene){ STATE.scene=scene; document.body.dataset.scene=scene; + if(scene==="processing" && STATE.focusStem) closeFocusTrack(); $$(".overlay").forEach(o=>o.classList.remove("show")); + if(scene==="empty"){ + STATE.pos=0; positionPlayhead(); + $("#crumb-title").textContent=""; $("#crumb-sep").style.display="none"; + $("#sub-meta").innerHTML=""; + $("#st-tempo").textContent="—"; $("#st-sig").textContent="—"; $("#st-key").textContent="—"; + $("#sbar-model").textContent="—"; $("#sbar-stems").textContent="—"; $("#sbar-sel").textContent="—"; + $("#sbar-dot").style.background="var(--t-3)"; $("#sbar-fmt").textContent=""; + $("#nav-tree").innerHTML=''; + $("#nav-count").textContent=""; + } const cfg=SCENES[scene]; if(cfg.ovr) $("#"+cfg.ovr).classList.add("show"); const pill=$("#scene-pill"); pill.className="pill "+(cfg.pill[0]||""); $("#scene-pill-t").textContent=cfg.pill[1]; - $("#crumb-dot").style.background = scene==="error"?"var(--bad)":scene==="success"?"var(--good)":"var(--warn)"; + $("#crumb-dot").style.background = scene==="error"?"var(--bad)":scene==="success"?"var(--good)":scene==="empty"?"var(--t-3)":"var(--warn)"; $("#sbar-scene").textContent=cfg.sbar; if(scene==="processing") runProcessing(); if(scene==="error") fillErrorLog(); @@ -354,6 +433,7 @@ function setScene(scene){ } function loadProject(key,scene){ STATE.pkey=key; const p=P(); + if(!p){ setScene("empty"); return; } STATE.sel=p.stems.find(s=>!s.removed)?.id||p.stems[0].id; STATE.muted=new Set(); STATE.solo=new Set(); STATE.pos=p.barOffset||5; document.body.dataset.project=key; @@ -362,8 +442,15 @@ function loadProject(key,scene){ renderAll(); setScene(scene||"success"); selectStem(STATE.sel); + _saveSession(); // save on project open } -function renderAll(){ renderMeta(); renderLeft(); renderScore(); renderTimeline(); renderInspector(); positionPlayhead(); } +function renderAll(){ + if(!P()) return; + if(STATE.focusStem && !P().stems.find(s=>s.id===STATE.focusStem)) closeFocusTrack(); + renderMeta(); renderLeft(); renderScore(); renderTimeline(); renderInspector(); positionPlayhead(); +} + +let _activePoll = null; /* processing animation */ const PSTAGES=[ @@ -388,39 +475,67 @@ function _renderProcStages(prog, stageIdx){ $("#proc-bar-i").style.width=pct+"%"; } async function runProcessing(){ + clearInterval(_activePoll); _activePoll = null; + clearInterval(procTimer); const host=$("#proc-stages"); host.innerHTML=PSTAGES.map((s,i)=>`
${svgIco(s.ic)}${s.n}
`).join(""); - clearInterval(procTimer); + $("#proc-pct").textContent="0%"; $("#proc-bar-i").style.width="0%"; + // reset title unless local file already set it + if(!STATE.url.startsWith("local:")){ $("#proc-tt").textContent="Fetching…"; $("#proc-ts").textContent=""; } // Try real backend; fall back to demo simulation if unavailable if(STATE.url && STATE.url.startsWith("http")){ try { - const r = await fetch("/api/analyze",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:STATE.url})}); + const r = await fetch(_api("/api/analyze"),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:STATE.url,force:!!STATE._forceReanalyze})}); if(!r.ok) throw new Error("Server "+r.status); const {session_id} = await r.json(); flash("Analyzing… session "+session_id.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/${session_id}`).then(x=>x.json()); } + try { + const _sr = await fetch(_api(`/api/status/${session_id}`)); + if(_sr.status===404){ + const _rr = await fetch(_api(`/api/resume/${session_id}`),{method:"POST"}); + if(_rr.ok){ flash("Resuming from cached audio…"); return; } + clearInterval(_activePoll); _activePoll=null; + fillErrorLog("Session lost — please resubmit URL"); + setScene("error"); return; + } + job = await _sr.json(); + } catch(_){ return; } + if(job.status==="error"){ + clearInterval(_activePoll); _activePoll=null; + fillErrorLog(job.error||"Unknown error"); + setScene("error"); + return; + } + + if(job.meta?.title && job.meta.title!=="Unknown"){ + $("#proc-tt").textContent=job.meta.title; + const dur=job.meta.duration?` · ${fmtTime(job.meta.duration)}`:""; + const art=job.meta.artist&&job.meta.artist!=="Unknown"?job.meta.artist:""; + $("#proc-ts").textContent=art+dur; + } const prog = job.progress||0; const si = job.stage>=0 ? job.stage : 0; _renderProcStages(prog, si); $("#proc-eta").textContent = prog>=1?"Finalizing…":"Processing locally…"; - $("#proc-gpu").textContent = job.meta?.gpu ? `GPU ${job.meta.gpu.util}% · ${job.meta.gpu.model}` : "GPU — 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", @@ -431,25 +546,28 @@ async function runProcessing(){ 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:_calcBars(), barOffset:0, + stems, diagnostics:[], spectrum: m.spectrum||[], }; STATE.pkey="live"; STATE.liveSid=session_id; + 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(session_id, STATE.liveProject.stems), - ]); - flash("Stems + MIDI loaded — press Space to play"); + flash("Loading score…"); + await _loadMidiNotes(session_id, STATE.liveProject.stems); } + // persist to recent + save session for cross-reload restore + if (m.title && STATE.url?.startsWith("http")) { + _pushRecent({ url: STATE.url, title: m.title, artist: m.artist || "", thumb: m.thumb || "", sid: session_id }); + } + 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); return; @@ -469,7 +587,6 @@ async function runProcessing(){ for(let i=0;i=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 + +

${_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 process.stdout.write('[server] ' + d)); server.stderr.on('data', d => process.stderr.write('[server] ' + d)); @@ -38,12 +49,18 @@ async function startBackend() { 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') { - spawn('taskkill', ['/pid', server.pid, '/f', '/t']); + const tk = spawn('taskkill', ['/pid', pid, '/f', '/t'], { windowsHide: true }); + tk.unref(); } else { - server.kill('SIGTERM'); + try { process.kill(pid, 'SIGTERM'); } catch (_) {} } - server = null; } // Poll until the server responds (max 60 s) @@ -76,7 +93,7 @@ async function createWindow() { backgroundColor: '#0a0a0c', webPreferences: { nodeIntegration: false }, }); - splash.loadURL(`data:text/html,
AEGISSCORE
Starting local engine…
`); + splash.loadFile(path.join(__dirname, 'assets', 'splash.html')); try { await waitForServer(); @@ -98,6 +115,7 @@ async function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, + webSecurity: false, preload: path.join(__dirname, 'preload.js'), }, }); @@ -161,7 +179,7 @@ app.whenReady().then(createWindow); app.on('window-all-closed', () => { killBackend(); - app.quit(); + app.exit(0); }); app.on('before-quit', killBackend); diff --git a/models/.gitkeep b/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/package.json b/package.json index 5d0c470..3c89dd4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "build": { "appId": "com.aegis.score-studio", "productName": "Aegis Score Studio", - "directories": { "output": "dist" }, + "directories": { + "output": "dist" + }, "files": [ "main.js", "index.html", @@ -24,8 +26,7 @@ "requirements.txt" ], "win": { - "target": "nsis", - "icon": "css/icon.ico" + "target": "nsis" }, "mac": { "target": "dmg", @@ -33,6 +34,15 @@ }, "linux": { "target": "AppImage" - } + }, + "extraResources": [ + { + "from": "dist/server_dist", + "to": "server_dist", + "filter": [ + "**/*" + ] + } + ] } -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 522f0e8..2a77276 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ librosa>=0.10.0 pretty-midi>=0.2.10 soundfile>=0.12.1 python-multipart +omnizart diff --git a/server.py b/server.py index 21f037c..d5aa723 100644 --- a/server.py +++ b/server.py @@ -3,23 +3,57 @@ Run: uvicorn server:app --port 7471 --reload """ from __future__ import annotations -import asyncio, uuid, shutil, sys, os +import asyncio, uuid, shutil, sys, os, time, traceback, threading, math from concurrent.futures import ThreadPoolExecutor from pathlib import Path +# Store all AI model weights in models/ next to server.py (visible, portable) +_MODELS_DIR = Path(__file__).parent / "models" +_MODELS_DIR.mkdir(exist_ok=True) +os.environ.setdefault("TORCH_HOME", str(_MODELS_DIR)) + +def _bg_update_ytdlp(): + """Silently update yt-dlp in background on startup — YouTube API changes frequently.""" + try: + import subprocess + subprocess.run([sys.executable, "-m", "pip", "install", "-U", "yt-dlp", "-q"], + capture_output=True, timeout=120) + except Exception: + pass + +threading.Thread(target=_bg_update_ytdlp, daemon=True).start() + from fastapi import FastAPI, BackgroundTasks, HTTPException, UploadFile, File from fastapi.staticfiles import StaticFiles from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel app = FastAPI(title="AegisScore") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) -WORK_DIR = Path("work") -WORK_DIR.mkdir(exist_ok=True) +WORK_DIR = Path(os.environ.get("AEGIS_WORK_DIR", "work")) +WORK_DIR.mkdir(exist_ok=True, parents=True) URL_CACHE: dict[str, str] = {} # url → sid JOBS: dict[str, dict] = {} +# Rebuild URL_CACHE from persisted sessions on startup +def _rebuild_cache(): + import json as _json + for sf in WORK_DIR.glob("*/session.json"): + try: + j = _json.loads(sf.read_text(encoding="utf-8")) + if j.get("status") == "done": + src = j.get("meta", {}).get("source", "") + if src.startswith("http"): + URL_CACHE[src] = sf.parent.name + JOBS[sf.parent.name] = j + except Exception: + pass + +_rebuild_cache() + DEMUCS_SOURCES = ["drums", "bass", "other", "vocals", "guitar", "piano"] STEM_META = { @@ -31,6 +65,73 @@ "other": {"name": "Other", "instrument": "Mixed / Other", "family": "Synth", "clef": "treble", "color": "--s-synth", "role": "Texture"}, } + +def _classify_stem_sync(stem_name: str, wav_path: Path, midi_path: Path) -> dict: + """Detect silence, classify 'other' stem, set clef from actual MIDI pitch range.""" + import librosa, numpy as np + SR = 22050 + + base = dict(STEM_META.get(stem_name, {})) # start from defaults + + try: + y, _ = librosa.load(str(wav_path), sr=SR, mono=True, duration=60) + rms = float(np.sqrt(np.mean(y ** 2))) + + # Silence — stem physically absent / near-zero + if rms < 0.0015: + base["removed"] = True + return base + base["removed"] = False + + # ── Clef from actual MIDI pitch range (beats hardcoded clef) ────────── + if stem_name != "drums" and midi_path.exists(): + try: + import pretty_midi + pm = pretty_midi.PrettyMIDI(str(midi_path)) + pitches = [n.pitch for inst in pm.instruments for n in inst.notes] + if pitches: + median_p = float(np.median(pitches)) + base["clef"] = "bass" if median_p < 52 else "treble" + except Exception: + pass + + # ── "other" stem: identify from spectral features ───────────────────── + if stem_name == "other": + centroid = float(np.mean(librosa.feature.spectral_centroid(y=y, sr=SR))) + bandwidth = float(np.mean(librosa.feature.spectral_bandwidth(y=y, sr=SR))) + zcr = float(np.mean(librosa.feature.zero_crossing_rate(y))) + y_harm = librosa.effects.harmonic(y=y) + harmonic = float(np.mean(y_harm ** 2)) / max(1e-9, float(np.mean(y ** 2))) + + if centroid < 400: + base.update({"name": "Cello", "instrument": "Cello", "family": "Low Strings · GM 42", "clef": "bass", "color": "--s-bass", "role": "Low end"}) + elif centroid < 900 and harmonic > 0.4: + base.update({"name": "Viola", "instrument": "Viola", "family": "Strings · GM 41", "clef": "treble", "color": "--s-synth", "role": "Harmony"}) + elif centroid < 1800 and harmonic > 0.5: + base.update({"name": "Strings", "instrument": "String Ensemble","family": "Strings · GM 48", "clef": "treble", "color": "--s-synth", "role": "Texture"}) + elif centroid < 1600 and zcr < 0.04: + base.update({"name": "Pad", "instrument": "Synth Pad", "family": "Pad · GM 88", "clef": "treble", "color": "--s-synth", "role": "Texture"}) + elif centroid < 2400 and harmonic > 0.35: + base.update({"name": "Brass", "instrument": "Brass Section", "family": "Brass · GM 61", "clef": "treble", "color": "--s-synth", "role": "Harmony"}) + elif centroid > 3500 and zcr < 0.06: + base.update({"name": "Flute", "instrument": "Flute", "family": "Woodwind · GM 73", "clef": "treble", "color": "--s-synth", "role": "Melody"}) + elif zcr > 0.10: + base.update({"name": "Synth Lead", "instrument": "Synth Lead", "family": "Synth · GM 80", "clef": "treble", "color": "--s-synth", "role": "Texture"}) + else: + base.update({"name": "Keys / Other", "instrument": "Electric Piano","family": "Keys · GM 4", "clef": "treble", "color": "--s-keys", "role": "Texture"}) + + # ── Acoustic guitar vs electric heuristic ───────────────────────────── + elif stem_name == "guitar": + centroid = float(np.mean(librosa.feature.spectral_centroid(y=y, sr=SR))) + zcr = float(np.mean(librosa.feature.zero_crossing_rate(y))) + if centroid < 1200 and zcr < 0.05: + base.update({"name": "Acoustic Guitar", "instrument": "Acoustic Guitar", "family": "Guitar · GM 25"}) + + except Exception: + pass + + return base + # ThreadPoolExecutor — safe on Windows (no spawn/pickle issues unlike ProcessPool) _POOL = ThreadPoolExecutor(max_workers=min(6, (os.cpu_count() or 2))) @@ -38,11 +139,21 @@ # ── Models ──────────────────────────────────────────────────────────────────── class AnalyzeRequest(BaseModel): url: str + force: bool = False # ── Helpers ─────────────────────────────────────────────────────────────────── def _set(sid: str, **kwargs): JOBS[sid].update(kwargs) + # Persist completed/failed jobs to disk so they survive a server restart + if kwargs.get("status") in ("done", "error"): + try: + import json as _json + (WORK_DIR / sid / "session.json").write_text( + _json.dumps(JOBS[sid], default=str), encoding="utf-8" + ) + except Exception: + pass async def _run_async(cmd: list[str]) -> None: @@ -57,57 +168,202 @@ async def _run_async(cmd: list[str]) -> None: raise RuntimeError(f"Command failed (exit {proc.returncode}):\n{detail}") -def _transcribe_stem_sync(wav_path: Path, midi_out: Path, is_drums: bool) -> int: - """Runs in thread pool. Returns note count.""" +# GM program per stem for correct MuseScore instrument assignment +_STEM_PROGRAM = { + "bass": 33, # Electric Bass (finger) + "guitar": 25, # Acoustic Guitar (nylon) — overridden to 27 (clean electric) if classified + "piano": 0, # Acoustic Grand Piano + "vocals": 52, # Choir Aahs + "other": 48, # String Ensemble 1 +} + + +def _transcribe_stem_sync(wav_path: Path, midi_out: Path, is_drums: bool, tempo: float = 120.0) -> tuple: + """Runs in thread pool. Returns (note_count, confidence_pct).""" import librosa import numpy as np import pretty_midi + job_dir = wav_path.parent.parent SR = 22050 - HOP = 1024 - y, sr = librosa.load(str(wav_path), sr=SR, mono=True, duration=300) - pm = pretty_midi.PrettyMIDI() + HOP = 2048 + stem_name = wav_path.stem + _log(job_dir, f"--- MIDI TRANSCRIPTION: {stem_name} ---") if is_drums: - drum = pretty_midi.Instrument(program=0, is_drum=True) + y, sr = librosa.load(str(wav_path), sr=SR, mono=True, duration=300) + pm = pretty_midi.PrettyMIDI(initial_tempo=float(tempo)) + drum = pretty_midi.Instrument(program=0, is_drum=True, name="Drums") onset_frames = librosa.onset.onset_detect(y=y, sr=sr, units="frames", backtrack=True, hop_length=HOP) onset_times = librosa.frames_to_time(onset_frames, sr=sr, hop_length=HOP) for i, t in enumerate(onset_times): drum.notes.append(pretty_midi.Note(velocity=90, - pitch=36 if i % 2 == 0 else 38, start=float(t), end=float(t) + 0.05)) + pitch=36 if i % 2 == 0 else 38, start=float(t), end=float(t) + 0.12)) pm.instruments.append(drum) pm.write(str(midi_out)) - return len(drum.notes) - - f0, voiced_flag, _ = librosa.pyin(y, fmin=librosa.note_to_hz("C2"), - fmax=librosa.note_to_hz("C7"), - sr=sr, frame_length=2048, hop_length=HOP) - times = librosa.times_like(f0, sr=sr, hop_length=HOP) - - instrument = pretty_midi.Instrument(program=0) - in_note = False - note_start = 0.0 - note_pitch = 0 - for t, freq, voiced in zip(times, f0, voiced_flag): - if voiced and not np.isnan(freq): - mp = max(0, min(127, int(round(librosa.hz_to_midi(freq))))) - if not in_note: - in_note, note_start, note_pitch = True, float(t), mp - elif mp != note_pitch: - if float(t) - note_start >= 0.05: - instrument.notes.append(pretty_midi.Note(velocity=80, pitch=note_pitch, - start=note_start, end=float(t))) - note_start, note_pitch = float(t), mp - elif in_note: - if float(t) - note_start >= 0.05: - instrument.notes.append(pretty_midi.Note(velocity=80, pitch=note_pitch, - start=note_start, end=float(t))) - in_note = False - - pm.instruments.append(instrument) + _log(job_dir, f" method: onset_detect (drums)", + f" onsets: {len(onset_frames)}", + f" notes_out: {len(drum.notes)}") + return len(drum.notes), 70.0 + + if "bass" in stem_name: + fmin, fmax = librosa.note_to_hz("C1"), librosa.note_to_hz("C5") + fmin_name, fmax_name = "C1", "C5" + elif "guitar" in stem_name: + fmin, fmax = librosa.note_to_hz("E2"), librosa.note_to_hz("E7") + fmin_name, fmax_name = "E2", "E7" + else: + fmin, fmax = librosa.note_to_hz("C2"), librosa.note_to_hz("C7") + fmin_name, fmax_name = "C2", "C7" + + # omnizart: multi-instrument neural transcription — best for orchestral/ensemble audio + if stem_name == "other" and _OMNIZART_AVAIL: + try: + import subprocess as _sp + _log(job_dir, f" method: omnizart (multi-instrument)") + # CLI: omnizart music transcribe -o + # outputs /.mid — matches midi_out path exactly + _proc = _sp.run( + [_OMNIZART_PYTHON, "-m", "omnizart", "music", "transcribe", + str(wav_path), "-o", str(midi_out.parent)], + capture_output=True, timeout=900 + ) + if _proc.returncode == 0 and midi_out.exists(): + import pretty_midi as _pm_omni + _pm_check = _pm_omni.PrettyMIDI(str(midi_out)) + note_count = sum(len(i.notes) for i in _pm_check.instruments) + _log(job_dir, + f" notes_out: {note_count}", + f" tracks: {len(_pm_check.instruments)}", + *[f" [{i.program:3d}] {i.name or 'unnamed'}: {len(i.notes)} notes" + for i in _pm_check.instruments]) + return note_count, 88.0 + else: + _log(job_dir, + f" omnizart exit {_proc.returncode} — falling back", + _proc.stderr.decode(errors="replace")[-400:]) + except Exception as exc: + _log(job_dir, f" omnizart error: {exc} — falling back to basic-pitch") + + # basic-pitch: polyphonic neural transcription (best quality, requires pip install basic-pitch) + if _BASIC_PITCH_AVAIL: + try: + from basic_pitch.inference import predict as _bp_predict + _log(job_dir, f" method: basic-pitch fmin={fmin_name} fmax={fmax_name}") + model_arg = _BP_MODEL if _BP_MODEL is not None else None + predict_kwargs = dict( + minimum_note_length=150.0, + minimum_frequency=fmin, + maximum_frequency=fmax, + onset_threshold=0.5, + frame_threshold=0.35, + melodia_trick=True, + ) + if model_arg is not None: + model_out, midi_data, _ = _bp_predict(str(wav_path), model_arg, **predict_kwargs) + else: + model_out, midi_data, _ = _bp_predict(str(wav_path), **predict_kwargs) + # Rewrite with correct tempo + instrument program so MuseScore imports cleanly + pm_out = pretty_midi.PrettyMIDI(initial_tempo=float(tempo)) + prog = _STEM_PROGRAM.get(stem_name, 0) + for inst in midi_data.instruments: + if not inst.is_drum: + inst.program = prog + inst.name = stem_name.title() + pm_out.instruments = midi_data.instruments + pm_out.write(str(midi_out)) + note_count = sum(len(inst.notes) for inst in pm_out.instruments) + note_probs = model_out.get("note") + if note_probs is not None and note_probs.size > 0: + conf = float(np.mean(np.max(note_probs, axis=-1))) * 100.0 + else: + conf = 50.0 + _log(job_dir, f" notes_out: {note_count}", f" confidence: {conf:.1f}%") + return note_count, round(conf, 1) + except Exception as exc: + _log(job_dir, f" basic-pitch error: {exc} — falling back to CQT") + + # CQT-based polyphonic transcription — works on separated orchestral stems, no extra deps + _log(job_dir, f" method: CQT-poly fmin={fmin_name} ({fmin:.1f} Hz) fmax={fmax_name} ({fmax:.1f} Hz)") + y, sr = librosa.load(str(wav_path), sr=SR, mono=True, duration=300) + + fmin_midi = max(0, int(round(librosa.hz_to_midi(fmin)))) + fmax_midi = min(127, int(round(librosa.hz_to_midi(fmax)))) + n_bins = max(1, fmax_midi - fmin_midi) + fmin_hz = float(librosa.midi_to_hz(fmin_midi)) + + C = np.abs(librosa.cqt(y, sr=sr, hop_length=HOP, + n_bins=n_bins, bins_per_octave=12, fmin=fmin_hz)) + + flat = C.ravel() + if flat.max() < 1e-7: + _log(job_dir, " WARNING: silent stem") + pm = pretty_midi.PrettyMIDI(initial_tempo=float(tempo)) + pm.instruments.append(pretty_midi.Instrument(program=_STEM_PROGRAM.get(stem_name, 0), name=stem_name.title())) + pm.write(str(midi_out)) + return 0, 0.0 + + # Per-frame max energy; global floor at 75th percentile of nonzero values + frame_max = C.max(axis=0) + global_floor = float(np.percentile(flat[flat > 0], 75)) + + times = librosa.frames_to_time(np.arange(C.shape[1]), sr=sr, hop_length=HOP) + MAX_POLY = 3 if "bass" in stem_name else 5 # bass usually monophonic/duophonic + MIN_DUR = 0.08 + + pm = pretty_midi.PrettyMIDI(initial_tempo=float(tempo)) + inst = pretty_midi.Instrument(program=_STEM_PROGRAM.get(stem_name, 0), name=stem_name.title()) + active: dict[int, float] = {} # midi_pitch -> start_time + + for fi in range(C.shape[1]): + t = float(times[fi]) + col = C[:, fi] + col_max = float(frame_max[fi]) + + if col_max < global_floor * 0.2: + # Silent frame — close all open notes + for mp, s in list(active.items()): + if t - s >= MIN_DUR: + inst.notes.append(pretty_midi.Note(70, mp, s, t)) + active.clear() + continue + + # Find pitches above 45 % of this frame's peak AND above global floor + col_norm = col / col_max + cands = np.where((col_norm > 0.45) & (col > global_floor * 0.3))[0] + if len(cands) > MAX_POLY: + cands = cands[np.argsort(col[cands])[-MAX_POLY:]] + on_now = set(int(fmin_midi + b) for b in cands if fmin_midi + int(b) <= 127) + + # Close notes that stopped + for mp in list(active.keys()): + if mp not in on_now: + s = active.pop(mp) + if t - s >= MIN_DUR: + inst.notes.append(pretty_midi.Note(70, mp, s, t)) + # Open new notes + for mp in on_now: + if mp not in active: + active[mp] = t + + # Close remaining open notes at end of file + end_t = float(times[-1]) + for mp, s in active.items(): + if end_t - s >= MIN_DUR: + inst.notes.append(pretty_midi.Note(70, mp, s, end_t)) + + pm.instruments.append(inst) pm.write(str(midi_out)) - return len(instrument.notes) + n_notes = len(inst.notes) + + loud_frames = int(np.sum(frame_max >= global_floor * 0.2)) + conf = float(loud_frames / max(1, C.shape[1])) * 100.0 + _log(job_dir, + f" notes_out: {n_notes}", + f" loud_frames:{loud_frames}/{C.shape[1]} ({conf:.1f}%)", + f" cqt_bins: {n_bins}") + return n_notes, round(conf, 1) def _gpu_info() -> dict: @@ -124,6 +380,172 @@ def _gpu_info() -> dict: _DEVICE = "cuda" if __import__("torch").cuda.is_available() else "cpu" +try: + import logging as _lg; _rl = _lg.getLogger(); _prev = _rl.level; _rl.setLevel(_lg.ERROR) + import basic_pitch # noqa: F401 + _BASIC_PITCH_AVAIL = True + _rl.setLevel(_prev) +except ImportError: + _BASIC_PITCH_AVAIL = False + +# omnizart requires Python ≤ 3.9 — may live in a separate venv. +# Point OMNIZART_PYTHON at that venv's python.exe to enable it. +_OMNIZART_PYTHON = os.environ.get("OMNIZART_PYTHON", sys.executable) +try: + import subprocess as _sp_omni_check + _r = _sp_omni_check.run( + [_OMNIZART_PYTHON, "-c", "import omnizart"], + capture_output=True, timeout=15) + _OMNIZART_AVAIL = (_r.returncode == 0) +except Exception: + _OMNIZART_AVAIL = False + +# Preload basic-pitch ONNX model once at startup — avoids 5× reload per session +_BP_MODEL = None +if _BASIC_PITCH_AVAIL: + try: + import logging as _lg2; _rl2 = _lg2.getLogger(); _p2 = _rl2.level; _rl2.setLevel(_lg2.ERROR) + from basic_pitch import ICASSP_2022_MODEL_PATH + from basic_pitch.inference import Model as _BPModel + _BP_MODEL = _BPModel(ICASSP_2022_MODEL_PATH) + _rl2.setLevel(_p2) + except Exception: + pass + + +# ── Debug logger ────────────────────────────────────────────────────────────── +def _log(job_dir: Path, *lines: str): + """Append timestamped lines to work/{sid}/debug.log.""" + log_path = job_dir / "debug.log" + ts = time.strftime("%Y-%m-%d %H:%M:%S") + with open(log_path, "a", encoding="utf-8") as f: + for line in lines: + f.write(f"[{ts}] {line}\n") + + +def _analyze_audio_sync(wav_path: Path, job_dir: Path) -> dict: + """Detect BPM, key, time sig, sample rate, bit depth. Runs in thread pool.""" + import librosa, numpy as np, soundfile as sf + + _log(job_dir, "=== AUDIO ANALYSIS ===", f"file: {wav_path.name}") + + info = sf.info(str(wav_path)) + sample_rate = info.samplerate + bit_depth = {"PCM_16": 16, "PCM_24": 24, "PCM_32": 32, "FLOAT": 32}.get(info.subtype, 16) + _log(job_dir, + f"format: {info.subtype} ({bit_depth}-bit)", + f"sample_rate: {sample_rate} Hz", + f"duration: {info.duration:.2f} s ({info.duration/60:.1f} min)", + f"channels: {info.channels}") + + SR = 22050 + y, _ = librosa.load(str(wav_path), sr=SR, mono=True, duration=180) + + # Tempo + time sig — beat_this (ISMIR 2024) → madmom → librosa + beat_frames = np.array([]) + tempo = 120 + time_sig = [4, 4] + _beat_method = "?" + + try: + from beat_this.inference import File2Beats + _log(job_dir, "tempo_method: beat_this (ISMIR 2024)") + f2b = File2Beats(checkpoint_path="final0", device=_DEVICE, dbn=False) + _bt_beats, _bt_db = f2b(str(wav_path)) + beat_times = np.array(_bt_beats, dtype=float) + db_arr = np.array(_bt_db, dtype=float) + if len(beat_times) >= 4: + intervals = np.diff(beat_times) + med = float(np.median(intervals)) + tempo = max(40, min(250, int(round(60.0 / med if med > 0 else 120.0)))) + beat_frames = librosa.time_to_frames(beat_times, sr=SR) + if len(db_arr) >= 3: + bpb_list = [] + for k in range(len(db_arr) - 1): + n = int(np.sum((beat_times >= db_arr[k]) & (beat_times < db_arr[k+1]))) + if 2 <= n <= 8: + bpb_list.append(n) + if bpb_list: + bpb = int(round(float(np.median(bpb_list)))) + time_sig = [bpb if bpb in (3, 4, 5, 6, 7) else 4, 4] + _beat_method = "beat_this" + _log(job_dir, + f"tempo: {tempo} BPM", + f"beats: {len(beat_times)} downbeats: {len(db_arr)}", + f"time_sig: {time_sig[0]}/{time_sig[1]} (from downbeats)") + except Exception as exc: + _log(job_dir, f"beat_this failed ({exc}), trying madmom") + try: + import madmom + from madmom.features.beats import RNNBeatProcessor, BeatTrackingProcessor + _log(job_dir, "tempo_method: madmom RNNBeatProcessor") + proc = RNNBeatProcessor()(str(wav_path)) + beat_times = BeatTrackingProcessor(fps=100)(proc) + if len(beat_times) >= 4: + intervals = np.diff(beat_times) + med = float(np.median(intervals)) + tempo = max(40, min(250, int(round(60.0 / med if med > 0 else 120.0)))) + beat_frames = librosa.time_to_frames(beat_times, sr=SR) + _beat_method = "madmom" + _log(job_dir, f"tempo: {tempo} BPM beats: {len(beat_times)}") + except Exception as exc2: + _log(job_dir, f"madmom failed ({exc2}), using librosa") + tempo_raw, beat_frames = librosa.beat.beat_track(y=y, sr=SR) + tempo = max(40, min(250, int(round(float(tempo_raw))))) + _beat_method = "librosa" + _log(job_dir, f"tempo: {tempo} BPM (librosa)") + + # Time sig autocorrelation fallback (when beat_this didn't set it via downbeats) + if _beat_method != "beat_this" and len(beat_frames) >= 8: + try: + oenv = librosa.onset.onset_strength(y=y, sr=SR) + bf = beat_frames[beat_frames < len(oenv)] + strengths = oenv[bf] - oenv[bf].mean() + ac = np.correlate(strengths, strengths, mode='full')[len(strengths) - 1:] + if len(ac) > 4: + ac3, ac4 = float(ac[3]), float(ac[4]) + if ac3 > ac4 * 1.15: + time_sig = [3, 4] + _log(job_dir, f"time_sig: {time_sig[0]}/{time_sig[1]} (autocorrelation)") + except Exception as exc: + _log(job_dir, f"time_sig_error: {exc}") + + # Key — Krumhansl-Schmuckler over CQT chroma + major_prof = np.array([6.35,2.23,3.48,2.33,4.38,4.09,2.52,5.19,2.39,3.66,2.29,2.88]) + minor_prof = np.array([6.33,2.68,3.52,5.38,2.60,3.53,2.54,4.75,3.98,2.69,3.34,3.17]) + chroma = librosa.feature.chroma_cqt(y=y, sr=SR).mean(axis=1) + note_names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'] + best_key, best_r = "C major", -2.0 + all_scores: list[tuple[float, str]] = [] + for i in range(12): + r_maj = float(np.corrcoef(chroma, np.roll(major_prof, i))[0, 1]) + r_min = float(np.corrcoef(chroma, np.roll(minor_prof, i))[0, 1]) + all_scores.append((r_maj, f"{note_names[i]} major")) + all_scores.append((r_min, f"{note_names[i]} minor")) + if r_maj > best_r: best_r, best_key = r_maj, f"{note_names[i]} major" + if r_min > best_r: best_r, best_key = r_min, f"{note_names[i]} minor" + top5 = sorted(all_scores, reverse=True)[:5] + _log(job_dir, f"key_detected: {best_key} (r={best_r:.4f})") + for r, k in top5: + _log(job_dir, f" {r:+.4f} {k}{' <-- CHOSEN' if k == best_key else ''}") + + # Spectrum — 40 log-spaced energy bins for UI visualizer (0–100 scale) + spectrum: list[float] = [] + try: + S = np.abs(librosa.stft(y, n_fft=2048, hop_length=512)) + freq_energy = np.log1p(S.mean(axis=1)) + if freq_energy.max() > 0: + freq_energy = freq_energy / freq_energy.max() * 100.0 + indices = np.linspace(0, len(freq_energy) - 1, 40) + spectrum = [float(max(0.0, min(100.0, np.interp(ix, np.arange(len(freq_energy)), freq_energy)))) + for ix in indices] + except Exception: + pass + + _log(job_dir, "=== ANALYSIS DONE ===") + return {"tempo": tempo, "key": best_key, "timeSig": time_sig, + "sampleRate": sample_rate, "bitDepth": bit_depth, "spectrum": spectrum} + # ── Shared separation + transcription logic ──────────────────────────────────── async def _separate_and_transcribe(sid: str, audio_path: Path, meta: dict): @@ -133,66 +555,553 @@ async def _separate_and_transcribe(sid: str, audio_path: Path, meta: dict): stems_dir.mkdir(exist_ok=True) midi_dir.mkdir(exist_ok=True) - # Stage 1: Demucs + # Stage 1: Demucs + audio analysis in parallel _set(sid, stage=1, status="separating", progress=0.20) + _log(job_dir, "=== SEPARATION ===", f"device: {_DEVICE}", f"model: htdemucs_6s") py = sys.executable - await _run_async([py, "-m", "demucs", - "--name", "htdemucs_6s", - "--device", _DEVICE, - "--out", str(job_dir / "demucs_out"), - str(audio_path)]) + loop = asyncio.get_running_loop() + analysis_fut = loop.run_in_executor(_POOL, _analyze_audio_sync, audio_path, job_dir) + + # Smooth progress 20%→60% while demucs runs (subprocess gives no callbacks) + # htdemucs_6s: CPU ~3.5× realtime, GPU ~0.7× realtime + _dur = meta.get("duration", 120) + _est = max(30.0, _dur * (0.8 if _DEVICE == "cuda" else 3.5)) + async def _sep_progress(): + _t0 = time.time() + while True: + _frac = min(0.97, 1.0 - math.exp(-2.5 * (time.time() - _t0) / _est)) + _set(sid, progress=0.20 + _frac * 0.40) + await asyncio.sleep(2) + _prog_task = asyncio.create_task(_sep_progress()) + try: + await _run_async([py, "-m", "demucs", + "--name", "htdemucs_6s", + "--device", _DEVICE, + "--out", str(job_dir / "demucs_out"), + str(audio_path)]) + finally: + _prog_task.cancel() + try: await _prog_task + except asyncio.CancelledError: pass + demucs_root = next((job_dir / "demucs_out" / "htdemucs_6s").iterdir()) + stem_sizes = {} for stem_wav in demucs_root.glob("*.wav"): shutil.copy(stem_wav, stems_dir / stem_wav.name) + stem_sizes[stem_wav.stem] = stem_wav.stat().st_size + analysis = await analysis_fut + _log(job_dir, "stems_separated:", + *[f" {k}: {v/1024:.0f} KB" for k, v in sorted(stem_sizes.items())]) _set(sid, progress=0.62) # Stage 2: Instrument ID _set(sid, stage=2, status="identifying", progress=0.64) _set(sid, progress=0.70) - # Stage 3: MIDI — parallel threads + # Stage 3: MIDI — sequential (ONNX not safe for concurrent CPU inference) _set(sid, stage=3, status="transcribing", progress=0.72) - loop = asyncio.get_event_loop() - tasks, stem_names = [], [] - for stem_name in DEMUCS_SOURCES: - stem_file = stems_dir / f"{stem_name}.wav" - if not stem_file.exists(): - continue - tasks.append(loop.run_in_executor( - _POOL, _transcribe_stem_sync, - stem_file, midi_dir / f"{stem_name}.mid", stem_name == "drums")) - stem_names.append(stem_name) + stem_queue = [(n, stems_dir / f"{n}.wav") for n in DEMUCS_SOURCES + if (stems_dir / f"{n}.wav").exists()] + midi_counts, midi_confs = {}, {} + _n_stems = max(len(stem_queue), 1) + for i, (stem_name, stem_file) in enumerate(stem_queue): + try: + r = await loop.run_in_executor( + _POOL, _transcribe_stem_sync, + stem_file, midi_dir / f"{stem_name}.mid", stem_name == "drums", + float(analysis.get("tempo", 120))) + if isinstance(r, tuple): + midi_counts[stem_name], midi_confs[stem_name] = r[0], r[1] + else: + midi_counts[stem_name] = int(r); midi_confs[stem_name] = 90.0 + except Exception as exc: + midi_counts[stem_name] = 0; midi_confs[stem_name] = 0.0 + _log(job_dir, f"MIDI_ERROR [{stem_name}]: {exc}") + _set(sid, progress=0.72 + (i + 1) / _n_stems * 0.18) - results = await asyncio.gather(*tasks, return_exceptions=True) - midi_counts = {n: (r if isinstance(r, int) else 0) for n, r in zip(stem_names, results)} - _set(sid, progress=0.90) + # Dynamic classification — silence filter + instrument ID + stem_classifications: dict[str, dict] = {} + for stem_name, stem_file in stem_queue: + try: + cls = await loop.run_in_executor( + _POOL, _classify_stem_sync, + stem_name, stem_file, midi_dir / f"{stem_name}.mid") + stem_classifications[stem_name] = cls + except Exception as exc: + _log(job_dir, f"CLASSIFY_ERROR [{stem_name}]: {exc}") + stem_classifications[stem_name] = {} # Stage 4: Engraving stub _set(sid, stage=4, status="engraving", progress=0.95) - # Build stem list + # Build stem list — merge dynamic classification over STEM_META defaults stems = [] for name in DEMUCS_SOURCES: if not (stems_dir / f"{name}.wav").exists(): continue - m = STEM_META.get(name, {}) + m = STEM_META.get(name, {}) + cls = stem_classifications.get(name, {}) + if cls.get("removed"): + _log(job_dir, f"STEM_REMOVED [{name}]: silent — skipped") + continue + stem_name_disp = cls.get("name") or m.get("name", name.title()) + stem_inst = cls.get("instrument") or m.get("instrument", "Unknown") + stem_family = cls.get("family") or m.get("family", "") + stem_clef = cls.get("clef") or m.get("clef", "treble") + + # ── Multi-track split: omnizart outputs separate tracks per instrument ───── + # Trigger: >1 instrument track with distinct GM programs and ≥10 notes each + _split_done = False + midi_file = midi_dir / f"{name}.mid" + if midi_file.exists() and name == "other": + try: + import pretty_midi as _pm_mt, re as _re_mt + _pm_mt_obj = _pm_mt.PrettyMIDI(str(midi_file)) + _valid_tracks = [i for i in _pm_mt_obj.instruments if len(i.notes) >= 10] + _distinct_progs = len({i.program for i in _valid_tracks if not i.is_drum}) + if _distinct_progs >= 2 or (len(_valid_tracks) >= 2): + _split_done = True + _log(job_dir, f"MULTI_TRACK_SPLIT [{name}]: {len(_valid_tracks)} tracks") + for _tidx, _tinst in enumerate(_valid_tracks): + _safe_id = _re_mt.sub(r"[^a-z0-9]+", "_", + (_tinst.name or f"track_{_tidx}").lower() + ).strip("_")[:18] or f"track_{_tidx}" + _tid = f"{name}_{_safe_id}" + _pm_single = _pm_mt.PrettyMIDI( + initial_tempo=float(analysis.get("tempo", 120))) + _pm_single.instruments.append(_tinst) + _pm_single.write(str(midi_dir / f"{_tid}.mid")) + if _tinst.is_drum: + _tn, _tf, _tc = "Drums", "Percussion", "percussion" + else: + _tn, _tf, _tc = _gm_program_meta(_tinst.program) + _tname = _tinst.name or _tn + stems.append({ + "id": _tid, "name": _tname, + "instrument": _tn, "family": _tf, "clef": _tc, + "color": m.get("color", "--s-synth"), "role": "", + "confidence": midi_confs.get(name, 0.0), + "midi": len(_tinst.notes), + "audio_url": f"/work/{sid}/stems/{name}.wav", + "midi_url": f"/work/{sid}/midi/{_tid}.mid", + "density": 3, "seed": hash(_tid) % 50 + 1, "note": "", + }) + except Exception as _mte: + _log(job_dir, f"MULTI_TRACK_SPLIT_ERROR [{name}]: {_mte}") + _split_done = False + + # ── Grand-staff split: if MIDI spans bass+treble registers, emit two staves ── + # Trigger: >12% of notes below E3 (MIDI 52) AND >12% above E3 + if not _split_done and midi_file.exists(): + try: + import pretty_midi as _pm_split + _pm_s = _pm_split.PrettyMIDI(str(midi_file)) + _all_p = [n.pitch for inst in _pm_s.instruments for n in inst.notes] + if len(_all_p) >= 20: + _n_bass = sum(1 for p in _all_p if p < 52) + _n_treble = sum(1 for p in _all_p if p >= 52) + _frac_b = _n_bass / len(_all_p) + _frac_t = _n_treble / len(_all_p) + if _frac_b > 0.12 and _frac_t > 0.12: + # Write split MIDI files + for _sfx, _pred, _clef_s in [ + ("_high", lambda p: p >= 52, "treble"), + ("_low", lambda p: p < 52, "bass"), + ]: + _pm_out = _pm_split.PrettyMIDI( + initial_tempo=float(analysis.get("tempo", 120))) + for _inst in _pm_s.instruments: + _ni = _pm_split.Instrument( + program=_inst.program, + is_drum=_inst.is_drum, + name=_inst.name) + _ni.notes = [n for n in _inst.notes if _pred(n.pitch)] + if _ni.notes: + _pm_out.instruments.append(_ni) + if _pm_out.instruments: + _pm_out.write(str(midi_dir / f"{name}{_sfx}.mid")) + if (midi_dir / f"{name}_high.mid").exists() and \ + (midi_dir / f"{name}_low.mid").exists(): + _split_done = True + _lbl_hi = stem_name_disp + " (Treble)" + _lbl_lo = stem_name_disp + " (Bass)" + _log(job_dir, f"GRAND_STAFF_SPLIT [{name}]: " + f"{_n_bass} bass / {_n_treble} treble notes") + stems.append({ + "id": f"{name}_high", "name": _lbl_hi, + "instrument": stem_inst, "family": stem_family, + "clef": "treble", "color": m.get("color", "--s-synth"), + "role": m.get("role", ""), "confidence": midi_confs.get(name, 0.0), + "midi": _n_treble, + "audio_url": f"/work/{sid}/stems/{name}.wav", + "midi_url": f"/work/{sid}/midi/{name}_high.mid", + "density": 3, "seed": hash(name + "_high") % 50 + 1, "note": "", + }) + stems.append({ + "id": f"{name}_low", "name": _lbl_lo, + "instrument": stem_inst, "family": stem_family, + "clef": "bass", "color": m.get("color", "--s-synth"), + "role": m.get("role", ""), "confidence": midi_confs.get(name, 0.0), + "midi": _n_bass, + "audio_url": f"/work/{sid}/stems/{name}.wav", + "midi_url": f"/work/{sid}/midi/{name}_low.mid", + "density": 3, "seed": hash(name + "_low") % 50 + 1, "note": "", + }) + except Exception as _se: + _log(job_dir, f"SPLIT_ERROR [{name}]: {_se}") + + if not _split_done: + stems.append({ + "id": name, + "name": stem_name_disp, + "instrument": stem_inst, + "family": stem_family, + "clef": stem_clef, + "color": m.get("color", "--s-synth"), + "role": m.get("role", ""), + "confidence": midi_confs.get(name, 0.0), + "midi": midi_counts.get(name, 0), + "audio_url": f"/work/{sid}/stems/{name}.wav", + "midi_url": f"/work/{sid}/midi/{name}.mid" if midi_file.exists() else None, + "density": 3, "seed": hash(name) % 50 + 1, "note": "", + }) + + final_meta = {**meta, + "tempo": analysis["tempo"], + "key": analysis["key"], + "timeSig": analysis["timeSig"], + "sampleRate": analysis["sampleRate"], + "bitDepth": analysis["bitDepth"], + "spectrum": analysis.get("spectrum", []), + "sepModel": "HT-Demucs v4 · 6-stem", + "transModel": ("omnizart + basic-pitch" if _OMNIZART_AVAIL and _BASIC_PITCH_AVAIL + else "omnizart" if _OMNIZART_AVAIL + else "Spotify basic-pitch" if _BASIC_PITCH_AVAIL + else "CQT-poly (librosa)"), + "gpu": _gpu_info()} + _log(job_dir, + "=== FINAL SUMMARY ===", + f"title: {meta.get('title','?')}", + f"artist: {meta.get('artist','?')}", + f"duration: {meta.get('duration',0):.1f} s", + f"tempo: {analysis['tempo']} BPM", + f"key: {analysis['key']}", + f"time_sig: {analysis['timeSig'][0]}/{analysis['timeSig'][1]}", + f"sample_rate: {analysis['sampleRate']} Hz", + f"bit_depth: {analysis['bitDepth']}-bit", + f"device: {_DEVICE}", + "midi_counts:", + *[f" {k}: {v} notes" for k, v in sorted(midi_counts.items())], + "=== PIPELINE DONE ===") + + # ── Human-readable detection log ────────────────────────────────────────── + try: + lines = [ + "═══════════════════════════════════════════════════", + " AEGIS SCORE STUDIO — Detection Report", + "═══════════════════════════════════════════════════", + f" Title : {meta.get('title','?')}", + f" Artist : {meta.get('artist','?')}", + f" Duration : {meta.get('duration',0):.1f} s", + f" Source : {meta.get('source','?')}", + "", + "── Audio Analysis ─────────────────────────────────", + f" BPM : {analysis['tempo']}", + f" Key : {analysis['key']}", + f" Time Sig : {analysis['timeSig'][0]}/{analysis['timeSig'][1]}", + f" Sample Rate: {analysis['sampleRate']} Hz", + f" Bit Depth : {analysis['bitDepth']}-bit", + "", + "── Models Used ────────────────────────────────────", + f" Separation : HT-Demucs v4 · 6-stem", + f" Transcribe : {'omnizart (other) + basic-pitch (melodic)' if _OMNIZART_AVAIL and _BASIC_PITCH_AVAIL else 'omnizart' if _OMNIZART_AVAIL else 'Spotify basic-pitch (ONNX)' if _BASIC_PITCH_AVAIL else 'CQT-poly (librosa)'}", + f" Beat Track : beat_this ISMIR-2024", + f" Device : {_DEVICE.upper()}", + "", + "── Detected Stems ─────────────────────────────────", + ] + for s in stems: + cls = stem_classifications.get(s["id"], {}) + status = "✓ kept" if not s.get("removed") else "✗ removed (silent)" + lines += [ + f" [{s['id'].upper():6}] {s['name']:18} {status}", + f" Instrument : {s['instrument']}", + f" Family : {s['family']}", + f" Clef : {s['clef']}", + f" MIDI notes : {s['midi']}", + f" Confidence : {s['confidence']:.0f}%", + ] + if s["id"] == "other": + lines.append(f" Detected as: {s['name']} (spectral classification)") + midi_file = job_dir / "midi" / f"{s['id']}.mid" + if midi_file.exists() and not s.get("removed"): + try: + import pretty_midi as _pm_log + _pm_obj = _pm_log.PrettyMIDI(str(midi_file)) + _all_notes = sorted( + [n for inst in _pm_obj.instruments for n in inst.notes], + key=lambda n: n.start, + ) + if _all_notes: + lines.append(f" Detected notes ({len(_all_notes)}):") + _MAX = 200 + for _n in _all_notes[:_MAX]: + _name = _pm_log.note_number_to_name(_n.pitch) + _dur = _n.end - _n.start + lines.append( + f" {_name:4} t={_n.start:7.2f}s" + f" dur={_dur:.2f}s vel={_n.velocity}" + ) + if len(_all_notes) > _MAX: + lines.append(f" ... +{len(_all_notes) - _MAX} more") + except Exception: + pass + lines.append("") + for stem_name_r in DEMUCS_SOURCES: + cls_r = stem_classifications.get(stem_name_r, {}) + if cls_r.get("removed") and not any(s["id"] == stem_name_r for s in stems): + lines.append(f" [{stem_name_r.upper():6}] (silent — removed from score)") + lines += [ + "", + "═══════════════════════════════════════════════════", + ] + (job_dir / "detection_log.txt").write_text("\n".join(lines), encoding="utf-8") + except Exception as _le: + _log(job_dir, f"detection_log write error: {_le}") + + _set(sid, status="done", progress=1.0, stage=4, stems=stems, meta=final_meta) + + +# ── Web MIDI fetch (fast path) ─────────────────────────────────────────────── +_GM_PROGRAM_MAP = [ + (8, "Piano", "Keys", "treble"), + (16, "Chromatic Perc", "Percussion", "treble"), + (24, "Organ", "Keys", "treble"), + (32, "Guitar", "Strings", "treble"), + (40, "Bass", "Bass", "bass"), + (48, "Strings", "Strings", "treble"), + (56, "Ensemble", "Strings", "treble"), + (64, "Brass", "Brass", "treble"), + (72, "Reed", "Woodwind", "treble"), + (80, "Pipe", "Woodwind", "treble"), + (88, "Synth Lead", "Synth", "treble"), + (96, "Synth Pad", "Synth", "treble"), + (104, "Synth Effects", "Synth", "treble"), + (112, "Ethnic", "World", "treble"), + (120, "Percussive", "Percussion", "treble"), + (128, "Sound Effects", "Other", "treble"), +] + +def _gm_program_meta(program: int) -> tuple: + for ceil, name, family, clef in _GM_PROGRAM_MAP: + if program < ceil: + return name, family, clef + return "Other", "Other", "treble" + + +def _try_fetch_midi_sync(title: str, artist: str, job_dir: Path): + """Search bitmidi.com then freemidi.org for a matching MIDI. Blocking. Returns Path or None.""" + import urllib.parse, urllib.request, json, re as _re2 + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + "Accept": "text/html,application/xhtml+xml,*/*", + } + + def _get(url, timeout=8): + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read().decode("utf-8", errors="replace") + + def _save(url, dest: Path, timeout=15): + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as r: + dest.write_bytes(r.read()) + return dest.exists() and dest.stat().st_size > 200 + except Exception: + return False + + raw = f"{artist} {title}".strip() + # Strip feat/remix/video annotations that confuse search + clean = _re2.sub(r"\s*[\(\[【【][^\)\]】]*[\)\]】]", "", raw).strip()[:60] + + # ── bitmidi.com ────────────────────────────────────────────────────────── + try: + html = _get(f"https://bitmidi.com/search?q={urllib.parse.quote_plus(clean)}") + m = _re2.search(r'', html, _re2.S) + if m: + data = json.loads(m.group(1)) + results = data.get("props", {}).get("pageProps", {}).get("results", []) + if results: + slug = results[0].get("slug") or str(results[0].get("id", "")) + if slug: + page = _get(f"https://bitmidi.com/{slug}") + dm = _re2.search(r'"(https://bitmidi\.com/uploads/[^"]+\.mid)"', page) + if dm: + out = job_dir / "fetched.mid" + if _save(dm.group(1), out): + return out + dm2 = _re2.search(r'href="(/uploads/[^"]+\.mid)"', page) + if dm2: + out = job_dir / "fetched.mid" + if _save("https://bitmidi.com" + dm2.group(1), out): + return out + except Exception: + pass + + # ── freemidi.org ───────────────────────────────────────────────────────── + try: + html = _get(f"https://freemidi.org/search?s={urllib.parse.quote_plus(clean)}") + lm = _re2.search(r'href="(https?://freemidi\.org/download[^"]+)"', html) + if lm: + page = _get(lm.group(1)) + gm = _re2.search(r'href="(https?://freemidi\.org/getter[^"]+)"', page) + if not gm: + gm = _re2.search(r'"(https?://[^"]+\.mid)"', page) + if gm: + out = job_dir / "fetched.mid" + if _save(gm.group(1), out): + return out + except Exception: + pass + + return None + + +async def _pipeline_fetched_midi(sid: str, midi_path: Path, audio_path: Path, meta: dict): + """Fast-path: use a web-fetched MIDI, skip Demucs + transcription.""" + import pretty_midi, re as _re3 + job_dir = midi_path.parent + stems_dir = job_dir / "stems" + midi_dir = job_dir / "midi" + stems_dir.mkdir(exist_ok=True) + midi_dir.mkdir(exist_ok=True) + loop = asyncio.get_running_loop() + + _log(job_dir, "=== FAST PATH: WEB MIDI ===", f"file: {midi_path.name}") + # Stage 1 slot: audio analysis (replaces Demucs) + _set(sid, stage=1, status="separating", progress=0.22) + analysis = await loop.run_in_executor(_POOL, _analyze_audio_sync, audio_path, job_dir) + # Stage 2 slot: instrument ID (instant — we read it from MIDI program numbers) + _set(sid, stage=2, status="identifying", progress=0.62) + pm = pretty_midi.PrettyMIDI(str(midi_path)) + tempo = float(analysis.get("tempo", 120)) + _set(sid, progress=0.70) + + # Original audio as single "mix" track for WAV playback + shutil.copy(audio_path, stems_dir / "mix.wav") + stems = [{ + "id": "mix", "name": "Mix (Audio)", "instrument": "Full Mix", + "family": "Audio", "clef": "treble", "color": "--s-synth", "role": "Playback", + "confidence": 100.0, "midi": 0, + "audio_url": f"/work/{sid}/stems/mix.wav", + "midi_url": None, + "density": 0, "seed": 1, "note": "Web-fetched MIDI · stem isolation N/A", + }] + midi_counts: dict = {} + used_ids: dict = {} + + # Stage 3 slot: MIDI import (replaces transcription) + _set(sid, stage=3, status="transcribing", progress=0.72) + for inst in pm.instruments: + if not inst.notes: + continue + raw_id = _re3.sub(r"[^a-z0-9]+", "_", + (inst.name or "track").lower()).strip("_")[:20] or "track" + n = used_ids.get(raw_id, 0) + used_ids[raw_id] = n + 1 + stem_id = raw_id if n == 0 else f"{raw_id}_{n}" + + if inst.is_drum: + inst_name, family, clef = "Drums", "Percussion", "percussion" + else: + inst_name, family, clef = _gm_program_meta(inst.program) + + pm_out = pretty_midi.PrettyMIDI(initial_tempo=tempo) + inst_copy = pretty_midi.Instrument( + program=inst.program, is_drum=inst.is_drum, + name=inst.name or inst_name) + inst_copy.notes = list(inst.notes) + pm_out.instruments.append(inst_copy) + pm_out.write(str(midi_dir / f"{stem_id}.mid")) + stems.append({ - "id": name, "name": m.get("name", name.title()), - "instrument": m.get("instrument", "Unknown"), - "family": m.get("family", ""), "clef": m.get("clef", "treble"), - "color": m.get("color", "--s-synth"), "role": m.get("role", ""), - "confidence": 90.0, "midi": midi_counts.get(name, 0), - "audio_url": f"/work/{sid}/stems/{name}.wav", - "midi_url": f"/work/{sid}/midi/{name}.mid" if (midi_dir / f"{name}.mid").exists() else None, - "density": 3, "seed": hash(name) % 50 + 1, "note": "", + "id": stem_id, + "name": inst.name or inst_name, + "instrument": inst_name, + "family": family, + "clef": clef, + "color": "--s-synth", + "role": "", + "confidence": 100.0, + "midi": len(inst.notes), + "audio_url": None, + "midi_url": f"/work/{sid}/midi/{stem_id}.mid", + "density": 3, "seed": hash(stem_id) % 50 + 1, "note": "", }) + midi_counts[stem_id] = len(inst.notes) - _set(sid, status="done", progress=1.0, stage=4, stems=stems, - meta={**meta, "tempo": 120, "key": "A minor", "timeSig": [4, 4], - "sampleRate": 44100, "bitDepth": 24, - "sepModel": "HT-Demucs v4 · 6-stem", - "transModel": "librosa-pyin", - "gpu": _gpu_info()}) + if len(stems) <= 1: + raise ValueError("Fetched MIDI has no playable tracks") + + _set(sid, stage=4, status="engraving", progress=0.92) + + final_meta = {**meta, + "tempo": analysis["tempo"], + "key": analysis["key"], + "timeSig": analysis["timeSig"], + "sampleRate": analysis["sampleRate"], + "bitDepth": analysis["bitDepth"], + "spectrum": analysis.get("spectrum", []), + "sepModel": "Web fetch · no stem separation", + "transModel": "bitmidi.com / freemidi.org", + "gpu": _gpu_info()} + + _log(job_dir, + "=== FAST PATH DONE ===", + f"tracks: {len(stems)-1}", + f"tempo: {analysis['tempo']} BPM", + f"key: {analysis['key']}", + *[f" {k}: {v} notes" for k, v in sorted(midi_counts.items())]) + + try: + dlines = [ + "═══════════════════════════════════════════════════", + " AEGIS SCORE STUDIO — Detection Report (Web MIDI)", + "═══════════════════════════════════════════════════", + f" Title : {meta.get('title','?')}", + f" Artist : {meta.get('artist','?')}", + f" Duration : {meta.get('duration',0):.1f} s", + f" Source : {meta.get('source','?')}", + "", + "── Audio Analysis ─────────────────────────────────", + f" BPM : {analysis['tempo']}", + f" Key : {analysis['key']}", + f" Time Sig : {analysis['timeSig'][0]}/{analysis['timeSig'][1]}", + f" Sample Rate: {analysis['sampleRate']} Hz", + f" Bit Depth : {analysis['bitDepth']}-bit", + "", + "── Pipeline ───────────────────────────────────────", + " MIDI source: Fetched from web (bitmidi.com / freemidi.org)", + " Separation : Skipped — stem isolation unavailable", + " Transcribe : Skipped — using web MIDI", + "", + "── MIDI Tracks ────────────────────────────────────", + ] + for s in stems[1:]: + dlines += [ + f" [{s['id'][:6].upper():6}] {s['name']:20} {s['midi']} notes", + f" Instrument : {s['instrument']}", + f" Family : {s['family']}", + "", + ] + dlines.append("═══════════════════════════════════════════════════") + (job_dir / "detection_log.txt").write_text("\n".join(dlines), encoding="utf-8") + except Exception as _le: + _log(job_dir, f"detection_log write error: {_le}") + + _set(sid, status="done", progress=1.0, stage=4, stems=stems, meta=final_meta) # ── Background pipeline: URL ────────────────────────────────────────────────── @@ -202,6 +1111,7 @@ async def pipeline_url(sid: str, url: str): audio_path = job_dir / "audio.wav" try: + _log(job_dir, "=== PIPELINE START (URL) ===", f"sid: {sid}", f"url: {url}") _set(sid, stage=0, status="downloading", progress=0.0) import yt_dlp ydl_opts = { @@ -211,9 +1121,14 @@ async def pipeline_url(sid: str, url: str): "outtmpl": str(job_dir / "audio"), "quiet": True, "no_warnings": True, - "extractor_args": {"youtube": {"player_client": ["android", "web"]}}, + # ios+mweb = current yt-dlp defaults; android+web as fallbacks + "extractor_args": {"youtube": {"player_client": ["ios", "mweb", "android", "web"]}}, + "noplaylist": True, + "extractor_retries": 5, + "fragment_retries": 10, + "retries": 5, } - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() def _download(): with yt_dlp.YoutubeDL(ydl_opts) as ydl: return ydl.extract_info(url, download=True) @@ -232,18 +1147,46 @@ def _download(): resolved.rename(audio_path) elif not resolved: raise FileNotFoundError(f"yt-dlp produced no WAV in {job_dir}. Is ffmpeg on PATH?") + _log(job_dir, + f"download_ok: {meta['title']}", + f"uploader: {meta['artist']}", + f"duration: {meta['duration']} s") _set(sid, meta=meta, progress=0.18) + # ── Fast path: try fetching existing MIDI (silent, part of normal flow) ── + fetched = await loop.run_in_executor( + None, _try_fetch_midi_sync, meta["title"], meta.get("artist", ""), job_dir) + if fetched: + _log(job_dir, f"midi_fetch_ok: {fetched.name} ({fetched.stat().st_size} bytes)") + await _pipeline_fetched_midi(sid, fetched, audio_path, meta) + URL_CACHE[url] = sid + return + + _log(job_dir, "midi_fetch: none found — running full pipeline") await _separate_and_transcribe(sid, audio_path, meta) URL_CACHE[url] = sid except Exception as exc: - _set(sid, status="error", error=str(exc), progress=1.0) + err_str = str(exc) + _log(job_dir, f"PIPELINE ERROR: {exc}", traceback.format_exc()) + # Friendlier messages for common failures + _lower = err_str.lower() + if "sign in" in _lower or "age" in _lower or "confirm your age" in _lower: + err_str = "Age-restricted video — YouTube requires sign-in. Try a different video." + elif "private" in _lower: + err_str = "Private video — not accessible." + elif "unavailable" in _lower or "removed" in _lower: + err_str = "Video unavailable or removed by uploader." + elif "ffmpeg" in _lower: + err_str = "ffmpeg not found on PATH — required for audio conversion." + _set(sid, status="error", error=err_str) # ── Background pipeline: local file ────────────────────────────────────────── async def pipeline_file(sid: str, audio_path: Path, filename: str): + job_dir = audio_path.parent try: + _log(job_dir, "=== PIPELINE START (FILE) ===", f"sid: {sid}", f"file: {filename}") _set(sid, stage=0, status="converting", progress=0.05) # If not WAV, convert with ffmpeg if audio_path.suffix.lower() != ".wav": @@ -262,20 +1205,23 @@ async def pipeline_file(sid: str, audio_path: Path, filename: str): "thumb": "", "source": filename, } + _log(job_dir, f"file_ok: {filename} duration={int(info.duration)}s") _set(sid, meta=meta, progress=0.18) await _separate_and_transcribe(sid, audio_path, meta) except Exception as exc: - _set(sid, status="error", error=str(exc), progress=1.0) + _log(job_dir, f"PIPELINE ERROR: {exc}", traceback.format_exc()) + _set(sid, status="error", error=str(exc)) # ── Routes ──────────────────────────────────────────────────────────────────── @app.post("/api/analyze") async def analyze(req: AnalyzeRequest, bg: BackgroundTasks): url = req.url.strip() - cached = URL_CACHE.get(url) - if cached and JOBS.get(cached, {}).get("status") == "done": - return {"session_id": cached, "cached": True} + if not req.force: + cached = URL_CACHE.get(url) + if cached and JOBS.get(cached, {}).get("status") == "done": + return {"session_id": cached, "cached": True} sid = str(uuid.uuid4()) JOBS[sid] = {"status": "queued", "progress": 0.0, "stage": -1, "meta": {}, "stems": [], "error": None} @@ -300,34 +1246,75 @@ async def upload(bg: BackgroundTasks, file: UploadFile = File(...)): return {"session_id": sid} +@app.post("/api/resume/{sid}") +async def resume(sid: str, bg: BackgroundTasks): + """Resume processing of a session whose audio.wav is already on disk.""" + job_dir = WORK_DIR / sid + audio_path = job_dir / "audio.wav" + if not audio_path.exists(): + raise HTTPException(404, "no audio found for this session") + if JOBS.get(sid, {}).get("status") in ("separating", "transcribing", "done"): + return {"session_id": sid, "status": JOBS[sid]["status"]} + import soundfile as sf + info = sf.info(str(audio_path)) + meta = {"title": sid[:8], "artist": "Local file", "duration": int(info.duration), + "thumb": "", "source": "resumed"} + JOBS[sid] = {"status": "queued", "progress": 0.18, "stage": 0, + "meta": meta, "stems": [], "error": None} + bg.add_task(_separate_and_transcribe, sid, audio_path, meta) + return {"session_id": sid} + + @app.get("/api/status/{sid}") async def status(sid: str): job = JOBS.get(sid) if not job: - raise HTTPException(404, "session not found") + # Server restarted — try reading persisted session from disk + session_file = WORK_DIR / sid / "session.json" + if session_file.exists(): + try: + import json as _json + job = _json.loads(session_file.read_text(encoding="utf-8")) + JOBS[sid] = job # reload into memory + except Exception: + raise HTTPException(404, "session not found") + else: + raise HTTPException(404, "session not found") return JSONResponse(job) +@app.get("/api/detection_log/{sid}") +async def get_detection_log(sid: str): + log_path = WORK_DIR / sid / "detection_log.txt" + if not log_path.exists(): + raise HTTPException(404, "log not found") + from fastapi.responses import PlainTextResponse + return PlainTextResponse(log_path.read_text(encoding="utf-8")) + + @app.get("/api/midi/{sid}/{stem_name}") async def get_midi_notes(sid: str, stem_name: str): midi_path = WORK_DIR / sid / "midi" / f"{stem_name}.mid" if not midi_path.exists(): raise HTTPException(404, "MIDI not found") - import pretty_midi - pm = pretty_midi.PrettyMIDI(str(midi_path)) - notes = [] - for inst in pm.instruments: - for note in inst.notes: - notes.append({ - "pitch": note.pitch, - "start": round(float(note.start), 4), - "end": round(float(note.end), 4), - "velocity": note.velocity, - "drum": bool(inst.is_drum), - }) - notes.sort(key=lambda n: n["start"]) - return {"notes": notes, "end_time": round(float(pm.get_end_time()), 2)} + try: + import pretty_midi + pm = pretty_midi.PrettyMIDI(str(midi_path)) + notes = [] + for inst in pm.instruments: + for note in inst.notes: + notes.append({ + "pitch": note.pitch, + "start": round(float(note.start), 4), + "end": round(float(note.end), 4), + "velocity": note.velocity, + "drum": bool(inst.is_drum), + }) + notes.sort(key=lambda n: n["start"]) + return {"notes": notes, "end_time": round(float(pm.get_end_time()), 2)} + except Exception as exc: + raise HTTPException(422, f"MIDI parse error: {exc}") app.mount("/work", StaticFiles(directory=str(WORK_DIR)), name="work") -app.mount("/", StaticFiles(directory=".", html=True), name="static") +app.mount("/", StaticFiles(directory=os.environ.get("AEGIS_STATIC_DIR", "."), html=True), name="static") diff --git a/server_launch.py b/server_launch.py new file mode 100644 index 0000000..07f5994 --- /dev/null +++ b/server_launch.py @@ -0,0 +1,21 @@ +""" +Entry point for the PyInstaller-compiled server binary. +Sets AEGIS_WORK_DIR / AEGIS_STATIC_DIR before importing server so paths resolve correctly. +""" +import sys +import os +from pathlib import Path + +if getattr(sys, "frozen", False): + # Running inside a PyInstaller bundle + _static_dir = Path(sys._MEIPASS) # extracted data lives here + _work_dir = Path(sys.executable).parent / "work" # writable, next to .exe + os.environ.setdefault("AEGIS_STATIC_DIR", str(_static_dir)) + os.environ.setdefault("AEGIS_WORK_DIR", str(_work_dir)) + _work_dir.mkdir(exist_ok=True, parents=True) + +import uvicorn +from server import app # noqa: E402 — must come after env vars are set + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=7471, log_level="warning") diff --git a/server_launch.spec b/server_launch.spec new file mode 100644 index 0000000..62bd376 --- /dev/null +++ b/server_launch.spec @@ -0,0 +1,79 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all +from pathlib import Path + +ROOT = Path(SPECPATH) + +datas = [ + (str(ROOT / 'index.html'), '.'), + (str(ROOT / 'css'), 'css'), + (str(ROOT / 'js'), 'js'), +] +binaries = [] +hiddenimports = [ + 'uvicorn.logging', + 'uvicorn.loops', 'uvicorn.loops.auto', + 'uvicorn.protocols', 'uvicorn.protocols.http', + 'uvicorn.protocols.http.auto', + 'uvicorn.protocols.http.httptools_impl', + 'uvicorn.protocols.http.h11_impl', + 'uvicorn.protocols.websockets', + 'uvicorn.protocols.websockets.auto', + 'uvicorn.protocols.websockets.wsproto_impl', + 'uvicorn.lifespan', 'uvicorn.lifespan.on', 'uvicorn.lifespan.off', + 'starlette', 'starlette.staticfiles', 'starlette.routing', + 'starlette.middleware', 'starlette.responses', + 'librosa', 'librosa.core', 'librosa.feature', + 'librosa.beat', 'librosa.onset', + 'pretty_midi', 'soundfile', 'yt_dlp', + 'demucs', 'demucs.pretrained', 'demucs.apply', 'demucs.audio', + 'torch', 'sklearn', 'scipy', 'scipy.signal', 'scipy.ndimage', +] + +for pkg in ('librosa', 'demucs', 'yt_dlp'): + d, b, h = collect_all(pkg) + datas += d; binaries += b; hiddenimports += h + +a = Analysis( + [str(ROOT / 'server_launch.py')], + pathex=[str(ROOT)], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='server_launch', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +# Name must be 'server_dist' — electron-builder copies dist/server_dist/ into resources +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='server_dist', +) diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..6ee1d0a --- /dev/null +++ b/setup.bat @@ -0,0 +1,205 @@ +@echo off +setlocal enabledelayedexpansion +cd /d "%~dp0" + +echo. +echo ============================================================ +echo Aegis Score Studio -- First-Time Setup +echo ============================================================ +echo. + +:: ── 0. Prerequisites check ─────────────────────────────────────────────────── +where node >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Node.js not found. + echo Download from https://nodejs.org ^(LTS^) + pause & exit /b 1 +) +where python >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Python not found on PATH. + echo Download Python 3.11+ from https://python.org + pause & exit /b 1 +) + +for /f "tokens=2" %%v in ('python --version 2^>^&1') do set PYVER=%%v +echo System Python: %PYVER% + +where ffmpeg >nul 2>&1 +if errorlevel 1 ( + echo WARNING: ffmpeg not found -- audio conversion will fail. + echo Download from https://ffmpeg.org and add to PATH. + echo Continuing anyway... + echo. +) + +:: ── 1. npm dependencies ────────────────────────────────────────────────────── +echo [1/5] Installing Node.js dependencies... +call npm install +if errorlevel 1 ( + echo ERROR: npm install failed. + pause & exit /b 1 +) +echo Done. +echo. + +:: ── 2. Main Python dependencies (system Python, 3.10+) ────────────────────── +echo [2/5] Installing main Python dependencies... +echo ^(fastapi, demucs, librosa, basic-pitch, yt-dlp, etc.^) +python -m pip install --upgrade pip -q +python -m pip install ^ + fastapi ^ + "uvicorn[standard]" ^ + yt-dlp ^ + "demucs>=4.0.1" ^ + torch ^ + torchaudio ^ + "librosa>=0.10.0" ^ + "pretty-midi>=0.2.10" ^ + "soundfile>=0.12.1" ^ + python-multipart ^ + basic-pitch ^ + beat-this +if errorlevel 1 ( + echo WARNING: Some packages may have failed. Check output above. + echo The app may still work without optional packages. +) +echo Done. +echo. + +:: ── 3. omnizart isolated environment ──────────────────────────────────────── +echo [3/5] Setting up omnizart ^(orchestral AI^) +echo. +echo Requires: Python 3.9 64-bit ^(TensorFlow needs 64-bit^) +echo omnizart's dependency madmom can't compile on Windows, +echo so we install omnizart --no-deps and supply its real deps manually. +echo. + +set OMNI_PY= +set OMNI_ENV=C:\omnizart_env + +:: Check 64-bit Python 3.9 via common install paths (prefer explicit paths over py launcher +:: because py launcher may return 32-bit installs which TensorFlow rejects) +for %%p in ( + "C:\Python39\python.exe" + "%LOCALAPPDATA%\Programs\Python\Python39\python.exe" + "C:\Program Files\Python39\python.exe" +) do ( + if "!OMNI_PY!"=="" ( + if exist %%p ( + :: Verify it's 64-bit + %%p -c "import struct; exit(0 if struct.calcsize('P')*8==64 else 1)" >nul 2>&1 + if not errorlevel 1 set OMNI_PY=%%p + ) + ) +) + +:: Try py launcher as fallback (may or may not be 64-bit) +if "!OMNI_PY!"=="" ( + py -3.9 --version >nul 2>&1 + if not errorlevel 1 ( + py -3.9 -c "import struct; exit(0 if struct.calcsize('P')*8==64 else 1)" >nul 2>&1 + if not errorlevel 1 set OMNI_PY=py -3.9 + ) +) + +if "!OMNI_PY!"=="" ( + echo 64-bit Python 3.9 NOT FOUND. + echo. + echo omnizart DISABLED. To enable orchestral AI transcription: + echo 1. Download Python 3.9.13 64-bit installer: + echo https://www.python.org/downloads/release/python-3913/ + echo ^(scroll to Files ^> Windows installer ^(64-bit^)^) + echo 2. Install to C:\Python39 ^(don't replace system Python^) + echo 3. Re-run setup.bat + echo. + echo App still works -- uses basic-pitch for all stems instead. + goto :skip_omnizart +) + +echo Found 64-bit Python 3.9: !OMNI_PY! +echo. + +if exist "!OMNI_ENV!\Scripts\python.exe" ( + echo omnizart env already exists. Delete !OMNI_ENV! to reinstall. + goto :download_models +) + +echo Creating virtual environment at !OMNI_ENV! ... +!OMNI_PY! -m venv !OMNI_ENV! +if errorlevel 1 ( echo ERROR: venv creation failed. & goto :skip_omnizart ) + +echo Upgrading pip... +"!OMNI_ENV!\Scripts\python.exe" -m pip install --upgrade pip -q + +echo Installing omnizart dependencies ^(skipping madmom -- Windows incompatible^)... +echo This downloads TensorFlow ~500 MB, may take 10+ min... +"!OMNI_ENV!\Scripts\pip.exe" install ^ + "numpy<2.0" ^ + "tensorflow>=2.3,<2.14" ^ + librosa ^ + scipy ^ + mir_eval ^ + pretty_midi ^ + h5py ^ + click ^ + colorama ^ + pyyaml +if errorlevel 1 ( + echo ERROR: Dependency install failed. Check output above. + rmdir /s /q "!OMNI_ENV!" + goto :skip_omnizart +) + +echo Installing omnizart ^(no-deps -- madmom excluded^)... +"!OMNI_ENV!\Scripts\pip.exe" install omnizart --no-deps +if errorlevel 1 ( + echo ERROR: omnizart install failed. + rmdir /s /q "!OMNI_ENV!" + goto :skip_omnizart +) + +echo Installing remaining omnizart deps ^(jsonschema, sherpa-onnx, tf-keras, etc.^)... +"!OMNI_ENV!\Scripts\pip.exe" install jsonschema tqdm Pillow matplotlib sherpa-onnx tf-keras +if errorlevel 1 ( + echo WARNING: Some optional deps failed. omnizart may still work. +) + +:download_models +echo. +echo [4/5] Pre-loading omnizart music model ^(auto-downloads ~300 MB on first use^)... +"!OMNI_ENV!\Scripts\python.exe" -c "from omnizart.music import app; print('omnizart ready')" 2>nul +if errorlevel 1 ( + echo WARNING: omnizart import check failed. Will retry on first transcription. +) + +echo omnizart ready. +goto :done_omnizart + +:skip_omnizart +echo Skipping omnizart setup. + +:done_omnizart +echo. + +:: ── 4. Write .env so launch script knows paths ─────────────────────────────── +echo [5/5] Writing launch configuration... + +if exist "C:\omnizart_env\Scripts\python.exe" ( + echo OMNIZART_PYTHON=C:\omnizart_env\Scripts\python.exe> .env.local + echo omnizart path saved to .env.local +) else ( + if exist .env.local del .env.local + echo No omnizart env -- .env.local not written. +) + +:: Mark setup complete +echo %date% %time%> .setup_complete + +echo. +echo ============================================================ +echo Setup complete! +echo Run "Launch Aegis.bat" to start the app. +echo ============================================================ +echo. +pause From 7832711afb1508080fe7e4830ee6b5513f1b700a Mon Sep 17 00:00:00 2001 From: arnavaggarwal-dev Date: Fri, 19 Jun 2026 05:25:00 +0800 Subject: [PATCH 5/6] Create README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcd0a34 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +download evrything as a zip. +use '''pip install -r requirements.txt''' +also make sure u have python 3.9 installed +have fun +this barely works +good luck +pls fix ts From 77dfe5decd0a5d391b126918e7ce11fa9b4b91db Mon Sep 17 00:00:00 2001 From: arnavaggarwal-dev Date: Fri, 19 Jun 2026 05:25:48 +0800 Subject: [PATCH 6/6] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dcd0a34..03a9a51 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ download evrything as a zip. -use '''pip install -r requirements.txt''' +use ```pip install -r requirements.txt``` also make sure u have python 3.9 installed have fun this barely works