repaire sentences

This commit is contained in:
2026-10-21 19:39:04 +08:00
parent 176d722c77
commit 532d9900ba
13 changed files with 1233 additions and 60 deletions

View File

@@ -4,7 +4,7 @@ import subprocess
import tempfile
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Sequence, Tuple
from .audio_metrics import (
AudioAnalysisError,
@@ -29,6 +29,10 @@ from .transcription import (
logger = logging.getLogger(__name__)
MOSS_ALGORITHM_VERSION = "moss-punctuation-v4"
SILENCE_ALGORITHM_VERSION = "silence-rms-v1"
GAP_REPAIR_MIN_MS = 8_000
GAP_REPAIR_INSET_MS = 150
GAP_REPAIR_MIN_SPEECH_MS = 400
GAP_REPAIR_MIN_SENTENCE_MS = 100
_CUES_ID = b"\x1e\x53\xb6\x80"
_CLUSTER_ID = b"\x1f\x43\xb6\x75"
_HEADER_SCAN_SIZE = 64 * 1024
@@ -173,6 +177,106 @@ class VideoProcessor:
)
self.repository.mark_oss_uploaded(document.video_hash, remote_url)
def repair_gaps(
self,
video_hash: str,
min_gap_ms: int = GAP_REPAIR_MIN_MS,
) -> Dict[str, int]:
"""Re-transcribe long unsentenced gaps without replacing existing cues."""
if min_gap_ms < 1:
raise ValueError("min_gap_ms must be positive")
if not self.transcriber.available:
raise RuntimeError("MOSS transcription is not configured on this server.")
video = self.repository.get_video(video_hash)
if video is None:
raise KeyError(video_hash)
document = self.repository.get_document(video_hash)
if document is None or not document.sentences:
raise ValueError("Sentence boundaries are not available for this video.")
media_path = self.settings.videos_dir / video["stored_filename"]
if not media_path.is_file():
raise FileNotFoundError(f"Stored video is missing: {media_path.name}")
gaps = find_sentence_gaps(document, min_gap_ms)
inserted: List[SentenceBoundary] = []
transcribed_gaps = 0
if gaps:
language = video.get("language")
with tempfile.TemporaryDirectory(prefix="gap-repair-") as temp_dir:
for gap_index, (raw_start, raw_end) in enumerate(gaps):
clipped = _inset_gap(raw_start, raw_end, document.duration_ms)
if clipped is None:
continue
start_ms, end_ms = clipped
chunk_path = Path(temp_dir) / f"gap-{gap_index}.wav"
try:
extract_audio_segment(
media_path,
chunk_path,
start_ms=start_ms,
duration_ms=end_ms - start_ms,
)
samples, sample_rate = decode_audio_mono(chunk_path)
metrics = analyze_samples(samples, sample_rate)
except (AudioAnalysisError, RuntimeError) as exc:
logger.info(
"Skipping sentence gap %s-%sms: %s",
start_ms,
end_ms,
exc,
)
continue
if metrics.speech_duration_ms < GAP_REPAIR_MIN_SPEECH_MS:
continue
try:
transcript = self.transcriber.transcribe(chunk_path, language)
except Exception as exc:
logger.warning(
"Gap transcription failed for %s %s-%sms: %s",
video_hash,
start_ms,
end_ms,
exc,
)
continue
gap_document = document_from_transcript(
video_hash=video_hash,
duration_ms=end_ms - start_ms,
transcript=transcript,
language=language,
audio_path=chunk_path,
end_padding_ms=self.settings.moss_end_padding_ms,
)
shifted = _shift_gap_sentences(
gap_document.sentences,
offset_ms=start_ms,
limit_end_ms=end_ms,
)
if not shifted:
continue
transcribed_gaps += 1
inserted.extend(shifted)
if not inserted:
return {
"min_gap_ms": min_gap_ms,
"gap_count": len(gaps),
"transcribed_gaps": transcribed_gaps,
"inserted_count": 0,
"sentence_count": len(document.sentences),
}
merged = _merge_sentence_lists(document.sentences, inserted)
updated = document.model_copy(update={"sentences": merged})
saved = self.repository.replace_sentences(updated)
return {
"min_gap_ms": min_gap_ms,
"gap_count": len(gaps),
"transcribed_gaps": transcribed_gaps,
"inserted_count": len(saved.sentences) - len(document.sentences),
"sentence_count": len(saved.sentences),
}
def extract_audio(media_path: Path, output_path: Path) -> None:
ffmpeg = shutil.which("ffmpeg")
@@ -241,6 +345,82 @@ def extract_audio_segment(
raise RuntimeError(f"Could not extract sentence audio: {message[-2000:]}")
def find_sentence_gaps(
document: SentenceBoundaryDocument,
min_gap_ms: int = GAP_REPAIR_MIN_MS,
) -> List[Tuple[int, int]]:
"""Return [start_ms, end_ms) ranges that are missing sentence coverage."""
if min_gap_ms < 1:
raise ValueError("min_gap_ms must be positive")
gaps: List[Tuple[int, int]] = []
cursor = 0
for sentence in document.sentences:
if sentence.start_ms - cursor >= min_gap_ms:
gaps.append((cursor, sentence.start_ms))
cursor = max(cursor, sentence.end_ms)
if document.duration_ms - cursor >= min_gap_ms:
gaps.append((cursor, document.duration_ms))
return gaps
def _inset_gap(
start_ms: int,
end_ms: int,
duration_ms: int,
inset_ms: int = GAP_REPAIR_INSET_MS,
) -> Optional[Tuple[int, int]]:
start = start_ms + (inset_ms if start_ms > 0 else 0)
end = end_ms - (inset_ms if end_ms < duration_ms else 0)
if end - start < GAP_REPAIR_MIN_SPEECH_MS:
return None
return start, end
def _shift_gap_sentences(
sentences: Sequence[SentenceBoundary],
*,
offset_ms: int,
limit_end_ms: int,
) -> List[SentenceBoundary]:
shifted: List[SentenceBoundary] = []
previous_end = offset_ms
for sentence in sentences:
start_ms = max(previous_end, sentence.start_ms + offset_ms)
end_ms = min(limit_end_ms, sentence.end_ms + offset_ms)
text = (sentence.text or "").strip()
if not text or end_ms - start_ms < GAP_REPAIR_MIN_SENTENCE_MS:
continue
shifted.append(
sentence.model_copy(
update={
"index": len(shifted),
"start_ms": start_ms,
"end_ms": end_ms,
"text": text,
"zh_text": None,
}
)
)
previous_end = end_ms
return shifted
def _merge_sentence_lists(
existing: Sequence[SentenceBoundary],
inserted: Sequence[SentenceBoundary],
) -> List[SentenceBoundary]:
combined = [*existing, *inserted]
combined.sort(key=lambda sentence: (sentence.start_ms, sentence.end_ms))
merged: List[SentenceBoundary] = []
previous_end = 0
for sentence in combined:
if sentence.start_ms < previous_end:
continue
merged.append(sentence.model_copy(update={"index": len(merged)}))
previous_end = sentence.end_ms
return merged
def split_sentence_text(
*,
transcriber: Transcriber,