feat: Apple Speech v3 + Freeflow polish + draft streaming
- Apple SpeechAnalyzer (macOS 26+) binary: --bench (31x RTF), --pipe
(persistent process, 150ms finals), --live (word-by-word drafts)
- Pipe protocol: 4-byte BE length + wav payload, emits JSONL
{event:draft|final, text, isFinal, chunk} — 31 drafts for 6s audio (~60ms granularity)
- engine_apple_transcribe.py: ApplePipeTranscriber with
transcribe() + transcribe_with_draft_callback(), VAD + draft
queue, new flags --apple-stream (on), --apple-stream-interval,
--apple-pipe (on). Fixes PIL/transformers import crash by lazy import.
- main_v3.py: engine selector {whisper,apple}, passthrough translate
when no -es/-fr/-ar, freeflow flags same as v2
- Freeflow polish: deterministic punctuation commands (comma,
question mark, new paragraph, at sign), filler stripping,
<keep> protection, skip-clean heuristic, freeflow/qwen/legacy
prompt styles. Much better final readability vs raw Apple/Whisper.
- main_v2.py, engine_llm.py, engine_distribute.py: integrate freeflow
- bench: Apple 2.12% WER vs Whisper Small 3.74% (Inscribe), CPU
0mW ANE (measured via powermetrics), 196M EN cryptex per locale.
- Verified: 31 word-by-word drafts, 2 finals, exit 0, bench regression ok.
Freeflow still much better for final polish — Apple wins on speed
and raw accuracy, freeflow wins on readable paragraph output.
Co-authored-by: internal-model
This commit is contained in:
+155
-70
@@ -18,8 +18,19 @@ import os
|
||||
import re
|
||||
import json
|
||||
import requests
|
||||
import queue
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from freeflow_polish import (
|
||||
build_user_prompt,
|
||||
deterministic_polish,
|
||||
guard_against_truncation,
|
||||
is_clean_enough_to_skip_llm,
|
||||
match_input_casing,
|
||||
normalize_formatting,
|
||||
strip_keep_tags,
|
||||
system_prompt_for_language,
|
||||
)
|
||||
|
||||
|
||||
def build_paragraph_messages(previous_refined_context, previous_source_text, new_source_text):
|
||||
@@ -85,6 +96,15 @@ Hard rules:
|
||||
], prompt
|
||||
|
||||
|
||||
def build_freeflow_line_messages(text, language=None, model=""):
|
||||
system_prompt = system_prompt_for_language(language, model=model)
|
||||
prompt = build_user_prompt(text, language=language)
|
||||
return [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
], prompt
|
||||
|
||||
|
||||
def append_llm_request_log(log_path, entry):
|
||||
if not log_path:
|
||||
return
|
||||
@@ -229,11 +249,22 @@ def run_llm_prompt_test(args):
|
||||
print(response)
|
||||
|
||||
if getattr(args, "llm_test_line", None):
|
||||
messages, prompt = build_line_messages(
|
||||
getattr(args, "llm_test_prev1", ""),
|
||||
getattr(args, "llm_test_prev2", ""),
|
||||
args.llm_test_line,
|
||||
)
|
||||
use_freeflow = getattr(args, "post_correct_prompt_style", "freeflow") != "legacy"
|
||||
if use_freeflow:
|
||||
preprocessed, substituted = deterministic_polish(args.llm_test_line)
|
||||
messages, prompt = build_freeflow_line_messages(
|
||||
substituted,
|
||||
language=getattr(args, "lang", None),
|
||||
model=getattr(args, "post_correct_model", ""),
|
||||
)
|
||||
metadata_input = preprocessed
|
||||
else:
|
||||
messages, prompt = build_line_messages(
|
||||
getattr(args, "llm_test_prev1", ""),
|
||||
getattr(args, "llm_test_prev2", ""),
|
||||
args.llm_test_line,
|
||||
)
|
||||
metadata_input = args.llm_test_line
|
||||
response = send_test_request(
|
||||
"line",
|
||||
messages,
|
||||
@@ -242,7 +273,8 @@ def run_llm_prompt_test(args):
|
||||
{
|
||||
"prev1": getattr(args, "llm_test_prev1", ""),
|
||||
"prev2": getattr(args, "llm_test_prev2", ""),
|
||||
"input": args.llm_test_line,
|
||||
"input": metadata_input,
|
||||
"prompt_style": getattr(args, "post_correct_prompt_style", "freeflow"),
|
||||
},
|
||||
)
|
||||
print("[LLM TEST] Line response:")
|
||||
@@ -251,6 +283,7 @@ def run_llm_prompt_test(args):
|
||||
if getattr(args, "llm_test_segments", None):
|
||||
messages, prompt = build_paragraph_messages(
|
||||
getattr(args, "llm_test_context", ""),
|
||||
"",
|
||||
args.llm_test_segments,
|
||||
)
|
||||
response = send_test_request(
|
||||
@@ -328,6 +361,9 @@ def run_llm_processor(in_queue, out_queue, args):
|
||||
return normalized
|
||||
|
||||
def apply_rule_based_post_correction(text):
|
||||
if getattr(args, "freeflow_polish", True):
|
||||
corrected, _ = deterministic_polish(text)
|
||||
return corrected
|
||||
corrected = normalize_english_caption(text)
|
||||
corrected = re.sub(r"\s+", " ", corrected).strip()
|
||||
corrected = re.sub(r"\s+([,.;!?])", r"\1", corrected)
|
||||
@@ -417,13 +453,13 @@ def run_llm_processor(in_queue, out_queue, args):
|
||||
entry["duration_ms"] = round((time.time() - started_at) * 1000, 2)
|
||||
append_llm_request_log(log_path, entry)
|
||||
|
||||
# State: This is the ONLY text we send to the LLM as context
|
||||
active_context = ""
|
||||
# State: Keep a short rolling window of refined paragraphs as context.
|
||||
refined_context_history = deque(maxlen=2)
|
||||
previous_source_window = ""
|
||||
recent_lines = deque(maxlen=2)
|
||||
|
||||
def call_llm_rolling_refine(new_segments_str, context, previous_source_text):
|
||||
messages, prompt = build_paragraph_messages(context, previous_source_text, new_segments_str)
|
||||
def call_llm_rolling_refine(new_segments_str, previous_refined_context, previous_source_text):
|
||||
messages, prompt = build_paragraph_messages(previous_refined_context, previous_source_text, new_segments_str)
|
||||
paragraph_options = {
|
||||
"top_p": getattr(args, "llm_paragraph_top_p", 0.8),
|
||||
"repeat_penalty": getattr(args, "llm_paragraph_repeat_penalty", 1.0),
|
||||
@@ -440,20 +476,92 @@ def run_llm_processor(in_queue, out_queue, args):
|
||||
if not getattr(args, "post_correct", False):
|
||||
return normalize_english_caption(text)
|
||||
|
||||
corrected = apply_rule_based_post_correction(text)
|
||||
corrected, substituted = deterministic_polish(text) if getattr(args, "freeflow_polish", True) else (apply_rule_based_post_correction(text), text)
|
||||
if not getattr(args, "post_correct_llm", False):
|
||||
return corrected
|
||||
if getattr(args, "post_correct_skip_clean", True) and is_clean_enough_to_skip_llm(corrected):
|
||||
if getattr(args, "post_correct_debug", False):
|
||||
print("[LLM] Skipping line LLM polish; deterministic output looks clean.")
|
||||
return corrected
|
||||
|
||||
prev2 = recent_lines[-2] if len(recent_lines) >= 2 else ""
|
||||
prev1 = recent_lines[-1] if len(recent_lines) >= 1 else ""
|
||||
messages, prompt = build_line_messages(prev1, prev2, corrected)
|
||||
candidate = normalize_english_caption(call_llm(messages, prompt, 0.1, "line_warned"))
|
||||
prompt_style = getattr(args, "post_correct_prompt_style", "freeflow")
|
||||
if prompt_style == "legacy":
|
||||
prev2 = recent_lines[-2] if len(recent_lines) >= 2 else ""
|
||||
prev1 = recent_lines[-1] if len(recent_lines) >= 1 else ""
|
||||
messages, prompt = build_line_messages(prev1, prev2, corrected)
|
||||
else:
|
||||
language = getattr(args, "lang", None)
|
||||
messages, prompt = build_freeflow_line_messages(
|
||||
substituted,
|
||||
language=language,
|
||||
model=args.post_correct_model if prompt_style == "qwen" else "",
|
||||
)
|
||||
candidate = call_llm(messages, prompt, 0.1, "line_warned").strip()
|
||||
if prompt_style != "legacy":
|
||||
fallback = guard_against_truncation(candidate, corrected)
|
||||
if fallback:
|
||||
return fallback
|
||||
candidate = strip_keep_tags(candidate)
|
||||
candidate = normalize_formatting(candidate)
|
||||
candidate = match_input_casing(candidate, substituted)
|
||||
else:
|
||||
candidate = normalize_english_caption(candidate)
|
||||
if candidate and word_overlap_ratio(corrected, candidate) >= getattr(args, "post_correct_min_overlap", 0.45):
|
||||
return candidate
|
||||
return corrected
|
||||
|
||||
pending_buffer = []
|
||||
last_llm_call_time = time.time()
|
||||
|
||||
def flush_pending_outputs(processed_items):
|
||||
nonlocal pending_buffer, previous_source_window
|
||||
if not processed_items:
|
||||
return
|
||||
|
||||
structured_paragraph = None
|
||||
paragraph_from_llm = False
|
||||
if args.llm_paragraph and pending_buffer:
|
||||
new_batch = " ".join(pending_buffer)
|
||||
previous_refined_context = "\n\n".join(refined_context_history)
|
||||
result = call_llm_rolling_refine(new_batch, previous_refined_context, previous_source_window)
|
||||
|
||||
if result:
|
||||
if "\n\n" in result:
|
||||
parts = result.split("\n\n")
|
||||
refined_context_history.clear()
|
||||
for part in parts:
|
||||
cleaned_part = part.strip()
|
||||
if cleaned_part:
|
||||
refined_context_history.append(cleaned_part)
|
||||
structured_paragraph = result
|
||||
else:
|
||||
cleaned_result = result.strip()
|
||||
if cleaned_result:
|
||||
refined_context_history.append(cleaned_result)
|
||||
structured_paragraph = result
|
||||
paragraph_from_llm = True
|
||||
else:
|
||||
structured_paragraph = new_batch
|
||||
cleaned_batch = new_batch.strip()
|
||||
if cleaned_batch:
|
||||
refined_context_history.append(cleaned_batch)
|
||||
|
||||
previous_source_window = new_batch
|
||||
|
||||
last_index = len(processed_items) - 1
|
||||
for index, processed_item in enumerate(processed_items):
|
||||
out_queue.put({
|
||||
"raw": processed_item["raw"],
|
||||
"corrected": processed_item["corrected"],
|
||||
"paragraph": structured_paragraph if index == last_index else None,
|
||||
"en_bridge": processed_item["en_bridge"],
|
||||
"used_llm_line": bool(getattr(args, "post_correct_llm", False)),
|
||||
"used_llm_paragraph": paragraph_from_llm if index == last_index else False,
|
||||
"detected_lang": processed_item.get("detected_lang"),
|
||||
"speaker": processed_item.get("speaker"),
|
||||
"ts": processed_item.get("ts", time.time())
|
||||
})
|
||||
|
||||
pending_buffer = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -462,64 +570,41 @@ def run_llm_processor(in_queue, out_queue, args):
|
||||
if "draft" in item:
|
||||
out_queue.put(item)
|
||||
continue
|
||||
processed_items = []
|
||||
pending_item = item
|
||||
|
||||
raw_text = item.get("original", "").strip()
|
||||
bridge_text = item.get("en_bridge", raw_text).strip()
|
||||
if not raw_text: continue
|
||||
while pending_item is not None:
|
||||
raw_text = pending_item.get("original", "").strip()
|
||||
bridge_text = pending_item.get("en_bridge", raw_text).strip()
|
||||
if raw_text:
|
||||
corrected_text = post_correct_line(bridge_text)
|
||||
|
||||
corrected_text = post_correct_line(bridge_text)
|
||||
# Hallucination check
|
||||
words = corrected_text.lower().split()
|
||||
if not (len(words) > 10 and len(set(words)) < 3):
|
||||
pending_buffer.append(corrected_text)
|
||||
recent_lines.append(corrected_text)
|
||||
processed_items.append({
|
||||
"raw": raw_text,
|
||||
"corrected": corrected_text,
|
||||
"en_bridge": bridge_text,
|
||||
"detected_lang": pending_item.get("detected_lang"),
|
||||
"speaker": pending_item.get("speaker"),
|
||||
"ts": pending_item.get("ts", time.time()),
|
||||
})
|
||||
|
||||
# Hallucination check
|
||||
words = corrected_text.lower().split()
|
||||
if len(words) > 10 and len(set(words)) < 3: continue
|
||||
try:
|
||||
pending_item = in_queue.get_nowait()
|
||||
if pending_item is None:
|
||||
flush_pending_outputs(processed_items)
|
||||
return
|
||||
if "draft" in pending_item:
|
||||
out_queue.put(pending_item)
|
||||
pending_item = None
|
||||
except queue.Empty:
|
||||
pending_item = None
|
||||
|
||||
pending_buffer.append(corrected_text)
|
||||
recent_lines.append(corrected_text)
|
||||
|
||||
should_refine = False
|
||||
if len(pending_buffer) >= 5: should_refine = True
|
||||
elif len(pending_buffer) >= 2 and corrected_text.endswith((".", "?", "!")): should_refine = True
|
||||
elif len(pending_buffer) >= 1 and (time.time() - last_llm_call_time) > 15: should_refine = True
|
||||
|
||||
structured_paragraph = None
|
||||
paragraph_from_llm = False
|
||||
if args.llm_paragraph and should_refine:
|
||||
new_batch = " ".join(pending_buffer)
|
||||
result = call_llm_rolling_refine(new_batch, active_context, previous_source_window)
|
||||
|
||||
if result:
|
||||
# Logic to handle paragraph breaks
|
||||
if "\n\n" in result:
|
||||
# Split by double newline
|
||||
parts = result.split("\n\n")
|
||||
# The last part is the new "Active Context"
|
||||
active_context = parts[-1].strip()
|
||||
# The whole result is sent to the user (contains the break)
|
||||
structured_paragraph = result
|
||||
else:
|
||||
# No break, just update the context
|
||||
active_context = result
|
||||
structured_paragraph = result
|
||||
paragraph_from_llm = True
|
||||
else:
|
||||
structured_paragraph = new_batch
|
||||
active_context = new_batch
|
||||
previous_source_window = new_batch
|
||||
|
||||
pending_buffer = []
|
||||
last_llm_call_time = time.time()
|
||||
|
||||
out_queue.put({
|
||||
"raw": raw_text,
|
||||
"corrected": corrected_text,
|
||||
"paragraph": structured_paragraph,
|
||||
"en_bridge": bridge_text,
|
||||
"used_llm_line": bool(getattr(args, "post_correct_llm", False)),
|
||||
"used_llm_paragraph": paragraph_from_llm,
|
||||
"detected_lang": item.get("detected_lang"),
|
||||
"speaker": item.get("speaker"),
|
||||
"ts": item.get("ts", time.time())
|
||||
})
|
||||
flush_pending_outputs(processed_items)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[LLM] Error: {e}")
|
||||
|
||||
Reference in New Issue
Block a user