feat: refactor

This commit is contained in:
leonardotrapani
2026-02-01 17:24:43 +01:00
parent 13b1de4e04
commit 8df3021a9d
33 changed files with 1290 additions and 1577 deletions
+14 -18
View File
@@ -689,22 +689,22 @@ func TestValidateModelLanguageCompatibility(t *testing.T) {
}{
{
name: "auto language always passes",
provider: "groq",
model: "distil-whisper-large-v3-en",
provider: "whisper-cpp",
model: "base.en",
langCode: "",
wantErr: false,
},
{
name: "english model supports english",
provider: "groq",
model: "distil-whisper-large-v3-en",
provider: "whisper-cpp",
model: "base.en",
langCode: "en",
wantErr: false,
},
{
name: "english model rejects spanish",
provider: "groq",
model: "distil-whisper-large-v3-en",
provider: "whisper-cpp",
model: "base.en",
langCode: "es",
wantErr: true,
errContains: "does not support Spanish (es)",
@@ -2079,9 +2079,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
t.Run("general.language validated against model", func(t *testing.T) {
config := baseConfig()
config.General.Language = "es"
config.Transcription.Provider = "groq-transcription"
config.Transcription.Model = "distil-whisper-large-v3-en" // english-only model
config.Transcription.APIKey = "gsk-test-key"
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err == nil {
@@ -2096,9 +2095,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
config := baseConfig()
config.General.Language = "en" // compatible
config.Transcription.Language = "es" // override with incompatible
config.Transcription.Provider = "groq-transcription"
config.Transcription.Model = "distil-whisper-large-v3-en" // english-only model
config.Transcription.APIKey = "gsk-test-key"
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err == nil {
@@ -2113,9 +2111,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
config := baseConfig()
config.General.Language = "es" // would be incompatible
config.Transcription.Language = "en" // override with compatible
config.Transcription.Provider = "groq-transcription"
config.Transcription.Model = "distil-whisper-large-v3-en" // english-only model
config.Transcription.APIKey = "gsk-test-key"
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err != nil {
@@ -2126,9 +2123,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
t.Run("auto language always passes", func(t *testing.T) {
config := baseConfig()
config.General.Language = "" // auto
config.Transcription.Provider = "groq-transcription"
config.Transcription.Model = "distil-whisper-large-v3-en" // english-only model
config.Transcription.APIKey = "gsk-test-key"
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err != nil {
+14 -35
View File
@@ -4,6 +4,7 @@ import (
"os"
"github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
)
@@ -22,11 +23,12 @@ func (c *Config) ToRecordingConfig() recording.Config {
func (c *Config) ToTranscriberConfig() transcriber.Config {
config := transcriber.Config{
Provider: c.Transcription.Provider,
Language: c.resolveEffectiveLanguage(),
Model: c.Transcription.Model,
Keywords: c.Keywords,
Threads: c.Transcription.Threads,
Provider: c.Transcription.Provider,
Language: c.resolveEffectiveLanguage(),
Model: c.Transcription.Model,
Keywords: c.Keywords,
Threads: c.Transcription.Threads,
Streaming: c.Transcription.Streaming,
}
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
@@ -44,29 +46,12 @@ func (c *Config) resolveEffectiveLanguage() string {
}
// resolveAPIKeyForProvider returns the API key for a provider from multiple sources
func (c *Config) resolveAPIKeyForProvider(provider string) string {
providerName := provider
envVar := ""
switch provider {
case "openai":
providerName = "openai"
envVar = "OPENAI_API_KEY"
case "groq-transcription", "groq-translation":
providerName = "groq"
envVar = "GROQ_API_KEY"
case "mistral-transcription":
providerName = "mistral"
envVar = "MISTRAL_API_KEY"
case "elevenlabs":
providerName = "elevenlabs"
envVar = "ELEVENLABS_API_KEY"
case "deepgram":
providerName = "deepgram"
envVar = "DEEPGRAM_API_KEY"
}
func (c *Config) resolveAPIKeyForProvider(providerName string) string {
baseName := provider.BaseProviderName(providerName)
envVar := provider.EnvVarForProvider(providerName)
if c.Providers != nil {
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
if pc, ok := c.Providers[baseName]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
@@ -106,17 +91,11 @@ func (c *Config) ToLLMConfig() LLMAdapterConfig {
}
// resolveAPIKeyForLLMProvider returns the API key for an LLM provider
func (c *Config) resolveAPIKeyForLLMProvider(provider string) string {
envVar := ""
switch provider {
case "openai":
envVar = "OPENAI_API_KEY"
case "groq":
envVar = "GROQ_API_KEY"
}
func (c *Config) resolveAPIKeyForLLMProvider(providerName string) string {
envVar := provider.EnvVarForProvider(providerName)
if c.Providers != nil {
if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" {
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
+185
View File
@@ -3,8 +3,193 @@ package config
import (
"fmt"
"os"
"strings"
)
// Save writes the config to the config file with formatted TOML output
func Save(cfg *Config) error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
var sb strings.Builder
// Header
sb.WriteString(`# Hyprvoice Configuration
# Generated by hyprvoice configure
# Changes are applied immediately without daemon restart.
`)
// Keywords (must be before any table definitions in TOML)
if len(cfg.Keywords) > 0 {
sb.WriteString("# Keywords help transcription and LLM spell names/terms correctly\n")
sb.WriteString("keywords = [")
for i, kw := range cfg.Keywords {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%q", kw))
}
sb.WriteString("]\n\n")
}
// General section
sb.WriteString(`# General Settings
[general]
`)
sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.General.Language))
sb.WriteString("\n")
// Providers section
if len(cfg.Providers) > 0 {
sb.WriteString("# API Keys for providers\n")
for name, pc := range cfg.Providers {
sb.WriteString(fmt.Sprintf("[providers.%s]\n", name))
sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey))
sb.WriteString("\n")
}
}
// Recording
sb.WriteString(`# Audio Recording Configuration
[recording]
`)
sb.WriteString(fmt.Sprintf(" sample_rate = %d\n", cfg.Recording.SampleRate))
sb.WriteString(fmt.Sprintf(" channels = %d\n", cfg.Recording.Channels))
sb.WriteString(fmt.Sprintf(" format = %q\n", cfg.Recording.Format))
sb.WriteString(fmt.Sprintf(" buffer_size = %d\n", cfg.Recording.BufferSize))
sb.WriteString(fmt.Sprintf(" device = %q\n", cfg.Recording.Device))
sb.WriteString(fmt.Sprintf(" channel_buffer_size = %d\n", cfg.Recording.ChannelBufferSize))
sb.WriteString(fmt.Sprintf(" timeout = %q\n", cfg.Recording.Timeout.String()))
sb.WriteString("\n")
// Transcription
sb.WriteString(`# Speech Transcription Configuration
[transcription]
`)
sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.Transcription.Provider))
sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.Transcription.Language))
sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.Transcription.Model))
sb.WriteString(fmt.Sprintf(" streaming = %v\n", cfg.Transcription.Streaming))
sb.WriteString(fmt.Sprintf(" threads = %d\n", cfg.Transcription.Threads))
sb.WriteString("\n")
// LLM
sb.WriteString(`# LLM Post-Processing Configuration
[llm]
`)
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.Enabled))
if cfg.LLM.Provider != "" {
sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.LLM.Provider))
}
if cfg.LLM.Model != "" {
sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.LLM.Model))
}
sb.WriteString("\n")
sb.WriteString(" [llm.post_processing]\n")
sb.WriteString(fmt.Sprintf(" remove_stutters = %v\n", cfg.LLM.PostProcessing.RemoveStutters))
sb.WriteString(fmt.Sprintf(" add_punctuation = %v\n", cfg.LLM.PostProcessing.AddPunctuation))
sb.WriteString(fmt.Sprintf(" fix_grammar = %v\n", cfg.LLM.PostProcessing.FixGrammar))
sb.WriteString(fmt.Sprintf(" remove_filler_words = %v\n", cfg.LLM.PostProcessing.RemoveFillerWords))
sb.WriteString("\n")
sb.WriteString(" [llm.custom_prompt]\n")
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.CustomPrompt.Enabled))
if cfg.LLM.CustomPrompt.Prompt != "" {
sb.WriteString(fmt.Sprintf(" prompt = %q\n", cfg.LLM.CustomPrompt.Prompt))
}
sb.WriteString("\n")
// Injection
sb.WriteString(`# Text Injection Configuration
[injection]
`)
sb.WriteString(" backends = [")
for i, b := range cfg.Injection.Backends {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%q", b))
}
sb.WriteString("]\n")
sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String()))
sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String()))
sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String()))
sb.WriteString("\n")
// Notifications
sb.WriteString(`# Desktop Notification Configuration
[notifications]
`)
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.Notifications.Enabled))
sb.WriteString(fmt.Sprintf(" type = %q\n", cfg.Notifications.Type))
// Write custom messages if any
msgs := cfg.Notifications.Messages
if hasCustomMessages(msgs) {
sb.WriteString("\n [notifications.messages]\n")
if msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" {
sb.WriteString(" [notifications.messages.recording_started]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.RecordingStarted.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingStarted.Body))
}
if msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" {
sb.WriteString(" [notifications.messages.transcribing]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body))
}
if msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" {
sb.WriteString(" [notifications.messages.llm_processing]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.LLMProcessing.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.LLMProcessing.Body))
}
if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" {
sb.WriteString(" [notifications.messages.config_reloaded]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.ConfigReloaded.Body))
}
if msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" {
sb.WriteString(" [notifications.messages.operation_cancelled]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.OperationCancelled.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.OperationCancelled.Body))
}
if msgs.RecordingAborted.Body != "" {
sb.WriteString(" [notifications.messages.recording_aborted]\n")
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingAborted.Body))
}
if msgs.InjectionAborted.Body != "" {
sb.WriteString(" [notifications.messages.injection_aborted]\n")
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.InjectionAborted.Body))
}
}
if _, err := file.WriteString(sb.String()); err != nil {
return fmt.Errorf("failed to write config content: %w", err)
}
return nil
}
func hasCustomMessages(msgs MessagesConfig) bool {
return msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" ||
msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" ||
msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" ||
msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" ||
msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" ||
msgs.RecordingAborted.Body != "" ||
msgs.InjectionAborted.Body != ""
}
// SaveDefaultConfig writes the default config template to the config file
func SaveDefaultConfig() error {
configPath, err := GetConfigPath()
if err != nil {
+6 -5
View File
@@ -62,11 +62,12 @@ type RecordingConfig struct {
}
type TranscriptionConfig struct {
Provider string `toml:"provider"`
APIKey string `toml:"api_key"`
Language string `toml:"language"`
Model string `toml:"model"`
Threads int `toml:"threads"` // CPU threads for local transcription (0 = auto: NumCPU-1)
Provider string `toml:"provider"`
APIKey string `toml:"api_key"`
Language string `toml:"language"`
Model string `toml:"model"`
Streaming bool `toml:"streaming"` // use streaming mode if model supports it
Threads int `toml:"threads"` // CPU threads for local transcription (0 = auto: NumCPU-1)
}
type InjectionConfig struct {
+7
View File
@@ -1,6 +1,7 @@
package notify
import (
"os"
"testing"
)
@@ -16,6 +17,9 @@ func testMessages() map[MessageType]Message {
}
func TestDesktop_Send(t *testing.T) {
if os.Getenv("CI") == "true" {
t.Skip("Skipping Desktop test in CI - calls notify-send")
}
desktop := NewDesktop(testMessages())
// Test Send for different message types (won't actually send, just verify no panic)
@@ -25,6 +29,9 @@ func TestDesktop_Send(t *testing.T) {
}
func TestDesktop_Error(t *testing.T) {
if os.Getenv("CI") == "true" {
t.Skip("Skipping Desktop test in CI - calls notify-send")
}
desktop := NewDesktop(testMessages())
desktop.Error("Test Error Message")
}
+61 -7
View File
@@ -45,6 +45,43 @@ type Pipeline interface {
GetNotifyCh() <-chan notify.MessageType
}
// Factory types for dependency injection
type RecorderFactory func(cfg recording.Config) recording.Recorder
type TranscriberFactory func(cfg transcriber.Config) (transcriber.Transcriber, error)
type InjectorFactory func(cfg injection.Config) injection.Injector
type LLMAdapterFactory func(cfg llm.Config) (llm.Adapter, error)
// Option configures the pipeline
type Option func(*pipeline)
// WithRecorderFactory sets a custom recorder factory
func WithRecorderFactory(f RecorderFactory) Option {
return func(p *pipeline) {
p.recorderFactory = f
}
}
// WithTranscriberFactory sets a custom transcriber factory
func WithTranscriberFactory(f TranscriberFactory) Option {
return func(p *pipeline) {
p.transcriberFactory = f
}
}
// WithInjectorFactory sets a custom injector factory
func WithInjectorFactory(f InjectorFactory) Option {
return func(p *pipeline) {
p.injectorFactory = f
}
}
// WithLLMAdapterFactory sets a custom LLM adapter factory
func WithLLMAdapterFactory(f LLMAdapterFactory) Option {
return func(p *pipeline) {
p.llmAdapterFactory = f
}
}
type pipeline struct {
status Status
actionCh chan Action
@@ -58,15 +95,32 @@ type pipeline struct {
stopOnce sync.Once
running atomic.Bool
// dependency factories (for testing)
recorderFactory RecorderFactory
transcriberFactory TranscriberFactory
injectorFactory InjectorFactory
llmAdapterFactory LLMAdapterFactory
}
func New(cfg *config.Config) Pipeline {
return &pipeline{
func New(cfg *config.Config, opts ...Option) Pipeline {
p := &pipeline{
actionCh: make(chan Action, 1),
errorCh: make(chan PipelineError, 10),
notifyCh: make(chan notify.MessageType, 10),
config: cfg,
// default factories
recorderFactory: recording.NewRecorder,
transcriberFactory: transcriber.NewTranscriber,
injectorFactory: injection.NewInjector,
llmAdapterFactory: llm.NewAdapter,
}
for _, opt := range opts {
opt(p)
}
return p
}
func (p *pipeline) Run(ctx context.Context) {
if !p.running.CompareAndSwap(false, true) {
@@ -91,7 +145,7 @@ func (p *pipeline) run(ctx context.Context) {
log.Printf("Pipeline: Starting recording")
p.setStatus(Recording)
recorder := recording.NewRecorder(p.config.ToRecordingConfig())
recorder := p.recorderFactory(p.config.ToRecordingConfig())
frameCh, rErrCh, err := recorder.Start(ctx)
if err != nil {
@@ -102,7 +156,7 @@ func (p *pipeline) run(ctx context.Context) {
defer recorder.Stop()
t, err := transcriber.NewTranscriber(p.config.ToTranscriberConfig())
t, err := p.transcriberFactory(p.config.ToTranscriberConfig())
if err != nil {
log.Printf("Pipeline: Failed to create transcriber: %v", err)
p.sendError("Transcription Error", "Failed to create transcriber", err)
@@ -221,7 +275,7 @@ func (p *pipeline) sendNotify(mt notify.MessageType) {
}
}
func (p *pipeline) handleInjectAction(ctx context.Context, recorder *recording.Recorder, t transcriber.Transcriber) {
func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Recorder, t transcriber.Transcriber) {
status := p.Status()
if status != Transcribing {
@@ -254,7 +308,7 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder *recording.R
log.Printf("Pipeline: LLM post-processing enabled, processing text")
llmCfg := p.config.ToLLMConfig()
adapter, err := llm.NewAdapter(llm.Config{
adapter, err := p.llmAdapterFactory(llm.Config{
Provider: llmCfg.Provider,
APIKey: llmCfg.APIKey,
Model: llmCfg.Model,
@@ -279,7 +333,7 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder *recording.R
p.setStatus(Injecting)
}
injector := injection.NewInjector(p.config.ToInjectionConfig())
injector := p.injectorFactory(p.config.ToInjectionConfig())
if err := injector.Inject(ctx, textToInject); err != nil {
p.sendError("Injection Error", "Failed to inject text", err)
+136
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/testutil"
)
func TestNew(t *testing.T) {
@@ -377,3 +378,138 @@ func TestPipeline_ConcurrentAccess(t *testing.T) {
<-done
<-done
}
func TestPipeline_WithMocks(t *testing.T) {
cfg := &config.Config{
Recording: config.RecordingConfig{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
ChannelBufferSize: 30,
Timeout: 5 * time.Minute,
},
Transcription: config.TranscriptionConfig{
Provider: "openai",
APIKey: "test-key",
Language: "en",
Model: "whisper-1",
},
Injection: config.InjectionConfig{
Backends: []string{"clipboard"},
ClipboardTimeout: 3 * time.Second,
},
Notifications: config.NotificationsConfig{
Enabled: true,
Type: "log",
},
}
mockRecorder := testutil.NewMockRecorder()
mockTranscriber := testutil.NewMockTranscriber("hello world")
mockInjector := testutil.NewMockInjector()
p := New(cfg,
WithRecorderFactory(testutil.MockRecorderFactory(mockRecorder)),
WithTranscriberFactory(testutil.MockTranscriberFactory(mockTranscriber)),
WithInjectorFactory(testutil.MockInjectorFactory(mockInjector)),
)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
p.Run(ctx)
// wait for pipeline to start recording/transcribing
time.Sleep(50 * time.Millisecond)
// send inject action
p.GetActionCh() <- Inject
// wait for injection to complete
time.Sleep(100 * time.Millisecond)
// verify injection happened
injected := mockInjector.GetInjectedTexts()
if len(injected) != 1 {
t.Errorf("expected 1 injected text, got %d", len(injected))
} else if injected[0] != "hello world" {
t.Errorf("expected injected text 'hello world', got %q", injected[0])
}
p.Stop()
}
func TestPipeline_WithMocks_LLMProcessing(t *testing.T) {
cfg := &config.Config{
Recording: config.RecordingConfig{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
ChannelBufferSize: 30,
Timeout: 5 * time.Minute,
},
Transcription: config.TranscriptionConfig{
Provider: "openai",
APIKey: "test-key",
Language: "en",
Model: "whisper-1",
},
Injection: config.InjectionConfig{
Backends: []string{"clipboard"},
ClipboardTimeout: 3 * time.Second,
},
Notifications: config.NotificationsConfig{
Enabled: true,
Type: "log",
},
LLM: config.LLMConfig{
Enabled: true,
Provider: "openai",
Model: "gpt-4",
},
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
}
mockRecorder := testutil.NewMockRecorder()
mockTranscriber := testutil.NewMockTranscriber("um hello um world")
mockInjector := testutil.NewMockInjector()
mockLLM := testutil.NewMockLLMAdapter("Hello, World!")
p := New(cfg,
WithRecorderFactory(testutil.MockRecorderFactory(mockRecorder)),
WithTranscriberFactory(testutil.MockTranscriberFactory(mockTranscriber)),
WithInjectorFactory(testutil.MockInjectorFactory(mockInjector)),
WithLLMAdapterFactory(testutil.MockLLMAdapterFactory(mockLLM)),
)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
p.Run(ctx)
time.Sleep(50 * time.Millisecond)
p.GetActionCh() <- Inject
time.Sleep(100 * time.Millisecond)
// verify LLM was called with transcription
if !mockLLM.ProcessCalled {
t.Error("expected LLM.Process to be called")
}
if mockLLM.InputText != "um hello um world" {
t.Errorf("expected LLM input 'um hello um world', got %q", mockLLM.InputText)
}
// verify injection used LLM output
injected := mockInjector.GetInjectedTexts()
if len(injected) != 1 {
t.Errorf("expected 1 injected text, got %d", len(injected))
} else if injected[0] != "Hello, World!" {
t.Errorf("expected injected text 'Hello, World!', got %q", injected[0])
}
p.Stop()
}
+12 -32
View File
@@ -4,7 +4,7 @@ package provider
type DeepgramProvider struct{}
func (p *DeepgramProvider) Name() string {
return "deepgram"
return ProviderDeepgram
}
func (p *DeepgramProvider) RequiresAPIKey() bool {
@@ -43,25 +43,15 @@ func (p *DeepgramProvider) Models() []Model {
{
ID: "nova-3",
Name: "Nova-3",
Description: "Best accuracy, 40+ languages, real-time",
Description: "Best accuracy, 40+ languages",
Type: Transcription,
Streaming: true,
SupportsBatch: true,
SupportsStreaming: true,
Local: false,
AdapterType: "deepgram",
AdapterType: AdapterDeepgram,
SupportedLanguages: nova3Langs,
Endpoint: &EndpointConfig{BaseURL: "wss://api.deepgram.com", Path: "/v1/listen"},
DocsURL: docsURL,
},
{
ID: "nova-3-general",
Name: "Nova-3 General",
Description: "General purpose, same as nova-3",
Type: Transcription,
Streaming: true,
Local: false,
AdapterType: "deepgram",
SupportedLanguages: nova3Langs,
Endpoint: &EndpointConfig{BaseURL: "wss://api.deepgram.com", Path: "/v1/listen"},
Endpoint: &EndpointConfig{BaseURL: "https://api.deepgram.com", Path: "/v1/listen"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.deepgram.com", Path: "/v1/listen"},
DocsURL: docsURL,
},
{
@@ -69,23 +59,13 @@ func (p *DeepgramProvider) Models() []Model {
Name: "Nova-2",
Description: "Fast, 30+ languages, filler words",
Type: Transcription,
Streaming: true,
SupportsBatch: true,
SupportsStreaming: true,
Local: false,
AdapterType: "deepgram",
AdapterType: AdapterDeepgram,
SupportedLanguages: nova2Langs,
Endpoint: &EndpointConfig{BaseURL: "wss://api.deepgram.com", Path: "/v1/listen"},
DocsURL: docsURL,
},
{
ID: "nova-2-general",
Name: "Nova-2 General",
Description: "General purpose, same as nova-2",
Type: Transcription,
Streaming: true,
Local: false,
AdapterType: "deepgram",
SupportedLanguages: nova2Langs,
Endpoint: &EndpointConfig{BaseURL: "wss://api.deepgram.com", Path: "/v1/listen"},
Endpoint: &EndpointConfig{BaseURL: "https://api.deepgram.com", Path: "/v1/listen"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.deepgram.com", Path: "/v1/listen"},
DocsURL: docsURL,
},
}
+27 -8
View File
@@ -25,14 +25,20 @@ func TestDeepgramProvider_Models(t *testing.T) {
p := &DeepgramProvider{}
models := p.Models()
if len(models) != 4 {
t.Errorf("Models() returned %d models, want 4", len(models))
if len(models) != 2 {
t.Errorf("Models() returned %d models, want 2", len(models))
}
// all models should be streaming
// all models should support both batch and streaming
for _, m := range models {
if !m.Streaming {
t.Errorf("model %s should be streaming", m.ID)
if !m.SupportsBatch {
t.Errorf("model %s should support batch", m.ID)
}
if !m.SupportsStreaming {
t.Errorf("model %s should support streaming", m.ID)
}
if !m.SupportsBothModes() {
t.Errorf("model %s should support both modes", m.ID)
}
if m.AdapterType != "deepgram" {
t.Errorf("model %s has AdapterType %q, want 'deepgram'", m.ID, m.AdapterType)
@@ -96,15 +102,28 @@ func TestDeepgramProvider_Endpoint(t *testing.T) {
models := p.Models()
for _, m := range models {
// batch endpoint (HTTP)
if m.Endpoint == nil {
t.Errorf("model %s has nil Endpoint", m.ID)
continue
}
if m.Endpoint.BaseURL != "wss://api.deepgram.com" {
t.Errorf("model %s has BaseURL %q, want 'wss://api.deepgram.com'", m.ID, m.Endpoint.BaseURL)
if m.Endpoint.BaseURL != "https://api.deepgram.com" {
t.Errorf("model %s has Endpoint.BaseURL %q, want 'https://api.deepgram.com'", m.ID, m.Endpoint.BaseURL)
}
if m.Endpoint.Path != "/v1/listen" {
t.Errorf("model %s has Path %q, want '/v1/listen'", m.ID, m.Endpoint.Path)
t.Errorf("model %s has Endpoint.Path %q, want '/v1/listen'", m.ID, m.Endpoint.Path)
}
// streaming endpoint (WebSocket)
if m.StreamingEndpoint == nil {
t.Errorf("model %s has nil StreamingEndpoint", m.ID)
continue
}
if m.StreamingEndpoint.BaseURL != "wss://api.deepgram.com" {
t.Errorf("model %s has StreamingEndpoint.BaseURL %q, want 'wss://api.deepgram.com'", m.ID, m.StreamingEndpoint.BaseURL)
}
if m.StreamingEndpoint.Path != "/v1/listen" {
t.Errorf("model %s has StreamingEndpoint.Path %q, want '/v1/listen'", m.ID, m.StreamingEndpoint.Path)
}
}
}
+14 -25
View File
@@ -6,7 +6,7 @@ import "github.com/leonardotrapani/hyprvoice/internal/language"
type ElevenLabsProvider struct{}
func (p *ElevenLabsProvider) Name() string {
return "elevenlabs"
return ProviderElevenLabs
}
func (p *ElevenLabsProvider) RequiresAPIKey() bool {
@@ -29,15 +29,15 @@ func (p *ElevenLabsProvider) Models() []Model {
docsURL := "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages"
return []Model{
// batch models
{
ID: "scribe_v1",
Name: "Scribe v1",
Description: "90+ languages, best accuracy",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "elevenlabs",
AdapterType: AdapterElevenLabs,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"},
DocsURL: docsURL,
@@ -45,36 +45,25 @@ func (p *ElevenLabsProvider) Models() []Model {
{
ID: "scribe_v2",
Name: "Scribe v2",
Description: "Lower latency, real-time optimized",
Description: "Lower latency batch transcription",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "elevenlabs",
AdapterType: AdapterElevenLabs,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"},
DocsURL: docsURL,
},
// streaming models
{
ID: "scribe_v1-streaming",
Name: "Scribe v1 Streaming",
Description: "Real-time transcription, 90+ languages",
ID: "scribe_v2_realtime",
Name: "Scribe v2 Realtime",
Description: "Real-time streaming, <150ms latency",
Type: Transcription,
Streaming: true,
SupportsBatch: false,
SupportsStreaming: true,
Local: false,
AdapterType: "elevenlabs-streaming",
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
DocsURL: docsURL,
},
{
ID: "scribe_v2-streaming",
Name: "Scribe v2 Streaming",
Description: "Real-time with <150ms latency",
Type: Transcription,
Streaming: true,
Local: false,
AdapterType: "elevenlabs-streaming",
AdapterType: AdapterElevenLabsStream,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
DocsURL: docsURL,
+16 -23
View File
@@ -10,7 +10,7 @@ import (
type GroqProvider struct{}
func (p *GroqProvider) Name() string {
return "groq"
return ProviderGroq
}
func (p *GroqProvider) RequiresAPIKey() bool {
@@ -36,9 +36,10 @@ func (p *GroqProvider) Models() []Model {
Name: "Whisper Large v3",
Description: "Full Whisper v3 model, best accuracy",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
@@ -48,34 +49,24 @@ func (p *GroqProvider) Models() []Model {
Name: "Whisper Large v3 Turbo",
Description: "Faster Whisper v3 with slightly lower accuracy",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
{
ID: "distil-whisper-large-v3-en",
Name: "Distil Whisper Large v3 EN",
Description: "English-only, fastest option",
Type: Transcription,
Streaming: false,
Local: false,
AdapterType: "openai",
SupportedLanguages: []string{"en"},
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
// LLM models
{
ID: "llama-3.3-70b-versatile",
Name: "Llama 3.3 70B Versatile",
Description: "Most capable Llama model",
Type: LLM,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
},
@@ -84,9 +75,10 @@ func (p *GroqProvider) Models() []Model {
Name: "Llama 3.1 8B Instant",
Description: "Fast and efficient",
Type: LLM,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
},
@@ -95,9 +87,10 @@ func (p *GroqProvider) Models() []Model {
Name: "Mixtral 8x7B",
Description: "Mixture of experts model",
Type: LLM,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
},
+9 -5
View File
@@ -6,7 +6,7 @@ import "github.com/leonardotrapani/hyprvoice/internal/language"
type MistralProvider struct{}
func (p *MistralProvider) Name() string {
return "mistral"
return ProviderMistral
}
func (p *MistralProvider) RequiresAPIKey() bool {
@@ -32,9 +32,11 @@ func (p *MistralProvider) Models() []Model {
Name: "Voxtral Mini Latest",
Description: "Latest Voxtral model, best for most uses",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: true,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
StreamingAdapter: "mistral-streaming", // not yet implemented
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
@@ -44,9 +46,11 @@ func (p *MistralProvider) Models() []Model {
Name: "Voxtral Mini 2507",
Description: "Stable Voxtral version from July 2025",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: true,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
StreamingAdapter: "mistral-streaming", // not yet implemented
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
+10 -2
View File
@@ -16,9 +16,12 @@ type Model struct {
Name string // display name (e.g., "Whisper 1", "GPT-4o Mini")
Description string // short description
Type ModelType // transcription or LLM
Streaming bool // supports streaming
SupportsBatch bool // can do batch/non-streaming transcription
SupportsStreaming bool // can do real-time streaming transcription
Local bool // runs locally (no API call)
AdapterType string // which adapter to use (e.g., "openai", "elevenlabs", "whisper-cpp")
StreamingAdapter string // adapter for streaming mode (if different from AdapterType)
StreamingEndpoint *EndpointConfig // endpoint for streaming mode (if different from Endpoint)
SupportedLanguages []string // explicit list of supported language codes
Endpoint *EndpointConfig // nil for local models
LocalInfo *LocalModelInfo // nil for cloud models
@@ -45,7 +48,12 @@ func (m *Model) NeedsDownload() bool {
// IsStreaming returns true if this model supports streaming
func (m *Model) IsStreaming() bool {
return m.Streaming
return m.SupportsStreaming
}
// SupportsBothModes returns true if this model supports both batch and streaming
func (m *Model) SupportsBothModes() bool {
return m.SupportsBatch && m.SupportsStreaming
}
// SupportsLanguage returns true if the model supports the given language code.
+53 -7
View File
@@ -60,15 +60,20 @@ func TestModel_IsStreaming(t *testing.T) {
expected bool
}{
{
name: "streaming model",
model: Model{ID: "scribe_v1-streaming", Streaming: true},
name: "streaming-only model",
model: Model{ID: "scribe_v2_realtime", SupportsBatch: false, SupportsStreaming: true},
expected: true,
},
{
name: "batch model",
model: Model{ID: "whisper-1", Streaming: false},
name: "batch-only model",
model: Model{ID: "whisper-1", SupportsBatch: true, SupportsStreaming: false},
expected: false,
},
{
name: "both modes model",
model: Model{ID: "nova-3", SupportsBatch: true, SupportsStreaming: true},
expected: true,
},
}
for _, tc := range tests {
@@ -80,6 +85,38 @@ func TestModel_IsStreaming(t *testing.T) {
}
}
func TestModel_SupportsBothModes(t *testing.T) {
tests := []struct {
name string
model Model
expected bool
}{
{
name: "streaming-only model",
model: Model{ID: "scribe_v2_realtime", SupportsBatch: false, SupportsStreaming: true},
expected: false,
},
{
name: "batch-only model",
model: Model{ID: "whisper-1", SupportsBatch: true, SupportsStreaming: false},
expected: false,
},
{
name: "both modes model",
model: Model{ID: "nova-3", SupportsBatch: true, SupportsStreaming: true},
expected: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.model.SupportsBothModes(); got != tc.expected {
t.Errorf("SupportsBothModes() = %v, want %v", got, tc.expected)
}
})
}
}
func TestModel_SupportsLanguage(t *testing.T) {
allCodes := language.AllLanguageCodes()
@@ -283,14 +320,20 @@ func TestModel_AllFields(t *testing.T) {
Name: "Test Model",
Description: "A test model for verification",
Type: Transcription,
Streaming: true,
SupportsBatch: true,
SupportsStreaming: true,
Local: true,
AdapterType: "test-adapter",
StreamingAdapter: "test-streaming-adapter",
SupportedLanguages: []string{"en", "es"},
Endpoint: &EndpointConfig{
BaseURL: "https://api.test.com",
Path: "/v1/test",
},
StreamingEndpoint: &EndpointConfig{
BaseURL: "wss://api.test.com",
Path: "/v1/stream",
},
LocalInfo: &LocalModelInfo{
Filename: "test.bin",
Size: "100MB",
@@ -311,8 +354,11 @@ func TestModel_AllFields(t *testing.T) {
if model.Type != Transcription {
t.Errorf("Type = %v, want Transcription", model.Type)
}
if !model.Streaming {
t.Error("Streaming should be true")
if !model.SupportsBatch {
t.Error("SupportsBatch should be true")
}
if !model.SupportsStreaming {
t.Error("SupportsStreaming should be true")
}
if !model.Local {
t.Error("Local should be true")
+73
View File
@@ -0,0 +1,73 @@
package provider
// Provider name constants for config and registry
const (
ProviderOpenAI = "openai"
ProviderGroq = "groq"
ProviderMistral = "mistral"
ProviderElevenLabs = "elevenlabs"
ProviderDeepgram = "deepgram"
ProviderWhisperCpp = "whisper-cpp"
)
// Config provider names (used in config file transcription.provider)
const (
ConfigProviderOpenAI = "openai"
ConfigProviderGroqTranscription = "groq-transcription"
ConfigProviderGroqTranslation = "groq-translation"
ConfigProviderMistralTranscription = "mistral-transcription"
ConfigProviderElevenLabs = "elevenlabs"
ConfigProviderDeepgram = "deepgram"
ConfigProviderWhisperCpp = "whisper-cpp"
)
// Environment variable names for API keys
const (
EnvOpenAIKey = "OPENAI_API_KEY"
EnvGroqKey = "GROQ_API_KEY"
EnvMistralKey = "MISTRAL_API_KEY"
EnvElevenLabsKey = "ELEVENLABS_API_KEY"
EnvDeepgramKey = "DEEPGRAM_API_KEY"
)
// Adapter type constants for transcription backends
const (
AdapterOpenAI = "openai"
AdapterElevenLabs = "elevenlabs"
AdapterElevenLabsStream = "elevenlabs-streaming"
AdapterDeepgram = "deepgram"
AdapterWhisperCpp = "whisper-cpp"
AdapterOpenAIRealtime = "openai-realtime"
)
// BaseProviderName maps config provider names to registry provider names
// e.g. "groq-transcription" -> "groq", "mistral-transcription" -> "mistral"
func BaseProviderName(configProvider string) string {
switch configProvider {
case ConfigProviderGroqTranscription, ConfigProviderGroqTranslation:
return ProviderGroq
case ConfigProviderMistralTranscription:
return ProviderMistral
default:
return configProvider
}
}
// EnvVarForProvider returns the environment variable name for a provider's API key
func EnvVarForProvider(provider string) string {
base := BaseProviderName(provider)
switch base {
case ProviderOpenAI:
return EnvOpenAIKey
case ProviderGroq:
return EnvGroqKey
case ProviderMistral:
return EnvMistralKey
case ProviderElevenLabs:
return EnvElevenLabsKey
case ProviderDeepgram:
return EnvDeepgramKey
default:
return ""
}
}
+20 -23
View File
@@ -10,7 +10,7 @@ import (
type OpenAIProvider struct{}
func (p *OpenAIProvider) Name() string {
return "openai"
return ProviderOpenAI
}
func (p *OpenAIProvider) RequiresAPIKey() bool {
@@ -37,9 +37,10 @@ func (p *OpenAIProvider) Models() []Model {
Name: "Whisper 1",
Description: "OpenAI's production speech-to-text model",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
@@ -49,11 +50,14 @@ func (p *OpenAIProvider) Models() []Model {
Name: "GPT-4o Transcribe",
Description: "High quality transcription with GPT-4o",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: true,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
StreamingAdapter: AdapterOpenAIRealtime,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.openai.com", Path: "/v1/realtime"},
DocsURL: docsURL,
},
{
@@ -61,23 +65,14 @@ func (p *OpenAIProvider) Models() []Model {
Name: "GPT-4o Mini Transcribe",
Description: "Fast transcription with GPT-4o Mini",
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: true,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
StreamingAdapter: AdapterOpenAIRealtime,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
{
ID: "gpt-4o-realtime-preview",
Name: "GPT-4o Realtime",
Description: "Real-time streaming transcription with GPT-4o",
Type: Transcription,
Streaming: true,
Local: false,
AdapterType: "openai-realtime",
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "wss://api.openai.com", Path: "/v1/realtime"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.openai.com", Path: "/v1/realtime"},
DocsURL: docsURL,
},
// LLM models
@@ -86,9 +81,10 @@ func (p *OpenAIProvider) Models() []Model {
Name: "GPT-4o Mini",
Description: "Fast and affordable GPT-4 variant",
Type: LLM,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
},
@@ -97,9 +93,10 @@ func (p *OpenAIProvider) Models() []Model {
Name: "GPT-4o",
Description: "Most capable GPT-4 model",
Type: LLM,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: "openai",
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
},
+62 -60
View File
@@ -177,9 +177,9 @@ func TestModelsOfType(t *testing.T) {
trans := ModelsOfType(p, Transcription)
llm := ModelsOfType(p, LLM)
// OpenAI has 4 transcription models: whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-realtime-preview
if len(trans) != 4 {
t.Errorf("ModelsOfType(Transcription) = %d, want 4", len(trans))
// OpenAI has 3 transcription models: whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe
if len(trans) != 3 {
t.Errorf("ModelsOfType(Transcription) = %d, want 3", len(trans))
}
// OpenAI has 2 LLM models: gpt-4o-mini, gpt-4o
if len(llm) != 2 {
@@ -213,22 +213,22 @@ func TestFindModelByID(t *testing.T) {
func TestModelsForLanguage(t *testing.T) {
groq := GetProvider("groq")
// en should include all models (distil supports en)
// en should include all models
enModels := ModelsForLanguage(groq, Transcription, "en")
if len(enModels) != 3 {
t.Errorf("ModelsForLanguage('en') = %d, want 3", len(enModels))
if len(enModels) != 2 {
t.Errorf("ModelsForLanguage('en') = %d, want 2", len(enModels))
}
// es should exclude distil-whisper-large-v3-en
// es should include all models (both are multilingual)
esModels := ModelsForLanguage(groq, Transcription, "es")
if len(esModels) != 2 {
t.Errorf("ModelsForLanguage('es') = %d, want 2 (distil excluded)", len(esModels))
t.Errorf("ModelsForLanguage('es') = %d, want 2", len(esModels))
}
// auto ("") should include all models
autoModels := ModelsForLanguage(groq, Transcription, "")
if len(autoModels) != 3 {
t.Errorf("ModelsForLanguage('') = %d, want 3 (auto returns all)", len(autoModels))
if len(autoModels) != 2 {
t.Errorf("ModelsForLanguage('') = %d, want 2 (auto returns all)", len(autoModels))
}
}
@@ -239,16 +239,16 @@ func TestValidateModelLanguage(t *testing.T) {
t.Errorf("ValidateModelLanguage(whisper-large-v3, 'es') unexpected error: %v", err)
}
// invalid language for English-only model
err = ValidateModelLanguage("groq", "distil-whisper-large-v3-en", "es")
if err == nil {
t.Error("ValidateModelLanguage(distil-whisper, 'es') should return error")
// valid language for another multilingual model
err = ValidateModelLanguage("groq", "whisper-large-v3-turbo", "de")
if err != nil {
t.Errorf("ValidateModelLanguage(whisper-large-v3-turbo, 'de') unexpected error: %v", err)
}
// auto always passes
err = ValidateModelLanguage("groq", "distil-whisper-large-v3-en", "")
err = ValidateModelLanguage("groq", "whisper-large-v3", "")
if err != nil {
t.Errorf("ValidateModelLanguage(distil-whisper, '') should pass (auto): %v", err)
t.Errorf("ValidateModelLanguage(whisper-large-v3, '') should pass (auto): %v", err)
}
// unknown provider
@@ -266,19 +266,20 @@ func TestValidateModelLanguage(t *testing.T) {
func TestValidateModelLanguage_ErrorFormat(t *testing.T) {
// verify error includes model name, not ID
err := ValidateModelLanguage("groq", "distil-whisper-large-v3-en", "es")
// use whisper-cpp base.en model which is English-only
err := ValidateModelLanguage("whisper-cpp", "base.en", "es")
if err == nil {
t.Fatal("expected error for unsupported language")
}
errMsg := err.Error()
// should contain model name (from Model.Name)
if !strings.Contains(errMsg, "Distil Whisper Large v3 EN") {
if !strings.Contains(errMsg, "Base English") {
t.Errorf("error should contain model name, got: %s", errMsg)
}
// should contain docs URL
if !strings.Contains(errMsg, "https://console.groq.com/docs/speech-to-text#supported-languages") {
if !strings.Contains(errMsg, "https://github.com/openai/whisper") {
t.Errorf("error should contain docs URL, got: %s", errMsg)
}
@@ -287,36 +288,39 @@ func TestValidateModelLanguage_ErrorFormat(t *testing.T) {
t.Errorf("error should contain language code, got: %s", errMsg)
}
// should have truncated language list (only 5 supported langs, English-only has 1)
// should contain supported languages (English-only has just 'en')
if !strings.Contains(errMsg, "en") {
t.Errorf("error should contain supported languages, got: %s", errMsg)
}
}
func TestOpenAIRealtimeModel(t *testing.T) {
m, err := GetModel("openai", "gpt-4o-realtime-preview")
func TestOpenAIStreamingModels(t *testing.T) {
// gpt-4o-transcribe supports both batch and streaming
m, err := GetModel("openai", "gpt-4o-transcribe")
if err != nil {
t.Fatalf("GetModel('openai', 'gpt-4o-realtime-preview') error: %v", err)
t.Fatalf("GetModel('openai', 'gpt-4o-transcribe') error: %v", err)
}
if !m.Streaming {
t.Error("gpt-4o-realtime-preview should have Streaming=true")
if !m.SupportsBatch {
t.Error("gpt-4o-transcribe should have SupportsBatch=true")
}
if !m.SupportsStreaming {
t.Error("gpt-4o-transcribe should have SupportsStreaming=true")
}
if !m.SupportsBothModes() {
t.Error("gpt-4o-transcribe should support both modes")
}
if m.AdapterType != "openai-realtime" {
t.Errorf("gpt-4o-realtime-preview AdapterType=%q, want 'openai-realtime'", m.AdapterType)
if m.StreamingAdapter != "openai-realtime" {
t.Errorf("gpt-4o-transcribe StreamingAdapter=%q, want 'openai-realtime'", m.StreamingAdapter)
}
if m.Endpoint == nil {
t.Fatal("gpt-4o-realtime-preview should have Endpoint set")
if m.StreamingEndpoint == nil {
t.Fatal("gpt-4o-transcribe should have StreamingEndpoint set")
}
if m.Endpoint.BaseURL != "wss://api.openai.com" {
t.Errorf("gpt-4o-realtime-preview Endpoint.BaseURL=%q, want 'wss://api.openai.com'", m.Endpoint.BaseURL)
}
if len(m.SupportedLanguages) != 57 {
t.Errorf("gpt-4o-realtime-preview has %d languages, want 57", len(m.SupportedLanguages))
if m.StreamingEndpoint.BaseURL != "wss://api.openai.com" {
t.Errorf("gpt-4o-transcribe StreamingEndpoint.BaseURL=%q, want 'wss://api.openai.com'", m.StreamingEndpoint.BaseURL)
}
// default model should still be whisper-1
@@ -334,18 +338,21 @@ func TestElevenLabsProvider(t *testing.T) {
models := p.Models()
// ElevenLabsProvider.Models() returns 4 models
if len(models) != 4 {
t.Errorf("ElevenLabsProvider.Models() = %d models, want 4", len(models))
// ElevenLabsProvider.Models() returns 3 models
if len(models) != 3 {
t.Errorf("ElevenLabsProvider.Models() = %d models, want 3", len(models))
}
// Check batch models
// Check batch-only models
scribeV1, err := GetModel("elevenlabs", "scribe_v1")
if err != nil {
t.Fatalf("GetModel('elevenlabs', 'scribe_v1') error: %v", err)
}
if scribeV1.Streaming {
t.Error("scribe_v1 should have Streaming=false")
if !scribeV1.SupportsBatch {
t.Error("scribe_v1 should have SupportsBatch=true")
}
if scribeV1.SupportsStreaming {
t.Error("scribe_v1 should have SupportsStreaming=false")
}
if scribeV1.AdapterType != "elevenlabs" {
t.Errorf("scribe_v1 AdapterType=%q, want 'elevenlabs'", scribeV1.AdapterType)
@@ -355,34 +362,29 @@ func TestElevenLabsProvider(t *testing.T) {
if err != nil {
t.Fatalf("GetModel('elevenlabs', 'scribe_v2') error: %v", err)
}
if scribeV2.Streaming {
t.Error("scribe_v2 should have Streaming=false")
if !scribeV2.SupportsBatch {
t.Error("scribe_v2 should have SupportsBatch=true")
}
if scribeV2.SupportsStreaming {
t.Error("scribe_v2 should have SupportsStreaming=false")
}
if scribeV2.AdapterType != "elevenlabs" {
t.Errorf("scribe_v2 AdapterType=%q, want 'elevenlabs'", scribeV2.AdapterType)
}
// Check streaming models
scribeV1S, err := GetModel("elevenlabs", "scribe_v1-streaming")
// Check streaming-only model
scribeV2Realtime, err := GetModel("elevenlabs", "scribe_v2_realtime")
if err != nil {
t.Fatalf("GetModel('elevenlabs', 'scribe_v1-streaming') error: %v", err)
t.Fatalf("GetModel('elevenlabs', 'scribe_v2_realtime') error: %v", err)
}
if !scribeV1S.Streaming {
t.Error("scribe_v1-streaming should have Streaming=true")
if scribeV2Realtime.SupportsBatch {
t.Error("scribe_v2_realtime should have SupportsBatch=false")
}
if scribeV1S.AdapterType != "elevenlabs-streaming" {
t.Errorf("scribe_v1-streaming AdapterType=%q, want 'elevenlabs-streaming'", scribeV1S.AdapterType)
if !scribeV2Realtime.SupportsStreaming {
t.Error("scribe_v2_realtime should have SupportsStreaming=true")
}
scribeV2S, err := GetModel("elevenlabs", "scribe_v2-streaming")
if err != nil {
t.Fatalf("GetModel('elevenlabs', 'scribe_v2-streaming') error: %v", err)
}
if !scribeV2S.Streaming {
t.Error("scribe_v2-streaming should have Streaming=true")
}
if scribeV2S.AdapterType != "elevenlabs-streaming" {
t.Errorf("scribe_v2-streaming AdapterType=%q, want 'elevenlabs-streaming'", scribeV2S.AdapterType)
if scribeV2Realtime.AdapterType != "elevenlabs-streaming" {
t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs-streaming'", scribeV2Realtime.AdapterType)
}
// All models have explicit SupportedLanguages from docs (subset of our 57)
+4 -3
View File
@@ -9,7 +9,7 @@ import (
type WhisperCppProvider struct{}
func (p *WhisperCppProvider) Name() string {
return "whisper-cpp"
return ProviderWhisperCpp
}
func (p *WhisperCppProvider) RequiresAPIKey() bool {
@@ -45,9 +45,10 @@ func (p *WhisperCppProvider) Models() []Model {
Name: wm.Name,
Description: modelDescription(wm),
Type: Transcription,
Streaming: false,
SupportsBatch: true,
SupportsStreaming: false,
Local: true,
AdapterType: "whisper-cpp",
AdapterType: AdapterWhisperCpp,
SupportedLanguages: langs,
Endpoint: nil, // local CLI, no HTTP endpoint
LocalInfo: &LocalModelInfo{
+18 -11
View File
@@ -29,7 +29,14 @@ type Config struct {
Timeout time.Duration
}
type Recorder struct {
// Recorder interface for audio recording
type Recorder interface {
Start(ctx context.Context) (<-chan AudioFrame, <-chan error, error)
Stop()
IsRecording() bool
}
type recorder struct {
config Config
recording atomic.Bool
@@ -40,15 +47,15 @@ type Recorder struct {
wg sync.WaitGroup
}
func NewRecorder(config Config) *Recorder {
return &Recorder{config: config}
func NewRecorder(config Config) Recorder {
return &recorder{config: config}
}
func (r *Recorder) IsRecording() bool {
func (r *recorder) IsRecording() bool {
return r.recording.Load()
}
func (r *Recorder) Start(ctx context.Context) (<-chan AudioFrame, <-chan error, error) {
func (r *recorder) Start(ctx context.Context) (<-chan AudioFrame, <-chan error, error) {
if r.recording.Load() {
return nil, nil, fmt.Errorf("already recording")
}
@@ -77,7 +84,7 @@ func (r *Recorder) Start(ctx context.Context) (<-chan AudioFrame, <-chan error,
return frameCh, errCh, nil
}
func (r *Recorder) Stop() {
func (r *recorder) Stop() {
if !r.recording.Load() {
return
}
@@ -87,7 +94,7 @@ func (r *Recorder) Stop() {
r.wg.Wait()
}
func (r *Recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, errCh chan<- error) {
func (r *recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, errCh chan<- error) {
defer func() {
close(frameCh)
close(errCh)
@@ -178,7 +185,7 @@ func (r *Recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, e
}
}
func (r *Recorder) requestCancel() {
func (r *recorder) requestCancel() {
r.mu.Lock()
cancel := r.cancel
r.mu.Unlock()
@@ -187,7 +194,7 @@ func (r *Recorder) requestCancel() {
}
}
func (r *Recorder) emitErr(errCh chan<- error, err error) {
func (r *recorder) emitErr(errCh chan<- error, err error) {
select {
case errCh <- err:
default:
@@ -195,7 +202,7 @@ func (r *Recorder) emitErr(errCh chan<- error, err error) {
log.Printf("Recording error: %v", err)
}
func (r *Recorder) buildPwRecordArgs() []string {
func (r *recorder) buildPwRecordArgs() []string {
args := []string{
"--format", r.config.Format,
"--rate", strconv.Itoa(r.config.SampleRate),
@@ -222,7 +229,7 @@ func CheckPipeWireAvailable(ctx context.Context) error {
return nil
}
func (r *Recorder) validateConfig() error {
func (r *recorder) validateConfig() error {
if r.config.SampleRate <= 0 {
return fmt.Errorf("invalid SampleRate: %d", r.config.SampleRate)
}
+7 -87
View File
@@ -24,8 +24,9 @@ func TestNewRecorder(t *testing.T) {
return
}
if recorder.config.SampleRate != config.SampleRate {
t.Errorf("SampleRate not set correctly: got %d, want %d", recorder.config.SampleRate, config.SampleRate)
// verify recorder implements the interface
if !recorder.IsRecording() {
t.Logf("Recorder created successfully, not recording initially")
}
}
@@ -54,19 +55,6 @@ func TestRecorder_ValidateConfig(t *testing.T) {
config Config
wantErr bool
}{
{
name: "valid config",
config: Config{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
Device: "",
ChannelBufferSize: 30,
Timeout: 5 * time.Minute,
},
wantErr: false,
},
{
name: "invalid sample rate",
config: Config{
@@ -127,85 +115,17 @@ func TestRecorder_ValidateConfig(t *testing.T) {
},
wantErr: true,
},
{
name: "invalid timeout",
config: Config{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
ChannelBufferSize: 30,
Timeout: 0,
},
wantErr: false, // Timeout validation is not implemented in validateConfig
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := NewRecorder(tt.config)
err := recorder.validateConfig()
ctx := context.Background()
_, _, err := recorder.Start(ctx)
if (err != nil) != tt.wantErr {
t.Errorf("validateConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestRecorder_BuildPwRecordArgs(t *testing.T) {
tests := []struct {
name string
config Config
expected []string
}{
{
name: "default config",
config: Config{
SampleRate: 16000,
Channels: 1,
Format: "s16",
Device: "",
},
expected: []string{
"--format", "s16",
"--rate", "16000",
"--channels", "1",
"-",
},
},
{
name: "with device",
config: Config{
SampleRate: 44100,
Channels: 2,
Format: "s32",
Device: "hw:0",
},
expected: []string{
"--format", "s32",
"--rate", "44100",
"--channels", "2",
"-",
"--target", "hw:0",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := NewRecorder(tt.config)
args := recorder.buildPwRecordArgs()
if len(args) != len(tt.expected) {
t.Errorf("buildPwRecordArgs() returned %d args, want %d", len(args), len(tt.expected))
return
}
for i, arg := range args {
if arg != tt.expected[i] {
t.Errorf("buildPwRecordArgs()[%d] = %q, want %q", i, arg, tt.expected[i])
}
t.Errorf("Start() with invalid config error = %v, wantErr %v", err, tt.wantErr)
}
recorder.Stop()
})
}
}
+213
View File
@@ -5,11 +5,16 @@ import (
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/llm"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
)
// TestConfig returns a valid configuration for testing
@@ -180,3 +185,211 @@ func CaptureOutput(t *testing.T, fn func()) string {
out, _ := io.ReadAll(r)
return string(out)
}
// MockRecorder implements recording.Recorder for testing
type MockRecorder struct {
Frames []recording.AudioFrame
StartError error
mu sync.Mutex
recording atomic.Bool
stopCh chan struct{}
}
func NewMockRecorder() *MockRecorder {
return &MockRecorder{
Frames: []recording.AudioFrame{MockAudioFrame(nil)},
}
}
func (m *MockRecorder) Start(ctx context.Context) (<-chan recording.AudioFrame, <-chan error, error) {
if m.StartError != nil {
return nil, nil, m.StartError
}
m.mu.Lock()
m.stopCh = make(chan struct{})
m.mu.Unlock()
m.recording.Store(true)
frameCh := make(chan recording.AudioFrame, len(m.Frames)+1)
errCh := make(chan error, 1)
go func() {
defer close(frameCh)
defer close(errCh)
for _, frame := range m.Frames {
select {
case <-ctx.Done():
return
case <-m.stopCh:
return
case frameCh <- frame:
}
}
// keep channel open until stopped
select {
case <-ctx.Done():
case <-m.stopCh:
}
}()
return frameCh, errCh, nil
}
func (m *MockRecorder) Stop() {
if !m.recording.Load() {
return
}
m.recording.Store(false)
m.mu.Lock()
if m.stopCh != nil {
close(m.stopCh)
m.stopCh = nil
}
m.mu.Unlock()
}
func (m *MockRecorder) IsRecording() bool {
return m.recording.Load()
}
// MockTranscriber implements transcriber.Transcriber for testing
type MockTranscriber struct {
Transcription string
StartError error
StopError error
GetError error
mu sync.Mutex
started bool
}
func NewMockTranscriber(transcription string) *MockTranscriber {
return &MockTranscriber{Transcription: transcription}
}
func (m *MockTranscriber) Start(ctx context.Context, frameCh <-chan recording.AudioFrame) (<-chan error, error) {
if m.StartError != nil {
return nil, m.StartError
}
m.mu.Lock()
m.started = true
m.mu.Unlock()
errCh := make(chan error, 1)
// drain frames in background
go func() {
defer close(errCh)
for range frameCh {
}
}()
return errCh, nil
}
func (m *MockTranscriber) Stop(ctx context.Context) error {
m.mu.Lock()
m.started = false
m.mu.Unlock()
return m.StopError
}
func (m *MockTranscriber) GetFinalTranscription() (string, error) {
if m.GetError != nil {
return "", m.GetError
}
return m.Transcription, nil
}
// MockInjector implements injection.Injector for testing
type MockInjector struct {
InjectedTexts []string
InjectError error
mu sync.Mutex
}
func NewMockInjector() *MockInjector {
return &MockInjector{}
}
func (m *MockInjector) Inject(ctx context.Context, text string) error {
if m.InjectError != nil {
return m.InjectError
}
m.mu.Lock()
m.InjectedTexts = append(m.InjectedTexts, text)
m.mu.Unlock()
return nil
}
func (m *MockInjector) GetInjectedTexts() []string {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]string, len(m.InjectedTexts))
copy(result, m.InjectedTexts)
return result
}
// MockLLMAdapter implements llm.Adapter for testing
type MockLLMAdapter struct {
ProcessedText string
ProcessError error
mu sync.Mutex
ProcessCalled bool
InputText string
}
func NewMockLLMAdapter(processedText string) *MockLLMAdapter {
return &MockLLMAdapter{ProcessedText: processedText}
}
func (m *MockLLMAdapter) Process(ctx context.Context, text string) (string, error) {
m.mu.Lock()
m.ProcessCalled = true
m.InputText = text
m.mu.Unlock()
if m.ProcessError != nil {
return "", m.ProcessError
}
return m.ProcessedText, nil
}
// Factory helpers for pipeline testing
// MockRecorderFactory returns a factory that creates the given mock recorder
func MockRecorderFactory(mock *MockRecorder) func(cfg recording.Config) recording.Recorder {
return func(cfg recording.Config) recording.Recorder {
return mock
}
}
// MockTranscriberFactory returns a factory that creates the given mock transcriber
func MockTranscriberFactory(mock *MockTranscriber) func(cfg transcriber.Config) (transcriber.Transcriber, error) {
return func(cfg transcriber.Config) (transcriber.Transcriber, error) {
return mock, nil
}
}
// MockInjectorFactory returns a factory that creates the given mock injector
func MockInjectorFactory(mock *MockInjector) func(cfg injection.Config) injection.Injector {
return func(cfg injection.Config) injection.Injector {
return mock
}
}
// MockLLMAdapterFactory returns a factory that creates the given mock LLM adapter
func MockLLMAdapterFactory(mock *MockLLMAdapter) func(cfg llm.Config) (llm.Adapter, error) {
return func(cfg llm.Config) (llm.Adapter, error) {
return mock, nil
}
}
@@ -0,0 +1,126 @@
package transcriber
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// DeepgramBatchAdapter implements BatchAdapter for Deepgram pre-recorded transcription
type DeepgramBatchAdapter struct {
endpoint *provider.EndpointConfig
apiKey string
model string
language string
}
// deepgramBatchResponse is the response from the pre-recorded API
type deepgramBatchResponse struct {
Results *deepgramBatchResults `json:"results,omitempty"`
Error *deepgramError `json:"error,omitempty"`
}
type deepgramBatchResults struct {
Channels []deepgramBatchChannel `json:"channels,omitempty"`
}
type deepgramBatchChannel struct {
Alternatives []deepgramAlternative `json:"alternatives,omitempty"`
}
// NewDeepgramBatchAdapter creates a new batch adapter for Deepgram
func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramBatchAdapter {
return &DeepgramBatchAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
}
}
// Transcribe sends audio data to Deepgram's pre-recorded API
func (a *DeepgramBatchAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
// build URL with query parameters
apiURL, err := a.buildURL()
if err != nil {
return "", fmt.Errorf("build url: %w", err)
}
// create request with audio data as body
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(audioData))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
// set headers
req.Header.Set("Authorization", "Token "+a.apiKey)
req.Header.Set("Content-Type", "audio/wav") // we send raw PCM wrapped as WAV
// send request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
// read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("deepgram api error (status %d): %s", resp.StatusCode, string(body))
}
// parse response
var result deepgramBatchResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if result.Error != nil {
return "", fmt.Errorf("deepgram error: %s", result.Error.Message)
}
// extract transcript
if result.Results == nil || len(result.Results.Channels) == 0 {
return "", nil
}
if len(result.Results.Channels[0].Alternatives) == 0 {
return "", nil
}
return result.Results.Channels[0].Alternatives[0].Transcript, nil
}
// buildURL constructs the API URL with query parameters
func (a *DeepgramBatchAdapter) buildURL() (string, error) {
baseURL := a.endpoint.BaseURL + a.endpoint.Path
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
q := u.Query()
q.Set("model", a.model)
q.Set("smart_format", "true")
q.Set("punctuate", "true")
// add language if specified
providerLang := language.ToProviderFormat(a.language, "deepgram")
if providerLang != "" {
q.Set("language", providerLang)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
+51 -47
View File
@@ -4,11 +4,12 @@ import (
"context"
"fmt"
"log"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
"golang.org/x/text/cases"
"golang.org/x/text/language"
lang "github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/notify"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording"
)
@@ -27,26 +28,13 @@ type BatchAdapter interface {
// Configuration for the transcriber
type Config struct {
Provider string
APIKey string
Language string
Model string
Keywords []string
Threads int // CPU threads for local transcription (0 = auto)
}
// mapConfigProviderToRegistryName maps config provider names to provider registry names
// Config uses names like "groq-transcription", "groq-translation", "mistral-transcription"
// Registry uses base names like "groq", "mistral"
func mapConfigProviderToRegistryName(configProvider string) string {
switch configProvider {
case "groq-transcription", "groq-translation":
return "groq"
case "mistral-transcription":
return "mistral"
default:
return configProvider
}
Provider string
APIKey string
Language string
Model string
Keywords []string
Threads int // CPU threads for local transcription (0 = auto)
Streaming bool // use streaming mode if model supports it
}
// NewTranscriber creates a new transcriber based on model metadata
@@ -56,7 +44,7 @@ func NewTranscriber(config Config) (Transcriber, error) {
}
// special case: groq-translation uses CreateTranslation API (different from transcription)
if config.Provider == "groq-translation" {
if config.Provider == provider.ConfigProviderGroqTranslation {
if config.APIKey == "" {
return nil, fmt.Errorf("Groq API key required")
}
@@ -65,7 +53,7 @@ func NewTranscriber(config Config) (Transcriber, error) {
}
// map config provider name to registry provider name
registryProvider := mapConfigProviderToRegistryName(config.Provider)
registryProvider := provider.BaseProviderName(config.Provider)
// lookup provider
p := provider.GetProvider(registryProvider)
@@ -75,7 +63,7 @@ func NewTranscriber(config Config) (Transcriber, error) {
// check API key requirement
if p.RequiresAPIKey() && config.APIKey == "" {
return nil, fmt.Errorf("%s API key required", strings.Title(registryProvider))
return nil, fmt.Errorf("%s API key required", cases.Title(language.English).String(registryProvider))
}
// lookup model from provider
@@ -101,41 +89,57 @@ func NewTranscriber(config Config) (Transcriber, error) {
// runtime language-model compatibility check with fallback
// primary validation happens at config time (hard error), this is a safety net
if config.Language != "" && !model.SupportsLanguage(config.Language) {
langName := language.FromCode(config.Language).Name
langName := lang.FromCode(config.Language).Name
log.Printf("warning: model %s does not support language %s, falling back to auto-detect", model.ID, langName)
// send desktop notification to alert user
notifier := notify.NewDesktop(nil)
notifier.Error(fmt.Sprintf("Model %s does not support %s. Using auto-detect.", model.Name, langName))
// override language to auto for this session
config.Language = ""
}
// streaming models use StreamingTranscriber
if model.Streaming {
// determine if we should use streaming mode
useStreaming := config.Streaming && model.SupportsStreaming
// fail if streaming-only model is used without streaming enabled
if !useStreaming && !model.SupportsBatch {
return nil, fmt.Errorf("model %s requires streaming mode (set streaming = true in config)", model.ID)
}
// streaming mode: use StreamingTranscriber
if useStreaming {
// pick the right adapter type for streaming
adapterType := model.AdapterType
if model.StreamingAdapter != "" {
adapterType = model.StreamingAdapter
}
// pick the right endpoint for streaming
endpoint := model.Endpoint
if model.StreamingEndpoint != nil {
endpoint = model.StreamingEndpoint
}
var streamingAdapter StreamingAdapter
switch model.AdapterType {
case "elevenlabs-streaming":
streamingAdapter = NewElevenLabsStreamingAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
case "deepgram":
streamingAdapter = NewDeepgramAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
case "openai-realtime":
streamingAdapter = NewOpenAIRealtimeAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
switch adapterType {
case provider.AdapterElevenLabsStream:
streamingAdapter = NewElevenLabsStreamingAdapter(endpoint, config.APIKey, model.ID, config.Language)
case provider.AdapterDeepgram:
streamingAdapter = NewDeepgramAdapter(endpoint, config.APIKey, model.ID, config.Language)
case provider.AdapterOpenAIRealtime:
streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language)
default:
return nil, fmt.Errorf("unsupported streaming adapter type: %s", model.AdapterType)
return nil, fmt.Errorf("unsupported streaming adapter type: %s", adapterType)
}
return NewStreamingTranscriber(streamingAdapter, config.Language), nil
}
// batch models use SimpleTranscriber
// batch mode: use SimpleTranscriber
var adapter BatchAdapter
switch model.AdapterType {
case "openai":
case provider.AdapterOpenAI:
adapter = NewOpenAIAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords, registryProvider)
case "elevenlabs":
case provider.AdapterElevenLabs:
adapter = NewElevenLabsAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
case "whisper-cpp":
case provider.AdapterDeepgram:
adapter = NewDeepgramBatchAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
case provider.AdapterWhisperCpp:
modelPath := whisper.GetModelPath(config.Model)
if modelPath == "" {
return nil, fmt.Errorf("unknown whisper model: %s", config.Model)
+29 -29
View File
@@ -157,30 +157,33 @@ func TestNewTranscriber(t *testing.T) {
{
name: "elevenlabs streaming model creates StreamingTranscriber",
config: Config{
Provider: "elevenlabs",
APIKey: "test-key",
Language: "en",
Model: "scribe_v1-streaming",
},
wantErr: false, // streaming is now supported
},
{
name: "deepgram streaming model creates StreamingTranscriber",
config: Config{
Provider: "deepgram",
APIKey: "test-key",
Language: "en",
Model: "nova-3",
Provider: "elevenlabs",
APIKey: "test-key",
Language: "en",
Model: "scribe_v2_realtime",
Streaming: true,
},
wantErr: false,
},
{
name: "openai realtime streaming model creates StreamingTranscriber",
name: "deepgram streaming model creates StreamingTranscriber",
config: Config{
Provider: "openai",
APIKey: "test-key",
Language: "en",
Model: "gpt-4o-realtime-preview",
Provider: "deepgram",
APIKey: "test-key",
Language: "en",
Model: "nova-3",
Streaming: true,
},
wantErr: false,
},
{
name: "openai streaming model creates StreamingTranscriber",
config: Config{
Provider: "openai",
APIKey: "test-key",
Language: "en",
Model: "gpt-4o-transcribe",
Streaming: true,
},
wantErr: false,
},
@@ -997,12 +1000,11 @@ func TestStreamingTranscriber_GetFinalTranscriptionSafe(t *testing.T) {
func TestNewTranscriber_LanguageFallback(t *testing.T) {
// test that incompatible language falls back to auto-detect (no error)
// distil-whisper-large-v3-en only supports English
// base.en only supports English
config := Config{
Provider: "groq-transcription",
APIKey: "test-key",
Provider: "whisper-cpp",
Language: "es", // Spanish not supported by English-only model
Model: "distil-whisper-large-v3-en",
Model: "base.en",
}
// should succeed (fallback to auto), not error
@@ -1020,10 +1022,9 @@ func TestNewTranscriber_LanguageFallback(t *testing.T) {
func TestNewTranscriber_AutoLanguageNoFallback(t *testing.T) {
// test that auto language never triggers warning/fallback
config := Config{
Provider: "groq-transcription",
APIKey: "test-key",
Provider: "whisper-cpp",
Language: "", // auto
Model: "distil-whisper-large-v3-en",
Model: "base.en",
}
transcriber, err := NewTranscriber(config)
@@ -1040,10 +1041,9 @@ func TestNewTranscriber_AutoLanguageNoFallback(t *testing.T) {
func TestNewTranscriber_CompatibleLanguageNoFallback(t *testing.T) {
// test that compatible language works normally
config := Config{
Provider: "groq-transcription",
APIKey: "test-key",
Provider: "whisper-cpp",
Language: "en", // English supported by English-only model
Model: "distil-whisper-large-v3-en",
Model: "base.en",
}
transcriber, err := NewTranscriber(config)
+10 -1
View File
@@ -41,6 +41,10 @@ func editProviders(cfg *config.Config, onboarding bool) error {
if onboarding {
exitLabel = "Next"
}
// track if we should default to "back" (Next) after configuring a provider
defaultToExit := false
for {
var options []huh.Option[string]
for _, name := range AllProviders {
@@ -48,7 +52,11 @@ func editProviders(cfg *config.Config, onboarding bool) error {
}
options = append(options, huh.NewOption(exitLabel, "back"))
var selected string
selected := ""
if defaultToExit {
selected = "back"
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
@@ -77,6 +85,7 @@ func editProviders(cfg *config.Config, onboarding bool) error {
cfg.Providers = make(map[string]config.ProviderConfig)
}
cfg.Providers[selected] = config.ProviderConfig{APIKey: apiKey}
defaultToExit = true
}
}
}
+37 -30
View File
@@ -244,8 +244,37 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
}
cfg.Transcription.Model = selectedModel
// language is now set in the Language menu (cfg.General.Language)
// cfg.Transcription.Language can still be used as override but not set here
// set streaming mode based on model capabilities
model, err := provider.GetModel(registryName, selectedModel)
if err == nil {
if model.SupportsBothModes() {
// model supports both: ask user
useStreaming := cfg.Transcription.Streaming
streamingForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable streaming mode?").
Description("This model supports both batch and streaming modes").
Affirmative("Yes, use streaming (real-time)").
Negative("No, use batch (after recording)").
Value(&useStreaming),
),
).WithTheme(getTheme())
if err := streamingForm.Run(); err != nil {
return configuredProviders, err
}
cfg.Transcription.Streaming = useStreaming
} else if model.SupportsStreaming {
// streaming-only model
cfg.Transcription.Streaming = true
fmt.Println(StyleSuccess.Render("Streaming mode enabled (this model only supports streaming)"))
} else {
// batch-only model
cfg.Transcription.Streaming = false
}
}
return configuredProviders, nil
}
@@ -292,24 +321,8 @@ func getTranscriptionModelOptions(configProvider string, currentLang string) []h
models := provider.ModelsOfType(p, provider.Transcription)
// separate batch and streaming models
var batchModels, streamingModels []provider.Model
for _, m := range models {
if m.Streaming {
streamingModels = append(streamingModels, m)
} else {
batchModels = append(batchModels, m)
}
}
var options []huh.Option[string]
// add batch models first (with header if we have both types)
hasBoth := len(batchModels) > 0 && len(streamingModels) > 0
if hasBoth && len(batchModels) > 0 {
options = append(options, huh.NewOption("─── Batch ───", ""))
}
for _, m := range batchModels {
for _, m := range models {
label := buildModelLabel(m, currentLang)
if m.Local && registryName == "whisper-cpp" {
if whisper.IsInstalled(m.ID) {
@@ -321,15 +334,6 @@ func getTranscriptionModelOptions(configProvider string, currentLang string) []h
options = append(options, huh.NewOption(label, m.ID))
}
// add streaming models (with header if we have both types)
if hasBoth && len(streamingModels) > 0 {
options = append(options, huh.NewOption("─── Streaming ───", ""))
}
for _, m := range streamingModels {
label := buildModelLabel(m, currentLang)
options = append(options, huh.NewOption(label, m.ID))
}
return options
}
@@ -354,10 +358,13 @@ func buildModelLabel(m provider.Model, currentLang string) string {
label += fmt.Sprintf(" [%s]", m.LocalInfo.Size)
}
// append streaming tag
if m.Streaming {
// append mode capabilities
if m.SupportsBothModes() {
label += " [batch+streaming]"
} else if m.SupportsStreaming {
label += " [streaming]"
}
// batch-only models don't need a tag (it's the default)
// append language warning if model doesn't support current language
if currentLang != "" && !m.SupportsLanguage(currentLang) {
+58 -73
View File
@@ -1,115 +1,100 @@
package tui
import (
"strings"
"testing"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
func TestGetTranscriptionModelOptions_GroupsModels(t *testing.T) {
// test elevenlabs - has both batch and streaming
func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
// test elevenlabs - has batch-only and streaming-only models
options := getTranscriptionModelOptions("elevenlabs", "")
// find headers
var batchHeaderIdx, streamingHeaderIdx int
batchHeaderIdx = -1
streamingHeaderIdx = -1
for i, opt := range options {
if opt.Value == "" {
if opt.Key == "─── Batch ───" {
batchHeaderIdx = i
}
if opt.Key == "─── Streaming ───" {
streamingHeaderIdx = i
}
}
// should have 3 models: scribe_v1, scribe_v2, scribe_v2_realtime
if len(options) != 3 {
t.Errorf("expected 3 options for elevenlabs, got %d", len(options))
}
if batchHeaderIdx == -1 {
t.Error("expected Batch header for provider with both types")
}
if streamingHeaderIdx == -1 {
t.Error("expected Streaming header for provider with both types")
}
if batchHeaderIdx >= streamingHeaderIdx {
t.Errorf("Batch header should come before Streaming header: batch=%d, streaming=%d", batchHeaderIdx, streamingHeaderIdx)
}
// verify models are grouped correctly
for i, opt := range options {
if opt.Value == "" {
continue // skip headers
}
// verify models show capability tags
for _, opt := range options {
model, _, _ := provider.FindModelByID(opt.Value)
if model == nil {
continue // unknown model
continue
}
if i < streamingHeaderIdx && model.Streaming {
t.Errorf("streaming model %s found before streaming header", opt.Value)
}
if i > streamingHeaderIdx && !model.Streaming {
t.Errorf("batch model %s found after streaming header", opt.Value)
if model.SupportsStreaming && !model.SupportsBatch {
// streaming-only should have [streaming] tag
if !strings.Contains(opt.Key, "[streaming]") {
t.Errorf("streaming-only model %s should have [streaming] tag in label: %s", opt.Value, opt.Key)
}
} else if model.SupportsBothModes() {
// both modes should have [batch+streaming] tag
if !strings.Contains(opt.Key, "[batch+streaming]") {
t.Errorf("both-modes model %s should have [batch+streaming] tag in label: %s", opt.Value, opt.Key)
}
}
// batch-only models don't need a tag
}
}
func TestGetTranscriptionModelOptions_NoHeadersForSingleType(t *testing.T) {
// test groq - batch only (no streaming models)
options := getTranscriptionModelOptions("groq-transcription", "")
func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
// we removed batch/streaming section headers
options := getTranscriptionModelOptions("elevenlabs", "")
for _, opt := range options {
if opt.Value == "" {
t.Errorf("expected no headers for provider with only one model type, got: %s", opt.Key)
t.Errorf("should not have headers anymore, got: %s", opt.Key)
}
}
}
func TestGetTranscriptionModelOptions_OpenAI_GroupsCorrectly(t *testing.T) {
func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
options := getTranscriptionModelOptions("openai", "")
var batchHeaderIdx, streamingHeaderIdx int
batchHeaderIdx = -1
streamingHeaderIdx = -1
// OpenAI has 3 transcription models: whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe
if len(options) != 3 {
t.Errorf("expected 3 options for openai, got %d", len(options))
}
for i, opt := range options {
if opt.Value == "" {
if opt.Key == "─── Batch ───" {
batchHeaderIdx = i
}
if opt.Key == "─── Streaming ───" {
streamingHeaderIdx = i
// gpt-4o-transcribe and gpt-4o-mini-transcribe should have [batch+streaming]
for _, opt := range options {
if strings.Contains(opt.Value, "gpt-4o") {
if !strings.Contains(opt.Key, "[batch+streaming]") {
t.Errorf("gpt-4o model %s should have [batch+streaming] tag: %s", opt.Value, opt.Key)
}
}
}
}
// OpenAI has 3 batch + 1 streaming
if batchHeaderIdx == -1 {
t.Error("expected Batch header for OpenAI")
}
if streamingHeaderIdx == -1 {
t.Error("expected Streaming header for OpenAI")
func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
options := getTranscriptionModelOptions("deepgram", "")
// Deepgram has 2 models: nova-3, nova-2 - both support batch+streaming
if len(options) != 2 {
t.Errorf("expected 2 options for deepgram, got %d", len(options))
}
// count models (not headers) by position
batchCount := 0
streamingCount := 0
for i, opt := range options {
if opt.Value == "" {
continue // skip headers
}
if i > batchHeaderIdx && i < streamingHeaderIdx {
batchCount++
} else if i > streamingHeaderIdx {
streamingCount++
for _, opt := range options {
if !strings.Contains(opt.Key, "[batch+streaming]") {
t.Errorf("deepgram model %s should have [batch+streaming] tag: %s", opt.Value, opt.Key)
}
}
}
if batchCount < 3 {
t.Errorf("expected at least 3 batch models for OpenAI, got %d", batchCount)
func TestGetTranscriptionModelOptions_Groq_BatchOnly(t *testing.T) {
// test groq - batch only (no streaming models)
options := getTranscriptionModelOptions("groq-transcription", "")
// should have 2 models: whisper-large-v3, whisper-large-v3-turbo
if len(options) != 2 {
t.Errorf("expected 2 options for groq, got %d", len(options))
}
if streamingCount < 1 {
t.Errorf("expected at least 1 streaming model for OpenAI, got %d", streamingCount)
// batch-only models should not have any mode tags
for _, opt := range options {
if strings.Contains(opt.Key, "[streaming]") || strings.Contains(opt.Key, "[batch]") {
t.Errorf("batch-only model should not have mode tags: %s", opt.Key)
}
}
}
+9 -4
View File
@@ -39,26 +39,31 @@ func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) {
return &ConfigureResult{Cancelled: true}, nil
}
// 4. Keywords
// 4. Language selection
if err := editLanguage(cfg); err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
// 5. Keywords
keywords, err := inputKeywords(cfg.Keywords)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Keywords = keywords
// 5. Injection backends
// 6. Injection backends
backends, err := selectBackends(cfg.Injection.Backends)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Injection.Backends = backends
// 6. Notifications - same screen as menu
// 7. Notifications - same screen as menu
if err := editNotifications(cfg); err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
// 7. Advanced settings prompt
// 8. Advanced settings prompt
wantAdvanced, err := askAdvancedSettings()
if err != nil {
return &ConfigureResult{Cancelled: true}, nil