summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 08:16:57 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 08:16:57 +0300
commite905d0d7435a827825017c5612b6e61d1f1f5da7 (patch)
tree26d9bef15305895c0b1703c629f042800367fb04
parent0349a83743f8b8c8125a35b93b2271e402352b99 (diff)
Centralize audio sidecar generation
-rw-r--r--internal/audio/sidecar.go116
-rw-r--r--internal/audio/sidecar_test.go78
-rw-r--r--internal/gui/generator.go74
-rw-r--r--internal/gui/generator_test.go34
-rw-r--r--internal/processor/processor.go61
-rw-r--r--internal/processor/processor_test.go36
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) {