diff options
Diffstat (limited to 'internal/comic/narrator.go')
| -rw-r--r-- | internal/comic/narrator.go | 360 |
1 files changed, 360 insertions, 0 deletions
diff --git a/internal/comic/narrator.go b/internal/comic/narrator.go new file mode 100644 index 0000000..461eb42 --- /dev/null +++ b/internal/comic/narrator.go @@ -0,0 +1,360 @@ +package comic + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "codeberg.org/snonux/comicforge/internal/provider" +) + +// NarratorConfig configures narration generation. +type NarratorConfig struct { + TextProvider provider.TextProvider + MainProvider provider.TTSProvider + ConclusionProvider provider.TTSProvider + Prompts PromptRenderer + VoiceName string + Language string + Script string +} + +// Narrator generates intro, story, and conclusion narration. +type Narrator struct { + textProvider provider.TextProvider + mainProvider provider.TTSProvider + conclusionProvider provider.TTSProvider + prompts PromptRenderer + voiceName string + language string + script string + initErr error +} + +// NewNarrator creates a narration pipeline. +func NewNarrator(cfg *NarratorConfig) *Narrator { + n := &Narrator{ + language: "Bulgarian", + script: "Cyrillic", + } + if cfg == nil { + n.initErr = fmt.Errorf("narrator config is required") + return n + } + n.textProvider = cfg.TextProvider + n.mainProvider = cfg.MainProvider + n.conclusionProvider = cfg.ConclusionProvider + n.prompts = cfg.Prompts + n.voiceName = cfg.VoiceName + n.language = orDefault(cfg.Language, n.language) + n.script = orDefault(cfg.Script, n.script) + if n.conclusionProvider == nil { + n.conclusionProvider = n.mainProvider + } + if n.mainProvider == nil { + n.initErr = fmt.Errorf("%w: main TTS provider", ErrMissingProvider) + } + if n.prompts == nil { + n.initErr = errorsJoin(n.initErr, fmt.Errorf("%w: prompt renderer", ErrMissingProvider)) + } + return n +} + +// Narrate generates a cinematic MP3 narration of storyText and saves it to outputFile. +func (n *Narrator) Narrate(ctx context.Context, storyText, outputFile string) error { + if err := n.ready(); err != nil { + return err + } + + tmpDir, err := os.MkdirTemp("", "comicforge-narration-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + var allPaths []string + if introPath, ok := n.narrateIntro(ctx, storyText, tmpDir); ok { + allPaths = append(allPaths, introPath) + } + chunkPaths, err := n.narrateMainStory(ctx, storyText, tmpDir) + if err != nil { + return err + } + allPaths = append(allPaths, chunkPaths...) + if conclusionPath, ok := n.narrateConclusion(ctx, storyText, tmpDir); ok { + allPaths = append(allPaths, conclusionPath) + } + + combinedPath := filepath.Join(tmpDir, "combined.mp3") + if len(allPaths) == 1 { + combinedPath = allPaths[0] + } else if err := concatenateMP3s(allPaths, combinedPath, tmpDir); err != nil { + return err + } + return convertToStereo(combinedPath, outputFile) +} + +func (n *Narrator) ready() error { + if n == nil { + return fmt.Errorf("narrator is nil") + } + if n.initErr != nil { + return n.initErr + } + return nil +} + +func (n *Narrator) narrateMainStory(ctx context.Context, storyText, tmpDir string) ([]string, error) { + chunks := splitIntoNarrationChunks(storyText, narratorChunkWords) + fmt.Printf(" Splitting narration into %d chunks for consistent voice quality...\n", len(chunks)) + + var paths []string + for i, chunk := range chunks { + path := filepath.Join(tmpDir, fmt.Sprintf("chunk_%03d.mp3", i+1)) + fmt.Printf(" Narrating chunk %d/%d...\n", i+1, len(chunks)) + if err := n.narrateChunkWith(ctx, n.mainProvider, cinematicInstruction+chunk, path); err != nil { + return nil, fmt.Errorf("narrate chunk %d: %w", i+1, err) + } + paths = append(paths, path) + } + return paths, nil +} + +func (n *Narrator) narrateIntro(ctx context.Context, storyText, tmpDir string) (string, bool) { + intro := n.buildIntro(ctx, storyText) + if intro == "" { + return "", false + } + introRaw := filepath.Join(tmpDir, "intro_narration.mp3") + if err := n.narrateChunkWith(ctx, n.conclusionProvider, cinematicInstruction+intro, introRaw); err != nil { + fmt.Printf(" Warning: intro narration failed: %v\n", err) + return "", false + } + + introWithMusic := filepath.Join(tmpDir, "intro_with_music.mp3") + if err := mixAmbientMusic(introRaw, introWithMusic, tmpDir); err != nil { + fmt.Printf(" Warning: intro music mix failed (%v) — using narration only\n", err) + return introRaw, true + } + return introWithMusic, true +} + +func (n *Narrator) buildIntro(ctx context.Context, storyText string) string { + return n.buildTeaser(ctx, introSystemTemplate, storyText) +} + +func (n *Narrator) narrateConclusion(ctx context.Context, storyText, tmpDir string) (string, bool) { + conclusion := n.buildConclusion(ctx, storyText) + if conclusion == "" { + return "", false + } + + chunks := splitIntoNarrationChunks(conclusion, narratorChunkWords) + var paths []string + for i, chunk := range chunks { + path := filepath.Join(tmpDir, fmt.Sprintf("conclusion_%03d.mp3", i+1)) + if err := n.narrateChunkWith(ctx, n.conclusionProvider, cinematicInstruction+chunk, path); err != nil { + fmt.Printf(" Warning: conclusion narration failed: %v\n", err) + return "", false + } + paths = append(paths, path) + } + + conclusionNarration := filepath.Join(tmpDir, "conclusion_narration.mp3") + if len(paths) == 1 { + conclusionNarration = paths[0] + } else if err := concatenateMP3s(paths, conclusionNarration, tmpDir); err != nil { + fmt.Printf(" Warning: conclusion concat failed: %v\n", err) + return paths[len(paths)-1], true + } + + conclusionWithMusic := filepath.Join(tmpDir, "conclusion_with_music.mp3") + if err := mixAmbientMusic(conclusionNarration, conclusionWithMusic, tmpDir); err != nil { + fmt.Printf(" Warning: background music mix failed (%v) — using narration only\n", err) + return conclusionNarration, true + } + return conclusionWithMusic, true +} + +func (n *Narrator) buildConclusion(ctx context.Context, storyText string) string { + return n.buildTeaser(ctx, conclusionSystemTemplate, storyText) +} + +func (n *Narrator) buildTeaser(ctx context.Context, templateName, storyText string) string { + if n.textProvider == nil { + return "" + } + systemPrompt, err := n.prompts.RenderPrompt(templateName, map[string]any{ + "StoryText": storyText, + "Language": n.language, + "Script": n.script, + }) + if err != nil { + fmt.Printf(" Warning: text prompt render failed: %v\n", err) + return "" + } + prompt := systemPrompt + "\n\n" + storyText + callCtx, cancel := withTimeout(ctx, helperTimeout) + defer cancel() + text, err := n.textProvider.GenerateText(callCtx, prompt) + if err != nil { + fmt.Printf(" Warning: teaser generation failed: %v\n", err) + return "" + } + return strings.TrimSpace(text) +} + +func (n *Narrator) narrateChunkWith(ctx context.Context, provider provider.TTSProvider, text, outputFile string) error { + callCtx, cancel := withTimeout(ctx, narratorTimeout) + defer cancel() + return provider.GenerateAudio(callCtx, text, outputFile) +} + +const ( + cinematicInstruction = `You are a dramatic cinematic narrator performing a story written in BULGARIAN. +IMPORTANT: This text is in the BULGARIAN language — NOT Russian, NOT Serbian, NOT any other Slavic language. +Pronounce every word using authentic BULGARIAN phonology and accent. Bulgarian vowels are clear and distinct; +do not apply Russian stress patterns or Russian vowel reduction. The letter 'ъ' in Bulgarian is a mid-central +vowel (like the 'u' in "but"), not the Russian reduced schwa. +Deliver this as a professional movie trailer narrator would: deep, resonant, and commanding. +Use long dramatic pauses before key moments. Build tension with slower, deliberate pacing, +then accelerate through action. Drop your voice low and gravelly for mysterious or serious +passages; let warmth and energy rise for joyful or triumphant ones. Breathe life into every +sentence — this should sound like an epic Bulgarian film, not a reading exercise. + +` + + introSystemTemplate = "narrator_intro_system.md" + conclusionSystemTemplate = "narrator_conclusion_system.md" +) + +func splitIntoNarrationChunks(text string, targetWords int) []string { + paragraphs := splitParagraphs(text) + if len(paragraphs) == 0 { + return []string{strings.TrimSpace(text)} + } + + var chunks []string + var current strings.Builder + currentWords := 0 + for _, paragraph := range paragraphs { + paraWords := len(strings.Fields(paragraph)) + 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(paragraph) + currentWords += paraWords + } + if current.Len() > 0 { + chunks = append(chunks, strings.TrimSpace(current.String())) + } + return chunks +} + +func mixAmbientMusic(narrationFile, outputFile, tmpDir string) error { + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + return fmt.Errorf("ffmpeg not found") + } + + musicPath := filepath.Join(tmpDir, "ambient_pad.mp3") + if err := generateAmbientPad(ffmpegPath, musicPath); err != nil { + return err + } + + cmd := exec.Command(ffmpegPath, + "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-i", narrationFile, + "-i", musicPath, + "-filter_complex", "[0:a][1:a]amix=inputs=2:weights=1 0.3:duration=first[aout]", + "-map", "[aout]", + "-ac", "2", + "-codec:a", "libmp3lame", "-q:a", "2", + outputFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("background music mix failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func generateAmbientPad(ffmpegPath, outputFile string) error { + droneExpr := "0.04*sin(65*2*PI*t)+0.03*sin(98*2*PI*t)+0.02*sin(130*2*PI*t)" + cmd := exec.Command(ffmpegPath, + "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", + "-i", fmt.Sprintf("aevalsrc=%s:sample_rate=44100", droneExpr), + "-f", "lavfi", "-i", "anoisesrc=color=pink:amplitude=0.008", + "-filter_complex", "[0:a][1:a]amix=inputs=2:duration=first[mixed];[mixed]afade=t=in:st=0:d=4[aout]", + "-map", "[aout]", + "-t", "300", + "-ac", "2", + outputFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ambient pad generation failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +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) + } + + listPath := filepath.Join(tmpDir, "concat_list.txt") + var sb strings.Builder + for _, path := range chunkPaths { + sb.WriteString(fmt.Sprintf("file '%s'\n", path)) + } + if err := os.WriteFile(listPath, []byte(sb.String()), 0o600); 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 +} + +func convertToStereo(inputFile, outputFile string) error { + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + fmt.Println(" Warning: ffmpeg not found, narration will be mono") + return os.Rename(inputFile, outputFile) + } + + cmd := exec.Command(ffmpegPath, + "-nostdin", "-hide_banner", "-loglevel", "error", + "-y", + "-i", inputFile, + "-ac", "2", + "-codec:a", "libmp3lame", "-q:a", "2", + outputFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg stereo conversion failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} |
