from __future__ import annotations import html import re from dataclasses import dataclass from types import SimpleNamespace SLOW_WPM_LIMIT = 110 FAST_WPM_LIMIT = 165 LOW_CONFIDENCE_SECONDS = 5 LOW_CONFIDENCE_WORDS = 8 GAUGE_MIN_WPM = 60.0 GAUGE_MAX_WPM = 220.0 WORD_PATTERN = re.compile(r"[A-Za-z0-9]+(?:'[A-Za-z0-9]+)?") sf = SimpleNamespace() @dataclass(frozen=True) class TimingAnalysis: duration_seconds: float word_count: int words_per_minute: float pace_label: str low_confidence: bool def get_audio_duration_seconds(audio_path: str) -> float: if not hasattr(sf, "info"): import soundfile as soundfile_module sf.info = soundfile_module.info try: info = sf.info(audio_path) except RuntimeError as exc: raise ValueError(f"Could not read the recording duration: {exc}") from exc duration_seconds = float(info.duration) if duration_seconds <= 0: raise ValueError("The recording appears to be empty, so timing feedback is unavailable.") return duration_seconds def count_words(transcript: str) -> int: return len(WORD_PATTERN.findall(transcript)) def classify_pace(words_per_minute: float) -> str: if words_per_minute < SLOW_WPM_LIMIT: return "slow/spacious" if words_per_minute <= FAST_WPM_LIMIT: return "steady" return "fast" def analyze_timing(audio_path: str, transcript: str) -> TimingAnalysis: duration_seconds = get_audio_duration_seconds(audio_path) word_count = count_words(transcript) words_per_minute = 0.0 if word_count: words_per_minute = word_count / duration_seconds * 60 return TimingAnalysis( duration_seconds=duration_seconds, word_count=word_count, words_per_minute=words_per_minute, pace_label=classify_pace(words_per_minute), low_confidence=duration_seconds < LOW_CONFIDENCE_SECONDS or word_count < LOW_CONFIDENCE_WORDS, ) def _pacing_suggestion(analysis: TimingAnalysis) -> str: if analysis.low_confidence: return "Try a slightly longer rehearsal clip before treating the pace estimate as reliable." if analysis.pace_label == "slow/spacious": return "Keep the breathing room, but check that pauses feel intentional rather than hesitant." if analysis.pace_label == "fast": return "Slow the setup lines and leave space after jokes or emotional beats." return "This is a workable rehearsal pace; keep using pauses at transitions." def format_timing_summary(analysis: TimingAnalysis) -> str: lines = [ f"Duration: {analysis.duration_seconds:.1f} seconds", f"Estimated words: {analysis.word_count}", f"Estimated pace: {analysis.words_per_minute:.1f} words per minute ({analysis.pace_label})", ] if analysis.low_confidence: lines.append("Confidence: low because this clip is very short or has very few words.") lines.append(f"Try this next: {_pacing_suggestion(analysis)}") return "\n".join(lines) def summarize_timing(audio_path: str, transcript: str) -> str: return format_timing_summary(analyze_timing(audio_path, transcript)) def _format_clock(seconds: float) -> str: total = int(round(seconds)) return f"{total // 60}:{total % 60:02d}" def _gauge_pct(wpm: float) -> float: pct = (wpm - GAUGE_MIN_WPM) / (GAUGE_MAX_WPM - GAUGE_MIN_WPM) * 100.0 return max(0.0, min(100.0, pct)) def format_pacing_preview_html(duration_seconds: float) -> str: """Shown after recording, before the transcript exists.""" return ( '
Pacing lands once the transcript is ready.
' ) def format_pacing_html(audio_path: str, transcript: str) -> str: analysis = analyze_timing(audio_path, transcript) wpm = analysis.words_per_minute in_band = SLOW_WPM_LIMIT <= wpm <= FAST_WPM_LIMIT flag = "" if (in_band or wpm == 0) else " num--flag" band_left = _gauge_pct(SLOW_WPM_LIMIT) band_width = _gauge_pct(FAST_WPM_LIMIT) - band_left marker = ( f'' if wpm else "" ) confidence = ( 'Low confidence: very short clip or few words.
' if analysis.low_confidence else "" ) return ( '{html.escape(_pacing_suggestion(analysis))}
' f'{confidence}' )