fix(audio): measure audio_duration from the real file, not SubMaker cues - #1263
Conversation
generate_audio() computed audio_duration via voice.get_audio_duration(sub_maker), which reads SubMaker.cues[-1].end - the last WORD BOUNDARY's end time reported by the TTS engine's streaming metadata. TTS engines commonly leave a short tail beyond that boundary (trailing silence/breath room), so this under-counts the real audio length. Verified with a 2-sentence test TTS call: SubMaker-based measurement returned 5.39s while the actual written audio file measured 7s via ffmpeg - a 1.6s discrepancy on a very short clip. Any downstream logic sized off audio_duration (trimming/concatenating video clips to match narration length, for example) can end up shorter than the real audio. Muxing with -shortest then silently truncates the tail of the narration, with no error or visual artifact - the video just quietly ends a fraction of a second early. voice.get_audio_duration() already supports measuring a real audio file path directly (_get_audio_duration_from_file, via moviepy/ffmpeg); this path just wasn't used here even though audio_file was already in scope. Fix: measure audio_file first, falling back to the SubMaker-based figure only if file measurement fails (e.g. file not yet flushed).
|
LGTM |
|
Hi @YUSAKRU, thank you for identifying and fixing this discrepancy. I reproduced it with a real Edge TTS call: the SubMaker duration was 7.8375 seconds while the generated MP3 was 8.4 seconds. I also ran a complete local-material video render, and the final MP4 matched the 8.4-second audio correctly. The focused tests, full suite, and Ruff all pass locally. Before merging, could you please add regression tests for the two new branches in generate_audio():
The implementation looks good; it just needs this coverage to prevent the priority and fallback behavior from regressing later. Thanks again! |
generate_audio() now measures the written audio file first and only falls back to the SubMaker word-boundary figure when that measurement returns 0, but neither path had any coverage: every existing generate_audio test exercises the custom-audio branch, so the whole TTS branch was untested. Add three regression tests: - priority: three cases whose file and SubMaker durations deliberately ceil() to different integers, so each one can tell which source was used. 8.4/7.8375 are the maintainer's own reproduction numbers; 8.0/6.2 pins math.ceil() and rules out an off-by-one; 5.0/7.8375 covers the only case where the change lowers audio_duration, pinning the contract as "the file wins" rather than "the longer value wins". Each case asserts the measurement is made exactly once and with the audio file path, and that the result is an int. - fallback: a 0.0 file measurement falls back to the SubMaker duration, asserting both calls in order. - zero guard: with both sources at 0 the existing failure path still fires. This branch had no coverage anywhere in test/, and since this PR changes how audio_duration is computed it needs to be proven intact. The get_audio_duration stub dispatches on argument type rather than call order; a sequence side_effect would still pass if the SubMaker were measured first, which is the regression these tests exist to catch. Also correct the explanatory comment in generate_audio(): combine_videos() re-measures the audio from the file and the pipeline never muxes with -shortest, so the narration is not silently truncated. The real damage from the under-count is paid BGM sized off it, the wrong audio_duration reported to the API/WebUI, and under-sourced footage scaled by video_count. Measured on Edge TTS, the gap is a fixed ~0.88s tail at any script length, so short scripts are hit hardest.
|
Thanks @harry0703 — regression tests pushed in Priority — three cases whose file and SubMaker durations deliberately
Each case asserts the measurement happens exactly once and with the audio file path, so an implementation that measured the SubMaker first cannot pass. The stub dispatches on argument type, not call order, for the same reason. Fallback — a I also added a third test for the Verified the tests are load-bearing: reverting the fix turns all three priority cases and the fallback test red. One correction to my original description, which I've now rewritten in the PR body. I claimed the final mux truncates the narration via The argument I should have led with instead: For what it's worth, I characterised the gap on live Edge TTS ( Two adjacent issues I ran into and deliberately left out, noted at the bottom of the PR body: the |
|
Thanks for the fix and for adding the detailed regression coverage. I also verified it locally with real Edge TTS and a complete video render; the measured duration now matches the generated audio correctly. Merged! |
Summary
generate_audio()computedaudio_durationfromvoice.get_audio_duration(sub_maker), which readsSubMaker.cues[-1].end— the last word boundary reported by the TTS engine's streaming metadata. TTS engines leave a short tail of silence beyond that boundary, so this systematically under-counts the real audio length.This is not a new opinion about which number is correct — it is an internal inconsistency the codebase already resolves the other way. The WebUI voice-preview path measures the real file (
webui/Main.py:4205→voice.get_audio_duration(audio_file)), andgenerate_audio()returnsmath.ceil()of that value when the preview cache is reused (app/services/task.py:474). So before this change, the same script with the same voice produced a differentaudio_durationdepending on whether the preview cache happened to hit (file-measured, correct) or miss (word-boundary, short). This PR makes the TTS path agree with the path right above it.Measured
Live Edge TTS,
en-US-AriaNeural, measured with the project's ownvoice.get_audio_duration()on both targets and cross-checked withffprobe:The gap is a constant ~0.88s tail, not a proportional error — identical across a 13× range of audio length, and byte-deterministic across repeated synthesis of the same text. The tail size varies by voice (@harry0703 measured 0.5625s on his reproduction: 7.8375s vs 8.4s), but it is constant within a voice.
Because the tail is fixed, the relative error is worst on short clips — 19% on a 7-word script — which is this project's primary use case.
What the under-count actually affects
Ranked by impact:
generate_bgm(video_duration=audio_duration)(app/services/task.py:838) — a paid third-party call (Sonilo/ElevenLabs) sized off the under-count, producing music shorter than the video you were billed for.app/services/task.py:1350,:1444) — the API response and WebUI show a duration that is short by the tail.download_videos(audio_duration=audio_duration * params.video_count)(app/services/task.py:721) — under-sourced footage, with the error multiplied byvideo_count.combine_videos()compensates by looping clips (app/services/video.py:724-737), so the visible result is more repetition rather than a short video.Correction to the original description of this PR: I initially wrote that the final mux truncates the narration via
-shortest. That is wrong for this pipeline and I want to withdraw it rather than have it merged into the history.combine_videos()never receivesaudio_duration; it re-measures the audio from the real file (app/services/video.py:549-553) and adds a 0.1s safety margin (app/services/video.py:94-102), and there is no-shortestanywhere inapp/(zero grep hits). The final render is correct today — which is exactly why @harry0703's end-to-end check matched the 8.4s audio. The defect is the wrong value, not a truncated render.Fix
Measure
audio_filefirst; fall back to the SubMaker figure only when file measurement returns0— whichvoice.get_audio_duration()returns for a missing file (app/services/voice.py:2204) or a decode failure (:2211).voice.get_audio_duration()already accepted a file path (_get_audio_duration_from_file); the call site simply wasn't using it even thoughaudio_filewas in scope.Test plan
Regression coverage as requested, in
test/services/test_task.py:ceil), and a file duration shorter than the SubMaker value (5.0 vs 7.8375 → 5), which pins the contract as "the file wins" rather than "the larger value wins"."generated audio duration is zero"failure still fires. This branch had no coverage anywhere intest/; since this PR changes howaudio_durationis computed, it is a regression test for this change.ruff checkand the fullpytest -q testsuite pass locally on Python 3.11.Mocked at the
voice.tts/voice.get_audio_durationboundary, following the existing convention in this file — no real ffmpeg, sincetest_task.pyalso runs in the Windows smoke job.Follow-ups (deliberately not in this PR)
Two adjacent issues I found while writing these tests, left out to keep this change reviewable:
generate_audio()returns anintfrom the TTS branch (math.ceil) but a rawfloatfrom the custom-audio branch (app/services/task.py:557), and both land in the sameaudio_durationkey and the samevideo_duration=argument."custom audio duration is zero"branch (app/services/task.py:558) is also untested.Happy to open a separate PR for either if you want them.