Skip to content

Commit 4a92e18

Browse files
authored
Merge pull request #1268 from Mihir7027/fix/siliconflow-subtitle-end-truncation
fix(voice): anchor SiliconFlow subtitle timeline end to full audio duration
2 parents ab1c790 + a41d7cb commit 4a92e18

2 files changed

Lines changed: 61 additions & 61 deletions

File tree

app/services/voice.py

Lines changed: 10 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -948,74 +948,24 @@ def siliconflow_tts(
948948
with open(voice_file, "wb") as f:
949949
f.write(response.content)
950950

951-
# 这里仍然沿用项目原有的字幕结构,因此需要补齐旧字段。
952951
sub_maker = ensure_legacy_submaker_fields(SubMaker())
953952

954-
# 获取音频文件的实际长度
955953
try:
956-
# 尝试使用moviepy获取音频长度
957-
from moviepy import AudioFileClip
958-
959954
audio_clip = AudioFileClip(voice_file)
960-
audio_duration = audio_clip.duration
961-
audio_clip.close()
962-
963-
# 将音频长度转换为100纳秒单位(与edge_tts兼容)
964-
audio_duration_100ns = int(audio_duration * 10000000)
965-
966-
# 使用文本分割来创建更准确的字幕
967-
# 将文本按标点符号分割成句子
968-
sentences = utils.split_string_by_punctuations(text)
969-
970-
if sentences:
971-
# 计算每个句子的大致时长(按字符数比例分配)
972-
total_chars = sum(len(s) for s in sentences)
973-
char_duration = (
974-
audio_duration_100ns / total_chars if total_chars > 0 else 0
975-
)
976-
977-
current_offset = 0
978-
for sentence in sentences:
979-
if not sentence.strip():
980-
continue
981-
982-
# 计算当前句子的时长
983-
sentence_chars = len(sentence)
984-
sentence_duration = int(sentence_chars * char_duration)
985-
986-
# 添加到SubMaker
987-
sub_maker.subs.append(sentence)
988-
sub_maker.offset.append(
989-
(current_offset, current_offset + sentence_duration)
990-
)
991-
992-
# 更新偏移量
993-
current_offset += sentence_duration
994-
else:
995-
# 如果无法分割,则使用整个文本作为一个字幕
996-
sub_maker.subs = [text]
997-
sub_maker.offset = [(0, audio_duration_100ns)]
998-
955+
try:
956+
audio_duration = audio_clip.duration
957+
finally:
958+
audio_clip.close()
999959
except Exception as e:
1000-
logger.warning(f"Failed to create accurate subtitles: {str(e)}")
1001-
# 回退到简单的字幕
1002-
sub_maker.subs = [text]
1003-
# 使用音频文件的实际长度,如果无法获取,则假设为10秒
1004-
sub_maker.offset = [
1005-
(
1006-
0,
1007-
audio_duration_100ns
1008-
if "audio_duration_100ns" in locals()
1009-
else 10000000,
1010-
)
1011-
]
960+
logger.warning(f"Failed to read audio duration: {str(e)}")
961+
audio_duration = 10.0
1012962

1013963
logger.success(f"siliconflow tts succeeded: {voice_file}")
1014-
logger.debug(
1015-
"siliconflow subtitle timeline generated, "
1016-
f"subs: {len(sub_maker.subs)}, offsets: {len(sub_maker.offset)}"
964+
return populate_legacy_submaker_with_full_text(
965+
sub_maker=sub_maker,
966+
text=text,
967+
audio_duration_seconds=audio_duration,
1017968
)
1018-
return sub_maker
1019969
else:
1020970
logger.error(
1021971
f"siliconflow tts failed with status code {response.status_code}: {response.text}"

test/services/test_voice.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1318,7 +1318,57 @@ def test_elevenlabs_api_key_matches_music_service(self):
13181318
)
13191319

13201320

1321+
def test_siliconflow_subtitle_spans_full_audio_duration(self):
1322+
"""Last subtitle entry must end at the actual audio end, not truncated early.
1323+
1324+
The old ad-hoc loop applied integer division independently to every
1325+
sentence, so accumulated truncation meant the final subtitle always
1326+
ended a few units before the real audio end. Every other TTS provider
1327+
already delegates to populate_legacy_submaker_with_full_text, which
1328+
anchors the last entry to the full duration; this test verifies
1329+
siliconflow_tts now does the same.
1330+
"""
1331+
audio_duration_seconds = 7.3
1332+
expected_end_100ns = int(audio_duration_seconds * 10_000_000)
1333+
1334+
fake_response = SimpleNamespace(status_code=200, content=b"fake-mp3")
1335+
fake_clip = SimpleNamespace(
1336+
duration=audio_duration_seconds, close=lambda: None
1337+
)
1338+
1339+
with (
1340+
tempfile.TemporaryDirectory() as tmp_dir,
1341+
patch.object(vs.requests, "post", return_value=fake_response),
1342+
patch.object(vs, "AudioFileClip", return_value=fake_clip),
1343+
patch.object(vs.config, "siliconflow", {"api_key": "test-key"}),
1344+
):
1345+
voice_file = str(Path(tmp_dir) / "test.mp3")
1346+
sub_maker = vs.siliconflow_tts(
1347+
text=(
1348+
"First sentence. Second sentence. "
1349+
"Third sentence. Fourth sentence."
1350+
),
1351+
model="FunAudioLLM/CosyVoice2-0.5B",
1352+
voice="FunAudioLLM/CosyVoice2-0.5B:alex",
1353+
voice_rate=1.0,
1354+
voice_file=voice_file,
1355+
)
1356+
1357+
self.assertIsNotNone(sub_maker)
1358+
offsets = getattr(sub_maker, "offset", [])
1359+
self.assertGreater(
1360+
len(offsets), 1, "multi-sentence text must produce multiple subtitles"
1361+
)
1362+
last_end = offsets[-1][1]
1363+
self.assertEqual(
1364+
last_end,
1365+
expected_end_100ns,
1366+
f"last subtitle end ({last_end}) must equal the full audio duration "
1367+
f"({expected_end_100ns} units = {audio_duration_seconds}s)",
1368+
)
1369+
1370+
13211371
if __name__ == "__main__":
13221372
# python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v1
13231373
# python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v2
1324-
unittest.main()
1374+
unittest.main()

0 commit comments

Comments
 (0)