A more neutral and elegant voice.Transcriber interface

This commit is contained in:
Dimitrij Denissenko 2026-03-01 21:02:16 +00:00
parent b1386ad71f
commit b74f92ed28
4 changed files with 99 additions and 31 deletions

View file

@ -7,7 +7,6 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
@ -136,20 +135,10 @@ func gatewayCmd(debug bool) error {
agentLoop.SetChannelManager(channelManager) agentLoop.SetChannelManager(channelManager)
agentLoop.SetMediaStore(mediaStore) agentLoop.SetMediaStore(mediaStore)
// Wire up voice transcription if Groq API key is available // Wire up voice transcription if a supported provider is configured.
groqAPIKey := cfg.Providers.Groq.APIKey if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
if groqAPIKey == "" {
for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
groqAPIKey = mc.APIKey
break
}
}
}
if groqAPIKey != "" {
transcriber := voice.NewGroqTranscriber(groqAPIKey)
agentLoop.SetTranscriber(transcriber) agentLoop.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq voice transcription enabled (agent-level)") logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} }
enabledChannels := channelManager.GetEnabledChannels() enabledChannels := channelManager.GetEnabledChannels()

View file

@ -276,7 +276,7 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and // transcribeAudioInMessage resolves audio media refs, transcribes them, and
// replaces audio annotations in msg.Content with the transcribed text. // replaces audio annotations in msg.Content with the transcribed text.
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage { func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage {
if al.transcriber == nil || !al.transcriber.IsAvailable() || al.mediaStore == nil || len(msg.Media) == 0 { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
return msg return msg
} }

View file

@ -10,15 +10,17 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
type Transcriber interface { type Transcriber interface {
Name() string
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
IsAvailable() bool
} }
type GroqTranscriber struct { type GroqTranscriber struct {
@ -157,8 +159,22 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
return &result, nil return &result, nil
} }
func (t *GroqTranscriber) IsAvailable() bool { func (t *GroqTranscriber) Name() string {
available := t.apiKey != "" return "groq"
logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available}) }
return available
// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
// nil if no supported transcription provider is configured.
func DetectTranscriber(cfg *config.Config) Transcriber {
// Direct Groq provider config takes priority.
if key := cfg.Providers.Groq.APIKey; key != "" {
return NewGroqTranscriber(key)
}
// Fall back to any model-list entry that uses the groq/ protocol.
for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
return NewGroqTranscriber(mc.APIKey)
}
}
return nil
} }

View file

@ -8,25 +8,88 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/config"
) )
// Ensure GroqTranscriber satisfies the Transcriber interface at compile time. // Ensure GroqTranscriber satisfies the Transcriber interface at compile time.
var _ Transcriber = (*GroqTranscriber)(nil) var _ Transcriber = (*GroqTranscriber)(nil)
func TestIsAvailable(t *testing.T) { func TestGroqTranscriberName(t *testing.T) {
tests := []struct { tr := NewGroqTranscriber("sk-test")
name string if got := tr.Name(); got != "groq" {
apiKey string t.Errorf("Name() = %q, want %q", got, "groq")
want bool
}{
{"with key", "sk-test-key", true},
{"empty key", "", false},
} }
}
func TestDetectTranscriber(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
wantNil bool
wantName string
}{
{
name: "no config",
cfg: &config.Config{},
wantNil: true,
},
{
name: "groq provider key",
cfg: &config.Config{
Providers: config.ProvidersConfig{
Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
},
},
wantName: "groq",
},
{
name: "groq via model list",
cfg: &config.Config{
ModelList: []config.ModelConfig{
{Model: "openai/gpt-4o", APIKey: "sk-openai"},
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
},
},
wantName: "groq",
},
{
name: "groq model list entry without key is skipped",
cfg: &config.Config{
ModelList: []config.ModelConfig{
{Model: "groq/llama-3.3-70b", APIKey: ""},
},
},
wantNil: true,
},
{
name: "provider key takes priority over model list",
cfg: &config.Config{
Providers: config.ProvidersConfig{
Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
},
ModelList: []config.ModelConfig{
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
},
},
wantName: "groq",
},
}
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
tr := NewGroqTranscriber(tc.apiKey) tr := DetectTranscriber(tc.cfg)
if got := tr.IsAvailable(); got != tc.want { if tc.wantNil {
t.Errorf("IsAvailable() = %v, want %v", got, tc.want) if tr != nil {
t.Errorf("DetectTranscriber() = %v, want nil", tr)
}
return
}
if tr == nil {
t.Fatal("DetectTranscriber() = nil, want non-nil")
}
if got := tr.Name(); got != tc.wantName {
t.Errorf("Name() = %q, want %q", got, tc.wantName)
} }
}) })
} }