summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-15 23:28:13 +0300
committerPaul Buetow <paul@buetow.org>2025-07-15 23:28:13 +0300
commit61529facc2c5321de9f0ab9123cb1de25bcab62c (patch)
tree0768d5d5e68c71ea52fc31ca2d33950c93977314 /internal
parent9c77f2a7bef485fa137f123cbf55b42cacb2b285 (diff)
feat: remove espeak, add random voice/style selection, fix punctuation in TTS
- Removed espeak audio provider completely, now only uses OpenAI TTS - Audio now uses random voice selection by default (can override with --openai-voice) - Added --all-voices flag to generate audio in all 11 OpenAI voices - Images now use random art styles (13 different styles including superhero, yoga, cat-themed) - Fixed TTS to remove punctuation marks before speaking - Updated Bulgarian pronunciation instructions to explicitly avoid Russian accent 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/audio/doc.go2
-rw-r--r--internal/audio/espeak.go217
-rw-r--r--internal/audio/espeak_provider.go65
-rw-r--r--internal/audio/espeak_test.go198
-rw-r--r--internal/audio/openai_provider.go16
-rw-r--r--internal/audio/provider.go27
-rw-r--r--internal/audio/validate.go28
-rw-r--r--internal/image/openai.go26
-rw-r--r--internal/image/openai_test.go23
9 files changed, 67 insertions, 535 deletions
diff --git a/internal/audio/doc.go b/internal/audio/doc.go
index 1fd216b..c8a5ce4 100644
--- a/internal/audio/doc.go
+++ b/internal/audio/doc.go
@@ -1,3 +1,3 @@
-// Package audio provides audio generation functionality using espeak-ng
+// Package audio provides audio generation functionality using OpenAI TTS
// for Bulgarian text-to-speech conversion.
package audio
diff --git a/internal/audio/espeak.go b/internal/audio/espeak.go
deleted file mode 100644
index cd42360..0000000
--- a/internal/audio/espeak.go
+++ /dev/null
@@ -1,217 +0,0 @@
-package audio
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-)
-
-// ESpeakConfig holds configuration for espeak-ng audio generation
-type ESpeakConfig struct {
- Voice string // Voice variant (e.g., "bg", "bg+m1", "bg+f1")
- Speed int // Speech speed in words per minute (default: 150)
- Pitch int // Pitch adjustment, 0 to 99 (default: 50)
- Amplitude int // Volume/amplitude, 0 to 200 (default: 100)
- WordGap int // Gap between words in 10ms units (default: 0)
- OutputDir string // Directory for output files
-}
-
-// DefaultConfig returns the default configuration for Bulgarian voice
-func DefaultConfig() *ESpeakConfig {
- return &ESpeakConfig{
- Voice: "bg",
- Speed: 150,
- Pitch: 50,
- Amplitude: 100,
- WordGap: 0,
- OutputDir: "./",
- }
-}
-
-// ESpeak provides an interface to the espeak-ng text-to-speech engine
-type ESpeak struct {
- config *ESpeakConfig
-}
-
-// New creates a new ESpeak instance with the given configuration
-func New(config *ESpeakConfig) (*ESpeak, error) {
- // Check if espeak-ng is installed
- if err := checkESpeakInstalled(); err != nil {
- return nil, err
- }
-
- if config == nil {
- config = DefaultConfig()
- }
-
- return &ESpeak{config: config}, nil
-}
-
-// GenerateAudio generates an audio file for the given Bulgarian text
-func (e *ESpeak) GenerateAudio(text string, outputFile string) error {
- // Validate input
- if text == "" {
- return fmt.Errorf("text cannot be empty")
- }
-
- // Ensure output directory exists
- dir := filepath.Dir(outputFile)
- if dir != "" && dir != "." {
- if err := os.MkdirAll(dir, 0755); err != nil {
- return fmt.Errorf("failed to create output directory: %w", err)
- }
- }
-
- // Build espeak-ng command
- args := []string{
- "-v", e.config.Voice, // Voice selection
- "-s", fmt.Sprintf("%d", e.config.Speed), // Speed
- "-p", fmt.Sprintf("%d", e.config.Pitch), // Pitch
- "-a", fmt.Sprintf("%d", e.config.Amplitude), // Amplitude/volume
- }
-
- // Add word gap if specified
- if e.config.WordGap > 0 {
- args = append(args, "-g", fmt.Sprintf("%d", e.config.WordGap))
- }
-
- // Add output file and text
- args = append(args, "-w", outputFile, text)
-
- cmd := exec.Command("espeak-ng", args...)
-
- // Run the command
- output, err := cmd.CombinedOutput()
- if err != nil {
- return fmt.Errorf("espeak-ng failed: %w\nOutput: %s", err, string(output))
- }
-
- return nil
-}
-
-// SetVoice updates the voice variant
-func (e *ESpeak) SetVoice(voice string) {
- e.config.Voice = voice
-}
-
-// SetSpeed updates the speech speed
-func (e *ESpeak) SetSpeed(speed int) {
- if speed < 80 {
- speed = 80
- } else if speed > 450 {
- speed = 450
- }
- e.config.Speed = speed
-}
-
-// SetPitch updates the pitch (0-99, 50 is default)
-func (e *ESpeak) SetPitch(pitch int) {
- if pitch < 0 {
- pitch = 0
- } else if pitch > 99 {
- pitch = 99
- }
- e.config.Pitch = pitch
-}
-
-// SetAmplitude updates the volume/amplitude (0-200, 100 is default)
-func (e *ESpeak) SetAmplitude(amplitude int) {
- if amplitude < 0 {
- amplitude = 0
- } else if amplitude > 200 {
- amplitude = 200
- }
- e.config.Amplitude = amplitude
-}
-
-// SetWordGap updates the gap between words in 10ms units
-func (e *ESpeak) SetWordGap(gap int) {
- if gap < 0 {
- gap = 0
- }
- e.config.WordGap = gap
-}
-
-// checkESpeakInstalled verifies that espeak-ng is available on the system
-func checkESpeakInstalled() error {
- cmd := exec.Command("espeak-ng", "--version")
- if err := cmd.Run(); err != nil {
- return fmt.Errorf("espeak-ng is not installed or not in PATH: %w", err)
- }
- return nil
-}
-
-// ValidateBulgarianText performs basic validation of Bulgarian text
-func ValidateBulgarianText(text string) error {
- if text == "" {
- return fmt.Errorf("text cannot be empty")
- }
-
- // Check if text contains at least one Cyrillic character
- hasCyrillic := false
- for _, r := range text {
- // Bulgarian Cyrillic range
- if (r >= 'А' && r <= 'я') || r == 'Ё' || r == 'ё' {
- hasCyrillic = true
- break
- }
- }
-
- if !hasCyrillic {
- return fmt.Errorf("text must contain Bulgarian Cyrillic characters")
- }
-
- return nil
-}
-
-// ListVoices returns available Bulgarian voice variants
-func ListVoices() []string {
- return []string{
- "bg", // Default Bulgarian voice
- "bg+m1", // Bulgarian male voice 1
- "bg+m2", // Bulgarian male voice 2
- "bg+m3", // Bulgarian male voice 3
- "bg+f1", // Bulgarian female voice 1
- "bg+f2", // Bulgarian female voice 2
- "bg+f3", // Bulgarian female voice 3
- }
-}
-
-// ConvertWAVToMP3 converts a WAV file to MP3 using ffmpeg
-func ConvertWAVToMP3(wavFile, mp3File string) error {
- // Check if ffmpeg is installed
- if err := exec.Command("ffmpeg", "-version").Run(); err != nil {
- return fmt.Errorf("ffmpeg is not installed or not in PATH: %w", err)
- }
-
- cmd := exec.Command("ffmpeg", "-i", wavFile, "-acodec", "mp3", "-y", mp3File)
- output, err := cmd.CombinedOutput()
- if err != nil {
- return fmt.Errorf("ffmpeg conversion failed: %w\nOutput: %s", err, string(output))
- }
-
- return nil
-}
-
-// GenerateMP3 generates an MP3 file for the given Bulgarian text
-func (e *ESpeak) GenerateMP3(text string, outputFile string) error {
- // Generate temporary WAV file
- tempWAV := strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + "_temp.wav"
-
- // Generate WAV
- if err := e.GenerateAudio(text, tempWAV); err != nil {
- return err
- }
-
- // Convert to MP3
- if err := ConvertWAVToMP3(tempWAV, outputFile); err != nil {
- // Clean up temporary file
- os.Remove(tempWAV)
- return err
- }
-
- // Clean up temporary file
- return os.Remove(tempWAV)
-} \ No newline at end of file
diff --git a/internal/audio/espeak_provider.go b/internal/audio/espeak_provider.go
deleted file mode 100644
index 177e2a6..0000000
--- a/internal/audio/espeak_provider.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package audio
-
-import (
- "context"
- "path/filepath"
- "strings"
-)
-
-// ESpeakProvider implements Provider interface for espeak-ng
-type ESpeakProvider struct {
- espeak *ESpeak
- format string
-}
-
-// NewESpeakProvider creates a new espeak-ng provider
-func NewESpeakProvider(config *ESpeakConfig) (Provider, error) {
- espeak, err := New(config)
- if err != nil {
- return nil, err
- }
-
- return &ESpeakProvider{
- espeak: espeak,
- format: "mp3", // default format
- }, nil
-}
-
-// GenerateAudio generates audio using espeak-ng
-func (p *ESpeakProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error {
- // Validate Bulgarian text
- if err := ValidateBulgarianText(text); err != nil {
- return err
- }
-
- // Determine format from output file extension
- ext := strings.ToLower(filepath.Ext(outputFile))
-
- switch ext {
- case ".mp3":
- return p.espeak.GenerateMP3(text, outputFile)
- case ".wav":
- return p.espeak.GenerateAudio(text, outputFile)
- default:
- // Default to MP3 if extension is unclear
- if !strings.HasSuffix(outputFile, ".mp3") {
- outputFile += ".mp3"
- }
- return p.espeak.GenerateMP3(text, outputFile)
- }
-}
-
-// Name returns the provider name
-func (p *ESpeakProvider) Name() string {
- return "espeak-ng"
-}
-
-// IsAvailable checks if espeak-ng is installed
-func (p *ESpeakProvider) IsAvailable() error {
- return checkESpeakInstalled()
-}
-
-// SetFormat sets the output format preference
-func (p *ESpeakProvider) SetFormat(format string) {
- p.format = format
-} \ No newline at end of file
diff --git a/internal/audio/espeak_test.go b/internal/audio/espeak_test.go
deleted file mode 100644
index 66c45f5..0000000
--- a/internal/audio/espeak_test.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package audio
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestValidateBulgarianText(t *testing.T) {
- tests := []struct {
- name string
- text string
- wantErr bool
- }{
- {
- name: "valid Bulgarian word",
- text: "ябълка",
- wantErr: false,
- },
- {
- name: "valid Bulgarian phrase",
- text: "добър ден",
- wantErr: false,
- },
- {
- name: "empty string",
- text: "",
- wantErr: true,
- },
- {
- name: "only Latin characters",
- text: "apple",
- wantErr: true,
- },
- {
- name: "mixed Cyrillic and Latin",
- text: "ябълка apple",
- wantErr: false, // Contains at least one Cyrillic
- },
- {
- name: "numbers only",
- text: "12345",
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := ValidateBulgarianText(tt.text)
- if (err != nil) != tt.wantErr {
- t.Errorf("ValidateBulgarianText() error = %v, wantErr %v", err, tt.wantErr)
- }
- })
- }
-}
-
-func TestListVoices(t *testing.T) {
- voices := ListVoices()
-
- if len(voices) == 0 {
- t.Error("ListVoices() returned empty slice")
- }
-
- // Check for expected voices
- expectedVoices := []string{"bg", "bg+m1", "bg+f1"}
- for _, expected := range expectedVoices {
- found := false
- for _, voice := range voices {
- if voice == expected {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("Expected voice %s not found in list", expected)
- }
- }
-}
-
-func TestDefaultConfig(t *testing.T) {
- config := DefaultConfig()
-
- if config == nil {
- t.Fatal("DefaultConfig() returned nil")
- }
-
- if config.Voice != "bg" {
- t.Errorf("Expected default voice 'bg', got '%s'", config.Voice)
- }
-
- if config.Speed != 150 {
- t.Errorf("Expected default speed 150, got %d", config.Speed)
- }
-
- if config.OutputDir != "./" {
- t.Errorf("Expected default output dir './', got '%s'", config.OutputDir)
- }
-}
-
-func TestNew(t *testing.T) {
- // This test will fail if espeak-ng is not installed
- // We'll skip it in that case
- espeak, err := New(nil)
- if err != nil {
- if checkESpeakInstalled() != nil {
- t.Skip("espeak-ng not installed, skipping test")
- }
- t.Fatalf("New() failed: %v", err)
- }
-
- if espeak == nil {
- t.Fatal("New() returned nil ESpeak instance")
- }
-
- if espeak.config == nil {
- t.Fatal("ESpeak instance has nil config")
- }
-}
-
-func TestSetSpeed(t *testing.T) {
- config := DefaultConfig()
- espeak := &ESpeak{config: config}
-
- tests := []struct {
- input int
- expected int
- }{
- {150, 150}, // Normal speed
- {50, 80}, // Below minimum
- {500, 450}, // Above maximum
- {200, 200}, // Valid speed
- }
-
- for _, tt := range tests {
- espeak.SetSpeed(tt.input)
- if espeak.config.Speed != tt.expected {
- t.Errorf("SetSpeed(%d) resulted in speed %d, expected %d",
- tt.input, espeak.config.Speed, tt.expected)
- }
- }
-}
-
-func TestGenerateAudio_InvalidInput(t *testing.T) {
- // Skip if espeak-ng not installed
- if checkESpeakInstalled() != nil {
- t.Skip("espeak-ng not installed, skipping test")
- }
-
- espeak, err := New(nil)
- if err != nil {
- t.Fatalf("Failed to create ESpeak: %v", err)
- }
-
- // Test with empty text
- err = espeak.GenerateAudio("", "test.wav")
- if err == nil {
- t.Error("GenerateAudio() with empty text should return error")
- }
-}
-
-func TestGenerateAudio_Integration(t *testing.T) {
- // Skip if espeak-ng not installed
- if checkESpeakInstalled() != nil {
- t.Skip("espeak-ng not installed, skipping integration test")
- }
-
- // Create temporary directory
- tempDir := t.TempDir()
-
- config := &ESpeakConfig{
- Voice: "bg",
- Speed: 150,
- OutputDir: tempDir,
- }
-
- espeak, err := New(config)
- if err != nil {
- t.Fatalf("Failed to create ESpeak: %v", err)
- }
-
- // Generate audio file
- outputFile := filepath.Join(tempDir, "test.wav")
- err = espeak.GenerateAudio("ябълка", outputFile)
- if err != nil {
- t.Fatalf("GenerateAudio() failed: %v", err)
- }
-
- // Check if file was created
- info, err := os.Stat(outputFile)
- if err != nil {
- t.Fatalf("Output file not created: %v", err)
- }
-
- // Check file size (WAV file should have some content)
- if info.Size() == 0 {
- t.Error("Output file is empty")
- }
-} \ No newline at end of file
diff --git a/internal/audio/openai_provider.go b/internal/audio/openai_provider.go
index a61957a..b72d793 100644
--- a/internal/audio/openai_provider.go
+++ b/internal/audio/openai_provider.go
@@ -169,13 +169,19 @@ func (p *OpenAIProvider) IsAvailable() error {
// preprocessBulgarianText prepares Bulgarian text for clearer TTS pronunciation
func (p *OpenAIProvider) preprocessBulgarianText(text string) string {
- // For single words, we add subtle punctuation to create natural pauses
- // without repeating the word
-
- // First, clean the text
+ // First, clean the text and remove punctuation that shouldn't be spoken
cleanedText := strings.TrimSpace(text)
- // Add ellipsis after the word to create a natural pause and slow down
+ // Remove common punctuation marks that shouldn't be pronounced
+ punctuationToRemove := []string{"!", "?", ".", ",", ";", ":", "\"", "'", "(", ")", "[", "]", "{", "}", "-", "—", "–"}
+ for _, punct := range punctuationToRemove {
+ cleanedText = strings.ReplaceAll(cleanedText, punct, "")
+ }
+
+ // Trim any remaining whitespace
+ cleanedText = strings.TrimSpace(cleanedText)
+
+ // For single words, we add subtle punctuation to create natural pauses
// This helps the TTS engine pronounce it more carefully
processedText := fmt.Sprintf("%s...", cleanedText)
diff --git a/internal/audio/provider.go b/internal/audio/provider.go
index 94605b7..3508121 100644
--- a/internal/audio/provider.go
+++ b/internal/audio/provider.go
@@ -19,17 +19,10 @@ type Provider interface {
// Config holds common configuration for audio providers
type Config struct {
- Provider string // Provider name: "espeak" or "openai"
+ Provider string // Provider name: "openai"
OutputDir string // Directory for output files
OutputFormat string // Output format: "mp3" or "wav"
- // ESpeak-specific settings
- ESpeakVoice string
- ESpeakSpeed int
- ESpeakPitch int
- ESpeakAmplitude int
- ESpeakWordGap int
-
// OpenAI-specific settings
OpenAIKey string
OpenAIModel string // "tts-1", "tts-1-hd", or "gpt-4o-mini-tts"
@@ -48,15 +41,10 @@ func DefaultProviderConfig() *Config {
Provider: "openai",
OutputDir: "./",
OutputFormat: "mp3",
- ESpeakVoice: "bg",
- ESpeakSpeed: 150,
- ESpeakPitch: 50,
- ESpeakAmplitude: 100,
- ESpeakWordGap: 0,
OpenAIModel: "gpt-4o-mini-tts", // New model with voice instructions support
OpenAIVoice: "nova",
OpenAISpeed: 0.8, // Slightly slower for clarity (note: may be ignored by gpt-4o-mini-tts)
- OpenAIInstruction: "Speak slowly and clearly with natural Bulgarian pronunciation, emphasizing each syllable distinctly",
+ OpenAIInstruction: "You are speaking Bulgarian language (български език). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian. Speak slowly and clearly for language learners.",
EnableCache: true,
CacheDir: "./.audio_cache",
}
@@ -69,17 +57,6 @@ func NewProvider(config *Config) (Provider, error) {
}
switch config.Provider {
- case "espeak", "espeak-ng":
- espeakConfig := &ESpeakConfig{
- Voice: config.ESpeakVoice,
- Speed: config.ESpeakSpeed,
- Pitch: config.ESpeakPitch,
- Amplitude: config.ESpeakAmplitude,
- WordGap: config.ESpeakWordGap,
- OutputDir: config.OutputDir,
- }
- return NewESpeakProvider(espeakConfig)
-
case "openai":
if config.OpenAIKey == "" {
return nil, fmt.Errorf("OpenAI API key is required")
diff --git a/internal/audio/validate.go b/internal/audio/validate.go
new file mode 100644
index 0000000..db042bd
--- /dev/null
+++ b/internal/audio/validate.go
@@ -0,0 +1,28 @@
+package audio
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+// ValidateBulgarianText validates that the input text contains valid Bulgarian text
+func ValidateBulgarianText(text string) error {
+ if strings.TrimSpace(text) == "" {
+ return fmt.Errorf("text cannot be empty")
+ }
+
+ hasCyrillic := false
+ for _, r := range text {
+ if unicode.In(r, unicode.Cyrillic) {
+ hasCyrillic = true
+ break
+ }
+ }
+
+ if !hasCyrillic {
+ return fmt.Errorf("text must contain Cyrillic characters")
+ }
+
+ return nil
+} \ No newline at end of file
diff --git a/internal/image/openai.go b/internal/image/openai.go
index 839c735..ee05c80 100644
--- a/internal/image/openai.go
+++ b/internal/image/openai.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"io"
+ "math/rand"
"net/http"
"os"
"path/filepath"
@@ -230,14 +231,35 @@ func (c *OpenAIClient) Name() string {
// createEducationalPrompt generates a prompt optimized for language learning
func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation string) string {
+ // Define different art styles for variety
+ styles := []string{
+ "photorealistic, high quality photograph",
+ "detailed digital illustration, clean vector art style",
+ "vibrant cartoon style, animated movie quality",
+ "minimalist flat design, modern graphic style",
+ "watercolor painting, soft artistic style",
+ "pencil sketch, detailed drawing style",
+ "3D rendered, pixar-style animation",
+ "oil painting, classical art style",
+ "paper cut art, layered craft style",
+ "isometric illustration, technical drawing style",
+ "superhero comic book style, dynamic action pose, bold colors",
+ "yoga/wellness illustration style, peaceful zen aesthetic",
+ "cute illustration with cats interacting with the subject, whimsical cat-themed",
+ }
+
+ // Select a random style
+ selectedStyle := styles[rand.Intn(len(styles))]
+ fmt.Printf(" Using image style: %s\n", selectedStyle)
+
// Create a simple, clear prompt for educational images
return fmt.Sprintf(
- "Generate a simple, clear image of: %s. "+
+ "Generate a %s of: %s. "+
"This is for the Bulgarian word '%s' which means %s. "+
"The image should be educational and suitable for language learning flashcards. "+
"Requirements: single main subject, plain background, clear and recognizable. "+
"No text, labels, or writing in the image.",
- englishTranslation, bulgarianWord, englishTranslation,
+ selectedStyle, englishTranslation, bulgarianWord, englishTranslation,
)
}
diff --git a/internal/image/openai_test.go b/internal/image/openai_test.go
index 8f42aeb..c096d11 100644
--- a/internal/image/openai_test.go
+++ b/internal/image/openai_test.go
@@ -123,28 +123,7 @@ func TestOpenAIClient_getCacheFilePath(t *testing.T) {
}
}
-func TestOpenAIClient_translateBulgarianToEnglish(t *testing.T) {
- tests := []struct {
- input string
- expected string
- }{
- {"ябълка", "apple"},
- {"котка", "cat"},
- {"куче", "dog"},
- {"хляб", "bread"},
- {"unknown", "unknown"}, // Should return original if not in dictionary
- }
-
- for _, tt := range tests {
- t.Run(tt.input, func(t *testing.T) {
- result := translateBulgarianToEnglish(tt.input)
- if result != tt.expected {
- t.Errorf("translateBulgarianToEnglish(%s) = %s, want %s",
- tt.input, result, tt.expected)
- }
- })
- }
-}
+// translateBulgarianToEnglish test removed - now uses OpenAI API
func TestOpenAIClient_getSizeWidthHeight(t *testing.T) {
tests := []struct {