quranic-universal-aligner / src /ui /mfa_compute.py
hetchyy's picture
deploy
3ec25f6 verified
Raw
History Blame Contribute Delete
17.7 kB
"""MFA compute flows — qua_sdk transport, batch submission, the Animate-All
generator, the synchronous API variant, and the on-demand segment-WAV slicer.
The wire transport is qua_sdk's MfaRemoteSpace (timing.mfa_remote). Batches go
through the backend's transport with caller-built refs instead of
``backend.timestamps()`` because the legacy raw-results contract requires it:
refs may repeat within one batch (two Basmala takes must not share timings),
and the fused-combined split ref ("Isti'adha+Basmala+<span>") is not
expressible through ``build_ref``'s segment grammar.
"""
import os
import gradio as gr
from config import (MFA_SPACE_URL, MFA_MODEL_ID, MFA_TIMEOUT, MFA_PROGRESS_SEGMENT_RATE,
MFA_SPLIT_PADDING)
from src.ui.mfa_lookups import (
_build_crossword_groups, _build_enriched_json, _build_mfa_ref,
_build_timestamp_lookups, _extend_word_timestamps,
)
from src.ui.mfa_render import _ts_progress_bar_html, inject_timestamps_into_html
def _backend():
from qua_sdk.components.timing.runtimes.remote_space import MfaRemoteSpace
return MfaRemoteSpace(space_url=MFA_SPACE_URL)
def _timing_params(padding):
from qua_sdk.components.timing.runtimes.mfa_params import MfaParams
return MfaParams(padding=padding, timeout_s=MFA_TIMEOUT,
model_id=MFA_MODEL_ID or None)
def _mfa_align_batch(refs, audio_paths, padding="forward", on_started=None):
"""Submit one align_batch; block until the raw results list arrives.
InfraError (Space paused / timeout) surfaces as gr.Error with the same
user-facing text the legacy client raised; component failures become
RuntimeError like the legacy SSE error paths.
"""
from qua_sdk.errors import ComponentError, InfraError
backend = _backend()
params = _timing_params(padding)
try:
event_id, headers = backend._upload_and_submit(refs, audio_paths, params)
return backend._wait_result(event_id, headers, params, on_started)
except InfraError as e:
raise gr.Error(e.user_message) from e
except ComponentError as e:
detail = f" ({e.internal_detail})" if e.internal_detail else ""
raise RuntimeError(f"{e.user_message}{detail}") from e
def _mfa_align_batch_streaming(refs, audio_paths, padding="forward"):
"""Generator: yields ("started", None) when the MFA Space exits its queue
and starts processing, then ("result", results_list) when alignment
finishes. Lets callers swap a "Preparing alignment" indicator for a real
progress bar at the moment work actually begins.
The blocking SDK wait runs in a worker thread; its on_started callback is
bridged through a queue so this stays a plain generator.
"""
import queue
import threading
from qua_sdk.errors import ComponentError, InfraError
backend = _backend()
params = _timing_params(padding)
try:
event_id, headers = backend._upload_and_submit(refs, audio_paths, params)
except InfraError as e:
raise gr.Error(e.user_message) from e
q = queue.Queue()
def _wait():
try:
results = backend._wait_result(
event_id, headers, params, lambda: q.put(("started", None)))
q.put(("result", results))
except BaseException as e: # noqa: BLE001 — re-raised on the caller thread
q.put(("error", e))
threading.Thread(target=_wait, name="mfa_wait_result", daemon=True).start()
while True:
kind, data = q.get()
if kind == "error":
if isinstance(data, InfraError):
raise gr.Error(data.user_message) from data
if isinstance(data, ComponentError):
detail = f" ({data.internal_detail})" if data.internal_detail else ""
raise RuntimeError(f"{data.user_message}{detail}") from data
raise data
yield kind, data
if kind == "result":
return
def mfa_split_timestamps(audio_int16, sample_rate, mfa_refs,
padding=MFA_SPLIT_PADDING):
"""Call MFA to get word timestamps for splitting segments.
Args:
audio_int16: List of int16 audio arrays (one per segment to split).
sample_rate: Audio sample rate.
mfa_refs: List of MFA ref strings (one per segment).
padding: Gap-padding strategy ("forward", "symmetric", "none").
Returns:
List of results (one per segment), each a list of
{location, start, end} dicts, or None on failure for that segment.
"""
import tempfile
import wave
if not mfa_refs or not audio_int16:
return [None] * len(mfa_refs)
# Write segment audio to temp WAV files
audio_paths = []
for audio in audio_int16:
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
with wave.open(tmp.name, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(audio.tobytes())
audio_paths.append(tmp.name)
try:
results = _mfa_align_batch(mfa_refs, audio_paths, padding=padding)
print(f"[MFA_SPLIT] Got {len(results)} results from MFA API")
out = []
for result in results:
if result.get("status") != "ok":
print(f"[MFA_SPLIT] Segment failed: ref={result.get('ref')} error={result.get('error')}")
out.append(None)
else:
out.append(result.get("words", []))
return out
except Exception as e:
print(f"[MFA_SPLIT] MFA call failed: {e}")
return [None] * len(mfa_refs)
finally:
import os as _os
for p in audio_paths:
try:
_os.unlink(p)
except OSError:
pass
def _ensure_segment_wavs(segments, segment_dir):
"""Write individual segment WAVs from full.wav on demand (for MFA).
Segments are sliced from the full recording using soundfile's
frame-level random access — no need to load the entire file.
"""
if not segment_dir:
return
full_path = os.path.join(segment_dir, "full.wav")
if not os.path.exists(full_path):
return
import soundfile as sf
info = sf.info(full_path)
sr = info.samplerate
written = 0
for seg in segments:
idx = seg.get("segment", 0) - 1
wav_path = os.path.join(segment_dir, f"seg_{idx}.wav")
if os.path.exists(wav_path):
continue
start_frame = int(seg.get("time_from", 0) * sr)
stop_frame = int(seg.get("time_to", 0) * sr)
audio_slice, _ = sf.read(full_path, start=start_frame, stop=stop_frame, dtype='int16')
sf.write(wav_path, audio_slice, sr, format='WAV', subtype='PCM_16')
written += 1
if written:
print(f"[MFA] Wrote {written} segment WAVs on demand from full.wav")
def _build_mfa_refs(segments, segment_dir, require_existing=True):
"""Build MFA refs and audio paths from segments.
Returns (refs, audio_paths, seg_to_result_idx).
"""
refs = []
audio_paths = []
seg_to_result_idx = {}
for seg in segments:
seg_idx = seg.get("segment", 0) - 1
mfa_ref = _build_mfa_ref(seg)
if mfa_ref is None:
continue
audio_path = os.path.join(segment_dir, f"seg_{seg_idx}.wav") if segment_dir else None
if not audio_path or (require_existing and not os.path.exists(audio_path)):
continue
seg_to_result_idx[seg_idx] = len(refs)
refs.append(mfa_ref)
audio_paths.append(audio_path)
return refs, audio_paths, seg_to_result_idx
def compute_mfa_timestamps_api(segments, segment_dir, granularity="words"):
"""Run MFA forced alignment and return enriched segments (no UI/HTML).
Args:
segments: List of segment dicts (same format as alignment response).
segment_dir: Path to directory containing per-segment WAV files.
granularity: "words" or "words+chars".
Returns:
Dict with "segments" key containing enriched segment data.
"""
if not granularity or granularity not in ("words", "words+chars"):
granularity = "words"
# Write individual segment WAVs on demand (sliced from full.wav)
_ensure_segment_wavs(segments, segment_dir)
refs, audio_paths, seg_to_result_idx = _build_mfa_refs(segments, segment_dir)
if not refs:
return {"segments": segments}
results = _mfa_align_batch(refs, audio_paths)
word_ts, letter_ts, _ = _build_timestamp_lookups(results)
_build_crossword_groups(results, letter_ts)
_extend_word_timestamps(word_ts, segments, seg_to_result_idx, results, segment_dir)
return _build_enriched_json(segments, results, seg_to_result_idx,
word_ts, letter_ts, granularity, minimal=True)
def compute_mfa_timestamps(
current_html,
json_output,
segment_dir,
cached_log_row=None,
):
"""Animate All handler: MFA-align only uncomputed segments, then signal the mega card.
Generator that yields (output_html, animate_all_btn, edit_patch, progress_bar, json_output)
tuples. Skips segments whose SegmentInfo.words is already populated (from prior per-card
or batch MFA). Progress counter reflects the uncomputed subset only.
Preload/CDN mode: segments with pre-baked ``.words`` participate without per-segment
WAVs because ``current_html`` arrives already stamped (see ``_stamp_prebuilt_words``).
"""
import json as _json_mod
import time as _time_mod
import traceback
# Signal-only patch: tells the JS observer "MFA is done, start the mega
# card." The JS handler polls .segments-container for data-start before
# building the mega card, so it doesn't matter whether Gradio's @html flush
# for output_html lands before or after the patch arrives.
def _make_start_patch():
return _json_mod.dumps({
"status": "start_megacard",
"nonce": _time_mod.time(),
})
# json_output is now List[SegmentInfo] from gr.State (not a JSON dict)
segments_state = json_output if isinstance(json_output, list) else []
if not segments_state:
yield current_html, gr.update(), gr.update(), gr.update(), gr.update()
return
# Preload (no segment_dir, no per-segment WAVs): current_html arrives
# already stamped with data-start via _stamp_prebuilt_words at preload-load
# time. Skip the entire MFA path — emit the patch unchanged so the JS
# observer fires without forcing a Gradio output_html replacement (which
# would clobber the mega card the observer is about to append).
if not segment_dir:
yield (
current_html,
gr.update(visible=True, interactive=True, variant="primary"),
gr.update(value=_make_start_patch()),
gr.update(visible=False),
segments_state,
)
return
# Re-render HTML from SegmentInfo to pick up any inline edits and any
# prior MFA timestamps already attached to .words.
from src.ui.segments import render_segments
full_audio_url = f"/gradio_api/file={segment_dir}/full.wav"
current_html = render_segments(
segments_state, full_audio_url=full_audio_url, segment_dir=str(segment_dir)
)
if not current_html or '<span class="word"' not in current_html:
yield current_html, gr.update(), gr.update(), gr.update(), gr.update()
return
# Convert to dicts at the MFA boundary (MFA internals expect dict-based segments)
segment_dicts = [seg.to_json_dict() for seg in segments_state]
all_refs, all_audio_paths, all_seg_to_result_idx = _build_mfa_refs(
segment_dicts, segment_dir, require_existing=False
)
# Partition animatable segments into "already computed" (synthesize a
# result from SegmentInfo.words) and "needs MFA" (batch-align). Both
# paths feed a unified results list so inject_timestamps_into_html can
# repaint every animatable segment in one pass after re-rendering.
refs, audio_paths = [], []
seg_to_result_idx = {}
prebuilt_slots = {}
new_batch_slots = []
for seg_idx, old_ri in all_seg_to_result_idx.items():
combined_idx = len(seg_to_result_idx)
seg_to_result_idx[seg_idx] = combined_idx
if 0 <= seg_idx < len(segments_state) and segments_state[seg_idx].words:
prebuilt_slots[combined_idx] = {
"status": "ok",
"ref": all_refs[old_ri],
"words": segments_state[seg_idx].words,
}
else:
refs.append(all_refs[old_ri])
audio_paths.append(all_audio_paths[old_ri])
new_batch_slots.append(combined_idx)
if not refs:
# Everything is already timestamped — DOM still has the data-start from
# the prior MFA run, so no per-segment text patch is needed. Empty
# patch list just signals "start the mega card."
yield (
gr.update(),
gr.update(visible=True, interactive=True, variant="primary"),
gr.update(value=_make_start_patch()),
gr.update(visible=False),
segments_state,
)
return
# Yield 1: hide the button so the progress bar occupies the ts-row slot.
# Bar shows static "Preparing Alignment..." — held until the MFA Space
# signals it has exited its queue and started actual work.
total_segments = len(refs)
static_bar = _ts_progress_bar_html(total_segments, MFA_PROGRESS_SEGMENT_RATE, animated=False)
yield (
gr.update(),
gr.update(visible=False),
gr.update(),
gr.update(value=static_bar, visible=True),
gr.update(),
)
# Write individual segment WAVs on demand (sliced from full.wav) after the
# UI has flushed the preparing state, so long disk work is not invisible.
_ensure_segment_wavs(segment_dicts, segment_dir)
# Upload, then stream events from the MFA Space (upload happens on the
# generator's first pull — the bar holds "Preparing..." through it). The
# "started" event arrives the moment MFA exits its queue and actually
# begins work — that's when we swap the static bar for the animated
# counter. The "result" event delivers the final batch.
batch_results = None
try:
for kind, data in _mfa_align_batch_streaming(refs, audio_paths):
if kind == "started":
animated_bar = _ts_progress_bar_html(
total_segments, MFA_PROGRESS_SEGMENT_RATE, animated=True)
yield (
gr.update(),
gr.update(),
gr.update(),
gr.update(value=animated_bar),
gr.update(),
)
elif kind == "result":
batch_results = data
except Exception as e:
traceback.print_exc()
yield (
gr.update(),
gr.update(visible=True, interactive=True, variant="primary"),
gr.update(),
gr.update(visible=False),
gr.update(),
)
raise
if batch_results is None:
raise RuntimeError("MFA align_batch did not return a result")
# Splice synthesized "prior" results and fresh MFA results into a single
# list aligned with seg_to_result_idx.
total_slots = len(seg_to_result_idx)
results = [None] * total_slots
for combined_idx, synth in prebuilt_slots.items():
results[combined_idx] = synth
for i, combined_idx in enumerate(new_batch_slots):
results[combined_idx] = batch_results[i] if i < len(batch_results) else {"status": "failed"}
html, enriched_json = inject_timestamps_into_html(
current_html, segment_dicts, results, seg_to_result_idx, segment_dir
)
# V3 note: word/char timestamps are no longer logged to the main dataset.
# The offline `extract_timestamps.py` + Inspector flows are the authoritative
# timestamp producers. A separate `quran-aligner-timestamps` dataset may be
# added in v3.1 if the Space MFA path shows enough traffic to warrant it.
# Copy MFA word/letter data back onto SegmentInfo objects
enriched_segs = enriched_json.get("segments", []) if enriched_json else []
for seg in segments_state:
idx = seg.segment_number - 1
if 0 <= idx < len(enriched_segs) and "words" in enriched_segs[idx]:
seg.words = enriched_segs[idx]["words"]
# Final yield: emit the freshly stamped html so Gradio @html-flushes
# .segments-container with timestamped cards. The mega card lives in the
# right column (sibling of the .output-html wrapper, not inside it), so it
# survives the flush. The JS observer polls for data-start before
# triggering the synth click — the timing of the flush vs the patch
# doesn't matter.
# Yield a NEW list reference for c.output_json. segments_state was mutated
# in place (seg.words populated above), so the previous c.output_json
# already pointed to this same list — without a fresh reference,
# gr.State.change short-circuits on identity and the autosave hook on
# c.output_json.change never fires. Result: pipeline_state.pkl on the
# bucket stays without .words, so reloading + clicking Animate All again
# re-runs MFA instead of finding everything pre-baked.
yield (
html,
gr.update(visible=True, interactive=True, variant="primary"),
gr.update(value=_make_start_patch()),
gr.update(visible=False),
list(segments_state),
)