mirror of
https://github.com/hanzoai/speech.git
synced 2026-10-04 07:37:19 +00:00
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:
@@ -47,6 +47,11 @@ against its own CC BY 4.0 LICENSE file.
|
|||||||
|
|
||||||
## Routing
|
## 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.
|
`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
|
`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
|
`language` outside its set goes straight there; with none named, the speech
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import (
|
|||||||
// data cannot be demuxed from a stream that cannot seek, and that is the layout
|
// 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
|
// 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.
|
// 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 {
|
if len(data) == 0 {
|
||||||
return nil, fmt.Errorf("the file is empty")
|
return nil, fmt.Errorf("the file is empty")
|
||||||
}
|
}
|
||||||
@@ -35,8 +39,11 @@ func decode(ctx context.Context, data []byte) ([]float32, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var out, errs bytes.Buffer
|
var out, errs bytes.Buffer
|
||||||
cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
|
args := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-i", f.Name(), "-vn", "-ac", "1", "-ar", fmt.Sprint(Rate)}
|
||||||
"-i", f.Name(), "-vn", "-ac", "1", "-ar", fmt.Sprint(Rate), "-f", "f32le", "pipe:1")
|
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
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
msg := strings.TrimSpace(errs.String())
|
msg := strings.TrimSpace(errs.String())
|
||||||
@@ -53,6 +60,10 @@ func decode(ctx context.Context, data []byte) ([]float32, error) {
|
|||||||
return pcm, nil
|
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 {
|
func lastLine(s string) string {
|
||||||
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
|
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
|
||||||
return s[i+1:]
|
return s[i+1:]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"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)
|
fail(w, http.StatusBadRequest, "reading the upload: %v", err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
fail(w, http.StatusBadRequest, "%v", err)
|
fail(w, http.StatusBadRequest, "%v", err)
|
||||||
return
|
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()
|
began := time.Now()
|
||||||
t, err := sp.transcribe(model, lang, pcm)
|
t, err := sp.transcribe(model, lang, pcm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+2
-2
@@ -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.
|
// the same step a browser takes before it pushes raw audio.
|
||||||
func at16k(t *testing.T, s []float32) []float32 {
|
func at16k(t *testing.T, s []float32) []float32 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
pcm, err := decode(context.Background(), wav(s, Spoken))
|
pcm, err := decode(context.Background(), wav(s, Spoken), 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ func TestLiveEveryBrowserContainerTranscribes(t *testing.T) {
|
|||||||
t.Fatalf("%s: %v %s", name, err, b)
|
t.Fatalf("%s: %v %s", name, err, b)
|
||||||
}
|
}
|
||||||
data, _ := os.ReadFile(out)
|
data, _ := os.ReadFile(out)
|
||||||
pcm, err := decode(context.Background(), data)
|
pcm, err := decode(context.Background(), data, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s: %v", name, err)
|
t.Fatalf("%s: %v", name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestPlainJSONIsJustTheText(t *testing.T) {
|
||||||
ffmpegOrSkip(t)
|
ffmpegOrSkip(t)
|
||||||
r := newRig(t, roomVAD{}, "en")
|
r := newRig(t, roomVAD{}, "en")
|
||||||
|
|||||||
Reference in New Issue
Block a user