From b1daf19ac12dd7a7f575448b71dcc66751cd294d Mon Sep 17 00:00:00 2001 From: Seth Rose Date: Wed, 25 Mar 2026 20:37:56 -0500 Subject: [PATCH] feat(voice): add Parakeet local STT support --- config/config.example.json | 5 +- pkg/config/config.go | 3 + pkg/voice/parakeet_transcriber.go | 150 ++++++++++++++++++++++++++++++ pkg/voice/transcriber.go | 9 ++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 pkg/voice/parakeet_transcriber.go diff --git a/config/config.example.json b/config/config.example.json index ff2969dcb..7f14cb1d6 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -440,7 +440,10 @@ }, "voice": { "model_name": "", - "echo_transcription": false + "echo_transcription": false, + "parakeet_enabled": false, + "parakeet_api_base": "http://localhost:5092", + "parakeet_model": "parakeet" }, "hooks": { "enabled": true, diff --git a/pkg/config/config.go b/pkg/config/config.go index 367952301..b59845827 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -836,6 +836,9 @@ type VoiceConfig struct { ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"` + ParakeetEnabled bool `json:"parakeet_enabled" env:"PICOCLAW_VOICE_PARAKEET_ENABLED"` + ParakeetAPIBase string `json:"parakeet_api_base,omitempty" env:"PICOCLAW_VOICE_PARAKEET_API_BASE"` + ParakeetModel string `json:"parakeet_model,omitempty" env:"PICOCLAW_VOICE_PARAKEET_MODEL"` } // ModelConfig represents a model-centric provider configuration. diff --git a/pkg/voice/parakeet_transcriber.go b/pkg/voice/parakeet_transcriber.go new file mode 100644 index 000000000..933d8ba38 --- /dev/null +++ b/pkg/voice/parakeet_transcriber.go @@ -0,0 +1,150 @@ +package voice + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// ParakeetTranscriber uses a local Parakeet model for speech-to-text. +type ParakeetTranscriber struct { + apiBase string + model string + httpClient *http.Client +} + +func NewParakeetTranscriber(apiBase string, model string) *ParakeetTranscriber { + logger.DebugCF("voice", "Creating Parakeet transcriber", map[string]any{ + "api_base": apiBase, + "model": model, + }) + + if apiBase == "" { + apiBase = "http://localhost:5092" + } + if model == "" { + model = "parakeet" + } + + return &ParakeetTranscriber{ + apiBase: apiBase, + model: model, + httpClient: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +func (t *ParakeetTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting Parakeet transcription", map[string]any{"audio_file": audioFilePath}) + + audioFile, err := os.Open(audioFilePath) + if err != nil { + logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) + return nil, fmt.Errorf("failed to open audio file: %w", err) + } + defer audioFile.Close() + + fileInfo, err := audioFile.Stat() + if err != nil { + logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err}) + return nil, fmt.Errorf("failed to get file info: %w", err) + } + + logger.DebugCF("voice", "Audio file details", map[string]any{ + "size_bytes": fileInfo.Size(), + "file_name": filepath.Base(audioFilePath), + }) + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) + if err != nil { + logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create form file: %w", err) + } + + if _, err = io.Copy(part, audioFile); err != nil { + logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to copy file content: %w", err) + } + + // Note: Parakeet uses "model" field, not "model_id" + if err = writer.WriteField("model", t.model); err != nil { + return nil, fmt.Errorf("failed to write model field: %w", err) + } + + if err = writer.Close(); err != nil { + logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + url := t.apiBase + "/v1/audio/transcriptions" + req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) + if err != nil { + logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + logger.DebugCF("voice", "Sending transcription request to Parakeet", map[string]any{ + "url": url, + "request_size_bytes": requestBody.Len(), + "file_size_bytes": fileInfo.Size(), + }) + + resp, err := t.httpClient.Do(req) + if err != nil { + logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("voice", "Parakeet API error", map[string]any{ + "status_code": resp.StatusCode, + "response": string(body), + }) + return nil, fmt.Errorf("Parakeet API error (status %d): %s", resp.StatusCode, string(body)) + } + + logger.DebugCF("voice", "Received response from Parakeet", map[string]any{ + "status_code": resp.StatusCode, + "response_size_bytes": len(body), + }) + + var result TranscriptionResponse + if err := json.Unmarshal(body, &result); err != nil { + logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + logger.InfoCF("voice", "Parakeet transcription completed successfully", map[string]any{ + "text_length": len(result.Text), + "transcription_preview": utils.Truncate(result.Text, 50), + }) + + return &result, nil +} + +func (t *ParakeetTranscriber) Name() string { + return "parakeet" +} diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index f56fdeedd..2f25a0271 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -58,6 +58,15 @@ func DetectTranscriber(cfg *config.Config) Transcriber { if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" { return NewElevenLabsTranscriber(key) } + + // Local Parakeet model (no API key required). + if cfg.Voice.ParakeetEnabled { + return NewParakeetTranscriber( + cfg.Voice.ParakeetAPIBase, + cfg.Voice.ParakeetModel, + ) + } + // 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() != "" {