feat(speech): max_seconds holds a transcription to a length, and the decode stops just past it

Claude-Session: https://claude.ai/code/session_01AGuBEqCjuman4E9FdKEgeV
This commit is contained in:
2026-10-03 01:09:28 -07:00
parent 0a0f42c9ea
commit 39abc2b228
5 changed files with 61 additions and 6 deletions
+5
View File
@@ -47,6 +47,11 @@ against its own CC BY 4.0 LICENSE file.
## Routing
`max_seconds` on a transcription holds the caller to a length: the ffmpeg
decode stops a quarter second past it and audio that runs over is refused 413
before the model runs. The ai plane's public lane (visitors with no account)
sends it; a signed-in caller's request carries none.
`whisper` decodes everything, in the language named or the one it detects.
`parakeet` decodes its 25 languages itself and hands the rest to whisper: a
`language` outside its set goes straight there; with none named, the speech
+14 -3
View File
@@ -18,7 +18,11 @@ import (
// data cannot be demuxed from a stream that cannot seek, and that is the layout
// most m4a files have. The container is sniffed from the bytes, never from the
// filename — the ai plane names every upload audio.webm whatever it is.
func decode(ctx context.Context, data []byte) ([]float32, error) {
//
// longest, when positive, stops the decode just past that many seconds, so a
// caller held to a length pays for no more decoding than the length allows: a
// file far longer than the cap costs a fraction of a second, not its whole run.
func decode(ctx context.Context, data []byte, longest float64) ([]float32, error) {
if len(data) == 0 {
return nil, fmt.Errorf("the file is empty")
}
@@ -35,8 +39,11 @@ func decode(ctx context.Context, data []byte) ([]float32, error) {
return nil, err
}
var out, errs bytes.Buffer
cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
"-i", f.Name(), "-vn", "-ac", "1", "-ar", fmt.Sprint(Rate), "-f", "f32le", "pipe:1")
args := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-i", f.Name(), "-vn", "-ac", "1", "-ar", fmt.Sprint(Rate)}
if longest > 0 {
args = append(args, "-t", fmt.Sprintf("%.3f", longest+overrun))
}
cmd := exec.CommandContext(ctx, "ffmpeg", append(args, "-f", "f32le", "pipe:1")...)
cmd.Stdout, cmd.Stderr = &out, &errs
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errs.String())
@@ -53,6 +60,10 @@ func decode(ctx context.Context, data []byte) ([]float32, error) {
return pcm, nil
}
// overrun is how far past a cap the decode reads, so audio that runs over is told
// from audio that ends exactly at it.
const overrun = 0.25
func lastLine(s string) string {
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
return s[i+1:]
+17 -1
View File
@@ -15,6 +15,7 @@ import (
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -153,11 +154,26 @@ func (s *server) transcriptions(w http.ResponseWriter, r *http.Request, sp *spee
fail(w, http.StatusBadRequest, "reading the upload: %v", err)
return
}
pcm, err := decode(r.Context(), data)
// 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 {
+2 -2
View File
@@ -78,7 +78,7 @@ func speak(t *testing.T, sp *speech, text, voice string) []float32 {
// the same step a browser takes before it pushes raw audio.
func at16k(t *testing.T, s []float32) []float32 {
t.Helper()
pcm, err := decode(context.Background(), wav(s, Spoken))
pcm, err := decode(context.Background(), wav(s, Spoken), 0)
if err != nil {
t.Fatal(err)
}
@@ -174,7 +174,7 @@ func TestLiveEveryBrowserContainerTranscribes(t *testing.T) {
t.Fatalf("%s: %v %s", name, err, b)
}
data, _ := os.ReadFile(out)
pcm, err := decode(context.Background(), data)
pcm, err := decode(context.Background(), data, 0)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
+23
View File
@@ -334,6 +334,29 @@ func TestVerboseJSONCarriesTheDurationSubmitted(t *testing.T) {
}
}
// A CAP ON LENGTH BOUNDS THE WORK. The ai plane's public lane holds a visitor to a
// length with max_seconds; audio past it is refused before any decode of the model,
// and the ffmpeg decode itself stops just past the cap.
func TestMaxSecondsRefusesAudioPastIt(t *testing.T) {
ffmpegOrSkip(t)
r := newRig(t, roomVAD{}, "en")
resp, b := r.transcribe(wavOf(recording), map[string][]string{"model": {"whisper"}, "max_seconds": {"5"}})
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("7 s under a 5 s cap answered %d %s, want 413", resp.StatusCode, b)
}
resp, b = r.transcribe(wavOf(recording), map[string][]string{"model": {"whisper"}, "max_seconds": {"7"}, "response_format": {"verbose_json"}})
if resp.StatusCode != 200 {
t.Fatalf("7 s under a 7 s cap answered %d %s, want 200", resp.StatusCode, b)
}
if got := decodeJSON(t, b)["duration"].(float64); math.Abs(got-7) > 0.01 {
t.Fatalf("duration %v, want 7", got)
}
resp, _ = r.transcribe(wavOf(recording), map[string][]string{"model": {"whisper"}, "max_seconds": {"-1"}})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("a negative cap answered %d, want 400", resp.StatusCode)
}
}
func TestPlainJSONIsJustTheText(t *testing.T) {
ffmpegOrSkip(t)
r := newRig(t, roomVAD{}, "en")