fixed a bug

This commit is contained in:
2026-08-26 10:46:09 +08:00
parent 1b6090e44c
commit a6297e2f9f
4 changed files with 102 additions and 2 deletions

View File

@@ -100,6 +100,7 @@ class MainActivity : Activity() {
private var mediaRecorder: MediaRecorder? = null private var mediaRecorder: MediaRecorder? = null
private var recordingFile: File? = null private var recordingFile: File? = null
private val dubSegments = mutableMapOf<Int, File>() private val dubSegments = mutableMapOf<Int, File>()
private val latestScores = mutableMapOf<Int, ImitationAssessmentResult>()
private var mergedDubFile: File? = null private var mergedDubFile: File? = null
private var dubbingStatusText: TextView? = null private var dubbingStatusText: TextView? = null
private var dubbingPlayback = false private var dubbingPlayback = false
@@ -1207,6 +1208,25 @@ class MainActivity : Activity() {
"{\"sentence_index\":$it}" "{\"sentence_index\":$it}"
} }
text("segments", payload) text("segments", payload)
val scores = dubSegments.keys.sorted().map { index ->
latestScores[index]
}.filterNotNull().joinToString(",", "[", "]") { score ->
buildString {
append("{\"sentence_index\":")
append(score.sentenceIndex)
if (score.overallScore != null) {
append(",\"overall_score\":")
append(score.overallScore)
}
score.recognizedText?.let { text ->
append(",\"recognized_text\":\"")
append(text.replace("\\", "\\\\").replace("\"", "\\\""))
append("\"")
}
append("}")
}
}
text("scores", scores)
dubSegments.toSortedMap().forEach { (index, file) -> dubSegments.toSortedMap().forEach { (index, file) ->
output.writeBytes("--$boundary\r\n") output.writeBytes("--$boundary\r\n")
output.writeBytes( output.writeBytes(
@@ -1260,6 +1280,9 @@ class MainActivity : Activity() {
if (::testStatusText.isInitialized) { if (::testStatusText.isInitialized) {
applyAssessmentResult(result) applyAssessmentResult(result)
} }
sentence.index.let { index ->
latestScores[index] = result
}
} }
override fun onError(error: Throwable) { override fun onError(error: Throwable) {

View File

@@ -2,6 +2,8 @@ import hashlib
import hmac import hmac
import logging import logging
import mimetypes import mimetypes
import shutil
import subprocess
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any, BinaryIO, Dict, List, Optional from typing import Any, BinaryIO, Dict, List, Optional
@@ -388,6 +390,7 @@ def create_app(
video_hash: str = Form(...), video_hash: str = Form(...),
title: str = Form(default="我的口语配音"), title: str = Form(default="我的口语配音"),
segments: str = Form(...), segments: str = Form(...),
scores: str = Form(default="[]"),
files: List[UploadFile] = File(...), files: List[UploadFile] = File(...),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
try: try:
@@ -398,6 +401,17 @@ def create_app(
raise HTTPException(status_code=400, detail="At least one dubbing segment is required.") raise HTTPException(status_code=400, detail="At least one dubbing segment is required.")
if len(segment_items) != len(files): if len(segment_items) != len(files):
raise HTTPException(status_code=400, detail="Segment count does not match audio file count.") raise HTTPException(status_code=400, detail="Segment count does not match audio file count.")
try:
score_items = json.loads(scores)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="scores must be valid JSON.") from exc
if not isinstance(score_items, list):
raise HTTPException(status_code=400, detail="scores must be a JSON array.")
scores_by_index = {
int(item["sentence_index"]): item
for item in score_items
if isinstance(item, dict) and "sentence_index" in item
}
document = find_document(video_hash.lower()) document = find_document(video_hash.lower())
if document is None: if document is None:
raise HTTPException(status_code=404, detail="Video or sentence boundaries were not found.") raise HTTPException(status_code=404, detail="Video or sentence boundaries were not found.")
@@ -418,7 +432,9 @@ 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(_trim_leading_silence, audio_path)
saved_files.append(audio_path) saved_files.append(audio_path)
score = scores_by_index.get(sentence_index, {})
prepared_segments.append({ prepared_segments.append({
"sentence_index": sentence_index, "sentence_index": sentence_index,
"start_ms": boundary.start_ms, "start_ms": boundary.start_ms,
@@ -426,6 +442,8 @@ def create_app(
"text": boundary.text, "text": boundary.text,
"audio_filename": audio_name, "audio_filename": audio_name,
"audio_size_bytes": audio_path.stat().st_size, "audio_size_bytes": audio_path.stat().st_size,
"overall_score": float(score.get("overall_score", 0)),
"recognized_text": str(score.get("recognized_text") or ""),
}) })
share = video_repository.create_dub_share( share = video_repository.create_dub_share(
video_hash=video_hash.lower(), video_hash=video_hash.lower(),
@@ -458,12 +476,15 @@ def create_app(
"video_hash": share["video_hash"], "video_hash": share["video_hash"],
"title": share["title"], "title": share["title"],
"created_at": share["created_at"], "created_at": share["created_at"],
"video_hash": share["video_hash"],
"segments": [ "segments": [
{ {
"index": row["sentence_index"], "index": row["sentence_index"],
"start_ms": row["start_ms"], "start_ms": row["start_ms"],
"end_ms": row["end_ms"], "end_ms": row["end_ms"],
"text": row["text"], "text": row["text"],
"overall_score": row["overall_score"],
"recognized_text": row["recognized_text"],
"audio_url": f"/api/v1/dub-shares/{share_id}/audio/{row['audio_filename']}", "audio_url": f"/api/v1/dub-shares/{share_id}/audio/{row['audio_filename']}",
} }
for row in share["segments"] for row in share["segments"]
@@ -519,6 +540,34 @@ def _persist_upload(source: BinaryIO, destination: Path, max_bytes: int) -> tupl
return digest.hexdigest(), size return digest.hexdigest(), size
def _trim_leading_silence(audio_path: Path) -> None:
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
return
trimmed_path = audio_path.with_name(f"{audio_path.stem}-trimmed{audio_path.suffix}")
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(audio_path),
"-af",
"silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.08",
"-c:a",
"aac",
str(trimmed_path),
]
try:
subprocess.run(command, check=True)
if trimmed_path.stat().st_size == 0:
raise RuntimeError("Trimmed audio is empty.")
trimmed_path.replace(audio_path)
except (OSError, subprocess.SubprocessError):
trimmed_path.unlink(missing_ok=True)
async def _persist_request_stream( async def _persist_request_stream(
request: Request, request: Request,
destination: Path, destination: Path,

View File

@@ -86,12 +86,28 @@ class VideoRepository:
text TEXT, text TEXT,
audio_filename TEXT NOT NULL, audio_filename TEXT NOT NULL,
audio_size_bytes INTEGER NOT NULL, audio_size_bytes INTEGER NOT NULL,
overall_score REAL NOT NULL DEFAULT 0,
recognized_text TEXT NOT NULL DEFAULT '',
FOREIGN KEY (share_id) REFERENCES dub_shares(share_id) ON DELETE CASCADE, FOREIGN KEY (share_id) REFERENCES dub_shares(share_id) ON DELETE CASCADE,
UNIQUE (share_id, sentence_index) UNIQUE (share_id, sentence_index)
); );
""" """
) )
with self._connect() as connection:
columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(dub_share_segments)")
}
if "overall_score" not in columns:
connection.execute(
"ALTER TABLE dub_share_segments ADD COLUMN overall_score REAL NOT NULL DEFAULT 0"
)
if "recognized_text" not in columns:
connection.execute(
"ALTER TABLE dub_share_segments ADD COLUMN recognized_text TEXT NOT NULL DEFAULT ''"
)
def upsert_upload( def upsert_upload(
self, self,
*, *,
@@ -312,8 +328,8 @@ class VideoRepository:
""" """
INSERT INTO dub_share_segments ( INSERT INTO dub_share_segments (
share_id, sentence_index, start_ms, end_ms, text, share_id, sentence_index, start_ms, end_ms, text,
audio_filename, audio_size_bytes audio_filename, audio_size_bytes, overall_score, recognized_text
) VALUES (?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
[ [
( (
@@ -324,6 +340,8 @@ class VideoRepository:
segment["text"], segment["text"],
segment["audio_filename"], segment["audio_filename"],
segment["audio_size_bytes"], segment["audio_size_bytes"],
segment.get("overall_score", 0),
segment.get("recognized_text", ""),
) )
for segment in segments for segment in segments
], ],

View File

@@ -15,6 +15,10 @@ button{flex:1;height:44px;border:0;border-radius:10px;color:#fff;background:#256
.sentence{padding:12px;border-radius:10px;background:#1c2228;color:#cbd5e1;line-height:1.5} .sentence{padding:12px;border-radius:10px;background:#1c2228;color:#cbd5e1;line-height:1.5}
.active-sentence{outline:2px solid #14b8a6;color:#fff} .active-sentence{outline:2px solid #14b8a6;color:#fff}
.selected-sentence{background:#334155;color:#fff} .selected-sentence{background:#334155;color:#fff}
.score{margin-top:6px;font-size:12px;color:#94a3b8}
.score.good{color:#14b8a6}.score.low{color:#f97316}
.footer{margin-top:16px;font-size:13px;color:#94a3b8}
.footer a{color:#60a5fa;text-decoration:none}
</style> </style>
<div class="wrap"> <div class="wrap">
@@ -128,6 +132,11 @@ async function load() {
}); });
video.currentTime = item.start_ms / 1000; video.currentTime = item.start_ms / 1000;
}; };
const score = document.createElement('div');
score.className = 'score' + (item.overall_score >= 80 ? ' good' : item.overall_score && item.overall_score < 60 ? ' low' : '');
score.textContent = `总分 ${Number(item.overall_score || 0).toFixed(1)}`;
if (item.recognized_text) score.textContent += ` · 识别:${item.recognized_text}`;
node.appendChild(score);
sentenceList.appendChild(node); sentenceList.appendChild(node);
}); });
originalButton.onclick = playOriginal; originalButton.onclick = playOriginal;
@@ -142,3 +151,4 @@ async function load() {
} }
load(); load();
</script> </script>
<p class="footer">了解更多或下载跟读App<a href="https://www.pgyer.com/genduchong" target="_blank" rel="noopener">www.pgyer.com/genduchong</a></p>