summaryrefslogtreecommitdiff
path: root/server/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-29 07:09:29 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-29 07:09:29 +0000
commit007f663730728f25055eb24d7b03489d328869d8 (patch)
treef0f7b9ba4d108e494cb2a2cd6706b71868f249b2 /server/internal
parentcbf6fb8c5f3d5edb4ce8bcd89666070d0c35c49b (diff)
Recognise the words in episodes no publisher transcript can describe
whisper.cpp, installed on tungsten and shelled out to per episode. The other 304 of 342 mirrored episodes -- the ones whose publisher's transcript was declined because this copy carries inserted advertising the master does not describe, which is both why it is unusable and why having words would be worth most. **Measured, and an order of magnitude cheaper than I told Peter.** base.en on six threads does 10.3x realtime on this box: a 63-minute Darknet Diaries episode took 6m11s at 793MB peak. The earlier "roughly realtime, days of backfill" was wrong. The real backlog is 298 episodes, 266 hours of audio, about 26 hours of CPU -- three days at 45 minutes per two-hour cycle, which is the default budget. Bounded per cycle rather than exhaustive, and only on the timer: a wakeup is the phone pushing a subscription list and wanting that show polled now, and following it with three quarters of an hour of recognition would make a new subscription feel broken. Newest first, because an episode about to be played is worth having words for before one from the back of a catalogue. **The first real run found two bugs the tests could not have.** The working directory was never created, so ffmpeg could not write and every episode failed -- and each 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, which is precisely the shape of the midroll scan marking episodes checked forever after an OutOfMemoryError nobody saw. transcribe.Breaker now abandons a pass after three consecutive failures and gives those attempts back, since a run of failures is the engine rather than the episodes -- while one bad file among good ones is still marked, because the same bytes will fail the same way next time. NeedsRecognising is deliberately not the negation of TranscriptExamined. A declined transcript means examined, no words, and exactly the episode recognition exists for. **First validation against ground truth the detector did not produce.** Up First "Democrats Lead In NPR Poll": the feed declares 788s, the file served 890s, so 102s of advertising was inserted -- and reading the recognised words found 100s of it, in two pods, every line unmistakable ad copy. The sibling episode found 21s of 131s: a whole pod at 54-130s was missed because its copy carries only one kind of marker. Reported rather than tuned around, on two episodes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'server/internal')
-rw-r--r--server/internal/store/store.go20
-rw-r--r--server/internal/store/store_test.go48
-rw-r--r--server/internal/transcribe/breaker.go46
-rw-r--r--server/internal/transcribe/breaker_test.go77
-rw-r--r--server/internal/transcribe/whisper.go218
-rw-r--r--server/internal/transcribe/whisper_test.go147
6 files changed, 556 insertions, 0 deletions
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)
+ }
+}