diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-14 22:27:33 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-14 22:27:33 +0300 |
| commit | cbb1581356ed59e81cf5fedb30145c7521165e3d (patch) | |
| tree | a36a91d3a0d2258977a43ea1dc9da8bfd2741ca6 | |
initial commit
| -rw-r--r-- | .gitignore | 45 | ||||
| -rw-r--r-- | CLAUDE.md | 128 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 264 | ||||
| -rw-r--r-- | TODO.md | 4 | ||||
| -rw-r--r-- | Taskfile.yaml | 15 | ||||
| -rw-r--r-- | cmd/bulg/main.go | 447 | ||||
| -rw-r--r-- | go.mod | 24 | ||||
| -rw-r--r-- | go.sum | 44 | ||||
| -rw-r--r-- | internal/anki/doc.go | 3 | ||||
| -rw-r--r-- | internal/anki/generator.go | 318 | ||||
| -rw-r--r-- | internal/audio/doc.go | 3 | ||||
| -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 | 219 | ||||
| -rw-r--r-- | internal/audio/provider.go | 139 | ||||
| -rw-r--r-- | internal/config/doc.go | 3 | ||||
| -rw-r--r-- | internal/image/doc.go | 3 | ||||
| -rw-r--r-- | internal/image/download.go | 244 | ||||
| -rw-r--r-- | internal/image/pixabay.go | 231 | ||||
| -rw-r--r-- | internal/image/search.go | 87 | ||||
| -rw-r--r-- | internal/image/search_test.go | 146 | ||||
| -rw-r--r-- | internal/image/translate.go | 90 | ||||
| -rw-r--r-- | internal/image/unsplash.go | 263 | ||||
| -rw-r--r-- | internal/version.go | 3 | ||||
| -rw-r--r-- | testdata/common_words.txt | 20 |
27 files changed, 3244 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f0af1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# Build output +totalrecall + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp + +# Audio cache (OpenAI TTS) +.audio_cache/ + +# Geberated cards data +anki_cards + +# Configuration with API keys +.totalrecall.yaml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ade1542 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,128 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview +**bulg** - Bulgarian Anki Flashcard Generator + +A Go CLI tool that generates Anki flashcard materials from Bulgarian words: +- Generates audio pronunciation using espeak-ng +- Downloads representative images via web search +- Creates Anki-compatible output files + +## Important: Task Tracking +**Always check TODO.md for the current implementation status and pending tasks.** The TODO.md file contains a comprehensive breakdown of all features and their completion status. + +## Build and Development Commands + +### Available Tasks (via Taskfile) +```bash +# Build the binary +task +# or +task default + +# Run the application +task run + +# Run tests +task test + +# Install to Go bin directory +task install +``` + +### Common Development Commands +```bash +# Build for current platform +go build -o bulg ./cmd/bulg + +# Run without building +go run ./cmd/bulg "ябълка" + +# Run tests with coverage +go test -v -cover ./... + +# Check for race conditions +go test -race ./... + +# Format code +go fmt ./... + +# Lint code (requires golangci-lint) +golangci-lint run +``` + +## Architecture Overview + +### Package Structure +``` +bulg/ +├── cmd/bulg/ # CLI entry point +├── internal/ # Private packages +│ ├── audio/ # Audio generation (espeak-ng wrapper) +│ ├── image/ # Image search functionality +│ ├── anki/ # Anki format generation +│ ├── config/ # Configuration management +│ └── version.go # Version information +``` + +### 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) +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 + ``` + +### API Configuration +Image search APIs require configuration in `.bulg.yaml`: +- **Pixabay**: Optional API key for higher rate limits +- **Unsplash**: Required API key + +## Testing Approach +1. Unit tests mock external commands (espeak-ng) and 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/bulg/main.go` has: +```go +package main // NOT package bulg +``` + +## Development Workflow +1. Check TODO.md for next tasks +2. Create feature branch +3. Implement with tests +4. Update documentation +5. Run full test suite +6. Submit for review + +## 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 @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 bulg contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e342fd7 --- /dev/null +++ b/README.md @@ -0,0 +1,264 @@ +# bulg - Bulgarian Anki Flashcard Generator + +`bulg` is a command-line tool that generates Anki flashcard materials from Bulgarian words. It creates audio pronunciation files using espeak-ng or OpenAI TTS and downloads representative images from web search APIs. + +## Features + +- Audio generation with multiple providers: + - **espeak-ng**: Free, offline Bulgarian voices (robotic quality) + - **OpenAI TTS**: High-quality, natural-sounding voices (requires API key) +- Image search via Pixabay and Unsplash APIs +- 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) + +## 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): + - 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: + - Environment variable: `export OPENAI_API_KEY="sk-..."` + - Configuration file: Add to `.bulg.yaml` + +### Building from Source + +```bash +git clone https://github.com/yourusername/bulg.git +cd bulg +go build -o bulg ./cmd/bulg +``` + +Or install directly: + +```bash +go install codeberg.org/snonux/bulg/cmd/bulg@latest +``` + +## Quick Start + +1. Generate materials for a single word: + ```bash + bulg ябълка + ``` + +2. Process multiple words from a file: + ```bash + bulg --batch words.txt + ``` + +3. Generate with Anki CSV: + ```bash + bulg ябълка --anki + ``` + +## Configuration + +Create a `.bulg.yaml` file in your home directory or project folder: + +```yaml +audio: + provider: openai # Audio provider (espeak or 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: "tts-1" # Model: tts-1 or tts-1-hd + openai_voice: "nova" # Voice: alloy, echo, fable, onyx, nova, shimmer + openai_speed: 1.0 # Speed: 0.25 to 4.0 + + # Caching + enable_cache: true + cache_dir: "./.audio_cache" + +image: + provider: pixabay # Image provider (pixabay or unsplash) + pixabay_key: "" # Optional API key for higher limits + unsplash_key: "" # Required for Unsplash + size: medium # Image size preference + +output: + directory: ./anki_cards + naming: "{word}_{type}" +``` + +## Usage + +```bash +bulg [word] [flags] +``` + +### Flags + +- `-v, --voice string`: Voice variant (default "bg+f1") +- `-o, --output string`: Output directory (default "./anki_cards") +- `-f, --format string`: Audio format - wav or mp3 (default "mp3") +- `--batch string`: Process words from file (one per line) +- `--anki`: Generate Anki import CSV file +- `--skip-audio`: Skip audio generation +- `--skip-images`: Skip image download +- `--images-per-word int`: Number of images per word (default 1) +- `--image-api string`: Image source - pixabay or unsplash (default "pixabay") + +#### Audio Provider Options +- `--audio-provider string`: Audio provider - espeak or openai (default "espeak") + +#### 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) + +#### OpenAI Options +- `--openai-model string`: Model - tts-1 or tts-1-hd (default "tts-1") +- `--openai-voice string`: Voice - alloy, echo, fable, onyx, nova, shimmer (default "nova") +- `--openai-speed float`: Speech speed 0.25-4.0 (default 1.0) + +## API Keys + +### Pixabay +- Optional - works without key but with lower rate limits +- Get your key at: https://pixabay.com/api/docs/ + +### Unsplash +- Required for Unsplash searches +- Get your key at: https://unsplash.com/developers + +## Examples + +### Basic Usage +```bash +# Single word with espeak-ng +bulg котка + +# Using OpenAI TTS (requires API key in config) +bulg котка --audio-provider openai + +# High-quality OpenAI with specific voice +bulg ябълка --audio-provider openai --openai-model tts-1-hd --openai-voice alloy + +# Multiple words with custom output +bulg --batch animals.txt -o ./animal_cards + +# ESpeak with tuning +bulg ябълка --pitch 40 --word-gap 3 + +# Skip images, audio only +bulg куче --skip-images + +# Generate Anki import file +bulg --batch words.txt --anki +``` + +### Batch File Format +Create a text file with one Bulgarian word per line: +``` +ябълка +котка +куче +хляб +вода +``` + +## Anki Import + +1. Generate materials with the `--anki` flag +2. In Anki, go to File → Import +3. Select the generated `anki_import.csv` +4. Copy all media files to your Anki media folder +5. Map fields appropriately during import + +## Voice Variants + +Available Bulgarian voices: +- `bg` - Default Bulgarian voice +- `bg+m1`, `bg+m2`, `bg+m3` - Male voices +- `bg+f1`, `bg+f2`, `bg+f3` - Female voices + +## Troubleshooting + +### espeak-ng not found +Make sure espeak-ng is installed and in your PATH. + +### No images found +- Check your internet connection +- Verify API keys in configuration +- Try using English translations for better results + +### OpenAI API errors +- Verify your API key is correct and has credits +- Check the API key has TTS 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 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 bulg with tuning +bulg ябълка --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 provides much more natural Bulgarian pronunciation: + +```bash +# Option 1: Use environment variable +export OPENAI_API_KEY="sk-your-key-here" +bulg ябълка --audio-provider openai + +# Option 2: Set in .bulg.yaml +audio: + provider: openai + openai_key: "sk-your-key-here" + +# Use with custom voice +bulg ябълка --audio-provider openai --openai-voice alloy +``` + +**OpenAI Pricing**: +- tts-1: $0.015 per 1K characters (~$0.0001 per word) +- tts-1-hd: $0.030 per 1K characters (~$0.0002 per word) + +The tool caches audio to avoid repeated API calls for the same words. + +## License + +MIT License - see LICENSE file for details
\ No newline at end of file @@ -0,0 +1,4 @@ +# TODO's + +[ ] Rename the project from bulg to totalrecall. Look at all bulg references, and rename them. Also change the Go module name. +[ ] Ultra think about an Implementation of using OpenAPI key to use an OpenAI LLM to generate an image for the flash card. And add all to-do's into this file. diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..dc10b97 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,15 @@ +version: '3' + +tasks: + default: + cmds: + - go build -o bulg ./cmd/bulg + run: + cmds: + - go run ./cmd/bulg + test: + cmds: + - go test ./... + install: + cmds: + - go install ./cmd/bulg diff --git a/cmd/bulg/main.go b/cmd/bulg/main.go new file mode 100644 index 0000000..b3c2af1 --- /dev/null +++ b/cmd/bulg/main.go @@ -0,0 +1,447 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "codeberg.org/snonux/bulg/internal" + "codeberg.org/snonux/bulg/internal/anki" + "codeberg.org/snonux/bulg/internal/audio" + "codeberg.org/snonux/bulg/internal/image" +) + +var ( + // Flags + cfgFile string + voice string + outputDir string + audioFormat string + imageAPI string + batchFile string + skipAudio bool + skipImages bool + imagesPerWord int + generateAnki bool + // Audio provider flags + audioProvider string + // Audio tuning flags (espeak) + audioPitch int + audioAmplitude int + audioWordGap int + // OpenAI flags + openAIModel string + openAIVoice string + openAISpeed float64 +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "bulg [word]", + Short: "Bulgarian Anki Flashcard Generator", + Long: `bulg generates Anki flashcard materials from Bulgarian words. + +It creates audio pronunciation files using espeak-ng and downloads +representative images from web search APIs. + +Example: + bulg ябълка # Generate materials for "apple" + bulg --batch words.txt # Process multiple words from file`, + Args: cobra.MaximumNArgs(1), + RunE: runCommand, + Version: internal.Version, +} + +func init() { + cobra.OnInitialize(initConfig) + + // Global flags + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bulg.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", "pixabay", "Image source (pixabay or unsplash)") + rootCmd.Flags().StringVar(&batchFile, "batch", "", "Process words from file (one per line)") + rootCmd.Flags().BoolVar(&skipAudio, "skip-audio", false, "Skip audio generation") + rootCmd.Flags().BoolVar(&skipImages, "skip-images", false, "Skip image download") + 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") + + // Audio provider selection + rootCmd.Flags().StringVar(&audioProvider, "audio-provider", "espeak", "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)") + + // OpenAI flags + rootCmd.Flags().StringVar(&openAIModel, "openai-model", "tts-1", "OpenAI model: tts-1 or tts-1-hd") + rootCmd.Flags().StringVar(&openAIVoice, "openai-voice", "nova", "OpenAI voice: alloy, echo, fable, onyx, nova, shimmer") + rootCmd.Flags().Float64Var(&openAISpeed, "openai-speed", 1.0, "OpenAI speech speed (0.25 to 4.0)") + + // Bind flags to viper + viper.BindPFlag("audio.provider", rootCmd.Flags().Lookup("audio-provider")) + viper.BindPFlag("audio.voice", rootCmd.Flags().Lookup("voice")) + viper.BindPFlag("audio.format", rootCmd.Flags().Lookup("format")) + viper.BindPFlag("audio.pitch", rootCmd.Flags().Lookup("pitch")) + viper.BindPFlag("audio.amplitude", rootCmd.Flags().Lookup("amplitude")) + viper.BindPFlag("audio.word_gap", rootCmd.Flags().Lookup("word-gap")) + viper.BindPFlag("audio.openai_model", rootCmd.Flags().Lookup("openai-model")) + viper.BindPFlag("audio.openai_voice", rootCmd.Flags().Lookup("openai-voice")) + viper.BindPFlag("audio.openai_speed", rootCmd.Flags().Lookup("openai-speed")) + viper.BindPFlag("output.directory", rootCmd.Flags().Lookup("output")) + viper.BindPFlag("image.provider", rootCmd.Flags().Lookup("image-api")) +} + +func initConfig() { + if cfgFile != "" { + // Use config file from the flag + viper.SetConfigFile(cfgFile) + } else { + // Find home directory + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + // Search config in home directory with name ".bulg" (without extension) + viper.AddConfigPath(home) + viper.AddConfigPath(".") + viper.SetConfigType("yaml") + viper.SetConfigName(".bulg") + } + + // Environment variables + viper.SetEnvPrefix("BULG") + viper.AutomaticEnv() + + // Read config file + if err := viper.ReadInConfig(); err == nil { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + } +} + +func runCommand(cmd *cobra.Command, args []string) error { + // Determine words to process + var words []string + + if batchFile != "" { + // Read words from file + content, err := os.ReadFile(batchFile) + if err != nil { + return fmt.Errorf("failed to read batch file: %w", err) + } + // Split by newlines and filter empty lines + lines := string(content) + for _, line := range splitLines(lines) { + if line = trimSpace(line); line != "" { + words = append(words, line) + } + } + } else if len(args) > 0 { + // Single word from command line + words = []string{args[0]} + } else { + // No input provided + return fmt.Errorf("please provide a Bulgarian word or use --batch flag") + } + + // Validate words + for _, word := range words { + if err := audio.ValidateBulgarianText(word); err != nil { + return fmt.Errorf("invalid word '%s': %w", word, err) + } + } + + // Create output directory + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Process each word + for i, word := range words { + fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(words), word) + + if err := processWord(word); err != nil { + fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", word, err) + // Continue with next word + } + } + + // Generate Anki CSV if requested + if generateAnki { + fmt.Printf("\nGenerating Anki import file...\n") + if err := generateAnkiCSV(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to generate Anki CSV: %v\n", err) + } else { + fmt.Println("Anki import file created: anki_import.csv") + } + } + + fmt.Println("\nDone! Materials saved to:", outputDir) + return nil +} + +func processWord(word string) error { + // Generate audio + if !skipAudio { + fmt.Printf(" Generating audio...\n") + if err := generateAudio(word); err != nil { + return fmt.Errorf("audio generation failed: %w", err) + } + } + + // Download images + if !skipImages { + fmt.Printf(" Downloading images...\n") + if err := downloadImages(word); err != nil { + return fmt.Errorf("image download failed: %w", err) + } + } + + return nil +} + +func generateAudio(word string) error { + // Create audio provider configuration + providerConfig := &audio.Config{ + Provider: audioProvider, + 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, + OpenAISpeed: openAISpeed, + + // Caching + EnableCache: viper.GetBool("audio.enable_cache"), + CacheDir: viper.GetString("audio.cache_dir"), + } + + // 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 == "espeak" && 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 == "tts-1" && viper.IsSet("audio.openai_model") { + providerConfig.OpenAIModel = viper.GetString("audio.openai_model") + } + if openAIVoice == "nova" && viper.IsSet("audio.openai_voice") { + providerConfig.OpenAIVoice = viper.GetString("audio.openai_voice") + } + if openAISpeed == 1.0 && viper.IsSet("audio.openai_speed") { + providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed") + } + + // 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 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 + } + } + + // Generate audio file + filename := sanitizeFilename(word) + outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.%s", filename, audioFormat)) + + ctx := context.Background() + return provider.GenerateAudio(ctx, word, outputFile) +} + +func downloadImages(word string) error { + // Create image searcher based on provider + var searcher image.ImageSearcher + var err error + + switch imageAPI { + case "pixabay": + apiKey := viper.GetString("image.pixabay_key") + searcher = image.NewPixabayClient(apiKey) + + case "unsplash": + apiKey := viper.GetString("image.unsplash_key") + if apiKey == "" { + return fmt.Errorf("Unsplash API key is required in config") + } + searcher, err = image.NewUnsplashClient(apiKey) + if err != nil { + return err + } + + default: + return fmt.Errorf("unknown image provider: %s", imageAPI) + } + + // Create downloader + downloadOpts := &image.DownloadOptions{ + OutputDir: outputDir, + OverwriteExisting: false, + CreateDir: true, + FileNamePattern: "{word}_{index}", + MaxSizeBytes: 5 * 1024 * 1024, // 5MB + } + + downloader := image.NewDownloader(searcher, downloadOpts) + + // Download images + ctx := context.Background() + if imagesPerWord == 1 { + _, path, err := downloader.DownloadBestMatch(ctx, word) + if err != nil { + return err + } + fmt.Printf(" Downloaded: %s\n", path) + } else { + paths, err := downloader.DownloadMultiple(ctx, word, imagesPerWord) + if err != nil { + return err + } + for _, path := range paths { + fmt.Printf(" Downloaded: %s\n", path) + } + } + + return nil +} + +func sanitizeFilename(s string) string { + // Simple filename sanitization + result := "" + for _, r := range s { + if isAlphaNumeric(r) || r == '-' || r == '_' { + result += string(r) + } else { + result += "_" + } + } + return result +} + +func isAlphaNumeric(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || (r >= 'а' && r <= 'я') || + (r >= 'А' && r <= 'Я') +} + +func splitLines(s string) []string { + // Simple line splitter + var lines []string + current := "" + for _, r := range s { + if r == '\n' { + lines = append(lines, current) + current = "" + } else if r != '\r' { + current += string(r) + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func trimSpace(s string) string { + // Simple trim implementation + start := 0 + end := len(s) + + // Trim from start + for start < end && isSpace(rune(s[start])) { + start++ + } + + // Trim from end + for end > start && isSpace(rune(s[end-1])) { + end-- + } + + return s[start:end] +} + +func isSpace(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' || r == '\r' +} + +func generateAnkiCSV() error { + // Create Anki generator + gen := anki.NewGenerator(&anki.GeneratorOptions{ + OutputPath: filepath.Join(outputDir, "anki_import.csv"), + MediaFolder: outputDir, + IncludeHeaders: true, + AudioFormat: audioFormat, + }) + + // Generate cards from output directory + if err := gen.GenerateFromDirectory(outputDir); err != nil { + return fmt.Errorf("failed to generate cards: %w", err) + } + + // Generate CSV + if err := gen.GenerateCSV(); err != nil { + return fmt.Errorf("failed to generate CSV: %w", err) + } + + // Print stats + total, withAudio, withImages := gen.Stats() + fmt.Printf(" Generated %d cards (%d with audio, %d with images)\n", + total, withAudio, withImages) + + return nil +} + +func getOpenAIKey() string { + // First check environment variable + if key := os.Getenv("OPENAI_API_KEY"); key != "" { + return key + } + + // Then check config file + return viper.GetString("audio.openai_key") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +}
\ No newline at end of file |
