summaryrefslogtreecommitdiff
path: root/internal/audio
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-14 22:27:33 +0300
committerPaul Buetow <paul@buetow.org>2025-07-14 22:27:33 +0300
commitcbb1581356ed59e81cf5fedb30145c7521165e3d (patch)
treea36a91d3a0d2258977a43ea1dc9da8bfd2741ca6 /internal/audio
initial commit
Diffstat (limited to 'internal/audio')
-rw-r--r--internal/audio/doc.go3
-rw-r--r--internal/audio/espeak.go217
-rw-r--r--internal/audio/espeak_provider.go65
-rw-r--r--internal/audio/espeak_test.go198
-rw-r--r--internal/audio/openai_provider.go219
-rw-r--r--internal/audio/provider.go139
6 files changed, 841 insertions, 0 deletions
diff --git a/internal/audio/doc.go b/internal/audio/doc.go
new file mode 100644
index 0000000..1fd216b
--- /dev/null
+++ b/internal/audio/doc.go
@@ -0,0 +1,3 @@
+// Package audio provides audio generation functionality using espeak-ng
+// for Bulgarian text-to-speech conversion.
+package audio
diff --git a/internal/audio/espeak.go b/internal/audio/espeak.go
new file mode 100644
index 0000000..cd42360
--- /dev/null
+++ b/internal/audio/espeak.go
@@ -0,0 +1,217 @@
+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
new file mode 100644
index 0000000..177e2a6
--- /dev/null
+++ b/internal/audio/espeak_provider.go
@@ -0,0 +1,65 @@
+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
new file mode 100644
index 0000000..66c45f5
--- /dev/null
+++ b/internal/audio/espeak_test.go
@@ -0,0 +1,198 @@
+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
new file mode 100644
index 0000000..9efbcd2
--- /dev/null
+++ b/internal/audio/openai_provider.go
@@ -0,0 +1,219 @@
+package audio
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sashabaranov/go-openai"
+)
+
+// OpenAIProvider implements Provider interface for OpenAI TTS
+type OpenAIProvider struct {
+ client *openai.Client
+ config *Config
+ cacheDir string
+ enableCache bool
+}
+
+// NewOpenAIProvider creates a new OpenAI TTS provider
+func NewOpenAIProvider(config *Config) (Provider, error) {
+ if config.OpenAIKey == "" {
+ return nil, fmt.Errorf("OpenAI API key is required")
+ }
+
+ client := openai.NewClient(config.OpenAIKey)
+
+ provider := &OpenAIProvider{
+ client: client,
+ config: config,
+ cacheDir: config.CacheDir,
+ enableCache: config.EnableCache,
+ }
+
+ // Create cache directory if caching is enabled
+ if provider.enableCache && provider.cacheDir != "" {
+ if err := os.MkdirAll(provider.cacheDir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create cache directory: %w", err)
+ }
+ }
+
+ return provider, nil
+}
+
+// GenerateAudio generates audio using OpenAI TTS
+func (p *OpenAIProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error {
+ // Validate Bulgarian text
+ if err := ValidateBulgarianText(text); err != nil {
+ return err
+ }
+
+ // Check cache first
+ if p.enableCache {
+ cacheFile := p.getCacheFilePath(text)
+ if _, err := os.Stat(cacheFile); err == nil {
+ // Cache hit - copy cached file
+ return p.copyFile(cacheFile, outputFile)
+ }
+ }
+
+ // Prepare the TTS request
+ req := openai.CreateSpeechRequest{
+ Model: openai.SpeechModel(p.config.OpenAIModel),
+ Input: text,
+ Voice: openai.SpeechVoice(p.config.OpenAIVoice),
+ Speed: p.config.OpenAISpeed,
+ }
+
+ // Determine response format based on output file extension
+ ext := strings.ToLower(filepath.Ext(outputFile))
+ switch ext {
+ case ".mp3":
+ req.ResponseFormat = openai.SpeechResponseFormatMp3
+ case ".wav":
+ req.ResponseFormat = openai.SpeechResponseFormatWav
+ case ".opus":
+ req.ResponseFormat = openai.SpeechResponseFormatOpus
+ case ".aac":
+ req.ResponseFormat = openai.SpeechResponseFormatAac
+ case ".flac":
+ req.ResponseFormat = openai.SpeechResponseFormatFlac
+ default:
+ req.ResponseFormat = openai.SpeechResponseFormatMp3
+ if !strings.HasSuffix(outputFile, ".mp3") {
+ outputFile += ".mp3"
+ }
+ }
+
+ // Make the API call
+ response, err := p.client.CreateSpeech(ctx, req)
+ if err != nil {
+ return fmt.Errorf("OpenAI TTS API error: %w", err)
+ }
+ defer response.Close()
+
+ // 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)
+ }
+ }
+
+ // Create output file
+ out, err := os.Create(outputFile)
+ if err != nil {
+ return fmt.Errorf("failed to create output file: %w", err)
+ }
+ defer out.Close()
+
+ // Copy the audio data
+ written, err := io.Copy(out, response)
+ if err != nil {
+ return fmt.Errorf("failed to write audio file: %w", err)
+ }
+
+ if written == 0 {
+ return fmt.Errorf("no audio data received from OpenAI")
+ }
+
+ // Cache the result if caching is enabled
+ if p.enableCache {
+ cacheFile := p.getCacheFilePath(text)
+ _ = p.copyFile(outputFile, cacheFile) // Ignore cache errors
+ }
+
+ return nil
+}
+
+// Name returns the provider name
+func (p *OpenAIProvider) Name() string {
+ return "openai"
+}
+
+// IsAvailable checks if the OpenAI API is accessible
+func (p *OpenAIProvider) IsAvailable() error {
+ if p.config.OpenAIKey == "" {
+ return fmt.Errorf("OpenAI API key not configured")
+ }
+
+ // We could make a test API call here, but that would use credits
+ // For now, just check that we have a key
+ return nil
+}
+
+// getCacheFilePath generates a cache file path for the given text
+func (p *OpenAIProvider) getCacheFilePath(text string) string {
+ // Create a hash of the text and settings
+ h := md5.New()
+ h.Write([]byte(text))
+ h.Write([]byte(p.config.OpenAIModel))
+ h.Write([]byte(p.config.OpenAIVoice))
+ h.Write([]byte(fmt.Sprintf("%.2f", p.config.OpenAISpeed)))
+ hash := hex.EncodeToString(h.Sum(nil))
+
+ // Use first 2 chars as subdirectory for better file system performance
+ subdir := hash[:2]
+ filename := hash[2:] + ".mp3"
+
+ return filepath.Join(p.cacheDir, subdir, filename)
+}
+
+// copyFile copies a file from src to dst
+func (p *OpenAIProvider) copyFile(src, dst string) error {
+ // Ensure destination directory exists
+ dir := filepath.Dir(dst)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+ }
+
+ source, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer source.Close()
+
+ destination, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer destination.Close()
+
+ _, err = io.Copy(destination, source)
+ return err
+}
+
+// ClearCache removes all cached audio files
+func (p *OpenAIProvider) ClearCache() error {
+ if p.cacheDir == "" {
+ return nil
+ }
+ return os.RemoveAll(p.cacheDir)
+}
+
+// GetCacheStats returns cache statistics
+func (p *OpenAIProvider) GetCacheStats() (fileCount int, totalSize int64, err error) {
+ if !p.enableCache || p.cacheDir == "" {
+ return 0, 0, nil
+ }
+
+ err = filepath.Walk(p.cacheDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ fileCount++
+ totalSize += info.Size()
+ }
+ return nil
+ })
+
+ return fileCount, totalSize, err
+} \ No newline at end of file
diff --git a/internal/audio/provider.go b/internal/audio/provider.go
new file mode 100644
index 0000000..5b8c336
--- /dev/null
+++ b/internal/audio/provider.go
@@ -0,0 +1,139 @@
+package audio
+
+import (
+ "context"
+ "fmt"
+)
+
+// Provider defines the interface for text-to-speech providers
+type Provider interface {
+ // GenerateAudio generates audio from text and saves it to the specified file
+ GenerateAudio(ctx context.Context, text string, outputFile string) error
+
+ // Name returns the provider name
+ Name() string
+
+ // IsAvailable checks if the provider is properly configured and available
+ IsAvailable() error
+}
+
+// Config holds common configuration for audio providers
+type Config struct {
+ Provider string // Provider name: "espeak" or "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" or "tts-1-hd"
+ OpenAIVoice string // "alloy", "echo", "fable", "onyx", "nova", "shimmer"
+ OpenAISpeed float64 // 0.25 to 4.0
+
+ // Caching settings
+ EnableCache bool
+ CacheDir string
+}
+
+// DefaultConfig returns default configuration
+func DefaultProviderConfig() *Config {
+ return &Config{
+ Provider: "espeak",
+ OutputDir: "./",
+ OutputFormat: "mp3",
+ ESpeakVoice: "bg",
+ ESpeakSpeed: 150,
+ ESpeakPitch: 50,
+ ESpeakAmplitude: 100,
+ ESpeakWordGap: 0,
+ OpenAIModel: "tts-1",
+ OpenAIVoice: "nova",
+ OpenAISpeed: 1.0,
+ EnableCache: true,
+ CacheDir: "./.audio_cache",
+ }
+}
+
+// NewProvider creates the appropriate audio provider based on configuration
+func NewProvider(config *Config) (Provider, error) {
+ if config == nil {
+ config = DefaultProviderConfig()
+ }
+
+ 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")
+ }
+ return NewOpenAIProvider(config)
+
+ default:
+ return nil, fmt.Errorf("unknown audio provider: %s", config.Provider)
+ }
+}
+
+// ProviderWithFallback wraps a primary provider with a fallback option
+type ProviderWithFallback struct {
+ primary Provider
+ fallback Provider
+}
+
+// NewProviderWithFallback creates a provider that falls back to secondary if primary fails
+func NewProviderWithFallback(primary, fallback Provider) Provider {
+ return &ProviderWithFallback{
+ primary: primary,
+ fallback: fallback,
+ }
+}
+
+// GenerateAudio tries primary provider first, falls back to secondary on error
+func (p *ProviderWithFallback) GenerateAudio(ctx context.Context, text string, outputFile string) error {
+ err := p.primary.GenerateAudio(ctx, text, outputFile)
+ if err != nil {
+ // Log the primary error
+ fmt.Printf("Primary provider (%s) failed: %v. Falling back to %s\n",
+ p.primary.Name(), err, p.fallback.Name())
+
+ // Try fallback
+ return p.fallback.GenerateAudio(ctx, text, outputFile)
+ }
+ return nil
+}
+
+// Name returns the provider name
+func (p *ProviderWithFallback) Name() string {
+ return fmt.Sprintf("%s (fallback: %s)", p.primary.Name(), p.fallback.Name())
+}
+
+// IsAvailable checks if at least one provider is available
+func (p *ProviderWithFallback) IsAvailable() error {
+ primaryErr := p.primary.IsAvailable()
+ if primaryErr == nil {
+ return nil
+ }
+
+ fallbackErr := p.fallback.IsAvailable()
+ if fallbackErr == nil {
+ return nil
+ }
+
+ return fmt.Errorf("both providers unavailable: primary=%v, fallback=%v",
+ primaryErr, fallbackErr)
+} \ No newline at end of file