Output:
Chunk ID: efe612
Wall time: 0.0512 seconds
Process exited with code 0
Original token count: 3416
Output:
     1	"""
     2	Simple Transcription Validator
     3	==============================
     4
     5	Three checks:
     6	1. Character validation - catch garbage/alien characters
     7	2. Native CTC alignment - verify native script matches audio
     8	3. Romanized MMS alignment - verify deterministic romanized text matches audio
     9
    10	Combined scoring: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
    11	  N = native CTC score, R = romanized MMS score
    12	  Accept if S >= 0.78 and min(N,R) >= 0.55
    13	  Forced review if abs(N-R) > 0.20 (high validator disagreement)
    14
    15	Usage:
    16	    from src.validators.simple_validator import validate_transcription
    17
    18	    result = validate_transcription(
    19	        "audio.flac", "నాకు కొన్ని యాడ్స్",
    20	        romanized_text="naaku konni ads", language="te"
    21	    )
    22	    print(result.status)          # accept / review / retry / reject
    23	    print(result.combined_score)  # 0-1
    24	"""
    25	from dataclasses import dataclass
    26	from typing import List, Dict, Optional, Set
    27
    28	from src.romanization import (
    29	    derive_romanized_text,
    30	    normalize_roman_for_mms,
    31	    strip_placeholders,
    32	)
    33
    34
    35	# Unicode ranges for Indic scripts (all 12 supported languages)
    36	SCRIPT_RANGES = {
    37	    "te": {  # Telugu
    38	        "name": "Telugu",
    39	        "ranges": [(0x0C00, 0x0C7F)],
    40	        "allow_ascii_punct": True,
    41	        "allow_digits": True,
    42	    },
    43	    "hi": {  # Hindi (Devanagari)
    44	        "name": "Hindi",
    45	        "ranges": [(0x0900, 0x097F)],
    46	        "allow_ascii_punct": True,
    47	        "allow_digits": True,
    48	    },
    49	    "mr": {  # Marathi (Devanagari, same block as Hindi)
    50	        "name": "Marathi",
    51	        "ranges": [(0x0900, 0x097F)],
    52	        "allow_ascii_punct": True,
    53	        "allow_digits": True,
    54	    },
    55	    "ta": {  # Tamil
    56	        "name": "Tamil",
    57	        "ranges": [(0x0B80, 0x0BFF)],
    58	        "allow_ascii_punct": True,
    59	        "allow_digits": True,
    60	    },
    61	    "kn": {  # Kannada
    62	        "name": "Kannada",
    63	        "ranges": [(0x0C80, 0x0CFF)],
    64	        "allow_ascii_punct": True,
    65	        "allow_digits": True,
    66	    },
    67	    "ml": {  # Malayalam
    68	        "name": "Malayalam",
    69	        "ranges": [(0x0D00, 0x0D7F)],
    70	        "allow_ascii_punct": True,
    71	        "allow_digits": True,
    72	    },
    73	    "bn": {  # Bengali
    74	        "name": "Bengali",
    75	        "ranges": [(0x0980, 0x09FF)],
    76	        "allow_ascii_punct": True,
    77	        "allow_digits": True,
    78	    },
    79	    "as": {  # Assamese (same Unicode block as Bengali, slight char differences)
    80	        "name": "Assamese",
    81	        "ranges": [(0x0980, 0x09FF)],
    82	        "allow_ascii_punct": True,
    83	        "allow_digits": True,
    84	    },
    85	    "gu": {  # Gujarati
    86	        "name": "Gujarati",
    87	        "ranges": [(0x0A80, 0x0AFF)],
    88	        "allow_ascii_punct": True,
    89	        "allow_digits": True,
    90	    },
    91	    "pa": {  # Punjabi (Gurmukhi)
    92	        "name": "Punjabi",
    93	        "ranges": [(0x0A00, 0x0A7F)],
    94	        "allow_ascii_punct": True,
    95	        "allow_digits": True,
    96	    },
    97	    "or": {  # Odia
    98	        "name": "Odia",
    99	        "ranges": [(0x0B00, 0x0B7F)],
   100	        "allow_ascii_punct": True,
   101	        "allow_digits": True,
   102	    },
   103	    "en": {  # English (Latin)
   104	        "name": "English",
   105	        "ranges": [(0x0041, 0x005A), (0x0061, 0x007A)],  # A-Z, a-z
   106	        "allow_ascii_punct": True,
   107	        "allow_digits": True,
   108	    },
   109	}
   110
   111	# Common allowed characters across all languages
   112	ALLOWED_COMMON = set(" \t\n.,!?;:'\"()-–—[]0123456789")
   113
   114	# English characters (for code-mixed text)
   115	ENGLISH_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
   116
   117
   118	# Validation thresholds (strict defaults)
   119	ACCEPT_THRESHOLD = 0.78
   120	REVIEW_THRESHOLD = 0.68
   121	RETRY_THRESHOLD = 0.55
   122	MIN_ACCEPT_VALIDATOR_SCORE = 0.55
   123	MAX_ACCEPT_DISAGREEMENT = 0.20
   124
   125	# Word-level quality gates from native CTC alignments
   126	MIN_INTERNAL_WORD_CONFIDENCE = 0.55
   127	MAX_INTERNAL_LOW_CONF_RATIO = 0.20
   128	BOUNDARY_LOW_CONFIDENCE = 0.35
   129
   130
   131	def get_valid_chars(language: str, allow_english: bool = True) -> Set[int]:
   132	    """Get set of valid Unicode codepoints for a language."""
   133	    valid = set()
   134
   135	    # Add common chars
   136	    for c in ALLOWED_COMMON:
   137	        valid.add(ord(c))
   138
   139	    # Add script-specific chars
   140	    lang_config = SCRIPT_RANGES.get(language, SCRIPT_RANGES["te"])
   141	    for start, end in lang_config["ranges"]:
   142	        for cp in range(start, end + 1):
   143	            valid.add(cp)
   144
   145	    # Allow English for code-mixed
   146	    if allow_english:
   147	        for c in ENGLISH_CHARS:
   148	            valid.add(ord(c))
   149
   150	    return valid
   151
   152
   153	def check_characters(
   154	    text: str,
   155	    language: str = "te",
   156	    allow_english: bool = False  # Changed: native = no English
   157	) -> Dict:
   158	    """
   159	    Check if text contains only valid characters for the language.
   160
   161	    For native transcription: allow_english=False (default)
   162
   163	    Returns:
   164	        {
   165	            "valid": True/False,
   166	            "invalid_chars": [...],
   167	            "script_ratio": 0.8,  # ratio of native script chars
   168	        }
   169	    """
   170	    text = strip_placeholders(text or "")
   171	    if not text or not text.strip():
   172	        return {"valid": False, "invalid_chars": [], "script_ratio": 0, "reason": "empty"}
   173
   174	    valid_chars = get_valid_chars(language, allow_english)
   175	    lang_config = SCRIPT_RANGES.get(language, SCRIPT_RANGES["te"])
   176
   177	    invalid_chars = []
   178	    script_count = 0
   179	    total_alpha = 0
   180
   181	    for i, char in enumerate(text):
   182	        cp = ord(char)
   183	        if char.isalpha():
   184	            total_alpha += 1
   185
   186	        # Check if in native script
   187	        is_native = any(start <= cp <= end for start, end in lang_config["ranges"])
   188	        if is_native:
   189	            script_count += 1
   190
   191	        # Check if invalid (includes English if not allowed)
   192	        elif cp not in valid_chars:
   193	            invalid_chars.append({
   194	                "char": char,
   195	                "codepoint": cp,
   196	                "position": i
   197	            })
   198
   199	    return {
   200	        "valid": len(invalid_chars) == 0,
   201	        "invalid_chars": invalid_chars[:10],  # First 10 only
   202	        "invalid_count": len(invalid_chars),
   203	        "script_ratio": script_count / total_alpha if total_alpha > 0 else 0,
   204	    }
   205
   206
   207	@dataclass
   208	class ValidationResult:
   209	    """Result of transcription validation with dual scoring."""
   210	    status: str  # "accept", "review", "retry", "reject"
   211
   212	    # Character check
   213	    char_valid: bool = True
   214	    invalid_chars: List[Dict] = None
   215	    script_ratio: float = 0.0
   216
   217	    # Native CTC alignment (language-specific wav2vec2)
   218	    alignment_score: float = 0.0       # kept for backward compat
   219	    native_ctc_score: float = 0.0
   220	    low_confidence_words: List[str] = None
   221	    low_confidence_ratio: float = 0.0
   222
   223	    # Romanized MMS alignment (language-agnostic torchaudio MMS_FA)
   224	    roman_mms_score: float = 0.0
   225	    romanization_source: str = ""
   226
   227	    # Combined weighted score: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
   228	    combined_score: float = 0.0
   229	    audio_duration_sec: float = 0.0
   230
   231	    # Word-level quality gates
   232	    boundary_start_confidence: float = 0.0
   233	    boundary_end_confidence: float = 0.0
   234	    min_internal_word_confidence: float = 0.0
   235	    internal_low_conf_ratio: float = 0.0
   236
   237	    # Reasons
   238	    reasons: List[str] = None
   239
   240	    def to_dict(self) -> Dict:
   241	        return {
   242	            "status": self.status,
   243	            "char_valid": self.char_valid,
   244	            "invalid_chars": self.invalid_chars or [],
   245	            "script_ratio": round(self.script_ratio, 3),
   246	            "native_ctc_score": round(self.native_ctc_score, 4),
   247	            "roman_mms_score": round(self.roman_mms_score, 4),
   248	            "romanization_source": self.romanization_source,
   249	            "combined_score": round(self.combined_score, 4),
   250	            "alignment_score": round(self.alignment_score, 4),
   251	            "low_confidence_words": self.low_confidence_words or [],
   252	            "low_confidence_ratio": round(self.low_confidence_ratio, 3),
   253	            "audio_duration_sec": round(self.audio_duration_sec, 3),
   254	            "boundary_start_confidence": round(self.boundary_start_confidence, 4),
   255	            "boundary_end_confidence": round(self.boundary_end_confidence, 4),
   256	            "min_internal_word_confidence": round(self.min_internal_word_confidence, 4),
   257	            "internal_low_conf_ratio": round(self.internal_low_conf_ratio, 4),
   258	            "reasons": self.reasons or []
   259	        }
   260
   261
   262	# Global aligners (lazy loaded)
   263	_ctc_aligner = None
   264	_mms_aligner = None
   265
   266	def _get_ctc_aligner(language: str):
   267	    """Lazy load native CTC aligner."""
   268	    global _ctc_aligner
   269	    if _ctc_aligner is None:
   270	        from .ctc_forced_aligner import CTCForcedAligner
   271	        _ctc_aligner = CTCForcedAligner(language=language)
   272	    return _ctc_aligner
   273
   274	def _get_mms_aligner():
   275	    """Lazy load romanized MMS aligner."""
   276	    global _mms_aligner
   277	    if _mms_aligner is None:
   278	        from .mms_aligner import MMSAligner
   279	        _mms_aligner = MMSAligner()
   280	    return _mms_aligner
   281
   282
   283	def compute_combined_score(
   284	    native_ctc: float,
   285	    roman_mms: float,
   286	) -> tuple:
   287	    """
   288	    Compute weighted validation score with disagreement penalty.
   289
   290	    Formula: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
   291	    MMS weighted higher (0.55): more stable on code-mixed Indic audio.
   292	    Disagreement penalty: flags when validators diverge (suspicious).
   293
   294	    Returns:
   295	        (combined_score, verdict)
   296	        verdict: "accept" / "review" / "retry" / "reject"
   297	    """
   298	    N, R = native_ctc, roman_mms
   299	    S = 0.45 * N + 0.55 * R - 0.10 * abs(N - R)
   300
   301	    if S >= ACCEPT_THRESHOLD and min(N, R) >= MIN_ACCEPT_VALIDATOR_SCORE:
   302	        if abs(N - R) > MAX_ACCEPT_DISAGREEMENT:
   303	            # High disagreement — flag even though score passes
   304	            verdict = "review"
   305	        else:
   306	            verdict = "accept"
   307	    elif S >= REVIEW_THRESHOLD:
   308	        verdict = "review"
   309	    elif S >= RETRY_THRESHOLD:
   310	        verdict = "retry"
   311	    else:
   312	        verdict = "reject"
   313
   314	    return round(S, 4), verdict
   315
   316
   317	def _strip_punctuation(text: str) -> str:
   318	    """Strip punctuation for CTC alignment. Keeps native script + spaces."""
   319	    import re
   320	    # Remove common punctuation marks (keep native script chars, digits, spaces)
   321	    return re.sub(r'[।,.!?;:\'"()\-–—]', '', text).strip()
   322
   323
   324	def _compute_word_quality_metrics(word_alignments: List) -> Dict[str, float]:
   325	    """
   326	    Compute boundary/internal confidence metrics from word-level CTC alignment.
   327	    """
   328	    confidences = [
   329	        float(getattr(wa, "confidence", 0.0))
   330	        for wa in (word_alignments or [])
   331	        if getattr(wa, "word", "").strip()
   332	    ]
   333	    if not confidences:
   334	        return {
   335	            "boundary_start": 0.0,
   336	            "boundary_end": 0.0,
   337	            "min_internal": 0.0,
   338	            "internal_low_ratio": 1.0,
   339	            "has_metrics": False,
   340	        }
   341
   342	    if len(confidences) >= 3:
   343	        internal = confidences[1:-1]
   344	        boundary_start = confidences[0]
   345	        boundary_end = confidences[-1]
   346	    else:
   347	        internal = confidences
   348	        boundary_start = confidences[0]
   349	        boundary_end = confidences[-1]
   350
   351	    if internal:
   352	        min_internal = min(internal)
   353	        low_internal = sum(1 for c in internal if c < 0.60)
   354	        internal_low_ratio = low_internal / len(internal)
   355	    else:
   356	        min_internal = 0.0
   357	        internal_low_ratio = 1.0
   358
   359	    return {
   360	        "boundary_start": boundary_start,


Output:
Chunk ID: fe4ee8
Wall time: 0.0514 seconds
Process exited with code 0
Original token count: 2857
Output:
   360	        "boundary_start": boundary_start,
   361	        "boundary_end": boundary_end,
   362	        "min_internal": min_internal,
   363	        "internal_low_ratio": internal_low_ratio,
   364	        "has_metrics": True,
   365	    }
   366
   367
   368	def validate_transcription(
   369	    audio_path: str,
   370	    transcription: str,
   371	    language: str = "te",
   372	    romanized_text: str = "",
   373	    check_audio: bool = True,
   374	    min_duration_sec: float = 2.0,
   375	) -> ValidationResult:
   376	    """
   377	    Validate a transcription using dual scoring (native CTC + romanized MMS).
   378
   379	    Runs:
   380	    1. Character validation (instant) on native script
   381	    2. Native CTC forced alignment (language-specific wav2vec2)
   382	    3. Romanized MMS forced alignment (language-agnostic, deterministic romanized text)
   383	    4. Weighted combined score: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
   384
   385	    Args:
   386	        audio_path: Path to audio file
   387	        transcription: Native script text (may include punctuation)
   388	        language: Language code (te, hi, ta, etc.)
   389	        romanized_text: Optional model romanization fallback
   390	        check_audio: Whether to run audio alignment checks (slower)
   391	        min_duration_sec: Segments shorter than this cannot auto-accept
   392
   393	    Returns:
   394	        ValidationResult with dual scores and combined verdict
   395	    """
   396	    reasons = []
   397
   398	    # Normalize text surfaces before validation.
   399	    native_no_placeholders = strip_placeholders(transcription or "")
   400	    clean_text = _strip_punctuation(native_no_placeholders)
   401	    romanized = derive_romanized_text(
   402	        native_text=clean_text,
   403	        language=language,
   404	        fallback_romanized=romanized_text,
   405	    )
   406	    clean_roman = normalize_roman_for_mms(romanized.text)
   407
   408	    # === STEP 1: Character validation (instant) ===
   409	    # For English language, allow English chars in native check
   410	    allow_eng = language == "en"
   411	    char_check = check_characters(clean_text, language, allow_english=allow_eng)
   412
   413	    if not char_check["valid"]:
   414	        return ValidationResult(
   415	            status="reject",
   416	            char_valid=False,
   417	            invalid_chars=char_check["invalid_chars"],
   418	            script_ratio=char_check["script_ratio"],
   419	            reasons=["Invalid/alien characters found"]
   420	        )
   421
   422	    if char_check["script_ratio"] < 0.5:
   423	        reasons.append(f"Too few native chars ({char_check['script_ratio']:.0%})")
   424
   425	    # === STEP 2: Native CTC alignment ===
   426	    native_ctc_score = 0.0
   427	    low_conf_words = []
   428	    low_conf_ratio = 0.0
   429	    audio_duration_sec = 0.0
   430	    boundary_start_conf = 0.0
   431	    boundary_end_conf = 0.0
   432	    min_internal_word_conf = 0.0
   433	    internal_low_conf_ratio = 0.0
   434
   435	    if check_audio and clean_text:
   436	        try:
   437	            aligner = _get_ctc_aligner(language)
   438	            ctc_result = aligner.align(audio_path, clean_text)
   439	            native_ctc_score = ctc_result.alignment_score
   440	            low_conf_words = ctc_result.low_confidence_words
   441	            low_conf_ratio = ctc_result.low_confidence_ratio
   442	            audio_duration_sec = ctc_result.audio_duration_sec
   443
   444	            metrics = _compute_word_quality_metrics(ctc_result.word_alignments)
   445	            if metrics["has_metrics"]:
   446	                boundary_start_conf = metrics["boundary_start"]
   447	                boundary_end_conf = metrics["boundary_end"]
   448	                min_internal_word_conf = metrics["min_internal"]
   449	                internal_low_conf_ratio = metrics["internal_low_ratio"]
   450	        except Exception as e:
   451	            reasons.append(f"CTC alignment failed: {str(e)[:50]}")
   452	    elif check_audio:
   453	        reasons.append("Empty native text after normalization")
   454
   455	    # === STEP 3: Romanized MMS alignment ===
   456	    roman_mms_score = 0.0
   457
   458	    if check_audio and clean_roman:
   459	        try:
   460	            mms = _get_mms_aligner()
   461	            mms_result = mms.align(audio_path, clean_roman)
   462	            roman_mms_score = mms_result.alignment_score
   463	        except Exception as e:
   464	            reasons.append(f"MMS alignment failed: {str(e)[:50]}")
   465
   466	    # === STEP 4: Combined scoring ===
   467	    if check_audio and native_ctc_score > 0 and roman_mms_score > 0:
   468	        # Both validators returned scores - use weighted formula
   469	        combined_score, verdict = compute_combined_score(
   470	            native_ctc_score, roman_mms_score
   471	        )
   472	        if abs(native_ctc_score - roman_mms_score) > MAX_ACCEPT_DISAGREEMENT:
   473	            reasons.append(
   474	                f"Validator disagreement: CTC={native_ctc_score:.2f} "
   475	                f"MMS={roman_mms_score:.2f}"
   476	            )
   477	    elif check_audio and native_ctc_score > 0:
   478	        # CTC only (MMS failed or no romanized text)
   479	        combined_score = native_ctc_score
   480	        if native_ctc_score >= ACCEPT_THRESHOLD:
   481	            verdict = "accept"
   482	        elif native_ctc_score >= REVIEW_THRESHOLD:
   483	            verdict = "review"
   484	        elif native_ctc_score >= RETRY_THRESHOLD:
   485	            verdict = "retry"
   486	        else:
   487	            verdict = "reject"
   488	        if roman_mms_score == 0 and clean_roman:
   489	            reasons.append("MMS unavailable, using CTC only")
   490	    elif check_audio and roman_mms_score > 0:
   491	        # MMS only (CTC failed)
   492	        combined_score = roman_mms_score
   493	        if roman_mms_score >= ACCEPT_THRESHOLD:
   494	            verdict = "accept"
   495	        elif roman_mms_score >= REVIEW_THRESHOLD:
   496	            verdict = "review"
   497	        elif roman_mms_score >= RETRY_THRESHOLD:
   498	            verdict = "retry"
   499	        else:
   500	            verdict = "reject"
   501	        reasons.append("CTC failed, using MMS only")
   502	    elif check_audio:
   503	        # Both validators returned 0
   504	        combined_score = 0.0
   505	        verdict = "review"
   506	        reasons.append("Both validators returned 0")
   507	    else:
   508	        # No audio check requested
   509	        combined_score = 0.0
   510	        verdict = "review" if reasons else "accept"
   511
   512	    # Character validation failures override
   513	    if not char_check["valid"]:
   514	        verdict = "reject"
   515
   516	    # Duration guard: very short clips are unstable for reliable auto-accept.
   517	    if check_audio and 0 < audio_duration_sec < min_duration_sec:
   518	        reasons.append(
   519	            f"Short clip ({audio_duration_sec:.2f}s < {min_duration_sec:.2f}s)"
   520	        )
   521	        if verdict == "accept":
   522	            verdict = "review"
   523
   524	    # Boundary-aware gates: protect against VAD mis-cuts.
   525	    boundary_low = (
   526	        boundary_start_conf > 0 and boundary_start_conf < BOUNDARY_LOW_CONFIDENCE
   527	    ) or (
   528	        boundary_end_conf > 0 and boundary_end_conf < BOUNDARY_LOW_CONFIDENCE
   529	    )
   530	    internal_bad = (
   531	        min_internal_word_conf > 0 and (
   532	            min_internal_word_conf < MIN_INTERNAL_WORD_CONFIDENCE
   533	            or internal_low_conf_ratio > MAX_INTERNAL_LOW_CONF_RATIO
   534	        )
   535	    )
   536
   537	    if verdict == "accept" and internal_bad:
   538	        verdict = "review"
   539	        reasons.append(
   540	            "Internal word confidence too low "
   541	            f"(min={min_internal_word_conf:.2f}, "
   542	            f"low_ratio={internal_low_conf_ratio:.0%})"
   543	        )
   544	    elif verdict in ("accept", "retry") and boundary_low and not internal_bad:
   545	        verdict = "review"
   546	        reasons.append(
   547	            "Low boundary confidence with strong internal words "
   548	            "(likely boundary cut artifact)"
   549	        )
   550
   551	    # Append score-based reasons
   552	    if verdict == "retry":
   553	        reasons.append(
   554	            f"Below threshold: combined={combined_score:.2f} "
   555	            f"(CTC={native_ctc_score:.2f}, MMS={roman_mms_score:.2f})"
   556	        )
   557	    elif verdict == "reject" and combined_score > 0:
   558	        reasons.append(
   559	            f"Poor alignment: combined={combined_score:.2f} "
   560	            f"(CTC={native_ctc_score:.2f}, MMS={roman_mms_score:.2f})"
   561	        )
   562
   563	    return ValidationResult(
   564	        status=verdict,
   565	        char_valid=char_check["valid"],
   566	        invalid_chars=char_check.get("invalid_chars"),
   567	        script_ratio=char_check["script_ratio"],
   568	        alignment_score=native_ctc_score,  # backward compat
   569	        native_ctc_score=native_ctc_score,
   570	        roman_mms_score=roman_mms_score,
   571	        romanization_source=romanized.source,
   572	        combined_score=combined_score,
   573	        low_confidence_words=low_conf_words,
   574	        low_confidence_ratio=low_conf_ratio,
   575	        audio_duration_sec=audio_duration_sec,
   576	        boundary_start_confidence=boundary_start_conf,
   577	        boundary_end_confidence=boundary_end_conf,
   578	        min_internal_word_confidence=min_internal_word_conf,
   579	        internal_low_conf_ratio=internal_low_conf_ratio,
   580	        reasons=reasons if reasons else None,
   581	    )
   582
   583
   584	def cleanup():
   585	    """Release all aligner resources (CTC + MMS)."""
   586	    global _ctc_aligner, _mms_aligner
   587	    if _ctc_aligner is not None:
   588	        _ctc_aligner.cleanup()
   589	        _ctc_aligner = None
   590	    if _mms_aligner is not None:
   591	        _mms_aligner.cleanup()
   592	        _mms_aligner = None
   593
   594
   595	# === Quick validation (no audio check) ===
   596	def quick_validate(transcription: str, language: str = "te") -> Dict:
   597	    """
   598	    Quick character-only validation for NATIVE transcription.
   599
   600	    Checks:
   601	    - Not empty
   602	    - All characters in target script (no English/garbage)
   603	    - Sufficient native script content
   604
   605	    Args:
   606	        transcription: Native script text to validate
   607	        language: Language code (te, hi, ta, etc.)
   608
   609	    Returns:
   610	        {"valid": True/False, "reason": "..."}
   611	    """
   612	    if not transcription or not transcription.strip():
   613	        return {"valid": False, "reason": "Empty transcription"}
   614
   615	    char_check = check_characters(transcription, language, allow_english=False)
   616
   617	    if not char_check["valid"]:
   618	        invalid = [c['char'] for c in char_check['invalid_chars'][:5]]
   619	        return {
   620	            "valid": False,


Output:
Chunk ID: ab53bd
Wall time: 0.0504 seconds
Process exited with code 0
Original token count: 2912
Output:
     1	"""
     2	Audio processing module for handling segment duration and chunking.
     3
     4	Splitting strategy: VAD-aware energy-based cutting.
     5	When segments >max_duration_sec, finds the lowest-energy point in a window
     6	around the target split time (8-13s) to avoid cutting mid-speech.
     7	Sequential chunks track their original segment + cut points for later merging.
     8	"""
     9	import os
    10	import numpy as np
    11	from pathlib import Path
    12	from typing import List, Dict, Optional
    13	from dataclasses import dataclass
    14	from pydub import AudioSegment
    15
    16
    17	@dataclass
    18	class AudioChunk:
    19	    """Represents an audio chunk ready for transcription."""
    20	    file_path: str
    21	    original_segment: str  # Original segment filename
    22	    chunk_index: int       # Index within the segment (0 if not split)
    23	    total_chunks: int      # Total chunks for this segment
    24	    start_ms: int          # Start time in milliseconds within original
    25	    end_ms: int            # End time in milliseconds within original
    26	    duration_sec: float    # Duration in seconds
    27
    28	    @property
    29	    def is_split(self) -> bool:
    30	        return self.total_chunks > 1
    31
    32
    33	class AudioProcessor:
    34	    """Handles audio segment processing with duration controls."""
    35
    36	    def __init__(
    37	        self,
    38	        max_duration_sec: float = 10.0,
    39	        min_duration_sec: float = 2.0,
    40	        overlap_sec: float = 0.0,
    41	        boundary_padding_ms: float = 80.0,
    42	        output_format: str = "flac"
    43	    ):
    44	        """
    45	        Initialize the audio processor.
    46
    47	        Args:
    48	            max_duration_sec: Maximum allowed segment duration (hard limit)
    49	            min_duration_sec: Minimum segment duration to process
    50	            overlap_sec: Overlap between chunks when splitting (helps with word boundaries)
    51	            boundary_padding_ms: Context padding around split boundaries
    52	            output_format: Output format for split chunks (flac, wav, mp3)
    53	        """
    54	        self.max_duration_sec = max_duration_sec
    55	        self.min_duration_sec = min_duration_sec
    56	        self.overlap_sec = overlap_sec
    57	        self.boundary_padding_ms = boundary_padding_ms
    58	        self.output_format = output_format
    59
    60	    def get_audio_duration(self, file_path: str) -> float:
    61	        """Get duration of an audio file in seconds."""
    62	        audio = AudioSegment.from_file(file_path)
    63	        return len(audio) / 1000.0
    64
    65	    def needs_splitting(self, file_path: str) -> bool:
    66	        """Check if a file needs to be split based on duration."""
    67	        duration = self.get_audio_duration(file_path)
    68	        return duration > self.max_duration_sec
    69
    70	    def _find_energy_valley(self, samples: np.ndarray, sr: int,
    71	                            target_ms: int, search_window_ms: int = 3000
    72	                            ) -> int:
    73	        """
    74	        Find the lowest-energy point near target_ms for VAD-aware splitting.
    75	        Searches +/- search_window_ms around target for a silence/low-energy valley.
    76	        Returns the best cut point in milliseconds.
    77	        """
    78	        # RMS energy in 20ms windows
    79	        frame_ms = 20
    80	        frame_samples = int(sr * frame_ms / 1000)
    81	        total_ms = int(len(samples) / sr * 1000)
    82
    83	        search_start = max(0, target_ms - search_window_ms)
    84	        search_end = min(total_ms, target_ms + search_window_ms)
    85
    86	        best_energy = float('inf')
    87	        best_ms = target_ms  # fallback to target if no valley found
    88
    89	        for t_ms in range(search_start, search_end, frame_ms):
    90	            idx = int(t_ms * sr / 1000)
    91	            frame = samples[idx:idx + frame_samples]
    92	            if len(frame) < frame_samples // 2:
    93	                continue
    94	            rms = np.sqrt(np.mean(frame ** 2)) + 1e-10
    95	            energy_db = 20 * np.log10(rms)
    96	            if energy_db < best_energy:
    97	                best_energy = energy_db
    98	                best_ms = t_ms
    99
   100	        return best_ms
   101
   102	    def split_audio(
   103	        self,
   104	        file_path: str,
   105	        output_dir: str,
   106	        chunk_duration_sec: Optional[float] = None
   107	    ) -> List[AudioChunk]:
   108	        """
   109	        Split audio using VAD-aware energy-based cutting.
   110
   111	        Instead of chopping at fixed intervals, finds the lowest-energy point
   112	        in a window around the target split time to avoid cutting mid-speech.
   113	        Target: 8-13s chunks. Searches +/- 3s around target for silence valleys.
   114
   115	        Sequential chunks track their original segment + cut points so they
   116	        can be merged back later (transcripts appended, original audio retained).
   117
   118	        Args:
   119	            file_path: Path to the audio file
   120	            output_dir: Directory to save chunks
   121	            chunk_duration_sec: Target chunk duration (defaults to max_duration_sec)
   122
   123	        Returns:
   124	            List of AudioChunk objects with sequential chunk indices
   125	        """
   126	        if chunk_duration_sec is None:
   127	            chunk_duration_sec = self.max_duration_sec
   128
   129	        audio = AudioSegment.from_file(file_path)
   130	        total_duration_ms = len(audio)
   131	        total_duration_sec = total_duration_ms / 1000.0
   132
   133	        os.makedirs(output_dir, exist_ok=True)
   134	        original_name = Path(file_path).stem
   135
   136	        # If already short enough, return as single chunk
   137	        if total_duration_sec <= self.max_duration_sec:
   138	            return [AudioChunk(
   139	                file_path=file_path,
   140	                original_segment=Path(file_path).name,
   141	                chunk_index=0,
   142	                total_chunks=1,
   143	                start_ms=0,
   144	                end_ms=total_duration_ms,
   145	                duration_sec=total_duration_sec
   146	            )]
   147
   148	        # Convert to numpy for energy analysis
   149	        samples = np.array(audio.get_array_of_samples(), dtype=np.float32)
   150	        if audio.channels > 1:
   151	            samples = samples.reshape(-1, audio.channels).mean(axis=1)
   152	        samples = samples / (2**15)  # normalize int16 to float
   153	        sr = audio.frame_rate
   154
   155	        # Find VAD-aware split points using energy valleys
   156	        target_ms = int(chunk_duration_sec * 1000)
   157	        split_points = [0]  # start of first chunk
   158	        cursor = 0
   159
   160	        while cursor + target_ms < total_duration_ms:
   161	            # Look for lowest energy near the target split point
   162	            candidate = cursor + target_ms
   163	            cut_ms = self._find_energy_valley(samples, sr, candidate)
   164	            # Don't cut too short (min 5s) or too long (max 15s)
   165	            chunk_len = cut_ms - cursor
   166	            if chunk_len < 5000:
   167	                cut_ms = cursor + 5000
   168	            elif chunk_len > 15000:
   169	                cut_ms = cursor + int(chunk_duration_sec * 1000)
   170	            split_points.append(cut_ms)
   171	            cursor = cut_ms
   172
   173	        split_points.append(total_duration_ms)  # end of last chunk
   174
   175	        # Avoid tiny final chunks by merging tail into previous chunk.
   176	        min_chunk_ms = int(self.min_duration_sec * 1000)
   177	        if (
   178	            len(split_points) > 2
   179	            and (split_points[-1] - split_points[-2]) < min_chunk_ms
   180	        ):
   181	            split_points.pop(-2)
   182
   183	        # Build chunks from split points
   184	        chunks = []
   185	        num_chunks = len(split_points) - 1
   186	        overlap_ms = int(max(0.0, self.overlap_sec) * 1000)
   187	        pad_ms = int(max(0.0, self.boundary_padding_ms))
   188
   189	        for i in range(num_chunks):
   190	            raw_start_ms = split_points[i]
   191	            raw_end_ms = split_points[i + 1]
   192	            start_ms = max(0, raw_start_ms - overlap_ms - pad_ms)
   193	            end_ms = min(total_duration_ms, raw_end_ms + overlap_ms + pad_ms)
   194	            chunk_audio = audio[start_ms:end_ms]
   195	            chunk_duration = len(chunk_audio) / 1000.0
   196
   197	            # Skip tiny trailing chunks
   198	            if chunk_duration < self.min_duration_sec and i > 0:
   199	                continue
   200
   201	            chunk_filename = f"{original_name}_chunk{i:03d}.{self.output_format}"
   202	            chunk_path = os.path.join(output_dir, chunk_filename)
   203	            chunk_audio.export(chunk_path, format=self.output_format)
   204
   205	            chunks.append(AudioChunk(
   206	                file_path=chunk_path,
   207	                original_segment=Path(file_path).name,
   208	                chunk_index=i,
   209	                total_chunks=num_chunks,
   210	                start_ms=start_ms,
   211	                end_ms=end_ms,
   212	                duration_sec=chunk_duration
   213	            ))
   214
   215	        return chunks
   216
   217	    def process_segments_directory(
   218	        self,
   219	        segments_dir: str,
   220	        output_dir: Optional[str] = None,
   221	        max_segments: Optional[int] = None,
   222	        skip_short: bool = True
   223	    ) -> List[AudioChunk]:
   224	        """
   225	        Process all segments in a directory, splitting as needed.
   226
   227	        Args:
   228	            segments_dir: Directory containing audio segments
   229	            output_dir: Directory for split chunks (default: segments_dir/chunks)
   230	            max_segments: Maximum number of segments to process (for testing)
   231	            skip_short: Skip segments shorter than min_duration_sec
   232
   233	        Returns:
   234	            List of AudioChunk objects ready for transcription
   235	        """
   236	        if output_dir is None:
   237	            output_dir = os.path.join(segments_dir, "chunks")
   238
   239	        # Find all audio files
   240	        audio_extensions = {'.flac', '.wav', '.mp3', '.ogg', '.m4a'}
   241	        segment_files = []
   242
   243	        for ext in audio_extensions:
   244	            segment_files.extend(Path(segments_dir).glob(f"*{ext}"))
   245
   246	        # Sort by filename for consistent ordering
   247	        segment_files = sorted(segment_files, key=lambda x: x.name)
   248
   249	        if max_segments:
   250	            segment_files = segment_files[:max_segments]
   251
   252	        print(f"[Audio] Processing {len(segment_files)} segments...")
   253
   254	        all_chunks = []
   255	        split_count = 0
   256	        skipped_count = 0
   257
   258	        for segment_path in segment_files:
   259	            duration = self.get_audio_duration(str(segment_path))
   260


# Collaboration Mode: Default

You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.

Your active mode changes only when new developer instructions with a different `<collaboration_mode>...</collaboration_mode>` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan.

## request_user_input availability

The `request_user_input` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.

If a decision is necessary and cannot be discovered from local context, ask the user directly. However, in Default mode you should strongly prefer executing the user's request rather than stopping to ask questions.

Summary: auto

## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.
### Available skills
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: /home/ubuntu/.codex/skills/.system/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: /home/ubuntu/.codex/skills/.system/skill-installer/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
  2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
  3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
  4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
  - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.


**Planning code reference capture**


nl -ba src/validators/mms_aligner.py | sed -n '1,260p'


Output:
Chunk ID: a29f7f
Wall time: 0.0520 seconds
Process exited with code 0
Original token count: 1047
Output:
    30
    31
    32	@dataclass
    33	class PipelineConfig:
    34	    """
    35	    Complete configuration for the transcription pipeline.
    36	    All parameters are exposed for full control.
    37	    """
    38	    # === Video Settings ===
    39	    video_id: str
    40	    language: Optional[str] = None  # If None, fetched from Supabase
    41	    default_language: str = "Telugu"  # Fallback if Supabase lookup fails
    42
    43	    # === Audio Processing Settings ===
    44	    max_segment_duration_sec: float = 10.0  # Hard limit - segments longer than this are split
    45	    min_segment_duration_sec: float = 2.0   # Skip segments shorter than this
    46	    chunk_overlap_sec: float = 0.0          # Overlap when splitting (for word boundaries)
    47	    split_boundary_padding_ms: float = 80.0 # Add a bit of silence context around split cuts
    48
    49	    # === Segment Selection Settings ===
    50	    max_segments: Optional[int] = None      # Limit total segments processed (for testing)
    51	    segment_start_index: int = 0            # Start from this segment index
    52	    skip_short_segments: bool = True        # Skip segments < min_duration
    53
    54	    # === Transcription Settings ===
    55	    model: str = "gemini-3-flash-preview"   # Gemini model to use
    56	    thinking_level: Optional[str] = "low"   # minimal, low, medium, high (Gemini 3)
    57	    temperature: float = 0.0                # 0 for deterministic (tested safe with strict prompt)
    58
    59	    # === Validation Settings ===
    60	    validate_transcriptions: bool = True    # Validate native transcriptions
    61	    validation_action: str = "retry"        # "flag" (mark status) or "retry" (re-transcribe with pro)
    62	    retry_model: str = "gemini-3-pro-preview"  # Model for retry attempts
    63	    retry_thinking_level: str = "low"        # Thinking level for retry
    64	    min_snr_db: float = 5.0                 # SNR gate: skip segments below this (saves API credits)
    65
    66	    # === Output Settings ===
    67	    output_dir: str = "./transcriptions"
    68	    save_intermediate: bool = True          # Save results after each batch
    69	    batch_size: int = 10                    # How many to process before saving
    70
    71	    # === Audio Polishing Settings ===
    72	    polish_audio: bool = True               # Polish segment boundaries before transcription
    73
    74	    # === Storage Settings ===
    75	    work_dir: str = field(default_factory=lambda: DEFAULT_SETTINGS["work_dir"])
    76	    cleanup_after: bool = False             # Remove downloaded files after processing
    77	    use_regex_fallback: bool = True         # Try regex if exact R2 match fails
    78
    79
    80	@dataclass
    81	class PipelineResult:
    82	    """Results from a pipeline run."""
    83	    video_id: str
    84	    language: str
    85	    model: str
    86	    thinking_level: Optional[str]
    87
    88	    total_segments_found: int
    89	    segments_processed: int
    90	    chunks_created: int
    91	    transcriptions_completed: int
    92
    93	    total_audio_duration_sec: float
    94	    total_processing_time_sec: float
    95
    96	    output_file: str
    97	    errors: List[str]
    98
    99	    started_at: str
   100	    completed_at: str
   101
   102
   103	@dataclass
   104	class _BatchHealth:
   105	    """Tracks validator health per batch. Catches silent degradation."""
   106	    validated: int = 0
   107	    ctc_success: int = 0       # CTC returned > 0
   108	    mms_success: int = 0       # MMS returned > 0
   109	    dual_success: int = 0      # Both returned > 0
   110	    one_sided_accept: int = 0  # Accepted with only one validator
   111	    accept: int = 0
   112	    review: int = 0
   113	    retry: int = 0
   114	    reject: int = 0
   115	    retry_improved: int = 0
   116	    total_combined: float = 0.0
   117
   118	    # Alert thresholds
   119	    CTC_FAIL_THRESHOLD = 0.20    # Warn if CTC fails on >20% of batch
   120	    MMS_FAIL_THRESHOLD = 0.20    # Warn if MMS fails on >20% of batch


Output:
Chunk ID: fe8082
Wall time: 0.0503 seconds
Process exited with code 0
Original token count: 3089
Output:
   232	            raise
   233
   234	        # Get segment stats
   235	        stats = get_segment_stats(segments_dir)
   236	        self._log(f"Found {stats.get('total_files', 0)} audio files, "
   237	                  f"total duration: {stats.get('total_duration_sec', 0):.1f}s")
   238	        if stats.get('over_10s_count', 0) > 0:
   239	            self._log(f"  -> {stats['over_10s_count']} segments over 10s will be split")
   240	        if stats.get('under_2s_count', 0) > 0:
   241	            self._log(
   242	                f"  -> {stats['under_2s_count']} segments under 2s may be skipped",
   243	                "WARN",
   244	            )
   245
   246	        # === Step 2: Get Language ===
   247	        self._log("Step 2: Fetching language info...")
   248	        if self.config.language:
   249	            language = self.config.language
   250	            self._log(f"Using specified language: {language}")
   251	        else:
   252	            language = get_video_language(
   253	                self.config.video_id,
   254	                default=self.config.default_language
   255	            )
   256	            self._log(f"Language from Supabase: {language}")
   257
   258	        # === Step 3: Process Audio Segments ===
   259	        self._log("Step 3: Processing audio segments...")
   260	        processor = AudioProcessor(
   261	            max_duration_sec=self.config.max_segment_duration_sec,
   262	            min_duration_sec=self.config.min_segment_duration_sec,
   263	            overlap_sec=self.config.chunk_overlap_sec,
   264	            boundary_padding_ms=self.config.split_boundary_padding_ms,
   265	        )
   266
   267	        chunks = processor.process_segments_directory(
   268	            segments_dir,
   269	            max_segments=self.config.max_segments,
   270	            skip_short=self.config.skip_short_segments
   271	        )
   272
   273	        # Apply start index
   274	        if self.config.segment_start_index > 0:
   275	            chunks = chunks[self.config.segment_start_index:]
   276	            self._log(f"Starting from index {self.config.segment_start_index}, "
   277	                      f"{len(chunks)} chunks remaining")
   278
   279	        # === Step 3.5: Polish Audio + SNR Gate ===
   280	        if self.config.polish_audio:
   281	            self._log("Step 3.5: Polishing audio boundaries...")
   282	            polisher = AudioPolisher()
   283	            polished_dir = os.path.join(segments_dir, "polished")
   284	            polished_count = 0
   285	            snr_skipped = 0
   286	            surviving_chunks = []
   287	            for chunk in chunks:
   288	                result = polisher.polish(chunk.file_path, output_dir=polished_dir)
   289	                if result.was_modified:
   290	                    chunk.file_path = result.output_path
   291	                    polished_count += 1
   292	                # SNR gate: skip segments with very low SNR (likely noise/music)
   293	                if result.snr_db < self.config.min_snr_db:
   294	                    snr_skipped += 1
   295	                    continue
   296	                # Duration gate: polishing might shrink segments below minimum
   297	                if result.polished_duration_ms / 1000.0 < self.config.min_segment_duration_sec:
   298	                    snr_skipped += 1  # reuse counter, both are pre-tx filters
   299	                    continue
   300	                surviving_chunks.append(chunk)
   301	            self._log(f"Polished {polished_count}/{len(chunks)} chunks "
   302	                      f"({len(chunks) - polished_count} already clean)")
   303	            if snr_skipped > 0:
   304	                self._log(f"SNR gate: skipped {snr_skipped} chunks "
   305	                          f"(SNR < {self.config.min_snr_db}dB)")
   306	            chunks = surviving_chunks
   307
   308	        # === Step 4: Transcribe ===
   309	        self._log(f"Step 4: Transcribing {len(chunks)} chunks...")
   310
   311	        transcriber = GeminiTranscriber()
   312	        transcription_config = TranscriptionConfig(
   313	            model=self.config.model,
   314	            thinking_level=self.config.thinking_level,
   315	            temperature=self.config.temperature,
   316	            language=language
   317	        )
   318
   319	        # Process in batches
   320	        for batch_start in range(0, len(chunks), self.config.batch_size):
   321	            batch_end = min(batch_start + self.config.batch_size, len(chunks))
   322	            batch = chunks[batch_start:batch_end]
   323
   324	            self._log(f"Processing batch {batch_start + 1}-{batch_end} of {len(chunks)}")
   325
   326	            batch_results = transcriber.transcribe_batch(
   327	                batch,
   328	                transcription_config,
   329	                progress_callback=lambda c, t: self._log(
   330	                    f"  Chunk {batch_start + c}/{len(chunks)}: "
   331	                    f"{batch[c-1].original_segment}"
   332	                )
   333	            )
   334
   335	            # === Validate transcriptions (dual scoring: native CTC + romanized MMS) ===
   336	            if self.config.validate_transcriptions:
   337	                lang_code = self._get_language_code(language)
   338	                batch_health = _BatchHealth()
   339	                for i, result in enumerate(batch_results):
   340	                    if result.native:
   341	                        romanized_result = derive_romanized_text(
   342	                            native_text=result.native,
   343	                            language=lang_code,
   344	                            fallback_romanized=result.transcription.romanized or "",
   345	                        )
   346	                        # Make romanized deterministic across runs.
   347	                        result.transcription.romanized = romanized_result.text
   348	                        validation = validate_transcription(
   349	                            batch[i].file_path,
   350	                            result.native,
   351	                            romanized_text=romanized_result.text,
   352	                            language=lang_code,
   353	                            min_duration_sec=self.config.min_segment_duration_sec,
   354	                        )
   355	                        result.validation_status = validation.status
   356	                        result.validation_score = validation.combined_score
   357
   358	                        # Retry with pro model if below accept threshold
   359	                        if (validation.status in ("retry", "reject")
   360	                                and self.config.validation_action == "retry"):
   361	                            self._log(
   362	                                f"  Retrying {result.segment_id} with "
   363	                                f"{self.config.retry_model} "
   364	                                f"(S={validation.combined_score:.2f})", "WARN"
   365	                            )
   366	                            retry_config = TranscriptionConfig(
   367	                                model=self.config.retry_model,
   368	                                thinking_level=self.config.retry_thinking_level,
   369	                                temperature=self.config.temperature,
   370	                                language=language,
   371	                            )
   372	                            retry_raw = transcriber.transcribe_audio(
   373	                                batch[i].file_path, retry_config
   374	                            )
   375	                            if not retry_raw.get("error"):
   376	                                # Re-validate the retry result
   377	                                from src.backend.transcription_schema import (
   378	                                    TranscriptionOutput, SpeakerMeta
   379	                                )
   380	                                speaker_data = retry_raw.get('speaker')
   381	                                speaker = (SpeakerMeta(**speaker_data)
   382	                                           if isinstance(speaker_data, dict)
   383	                                           else None)
   384	                                retry_output = TranscriptionOutput(
   385	                                    transcription=retry_raw.get('transcription', ''),
   386	                                    code_switch=retry_raw.get('code_switch', ''),
   387	                                    romanized=retry_raw.get('romanized', ''),
   388	                                    tagged=retry_raw.get('tagged', ''),
   389	                                    speaker=speaker,
   390	                                    detected_language=retry_raw.get('detected_language', ''),
   391	                                )
   392	                                retry_romanized = derive_romanized_text(
   393	                                    native_text=retry_output.transcription,
   394	                                    language=lang_code,
   395	                                    fallback_romanized=retry_output.romanized or "",
   396	                                )
   397	                                retry_output.romanized = retry_romanized.text
   398	                                retry_val = validate_transcription(
   399	                                    batch[i].file_path,
   400	                                    retry_output.transcription,
   401	                                    romanized_text=retry_romanized.text,
   402	                                    language=lang_code,
   403	                                    min_duration_sec=self.config.min_segment_duration_sec,
   404	                                )
   405	                                # Keep the better result
   406	                                if retry_val.combined_score > validation.combined_score:
   407	                                    result.transcription = retry_output
   408	                                    result.model_used = retry_config.model
   409	                                    result.thinking_level = retry_config.thinking_level
   410	                                    validation = retry_val
   411	                                    self._log(
   412	                                        f"  Retry improved: "
   413	                                        f"S={retry_val.combined_score:.2f} "
   414	                                        f"({retry_val.status})"
   415	                                    )
   416	                                    batch_health.retry_improved += 1
   417	                                else:
   418	                                    self._log(
   419	                                        f"  Retry did not improve: "
   420	                                        f"S={retry_val.combined_score:.2f}"
   421	                                    )
   422	                            # Update final validation state
   423	                            result.validation_status = validation.status
   424	                            result.validation_score = validation.combined_score
   425
   426	                        if validation.status in ("reject", "retry"):
   427	                            self._log(
   428	                                f"  Validation: {result.segment_id} -> "
   429	                                f"{validation.status} "
   430	                                f"(S={validation.combined_score:.2f}, "
   431	                                f"CTC={validation.native_ctc_score:.2f}, "
   432	                                f"MMS={validation.roman_mms_score:.2f})",
   433	                                "WARN"
   434	                            )
   435
   436	                        # Record for batch health monitoring
   437	                        batch_health.record(validation)
   438
   439	                # Batch health report + alerts
   440	                alerts = batch_health.report(self._log)


Output:
Chunk ID: 6f1188
Wall time: 0.0511 seconds
Process exited with code 0
Original token count: 1713
Output:
   520	            json.dump(output_data, f, ensure_ascii=False, indent=2)
   521
   522	        return output_path
   523
   524
   525	def run_pipeline(
   526	    video_id: str,
   527	    language: Optional[str] = None,
   528	    model: str = "gemini-3-flash-preview",
   529	    thinking_level: str = "low",
   530	    temperature: float = 0.0,
   531	    max_segments: Optional[int] = None,
   532	    max_duration_sec: float = 10.0,
   533	    min_duration_sec: float = 2.0,
   534	    split_pad_ms: float = 80.0,
   535	    output_dir: str = "./transcriptions",
   536	    validate: bool = True
   537	) -> PipelineResult:
   538	    """
   539	    Convenience function to run the pipeline with common settings.
   540
   541	    Args:
   542	        video_id: YouTube video ID
   543	        language: Primary language (if None, fetched from Supabase)
   544	        model: Gemini model to use
   545	        thinking_level: Thinking level for Gemini 3 (default: low)
   546	        temperature: Generation temperature (default: 0.0 deterministic)
   547	        max_segments: Limit segments for testing
   548	        max_duration_sec: Max segment duration (hard limit)
   549	        min_duration_sec: Skip segments shorter than this duration
   550	        split_pad_ms: Padding context to add around split boundaries
   551	        output_dir: Directory for output files
   552	        validate: Whether to validate transcriptions
   553
   554	    Returns:
   555	        PipelineResult with summary
   556	    """
   557	    config = PipelineConfig(
   558	        video_id=video_id,
   559	        language=language,
   560	        model=model,
   561	        thinking_level=thinking_level,
   562	        temperature=temperature,
   563	        max_segments=max_segments,
   564	        max_segment_duration_sec=max_duration_sec,
   565	        min_segment_duration_sec=min_duration_sec,
   566	        split_boundary_padding_ms=split_pad_ms,
   567	        output_dir=output_dir,
   568	        validate_transcriptions=validate
   569	    )
   570
   571	    pipeline = TranscriptionPipeline(config)
   572	    return pipeline.run()
   573
   574
   575	def test_models(
   576	    video_id: str,
   577	    language: str,
   578	    models: List[str],
   579	    thinking_levels: List[str] = ["high"],
   580	    max_segments: int = 3
   581	) -> Dict[str, PipelineResult]:
   582	    """
   583	    Test multiple models on the same video for comparison.
   584
   585	    Args:
   586	        video_id: Video ID to test
   587	        language: Primary language
   588	        models: List of model names to test
   589	        thinking_levels: Thinking levels to try
   590	        max_segments: Segments per test
   591
   592	    Returns:
   593	        Dict mapping model_thinking to PipelineResult
   594	    """
   595	    results = {}
   596
   597	    for model in models:
   598	        for thinking in thinking_levels:
   599	            key = f"{model}_{thinking}"
   600	            print(f"\n{'='*60}")
   601	            print(f"Testing: {key}")
   602	            print(f"{'='*60}")
   603
   604	            try:
   605	                result = run_pipeline(
   606	                    video_id=video_id,
   607	                    language=language,
   608	                    model=model,
   609	                    thinking_level=thinking,
   610	                    max_segments=max_segments,
   611	                    output_dir=f"./transcriptions/test_{video_id}"
   612	                )
   613	                results[key] = result
   614	            except Exception as e:
   615	                print(f"ERROR testing {key}: {e}")
   616
   617	    return results
   618
   619
   620	if __name__ == "__main__":
   621	    import argparse
   622
   623	    parser = argparse.ArgumentParser(description="Transcription Pipeline")
   624	    parser.add_argument("video_id", help="YouTube video ID")
   625	    parser.add_argument("--language", "-l", help="Primary language (default: auto-detect)")
   626	    parser.add_argument("--model", "-m", default="gemini-3-flash-preview",
   627	                        help="Gemini model to use")
   628	    parser.add_argument("--thinking", "-t", default="low",
   629	                        choices=["minimal", "low", "medium", "high"],
   630	                        help="Thinking level for Gemini 3 (default: low)")
   631	    parser.add_argument("--temperature", type=float, default=0.0,
   632	                        help="Generation temperature (default: 0.0 deterministic)")
   633	    parser.add_argument("--no-validate", action="store_true",
   634	                        help="Skip transcription validation")
   635	    parser.add_argument("--max-segments", "-n", type=int,
   636	                        help="Maximum segments to process")
   637	    parser.add_argument("--max-duration", "-d", type=float, default=10.0,
   638	                        help="Maximum segment duration in seconds")
   639	    parser.add_argument("--min-duration", type=float, default=2.0,
   640	                        help="Minimum segment duration in seconds (default: 2.0)")
   641	    parser.add_argument("--split-pad-ms", type=float, default=80.0,
   642	                        help="Padding (ms) added around split boundaries (default: 80)")
   643	    parser.add_argument("--output", "-o", default="./transcriptions",
   644	                        help="Output directory")
   645
   646	    args = parser.parse_args()
   647
   648	    result = run_pipeline(
   649	        video_id=args.video_id,
   650	        language=args.language,
   651	        model=args.model,
   652	        thinking_level=args.thinking,
   653	        temperature=args.temperature,
   654	        max_segments=args.max_segments,
   655	        max_duration_sec=args.max_duration,
   656	        min_duration_sec=args.min_duration,
   657	        split_pad_ms=args.split_pad_ms,
   658	        output_dir=args.output,
   659	        validate=not args.no_validate
   660	    )
   661
   662	    print(f"\n{'='*60}")
   663	    print("Pipeline Summary")
   664	    print(f"{'='*60}")
   665	    print(f"Video ID: {result.video_id}")
   666	    print(f"Language: {result.language}")
   667	    print(f"Model: {result.model} (thinking: {result.thinking_level})")
   668	    print(f"Segments processed: {result.segments_processed}")
   669	    print(f"Chunks transcribed: {result.transcriptions_completed}")
   670	    print(f"Total audio: {result.total_audio_duration_sec:.1f}s")
   671	    print(f"Total time: {result.total_processing_time_sec:.1f}s")
   672	    print(f"Output: {result.output_file}")
   673	    if result.errors:
   674	        print(f"Errors: {result.errors}")


Output:
Chunk ID: 177bb7
Wall time: 0.0509 seconds
Process exited with code 0
Original token count: 2374
Output:
     1	"""
     2	MMS Forced Aligner (Romanized Text)
     3	====================================
     4
     5	Language-agnostic forced alignment using Meta's MMS_FA model via torchaudio.
     6	Works on romanized/Latin-script text. Complements the native CTC aligner.
     7
     8	MMS_FA is trained on 1000+ languages — handles romanized Indic + English equally well.
     9	Used alongside native CTC to produce dual validation scores.
    10
    11	Usage:
    12	    aligner = MMSAligner()
    13	    result = aligner.align("audio.flac", "naaku konni ads gurtuntaayi")
    14	    print(f"Score: {result.alignment_score}")  # 0-1
    15	"""
    16	import torch
    17	import torchaudio
    18	import soundfile as sf
    19	import numpy as np
    20	import time
    21	from dataclasses import dataclass, field
    22	from typing import List, Dict, Optional
    23
    24	from src.romanization import normalize_roman_for_mms
    25
    26
    27	@dataclass
    28	class MMSAlignmentResult:
    29	    """Result of MMS forced alignment on romanized text."""
    30	    audio_path: str
    31	    transcription: str
    32	    normalized_transcription: str = ""
    33
    34	    alignment_score: float  # 0-1, overall quality
    35	    mean_log_prob: float    # Raw mean log prob
    36
    37	    num_tokens: int = 0
    38	    num_frames: int = 0
    39	    audio_duration_sec: float = 0.0
    40	    processing_time_sec: float = 0.0
    41
    42	    def to_dict(self) -> Dict:
    43	        return {
    44	            "audio_path": self.audio_path,
    45	            "transcription": self.transcription[:100],
    46	            "normalized_transcription": self.normalized_transcription[:100],
    47	            "alignment_score": round(self.alignment_score, 4),
    48	            "mean_log_prob": round(self.mean_log_prob, 4),
    49	            "num_tokens": self.num_tokens,
    50	            "num_frames": self.num_frames,
    51	            "audio_duration_sec": round(self.audio_duration_sec, 3),
    52	            "processing_time_sec": round(self.processing_time_sec, 3),
    53	        }
    54
    55
    56	class MMSAligner:
    57	    """
    58	    Language-agnostic forced alignment using torchaudio MMS_FA.
    59
    60	    Tokenization: space → * (word boundary, idx 28), CTC blank = - (idx 0).
    61	    Score normalization: log_prob range ~[-10, 0] mapped to [0, 1].
    62	    """
    63
    64	    def __init__(self, device: str = "auto"):
    65	        if device == "auto":
    66	            self.device = "cuda" if torch.cuda.is_available() else "cpu"
    67	        else:
    68	            self.device = device
    69
    70	        self.model = None
    71	        self.labels = None
    72	        self.label2idx = None
    73	        self.sample_rate = None
    74	        self._setup_complete = False
    75
    76	        # Token indices (set during setup)
    77	        self.BLANK_IDX = 0   # '-' = CTC blank
    78	        self.STAR_IDX = 28   # '*' = word boundary
    79
    80	    def setup(self) -> bool:
    81	        """Lazy load MMS_FA model."""
    82	        if self._setup_complete:
    83	            return True
    84
    85	        try:
    86	            bundle = torchaudio.pipelines.MMS_FA
    87	            self.model = bundle.get_model().to(self.device)
    88	            self.model.eval()
    89	            self.labels = bundle.get_labels()
    90	            self.sample_rate = bundle.sample_rate  # 16000
    91	            self.label2idx = {l: i for i, l in enumerate(self.labels)}
    92	            self.BLANK_IDX = self.label2idx.get("-", 0)
    93	            self.STAR_IDX = self.label2idx.get("*", 28)
    94
    95	            print(f"[MMSAligner] Ready on {self.device} "
    96	                  f"({len(self.labels)} labels, SR={self.sample_rate})")
    97	            self._setup_complete = True
    98	            return True
    99
   100	        except Exception as e:
   101	            print(f"[MMSAligner] Setup failed: {e}")
   102	            return False
   103
   104	    def _load_audio(self, audio_path: str) -> torch.Tensor:
   105	        """Load and resample audio to MMS sample rate (16kHz)."""
   106	        data, sr = sf.read(audio_path)
   107	        if len(data.shape) > 1:
   108	            data = data.mean(axis=1)
   109	        waveform = torch.tensor(data, dtype=torch.float32).unsqueeze(0)
   110	        if sr != self.sample_rate:
   111	            waveform = torchaudio.functional.resample(
   112	                waveform, sr, self.sample_rate
   113	            )
   114	        return waveform
   115
   116	    def _text_to_tokens(self, text: str) -> List[int]:
   117	        """
   118	        Convert romanized text to MMS token indices.
   119	        Space → * (word boundary). Unknown chars skipped.
   120	        Punctuation stripped.
   121	        """
   122	        tokens = []
   123	        for ch in text.lower().strip():
   124	            if ch == " ":
   125	                # Word boundary - don't duplicate
   126	                if tokens and tokens[-1] != self.STAR_IDX:
   127	                    tokens.append(self.STAR_IDX)
   128	            elif ch in self.label2idx and self.label2idx[ch] != self.BLANK_IDX:
   129	                tokens.append(self.label2idx[ch])
   130	            # Punctuation and unknown chars silently skipped
   131	        # Strip leading/trailing boundaries
   132	        while tokens and tokens[0] == self.STAR_IDX:
   133	            tokens.pop(0)
   134	        while tokens and tokens[-1] == self.STAR_IDX:
   135	            tokens.pop()
   136	        return tokens
   137
   138	    def align(self, audio_path: str, romanized_text: str) -> MMSAlignmentResult:
   139	        """
   140	        Perform forced alignment of romanized text against audio.
   141
   142	        Args:
   143	            audio_path: Path to audio file
   144	            romanized_text: Latin-script text to align
   145
   146	        Returns:
   147	            MMSAlignmentResult with alignment score
   148	        """
   149	        if not self._setup_complete:
   150	            if not self.setup():
   151	                return MMSAlignmentResult(
   152	                    audio_path=audio_path,
   153	                    transcription=romanized_text,
   154	                    normalized_transcription=normalize_roman_for_mms(romanized_text),
   155	                    alignment_score=0.0,
   156	                    mean_log_prob=-10.0,
   157	                )
   158
   159	        start_time = time.time()
   160	        normalized = normalize_roman_for_mms(romanized_text)
   161
   162	        try:
   163	            waveform = self._load_audio(audio_path)
   164	            audio_dur = waveform.shape[1] / self.sample_rate
   165	            tokens = self._text_to_tokens(normalized)
   166
   167	            if not tokens:
   168	                return MMSAlignmentResult(
   169	                    audio_path=audio_path,
   170	                    transcription=romanized_text,
   171	                    normalized_transcription=normalized,
   172	                    alignment_score=0.0,
   173	                    mean_log_prob=-10.0,
   174	                    audio_duration_sec=audio_dur,
   175	                    processing_time_sec=time.time() - start_time,
   176	                )
   177
   178	            with torch.no_grad():
   179	                emission, _ = self.model(waveform.to(self.device))
   180	            emission = emission.cpu()
   181
   182	            token_tensor = torch.tensor([tokens], dtype=torch.int32)
   183
   184	            _, scores = torchaudio.functional.forced_align(
   185	                emission, token_tensor, blank=self.BLANK_IDX
   186	            )
   187
   188	            mean_lp = scores.mean().item()
   189	            # Normalize: log probs range ~[-10, 0]. Map to [0, 1].
   190	            # Same formula as CTC aligner for consistency.
   191	            alignment_score = max(0.0, min(1.0, 1.0 + mean_lp / 5.0))
   192
   193	            return MMSAlignmentResult(
   194	                audio_path=audio_path,
   195	                transcription=romanized_text,
   196	                normalized_transcription=normalized,
   197	                alignment_score=alignment_score,
   198	                mean_log_prob=mean_lp,
   199	                num_tokens=len(tokens),
   200	                num_frames=emission.shape[1],
   201	                audio_duration_sec=audio_dur,
   202	                processing_time_sec=time.time() - start_time,
   203	            )
   204
   205	        except Exception as e:
   206	            import traceback
   207	            traceback.print_exc()
   208	            return MMSAlignmentResult(
   209	                audio_path=audio_path,
   210	                transcription=romanized_text,
   211	                normalized_transcription=normalized,
   212	                alignment_score=0.0,
   213	                mean_log_prob=-10.0,
   214	                processing_time_sec=time.time() - start_time,
   215	            )
   216
   217	    def cleanup(self):
   218	        """Release resources."""
   219	        if self.model is not None:
   220	            del self.model
   221	            self.model = None
   222	        self._setup_complete = False
   223	        if torch.cuda.is_available():
   224	            torch.cuda.empty_cache()


Summary: auto


**Planning line numbering approach**


nl -ba src/backend/config.py | sed -n '68,92p'


Output:
Chunk ID: 5d4746
Wall time: 0.0515 seconds
Process exited with code 0
Original token count: 235
Output:
    68	# Alias mapping for easier usage
    69	MODEL_ALIASES = {
    70	    "gemini-flash-latest": "gemini-3-flash-preview",
    71	    "gemini-flash-lite-latest": "gemini-2.5-flash-lite",
    72	}
    73
    74	# === Default Pipeline Settings ===
    75	DEFAULT_SETTINGS = {
    76	    "max_segment_duration_sec": 10.0,  # Hard limit for segment duration
    77	    "min_segment_duration_sec": 2.0,   # Minimum segment duration
    78	    "split_boundary_padding_ms": 80.0,
    79	    "default_model": "gemini-3-flash-preview",
    80	    "default_thinking_level": "high",
    81	    "work_dir": "/tmp/maya3_transcribe",
    82	    "output_dir": "./transcriptions"
    83	}
    84
    85	# === Language Mapping (for Supabase language codes) ===
    86	LANGUAGE_MAP = {
    87	    "te": "Telugu",
    88	    "hi": "Hindi",
    89	    "ta": "Tamil",
    90	    "kn": "Kannada",
    91	    "ml": "Malayalam",
    92	    "bn": "Bengali",


Output:
Chunk ID: ac81ce
Wall time: 0.0516 seconds
Process exited with code 0
Original token count: 52
Output:
     1	boto3>=1.42.0
     2	google-genai>=1.61.0
     3	pydantic>=2.12.0
     4	supabase>=2.27.0
     5	pydub>=0.25.0
     6	python-dotenv>=1.2.0
     7	Unidecode>=1.3.8
     8	indic-transliteration>=2.3.65


**Assessing pipeline import and validation issues**


python3 - <<'PY'
import pipeline
print('pipeline import ok')
PY


Output:
Chunk ID: 18503f
Wall time: 0.0514 seconds
Process exited with code 1
Original token count: 133
Output:
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/ubuntu/maya3_transcribe/pipeline.py", line 19, in <module>
    from src.backend.config import DEFAULT_SETTINGS, GEMINI_MODELS, get_model_name
  File "/home/ubuntu/maya3_transcribe/src/backend/__init__.py", line 25, in <module>
    from .r2_storage import R2StorageClient, download_video_segments
  File "/home/ubuntu/maya3_transcribe/src/backend/r2_storage.py", line 12, in <module>
    import boto3
ModuleNotFoundError: No module named 'boto3'


**Verifying dependencies in virtual environment**


./venv/bin/python - <<'PY'
import pipeline
print('pipeline import ok')
PY


Output:
Chunk ID: f662fa
Wall time: 2.2718 seconds
Process exited with code 0
Original token count: 5
Output:
pipeline import ok


**Planning venv smoke test**


./venv/bin/python - <<'PY'
from src.romanization import derive_romanized_text
from src.validators.simple_validator import validate_transcription
print(derive_romanized_text('నాకు కొన్ని యాడ్స్ గుర్తుంటాయి','te').text)
r = validate_transcription('dummy.wav','నాకు కొన్ని యాడ్స్',language='te',check_audio=False)
print(r.status, r.romanization_source)
PY


Output:
Chunk ID: 1a4533
Wall time: 0.0504 seconds
Process exited with code 0
Original token count: 5
Output:

accept passthrough


**Ensuring robust romanization fallback**


./venv/bin/python - <<'PY'
from src.romanization import derive_romanized_text
r = derive_romanized_text('నాకు కొన్ని యాడ్స్','te',fallback_romanized='naaku konni yaads')
print(r.text, r.source)
PY


Output:
Chunk ID: 915858
Wall time: 0.0513 seconds
Process exited with code 0
Original token count: 9
Output:
naaku konni yaads fallback_model


**Reviewing fallback and validation logic**


Output:
Chunk ID: a3e0f2
Wall time: 0.0513 seconds
Process exited with code 0
Original token count: 335
Output:
 M pipeline.py
 M requirements.txt
 M src/backend/__pycache__/audio_processor.cpython-310.pyc
 M src/backend/__pycache__/config.cpython-310.pyc
 M src/backend/__pycache__/gemini_transcriber.cpython-310.pyc
 M src/backend/__pycache__/transcription_schema.cpython-310.pyc
 M src/backend/audio_processor.py
 M src/backend/config.py
 M src/backend/gemini_transcriber.py
 M src/backend/transcription_schema.py
 M src/validators/__init__.py
 M src/validators/__pycache__/__init__.cpython-310.pyc
 M src/validators/__pycache__/ctc_forced_aligner.cpython-310.pyc
 M src/validators/__pycache__/simple_validator.cpython-310.pyc
 M src/validators/ctc_forced_aligner.py
 M src/validators/simple_validator.py
?? SPEAKER_00_0000_0.03-2.61.flac
?? SPEAKER_00_0004_36.29-37.41.wav
?? SPEAKER_00_0038_529.16-531.25.wav
?? SPEAKER_00_0059_741.63-748.92.wav
?? SPEAKER_00_0076_1013.51-1020.24.wav
?? SPEAKER_00_0077_1026.28-1027.43.wav
?? SPEAKER_00_0082_1061.11-1065.06.wav
?? SPEAKER_00_0107_1343.96-1350.94.wav
?? SPEAKER_01_0422_5442.68-5452.49.flac
?? __pycache__/
?? analysis_results/
?? bin/
?? consistency_test/
?? golden_set/
?? models/
?? src/__pycache__/romanization.cpython-310.pyc
?? src/romanization.py
?? src/validators/__pycache__/mms_aligner.cpython-310.pyc
?? src/validators/mms_aligner.py
?? transcriptions/
?? validation_results/
?? venv/


**Assessing runtime dependencies and romanization impact**


I spotted that the current venv doesn’t yet have transliteration dependencies, so deterministic derivation falls back to model romanization unless installed. I’m installing the new requirements in the venv and re-running a quick romanization smoke test.


./venv/bin/pip install -q Unidecode indic-transliteration


Output:
Chunk ID: 496cbb
Wall time: 2.8163 seconds
Process exited with code 0
Original token count: 41
Output:

[notice] A new release of pip is available: 26.0 -> 26.0.1
[notice] To update, run: /home/ubuntu/maya3_transcribe/venv/bin/python3 -m pip install --upgrade pip


**Running smoke test in venv**


./venv/bin/python - <<'PY'
from src.romanization import derive_romanized_text
print(derive_romanized_text('నాకు కొన్ని యాడ్స్ గుర్తుంటాయి','te').text, derive_romanized_text('నాకు కొన్ని యాడ్స్ గుర్తుంటాయి','te').source)
print(derive_romanized_text('ఇంకా','te').text, derive_romanized_text('ఇంకా','te').source)
PY


Output:
Chunk ID: b3acaa
Wall time: 0.2787 seconds
Process exited with code 0
Original token count: 15
Output:
naaku konni yaadds gurtunttaayi unidecode
inkaa unidecode


Summary: auto


**Verifying Indic transliteration integration**


./venv/bin/python - <<'PY'
try:
    from indic_transliteration.sanscript import TELUGU, transliterate
    print('TELUGU ok', type(TELUGU))
    out = transliterate('నాకు కొన్ని యాడ్స్ గుర్తుంటాయి', TELUGU, 'hk')
    print('out', out)
except Exception as e:
    print('err', type(e).__name__, e)

try:
    from indic_transliteration.sanscript import ASSAMESE
    print('ASSAMESE ok', ASSAMESE)
except Exception as e:
    print('ASSAMESE err', e)
PY


Output:
Chunk ID: 45d058
Wall time: 0.0705 seconds
Process exited with code 0
Original token count: 62
Output:
TELUGU ok <class 'str'>
out nAku kònni yADs gurtuMTAyi
ASSAMESE err cannot import name 'ASSAMESE' from 'indic_transliteration.sanscript' (/home/ubuntu/maya3_transcribe/venv/lib/python3.10/site-packages/indic_transliteration/sanscript/__init__.py)


**Patching romanization to avoid import failures**


Input:
*** Begin Patch
*** Update File: src/romanization.py
@@
 def _romanize_with_indic_transliteration(
     native_text: str,
     language: str,
 ) -> Optional[str]:
@@
-    try:
-        from indic_transliteration.sanscript import (
-            ASSAMESE,
-            BENGALI,
-            DEVANAGARI,
-            GUJARATI,
-            GURMUKHI,
-            KANNADA,
-            MALAYALAM,
-            ORIYA,
-            TAMIL,
-            TELUGU,
-            transliterate,
-        )
-    except Exception:
-        return None
+    try:
+        from indic_transliteration.sanscript import transliterate
+    except Exception:
+        return None

     lang = (language or "").lower()[:2]
     script_map = {
-        "te": TELUGU,
-        "hi": DEVANAGARI,
-        "mr": DEVANAGARI,
-        "ta": TAMIL,
-        "kn": KANNADA,
-        "ml": MALAYALAM,
-        "bn": BENGALI,
-        "as": ASSAMESE,
-        "gu": GUJARATI,
-        "pa": GURMUKHI,
-        "or": ORIYA,
+        "te": "telugu",
+        "hi": "devanagari",
+        "mr": "devanagari",
+        "ta": "tamil",
+        "kn": "kannada",
+        "ml": "malayalam",
+        "bn": "bengali",
+        "as": "bengali",
+        "gu": "gujarati",
+        "pa": "gurmukhi",
+        "or": "oriya",
     }
*** End Patch


Output: {"output":"Success. Updated the following files:\nM src/romanization.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


**Retesting romanization with indic_transliteration**


./venv/bin/python - <<'PY'
from src.romanization import derive_romanized_text
for s in ['నాకు కొన్ని యాడ్స్ గుర్తుంటాయి','ఇంకా','అండ్']:
    r=derive_romanized_text(s,'te')
    print(s,'->',r.text,'|',r.source)
PY


Output:
Chunk ID: b72aa6
Wall time: 0.0742 seconds
Process exited with code 0
Original token count: 57
Output:
నాకు కొన్ని యాడ్స్ గుర్తుంటాయి -> naku k nni yads gurtumtayi | indic_transliteration
ఇంకా -> imka | indic_transliteration
అండ్ -> amd | indic_transliteration


**Testing alternative transliteration scheme**


./venv/bin/python - <<'PY'
from indic_transliteration.sanscript import transliterate
s='నాకు కొన్ని యాడ్స్ గుర్తుంటాయి'
for t in ['hk','itrans','iast','slp1']:
    out=transliterate(s,'telugu',t)
    print(t, out)
PY


Output:
Chunk ID: d35b6a
Wall time: 0.0642 seconds
Process exited with code 0
Original token count: 36
Output:
hk nAku kònni yADs gurtuMTAyi
itrans nAku kònni yADs gurtuMTAyi
iast nāku kònni yāḍs gurtuṃṭāyi
slp1 nAku kònni yAqs gurtuMwAyi


**Updating normalization to NFKD and strip marks**


Input:
*** Begin Patch
*** Update File: src/romanization.py
@@
 def normalize_roman_for_mms(text: str) -> str:
@@
-    cleaned = unicodedata.normalize("NFKC", cleaned)
+    cleaned = unicodedata.normalize("NFKD", cleaned)
+    cleaned = "".join(ch for ch in cleaned if not unicodedata.combining(ch))
     cleaned = cleaned.translate(_APOSTROPHE_TRANSLATION)
*** End Patch


Output: {"output":"Success. Updated the following files:\nM src/romanization.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


**Retesting romanization outputs**


./venv/bin/python - <<'PY'
from src.romanization import derive_romanized_text
for s in ['నాకు కొన్ని యాడ్స్ గుర్తుంటాయి','ఇంకా','అండ్','కచ్చా మ్యాంగో బైట్']:
    r=derive_romanized_text(s,'te')
    print(s,'->',r.text,'|',r.source)
PY


Output:
Chunk ID: 488f7a
Wall time: 0.0737 seconds
Process exited with code 0
Original token count: 81
Output:
నాకు కొన్ని యాడ్స్ గుర్తుంటాయి -> naku konni yads gurtumtayi | indic_transliteration
ఇంకా -> imka | indic_transliteration
అండ్ -> amd | indic_transliteration
కచ్చా మ్యాంగో బైట్ -> kacca myamgo bait | indic_transliteration


**Reprioritizing transliteration sources**


Input:
*** Begin Patch
*** Update File: src/romanization.py
@@
     Fallback priority:
-    1) indic-transliteration (if installed)
-    2) unidecode
+    1) unidecode
+    2) indic-transliteration (if installed)
     3) model-provided romanized fallback (normalized)
@@
-    via_indic = _romanize_with_indic_transliteration(native, language)
-    if via_indic:
-        normalized = normalize_roman_for_mms(via_indic)
-        if normalized:
-            return RomanizationResult(text=normalized, source="indic_transliteration")
-
     via_unidecode = normalize_roman_for_mms(_romanize_with_unidecode(native))
     if via_unidecode:
         return RomanizationResult(text=via_unidecode, source="unidecode")
+
+    via_indic = _romanize_with_indic_transliteration(native, language)
+    if via_indic:
+        normalized = normalize_roman_for_mms(via_indic)
+        if normalized:
+            return RomanizationResult(text=normalized, source="indic_transliteration")
*** End Patch


Output: {"output":"Success. Updated the following files:\nM src/romanization.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


**Running quick retest**


Output:
Chunk ID: 14dfce
Wall time: 0.0511 seconds
Process exited with code 0
Original token count: 72
Output:
నాకు కొన్ని యాడ్స్ గుర్తుంటాయి -> naaku konni yaadds gurtunttaayi | unidecode
ఇంకా -> inkaa | unidecode
అండ్ -> andd | unidecode
కచ్చా మ్యాంగో బైట్ -> kccaa myaangoo baitt | unidecode


**Assessing unidecode output quirks**


./venv/bin/python - <<'PY'
from src.romanization import derive_romanized_text
for s,lang in [('सॉल्ट बिस्किट्स','hi'),('हाँ ठीक है','hi'),('আমি বাংলা বলি','bn')]:
    r=derive_romanized_text(s,lang)
    print(lang,s,'->',r.text)
PY


Output:
Chunk ID: 0e63a8
Wall time: 0.0514 seconds
Process exited with code 0
Original token count: 44
Output:
hi सॉल्ट बिस्किट्स -> sonltt biskitts
hi हाँ ठीक है -> haan tthiik hai
bn আমি বাংলা বলি -> aami baanlaa bli


**Choosing transliteration priority**


Input:
*** Begin Patch
*** Update File: src/romanization.py
@@
     Fallback priority:
-    1) unidecode
-    2) indic-transliteration (if installed)
+    1) indic-transliteration (if installed)
+    2) unidecode
     3) model-provided romanized fallback (normalized)
@@
-    via_unidecode = normalize_roman_for_mms(_romanize_with_unidecode(native))
-    if via_unidecode:
-        return RomanizationResult(text=via_unidecode, source="unidecode")
-
     via_indic = _romanize_with_indic_transliteration(native, language)
     if via_indic:
         normalized = normalize_roman_for_mms(via_indic)
         if normalized:
             return RomanizationResult(text=normalized, source="indic_transliteration")
+
+    via_unidecode = normalize_roman_for_mms(_romanize_with_unidecode(native))
+    if via_unidecode:
+        return RomanizationResult(text=via_unidecode, source="unidecode")
*** End Patch


Output: {"output":"Success. Updated the following files:\nM src/romanization.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Output:
Chunk ID: 863028
Wall time: 0.0753 seconds
Process exited with code 0
Original token count: 57
Output:
నాకు కొన్ని యాడ్స్ గుర్తుంటాయి -> naku konni yads gurtumtayi | indic_transliteration
ఇంకా -> imka | indic_transliteration
అండ్ -> amd | indic_transliteration


**Running compileall after changes**


Output:
Chunk ID: 5b4668
Wall time: 0.0514 seconds
Process exited with code 0
Original token count: 18
Output:
Listing 'src'...
Listing 'src/backend'...
Listing 'src/validators'...


Summary: auto


**Validating pipeline import in venv**


./venv/bin/python - <<'PY'
import pipeline
from src.validators.simple_validator import validate_transcription
from src.romanization import derive_romanized_text
print('ok', derive_romanized_text('ఇంకా','te').source)
r = validate_transcription('dummy.wav','ఇంకా',language='te',check_audio=False)
print(r.status, r.romanization_source)
PY


Output:
Chunk ID: b4636a
Wall time: 1.7896 seconds
Process exited with code 0
Original token count: 14
Output:
ok indic_transliteration
accept indic_transliteration


**Summarizing implementation and tests**


Implemented. I applied the pipeline hardening you asked for:

- Deterministic romanization from authoritative native text:
  - Added `src/romanization.py` (`derive_romanized_text`, `normalize_roman_for_mms`) at `src/romanization.py:35`, `src/romanization.py:47`, `src/romanization.py:129`.
  - Uses deterministic transliteration (Indic transliteration when available, fallback path included), then strict MMS-safe normalization.

- Validator upgraded to stricter, boundary-aware scoring:
  - New strict thresholds and disagreement gates at `src/validators/simple_validator.py:118`.
  - Auto-derives romanized text from native transcription (instead of trusting model romanized) in `src/validators/simple_validator.py:398`.
  - Added boundary/internal word confidence gates and short-clip guard (`<2s`) in `src/validators/simple_validator.py:516`, `src/validators/simple_validator.py:524`.
  - Added richer debug fields (`romanization_source`, boundary confidence metrics) in `src/validators/simple_validator.py:207`.

- MMS aligner normalization tightened:
  - Alignment now uses normalized roman text consistently (`src/validators/mms_aligner.py:24`, `src/validators/mms_aligner.py:160`).

- Segment quality controls tightened:
  - Default minimum segment duration raised to `2.0s` in `pipeline.py:45` and `src/backend/config.py:77`.
  - Added split boundary padding control (`split_boundary_padding_ms`, default `80ms`) in `pipeline.py:47`, `src/backend/audio_processor.py:41`.
  - Splitter now merges tiny trailing chunks and pads split boundaries (`src/backend/audio_processor.py:175`, `src/backend/audio_processor.py:192`).

- Pipeline integration:
  - Validation now uses deterministic romanized text and writes it back to output (`pipeline.py:341`, `pipeline.py:347`).
  - Retry path also re-derives deterministic romanization (`pipeline.py:392`).
  - Validation now passes minimum-duration guard (`pipeline.py:353`, `pipeline.py:403`).
  - CLI/API knobs added: `--min-duration`, `--split-pad-ms` (`pipeline.py:639`, `pipeline.py:641`, `pipeline.py:533`, `pipeline.py:534`).

- Dependencies:
  - Added transliteration deps to `requirements.txt:7` and `requirements.txt:8`.

Checks run:
- `python3 -m compileall pipeline.py src` (passes).
- `./venv/bin/python` smoke imports and function checks (passes).
- Did not run a full end-to-end transcription job (would require live R2/Supabase/Gemini + model/runtime cost).


just so you know,

నాకు కొన్ని యాడ్స్ గుర్తుంటాయి -> naaku konni yaadds gurtuNttaayi
అవును -> avunu
చూడండి సో -> cuuddNddi soo
ఇంకా -> iNkaa
అండ్ -> aNdd
सॉल्ट बिस्किट्स -> soNltt biskitts

are all inaccurate.

నాకు కొన్ని యాడ్స్ గుర్తుంటాయి -> naaku konni yaads gurtuntayi
అవును -> avunu
చూడండి సో -> chudandi soo
ఇంకా -> inkaa
అండ్ -> and
सॉल्ट बिस्किट्स -> salt biscuits

rollback these derive_roman function changes.

and for the VAD aware polishing, we need a clear energy based vad aware cutting and small silence addition to ensure max crispiness in audio segments and we have to ensure the audio segments are not cut/started with voice energy levels. you can check the amplitude or voice activity and cut/start to that point where the energy is started again ensuring real/clean boundaries.

and when done, skip the supabase path, we have few tmp segments in below snippet, that you can use to validate by running it twice/thrice to evaluate the determinism and consistency in scoring and other metrics.

cd /home/ubuntu/maya3_transcribe && source venv/bin/activate && python3 << 'TESTSCRIPT'
"""Consistency test v2: 15 NEW segments x 3 runs with updated pipeline."""
import json, time, os
os.chdir("/home/ubuntu/maya3_transcribe")

from src.backend.audio_processor import AudioProcessor, AudioChunk
from src.backend.gemini_transcriber import GeminiTranscriber, TranscriptionConfig
from src.validators import validate_transcription, cleanup

SEG_DIR = "/tmp/maya3_transcribe/pF_BQpHaIdU/extracted/pF_BQpHaIdU/segments"
SEGMENTS = [
    "SPEAKER_00_0038_529.16-531.25.flac",
    "SPEAKER_00_0039_531.77-535.96.flac",
    "SPEAKER_00_0057_715.64-717.95.flac",
    "SPEAKER_00_0059_741.63-748.92.flac",
    "SPEAKER_00_0076_1013.51-1020.24.flac",
    "SPEAKER_00_0077_1026.28-1027.43.flac",
    "SPEAKER_00_0078_1028.09-1035.12.flac",
    "SPEAKER_00_0079_1035.75-1040.36.flac",
    "SPEAKER_00_0080_1041.01-1050.11.flac",
    "SPEAKER_00_0082_1061.11-1065.06.flac",
    "SPEAKER_00_0084_1069.99-1078.59.flac",
    "SPEAKER_00_0085_1079.22-1086.36.flac",
    "SPEAKER_00_0107_1343.96-1350.94.flac",
    "SPEAKER_00_0110_1381.19-1387.98.flac",
    "SPEAKER_00_0111_1388.60-1392.70.flac",
]
NUM_RUNS = 3
proc = AudioProcessor()
transcriber = GeminiTranscriber()
config = TranscriptionConfig(model="gemini-3-flash-preview", thinking_level="low",
                              temperature=0.0, language="Telugu")

chunks = []
for name in SEGMENTS:
    path = f"{SEG_DIR}/{name}"
    if not os.path.exists(path): print(f"SKIP: {name}"); continue
    d = proc.get_audio_duration(path)
    chunks.append(AudioChunk(file_path=path, original_segment=name,
        chunk_index=0, total_chunks=1, start_ms=0, end_ms=int(d*1000), duration_sec=d))

print(f"Testing {len(chunks)} segments x {NUM_RUNS} runs\n")

all_runs = []
for run_id in range(1, NUM_RUNS + 1):
    print(f"{'='*60}\nRUN {run_id}/{NUM_RUNS}\n{'='*60}")
    results = {}
    for i, chunk in enumerate(chunks):
        print(f"  [{run_id}] {i+1}/{len(chunks)}: {chunk.original_segment} ({chunk.duration_sec:.1f}s)")
        raw = transcriber.transcribe_audio(chunk.file_path, config)
        if raw.get("error"): print(f"    ERR: {raw['error']}"); continue
        native = raw.get("transcription", "")
        roman = raw.get("romanized", "")
        detected = raw.get("detected_language", "")
        val = validate_transcription(chunk.file_path, native, romanized_text=roman, language="te")
        results[chunk.original_segment] = {
            "transcription": native, "romanized": roman,
            "detected_language": detected,
            "native_ctc": val.native_ctc_score, "roman_mms": val.roman_mms_score,
            "combined": val.combined_score, "status": val.status,
        }
        if i < len(chunks) - 1: time.sleep(0.5)
    all_runs.append(results)
    if run_id < NUM_RUNS: print("Pause 5s..."); time.sleep(5)

print(f"\n{'='*80}\nCONSISTENCY + VALIDATION RESULTS\n{'='*80}\n")
identical = 0
for seg in SEGMENTS:
    natives = [r.get(seg, {}).get("transcription", "") for r in all_runs]
    if not all(natives): continue
    match = all(n == natives[0] for n in natives)
    r0 = all_runs[0].get(seg, {})
    short = seg.replace("SPEAKER_00_","").replace(".flac","")
    status = r0.get("status", "?")
    S = r0.get("combined", 0)
    N = r0.get("native_ctc", 0)
    R = r0.get("roman_mms", 0)
    det = r0.get("detected_language", "?")
    tag = "IDENTICAL" if match else "DIFFERS"
    if match: identical += 1
    print(f"{short}: {tag} | S={S:.3f} CTC={N:.3f} MMS={R:.3f} | {status} | lang={det}")
    if not match:
        for ri, r in enumerate(all_runs):
            t = r.get(seg, {}).get("transcription", "")[:80]
            print(f"  Run {ri+1}: {t}")

total = len([s for s in SEGMENTS if all_runs[0].get(s)])
print(f"\nSUMMARY: {identical}/{total} identical ({identical/max(total,1):.0%})")
scores = [r.get("combined", 0) for r in all_runs[0].values() if r.get("combined")]
statuses = [r.get("status") for r in all_runs[0].values() if r.get("status")]
print(f"Avg S: {sum(scores)/max(len(scores),1):.3f}")
sdict = {}
for s in statuses: sdict[s] = sdict.get(s, 0) + 1
print(f"Verdicts: {sdict}")

cleanup()
os.makedirs("./consistency_test", exist_ok=True)
with open("./consistency_test/v2_comparison.json", "w", encoding="utf-8") as f:
    json.dump({"config": {"segments": len(chunks), "runs": NUM_RUNS}, "runs": all_runs}, f, ensure_ascii=False, indent=2)
print(f"Saved to ./consistency_test/v2_comparison.json")
TESTSCRIPT


**Planning rollback and VAD improvements**


Understood. I’ll roll back deterministic `derive_romanized` usage completely, keep model-provided romanized text, then improve boundary polishing with explicit energy/activity edge checks plus small silence padding when edges are hot. After that I’ll run your consistency script on the local `/tmp` segments and report metrics.


rg -n "derive_romanized_text|normalize_roman_for_mms|strip_placeholders|src\.romanization" pipeline.py src/validators src/backend requirements.txt -S


sed -n '1,240p' src/backend/audio_polisher.py


sed -n '240,520p' src/backend/audio_polisher.py


Output:
Chunk ID: 7211f8
Wall time: 0.0518 seconds
Process exited with code 0
Original token count: 292
Output:
pipeline.py:26:from src.romanization import derive_romanized_text
pipeline.py:341:                        romanized_result = derive_romanized_text(
pipeline.py:392:                                retry_romanized = derive_romanized_text(
src/validators/mms_aligner.py:24:from src.romanization import normalize_roman_for_mms
src/validators/mms_aligner.py:154:                    normalized_transcription=normalize_roman_for_mms(romanized_text),
src/validators/mms_aligner.py:160:        normalized = normalize_roman_for_mms(romanized_text)
src/validators/simple_validator.py:28:from src.romanization import (
src/validators/simple_validator.py:29:    derive_romanized_text,
src/validators/simple_validator.py:30:    normalize_roman_for_mms,
src/validators/simple_validator.py:31:    strip_placeholders,
src/validators/simple_validator.py:170:    text = strip_placeholders(text or "")
src/validators/simple_validator.py:399:    native_no_placeholders = strip_placeholders(transcription or "")
src/validators/simple_validator.py:401:    romanized = derive_romanized_text(
src/validators/simple_validator.py:406:    clean_roman = normalize_roman_for_mms(romanized.text)