diff options
| -rw-r--r-- | server/cmd/listend/main.go | 155 | ||||
| -rw-r--r-- | server/internal/store/store.go | 20 | ||||
| -rw-r--r-- | server/internal/store/store_test.go | 48 | ||||
| -rw-r--r-- | server/internal/transcribe/breaker.go | 46 | ||||
| -rw-r--r-- | server/internal/transcribe/breaker_test.go | 77 | ||||
| -rw-r--r-- | server/internal/transcribe/whisper.go | 218 | ||||
| -rw-r--r-- | server/internal/transcribe/whisper_test.go | 147 |
7 files changed, 707 insertions, 4 deletions
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go index 495ed03..1b746f1 100644 --- a/server/cmd/listend/main.go +++ b/server/cmd/listend/main.go @@ -23,6 +23,7 @@ import ( "os" "os/signal" "path/filepath" + "sort" "sync" "syscall" "time" @@ -35,6 +36,7 @@ import ( "github.com/terst/listen/server/internal/signature" "github.com/terst/listen/server/internal/skips" "github.com/terst/listen/server/internal/store" + "github.com/terst/listen/server/internal/transcribe" "github.com/terst/listen/server/internal/transcript" ) @@ -46,6 +48,12 @@ func main() { pollEvery = flag.Duration("poll", 2*time.Hour, "how often to refresh every feed") tokenFile = flag.String("token-file", "", "file holding the API token (defaults to <data>/token)") once = flag.Bool("once", false, "run one poll cycle and exit, for checking a deployment") + + whisperBin = flag.String("whisper", "", "whisper-cli binary; empty disables recognition") + whisperModel = flag.String("whisper-model", "", "ggml model file for recognition") + whisperThreads = flag.Int("whisper-threads", 6, "cores one recognition run may use") + transcribeBudget = flag.Duration("transcribe-budget", 45*time.Minute, + "how long each cycle may spend recognising episodes") ) flag.Parse() @@ -83,8 +91,17 @@ func main() { decoder: decoder, transcripts: transcripts, harvester: transcript.Harvester{Store: transcripts}, - requests: make(chan request, 64), - wakeups: make(chan struct{}, 1), + recogniser: transcribe.Whisper{ + Binary: *whisperBin, + Model: *whisperModel, + Threads: *whisperThreads, + // Beside the audio rather than in /tmp: a decoded hour is 115MB of PCM, and the + // data directory is the volume with room for it. + WorkDir: filepath.Join(*dataDir, "work"), + }, + transcribeBudget: *transcribeBudget, + requests: make(chan request, 64), + wakeups: make(chan struct{}, 1), } server := api.New(state, worker, api.Options{ @@ -113,6 +130,9 @@ func main() { if *once { worker.pollAll(ctx) + // Recognition too, so a deployment check exercises the expensive path rather than + // discovering it is misconfigured hours later in a log nobody is reading. + worker.transcribeSome(ctx) if err := state.Save(statePath); err != nil { log.Fatalf("save: %v", err) } @@ -167,8 +187,14 @@ type worker struct { // they describe the copy that was mirrored. See transcript.Fits for when they do not. transcripts transcript.Store harvester transcript.Harvester - requests chan request - wakeups chan struct{} + // recogniser turns an episode's audio into its words where no usable published transcript + // exists. Zero when recognition is not configured, which is a working state. + recogniser transcribe.Whisper + // transcribeBudget is how long one cycle may spend recognising. Bounded so the backlog is + // worked through steadily rather than monopolising the machine -- see transcribeSome. + transcribeBudget time.Duration + requests chan request + wakeups chan struct{} } // wake asks for a poll now. Coalescing, so a burst of subscription updates is one poll. @@ -192,6 +218,7 @@ func (w *worker) Request(feedURL, guid, enclosureURL, title string) { func (w *worker) run(ctx context.Context, every time.Duration) { w.pollAll(ctx) w.save() + w.transcribeSome(ctx) ticker := time.NewTicker(every) defer ticker.Stop() @@ -202,6 +229,10 @@ func (w *worker) run(ctx context.Context, every time.Duration) { case <-ticker.C: w.pollAll(ctx) w.save() + // Only on the timer. A wakeup is the phone pushing a subscription list and + // wanting that show polled now; following it with three quarters of an hour of + // recognition would make a new subscription feel broken. + w.transcribeSome(ctx) case <-w.wakeups: w.pollAll(ctx) w.save() @@ -387,6 +418,122 @@ func (w *worker) harvestTranscripts(ctx context.Context, show *store.Show, candi } } +// transcribeSome recognises the words in episodes that have none, for as long as the budget +// allows. +// +// Bounded rather than exhaustive, and run after polling rather than inside it. Recognition is +// the most expensive thing this server does -- measured at about 11x realtime with base.en on +// six threads, so an hour-long episode costs about five minutes -- and the backlog at the time +// this was built was 304 episodes. Spending a fixed slice of each cycle works through that in a +// few days without ever making a poll wait hours or taking the machine over. +// +// Newest first. An episode Peter is likely to play soon is worth having words for before one +// from the back of the catalogue. +func (w *worker) transcribeSome(ctx context.Context) { + if !w.recogniser.Available() || w.transcribeBudget <= 0 { + return + } + deadline := time.Now().Add(w.transcribeBudget) + done, failed := 0, 0 + // Attempts made during the current run of failures, so they can be given back if the run + // turns out to be the engine rather than the episodes. See transcribe.Breaker. + var breaker transcribe.Breaker + var failing []*store.Episode + + for _, e := range w.pendingRecognition() { + if ctx.Err() != nil || time.Now().After(deadline) { + break + } + started := time.Now() + result, err := w.recogniser.Transcribe(ctx, filepath.Join(w.mirror.AudioDir, e.Path)) + if ctx.Err() != nil { + // Interrupted rather than attempted. Recording it would quietly skip whatever was + // in flight across a redeploy, and this runs for days at a time. + return + } + e.TranscribeAttemptedAtUnix = time.Now().Unix() + if err != nil { + log.Printf("transcribe %q: %v", e.Title, err) + failed++ + failing = append(failing, e) + if breaker.Failure() { + giveBackAttempts(failing) + log.Printf("recognition abandoned after %d failures in a row -- "+ + "giving those attempts back, since this is the engine and not the episodes", + breaker.Run()) + w.save() + return + } + w.save() + continue + } + + stored := transcript.Transcript{ + EpisodeID: e.ID, + Source: transcript.SourceASR, + LanguageCode: result.LanguageCode, + Cues: result.Cues, + Ads: transcript.Spot(result.Cues), + } + if err := w.transcripts.Put(stored); err != nil { + log.Printf("transcribe %q: %v", e.Title, err) + failed++ + failing = append(failing, e) + if breaker.Failure() { + giveBackAttempts(failing) + log.Printf("recognition abandoned after %d failures in a row -- "+ + "giving those attempts back, since this is the engine and not the episodes", + breaker.Run()) + w.save() + return + } + w.save() + continue + } + e.TranscriptSource = string(transcript.SourceASR) + e.TranscriptCues = len(stored.Cues) + e.TranscriptAtUnix = time.Now().Unix() + // The note said why the publisher's transcript could not be used. There are words now, + // so it is answered rather than still true. + e.TranscriptNote = "" + done++ + breaker.Success() + failing = nil + log.Printf("transcribed %q: %d lines, %d ad candidate(s), took %s", + e.Title, len(stored.Cues), len(stored.Ads), time.Since(started).Round(time.Second)) + w.save() + } + + if done > 0 || failed > 0 { + log.Printf("recognition -- %d done, %d failed, %d still waiting", + done, failed, len(w.pendingRecognition())) + } +} + +// giveBackAttempts unmarks episodes that failed for a reason that was nothing to do with them, +// so they are tried again rather than hidden for ever. +func giveBackAttempts(episodes []*store.Episode) { + for _, e := range episodes { + e.TranscribeAttemptedAtUnix = 0 + } +} + +// pendingRecognition is every mirrored episode still without words, newest first. +func (w *worker) pendingRecognition() []*store.Episode { + var pending []*store.Episode + for _, show := range w.state.Shows() { + for _, e := range show.Episodes { + if e.NeedsRecognising() { + pending = append(pending, e) + } + } + } + sort.Slice(pending, func(i, j int) bool { + return pending[i].PublishedUnix > pending[j].PublishedUnix + }) + return pending +} + // detect runs the intro/outro search over a show's mirrored episodes and records the result. // // Every mirrored episode at once, which is the whole reason this is a server: the phone could diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 26dab82..3c44060 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -68,6 +68,12 @@ type Episode struct { TranscriptCues int `json:"transcriptCues,omitempty"` // TranscriptAtUnix is when the transcript landed. TranscriptAtUnix int64 `json:"transcriptAtUnix,omitempty"` + // TranscribeAttemptedAtUnix is when speech recognition last ran over this episode, whether + // it worked or not, so a failure is not retried on every poll for ever. Recorded only when + // a run actually finished: one interrupted by a restart is not an attempt, or a redeploy + // during the backfill would quietly skip whatever was in flight. + TranscribeAttemptedAtUnix int64 `json:"transcribeAttemptedAtUnix,omitempty"` + // TranscriptNote is why a transcript that was offered was not taken -- most often that this // copy carries inserted advertising the publisher's master does not describe. Kept because // refused and never-looked-at are different states, and because a refusal here is the @@ -80,10 +86,24 @@ func (e *Episode) HasTranscript() bool { return e.TranscriptCues > 0 } // TranscriptExamined reports whether anything has been decided about this episode's words, so a // refusal is not re-read as an episode nobody has looked at yet. +// +// This gates the publisher's transcript only. Recognition asks a different question -- see +// [Episode.NeedsRecognising]. func (e *Episode) TranscriptExamined() bool { return e.HasTranscript() || e.TranscriptNote != "" } +// NeedsRecognising reports whether speech recognition should be run over this episode. +// +// Deliberately not the negation of [Episode.TranscriptExamined]. An episode whose publisher's +// transcript was declined has been examined and has no words, and it is precisely the episode +// recognition exists for: the refusal means this copy carries inserted advertising the +// publisher's master does not describe, which is both why the transcript is unusable and why +// having one would be worth most. +func (e *Episode) NeedsRecognising() bool { + return e.Mirrored() && !e.HasTranscript() && e.TranscribeAttemptedAtUnix == 0 +} + // Break is one stretch of an episode to skip. type Break struct { StartMs int64 `json:"startMs"` diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go index 61cbb30..c04dc29 100644 --- a/server/internal/store/store_test.go +++ b/server/internal/store/store_test.go @@ -387,3 +387,51 @@ func TestARefusedTranscriptIsNotTheSameAsNoneFound(t *testing.T) { t.Error("an episode nobody has looked at reads as examined") } } + +/* +Which episodes still need their words recognised. + +Not the same question as whether a transcript was examined. An episode whose publisher's +transcript was declined -- because this copy carries inserted advertising the publisher's master +does not describe -- has been examined and has no words, and it is exactly the episode +recognition exists for. +*/ + +func TestAnEpisodeWithARefusedTranscriptStillNeedsRecognising(t *testing.T) { + e := Episode{ + Path: "abc.mp3", + TranscriptNote: "this copy carries 4:16 of inserted advertising", + } + if !e.NeedsRecognising() { + t.Error("an episode whose publisher's transcript was refused is the point of recognition") + } +} + +func TestAnEpisodeWithWordsDoesNotNeedRecognising(t *testing.T) { + e := Episode{Path: "abc.mp3", TranscriptCues: 412} + if e.NeedsRecognising() { + t.Error("an episode that already has words would be transcribed again") + } +} + +// Recognition reads the mirrored file. There is nothing to read for an episode without one. +func TestAnUnmirroredEpisodeCannotBeRecognised(t *testing.T) { + unmirrored := Episode{} + if unmirrored.NeedsRecognising() { + t.Error("an episode with no audio was offered for recognition") + } +} + +/* +A finished attempt is not repeated. + +A run that failed will fail the same way on the same bytes, and retrying it every two hours +would spend the whole budget on the episodes that cannot be done while the rest of the library +waits. +*/ +func TestAFinishedAttemptIsNotRepeated(t *testing.T) { + e := Episode{Path: "abc.mp3", TranscribeAttemptedAtUnix: 1_700_000_000} + if e.NeedsRecognising() { + t.Error("an episode already attempted would be attempted again") + } +} diff --git a/server/internal/transcribe/breaker.go b/server/internal/transcribe/breaker.go new file mode 100644 index 0000000..f2fffd4 --- /dev/null +++ b/server/internal/transcribe/breaker.go @@ -0,0 +1,46 @@ +package transcribe + +// defaultBreakerLimit is how many episodes may fail in a row before the pass is abandoned. +// Three: enough that a couple of genuinely broken files do not stop the backfill, few enough +// that a misconfiguration costs three episodes rather than the library. +const defaultBreakerLimit = 3 + +// Breaker stops a recognition pass that is failing for an environmental reason rather than +// because of the episode in front of it. +// +// It exists because of a real failure on the first run: the working directory did not exist, so +// ffmpeg could not write, so every episode failed -- and every failure was recorded as "do not +// try this one again". A misconfigured deploy would have marked the whole backlog attempted in +// seconds and nothing would ever have been transcribed. The same shape as the midroll scan +// marking episodes checked forever after an OutOfMemoryError nobody saw. +// +// Consecutive failures, because one bad file among good ones is the episode's own problem and +// should be marked: the same bytes will fail the same way next time. +type Breaker struct { + // Limit is the number of consecutive failures that abandons the pass. Zero uses the + // default. + Limit int + run int +} + +// Failure records a failed episode and reports whether the pass should stop. +func (b *Breaker) Failure() bool { + b.run++ + return b.run >= b.limit() +} + +// Success records a transcribed episode, which clears the run. +func (b *Breaker) Success() { b.run = 0 } + +// Run is how many episodes have failed in a row. +// +// When the pass stops, these are the attempts to give back: they failed for a reason that was +// nothing to do with them, and leaving them marked would hide the episodes for ever. +func (b *Breaker) Run() int { return b.run } + +func (b *Breaker) limit() int { + if b.Limit > 0 { + return b.Limit + } + return defaultBreakerLimit +} diff --git a/server/internal/transcribe/breaker_test.go b/server/internal/transcribe/breaker_test.go new file mode 100644 index 0000000..7bdd6cf --- /dev/null +++ b/server/internal/transcribe/breaker_test.go @@ -0,0 +1,77 @@ +package transcribe + +import "testing" + +/* +Stopping a recognition pass that is failing for an environmental reason. + +Found the hard way on the first real run: the working directory did not exist, so ffmpeg could +not write, so every episode failed -- and each failure was recorded as an attempt, which means +"do not try this episode again". A misconfigured deploy would have marked the entire backlog as +attempted in a few seconds, and nothing would ever have been transcribed again. Exactly the +shape of the midroll bug that marked episodes checked forever after an OutOfMemoryError nobody +saw. + +One bad file among good ones is a different thing and should be marked: the same bytes will fail +the same way next time. So the rule is consecutive failures, and the attempts in the failing run +are given back. +*/ + +func TestAFailingRunStopsThePassAndGivesBackItsAttempts(t *testing.T) { + breaker := Breaker{Limit: 3} + + if breaker.Failure() || breaker.Failure() { + t.Fatal("stopped before the limit") + } + if !breaker.Failure() { + t.Error("did not stop on the third consecutive failure") + } + if breaker.Run() != 3 { + t.Errorf("run = %d, want 3 -- those are the attempts to give back", breaker.Run()) + } +} + +// A bad file among good ones is the episode's own problem, and marking it is right. +func TestOneFailureAmongSuccessesDoesNotStopThePass(t *testing.T) { + breaker := Breaker{Limit: 3} + + breaker.Success() + if breaker.Failure() { + t.Error("stopped on a single failure") + } + breaker.Success() + if breaker.Failure() { + t.Error("stopped on a second non-consecutive failure") + } + if breaker.Run() != 1 { + t.Errorf("run = %d, want 1 -- a success cleared the earlier failure", breaker.Run()) + } +} + +func TestASuccessClearsTheRun(t *testing.T) { + breaker := Breaker{Limit: 3} + breaker.Failure() + breaker.Failure() + breaker.Success() + + if breaker.Run() != 0 { + t.Errorf("run = %d, want 0", breaker.Run()) + } + if breaker.Failure() { + t.Error("stopped on the first failure after a success") + } +} + +// A limit of zero would stop the pass on its first failure, which is not a configuration +// anybody wants by accident. +func TestAnUnsetLimitUsesTheDefault(t *testing.T) { + breaker := Breaker{} + for i := 0; i < defaultBreakerLimit-1; i++ { + if breaker.Failure() { + t.Fatalf("stopped after %d failures", i+1) + } + } + if !breaker.Failure() { + t.Errorf("did not stop after %d failures", defaultBreakerLimit) + } +} diff --git a/server/internal/transcribe/whisper.go b/server/internal/transcribe/whisper.go new file mode 100644 index 0000000..c6e4f83 --- /dev/null +++ b/server/internal/transcribe/whisper.go @@ -0,0 +1,218 @@ +// Package transcribe turns an episode's audio into its words, using whisper.cpp. +// +// The other half of the transcript story. A publisher's own transcript is exact and free and +// describes the publisher's ad-free master, so the server can only use one where no advertising +// was inserted -- 38 of 342 mirrored episodes, measured 2026-10-28. Recognition is approximate +// and costs CPU, and describes exactly the file that was mirrored, which is the other 304. Those +// are also the episodes where a transcript is worth most: they are the ones carrying inserted +// advertising, and the ones the ad scan has to work on. +// +// Shelling out rather than binding the library. The same call ffmpeg gets in this project, for +// the same reasons: one process per episode is killable, its memory is its own, and a crash in +// a C++ decoder cannot take the server down with it. +package transcribe + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/terst/listen/server/internal/transcript" +) + +// Result is one episode's recognised words. +type Result struct { + LanguageCode string + Cues []transcript.Cue +} + +// Whisper runs whisper.cpp over an episode's audio. +type Whisper struct { + // Binary is the whisper-cli executable; Model the ggml model file. Both empty means + // recognition is not configured, which is a working state -- the server still takes + // publishers' transcripts. + Binary string + Model string + // Threads is how many cores one run may use. Fewer than the machine has, deliberately: + // detection is running on the same box and this must never be the thing that makes it slow. + Threads int + // WorkDir is where the decoded audio and the output land. Defaults to the system temp dir. + WorkDir string + // Timeout bounds one episode. A run that hangs must not stop every later episode from + // being done. + Timeout time.Duration +} + +const ( + defaultThreads = 6 + // defaultTimeout is generous against the measured rate. whisper.cpp does about 11x + // realtime on this machine with base.en on six threads, so an hour-long episode takes + // about five minutes; an hour means something has gone wrong, not that the episode is long. + defaultTimeout = time.Hour + // sampleRate and channels are what whisper.cpp requires of its input, which is why the + // audio is decoded first rather than handed over as an mp3. + sampleRate = "16000" + channels = "1" +) + +// Available reports whether recognition is configured and its files are present. +// +// Checked once rather than discovered per episode: without this the first attempt reports +// whatever the shell says about a missing binary, once for every episode in the library. +func (w Whisper) Available() bool { + if w.Binary == "" || w.Model == "" { + return false + } + for _, path := range []string{w.Binary, w.Model} { + if _, err := os.Stat(path); err != nil { + return false + } + } + return true +} + +// Transcribe recognises the words in one audio file. +func (w Whisper) Transcribe(ctx context.Context, audioPath string) (Result, error) { + if !w.Available() { + return Result{}, fmt.Errorf("transcribe: recognition is not configured") + } + + ctx, cancel := context.WithTimeout(ctx, w.timeout()) + defer cancel() + + dir := w.WorkDir + if dir == "" { + dir = os.TempDir() + } + // Created here rather than assumed. The first real run failed on every episode because this + // directory did not exist, and each failure was recorded as "do not try this one again". + if err := os.MkdirAll(dir, 0o755); err != nil { + return Result{}, fmt.Errorf("transcribe: %w", err) + } + base := filepath.Join(dir, workingName(audioPath)) + wav := base + ".wav" + // Named for the episode so two runs can never collide, and removed on the way out so a + // leftover from a killed run is never read as this episode's words. + defer os.Remove(wav) + defer os.Remove(base + ".json") + + if err := w.decode(ctx, audioPath, wav); err != nil { + return Result{}, err + } + if err := w.run(ctx, wav, base); err != nil { + return Result{}, err + } + + raw, err := os.ReadFile(base + ".json") + if err != nil { + return Result{}, fmt.Errorf("transcribe: %w", err) + } + return parseWhisperJSON(raw) +} + +// decode turns the mirrored audio into the 16kHz mono PCM whisper.cpp requires. +func (w Whisper) decode(ctx context.Context, audioPath, wav string) error { + cmd := exec.CommandContext(ctx, "ffmpeg", + "-nostdin", "-loglevel", "error", + "-i", audioPath, + "-ar", sampleRate, "-ac", channels, "-c:a", "pcm_s16le", + "-y", wav) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("transcribe: decode %s: %w: %s", filepath.Base(audioPath), err, trim(out)) + } + return nil +} + +func (w Whisper) run(ctx context.Context, wav, base string) error { + cmd := exec.CommandContext(ctx, w.Binary, + "-m", w.Model, + "-f", wav, + "-t", fmt.Sprint(w.threads()), + "-oj", "-of", base, + "--no-prints") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("transcribe: %s: %w: %s", filepath.Base(base), err, trim(out)) + } + return nil +} + +func (w Whisper) threads() int { + if w.Threads > 0 { + return w.Threads + } + return defaultThreads +} + +func (w Whisper) timeout() time.Duration { + if w.Timeout > 0 { + return w.Timeout + } + return defaultTimeout +} + +// whisperOutput is the shape whisper-cli writes with -oj. +type whisperOutput struct { + Result struct { + Language string `json:"language"` + } `json:"result"` + Transcription []struct { + Offsets struct { + From int64 `json:"from"` + To int64 `json:"to"` + } `json:"offsets"` + Text string `json:"text"` + } `json:"transcription"` +} + +// parseWhisperJSON reads whisper-cli's output into cues. +// +// The offsets are milliseconds, which is what both the app and the ad scan want; the timestamps +// field beside them is the same numbers written as text. +func parseWhisperJSON(raw []byte) (Result, error) { + var out whisperOutput + if err := json.Unmarshal(raw, &out); err != nil { + return Result{}, fmt.Errorf("transcribe: reading output: %w", err) + } + + cues := make([]transcript.Cue, 0, len(out.Transcription)) + for _, segment := range out.Transcription { + // whisper pads every segment with a leading space; a transcript full of them reads + // badly and matches ad phrases differently. + text := strings.TrimSpace(segment.Text) + if text == "" || segment.Offsets.To <= segment.Offsets.From { + continue + } + cues = append(cues, transcript.Cue{ + StartMs: segment.Offsets.From, + EndMs: segment.Offsets.To, + Text: text, + }) + } + if len(cues) == 0 { + // A run that produced nothing is a failure, not an empty transcript. Stored as one the + // episode would be marked done for ever -- exactly how the midroll scanner came to + // report "none found" for episodes it had never managed to decode. + return Result{}, fmt.Errorf("transcribe: the run produced no words") + } + return Result{LanguageCode: out.Result.Language, Cues: cues}, nil +} + +// workingName is the episode's id, which is what its audio file is named after. Used for the +// decoded audio and the output so two runs can never collide. +func workingName(audioPath string) string { + base := filepath.Base(audioPath) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + +func trim(out []byte) string { + s := strings.TrimSpace(string(out)) + if len(s) > 300 { + return s[:300] + "..." + } + return s +} diff --git a/server/internal/transcribe/whisper_test.go b/server/internal/transcribe/whisper_test.go new file mode 100644 index 0000000..ba3111e --- /dev/null +++ b/server/internal/transcribe/whisper_test.go @@ -0,0 +1,147 @@ +package transcribe + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +/* +Speech recognition over the bytes the phone will play. + +The other half of the transcript story. A publisher's transcript is exact and free, and +describes the publisher's ad-free master -- so the server can only use one where no advertising +was inserted, which is 38 of 342 mirrored episodes. Recognition is approximate and costs CPU, +and describes exactly the file that was mirrored, which is the other 304. + +whisper.cpp is a separate binary, so the parts worth testing off a machine with it installed are +the ones that go wrong quietly: reading its output, and refusing to pretend a failed run +produced an empty transcript. +*/ + +// The shape whisper-cli writes with -oj. Offsets are milliseconds, which is what the app and +// the detector both want -- the timestamps field beside it is the same numbers as text. +const sampleJSON = `{ + "systeminfo": "AVX2", + "model": {"type": "base"}, + "params": {"model": "ggml-base.en.bin"}, + "result": {"language": "en"}, + "transcription": [ + { + "timestamps": {"from": "00:00:00,000", "to": "00:00:09,800"}, + "offsets": {"from": 0, "to": 9800}, + "text": " Hey, it's Jack, the host of the show." + }, + { + "timestamps": {"from": "00:00:09,800", "to": "00:00:14,200"}, + "offsets": {"from": 9800, "to": 14200}, + "text": " This week we are talking about something else." + } + ] +}` + +func TestReadsCuesAndLanguageFromWhisperOutput(t *testing.T) { + got, err := parseWhisperJSON([]byte(sampleJSON)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got.LanguageCode != "en" { + t.Errorf("language = %q, want en", got.LanguageCode) + } + if len(got.Cues) != 2 { + t.Fatalf("got %d cues, want 2", len(got.Cues)) + } + if got.Cues[0].StartMs != 0 || got.Cues[0].EndMs != 9800 { + t.Errorf("first cue = %+v", got.Cues[0]) + } +} + +// whisper pads every segment with a leading space, and a transcript full of them reads badly +// and matches ad phrases differently. +func TestTrimsTheLeadingSpaceWhisperAdds(t *testing.T) { + got, err := parseWhisperJSON([]byte(sampleJSON)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got.Cues[0].Text != "Hey, it's Jack, the host of the show." { + t.Errorf("text = %q", got.Cues[0].Text) + } +} + +/* +A run that produced nothing is a failure, not an empty transcript. + +The difference decides whether the episode is tried again. Stored as an empty transcript it +would be marked done for ever, which is exactly how the midroll scanner came to report "none +found" for episodes it had never managed to decode. +*/ +func TestAnEmptyTranscriptionIsAnError(t *testing.T) { + if _, err := parseWhisperJSON([]byte(`{"result":{"language":"en"},"transcription":[]}`)); err == nil { + t.Error("an empty run parsed as a transcript") + } +} + +func TestSomethingThatIsNotWhisperOutputIsAnError(t *testing.T) { + if _, err := parseWhisperJSON([]byte("not json at all")); err == nil { + t.Error("parsed rubbish without complaint") + } +} + +// A segment with no words is silence whisper labelled, and nothing to show or seek to. +func TestDropsSegmentsWithNoWordsOrNoDuration(t *testing.T) { + const mixed = `{"result":{"language":"en"},"transcription":[ + {"offsets":{"from":0,"to":1000},"text":" Real words."}, + {"offsets":{"from":1000,"to":2000},"text":" "}, + {"offsets":{"from":3000,"to":3000},"text":" No duration."}, + {"offsets":{"from":5000,"to":4000},"text":" Backwards."}]}` + + got, err := parseWhisperJSON([]byte(mixed)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(got.Cues) != 1 || got.Cues[0].Text != "Real words." { + t.Errorf("got %+v, want only the real one", got.Cues) + } +} + +/* +The engine refuses to run when it has not been configured. + +Without this the first attempt reports whatever the shell says about a missing binary, once per +episode, for every episode in the library. +*/ +func TestAnUnconfiguredEngineSaysSoRatherThanFailingPerEpisode(t *testing.T) { + if (Whisper{}).Available() { + t.Error("an engine with no binary or model claims to be available") + } + _, err := (Whisper{}).Transcribe(context.Background(), "whatever.mp3") + if err == nil || !strings.Contains(err.Error(), "not configured") { + t.Errorf("err = %v, want it to say the engine is not configured", err) + } +} + +func TestAnEngineWithABinaryAndAModelIsAvailable(t *testing.T) { + dir := t.TempDir() + binary := filepath.Join(dir, "whisper-cli") + model := filepath.Join(dir, "model.bin") + for _, f := range []string{binary, model} { + if err := os.WriteFile(f, []byte("x"), 0o755); err != nil { + t.Fatalf("write: %v", err) + } + } + + if !(Whisper{Binary: binary, Model: model}).Available() { + t.Error("an engine with both files says it is unavailable") + } +} + +// Named after the file rather than a fixed name: several episodes must never collide, and a +// leftover from a killed run must not be read as this episode's words. +func TestTheWorkingFilesAreNamedForTheEpisode(t *testing.T) { + got := workingName("/site/audio/106a30778891d3d5b2497d95.mp3") + if got != "106a30778891d3d5b2497d95" { + t.Errorf("got %q", got) + } +} |
