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 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,

View File

@@ -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}`;
}

View File

@@ -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,
):
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", make_wav(), "audio/wav")},
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):