fix(audio): detect transcript-wide hallucinations

This commit is contained in:
2026-08-12 13:32:10 +08:00
parent c321d020dd
commit 48f4efaf3d
10 changed files with 318 additions and 13 deletions
+12
View File
@@ -2138,6 +2138,15 @@ def video_transcript(
int(video["audio_rejected_segments"] or 0), int(video["audio_rejected_segments"] or 0),
quality_flags, quality_flags,
) )
repeated_phrases = []
for flag in quality_flags:
if not isinstance(flag, str) or not flag.startswith("repeated_phrase:"):
continue
try:
phrase, count = flag.removeprefix("repeated_phrase:").rsplit(":", 1)
repeated_phrases.append({"text": phrase, "count": int(count)})
except (TypeError, ValueError):
continue
if job and job["status"] in {"queued", "running"}: if job and job["status"] in {"queued", "running"}:
status = job["status"] status = job["status"]
elif current: elif current:
@@ -2159,6 +2168,9 @@ def video_transcript(
else "legacy", else "legacy",
"rejected_segments": int(video["audio_rejected_segments"] or 0), "rejected_segments": int(video["audio_rejected_segments"] or 0),
"quality_flags": quality_flags, "quality_flags": quality_flags,
"aggregate_risk": quality_state == "low_quality",
"repeated_phrases": repeated_phrases,
"quality_score_semantics": "rule_check",
"items": [dict(row) for row in rows], "items": [dict(row) for row in rows],
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
+50 -8
View File
@@ -15,7 +15,14 @@ from pathlib import Path
from .accelerator import AcceleratorService from .accelerator import AcceleratorService
from .config import Settings from .config import Settings
from .speech import SpeechService, SpeechStageError from .speech import SpeechService, SpeechStageError
from .speech_quality import normalize_language, pcm16_voiced_regions, select_language, transcript_quality from .speech_quality import (
aggregate_transcript_quality,
normalize_language,
pcm16_speech_ratio,
pcm16_voiced_regions,
select_language,
transcript_quality,
)
def _emit(payload: dict) -> None: def _emit(payload: dict) -> None:
@@ -39,6 +46,7 @@ def _arguments() -> argparse.Namespace:
parser.add_argument("--language-policy", choices=("zh_priority", "auto", "zh"), default="zh_priority") parser.add_argument("--language-policy", choices=("zh_priority", "auto", "zh"), default="zh_priority")
parser.add_argument("--quality-profile", choices=("speed", "balanced", "accuracy"), default="accuracy") parser.add_argument("--quality-profile", choices=("speed", "balanced", "accuracy"), default="accuracy")
parser.add_argument("--model-variant", choices=("small", "medium"), default="small") parser.add_argument("--model-variant", choices=("small", "medium"), default="small")
parser.add_argument("--segmentation", choices=("vad", "continuous"), default="vad")
return parser.parse_args() return parser.parse_args()
@@ -240,6 +248,7 @@ def run(args: argparse.Namespace) -> int:
strongest_voiced_frame = 0 strongest_voiced_frame = 0
rejected_candidate_units = 0 rejected_candidate_units = 0
verification_duration_ms = 0 verification_duration_ms = 0
accepted_all: list[dict] = []
with wave.open(str(wav_path), "rb") as handle: with wave.open(str(wav_path), "rb") as handle:
sample_rate = handle.getframerate() sample_rate = handle.getframerate()
total_frames = handle.getnframes() total_frames = handle.getnframes()
@@ -273,12 +282,15 @@ def run(args: argparse.Namespace) -> int:
upper_ms = end_ms - (args.overlap_seconds * 500 if end_frame < total_frames else 0) upper_ms = end_ms - (args.overlap_seconds * 500 if end_frame < total_frames else 0)
energy = _sample_energy(samples) energy = _sample_energy(samples)
segments = [] segments = []
regions = pcm16_voiced_regions(raw, sample_rate, mode=2) if args.segmentation == "continuous":
if not regions and energy > 1e-6: regions = [(0, len(raw) // 2, pcm16_speech_ratio(raw, sample_rate, mode=2))]
regions = pcm16_voiced_regions(raw, sample_rate, mode=1) else:
regions = pcm16_voiced_regions(raw, sample_rate, mode=2)
if not regions and energy > 1e-6:
regions = pcm16_voiced_regions(raw, sample_rate, mode=1)
windows: list[tuple[list[float], float, int, int]] = [] windows: list[tuple[list[float], float, int, int]] = []
for region_start, region_end, speech_ratio in regions: for region_start, region_end, speech_ratio in regions:
if speech_ratio < 0.08: if speech_ratio < 0.08 and args.segmentation != "continuous":
continue continue
if speech_ratio > strongest_voiced_ratio: if speech_ratio > strongest_voiced_ratio:
strongest_voiced_ratio = speech_ratio strongest_voiced_ratio = speech_ratio
@@ -341,6 +353,29 @@ def run(args: argparse.Namespace) -> int:
rejected_total += rejected rejected_total += rejected
quality_flags.extend(flags) quality_flags.extend(flags)
quality_scores.append(candidate_score) quality_scores.append(candidate_score)
elapsed = time.monotonic() - inference_started
_emit(
{
"event": "window_result",
"chunk_index": index + 1,
"window_index": offset + 1,
"start_ms": window_base_ms,
"duration_ms": window_duration_ms,
"speech_ratio": round(speech_ratio, 4),
"language_candidates": detected,
"forced_language": resolved_language,
"raw_output": raw_segments,
"accepted_output": scored,
"quality_score": candidate_score,
"quality_flags": flags,
"rejected_segments": rejected,
"device": args.device,
"beam": 1 if args.device == "GPU" else speech.generation_beams(quality_profile),
"elapsed_seconds": round(elapsed, 3),
"rtf": round(elapsed / max(0.001, window_duration_ms / 1000), 3),
"segmentation": args.segmentation,
}
)
for segment in scored: for segment in scored:
segment["start_ms"] += window_base_ms segment["start_ms"] += window_base_ms
segment["end_ms"] += window_base_ms segment["end_ms"] += window_base_ms
@@ -349,6 +384,7 @@ def run(args: argparse.Namespace) -> int:
midpoint = (segment["start_ms"] + segment["end_ms"]) // 2 midpoint = (segment["start_ms"] + segment["end_ms"]) // 2
if lower_ms <= midpoint <= upper_ms: if lower_ms <= midpoint <= upper_ms:
segments.append(segment) segments.append(segment)
accepted_all.append(dict(segment))
recognized_segments += len(segments) recognized_segments += len(segments)
if not segments: if not segments:
if energy > strongest_empty_energy: if energy > strongest_empty_energy:
@@ -397,20 +433,26 @@ def run(args: argparse.Namespace) -> int:
"segmentation": "webrtcvad", "segmentation": "webrtcvad",
}, },
) )
aggregate = aggregate_transcript_quality(
accepted_all,
base_score=round(sum(quality_scores) / len(quality_scores), 3) if quality_scores else None,
base_flags=quality_flags,
)
_emit( _emit(
{ {
"event": "complete", "event": "complete",
"detected_language": resolved_language, "detected_language": resolved_language,
"quality_score": round(sum(quality_scores) / len(quality_scores), 3) if quality_scores else None, "quality_score": aggregate.score,
"rejected_segments": rejected_total, "rejected_segments": rejected_total,
"quality_flags": list(dict.fromkeys(quality_flags)), "quality_flags": list(aggregate.flags),
"inference_diagnostics": { "inference_diagnostics": {
"requested_device": args.device, "requested_device": args.device,
"actual_device": component.get("actual_device") or component.get("device") or args.device, "actual_device": component.get("actual_device") or component.get("device") or args.device,
"primary_backend": "direct_generate", "primary_backend": "direct_generate",
"model_variant": model_variant, "model_variant": model_variant,
"num_beams": 1 if args.device == "GPU" else speech.generation_beams(quality_profile), "num_beams": 1 if args.device == "GPU" else speech.generation_beams(quality_profile),
"segmentation": "webrtcvad", "segmentation": args.segmentation,
"quality_score_semantics": "rule_check",
"language_candidates": detected, "language_candidates": detected,
"selected_language": resolved_language, "selected_language": resolved_language,
"fallback_scope": None, "fallback_scope": None,
+9 -2
View File
@@ -25,7 +25,7 @@ from .database import Database, utcnow
from .jobs import JobCancelled, JobQueue, JobRetry from .jobs import JobCancelled, JobQueue, JobRetry
from .media import MediaInput, MediaService from .media import MediaInput, MediaService
from .runtime import RuntimeToolManager from .runtime import RuntimeToolManager
from .speech_quality import normalize_language, transcript_quality_state from .speech_quality import aggregate_transcript_quality, normalize_language, transcript_quality_state
from .text import search_tokens from .text import search_tokens
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1550,6 +1550,11 @@ class AudioIndexer:
quality_score = getattr(segments, "quality_score", None) quality_score = getattr(segments, "quality_score", None)
rejected_segments = int(getattr(segments, "rejected_segments", 0) or 0) rejected_segments = int(getattr(segments, "rejected_segments", 0) or 0)
quality_flags = list(getattr(segments, "quality_flags", []) or []) quality_flags = list(getattr(segments, "quality_flags", []) or [])
aggregate_quality = aggregate_transcript_quality(
segments, base_score=quality_score, base_flags=quality_flags
)
quality_score = aggregate_quality.score
quality_flags = list(aggregate_quality.flags)
quality_state = transcript_quality_state( quality_state = transcript_quality_state(
quality_score, quality_score,
len(segments), len(segments),
@@ -1581,7 +1586,9 @@ class AudioIndexer:
utcnow(), utcnow(),
), ),
) )
if tokens: # Risky transcripts remain visible for diagnosis and playback,
# but repeated hallucinations must not pollute global search.
if tokens and quality_state != "low_quality":
conn.execute("INSERT INTO text_fts(entry_id,tokens) VALUES(?,?)", (entry_id, tokens)) conn.execute("INSERT INTO text_fts(entry_id,tokens) VALUES(?,?)", (entry_id, tokens))
conn.execute( conn.execute(
"UPDATE videos SET audio_model_version=?,audio_index_revision=?,audio_detected_language=?," "UPDATE videos SET audio_model_version=?,audio_index_revision=?,audio_detected_language=?,"
+57
View File
@@ -43,6 +43,60 @@ class TranscriptQuality:
units: int units: int
@dataclass(frozen=True, slots=True)
class AggregateTranscriptQuality:
score: float
flags: tuple[str, ...]
repeated_phrases: tuple[tuple[str, int], ...]
short_segment_ratio: float
_ENDING_HALLUCINATIONS = {
"拜拜", "再见", "谢谢观看", "谢谢大家观看", "谢谢大家收看",
"感谢观看", "下期再见", "謝謝觀看", "謝謝大家觀看", "謝謝大家收看",
"感謝觀看", "下期再見", "我去看看", "thanksforwatching",
}
def _phrase_key(text: object) -> str:
value = unicodedata.normalize("NFKC", str(text or "")).casefold()
return "".join(character for character in value if character.isalnum())
def aggregate_transcript_quality(
segments: list[dict] | tuple[dict, ...],
*,
base_score: float | None = None,
base_flags: list[str] | tuple[str, ...] = (),
) -> AggregateTranscriptQuality:
"""Detect risks that only become visible across a whole transcript."""
texts = [_phrase_key(item.get("text")) for item in segments]
texts = [text for text in texts if text]
flags = list(dict.fromkeys(str(flag) for flag in base_flags if flag))
if not texts:
return AggregateTranscriptQuality(round(float(base_score or 0.0), 3), tuple(flags), (), 0.0)
counts = Counter(texts)
repeated = tuple(sorted(
((text, count) for text, count in counts.items() if count >= 3),
key=lambda item: (-item[1], item[0]),
)[:12])
short_ratio = sum(len(text) < 4 for text in texts) / len(texts)
repeated_units = sum(count for _text, count in repeated)
penalty = 0.0
if repeated and (repeated_units >= 6 or repeated_units / len(texts) >= 0.20):
flags.append("whole_repeated_phrase")
penalty += 0.35
if short_ratio >= 0.65 and len(texts) >= 10:
flags.append("whole_short_segment_dominance")
penalty += 0.35
if any(text in _ENDING_HALLUCINATIONS for text, _count in repeated):
flags.append("whole_ending_hallucination")
penalty += 0.45
flags.extend(f"repeated_phrase:{text}:{count}" for text, count in repeated)
score = max(0.0, min(1.0, float(1.0 if base_score is None else base_score) - penalty))
return AggregateTranscriptQuality(round(score, 3), tuple(dict.fromkeys(flags)), repeated, round(short_ratio, 3))
def normalize_language(value: object) -> str | None: def normalize_language(value: object) -> str | None:
language = str(value or "").strip().lower() language = str(value or "").strip().lower()
if language.startswith("<|") and language.endswith("|>"): if language.startswith("<|") and language.endswith("|>"):
@@ -98,6 +152,9 @@ def transcript_quality_state(
"no_speech_hallucination", "no_speech_hallucination",
"language_script_conflict", "language_script_conflict",
"untrusted_language", "untrusted_language",
"whole_repeated_phrase",
"whole_short_segment_dominance",
"whole_ending_hallucination",
} }
if high_risk.intersection(flags or ()): if high_risk.intersection(flags or ()):
return "low_quality" return "low_quality"
+3 -3
View File
@@ -40,7 +40,7 @@ type Result = {
}; };
type SearchCoverage = {available:boolean;model_version?:string;total:number;indexed:number;searchable:number;empty:number;queued:number;running:number;failed:number;low_quality?:number;pending:number;percent:number;current_model_percent:number}; type SearchCoverage = {available:boolean;model_version?:string;total:number;indexed:number;searchable:number;empty:number;queued:number;running:number;failed:number;low_quality?:number;pending:number;percent:number;current_model_percent:number};
type TranscriptItem = {id:string;start_ms:number;end_ms:number;raw_text:string;language?:string;quality_score?:number}; type TranscriptItem = {id:string;start_ms:number;end_ms:number;raw_text:string;language?:string;quality_score?:number};
type TranscriptPage = {status:"ready"|"filtered"|"low_quality"|"empty"|"queued"|"running"|"failed"|"unavailable"|"pending";model_version?:string;indexed_version?:string;detected_language?:string;quality_score?:number;quality_state:"ready"|"filtered"|"low_quality"|"empty"|"legacy";rejected_segments:number;quality_flags:string[];items:TranscriptItem[];page:number;page_size:number;total:number;pages:number;job?:Pick<Job,"id"|"status"|"progress"|"message"|"error">}; type TranscriptPage = {status:"ready"|"filtered"|"low_quality"|"empty"|"queued"|"running"|"failed"|"unavailable"|"pending";model_version?:string;indexed_version?:string;detected_language?:string;quality_score?:number;quality_state:"ready"|"filtered"|"low_quality"|"empty"|"legacy";rejected_segments:number;quality_flags:string[];aggregate_risk?:boolean;repeated_phrases?:{text:string;count:number}[];quality_score_semantics?:"rule_check";items:TranscriptItem[];page:number;page_size:number;total:number;pages:number;job?:Pick<Job,"id"|"status"|"progress"|"message"|"error">};
type SpeechConfig = {language_policy:"zh_priority"|"auto"|"zh";quality_profile:"speed"|"balanced"|"accuracy";model_variant:"small"|"medium";index_revision:number}; type SpeechConfig = {language_policy:"zh_priority"|"auto"|"zh";quality_profile:"speed"|"balanced"|"accuracy";model_variant:"small"|"medium";index_revision:number};
type RecognitionType = "visual"|"ocr"|"person"|"audio"|"subtitle"|"metadata"; type RecognitionType = "visual"|"ocr"|"person"|"audio"|"subtitle"|"metadata";
type UploadTask = { type UploadTask = {
@@ -628,7 +628,7 @@ function PlayerPage({ video, startMs, videos, collections, onBack, onPlay, onEdi
</section> </section>
<section className={`transcript-panel ${transcriptOpen?"open":""}`}> <section className={`transcript-panel ${transcriptOpen?"open":""}`}>
<button className="transcript-toggle" aria-expanded={transcriptOpen} onClick={()=>setTranscriptOpen(value=>!value)}><span><Volume2/><span><strong></strong><small>{transcriptLoading?"正在读取识别状态…":transcript?.status==="ready"?`${transcript.total} 个高可信时间片段`:transcript?.status==="filtered"?`${transcript.total} 个可搜索片段,部分内容已过滤`:transcript?.status==="low_quality"?"识别质量偏低,系统将进行一次增强修复":transcript?.status==="empty"?"已识别,未检测到语音":transcript?.status==="running"?`正在识别 ${Math.round((transcript.job?.progress||0)*100)}%`:transcript?.status==="queued"?"等待后台识别":transcript?.status==="failed"?"识别失败":transcript?.status==="unavailable"?"音频模型未安装":"尚未识别"}</small></span></span>{transcriptLoading?<RefreshCw className="spin"/>:transcriptOpen?<ArrowUp/>:<ArrowDown/>}</button> <button className="transcript-toggle" aria-expanded={transcriptOpen} onClick={()=>setTranscriptOpen(value=>!value)}><span><Volume2/><span><strong></strong><small>{transcriptLoading?"正在读取识别状态…":transcript?.status==="ready"?`${transcript.total} 个高可信时间片段`:transcript?.status==="filtered"?`${transcript.total} 个可搜索片段,部分内容已过滤`:transcript?.status==="low_quality"?"识别质量偏低,系统将进行一次增强修复":transcript?.status==="empty"?"已识别,未检测到语音":transcript?.status==="running"?`正在识别 ${Math.round((transcript.job?.progress||0)*100)}%`:transcript?.status==="queued"?"等待后台识别":transcript?.status==="failed"?"识别失败":transcript?.status==="unavailable"?"音频模型未安装":"尚未识别"}</small></span></span>{transcriptLoading?<RefreshCw className="spin"/>:transcriptOpen?<ArrowUp/>:<ArrowDown/>}</button>
{transcriptOpen&&<div className="transcript-body">{transcriptLoading?<AsyncNotice compact label="正在读取音频转写…"/>:<>{transcript&&<div className={`transcript-quality ${transcript.quality_state}`} role="status"><span><strong>{transcript.detected_language?`识别语言:${({zh:"中文",en:"英语",ja:"日语",ko:"韩语"} as Record<string,string>)[transcript.detected_language]||transcript.detected_language}`:"识别语言:自动"}</strong><small>{transcript.quality_state==="low_quality"?`整体质量偏低,已保留可信片段并仅自动增强一次`:transcript.quality_state==="filtered"?`已过滤 ${transcript.rejected_segments} 个不可信片段`:transcript.quality_state==="empty"?"有界语音检测未发现可识别内容":transcript.quality_state==="legacy"?"旧版转写,建议重新识别":"质量检查已通过"}{typeof transcript.quality_score==="number"?` · 可信度 ${Math.round(transcript.quality_score*100)}%`:""}</small></span>{["filtered","low_quality","legacy"].includes(transcript.quality_state)?<AlertCircle/>:<ShieldCheck/>}</div>}{transcript?.items.length?<><div className="transcript-list">{transcript.items.map(item=><button key={item.id} onClick={()=>{seekTo(item.start_ms/1000);void element.current?.play().catch(()=>{})}}><time>{formatTime(item.start_ms)}</time><span>{item.raw_text}</span><Play/></button>)}</div>{transcript.pages>1&&<div className="transcript-pages"><button disabled={transcript.page<=1} onClick={()=>void loadTranscript(transcript.page-1)}><ChevronLeft/></button><span>{transcript.page} / {transcript.pages}</span><button disabled={transcript.page>=transcript.pages} onClick={()=>void loadTranscript(transcript.page+1)}><ChevronRight/></button></div>}</>:<div className="transcript-empty"><VolumeX/><p>{transcript?.job?.error||transcript?.job?.message||"当前视频还没有可显示的音频转写。"}</p></div>}</>}{transcriptError&&<div className="error"><AlertCircle/>{transcriptError}</div>}<button className="secondary transcript-reindex" disabled={transcriptBusy||transcript?.status==="running"||transcript?.status==="queued"} onClick={()=>setTranscriptLanguageOpen(true)}>{transcriptBusy?<RefreshCw className="spin"/>:<RefreshCw/>}{transcript?.status==="failed"?"重试识别":"重新识别"}</button></div>} {transcriptOpen&&<div className="transcript-body">{transcriptLoading?<AsyncNotice compact label="正在读取音频转写…"/>:<>{transcript&&<div className={`transcript-quality ${transcript.quality_state}`} role="status"><span><strong>{transcript.detected_language?`识别语言:${({zh:"中文",en:"英语",ja:"日语",ko:"韩语"} as Record<string,string>)[transcript.detected_language]||transcript.detected_language}`:"识别语言:自动"}</strong><small>{transcript.quality_state==="low_quality"?`检测到整片重复或低质量风险,结果仅供诊断且不进入搜索`:transcript.quality_state==="filtered"?`已过滤 ${transcript.rejected_segments} 个不可信片段`:transcript.quality_state==="empty"?"有界语音检测未发现可识别内容":transcript.quality_state==="legacy"?"旧版转写,建议重新识别":"规则检查未发现明显异常"}{typeof transcript.quality_score==="number"?` · 规则检查分 ${Math.round(transcript.quality_score*100)}%`:""}{transcript.repeated_phrases?.length?<>{" · 重复:"+transcript.repeated_phrases.slice(0,3).map(item=>item.text+"×"+item.count).join("、")}</>:null}</small></span>{["filtered","low_quality","legacy"].includes(transcript.quality_state)?<AlertCircle/>:<ShieldCheck/>}</div>}{transcript?.items.length?<><div className="transcript-list">{transcript.items.map(item=><button key={item.id} onClick={()=>{seekTo(item.start_ms/1000);void element.current?.play().catch(()=>{})}}><time>{formatTime(item.start_ms)}</time><span>{item.raw_text}</span><Play/></button>)}</div>{transcript.pages>1&&<div className="transcript-pages"><button disabled={transcript.page<=1} onClick={()=>void loadTranscript(transcript.page-1)}><ChevronLeft/></button><span>{transcript.page} / {transcript.pages}</span><button disabled={transcript.page>=transcript.pages} onClick={()=>void loadTranscript(transcript.page+1)}><ChevronRight/></button></div>}</>:<div className="transcript-empty"><VolumeX/><p>{transcript?.job?.error||transcript?.job?.message||"当前视频还没有可显示的音频转写。"}</p></div>}</>}{transcriptError&&<div className="error"><AlertCircle/>{transcriptError}</div>}<button className="secondary transcript-reindex" disabled={transcriptBusy||transcript?.status==="running"||transcript?.status==="queued"} onClick={()=>setTranscriptLanguageOpen(true)}>{transcriptBusy?<RefreshCw className="spin"/>:<RefreshCw/>}{transcript?.status==="failed"?"重试识别":"重新识别"}</button></div>}
</section> </section>
</div> </div>
<aside className={`related player-side-panel ${collectionDetail?"player-collection-directory":""}`}> <aside className={`related player-side-panel ${collectionDetail?"player-collection-directory":""}`}>
@@ -1359,7 +1359,7 @@ function SettingsPage({ preferences, sources, onPreferences }: {preferences:Pref
{speechLoad.phase==="loading"||speechLoad.phase==="idle"?<AsyncNotice compact label="正在读取语音识别策略…"/>:speechLoad.phase==="error"&&!speechConfig?<div className="error"><AlertCircle/>{speechLoad.error}<button onClick={()=>void loadSpeech()}></button></div>:speechConfig&&<> {speechLoad.phase==="loading"||speechLoad.phase==="idle"?<AsyncNotice compact label="正在读取语音识别策略…"/>:speechLoad.phase==="error"&&!speechConfig?<div className="error"><AlertCircle/>{speechLoad.error}<button onClick={()=>void loadSpeech()}></button></div>:speechConfig&&<>
<fieldset><legend></legend><div className="audio-model-variants">{(["small","medium"] as const).map(variant=>{const info=models.audio_variants?.[variant]||{};const state=models.installations?.[`audio:${variant}`] as ModelInstallation|undefined;const active=state?.status==="queued"||state?.status==="running";const progress=Math.round(Math.max(0,Math.min(1,state?.progress||0))*100);const selected=speechConfig.model_variant===variant;return <article className={`${selected?"selected":""} ${info.installed?"installed":"missing"}`} key={variant}><button type="button" className="audio-model-choice" disabled={!info.installed||active||(variant==="medium"&&!gpuId)} aria-pressed={selected} onClick={()=>setSpeechConfig({...speechConfig,model_variant:variant})}><span>{selected?<CheckCircle2/>:<Boxes/>}</span><div><strong>Whisper {variant==="medium"?"Medium INT8":"Small"}</strong><small>{variant==="medium"?"高质量 · 仅 Intel GPU · 约 0.78 GB":"标准模型 · 约 0.49 GB · 纯 CPU 设备可用"}</small></div></button>{active?<div className="audio-model-progress" role="status"><span>{state?.message||"正在下载安装"}</span><b>{state?.status==="queued"?"等待":`${progress}%`}</b><i><span style={{width:`${state?.status==="queued"?4:progress}%`}}/></i></div>:info.needs_update?<button className="secondary audio-model-install" disabled={modelBusy||Boolean(modelJob)||(variant==="medium"&&!gpuId)} onClick={()=>void installOnline("audio",variant)}><RefreshCw/> INT8 · {formatSize(info.size_bytes||0)}</button>:info.installed?<div className="audio-model-actions"><span>{selected?"当前选择":formatSize(info.size_bytes||0)}</span>{!selected&&<button className="danger-text" disabled={modelBusy} onClick={()=>void removeAudioVariant(variant)}><Trash2/></button>}</div>:<button className="secondary audio-model-install" disabled={modelBusy||Boolean(modelJob)||(variant==="medium"&&!gpuId)} onClick={()=>void installOnline("audio",variant)}><Download/>{variant==="medium"&&!gpuId?"需要 Intel GPU":"下载安装"}</button>}</article>})}</div></fieldset> <fieldset><legend></legend><div className="audio-model-variants">{(["small","medium"] as const).map(variant=>{const info=models.audio_variants?.[variant]||{};const state=models.installations?.[`audio:${variant}`] as ModelInstallation|undefined;const active=state?.status==="queued"||state?.status==="running";const progress=Math.round(Math.max(0,Math.min(1,state?.progress||0))*100);const selected=speechConfig.model_variant===variant;return <article className={`${selected?"selected":""} ${info.installed?"installed":"missing"}`} key={variant}><button type="button" className="audio-model-choice" disabled={!info.installed||active||(variant==="medium"&&!gpuId)} aria-pressed={selected} onClick={()=>setSpeechConfig({...speechConfig,model_variant:variant})}><span>{selected?<CheckCircle2/>:<Boxes/>}</span><div><strong>Whisper {variant==="medium"?"Medium INT8":"Small"}</strong><small>{variant==="medium"?"高质量 · 仅 Intel GPU · 约 0.78 GB":"标准模型 · 约 0.49 GB · 纯 CPU 设备可用"}</small></div></button>{active?<div className="audio-model-progress" role="status"><span>{state?.message||"正在下载安装"}</span><b>{state?.status==="queued"?"等待":`${progress}%`}</b><i><span style={{width:`${state?.status==="queued"?4:progress}%`}}/></i></div>:info.needs_update?<button className="secondary audio-model-install" disabled={modelBusy||Boolean(modelJob)||(variant==="medium"&&!gpuId)} onClick={()=>void installOnline("audio",variant)}><RefreshCw/> INT8 · {formatSize(info.size_bytes||0)}</button>:info.installed?<div className="audio-model-actions"><span>{selected?"当前选择":formatSize(info.size_bytes||0)}</span>{!selected&&<button className="danger-text" disabled={modelBusy} onClick={()=>void removeAudioVariant(variant)}><Trash2/></button>}</div>:<button className="secondary audio-model-install" disabled={modelBusy||Boolean(modelJob)||(variant==="medium"&&!gpuId)} onClick={()=>void installOnline("audio",variant)}><Download/>{variant==="medium"&&!gpuId?"需要 Intel GPU":"下载安装"}</button>}</article>})}</div></fieldset>
<fieldset><legend></legend><div className="speech-policy-options">{([['zh_priority','中文优先','音轨标签只作提示;弱检测或冲突时回到中文'],['auto','智能检测','由多个有声片段多数决,音轨标签仅用于平票'],['zh','固定中文','所有任务均明确按中文解码']] as const).map(([key,label,description])=><button type="button" className={speechConfig.language_policy===key?"active":""} aria-pressed={speechConfig.language_policy===key} key={key} onClick={()=>setSpeechConfig({...speechConfig,language_policy:key})}><strong>{label}</strong><small>{description}</small>{speechConfig.language_policy===key&&<CheckCircle2/>}</button>)}</div></fieldset> <fieldset><legend></legend><div className="speech-policy-options">{([['zh_priority','中文优先','音轨标签只作提示;弱检测或冲突时回到中文'],['auto','智能检测','由多个有声片段多数决,音轨标签仅用于平票'],['zh','固定中文','所有任务均明确按中文解码']] as const).map(([key,label,description])=><button type="button" className={speechConfig.language_policy===key?"active":""} aria-pressed={speechConfig.language_policy===key} key={key} onClick={()=>setSpeechConfig({...speechConfig,language_policy:key})}><strong>{label}</strong><small>{description}</small>{speechConfig.language_policy===key&&<CheckCircle2/>}</button>)}</div></fieldset>
<fieldset><legend></legend><div className="speech-policy-options quality">{([['speed','速度优先','单 beam 解码,适合快速建立索引'],['balanced','均衡','提高解码候选数,兼顾速度与准确率'],['accuracy','准确率优先','使用更多 beam,处理时间更长']] as const).map(([key,label,description])=><button type="button" className={speechConfig.quality_profile===key?"active":""} aria-pressed={speechConfig.quality_profile===key} key={key} onClick={()=>setSpeechConfig({...speechConfig,quality_profile:key})}><strong>{label}</strong><small>{description}</small>{speechConfig.quality_profile===key&&<CheckCircle2/>}</button>)}</div></fieldset> <fieldset><legend></legend><div className="speech-policy-options quality">{([['speed','速度优先','单 beam 解码,适合快速建立索引'],['balanced','均衡','CPU 会增加候选;Intel GPU 仍为单 beam'],['accuracy','准确率优先','CPU 使用 5 beamIntel GPU 受限为单 beam']] as const).map(([key,label,description])=><button type="button" className={speechConfig.quality_profile===key?"active":""} aria-pressed={speechConfig.quality_profile===key} key={key} onClick={()=>setSpeechConfig({...speechConfig,quality_profile:key})}><strong>{label}</strong><small>{description}</small>{speechConfig.quality_profile===key&&<CheckCircle2/>}</button>)}</div></fieldset>
<p className="speech-resource-note"><ShieldCheck/>Medium GPU退 CPU</p> <p className="speech-resource-note"><ShieldCheck/>Medium GPU退 CPU</p>
{speechLoad.error&&<div className="error"><AlertCircle/>{speechLoad.error}<button onClick={()=>void loadSpeech()}></button></div>} {speechLoad.error&&<div className="error"><AlertCircle/>{speechLoad.error}<button onClick={()=>void loadSpeech()}></button></div>}
<div className="speech-policy-actions"><button className="primary" disabled={speechBusy} onClick={()=>void saveSpeechConfig()}>{speechBusy?<RefreshCw className="spin"/>:<CheckCircle2/>}</button><button className="secondary" disabled={speechBusy} onClick={()=>void reconcileSpeech()}><ShieldCheck/></button></div>{speechMessage&&<p className="status-line"><Activity/>{speechMessage}</p>} <div className="speech-policy-actions"><button className="primary" disabled={speechBusy} onClick={()=>void saveSpeechConfig()}>{speechBusy?<RefreshCw className="spin"/>:<CheckCircle2/>}</button><button className="secondary" disabled={speechBusy} onClick={()=>void reconcileSpeech()}><ShieldCheck/></button></div>{speechMessage&&<p className="status-line"><Activity/>{speechMessage}</p>}
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
import time
import wave
from pathlib import Path
COMBINATIONS = (
("small_vad_gpu_1beam", "small", "GPU", "vad"),
("small_context_gpu_1beam", "small", "GPU", "continuous"),
("small_context_cpu_5beam", "small", "CPU", "continuous"),
("medium_current_baseline", "medium", "GPU", "vad"),
)
def arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Read-only Small/Medium audio A/B diagnostics")
parser.add_argument("--data-dir", required=True, type=Path)
parser.add_argument("--media", required=True, type=Path, action="append")
parser.add_argument("--windows", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--ffmpeg", default="ffmpeg")
parser.add_argument("--force", action="store_true")
return parser.parse_args()
def worker_run(data_dir: Path, wav: Path, window: dict, combination: tuple[str, ...]) -> dict:
name, model, device, segmentation = combination
with wave.open(str(wav), "rb") as handle:
sample_rate = handle.getframerate()
start_ms = int(window["start_ms"])
duration_ms = int(window["end_ms"]) - start_ms
command = [
sys.executable, "-m", "imagefind.audio_worker",
"--data-dir", str(data_dir), "--wav", str(wav),
"--device", device, "--model-variant", model,
"--segmentation", segmentation, "--quality-profile", "accuracy",
"--language-policy", str(window.get("language_policy", "zh_priority")),
"--chunk-seconds", str(max(15, min(30, (duration_ms + 999) // 1000))),
"--overlap-seconds", "0",
"--start-frame", str(start_ms * sample_rate // 1000),
"--max-chunks", "1", "--cpu-threads", "2",
]
started = time.monotonic()
process = subprocess.run(command, text=True, capture_output=True, encoding="utf-8", errors="replace")
elapsed = time.monotonic() - started
events = []
for line in process.stdout.splitlines():
try:
events.append(json.loads(line))
except json.JSONDecodeError:
pass
return {
"combination": name,
"model": model,
"device": device,
"beam": 1 if device == "GPU" else 5,
"segmentation": segmentation,
"elapsed_seconds": round(elapsed, 3),
"window_seconds": round(duration_ms / 1000, 3),
"rtf": round(elapsed / max(0.001, duration_ms / 1000), 3),
"exit_code": process.returncode,
"events": events,
"stderr_tail": process.stderr[-2000:],
}
def main() -> int:
args = arguments()
if args.output.exists() and not args.force:
raise SystemExit("output exists; pass --force to replace this diagnostic artifact")
windows = json.loads(args.windows.read_text(encoding="utf-8"))
artifact = {
"schema_version": 1,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"database_writes": False,
"runs": [],
}
with tempfile.TemporaryDirectory(prefix="imagefind-audio-ab-") as work:
for media in args.media:
wav = Path(work) / f"{media.stem}.wav"
subprocess.run(
[
args.ffmpeg, "-nostdin", "-y", "-i", str(media), "-vn",
"-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(wav),
],
check=True,
capture_output=True,
)
selected = windows.get(media.name)
if not isinstance(selected, list) or len(selected) != 8:
raise SystemExit(f"{media.name}: windows JSON must contain exactly 8 labelled windows")
for window in selected:
for combination in COMBINATIONS:
result = worker_run(args.data_dir, wav, window, combination)
result.update({"media": media.name, "window": window})
artifact["runs"].append(result)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,12 @@
{
"M3316 直男与0-勾引直男体育生2.mp4": [
{"label": "clear_dialogue_1", "start_ms": 0, "end_ms": 20000},
{"label": "clear_dialogue_2", "start_ms": 20000, "end_ms": 40000},
{"label": "short_dense_1", "start_ms": 40000, "end_ms": 60000},
{"label": "short_dense_2", "start_ms": 60000, "end_ms": 80000},
{"label": "suspected_hallucination_1", "start_ms": 80000, "end_ms": 100000},
{"label": "suspected_hallucination_2", "start_ms": 100000, "end_ms": 120000},
{"label": "vad_miss_1", "start_ms": 120000, "end_ms": 140000},
{"label": "vad_miss_2", "start_ms": 140000, "end_ms": 160000}
]
}
+3
View File
@@ -638,6 +638,9 @@ def test_transcript_coverage_pagination_and_manual_reindex(tmp_path: Path, monke
assert payload["items"][0]["start_ms"] == 10_000 assert payload["items"][0]["start_ms"] == 10_000
assert payload["detected_language"] == "zh" assert payload["detected_language"] == "zh"
assert payload["quality_state"] == "ready" assert payload["quality_state"] == "ready"
assert payload["aggregate_risk"] is False
assert payload["repeated_phrases"] == []
assert payload["quality_score_semantics"] == "rule_check"
assert payload["quality_flags"] == [] assert payload["quality_flags"] == []
speech = await client.get("/api/v1/speech/config", headers=headers) speech = await client.get("/api/v1/speech/config", headers=headers)
+36
View File
@@ -120,6 +120,42 @@ def test_audio_indexer_writes_timed_fts_entries_and_reconciles(tmp_path: Path, m
assert json.loads(job["payload_json"]) == {"video_id": "video"} assert json.loads(job["payload_json"]) == {"video_id": "video"}
def test_repeated_hallucinations_are_saved_but_not_searchable(tmp_path: Path, monkeypatch):
app = _app(tmp_path)
service = app.state.services
monkeypatch.setattr(service.media, "input_for", lambda _video: MediaInput("movie.mp4"))
monkeypatch.setattr(
service.media,
"probe",
lambda _media: {"raw": {"streams": [{"codec_type": "audio"}]}, "duration_ms": 30_000},
)
monkeypatch.setattr(
service.speech,
"transcribe",
lambda *_args, **_kwargs: [
{"text": "拜拜", "start_ms": index * 1000, "end_ms": index * 1000 + 800}
for index in range(8)
],
)
job_id = service.jobs.enqueue("transcribe_audio", {"video_id": "video"}, dedupe_key="audio:video")
service.audio_indexer.index(job_id, "video", quality_repair=True)
with service.db.read() as conn:
entries = conn.execute(
"SELECT count(*) FROM text_entries WHERE video_id='video' AND kind='audio'"
).fetchone()[0]
searchable = conn.execute(
"SELECT count(*) FROM text_fts WHERE entry_id IN "
"(SELECT id FROM text_entries WHERE video_id='video' AND kind='audio')"
).fetchone()[0]
video = conn.execute(
"SELECT audio_quality_score,audio_quality_flags_json FROM videos WHERE id='video'"
).fetchone()
assert entries == 8
assert searchable == 0
assert video["audio_quality_score"] < 0.7
assert "whole_ending_hallucination" in json.loads(video["audio_quality_flags_json"])
def test_audio_indexer_retries_medium_memory_pressure(tmp_path: Path, monkeypatch): def test_audio_indexer_retries_medium_memory_pressure(tmp_path: Path, monkeypatch):
app = _app(tmp_path) app = _app(tmp_path)
service = app.state.services service = app.state.services
+27
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from imagefind.speech_quality import ( from imagefind.speech_quality import (
aggregate_transcript_quality,
normalize_language, normalize_language,
pcm16_voiced_regions, pcm16_voiced_regions,
select_language, select_language,
@@ -10,6 +11,32 @@ from imagefind.speech_quality import (
) )
def test_aggregate_quality_rejects_repeated_short_hallucinations() -> None:
segments = ([{"text": ""}] * 11) + ([{"text": "拜拜"}] * 7) + ([{"text": "正常对话内容"}] * 4)
result = aggregate_transcript_quality(segments, base_score=1.0)
assert result.score < 0.7
assert "whole_repeated_phrase" in result.flags
assert "whole_short_segment_dominance" in result.flags
assert "whole_ending_hallucination" in result.flags
assert ("拜拜", 7) in result.repeated_phrases
def test_sports_samples_patterns_cannot_remain_ready() -> None:
sports_2 = ([{"text": ""}] * 11) + ([{"text": "拜拜"}] * 7) + ([{"text": ""}] * 5)
sports_2 += [{"text": f"正常对话{i}"} for i in range(19)]
sports_3 = ([{"text": "拜拜"}] * 8) + ([{"text": ""}] * 7) + ([{"text": ""}] * 7)
sports_3 += ([{"text": "谢谢大家收看"}] * 3) + [{"text": f"正常内容{i}"} for i in range(32)]
for segments in (sports_2, sports_3):
result = aggregate_transcript_quality(segments, base_score=1.0)
assert result.score < 0.7
assert "whole_repeated_phrase" in result.flags
def test_traditional_ending_phrase_is_flagged() -> None:
result = aggregate_transcript_quality([{"text": "謝謝大家收看"}] * 3, base_score=1.0)
assert "whole_ending_hallucination" in result.flags
def test_language_policy_treats_stream_metadata_as_weak_hint() -> None: def test_language_policy_treats_stream_metadata_as_weak_hint() -> None:
assert select_language(["en", "zh"], policy="zh_priority") == "zh" assert select_language(["en", "zh"], policy="zh_priority") == "zh"
assert select_language(["en", "zh"], policy="auto") == "en" assert select_language(["en", "zh"], policy="auto") == "en"