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 /internal/audio | |
initial commit
Diffstat (limited to 'internal/audio')
| -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 |
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 |
