feat(narration): generate speech with word timing and caption sidecars - #171
DonIsmaelito wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
4 issues found across 5 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="helpers/narrate.py">
<violation number="1" location="helpers/narrate.py:123">
P2: When an HTML comment touches adjacent words, the parser concatenates those words and changes the narration text and timings. Replace comments with a space before paragraph normalization.</violation>
<violation number="2" location="helpers/narrate.py:222">
P2: When unknown bracketed text is spoken, this conversion removes it from the generated words and SRT even though `parse_script` passes it to ElevenLabs. Strip only known provider markup so captions retain ordinary bracketed text.</violation>
<violation number="3" location="helpers/narrate.py:257">
P2: When a chunk boundary inserts silence without punctuation, `write_srt()` keeps the previous cue open and appends the next chunk's words. Flush cues across timing gaps (or pass chunk boundaries into `write_srt()`) so captions do not span the deliberate inter-paragraph silence.</violation>
<violation number="4" location="helpers/narrate.py:511">
P2: When `.tts_metrics.json` is edited while the other artifacts remain unchanged, `cache_valid()` still reuses the output because it never authenticates the metrics artifact. Validate the metadata with a separate completion hash/signature or compare its settings fields to the current invocation before accepting a cache hit.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| previous_start, previous_end = start, end | ||
| text = "".join(chars) | ||
| # blank markup without changing the character positions used by provider timestamps | ||
| text = re.sub(r"<[^>]*>|\[[^\]]*\]", lambda match: " " * len(match.group()), text) |
There was a problem hiding this comment.
P2: When unknown bracketed text is spoken, this conversion removes it from the generated words and SRT even though parse_script passes it to ElevenLabs. Strip only known provider markup so captions retain ordinary bracketed text.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/narrate.py, line 222:
<comment>When unknown bracketed text is spoken, this conversion removes it from the generated words and SRT even though `parse_script` passes it to ElevenLabs. Strip only known provider markup so captions retain ordinary bracketed text.</comment>
<file context>
@@ -0,0 +1,722 @@
+ previous_start, previous_end = start, end
+ text = "".join(chars)
+ # blank markup without changing the character positions used by provider timestamps
+ text = re.sub(r"<[^>]*>|\[[^\]]*\]", lambda match: " " * len(match.group()), text)
+ words = []
+ for match in re.finditer(r"\S+", text):
</file context>
| Non v3 pause markers become SSML breaks. V3 keeps untimed pause tags and | ||
| rejects timed pauses. Headings and HTML comments are not spoken. | ||
| """ | ||
| text = re.sub(r"<!--.*?-->", "", text, flags=re.S) |
There was a problem hiding this comment.
P2: When an HTML comment touches adjacent words, the parser concatenates those words and changes the narration text and timings. Replace comments with a space before paragraph normalization.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/narrate.py, line 123:
<comment>When an HTML comment touches adjacent words, the parser concatenates those words and changes the narration text and timings. Replace comments with a space before paragraph normalization.</comment>
<file context>
@@ -0,0 +1,722 @@
+ Non v3 pause markers become SSML breaks. V3 keeps untimed pause tags and
+ rejects timed pauses. Headings and HTML comments are not spoken.
+ """
+ text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
+ paragraphs: list[dict[str, Any]] = []
+ for raw in re.split(r"\n\s*\n", text.strip()):
</file context>
| text = re.sub(r"<!--.*?-->", "", text, flags=re.S) | |
| text = re.sub(r"<!--.*?-->", " ", text, flags=re.S) |
| and not path.is_symlink() | ||
| and metrics.get("output_hashes", {}).get(suffix) == file_hash(path) | ||
| for suffix, path in paths.items() | ||
| if suffix != ".tts_metrics.json" |
There was a problem hiding this comment.
P2: When .tts_metrics.json is edited while the other artifacts remain unchanged, cache_valid() still reuses the output because it never authenticates the metrics artifact. Validate the metadata with a separate completion hash/signature or compare its settings fields to the current invocation before accepting a cache hit.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/narrate.py, line 511:
<comment>When `.tts_metrics.json` is edited while the other artifacts remain unchanged, `cache_valid()` still reuses the output because it never authenticates the metrics artifact. Validate the metadata with a separate completion hash/signature or compare its settings fields to the current invocation before accepting a cache hit.</comment>
<file context>
@@ -0,0 +1,722 @@
+ and not path.is_symlink()
+ and metrics.get("output_hashes", {}).get(suffix) == file_hash(path)
+ for suffix, path in paths.items()
+ if suffix != ".tts_metrics.json"
+ )
+ except (OSError, ValueError, AttributeError, TypeError):
</file context>
| ) | ||
| current = [] | ||
|
|
||
| for word in words: |
There was a problem hiding this comment.
P2: When a chunk boundary inserts silence without punctuation, write_srt() keeps the previous cue open and appends the next chunk's words. Flush cues across timing gaps (or pass chunk boundaries into write_srt()) so captions do not span the deliberate inter-paragraph silence.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/narrate.py, line 257:
<comment>When a chunk boundary inserts silence without punctuation, `write_srt()` keeps the previous cue open and appends the next chunk's words. Flush cues across timing gaps (or pass chunk boundaries into `write_srt()`) so captions do not span the deliberate inter-paragraph silence.</comment>
<file context>
@@ -0,0 +1,722 @@
+ )
+ current = []
+
+ for word in words:
+ proposed = " ".join(w["text"] for w in [*current, word])
+ if current and (len(current) >= max_words or len(proposed) > max_chars):
</file context>
Why
Creating a narrated video needs both speech audio and timing for its captions. This adds one command that generates them together from a script.
Changes
Limits
Requires an ElevenLabs account, an available voice and FFmpeg. Real generation may incur charges; live API generation and listening were not tested. Dry run makes no API calls.
Failed multi-request runs do not resume individual chunks. Cache checks still resolve the voice online, and multi-file publication is not atomic. Provider word timing needs human review and is not an independent transcription.
This PR targets main independently. It complements the narration guidance in #146 and does not replace transcription or the SRT editing pipeline in #41. No renderer changes, voices, media assets or fonts are bundled.