hetchyy's picture
deploy
d9edf34 verified
Raw
History Blame Contribute Delete
10.5 kB
"""Adapters between qua_sdk schemas and the app's legacy shapes.
The SDK speaks Audio/Regions/Emissions/Alignment/Timings; the app's UI,
renderer, API responses, saved sessions, and telemetry all speak SegmentInfo,
ProfilingData, and plain gr.State tuples. Everything that crosses that seam
goes through here so the rest of the app stays untouched.
Joins are by ``AlignedSegment.id`` (== input region index, stamped into
``SegmentInfo._original_alignment_idx + 1``), never by list position β€” merges
keep consumed rows in the Alignment and those are never emitted as cards.
Tahmeed merges are silently absorbed; waqf-sakt merges are surfaced as
"Auto-merged" groups via ``src/core/auto_merge.py``.
"""
from __future__ import annotations
import numpy as np
from qua_sdk.schemas import Alignment, Emissions, Region, Regions, Timings
from src.core.auto_merge import stamp_auto_merge_group, waqf_sakt_consumed_by_target
from src.core.segment_types import ProfilingData, SegmentInfo, compute_reading_sequence
SAMPLE_RATE = 16_000
# ---------------------------------------------------------------------------
# Alignment β†’ SegmentInfo
# ---------------------------------------------------------------------------
def alignment_to_segment_infos(
alignment: Alignment,
emissions: Emissions,
regions: Regions,
) -> list[SegmentInfo]:
"""Map an SDK Alignment onto the legacy SegmentInfo list.
Mirrors the legacy result builder: consumed (merged) rows are never
emitted as cards β€” the SDK already extended the target's region end. A
waqf-sakt merge target additionally gets an "Auto-merged" group stamped
(see auto_merge.py) so the user can see and undo the pipeline's merge.
The non-verse-final penalty and the low-confidence error/blanking are
applied by the SDK matcher; they are NOT re-applied here.
"""
tokens = emissions.tokens
auto_merged = waqf_sakt_consumed_by_target(alignment)
segments: list[SegmentInfo] = []
for seg in alignment.segments:
if seg.merged_into is not None:
continue
matched_ref = seg.matched_ref or ""
phoneme_text = " ".join(tokens[seg.id]) if seg.id < len(tokens) else ""
wrap_ranges = seg.wrap_word_ranges
rep_ranges, rep_text = derive_repetition(matched_ref, wrap_ranges)
info = SegmentInfo(
start_time=seg.region.start_s,
end_time=seg.region.end_s,
transcribed_text=phoneme_text,
matched_text=seg.matched_text,
matched_ref=matched_ref,
match_score=seg.confidence,
error=seg.error,
# has_missing_words is derived later by recompute_missing_words
# (the single coverage-based authority), not from the matcher.
has_missing_words=False,
has_repeated_words=bool(wrap_ranges),
wrap_word_ranges=wrap_ranges,
repeated_ranges=rep_ranges,
repeated_text=rep_text,
# DebugCollector/log rows key per-segment entries by 1-indexed
# absolute region position (== AlignedSegment.id + 1).
_original_alignment_idx=seg.id + 1,
)
consumed = auto_merged.get(seg.id)
if consumed is not None:
stamp_auto_merge_group(info, seg, consumed, regions)
segments.append(info)
return segments
def derive_repetition(matched_ref: str, wrap_ranges) -> tuple[list | None, list | None]:
"""Reading-sequence ranges + display texts for a repetition segment.
Uses the app's quran index (digital-khatt display script) so the rendered
repeated_text matches what every other card shows.
"""
if not (wrap_ranges and matched_ref and "-" in matched_ref):
return None, None
from src.core.quran_index import get_quran_index
ref_from, ref_to = matched_ref.split("-", 1)
rep_ranges = compute_reading_sequence(ref_from, ref_to, wrap_ranges)
qi = get_quran_index()
rep_text = []
for sec_from, sec_to in rep_ranges:
indices = qi.ref_to_indices(f"{sec_from}-{sec_to}")
if indices:
s_i, e_i = indices
rep_text.append(" ".join(w.display_text for w in qi.words[s_i:e_i + 1]))
else:
rep_text.append("")
return rep_ranges, rep_text
# ---------------------------------------------------------------------------
# Timings β†’ SegmentInfo.words
# ---------------------------------------------------------------------------
def timings_to_words(timings: Timings, segment_infos: list[SegmentInfo]) -> None:
"""Attach SDK word timings onto ``SegmentInfo.words`` dicts in place.
Joined by segment id via ``_original_alignment_idx`` (id + 1). Segments
whose backend run failed (``words=None``) are left untouched.
"""
by_id = {}
for seg in segment_infos:
if seg._original_alignment_idx is not None:
by_id[seg._original_alignment_idx - 1] = seg
for st in timings.segments:
seg = by_id.get(st.segment_id)
if seg is None or st.words is None:
continue
words = []
for w in st.words:
entry = {"location": w.location, "start": w.start_s, "end": w.end_s}
if w.letters:
entry["letters"] = [
{"char": ch, "start": s, "end": e} for ch, s, e in w.letters
]
if w.line_idx is not None:
entry["line_idx"] = w.line_idx
words.append(entry)
seg.words = words
# ---------------------------------------------------------------------------
# Regions ↔ gr.State wire shapes
# ---------------------------------------------------------------------------
# The cached_speech_intervals State slot (and pipeline_state.pkl in saved
# sessions) holds the detector's raw intervals as an int-sample numpy array β€”
# the exact shape the legacy VAD wrapper produced. Keep that wire shape and
# convert to/from Regions at the SDK boundary.
def regions_to_state(regions: Regions) -> tuple[np.ndarray | None, bool | None]:
"""Regions β†’ (raw sample-int ndarray, is_complete) for the gr.State slots."""
if regions.raw is None:
return None, regions.is_complete
raw = np.array(
[[round(r.start_s * SAMPLE_RATE), round(r.end_s * SAMPLE_RATE)] for r in regions.raw],
dtype=np.int64,
).reshape(-1, 2)
return raw, regions.is_complete
def state_to_regions(raw_state, is_complete, audio_duration_s: float | None = None) -> Regions:
"""(raw sample intervals, is_complete) State values β†’ Regions for clean().
Accepts the legacy shapes: numpy array, torch tensor, or list of pairs,
all in sample units. ``regions`` is left empty β€” clean() re-derives it.
"""
if hasattr(raw_state, "detach"): # torch tensor from an old session
raw_state = raw_state.detach().cpu().numpy()
if isinstance(raw_state, np.ndarray):
raw_state = raw_state.tolist()
raw = [
Region(start_s=float(s) / SAMPLE_RATE, end_s=float(e) / SAMPLE_RATE)
for s, e in raw_state
]
if hasattr(is_complete, "item"): # numpy scalar/array
is_complete = bool(np.asarray(is_complete).all())
return Regions(
regions=[],
is_complete=bool(is_complete) if is_complete is not None else None,
raw=raw,
audio_duration_s=audio_duration_s,
)
def intervals_from_regions(regions: Regions) -> list[tuple[float, float]]:
"""Cleaned Regions β†’ the legacy list of (start_s, end_s) tuples."""
return [(r.start_s, r.end_s) for r in regions.regions]
# ---------------------------------------------------------------------------
# Stage metrics β†’ ProfilingData
# ---------------------------------------------------------------------------
def metrics_to_profiling(stages: dict, profiling: ProfilingData) -> None:
"""Populate ProfilingData from per-stage SDK metrics, in place.
``stages`` maps stage name β†’ StageMeta (or a plain metrics dict). VAD/ASR
GPU/wall times and VRAM are lease-level concerns stamped by the caller β€”
only the per-component breakdowns land here.
"""
seg = _metrics(stages.get("segmentation"))
if seg:
profiling.vad_model_load_time = seg.get("model_load_s", 0.0)
profiling.vad_model_move_time = seg.get("model_move_s", 0.0)
profiling.vad_inference_time = seg.get("inference_s", 0.0)
rec = _metrics(stages.get("recognition"))
if rec:
profiling.asr_sorting_time = rec.get("sorting_s", 0.0)
profiling.asr_batch_build_time = rec.get("batch_build_s", 0.0)
profiling.asr_model_move_time = rec.get("model_move_s", 0.0)
profiling.asr_batch_profiling = rec.get("batches") or []
match = _metrics(stages.get("matching"))
if match:
from config import PHONEME_ALIGNMENT_PROFILING
if PHONEME_ALIGNMENT_PROFILING:
profiling.phoneme_num_segments = match.get("num_segments", 0)
profiling.retry_attempts = match.get("retry_attempts", 0)
profiling.retry_passed = match.get("retry_passed", 0)
profiling.retry_segments = match.get("retry_segments", [])
profiling.consec_reanchors = match.get("consec_reanchors", 0)
profiling.segments_attempted = match.get("segments_attempted", 0)
profiling.segments_passed = match.get("segments_passed", 0)
profiling.special_merges = match.get("special_merges", 0)
profiling.transition_skips = match.get("transition_skips", 0)
profiling.phoneme_wraps_detected = match.get("phoneme_wraps_detected", 0)
wall = _wall_s(stages.get("matching"))
if wall is not None:
profiling.phoneme_total_time = wall
def matching_events_to_collector(stages: dict, dc) -> None:
"""Bridge the matcher's event stream onto the app DebugCollector.
SDK events are ``{"event": name, **fields}``; the collector (and the v3
log row's events block) uses ``{"type": name, **fields}``.
"""
if dc is None:
return
match = _metrics(stages.get("matching"))
for ev in (match or {}).get("events") or []:
fields = {k: v for k, v in ev.items() if k != "event"}
dc.add_event(ev.get("event"), **fields)
def _metrics(stage) -> dict | None:
if stage is None:
return None
return stage.metrics if hasattr(stage, "metrics") else dict(stage)
def _wall_s(stage) -> float | None:
if stage is None:
return None
return getattr(stage, "wall_s", None)