diff --git a/history.md b/history.md index d9e8105..fd6fddb 100644 --- a/history.md +++ b/history.md @@ -160,3 +160,11 @@ The project now features a high-performance, Apple Silicon-optimized pipeline th - Wrapped Arabic caption content in explicit RTL embedding marks for better bidirectional layout stability. - Added punctuation normalization for Arabic display (`،`, `؛`, `؟`) to reduce LTR punctuation artifacts. - **Outcome:** Arabic captions render more consistently in terminal output, especially when adjacent to English/French/Spanish caption lines. + +## Phase 20: Post-Correct Mode (V2 Foundation) +- **Goal:** Add a practical second-stage correction pass for finalized English captions. +- **Approach:** + - Added `--post-correct` mode to run deterministic rule-based cleanup after ASR (spacing, punctuation cleanup, disfluency reduction, immediate duplicate-word collapse). + - Added optional local LLM correction via Ollama (`--post-correct-llm`, model/url/timeout flags) with strict fallback to rules-only when unavailable. + - Integrated post-correction after bridge-to-English and before downstream translation so improved English text propagates to target-language MT. +- **Outcome:** The pipeline now supports a two-stage transcription flow (ASR -> correction) with low-latency defaults and optional local generative enhancement. diff --git a/transcribe.py b/transcribe.py index 827f3e5..cf36bfc 100644 --- a/transcribe.py +++ b/transcribe.py @@ -207,6 +207,69 @@ def normalize_english_caption(text): normalized = normalized[0].upper() + normalized[1:] return normalized +def apply_rule_based_post_correction(text): + """Apply lightweight deterministic cleanup for finalized English captions.""" + if not text: + return "" + + corrected = normalize_english_caption(text) + corrected = re.sub(r"\s+", " ", corrected).strip() + corrected = re.sub(r"\s+([,.;!?])", r"\1", corrected) + corrected = re.sub(r"([,.;!?]){2,}", r"\1", corrected) + + # Remove common disfluencies conservatively. + corrected = re.sub(r"\b(uh+|um+|erm+|ah+|hmm+)\b", "", corrected, flags=re.IGNORECASE) + corrected = re.sub(r"\s+", " ", corrected).strip() + + # Collapse immediate duplicated words (e.g., "the the", "I I"). + corrected = re.sub(r"\b(\w+)\s+\1\b", r"\1", corrected, flags=re.IGNORECASE) + corrected = re.sub(r"\s+", " ", corrected).strip() + + if corrected and corrected[0].isalpha(): + corrected = corrected[0].upper() + corrected[1:] + return corrected + +def post_correct_with_local_llm(text, args, llm_state, context_hint=""): + """Optionally rewrite finalized English caption via a local Ollama model.""" + if not args.post_correct_llm or not llm_state.get("available", True): + return text + + prompt = ( + "You correct live English captions.\n" + "Rules:\n" + "- Preserve original meaning exactly.\n" + "- Fix grammar, punctuation, and natural wording.\n" + "- Remove filler words only when they do not change meaning.\n" + "- Keep output concise and in one line.\n" + "- Return only corrected text, no quotes or explanation.\n" + ) + if context_hint: + prompt += f"Previous caption context: {context_hint}\n" + prompt += f"Caption: {text}\nCorrected:" + + try: + response = requests.post( + args.post_correct_ollama_url, + json={ + "model": args.post_correct_model, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.1}, + }, + timeout=args.post_correct_llm_timeout, + ) + response.raise_for_status() + raw = response.json().get("response", "").strip() + cleaned = normalize_english_caption(raw) + if cleaned: + return cleaned + except Exception as exc: + if not llm_state.get("warned", False): + print(f"\n[SYSTEM]: Local post-correct LLM unavailable ({exc}). Falling back to rules-only.") + llm_state["warned"] = True + llm_state["available"] = False + return text + def maybe_merge_recent_caption(recent_en_lines, current_text, now_ts, window_sec): if not recent_en_lines: return None @@ -350,12 +413,24 @@ def main(): parser.add_argument("--caption-correction-window", type=float, default=3.0, help="Seconds where previous English line can still be merged/corrected (default: 3.0)") parser.add_argument("--glossary-file", type=str, default="glossary.txt", help="Path to glossary file with SOURCE=TARGET pairs (default: glossary.txt if present)") parser.add_argument("--glossary-pair", action="append", type=parse_glossary_pair, default=[], help="Term replacement pair SOURCE=TARGET (repeatable)") + parser.add_argument("--post-correct", action="store_true", help="Enable v2 finalized English caption post-correction") + parser.add_argument("--post-correct-llm", action="store_true", help="Use local LLM for post-correction (fallback to rules on failure)") + parser.add_argument("--post-correct-model", type=str, default="llama3.1:8b-instruct", help="Local Ollama model for post-correction") + parser.add_argument("--post-correct-ollama-url", type=str, default="http://127.0.0.1:11434/api/generate", help="Ollama generate endpoint for local post-correction") + parser.add_argument("--post-correct-llm-timeout", type=float, default=3.0, help="Timeout seconds for local LLM post-correction (default: 3.0)") args = parser.parse_args() + if args.post_correct_llm and not args.post_correct: + args.post_correct = True + print("[SYSTEM]: Enabling --post-correct because --post-correct-llm was requested.") file_glossary_pairs = load_glossary_file(args.glossary_file) merged_glossary_pairs = file_glossary_pairs + args.glossary_pair if file_glossary_pairs: print(f"Loaded {len(file_glossary_pairs)} glossary terms from {args.glossary_file}") + if args.post_correct: + print("Post-correct mode: rules enabled.") + if args.post_correct_llm: + print(f"Post-correct LLM: {args.post_correct_model} via {args.post_correct_ollama_url}") if args.quantize: WHISPER_MODEL = "mlx-community/whisper-small-mlx-4bit" @@ -437,6 +512,7 @@ def main(): rolling_context_en = "" last_draft_text = "" recent_en_lines = deque(maxlen=2) + llm_post_correct_state = {"available": True, "warned": False} try: with sd.InputStream(samplerate=SAMPLERATE, channels=args.channels, callback=callback, blocksize=BLOCK_SIZE, device=device_index): @@ -578,6 +654,19 @@ def main(): if args.smart_correct and english_for_translation: english_for_translation = normalize_english_caption(english_for_translation) english_for_translation = apply_glossary(english_for_translation, merged_glossary_pairs) + elif merged_glossary_pairs and english_for_translation: + # Allow glossary use even when smart-correct is disabled. + english_for_translation = apply_glossary(english_for_translation, merged_glossary_pairs) + + if args.post_correct and english_for_translation: + context_hint = recent_en_lines[-1]["text"] if recent_en_lines else "" + english_for_translation = apply_rule_based_post_correction(english_for_translation) + english_for_translation = post_correct_with_local_llm( + english_for_translation, + args, + llm_post_correct_state, + context_hint=context_hint, + ) english_for_caption = english_for_translation if args.smart_correct and english_for_caption: