Output:
Chunk ID: aedb98
Wall time: 0.0509 seconds
Process exited with code 0
Original token count: 2352
Output:
"""
Gemini transcription module using Google AI Studio endpoints.
Handles audio transcription with structured output and multiple model support.
"""
import time
from pathlib import Path
from typing import List, Optional, Dict, Any
from dataclasses import dataclass

from google import genai
from google.genai import types

from .config import GEMINI_API_KEY, GEMINI_MODELS, get_model_name
from .transcription_schema import (
    TranscriptionOutput,
    TranscriptionResult,
    SpeakerMeta,
    get_transcription_prompt,
    get_user_prompt,
    TRANSCRIPTION_JSON_SCHEMA
)
from .audio_processor import AudioChunk

@dataclass
class TranscriptionConfig:
    """Configuration for a transcription job."""
    model: str = "gemini-3-flash-preview"
    thinking_level: Optional[str] = "low"   # minimal, low, medium, high
    # Gemini 3 docs recommend 1.0, but our testing shows 0.0 produces
    # more deterministic, accurate transcriptions with the strict prompt.
    # No looping observed across 30+ test segments.
    temperature: float = 0.0
    language: str = "Telugu"
    timeout_sec: int = 60

class GeminiTranscriber:
    """
    Handles audio transcription using Gemini models via Google AI Studio.
    """

    def __init__(self, api_key: Optional[str] = None):
        """
        Initialize the transcriber.

        Args:
            api_key: Gemini API key (defaults to env var)
        """
        self.api_key = api_key or GEMINI_API_KEY
        if not self.api_key:
            raise ValueError("GEMINI_API_KEY not set")

        self.client = genai.Client(api_key=self.api_key)

    def _get_mime_type(self, file_path: str) -> str:
        """Determine MIME type from file extension."""
        ext = Path(file_path).suffix.lower()
        mime_types = {
            '.flac': 'audio/flac',
            '.wav': 'audio/wav',
            '.mp3': 'audio/mpeg',
            '.ogg': 'audio/ogg',
            '.m4a': 'audio/mp4',
            '.aac': 'audio/aac'
        }
        return mime_types.get(ext, 'audio/flac')

    def _load_audio_bytes(self, file_path: str) -> bytes:
        """Load audio file as bytes."""
        with open(file_path, 'rb') as f:
            return f.read()

    def _build_config(self, config: TranscriptionConfig) -> types.GenerateContentConfig:
        """Build the generation config for the API call."""
        model_name = get_model_name(config.model)
        model_info = GEMINI_MODELS.get(model_name, {})

        # Build thinking config for Gemini 3 models
        thinking_config = None
        if model_info.get("supports_thinking") and config.thinking_level:
            if model_name.startswith("gemini-3"):
                thinking_config = types.ThinkingConfig(
                    thinking_level=config.thinking_level.upper()
                )

        gen_config = types.GenerateContentConfig(
            temperature=config.temperature,
            response_mime_type="application/json",
            response_json_schema=TRANSCRIPTION_JSON_SCHEMA,
            system_instruction=[
                types.Part.from_text(text=get_transcription_prompt(config.language))
            ]
        )

        if thinking_config:
            gen_config.thinking_config = thinking_config

        return gen_config

    def transcribe_audio(
        self,
        audio_path: str,
        config: TranscriptionConfig
    ) -> Dict[str, Any]:
        """
        Transcribe a single audio file.

        Args:
            audio_path: Path to the audio file
            config: Transcription configuration

        Returns:
            Dictionary with transcription results
        """
        model_name = get_model_name(config.model)

        # Load audio
        audio_bytes = self._load_audio_bytes(audio_path)
        mime_type = self._get_mime_type(audio_path)

        # Build content
        contents = [
            types.Content(
                role="user",
                parts=[
                    types.Part.from_bytes(
                        mime_type=mime_type,
                        data=audio_bytes
                    ),
                    types.Part.from_text(text=get_user_prompt())
                ]
            )
        ]

        # Build config
        gen_config = self._build_config(config)

        # Make API call
        start_time = time.time()
        try:
            response = self.client.models.generate_content(
                model=model_name,
                contents=contents,
                config=gen_config
            )
            processing_time = time.time() - start_time

            # Parse response
            if response.text:
                import json
                result = json.loads(response.text)
                result['_processing_time_sec'] = processing_time
                result['_model'] = model_name
                result['_thinking_level'] = config.thinking_level
                return result
            else:
                return {
                    "error": "Empty response from model",
                    "_processing_time_sec": processing_time,
                    "_model": model_name
                }

        except Exception as e:
            processing_time = time.time() - start_time
            return {
                "error": str(e),
                "_processing_time_sec": processing_time,
                "_model": model_name
            }

    def transcribe_chunk(
        self,
        chunk: AudioChunk,
        config: TranscriptionConfig
    ) -> TranscriptionResult:
        """
        Transcribe an AudioChunk and return structured result.

        Args:
            chunk: AudioChunk to transcribe
            config: Transcription configuration

        Returns:
            TranscriptionResult with full metadata
        """
        raw_result = self.transcribe_audio(chunk.file_path, config)

        error_msg = raw_result.get("error")

        # Handle errors
        if error_msg:
            transcription = TranscriptionOutput(
                transcription=f"[ERROR: {error_msg}]",
                code_switch=f"[ERROR: {error_msg}]",
                romanized=f"[ERROR: {error_msg}]",
                tagged=f"[ERROR: {error_msg}]",
            )
        else:
            # Parse speaker metadata if present
            speaker_data = raw_result.get('speaker')
            speaker = SpeakerMeta(**speaker_data) if isinstance(speaker_data, dict) else None

            transcription = TranscriptionOutput(
                transcription=raw_result.get('transcription', ''),
                code_switch=raw_result.get('code_switch', ''),
                romanized=raw_result.get('romanized', ''),
                tagged=raw_result.get('tagged', ''),
                speaker=speaker,
                detected_language=raw_result.get('detected_language', ''),
            )

        return TranscriptionResult(
            segment_id=chunk.original_segment,
            chunk_index=chunk.chunk_index,
            total_chunks=chunk.total_chunks,
            duration_sec=chunk.duration_sec,
            language=config.language,
            transcription=transcription,
            model_used=raw_result.get('_model', config.model),
            thinking_level=raw_result.get('_thinking_level'),
            processing_time_sec=raw_result.get('_processing_time_sec')
        )

    def transcribe_batch(
        self,
        chunks: List[AudioChunk],
        config: TranscriptionConfig,
        max_chunks: Optional[int] = None,
        progress_callback: Optional[callable] = None
    ) -> List[TranscriptionResult]:
        """
        Transcribe multiple audio chunks.

        Args:
            chunks: List of AudioChunks to transcribe
            config: Transcription configuration
            max_chunks: Maximum chunks to process (for testing)
            progress_callback: Optional callback(current, total) for progress updates

        Returns:
            List of TranscriptionResults
        """
        if max_chunks:
            chunks = chunks[:max_chunks]

        results = []
        total = len(chunks)

        print(f"[Transcriber] Processing {total} chunks with {config.model}...")

        for i, chunk in enumerate(chunks):
            if progress_callback:
                progress_callback(i + 1, total)
            else:
                print(f"[Transcriber] Processing {i + 1}/{total}: {chunk.original_segment} "
                      f"(chunk {chunk.chunk_index + 1}/{chunk.total_chunks})")

            result = self.transcribe_chunk(chunk, config)
            results.append(result)

            # Brief pause to avoid rate limiting
            if i < total - 1:
                time.sleep(0.5)

        print(f"[Transcriber] Completed {len(results)} transcriptions")
        return results

def transcribe_segments(
    segments: List[AudioChunk],
    language: str = "Telugu",
    model: str = "gemini-3-flash-preview",
    thinking_level: str = "high",
    max_segments: Optional[int] = None
) -> List[TranscriptionResult]:
    """
    Convenience function to transcribe a list of audio segments.

    Args:


Output:
Chunk ID: 449216
Wall time: 0.0510 seconds
Process exited with code 0
Original token count: 2593
Output:
"""
Audio processing module for handling segment duration and chunking.

Splitting strategy: VAD-aware energy-based cutting.
When segments >max_duration_sec, finds the lowest-energy point in a window
around the target split time (8-13s) to avoid cutting mid-speech.
Sequential chunks track their original segment + cut points for later merging.
"""
import os
import math
import numpy as np
from pathlib import Path
from typing import List, Dict, Optional
from dataclasses import dataclass
from pydub import AudioSegment

@dataclass
class AudioChunk:
    """Represents an audio chunk ready for transcription."""
    file_path: str
    original_segment: str  # Original segment filename
    chunk_index: int       # Index within the segment (0 if not split)
    total_chunks: int      # Total chunks for this segment
    start_ms: int          # Start time in milliseconds within original
    end_ms: int            # End time in milliseconds within original
    duration_sec: float    # Duration in seconds

    @property
    def is_split(self) -> bool:
        return self.total_chunks > 1

class AudioProcessor:
    """Handles audio segment processing with duration controls."""

    def __init__(
        self,
        max_duration_sec: float = 10.0,
        min_duration_sec: float = 1.0,
        overlap_sec: float = 0.0,
        output_format: str = "flac"
    ):
        """
        Initialize the audio processor.

        Args:
            max_duration_sec: Maximum allowed segment duration (hard limit)
            min_duration_sec: Minimum segment duration to process
            overlap_sec: Overlap between chunks when splitting (helps with word boundaries)
            output_format: Output format for split chunks (flac, wav, mp3)
        """
        self.max_duration_sec = max_duration_sec
        self.min_duration_sec = min_duration_sec
        self.overlap_sec = overlap_sec
        self.output_format = output_format

    def get_audio_duration(self, file_path: str) -> float:
        """Get duration of an audio file in seconds."""
        audio = AudioSegment.from_file(file_path)
        return len(audio) / 1000.0

    def needs_splitting(self, file_path: str) -> bool:
        """Check if a file needs to be split based on duration."""
        duration = self.get_audio_duration(file_path)
        return duration > self.max_duration_sec

    def _find_energy_valley(self, samples: np.ndarray, sr: int,
                            target_ms: int, search_window_ms: int = 3000
                            ) -> int:
        """
        Find the lowest-energy point near target_ms for VAD-aware splitting.
        Searches +/- search_window_ms around target for a silence/low-energy valley.
        Returns the best cut point in milliseconds.
        """
        # RMS energy in 20ms windows
        frame_ms = 20
        frame_samples = int(sr * frame_ms / 1000)
        total_ms = int(len(samples) / sr * 1000)

        search_start = max(0, target_ms - search_window_ms)
        search_end = min(total_ms, target_ms + search_window_ms)

        best_energy = float('inf')
        best_ms = target_ms  # fallback to target if no valley found

        for t_ms in range(search_start, search_end, frame_ms):
            idx = int(t_ms * sr / 1000)
            frame = samples[idx:idx + frame_samples]
            if len(frame) < frame_samples // 2:
                continue
            rms = np.sqrt(np.mean(frame ** 2)) + 1e-10
            energy_db = 20 * np.log10(rms)
            if energy_db < best_energy:
                best_energy = energy_db
                best_ms = t_ms

        return best_ms

    def split_audio(
        self,
        file_path: str,
        output_dir: str,
        chunk_duration_sec: Optional[float] = None
    ) -> List[AudioChunk]:
        """
        Split audio using VAD-aware energy-based cutting.

        Instead of chopping at fixed intervals, finds the lowest-energy point
        in a window around the target split time to avoid cutting mid-speech.
        Target: 8-13s chunks. Searches +/- 3s around target for silence valleys.

        Sequential chunks track their original segment + cut points so they
        can be merged back later (transcripts appended, original audio retained).

        Args:
            file_path: Path to the audio file
            output_dir: Directory to save chunks
            chunk_duration_sec: Target chunk duration (defaults to max_duration_sec)

        Returns:
            List of AudioChunk objects with sequential chunk indices
        """
        if chunk_duration_sec is None:
            chunk_duration_sec = self.max_duration_sec

        audio = AudioSegment.from_file(file_path)
        total_duration_ms = len(audio)
        total_duration_sec = total_duration_ms / 1000.0

        os.makedirs(output_dir, exist_ok=True)
        original_name = Path(file_path).stem

        # If already short enough, return as single chunk
        if total_duration_sec <= self.max_duration_sec:
            return [AudioChunk(
                file_path=file_path,
                original_segment=Path(file_path).name,
                chunk_index=0,
                total_chunks=1,
                start_ms=0,
                end_ms=total_duration_ms,
                duration_sec=total_duration_sec
            )]

        # Convert to numpy for energy analysis
        samples = np.array(audio.get_array_of_samples(), dtype=np.float32)
        if audio.channels > 1:
            samples = samples.reshape(-1, audio.channels).mean(axis=1)
        samples = samples / (2**15)  # normalize int16 to float
        sr = audio.frame_rate

        # Find VAD-aware split points using energy valleys
        target_ms = int(chunk_duration_sec * 1000)
        split_points = [0]  # start of first chunk
        cursor = 0

        while cursor + target_ms < total_duration_ms:
            # Look for lowest energy near the target split point
            candidate = cursor + target_ms
            cut_ms = self._find_energy_valley(samples, sr, candidate)
            # Don't cut too short (min 5s) or too long (max 15s)
            chunk_len = cut_ms - cursor
            if chunk_len < 5000:
                cut_ms = cursor + 5000
            elif chunk_len > 15000:
                cut_ms = cursor + int(chunk_duration_sec * 1000)
            split_points.append(cut_ms)
            cursor = cut_ms

        split_points.append(total_duration_ms)  # end of last chunk

        # Build chunks from split points
        chunks = []
        num_chunks = len(split_points) - 1

        for i in range(num_chunks):
            start_ms = split_points[i]
            end_ms = split_points[i + 1]
            chunk_audio = audio[start_ms:end_ms]
            chunk_duration = len(chunk_audio) / 1000.0

            # Skip tiny trailing chunks
            if chunk_duration < self.min_duration_sec and i > 0:
                continue

            chunk_filename = f"{original_name}_chunk{i:03d}.{self.output_format}"
            chunk_path = os.path.join(output_dir, chunk_filename)
            chunk_audio.export(chunk_path, format=self.output_format)

            chunks.append(AudioChunk(
                file_path=chunk_path,
                original_segment=Path(file_path).name,
                chunk_index=i,
                total_chunks=num_chunks,
                start_ms=start_ms,
                end_ms=end_ms,
                duration_sec=chunk_duration
            ))

        return chunks

    def process_segments_directory(
        self,
        segments_dir: str,
        output_dir: Optional[str] = None,
        max_segments: Optional[int] = None,
        skip_short: bool = True
    ) -> List[AudioChunk]:
        """
        Process all segments in a directory, splitting as needed.

        Args:
            segments_dir: Directory containing audio segments
            output_dir: Directory for split chunks (default: segments_dir/chunks)
            max_segments: Maximum number of segments to process (for testing)
            skip_short: Skip segments shorter than min_duration_sec

        Returns:
            List of AudioChunk objects ready for transcription
        """
        if output_dir is None:
            output_dir = os.path.join(segments_dir, "chunks")

        # Find all audio files
        audio_extensions = {'.flac', '.wav', '.mp3', '.ogg', '.m4a'}
        segment_files = []

        for ext in audio_extensions:
            segment_files.extend(Path(segments_dir).glob(f"*{ext}"))

        # Sort by filename for consistent ordering
        segment_files = sorted(segment_files, key=lambda x: x.name)

        if max_segments:
            segment_files = segment_files[:max_segments]

        print(f"[Audio] Processing {len(segment_files)} segments...")

        all_chunks = []
        split_count = 0
        skipped_count = 0

        for segment_path in segment_files:
            duration = self.get_audio_duration(str(segment_path))

            # Skip very short segments
            if skip_short and duration < self.min_duration_sec:
                skipped_count += 1
                continue

            # Split if needed
            if duration > self.max_duration_sec:
                chunks = self.split_audio(str(segment_path), output_dir)
                split_count += 1
                print(f"[Audio] Split {segment_path.name}: {duration:.1f}s -> {len(chunks)} chunks")
            else:
                # Use original file
                chunks = [AudioChunk(
                    file_path=str(segment_path),
                    original_segment=segment_path.name,
                    chunk_index=0,
                    total_chunks=1,
                    start_ms=0,
                    end_ms=int(duration * 1000),
                    duration_sec=duration
                )]

            all_chunks.extend(chunks)

        print(f"[Audio] Processed {len(segment_files)} segments -> {len(all_chunks)} chunks")
        print(f"[Audio] Split: {split_count}, Skipped (too short): {skipped_count}")

        return all_chunks

def get_segment_stats(segments_dir: str) -> Dict:
    """
    Get statistics about segments in a directory.


# 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.


**Fetching pipeline continuation**


sed -n '1,320p' src/backend/transcription_schema.py


sed -n '1,260p' src/validators/simple_validator.py


Output:
Chunk ID: 400f16
Wall time: 0.0510 seconds
Process exited with code 0
Original token count: 3995
Output:
            segments_dir,
            max_segments=self.config.max_segments,
            skip_short=self.config.skip_short_segments
        )

        # Apply start index
        if self.config.segment_start_index > 0:
            chunks = chunks[self.config.segment_start_index:]
            self._log(f"Starting from index {self.config.segment_start_index}, "
                      f"{len(chunks)} chunks remaining")

        # === Step 3.5: Polish Audio + SNR Gate ===
        if self.config.polish_audio:
            self._log("Step 3.5: Polishing audio boundaries...")
            polisher = AudioPolisher()
            polished_dir = os.path.join(segments_dir, "polished")
            polished_count = 0
            snr_skipped = 0
            surviving_chunks = []
            for chunk in chunks:
                result = polisher.polish(chunk.file_path, output_dir=polished_dir)
                if result.was_modified:
                    chunk.file_path = result.output_path
                    polished_count += 1
                # SNR gate: skip segments with very low SNR (likely noise/music)
                if result.snr_db < self.config.min_snr_db:
                    snr_skipped += 1
                    continue
                # Duration gate: polishing might shrink segments below minimum
                if result.polished_duration_ms / 1000.0 < self.config.min_segment_duration_sec:
                    snr_skipped += 1  # reuse counter, both are pre-tx filters
                    continue
                surviving_chunks.append(chunk)
            self._log(f"Polished {polished_count}/{len(chunks)} chunks "
                      f"({len(chunks) - polished_count} already clean)")
            if snr_skipped > 0:
                self._log(f"SNR gate: skipped {snr_skipped} chunks "
                          f"(SNR < {self.config.min_snr_db}dB)")
            chunks = surviving_chunks

        # === Step 4: Transcribe ===
        self._log(f"Step 4: Transcribing {len(chunks)} chunks...")

        transcriber = GeminiTranscriber()
        transcription_config = TranscriptionConfig(
            model=self.config.model,
            thinking_level=self.config.thinking_level,
            temperature=self.config.temperature,
            language=language
        )

        # Process in batches
        for batch_start in range(0, len(chunks), self.config.batch_size):
            batch_end = min(batch_start + self.config.batch_size, len(chunks))
            batch = chunks[batch_start:batch_end]

            self._log(f"Processing batch {batch_start + 1}-{batch_end} of {len(chunks)}")

            batch_results = transcriber.transcribe_batch(
                batch,
                transcription_config,
                progress_callback=lambda c, t: self._log(
                    f"  Chunk {batch_start + c}/{len(chunks)}: "
                    f"{batch[c-1].original_segment}"
                )
            )

            # === Validate transcriptions (dual scoring: native CTC + romanized MMS) ===
            if self.config.validate_transcriptions:
                lang_code = self._get_language_code(language)
                batch_health = _BatchHealth()
                for i, result in enumerate(batch_results):
                    if result.native:
                        romanized = result.transcription.romanized or ""
                        validation = validate_transcription(
                            batch[i].file_path,
                            result.native,
                            romanized_text=romanized,
                            language=lang_code,
                        )
                        result.validation_status = validation.status
                        result.validation_score = validation.combined_score

                        # Retry with pro model if below accept threshold
                        if (validation.status in ("retry", "reject")
                                and self.config.validation_action == "retry"):
                            self._log(
                                f"  Retrying {result.segment_id} with "
                                f"{self.config.retry_model} "
                                f"(S={validation.combined_score:.2f})", "WARN"
                            )
                            retry_config = TranscriptionConfig(
                                model=self.config.retry_model,
                                thinking_level=self.config.retry_thinking_level,
                                temperature=self.config.temperature,
                                language=language,
                            )
                            retry_raw = transcriber.transcribe_audio(
                                batch[i].file_path, retry_config
                            )
                            if not retry_raw.get("error"):
                                # Re-validate the retry result
                                from src.backend.transcription_schema import (
                                    TranscriptionOutput, SpeakerMeta
                                )
                                speaker_data = retry_raw.get('speaker')
                                speaker = (SpeakerMeta(**speaker_data)
                                           if isinstance(speaker_data, dict)
                                           else None)
                                retry_output = TranscriptionOutput(
                                    transcription=retry_raw.get('transcription', ''),
                                    code_switch=retry_raw.get('code_switch', ''),
                                    romanized=retry_raw.get('romanized', ''),
                                    tagged=retry_raw.get('tagged', ''),
                                    speaker=speaker,
                                )
                                retry_roman = retry_output.romanized or ""
                                retry_val = validate_transcription(
                                    batch[i].file_path,
                                    retry_output.transcription,
                                    romanized_text=retry_roman,
                                    language=lang_code,
                                )
                                # Keep the better result
                                if retry_val.combined_score > validation.combined_score:
                                    result.transcription = retry_output
                                    result.model_used = retry_config.model
                                    result.thinking_level = retry_config.thinking_level
                                    validation = retry_val
                                    self._log(
                                        f"  Retry improved: "
                                        f"S={retry_val.combined_score:.2f} "
                                        f"({retry_val.status})"
                                    )
                                    batch_health.retry_improved += 1
                                else:
                                    self._log(
                                        f"  Retry did not improve: "
                                        f"S={retry_val.combined_score:.2f}"
                                    )
                            # Update final validation state
                            result.validation_status = validation.status
                            result.validation_score = validation.combined_score

                        if validation.status in ("reject", "retry"):
                            self._log(
                                f"  Validation: {result.segment_id} -> "
                                f"{validation.status} "
                                f"(S={validation.combined_score:.2f}, "
                                f"CTC={validation.native_ctc_score:.2f}, "
                                f"MMS={validation.roman_mms_score:.2f})",
                                "WARN"
                            )

                        # Record for batch health monitoring
                        batch_health.record(validation)

                # Batch health report + alerts
                alerts = batch_health.report(self._log)
                self._health_alerts.extend(alerts)

            self.results.extend(batch_results)

            # Save intermediate results
            if self.config.save_intermediate:
                self._save_results(partial=True)

        # === Step 5: Save Final Results ===
        self._log("Step 5: Saving results...")
        output_file = self._save_results(partial=False)

        # === Cleanup ===
        if self.config.validate_transcriptions:
            cleanup_validator()  # Release CTC model resources

        if self.config.cleanup_after:
            self._log("Cleaning up downloaded files...")
            import shutil
            video_work_dir = os.path.join(self.config.work_dir, self.config.video_id)
            if os.path.exists(video_work_dir):
                shutil.rmtree(video_work_dir)

        # === Build Result ===
        total_time = time.time() - self.start_time
        completed_at = datetime.now().isoformat()

        total_audio = sum(r.duration_sec for r in self.results)

        result = PipelineResult(
            video_id=self.config.video_id,
            language=language,
            model=self.config.model,
            thinking_level=self.config.thinking_level,
            total_segments_found=stats.get('total_files', 0),
            segments_processed=len(set(r.segment_id for r in self.results)),
            chunks_created=len(chunks),
            transcriptions_completed=len(self.results),
            total_audio_duration_sec=total_audio,
            total_processing_time_sec=total_time,
            output_file=output_file,
            errors=self.errors,
            started_at=started_at,
            completed_at=completed_at
        )

        self._log(f"Pipeline completed in {total_time:.1f}s")
        self._log(f"Transcribed {total_audio:.1f}s of audio")
        self._log(f"Output saved to: {output_file}")

        return result

    def _save_results(self, partial: bool = False) -> str:
        """Save results to JSON file."""
        os.makedirs(self.config.output_dir, exist_ok=True)

        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        suffix = "_partial" if partial else ""
        filename = f"{self.config.video_id}_{self.config.model.replace('-', '_')}_{timestamp}{suffix}.json"
        output_path = os.path.join(self.config.output_dir, filename)

        # Convert results to dict
        output_data = {
            "video_id": self.config.video_id,
            "model": self.config.model,
            "thinking_level": self.config.thinking_level,
            "language": self.results[0].language if self.results else self.config.default_language,
            "config": {
                "max_segment_duration_sec": self.config.max_segment_duration_sec,
                "min_segment_duration_sec": self.config.min_segment_duration_sec,
                "max_segments": self.config.max_segments,
            },
            "results_count": len(self.results),
            "total_duration_sec": sum(r.duration_sec for r in self.results),
            "results": [r.model_dump() for r in self.results]
        }

        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(output_data, f, ensure_ascii=False, indent=2)

        return output_path

def run_pipeline(
    video_id: str,
    language: Optional[str] = None,
    model: str = "gemini-3-flash-preview",
    thinking_level: str = "low",
    temperature: float = 0.0,
    max_segments: Optional[int] = None,
    max_duration_sec: float = 10.0,
    output_dir: str = "./transcriptions",
    validate: bool = True
) -> PipelineResult:
    """
    Convenience function to run the pipeline with common settings.

    Args:
        video_id: YouTube video ID
        language: Primary language (if None, fetched from Supabase)
        model: Gemini model to use
        thinking_level: Thinking level for Gemini 3 (default: low)
        temperature: Generation temperature (default: 0.0 deterministic)
        max_segments: Limit segments for testing
        max_duration_sec: Max segment duration (hard limit)
        output_dir: Directory for output files
        validate: Whether to validate transcriptions

    Returns:
        PipelineResult with summary
    """
    config = PipelineConfig(
        video_id=video_id,
        language=language,
        model=model,
        thinking_level=thinking_level,
        temperature=temperature,
        max_segments=max_segments,
        max_segment_duration_sec=max_duration_sec,
        output_dir=output_dir,
        validate_transcriptions=validate
    )

    pipeline = TranscriptionPipeline(config)
    return pipeline.run()

def test_models(
    video_id: str,
    language: str,
    models: List[str],
    thinking_levels: List[str] = ["high"],
    max_segments: int = 3
) -> Dict[str, PipelineResult]:
    """
    Test multiple models on the same video for comparison.

    Args:
        video_id: Video ID to test
        language: Primary language
        models: List of model names to test
        thinking_levels: Thinking levels to try
        max_segments: Segments per test

    Returns:
        Dict mapping model_thinking to PipelineResult
    """
    results = {}

    for model in models:
        for thinking in thinking_levels:
            key = f"{model}_{thinking}"
            print(f"\n{'='*60}")
            print(f"Testing: {key}")
            print(f"{'='*60}")

            try:
                result = run_pipeline(
                    video_id=video_id,
                    language=language,
                    model=model,
                    thinking_level=thinking,
                    max_segments=max_segments,
                    output_dir=f"./transcriptions/test_{video_id}"
                )
                results[key] = result
            except Exception as e:
                print(f"ERROR testing {key}: {e}")

    return results

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Transcription Pipeline")
    parser.add_argument("video_id", help="YouTube video ID")
    parser.add_argument("--language", "-l", help="Primary language (default: auto-detect)")
    parser.add_argument("--model", "-m", default="gemini-3-flash-preview",
                        help="Gemini model to use")
    parser.add_argument("--thinking", "-t", default="low",
                        choices=["minimal", "low", "medium", "high"],
                        help="Thinking level for Gemini 3 (default: low)")
    parser.add_argument("--temperature", type=float, default=0.0,
                        help="Generation temperature (default: 0.0 deterministic)")
    parser.add_argument("--no-validate", action="store_true",
                        help="Skip transcription validation")
    parser.add_argument("--max-segments", "-n", type=int,
                        help="Maximum segments to process")
    parser.add_argument("--max-duration", "-d", type=float, default=10.0,
                        help="Maximum segment duration in seconds")
    parser.add_argument("--output", "-o", default="./transcriptions",
                        help="Output directory")

    args = parser.parse_args()

    result = run_pipeline(
        video_id=args.video_id,
        language=args.language,
        model=args.model,
        thinking_level=args.thinking,
        temperature=args.temperature,


Output:
Chunk ID: 64ce5e
Wall time: 0.0507 seconds
Process exited with code 0
Original token count: 3529
Output:
"""
Pydantic schemas for structured transcription output.

Output fields (what Gemini returns per segment):
  1. transcription   - Native script with punctuation (PRIMARY, authoritative)
  2. code_switch     - Mixed script: native + English in Latin (derived from 1)
  3. romanized       - Full Latin transliteration (derived from 1)
  4. tagged          - Code-switch + audio event tags [laugh] etc. (derived from 2)
  5. speaker         - Metadata: emotion, style, pace, accent

Prompt v3 design (evolved from v1 original -> v2 strict -> v3 current):
  - Single master template, dynamic language pack injection for 12 languages
  - "Verbatim transcription system" persona (explicit NOT assistant negation)
  - Acoustic punctuation (prosody-based pauses, not grammar rules)
  - Anti-hallucination: [UNK] for uncertain words, no sentence completion
  - Field derivation ordering: transcription is authoritative, others derive from it
  - Transliterate != Translate distinction (fixed v2 contradiction that caused
    English speech to be translated into Telugu on segment 3 test)
  - Per-language script tips (1-2 practical tips, not grammar rules)
"""
from typing import Optional, Dict
from pydantic import BaseModel, Field

# === Language Pack Configs ===
# Per-language metadata + script-specific rules injected into the master prompt.
# Only rules that prevent ACTUAL transcription errors, not generic grammar.
# v1: 12 languages (11 Indic + English). Add more as needed.

LANGUAGE_CONFIGS: Dict[str, Dict[str, str]] = {
    # 1-2 practical script tips per language. Not grammar rules.
    "Hindi": {
        "bcp47": "hi-IN",
        "script": "Devanagari",
        "script_rules": "Preserve Nukta (ज़ vs ज, फ़ vs फ) when clearly pronounced.",
    },
    "Marathi": {
        "bcp47": "mr-IN",
        "script": "Devanagari",
        "script_rules": "Preserve ळ (retroflex lateral) accurately.",
    },
    "Telugu": {
        "bcp47": "te-IN",
        "script": "Telugu",
        "script_rules": "Don't over-split words. Preserve Sandhi/combined forms as spoken.",
    },
    "Tamil": {
        "bcp47": "ta-IN",
        "script": "Tamil",
        "script_rules": "Distinguish short and long vowels accurately.",
    },
    "Kannada": {
        "bcp47": "kn-IN",
        "script": "Kannada",
        "script_rules": "Preserve agglutinated/combined forms as spoken.",
    },
    "Malayalam": {
        "bcp47": "ml-IN",
        "script": "Malayalam",
        "script_rules": "Don't split agglutinated words. Preserve chillu letters.",
    },
    "Gujarati": {
        "bcp47": "gu-IN",
        "script": "Gujarati",
        "script_rules": "",
    },
    "Punjabi": {
        "bcp47": "pa-IN",
        "script": "Gurmukhi",
        "script_rules": "",
    },
    "Bengali": {
        "bcp47": "bn-IN",
        "script": "Bengali",
        "script_rules": "Preserve Chandrabindu for nasalization where spoken.",
    },
    "Assamese": {
        "bcp47": "as-IN",
        "script": "Assamese",
        # Critical: these chars look like Bengali but are Assamese-specific.
        "script_rules": "Use Assamese-specific ৰ (ra) and ৱ (wa), NOT Bengali equivalents.",
    },
    "Odia": {
        "bcp47": "or-IN",
        "script": "Odia",
        "script_rules": "",
    },
    "English": {
        "bcp47": "en-IN",
        "script": "Latin",
        "script_rules": "Standard English spelling. Don't phonetically approximate accents.",
    },
}

class SpeakerMeta(BaseModel):
    """Speaker metadata for TTS training. Only what matters."""
    emotion: str = Field(
        default="neutral",
        description="neutral, happy, sad, angry, excited, surprised"
    )
    speaking_style: str = Field(
        default="conversational",
        description="conversational, narrative, excited, calm, emphatic, sarcastic, formal"
    )
    pace: str = Field(
        default="normal",
        description="slow, normal, fast"
    )
    accent: str = Field(
        default="",
        description="Regional accent/dialect if detectable, empty string if unknown"
    )

class TranscriptionOutput(BaseModel):
    """Structured output: 4 transcription formats + speaker metadata."""

    transcription: str = Field(
        description="Native script transcription with minimal punctuation. "
                    "English words transliterated phonetically in native script."
    )

    code_switch: str = Field(
        description="Mixed script: native language in native script, "
                    "English words in Latin script. Minimal punctuation."
    )

    romanized: str = Field(
        description="Full Roman/Latin script transliteration as pronounced."
    )

    tagged: str = Field(
        description="Code-switch transcription with audio event tags "
                    "([laugh], [cough], [sigh], [breath], [singing], "
                    "[noise], [music], [applause]) at positions where they occur."
    )

    speaker: Optional[SpeakerMeta] = Field(
        default=None,
        description="Speaker metadata: emotion, style, pace, accent."
    )

    detected_language: str = Field(
        default="",
        description="The language actually spoken in the audio as detected by the model. "
                    "Helps catch language mismatches vs pipeline-provided language."
    )

class TranscriptionResult(BaseModel):
    """Complete result for a transcribed segment including metadata."""

    segment_id: str = Field(description="Identifier for the audio segment")
    chunk_index: int = Field(default=0, description="Chunk index if segment was split")
    total_chunks: int = Field(default=1, description="Total chunks for this segment")
    duration_sec: float = Field(description="Duration of audio in seconds")
    language: str = Field(description="Primary language of the audio")

    transcription: TranscriptionOutput = Field(description="The transcription outputs")

    model_used: str = Field(description="Gemini model used for transcription")
    thinking_level: Optional[str] = Field(default=None, description="Thinking level used")
    processing_time_sec: Optional[float] = Field(default=None, description="API call time")

    # Validation fields (filled by CTC validator, not Gemini)
    validation_status: Optional[str] = Field(default=None, description="accept/review/reject")
    validation_score: Optional[float] = Field(default=None, description="Alignment score 0-1")

    @property
    def native(self) -> str:
        """Shortcut to primary native transcription."""
        return self.transcription.transcription

# === Prompt v3 ===
# Master template: one prompt for all 12 languages, with dynamic language pack.
# Evolution: v1 (original) -> v2 (strict, fixed segment 3 translation) -> v3 (below)
# v3 changes: translate/transliterate fix, field derivation ordering, [UNK] tokens,
#   prosody-based punctuation, simplified language script tips, no-preamble constraint,
#   "not assistant" negation, additionalProperties in schema.

def get_transcription_prompt(language: str) -> str:
    """
    System instruction for Gemini transcription.

    Uses master template with dynamic language pack injection.
    Key design: "transcription" field is authoritative, all others derive from it.
    """
    # Language pack (fallback to generic if unknown language)
    lang_config = LANGUAGE_CONFIGS.get(language, {})
    script_name = lang_config.get("script", f"{language} native")
    bcp47 = lang_config.get("bcp47", "")
    script_rules = lang_config.get("script_rules", "")

    # Build optional script rules section
    script_section = ""
    if script_rules:
        script_section = (
            f"\nSCRIPT RULES FOR {language.upper()}:\n{script_rules}\n"
        )

    # English has different field logic (no transliteration needed)
    if language.lower() == "english":
        native_field_rule = (
            "Write in standard English spelling."
        )
        code_switch_rule = (
            "Same as transcription. If the speaker uses words from another "
            "language, write them in Latin script as heard."
        )
    else:
        # The transliterate vs translate distinction is critical here.
        # v2 prompt had a contradiction: "don't convert English to {language}"
        # + "write English phonetically in {language} script". The word "convert"
        # was ambiguous and caused segment 3 to be translated instead of transcribed.
        native_field_rule = (
            f"Write ONLY in {script_name} script. No Latin characters in this field.\n"
            f"   When the speaker uses English words, TRANSLITERATE them phonetically "
            f"into {script_name} (sound mapping, NOT meaning translation).\n"
            f"   Example: spoken 'computer' → phonetic form in {script_name}, "
            f"NOT the {language} word for computer."
        )
        code_switch_rule = (
            f"Preserve language switching exactly as spoken.\n"
            f"   {language} words stay in {script_name} script. "
            f"English words stay in Latin script.\n"
            f"   Do NOT translate in either direction."
        )

    lang_label = f"{language} ({bcp47})" if bcp47 else language

    return f"""You are a verbatim speech-to-text transcription system. You are NOT a conversational assistant. Your output must precisely match the audio content.

TARGET: {lang_label}

CRITICAL RULES (violations cause rejection):
1. NEVER TRANSLATE. This is transcription, not translation. If the speaker says English words, those are English. If the speaker says {language} words, those are {language}.
2. VERBATIM FIDELITY: Every repetition, filler, stammer, false start, hesitation - exactly as spoken.
3. NO CORRECTION: Do not fix grammar, pronunciation, dialect, or word choice.
4. NO HALLUCINATION: Never add words or phrases not in the audio. If audio cuts off mid-sentence, STOP where the audio stops. Do not complete anything. Do not add conversational preamble like "Sure, here is..." - output ONLY the JSON.
5. UNCERTAINTY: If a word is unclear, write [UNK]. Use [INAUDIBLE] when speech exists but is unintelligible. Use [NO_SPEECH] when the segment contains no speech at all (silence, noise, music only).
6. BOUNDARY HANDLING: Audio is VAD-cut and may start/end mid-speech. Transcribe everything you can confidently hear, including speech at boundaries. Only omit what is truly inaudible.
7. LANGUAGE MISMATCH: The target language is {language}, but trust what you hear. If the audio is clearly in a different language, transcribe in that language's script and set detected_language accordingly. If code-mixed, use the dominant language for the native script fields.

PUNCTUATION (prosody-based, not grammar):
- Only: comma, period, ? and !
- Insert from audible pauses/intonation only. No pause = no punctuation.
{script_section}
FIELD DERIVATION:
"transcription" is the PRIMARY output. code_switch and romanized must contain the same words in the same order, differing only in script. "tagged" is code_switch with audio event markers inserted at their positions. Do not re-interpret the audio independently for each field.

OUTPUT FIELDS:

1. transcription (AUTHORITATIVE - native script)
   {native_field_rule}
   Punctuation: period, comma, ? and ! only, based on audible prosodic cues.

2. code_switch (derived from transcription - mixed script)
   {code_switch_rule}

3. romanized (derived from transcription - Latin script)
   Full Roman/Latin transliteration of the entire audio as pronounced.
   Preserve pronunciation faithfully even if spelling looks non-standard.

4. tagged (derived from code_switch - mixed script + event tags)
   Same text as code_switch with audio event tags at positions where they occur.
   ONLY these tags, ONLY if clearly and prominently audible (never hallucinate):
   [laugh] [cough] [sigh] [breath] [singing] [noise] [music] [applause]

5. speaker (metadata from audio prosody)
   emotion: neutral | happy | sad | angry | excited | surprised
   speaking_style: conversational | narrative | excited | calm | emphatic | sarcastic | formal
   pace: slow | normal | fast
   accent: regional dialect/accent if confidently detectable, empty string otherwise. Use defaults if unsure.

6. detected_language
   The language you actually hear spoken in the audio. If the audio is in {language}, write "{language}". If the audio is actually in a different language, write that language instead. If code-mixed (e.g. Telugu + English), write the dominant language. This helps catch pipeline language mismatches."""

def get_user_prompt() -> str:
    """User prompt to accompany the audio."""
    return ("Transcribe this audio segment following the system instructions. "
            "Return a valid JSON object with all required fields.")

# === JSON Schema for Gemini structured output ===
# Uses response_json_schema (JSON Schema path), NOT response_schema (OpenAPI path).
# additionalProperties: False is supported on the JSON Schema path.
# Enums mechanically lock speaker values at the API level (not just prompt-level).
# Gemini structured output enforces schema shape but NOT semantic correctness -
# downstream CTC validation still required.

TRANSCRIPTION_JSON_SCHEMA = {
    "type": "object",
    "properties": {
        "transcription": {
            "type": "string",
            "description": "Native script transcription with minimal punctuation"
        },
        "code_switch": {
            "type": "string",
            "description": "Mixed script preserving language switching"
        },
        "romanized": {
            "type": "string",
            "description": "Complete Roman script transliteration"
        },
        "tagged": {
            "type": "string",
            "description": "Code-switch with audio event tags"
        },
        "speaker": {
            "type": "object",
            "description": "Speaker metadata",
            "properties": {
                "emotion": {
                    "type": "string",
                    "enum": [
                        "neutral", "happy", "sad",


Output:
Chunk ID: 3e3cf2
Wall time: 0.0516 seconds
Process exited with code 0
Original token count: 1980
Output:
"""
Simple Transcription Validator
==============================

Three checks:
1. Character validation - catch garbage/alien characters
2. Native CTC alignment - verify native script matches audio
3. Romanized MMS alignment - verify romanized text matches audio

Combined scoring: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
  N = native CTC score, R = romanized MMS score
  Accept if S >= 0.70 and min(N,R) >= 0.40
  Forced review if abs(N-R) > 0.25 (high validator disagreement)

Usage:
    from src.validators.simple_validator import validate_transcription

    result = validate_transcription(
        "audio.flac", "నాకు కొన్ని యాడ్స్",
        romanized_text="naaku konni ads", language="te"
    )
    print(result.status)          # accept / review / retry / reject
    print(result.combined_score)  # 0-1
"""
import re
from dataclasses import dataclass
from typing import List, Dict, Optional, Set

# Unicode ranges for Indic scripts (all 12 supported languages)
SCRIPT_RANGES = {
    "te": {  # Telugu
        "name": "Telugu",
        "ranges": [(0x0C00, 0x0C7F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "hi": {  # Hindi (Devanagari)
        "name": "Hindi",
        "ranges": [(0x0900, 0x097F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "mr": {  # Marathi (Devanagari, same block as Hindi)
        "name": "Marathi",
        "ranges": [(0x0900, 0x097F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "ta": {  # Tamil
        "name": "Tamil",
        "ranges": [(0x0B80, 0x0BFF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "kn": {  # Kannada
        "name": "Kannada",
        "ranges": [(0x0C80, 0x0CFF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "ml": {  # Malayalam
        "name": "Malayalam",
        "ranges": [(0x0D00, 0x0D7F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "bn": {  # Bengali
        "name": "Bengali",
        "ranges": [(0x0980, 0x09FF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "as": {  # Assamese (same Unicode block as Bengali, slight char differences)
        "name": "Assamese",
        "ranges": [(0x0980, 0x09FF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "gu": {  # Gujarati
        "name": "Gujarati",
        "ranges": [(0x0A80, 0x0AFF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "pa": {  # Punjabi (Gurmukhi)
        "name": "Punjabi",
        "ranges": [(0x0A00, 0x0A7F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "or": {  # Odia
        "name": "Odia",
        "ranges": [(0x0B00, 0x0B7F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "en": {  # English (Latin)
        "name": "English",
        "ranges": [(0x0041, 0x005A), (0x0061, 0x007A)],  # A-Z, a-z
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
}

# Common allowed characters across all languages
ALLOWED_COMMON = set(" \t\n.,!?;:'\"()-–—0123456789")

# English characters (for code-mixed text)
ENGLISH_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

def get_valid_chars(language: str, allow_english: bool = True) -> Set[int]:
    """Get set of valid Unicode codepoints for a language."""
    valid = set()

    # Add common chars
    for c in ALLOWED_COMMON:
        valid.add(ord(c))

    # Add script-specific chars
    lang_config = SCRIPT_RANGES.get(language, SCRIPT_RANGES["te"])
    for start, end in lang_config["ranges"]:
        for cp in range(start, end + 1):
            valid.add(cp)

    # Allow English for code-mixed
    if allow_english:
        for c in ENGLISH_CHARS:
            valid.add(ord(c))

    return valid

def check_characters(
    text: str,
    language: str = "te",
    allow_english: bool = False  # Changed: native = no English
) -> Dict:
    """
    Check if text contains only valid characters for the language.

    For native transcription: allow_english=False (default)

    Returns:
        {
            "valid": True/False,
            "invalid_chars": [...],
            "script_ratio": 0.8,  # ratio of native script chars
        }
    """
    if not text or not text.strip():
        return {"valid": False, "invalid_chars": [], "script_ratio": 0, "reason": "empty"}

    valid_chars = get_valid_chars(language, allow_english)
    lang_config = SCRIPT_RANGES.get(language, SCRIPT_RANGES["te"])

    invalid_chars = []
    script_count = 0
    total_alpha = 0

    for i, char in enumerate(text):
        cp = ord(char)

        # Check if in native script
        is_native = any(start <= cp <= end for start, end in lang_config["ranges"])
        if is_native:
            script_count += 1
            total_alpha += 1

        # Check if invalid (includes English if not allowed)
        elif cp not in valid_chars:
            invalid_chars.append({
                "char": char,
                "codepoint": cp,
                "position": i
            })
            if char in ENGLISH_CHARS:
                total_alpha += 1

    return {
        "valid": len(invalid_chars) == 0,
        "invalid_chars": invalid_chars[:10],  # First 10 only
        "invalid_count": len(invalid_chars),
        "script_ratio": script_count / total_alpha if total_alpha > 0 else 0,
    }

@dataclass
class ValidationResult:
    """Result of transcription validation with dual scoring."""
    status: str  # "accept", "review", "retry", "reject"

    # Character check
    char_valid: bool = True
    invalid_chars: List[Dict] = None
    script_ratio: float = 0.0

    # Native CTC alignment (language-specific wav2vec2)
    alignment_score: float = 0.0       # kept for backward compat
    native_ctc_score: float = 0.0
    low_confidence_words: List[str] = None
    low_confidence_ratio: float = 0.0

    # Romanized MMS alignment (language-agnostic torchaudio MMS_FA)
    roman_mms_score: float = 0.0

    # Combined weighted score: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
    combined_score: float = 0.0

    # Reasons
    reasons: List[str] = None

    def to_dict(self) -> Dict:
        return {
            "status": self.status,
            "char_valid": self.char_valid,
            "invalid_chars": self.invalid_chars or [],
            "script_ratio": round(self.script_ratio, 3),
            "native_ctc_score": round(self.native_ctc_score, 4),
            "roman_mms_score": round(self.roman_mms_score, 4),
            "combined_score": round(self.combined_score, 4),
            "alignment_score": round(self.alignment_score, 4),
            "low_confidence_words": self.low_confidence_words or [],
            "low_confidence_ratio": round(self.low_confidence_ratio, 3),
            "reasons": self.reasons or []
        }

# Global aligners (lazy loaded)
_ctc_aligner = None
_mms_aligner = None

def _get_ctc_aligner(language: str):
    """Lazy load native CTC aligner."""
    global _ctc_aligner
    if _ctc_aligner is None:
        from .ctc_forced_aligner import CTCForcedAligner
        _ctc_aligner = CTCForcedAligner(language=language)
    return _ctc_aligner

def _get_mms_aligner():
    """Lazy load romanized MMS aligner."""
    global _mms_aligner
    if _mms_aligner is None:
        from .mms_aligner import MMSAligner
        _mms_aligner = MMSAligner()
    return _mms_aligner

def compute_combined_score(
    native_ctc: float,
    roman_mms: float,
) -> tuple:
    """
    Compute weighted validation score with disagreement penalty.

    Formula: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
    MMS weighted higher (0.55): more stable on code-mixed Indic audio.
    Disagreement penalty: flags when validators diverge (suspicious).


Summary: auto


sed -n '260,620p' src/validators/simple_validator.py


Output:
Chunk ID: 4bb919
Wall time: 0.0512 seconds
Process exited with code 0
Original token count: 2347
Output:
    Disagreement penalty: flags when validators diverge (suspicious).

    Returns:
        (combined_score, verdict)
        verdict: "accept" / "review" / "retry" / "reject"
    """
    N, R = native_ctc, roman_mms
    S = 0.45 * N + 0.55 * R - 0.10 * abs(N - R)

    if S >= 0.70 and min(N, R) >= 0.40:
        if abs(N - R) > 0.25:
            # High disagreement — flag even though score passes
            verdict = "review"
        else:
            verdict = "accept"
    elif S >= 0.55:
        verdict = "retry"
    else:
        verdict = "reject"

    return round(S, 4), verdict

def _strip_punctuation(text: str) -> str:
    """Strip punctuation for CTC alignment. Keeps native script + spaces."""
    import re
    # Remove common punctuation marks (keep native script chars, digits, spaces)
    return re.sub(r'[।,.!?;:\'"()\-–—]', '', text).strip()

def validate_transcription(
    audio_path: str,
    transcription: str,
    language: str = "te",
    romanized_text: str = "",
    check_audio: bool = True,
) -> ValidationResult:
    """
    Validate a transcription using dual scoring (native CTC + romanized MMS).

    Runs:
    1. Character validation (instant) on native script
    2. Native CTC forced alignment (language-specific wav2vec2)
    3. Romanized MMS forced alignment (language-agnostic, if romanized_text provided)
    4. Weighted combined score: S = 0.45*N + 0.55*R - 0.10*abs(N-R)

    Args:
        audio_path: Path to audio file
        transcription: Native script text (may include punctuation)
        language: Language code (te, hi, ta, etc.)
        romanized_text: Latin-script romanization (for MMS alignment)
        check_audio: Whether to run audio alignment checks (slower)

    Returns:
        ValidationResult with dual scores and combined verdict
    """
    reasons = []

    # Strip punctuation for validation
    clean_text = _strip_punctuation(transcription)
    clean_roman = _strip_punctuation(romanized_text) if romanized_text else ""

    # === STEP 1: Character validation (instant) ===
    # For English language, allow English chars in native check
    allow_eng = language == "en"
    char_check = check_characters(clean_text, language, allow_english=allow_eng)

    if not char_check["valid"]:
        return ValidationResult(
            status="reject",
            char_valid=False,
            invalid_chars=char_check["invalid_chars"],
            script_ratio=char_check["script_ratio"],
            reasons=["Invalid/alien characters found"]
        )

    if char_check["script_ratio"] < 0.5:
        reasons.append(f"Too few native chars ({char_check['script_ratio']:.0%})")

    # === STEP 2: Native CTC alignment ===
    native_ctc_score = 0.0
    low_conf_words = []
    low_conf_ratio = 0.0

    if check_audio:
        try:
            aligner = _get_ctc_aligner(language)
            ctc_result = aligner.align(audio_path, clean_text)
            native_ctc_score = ctc_result.alignment_score
            low_conf_words = ctc_result.low_confidence_words
            low_conf_ratio = ctc_result.low_confidence_ratio
        except Exception as e:
            reasons.append(f"CTC alignment failed: {str(e)[:50]}")

    # === STEP 3: Romanized MMS alignment ===
    roman_mms_score = 0.0

    if check_audio and clean_roman:
        try:
            mms = _get_mms_aligner()
            mms_result = mms.align(audio_path, clean_roman)
            roman_mms_score = mms_result.alignment_score
        except Exception as e:
            reasons.append(f"MMS alignment failed: {str(e)[:50]}")

    # === STEP 4: Combined scoring ===
    if check_audio and native_ctc_score > 0 and roman_mms_score > 0:
        # Both validators returned scores - use weighted formula
        combined_score, verdict = compute_combined_score(
            native_ctc_score, roman_mms_score
        )
        if verdict == "review" and abs(native_ctc_score - roman_mms_score) > 0.25:
            reasons.append(
                f"Validator disagreement: CTC={native_ctc_score:.2f} "
                f"MMS={roman_mms_score:.2f}"
            )
    elif check_audio and native_ctc_score > 0:
        # CTC only (MMS failed or no romanized text)
        combined_score = native_ctc_score
        if native_ctc_score >= 0.70:
            verdict = "accept"
        elif native_ctc_score >= 0.50:
            verdict = "retry"
        else:
            verdict = "reject"
        if roman_mms_score == 0 and clean_roman:
            reasons.append("MMS failed, using CTC only")
    elif check_audio and roman_mms_score > 0:
        # MMS only (CTC failed)
        combined_score = roman_mms_score
        if roman_mms_score >= 0.70:
            verdict = "accept"
        elif roman_mms_score >= 0.50:
            verdict = "retry"
        else:
            verdict = "reject"
        reasons.append("CTC failed, using MMS only")
    elif check_audio:
        # Both validators returned 0
        combined_score = 0.0
        verdict = "review"
        reasons.append("Both validators returned 0")
    else:
        # No audio check requested
        combined_score = 0.0
        verdict = "review" if reasons else "accept"

    # Character validation failures override
    if not char_check["valid"]:
        verdict = "reject"

    # Append score-based reasons
    if verdict == "retry":
        reasons.append(
            f"Below threshold: combined={combined_score:.2f} "
            f"(CTC={native_ctc_score:.2f}, MMS={roman_mms_score:.2f})"
        )
    elif verdict == "reject" and combined_score > 0:
        reasons.append(
            f"Poor alignment: combined={combined_score:.2f} "
            f"(CTC={native_ctc_score:.2f}, MMS={roman_mms_score:.2f})"
        )

    return ValidationResult(
        status=verdict,
        char_valid=char_check["valid"],
        invalid_chars=char_check.get("invalid_chars"),
        script_ratio=char_check["script_ratio"],
        alignment_score=native_ctc_score,  # backward compat
        native_ctc_score=native_ctc_score,
        roman_mms_score=roman_mms_score,
        combined_score=combined_score,
        low_confidence_words=low_conf_words,
        low_confidence_ratio=low_conf_ratio,
        reasons=reasons if reasons else None,
    )

def cleanup():
    """Release all aligner resources (CTC + MMS)."""
    global _ctc_aligner, _mms_aligner
    if _ctc_aligner is not None:
        _ctc_aligner.cleanup()
        _ctc_aligner = None
    if _mms_aligner is not None:
        _mms_aligner.cleanup()
        _mms_aligner = None

# === Quick validation (no audio check) ===
def quick_validate(transcription: str, language: str = "te") -> Dict:
    """
    Quick character-only validation for NATIVE transcription.

    Checks:
    - Not empty
    - All characters in target script (no English/garbage)
    - Sufficient native script content

    Args:
        transcription: Native script text to validate
        language: Language code (te, hi, ta, etc.)

    Returns:
        {"valid": True/False, "reason": "..."}
    """
    if not transcription or not transcription.strip():
        return {"valid": False, "reason": "Empty transcription"}

    char_check = check_characters(transcription, language, allow_english=False)

    if not char_check["valid"]:
        invalid = [c['char'] for c in char_check['invalid_chars'][:5]]
        return {
            "valid": False,
            "reason": f"Invalid chars: {invalid}"
        }

    if char_check["script_ratio"] < 0.5:
        return {
            "valid": False,
            "reason": f"Too few native chars ({char_check['script_ratio']:.0%})"
        }

    return {"valid": True, "script_ratio": char_check["script_ratio"]}

if __name__ == "__main__":
    import sys

    if len(sys.argv) < 3:
        print("""
Simple Validator
================

Usage:
    # Full validation (with audio check)
    python simple_validator.py <audio_path> <transcription>

    # Quick validation (character check only)
    python simple_validator.py --quick <transcription>

Examples:
    python simple_validator.py audio.flac "నాకు కొన్ని యాడ్స్ గుర్తుంటాయి"
    python simple_validator.py --quick "నాకు కొన్ని యాడ్స్"
""")
        sys.exit(1)

    if sys.argv[1] == "--quick":
        text = " ".join(sys.argv[2:])
        result = quick_validate(text)
        print(f"Valid: {result['valid']}")
        if not result['valid']:
            print(f"Reason: {result['reason']}")
    else:
        audio_path = sys.argv[1]
        transcription = " ".join(sys.argv[2:])

        print("Validating...")
        result = validate_transcription(audio_path, transcription)
        cleanup()

        print(f"\nStatus: {result.status.upper()}")
        print(f"Character valid: {result.char_valid}")
        print(f"Script ratio: {result.script_ratio:.1%}")
        print(f"Alignment score: {result.alignment_score:.4f}")
        print(f"Low conf words: {result.low_confidence_ratio:.1%}")

        if result.reasons:
            print(f"\nReasons for review:")
            for r in result.reasons:
                print(f"  - {r}")

        if result.low_confidence_words:
            print(f"\nLow confidence words: {result.low_confidence_words[:5]}")


**Inspecting deterministic behavior**


sed -n '1,340p' src/validators/mms_aligner.py


sed -n '1,280p' src/validators/ctc_forced_aligner.py


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


Output:
Chunk ID: 91c357
Wall time: 0.0519 seconds
Process exited with code 0
Original token count: 1863
Output:
"""
MMS Forced Aligner (Romanized Text)
====================================

Language-agnostic forced alignment using Meta's MMS_FA model via torchaudio.
Works on romanized/Latin-script text. Complements the native CTC aligner.

MMS_FA is trained on 1000+ languages — handles romanized Indic + English equally well.
Used alongside native CTC to produce dual validation scores.

Usage:
    aligner = MMSAligner()
    result = aligner.align("audio.flac", "naaku konni ads gurtuntaayi")
    print(f"Score: {result.alignment_score}")  # 0-1
"""
import torch
import torchaudio
import soundfile as sf
import numpy as np
import time
from dataclasses import dataclass, field
from typing import List, Dict, Optional

@dataclass
class MMSAlignmentResult:
    """Result of MMS forced alignment on romanized text."""
    audio_path: str
    transcription: str

    alignment_score: float  # 0-1, overall quality
    mean_log_prob: float    # Raw mean log prob

    num_tokens: int = 0
    num_frames: int = 0
    audio_duration_sec: float = 0.0
    processing_time_sec: float = 0.0

    def to_dict(self) -> Dict:
        return {
            "audio_path": self.audio_path,
            "transcription": self.transcription[:100],
            "alignment_score": round(self.alignment_score, 4),
            "mean_log_prob": round(self.mean_log_prob, 4),
            "num_tokens": self.num_tokens,
            "num_frames": self.num_frames,
            "audio_duration_sec": round(self.audio_duration_sec, 3),
            "processing_time_sec": round(self.processing_time_sec, 3),
        }

class MMSAligner:
    """
    Language-agnostic forced alignment using torchaudio MMS_FA.

    Tokenization: space → * (word boundary, idx 28), CTC blank = - (idx 0).
    Score normalization: log_prob range ~[-10, 0] mapped to [0, 1].
    """

    def __init__(self, device: str = "auto"):
        if device == "auto":
            self.device = "cuda" if torch.cuda.is_available() else "cpu"
        else:
            self.device = device

        self.model = None
        self.labels = None
        self.label2idx = None
        self.sample_rate = None
        self._setup_complete = False

        # Token indices (set during setup)
        self.BLANK_IDX = 0   # '-' = CTC blank
        self.STAR_IDX = 28   # '*' = word boundary

    def setup(self) -> bool:
        """Lazy load MMS_FA model."""
        if self._setup_complete:
            return True

        try:
            bundle = torchaudio.pipelines.MMS_FA
            self.model = bundle.get_model().to(self.device)
            self.model.eval()
            self.labels = bundle.get_labels()
            self.sample_rate = bundle.sample_rate  # 16000
            self.label2idx = {l: i for i, l in enumerate(self.labels)}
            self.BLANK_IDX = self.label2idx.get("-", 0)
            self.STAR_IDX = self.label2idx.get("*", 28)

            print(f"[MMSAligner] Ready on {self.device} "
                  f"({len(self.labels)} labels, SR={self.sample_rate})")
            self._setup_complete = True
            return True

        except Exception as e:
            print(f"[MMSAligner] Setup failed: {e}")
            return False

    def _load_audio(self, audio_path: str) -> torch.Tensor:
        """Load and resample audio to MMS sample rate (16kHz)."""
        data, sr = sf.read(audio_path)
        if len(data.shape) > 1:
            data = data.mean(axis=1)
        waveform = torch.tensor(data, dtype=torch.float32).unsqueeze(0)
        if sr != self.sample_rate:
            waveform = torchaudio.functional.resample(
                waveform, sr, self.sample_rate
            )
        return waveform

    def _text_to_tokens(self, text: str) -> List[int]:
        """
        Convert romanized text to MMS token indices.
        Space → * (word boundary). Unknown chars skipped.
        Punctuation stripped.
        """
        tokens = []
        for ch in text.lower().strip():
            if ch == " ":
                # Word boundary - don't duplicate
                if tokens and tokens[-1] != self.STAR_IDX:
                    tokens.append(self.STAR_IDX)
            elif ch in self.label2idx and self.label2idx[ch] != self.BLANK_IDX:
                tokens.append(self.label2idx[ch])
            # Punctuation and unknown chars silently skipped
        # Strip leading/trailing boundaries
        while tokens and tokens[0] == self.STAR_IDX:
            tokens.pop(0)
        while tokens and tokens[-1] == self.STAR_IDX:
            tokens.pop()
        return tokens

    def align(self, audio_path: str, romanized_text: str) -> MMSAlignmentResult:
        """
        Perform forced alignment of romanized text against audio.

        Args:
            audio_path: Path to audio file
            romanized_text: Latin-script text to align

        Returns:
            MMSAlignmentResult with alignment score
        """
        if not self._setup_complete:
            if not self.setup():
                return MMSAlignmentResult(
                    audio_path=audio_path,
                    transcription=romanized_text,
                    alignment_score=0.0,
                    mean_log_prob=-10.0,
                )

        start_time = time.time()

        try:
            waveform = self._load_audio(audio_path)
            audio_dur = waveform.shape[1] / self.sample_rate
            tokens = self._text_to_tokens(romanized_text)

            if not tokens:
                return MMSAlignmentResult(
                    audio_path=audio_path,
                    transcription=romanized_text,
                    alignment_score=0.0,
                    mean_log_prob=-10.0,
                    audio_duration_sec=audio_dur,
                    processing_time_sec=time.time() - start_time,
                )

            with torch.no_grad():
                emission, _ = self.model(waveform.to(self.device))
            emission = emission.cpu()

            token_tensor = torch.tensor([tokens], dtype=torch.int32)

            _, scores = torchaudio.functional.forced_align(
                emission, token_tensor, blank=self.BLANK_IDX
            )

            mean_lp = scores.mean().item()
            # Normalize: log probs range ~[-10, 0]. Map to [0, 1].
            # Same formula as CTC aligner for consistency.
            alignment_score = max(0.0, min(1.0, 1.0 + mean_lp / 5.0))

            return MMSAlignmentResult(
                audio_path=audio_path,
                transcription=romanized_text,
                alignment_score=alignment_score,
                mean_log_prob=mean_lp,
                num_tokens=len(tokens),
                num_frames=emission.shape[1],
                audio_duration_sec=audio_dur,
                processing_time_sec=time.time() - start_time,
            )

        except Exception as e:
            import traceback
            traceback.print_exc()
            return MMSAlignmentResult(
                audio_path=audio_path,
                transcription=romanized_text,
                alignment_score=0.0,
                mean_log_prob=-10.0,
                processing_time_sec=time.time() - start_time,
            )

    def cleanup(self):
        """Release resources."""
        if self.model is not None:
            del self.model
            self.model = None
        self._setup_complete = False
        if torch.cuda.is_available():
            torch.cuda.empty_cache()


Output:
Chunk ID: a8dcab
Wall time: 0.0505 seconds
Process exited with code 0
Original token count: 2501
Output:
"""
CTC Forced Aligner
==================

Proper forced alignment using torchaudio's CTC forced_align.
This is equivalent to MFA but using neural CTC instead of GMM-HMM.

Provides:
- Per-character alignment scores (log-prob)
- Per-word alignment scores
- Overall alignment quality score
- Identifies low-confidence regions

Usage:
    aligner = CTCForcedAligner(language="te")
    result = aligner.align("audio.flac", "transcription text")

    print(f"Alignment Score: {result.alignment_score}")  # 0-1
    print(f"Low confidence words: {result.low_confidence_words}")
"""
import torch
import torchaudio
import numpy as np
import soundfile as sf
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from pathlib import Path
import time

@dataclass
class WordAlignment:
    """Alignment info for a single word."""
    word: str
    start_frame: int
    end_frame: int
    start_time: float
    end_time: float
    log_prob: float  # Raw log probability
    confidence: float  # Normalized 0-1 score
    char_scores: List[float] = field(default_factory=list)

    @property
    def duration(self) -> float:
        return self.end_time - self.start_time

    @property
    def is_low_confidence(self) -> bool:
        return self.confidence < 0.5

@dataclass
class AlignmentResult:
    """Result of forced alignment."""
    audio_path: str
    transcription: str

    # Scores
    alignment_score: float  # 0-1, overall quality
    mean_log_prob: float  # Raw mean log prob

    # Per-word details
    word_alignments: List[WordAlignment] = field(default_factory=list)

    # Quality flags
    low_confidence_words: List[str] = field(default_factory=list)
    low_confidence_ratio: float = 0.0

    # Timing
    audio_duration_sec: float = 0.0
    processing_time_sec: float = 0.0

    def to_dict(self) -> Dict:
        return {
            "audio_path": self.audio_path,
            "transcription": self.transcription,
            "alignment_score": round(self.alignment_score, 4),
            "mean_log_prob": round(self.mean_log_prob, 4),
            "word_count": len(self.word_alignments),
            "low_confidence_words": self.low_confidence_words,
            "low_confidence_ratio": round(self.low_confidence_ratio, 4),
            "word_alignments": [
                {
                    "word": wa.word,
                    "start_time": round(wa.start_time, 3),
                    "end_time": round(wa.end_time, 3),
                    "confidence": round(wa.confidence, 4),
                    "log_prob": round(wa.log_prob, 4)
                }
                for wa in self.word_alignments
            ],
            "audio_duration_sec": round(self.audio_duration_sec, 3),
            "processing_time_sec": round(self.processing_time_sec, 3)
        }

class CTCForcedAligner:
    """
    CTC-based forced alignment using torchaudio.

    This provides MFA-equivalent functionality using neural CTC models.
    """

    # Language-specific Wav2Vec2 models for CTC forced alignment
    # Each model trained on the target language for best phoneme coverage.
    # "default" (XLSR-53) is multilingual fallback for languages without dedicated models.
    MODELS = {
        "te": "anuragshas/wav2vec2-large-xlsr-53-telugu",
        "hi": "theainerd/Wav2Vec2-large-xlsr-hindi",
        "mr": "theainerd/Wav2Vec2-large-xlsr-hindi",       # Marathi: Devanagari, use Hindi model
        "ta": "Harveenchadha/wav2vec2-large-xlsr-53-tamil",
        "kn": "Harveenchadha/wav2vec2-large-xlsr-53-kannada",
        "ml": "gvs/wav2vec2-large-xlsr-malayalam",
        "bn": "arijitx/wav2vec2-large-xlsr-bengali",       # Fixed: was pointing to Hindi
        "as": "arijitx/wav2vec2-large-xlsr-bengali",       # Assamese: closest to Bengali
        "gu": "facebook/wav2vec2-large-xlsr-53",            # Gujarati: use multilingual XLSR
        "pa": "facebook/wav2vec2-large-xlsr-53",            # Punjabi: use multilingual XLSR
        "or": "facebook/wav2vec2-large-xlsr-53",            # Odia: use multilingual XLSR
        "en": "facebook/wav2vec2-large-960h-lv60-self",     # English: dedicated English model
        "default": "facebook/wav2vec2-large-xlsr-53",
    }

    def __init__(
        self,
        language: str = "te",
        device: str = "auto",
        cache_dir: str = "./models/ctc_aligner"
    ):
        self.language = language.lower()[:2]
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)

        if device == "auto":
            self.device = "cuda" if torch.cuda.is_available() else "cpu"
        else:
            self.device = device

        self.model = None
        self.processor = None
        self.vocab = None
        self._setup_complete = False

    def setup(self) -> bool:
        """Load model and processor."""
        if self._setup_complete:
            return True

        try:
            from transformers import Wav2Vec2ForCTC, Wav2Vec2Processor

            model_name = self.MODELS.get(self.language, self.MODELS["default"])
            print(f"[CTCAligner] Loading {model_name}...")

            self.processor = Wav2Vec2Processor.from_pretrained(
                model_name, cache_dir=self.cache_dir
            )
            self.model = Wav2Vec2ForCTC.from_pretrained(
                model_name, cache_dir=self.cache_dir
            )

            self.model.to(self.device)
            self.model.eval()

            self.vocab = self.processor.tokenizer.get_vocab()
            self.blank_id = self.processor.tokenizer.pad_token_id

            print(f"[CTCAligner] Ready on {self.device}")
            self._setup_complete = True
            return True

        except Exception as e:
            print(f"[CTCAligner] Setup failed: {e}")
            return False

    def _load_audio(self, audio_path: str) -> Tuple[np.ndarray, int]:
        """Load and resample audio to 16kHz."""
        data, sr = sf.read(audio_path)

        if len(data.shape) > 1:
            data = data.mean(axis=1)

        if sr != 16000:
            waveform = torch.from_numpy(data).float().unsqueeze(0)
            data = torchaudio.functional.resample(waveform, sr, 16000).squeeze().numpy()
            sr = 16000

        return data, sr

    def _get_emissions(self, audio: np.ndarray) -> torch.Tensor:
        """Get CTC log-probability emissions."""
        inputs = self.processor(
            audio, sampling_rate=16000, return_tensors="pt", padding=True
        )

        with torch.no_grad():
            logits = self.model(inputs.input_values.to(self.device)).logits

        emissions = torch.log_softmax(logits.cpu(), dim=-1)
        return emissions

    def _text_to_tokens(self, text: str) -> tuple:
        """Convert text to token IDs. Returns (tensor, oov_ratio).
        OOV chars are dropped but tracked. High OOV = unreliable score."""
        tokens = []
        total_chars = 0
        oov_chars = 0
        for char in text:
            if char == " ":
                if "|" in self.vocab:
                    tokens.append(self.vocab["|"])
            elif char in self.vocab:
                tokens.append(self.vocab[char])
                total_chars += 1
            else:
                oov_chars += 1
                total_chars += 1
        oov_ratio = oov_chars / max(total_chars, 1)
        if oov_ratio > 0.1:
            print(f"[CTCAligner] WARNING: {oov_ratio:.0%} OOV chars "
                  f"({oov_chars}/{total_chars})")
        return torch.tensor([tokens], dtype=torch.int32), oov_ratio

    def _compute_word_alignments(
        self,
        text: str,
        alignment: torch.Tensor,
        scores: torch.Tensor,
        frame_duration: float
    ) -> List[WordAlignment]:
        """Extract word-level alignments from frame-level results."""
        words = text.split()

        # Build character-to-word mapping
        char_to_word = []
        for word_idx, word in enumerate(words):
            for _ in word:
                char_to_word.append(word_idx)
            char_to_word.append(-1)  # Space/separator

        # Remove trailing separator
        if char_to_word and char_to_word[-1] == -1:
            char_to_word = char_to_word[:-1]

        # Aggregate scores per word
        word_scores = {i: [] for i in range(len(words))}
        word_frames = {i: [] for i in range(len(words))}

        alignment_np = alignment[0].numpy()
        scores_np = scores[0].numpy()

        char_idx = 0
        for frame_idx, (token_idx, score) in enumerate(zip(alignment_np, scores_np)):
            if token_idx != self.blank_id and char_idx < len(char_to_word):
                word_idx = char_to_word[char_idx]
                if word_idx >= 0:
                    word_scores[word_idx].append(score)
                    word_frames[word_idx].append(frame_idx)
                char_idx += 1

        # Create WordAlignment objects
        alignments = []
        for word_idx, word in enumerate(words):
            word_score_list = word_scores.get(word_idx, [])
            frame_list = word_frames.get(word_idx, [])

            if word_score_list:
                mean_log_prob = np.mean(word_score_list)
                # Convert log prob to 0-1 confidence
                # log_prob of 0 = prob of 1 (perfect)
                # log_prob of -5 = prob of ~0.007 (bad)
                confidence = np.exp(mean_log_prob)
                confidence = min(1.0, max(0.0, confidence))

                start_frame = min(frame_list)
                end_frame = max(frame_list) + 1
            else:
                mean_log_prob = -10.0
                confidence = 0.0
                start_frame = 0
                end_frame = 0


Output:
Chunk ID: 8fd259
Wall time: 0.0506 seconds
Process exited with code 0
Original token count: 2790
Output:
"""
Audio Polisher - Segment boundary cleanup for transcription quality.
Conservative: only trims CUT SPEECH ARTIFACTS, never silence.

Philosophy:
  - Silence at boundaries is GOOD (natural padding, prevents abrupt starts/ends)
  - Only problem: segments starting/ending with ABRUPTLY CUT SPEECH from imprecise VAD
  - We detect the artifact pattern (burst -> gap -> speech) and remove ONLY the burst
  - The gap becomes natural silence padding (~30-50ms typically)
  - Segments that already start/end with silence pass through untouched

No ML models - pure signal processing, runs in <10ms/segment.
"""
import os
import numpy as np
import soundfile as sf
from dataclasses import dataclass
from typing import Optional, Tuple, List
from pathlib import Path

@dataclass
class PolishResult:
    """Result of audio polishing for a single segment."""
    input_path: str
    output_path: str
    was_modified: bool
    start_trimmed_ms: float = 0.0
    end_trimmed_ms: float = 0.0
    start_quality: str = "clean"
    end_quality: str = "clean"
    original_duration_ms: float = 0.0
    polished_duration_ms: float = 0.0
    snr_db: float = 0.0
    rms_db: float = 0.0
    peak_db: float = 0.0
    is_clipped: bool = False
    volume_adjusted: bool = False
    volume_gain_db: float = 0.0

    @property
    def total_trimmed_ms(self):
        return self.start_trimmed_ms + self.end_trimmed_ms

    def summary(self):
        name = Path(self.input_path).name
        if not self.was_modified:
            return (f"  {name}: SKIP | {self.original_duration_ms:.0f}ms | "
                    f"SNR:{self.snr_db:.1f}dB | start:{self.start_quality} "
                    f"end:{self.end_quality}")
        parts = []
        if self.start_trimmed_ms > 0:
            parts.append(
                f"start -{self.start_trimmed_ms:.0f}ms ({self.start_quality})")
        if self.end_trimmed_ms > 0:
            parts.append(
                f"end -{self.end_trimmed_ms:.0f}ms ({self.end_quality})")
        if self.volume_adjusted:
            parts.append(f"gain +{self.volume_gain_db:.1f}dB")
        changes = " | ".join(parts)
        return (f"  {name}: POLISHED | {self.original_duration_ms:.0f}ms -> "
                f"{self.polished_duration_ms:.0f}ms | {changes} | "
                f"SNR:{self.snr_db:.1f}dB")

class AudioPolisher:
    """
    Conservative audio segment polisher for transcription quality.
    Only modifies segments where artifact patterns are clearly detected.
    Uses energy + ZCR combined heuristic.
    """

    def __init__(
        self,
        frame_ms=10.0,
        hop_ms=5.0,
        max_start_trim_ms=200.0,
        artifact_search_ms=150.0,
        min_gap_ms=15.0,            # Min gap duration to confirm artifact pattern
        max_end_trim_ms=150.0,
        min_burst_ms=10.0,          # Min duration for trailing burst to be artifact
        max_burst_ms=100.0,         # Max duration - longer = probably real speech
        energy_gap_factor=0.35,
        zcr_artifact_threshold=0.15,
        min_dynamic_range_db=6.0,
        min_silence_pad_ms=30.0,    # Desired silence padding after artifact removal
        min_remaining_ms=300.0,
        min_trim_ms=10.0,
        fade_in_ms=5.0,             # Smooth fade-in after start trim
        fade_out_ms=10.0,           # Smooth fade-out on abrupt endings
        target_rms_db=-18.0,
        snr_boost_threshold_db=12.0,
        max_gain_db=12.0,
        clipping_threshold=0.99,
        output_format="flac",
    ):
        self.frame_ms = frame_ms
        self.hop_ms = hop_ms
        self.max_start_trim_ms = max_start_trim_ms
        self.artifact_search_ms = artifact_search_ms
        self.min_gap_ms = min_gap_ms
        self.max_end_trim_ms = max_end_trim_ms
        self.min_burst_ms = min_burst_ms
        self.max_burst_ms = max_burst_ms
        self.energy_gap_factor = energy_gap_factor
        self.zcr_artifact_threshold = zcr_artifact_threshold
        self.min_dynamic_range_db = min_dynamic_range_db
        self.min_silence_pad_ms = min_silence_pad_ms
        self.min_remaining_ms = min_remaining_ms
        self.min_trim_ms = min_trim_ms
        self.fade_in_ms = fade_in_ms
        self.fade_out_ms = fade_out_ms
        self.target_rms_db = target_rms_db
        self.snr_boost_threshold_db = snr_boost_threshold_db
        self.max_gain_db = max_gain_db
        self.clipping_threshold = clipping_threshold
        self.output_format = output_format

    def _compute_frames(self, audio, sr):
        """Compute per-frame energy (dB) and zero-crossing rate."""
        frame_samples = int(self.frame_ms * sr / 1000)
        hop_samples = int(self.hop_ms * sr / 1000)
        n_frames = max(1, (len(audio) - frame_samples) // hop_samples + 1)
        ste_db = np.zeros(n_frames)
        zcr = np.zeros(n_frames)
        frame_times = np.zeros(n_frames)
        for i in range(n_frames):
            start = i * hop_samples
            end = min(start + frame_samples, len(audio))
            frame = audio[start:end]
            rms = np.sqrt(np.mean(frame ** 2))
            ste_db[i] = 20 * np.log10(rms + 1e-10)
            if len(frame) > 1:
                zcr[i] = (np.sum(np.abs(np.diff(np.sign(frame))))
                          / (2 * len(frame)))
            frame_times[i] = (start + frame_samples / 2) / sr
        return ste_db, zcr, frame_times

    def _detect_leading_artifact(self, ste_db, zcr, frame_times):
        """
        Detect CUT SPEECH artifact at segment start.

        ONLY acts when: burst (cut speech) -> gap (silence) -> real speech.
        Trims at gap START = removes burst, keeps gap as silence padding.

        Does NOT trim if segment starts with silence (that's good padding).
        Does NOT trim if segment starts with continuous speech (natural start).
        """
        search_limit = self.artifact_search_ms / 1000
        si = np.where(frame_times < search_limit)[0]
        if len(si) < 3:
            return None, "clean"

        nf = np.percentile(ste_db, 15)
        sl = np.percentile(ste_db, 85)
        dr = sl - nf
        if dr < self.min_dynamic_range_db:
            return None, "clean"

        gap_thresh = nf + self.energy_gap_factor * dr

        # KEY CHECK: does segment start with speech energy or silence?
        # If first frames are below threshold = starts with silence = GOOD, skip
        n_check = min(3, len(si))
        first_frames_energy = ste_db[si[:n_check]]
        if np.all(first_frames_energy < gap_thresh):
            return None, "clean"  # Starts with silence - natural padding, don't touch

        # Segment starts with speech energy. Look for gap pattern.
        below = ste_db[si] < gap_thresh

        gaps = []
        in_gap = False
        gs = 0
        for i in range(len(below)):
            if below[i] and not in_gap:
                in_gap = True
                gs = i
            elif not below[i] and in_gap:
                in_gap = False
                dur = (frame_times[si[i]] - frame_times[si[gs]]) * 1000
                if dur >= self.min_gap_ms:
                    gaps.append((gs, i, dur))
        if in_gap:
            dur = (frame_times[si[-1]] - frame_times[si[gs]]) * 1000
            if dur >= self.min_gap_ms:
                gaps.append((gs, len(below), dur))

        if not gaps:
            return None, "clean"  # No gap = continuous speech from start

        g = gaps[0]
        # Gap must have speech BEFORE it (the artifact burst) and AFTER it (real speech)
        if g[0] == 0:
            return None, "clean"  # Gap at start = silence, already handled above
        if g[1] >= len(si):
            return None, "marginal_kept"  # Gap extends to search boundary

        # Artifact confirmed: speech burst -> gap -> more speech
        pre = si[:g[0]]
        mzcr = np.mean(zcr[pre]) if len(pre) > 0 else 0

        # Trim at GAP START = remove burst, keep gap as silence padding
        gap_start_frame = si[g[0]]
        tt = frame_times[gap_start_frame]

        if tt * 1000 > self.max_start_trim_ms or tt * 1000 < self.min_trim_ms:
            return None, "marginal_kept" if tt * 1000 > self.max_start_trim_ms else "clean"

        # Validate: gap should provide enough silence padding
        gap_ms = g[2]
        if gap_ms < self.min_silence_pad_ms:
            # Gap is too short for comfortable padding. Still trim if artifact
            # is clearly noise-like (high ZCR), but note the tight padding.
            if mzcr <= self.zcr_artifact_threshold:
                return None, "marginal_kept"

        # High ZCR = noise artifact (confident trim)
        if mzcr > self.zcr_artifact_threshold:
            return tt, "artifact_trimmed"
        # Low ZCR but clear gap = trim if gap is substantial
        if g[2] >= self.min_gap_ms * 1.5:
            return tt, "artifact_trimmed"

        return None, "marginal_kept"

    def _detect_trailing_artifact(self, ste_db, frame_times, total_dur):
        """
        Detect CUT SPEECH artifact at segment end.

        ONLY acts when: sustained speech -> silence gap -> isolated burst at end.
        The burst is a leak from the next VAD segment. Remove it, keep the silence.

        Does NOT trim if segment ends with silence (that's good padding).
        Does NOT trim if segment ends with sustained speech (natural or cut, can't help).
        Returns (trim_time, "artifact_trimmed") or applies fade-out flag.
        """
        nf = np.percentile(ste_db, 15)
        sl = np.percentile(ste_db, 85)
        dr = sl - nf
        if dr < self.min_dynamic_range_db:
            return None, "clean", False

        thresh = nf + self.energy_gap_factor * dr
        above = ste_db > thresh
        n = len(ste_db)

        # Check if segment ends with silence (last few frames below threshold)
        n_check = min(3, n)
        if np.all(~above[-n_check:]):
            return None, "clean", False  # Ends with silence - good padding

        # Segment ends with speech energy. Two sub-cases:
        # A) Isolated trailing burst: silence -> burst -> end
        #    = artifact from next segment, trim it
        # B) Sustained speech to the end: natural or cut ending
        #    = apply fade-out to soften abrupt cut

        # Find the last above-threshold frame
        ai = np.where(above)[0]
        if len(ai) == 0:
            return None, "clean", False

        last_above = ai[-1]

        # Check if this trailing speech is ISOLATED (preceded by silence gap)
        # Walk backwards from last_above to find if there's a gap before it
        lookback = min(10, last_above)  # ~50ms at 5ms hop
        if lookback >= 3:
            preceding = above[last_above - lookback:last_above]
            sustained = np.mean(preceding)

            if sustained < 0.3:
                # Low density before last speech = isolated trailing burst
                # Find where the burst starts (first above-thresh going backward)
                burst_start = last_above
                for bi in range(last_above - 1, -1, -1):
                    if above[bi]:
                        burst_start = bi
                    else:


sed -n '280,620p' src/backend/audio_polisher.py