mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
* Update GitHub repo references from feedback* to feedBack* * rename: slopsmith -> feedBack, byron -> got-feedBack Renames across the entire codebase: - slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack - byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack - /home/byron/ -> /opt/got-feedBack/ - byron@ougsoft.com -> hi@got-feedBack.org - github.com/byrongamatos/ -> github.com/got-feedback/ - com.byron. -> com.got-feedback. - SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback - Protocol/storage strings migrated with read-old/write-new pattern - window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias Refs: #rename-slopsmith * rename: complete regen against current main + fix backward-compat alias Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of current main (3 commits had landed since the branch: #572/#554/#574), resolving the four content conflicts in favour of main's newer content (autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge). Completion fixes on top of the mechanical rename: - Re-apply rename to post-branch content the original rename never saw: window.slopsmith(.Tour) consumers in lessons.js / notifications.js / onboarding-tour.js, and the matching JS + python tests (autoplay_exit, progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env vars now match server.py (which reads FEEDBACK_SYNC_STARTUP / FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real path again. - Restore the window.slopsmith backward-compat alias dropped during conflict resolution, and move the bus aliases to AFTER the _feedBackExisting merge block so they reference the fully-assembled object (also fixes the loop_api.test.js API-surface regex, which the original PR latently broke). - Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR) and gitignore it. - Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped source labels. Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669 passed / 1683 collected with 0 import errors; zero residual slopsmith/byron except the two intentional window.slopsmith aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rename: implement advertised backward-compat + prune dead community plugins Address gaps where PR #537's "Backward compatibility" section was advertised but not implemented, and clean up the community plugin list. Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured): - New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI / SLOPSMITH_PLUGINS_DIR / etc. deployments keep working. - Fix the rename collapsing plugins/__init__.py and minigames/routes.py from `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost). Storage (app.js update-channel): - Read feedBack-update-channel, fall back to legacy slopsmith-update-channel, and clear the legacy key on write — so a user's update-channel preference survives the rename instead of resetting to "stable". Community plugin list (README): the rename rewrote third-party repo URLs we don't own. Probed every one; their owners never renamed, so: - Restore the 13 live community plugins to their real slopsmith-* names. - Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle). - Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory). Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors, rename-sensitive + startup suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: byrongamatos <xasiklas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
513 lines
22 KiB
Python
513 lines
22 KiB
Python
"""WhisperX-based lyric transcription for vocal stems.
|
|
|
|
Acts as a fallback path when a sloppak lacks `lyrics.json`. Operates on an
|
|
already-isolated vocal stem (a Demucs `vocals.ogg`) — does NOT separate
|
|
vocals from a mixed track; that's the caller's responsibility.
|
|
|
|
Output shape matches the on-disk `lyrics.json` shape documented at
|
|
`docs/sloppak-spec.md` §2.3:
|
|
|
|
[{"t": float, "d": float, "w": str}, ...]
|
|
|
|
`t` and `d` are seconds. `w` carries a `-` suffix when it joins to the
|
|
following syllable, and a `+` suffix when it's the last syllable on a
|
|
line (the frontend renderer in `static/highway.js` keys off
|
|
`raw.endsWith('+')` and strips the suffix before drawing — see
|
|
`docs/sloppak-spec.md` §2.3). Both markers are suffixes on real
|
|
syllables, never standalone tokens. WhisperX emits words, not
|
|
syllables; the mapper appends `+` to the previous word on segment-gap
|
|
heuristics and otherwise lets each word stand as its own syllable.
|
|
|
|
Engine selection
|
|
────────────────
|
|
Two transcription paths share a common output:
|
|
|
|
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
|
|
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
|
|
reference server already hosts WhisperX alongside Demucs at the same
|
|
URL).
|
|
|
|
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
|
|
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
|
|
slow on CPU. Deferred imports of `whisperx`, `torch`, and `soundfile`
|
|
keep the rest of feedBack free of those dependencies.
|
|
|
|
Callers pick between them based on a `whisperx.server_url` config and
|
|
fall back as appropriate. This module does not read config — both
|
|
entry points are pure functions of their arguments.
|
|
|
|
Hallucination mitigation
|
|
────────────────────────
|
|
Whisper invents plausible-sounding lyrics on near-silent or purely
|
|
instrumental input. Two gates guard against that:
|
|
|
|
1. `vocals_has_signal(path, threshold)` — cheap RMS check before
|
|
inference. Skips songs where the vocal stem is below threshold
|
|
(Demucs returns near-silent vocals for instrumentals).
|
|
|
|
2. `min_word_score` post-filter — WhisperX's word alignment emits a
|
|
per-word confidence score; words below the threshold are dropped
|
|
from the output. Default 0.35 matches the value the reference
|
|
TabGrabber prototype settled on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import gc
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
log = logging.getLogger("feedBack.lib.lyrics_transcribe")
|
|
|
|
ProgressCB = Optional[Callable[[float, str, str], None]]
|
|
|
|
|
|
# ── Availability probes ──────────────────────────────────────────────────────
|
|
|
|
def whisperx_available() -> bool:
|
|
"""Cheap probe — does this interpreter have whisperx importable?
|
|
|
|
The local transcription path imports whisperx lazily, so this probe lets
|
|
callers gate on availability without paying the full import cost
|
|
(which transitively pulls torch and may try to initialize CUDA).
|
|
|
|
Catches a broader exception set than just ImportError because
|
|
importing whisperx can fail with OSError (libsndfile or other
|
|
native libs missing), RuntimeError (torch CUDA init failure,
|
|
BLAS/LAPACK load problems), or essentially any exception the
|
|
deep transitive stack chooses to raise. The tests assert this
|
|
probe NEVER raises; falling back to False for any failure mode
|
|
keeps that contract while still surfacing the real error if a
|
|
later actual transcription tries to use the helper."""
|
|
try:
|
|
import whisperx # noqa: F401
|
|
return True
|
|
except (ImportError, OSError, RuntimeError) as e:
|
|
log.debug("whisperx_available: import failed (%s)", e)
|
|
return False
|
|
except Exception as e:
|
|
# Last-resort catch so a transient/unexpected failure can't
|
|
# crash the caller. Logged at WARNING so it's visible in normal
|
|
# operation (vs the expected ImportError on installs without
|
|
# whisperx, which stays at DEBUG).
|
|
log.warning("whisperx_available: unexpected probe failure (%s)", e)
|
|
return False
|
|
|
|
|
|
# ── Silence gate ─────────────────────────────────────────────────────────────
|
|
|
|
def vocals_has_signal(vocals_path: Path, threshold: float = 0.005) -> bool:
|
|
"""Return True if the vocal stem has RMS energy above `threshold`.
|
|
|
|
Cheap pre-check intended to short-circuit transcription on
|
|
instrumentals — Demucs separates instrumental tracks into a
|
|
near-silent vocals stem, and running Whisper on silence produces
|
|
hallucinated lyrics. The default threshold is conservative; a
|
|
truly silent stem reads ~1e-6, normal vocals well above 0.01.
|
|
|
|
Returns True when soundfile or numpy is missing OR fails to load
|
|
its native lib (best-effort gate, not a hard requirement). The
|
|
transcription itself will surface the real failure if those deps
|
|
are actually needed downstream.
|
|
|
|
Catching OSError matters because `import soundfile` performs a
|
|
ctypes load of `libsndfile` at import time — on a host without the
|
|
native lib installed, that raises `OSError` (not ImportError) and
|
|
would otherwise propagate up and break the surrounding
|
|
transcription run instead of just skipping the gate."""
|
|
try:
|
|
import numpy as np
|
|
import soundfile as sf
|
|
except (ImportError, OSError) as e:
|
|
log.debug("vocals_has_signal: soundfile/numpy unavailable (%s) — skipping gate", e)
|
|
return True
|
|
# Stream the file in blocks instead of loading the whole stem into
|
|
# memory. A 4-minute stereo vocal stem at 44.1kHz is ~84 MB as
|
|
# float32; multiply that across a batch of conversions and the
|
|
# allocations get noticeable. SoundFile.blocks() yields chunks
|
|
# without ever holding the full buffer, and we only need a
|
|
# running sum-of-squares + frame count to compute RMS at the end.
|
|
# Short-circuit threshold check inside the loop: once we've
|
|
# accumulated enough signal to clear the gate, no need to keep
|
|
# scanning the rest of the file.
|
|
sumsq = 0.0
|
|
nframes = 0
|
|
try:
|
|
with sf.SoundFile(str(vocals_path)) as fh:
|
|
for block in fh.blocks(blocksize=65536, dtype="float32", always_2d=False):
|
|
if block.size == 0:
|
|
continue
|
|
if block.ndim > 1:
|
|
block = block.mean(axis=1)
|
|
sumsq += float(np.sum(np.square(block)))
|
|
nframes += int(block.shape[0])
|
|
# Early exit once we know the gate will pass — no point
|
|
# reading the rest of a 4-minute file to confirm.
|
|
if nframes > 0 and (sumsq / nframes) >= (threshold * threshold):
|
|
log.debug("vocals_has_signal: %s passed early at %d frames",
|
|
vocals_path.name, nframes)
|
|
return True
|
|
except Exception as e:
|
|
log.warning("vocals_has_signal: read of %s failed: %s", vocals_path, e)
|
|
return True
|
|
if nframes == 0:
|
|
return False
|
|
rms = float(np.sqrt(sumsq / nframes))
|
|
log.debug("vocals_has_signal: %s rms=%.6f threshold=%.6f", vocals_path.name, rms, threshold)
|
|
return rms >= threshold
|
|
|
|
|
|
# ── Output mapping ───────────────────────────────────────────────────────────
|
|
|
|
# Gap (in seconds) between WhisperX segments that triggers a `+` line break
|
|
# syllable in the sloppak output. Bumped from 1.5s (TabGrabber's value) to
|
|
# 3.0s after seeing the lower threshold produce short-burst phrasing on
|
|
# sung material — singers breathe at ~0.5-1.5s between phrases of the
|
|
# same verse, so the tighter cutoff fragmented every line into a few
|
|
# words. 3.0s captures stanza-level pauses (verse→chorus, end-of-bridge)
|
|
# while keeping intra-line breaths grouped on one rendered line. The
|
|
# highway renderer still has its own 4.0s safety fallback (see
|
|
# static/highway.js) that forces a wrap regardless, so this only
|
|
# controls when WE author breaks vs delegating to the renderer.
|
|
_LINE_BREAK_GAP_SECONDS = 3.0
|
|
|
|
# Floor on per-word duration in the sloppak output. WhisperX occasionally
|
|
# emits zero-length words for very short syllables; the highway overlay's
|
|
# fade timing expects a non-zero `d`, so clamp here.
|
|
_MIN_WORD_DURATION = 0.05
|
|
|
|
# Semver for the lyric-transcription artifact contract that gets stamped
|
|
# into the sloppak manifest's `lyric_transcription` block alongside the
|
|
# engine + model. Bump per the semantics defined in feedBack#357 (the
|
|
# parent `stem_separation` RFC):
|
|
# * patch — metadata-only or implementation fixes; no regeneration
|
|
# * minor — backward-compatible additions
|
|
# * major — output shape / semantics changed; existing transcriptions
|
|
# should be regenerated
|
|
# Independent from any upstream WhisperX / Whisper / wav2vec2 version.
|
|
LYRIC_TRANSCRIPTION_SCHEMA_VERSION = "1.0.0"
|
|
LYRIC_TRANSCRIPTION_ENGINE = "whisperx"
|
|
|
|
|
|
def _whisperx_to_sloppak(aligned: dict, min_score: float) -> list[dict]:
|
|
"""Map WhisperX `aligned` output to sloppak `lyrics.json` shape.
|
|
|
|
`aligned` is the dict returned by `whisperx.align()`: a `segments`
|
|
list, each segment carrying a `words` list of `{word, start, end,
|
|
score}` dicts. Drops words below `min_score` (hallucination filter)
|
|
and marks line breaks on segment gaps that exceed
|
|
`_LINE_BREAK_GAP_SECONDS`.
|
|
|
|
Line-break encoding follows the frontend lyric renderer's convention
|
|
in `static/highway.js`: `+` is a SUFFIX on the last word of a line,
|
|
not a standalone token. A bare `{"w": "+"}` token would be parsed
|
|
as an empty syllable that ends a line — visible as a blank slot in
|
|
the overlay. Emitting `"world+"` instead keeps the syllable count
|
|
correct and the renderer strips the suffix when drawing.
|
|
|
|
Times are rounded to 3 decimals to match the on-disk `lyrics.json`
|
|
convention (docs/sloppak-spec.md §2.3)."""
|
|
out: list[dict] = []
|
|
# `prev_end` tracks the actual end of the last processed segment
|
|
# (NOT the last surviving word), so the gap heuristic measures
|
|
# against real audio timing. Segments whose only words get filtered
|
|
# out still advance the cursor — otherwise the next segment's gap
|
|
# would falsely measure all the way back to whatever survived
|
|
# several segments ago.
|
|
prev_end: float | None = None
|
|
for segment in aligned.get("segments", []) or []:
|
|
words = segment.get("words") or []
|
|
# Walk every word, regardless of whether it survives the
|
|
# confidence filter, so we can apply the line-break heuristic
|
|
# at the moment we actually emit a syllable. Doing the gap
|
|
# check at emit time (vs. once per segment) means a segment
|
|
# whose entire word list gets filtered can't strand a "pending"
|
|
# break that fires against an unrelated syllable in a later
|
|
# segment.
|
|
for w in words:
|
|
text = (w.get("word") or "").strip()
|
|
if not text:
|
|
continue
|
|
start = w.get("start")
|
|
end = w.get("end")
|
|
score = w.get("score")
|
|
# Drop words that fail confidence threshold. WhisperX
|
|
# occasionally emits words without a score (e.g. when
|
|
# alignment couldn't localize them); treat those as
|
|
# untrustworthy and drop too.
|
|
if not isinstance(score, (int, float)) or score < min_score:
|
|
continue
|
|
if not isinstance(start, (int, float)) or not isinstance(end, (int, float)):
|
|
continue
|
|
# Line break: suffix `+` on the previous emitted syllable
|
|
# if there's a comfortably large silence between the last
|
|
# processed-segment cursor and the current surviving word.
|
|
# Anchoring on `prev_end` (segment end), not `out[-1]`'s
|
|
# actual end, keeps the heuristic aligned with real audio
|
|
# timing — a long mid-segment pause within one phrase
|
|
# shouldn't force a break, and a trailing-words-filtered
|
|
# segment shouldn't falsely inflate the gap to the next one.
|
|
if (
|
|
prev_end is not None
|
|
and (float(start) - prev_end) > _LINE_BREAK_GAP_SECONDS
|
|
and out
|
|
and not out[-1]["w"].endswith("+")
|
|
):
|
|
out[-1]["w"] = out[-1]["w"] + "+"
|
|
duration = max(_MIN_WORD_DURATION, float(end) - float(start))
|
|
out.append({
|
|
"t": round(float(start), 3),
|
|
"d": round(duration, 3),
|
|
"w": text,
|
|
})
|
|
# Advance `prev_end` to the segment's actual end (or the latest
|
|
# numeric word end if the segment lacks an `end`). This runs
|
|
# for every segment — even empty / fully-filtered ones — so
|
|
# the next segment's gap measurement reflects real audio
|
|
# timing regardless of survivorship.
|
|
seg_end = segment.get("end")
|
|
if isinstance(seg_end, (int, float)):
|
|
prev_end = float(seg_end)
|
|
else:
|
|
word_ends = [
|
|
float(w["end"]) for w in words
|
|
if isinstance(w.get("end"), (int, float))
|
|
]
|
|
if word_ends:
|
|
prev_end = max(word_ends)
|
|
return out
|
|
|
|
|
|
# ── Local transcription ─────────────────────────────────────────────────────
|
|
|
|
def _pick_compute_type(device: str) -> str:
|
|
"""Match TabGrabber's compute-type defaults: float16 on CUDA, int8 on CPU.
|
|
|
|
WhisperX accepts float16/float32/int8 on CUDA and int8/float32 on CPU.
|
|
int8 is the only viable choice for CPU inference at usable speeds."""
|
|
return "float16" if device == "cuda" else "int8"
|
|
|
|
|
|
def _resolve_device(device: str | None) -> str:
|
|
if device and device != "auto":
|
|
return device
|
|
try:
|
|
import torch
|
|
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
except ImportError:
|
|
return "cpu"
|
|
|
|
|
|
def _free_gpu_memory() -> None:
|
|
"""Force a GC + CUDA cache flush.
|
|
|
|
Note that `del`-ing a local in a helper function only deletes the
|
|
helper's parameter binding, not the caller's reference — to actually
|
|
drop the model the caller must null its own variables (see the
|
|
finally block in `transcribe_vocals_local`). This helper only handles
|
|
the GC + CUDA side, which is the same regardless of who held the
|
|
references. Safe to call regardless of CUDA availability or whether
|
|
torch is even installed."""
|
|
gc.collect()
|
|
try:
|
|
import torch
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
def transcribe_vocals_local(
|
|
vocals_path: Path,
|
|
*,
|
|
model_size: str = "medium",
|
|
language: str | None = None,
|
|
device: str | None = None,
|
|
compute_type: str | None = None,
|
|
min_word_score: float = 0.35,
|
|
progress_cb: ProgressCB = None,
|
|
) -> list[dict]:
|
|
"""Run WhisperX in-process against a vocal stem.
|
|
|
|
Deferred whisperx import — callers gate on `whisperx_available()`
|
|
first to avoid the ImportError surfacing here. Heavy: first call
|
|
downloads ~1.5 GB of model weights for `medium` (~3 GB for
|
|
`large-v2`) into the WhisperX cache.
|
|
|
|
`model_size` is one of WhisperX's accepted sizes: tiny, base, small,
|
|
medium, large-v2, large-v3. Default `medium` balances accuracy and
|
|
first-run download size; bump to `large-v2` for production quality.
|
|
|
|
`language` is an ISO code (e.g. `"en"`); `None` lets WhisperX
|
|
autodetect from the audio.
|
|
|
|
`device` is `"cuda"` / `"cpu"` / `None` (auto-detect). `compute_type`
|
|
follows TabGrabber's defaults when `None`."""
|
|
try:
|
|
import whisperx
|
|
except ImportError as e:
|
|
raise RuntimeError(
|
|
"whisperx not installed. Install via `pip install whisperx`."
|
|
) from e
|
|
|
|
resolved_device = _resolve_device(device)
|
|
resolved_compute = compute_type or _pick_compute_type(resolved_device)
|
|
|
|
if progress_cb:
|
|
try:
|
|
progress_cb(0.05, "transcribing", f"Loading WhisperX ({model_size}, {resolved_device})")
|
|
except Exception:
|
|
pass
|
|
|
|
# Wrap every model lifecycle call in a single try/finally so a failure in
|
|
# load_audio / transcribe / load_align_model still frees the ASR model —
|
|
# otherwise a bad stem in the middle of a batch run strands GPU memory and
|
|
# the next song's load_model OOMs.
|
|
#
|
|
# Caller-side `= None` reassignment is the only way to actually drop the
|
|
# references here; a helper's `del m` only releases the helper's binding,
|
|
# leaving the caller's reference live and the GPU memory pinned until
|
|
# this function returns. That defeats the purpose of running gc + empty
|
|
# cache mid-batch — by the time the next song's transcribe_vocals_local
|
|
# fires, we want the previous model GONE, not held until the caller
|
|
# frame unwinds.
|
|
asr_model = align_model = align_metadata = None
|
|
try:
|
|
asr_model = whisperx.load_model(model_size, resolved_device, compute_type=resolved_compute)
|
|
audio = whisperx.load_audio(str(vocals_path))
|
|
|
|
if progress_cb:
|
|
try:
|
|
progress_cb(0.30, "transcribing", "Transcribing vocals")
|
|
except Exception:
|
|
pass
|
|
|
|
result = asr_model.transcribe(audio, language=language)
|
|
detected_lang = result.get("language") or language or "en"
|
|
|
|
if progress_cb:
|
|
try:
|
|
progress_cb(0.60, "transcribing", f"Aligning words ({detected_lang})")
|
|
except Exception:
|
|
pass
|
|
|
|
align_model, align_metadata = whisperx.load_align_model(
|
|
language_code=detected_lang, device=resolved_device
|
|
)
|
|
aligned = whisperx.align(
|
|
result["segments"], align_model, align_metadata, audio,
|
|
resolved_device, return_char_alignments=False,
|
|
)
|
|
finally:
|
|
asr_model = None
|
|
align_model = None
|
|
align_metadata = None
|
|
_free_gpu_memory()
|
|
|
|
if progress_cb:
|
|
try:
|
|
progress_cb(0.90, "transcribing", "Building lyric tokens")
|
|
except Exception:
|
|
pass
|
|
|
|
return _whisperx_to_sloppak(aligned, min_word_score)
|
|
|
|
|
|
# ── Remote transcription ────────────────────────────────────────────────────
|
|
|
|
def transcribe_vocals_remote(
|
|
vocals_path: Path,
|
|
server_url: str,
|
|
*,
|
|
language: str | None = None,
|
|
api_key: str | None = None,
|
|
timeout: int = 300,
|
|
min_word_score: float = 0.35,
|
|
progress_cb: ProgressCB = None,
|
|
) -> list[dict]:
|
|
"""POST the vocal stem to `{server_url}/align` and parse the response.
|
|
|
|
Expects the server to respond with a JSON object carrying a `words` (or
|
|
`segments`) field in WhisperX's native shape; `_whisperx_to_sloppak`
|
|
consumes that directly.
|
|
|
|
`min_word_score` is applied to native `segments` responses the same
|
|
way the local path applies it, so the hallucination guard doesn't
|
|
weaken when routing to a remote server. Pre-flattened `{"words": [...]}`
|
|
responses are passed through unfiltered (the server is assumed to
|
|
have applied its own gating before flattening).
|
|
|
|
Errors raise `RuntimeError` with a truncated server response, same
|
|
idiom Demucs uses, so the caller can log+continue without bringing
|
|
down the surrounding split job."""
|
|
import requests
|
|
|
|
server_url = server_url.rstrip("/")
|
|
if progress_cb:
|
|
try:
|
|
progress_cb(0.10, "transcribing", f"Uploading to WhisperX server ({server_url})")
|
|
except Exception:
|
|
pass
|
|
|
|
headers: dict[str, str] = {}
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
params: dict[str, str] = {}
|
|
if language:
|
|
params["language"] = language
|
|
|
|
with open(vocals_path, "rb") as f:
|
|
resp = requests.post(
|
|
f"{server_url}/align",
|
|
files={"file": (vocals_path.name, f, "audio/ogg")},
|
|
params=params,
|
|
headers=headers or None,
|
|
timeout=timeout,
|
|
)
|
|
|
|
if resp.status_code != 200:
|
|
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {resp.text[:300]}")
|
|
|
|
data = resp.json()
|
|
|
|
# Two response shapes are accepted, in this order of preference:
|
|
#
|
|
# 1. Native WhisperX `{"segments": [...]}` — let the standard
|
|
# mapper handle it (line breaks + score filter + clamps).
|
|
# 2. Pre-flattened sloppak shape `{"words": [{"t","d","w"}, ...]}`
|
|
# — pass through with rounding for parity with local path.
|
|
#
|
|
# Anything else is an error: surface enough of the response that
|
|
# `_maybe_transcribe_lyrics` can log it and move on.
|
|
if "segments" in data:
|
|
return _whisperx_to_sloppak(data, min_score=min_word_score)
|
|
if "words" in data:
|
|
raw_words = data["words"]
|
|
if not isinstance(raw_words, list):
|
|
raise RuntimeError(
|
|
f"WhisperX server returned non-list `words`: {type(raw_words).__name__}"
|
|
)
|
|
out: list[dict] = []
|
|
for w in raw_words:
|
|
# Defensive: a malformed server could ship strings, numbers,
|
|
# or partial dicts. Skip anything that isn't a dict with all
|
|
# three required keys so the loop doesn't crash on bad data —
|
|
# the worst case is a partial transcription, not a wedged job.
|
|
if not isinstance(w, dict):
|
|
continue
|
|
if "t" not in w or "d" not in w or "w" not in w:
|
|
continue
|
|
try:
|
|
out.append({
|
|
"t": round(float(w["t"]), 3),
|
|
"d": round(float(w["d"]), 3),
|
|
"w": str(w["w"]),
|
|
})
|
|
except (TypeError, ValueError):
|
|
# Bad numeric types on this entry; skip and continue.
|
|
continue
|
|
return out
|
|
raise RuntimeError(f"WhisperX server returned unrecognized shape: {str(data)[:300]}")
|