Parent directory

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}