Skip to content

fix(audio): measure audio_duration from the real file, not SubMaker cues - #1263

Merged
harry0703 merged 2 commits into
harry0703:mainfrom
YUSAKRU:fix/audio-duration-from-file
Aug 26, 2026
Merged

fix(audio): measure audio_duration from the real file, not SubMaker cues#1263
harry0703 merged 2 commits into
harry0703:mainfrom
YUSAKRU:fix/audio-duration-from-file

Conversation

@YUSAKRU

@YUSAKRU YUSAKRU commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

generate_audio() computed audio_duration from voice.get_audio_duration(sub_maker), which reads SubMaker.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:4205voice.get_audio_duration(audio_file)), and generate_audio() returns math.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 different audio_duration depending 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 own voice.get_audio_duration() on both targets and cross-checked with ffprobe:

Script SubMaker Real file Gap Error
7 words 3.750s 4.630s 0.880s 19.0%
39 words 16.450s 17.330s 0.880s 5.1%
153 words 60.575s 61.460s 0.885s 1.4%

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:

  1. 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.
  2. Reported duration (app/services/task.py:1350, :1444) — the API response and WebUI show a duration that is short by the tail.
  3. download_videos(audio_duration=audio_duration * params.video_count) (app/services/task.py:721) — under-sourced footage, with the error multiplied by video_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 receives audio_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 -shortest anywhere in app/ (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_file first; fall back to the SubMaker figure only when file measurement returns 0 — which voice.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 though audio_file was in scope.

Test plan

Regression coverage as requested, in test/services/test_task.py:

  • Priority — a valid file duration wins over the shorter SubMaker duration, asserted with your own reproduction numbers (8.4 vs 7.8375 → 9). Two further cases: an exact-integer file duration (8.0 → 8, ruling out an off-by-one in the 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".
  • Fallback — when file measurement returns 0, the SubMaker duration is used.
  • Zero guard — when both measurements are 0, the existing "generated audio duration is zero" failure still fires. This branch had no coverage anywhere in test/; since this PR changes how audio_duration is computed, it is a regression test for this change.
  • Tests verified to be load-bearing: reverting the fix turns the priority cases red.
  • ruff check and the full pytest -q test suite pass locally on Python 3.11.

Mocked at the voice.tts / voice.get_audio_duration boundary, following the existing convention in this file — no real ffmpeg, since test_task.py also 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:

  1. generate_audio() returns an int from the TTS branch (math.ceil) but a raw float from the custom-audio branch (app/services/task.py:557), and both land in the same audio_duration key and the same video_duration= argument.
  2. The "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.

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).
@Mihir7027

Copy link
Copy Markdown
Contributor

LGTM

@harry0703

Copy link
Copy Markdown
Owner

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():

  • a valid file duration is preferred over the shorter SubMaker duration;
  • when file-duration measurement returns 0, the code falls back to the SubMaker duration.

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

YUSAKRU commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @harry0703 — regression tests pushed in bfe9867, covering both branches you named.

Priority — three cases whose file and SubMaker durations deliberately ceil() to different integers, so each case can actually tell which source was used:

  • 8.4 / 7.83759 — your own reproduction numbers.
  • 8.0 / 6.28 — an exact-integer file duration, so math.ceil() is pinned and an off-by-one (int()+1) is ruled out. The SubMaker value ceils to 7, so 8 can only have come from the file.
  • 5.0 / 7.83755 — the one case where this change lowers audio_duration. It pins the contract as "the file wins" rather than "the longer value wins". Let me know if you'd prefer the other semantics; I went with what the current implementation does.

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 0.0 file measurement falls back to the SubMaker duration, asserting both calls and their order.

I also added a third test for the "generated audio duration is zero" guard. It wasn't in your list, but this PR changes how audio_duration is computed and that branch had no coverage anywhere in test/, so it seemed worth proving it still fires. Happy to drop it if you'd rather keep the diff to exactly what you asked for.

Verified the tests are load-bearing: reverting the fix turns all three priority cases and the fallback test red. ruff check is clean and the full suite passes locally on 3.11 (767 passed, 11 skipped).

One correction to my original description, which I've now rewritten in the PR body. I claimed the final mux truncates the narration via -shortest. That was wrong for this pipeline and I'd rather withdraw it than have it merged into the history: combine_videos() never receives audio_duration, it re-measures the audio from the file (app/services/video.py:549-553) and adds a 0.1s margin, and there is no -shortest anywhere in app/. That's exactly why your end-to-end render matched the 8.4s audio. The defect is the wrong value, not a truncated render — it flows into generate_bgm(video_duration=...) (a paid call), the audio_duration returned to the API/WebUI, and download_videos() sizing scaled by video_count.

The argument I should have led with instead: webui/Main.py:4205 already measures the file for the preview cache, and generate_audio() returns math.ceil() of it when that cache is reused (app/services/task.py:474). So before this change the same script produced a different audio_duration depending on whether the preview cache hit or missed. This PR just makes the TTS path agree with the path directly above it.

For what it's worth, I characterised the gap on live Edge TTS (en-US-AriaNeural): it's a constant ~0.88s tail, not a proportional error — 0.880s on a 7-word clip, 0.885s on a 153-word one, byte-deterministic across repeats. So the relative error is worst on short scripts (19% vs 1.4%), which is this project's main use case. Your 0.5625s figure fits the same pattern with a different voice.

Two adjacent issues I ran into and deliberately left out, noted at the bottom of the PR body: the int/float return-type split between the TTS and custom-audio branches, and the untested "custom audio duration is zero" guard. Happy to open separate PRs for either.

@harry0703
harry0703 merged commit 7fc6a9e into harry0703:main Aug 26, 2026
@harry0703

Copy link
Copy Markdown
Owner

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants