From 61529facc2c5321de9f0ab9123cb1de25bcab62c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 15 Jul 2025 23:28:13 +0300 Subject: feat: remove espeak, add random voice/style selection, fix punctuation in TTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .totalrecall.yaml.example | 23 ++-- CLAUDE.md | 33 ++---- GPT4O_AUDIO_NOTE.md | 35 ------ README.md | 98 ++++------------- TODO.md | 16 --- cmd/totalrecall/main.go | 109 ++++++++++--------- internal/audio/doc.go | 2 +- internal/audio/espeak.go | 217 -------------------------------------- internal/audio/espeak_provider.go | 65 ------------ internal/audio/espeak_test.go | 198 ---------------------------------- internal/audio/openai_provider.go | 16 ++- internal/audio/provider.go | 27 +---- internal/audio/validate.go | 28 +++++ internal/image/openai.go | 26 ++++- internal/image/openai_test.go | 23 +--- 15 files changed, 158 insertions(+), 758 deletions(-) delete mode 100644 GPT4O_AUDIO_NOTE.md delete mode 100644 internal/audio/espeak.go delete mode 100644 internal/audio/espeak_provider.go delete mode 100644 internal/audio/espeak_test.go create mode 100644 internal/audio/validate.go diff --git a/.totalrecall.yaml.example b/.totalrecall.yaml.example index e41b97f..e649bde 100644 --- a/.totalrecall.yaml.example +++ b/.totalrecall.yaml.example @@ -1,21 +1,14 @@ -# TotalRecall Configuration Example +# TotalRecalooConfiguration Example # Copy this to ~/.totalrecall.yaml or ./.totalrecall.yaml # Audio configuration audio: - # Provider: espeak or openai - provider: openai + # Audio is generated using OpenAI TTS + # Provider field removed - OpenAI is now the only option # Audio output format format: mp3 - # ESpeak settings - voice: bg+f1 - speed: 150 - pitch: 50 - amplitude: 100 - word_gap: 0 - # OpenAI TTS settings openai_key: ${OPENAI_API_KEY} # Can also use environment variable openai_model: gpt-4o-mini-tts # Options: tts-1, tts-1-hd, gpt-4o-mini-tts @@ -24,12 +17,12 @@ audio: # Voice instructions for gpt-4o-mini-tts model # This allows you to customize how the AI speaks - openai_instruction: "Speak slowly and clearly with natural Bulgarian pronunciation, emphasizing each syllable distinctly" + openai_instruction: "You are speaking Bulgarian language (български език). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian. Speak slowly and clearly for language learners." # Alternative instruction examples: - # openai_instruction: "Pronounce with a native Bulgarian accent, speaking at a moderate pace suitable for language learning" - # openai_instruction: "Speak as a patient Bulgarian language teacher, clearly articulating each sound" - # openai_instruction: "Use clear Bulgarian pronunciation with slight pauses between syllables" + # openai_instruction: "Speak in Bulgarian (not Russian!). Use native Bulgarian pronunciation with clear articulation for each syllable." + # openai_instruction: "You are a Bulgarian language teacher. Pronounce the Bulgarian words slowly with authentic Bulgarian accent and phonetics." + # openai_instruction: "Speak Bulgarian text with proper Bulgarian pronunciation. Avoid Russian accent. Speak clearly at a pace suitable for beginners." # Caching enable_cache: true @@ -56,4 +49,4 @@ image: # Output configuration output: - directory: ./anki_cards \ No newline at end of file + directory: ./anki_cards diff --git a/CLAUDE.md b/CLAUDE.md index 4b972ef..4ab7679 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **totalrecall** - Bulgarian Anki Flashcard Generator A Go CLI tool that generates Anki flashcard materials from Bulgarian words: -- Generates audio pronunciation using espeak-ng +- Generates audio pronunciation using OpenAI TTS - Downloads representative images via web search - Creates Anki-compatible output files @@ -60,7 +60,7 @@ golangci-lint run totalrecall/ ├── cmd/totalrecall/ # CLI entry point ├── internal/ # Private packages -│ ├── audio/ # Audio generation (espeak-ng wrapper) +│ ├── audio/ # Audio generation (OpenAI TTS) │ ├── image/ # Image search functionality │ ├── anki/ # Anki format generation │ ├── config/ # Configuration management @@ -68,20 +68,13 @@ totalrecall/ ``` ### Key Design Decisions -1. **espeak-ng for TTS**: Open source, supports Bulgarian, no API keys needed -2. **Modular image search**: Support multiple providers (Pixabay, Unsplash) +1. **OpenAI TTS**: High-quality, natural-sounding Bulgarian pronunciation +2. **Modular image search**: Support multiple providers (Pixabay, Unsplash, OpenAI DALL-E) 3. **Configuration via YAML**: User-friendly configuration with viper 4. **Cobra for CLI**: Industry-standard CLI framework ### External Dependencies -- **espeak-ng**: Must be installed on the system - ```bash - # Ubuntu/Debian - sudo apt-get install espeak-ng - - # macOS - brew install espeak-ng - ``` +- **OpenAI API Key**: Required for audio generation ### API Configuration Image search APIs require configuration in `.bulg.yaml`: @@ -89,24 +82,12 @@ Image search APIs require configuration in `.bulg.yaml`: - **Unsplash**: Required API key ## Testing Approach -1. Unit tests mock external commands (espeak-ng) and API calls +1. Unit tests mock API calls 2. Integration tests use real services when available 3. Test with common Bulgarian words: ябълка, котка, куче, хляб ## Common Issues and Solutions -### espeak-ng Bulgarian pronunciation -There have been reported issues with Bulgarian pronunciation in espeak-ng v1.49.3. If pronunciation sounds wrong, try: -```bash -# Check version -espeak-ng --version - -# Test Bulgarian voice -espeak-ng -v bg "Здравей" - -# Try different voice variants -espeak-ng -v bg+f1 "Здравей" -``` ### Package Declaration Error If you see an error about `package main`, ensure `cmd/totalrecall/main.go` has: @@ -125,4 +106,4 @@ package main // NOT package bulg ## Bulgarian Language Notes - Input should be in Cyrillic script - Common test words: ябълка (apple), котка (cat), куче (dog) -- Voice variants: bg+m1 (male), bg+f1 (female) \ No newline at end of file +- OpenAI voices: nova, alloy, echo, shimmer (work well for Bulgarian) \ No newline at end of file diff --git a/GPT4O_AUDIO_NOTE.md b/GPT4O_AUDIO_NOTE.md deleted file mode 100644 index a227c60..0000000 --- a/GPT4O_AUDIO_NOTE.md +++ /dev/null @@ -1,35 +0,0 @@ -# GPT-4o Audio Support Note - -## Current Status - -The standard OpenAI Text-to-Speech API (`/v1/audio/speech`) currently supports only: -- `tts-1` - Standard quality -- `tts-1-hd` - High definition quality - -## GPT-4o Audio Capabilities - -According to OpenAI documentation, GPT-4o models have audio capabilities, but these work differently: - -1. **Realtime API**: GPT-4o audio generation might be part of the new Realtime API, which uses WebSockets for bidirectional audio streaming. - -2. **Chat Completions with Audio**: GPT-4o might support audio output through the chat completions API with special modality parameters, but this requires different request/response handling than the standard TTS API. - -3. **Model Names**: Models like `gpt-4o-audio-preview` or `gpt-4o-mini` with audio capabilities might not be compatible with the standard TTS endpoint. - -## Experimental Usage - -You can try experimental model names with the `--openai-model` flag: -```bash -./totalrecall "word" --openai-model gpt-4o-audio-preview -``` - -However, this will likely result in a 404 error as these models require different API endpoints. - -## Future Implementation - -To properly support GPT-4o audio generation, we would need to: -1. Implement support for the Realtime API (WebSocket-based) -2. Or implement the chat completions API with audio modalities -3. Handle different request/response formats for audio data - -For now, stick with `tts-1` or `tts-1-hd` for reliable audio generation. \ No newline at end of file diff --git a/README.md b/README.md index c866e23..7177b66 100644 --- a/README.md +++ b/README.md @@ -4,46 +4,28 @@ It has mainly been vibe coded using Claude Code CLI. -⚠️ **Important:** This tool uses OpenAI services by default, which requires an API key. See [Quick Start](#quick-start) for setup instructions or use the free alternatives with `--audio-provider espeak --image-api pixabay`. +⚠️ **Important:** This tool uses OpenAI services for audio generation, which requires an API key. See [Quick Start](#quick-start) for setup instructions. ## Features -- Audio generation with multiple providers: - - **espeak-ng**: Free, offline Bulgarian voices (robotic quality) - - **OpenAI TTS**: High-quality, natural-sounding voices (requires API key) +- Audio generation using **OpenAI TTS**: High-quality, natural-sounding voices (requires API key) + - Random voice selection by default for variety + - Option to generate in all 11 available voices - Image search and generation: - **Pixabay**: Free stock photo search (optional API key) - **Unsplash**: High-quality photo search (requires API key) - - **OpenAI DALL-E**: AI-generated educational images (requires API key) + - **OpenAI DALL-E**: AI-generated educational images with random art styles (requires API key) - Batch processing of multiple words - Anki-compatible CSV export - Configurable voice variants and speech speed - Support for WAV and MP3 audio formats -- Audio caching to save API costs (OpenAI) +- Audio and image caching to save API costs ## Installation ### Prerequisites -1. **For espeak-ng audio** (free, offline): - ```bash - # Ubuntu/Debian - sudo apt-get install espeak-ng - - # macOS - brew install espeak-ng - ``` - -2. **ffmpeg** (optional, for MP3 conversion with espeak): - ```bash - # Ubuntu/Debian - sudo apt-get install ffmpeg - - # macOS - brew install ffmpeg - ``` - -3. **For OpenAI TTS** (paid, high quality): +1. **For OpenAI TTS** (required for audio generation): - Create an account at https://platform.openai.com - Generate an API key at https://platform.openai.com/api-keys - Set the key using one of these methods: @@ -76,9 +58,9 @@ export OPENAI_API_KEY="sk-..." totalrecall ябълка ``` -2. Use free alternatives (espeak + pixabay): +2. Use free Pixabay for images: ```bash - totalrecall ябълка --audio-provider espeak --image-api pixabay + totalrecall ябълка --image-api pixabay ``` 3. Process multiple words from a file: @@ -97,20 +79,14 @@ Create a `.totalrecall.yaml` file in your home directory or project folder: ```yaml audio: - provider: openai # Audio provider (espeak or openai) - default: openai format: mp3 # Audio format (wav or mp3) - # ESpeak settings - voice: bg+f1 # Voice variant (bg, bg+m1, bg+f1, etc.) - speed: 150 # Speech speed (80-450 words/minute) - pitch: 50 # Pitch adjustment (0-99) - # OpenAI settings openai_key: "sk-..." # Your OpenAI API key openai_model: "gpt-4o-mini-tts" # Model: tts-1, tts-1-hd, or gpt-4o-mini-tts openai_voice: "nova" # Voice: alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse openai_speed: 0.8 # Speed: 0.25 to 4.0 (may be ignored by gpt-4o-mini models) - openai_instruction: "Speak slowly and clearly with natural Bulgarian pronunciation" # For gpt-4o-mini models only + openai_instruction: "You are speaking Bulgarian language (български език). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian." # For gpt-4o-mini models only # Caching enable_cache: true @@ -153,18 +129,13 @@ totalrecall [word] [flags] - `--skip-images`: Skip image download - `--images-per-word int`: Number of images per word (default 1) - `--image-api string`: Image source - pixabay, unsplash, or openai (default "openai") +- `--all-voices`: Generate audio in all available OpenAI voices (creates 11 files per word) -#### Audio Provider Options -- `--audio-provider string`: Audio provider - espeak or openai (default "openai") - -#### ESpeak Tuning Options -- `--pitch int`: Pitch adjustment 0-99 (default 50, lower=deeper, espeak only) -- `--amplitude int`: Volume 0-200 (default 100, espeak only) -- `--word-gap int`: Gap between words in 10ms units (default 0, espeak only) +#### Audio Options #### OpenAI Audio Options - `--openai-model string`: Model - tts-1, tts-1-hd, or gpt-4o-mini-tts (default "gpt-4o-mini-tts", requires special access) -- `--openai-voice string`: Voice - alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse (default "nova") +- `--openai-voice string`: Voice - alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse (default: random) - `--openai-speed float`: Speech speed 0.25-4.0 (default 0.8, may be ignored by gpt-4o-mini-tts) - `--openai-instruction string`: Voice instructions for gpt-4o-mini-tts model (e.g., "speak with a Bulgarian accent") @@ -197,11 +168,8 @@ totalrecall [word] [flags] # Single word (uses OpenAI by default) totalrecall котка -# Using espeak-ng (free alternative) -totalrecall котка --audio-provider espeak - # High-quality OpenAI with specific voice -totalrecall ябълка --audio-provider openai --openai-model tts-1-hd --openai-voice alloy +totalrecall ябълка --openai-model tts-1-hd --openai-voice alloy # Use gpt-4o-mini-tts with custom voice instructions totalrecall ябълка --openai-instruction "Speak like a patient Bulgarian teacher, very slowly and clearly" @@ -209,9 +177,6 @@ totalrecall ябълка --openai-instruction "Speak like a patient Bulgarian te # Multiple words with custom output totalrecall --batch animals.txt -o ./animal_cards -# ESpeak with tuning -totalrecall ябълка --pitch 40 --word-gap 3 - # Skip images, audio only totalrecall куче --skip-images @@ -225,7 +190,10 @@ totalrecall ябълка --image-api openai totalrecall котка --image-api openai --openai-image-model dall-e-3 --openai-image-quality hd # Combine OpenAI audio and images -totalrecall куче --audio-provider openai --image-api openai +totalrecall куче --image-api openai + +# Generate audio in all 11 OpenAI voices +totalrecall котка --all-voices --skip-images ``` ### Batch File Format @@ -255,8 +223,6 @@ Available Bulgarian voices: ## Troubleshooting -### espeak-ng not found -Make sure espeak-ng is installed and in your PATH. ### No images found - Check your internet connection @@ -275,48 +241,28 @@ Make sure espeak-ng is installed and in your PATH. - Both services cache results to avoid regenerating identical content ### Free Alternatives -- **Audio**: Use espeak-ng (free but robotic quality) - **Images**: Use Pixabay without API key (limited rate) ### OpenAI Troubleshooting - Check the API key has proper permissions enabled - If you get rate limit errors, wait a moment and try again -- The tool will automatically fall back to espeak-ng if OpenAI audio fails - -### Audio sounds robotic -The Bulgarian voice in espeak-ng can sound robotic. To improve quality: - -```bash -# Test with different settings -espeak-ng -v bg -p 40 -s 140 "Здравей" # Deeper, slower -espeak-ng -v bg+f1 -p 60 -g 2 "Здравей" # Higher pitch, word gaps - -# Using totalrecall with tuning -totalrecall ябълка --pitch 40 --word-gap 2 --amplitude 120 -``` -Recommended settings for clearer pronunciation: -- `--pitch 40`: Slightly deeper voice (less robotic) -- `--word-gap 2-5`: Small gaps between words -- `--amplitude 120`: Slightly louder -- `-v bg+f1`: Female variant often sounds clearer -### Using OpenAI for Better Quality +### OpenAI TTS Configuration -OpenAI TTS provides much more natural Bulgarian pronunciation: +OpenAI TTS provides natural Bulgarian pronunciation: ```bash # Option 1: Use environment variable export OPENAI_API_KEY="sk-your-key-here" -totalrecall ябълка --audio-provider openai +totalrecall ябълка # Option 2: Set in .totalrecall.yaml audio: - provider: openai openai_key: "sk-your-key-here" # Use with custom voice -totalrecall ябълка --audio-provider openai --openai-voice alloy +totalrecall ябълка --openai-voice alloy ``` **OpenAI TTS Models**: diff --git a/TODO.md b/TODO.md index 4ceec03..9b41e0a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,18 +1,2 @@ # TODO's -## Completed -1. [x] Implement OpenAI DALL-E image generation for flashcards - - [x] Create OpenAI image provider implementing ImageSearcher interface - - [x] Add configuration flags for DALL-E model, size, quality, and style - - [x] Implement caching mechanism to avoid regenerating identical images - - [x] Create educational prompt generation for language learning - - [x] Add OpenAI provider to image download workflow - - [x] Update documentation with examples and configuration - -## In Progress / Remaining -1. [ ] Write unit tests for OpenAI image provider -2. [ ] Add cost estimation warnings in output (show estimated API costs) -3. [ ] Test with common Bulgarian words (ябълка, котка, куче, хляб) -4. [ ] Consider adding batch image generation for cost optimization -5. [ ] Add image style presets for different learning contexts (e.g., children, adults) -6. [ ] Implement fallback from OpenAI to other providers on failure diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go index df9e666..2a43d51 100644 --- a/cmd/totalrecall/main.go +++ b/cmd/totalrecall/main.go @@ -3,10 +3,12 @@ package main import ( "context" "fmt" + "math/rand" "os" "path/filepath" "sort" "strings" + "time" "github.com/sashabaranov/go-openai" "github.com/spf13/cobra" @@ -21,7 +23,7 @@ import ( var ( // Flags cfgFile string - voice string + // voice removed - was only for espeak outputDir string audioFormat string imageAPI string @@ -31,12 +33,8 @@ var ( imagesPerWord int generateAnki bool listModels bool - // Audio provider flags - audioProvider string - // Audio tuning flags (espeak) - audioPitch int - audioAmplitude int - audioWordGap int + allVoices bool + // Audio provider flags removed - now only OpenAI // OpenAI flags openAIModel string openAIVoice string @@ -55,7 +53,7 @@ var rootCmd = &cobra.Command{ Short: "Bulgarian Anki Flashcard Generator", Long: `totalrecall generates Anki flashcard materials from Bulgarian words. -It creates audio pronunciation files using espeak-ng and downloads +It creates audio pronunciation files using OpenAI TTS and downloads representative images from web search APIs. Example: @@ -69,11 +67,13 @@ Example: func init() { cobra.OnInitialize(initConfig) + // Initialize random number generator + rand.Seed(time.Now().UnixNano()) + // Global flags rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.totalrecall.yaml)") // Local flags - rootCmd.Flags().StringVarP(&voice, "voice", "v", "bg+f1", "Voice variant (bg, bg+m1, bg+f1, etc.)") rootCmd.Flags().StringVarP(&outputDir, "output", "o", "./anki_cards", "Output directory") rootCmd.Flags().StringVarP(&audioFormat, "format", "f", "mp3", "Audio format (wav or mp3)") rootCmd.Flags().StringVar(&imageAPI, "image-api", "openai", "Image source (pixabay, unsplash, or openai)") @@ -83,18 +83,13 @@ func init() { rootCmd.Flags().IntVar(&imagesPerWord, "images-per-word", 1, "Number of images to download per word") rootCmd.Flags().BoolVar(&generateAnki, "anki", false, "Generate Anki import CSV file") rootCmd.Flags().BoolVar(&listModels, "list-models", false, "List available OpenAI models for the current API key") + rootCmd.Flags().BoolVar(&allVoices, "all-voices", false, "Generate audio in all available voices (creates multiple files)") - // Audio provider selection - rootCmd.Flags().StringVar(&audioProvider, "audio-provider", "openai", "Audio provider: espeak or openai") - - // Audio tuning flags (espeak) - rootCmd.Flags().IntVar(&audioPitch, "pitch", 50, "Audio pitch adjustment (0-99, default 50, espeak only)") - rootCmd.Flags().IntVar(&audioAmplitude, "amplitude", 100, "Audio volume (0-200, default 100, espeak only)") - rootCmd.Flags().IntVar(&audioWordGap, "word-gap", 0, "Gap between words in 10ms units (default 0, espeak only)") + // Audio provider removed - now only OpenAI // OpenAI flags rootCmd.Flags().StringVar(&openAIModel, "openai-model", "gpt-4o-mini-tts", "OpenAI TTS model: tts-1, tts-1-hd, gpt-4o-mini-tts") - rootCmd.Flags().StringVar(&openAIVoice, "openai-voice", "nova", "OpenAI voice: alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse") + rootCmd.Flags().StringVar(&openAIVoice, "openai-voice", "", "OpenAI voice: alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse (default: random)") rootCmd.Flags().Float64Var(&openAISpeed, "openai-speed", 0.8, "OpenAI speech speed (0.25 to 4.0, may be ignored by gpt-4o-mini-tts)") rootCmd.Flags().StringVar(&openAIInstruction, "openai-instruction", "", "Voice instructions for gpt-4o-mini-tts model (e.g., 'speak slowly with a Bulgarian accent')") @@ -237,23 +232,47 @@ func processWord(word string) error { } func generateAudio(word string) error { + allVoicesList := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} + + // Get list of voices to use + var voices []string + if allVoices { + voices = allVoicesList + } else if openAIVoice != "" { + // Use explicitly specified voice + voices = []string{openAIVoice} + fmt.Printf(" Using specified voice: %s\n", openAIVoice) + } else { + // Select a random voice + randomVoice := allVoicesList[rand.Intn(len(allVoicesList))] + voices = []string{randomVoice} + fmt.Printf(" Using random voice: %s\n", randomVoice) + } + + // Generate audio for each voice + for i, voice := range voices { + if allVoices { + fmt.Printf(" Generating audio %d/%d (voice: %s)...\n", i+1, len(voices), voice) + } + if err := generateAudioWithVoice(word, voice); err != nil { + return fmt.Errorf("failed to generate audio with voice %s: %w", voice, err) + } + } + + return nil +} + +func generateAudioWithVoice(word, voice string) error { // Create audio provider configuration providerConfig := &audio.Config{ - Provider: audioProvider, + Provider: "openai", OutputDir: outputDir, OutputFormat: audioFormat, - // ESpeak settings - ESpeakVoice: voice, - ESpeakSpeed: viper.GetInt("audio.speed"), - ESpeakPitch: audioPitch, - ESpeakAmplitude: audioAmplitude, - ESpeakWordGap: audioWordGap, - // OpenAI settings OpenAIKey: getOpenAIKey(), OpenAIModel: openAIModel, - OpenAIVoice: openAIVoice, + OpenAIVoice: voice, OpenAISpeed: openAISpeed, OpenAIInstruction: openAIInstruction, @@ -263,26 +282,11 @@ func generateAudio(word string) error { } // Set defaults - if providerConfig.ESpeakSpeed == 0 { - providerConfig.ESpeakSpeed = 150 - } if providerConfig.CacheDir == "" { providerConfig.CacheDir = "./.audio_cache" } // Use config file values if not overridden by flags - if audioProvider == "openai" && viper.IsSet("audio.provider") { - providerConfig.Provider = viper.GetString("audio.provider") - } - if audioPitch == 50 && viper.IsSet("audio.pitch") { - providerConfig.ESpeakPitch = viper.GetInt("audio.pitch") - } - if audioAmplitude == 100 && viper.IsSet("audio.amplitude") { - providerConfig.ESpeakAmplitude = viper.GetInt("audio.amplitude") - } - if audioWordGap == 0 && viper.IsSet("audio.word_gap") { - providerConfig.ESpeakWordGap = viper.GetInt("audio.word_gap") - } if openAIModel == "gpt-4o-mini-tts" && viper.IsSet("audio.openai_model") { providerConfig.OpenAIModel = viper.GetString("audio.openai_model") } @@ -299,25 +303,20 @@ func generateAudio(word string) error { // Create the audio provider provider, err := audio.NewProvider(providerConfig) if err != nil { - // If OpenAI fails, try to create a fallback to espeak - if providerConfig.Provider == "openai" { - fmt.Printf("Warning: OpenAI audio provider failed (%v), falling back to espeak-ng\n", err) - providerConfig.Provider = "espeak" - fallbackProvider, fallbackErr := audio.NewProvider(providerConfig) - if fallbackErr != nil { - return fmt.Errorf("both OpenAI and espeak-ng failed: %v", fallbackErr) - } - provider = fallbackProvider - } else { - return err - } + return err } // Generate audio file + ctx := context.Background() filename := sanitizeFilename(word) - outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.%s", filename, audioFormat)) - ctx := context.Background() + // Add voice name to filename if generating multiple voices + if allVoices { + outputFile := filepath.Join(outputDir, fmt.Sprintf("%s_%s.%s", filename, voice, audioFormat)) + return provider.GenerateAudio(ctx, word, outputFile) + } + + outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.%s", filename, audioFormat)) return provider.GenerateAudio(ctx, word, outputFile) } 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 { -- cgit v1.2.3