mirror of
https://github.com/hanzoai/speech.git
synced 2026-10-04 07:37:19 +00:00
414 lines
13 KiB
Go
414 lines
13 KiB
Go
package main
|
|
|
|
// One OpenAI-shaped surface. Nothing here authenticates: the ai plane
|
|
// (hanzoai/ai, behind api.hanzo.ai) fronts this service, authenticates every
|
|
// caller and meters what it is told — `duration` on a transcription, characters
|
|
// on speech, `seconds` on a growing transcript. A provider row pointing at
|
|
// http://speech.hanzo.svc/v1 is the whole integration.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
type server struct {
|
|
here string // where THIS process answers, for a transcript's `at`
|
|
sp atomic.Pointer[speech]
|
|
live sessions
|
|
maxIn int64 // largest upload accepted
|
|
}
|
|
|
|
func (s *server) routes() http.Handler {
|
|
m := http.NewServeMux()
|
|
m.HandleFunc("GET /healthz", s.healthz)
|
|
m.HandleFunc("GET /v1/models", s.ready(s.models))
|
|
m.HandleFunc("POST /v1/audio/transcriptions", s.ready(s.transcriptions))
|
|
m.HandleFunc("POST /v1/audio/speech", s.ready(s.speech))
|
|
m.HandleFunc("POST /v1/audio/transcript", s.ready(s.transcriptOpen))
|
|
m.HandleFunc("POST /v1/audio/transcript/{id}", s.ready(s.transcriptPush))
|
|
m.HandleFunc("DELETE /v1/audio/transcript/{id}", s.ready(s.transcriptClose))
|
|
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
fail(w, http.StatusNotFound, "no route %s %s", r.Method, r.URL.Path)
|
|
})
|
|
return m
|
|
}
|
|
|
|
// healthz is ready only once every model has loaded: a pod that answers before
|
|
// then would take traffic it can only refuse.
|
|
func (s *server) healthz(w http.ResponseWriter, r *http.Request) {
|
|
if s.sp.Load() == nil {
|
|
reply(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "loading": true})
|
|
return
|
|
}
|
|
reply(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *server) ready(h func(http.ResponseWriter, *http.Request, *speech)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sp := s.sp.Load()
|
|
if sp == nil {
|
|
fail(w, http.StatusServiceUnavailable, "models are still loading")
|
|
return
|
|
}
|
|
h(w, r, sp)
|
|
}
|
|
}
|
|
|
|
func (s *server) models(w http.ResponseWriter, r *http.Request, sp *speech) {
|
|
type model struct {
|
|
ID string `json:"id"`
|
|
Object string `json:"object"`
|
|
OwnedBy string `json:"owned_by"`
|
|
}
|
|
var data []model
|
|
for _, m := range sp.models() {
|
|
data = append(data, model{ID: m, Object: "model", OwnedBy: "hanzo"})
|
|
}
|
|
reply(w, http.StatusOK, map[string]any{"object": "list", "data": data})
|
|
}
|
|
|
|
// What a caller may ask to be timed.
|
|
var granularities = map[string]bool{"word": true, "segment": true}
|
|
|
|
func (s *server) transcriptions(w http.ResponseWriter, r *http.Request, sp *speech) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.maxIn)
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
var big *http.MaxBytesError
|
|
if errors.As(err, &big) {
|
|
fail(w, http.StatusRequestEntityTooLarge, "upload exceeds %d bytes", s.maxIn)
|
|
return
|
|
}
|
|
fail(w, http.StatusBadRequest, "expected a multipart form: %v", err)
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
model := r.FormValue("model")
|
|
if model == "" {
|
|
model = "whisper"
|
|
}
|
|
if sp.ears[model] == nil {
|
|
fail(w, http.StatusNotFound, "unknown model %q; transcription models: %s", model, strings.Join(present(heardBy, sp.ears), ", "))
|
|
return
|
|
}
|
|
lang, err := language(r.FormValue("language"))
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, "%v", err)
|
|
return
|
|
}
|
|
format := r.FormValue("response_format")
|
|
if format == "" {
|
|
format = "json"
|
|
}
|
|
if format != "json" && format != "text" && format != "verbose_json" {
|
|
fail(w, http.StatusBadRequest, "unsupported response_format %q; supported: json, text, verbose_json", format)
|
|
return
|
|
}
|
|
// The bracketed name is how a multipart array is spelled: it is what the
|
|
// OpenAI SDKs send and what the ai plane sends. An unrecognized granularity
|
|
// is refused by name; ignoring it would answer 200 with no timings and no
|
|
// reason, which reads as the feature being missing.
|
|
asked := r.MultipartForm.Value["timestamp_granularities[]"]
|
|
var unknown []string
|
|
for _, g := range asked {
|
|
if !granularities[g] {
|
|
unknown = append(unknown, g)
|
|
}
|
|
}
|
|
if len(unknown) > 0 {
|
|
sort.Strings(unknown)
|
|
fail(w, http.StatusBadRequest, "unknown timestamp_granularities %v; supported: segment, word", unknown)
|
|
return
|
|
}
|
|
// Timings ride the verbose body and nowhere else, so asking for them beside a
|
|
// body that cannot carry them is a mistake worth naming.
|
|
if len(asked) > 0 && format != "verbose_json" {
|
|
fail(w, http.StatusBadRequest, "timestamp_granularities requires response_format=verbose_json")
|
|
return
|
|
}
|
|
want := map[string]bool{}
|
|
for _, g := range asked {
|
|
want[g] = true
|
|
}
|
|
if len(want) == 0 {
|
|
want["segment"] = true // OpenAI's default granularity
|
|
}
|
|
|
|
f, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, "a transcription needs a `file`")
|
|
return
|
|
}
|
|
data, err := io.ReadAll(f)
|
|
f.Close()
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, "reading the upload: %v", err)
|
|
return
|
|
}
|
|
// max_seconds holds a caller to a length — the ai plane's public lane sends
|
|
// it for a visitor with no account. The decode stops just past it, so the cap
|
|
// bounds the work and not only the answer.
|
|
var longest float64
|
|
if v := r.FormValue("max_seconds"); v != "" {
|
|
longest, err = strconv.ParseFloat(v, 64)
|
|
if err != nil || longest <= 0 {
|
|
fail(w, http.StatusBadRequest, "max_seconds must be a positive number of seconds")
|
|
return
|
|
}
|
|
}
|
|
pcm, err := decode(r.Context(), data, longest)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, "%v", err)
|
|
return
|
|
}
|
|
if longest > 0 && float64(len(pcm)) > (longest+overrun/2)*Rate {
|
|
fail(w, http.StatusRequestEntityTooLarge, "the audio runs past %g s; this request takes at most %g s", longest, longest)
|
|
return
|
|
}
|
|
began := time.Now()
|
|
t, err := sp.transcribe(model, lang, pcm)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, "transcription failed: %v", err)
|
|
return
|
|
}
|
|
slog.Info("transcribed", "model", model, "language", lang, "audio_s", t.duration, "wall_s", time.Since(began).Seconds())
|
|
|
|
switch format {
|
|
case "text":
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = io.WriteString(w, t.text)
|
|
return
|
|
case "json":
|
|
// Plain json stays {"text"}, so a client reading the standard shape is
|
|
// unaffected by what verbose_json carries.
|
|
reply(w, http.StatusOK, map[string]any{"text": t.text})
|
|
return
|
|
}
|
|
// verbose_json carries `duration` as OpenAI's does, and the ai plane asks
|
|
// for it because that duration is what meters the call.
|
|
body := map[string]any{"task": "transcribe", "duration": round(t.duration), "text": t.text}
|
|
if want["segment"] {
|
|
body["segments"] = orEmpty(t.segments)
|
|
}
|
|
if want["word"] {
|
|
body["words"] = orEmpty(t.words)
|
|
}
|
|
reply(w, http.StatusOK, body)
|
|
}
|
|
|
|
func orEmpty[T any](s []T) []T {
|
|
if s == nil {
|
|
return []T{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func present[T any](names []string, have map[string]T) []string {
|
|
var out []string
|
|
for _, n := range names {
|
|
if _, ok := have[n]; ok {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// speakRequest is OpenAI's /v1/audio/speech body.
|
|
type speakRequest struct {
|
|
Model string `json:"model"`
|
|
Input string `json:"input"`
|
|
Voice string `json:"voice"`
|
|
ResponseFormat string `json:"response_format"`
|
|
Speed *float64 `json:"speed"`
|
|
}
|
|
|
|
// maxInput is the longest text one request speaks. The ai plane caps callers at
|
|
// 4096 characters; this is the same order, for in-cluster callers.
|
|
const maxInput = 8192
|
|
|
|
func (s *server) speech(w http.ResponseWriter, r *http.Request, sp *speech) {
|
|
var ask speakRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&ask); err != nil {
|
|
fail(w, http.StatusBadRequest, "expected a JSON body: %v", err)
|
|
return
|
|
}
|
|
if ask.Model == "" {
|
|
ask.Model = "kokoro"
|
|
}
|
|
m := sp.mouths[ask.Model]
|
|
if m == nil {
|
|
fail(w, http.StatusNotFound, "unknown model %q; speech models: %s", ask.Model, strings.Join(present(spokenBy, sp.mouths), ", "))
|
|
return
|
|
}
|
|
v, err := voice(ask.Model, ask.Voice)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, "%v", err)
|
|
return
|
|
}
|
|
if ask.ResponseFormat == "" {
|
|
ask.ResponseFormat = "mp3" // OpenAI's default, and the one every browser plays
|
|
}
|
|
// Refuse a format we cannot make rather than answering in a different one.
|
|
if _, ok := formats[ask.ResponseFormat]; !ok {
|
|
names := make([]string, 0, len(formats))
|
|
for n := range formats {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
fail(w, http.StatusBadRequest, "unsupported response_format %q; supported: %s", ask.ResponseFormat, strings.Join(names, ", "))
|
|
return
|
|
}
|
|
speed := 1.0
|
|
if ask.Speed != nil {
|
|
speed = *ask.Speed
|
|
}
|
|
if math.IsNaN(speed) || speed < 0.25 || speed > 4.0 {
|
|
fail(w, http.StatusBadRequest, "speed %v is outside 0.25 to 4.0", speed)
|
|
return
|
|
}
|
|
text := strings.TrimSpace(ask.Input)
|
|
if text == "" {
|
|
fail(w, http.StatusBadRequest, "speech needs an `input`")
|
|
return
|
|
}
|
|
if len([]rune(text)) > maxInput {
|
|
fail(w, http.StatusRequestEntityTooLarge, "`input` exceeds %d characters", maxInput)
|
|
return
|
|
}
|
|
began := time.Now()
|
|
said, err := m.speak(text, v, float32(speed))
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, "synthesis failed: %v", err)
|
|
return
|
|
}
|
|
audio, mime, err := encode(r.Context(), said, Spoken, ask.ResponseFormat)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
slog.Info("spoke", "model", ask.Model, "voice", v, "chars", len(text), "audio_s", float64(len(said))/Spoken, "wall_s", time.Since(began).Seconds())
|
|
w.Header().Set("Content-Type", mime)
|
|
_, _ = w.Write(audio)
|
|
}
|
|
|
|
// openRequest opens a growing transcript.
|
|
type openRequest struct {
|
|
Model string `json:"model"`
|
|
Language string `json:"language"`
|
|
Format string `json:"format"`
|
|
Rate int `json:"rate"`
|
|
Channels int `json:"channels"`
|
|
}
|
|
|
|
func (s *server) transcriptOpen(w http.ResponseWriter, r *http.Request, sp *speech) {
|
|
ask := openRequest{Model: "whisper", Format: "pcm16", Rate: Rate, Channels: 1}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&ask); err != nil && !errors.Is(err, io.EOF) {
|
|
fail(w, http.StatusBadRequest, "expected a JSON body: %v", err)
|
|
return
|
|
}
|
|
if sp.ears[ask.Model] == nil {
|
|
fail(w, http.StatusNotFound, "unknown model %q; transcription models: %s", ask.Model, strings.Join(present(heardBy, sp.ears), ", "))
|
|
return
|
|
}
|
|
// Raw audio carries no header saying what it is, so a mismatch here is not an
|
|
// error downstream: 8 kHz read as 16 kHz transcribes as gibberish. Refuse it
|
|
// by name instead of resampling something we were told is correct.
|
|
if ask.Format != "pcm16" || ask.Rate != Rate || ask.Channels != 1 {
|
|
fail(w, http.StatusBadRequest, "expected pcm16 mono at %d Hz; got %q %dch at %d Hz", Rate, ask.Format, ask.Channels, ask.Rate)
|
|
return
|
|
}
|
|
lang, err := language(ask.Language)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, "%v", err)
|
|
return
|
|
}
|
|
t := newTranscript(sp, ask.Model, lang)
|
|
s.live.begin(t)
|
|
reply(w, http.StatusCreated, map[string]any{
|
|
"id": t.id,
|
|
"at": s.here,
|
|
"model": t.model,
|
|
"format": "pcm16",
|
|
"rate": Rate,
|
|
"channels": 1,
|
|
"chunk_ms": int(math.Round(float64(Chunk) / Second * 1000)),
|
|
"max_bytes": Ceiling,
|
|
"max_seconds": Limit,
|
|
"idle_seconds": Idle,
|
|
"expires_at": time.Now().Add(time.Duration(Idle) * time.Second).Unix(),
|
|
})
|
|
}
|
|
|
|
// transcriptPush takes a chunk and answers with the newest state. `duration` is
|
|
// what this push put in front of a decoder.
|
|
func (s *server) transcriptPush(w http.ResponseWriter, r *http.Request, sp *speech) {
|
|
id := r.PathValue("id")
|
|
t := s.live.find(id)
|
|
if t == nil {
|
|
fail(w, http.StatusNotFound, "no transcript %q", id)
|
|
return
|
|
}
|
|
pcm, err := io.ReadAll(io.LimitReader(r.Body, Ceiling+1))
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, "reading the chunk: %v", err)
|
|
return
|
|
}
|
|
if len(pcm) > Ceiling {
|
|
fail(w, http.StatusRequestEntityTooLarge, "chunk is over %d bytes; limit %d", Ceiling, Ceiling)
|
|
return
|
|
}
|
|
if len(pcm)%Width != 0 {
|
|
fail(w, http.StatusBadRequest, "%d bytes is not whole int16 frames", len(pcm))
|
|
return
|
|
}
|
|
t.mu.Lock()
|
|
full := t.full()
|
|
t.mu.Unlock()
|
|
if full {
|
|
fail(w, http.StatusConflict, "transcript is at its %gs limit; close it", Limit)
|
|
return
|
|
}
|
|
reply(w, http.StatusOK, t.state(t.push(samples(pcm))))
|
|
}
|
|
|
|
// transcriptClose decodes the remainder and commits it. No audio arrives, so
|
|
// nothing is billed here: every second was metered by the push that carried it.
|
|
func (s *server) transcriptClose(w http.ResponseWriter, r *http.Request, sp *speech) {
|
|
id := r.PathValue("id")
|
|
t := s.live.drop(id)
|
|
if t == nil {
|
|
fail(w, http.StatusNotFound, "no transcript %q", id)
|
|
return
|
|
}
|
|
if err := t.close(); err != nil {
|
|
fail(w, http.StatusInternalServerError, "closing transcript: %v", err)
|
|
return
|
|
}
|
|
reply(w, http.StatusOK, t.state(0))
|
|
}
|
|
|
|
func reply(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// fail answers in OpenAI's error shape, which the ai plane reads the message
|
|
// out of (error.message) and hands to the caller.
|
|
func fail(w http.ResponseWriter, code int, format string, args ...any) {
|
|
kind := "invalid_request_error"
|
|
if code >= 500 {
|
|
kind = "server_error"
|
|
}
|
|
reply(w, code, map[string]any{"error": map[string]string{"message": fmt.Sprintf(format, args...), "type": kind}})
|
|
}
|