summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 20:16:17 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 20:16:17 +0300
commit893489d63d9f2c803b49f3cb460e93bd3bb34e87 (patch)
tree743b46b0095452722f29874cf730847297cde5c1
parentdff22c99773cb3b5eeffcedc354dc691d0f563f1 (diff)
fix: split long story narration into chunks to preserve voice quality
Gemini TTS voice quality degrades noticeably over long single-call texts. Split the story into ~200-word paragraph-aligned chunks, narrate each separately, then concatenate with ffmpeg's concat demuxer (copy codec, no re-encoding). Short stories (single chunk) still use one call. Temp files are written to a OS temp dir and cleaned up automatically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/story/narrator.go124
1 files changed, 115 insertions, 9 deletions
diff --git a/internal/story/narrator.go b/internal/story/narrator.go
index fec50d4..bb0e9fe 100644
--- a/internal/story/narrator.go
+++ b/internal/story/narrator.go
@@ -4,18 +4,27 @@ import (
"context"
"fmt"
"math/rand/v2"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
"time"
"codeberg.org/snonux/totalrecall/internal/audio"
)
const (
- // narratorTimeout gives the TTS API up to 3 minutes to narrate a full story.
- // A ~500-word story is much longer than a flashcard word, so the generous
- // timeout prevents premature cancellation on slow API responses.
- narratorTimeout = 3 * time.Minute
+ // narratorTimeout gives the TTS API up to 2 minutes per chunk.
+ // Chunks are much shorter than the full story, so this is generous.
+ narratorTimeout = 2 * time.Minute
- // cinematicInstruction is prepended to the story text before the TTS call.
+ // narratorChunkWords is the target word count per TTS chunk.
+ // Gemini TTS degrades in quality and voice consistency for long texts;
+ // splitting at ~200 words keeps each call short and the voice stable.
+ // Chunks are split at paragraph boundaries whenever possible.
+ narratorChunkWords = 200
+
+ // cinematicInstruction is prepended to every chunk before the TTS call.
// Gemini TTS reads style instructions from the user-turn prompt, so embedding
// the directive here (rather than as a SystemInstruction) is the supported way
// to control voice style, pacing, and emotional delivery.
@@ -81,14 +90,111 @@ func NewNarrator(config *NarratorConfig) (*Narrator, error) {
}
// Narrate generates a cinematic MP3 narration of storyText and saves it to
-// outputFile. The cinematic instruction is prepended to the text so the TTS
-// model applies dramatic pacing and expressive intonation.
+// outputFile. The story is split into short paragraph-aligned chunks before
+// calling the TTS API so the voice quality and consistency stay high throughout
+// the full narration (Gemini TTS degrades on long single-call texts).
func (n *Narrator) Narrate(storyText, outputFile string) error {
+ chunks := splitIntoNarrationChunks(storyText, narratorChunkWords)
+ if len(chunks) == 1 {
+ // Single short story — narrate in one call, no concatenation needed.
+ return n.narrateChunk(cinematicInstruction+chunks[0], outputFile)
+ }
+
+ fmt.Printf(" Splitting narration into %d chunks for consistent voice quality...\n", len(chunks))
+
+ tmpDir, err := os.MkdirTemp("", "totalrecall-narration-*")
+ if err != nil {
+ return fmt.Errorf("create temp dir: %w", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ var chunkPaths []string
+ for i, chunk := range chunks {
+ chunkPath := filepath.Join(tmpDir, fmt.Sprintf("chunk_%03d.mp3", i+1))
+ fmt.Printf(" Narrating chunk %d/%d...\n", i+1, len(chunks))
+ if err := n.narrateChunk(cinematicInstruction+chunk, chunkPath); err != nil {
+ return fmt.Errorf("narrate chunk %d: %w", i+1, err)
+ }
+ chunkPaths = append(chunkPaths, chunkPath)
+ }
+
+ return concatenateMP3s(chunkPaths, outputFile, tmpDir)
+}
+
+// narrateChunk calls the TTS provider for a single text segment.
+func (n *Narrator) narrateChunk(text, outputFile string) error {
ctx, cancel := context.WithTimeout(context.Background(), narratorTimeout)
defer cancel()
+ return n.provider.GenerateAudio(ctx, text, outputFile)
+}
+
+// concatenateMP3s joins chunkPaths into outputFile using ffmpeg's concat demuxer.
+// The list file is written to tmpDir and cleaned up with it by the caller.
+func concatenateMP3s(chunkPaths []string, outputFile, tmpDir string) error {
+ ffmpegPath, err := exec.LookPath("ffmpeg")
+ if err != nil {
+ return fmt.Errorf("ffmpeg not found — required for multi-chunk narration: %w", err)
+ }
- cinematicText := cinematicInstruction + storyText
- return n.provider.GenerateAudio(ctx, cinematicText, outputFile)
+ // Write an ffmpeg concat list: one "file 'path'" line per chunk.
+ listPath := filepath.Join(tmpDir, "concat_list.txt")
+ var sb strings.Builder
+ for _, p := range chunkPaths {
+ sb.WriteString(fmt.Sprintf("file '%s'\n", p))
+ }
+ if err := os.WriteFile(listPath, []byte(sb.String()), 0600); err != nil {
+ return fmt.Errorf("write concat list: %w", err)
+ }
+
+ cmd := exec.Command(ffmpegPath,
+ "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-y",
+ "-f", "concat", "-safe", "0",
+ "-i", listPath,
+ "-codec:a", "copy",
+ outputFile,
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("ffmpeg concat failed: %w\n%s", err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+// splitIntoNarrationChunks divides text into chunks of at most targetWords words,
+// splitting at paragraph boundaries (double newline) whenever possible.
+// Each chunk is trimmed and non-empty.
+func splitIntoNarrationChunks(text string, targetWords int) []string {
+ paragraphs := splitParagraphs(text) // reuse artist.go helper
+ if len(paragraphs) == 0 {
+ return []string{strings.TrimSpace(text)}
+ }
+
+ var chunks []string
+ var current strings.Builder
+ currentWords := 0
+
+ for _, para := range paragraphs {
+ paraWords := len(strings.Fields(para))
+
+ // If adding this paragraph would exceed the target, flush the current chunk.
+ if currentWords > 0 && currentWords+paraWords > targetWords {
+ chunks = append(chunks, strings.TrimSpace(current.String()))
+ current.Reset()
+ currentWords = 0
+ }
+
+ if current.Len() > 0 {
+ current.WriteString("\n\n")
+ }
+ current.WriteString(para)
+ currentWords += paraWords
+ }
+
+ if current.Len() > 0 {
+ chunks = append(chunks, strings.TrimSpace(current.String()))
+ }
+ return chunks
}
// pickCinematicVoice returns a random voice from the cinematicVoices pool.