@@ -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