diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-02 08:16:57 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-02 08:16:57 +0300 |
| commit | e905d0d7435a827825017c5612b6e61d1f1f5da7 (patch) | |
| tree | 26d9bef15305895c0b1703c629f042800367fb04 | |
| parent | 0349a83743f8b8c8125a35b93b2271e402352b99 (diff) | |
Centralize audio sidecar generation
| -rw-r--r-- | internal/audio/sidecar.go | 116 | ||||
| -rw-r--r-- | internal/audio/sidecar_test.go | 78 | ||||
| -rw-r--r-- | internal/gui/generator.go | 74 | ||||
| -rw-r--r-- | internal/gui/generator_test.go | 34 | ||||
| -rw-r--r-- | internal/processor/processor.go | 61 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 36 |
6 files changed, 308 insertions, 91 deletions
diff --git a/internal/audio/sidecar.go b/internal/audio/sidecar.go new file mode 100644 index 0000000..ee591f0 --- /dev/null +++ b/internal/audio/sidecar.go @@ -0,0 +1,116 @@ +package audio + +import ( + "fmt" + "path/filepath" + "strings" +) + +// SidecarMetadataParams describes the metadata written alongside generated audio. +type SidecarMetadataParams struct { + Provider string + OutputFormat string + CardType string + AudioFile string + AudioFileBack string + + OpenAIModel string + OpenAIVoice string + OpenAISpeed float64 + OpenAIInstruction string + + GeminiTTSModel string + GeminiVoice string + GeminiSpeed float64 +} + +// ProcessedTextForWord returns the sanitized text sent to TTS providers. +func ProcessedTextForWord(text string) string { + cleanedText := strings.TrimSpace(text) + punctuationToRemove := []string{"!", "?", ".", ",", ";", ":", "\"", "'", "(", ")", "[", "]", "{", "}", "-", "—", "–"} + for _, punct := range punctuationToRemove { + cleanedText = strings.ReplaceAll(cleanedText, punct, "") + } + + return fmt.Sprintf("%s...", strings.TrimSpace(cleanedText)) +} + +// BuildSidecarMetadata formats the audio metadata written for GUI reloads and batch exports. +func BuildSidecarMetadata(params SidecarMetadataParams) string { + var b strings.Builder + provider := strings.ToLower(strings.TrimSpace(params.Provider)) + if provider == "" { + provider = "openai" + } + + if params.CardType == "" { + params.CardType = inferCardType(params.AudioFile, params.AudioFileBack) + } + + fmt.Fprintf(&b, "provider=%s\n", provider) + switch provider { + case "gemini": + model := strings.TrimSpace(params.GeminiTTSModel) + if model == "" { + model = DefaultProviderConfig().GeminiTTSModel + } + fmt.Fprintf(&b, "model=%s\n", model) + voice := strings.TrimSpace(params.GeminiVoice) + if voice == "" { + voice = "model-default" + } + fmt.Fprintf(&b, "voice=%s\n", voice) + fmt.Fprintf(&b, "speed=%.2f\n", params.GeminiSpeed) + default: + model := strings.TrimSpace(params.OpenAIModel) + if model == "" { + model = DefaultProviderConfig().OpenAIModel + } + fmt.Fprintf(&b, "model=%s\n", model) + voice := strings.TrimSpace(params.OpenAIVoice) + if voice != "" { + fmt.Fprintf(&b, "voice=%s\n", voice) + } + speed := params.OpenAISpeed + if speed <= 0 { + speed = DefaultProviderConfig().OpenAISpeed + } + fmt.Fprintf(&b, "speed=%.2f\n", speed) + if instruction := strings.TrimSpace(params.OpenAIInstruction); instruction != "" { + fmt.Fprintf(&b, "instruction=%s\n", instruction) + } + } + + format := strings.TrimSpace(params.OutputFormat) + if format == "" { + format = DefaultProviderConfig().OutputFormat + } + fmt.Fprintf(&b, "format=%s\n", format) + if params.CardType != "" { + fmt.Fprintf(&b, "cardtype=%s\n", params.CardType) + } + if audioFile := strings.TrimSpace(params.AudioFile); audioFile != "" { + fmt.Fprintf(&b, "audio_file=%s\n", filepath.Base(audioFile)) + } + if audioFileBack := strings.TrimSpace(params.AudioFileBack); audioFileBack != "" { + fmt.Fprintf(&b, "audio_file_back=%s\n", filepath.Base(audioFileBack)) + } + + return b.String() +} + +func inferCardType(audioFile, audioFileBack string) string { + if strings.TrimSpace(audioFileBack) != "" { + return "bg-bg" + } + + base := filepath.Base(strings.TrimSpace(audioFile)) + switch { + case strings.HasPrefix(base, "audio_front."): + return "bg-bg" + case strings.HasPrefix(base, "audio_back."): + return "bg-bg" + default: + return "en-bg" + } +} diff --git a/internal/audio/sidecar_test.go b/internal/audio/sidecar_test.go new file mode 100644 index 0000000..63eef6e --- /dev/null +++ b/internal/audio/sidecar_test.go @@ -0,0 +1,78 @@ +package audio + +import ( + "strings" + "testing" +) + +func TestProcessedTextForWord(t *testing.T) { + got := ProcessedTextForWord(" ябълка!? ") + if got != "ябълка..." { + t.Fatalf("ProcessedTextForWord() = %q, want %q", got, "ябълка...") + } +} + +func TestBuildSidecarMetadata(t *testing.T) { + tests := []struct { + name string + got string + want []string + }{ + { + name: "gemini bg-bg", + got: BuildSidecarMetadata(SidecarMetadataParams{ + Provider: "gemini", + OutputFormat: "wav", + CardType: "bg-bg", + AudioFile: "audio_front.wav", + AudioFileBack: "audio_back.wav", + GeminiTTSModel: "gemini-2.5-flash-preview-tts", + GeminiVoice: "", + GeminiSpeed: 1.00, + }), + want: []string{ + "provider=gemini", + "model=gemini-2.5-flash-preview-tts", + "voice=model-default", + "speed=1.00", + "format=wav", + "cardtype=bg-bg", + "audio_file=audio_front.wav", + "audio_file_back=audio_back.wav", + }, + }, + { + name: "openai en-bg", + got: BuildSidecarMetadata(SidecarMetadataParams{ + Provider: "openai", + OutputFormat: "mp3", + CardType: "en-bg", + AudioFile: "audio.mp3", + OpenAIModel: "gpt-4o-mini-tts", + OpenAIVoice: "alloy", + OpenAISpeed: 0.95, + OpenAIInstruction: "Speak clearly.", + }), + want: []string{ + "provider=openai", + "model=gpt-4o-mini-tts", + "voice=alloy", + "speed=0.95", + "instruction=Speak clearly.", + "format=mp3", + "cardtype=en-bg", + "audio_file=audio.mp3", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, want := range tt.want { + if !strings.Contains(tt.got, want) { + t.Fatalf("metadata = %q, missing %q", tt.got, want) + } + } + }) + } +} diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 7143cff..45cd7bc 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -421,6 +421,7 @@ func (a *Application) imagePromptCallback(cardDir, word string) func(prompt stri // saveAudioAttribution saves attribution info for generated audio func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed float64) error { + processedText := audio.ProcessedTextForWord(word) var attribution string switch a.audioProviderName() { case "gemini": @@ -429,11 +430,12 @@ func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed model = a.audioConfig.GeminiTTSModel } attribution = audio.BuildGeminiAttribution(audio.AttributionParams{ - Word: word, - Model: model, - Voice: voice, - Speed: speed, - GeneratedAt: time.Now(), + Word: word, + Model: model, + Voice: voice, + Speed: speed, + ProcessedText: processedText, + GeneratedAt: time.Now(), }) default: model := audio.DefaultProviderConfig().OpenAIModel @@ -447,12 +449,13 @@ func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed } } attribution = audio.BuildOpenAIAttribution(audio.AttributionParams{ - Word: word, - Model: model, - Voice: voice, - Speed: speed, - Instruction: instruction, - GeneratedAt: time.Now(), + Word: word, + Model: model, + Voice: voice, + Speed: speed, + Instruction: instruction, + ProcessedText: processedText, + GeneratedAt: time.Now(), }) } @@ -476,39 +479,22 @@ func (a *Application) saveAudioMetadata(cardDir string, audioConfig audio.Config } } - metadata := strings.Builder{} - - fmt.Fprintf(&metadata, "provider=%s\n", audioConfig.Provider) - switch strings.ToLower(strings.TrimSpace(audioConfig.Provider)) { - case "gemini": - model := strings.TrimSpace(audioConfig.GeminiTTSModel) - if model == "" { - model = audio.DefaultProviderConfig().GeminiTTSModel - } - fmt.Fprintf(&metadata, "model=%s\n", model) - default: - model := strings.TrimSpace(audioConfig.OpenAIModel) - if model == "" { - model = audio.DefaultProviderConfig().OpenAIModel - } - fmt.Fprintf(&metadata, "model=%s\n", model) - } - voiceLine := strings.TrimSpace(voice) - if voiceLine == "" && strings.ToLower(strings.TrimSpace(audioConfig.Provider)) == "gemini" { - voiceLine = "model-default" - } - fmt.Fprintf(&metadata, "voice=%s\n", voiceLine) - fmt.Fprintf(&metadata, "speed=%.2f\n", speed) - fmt.Fprintf(&metadata, "format=%s\n", audioConfig.OutputFormat) - fmt.Fprintf(&metadata, "cardtype=%s\n", cardType) - if audioFile != "" { - fmt.Fprintf(&metadata, "audio_file=%s\n", filepath.Base(audioFile)) - } - if audioFileBack != "" { - fmt.Fprintf(&metadata, "audio_file_back=%s\n", filepath.Base(audioFileBack)) - } - - if err := os.WriteFile(metadataFile, []byte(metadata.String()), 0644); err != nil { + metadata := audio.BuildSidecarMetadata(audio.SidecarMetadataParams{ + Provider: audioConfig.Provider, + OutputFormat: audioConfig.OutputFormat, + CardType: cardType, + AudioFile: audioFile, + AudioFileBack: audioFileBack, + OpenAIModel: audioConfig.OpenAIModel, + OpenAIVoice: voice, + OpenAISpeed: speed, + OpenAIInstruction: audioConfig.OpenAIInstruction, + GeminiTTSModel: audioConfig.GeminiTTSModel, + GeminiVoice: voice, + GeminiSpeed: speed, + }) + + if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil { return fmt.Errorf("failed to write audio metadata file: %w", err) } diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go index 31b4aa2..0c1ac41 100644 --- a/internal/gui/generator_test.go +++ b/internal/gui/generator_test.go @@ -239,6 +239,19 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { if strings.Contains(metadata, "sentinel-gemini-model") { t.Fatalf("openai metadata should not use Gemini model when provider is OpenAI: %q", metadata) } + if !strings.Contains(metadata, "cardtype=en-bg") { + t.Fatalf("openai metadata missing card type: %q", metadata) + } + + attrPath := audio.AttributionPath(outputPath) + attributionData, err := os.ReadFile(attrPath) + if err != nil { + t.Fatalf("expected attribution file %q: %v", attrPath, err) + } + attribution := string(attributionData) + if !strings.Contains(attribution, "Processed text sent to TTS: ябълка...") { + t.Fatalf("openai attribution missing processed text: %q", attribution) + } } func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) { @@ -318,6 +331,9 @@ func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) { if strings.Contains(attribution, "sentinel-gemini-voice") { t.Fatalf("gemini attribution should not use the shared voice list when voice is unset: %q", attribution) } + if !strings.Contains(attribution, "Processed text sent to TTS: ябълка...") { + t.Fatalf("gemini attribution missing processed text: %q", attribution) + } metadataData, err := os.ReadFile(filepath.Join(cardDir, "audio_metadata.txt")) if err != nil { @@ -422,6 +438,24 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { if !strings.Contains(metadata, "cardtype=bg-bg") { t.Fatalf("bg-bg metadata missing card type: %q", metadata) } + + for _, tc := range []struct { + audioPath string + wantText string + }{ + {audioPath: frontPath, wantText: "ябълка..."}, + {audioPath: backPath, wantText: "круша..."}, + } { + attrPath := audio.AttributionPath(tc.audioPath) + attributionData, err := os.ReadFile(attrPath) + if err != nil { + t.Fatalf("expected attribution file %q: %v", attrPath, err) + } + attribution := string(attributionData) + if !strings.Contains(attribution, "Processed text sent to TTS: "+tc.wantText) { + t.Fatalf("bg-bg attribution missing processed text %q: %q", tc.wantText, attribution) + } + } } func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 28b2352..92e7b29 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -963,13 +963,7 @@ func (p *Processor) isWordFullyProcessed(word string) bool { return true // All required files exist } func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.Config) error { - // Add preprocessing information - cleanedWord := strings.TrimSpace(word) - punctuationToRemove := []string{"!", "?", ".", ",", ";", ":", "\"", "'", "(", ")", "[", "]", "{", "}", "-", "—", "–"} - for _, punct := range punctuationToRemove { - cleanedWord = strings.ReplaceAll(cleanedWord, punct, "") - } - processedText := fmt.Sprintf("%s...", strings.TrimSpace(cleanedWord)) + processedText := audio.ProcessedTextForWord(word) var attribution string switch strings.ToLower(strings.TrimSpace(config.Provider)) { case "gemini": @@ -1012,47 +1006,20 @@ func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.C } func (p *Processor) buildAudioMetadata(config *audio.Config, audioFile string) string { - var b strings.Builder - provider := strings.ToLower(strings.TrimSpace(config.Provider)) - if provider == "" { - provider = "openai" - } - - fmt.Fprintf(&b, "provider=%s\n", provider) - switch provider { - case "gemini": - fmt.Fprintf(&b, "model=%s\n", config.GeminiTTSModel) - voice := strings.TrimSpace(config.GeminiVoice) - if voice == "" { - voice = "model-default" - } - fmt.Fprintf(&b, "voice=%s\n", voice) - fmt.Fprintf(&b, "speed=%.2f\n", config.GeminiSpeed) - default: - fmt.Fprintf(&b, "model=%s\n", config.OpenAIModel) - voice := strings.TrimSpace(config.OpenAIVoice) - if voice != "" { - fmt.Fprintf(&b, "voice=%s\n", voice) - } - fmt.Fprintf(&b, "speed=%.2f\n", config.OpenAISpeed) - if instruction := strings.TrimSpace(config.OpenAIInstruction); instruction != "" { - fmt.Fprintf(&b, "instruction=%s\n", instruction) - } - } - format := strings.TrimSpace(config.OutputFormat) - if format == "" { - format = p.effectiveAudioFormat() - } - fmt.Fprintf(&b, "format=%s\n", format) audioFileHint, audioFileBackHint := p.audioMetadataFileHints(audioFile) - if audioFileHint != "" { - fmt.Fprintf(&b, "audio_file=%s\n", filepath.Base(audioFileHint)) - } - if audioFileBackHint != "" { - fmt.Fprintf(&b, "audio_file_back=%s\n", filepath.Base(audioFileBackHint)) - } - - return b.String() + return audio.BuildSidecarMetadata(audio.SidecarMetadataParams{ + Provider: config.Provider, + OutputFormat: config.OutputFormat, + AudioFile: audioFileHint, + AudioFileBack: audioFileBackHint, + OpenAIModel: config.OpenAIModel, + OpenAIVoice: config.OpenAIVoice, + OpenAISpeed: config.OpenAISpeed, + OpenAIInstruction: config.OpenAIInstruction, + GeminiTTSModel: config.GeminiTTSModel, + GeminiVoice: config.GeminiVoice, + GeminiSpeed: config.GeminiSpeed, + }) } func (p *Processor) audioMetadataFileHints(audioFile string) (string, string) { diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index f658713..69250bf 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -436,6 +436,7 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { metadata := string(metadataData) for _, want := range []string{ "provider=openai", + "cardtype=bg-bg", "audio_file=audio_front.mp3", "audio_file_back=audio_back.mp3", } { @@ -443,6 +444,19 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { t.Fatalf("metadata = %q, missing %q", metadata, want) } } + + for i, outputFile := range fakeProvider.outputFiles { + attrPath := audio.AttributionPath(outputFile) + attributionData, err := os.ReadFile(attrPath) + if err != nil { + t.Fatalf("expected attribution file %q: %v", attrPath, err) + } + attribution := string(attributionData) + wantText := []string{"ябълка...", "круша..."}[i] + if !strings.Contains(attribution, "Processed text sent to TTS: "+wantText) { + t.Fatalf("bg-bg attribution missing processed text %q: %q", wantText, attribution) + } + } } func TestGenerateAudioProviderFactoryError(t *testing.T) { @@ -548,11 +562,22 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) { "speed=1.00", "format=wav", "audio_file=audio.wav", + "cardtype=en-bg", } { if !strings.Contains(metadata, want) { t.Fatalf("metadata = %q, missing %q", metadata, want) } } + + attrPath := audio.AttributionPath(fakeProvider.lastOutputFile) + attributionData, err := os.ReadFile(attrPath) + if err != nil { + t.Fatalf("expected attribution file %q: %v", attrPath, err) + } + attribution := string(attributionData) + if !strings.Contains(attribution, "Processed text sent to TTS: ябълка...") { + t.Fatalf("gemini attribution missing processed text: %q", attribution) + } } func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *testing.T) { @@ -612,11 +637,22 @@ func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *test "model=gpt-4o-mini-tts", "voice=alloy", "format=mp3", + "cardtype=en-bg", } { if !strings.Contains(metadata, want) { t.Fatalf("metadata = %q, missing %q", metadata, want) } } + + attrPath := audio.AttributionPath(fakeProvider.lastOutputFile) + attributionData, err := os.ReadFile(attrPath) + if err != nil { + t.Fatalf("expected attribution file %q: %v", attrPath, err) + } + attribution := string(attributionData) + if !strings.Contains(attribution, "Processed text sent to TTS: ябълка...") { + t.Fatalf("openai attribution missing processed text: %q", attribution) + } } func TestGenerateAnkiFileUsesEffectiveAudioFormatForGemini(t *testing.T) { |
