ankui-align / serve.py
italant7's picture
Upload serve.py with huggingface_hub
d09acdd verified
Raw
History Blame Contribute Delete
15.4 kB
"""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": "<text>", "audio_b64": "<base64>", "ext": "m4a"}
POST /transcribe (JSON body) -> 202 {"jobId": "..."}
body: {"audio_b64": "<base64>", "ext": "m4a"} (no lyrics — STT)
POST /instrumental (JSON body) -> 202 {"jobId": "..."}
body: {"audio_b64": "<base64>", "ext": "m4a"} (no lyrics — karaoke минус)
GET /jobs/{id} -> {"status":"running|done|error", "progress":0..1,
"lines":[...] | "text":"<draft>" |
"audio_b64":"<m4a>", "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/<id>, GET /health)", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()