fixed again

This commit is contained in:
2026-09-05 13:25:12 +08:00
parent 6d9f9a5468
commit 7d843e8ff1
3 changed files with 68 additions and 156 deletions

View File

@@ -2,8 +2,6 @@ import hashlib
import hmac import hmac
import logging import logging
import mimetypes import mimetypes
import shutil
import subprocess
import uuid import uuid
import urllib.request import urllib.request
import urllib.error import urllib.error
@@ -721,7 +719,6 @@ def create_app(
audio_name = f"{uuid.uuid4().hex}{suffix}" audio_name = f"{uuid.uuid4().hex}{suffix}"
audio_path = service_settings.dub_shares_dir / audio_name 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(_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) saved_files.append(audio_path)
score = scores_by_index.get(sentence_index, {}) score = scores_by_index.get(sentence_index, {})
score_details = { score_details = {
@@ -843,76 +840,6 @@ def _persist_upload(source: BinaryIO, destination: Path, max_bytes: int) -> tupl
return digest.hexdigest(), size 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( async def _persist_request_stream(
request: Request, request: Request,
destination: Path, destination: Path,

View File

@@ -40,6 +40,7 @@ const statusText = document.getElementById('status');
const sentenceList = document.getElementById('sentences'); const sentenceList = document.getElementById('sentences');
let dubAudio = new Audio(); let dubAudio = new Audio();
dubAudio.preload = 'auto'; dubAudio.preload = 'auto';
dubAudio.playbackRate = 1;
let segments = []; let segments = [];
let selectedIndex = 0; let selectedIndex = 0;
let mode = null; let mode = null;
@@ -58,39 +59,34 @@ function playOriginal() {
mode = 'original'; mode = 'original';
originalButton.classList.add('active'); originalButton.classList.add('active');
video.muted = false; 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 timeMs = video.currentTime * 1000;
const sentence = segments[selectedIndex]; const selected = segments[selectedIndex];
if (sentence && timeMs >= sentence.end_ms + PLAY_EXTEND_MS) { const active = (selected && timeMs >= selected.start_ms && timeMs < selected.end_ms + PLAY_EXTEND_MS)
video.pause(); ? selected
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
: segments.find(item => timeMs >= item.start_ms && timeMs < item.end_ms); : segments.find(item => timeMs >= item.start_ms && timeMs < item.end_ms);
document.querySelectorAll('.sentence').forEach((node, index) => { document.querySelectorAll('.sentence').forEach((node, index) => {
node.classList.toggle('active-sentence', segments[index] === active); node.classList.toggle('active-sentence', segments[index] === active);
}); });
if (mode !== 'dubbing') return; return { timeMs, selected };
if (!active) { }
dubAudio.pause();
return; 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() { function playDubbing() {
@@ -98,17 +94,33 @@ function playDubbing() {
mode = 'dubbing'; mode = 'dubbing';
dubbingButton.classList.add('active'); dubbingButton.classList.add('active');
video.muted = true; 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() { dubAudio.addEventListener('ended', () => {
const sentence = segments[selectedIndex]; if (mode === 'dubbing') {
if (!sentence) return; video.pause();
video.currentTime = sentence.start_ms / 1000; }
dubAudio.dataset.src = ''; });
dubAudio.src = '';
video.play().catch(() => {});
}
async function load() { async function load() {
try { try {
@@ -154,10 +166,9 @@ async function load() {
}); });
originalButton.onclick = playOriginal; originalButton.onclick = playOriginal;
dubbingButton.onclick = playDubbing; dubbingButton.onclick = playDubbing;
video.addEventListener('timeupdate', syncDub); video.addEventListener('timeupdate', onVideoTimeUpdate);
video.addEventListener('play', () => { if (mode === 'dubbing') syncDub(); }); video.addEventListener('seeked', highlightActiveSentence);
video.addEventListener('pause', () => dubAudio.pause()); // Keep dubbing audio independent: do not pause/seek it to force video alignment.
video.addEventListener('seeked', syncDub);
} catch (error) { } catch (error) {
statusText.textContent = `加载失败:${error.message}`; statusText.textContent = `加载失败:${error.message}`;
} }

View File

@@ -1,7 +1,6 @@
import io import io
import json import json
from pathlib import Path from pathlib import Path
import subprocess
from unittest import mock from unittest import mock
import wave import wave
from dataclasses import replace 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 assert response.json()["video_hash"] == VIDEO_HASH
def test_create_dub_share_aligns_audio_with_whisper_boundaries(tmp_path): def test_create_dub_share_keeps_original_audio_without_alignment(tmp_path):
from sentence_api.transcription import Transcript, TranscriptionSegment """Share audio should be stored as uploaded, without tempo/align processing."""
from types import SimpleNamespace client = make_client(tmp_path)
payload = make_wav()
aligned = tmp_path / "aligned.wav" response = client.post(
aligned.write_bytes(b"aligned") "/api/v1/dub-shares",
calls = [] data={
"video_hash": VIDEO_HASH,
class AlignedFakeTranscriber(FakeTranscriber): "segments": json.dumps([{"sentence_index": 0}]),
available = True },
files={"files": ("dub.wav", payload, "audio/wav")},
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")},
)
assert response.status_code == 201, response.text 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): def test_assessment_requires_a_valid_user_session(tmp_path):