summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/cmd/listend/main.go155
-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
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)
+ }
+}