Skip to content

Commit 0e9e64a

Browse files
committed
fix(tts): wrap AudioFileClip.duration in try/finally for elevenlabs, chatterbox, and fish_audio
In a long-running FastAPI server process, MoviePy's AudioFileClip spawns an FFmpeg subprocess. If anything raises between AudioFileClip() and .close() the subprocess is leaked. The three providers affected (elevenlabs_tts, chatterbox_tts, fish_audio_tts) all used the bare sequential pattern; the pre-existing _write_validated_minimax_audio already wraps .duration in try/finally. This commit makes all four sites consistent. Add three unit tests that confirm .close() is called even when reading .duration raises a RuntimeError.
1 parent 465b8b3 commit 0e9e64a

2 files changed

Lines changed: 115 additions & 6 deletions

File tree

app/services/voice.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,8 +1686,10 @@ def elevenlabs_tts(
16861686
f.write(response.content)
16871687

16881688
audio_clip = AudioFileClip(voice_file)
1689-
audio_duration = audio_clip.duration
1690-
audio_clip.close()
1689+
try:
1690+
audio_duration = audio_clip.duration
1691+
finally:
1692+
audio_clip.close()
16911693

16921694
sub_maker = ensure_legacy_submaker_fields(SubMaker())
16931695
logger.success(f"elevenlabs tts succeeded: {voice_file}")
@@ -1773,8 +1775,10 @@ def chatterbox_tts(
17731775
f.write(response.content)
17741776

17751777
audio_clip = AudioFileClip(voice_file)
1776-
audio_duration = audio_clip.duration
1777-
audio_clip.close()
1778+
try:
1779+
audio_duration = audio_clip.duration
1780+
finally:
1781+
audio_clip.close()
17781782

17791783
sub_maker = ensure_legacy_submaker_fields(SubMaker())
17801784
logger.success(f"chatterbox tts succeeded: {voice_file}")
@@ -1915,8 +1919,10 @@ def fish_audio_tts(
19151919
f.write(response.content)
19161920

19171921
audio_clip = AudioFileClip(voice_file)
1918-
audio_duration = audio_clip.duration
1919-
audio_clip.close()
1922+
try:
1923+
audio_duration = audio_clip.duration
1924+
finally:
1925+
audio_clip.close()
19201926

19211927
sub_maker = ensure_legacy_submaker_fields(SubMaker())
19221928
logger.success(f"fish audio tts succeeded: {voice_file}")

test/services/test_voice.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,109 @@ class _FakeResponse:
953953
self.assertIsNone(result)
954954
self.assertEqual(post.call_count, 3)
955955

956+
def _make_broken_clip_class(self, close_calls: list):
957+
"""Return a clip class whose .duration raises and whose .close() records calls."""
958+
959+
class _BrokenClip:
960+
@property
961+
def duration(self):
962+
raise RuntimeError("FFmpeg probe failed")
963+
964+
def close(self):
965+
close_calls.append(True)
966+
967+
return _BrokenClip
968+
969+
def test_elevenlabs_tts_audio_clip_closed_on_duration_error(self):
970+
"""AudioFileClip.close() must be called even when reading .duration raises."""
971+
close_calls: list = []
972+
BrokenClip = self._make_broken_clip_class(close_calls)
973+
974+
class _OkResponse:
975+
status_code = 200
976+
content = b"fake-mp3"
977+
text = ""
978+
979+
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
980+
out = f.name
981+
try:
982+
with (
983+
patch.object(
984+
vs.config,
985+
"elevenlabs",
986+
{"api_key": "test-key", "model_id": "eleven_multilingual_v2"},
987+
),
988+
patch.object(vs.requests, "post", return_value=_OkResponse()),
989+
patch.object(vs, "AudioFileClip", side_effect=lambda _: BrokenClip()),
990+
):
991+
result = vs.elevenlabs_tts("Hello world.", "voice-id", out)
992+
finally:
993+
if os.path.exists(out):
994+
os.remove(out)
995+
996+
self.assertIsNone(result)
997+
self.assertTrue(close_calls, "AudioFileClip.close() was never called")
998+
999+
def test_chatterbox_tts_audio_clip_closed_on_duration_error(self):
1000+
"""AudioFileClip.close() must be called even when reading .duration raises."""
1001+
close_calls: list = []
1002+
BrokenClip = self._make_broken_clip_class(close_calls)
1003+
1004+
class _OkResponse:
1005+
status_code = 200
1006+
content = b"fake-mp3"
1007+
text = ""
1008+
1009+
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
1010+
out = f.name
1011+
try:
1012+
with (
1013+
patch.object(
1014+
vs.config,
1015+
"chatterbox",
1016+
{"base_url": "http://localhost:4123", "api_key": "", "model_id": "chatterbox"},
1017+
),
1018+
patch.object(vs.requests, "post", return_value=_OkResponse()),
1019+
patch.object(vs, "AudioFileClip", side_effect=lambda _: BrokenClip()),
1020+
):
1021+
result = vs.chatterbox_tts("Hello world.", "default", out)
1022+
finally:
1023+
if os.path.exists(out):
1024+
os.remove(out)
1025+
1026+
self.assertIsNone(result)
1027+
self.assertTrue(close_calls, "AudioFileClip.close() was never called")
1028+
1029+
def test_fish_audio_tts_audio_clip_closed_on_duration_error(self):
1030+
"""AudioFileClip.close() must be called even when reading .duration raises."""
1031+
close_calls: list = []
1032+
BrokenClip = self._make_broken_clip_class(close_calls)
1033+
1034+
class _OkResponse:
1035+
status_code = 200
1036+
content = b"x" * 200
1037+
text = ""
1038+
1039+
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
1040+
out = f.name
1041+
try:
1042+
with (
1043+
patch.object(
1044+
vs.config,
1045+
"fish_audio",
1046+
{"api_key": "test-key", "model": "s2.1-pro-free"},
1047+
),
1048+
patch.object(vs.requests, "post", return_value=_OkResponse()),
1049+
patch.object(vs, "AudioFileClip", side_effect=lambda _: BrokenClip()),
1050+
):
1051+
result = vs.fish_audio_tts("Hello world.", out)
1052+
finally:
1053+
if os.path.exists(out):
1054+
os.remove(out)
1055+
1056+
self.assertIsNone(result)
1057+
self.assertTrue(close_calls, "AudioFileClip.close() was never called")
1058+
9561059
def test_generate_subtitle_keeps_edge_provider_for_gemini_legacy_submaker(self):
9571060
"""
9581061
验证 Gemini TTS 返回的 legacy 字幕结构在 edge provider 下可以直接产出

0 commit comments

Comments
 (0)