repaire sentences
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user