feat: better language selection
This commit is contained in:
@@ -1074,38 +1074,6 @@ func TestConfig_Validate_GroqTranscription(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_GroqTranslation(t *testing.T) {
|
||||
config := &Config{
|
||||
Recording: RecordingConfig{
|
||||
SampleRate: 16000,
|
||||
Channels: 1,
|
||||
Format: "s16",
|
||||
BufferSize: 8192,
|
||||
ChannelBufferSize: 30,
|
||||
Timeout: time.Minute,
|
||||
},
|
||||
Transcription: TranscriptionConfig{
|
||||
Provider: "groq-translation",
|
||||
APIKey: "gsk-test-key",
|
||||
Language: "es",
|
||||
Model: "whisper-large-v3", // Translation only supports non-turbo
|
||||
},
|
||||
Injection: InjectionConfig{
|
||||
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||
WtypeTimeout: time.Second,
|
||||
ClipboardTimeout: time.Second,
|
||||
},
|
||||
Notifications: NotificationsConfig{
|
||||
Type: "log",
|
||||
},
|
||||
}
|
||||
|
||||
err := config.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("Validate() should have passed with valid groq-translation config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_GroqInvalidModel(t *testing.T) {
|
||||
config := &Config{
|
||||
Recording: RecordingConfig{
|
||||
@@ -1248,41 +1216,6 @@ func TestConfig_ToTranscriberConfig_GroqWithEnvVar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_GroqTranslation_RejectsTurbo(t *testing.T) {
|
||||
config := &Config{
|
||||
Recording: RecordingConfig{
|
||||
SampleRate: 16000,
|
||||
Channels: 1,
|
||||
Format: "s16",
|
||||
BufferSize: 8192,
|
||||
ChannelBufferSize: 30,
|
||||
Timeout: time.Minute,
|
||||
},
|
||||
Transcription: TranscriptionConfig{
|
||||
Provider: "groq-translation",
|
||||
APIKey: "gsk-test-key",
|
||||
Language: "es",
|
||||
Model: "whisper-large-v3-turbo", // Turbo not supported for translation
|
||||
},
|
||||
Injection: InjectionConfig{
|
||||
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||
WtypeTimeout: time.Second,
|
||||
ClipboardTimeout: time.Second,
|
||||
},
|
||||
Notifications: NotificationsConfig{
|
||||
Type: "log",
|
||||
},
|
||||
}
|
||||
|
||||
err := config.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate() should have rejected whisper-large-v3-turbo for groq-translation")
|
||||
}
|
||||
if err != nil && err.Error() != "invalid model for groq-translation: whisper-large-v3-turbo (must be whisper-large-v3, turbo version not supported for translation)" {
|
||||
t.Errorf("Unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesConfig_Resolve_Defaults(t *testing.T) {
|
||||
cfg := createTestConfig()
|
||||
msgs := cfg.Notifications.Messages.Resolve()
|
||||
|
||||
@@ -104,7 +104,7 @@ func (c *Config) migrateTranscriptionAPIKey(apiKey string) {
|
||||
switch providerName {
|
||||
case "openai":
|
||||
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
|
||||
case "groq-transcription", "groq-translation":
|
||||
case "groq-transcription":
|
||||
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
|
||||
case "mistral-transcription":
|
||||
c.Providers["mistral"] = ProviderConfig{APIKey: apiKey}
|
||||
|
||||
@@ -246,7 +246,7 @@ keywords = []
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[transcription]
|
||||
provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs", "whisper-cpp"
|
||||
provider = "openai" # "openai", "groq-transcription", "mistral-transcription", "elevenlabs", "whisper-cpp"
|
||||
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
|
||||
language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
|
||||
threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
|
||||
@@ -325,7 +325,6 @@ keywords = []
|
||||
# Transcription providers:
|
||||
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
|
||||
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
|
||||
# - "groq-translation": Groq translation to English (always outputs English text, model: whisper-large-v3)
|
||||
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
|
||||
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2)
|
||||
#
|
||||
|
||||
@@ -2,19 +2,17 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
// mapConfigProviderToRegistryName maps config provider names to provider registry names
|
||||
// Config uses names like "groq-transcription", "groq-translation", "mistral-transcription"
|
||||
// Config uses names like "groq-transcription", "mistral-transcription"
|
||||
// Registry uses base names like "groq", "mistral"
|
||||
func mapConfigProviderToRegistryName(configProvider string) string {
|
||||
switch configProvider {
|
||||
case "groq-transcription", "groq-translation":
|
||||
case "groq-transcription":
|
||||
return "groq"
|
||||
case "mistral-transcription":
|
||||
return "mistral"
|
||||
@@ -85,21 +83,11 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// validate language codes - warn if not recognized but don't error
|
||||
if c.Transcription.Language != "" && !language.IsValidCode(c.Transcription.Language) {
|
||||
log.Printf("warning: unrecognized language code '%s' in transcription.language, will be passed as-is to provider", c.Transcription.Language)
|
||||
}
|
||||
|
||||
// validate model exists
|
||||
if c.Transcription.Model == "" {
|
||||
return fmt.Errorf("invalid transcription.model: empty")
|
||||
}
|
||||
|
||||
// groq-translation is a special case - only supports whisper-large-v3
|
||||
if c.Transcription.Provider == "groq-translation" && c.Transcription.Model != "whisper-large-v3" {
|
||||
return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model)
|
||||
}
|
||||
|
||||
// validate model exists in provider
|
||||
_, err := provider.GetModel(registryName, c.Transcription.Model)
|
||||
if err != nil {
|
||||
@@ -205,11 +193,6 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri
|
||||
}
|
||||
|
||||
// language not supported - build helpful error message
|
||||
langName := language.FromCode(langCode).Name
|
||||
if langName == "Auto-detect" {
|
||||
langName = langCode // use code if not found
|
||||
}
|
||||
|
||||
// truncate supported languages for error message
|
||||
supported := model.SupportedLanguages
|
||||
suffix := ""
|
||||
@@ -225,9 +208,8 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"model %s does not support %s (%s).%s Supported: %s%s",
|
||||
"model %s does not support language '%s'.%s Supported: %s%s",
|
||||
model.Name,
|
||||
langName,
|
||||
langCode,
|
||||
docsHint,
|
||||
strings.Join(supported, ", "),
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
package language
|
||||
|
||||
// Language represents a supported transcription language
|
||||
type Language struct {
|
||||
Code string // ISO 639-1 code (e.g., "en", "es", "zh")
|
||||
Name string // English name (e.g., "English", "Spanish")
|
||||
NativeName string // Native name (e.g., "English", "Espanol", "中文")
|
||||
}
|
||||
|
||||
// Auto represents auto-detection - used when user doesn't specify a language
|
||||
var Auto = Language{Code: "", Name: "Auto-detect", NativeName: ""}
|
||||
|
||||
// languages is the master list of supported languages
|
||||
// derived from OpenAI Whisper's 57 supported languages
|
||||
var languages = []Language{
|
||||
{Code: "af", Name: "Afrikaans", NativeName: "Afrikaans"},
|
||||
{Code: "ar", Name: "Arabic", NativeName: "العربية"},
|
||||
{Code: "hy", Name: "Armenian", NativeName: "Հdelays"},
|
||||
{Code: "az", Name: "Azerbaijani", NativeName: "Azərbaycan"},
|
||||
{Code: "be", Name: "Belarusian", NativeName: "Беларуская"},
|
||||
{Code: "bs", Name: "Bosnian", NativeName: "Bosanski"},
|
||||
{Code: "bg", Name: "Bulgarian", NativeName: "Български"},
|
||||
{Code: "ca", Name: "Catalan", NativeName: "Català"},
|
||||
{Code: "zh", Name: "Chinese", NativeName: "中文"},
|
||||
{Code: "hr", Name: "Croatian", NativeName: "Hrvatski"},
|
||||
{Code: "cs", Name: "Czech", NativeName: "Čeština"},
|
||||
{Code: "da", Name: "Danish", NativeName: "Dansk"},
|
||||
{Code: "nl", Name: "Dutch", NativeName: "Nederlands"},
|
||||
{Code: "en", Name: "English", NativeName: "English"},
|
||||
{Code: "et", Name: "Estonian", NativeName: "Eesti"},
|
||||
{Code: "fi", Name: "Finnish", NativeName: "Suomi"},
|
||||
{Code: "fr", Name: "French", NativeName: "Français"},
|
||||
{Code: "gl", Name: "Galician", NativeName: "Galego"},
|
||||
{Code: "de", Name: "German", NativeName: "Deutsch"},
|
||||
{Code: "el", Name: "Greek", NativeName: "Ελληνικά"},
|
||||
{Code: "he", Name: "Hebrew", NativeName: "עברית"},
|
||||
{Code: "hi", Name: "Hindi", NativeName: "हिन्दी"},
|
||||
{Code: "hu", Name: "Hungarian", NativeName: "Magyar"},
|
||||
{Code: "is", Name: "Icelandic", NativeName: "Íslenska"},
|
||||
{Code: "id", Name: "Indonesian", NativeName: "Bahasa Indonesia"},
|
||||
{Code: "it", Name: "Italian", NativeName: "Italiano"},
|
||||
{Code: "ja", Name: "Japanese", NativeName: "日本語"},
|
||||
{Code: "kn", Name: "Kannada", NativeName: "ಕನ್ನಡ"},
|
||||
{Code: "kk", Name: "Kazakh", NativeName: "Қазақ"},
|
||||
{Code: "ko", Name: "Korean", NativeName: "한국어"},
|
||||
{Code: "lv", Name: "Latvian", NativeName: "Latviešu"},
|
||||
{Code: "lt", Name: "Lithuanian", NativeName: "Lietuvių"},
|
||||
{Code: "mk", Name: "Macedonian", NativeName: "Македонски"},
|
||||
{Code: "ms", Name: "Malay", NativeName: "Bahasa Melayu"},
|
||||
{Code: "mr", Name: "Marathi", NativeName: "मराठी"},
|
||||
{Code: "mi", Name: "Maori", NativeName: "Māori"},
|
||||
{Code: "ne", Name: "Nepali", NativeName: "नेपाली"},
|
||||
{Code: "no", Name: "Norwegian", NativeName: "Norsk"},
|
||||
{Code: "fa", Name: "Persian", NativeName: "فارسی"},
|
||||
{Code: "pl", Name: "Polish", NativeName: "Polski"},
|
||||
{Code: "pt", Name: "Portuguese", NativeName: "Português"},
|
||||
{Code: "ro", Name: "Romanian", NativeName: "Română"},
|
||||
{Code: "ru", Name: "Russian", NativeName: "Русский"},
|
||||
{Code: "sr", Name: "Serbian", NativeName: "Српски"},
|
||||
{Code: "sk", Name: "Slovak", NativeName: "Slovenčina"},
|
||||
{Code: "sl", Name: "Slovenian", NativeName: "Slovenščina"},
|
||||
{Code: "es", Name: "Spanish", NativeName: "Español"},
|
||||
{Code: "sw", Name: "Swahili", NativeName: "Kiswahili"},
|
||||
{Code: "sv", Name: "Swedish", NativeName: "Svenska"},
|
||||
{Code: "tl", Name: "Tagalog", NativeName: "Tagalog"},
|
||||
{Code: "ta", Name: "Tamil", NativeName: "தமிழ்"},
|
||||
{Code: "th", Name: "Thai", NativeName: "ไทย"},
|
||||
{Code: "tr", Name: "Turkish", NativeName: "Türkçe"},
|
||||
{Code: "uk", Name: "Ukrainian", NativeName: "Українська"},
|
||||
{Code: "ur", Name: "Urdu", NativeName: "اردو"},
|
||||
{Code: "vi", Name: "Vietnamese", NativeName: "Tiếng Việt"},
|
||||
{Code: "cy", Name: "Welsh", NativeName: "Cymraeg"},
|
||||
}
|
||||
|
||||
// codeIndex maps language codes to their Language structs for fast lookup
|
||||
var codeIndex map[string]Language
|
||||
|
||||
func init() {
|
||||
codeIndex = make(map[string]Language, len(languages)+1)
|
||||
codeIndex[""] = Auto // auto-detect is valid
|
||||
for _, lang := range languages {
|
||||
codeIndex[lang.Code] = lang
|
||||
}
|
||||
}
|
||||
|
||||
// FromCode returns the Language for the given code.
|
||||
// Returns Auto if code is not found.
|
||||
func FromCode(code string) Language {
|
||||
if lang, ok := codeIndex[code]; ok {
|
||||
return lang
|
||||
}
|
||||
return Auto
|
||||
}
|
||||
|
||||
// List returns all supported languages (excluding Auto)
|
||||
func List() []Language {
|
||||
result := make([]Language, len(languages))
|
||||
copy(result, languages)
|
||||
return result
|
||||
}
|
||||
|
||||
// Codes returns all language codes (excluding empty string for auto)
|
||||
func Codes() []string {
|
||||
codes := make([]string, len(languages))
|
||||
for i, lang := range languages {
|
||||
codes[i] = lang.Code
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// AllLanguageCodes is an alias for Codes - used by models that support all languages
|
||||
func AllLanguageCodes() []string {
|
||||
return Codes()
|
||||
}
|
||||
|
||||
// IsValidCode returns true if the code is recognized (including empty for auto)
|
||||
func IsValidCode(code string) bool {
|
||||
_, ok := codeIndex[code]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ToProviderFormat converts a canonical language code to the format expected by a specific provider.
|
||||
// Each provider may have different expectations:
|
||||
// - whisper-cpp: uses standard codes like 'en', 'auto' for auto-detect
|
||||
// - openai: uses standard codes like 'en', empty string for auto-detect
|
||||
// - groq: same as openai (OpenAI-compatible)
|
||||
// - mistral: same as openai (OpenAI-compatible)
|
||||
// - deepgram: uses locale codes like 'en-US', 'es' for Spanish
|
||||
// - elevenlabs: uses standard codes or full names depending on API version
|
||||
func ToProviderFormat(code string, providerName string) string {
|
||||
// handle auto-detect (empty code)
|
||||
if code == "" {
|
||||
switch providerName {
|
||||
case "whisper-cpp":
|
||||
return "auto"
|
||||
default:
|
||||
// most providers use empty string or omit the parameter
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
switch providerName {
|
||||
case "deepgram":
|
||||
// deepgram prefers locale codes for some languages
|
||||
return toDeepgramFormat(code)
|
||||
default:
|
||||
// whisper-cpp, openai, groq, mistral, elevenlabs use standard codes
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
// toDeepgramFormat maps standard codes to Deepgram's preferred format
|
||||
func toDeepgramFormat(code string) string {
|
||||
// deepgram uses locale codes for English variants, standard for most others
|
||||
deepgramMappings := map[string]string{
|
||||
"en": "en-US",
|
||||
"es": "es", // Spanish uses base code
|
||||
"pt": "pt-BR", // Portuguese defaults to Brazilian
|
||||
"zh": "zh-CN", // Chinese defaults to Simplified
|
||||
}
|
||||
|
||||
if mapped, ok := deepgramMappings[code]; ok {
|
||||
return mapped
|
||||
}
|
||||
return code
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package language
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFromCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
code string
|
||||
wantCode string
|
||||
wantName string
|
||||
}{
|
||||
{"en", "en", "English"},
|
||||
{"es", "es", "Spanish"},
|
||||
{"zh", "zh", "Chinese"},
|
||||
{"invalid", "", "Auto-detect"},
|
||||
{"", "", "Auto-detect"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.code, func(t *testing.T) {
|
||||
got := FromCode(tt.code)
|
||||
if got.Code != tt.wantCode {
|
||||
t.Errorf("FromCode(%q).Code = %q, want %q", tt.code, got.Code, tt.wantCode)
|
||||
}
|
||||
if got.Name != tt.wantName {
|
||||
t.Errorf("FromCode(%q).Name = %q, want %q", tt.code, got.Name, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromCodeEnglish(t *testing.T) {
|
||||
lang := FromCode("en")
|
||||
if lang.Code != "en" {
|
||||
t.Errorf("FromCode('en').Code = %q, want 'en'", lang.Code)
|
||||
}
|
||||
if lang.Name != "English" {
|
||||
t.Errorf("FromCode('en').Name = %q, want 'English'", lang.Name)
|
||||
}
|
||||
if lang.NativeName != "English" {
|
||||
t.Errorf("FromCode('en').NativeName = %q, want 'English'", lang.NativeName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{"en", true},
|
||||
{"es", true},
|
||||
{"zh", true},
|
||||
{"invalid", false},
|
||||
{"", true}, // auto is valid
|
||||
{"xyz", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.code, func(t *testing.T) {
|
||||
got := IsValidCode(tt.code)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsValidCode(%q) = %v, want %v", tt.code, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
list := List()
|
||||
if len(list) != 57 {
|
||||
t.Errorf("List() returned %d languages, want 57", len(list))
|
||||
}
|
||||
|
||||
// verify English is in the list
|
||||
found := false
|
||||
for _, lang := range list {
|
||||
if lang.Code == "en" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("List() does not contain English")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodes(t *testing.T) {
|
||||
codes := Codes()
|
||||
if len(codes) != 57 {
|
||||
t.Errorf("Codes() returned %d codes, want 57", len(codes))
|
||||
}
|
||||
|
||||
// verify 'en' is in the codes
|
||||
found := false
|
||||
for _, code := range codes {
|
||||
if code == "en" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Codes() does not contain 'en'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllLanguageCodes(t *testing.T) {
|
||||
codes := AllLanguageCodes()
|
||||
if len(codes) != 57 {
|
||||
t.Errorf("AllLanguageCodes() returned %d codes, want 57", len(codes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuto(t *testing.T) {
|
||||
if Auto.Code != "" {
|
||||
t.Errorf("Auto.Code = %q, want empty string", Auto.Code)
|
||||
}
|
||||
if Auto.Name != "Auto-detect" {
|
||||
t.Errorf("Auto.Name = %q, want 'Auto-detect'", Auto.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToProviderFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
code string
|
||||
provider string
|
||||
want string
|
||||
}{
|
||||
// whisper-cpp
|
||||
{"en", "whisper-cpp", "en"},
|
||||
{"es", "whisper-cpp", "es"},
|
||||
{"", "whisper-cpp", "auto"},
|
||||
|
||||
// openai
|
||||
{"en", "openai", "en"},
|
||||
{"", "openai", ""},
|
||||
|
||||
// groq (openai-compatible)
|
||||
{"en", "groq", "en"},
|
||||
{"", "groq", ""},
|
||||
|
||||
// mistral (openai-compatible)
|
||||
{"en", "mistral", "en"},
|
||||
{"", "mistral", ""},
|
||||
|
||||
// deepgram (uses locale codes)
|
||||
{"en", "deepgram", "en-US"},
|
||||
{"es", "deepgram", "es"},
|
||||
{"pt", "deepgram", "pt-BR"},
|
||||
{"zh", "deepgram", "zh-CN"},
|
||||
{"fr", "deepgram", "fr"}, // no special mapping, passthrough
|
||||
{"", "deepgram", ""},
|
||||
|
||||
// elevenlabs
|
||||
{"en", "elevenlabs", "en"},
|
||||
{"", "elevenlabs", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.code+"_"+tt.provider, func(t *testing.T) {
|
||||
got := ToProviderFormat(tt.code, tt.provider)
|
||||
if got != tt.want {
|
||||
t.Errorf("ToProviderFormat(%q, %q) = %q, want %q", tt.code, tt.provider, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,21 +21,10 @@ func (p *DeepgramProvider) IsLocal() bool {
|
||||
}
|
||||
|
||||
func (p *DeepgramProvider) Models() []Model {
|
||||
// Nova-3 language support - maps to our 57 language list
|
||||
// from https://developers.deepgram.com/docs/models-languages-overview
|
||||
nova3Langs := []string{
|
||||
"ar", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi",
|
||||
"fr", "de", "el", "hi", "hu", "id", "it", "ja", "kn", "ko", "lv", "lt",
|
||||
"mk", "ms", "mr", "no", "pl", "pt", "ro", "ru", "sr", "sk", "sl", "es",
|
||||
"sv", "tl", "ta", "tr", "uk", "vi",
|
||||
}
|
||||
|
||||
// Nova-2 language support - subset of nova-3
|
||||
nova2Langs := []string{
|
||||
"bg", "ca", "zh", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el",
|
||||
"hi", "hu", "id", "it", "ja", "ko", "lv", "lt", "ms", "no", "pl", "pt",
|
||||
"ro", "ru", "sk", "es", "sv", "th", "tr", "uk", "vi",
|
||||
}
|
||||
// https://developers.deepgram.com/docs/models-languages-overview
|
||||
nova3Langs := deepgramNova3Languages
|
||||
// https://developers.deepgram.com/docs/models-languages-overview
|
||||
nova2Langs := deepgramNova2Languages
|
||||
|
||||
docsURL := "https://developers.deepgram.com/docs/language"
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package provider
|
||||
|
||||
import "github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
|
||||
// ElevenLabsProvider implements Provider for ElevenLabs services (transcription only)
|
||||
type ElevenLabsProvider struct{}
|
||||
|
||||
@@ -23,10 +21,9 @@ func (p *ElevenLabsProvider) IsLocal() bool {
|
||||
}
|
||||
|
||||
func (p *ElevenLabsProvider) Models() []Model {
|
||||
// ElevenLabs Scribe supports 90+ languages, including all 57 from our master list
|
||||
// See: https://elevenlabs.io/speech-to-text
|
||||
allLangs := language.AllLanguageCodes()
|
||||
docsURL := "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages"
|
||||
// https://elevenlabs.io/speech-to-text
|
||||
allLangs := elevenLabsTranscriptionLanguages
|
||||
docsURL := "https://elevenlabs.io/speech-to-text"
|
||||
|
||||
return []Model{
|
||||
{
|
||||
|
||||
+30
-36
@@ -1,10 +1,6 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// GroqProvider implements Provider for Groq services
|
||||
type GroqProvider struct{}
|
||||
@@ -26,7 +22,8 @@ func (p *GroqProvider) IsLocal() bool {
|
||||
}
|
||||
|
||||
func (p *GroqProvider) Models() []Model {
|
||||
allLangs := language.AllLanguageCodes()
|
||||
// https://console.groq.com/docs/speech-to-text#supported-languages
|
||||
allLangs := groqTranscriptionLanguages
|
||||
docsURL := "https://console.groq.com/docs/speech-to-text#supported-languages"
|
||||
|
||||
return []Model{
|
||||
@@ -59,40 +56,37 @@ func (p *GroqProvider) Models() []Model {
|
||||
},
|
||||
// LLM models
|
||||
{
|
||||
ID: "llama-3.3-70b-versatile",
|
||||
Name: "Llama 3.3 70B Versatile",
|
||||
Description: "Most capable Llama model",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
|
||||
ID: "llama-3.3-70b-versatile",
|
||||
Name: "Llama 3.3 70B Versatile",
|
||||
Description: "Most capable Llama model",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
|
||||
},
|
||||
{
|
||||
ID: "llama-3.1-8b-instant",
|
||||
Name: "Llama 3.1 8B Instant",
|
||||
Description: "Fast and efficient",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
|
||||
ID: "llama-3.1-8b-instant",
|
||||
Name: "Llama 3.1 8B Instant",
|
||||
Description: "Fast and efficient",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
|
||||
},
|
||||
{
|
||||
ID: "mixtral-8x7b-32768",
|
||||
Name: "Mixtral 8x7B",
|
||||
Description: "Mixture of experts model",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
|
||||
ID: "mixtral-8x7b-32768",
|
||||
Name: "Mixtral 8x7B",
|
||||
Description: "Mixture of experts model",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package provider
|
||||
|
||||
var openaiTranscriptionLanguages = []string{
|
||||
"af", "ar", "hy", "az", "be", "bs", "bg", "ca", "zh", "hr", "cs", "da",
|
||||
"nl", "en", "et", "fi", "fr", "gl", "de", "el", "he", "hi", "hu", "is",
|
||||
"id", "it", "ja", "kn", "kk", "ko", "lv", "lt", "mk", "ms", "mr", "mi",
|
||||
"ne", "no", "fa", "pl", "pt", "ro", "ru", "sr", "sk", "sl", "es", "sw",
|
||||
"sv", "tl", "ta", "th", "tr", "uk", "ur", "vi", "cy",
|
||||
}
|
||||
|
||||
var groqTranscriptionLanguages = openaiTranscriptionLanguages
|
||||
var mistralTranscriptionLanguages = openaiTranscriptionLanguages
|
||||
var whisperTranscriptionLanguages = openaiTranscriptionLanguages
|
||||
|
||||
var whisperEnglishOnlyLanguages = []string{"en"}
|
||||
|
||||
var deepgramNova3Languages = []string{
|
||||
"multi",
|
||||
"ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR",
|
||||
"be", "bn", "bs", "bg", "ca", "hr", "cs", "da", "da-DK", "nl", "nl-BE",
|
||||
"en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ", "et", "fi", "fr", "fr-CA",
|
||||
"de", "de-CH", "el", "hi", "hu", "id", "it", "ja", "kn", "ko", "ko-KR",
|
||||
"lv", "lt", "mk", "ms", "mr", "no", "pl", "pt", "pt-BR", "pt-PT", "ro",
|
||||
"ru", "sr", "sk", "sl", "es", "es-419", "sv", "sv-SE", "tl", "ta", "te",
|
||||
"tr", "uk", "vi",
|
||||
}
|
||||
|
||||
var deepgramNova2Languages = []string{
|
||||
"multi",
|
||||
"bg", "ca", "zh", "zh-CN", "zh-Hans", "zh-TW", "zh-Hant", "zh-HK", "cs",
|
||||
"da", "da-DK", "nl", "nl-BE", "en", "en-US", "en-AU", "en-GB", "en-NZ", "en-IN",
|
||||
"et", "fi", "fr", "fr-CA", "de", "de-CH", "el", "hi", "hu", "id", "it", "ja",
|
||||
"ko", "ko-KR", "lv", "lt", "ms", "no", "pl", "pt", "pt-BR", "pt-PT", "ro",
|
||||
"ru", "sk", "es", "es-419", "sv", "sv-SE", "th", "th-TH", "tr", "uk", "vi",
|
||||
}
|
||||
|
||||
var elevenLabsTranscriptionLanguages = []string{
|
||||
"bel", "bos", "bul", "cat", "hrv", "ces", "dan", "nld", "eng", "est", "fin", "fra",
|
||||
"glg", "deu", "ell", "hun", "isl", "ind", "ita", "jpn", "kan", "lav", "mkd", "msa",
|
||||
"mal", "nor", "pol", "por", "ron", "rus", "slk", "spa", "swe", "tur", "ukr", "vie",
|
||||
"hye", "aze", "ben", "yue", "fil", "kat", "guj", "hin", "kaz", "lit", "mlt", "cmn",
|
||||
"mar", "nep", "ori", "fas", "srp", "slv", "swa", "tam", "tel",
|
||||
"afr", "ara", "asm", "ast", "mya", "hau", "heb", "jav", "kor", "kir", "ltz", "mri",
|
||||
"oci", "pan", "tgk", "tha", "uzb", "cym",
|
||||
"amh", "lug", "ibo", "gle", "khm", "kur", "lao", "mon", "nso", "pus", "sna", "snd",
|
||||
"som", "urd", "wol", "xho", "yor", "zul",
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package provider
|
||||
|
||||
import "github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
|
||||
// MistralProvider implements Provider for Mistral services (transcription only)
|
||||
type MistralProvider struct{}
|
||||
|
||||
@@ -23,8 +21,9 @@ func (p *MistralProvider) IsLocal() bool {
|
||||
}
|
||||
|
||||
func (p *MistralProvider) Models() []Model {
|
||||
allLangs := language.AllLanguageCodes()
|
||||
docsURL := "https://docs.mistral.ai/capabilities/speech/"
|
||||
// https://docs.mistral.ai/capabilities/audio/
|
||||
allLangs := mistralTranscriptionLanguages
|
||||
docsURL := "https://docs.mistral.ai/capabilities/audio/"
|
||||
|
||||
return []Model{
|
||||
{
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package provider
|
||||
|
||||
import "github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
|
||||
// ModelType represents the type of a model
|
||||
type ModelType int
|
||||
|
||||
@@ -22,7 +20,7 @@ type Model struct {
|
||||
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
|
||||
SupportedLanguages []string // explicit list of provider language codes
|
||||
Endpoint *EndpointConfig // nil for local models
|
||||
LocalInfo *LocalModelInfo // nil for cloud models
|
||||
DocsURL string // URL to provider's language support documentation
|
||||
@@ -69,9 +67,3 @@ func (m *Model) SupportsLanguage(code string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SupportsAllLanguages returns true if the model supports all 57 languages
|
||||
func (m *Model) SupportsAllLanguages() bool {
|
||||
allCodes := language.AllLanguageCodes()
|
||||
return len(m.SupportedLanguages) == len(allCodes)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestModel_NeedsDownload(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -118,11 +114,9 @@ func TestModel_SupportsBothModes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestModel_SupportsLanguage(t *testing.T) {
|
||||
allCodes := language.AllLanguageCodes()
|
||||
|
||||
multilingualModel := Model{
|
||||
ID: "whisper-large-v3",
|
||||
SupportedLanguages: allCodes,
|
||||
SupportedLanguages: []string{"en", "es", "zh"},
|
||||
}
|
||||
|
||||
englishOnlyModel := Model{
|
||||
@@ -207,65 +201,6 @@ func TestModel_SupportsLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_SupportsAllLanguages(t *testing.T) {
|
||||
allCodes := language.AllLanguageCodes()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model Model
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "model with all 57 languages",
|
||||
model: Model{
|
||||
ID: "whisper-large-v3",
|
||||
SupportedLanguages: allCodes,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "english-only model",
|
||||
model: Model{
|
||||
ID: "base.en",
|
||||
SupportedLanguages: []string{"en"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "model with some languages",
|
||||
model: Model{
|
||||
ID: "partial",
|
||||
SupportedLanguages: []string{"en", "es", "fr", "de"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "model with empty languages",
|
||||
model: Model{
|
||||
ID: "empty",
|
||||
SupportedLanguages: []string{},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "model with nil languages",
|
||||
model: Model{
|
||||
ID: "nil",
|
||||
SupportedLanguages: nil,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.model.SupportsAllLanguages(); got != tc.expected {
|
||||
t.Errorf("SupportsAllLanguages() = %v, want %v", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelType_Constants(t *testing.T) {
|
||||
// verify ModelType constants exist and are distinct
|
||||
if Transcription == LLM {
|
||||
@@ -387,8 +322,8 @@ func TestAllTranscriptionModels_HaveDocsURL(t *testing.T) {
|
||||
expectedDocsURLs := map[string]string{
|
||||
"openai": "https://platform.openai.com/docs/guides/speech-to-text#supported-languages",
|
||||
"groq": "https://console.groq.com/docs/speech-to-text#supported-languages",
|
||||
"mistral": "https://docs.mistral.ai/capabilities/speech/",
|
||||
"elevenlabs": "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages",
|
||||
"mistral": "https://docs.mistral.ai/capabilities/audio/",
|
||||
"elevenlabs": "https://elevenlabs.io/speech-to-text",
|
||||
"deepgram": "https://developers.deepgram.com/docs/language",
|
||||
"whisper-cpp": "https://github.com/openai/whisper#available-models-and-languages",
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const (
|
||||
const (
|
||||
ConfigProviderOpenAI = "openai"
|
||||
ConfigProviderGroqTranscription = "groq-transcription"
|
||||
ConfigProviderGroqTranslation = "groq-translation"
|
||||
ConfigProviderMistralTranscription = "mistral-transcription"
|
||||
ConfigProviderElevenLabs = "elevenlabs"
|
||||
ConfigProviderDeepgram = "deepgram"
|
||||
@@ -44,7 +43,7 @@ const (
|
||||
// e.g. "groq-transcription" -> "groq", "mistral-transcription" -> "mistral"
|
||||
func BaseProviderName(configProvider string) string {
|
||||
switch configProvider {
|
||||
case ConfigProviderGroqTranscription, ConfigProviderGroqTranslation:
|
||||
case ConfigProviderGroqTranscription:
|
||||
return ProviderGroq
|
||||
case ConfigProviderMistralTranscription:
|
||||
return ProviderMistral
|
||||
|
||||
+21
-26
@@ -1,10 +1,6 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// OpenAIProvider implements Provider for OpenAI services
|
||||
type OpenAIProvider struct{}
|
||||
@@ -26,7 +22,8 @@ func (p *OpenAIProvider) IsLocal() bool {
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Models() []Model {
|
||||
allLangs := language.AllLanguageCodes()
|
||||
// https://platform.openai.com/docs/guides/speech-to-text#supported-languages
|
||||
allLangs := openaiTranscriptionLanguages
|
||||
|
||||
docsURL := "https://platform.openai.com/docs/guides/speech-to-text#supported-languages"
|
||||
|
||||
@@ -77,28 +74,26 @@ func (p *OpenAIProvider) Models() []Model {
|
||||
},
|
||||
// LLM models
|
||||
{
|
||||
ID: "gpt-4o-mini",
|
||||
Name: "GPT-4o Mini",
|
||||
Description: "Fast and affordable GPT-4 variant",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
|
||||
ID: "gpt-4o-mini",
|
||||
Name: "GPT-4o Mini",
|
||||
Description: "Fast and affordable GPT-4 variant",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
|
||||
},
|
||||
{
|
||||
ID: "gpt-4o",
|
||||
Name: "GPT-4o",
|
||||
Description: "Most capable GPT-4 model",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
|
||||
ID: "gpt-4o",
|
||||
Name: "GPT-4o",
|
||||
Description: "Most capable GPT-4 model",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
|
||||
)
|
||||
import "github.com/leonardotrapani/hyprvoice/internal/models/whisper"
|
||||
|
||||
// WhisperCppProvider implements Provider for local whisper.cpp transcription
|
||||
type WhisperCppProvider struct{}
|
||||
@@ -25,8 +22,10 @@ func (p *WhisperCppProvider) IsLocal() bool {
|
||||
}
|
||||
|
||||
func (p *WhisperCppProvider) Models() []Model {
|
||||
allLangs := language.AllLanguageCodes()
|
||||
englishOnly := []string{"en"}
|
||||
// https://github.com/openai/whisper#available-models-and-languages
|
||||
allLangs := whisperTranscriptionLanguages
|
||||
// https://github.com/openai/whisper#available-models-and-languages
|
||||
englishOnly := whisperEnglishOnlyLanguages
|
||||
docsURL := "https://github.com/openai/whisper#available-models-and-languages"
|
||||
|
||||
whisperModels := whisper.ListModels()
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestWhisperCppProvider_GetProvider(t *testing.T) {
|
||||
p := GetProvider("whisper-cpp")
|
||||
@@ -88,20 +84,18 @@ func TestWhisperCppProvider_MultilingualModels(t *testing.T) {
|
||||
"large-v3": true,
|
||||
}
|
||||
|
||||
allLangs := language.AllLanguageCodes()
|
||||
|
||||
for _, m := range models {
|
||||
isMultilingual := multilingualIDs[m.ID]
|
||||
if isMultilingual {
|
||||
if len(m.SupportedLanguages) != len(allLangs) {
|
||||
t.Errorf("model %s: expected %d languages, got %d", m.ID, len(allLangs), len(m.SupportedLanguages))
|
||||
}
|
||||
if !m.SupportsAllLanguages() {
|
||||
t.Errorf("model %s: SupportsAllLanguages() should be true", m.ID)
|
||||
if len(m.SupportedLanguages) <= 1 {
|
||||
t.Errorf("model %s: expected multiple languages, got %d", m.ID, len(m.SupportedLanguages))
|
||||
}
|
||||
if !m.SupportsLanguage("es") {
|
||||
t.Errorf("model %s: SupportsLanguage('es') should be true", m.ID)
|
||||
}
|
||||
if !m.SupportsLanguage("en") {
|
||||
t.Errorf("model %s: SupportsLanguage('en') should be true", m.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ type DeepgramAdapter struct {
|
||||
apiKey string
|
||||
model string
|
||||
language string
|
||||
keywords []string
|
||||
conn *websocket.Conn
|
||||
resultsCh chan TranscriptionResult
|
||||
mu sync.Mutex
|
||||
@@ -82,13 +83,14 @@ type deepgramError struct {
|
||||
// endpoint: the WebSocket endpoint config (e.g., wss://api.deepgram.com, /v1/listen)
|
||||
// apiKey: Deepgram API key
|
||||
// model: model ID (e.g., "nova-3")
|
||||
// lang: canonical language code (will be converted to provider format)
|
||||
func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramAdapter {
|
||||
// lang: provider language code
|
||||
func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramAdapter {
|
||||
return &DeepgramAdapter{
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
language: lang,
|
||||
keywords: keywords,
|
||||
resultsCh: make(chan TranscriptionResult, 100),
|
||||
maxRetries: 3,
|
||||
retryDelays: defaultRetryDelays,
|
||||
@@ -228,9 +230,12 @@ func (a *DeepgramAdapter) buildURL() (string, error) {
|
||||
q.Set("punctuate", "true")
|
||||
|
||||
// add language if specified
|
||||
providerLang := language.ToProviderFormat(a.language, "deepgram")
|
||||
if providerLang != "" {
|
||||
q.Set("language", providerLang)
|
||||
if a.language != "" {
|
||||
q.Set("language", a.language)
|
||||
}
|
||||
|
||||
if len(a.keywords) > 0 {
|
||||
q.Set("keywords", strings.Join(a.keywords, ","))
|
||||
}
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ type DeepgramBatchAdapter struct {
|
||||
apiKey string
|
||||
model string
|
||||
language string
|
||||
keywords []string
|
||||
}
|
||||
|
||||
// deepgramBatchResponse is the response from the pre-recorded API
|
||||
@@ -36,12 +37,13 @@ type deepgramBatchChannel struct {
|
||||
}
|
||||
|
||||
// NewDeepgramBatchAdapter creates a new batch adapter for Deepgram
|
||||
func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramBatchAdapter {
|
||||
func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramBatchAdapter {
|
||||
return &DeepgramBatchAdapter{
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
language: lang,
|
||||
keywords: keywords,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,9 +118,12 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) {
|
||||
q.Set("punctuate", "true")
|
||||
|
||||
// add language if specified
|
||||
providerLang := language.ToProviderFormat(a.language, "deepgram")
|
||||
if providerLang != "" {
|
||||
q.Set("language", providerLang)
|
||||
if a.language != "" {
|
||||
q.Set("language", a.language)
|
||||
}
|
||||
|
||||
if len(a.keywords) > 0 {
|
||||
q.Set("keywords", strings.Join(a.keywords, ","))
|
||||
}
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestDeepgramAdapter_Creation(t *testing.T) {
|
||||
BaseURL: "wss://api.deepgram.com",
|
||||
Path: "/v1/listen",
|
||||
}
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
|
||||
|
||||
if adapter.apiKey != "test-api-key" {
|
||||
t.Errorf("apiKey = %q, want %q", adapter.apiKey, "test-api-key")
|
||||
@@ -72,7 +72,7 @@ func TestDeepgramAdapter_BuildURL(t *testing.T) {
|
||||
BaseURL: "wss://api.deepgram.com",
|
||||
Path: "/v1/listen",
|
||||
}
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-key", tt.model, tt.language)
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-key", tt.model, tt.language, nil)
|
||||
|
||||
url, err := adapter.buildURL()
|
||||
if err != nil {
|
||||
@@ -93,7 +93,7 @@ func TestDeepgramAdapter_SendChunkNotStarted(t *testing.T) {
|
||||
BaseURL: "wss://api.deepgram.com",
|
||||
Path: "/v1/listen",
|
||||
}
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en")
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en", nil)
|
||||
|
||||
err := adapter.SendChunk([]byte("audio data"))
|
||||
if err == nil {
|
||||
@@ -109,7 +109,7 @@ func TestDeepgramAdapter_CloseNotStarted(t *testing.T) {
|
||||
BaseURL: "wss://api.deepgram.com",
|
||||
Path: "/v1/listen",
|
||||
}
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en")
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en", nil)
|
||||
|
||||
// closing not-started adapter should not error
|
||||
err := adapter.Close()
|
||||
@@ -173,7 +173,7 @@ func TestDeepgramAdapter_StartAndClose(t *testing.T) {
|
||||
Path: "",
|
||||
}
|
||||
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
|
||||
|
||||
ctx := context.Background()
|
||||
if err := adapter.Start(ctx, ""); err != nil {
|
||||
@@ -229,7 +229,7 @@ func TestDeepgramAdapter_ReceivesResults(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
|
||||
|
||||
ctx := context.Background()
|
||||
if err := adapter.Start(ctx, ""); err != nil {
|
||||
@@ -312,7 +312,7 @@ func TestDeepgramAdapter_SendsRawBinaryAudio(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
|
||||
|
||||
ctx := context.Background()
|
||||
if err := adapter.Start(ctx, ""); err != nil {
|
||||
@@ -357,7 +357,7 @@ func TestDeepgramAdapter_HandlesError(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
|
||||
|
||||
ctx := context.Background()
|
||||
if err := adapter.Start(ctx, ""); err != nil {
|
||||
@@ -398,7 +398,7 @@ func TestDeepgramAdapter_ContextCancellation(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
|
||||
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := adapter.Start(ctx, ""); err != nil {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
@@ -22,6 +21,7 @@ type ElevenLabsAdapter struct {
|
||||
apiKey string
|
||||
model string
|
||||
language string
|
||||
keywords []string
|
||||
}
|
||||
|
||||
// ElevenLabsResponse represents the API response
|
||||
@@ -33,14 +33,15 @@ type ElevenLabsResponse struct {
|
||||
// endpoint: the endpoint config (BaseURL + Path)
|
||||
// apiKey: ElevenLabs API key
|
||||
// model: model ID (e.g., "scribe_v1")
|
||||
// lang: canonical language code (will be converted to provider format)
|
||||
func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *ElevenLabsAdapter {
|
||||
// lang: provider language code
|
||||
func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsAdapter {
|
||||
return &ElevenLabsAdapter{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
language: lang,
|
||||
keywords: keywords,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ func NewElevenLabsAdapterFromConfig(config Config) *ElevenLabsAdapter {
|
||||
config.APIKey,
|
||||
config.Model,
|
||||
config.Language,
|
||||
config.Keywords,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,14 +87,23 @@ func (a *ElevenLabsAdapter) Transcribe(ctx context.Context, audioData []byte) (s
|
||||
return "", fmt.Errorf("write model_id: %w", err)
|
||||
}
|
||||
|
||||
// Add language_code if specified (convert to provider format)
|
||||
providerLang := language.ToProviderFormat(a.language, "elevenlabs")
|
||||
if providerLang != "" {
|
||||
if err := writer.WriteField("language_code", providerLang); err != nil {
|
||||
// Add language_code if specified
|
||||
if a.language != "" {
|
||||
if err := writer.WriteField("language_code", a.language); err != nil {
|
||||
return "", fmt.Errorf("write language_code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(a.keywords) > 0 {
|
||||
keytermsJSON, err := json.Marshal(a.keywords)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal keyterms: %w", err)
|
||||
}
|
||||
if err := writer.WriteField("keyterms", string(keytermsJSON)); err != nil {
|
||||
return "", fmt.Errorf("write keyterms: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("close writer: %w", err)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ type ElevenLabsStreamingAdapter struct {
|
||||
apiKey string
|
||||
model string
|
||||
language string
|
||||
keywords []string
|
||||
conn *websocket.Conn
|
||||
resultsCh chan TranscriptionResult
|
||||
mu sync.Mutex
|
||||
@@ -38,15 +39,17 @@ type ElevenLabsStreamingAdapter struct {
|
||||
retryDelays []time.Duration
|
||||
|
||||
// finalization signaling
|
||||
commitDone chan struct{}
|
||||
commitDone chan struct{}
|
||||
contextSent bool
|
||||
}
|
||||
|
||||
// ElevenLabs WebSocket message types (outgoing)
|
||||
type elevenLabsInputAudioChunk struct {
|
||||
MessageType string `json:"message_type"`
|
||||
AudioBase64 string `json:"audio_base_64"`
|
||||
Commit bool `json:"commit"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
MessageType string `json:"message_type"`
|
||||
AudioBase64 string `json:"audio_base_64"`
|
||||
Commit bool `json:"commit"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
PreviousText string `json:"previous_text,omitempty"`
|
||||
}
|
||||
|
||||
// ElevenLabs WebSocket response types (incoming)
|
||||
@@ -62,13 +65,14 @@ type elevenLabsWSMessage struct {
|
||||
// endpoint: the WebSocket endpoint config (e.g., wss://api.elevenlabs.io, /v1/speech-to-text/realtime)
|
||||
// apiKey: ElevenLabs API key
|
||||
// model: model ID (e.g., "scribe_v1")
|
||||
// lang: canonical language code (will be converted to provider format)
|
||||
func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *ElevenLabsStreamingAdapter {
|
||||
// lang: provider language code
|
||||
func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsStreamingAdapter {
|
||||
return &ElevenLabsStreamingAdapter{
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
language: lang,
|
||||
keywords: keywords,
|
||||
resultsCh: make(chan TranscriptionResult, 100),
|
||||
maxRetries: 3,
|
||||
retryDelays: defaultRetryDelays,
|
||||
@@ -126,6 +130,7 @@ func (a *ElevenLabsStreamingAdapter) connectLocked() error {
|
||||
return fmt.Errorf("websocket dial: %w", err)
|
||||
}
|
||||
a.conn = conn
|
||||
a.contextSent = false
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -199,9 +204,8 @@ func (a *ElevenLabsStreamingAdapter) buildURL() (string, error) {
|
||||
q.Set("audio_format", "pcm_16000") // we use 16kHz PCM
|
||||
|
||||
// add language if specified
|
||||
providerLang := language.ToProviderFormat(a.language, "elevenlabs")
|
||||
if providerLang != "" {
|
||||
q.Set("language_code", providerLang)
|
||||
if a.language != "" {
|
||||
q.Set("language_code", a.language)
|
||||
}
|
||||
|
||||
// use VAD for automatic commit (easier for real-time use)
|
||||
@@ -334,6 +338,13 @@ func (a *ElevenLabsStreamingAdapter) SendChunk(audio []byte) error {
|
||||
SampleRate: 16000,
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
if !a.contextSent && len(a.keywords) > 0 {
|
||||
msg.PreviousText = strings.Join(a.keywords, ", ")
|
||||
a.contextSent = true
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
// send as JSON
|
||||
a.mu.Lock()
|
||||
err := a.conn.WriteJSON(msg)
|
||||
|
||||
@@ -72,6 +72,7 @@ func TestElevenLabsStreamingAdapter_Start(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -126,6 +127,7 @@ func TestElevenLabsStreamingAdapter_SendChunk(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -188,6 +190,7 @@ func TestElevenLabsStreamingAdapter_Results(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -262,6 +265,7 @@ func TestElevenLabsStreamingAdapter_ErrorMessages(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -325,6 +329,7 @@ func TestElevenLabsStreamingAdapter_LanguageConversion(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"es", // Spanish
|
||||
nil,
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -372,6 +377,7 @@ func TestElevenLabsStreamingAdapter_Close(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -408,6 +414,7 @@ func TestElevenLabsStreamingAdapter_NotStarted(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
|
||||
// SendChunk should fail when not started
|
||||
@@ -470,6 +477,7 @@ func TestElevenLabsStreamingAdapter_ReconnectOnReadError(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
// use very short delays for testing
|
||||
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
|
||||
@@ -547,6 +555,7 @@ func TestElevenLabsStreamingAdapter_ReconnectNotifiesClient(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
|
||||
|
||||
@@ -633,6 +642,7 @@ func TestElevenLabsStreamingAdapter_MaxRetriesExhausted(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
adapter.retryDelays = []time.Duration{5 * time.Millisecond, 10 * time.Millisecond, 15 * time.Millisecond}
|
||||
adapter.maxRetries = 2
|
||||
@@ -709,6 +719,7 @@ func TestElevenLabsStreamingAdapter_ReconnectExponentialBackoff(t *testing.T) {
|
||||
"test-api-key",
|
||||
"scribe_v1",
|
||||
"en",
|
||||
nil,
|
||||
)
|
||||
// use measurable delays
|
||||
adapter.retryDelays = []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestNewElevenLabsAdapter(t *testing.T) {
|
||||
Path: "/v1/speech-to-text",
|
||||
}
|
||||
|
||||
adapter := NewElevenLabsAdapter(endpoint, "test-api-key", "scribe_v1", "en")
|
||||
adapter := NewElevenLabsAdapter(endpoint, "test-api-key", "scribe_v1", "en", nil)
|
||||
|
||||
if adapter == nil {
|
||||
t.Fatalf("NewElevenLabsAdapter() returned nil")
|
||||
@@ -74,7 +74,7 @@ func TestElevenLabsAdapter_Transcribe_EmptyAudio(t *testing.T) {
|
||||
Path: "/v1/speech-to-text",
|
||||
}
|
||||
|
||||
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "")
|
||||
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := adapter.Transcribe(ctx, []byte{})
|
||||
@@ -94,7 +94,7 @@ func TestElevenLabsAdapter_Transcribe_ValidAudio(t *testing.T) {
|
||||
Path: "/v1/speech-to-text",
|
||||
}
|
||||
|
||||
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "en")
|
||||
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "en", nil)
|
||||
|
||||
if adapter == nil {
|
||||
t.Fatal("NewElevenLabsAdapter() returned nil")
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package transcriber
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// GroqTranslationAdapter implements BatchAdapter for Groq Translation API
|
||||
// Translates audio to English text. The Language field in config hints at the source language.
|
||||
type GroqTranslationAdapter struct {
|
||||
client *openai.Client
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewGroqTranslationAdapter(config Config) *GroqTranslationAdapter {
|
||||
clientConfig := openai.DefaultConfig(config.APIKey)
|
||||
clientConfig.BaseURL = "https://api.groq.com/openai/v1"
|
||||
client := openai.NewClientWithConfig(clientConfig)
|
||||
|
||||
return &GroqTranslationAdapter{
|
||||
client: client,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GroqTranslationAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
|
||||
if len(audioData) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Convert raw PCM to WAV format
|
||||
wavData, err := convertToWAV(audioData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("convert to WAV: %w", err)
|
||||
}
|
||||
|
||||
// Create translation request
|
||||
// Note: Translation always outputs English, regardless of target language
|
||||
// The Language field in the request hints at the source audio language for better accuracy
|
||||
req := openai.AudioRequest{
|
||||
Model: a.config.Model,
|
||||
Reader: bytes.NewReader(wavData),
|
||||
FilePath: "audio.wav",
|
||||
Language: a.config.Language, // Source language hint
|
||||
}
|
||||
|
||||
// Add keywords as prompt to help with spelling hints
|
||||
if len(a.config.Keywords) > 0 {
|
||||
req.Prompt = strings.Join(a.config.Keywords, ", ")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := a.client.CreateTranslation(ctx, req)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("groq-translation-adapter: API call failed after %v: %v", duration, err)
|
||||
return "", fmt.Errorf("groq translation: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("groq-translation-adapter: translated %d bytes in %v: %q", len(audioData), duration, resp.Text)
|
||||
return resp.Text, nil
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
@@ -27,7 +26,7 @@ type OpenAIAdapter struct {
|
||||
// endpoint: the BaseURL for the API (e.g., "https://api.openai.com", "https://api.groq.com/openai")
|
||||
// apiKey: the API key for authentication
|
||||
// model: model ID to use
|
||||
// lang: canonical language code (will be converted to provider format)
|
||||
// lang: provider language code
|
||||
// keywords: optional spelling hints
|
||||
// providerName: used for logging and language format conversion
|
||||
func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string, providerName string) *OpenAIAdapter {
|
||||
@@ -69,15 +68,12 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin
|
||||
return "", fmt.Errorf("convert to WAV: %w", err)
|
||||
}
|
||||
|
||||
// Convert language code to provider format
|
||||
providerLang := language.ToProviderFormat(a.language, a.providerName)
|
||||
|
||||
// Create transcription request
|
||||
req := openai.AudioRequest{
|
||||
Model: a.model,
|
||||
Reader: bytes.NewReader(wavData),
|
||||
FilePath: "audio.wav",
|
||||
Language: providerLang,
|
||||
Language: a.language,
|
||||
}
|
||||
|
||||
// Add keywords as initial_prompt to help with spelling hints
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,7 @@ type OpenAIRealtimeAdapter struct {
|
||||
apiKey string
|
||||
model string
|
||||
language string
|
||||
keywords []string
|
||||
conn *websocket.Conn
|
||||
resultsCh chan TranscriptionResult
|
||||
mu sync.Mutex
|
||||
@@ -56,6 +58,7 @@ type openaiRealtimeSessionConfig struct {
|
||||
type openaiRealtimeTranscription struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
type openaiRealtimeTurnDetection struct {
|
||||
@@ -104,12 +107,13 @@ type openaiRealtimeError struct {
|
||||
// apiKey: OpenAI API key
|
||||
// model: model ID (e.g., "gpt-4o-realtime-preview")
|
||||
// lang: canonical language code (will be used for transcription config)
|
||||
func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *OpenAIRealtimeAdapter {
|
||||
func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *OpenAIRealtimeAdapter {
|
||||
return &OpenAIRealtimeAdapter{
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
language: lang,
|
||||
keywords: keywords,
|
||||
resultsCh: make(chan TranscriptionResult, 100),
|
||||
maxRetries: 3,
|
||||
retryDelays: defaultRetryDelays,
|
||||
@@ -206,6 +210,10 @@ func (a *OpenAIRealtimeAdapter) configureSession() error {
|
||||
sessionUpdate.Session.InputAudioTranscription.Language = a.language
|
||||
}
|
||||
|
||||
if len(a.keywords) > 0 {
|
||||
sessionUpdate.Session.InputAudioTranscription.Prompt = strings.Join(a.keywords, ", ")
|
||||
}
|
||||
|
||||
return a.conn.WriteJSON(sessionUpdate)
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestOpenAIRealtimeAdapter_Start(t *testing.T) {
|
||||
Path: "",
|
||||
}
|
||||
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test-key", "gpt-4o-realtime-preview", "en")
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test-key", "gpt-4o-realtime-preview", "en", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
@@ -198,7 +198,7 @@ func TestOpenAIRealtimeAdapter_SendChunk(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "")
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
@@ -282,7 +282,7 @@ func TestOpenAIRealtimeAdapter_TranscriptionResults(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "en")
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "en", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
@@ -369,7 +369,7 @@ func TestOpenAIRealtimeAdapter_ErrorHandling(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "")
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
@@ -433,7 +433,7 @@ func TestOpenAIRealtimeAdapter_Reconnection(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "")
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
|
||||
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -473,7 +473,7 @@ func TestOpenAIRealtimeAdapter_Close(t *testing.T) {
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "")
|
||||
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
)
|
||||
|
||||
// WhisperCppAdapter implements BatchAdapter for local whisper-cpp transcription
|
||||
@@ -23,7 +21,7 @@ type WhisperCppAdapter struct {
|
||||
|
||||
// NewWhisperCppAdapter creates a new whisper-cpp adapter
|
||||
// modelPath: full path to the model file (e.g., ~/.local/share/hyprvoice/models/whisper/ggml-base.en.bin)
|
||||
// lang: canonical language code (will be converted to whisper-cpp format)
|
||||
// lang: whisper-cpp language code
|
||||
// threads: number of CPU threads (0 for auto)
|
||||
func NewWhisperCppAdapter(modelPath, lang string, threads int) *WhisperCppAdapter {
|
||||
return &WhisperCppAdapter{
|
||||
@@ -63,8 +61,11 @@ func (a *WhisperCppAdapter) Transcribe(ctx context.Context, audioData []byte) (s
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
// convert language to whisper-cpp format
|
||||
lang := language.ToProviderFormat(a.language, "whisper-cpp")
|
||||
// use whisper-cpp auto if unspecified
|
||||
lang := a.language
|
||||
if lang == "" {
|
||||
lang = "auto"
|
||||
}
|
||||
|
||||
// build command args
|
||||
args := []string{
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"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/provider"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
||||
@@ -43,15 +42,6 @@ func NewTranscriber(config Config) (Transcriber, error) {
|
||||
return nil, fmt.Errorf("provider is required")
|
||||
}
|
||||
|
||||
// special case: groq-translation uses CreateTranslation API (different from transcription)
|
||||
if config.Provider == provider.ConfigProviderGroqTranslation {
|
||||
if config.APIKey == "" {
|
||||
return nil, fmt.Errorf("Groq API key required")
|
||||
}
|
||||
adapter := NewGroqTranslationAdapter(config)
|
||||
return NewSimpleTranscriber(config, adapter), nil
|
||||
}
|
||||
|
||||
// map config provider name to registry provider name
|
||||
registryProvider := provider.BaseProviderName(config.Provider)
|
||||
|
||||
@@ -89,8 +79,7 @@ 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 := lang.FromCode(config.Language).Name
|
||||
log.Printf("warning: model %s does not support language %s, falling back to auto-detect", model.ID, langName)
|
||||
log.Printf("warning: model %s does not support language %s, falling back to auto-detect", model.ID, config.Language)
|
||||
config.Language = ""
|
||||
}
|
||||
|
||||
@@ -119,11 +108,11 @@ func NewTranscriber(config Config) (Transcriber, error) {
|
||||
var streamingAdapter StreamingAdapter
|
||||
switch adapterType {
|
||||
case provider.AdapterElevenLabsStream:
|
||||
streamingAdapter = NewElevenLabsStreamingAdapter(endpoint, config.APIKey, model.ID, config.Language)
|
||||
streamingAdapter = NewElevenLabsStreamingAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
|
||||
case provider.AdapterDeepgram:
|
||||
streamingAdapter = NewDeepgramAdapter(endpoint, config.APIKey, model.ID, config.Language)
|
||||
streamingAdapter = NewDeepgramAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
|
||||
case provider.AdapterOpenAIRealtime:
|
||||
streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language)
|
||||
streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported streaming adapter type: %s", adapterType)
|
||||
}
|
||||
@@ -136,9 +125,9 @@ func NewTranscriber(config Config) (Transcriber, error) {
|
||||
case provider.AdapterOpenAI:
|
||||
adapter = NewOpenAIAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords, registryProvider)
|
||||
case provider.AdapterElevenLabs:
|
||||
adapter = NewElevenLabsAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
|
||||
adapter = NewElevenLabsAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
|
||||
case provider.AdapterDeepgram:
|
||||
adapter = NewDeepgramBatchAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
|
||||
adapter = NewDeepgramBatchAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
|
||||
case provider.AdapterWhisperCpp:
|
||||
modelPath := whisper.GetModelPath(config.Model)
|
||||
if modelPath == "" {
|
||||
|
||||
@@ -56,26 +56,6 @@ func TestNewTranscriber(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid groq-translation config",
|
||||
config: Config{
|
||||
Provider: "groq-translation",
|
||||
APIKey: "gsk-test-key",
|
||||
Language: "es",
|
||||
Model: "whisper-large-v3-turbo",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "groq-translation config without api key",
|
||||
config: Config{
|
||||
Provider: "groq-translation",
|
||||
APIKey: "",
|
||||
Language: "es",
|
||||
Model: "whisper-large-v3-turbo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid mistral-transcription config",
|
||||
config: Config{
|
||||
|
||||
@@ -37,7 +37,7 @@ func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error)
|
||||
|
||||
enableLLM := cfg.LLM.Enabled
|
||||
|
||||
enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"
|
||||
enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text. Recommended for weak voice models"
|
||||
if cfg.LLM.Enabled {
|
||||
enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc)
|
||||
} else {
|
||||
|
||||
@@ -47,6 +47,7 @@ func editProviders(cfg *config.Config, onboarding bool) error {
|
||||
|
||||
for {
|
||||
var options []huh.Option[string]
|
||||
options = append(options, huh.NewOption("Local", "local"))
|
||||
for _, name := range AllProviders {
|
||||
options = append(options, huh.NewOption(formatProviderOption(cfg, name), name))
|
||||
}
|
||||
@@ -75,6 +76,13 @@ func editProviders(cfg *config.Config, onboarding bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if selected == "local" {
|
||||
if err := showLocalProviderInfo(); err != nil {
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
apiKey, err := configureSingleProvider(cfg, selected)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -90,6 +98,25 @@ func editProviders(cfg *config.Config, onboarding bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
func showLocalProviderInfo() error {
|
||||
selected := "done"
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Local Models").
|
||||
Description("No need to configure any API keys for local models, go to the next step.").
|
||||
Options(huh.NewOption("Done", "done")).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatProviderOption formats a provider menu option with status
|
||||
func formatProviderOption(cfg *config.Config, name string) string {
|
||||
var status string
|
||||
@@ -190,7 +217,7 @@ func inputAPIKey(providerName string) (string, error) {
|
||||
func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string {
|
||||
providerName := selectedProvider
|
||||
switch selectedProvider {
|
||||
case "groq-transcription", "groq-translation":
|
||||
case "groq-transcription":
|
||||
providerName = "groq"
|
||||
case "mistral-transcription":
|
||||
providerName = "mistral"
|
||||
|
||||
@@ -35,8 +35,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
|
||||
huh.NewOption("OpenAI Whisper", "openai"))
|
||||
case "groq":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("Groq Whisper (transcription)", "groq-transcription"),
|
||||
huh.NewOption("Groq Whisper (translate to English)", "groq-translation"))
|
||||
huh.NewOption("Groq Whisper", "groq-transcription"))
|
||||
case "mistral":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("Mistral Voxtral", "mistral-transcription"))
|
||||
@@ -210,25 +209,37 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
languageOptions := getModelLanguageOptions(model, cfg.Transcription.Language)
|
||||
selectedLanguage := cfg.Transcription.Language
|
||||
|
||||
languageForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Language").
|
||||
Description("Select language for transcription").
|
||||
Options(languageOptions...).
|
||||
Filtering(true).
|
||||
Value(&selectedLanguage),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := languageForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
if cfg.Transcription.Language != "" && !model.SupportsLanguage(cfg.Transcription.Language) {
|
||||
cfg.Transcription.Language = ""
|
||||
}
|
||||
|
||||
cfg.Transcription.Language = selectedLanguage
|
||||
if len(model.SupportedLanguages) <= 1 {
|
||||
if len(model.SupportedLanguages) == 1 {
|
||||
cfg.Transcription.Language = model.SupportedLanguages[0]
|
||||
} else {
|
||||
cfg.Transcription.Language = ""
|
||||
}
|
||||
} else {
|
||||
languageOptions := getModelLanguageOptions(model, cfg.Transcription.Language)
|
||||
selectedLanguage := cfg.Transcription.Language
|
||||
|
||||
languageForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Language").
|
||||
Description("Select language for transcription").
|
||||
Options(languageOptions...).
|
||||
Filtering(true).
|
||||
Value(&selectedLanguage),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := languageForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
cfg.Transcription.Language = selectedLanguage
|
||||
}
|
||||
|
||||
// set streaming mode based on model capabilities
|
||||
if model.SupportsBothModes() {
|
||||
@@ -271,8 +282,7 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt
|
||||
}
|
||||
if !configured["groq"] {
|
||||
options = append(options,
|
||||
huh.NewOption("Groq Whisper transcription (not configured)", "groq-transcription"),
|
||||
huh.NewOption("Groq Whisper translation (not configured)", "groq-translation"))
|
||||
huh.NewOption("Groq Whisper (not configured)", "groq-transcription"))
|
||||
}
|
||||
if !configured["mistral"] {
|
||||
options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription"))
|
||||
@@ -284,13 +294,6 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt
|
||||
}
|
||||
|
||||
func getTranscriptionModelOptions(configProvider string) []huh.Option[string] {
|
||||
// special case: groq-translation only supports whisper-large-v3
|
||||
if configProvider == "groq-translation" {
|
||||
return []huh.Option[string]{
|
||||
huh.NewOption("whisper-large-v3 (only option)", "whisper-large-v3"),
|
||||
}
|
||||
}
|
||||
|
||||
// map config provider name to registry provider name
|
||||
registryName := mapConfigProviderToRegistry(configProvider)
|
||||
p := provider.GetProvider(registryName)
|
||||
@@ -319,7 +322,7 @@ func getTranscriptionModelOptions(configProvider string) []huh.Option[string] {
|
||||
// mapConfigProviderToRegistry maps config provider names to registry provider names
|
||||
func mapConfigProviderToRegistry(configProvider string) string {
|
||||
switch configProvider {
|
||||
case "groq-transcription", "groq-translation":
|
||||
case "groq-transcription":
|
||||
return "groq"
|
||||
case "mistral-transcription":
|
||||
return "mistral"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
@@ -13,33 +10,23 @@ func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Op
|
||||
var options []huh.Option[string]
|
||||
|
||||
// auto-detect is always first
|
||||
autoLabel := "Auto-detect"
|
||||
autoLabel := "Auto-detect (recommended)"
|
||||
if currentLang == "" {
|
||||
autoLabel += " (current)"
|
||||
}
|
||||
options = append(options, huh.NewOption(autoLabel, ""))
|
||||
|
||||
// only show languages supported by the model
|
||||
for _, lang := range language.List() {
|
||||
if model != nil && !model.SupportsLanguage(lang.Code) {
|
||||
continue
|
||||
}
|
||||
if model == nil {
|
||||
return options
|
||||
}
|
||||
|
||||
label := formatLanguageLabel(lang)
|
||||
if lang.Code == currentLang {
|
||||
for _, code := range model.SupportedLanguages {
|
||||
label := code
|
||||
if code == currentLang {
|
||||
label += " (current)"
|
||||
}
|
||||
|
||||
options = append(options, huh.NewOption(label, lang.Code))
|
||||
options = append(options, huh.NewOption(label, code))
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// formatLanguageLabel formats a language for display
|
||||
func formatLanguageLabel(lang language.Language) string {
|
||||
if lang.Name == lang.NativeName || lang.NativeName == "" {
|
||||
return fmt.Sprintf("%s (%s)", lang.Name, lang.Code)
|
||||
}
|
||||
return fmt.Sprintf("%s - %s (%s)", lang.Name, lang.NativeName, lang.Code)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user