diff --git a/sentence_api/main.py b/sentence_api/main.py index e760bd9..0f6756f 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -2,8 +2,6 @@ import hashlib import hmac import logging import mimetypes -import shutil -import subprocess import uuid import urllib.request import urllib.error @@ -721,7 +719,6 @@ def create_app( audio_name = f"{uuid.uuid4().hex}{suffix}" audio_path = service_settings.dub_shares_dir / audio_name await run_in_threadpool(_persist_upload, upload.file, audio_path, 50 * 1024 * 1024) - await run_in_threadpool(_align_share_audio, audio_path, boundary.end_ms / 1000, moss) saved_files.append(audio_path) score = scores_by_index.get(sentence_index, {}) score_details = { @@ -843,76 +840,6 @@ def _persist_upload(source: BinaryIO, destination: Path, max_bytes: int) -> tupl return digest.hexdigest(), size -def _align_share_audio(audio_path: Path, target_seconds: float, transcriber: Transcriber) -> None: - ffmpeg = shutil.which("ffmpeg") - if ffmpeg is None: - return - aligned_path = audio_path.with_name(f"{audio_path.stem}-aligned{audio_path.suffix}") - try: - transcript = transcriber.transcribe(audio_path) - speech_segments = [segment for segment in transcript.segments if segment.text.strip()] - if not speech_segments: - return - start_seconds = max(0.0, min(segment.start_seconds for segment in speech_segments) - 0.05) - end_seconds = min( - _media_duration_seconds(audio_path), - max(segment.end_seconds for segment in speech_segments) + 0.58, - ) - if end_seconds <= start_seconds + 0.1: - return - - filters: List[str] = [ - "silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.03" - ] - if target_seconds > 0.2: - speech_seconds = max(0.1, end_seconds - start_seconds - 0.5) - speed_factor = speech_seconds / target_seconds - if 0.5 <= speed_factor <= 2.0: - filters.append(f"atempo={speed_factor:.6f}") - - command = [ - ffmpeg, - "-hide_banner", - "-loglevel", - "error", - "-y", - "-ss", - f"{start_seconds:.3f}", - "-t", - f"{end_seconds - start_seconds:.3f}", - "-i", - str(audio_path), - "-af", - ",".join(filters), - "-c:a", - "aac", - str(aligned_path), - ] - subprocess.run(command, check=True) - if aligned_path.stat().st_size > 0: - aligned_path.replace(audio_path) - except Exception: - logger.warning("Could not align share audio %s; keeping original.", audio_path.name, exc_info=True) - finally: - aligned_path.unlink(missing_ok=True) - - -def _media_duration_seconds(audio_path: Path) -> float: - ffprobe = shutil.which("ffprobe") - if ffprobe is None: - return 0.0 - try: - completed = subprocess.run( - [ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(audio_path)], - check=True, - capture_output=True, - text=True, - ) - return float(completed.stdout.strip()) - except (OSError, ValueError, subprocess.SubprocessError): - return 0.0 - - async def _persist_request_stream( request: Request, destination: Path, diff --git a/sentence_api/static/dub-share.html b/sentence_api/static/dub-share.html index f58bf4a..757904c 100644 --- a/sentence_api/static/dub-share.html +++ b/sentence_api/static/dub-share.html @@ -40,6 +40,7 @@ const statusText = document.getElementById('status'); const sentenceList = document.getElementById('sentences'); let dubAudio = new Audio(); dubAudio.preload = 'auto'; +dubAudio.playbackRate = 1; let segments = []; let selectedIndex = 0; let mode = null; @@ -58,39 +59,34 @@ function playOriginal() { mode = 'original'; originalButton.classList.add('active'); video.muted = false; - playSelectedSentence(); + const sentence = segments[selectedIndex]; + if (!sentence) return; + video.currentTime = sentence.start_ms / 1000; + video.play().catch(() => {}); } -function syncDub() { +function highlightActiveSentence() { const timeMs = video.currentTime * 1000; - const sentence = segments[selectedIndex]; - if (sentence && timeMs >= sentence.end_ms + PLAY_EXTEND_MS) { - video.pause(); - dubAudio.pause(); - video.currentTime = (sentence.end_ms + PLAY_EXTEND_MS) / 1000; - return; - } - const active = (sentence && timeMs >= sentence.start_ms && timeMs < sentence.end_ms + PLAY_EXTEND_MS) - ? sentence + const selected = segments[selectedIndex]; + const active = (selected && timeMs >= selected.start_ms && timeMs < selected.end_ms + PLAY_EXTEND_MS) + ? selected : segments.find(item => timeMs >= item.start_ms && timeMs < item.end_ms); document.querySelectorAll('.sentence').forEach((node, index) => { node.classList.toggle('active-sentence', segments[index] === active); }); - if (mode !== 'dubbing') return; - if (!active) { - dubAudio.pause(); - return; + return { timeMs, selected }; +} + +function onVideoTimeUpdate() { + const { timeMs, selected } = highlightActiveSentence(); + if (!selected) return; + // Only bound the muted video window. Dubbing audio plays at its original speed/duration. + if (timeMs >= selected.end_ms + PLAY_EXTEND_MS) { + video.pause(); + if (mode === 'original') { + video.currentTime = (selected.end_ms + PLAY_EXTEND_MS) / 1000; + } } - const source = active.audio_url; - if (dubAudio.dataset.src !== source) { - dubAudio.dataset.src = source; - dubAudio.src = source; - } - const target = Math.max(0, timeMs - active.start_ms) / 1000; - if (Math.abs(dubAudio.currentTime - target) > 0.25 && Number.isFinite(target)) { - dubAudio.currentTime = target; - } - if (video.paused || dubAudio.paused) dubAudio.play().catch(() => {}); } function playDubbing() { @@ -98,17 +94,33 @@ function playDubbing() { mode = 'dubbing'; dubbingButton.classList.add('active'); video.muted = true; - playSelectedSentence(); + const sentence = segments[selectedIndex]; + if (!sentence || !sentence.audio_url) return; + video.currentTime = sentence.start_ms / 1000; + dubAudio.pause(); + dubAudio.playbackRate = 1; + dubAudio.src = sentence.audio_url; + const startBoth = () => { + try { dubAudio.currentTime = 0; } catch (_) {} + video.play().catch(() => {}); + dubAudio.play().catch(() => {}); + }; + if (dubAudio.readyState >= 2) { + startBoth(); + } else { + dubAudio.onloadeddata = () => { + dubAudio.onloadeddata = null; + startBoth(); + }; + dubAudio.load(); + } } -function playSelectedSentence() { - const sentence = segments[selectedIndex]; - if (!sentence) return; - video.currentTime = sentence.start_ms / 1000; - dubAudio.dataset.src = ''; - dubAudio.src = ''; - video.play().catch(() => {}); -} +dubAudio.addEventListener('ended', () => { + if (mode === 'dubbing') { + video.pause(); + } +}); async function load() { try { @@ -154,10 +166,9 @@ async function load() { }); originalButton.onclick = playOriginal; dubbingButton.onclick = playDubbing; - video.addEventListener('timeupdate', syncDub); - video.addEventListener('play', () => { if (mode === 'dubbing') syncDub(); }); - video.addEventListener('pause', () => dubAudio.pause()); - video.addEventListener('seeked', syncDub); + video.addEventListener('timeupdate', onVideoTimeUpdate); + video.addEventListener('seeked', highlightActiveSentence); + // Keep dubbing audio independent: do not pause/seek it to force video alignment. } catch (error) { statusText.textContent = `加载失败:${error.message}`; } diff --git a/sentence_api/tests/test_assessment_api.py b/sentence_api/tests/test_assessment_api.py index 662b971..014ebf5 100644 --- a/sentence_api/tests/test_assessment_api.py +++ b/sentence_api/tests/test_assessment_api.py @@ -1,7 +1,6 @@ import io import json from pathlib import Path -import subprocess from unittest import mock import wave from dataclasses import replace @@ -161,52 +160,27 @@ def test_get_dub_share_includes_video_hash(tmp_path): assert response.json()["video_hash"] == VIDEO_HASH -def test_create_dub_share_aligns_audio_with_whisper_boundaries(tmp_path): - from sentence_api.transcription import Transcript, TranscriptionSegment - from types import SimpleNamespace - - aligned = tmp_path / "aligned.wav" - aligned.write_bytes(b"aligned") - calls = [] - - class AlignedFakeTranscriber(FakeTranscriber): - available = True - - def transcribe(self, audio_path, language=None): - calls.append(audio_path) - return Transcript( - text="The meeting starts at nine.", - segments=[ - TranscriptionSegment( - start_seconds=0.4, - end_seconds=1.6, - text="The meeting starts at nine.", - ) - ], - ) - - client = make_client(tmp_path, transcriber=AlignedFakeTranscriber()) - original_run = subprocess.run - - def fake_run(command, **kwargs): - Path(command[-1]).write_bytes(b"aligned-audio") - return SimpleNamespace(returncode=0) - - with mock.patch("sentence_api.main.subprocess.run", side_effect=fake_run), mock.patch( - "sentence_api.main._media_duration_seconds", - return_value=2.0, - ): - response = client.post( - "/api/v1/dub-shares", - data={ - "video_hash": VIDEO_HASH, - "segments": json.dumps([{"sentence_index": 0}]), - }, - files={"files": ("dub.wav", make_wav(), "audio/wav")}, - ) +def test_create_dub_share_keeps_original_audio_without_alignment(tmp_path): + """Share audio should be stored as uploaded, without tempo/align processing.""" + client = make_client(tmp_path) + payload = make_wav() + response = client.post( + "/api/v1/dub-shares", + data={ + "video_hash": VIDEO_HASH, + "segments": json.dumps([{"sentence_index": 0}]), + }, + files={"files": ("dub.wav", payload, "audio/wav")}, + ) assert response.status_code == 201, response.text - assert calls[0].read_bytes() == b"aligned-audio" + share_id = response.json()["share_id"] + detail = client.get(f"/api/v1/dub-shares/{share_id}") + assert detail.status_code == 200 + audio_url = detail.json()["segments"][0]["audio_url"] + audio = client.get(audio_url) + assert audio.status_code == 200 + assert audio.content == payload def test_assessment_requires_a_valid_user_session(tmp_path):