Skip to content

Commit 7fc6a9e

Browse files
authored
Merge pull request #1263 from YUSAKRU/fix/audio-duration-from-file
fix(audio): measure audio_duration from the real file, not SubMaker cues
2 parents 4a92e18 + bfe9867 commit 7fc6a9e

2 files changed

Lines changed: 149 additions & 1 deletion

File tree

app/services/task.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,17 @@ def generate_audio(
535535
"failed to synthesize audio; verify the selected voice and TTS connectivity",
536536
)
537537
return None, None, None
538-
audio_duration = math.ceil(voice.get_audio_duration(sub_maker))
538+
# Measure the real written audio_file, not sub_maker.cues[-1].end:
539+
# the latter is the last WORD BOUNDARY, and TTS leaves a fixed tail
540+
# past it (Edge TTS: ~0.88s at any length - 19% of a 7-word clip but
541+
# 1.4% of a 153-word one, so short scripts suffer most). The
542+
# under-count sizes paid generate_bgm() calls, is reported as
543+
# audio_duration to the API/WebUI, and under-sources
544+
# download_videos() material, scaled by video_count.
545+
file_duration = voice.get_audio_duration(audio_file)
546+
audio_duration = math.ceil(
547+
file_duration if file_duration > 0 else voice.get_audio_duration(sub_maker)
548+
)
539549
if audio_duration == 0:
540550
_mark_task_failed(task_id, "audio", "generated audio duration is zero")
541551
return None, None, None

test/services/test_task.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,144 @@ def test_generate_audio_rejects_missing_custom_file_without_tts(self):
831831
self.assertEqual(failed_task["failed_stage"], "audio")
832832
self.assertIn("does not exist", failed_task["error"])
833833

834+
def test_generate_audio_prefers_file_duration_over_sub_maker(self):
835+
# Every fixture deliberately makes the file duration and the SubMaker
836+
# duration ceil to DIFFERENT integers. If someone "simplifies" them to
837+
# values that share a ceil, this test can no longer tell which source
838+
# the implementation used - it stops discriminating, silently.
839+
cases = (
840+
# The maintainer's own reproduction numbers from the PR discussion.
841+
(8.4, 7.8375, 9),
842+
# An exact-integer file duration: proves math.ceil() is really
843+
# used and rules out int()+1 style code that adds a spurious
844+
# second. The SubMaker value ceils to 7, so 8 can only come
845+
# from the file.
846+
(8.0, 6.2, 8),
847+
# File duration shorter than the SubMaker value: the only case
848+
# where this change makes audio_duration smaller than before (the
849+
# old code returned 8). The contract is "the file wins", not
850+
# "the larger value wins".
851+
(5.0, 7.8375, 5),
852+
)
853+
854+
for file_duration, sub_maker_duration, expected in cases:
855+
with self.subTest(file_duration=file_duration):
856+
task_id = f"test-tts-audio-priority-{uuid4().hex}"
857+
task_dir = utils.task_dir(task_id)
858+
audio_path = os.path.join(task_dir, "audio.mp3")
859+
params = VideoParams(
860+
video_subject="tts audio",
861+
video_script="",
862+
voice_name="test-voice",
863+
)
864+
sub_maker = MagicMock()
865+
866+
def fake_duration(target, _file=file_duration, _sub=sub_maker_duration):
867+
# Dispatch on argument type, never on call order: a
868+
# sequence side_effect would still pass against an
869+
# implementation that measured the SubMaker first, which
870+
# is exactly the regression this test exists to catch.
871+
return _file if isinstance(target, str) else _sub
872+
873+
try:
874+
with (
875+
patch.object(tm.voice, "tts", return_value=sub_maker) as tts,
876+
patch.object(
877+
tm.voice, "get_audio_duration", side_effect=fake_duration
878+
) as get_duration,
879+
):
880+
audio_file, audio_duration, result_sub_maker = tm.generate_audio(
881+
task_id, params, "script"
882+
)
883+
finally:
884+
shutil.rmtree(task_dir, ignore_errors=True)
885+
886+
self.assertEqual(audio_file, audio_path)
887+
self.assertEqual(audio_duration, expected)
888+
# Asserting the value alone would still pass an
889+
# implementation returning 9.0; the type assertion pins the
890+
# other side of the rounding contract, so a refactor cannot
891+
# drop math.ceil() and pass the float straight through.
892+
self.assertIsInstance(audio_duration, int)
893+
self.assertIs(result_sub_maker, sub_maker)
894+
tts.assert_called_once()
895+
# When file measurement succeeds the SubMaker must not be
896+
# measured at all: exactly one call, and that call's argument
897+
# is the audio file path. Both assertions together are what
898+
# prove the priority order.
899+
self.assertEqual(len(get_duration.call_args_list), 1)
900+
self.assertEqual(get_duration.call_args_list[0].args[0], audio_path)
901+
902+
def test_generate_audio_falls_back_to_sub_maker_when_file_duration_is_zero(self):
903+
task_id = "test-tts-audio-fallback"
904+
task_dir = utils.task_dir(task_id)
905+
audio_path = os.path.join(task_dir, "audio.mp3")
906+
params = VideoParams(
907+
video_subject="tts audio",
908+
video_script="",
909+
voice_name="test-voice",
910+
)
911+
sub_maker = MagicMock()
912+
913+
def fake_duration(target):
914+
# voice.get_audio_duration() returns 0.0 when file measurement
915+
# fails (missing file or decode error); only then may the
916+
# SubMaker word-boundary duration be used.
917+
return 0.0 if isinstance(target, str) else 7.8375
918+
919+
try:
920+
with (
921+
patch.object(tm.voice, "tts", return_value=sub_maker),
922+
patch.object(
923+
tm.voice, "get_audio_duration", side_effect=fake_duration
924+
) as get_duration,
925+
):
926+
audio_file, audio_duration, result_sub_maker = tm.generate_audio(
927+
task_id, params, "script"
928+
)
929+
finally:
930+
shutil.rmtree(task_dir, ignore_errors=True)
931+
932+
self.assertEqual(audio_file, audio_path)
933+
self.assertEqual(audio_duration, 8)
934+
self.assertIsInstance(audio_duration, int)
935+
self.assertIs(result_sub_maker, sub_maker)
936+
self.assertEqual(len(get_duration.call_args_list), 2)
937+
self.assertEqual(get_duration.call_args_list[0].args[0], audio_path)
938+
self.assertIs(get_duration.call_args_list[1].args[0], sub_maker)
939+
940+
def test_generate_audio_fails_when_file_and_sub_maker_durations_are_zero(self):
941+
# This change replaces the source of audio_duration, so the
942+
# pre-existing zero-duration guard must be proven to still fire
943+
# rather than be bypassed by the new file-measurement branch.
944+
task_id = "test-tts-audio-zero-duration"
945+
task_dir = utils.task_dir(task_id)
946+
params = VideoParams(
947+
video_subject="tts audio",
948+
video_script="",
949+
voice_name="test-voice",
950+
)
951+
sub_maker = MagicMock()
952+
953+
try:
954+
with (
955+
patch.object(tm.voice, "tts", return_value=sub_maker),
956+
patch.object(tm.voice, "get_audio_duration", return_value=0.0),
957+
patch.object(tm, "_mark_task_failed") as mark_task_failed,
958+
):
959+
audio_file, audio_duration, result_sub_maker = tm.generate_audio(
960+
task_id, params, "script"
961+
)
962+
finally:
963+
shutil.rmtree(task_dir, ignore_errors=True)
964+
965+
self.assertIsNone(audio_file)
966+
self.assertIsNone(audio_duration)
967+
self.assertIsNone(result_sub_maker)
968+
mark_task_failed.assert_called_once_with(
969+
task_id, "audio", "generated audio duration is zero"
970+
)
971+
834972
def test_generate_subtitle_uses_whisper_for_custom_audio_without_sub_maker(self):
835973
"""
836974
自定义音频不会经过 TTS,所以没有 sub_maker。

0 commit comments

Comments
 (0)