fixed a bug
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user