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..fb59169 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ build/ venv/ *.log test_sf.wav +aegis.db + +.env.local +.setup_complete +models/ 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/Launch Aegis.bat b/Launch Aegis.bat new file mode 100644 index 0000000..13a7ac0 --- /dev/null +++ b/Launch Aegis.bat @@ -0,0 +1,39 @@ +@echo off +setlocal enabledelayedexpansion +cd /d "%~dp0" + +:: Auto-run setup on first launch +if not exist ".setup_complete" ( + echo First launch detected -- running setup... + echo. + call setup.bat + if errorlevel 1 ( + echo Setup failed. Please fix errors above and try again. + pause & exit /b 1 + ) +) + +:: Load omnizart path from .env.local if present +if exist ".env.local" ( + for /f "tokens=1,* delims==" %%a in (.env.local) do ( + if "%%a"=="OMNIZART_PYTHON" set OMNIZART_PYTHON=%%b + ) +) + +:: Validate omnizart path still exists (venv may have been deleted) +if defined OMNIZART_PYTHON ( + if not exist "!OMNIZART_PYTHON!" ( + echo WARNING: OMNIZART_PYTHON path not found: !OMNIZART_PYTHON! + echo omnizart disabled. Re-run setup.bat to reinstall. + set OMNIZART_PYTHON= + ) +) + +if defined OMNIZART_PYTHON ( + echo omnizart: enabled ^(!OMNIZART_PYTHON!^) +) else ( + echo omnizart: disabled ^(install Python 3.9 + run setup.bat to enable^) +) + +echo. +npm start diff --git a/README.md b/README.md new file mode 100644 index 0000000..03a9a51 --- /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 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/assets/splash.html b/assets/splash.html new file mode 100644 index 0000000..9099b99 --- /dev/null +++ b/assets/splash.html @@ -0,0 +1,24 @@ + + + + + + + + +
AEGISSCORE
+
Starting local engine…
+ + 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..bd2b02f --- /dev/null +++ b/backend/api/app.py @@ -0,0 +1,35 @@ +"""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, history +from backend.db.database import init_db + + +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=["*"], + ) + + 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) + 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/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/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/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/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..6ad7337 --- /dev/null +++ b/backend/services/pipeline.py @@ -0,0 +1,187 @@ +""" +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 + 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) + + +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) + 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/build.bat b/build.bat new file mode 100644 index 0000000..b77f2fc --- /dev/null +++ b/build.bat @@ -0,0 +1,21 @@ +@echo off +cd /d "%~dp0" + +echo [1/3] Checking PyInstaller... +python -m PyInstaller --version >nul 2>&1 +if errorlevel 1 ( + echo Installing PyInstaller... + pip install pyinstaller +) + +echo [2/3] Bundling Python server... +python -m PyInstaller server_launch.spec --noconfirm --distpath dist +if errorlevel 1 ( echo PyInstaller failed & pause & exit /b 1 ) + +echo [3/3] Building Electron installer... +npm run build +if errorlevel 1 ( echo electron-builder failed & pause & exit /b 1 ) + +echo. +echo Done! Installer is in dist\ +pause diff --git a/build.py b/build.py new file mode 100644 index 0000000..8312406 --- /dev/null +++ b/build.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Aegis Score Studio — build script +Produces: dist/Aegis Score Studio Setup.exe (Windows NSIS installer) + +Usage: + python build.py # full build (PyInstaller server + Electron installer) + python build.py --electron-only # skip PyInstaller, requires Python on target + +Steps: + 1. Verify / fix setuptools so PyInstaller works + 2. PyInstaller: server_launch.py -> dist/server_dist/server_launch.exe + 3. electron-builder: bundles Electron + server_dist -> dist/*.exe installer +""" + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).parent.resolve() + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def run(cmd, **kwargs): + cmd_str = " ".join(str(c) for c in cmd) + print(f"\n>>> {cmd_str}") + subprocess.run(cmd, check=True, **kwargs) + + +def run_py(*args): + run([sys.executable, *args]) + + +# ── Step 1: Fix setuptools / pkg_resources ──────────────────────────────────── + +def ensure_pyinstaller(): + # setuptools >= 80 dropped pkg_resources; PyInstaller (via altgraph) needs it + try: + import pkg_resources # noqa: F401 + import PyInstaller # noqa: F401 + print("[1] setuptools + PyInstaller OK") + return + except ModuleNotFoundError: + pass + + print("[1] Fixing setuptools (pinning <80 for pkg_resources) ...") + run_py("-m", "pip", "install", "setuptools<80", "--quiet") + print("[1] Installing / upgrading PyInstaller ...") + run_py("-m", "pip", "install", "pyinstaller", "--upgrade", "--quiet") + print("[1] Done.") + + +# ── Step 2: PyInstaller ─────────────────────────────────────────────────────── + +HIDDEN = [ + # uvicorn lazy imports + "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 / fastapi + "starlette", "starlette.staticfiles", "starlette.routing", + "starlette.middleware", "starlette.responses", + # audio + ML + "librosa", "librosa.core", "librosa.feature", "librosa.beat", + "librosa.onset", "librosa.decompose", "librosa.effects", + "pretty_midi", "soundfile", "yt_dlp", + "demucs", "demucs.pretrained", "demucs.apply", "demucs.audio", + "torchaudio", "torch", "sklearn", + "scipy", "scipy.signal", "scipy.ndimage", +] + +COLLECT_ALL = ["librosa", "demucs", "torchaudio", "yt_dlp"] + +STATIC_DATA = [ + ("index.html", "."), + ("css", "css"), + ("js", "js"), + ("assets", "assets"), +] + + +def _data_flag(src, dst): + sep = ";" if sys.platform == "win32" else ":" + return f"--add-data={src}{sep}{dst}" + + +def build_server_exe(): + print("[2] Building server_launch.exe with PyInstaller ...") + out_dir = ROOT / "dist" / "server_dist" + + cmd = [ + sys.executable, "-m", "PyInstaller", + "--noconfirm", + "--onedir", + "--name", "server_launch", + "--distpath", str(ROOT / "dist"), + "--workpath", str(ROOT / "build_pyinstaller"), + "--specpath", str(ROOT), + "--noconsole", # no shell window — Electron reads stdout/stderr via pipe + ] + + for h in HIDDEN: + cmd += ["--hidden-import", h] + + for pkg in COLLECT_ALL: + cmd += ["--collect-all", pkg] + + for src, dst in STATIC_DATA: + p = ROOT / src + if p.exists(): + cmd.append(_data_flag(str(p), dst)) + else: + print(f" [warn] skipping missing: {src}") + + cmd.append(str(ROOT / "server_launch.py")) + run(cmd, cwd=ROOT) + print(f"[2] server_launch.exe → {out_dir}") + + +# ── Step 3: Update package.json extraResources ──────────────────────────────── + +def patch_package_json(): + print("[3] Patching package.json extraResources ...") + pkg_path = ROOT / "package.json" + pkg = json.loads(pkg_path.read_text(encoding="utf-8")) + + pkg.setdefault("build", {}) + pkg["build"]["extraResources"] = [ + { + "from": "dist/server_dist", + "to": "server_dist", + "filter": ["**/*"] + } + ] + + # Ensure icon path exists or drop it + win_cfg = pkg["build"].get("win", {}) + icon_path = ROOT / win_cfg.get("icon", "NONE") + if not icon_path.exists(): + print(f" [warn] icon not found ({icon_path.name}) — removing from config") + win_cfg.pop("icon", None) + pkg["build"]["win"] = win_cfg + + pkg_path.write_text(json.dumps(pkg, indent=2), encoding="utf-8") + print("[3] Done.") + + +# ── Step 4: electron-builder ────────────────────────────────────────────────── + +def build_electron(): + print("[4] Running electron-builder ...") + # npm is a .cmd on Windows + npm = "npm.cmd" if sys.platform == "win32" else "npm" + run([npm, "run", "build"], cwd=ROOT) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Build Aegis Score Studio") + parser.add_argument("--electron-only", action="store_true", + help="Skip PyInstaller — target machine needs Python installed") + args = parser.parse_args() + + print("=" * 62) + print(" Aegis Score Studio — Build Script") + print("=" * 62) + + if args.electron_only: + print("\n[MODE] electron-only (Python required on target)") + pkg_path = ROOT / "package.json" + pkg = json.loads(pkg_path.read_text(encoding="utf-8")) + pkg["build"].pop("extraResources", None) + pkg_path.write_text(json.dumps(pkg, indent=2), encoding="utf-8") + build_electron() + else: + print("\n[MODE] full (self-contained — bundles Python + all deps)") + print(" Expected output size: 2–4 GB (torch + demucs + librosa)\n") + ensure_pyinstaller() + build_server_exe() + patch_package_json() + build_electron() + + dist = ROOT / "dist" + installer = next(dist.glob("*.exe"), None) + if installer: + size_mb = installer.stat().st_size / 1024 / 1024 + print(f"\n{'='*62}") + print(f" Done! {installer.name} ({size_mb:.0f} MB)") + print(f" Path: {installer}") + print(f"{'='*62}") + else: + print(f"\nBuild complete. Output in: {dist}") + + +if __name__ == "__main__": + main() 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/css/app.css b/css/app.css index 4e87810..0157325 100644 --- a/css/app.css +++ b/css/app.css @@ -284,6 +284,8 @@ html, body { .crumbs { display: flex; align-items: center; gap: 7px; color: var(--t-2); } .crumbs b { color: var(--t-0); font-weight: 600; } .crumbs .sep { color: var(--t-3); } +#crumb-title:not(:empty) { cursor: pointer; border-radius: 4px; padding: 1px 4px; transition: background var(--t-micro); } +#crumb-title:not(:empty):hover { background: var(--bg-3); } .crumbs .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); } .pill { @@ -473,6 +475,8 @@ html, body { } .tbtn svg { width: 14px; height: 14px; } .tbtn.play svg { width: 13px; height: 13px; } +.tp-mode-btn { width: auto; padding: 0 8px; font-size: 10px; font-weight: 700; font-family: var(--mono); letter-spacing: .04em; } +.tp-mode-btn.on { background: var(--acc); border-color: var(--acc-bright); color: var(--bg-0); } .timecode { display: flex; align-items: baseline; gap: 2px; @@ -775,7 +779,7 @@ html, body { CONTEXT MENU ============================================================ */ .ctx { - position: fixed; z-index: 200; min-width: 210px; + position: fixed; z-index: 700; min-width: 210px; background: oklch(0.20 0.008 258 / 0.92); backdrop-filter: blur(16px) saturate(1.4); -webkit-backdrop-filter: blur(16px) saturate(1.4); 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/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 new file mode 100644 index 0000000..3eeae48 --- /dev/null +++ b/frontend/qml/components/Inspector.qml @@ -0,0 +1,170 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Rectangle { + id: root + property var job: null + property string sessionId: "" + signal exportMidi() + signal exportWav() + color: "#090d18" + + Column { + anchors { top: parent.top; left: parent.left; right: parent.right; topMargin: 0 } + spacing: 0 + + // 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 } + } + + // 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 } + } + } + + Rectangle { width: parent.width; height: 1; color: "#131c2e" } + + // 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" } + } + } + + Rectangle { width: parent.width; height: 1; color: "#131c2e" } + + // 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" } + + // 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/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..d3a1572 --- /dev/null +++ b/frontend/qml/components/Sidebar.qml @@ -0,0 +1,136 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +Rectangle { + id: root + property var job: null + signal loadSession(string sid) + color: "#090d18" + + Column { + anchors { top: parent.top; left: parent.left; right: parent.right; topMargin: 0 } + spacing: 0 + + // 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: 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: 12 } + spacing: 10 + + // 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: 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/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..4e81ab5 --- /dev/null +++ b/frontend/qml/views/MainWindow.qml @@ -0,0 +1,175 @@ +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" + color: "#0b0e17" + + // ── Global state ─────────────────────────────────────────────────────── + property string scene: "empty" + property var liveJob: null + property string sessionId: "" + property real progress: 0.0 + property int stage: -1 + property string errorMsg: "" + + // ── Bridge connections ───────────────────────────────────────────────── + Connections { + target: AppBridge + + function onSessionStarted(sid) { + root.sessionId = sid + root.scene = "processing" + root.progress = 0.0 + root.liveJob = null + } + + function onProgressChanged(prog, stg, status) { + root.progress = prog + root.stage = stg + } + + function onAnalysisReady(job) { + root.liveJob = job + root.scene = "success" + } + + function onErrorOccurred(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 { top: omni.bottom; left: parent.left; right: parent.right; bottom: statusBar.top } + spacing: 0 + + // Left sidebar + Sidebar { + Layout.preferredWidth: 220 + Layout.fillHeight: true + job: root.liveJob + onLoadSession: (sid) => { /* TODO: restore session */ } + } + + // Divider + Rectangle { Layout.preferredWidth: 1; Layout.fillHeight: true; color: "#1a2035" } + + // Center content + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + // Scene switcher + Loader { + id: centerLoader + anchors.fill: parent + + 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) + 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: 270 + Layout.fillHeight: true + job: root.liveJob + sessionId: root.sessionId + onExportMidi: _downloadStems("midi") + onExportWav: _downloadStems("wav") + } + } + + // 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 + } + } + + 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" + } + + 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/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..fb483d8 --- /dev/null +++ b/frontend/qml/views/ScoreView.qml @@ -0,0 +1,191 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import "../components" + +Item { + id: root + property var job: null + property string sessionId: "" + + 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 + spacing: 0 + + // ── Transport bar ────────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 44 + color: "#0d1220" + + Row { + anchors { verticalCenter: parent.verticalCenter; left: parent.left; leftMargin: 16 } + spacing: 12 + + // 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 { + 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 } + } + + // 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 } + } + + // 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 + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: "#1e2535" } + + // ── 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/index.html b/index.html index f4fff85..28aae6a 100644 --- a/index.html +++ b/index.html @@ -1,6 +1,7 @@ + Aegis Score Studio @@ -18,9 +19,134 @@ .rk-lab{ position:absolute; top:5px; left:4px; font-family:var(--mono); font-size:9.5px; color:var(--t-2); } #grid-canvas{ position:absolute; inset:0; z-index:0; pointer-events:none; } .lanes-inner{ position:relative; z-index:1; } + /* focus track panel */ + #ov-focus{ position:fixed;bottom:0;left:0;right:0;z-index:120;display:none;pointer-events:none; } + #ov-focus.show{ display:block;pointer-events:auto; } + .focus-panel{ background:var(--bg-1,#14141c);border-top:1px solid var(--acc-line,rgba(255,255,255,.12));padding:10px 16px 14px;box-shadow:0 -8px 32px rgba(0,0,0,.5); } + .focus-hdr{ display:flex;align-items:center;gap:8px;margin-bottom:8px; } + .focus-hdr .fdot{ width:10px;height:10px;border-radius:50%;flex-shrink:0; } + .focus-hdr .fname{ font-size:13px;font-weight:600;color:var(--t-0,#fff); } + .focus-hdr .finfo{ font-size:11px;color:var(--t-3,rgba(255,255,255,.35));margin-left:4px; } + .focus-hdr .fclose{ margin-left:auto;background:none;border:none;color:var(--t-2,rgba(255,255,255,.4));cursor:pointer;font-size:15px;line-height:1;padding:4px 6px;border-radius:4px; } + .focus-hdr .fclose:hover{ background:var(--bg-3,rgba(255,255,255,.06));color:var(--t-0,#fff); } + .focus-wave-wrap{ position:relative;height:80px;border-radius:5px;overflow:hidden;background:var(--bg-2,#1a1a22);cursor:crosshair; } + #focus-wave-cv{ display:block; } + .focus-ph{ position:absolute;top:0;bottom:0;width:2px;background:rgba(255,255,255,.9);left:0%;pointer-events:none;box-shadow:0 0 6px rgba(255,255,255,.45); } + /* processing waveform animation */ + .proc-anim-wrap{ display:flex;justify-content:center;align-items:flex-end;padding:18px 0 14px; } + .proc-wave-anim{ width:110px;height:44px; } + @keyframes pb-dance{ 0%,100%{ transform:scaleY(.12); } 50%{ transform:scaleY(1); } } + .pb{ fill:var(--acc,oklch(.65 .18 260));transform-origin:50% 100%;animation:pb-dance 1.05s ease-in-out infinite; } + .pb:nth-child(1){ animation-delay:0s; animation-duration:1.00s; } + .pb:nth-child(2){ animation-delay:.13s; animation-duration:1.18s; } + .pb:nth-child(3){ animation-delay:.05s; animation-duration:0.92s; } + .pb:nth-child(4){ animation-delay:.22s; animation-duration:1.26s; } + .pb:nth-child(5){ animation-delay:.08s; animation-duration:1.04s; } + .pb:nth-child(6){ animation-delay:.17s; animation-duration:1.15s; } + .pb:nth-child(7){ animation-delay:.03s; animation-duration:0.96s; } + .pb:nth-child(8){ animation-delay:.20s; animation-duration:1.10s; } + .pb:nth-child(9){ animation-delay:.11s; animation-duration:1.02s; } + .nav-empty-msg{ padding:12px;font-size:12px;color:var(--t-3,rgba(255,255,255,.3));font-style:italic; } + .proj-title-row{ padding:8px 12px 4px;font-size:11px;font-weight:600;color:var(--t-2);letter-spacing:.04em;text-transform:uppercase; } + /* File dropdown */ + .mb-item{ position:relative; display:inline-block; } + .mb-dd{ display:none;position:absolute;top:100%;left:0;z-index:200;min-width:210px; + background:var(--bg-2,#1a1a26);border:1px solid var(--line,rgba(255,255,255,.1)); + border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:6px 0; } + .mb-dd.open{ display:block; } + .dd-sect{ padding:6px 12px 4px;font-size:10px;font-weight:600;text-transform:uppercase; + letter-spacing:.06em;color:var(--t-3,rgba(255,255,255,.3)); } + .dd-row{ display:flex;align-items:center;gap:8px;padding:4px 12px; } + .dd-lbl{ font-size:11px;color:var(--t-2,rgba(255,255,255,.55));white-space:nowrap; } + .dd-row select{ flex:1;background:var(--bg-3,rgba(255,255,255,.06));border:1px solid var(--line); + color:var(--t-0,#fff);border-radius:4px;padding:3px 6px;font-size:11px; } + .dd-sep{ height:1px;background:var(--line,rgba(255,255,255,.1));margin:4px 0; } + .dd-item{ display:block;width:100%;text-align:left;padding:6px 14px;background:none;border:none; + color:var(--t-1,rgba(255,255,255,.8));font-size:12px;cursor:pointer;border-radius:0; } + .dd-item:hover{ background:var(--bg-3,rgba(255,255,255,.06));color:var(--t-0,#fff); } + /* Score bar navigation */ + .score-nav{ display:flex;align-items:center;justify-content:center;gap:12px; + padding:10px 0 4px;border-top:1px solid var(--line,rgba(255,255,255,.08));margin-top:12px; } + .score-nav button{ background:var(--bg-3,rgba(255,255,255,.06));border:1px solid var(--line); + color:var(--t-1);font-size:11px;padding:4px 12px;border-radius:4px;cursor:pointer; } + .score-nav button:hover{ background:var(--acc,oklch(.55 .16 260));color:#fff;border-color:transparent; } + .score-nav button:disabled{ opacity:.3;cursor:default; } + #score-nav-label{ font-size:11px;color:var(--t-2);font-family:var(--mono);min-width:80px;text-align:center; } + + /* ── Godot-style Project Opener ─────────────────────── */ + #proj-opener{ display:none;position:fixed;inset:0;z-index:600; + background:rgba(0,0,0,.72);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px); + align-items:center;justify-content:center; } + [data-scene="empty"] #proj-opener{ display:flex; } + .po-card{ display:flex;width:min(880px,96vw);height:min(530px,90vh); + background:var(--bg-1);border:1px solid var(--line-strong);border-radius:12px; + overflow:hidden;box-shadow:0 40px 100px rgba(0,0,0,.75); } + .po-sidebar{ width:220px;flex-shrink:0;background:var(--bg-0); + border-right:1px solid var(--line);padding:24px 0;display:flex;flex-direction:column;gap:0; } + .po-brand{ padding:0 20px 22px;border-bottom:1px solid var(--line);margin-bottom:10px; } + .po-brand .glyph{ width:28px;height:28px;border-radius:7px;background:var(--acc);margin-bottom:10px; + display:grid;place-items:center; } + .po-brand .glyph svg{ width:16px;height:16px; } + .po-appname{ font-size:15px;font-weight:800;color:var(--t-0);letter-spacing:-.02em; } + .po-tagline{ font-size:10.5px;color:var(--t-3);margin-top:3px;font-family:var(--mono); } + .po-nav{ padding:4px 8px;flex:1; } + .po-navitem{ display:flex;align-items:center;gap:9px;width:100%;padding:8px 12px; + border:none;background:none;color:var(--t-2);font-size:12.5px;border-radius:6px; + cursor:pointer;text-align:left;font-family:var(--ui); } + .po-navitem:hover{ background:var(--bg-2);color:var(--t-0); } + .po-navitem.active{ background:var(--acc-ghost);color:var(--acc-bright); } + .po-navitem svg{ flex-shrink:0;stroke:currentColor;fill:none;stroke-width:1.8; + stroke-linecap:round;stroke-linejoin:round; } + .po-foot{ padding:16px 18px 0;font-size:10px;color:var(--t-3);line-height:1.55; } + .po-content{ flex:1;overflow-y:auto;padding:34px 40px 28px; } + .po-panel{ display:none; } + .po-panel.active{ display:block; } + .po-h{ font-size:19px;font-weight:700;color:var(--t-0);margin-bottom:6px; } + .po-sub{ font-size:12px;color:var(--t-2);margin-bottom:26px;line-height:1.6; } + .po-lbl{ display:block;font-size:10.5px;font-weight:600;color:var(--t-2); + text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px; } + .po-url-wrap{ display:flex;align-items:center;gap:10px; + background:var(--bg-2);border:1px solid var(--line-strong); + border-radius:7px;padding:10px 14px;transition:border-color .15s,box-shadow .15s; } + .po-url-wrap:focus-within{ border-color:var(--acc-line);box-shadow:0 0 0 2.5px var(--acc-glow); } + .po-url-inp{ flex:1;background:none;border:none;outline:none; + color:var(--t-0);font-size:13px;font-family:var(--mono); } + .po-url-inp::placeholder{ color:var(--t-3); } + .po-divider{ display:flex;align-items:center;gap:12px; + margin:20px 0;color:var(--t-3);font-size:10.5px; } + .po-divider::before,.po-divider::after{ content:'';flex:1;height:1px;background:var(--line); } + .po-drop{ border:1.5px dashed var(--line-strong);border-radius:8px;padding:22px; + text-align:center;cursor:pointer;transition:border-color .15s,background .15s; } + .po-drop:hover,.po-drop.drag-over{ border-color:var(--acc-line);background:var(--acc-ghost); } + .po-drop-ico{ opacity:.35;margin-bottom:10px; } + .po-drop-title{ font-size:13px;font-weight:600;color:var(--t-1);margin-bottom:4px; } + .po-drop-sub{ font-size:10.5px;color:var(--t-3);margin-bottom:14px;font-family:var(--mono); } + .po-browse-btn{ background:var(--bg-3);border:1px solid var(--line-strong); + color:var(--t-1);font-size:12px;padding:6px 18px;border-radius:5px;cursor:pointer; + font-family:var(--ui); } + .po-browse-btn:hover{ background:var(--bg-4);color:var(--t-0); } + .po-actions{ margin-top:24px;display:flex;justify-content:flex-end;gap:10px; } + .po-go{ background:var(--acc);color:#fff;border:none; + font-size:13.5px;font-weight:600;padding:10px 28px; + border-radius:7px;cursor:pointer;letter-spacing:.01em;font-family:var(--ui); + transition:background .12s; } + .po-go:hover{ background:var(--acc-bright); } + .po-no-recent{ color:var(--t-3);font-size:13px;padding:28px 0;text-align:center; } + .po-recent-item{ display:flex;align-items:center;gap:14px;padding:12px 14px; + border-radius:7px;cursor:pointer;border:1px solid transparent;position:relative; } + .po-recent-item:hover{ background:var(--bg-2);border-color:var(--line); } + .po-ri-thumb{ width:42px;height:42px;border-radius:5px;flex-shrink:0; + background:var(--bg-3);object-fit:cover; } + .po-ri-title{ font-size:13px;font-weight:600;color:var(--t-0);margin-bottom:2px; } + .po-ri-sub{ font-size:11px;color:var(--t-3);font-family:var(--mono); } + .po-ri-more{ display:none;position:absolute;right:10px;top:50%;transform:translateY(-50%); + background:var(--bg-3);border:1px solid var(--line-strong);border-radius:4px; + color:var(--t-1);font-size:16px;line-height:1;padding:1px 7px 3px;cursor:pointer; } + .po-recent-item:hover .po-ri-more{ display:block; } + .po-ri-more:hover{ background:var(--bg-4);color:var(--t-0); } - +
@@ -31,16 +157,28 @@
AEGISSCORE
- + ⌘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

+ +
+
+ +
+
+
+ + + +
+
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 { + const req = http.get(`http://127.0.0.1:${PORT}/`, res => { res.resume(); resolve(true); }); + req.on('error', () => resolve(false)); + req.setTimeout(500, () => { req.destroy(); resolve(false); }); + }); +} + +async function startBackend() { + if (await portInUse()) { + console.log(`[main] port ${PORT} already in use — reusing existing server`); + return; + } + let spawnCmd, spawnArgs, spawnCwd; + if (app.isPackaged) { + // Use the bundled server_launch.exe produced by PyInstaller + spawnCmd = path.join(process.resourcesPath, 'server_dist', 'server_launch.exe'); + spawnArgs = []; + spawnCwd = path.join(process.resourcesPath, 'server_dist'); + } else { + spawnCmd = process.platform === 'win32' ? 'python' : 'python3'; + spawnArgs = ['-m', 'uvicorn', 'server:app', `--port=${PORT}`, '--log-level=warning']; + spawnCwd = __dirname; + } + server = spawn(spawnCmd, spawnArgs, { + cwd: spawnCwd, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + server.stdout.on('data', d => process.stdout.write('[server] ' + d)); + server.stderr.on('data', d => process.stderr.write('[server] ' + d)); + server.on('error', err => { + dialog.showErrorBox('Backend error', + `Could not start Python server:\n${err.message}\n\n` + + `Make sure Python is on PATH and run:\n pip install -r requirements.txt`); + app.quit(); + }); +} + +function killBackend() { + if (!server) return; + const pid = server.pid; + // Destroy open pipes so Node stops tracking this child (prevents exit hang) + try { server.stdout.destroy(); } catch (_) {} + try { server.stderr.destroy(); } catch (_) {} + server.unref(); + server = null; + if (process.platform === 'win32') { + const tk = spawn('taskkill', ['/pid', pid, '/f', '/t'], { windowsHide: true }); + tk.unref(); + } else { + try { process.kill(pid, 'SIGTERM'); } catch (_) {} + } +} + +// Poll until the server responds (max 60 s) +function waitForServer() { + return new Promise((resolve, reject) => { + let attempts = 0; + const check = () => { + const req = http.get(`http://127.0.0.1:${PORT}/`, res => { + res.resume(); + resolve(); + }); + req.on('error', () => { + if (++attempts >= 60) return reject(new Error('Server did not start within 60 s')); + setTimeout(check, 1000); + }); + req.setTimeout(800, () => req.destroy()); + }; + check(); + }); +} + +// ── Window ──────────────────────────────────────────────────────────────────── +async function createWindow() { + await startBackend(); + + // Show a splash while waiting + const splash = new BrowserWindow({ + width: 380, height: 220, + frame: false, alwaysOnTop: true, resizable: false, + backgroundColor: '#0a0a0c', + webPreferences: { nodeIntegration: false }, + }); + splash.loadFile(path.join(__dirname, 'assets', 'splash.html')); + + try { + await waitForServer(); + } catch (err) { + splash.close(); + dialog.showErrorBox('Failed to start', err.message); + app.quit(); + return; + } + + win = new BrowserWindow({ + width: 1440, + height: 900, + minWidth: 1100, + minHeight: 720, + frame: false, // frameless — top bar acts as drag region + backgroundColor: '#0a0a0c', + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + preload: path.join(__dirname, 'preload.js'), + }, + }); + + win.loadURL(`http://127.0.0.1:${PORT}/`); + + win.once('ready-to-show', () => { + splash.close(); + win.show(); + // inject drag region + window controls CSS/JS after load + win.webContents.insertCSS(` + header.top { -webkit-app-region: drag; } + header.top button, + header.top input, + header.top .meter, + header.top .omnibox { -webkit-app-region: no-drag; } + .win-controls { + display: flex; align-items: center; gap: 6px; + -webkit-app-region: no-drag; padding: 0 8px; + } + .win-btn { + width: 12px; height: 12px; border-radius: 50%; + border: none; cursor: pointer; outline: none; + } + .win-btn.close { background: #ff5f57; } + .win-btn.min { background: #febc2e; } + .win-btn.max { background: #28c840; } + `); + win.webContents.executeJavaScript(` + (function() { + const ctrl = document.createElement('div'); + ctrl.className = 'win-controls'; + ctrl.innerHTML = + '