Files
Privatae 8822d5df85
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
bastion 0.55.3
2026-10-25 23:16:45 +00:00

1409 lines
61 KiB
Go

package main
// contextstt.go — SPEECH TO TEXT, on the user's own machine.
//
// A clip's audio is more searchable than its frames. Someone hunting "the demo
// where we talked about pricing" wants the transcript, not a keyframe — which is
// why this is the highest-value job in the lane. What comes back is
// TEXT, so it feeds the embedding lane that already works, with timestamps so a
// result can be jumped to.
//
// ── THE ENGINE, and why it is whisper.cpp's whisper-cli ─────────────────────
//
// Chosen by a full CPU-only benchmark of whisper.cpp, faster-whisper,
// openai-whisper and parakeet.cpp against LibriSpeech test-clean and test-other,
// scored with Whisper's own EnglishTextNormalizer plus jiwer (81 runs).
//
// The sentence that decided it is an ARCHITECTURE fit rather than an accuracy
// score: whisper-cli is the only candidate this box's existing detection can
// find at all. prepTool{Bin, Args} is executed through exec.CommandContext,
// which resolves a bare name on PATH (harness.go:590-600). faster-whisper
// installs ZERO console scripts and PEP 668 refuses `pip3 install
// faster-whisper` into the system Python on Ubuntu 26.04, so its only home is a
// venv at a path no PATH probe can discover. `whisper-cli` is the binary name on
// all three channels a user would install from — the Ubuntu 26.04 whisper.cpp
// package, Homebrew's whisper.cpp, and the whisper-bin-x64.zip release asset on
// Windows.
//
// ── MEASURED, AND IT SHAPED EVERY NUMBER BELOW ──────────────────────────────
//
// On this box (AMD EPYC 4345P, 8 physical cores, no GPU), whisper.cpp, 5 minutes
// of real audio, greedy decoding (-bs 1 -bo 1, the setting the rig used so every
// engine was compared at the same beam):
//
// model WER clean WER hard realtime x8 peak RSS
// tiny.en 5.32% 14.85% 38-91x 264 MB
// base.en 2.41% 12.55% 48-50x 354 MB
// small.en 1.90% 15.11% 15x 750 MB
// large-v3-turbo 1.90% 3.84% 3.9-4.6x 1857 MB
//
// RSS is effectively FLAT as a recording gets longer — 281 MB at one minute to
// 492 MB at thirty — which is the property that lets this survive a one-hour
// meeting on a laptop.
//
// ── THE KNOWN WEAKNESS, designed around rather than hidden ──────────────────
//
// On hard audio at the small model sizes whisper.cpp's errors are DELETIONS. At
// small.en it returned 740 words against a 781-word reference where
// faster-whisper returned 787. For a search feature a deleted word is not a
// wrong result, it is absent content the user will never find. The models
// converge at large-v3-turbo (3.84% against faster-whisper's 3.71%, one deletion
// each).
//
// So WHICH MODEL RAN MATTERS MORE HERE THAN FOR ANY OTHER JOB IN THIS LANE, and
// two things follow from that, both of them in this file:
//
// 1. The model is part of the ADVERTISEMENT (prepToolReport.Models) and part of
// the ANSWER (ctxExtractResp.Model). The host writes it onto every row, so
// the record never claims an accuracy the run did not have.
// 2. Decoding runs at whisper-cli's OWN DEFAULT beam, not the rig's -bs 1. The
// rig forced greedy so four engines could be compared at one setting,
// and that setting is where the deletions are
// worst. MEASURED both ways on the same audio: at the default beam base.en
// scores 9.60% on test-other against 12.55% greedy, and returns 789 words
// rather than 784; small.en scores 10.12% against 15.11% and returns 752
// rather than 740. It costs 2.7x the CPU on base.en (25.4 against 9.4
// CPU-seconds per audio-minute) and is still 16x realtime on eight cores.
// The exception is large-v3-turbo, which is BETTER greedy on hard audio
// (3.84% against 4.87%) — but a box holding large-v3-turbo is not the box
// whose deletions lose a user's words, so one setting is kept and it is the
// one that protects the small models.
//
// ── TWO JOBS, BECAUSE ffmpeg IS NOT ALWAYS THERE ────────────────────────────
//
// MEASURED against the real binary: whisper-cli's own help prints "supported
// audio formats: flac, mp3, ogg, wav", and it FAILS on mp4 and m4a. An mp4's
// audio track therefore needs an ffmpeg demux and a wav's does not.
//
// One job with ffmpeg in Needs would refuse a box that has whisper-cli and no
// ffmpeg — a box that can transcribe every voice memo the user owns. One job
// without it would advertise mp4 support on a box that would then fail. So
// there are two, exactly as pdf-text and pdf-ocr are two:
//
// transcribe whisper-cli alone. wav, mp3, flac, ogg.
// av-transcribe whisper-cli + ffmpeg. Anything in a container: mp4, m4a,
// mov, mkv, webm — a video's audio track included.
//
// ffprobe is deliberately NOT required. The only thing this job wants from it is
// the recording's duration, and the demux produces a wav whose own header states
// that exactly — so asking for a second binary would block boxes that have
// everything this job actually uses.
//
// A box with the engine and no ffmpeg reports av-transcribe as BLOCKED, naming
// ffmpeg, through the machinery contextprep.go already has for exactly this.
// So: transcribing an mp4 DOES require the user to have ffmpeg, it is said out
// loud, and it is never assumed.
//
// ── THE ENGINE EXITS 0 ON A FILE IT COULD NOT READ ──────────────────────────
//
// MEASURED, and it is the trap in this whole job: `whisper-cli -f clip.mp4`
// prints "error: failed to read audio file" TO STDERR AND EXITS 0. A runner that
// took the exit code as truth would hand back empty text from a successful job,
// and the host would tell the user their recording is silence. That is precisely
// the quiet-vs-blind failure this lane exists to avoid, so the decode failure is
// detected on the stderr text and reported as what it is. (A missing model file
// does exit non-zero — rc=3 — so only the decode needs this.)
//
// ── AND THE THIRD ANSWER ────────────────────────────────────────────────────
//
// "this box has no engine", "it has one and no model", and "that recording is
// silence" are three different things to tell a user, fixed by three different
// actions. The first is contextprep.go's quiet case. The second is a Prove
// refusal naming the directories that were searched — and it is the NORMAL state
// after installing the engine, because the Ubuntu whisper.cpp package ships no
// model at all (verified by unpacking the .deb: usr/bin/whisper-* and two sample
// wavs, no ggml-*.bin anywhere). The third is this file's Blank flag, which
// comes from the engine's own [BLANK_AUDIO] token.
//
// THE THIRD ONE IS ONLY AS GOOD AS THE MODEL, and that is stated rather than
// glossed. MEASURED on the same one second of digital silence: tiny.en returns
// [BLANK_AUDIO]; base.en returns the word "You". Whisper invents short filler on
// silence, it is a known property of the model rather than of this binary, and
// no whisper-cli flag removes it without costing more than it saves —
// `--suppress-nst` was tried and returns NOTHING AT ALL for silence, which makes
// "that recording is silence" indistinguishable from "the engine produced no
// output" and collapses two of the three answers into one.
//
// The real fix is voice activity detection: whisper.cpp ships a --vad path and a
// Silero VAD model, which would cut the silent stretches before the decoder ever
// sees them. It is a SECOND model download and therefore a second setup step, so
// it is named here as the follow-on rather than half-built. What this build does
// instead is never ASK the engine for audio that is not there — see sttArgv's
// note on -d, which is where the damaging version of this appeared.
import (
"bufio"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ── the job names ───────────────────────────────────────────────────────────
// sttTool transcribes what the engine reads by itself.
const sttTool = "transcribe"
// sttAVTool transcribes an audio track that is inside a container, which means
// ffmpeg first. Named for what it does rather than for the file types it covers,
// because the list of containers ffmpeg opens is ffmpeg's business and not this
// table's.
const sttAVTool = "av-transcribe"
const sttBin = "whisper-cli"
// ── how much of one recording a single job covers ───────────────────────────
//
// ONE HOUR, and the number is a policy choice with precedent behind it rather
// than a limit anyone hit.
//
// The precedent, read off what the transcription services publish as the line
// between "one request, answer now" and "queued job", which is the same line
// this lane draws: OpenAI's audio transcription endpoint caps an upload at 25 MB
// and documents splitting longer recordings; Google Cloud Speech-to-Text caps
// its synchronous recognize at 1 minute and its long-running batch at 480;
// AWS Transcribe's streaming session is 4 hours. Everyone puts the synchronous
// door well below the batch one.
//
// What it costs here, from the measurement: an hour of audio at base.en is about
// 9 CPU-minutes, which is 75 seconds of wall clock on eight cores and about 4
// minutes on one. At large-v3-turbo the same hour is 15 minutes of wall clock on
// eight cores and slower than realtime on one — which is why the job does not
// bound itself by this constant alone. It bounds itself by the clock the host
// actually granted, measures how far it got, and SAYS how much of the recording
// is in the corpus and how much is not. A partial transcript that says it is
// partial beats both a refusal and a silent half-answer; it is the same rule
// pdf-ocr's pages_more follows.
//
// It is a ceiling on what the PLATFORM will ask this machine for in one job,
// never a throttle on the user: nothing the user does on their own machine goes
// through it, and a longer recording is transcribed by running the job again on
// the rest.
const sttAudioCapSeconds = 3600
// sttCapMS is that cap as whisper-cli's own -d flag wants it (milliseconds,
// verified against the real binary's help: "-d N, --duration N duration of audio
// to process in milliseconds").
const sttCapMS = sttAudioCapSeconds * 1000
// ── the ceiling this box DECLARES for the job ───────────────────────────────
//
// It is MEASURED ON THIS MACHINE, not extrapolated from the rig. The benchmark
// was run on an 8-core EPYC with no GPU; a user's laptop is a different machine
// and a published realtime factor from one is not a fact about the other. So the
// probe does the job on a known input, times it, and the ceiling is derived from
// what THIS box actually did.
//
// whisper processes audio in 30-second windows, so the cost of a recording is
// roughly one model load plus one window's work per 30 seconds. The probe's
// input is a one-second wav, which costs exactly load + one window — so
// `sttAudioCapSeconds/30` probe-times is a conservative estimate of the cap's
// worth of audio (it pays the load again for every window). Checked against the
// rig: base.en's 5-minute run took 5.93 s of wall clock on 8 cores, 0.59 s per
// window, so a probe of about 1 s predicts 120 s for an hour where the true cost
// is ~75 s. Over by 1.6x, which is the safe direction for a deadline.
//
// The host clamps whatever is declared to its own ceiling on declared
// ceilings (1800 s) and grants the clamped number back
// as extract_budget, which ctxExtractBudget clamps this box down to. So a slow
// machine holding large-v3-turbo declares more than the host will wait for,
// takes the host's number, and reports an honest partial — rather than working
// past the moment the host gave up.
const sttMinCeiling = 60 * time.Second
// sttMaxCeiling is what this box will declare at most. It matches the host's own
// guard so the two never disagree about a number the box could have chosen not
// to send; a machine slower than this still works, it just returns the first
// part of the recording and says so.
const sttMaxCeiling = 30 * time.Minute
// sttUnprobedCeiling is what ctxExtractTimeoutFor answers before any probe has
// run. It can only be read in that window by a job arriving before the first
// heartbeat, which cannot happen through the advertised path, and the value is
// deliberately the floor rather than the ceiling: under-declaring costs a
// partial transcript, over-declaring parks a worker.
const sttUnprobedCeiling = sttMinCeiling
// sttProbeBudget bounds the probe itself. large-v3-turbo is 1.6 GB and loading
// it off a cold cache is not instant; a box slower than this is a box that
// cannot usefully transcribe anything and is better off reported as blocked.
const sttProbeBudget = 120 * time.Second
// sttDemuxShare is how much of the job's clock the ffmpeg demux may take before
// the transcription has had a chance to start. A demux is IO and a stream copy,
// orders of magnitude cheaper than the decode that follows, so a demux eating
// most of the budget means something is wrong with the file rather than that it
// needs more time.
const sttDemuxShare = 4
// sttMaxSegments bounds the reply's segment list independently of the text cap.
// An hour of speech is roughly 600 segments at whisper's own segmentation; this
// is 20x that, so it binds only on a pathological stream and never on a meeting.
const sttMaxSegments = 12000
// ── finding a model ─────────────────────────────────────────────────────────
// sttModelEnv names extra directories to look in, ahead of everything else.
// It exists because THERE IS NO CANONICAL LOCATION and this file must not
// pretend there is: whisper.cpp's own download-ggml-model.sh writes to the
// directory the script sits in, or to $PWD when that directory is a /bin
// (models/download-ggml-model.sh:27-33), and the Ubuntu package ships no model
// at all. So the box searches the places a person plausibly put one, says which
// places it searched when it finds none, and lets the user name their own.
const sttModelEnv = "PRIVATAE_WHISPER_MODELS"
// sttModelDirs is where a ggml model is looked for, in order.
//
// The first entry is this bastion's own directory, which is the answer for a
// user who has no opinion: it is per-user and already created 0700. It is NOT
// where the model-swap lane puts things — modelswap.go manages OLLAMA models
// through ollama's own API and never touches a file path, so the two do not
// share a directory and nothing here reads its ledger. The rest are the
// conventional shares.
func sttModelDirs() []string {
dirs := []string{}
for _, d := range filepath.SplitList(os.Getenv(sttModelEnv)) {
if strings.TrimSpace(d) != "" {
dirs = append(dirs, d)
}
}
if cfg, err := configDirPath(); err == nil {
dirs = append(dirs, filepath.Join(cfg, "whisper-models"))
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
dirs = append(dirs,
filepath.Join(home, ".local", "share", "whisper.cpp", "models"),
filepath.Join(home, ".cache", "whisper.cpp"),
filepath.Join(home, "whisper.cpp", "models"))
}
if runtime.GOOS != "windows" {
dirs = append(dirs,
"/usr/local/share/whisper.cpp/models",
"/usr/share/whisper.cpp/models",
"/opt/homebrew/share/whisper.cpp/models")
}
return dirs
}
// sttModel is one model file this box can load.
type sttModel struct {
// Name is the model as everyone writes it — "base.en", "large-v3-turbo" —
// which is the ggml filename with its prefix and suffix removed. It is what
// the host advertises, what a host may ASK for by name, and what the reply
// records as having run.
Name string
Path string
Bytes int64
}
// sttRank orders models best-first when the host names none.
//
// BY SIZE CLASS, read off the name, and the ordering source is stated because it
// is not what this rig measured. On test-other whisper.cpp put base.en AHEAD of
// small.en at both beams (9.60% against 10.12% at the default beam, 12.55%
// against 15.11% greedy), which inverts the conventional order. That result is
// one five-minute sample and it is not enough to build an unusual default on, so
// the conventional order stands, the measurement is written down here, and the
// host can name a model when it wants a different one.
//
// A quantized variant ranks with its parent: "base.en-q5_1" is a base.en.
func sttRank(name string) int {
n := strings.ToLower(name)
switch {
case strings.HasPrefix(n, "large"):
return 5
case strings.HasPrefix(n, "medium"):
return 4
case strings.HasPrefix(n, "small"):
return 3
case strings.HasPrefix(n, "base"):
return 2
case strings.HasPrefix(n, "tiny"):
return 1
}
// Something this build has not heard of. It is not ranked above a model that
// was measured; it is ranked at the bottom and is still usable by name.
return 0
}
// sttFindModels lists every ggml model on this machine, best-first.
//
// Only files named ggml-*.bin are considered, which is whisper.cpp's own naming
// and what its downloader writes. A non-whisper .bin in the same directory —
// a parakeet or a llama — would not load, and the probe would then advertise a
// model that fails on the first real job.
func sttFindModels() []sttModel {
seen := map[string]bool{}
out := []sttModel{}
for _, dir := range sttModelDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "ggml-") || !strings.HasSuffix(name, ".bin") {
continue
}
model := strings.TrimSuffix(strings.TrimPrefix(name, "ggml-"), ".bin")
// A model this build cannot name is a model the host cannot ask for.
if model == "" || seen[model] {
continue
}
info, err := e.Info()
if err != nil {
continue
}
// whisper.cpp's own test fixtures live beside real models in a source
// checkout and are not models. They are excluded by name rather than
// by size, because a legitimate tiny quantization is also small.
if strings.HasPrefix(model, "for-tests-") {
continue
}
seen[model] = true
out = append(out, sttModel{Name: model, Path: filepath.Join(dir, name),
Bytes: info.Size()})
}
}
sort.Slice(out, func(i, j int) bool {
ri, rj := sttRank(out[i].Name), sttRank(out[j].Name)
if ri != rj {
return ri > rj
}
// Within a class the larger file is the less quantized one.
if out[i].Bytes != out[j].Bytes {
return out[i].Bytes > out[j].Bytes
}
return out[i].Name < out[j].Name
})
return out
}
// sttPickModel resolves what the host asked for, or the best this box has.
//
// A host naming a model this machine does not hold is REFUSED with the list,
// never quietly served a different one: the whole point of carrying the model
// through the advertisement and the reply is that nobody has to guess which one
// read their recording.
func sttPickModel(want string) (sttModel, error) {
models := sttFindModels()
if len(models) == 0 {
return sttModel{}, fmt.Errorf("%s", sttNoModelReason())
}
want = strings.TrimSpace(want)
if want == "" {
return models[0], nil
}
for _, m := range models {
if strings.EqualFold(m.Name, want) {
return m, nil
}
}
names := make([]string, 0, len(models))
for _, m := range models {
names = append(names, m.Name)
}
return sttModel{}, fmt.Errorf(
"this machine has no speech model called %q — it has %s. The model that reads "+
"a recording decides how accurate the transcript is, so this job does not "+
"quietly substitute a different one", want, strings.Join(names, ", "))
}
// sttNoModelReason is the ENGINE-IS-HERE-AND-HAS-NO-MODEL sentence, and it names
// the directories that were actually searched.
//
// This is the normal state after installing the engine, not an edge case:
// verified by unpacking the Ubuntu 26.04 whisper.cpp package, which ships
// usr/bin/whisper-cli and two sample wavs and no model file of any kind. A user
// in this state who was told "no machine of yours can transcribe" would go and
// install software that is already on their disk.
func sttNoModelReason() string {
dirs := sttModelDirs()
return sttBin + " is installed on this machine and there is no speech model for it " +
"to load — the engine and the model are separate downloads and the package " +
"ships no model. Put a ggml model (e.g. ggml-base.en.bin, 148 MB, from " +
"whisper.cpp's models/download-ggml-model.sh) in one of: " +
strings.Join(dirs, ", ") + " — or set " + sttModelEnv + " to the directory " +
"holding the ones you already have"
}
// ── the probe: it LOADS a model and transcribes, it does not ask a version ──
// sttProbe is what the last successful probe learned, kept for the process
// lifetime and keyed by the model file's identity.
//
// It is NOT cleared with prepCache. prepCache expires every 10 minutes so that
// installing a tool shows up without a restart, and re-loading a 1.6 GB model
// six times an hour to re-learn a number that cannot have changed is a cost paid
// on the USER'S machine for nothing. Keyed by path+size+modtime, so replacing a
// model file does force a fresh measurement.
type sttProbeResult struct {
key string
window time.Duration
}
var (
sttProbeMu sync.Mutex
sttProbeSeen sttProbeResult
)
func sttModelKey(m sttModel) string {
st, err := os.Stat(m.Path)
if err != nil {
return m.Path
}
return fmt.Sprintf("%s|%d|%d", m.Path, st.Size(), st.ModTime().UnixNano())
}
// sttProve is the LAST word on whether this box can transcribe, and it proves it
// by transcribing.
//
// What it proves: the engine runs, a model file on this machine actually LOADS
// (a truncated 1.6 GB download is a real failure mode and exits non-zero), the
// audio path decodes, and the output is in the timestamped form this file parses
// — proved by round-tripping the engine's own [BLANK_AUDIO] token through the
// parser. What it does NOT prove is that the model transcribes WORDS correctly;
// there is no way to synthesise speech here to check that, and claiming
// otherwise in a probe would be the kind of proxy this lane keeps getting
// burned by. It is stated rather than glossed.
//
// It also TIMES the run, which is where the declared ceiling comes from.
func sttProve() (bool, string) {
model, err := sttPickModel("")
if err != nil {
return false, err.Error()
}
key := sttModelKey(model)
sttProbeMu.Lock()
cached := sttProbeSeen.key == key && sttProbeSeen.window > 0
sttProbeMu.Unlock()
if cached {
return true, ""
}
dir, err := os.MkdirTemp("", "sttprobe-")
if err != nil {
return false, sttBin + " is installed but there was no scratch space to prove it " +
"works: " + err.Error()
}
defer os.RemoveAll(dir)
wav := filepath.Join(dir, "probe.wav")
if err := os.WriteFile(wav, sttProbeWAV(), 0o600); err != nil {
return false, sttBin + " is installed but the probe audio could not be written: " +
err.Error()
}
start := time.Now()
out, errOut, rc, err := sttRun(sttProbeBudget, sttArgv(model.Path, wav, 0, sttDefaultLang)...)
took := time.Since(start)
if err != nil || rc != 0 {
return false, fmt.Sprintf("%s is installed and could not load the model %s — "+
"%s. A model file that will not load is usually an interrupted download; "+
"fetch it again", sttBin, model.Name, sttFirstProblem(errOut, out, err))
}
// THE EXIT CODE IS NOT THE ANSWER HERE. whisper-cli exits 0 on audio it could
// not read (measured, see this file's header), so the probe has to look.
if why := sttDecodeFailure(errOut); why != "" {
return false, fmt.Sprintf("%s loaded %s and could not read a one-second test "+
"recording — %s", sttBin, model.Name, why)
}
segs, _, _ := sttParse(out)
if len(segs) == 0 {
return false, fmt.Sprintf("%s loaded %s and returned nothing in the timestamped "+
"form this lane reads — the engine on this machine is not producing "+
"transcripts this build can parse", sttBin, model.Name)
}
sttProbeMu.Lock()
sttProbeSeen = sttProbeResult{key: key, window: took}
sttProbeMu.Unlock()
return true, ""
}
// sttModelsReport is what the heartbeat carries: every model this box holds,
// best-first, so the host can name one and a user can see which ones are there.
func sttModelsReport() []string {
models := sttFindModels()
out := make([]string, 0, len(models))
for _, m := range models {
out = append(out, m.Name)
}
return out
}
// sttCeiling is the seconds this box declares for a transcription job, derived
// from what the probe measured on THIS machine. See sttMinCeiling's note for the
// derivation and the check against the rig.
func sttCeiling() time.Duration {
sttProbeMu.Lock()
window := sttProbeSeen.window
sttProbeMu.Unlock()
if window <= 0 {
return sttUnprobedCeiling
}
d := time.Duration(sttAudioCapSeconds/30) * window
if d < sttMinCeiling {
return sttMinCeiling
}
if d > sttMaxCeiling {
return sttMaxCeiling
}
// Rounded to whole seconds so the number this box DECLARES and the number the
// host GRANTS back survive the float round trip exactly — the gate that
// asserts declared == enforced compares them for equality.
return d.Round(time.Second)
}
// ── running it ──────────────────────────────────────────────────────────────
// sttArgv is how whisper-cli is asked for a timestamped transcript on stdout.
//
// Every flag verified against the real binary's own help and a real run, not
// copied from documentation:
//
// -m <model> the model file. Not optional: the built-in default is a
// relative path (models/ggml-base.en.bin) that resolves against
// the working directory and would silently pick up a stranger's
// file in a source checkout.
// -f <in> the input. whisper-cli also takes bare positional files; -f is
// used so an input whose name begins with a dash is an input.
// -np suppress the engine's own progress chatter. It does NOT
// suppress the read_audio_data lines, which go to stderr and are
// where the decode failure is found.
// -d <ms> how much of the recording to process. PASSED ONLY WHEN THE
// RECORDING IS KNOWN TO BE LONGER THAN THE CAP, and this is not a
// tidiness point — MEASURED, `-d` is a duration whisper-cli
// processes whether or not the file HAS it. jfk.wav is eleven
// seconds and takes 0.36 s to transcribe; the same file with
// `-d 3600000` was still running at 30 s, inventing "You" and
// "(silence)" over the padding it had made up. So a cap applied
// blind turns every short recording into an hour of work on the
// user's own machine and fills their corpus with words nobody
// said. A recording whose length this box could not read is
// bounded by the CLOCK instead, which cannot overrun, and reports
// an honest partial.
//
// There is deliberately NO -bs/-bo: whisper-cli's own defaults are kept, for the
// reason measured in this file's header. And no -t: the thread count is the
// engine's default of 4 rather than this machine's core count, because this runs
// on the user's own laptop while they are using it and taking every core for a
// background job is not this lane's call to make.
//
// Timestamps are the DEFAULT output and -nt would remove them. They are the
// point: media_offset_ms is what turns a transcript into a moment a user can
// jump to.
//
// -l <lang> the spoken language. Passed only when the host named one and
// the model can honour it — see sttLang, which is where the whole
// language decision lives.
func sttArgv(model, in string, capMS int, lang string) []string {
args := []string{"-m", model, "-f", in, "-np"}
if capMS > 0 {
args = append(args, "-d", strconv.Itoa(capMS))
}
if lang != "" && lang != sttDefaultLang {
args = append(args, "-l", lang)
}
return args
}
// ── THE LANGUAGE, and why the box records one at all ────────────────────────
//
// Until this, nothing recorded it. A recording was transcribed under an English
// prior and the row said nothing about that, so a German voice memo that came
// back as English-shaped nonsense had no first thing to check. The reply already
// carries a `lang` field — the host writes it onto the row as ocr_lang and the
// OCR lane fills it (contextextract.go) — and the transcript lane simply left it
// empty. Filling it is the whole of the minimum fix and it costs nothing.
//
// WHAT THE DEFAULT IS, from the binary's own help rather than from a guess:
// `-l LANG, --language LANG [en] spoken language ('auto' for auto-detect)`. So
// when no -l is passed the engine decodes as English. That is what this build
// has always done and it is what it keeps doing — because the only thing that
// could justify changing it is a measurement, and the benchmark behind this file
// covered the English-only models plus large-v3-turbo ON ENGLISH AUDIO. Nobody
// has measured the multilingual models on non-English speech, and switching
// every multilingual box to `auto` on that basis would be choosing new behaviour
// from an unmeasured proxy.
//
// SO THE HOST NAMES IT, exactly the way the host names a model. Same shape, same
// file, same promise: the box does what it was asked or refuses and says why, and
// it never quietly does something else. `auto` is a language a host may name.
const sttDefaultLang = "en"
// sttModelIsEnglishOnly reports whether a model can only ever decode English.
//
// whisper.cpp's own naming: ggml-base.en.bin is the English-only base, ggml-base
// .bin is the multilingual one, and a quantization keeps the suffix in place
// ("base.en-q5_1"). This is a NAME and a name is a proxy, so it is used only to
// refuse EARLY and cheaply — the authority is the engine's own sentence, which
// sttEnglishOnlyRefusal checks on stderr after the fact. Both, because the cheap
// one saves a wasted job and the real one cannot be fooled.
func sttModelIsEnglishOnly(name string) bool {
n := strings.ToLower(name)
return n == "en" || strings.HasSuffix(n, ".en") ||
strings.Contains(n, ".en-") || strings.Contains(n, ".en.")
}
// sttLang resolves the language this job will run in, or refuses.
//
// THE REFUSAL IS THE LOAD-BEARING HALF. MEASURED against the real binary: an
// English-only model handed `-l de` prints "main: WARNING: model is not
// multilingual, ignoring language and translation options" to stderr, transcribes
// the audio as English, and EXITS 0. A box that passed the flag and reported
// success would put an English transcript on a row stamped German — the same
// silent-substitution failure sttPickModel exists to prevent, one field over. So
// a language request an English-only model cannot honour is refused before
// anything runs.
func sttLang(model sttModel, want string) (string, error) {
want = strings.TrimSpace(strings.ToLower(want))
if want == "" {
return sttDefaultLang, nil
}
if sttModelIsEnglishOnly(model.Name) && want != sttDefaultLang {
return "", fmt.Errorf(
"this machine's speech model %s reads English only, so it cannot "+
"transcribe %s — the engine would ignore the request and hand back an "+
"English transcript with a %s label on it. A multilingual model (one "+
"whose name has no .en in it, e.g. large-v3-turbo) is what reads "+
"another language", model.Name, want, want)
}
return want, nil
}
// sttEnglishOnlyRefusal is the BACKSTOP for the above: the engine's own sentence,
// taken as the authority over this build's reading of a filename. It fires only
// when a language was actually asked for, so a normal English job never sees it.
func sttEnglishOnlyRefusal(stderr string) bool {
return strings.Contains(strings.ToLower(stderr), "model is not multilingual")
}
// sttRun executes the engine with BOTH streams kept and bounded.
//
// runExtract merges the streams on failure and drops stderr on success, which is
// right for a tool whose complaint only matters when it exits non-zero. This
// engine's most important complaint arrives on stderr WITH a zero exit, so the
// streams stay separate here.
//
// Returns (stdout, stderr, exit code, error). A non-zero exit is reported in the
// code rather than only in err, so a caller can tell "the model would not load"
// (rc 3) from "the process was killed".
func sttRun(timeout time.Duration, args ...string) (string, string, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, sttBin, args...)
hideConsole(cmd) // console_windows.go
out := &capWriter{max: ctxExtractMaxText + 1}
errb := &capWriter{max: 64 << 10}
cmd.Stdout, cmd.Stderr = out, errb
err := cmd.Run()
code := 0
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
}
if ctx.Err() != nil {
// A DEADLINE IS NOT A FAILURE HERE. The engine prints each segment as it
// finishes it, so the text captured before the kill is real transcript of
// the user's real recording, and throwing it away to return a clean error
// helps nobody. The caller reads the partial and flags it.
return out.buf.String(), errb.buf.String(), code, errDeadline
}
return out.buf.String(), errb.buf.String(), code, err
}
// errDeadline marks the clock running out, distinct from every other failure.
var errDeadline = fmt.Errorf("the clock ran out")
// sttReadableDuration renders an elapsed time at a precision that can express
// it. Round(time.Second) was the old spelling and it turns every sub-second run
// into the literal string "0s"; a sentence claiming the engine was "still
// working after 0s" is no more believable than one claiming a negative. On a
// fast machine with a small model it is the COMMON case rather than an edge —
// MEASURED, this box's base.en is killed during model load inside 400 ms. Whole
// seconds above a second, milliseconds below it.
func sttReadableDuration(d time.Duration) time.Duration {
if d < time.Second {
return d.Round(time.Millisecond)
}
return d.Round(time.Second)
}
// sttDecodeFailure reads the engine's stderr for the case it does not put in its
// exit code. Returns "" when the audio was read.
//
// Matched on the engine's own sentences, verified by running it against an mp4
// and an m4a: "read_audio_data: failed to read audio data" followed by
// "error: failed to read audio file '<path>'". The path is NOT carried into the
// reason — it is a scratch name inside the pairing's root and means nothing to
// the user.
func sttDecodeFailure(stderr string) string {
low := strings.ToLower(stderr)
if strings.Contains(low, "failed to read audio") {
return "the engine could not decode this recording. whisper.cpp reads wav, " +
"mp3, flac and ogg by itself; anything in a container (mp4, m4a, mov, " +
"mkv, webm) has to have its audio track unpacked with ffmpeg first, " +
"which is the av-transcribe job"
}
return ""
}
// sttFirstProblem picks the most useful line out of a failed run.
func sttFirstProblem(stderr, stdout string, err error) string {
for _, s := range []string{stderr, stdout} {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(line), "error") {
return firstLine(line)
}
}
}
if s := strings.TrimSpace(stderr); s != "" {
return firstLine(s)
}
if err != nil {
return err.Error()
}
return "it gave no reason"
}
// ── reading what it printed ─────────────────────────────────────────────────
// sttBlankToken is what the engine prints for a stretch with no speech in it.
// MEASURED: five seconds of digital silence comes back as one segment reading
// "[BLANK_AUDIO]" with rc=0. It is the difference between "that recording is
// silence" and "this box could not read it", which are two of the three answers
// this lane owes a user.
const sttBlankToken = "[BLANK_AUDIO]"
// sttBlankMarks is every way the engine says "nothing was said here". Both were
// OBSERVED coming out of the real binary on real digital silence — [BLANK_AUDIO]
// on a clean run and "(silence)" on the over-run that the -d bug produced — and
// both are the engine describing the audio rather than transcribing it. They are
// dropped from the text for the same reason: a corpus that contains the word
// "silence" because a microphone was off is a corpus with a false hit in it.
//
// This list cannot be complete, and saying so matters more than pretending
// otherwise: Whisper is known to invent short filler on silence ("You", "Thank
// you") that is indistinguishable from speech at this layer. What protects the
// user from that is not a longer list, it is not asking the engine for audio
// that is not there — see sttArgv's note on -d.
var sttBlankMarks = []string{sttBlankToken, "(silence)", "[silence]"}
// sttIsBlankMark reports whether a segment is the engine describing silence
// rather than transcribing speech.
func sttIsBlankMark(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
for _, m := range sttBlankMarks {
if t == strings.ToLower(m) {
return true
}
}
return false
}
// sttIsNonSpeechMark reports whether a segment is the engine ANNOTATING a sound
// rather than transcribing speech: "[Music]", "(music)", "(applause)", "[♪♪♪]".
//
// ── WHY THIS IS STRUCTURAL AND NOT A WORD LIST ──────────────────────────────
//
// A list cannot work here, and that is measured rather than assumed. The same
// ten seconds of a synthesised melody through the real binary
// returns "[Music]" from base.en and "(music)" from tiny.en —
// two spellings, two brackets, two cases, one input. Eight seconds of a 440 Hz
// tone returns "(phone ringing)"; eight seconds of white noise returns "(machine
// whirring)". A real song produced "[♪♪♪]". The
// vocabulary is whatever the model decided the sound was, which is open-ended by
// construction, so any list is a list of the examples somebody happened to hit.
//
// WHAT IS INVARIANT is the SHAPE. Whisper wraps an annotation in brackets or
// parentheses and transcribes speech bare. Checked against 997 real speech
// segments — LibriSpeech test-clean and test-other, a voice clip, jfk.wav and a
// thirty-minute recording, all through base.en — of which ZERO were wholly
// wrapped and zero contained a bracket or a parenthesis anywhere at all. So a
// segment whose ENTIRE text is one bracketed group is the engine describing the
// audio, and dropping it cannot cost a user a word they said.
//
// It is deliberately narrow: the whole segment, one group, nothing outside it. A
// segment that merely CONTAINS a parenthesis is speech and is kept intact.
func sttIsNonSpeechMark(text string) bool {
t := strings.TrimSpace(text)
if len(t) < 2 {
return false
}
var closer byte
switch t[0] {
case '[':
closer = ']'
case '(':
closer = ')'
default:
return false
}
if t[len(t)-1] != closer {
return false
}
// ONE group, not two. "(yes) and (no)" ends in a bracket and is speech; the
// opening bracket must be closed only at the very end.
inner := t[1 : len(t)-1]
return strings.IndexByte(inner, closer) < 0
}
type sttSegment struct {
// T0 and T1 are milliseconds from the start of the recording. T0 is what
// becomes media_offset_ms on the row, which is what lets a search result be
// jumped to in the audio the user already has.
T0 int64 `json:"t0"`
T1 int64 `json:"t1"`
Text string `json:"text"`
}
// sttParse turns the engine's stdout into segments.
//
// The line it produces, verified against the real binary:
//
// [00:00:00.000 --> 00:00:05.560] "He hoped there would be stew for dinner...
//
// Parsed on the bracketed prefix only. A line that is not in that shape is
// SKIPPED rather than guessed at — the engine also writes banner and warning
// lines to stdout under some builds, and a parser that treated an unrecognised
// line as transcript would put the engine's own words into the user's corpus.
//
// ── WHAT THIS RETURNS, AND WHY IT IS THREE THINGS AND NOT TWO ───────────────
//
// (segments, blank, nonSpeech). The extra answer exists because "nobody spoke"
// and "nothing was said because it is a song" are different sentences, and
// folding the second into the first would tell a user their music upload is
// SILENT — which is a false statement about their file, made by us.
//
// The states this lane owes a user stay separate all the way down:
//
// no engine contextprep.go's quiet case. Never reaches this function.
// unreadable sttDecodeFailure, checked on stderr BEFORE this is called.
// silence blank — the engine's own [BLANK_AUDIO] and friends.
// sound, no nonSpeech — the engine annotated what it heard.
// speech
//
// This is also the reason `--suppress-nst` was refused rather than used. It
// returns NOTHING AT ALL for a non-speech stretch, which erases the difference
// between "the engine listened and there was no speech" and "the engine produced
// no output", collapsing two of these into one. Dropping the token at the PARSER
// keeps the segment — with its timestamps and an empty text — so the distinction
// survives while the token never reaches the corpus.
//
// Neither flag is set when any real speech was found: a recording with music at
// the top and a meeting after it is a meeting, and its transcript is the words.
func sttParse(out string) ([]sttSegment, bool, bool) {
segs := []sttSegment{}
sawText := false
sawBlank := false
sawNonSpeech := false
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
line := sc.Text()
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "[") {
continue
}
close := strings.Index(trimmed, "]")
if close < 0 {
continue
}
span := trimmed[1:close]
arrow := strings.Index(span, "-->")
if arrow < 0 {
continue
}
t0, ok0 := sttClock(span[:arrow])
t1, ok1 := sttClock(span[arrow+3:])
if !ok0 || !ok1 {
continue
}
text := strings.TrimSpace(trimmed[close+1:])
if text == "" {
continue
}
// SILENCE FIRST. [BLANK_AUDIO] is also bracket-wrapped, so the specific
// answer has to be taken before the general one or every silence would
// be reported as a sound the engine could not name.
if sttIsBlankMark(text) {
// Kept as a segment so "the engine read this and there is nothing in
// it" stays distinguishable from "the engine returned nothing", and
// dropped from the text so the token never reaches the user's corpus.
sawBlank = true
segs = append(segs, sttSegment{T0: t0, T1: t1})
continue
}
if sttIsNonSpeechMark(text) {
// The engine heard something and told us what it was. That is a
// description of the audio, not a word anybody said, and filing it as
// text puts "[Music]" in the user's searchable corpus as a false hit.
// Kept as a timestamped segment for the same reason silence is.
sawNonSpeech = true
segs = append(segs, sttSegment{T0: t0, T1: t1})
continue
}
sawText = true
segs = append(segs, sttSegment{T0: t0, T1: t1, Text: text})
if len(segs) >= sttMaxSegments {
break
}
}
return segs, sawBlank && !sawText && !sawNonSpeech, sawNonSpeech && !sawText
}
// sttClock reads "00:01:02.345" into milliseconds. Returns false for anything
// else, which is how a non-transcript line is told apart from a transcript one.
func sttClock(s string) (int64, bool) {
s = strings.TrimSpace(s)
parts := strings.Split(s, ":")
if len(parts) != 3 {
return 0, false
}
h, err1 := strconv.Atoi(parts[0])
m, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil || h < 0 || m < 0 {
return 0, false
}
sec := parts[2]
ms := 0
if dot := strings.Index(sec, "."); dot >= 0 {
frac := sec[dot+1:]
for len(frac) < 3 {
frac += "0"
}
v, err := strconv.Atoi(frac[:3])
if err != nil {
return 0, false
}
ms = v
sec = sec[:dot]
}
s2, err := strconv.Atoi(sec)
if err != nil || s2 < 0 {
return 0, false
}
return int64(h)*3600000 + int64(m)*60000 + int64(s2)*1000 + int64(ms), true
}
// sttText joins the segments into the plain transcript the embedding lane reads.
func sttText(segs []sttSegment) string {
var b strings.Builder
for _, s := range segs {
if s.Text == "" {
continue
}
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(s.Text)
}
return b.String()
}
// ── the result ──────────────────────────────────────────────────────────────
type sttResult struct {
Segments []sttSegment
Text string
// Model is the model that ACTUALLY RAN, not the one that was asked for and
// not the one the box happens to prefer. It is the field this whole job is
// arranged around: base.en and large-v3-turbo are three times apart in error
// rate on hard audio, so a transcript with no model on it is a transcript
// whose accuracy nobody can judge afterwards.
Model string
// Lang is the language the engine ACTUALLY DECODED IN, which is the engine's
// own default of "en" unless the host named one. Recorded for the same reason
// Model is: a transcript whose language nobody can see is a transcript whose
// first failure mode is invisible.
Lang string
// AudioMS is how long the recording is, when the box could find out — ffprobe
// on the av path, the RIFF header on a wav, and 0 when neither applies.
// 0 means UNKNOWN and is never reported as a duration.
AudioMS int64
// DoneMS is how far into the recording the transcript reaches: the end of the
// last segment the engine produced. Together with AudioMS it is what tells a
// user how much of their meeting is in the corpus.
DoneMS int64
// Blank is the engine reading the whole thing and finding no speech. A
// finished job with an empty answer, which is a different sentence from every
// other empty answer in this lane.
Blank bool
// NonSpeech is the engine having heard SOMETHING and annotated it rather than
// transcribed it — music, applause, a ringing phone. Not Blank, because the
// recording is not silent and telling a user their song is silence is a false
// statement about their file. Not text either: the annotation is the engine's
// word, never the user's, and it is dropped before it can reach the corpus.
NonSpeech bool
// Demuxed is ffmpeg having unpacked an audio track before the engine saw it.
Demuxed bool
// Deadline is the clock running out mid-recording, with the text being what
// was transcribed before it did. Partial and SAID to be partial.
Deadline bool
// Capped is the recording running past sttAudioCapSeconds, so the engine was
// only asked for the first hour of it.
Capped bool
}
// runTranscribe is the whole job: unpack an audio track if this is the av
// variant, transcribe, and report what was and was not covered.
func runTranscribe(root, in, tool, model, wantLang string, deadline time.Time) (sttResult, error) {
var res sttResult
chosen, err := sttPickModel(model)
if err != nil {
return res, err
}
res.Model = chosen.Name
lang, err := sttLang(chosen, wantLang)
if err != nil {
return res, err
}
res.Lang = lang
audio := in
if tool == sttAVTool {
// THE DEMUX, and it is why this job is separate from the other one. An
// mp4's audio track is not something whisper.cpp reads; ffmpeg unpacks it
// to the 16 kHz mono wav the engine wants anyway.
budget := time.Until(deadline) / sttDemuxShare
if budget <= 0 {
return res, fmt.Errorf("there was no time left to unpack this recording's " +
"audio track")
}
wav, ms, derr := sttDemux(root, in, budget)
if wav != "" {
defer os.Remove(wav)
}
if derr != nil {
return res, derr
}
audio, res.AudioMS, res.Demuxed = wav, ms, true
} else if ms, ok := wavDurationMS(in); ok {
// A wav says how long it is in its own header, which costs one read and
// is exact. Every other container on this path (mp3, ogg, flac) does not,
// and this job has no ffprobe to ask — so the duration stays UNKNOWN
// rather than being estimated, and the reply says nothing about it.
res.AudioMS = ms
}
left := time.Until(deadline)
if left <= 0 {
return res, fmt.Errorf("there was no time left to transcribe this recording")
}
// THE CAP IS ONLY APPLIED WHEN IT BINDS. See sttArgv: -d is a duration the
// engine will process whether the file has it or not, so passing it for a
// recording that is shorter — or one whose length is unknown — is how an
// eleven-second clip becomes an hour of invented text.
capMS := 0
if res.AudioMS > sttCapMS {
capMS = sttCapMS
res.Capped = true
}
// HOW LONG THE ENGINE ACTUALLY RAN, measured rather than derived.
//
// The deadline error below used to report time.Until(deadline) — the time
// REMAINING — in a branch that is only reachable once rerr == errDeadline,
// which means the deadline has already passed. So the value was NON-POSITIVE
// BY CONSTRUCTION, at every magnitude, on every machine. That is the whole
// defect and it needs no example: remaining time was never what "after %s"
// meant.
//
// What a user saw depended on how far past the deadline the kill landed.
// MEASURED on this box across the six runs that actually hit the deadline
// (base.en at 50/150/300 ms, large-v3-turbo at 200/1000/3000 ms): the
// overshoot is 3-84 ms, which Round(time.Second) renders as the literal
// string "after 0s" — a sentence claiming the engine was still working after
// no time at all. "after -4s" was also reported, which is the same defect
// with an overshoot past half a second; that figure was not reproduced here,
// so it is not written down as if it were.
//
// Both clocks here are time.Now() on this box, so the subtraction is
// monotonic and cannot go backwards. sttRun returns errDeadline only when its
// own context expired, which means this elapsed IS the budget the engine was
// given — the two agree by construction rather than by a second guess.
runStart := time.Now()
out, errOut, rc, rerr := sttRun(left, sttArgv(chosen.Path, audio, capMS, lang)...)
ran := time.Since(runStart)
if rerr != nil && rerr != errDeadline {
return res, fmt.Errorf("%s could not transcribe this recording: %s",
sttBin, sttFirstProblem(errOut, out, rerr))
}
if rc != 0 && rerr != errDeadline {
return res, fmt.Errorf("%s exited %d on this recording: %s",
sttBin, rc, sttFirstProblem(errOut, out, rerr))
}
// THE ZERO-EXIT FAILURE. Checked before the output is read, because the
// output of a failed decode is empty and empty would be reported as silence.
if why := sttDecodeFailure(errOut); why != "" {
return res, fmt.Errorf("%s", why)
}
// THE BACKSTOP FOR THE LANGUAGE, and it is the engine's own sentence rather
// than this build's reading of a filename. If the name check let a request
// through to a model that turns out to be English-only, the engine says so on
// stderr and transcribes English anyway — so the job is refused HERE rather
// than returning an English transcript labelled something else. Costs one run
// in the one case where the naming convention lied.
if wantLang != "" && lang != sttDefaultLang && sttEnglishOnlyRefusal(errOut) {
return res, fmt.Errorf(
"%s reported that the model %s is not multilingual and ignored the request "+
"to transcribe %s — what it produced is an English transcript, so this "+
"job is not filing it under %s", sttBin, chosen.Name, lang, lang)
}
res.Deadline = rerr == errDeadline
segs, blank, nonSpeech := sttParse(out)
res.Segments = segs
res.Blank = blank
res.NonSpeech = nonSpeech
res.Text = sttText(segs)
if len(segs) > 0 {
res.DoneMS = segs[len(segs)-1].T1
}
if res.Deadline && len(segs) == 0 {
return res, fmt.Errorf(
"%s was still loading or still working after %s and produced nothing "+
"before it was stopped — the recording was not rejected, it did not "+
"start returning text in the time this lane allows", sttBin,
sttReadableDuration(ran))
}
return res, nil
}
// handleCtxTranscribe owns the scratch file from the moment ctx_extract forks to
// it, including REMOVING IT BEFORE IT REPLIES.
//
// Before, not deferred, and for the reason contextextract.go records against its
// own removal: the terminal frame is written inside the reply helpers, so a
// deferred remove leaves the user's recording on their disk at the moment the
// host is told the job finished. The guarantee this lane makes is that it is
// gone by then.
func handleCtxTranscribe(ws *websocket.Conn, root, scratch string, req ctxExtractReq,
run func(root, in, tool, model, lang string, deadline time.Time) (sttResult, error),
source string, limit time.Duration) {
res, err := run(root, scratch, req.Tool, req.Model, req.Lang, time.Now().Add(limit))
_ = os.Remove(scratch)
if err != nil {
ctxExtractFail(ws, "ctx_extract: "+err.Error())
return
}
resp := ctxExtractResp{
Tool: req.Tool, Source: source,
Chars: len(res.Text), Text: res.Text,
Model: res.Model, Lang: res.Lang, Segments: res.Segments,
AudioMS: res.AudioMS, DoneMS: res.DoneMS,
Capped: res.Capped, Blank: res.Blank, NonSpeech: res.NonSpeech,
Demuxed: res.Demuxed, Deadline: res.Deadline,
}
// The text cap applies here too, and the SEGMENTS are trimmed with it rather
// than left to point past the end of the text they describe. A segment list
// longer than its transcript would give the host offsets for words it does
// not have.
if len(res.Text) > ctxExtractMaxText {
resp.Text = res.Text[:ctxExtractMaxText]
resp.Chars = ctxExtractMaxText
resp.CharsAll = len(res.Text)
resp.Truncated = true
resp.Segments = sttSegmentsWithin(res.Segments, ctxExtractMaxText)
if n := len(resp.Segments); n > 0 {
resp.DoneMS = resp.Segments[n-1].T1
}
}
b, err := json.Marshal(resp)
if err != nil {
ctxExtractFail(ws, "ctx_extract: cannot encode the result: "+err.Error())
return
}
sendMgmt(ws, 'O', b)
sendMgmtExit(ws, 0)
}
// sttSegmentsWithin keeps the segments whose words survived the text cap, so the
// offsets the host files always describe text the host actually received.
func sttSegmentsWithin(segs []sttSegment, max int) []sttSegment {
out := []sttSegment{}
used := 0
for _, s := range segs {
if s.Text == "" {
continue
}
next := used + len(s.Text)
if used > 0 {
next++ // the newline sttText joins with
}
if next > max {
break
}
used = next
out = append(out, s)
}
return out
}
// ── the demux ───────────────────────────────────────────────────────────────
// sttDemux writes the recording's audio track out as the 16 kHz mono wav the
// engine wants, and returns the track's duration.
//
// Flags, each one load-bearing:
//
// -nostdin ffmpeg reads stdin by default and would consume the daemon's.
// -v error quiet unless something is wrong, so the message this returns
// is ffmpeg's actual complaint and not its banner.
// -vn drop the video. On a clip this is most of the bytes.
// -ac 1 -ar 16000 -c:a pcm_s16le
// exactly what whisper wants. Doing it here rather than letting
// the engine resample means the conversion is ffmpeg's, which is
// the better resampler, and the intermediate file is as small as
// it can be.
// -f wav the format is stated rather than inferred from the name.
//
// NOTHING OF THE USER'S IS LEFT BEHIND: the wav is created inside the pairing's
// own sandboxed root with the same "extract-" prefix the source file uses, so
// the stale-scratch sweep that runs at worker start already removes it if this
// process dies mid-job.
func sttDemux(root, in string, budget time.Duration) (string, int64, error) {
f, err := os.CreateTemp(root, "extract-audio-*.wav")
if err != nil {
return "", 0, fmt.Errorf("no scratch space to unpack this recording's audio: %w", err)
}
out := f.Name()
f.Close()
ctx, cancel := context.WithTimeout(context.Background(), budget)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-v", "error", "-y",
"-i", in, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
"-f", "wav", out)
hideConsole(cmd) // console_windows.go
errb := &capWriter{max: 8 << 10}
cmd.Stdout, cmd.Stderr = &capWriter{max: 8 << 10}, errb
runErr := cmd.Run()
if ctx.Err() != nil {
return out, 0, fmt.Errorf(
"unpacking this recording's audio track was still running after %s and was "+
"stopped — nothing was transcribed", budget.Round(time.Second))
}
if runErr != nil {
msg := strings.TrimSpace(errb.buf.String())
// NO AUDIO TRACK IS ITS OWN ANSWER. A silent screen recording is a
// complete, well-formed file that this job has nothing to say about, and
// calling it a broken file would send the user to look at their camera.
low := strings.ToLower(msg)
if strings.Contains(low, "does not contain any stream") ||
strings.Contains(low, "no audio") {
return out, 0, fmt.Errorf("this recording has no audio track in it — there " +
"is nothing to transcribe. That is a complete answer about the file, " +
"not a failure of this machine")
}
if len(msg) > 300 {
msg = msg[:300]
}
return out, 0, fmt.Errorf("ffmpeg could not unpack this recording's audio track"+
"%s", ifNonEmpty(msg, ": "+msg))
}
ms, _ := wavDurationMS(out)
return out, ms, nil
}
// ── the wav header ──────────────────────────────────────────────────────────
// wavDurationMS reads how long a wav is out of its own header.
//
// Exact and free: a RIFF/WAVE file states its byte rate and its data chunk's
// length, so the duration is a division rather than a decode. Returns false for
// anything that is not a wav, which is the honest answer — an UNKNOWN duration
// is reported as unknown and never estimated, because the number is shown to the
// user as "how much of your recording is in the corpus".
func wavDurationMS(path string) (int64, bool) {
f, err := os.Open(path)
if err != nil {
return 0, false
}
defer f.Close()
head := make([]byte, 12)
if _, err := f.Read(head); err != nil {
return 0, false
}
if string(head[0:4]) != "RIFF" || string(head[8:12]) != "WAVE" {
return 0, false
}
var byteRate uint32
var dataLen uint32
// Walk the chunks. fmt carries the byte rate, data carries the length; a wav
// may hold other chunks (LIST, fact) between them, so this does not assume
// the canonical 44-byte layout.
for i := 0; i < 64; i++ {
hdr := make([]byte, 8)
if _, err := f.Read(hdr); err != nil {
break
}
id := string(hdr[0:4])
size := binary.LittleEndian.Uint32(hdr[4:8])
switch id {
case "fmt ":
body := make([]byte, size)
if _, err := f.Read(body); err != nil || len(body) < 16 {
return 0, false
}
byteRate = binary.LittleEndian.Uint32(body[8:12])
case "data":
dataLen = size
if byteRate > 0 {
return int64(dataLen) * 1000 / int64(byteRate), true
}
return 0, false
default:
// Chunks are word-aligned: an odd length is followed by a pad byte.
skip := int64(size) + int64(size%2)
if _, err := f.Seek(skip, 1); err != nil {
return 0, false
}
}
}
return 0, false
}
// sttProbeWAV is one second of 16 kHz mono silence as a wav, built here rather
// than embedded so there is no asset to keep in step with the parser.
//
// Silence is the right probe input precisely because its answer is a KNOWN
// token: the engine returns [BLANK_AUDIO], which exercises the model load, the
// decoder, the timestamp format and this file's parser in one run, and gives an
// answer that can be asserted rather than merely looked at.
func sttProbeWAV() []byte {
const rate = 16000
const samples = rate // one second
data := make([]byte, samples*2)
b := make([]byte, 0, 44+len(data))
b = append(b, "RIFF"...)
b = binary.LittleEndian.AppendUint32(b, uint32(36+len(data)))
b = append(b, "WAVEfmt "...)
b = binary.LittleEndian.AppendUint32(b, 16) // PCM fmt chunk size
b = binary.LittleEndian.AppendUint16(b, 1) // PCM
b = binary.LittleEndian.AppendUint16(b, 1) // mono
b = binary.LittleEndian.AppendUint32(b, rate) // sample rate
b = binary.LittleEndian.AppendUint32(b, rate*2)
b = binary.LittleEndian.AppendUint16(b, 2) // block align
b = binary.LittleEndian.AppendUint16(b, 16) // bits per sample
b = append(b, "data"...)
b = binary.LittleEndian.AppendUint32(b, uint32(len(data)))
b = append(b, data...)
return b
}