asr.go
3138 bytes
1// Package stt wraps push-to-talk recording and the audio.cpp ASR client. It
2// is an HTTP/process client only and never starts or manages any model service.
3package stt
4
5import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "fmt"
10 "io"
11 "mime/multipart"
12 "net/http"
13 "strings"
14 "time"
15)
16
17// ASRModelName is the model id sent in transcription requests.
18const ASRModelName = "qwen3-asr"
19
20// asrLanguage forces Japanese transcription.
21const asrLanguage = "ja"
22
23// asrTimeout bounds one transcription call; ASR is fast and a stuck call should
24// not hang a turn.
25const asrTimeout = 10 * time.Second
26
27// ASRClient is a multipart client for the externally managed audio.cpp
28// server's OpenAI-style transcriptions endpoint. It uploads a WAV file and
29// returns the recognized text.
30type ASRClient struct {
31 URL string
32 HTTP *http.Client
33}
34
35// NewASRClient takes the audio.cpp base URL and appends the
36// /v1/audio/transcriptions path for transcription requests.
37func NewASRClient(baseURL string, hc *http.Client) *ASRClient {
38 if hc == nil {
39 hc = &http.Client{}
40 }
41 return &ASRClient{URL: strings.TrimRight(baseURL, "/") + "/v1/audio/transcriptions", HTTP: hc}
42}
43
44// TranscribeBytes sends the WAV bytes to the transcriptions endpoint and
45// returns the recognized text.
46func (c *ASRClient) TranscribeBytes(ctx context.Context, wav []byte) (string, error) {
47 ctx, cancel := context.WithTimeout(ctx, asrTimeout)
48 defer cancel()
49 body, contentType, err := encodeMultipart(wav)
50 if err != nil {
51 return "", fmt.Errorf("stt: encode multipart: %w", err)
52 }
53 req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, body)
54 if err != nil {
55 return "", fmt.Errorf("stt: build request: %w", err)
56 }
57 req.Header.Set("Content-Type", contentType)
58
59 resp, err := c.HTTP.Do(req)
60 if err != nil {
61 return "", fmt.Errorf("stt: request to %s: %w", c.URL, err)
62 }
63 defer func() { _ = resp.Body.Close() }()
64
65 raw, err := io.ReadAll(resp.Body)
66 if err != nil {
67 return "", fmt.Errorf("stt: read response from %s: %w", c.URL, err)
68 }
69 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
70 return "", fmt.Errorf("stt: %s returned HTTP %d: %s", c.URL, resp.StatusCode, strings.TrimSpace(string(raw)))
71 }
72
73 var out struct {
74 Text string `json:"text"`
75 }
76 if err := json.Unmarshal(raw, &out); err != nil {
77 return "", fmt.Errorf("stt: decode response from %s: %w", c.URL, err)
78 }
79 return out.Text, nil
80}
81
82func encodeMultipart(wav []byte) (io.Reader, string, error) {
83 var buf bytes.Buffer
84 mw := multipart.NewWriter(&buf)
85
86 fw, err := mw.CreateFormFile("file", "recording.wav")
87 if err != nil {
88 return nil, "", fmt.Errorf("create file field: %w", err)
89 }
90 if _, err := fw.Write(wav); err != nil {
91 return nil, "", fmt.Errorf("write file field: %w", err)
92 }
93 if err := mw.WriteField("model", ASRModelName); err != nil {
94 return nil, "", fmt.Errorf("write model field: %w", err)
95 }
96 if err := mw.WriteField("language", asrLanguage); err != nil {
97 return nil, "", fmt.Errorf("write language field: %w", err)
98 }
99 if err := mw.Close(); err != nil {
100 return nil, "", fmt.Errorf("close multipart: %w", err)
101 }
102 return &buf, mw.FormDataContentType(), nil
103}