summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 20:35:57 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 20:35:57 +0300
commitb0e8ccfebc5094829054021250f08ddb93c9978e (patch)
treeca69e6dd49a8ac1aff31e33abc0da7771a565c8d
parent893489d63d9f2c803b49f3cb460e93bd3bb34e87 (diff)
feat: append Gemini-generated cinematic epilogue at end of narration
- buildConclusion() calls gemini-2.5-flash (same SystemInstruction pattern) to write a 3-4 sentence Bulgarian cinematic epilogue (~40-60 words, ~15-30s) that reflects the story's emotion rather than recapping the plot - narrateConclusion() narrates it as a separate TTS chunk and appends it to the chunk list before final concatenation; failure is non-fatal - Narrator struct now stores apiKey for the text-generation call - Single-chunk path simplified: Rename instead of unnecessary concatenation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/story/narrator.go93
1 files changed, 82 insertions, 11 deletions
diff --git a/internal/story/narrator.go b/internal/story/narrator.go
index bb0e9fe..6e9fb70 100644
--- a/internal/story/narrator.go
+++ b/internal/story/narrator.go
@@ -10,6 +10,8 @@ import (
"strings"
"time"
+ "google.golang.org/genai"
+
"codeberg.org/snonux/totalrecall/internal/audio"
)
@@ -24,6 +26,14 @@ const (
// Chunks are split at paragraph boundaries whenever possible.
narratorChunkWords = 200
+ // conclusionSystemInstruction directs Gemini to write a short cinematic
+ // epilogue in Bulgarian — roughly 40–60 words (≈15–30 seconds of narration).
+ conclusionSystemInstruction = `You are a dramatic cinematic narrator writing a closing epilogue for a Bulgarian story.
+Write a SHORT closing epilogue in Bulgarian: exactly 3–4 sentences, cinematic and poetic,
+with a warm and conclusive tone — like the final voice-over of a film that leaves the audience
+with a sense of wonder and completion. Do NOT summarise the plot; instead reflect on the deeper
+meaning or emotion of the story. Output only the Bulgarian epilogue text, nothing else.`
+
// 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
@@ -60,6 +70,7 @@ type NarratorConfig struct {
// Narrator wraps a Gemini TTS Provider and generates cinematic MP3 narrations.
type Narrator struct {
provider audio.Provider
+ apiKey string // stored for the conclusion text-generation call
voice string // resolved voice name, stored for progress logging
}
@@ -86,28 +97,24 @@ func NewNarrator(config *NarratorConfig) (*Narrator, error) {
return nil, fmt.Errorf("narrator: initialise Gemini TTS: %w", err)
}
- return &Narrator{provider: provider, voice: voice}, nil
+ return &Narrator{provider: provider, apiKey: config.APIKey, voice: voice}, nil
}
// Narrate generates a cinematic MP3 narration of storyText and saves it to
// 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).
+// calling the TTS API so the voice quality stays high throughout (Gemini TTS
+// degrades on long single-call texts). A Gemini-generated cinematic epilogue
+// is always appended as a final 15–30 second concluding segment.
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)
+ chunks := splitIntoNarrationChunks(storyText, narratorChunkWords)
+ fmt.Printf(" Splitting narration into %d chunks for consistent voice quality...\n", len(chunks))
+
var chunkPaths []string
for i, chunk := range chunks {
chunkPath := filepath.Join(tmpDir, fmt.Sprintf("chunk_%03d.mp3", i+1))
@@ -118,9 +125,73 @@ func (n *Narrator) Narrate(storyText, outputFile string) error {
chunkPaths = append(chunkPaths, chunkPath)
}
+ // Generate and append a cinematic epilogue as the final segment.
+ if conclusionPath, ok := n.narrateConclusion(storyText, tmpDir); ok {
+ chunkPaths = append(chunkPaths, conclusionPath)
+ }
+
+ if len(chunkPaths) == 1 {
+ // Only one segment (short story, no conclusion) — move directly.
+ return os.Rename(chunkPaths[0], outputFile)
+ }
return concatenateMP3s(chunkPaths, outputFile, tmpDir)
}
+// narrateConclusion generates a short Bulgarian cinematic epilogue via Gemini text,
+// then narrates it as an MP3 written to tmpDir. Returns the path and true on success,
+// or empty string and false on any failure (non-fatal — the main narration still saves).
+func (n *Narrator) narrateConclusion(storyText, tmpDir string) (string, bool) {
+ conclusion := n.buildConclusion(storyText)
+ if conclusion == "" {
+ return "", false
+ }
+
+ fmt.Println(" Narrating concluding epilogue...")
+ conclusionPath := filepath.Join(tmpDir, "conclusion.mp3")
+ if err := n.narrateChunk(cinematicInstruction+conclusion, conclusionPath); err != nil {
+ fmt.Printf(" Warning: conclusion narration failed: %v\n", err)
+ return "", false
+ }
+ return conclusionPath, true
+}
+
+// buildConclusion calls Gemini text to produce a short Bulgarian cinematic epilogue
+// (≈40–60 words, ~15–30 s of narration). Returns empty string on failure.
+func (n *Narrator) buildConclusion(storyText string) string {
+ if n.apiKey == "" {
+ return ""
+ }
+
+ client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey})
+ if err != nil {
+ fmt.Printf(" Warning: conclusion text generation failed: %v\n", err)
+ return ""
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), helperTimeout)
+ defer cancel()
+
+ resp, err := client.Models.GenerateContent(ctx, helperModel,
+ []*genai.Content{genai.NewContentFromText(storyText, genai.RoleUser)},
+ &genai.GenerateContentConfig{
+ SystemInstruction: &genai.Content{
+ Parts: []*genai.Part{{Text: conclusionSystemInstruction}},
+ },
+ MaxOutputTokens: helperMaxTokens,
+ },
+ )
+ if err != nil {
+ fmt.Printf(" Warning: conclusion text generation failed: %v\n", err)
+ return ""
+ }
+
+ text := strings.TrimSpace(resp.Text())
+ if text == "" {
+ fmt.Println(" Warning: conclusion text generation returned empty response")
+ }
+ return text
+}
+
// 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)