From 893489d63d9f2c803b49f3cb460e93bd3bb34e87 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 3 Apr 2026 20:16:17 +0300 Subject: 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 --- internal/story/narrator.go | 124 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 9 deletions(-) (limited to 'internal') 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. -- cgit v1.2.3