fixed again
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
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(
|
response = client.post(
|
||||||
"/api/v1/dub-shares",
|
"/api/v1/dub-shares",
|
||||||
data={
|
data={
|
||||||
"video_hash": VIDEO_HASH,
|
"video_hash": VIDEO_HASH,
|
||||||
"segments": json.dumps([{"sentence_index": 0}]),
|
"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 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):
|
||||||
|
|||||||
Reference in New Issue
Block a user