diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-15 23:28:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-15 23:28:13 +0300 |
| commit | 61529facc2c5321de9f0ab9123cb1de25bcab62c (patch) | |
| tree | 0768d5d5e68c71ea52fc31ca2d33950c93977314 /internal | |
| parent | 9c77f2a7bef485fa137f123cbf55b42cacb2b285 (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.go | 2 | ||||
| -rw-r--r-- | internal/audio/espeak.go | 217 | ||||
| -rw-r--r-- | internal/audio/espeak_provider.go | 65 | ||||
| -rw-r--r-- | internal/audio/espeak_test.go | 198 | ||||
| -rw-r--r-- | internal/audio/openai_provider.go | 16 | ||||
| -rw-r--r-- | internal/audio/provider.go | 27 | ||||
| -rw-r--r-- | internal/audio/validate.go | 28 | ||||
| -rw-r--r-- | internal/image/openai.go | 26 | ||||
| -rw-r--r-- | internal/image/openai_test.go | 23 |
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 { |
