admin can split a sentence into 2 sentences
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
@@ -16,9 +18,14 @@ from .generate_boundaries import ALGORITHM_VERSION, make_entry
|
||||
from .models import SentenceBoundary, SentenceBoundaryDocument
|
||||
from .oss import VolcanoOSSUploader
|
||||
from .repository import VideoRepository
|
||||
from .transcription import Transcript, Transcriber, split_sentences_at_punctuation
|
||||
from .transcription import (
|
||||
Transcript,
|
||||
Transcriber,
|
||||
split_sentences_at_punctuation,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MOSS_ALGORITHM_VERSION = "moss-punctuation-v3"
|
||||
|
||||
|
||||
@@ -128,6 +135,223 @@ def extract_audio(media_path: Path, output_path: Path) -> None:
|
||||
raise RuntimeError(f"Could not extract video audio: {message[-2000:]}")
|
||||
|
||||
|
||||
def extract_audio_segment(
|
||||
media_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
start_ms: int,
|
||||
duration_ms: int,
|
||||
) -> None:
|
||||
"""Extract one mono WAV segment for a focused Whisper request."""
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg is None:
|
||||
raise RuntimeError("ffmpeg is required for MOSS transcription but was not found.")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
ffmpeg,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{max(0, start_ms) / 1000:.3f}",
|
||||
"-t",
|
||||
f"{max(1, duration_ms) / 1000:.3f}",
|
||||
"-i",
|
||||
str(media_path),
|
||||
"-vn",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
str(output_path),
|
||||
]
|
||||
completed = subprocess.run(command, capture_output=True, text=True, timeout=1800)
|
||||
if completed.returncode != 0:
|
||||
message = completed.stderr.strip() or "unknown ffmpeg error"
|
||||
raise RuntimeError(f"Could not extract sentence audio: {message[-2000:]}")
|
||||
|
||||
|
||||
def split_sentence_text(
|
||||
*,
|
||||
transcriber: Transcriber,
|
||||
media_path: Path,
|
||||
sentence: SentenceBoundary,
|
||||
text: str,
|
||||
language: Optional[str],
|
||||
) -> List[SentenceBoundary]:
|
||||
"""Split one stored sentence at explicit line breaks.
|
||||
|
||||
When MOSS/Whisper is available, the sentence's own audio is transcribed and
|
||||
its word timestamps select the new boundary. If transcription is unavailable
|
||||
or fails, editing remains possible and boundaries fall back to text-length
|
||||
proportions so the admin UI does not lose the user's manual edit.
|
||||
"""
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
if len(lines) < 2:
|
||||
return [
|
||||
sentence.model_copy(
|
||||
update={
|
||||
"text": text.strip(),
|
||||
"language": language if language is not None else sentence.language,
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
transcript: Optional[Transcript] = None
|
||||
if transcriber.available and media_path.is_file():
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="sentence-split-") as temp_dir:
|
||||
audio_path = Path(temp_dir) / "sentence.wav"
|
||||
extract_audio_segment(
|
||||
media_path,
|
||||
audio_path,
|
||||
start_ms=sentence.start_ms,
|
||||
duration_ms=sentence.end_ms - sentence.start_ms,
|
||||
)
|
||||
transcript = transcriber.transcribe(audio_path, language)
|
||||
except Exception as exc:
|
||||
logger.warning("Whisper split-timing failed for sentence %s: %s", sentence.index, exc)
|
||||
|
||||
boundaries = _estimate_split_boundaries_ms(sentence, lines, transcript)
|
||||
weights = [_text_weight(line) for line in lines]
|
||||
total_weight = max(1, sum(weights))
|
||||
replacements: List[SentenceBoundary] = []
|
||||
for boundary, line, weight in zip(boundaries, lines, weights):
|
||||
reference_duration = sentence.reference_speech_duration_ms or (
|
||||
boundary.end_ms - boundary.start_ms
|
||||
)
|
||||
replacements.append(
|
||||
boundary.model_copy(
|
||||
update={
|
||||
"text": line,
|
||||
"language": language if language is not None else sentence.language,
|
||||
"reference_speech_duration_ms": max(
|
||||
1,
|
||||
int(round(reference_duration * weight / total_weight)),
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
return replacements
|
||||
|
||||
|
||||
def _estimate_split_boundaries_ms(
|
||||
sentence: SentenceBoundary,
|
||||
lines: List[str],
|
||||
transcript: Optional[Transcript],
|
||||
) -> List[SentenceBoundary]:
|
||||
estimates = _whisper_split_boundaries_ms(sentence, lines, transcript)
|
||||
if len(estimates) != len(lines) - 1:
|
||||
estimates = _proportional_split_boundaries_ms(sentence, lines)
|
||||
|
||||
# Keep every split strictly inside the source sentence, even if a Whisper
|
||||
# timestamp is slightly outside or two estimates collapse to one point.
|
||||
previous = sentence.start_ms
|
||||
remaining = len(lines)
|
||||
for index, estimate in enumerate(list(estimates)):
|
||||
low = previous + 1
|
||||
high = sentence.end_ms - (remaining - 1)
|
||||
if low >= high:
|
||||
estimates = _proportional_split_boundaries_ms(sentence, lines)
|
||||
break
|
||||
estimate = min(max(estimate, low), high)
|
||||
estimates[index] = estimate
|
||||
previous = estimate
|
||||
remaining -= 1
|
||||
|
||||
starts = [sentence.start_ms, *estimates]
|
||||
ends = [*estimates, sentence.end_ms]
|
||||
return [
|
||||
SentenceBoundary(
|
||||
index=index,
|
||||
start_ms=start,
|
||||
end_ms=end,
|
||||
text=lines[index],
|
||||
language=sentence.language,
|
||||
reference_speech_duration_ms=max(1, end - start),
|
||||
)
|
||||
for index, (start, end) in enumerate(zip(starts, ends))
|
||||
]
|
||||
|
||||
|
||||
def _whisper_split_boundaries_ms(
|
||||
sentence: SentenceBoundary,
|
||||
lines: List[str],
|
||||
transcript: Optional[Transcript],
|
||||
) -> List[int]:
|
||||
if transcript is None or not transcript.segments:
|
||||
return []
|
||||
|
||||
source_ratios = _cumulative_ratios([_text_weight(line) for line in lines])
|
||||
words = [word for segment in transcript.segments for word in (segment.words or [])]
|
||||
if len(words) >= 2:
|
||||
span_start = words[0].start_seconds
|
||||
span_end = max(word.end_seconds for word in words)
|
||||
if span_end <= span_start:
|
||||
return []
|
||||
total_weight = sum(max(1, len(word.text.strip())) for word in words)
|
||||
cumulative = 0
|
||||
samples: List[tuple[float, int]] = []
|
||||
for word in words[:-1]:
|
||||
cumulative += max(1, len(word.text.strip()))
|
||||
timestamp_ms = sentence.start_ms + int(
|
||||
round(
|
||||
(word.end_seconds - span_start)
|
||||
/ (span_end - span_start)
|
||||
* (sentence.end_ms - sentence.start_ms)
|
||||
)
|
||||
)
|
||||
samples.append((cumulative / total_weight, timestamp_ms))
|
||||
return [
|
||||
min(samples, key=lambda item: (abs(item[0] - ratio), item[1]))[1]
|
||||
for ratio in source_ratios
|
||||
]
|
||||
|
||||
# Some MOSS configurations omit word timestamps. Segment ends are still
|
||||
# much better than blind proportional allocation when they exist.
|
||||
if len(transcript.segments) < 2:
|
||||
return []
|
||||
total_weight = sum(max(1, len(segment.text.strip())) for segment in transcript.segments)
|
||||
cumulative = 0
|
||||
samples: List[tuple[float, int]] = []
|
||||
for segment in transcript.segments[:-1]:
|
||||
cumulative += max(1, len(segment.text.strip()))
|
||||
timestamp_ms = sentence.start_ms + int(round(segment.end_seconds * 1000))
|
||||
samples.append((cumulative / total_weight, timestamp_ms))
|
||||
return [
|
||||
min(samples, key=lambda item: (abs(item[0] - ratio), item[1]))[1]
|
||||
for ratio in source_ratios
|
||||
]
|
||||
|
||||
|
||||
def _proportional_split_boundaries_ms(
|
||||
sentence: SentenceBoundary,
|
||||
lines: List[str],
|
||||
) -> List[int]:
|
||||
duration = sentence.end_ms - sentence.start_ms
|
||||
return [
|
||||
sentence.start_ms + int(round(ratio * duration))
|
||||
for ratio in _cumulative_ratios([_text_weight(line) for line in lines])
|
||||
]
|
||||
|
||||
|
||||
def _cumulative_ratios(weights: List[int]) -> List[float]:
|
||||
total = max(1, sum(weights))
|
||||
cumulative = 0
|
||||
ratios = []
|
||||
for weight in weights[:-1]:
|
||||
cumulative += weight
|
||||
ratios.append(cumulative / total)
|
||||
return ratios
|
||||
|
||||
|
||||
def _text_weight(text: str) -> int:
|
||||
return max(1, sum(1 for char in text if not char.isspace()))
|
||||
|
||||
|
||||
def document_from_transcript(
|
||||
*,
|
||||
video_hash: str,
|
||||
|
||||
Reference in New Issue
Block a user