"""Forced-alignment microservice for the Ankui app (job-based). Wraps align.py's MMS Kazakh CTC aligner (with HDEMUCS vocal separation) behind an HTTP API. Designed to run anywhere — a local Mac, a VPS, or a free Hugging Face Space — so the iOS client can align a user's own audio + lyrics directly, with no DB / auth / prior upload. Because a full-song alignment can take a couple of minutes on CPU (longer than many reverse proxies allow for a single request), the API is *asynchronous*: GET / -> 200 {"status":"ok","ready":bool} (health) GET /health -> same POST /align (JSON body) -> 202 {"jobId": "..."} body: {"lyrics": "", "audio_b64": "", "ext": "m4a"} POST /transcribe (JSON body) -> 202 {"jobId": "..."} body: {"audio_b64": "", "ext": "m4a"} (no lyrics — STT) POST /instrumental (JSON body) -> 202 {"jobId": "..."} body: {"audio_b64": "", "ext": "m4a"} (no lyrics — karaoke минус) GET /jobs/{id} -> {"status":"running|done|error", "progress":0..1, "lines":[...] | "text":"" | "audio_b64":"", "error":?} `/transcribe` extracts a *draft* lyric transcript from the audio (Kazakh kk-turbo Whisper over the isolated vocal stem — the inverse of /align). It's meant to pre-fill the editor; the user corrects it, then /align times it. It also returns `lines` with the draft's own rough word timings. An /align job additionally reports `meanConfidence` (0…1), and every line and word carries a `confidence`, so the client can point the user at the timings worth checking instead of implying they are all equally certain. `/instrumental` returns the song's backing track with the vocals removed (HDEMUCS, keeping drums+bass+other — the complement of the stem /align isolates) as base64 m4a, for the app's real karaoke ("минус") mode. When ANKUI_ALIGN_KEY is set, POST /align and GET /jobs require a matching `X-Ankui-Key` header (basic abuse guard for a public endpoint). Audio is processed in a temp file and deleted immediately (PRD §6.5). Env: ANKUI_ALIGN_HOST (0.0.0.0), ANKUI_ALIGN_PORT or PORT (8765), ANKUI_ALIGN_DEVICE (cpu), ANKUI_ALIGN_KEY (shared secret, optional). """ from __future__ import annotations import base64 import json import os import re import sys import tempfile import threading import time import traceback import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import align # noqa: E402 HOST = os.environ.get("ANKUI_ALIGN_HOST", "0.0.0.0") PORT = int(os.environ.get("PORT") or os.environ.get("ANKUI_ALIGN_PORT") or "8765") DEVICE = os.environ.get("ANKUI_ALIGN_DEVICE", "cpu") SECRET = os.environ.get("ANKUI_ALIGN_KEY", "") _READY = False # jobId -> {"status","progress","lines","duration","alignMode","error","ts"} _JOBS: dict = {} _JOBS_LOCK = threading.Lock() _JOB_TTL = 3600 # forget finished jobs after an hour # Serialize the heavy model work (HDEMUCS separation, MMS alignment, Whisper # transcription) across ALL job types. The Space is CPU-only with ~2 vCPUs; # running two full-song passes at once doesn't parallelize — it thrashes the # cores (each already ~3-4x realtime) and stacks hundreds of MB of tensors, # risking an OOM kill. One worker at a time bounds both. Jobs queue here; # their status stays "running" (progress 0.05) until they acquire it. _WORK = threading.Semaphore(1) def _split_lines(raw: str) -> list[str]: return [ln.strip() for ln in raw.replace("\r", "").split("\n") if ln.strip()] def _set(job_id: str, **kw): with _JOBS_LOCK: _JOBS.setdefault(job_id, {}).update(kw) def _get(job_id: str): with _JOBS_LOCK: return dict(_JOBS.get(job_id, {})) or None def _gc(): now = time.time() with _JOBS_LOCK: for k in [k for k, v in _JOBS.items() if v.get("status") in ("done", "error") and now - v.get("ts", now) > _JOB_TTL]: _JOBS.pop(k, None) def _run_job(job_id: str, lines: list[str], audio: bytes, ext: str, map_structure: bool = False): path = None plan = None try: fd, path = tempfile.mkstemp(suffix="." + ext) try: os.write(fd, audio) finally: os.close(fd) with _WORK: # serialize heavy model work across all job types _set(job_id, status="running", progress=0.1, ts=time.time()) if map_structure: # Opt-in: a lyric sheet writes the chorus once, the recording # sings it three times, and forced alignment cannot invent the # repeat. Transcribing first tells us the *performance* order — # the transcript's words are poor but its structure is right. # Costs a full extra pass, which is why the caller has to ask. try: import structure draft = align.transcribe_audio(path, device=DEVICE) expanded, plan = structure.expand_reference( lines, draft.get("lines") or []) if len(expanded) >= len(lines): lines = expanded # additive only, never shorter except Exception as exc: # noqa: BLE001 — fall back to the sheet traceback.print_exc() sys.stderr.write(f"[serve] structure mapping skipped: {exc}\n") plan = None _set(job_id, status="running", progress=0.5, ts=time.time()) result = align.run_alignment(path, lines, device=DEVICE) out_lines = result.get("lines", []) if not out_lines: _set(job_id, status="error", error="alignment produced no lines", ts=time.time()) return # `meanConfidence` (and the per-line `confidence` inside each line) let # the client tell the user which timings to check rather than presenting # every alignment as equally certain. _set(job_id, status="done", progress=1.0, lines=out_lines, duration=result.get("duration", 0.0), meanConfidence=result.get("meanConfidence", 0.0), structurePlan=plan, alignMode=result.get("alignMode", "ctc-mms-fl102-kaz"), ts=time.time()) except Exception as exc: # noqa: BLE001 traceback.print_exc() _set(job_id, status="error", error=f"{type(exc).__name__}: {exc}", ts=time.time()) finally: if path: try: os.unlink(path) except OSError: pass def _run_transcribe_job(job_id: str, audio: bytes, ext: str): """STT draft: audio → text (no reference lyrics). Mirrors _run_job.""" path = None try: fd, path = tempfile.mkstemp(suffix="." + ext) try: os.write(fd, audio) finally: os.close(fd) with _WORK: # serialize heavy model work across all job types _set(job_id, status="running", progress=0.1, ts=time.time()) result = align.transcribe_audio(path, device=DEVICE) text = (result.get("text") or "").strip() if not text: _set(job_id, status="error", error="no speech detected in audio", ts=time.time()) return # `lines` carries the draft's own rough per-line/per-word timings from # Whisper. `text` is unchanged, so an older client keeps working. _set(job_id, status="done", progress=1.0, text=text, lines=result.get("lines") or [], duration=result.get("duration", 0.0), model=result.get("model", "kk-turbo-ksc2"), ts=time.time()) except Exception as exc: # noqa: BLE001 traceback.print_exc() _set(job_id, status="error", error=f"{type(exc).__name__}: {exc}", ts=time.time()) finally: if path: try: os.unlink(path) except OSError: pass def _run_instrumental_job(job_id: str, audio: bytes, ext: str): """Karaoke минус: audio → instrumental stem (vocals removed). Mirrors _run_job; the result carries the separated audio as base64 m4a, which the client decodes and caches as a local file for karaoke playback.""" path = None try: fd, path = tempfile.mkstemp(suffix="." + ext) try: os.write(fd, audio) finally: os.close(fd) with _WORK: # serialize heavy model work across all job types _set(job_id, status="running", progress=0.1, ts=time.time()) # Real per-chunk progress over the multi-minute separation so # the client shows an honest bar instead of a wall-clock guess. def _prog(frac): _set(job_id, status="running", progress=0.1 + 0.85 * float(frac), ts=time.time()) result = align.render_instrumental(path, device=DEVICE, progress=_prog) data = result.get("audio") or b"" if not data: _set(job_id, status="error", error="instrumental separation produced no audio", ts=time.time()) return _set(job_id, status="done", progress=1.0, audio_b64=base64.b64encode(data).decode("ascii"), ext=result.get("ext", "m4a"), duration=result.get("duration", 0.0), ts=time.time()) except Exception as exc: # noqa: BLE001 traceback.print_exc() _set(job_id, status="error", error=f"{type(exc).__name__}: {exc}", ts=time.time()) finally: if path: try: os.unlink(path) except OSError: pass class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, fmt, *args): sys.stderr.write("[serve] " + (fmt % args) + "\n") def _send(self, code: int, obj: dict): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _authorized(self) -> bool: if not SECRET: return True return self.headers.get("X-Ankui-Key", "") == SECRET def do_GET(self): if self.path in ("/", "/health"): self._send(200, {"status": "ok", "ready": _READY}) return if self.path.startswith("/jobs/"): if not self._authorized(): self._send(401, {"error": "unauthorized"}) return job = _get(self.path[len("/jobs/"):]) if not job: self._send(404, {"error": "unknown job"}) return self._send(200, job) return if self.path == "/stats": if not self._authorized(): self._send(401, {"error": "unauthorized"}) return with _JOBS_LOCK: recent = sorted(_JOBS.values(), key=lambda v: v.get("ts", 0), reverse=True)[:10] summary = [{"status": j.get("status"), "progress": j.get("progress"), "mode": j.get("alignMode"), "lines": len(j.get("lines", [])), "error": j.get("error")} for j in recent] total = len(_JOBS) self._send(200, {"ready": _READY, "totalJobs": total, "recent": summary}) return self._send(404, {"error": "not found"}) def do_POST(self): # Always drain the request body FIRST. Returning early (401/503) # without consuming Content-Length leaves the body in the socket; # on a kept-alive connection (HF's proxy reuses upstream conns) # those bytes corrupt the next request's start line. length = int(self.headers.get("Content-Length", "0") or 0) raw = self.rfile.read(length) if length > 0 else b"" if self.path not in ("/align", "/transcribe", "/instrumental"): self._send(404, {"error": "not found"}) return if not self._authorized(): self._send(401, {"error": "unauthorized"}) return if not _READY: self._send(503, {"error": "model still loading, retry shortly"}) return try: if not raw: self._send(400, {"error": "empty body"}) return payload = json.loads(raw) audio_b64 = payload.get("audio_b64", "") # Whitelist, not lstrip(".") — this string becomes a temp-file # suffix, and "../x", a NUL, or a 300-char value makes mkstemp raise # inside a daemon thread, stranding the job as "running" forever # (the client then polls it for 20 minutes before giving up). ext = re.sub(r"[^A-Za-z0-9]", "", (payload.get("ext") or "")).lower()[:8] or "m4a" if not audio_b64: self._send(400, {"error": "audio missing"}) return audio = base64.b64decode(audio_b64) _gc() job_id = uuid.uuid4().hex _set(job_id, status="running", progress=0.05, ts=time.time()) if self.path == "/transcribe": # STT draft — no lyrics needed. threading.Thread(target=_run_transcribe_job, args=(job_id, audio, ext), daemon=True).start() elif self.path == "/instrumental": # Karaoke минус — separate the backing track (no lyrics). threading.Thread(target=_run_instrumental_job, args=(job_id, audio, ext), daemon=True).start() else: lines = _split_lines(payload.get("lyrics", "")) if not lines: self._send(400, {"error": "lyrics are empty"}) return threading.Thread( target=_run_job, args=(job_id, lines, audio, ext, bool(payload.get("mapStructure"))), daemon=True).start() self._send(202, {"jobId": job_id}) except Exception as exc: # noqa: BLE001 traceback.print_exc() self._send(500, {"error": f"{type(exc).__name__}: {exc}"}) def _warmup(): """Load models in the background so the HTTP port comes up immediately (HF Spaces' health check needs the port live within its startup grace period; models are baked into the image so this is a fast disk load).""" global _READY t0 = time.time() align.load_model(DEVICE) try: align.load_separator(DEVICE) except Exception as exc: # noqa: BLE001 sys.stderr.write(f"[serve] separator preload failed (will run raw): {exc}\n") _READY = True print(f"[serve] models ready in {time.time()-t0:.1f}s", flush=True) def main(): threading.Thread(target=_warmup, daemon=True).start() server = ThreadingHTTPServer((HOST, PORT), Handler) print(f"[serve] listening on http://{HOST}:{PORT} (POST /align, GET /jobs/, GET /health)", flush=True) server.serve_forever() if __name__ == "__main__": main()