Skip to content

Commit fdf3a3f

Browse files
committed
fix: stabilize translation and audio generation
Validate translation responses and fail over between free providers. Fix logical audio URLs, bounded TTS generation, retries, and regression coverage for v1.2.1.
1 parent ce24ab8 commit fdf3a3f

11 files changed

Lines changed: 500 additions & 133 deletions

app.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,20 +166,27 @@ async def generate(request: Request, text: str = Form(""), voice: str = Form("")
166166

167167
# 翻译句子
168168
lang_code = lang if lang in LANGUAGE_CODES else "en"
169-
translated_sentences = get_text_translated(sentences, from_lang=lang_code)
169+
translated_sentences, translation_warning = get_text_translated(
170+
sentences, from_lang=lang_code
171+
)
170172

171173
# 生成音频
172174
voice_name = voice if voice else "en-US-ChristopherNeural"
173175
audio_dir, audio_filenames, warning_msg = await generate_audio(sentences, voice_name, title)
174176

175-
# 构建结果列表
177+
# 构建结果列表。结果中只保存可被 HTTP/导出页面使用的逻辑路径,
178+
# 绝不把本机磁盘绝对路径暴露给浏览器。
176179
results = []
177180
for i, (sentence, translation, audio_filename) in enumerate(zip(sentences, translated_sentences, audio_filenames)):
178-
audio_path = Path(audio_dir) / audio_filename
181+
audio_path = None
182+
if audio_filename:
183+
audio_path = "/".join(
184+
(Path(AUDIO_DIR).name, title, audio_filename)
185+
)
179186
results.append({
180187
"sentence": sentence,
181188
"translation": translation,
182-
"audio_path": str(audio_path).replace("\\", "/") # 确保路径分隔符统一
189+
"audio_path": audio_path,
183190
})
184191

185192
# 清理临时文件
@@ -196,13 +203,10 @@ async def generate(request: Request, text: str = Form(""), voice: str = Form("")
196203
CURRENT_UUID = title
197204

198205
# 返回结果页面
199-
if warning_msg:
200-
return templates.TemplateResponse(request, "results.html", {
201-
"results": results,
202-
"warning": warning_msg
203-
})
206+
warnings = [warning for warning in (translation_warning, warning_msg) if warning]
204207
return templates.TemplateResponse(request, "results.html", {
205-
"results": results
208+
"results": results,
209+
"warning": ";".join(warnings),
206210
})
207211

208212
except Exception as e:

templates/export_template.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,14 @@ <h1><a href="https://github.com/Pi3-l22/TingJu" target="_blank">听句 TingJu</a
5151
<!-- <input class="user-input" type="text" placeholder="播放音频,听写句子..."> -->
5252
<textarea class="user-input" placeholder="播放音频,听写句子..." spellcheck="true" autocomplete="off"></textarea>
5353
<div class=" audio-controls">
54+
{% if item.audio_path %}
5455
<audio controls class="audio-player">
5556
<source src="./{{ item.audio_path }}" type="audio/mpeg">
5657
<span class="warning">您的浏览器不支持音频播放,换一个浏览器试试</span>
5758
</audio>
59+
{% else %}
60+
<span class="warning">该句音频生成失败,请稍后重试</span>
61+
{% endif %}
5862
</div>
5963
</div>
6064
{% endfor %}
@@ -69,4 +73,4 @@ <h1><a href="https://github.com/Pi3-l22/TingJu" target="_blank">听句 TingJu</a
6973
<script src="./js/results.js"></script>
7074
</body>
7175

72-
</html>
76+
</html>

templates/results.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,14 @@ <h1><a href="https://github.com/Pi3-l22/TingJu" target="_blank">听句 TingJu</a
5252
<!-- <input class="user-input" type="text" placeholder="播放音频,听写句子..."> -->
5353
<textarea class="user-input" placeholder="播放音频,听写句子..." spellcheck="true" autocomplete="off"></textarea>
5454
<div class=" audio-controls">
55+
{% if item.audio_path %}
5556
<audio controls class="audio-player">
5657
<source src="/{{ item.audio_path }}" type="audio/mpeg">
5758
<span class="warning">您的浏览器不支持音频播放,换一个浏览器试试</span>
5859
</audio>
60+
{% else %}
61+
<span class="warning">该句音频生成失败,请稍后重试</span>
62+
{% endif %}
5963
</div>
6064
</div>
6165
{% endfor %}
@@ -71,4 +75,4 @@ <h1><a href="https://github.com/Pi3-l22/TingJu" target="_blank">听句 TingJu</a
7175
<script src="/static/js/results.js"></script>
7276
</body>
7377

74-
</html>
78+
</html>

tests/test_app.py

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
import tempfile
33
import os
44
import shutil
5+
import re
56
from pathlib import Path
67
from fastapi.testclient import TestClient
8+
from unittest.mock import AsyncMock, patch
79

810
from app import app, TEMP_DIR, EXPORT_DIR, AUDIO_DIR, cleanup_temp_files
11+
from utils.audio_generator import _get_filename
912

1013
class TestApp(unittest.IsolatedAsyncioTestCase):
1114
def setUp(self):
@@ -48,7 +51,10 @@ async def test_manual_input(self):
4851

4952
async def test_get_voices(self):
5053
"""测试获取音色列表"""
51-
response = self.client.get("/voices")
54+
with patch("app.list_voices", new=AsyncMock(return_value=[
55+
{"name": "en-US-TestNeural", "gender": "Female", "style": []}
56+
])):
57+
response = self.client.get("/voices")
5258
self.assertEqual(response.status_code, 200)
5359
data = response.json()
5460
# 检查返回数据是否包含voices键
@@ -102,11 +108,25 @@ async def test_upload_file_unsupported(self):
102108
async def test_generate(self):
103109
"""测试生成音频和翻译功能"""
104110
test_text = "This is a test sentence. This is another test sentence."
105-
response = self.client.post("/generate", data={
106-
"text": test_text,
107-
"voice": "en-US-ChristopherNeural",
108-
"lang": "en"
109-
})
111+
112+
async def fake_generate_audio(sentences, voice_name, title):
113+
audio_dir = Path(AUDIO_DIR) / title
114+
audio_dir.mkdir(parents=True, exist_ok=True)
115+
filenames = []
116+
for index, sentence in enumerate(sentences, start=1):
117+
filename = f"{index}_{_get_filename(sentence)}"
118+
(audio_dir / filename).write_bytes(b"\xff\xf3\x64\xc4")
119+
filenames.append(filename)
120+
return audio_dir, filenames, ""
121+
122+
with patch("app.get_text_translated", return_value=(
123+
["这是测试句子。", "这是另一个测试句子。"], ""
124+
)), patch("app.generate_audio", new=fake_generate_audio):
125+
response = self.client.post("/generate", data={
126+
"text": test_text,
127+
"voice": "en-US-ChristopherNeural",
128+
"lang": "en"
129+
})
110130

111131
# 检查响应
112132
self.assertEqual(response.status_code, 200)
@@ -118,6 +138,14 @@ async def test_generate(self):
118138
self.assertIn("This is a test sentence.", response.text)
119139
self.assertIn("This is another test sentence.", response.text)
120140

141+
audio_urls = re.findall(r'<source src="([^"]+)"', response.text)
142+
self.assertEqual(len(audio_urls), 2)
143+
for audio_url in audio_urls:
144+
self.assertTrue(audio_url.startswith("/audios/"))
145+
audio_response = self.client.get(audio_url)
146+
self.assertEqual(audio_response.status_code, 200)
147+
self.assertTrue(audio_response.content.startswith(b"\xff\xf3"))
148+
121149
async def test_generate_empty_text(self):
122150
"""测试生成空文本时的处理"""
123151
response = self.client.post("/generate", data={
@@ -136,11 +164,24 @@ async def test_export_content(self):
136164
"""测试导出功能"""
137165
# 先生成一些内容,设置CURRENT_UUID
138166
test_text = "This is a test sentence. This is another test sentence."
139-
response = self.client.post("/generate", data={
140-
"text": test_text,
141-
"voice": "en-US-ChristopherNeural",
142-
"lang": "en"
143-
})
167+
async def fake_generate_audio(sentences, voice_name, title):
168+
audio_dir = Path(AUDIO_DIR) / title
169+
audio_dir.mkdir(parents=True, exist_ok=True)
170+
filenames = []
171+
for index, sentence in enumerate(sentences, start=1):
172+
filename = f"{index}_{_get_filename(sentence)}"
173+
(audio_dir / filename).write_bytes(b"\xff\xf3\x64\xc4")
174+
filenames.append(filename)
175+
return audio_dir, filenames, ""
176+
177+
with patch("app.get_text_translated", return_value=(
178+
["这是测试句子。", "这是另一个测试句子。"], ""
179+
)), patch("app.generate_audio", new=fake_generate_audio):
180+
response = self.client.post("/generate", data={
181+
"text": test_text,
182+
"voice": "en-US-ChristopherNeural",
183+
"lang": "en"
184+
})
144185

145186
self.assertEqual(response.status_code, 200)
146187

@@ -164,7 +205,11 @@ async def test_export_content(self):
164205
self.assertTrue((export_path / "css").exists())
165206
self.assertTrue((export_path / "js").exists())
166207
self.assertTrue((export_path / "img").exists())
167-
self.assertTrue((export_path / AUDIO_DIR).exists())
208+
audios_path = export_path / Path(AUDIO_DIR).name
209+
self.assertTrue(audios_path.exists())
210+
exported_html = (export_path / "index.html").read_text(encoding="utf-8")
211+
self.assertIn("./audios/", exported_html)
212+
self.assertNotIn("D:/", exported_html)
168213

169214
if __name__ == "__main__":
170-
unittest.main()
215+
unittest.main()

tests/test_audio_generator.py

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,90 @@
11
import unittest
2-
import shutil
2+
import tempfile
33
from pathlib import Path
4-
from utils.audio_generator import list_voices, generate_audio, AUDIO_DIR
4+
from unittest.mock import AsyncMock, patch
5+
6+
from utils.audio_generator import list_voices, generate_audio
57

68
class TestAudioGenerator(unittest.IsolatedAsyncioTestCase):
79
async def test_list_voices(self):
810
"""测试音色获取功能"""
9-
voices = await list_voices()
11+
class FakeVoices:
12+
def find(self, **kwargs):
13+
return [{
14+
"ShortName": "en-US-TestNeural",
15+
"Gender": "Female",
16+
"VoiceTag": {
17+
"ContentCategories": [],
18+
"VoicePersonalities": [],
19+
},
20+
}]
21+
22+
async def create_voices():
23+
return FakeVoices()
24+
25+
with patch("utils.audio_generator.VoicesManager.create", new=create_voices):
26+
voices = await list_voices()
27+
1028
self.assertIsInstance(voices, list)
11-
self.assertGreater(len(voices), 10)
29+
self.assertEqual(voices[0]["name"], "en-US-TestNeural")
1230

1331
async def test_generate_audio(self):
1432
"""测试音频生成功能"""
15-
text_list = ['It is often said that we are what we repeatedly do.',
16-
'This simple statement highlights the incredible influence of our daily habits.',
17-
'Habits shape our thoughts, guide our actions, and ultimately determine the kind of life we live.',
18-
'Whether good or bad, habits are powerful forces that quietly direct our future.']
19-
voice_name = "en-US-ChristopherNeural"
20-
title = "test"
21-
audio_dir, filenames, warning_msg = await generate_audio(text_list, voice_name, title)
22-
print(audio_dir, filenames, warning_msg)
23-
24-
self.assertEqual(len(text_list), len(filenames))
25-
self.assertEqual(audio_dir, Path(AUDIO_DIR, title))
26-
self.assertTrue(audio_dir.exists())
27-
self.assertEqual(warning_msg, "")
28-
for i, filename in enumerate(filenames):
29-
self.assertTrue(audio_dir.joinpath(filename).exists())
30-
self.assertEqual(filename.split("_")[0], str(i+1))
31-
self.assertEqual(filename.split(".")[1], "mp3")
32-
self.assertEqual(len(filename.split("_")[1].split(".")[0]), 32)
33-
34-
# 清理测试目录
35-
dir = Path(audio_dir)
36-
if dir.exists():
37-
shutil.rmtree(dir)
33+
class FakeCommunicate:
34+
def __init__(self, text, voice):
35+
self.text = text
36+
37+
async def save(self, path):
38+
Path(path).write_bytes(b"\xff\xf3\x64\xc4")
39+
40+
text_list = ["First sentence.", "Second sentence.", "Third sentence."]
41+
with tempfile.TemporaryDirectory() as temp_dir, patch(
42+
"utils.audio_generator.AUDIO_DIR", Path(temp_dir) / "audios"
43+
), patch("utils.audio_generator.edge_tts.Communicate", FakeCommunicate):
44+
audio_dir, filenames, warning_msg = await generate_audio(
45+
text_list, "en-US-TestNeural", "test"
46+
)
47+
48+
self.assertEqual(len(text_list), len(filenames))
49+
self.assertEqual(audio_dir, Path(temp_dir) / "audios" / "test")
50+
self.assertTrue(audio_dir.exists())
51+
self.assertEqual(warning_msg, "")
52+
for i, filename in enumerate(filenames):
53+
self.assertIsNotNone(filename)
54+
self.assertTrue(audio_dir.joinpath(filename).exists())
55+
self.assertEqual(filename.split("_")[0], str(i + 1))
56+
self.assertEqual(filename.split(".")[1], "mp3")
57+
58+
async def test_invalid_audio_is_retried_and_keeps_alignment(self):
59+
attempts = {}
60+
61+
class FakeCommunicate:
62+
def __init__(self, text, voice):
63+
self.text = text
64+
65+
async def save(self, path):
66+
attempts[self.text] = attempts.get(self.text, 0) + 1
67+
if self.text == "retry" and attempts[self.text] == 1:
68+
raise RuntimeError("temporary")
69+
if self.text == "invalid":
70+
Path(path).write_bytes(b"bad!")
71+
else:
72+
Path(path).write_bytes(b"\xff\xf3\x64\xc4")
73+
74+
with tempfile.TemporaryDirectory() as temp_dir, patch(
75+
"utils.audio_generator.AUDIO_DIR", Path(temp_dir) / "audios"
76+
), patch("utils.audio_generator.edge_tts.Communicate", FakeCommunicate), patch(
77+
"utils.audio_generator.asyncio.sleep", new=AsyncMock()
78+
):
79+
audio_dir, filenames, warning_msg = await generate_audio(
80+
["ok", "retry", "invalid"], "en-US-TestNeural", "test"
81+
)
82+
83+
self.assertIsNotNone(filenames[0])
84+
self.assertIsNotNone(filenames[1])
85+
self.assertIsNone(filenames[2])
86+
self.assertEqual(attempts["retry"], 2)
87+
self.assertIn("第 3 个音频生成失败", warning_msg)
3888

3989
if __name__ == "__main__":
40-
unittest.main()
90+
unittest.main()

0 commit comments

Comments
 (0)