Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ var checkFfmpegAvailable = func() error {
return nil
}

// checkTranscriberAvailable reports whether the configured whisper.cpp
// transcriber toolchain (binary + model file) is ready to run. Checking this
// at record-start — not just inside Transcribe() after the recording has
// finished — lets a missing binary/model fail fast before a recording is
// spent. Injectable so tests never depend on (or require the absence of) a
// real whisper.cpp install.
var checkTranscriberAvailable = func(cfg *config.Config) error {
return jlog.CheckAvailable(cfg.LogTranscriberModelDirAbs(), cfg.Log.Transcriber.Model)
}

// spawnDetached re-invokes the current binary with args as a background
// process that survives after the current process exits (a new session, and
// stdio redirected to /dev/null).
Expand Down Expand Up @@ -200,6 +210,10 @@ func runLogStartRecording(cfg *config.Config, out io.Writer) error {
if err := checkFfmpegAvailable(); err != nil {
return err
}
if err := checkTranscriberAvailable(cfg); err != nil {
audio.Notify(newNotifier(cfg), "✕ journal log failed", err.Error(), out)
return err
}

tmpDir, err := cfg.LogAudioTmpDirAbs()
if err != nil {
Expand Down
77 changes: 77 additions & 0 deletions cmd/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,16 @@ func stubFfmpegAvailable(t *testing.T, err error) {
t.Cleanup(func() { checkFfmpegAvailable = orig })
}

// stubTranscriberAvailable overrides the transcriber-toolchain preflight
// check so start tests never depend on (or require the absence of) a real
// whisper.cpp install/model.
func stubTranscriberAvailable(t *testing.T, err error) {
t.Helper()
orig := checkTranscriberAvailable
checkTranscriberAvailable = func(*config.Config) error { return err }
t.Cleanup(func() { checkTranscriberAvailable = orig })
}

// stubSpawnDaemon overrides spawnDaemon to record its call instead of
// forking a real background process. It writes the lockfile itself,
// simulating the daemon's first action, unless writeLock is false.
Expand Down Expand Up @@ -506,6 +516,7 @@ func TestLogToggleStartsWhenIdle(t *testing.T) {
cfg := testRepo(t, nil)
isolateLock(t)
stubFfmpegAvailable(t, nil)
stubTranscriberAvailable(t, nil)
gotWAV := stubSpawnDaemon(t, true)

var out bytes.Buffer
Expand All @@ -524,6 +535,7 @@ func TestLogStartRecordingNotifiesOnStart(t *testing.T) {
cfg := testRepo(t, nil)
isolateLock(t)
stubFfmpegAvailable(t, nil)
stubTranscriberAvailable(t, nil)
stubSpawnDaemon(t, true)
notifier := stubNewNotifier(t)

Expand Down Expand Up @@ -567,6 +579,7 @@ func TestLogToggleRecoversStaleLock(t *testing.T) {
t.Fatal(err)
}
stubFfmpegAvailable(t, nil)
stubTranscriberAvailable(t, nil)
gotWAV := stubSpawnDaemon(t, true)

var out bytes.Buffer
Expand Down Expand Up @@ -634,6 +647,70 @@ func TestLogStartRecordingFfmpegMissingFailsFast(t *testing.T) {
}
}

func TestLogStartRecordingTranscriberPreflight(t *testing.T) {
tests := []struct {
name string
transcriberErr error
}{
{name: "missing binary", transcriberErr: errors.New("whisper.cpp binary not found in PATH")},
{name: "missing model", transcriberErr: errors.New(`model file not found at "x.bin": run "journal models pull"`)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := testRepo(t, nil)
isolateLock(t)
stubFfmpegAvailable(t, nil)
stubTranscriberAvailable(t, tt.transcriberErr)
notifier := stubNewNotifier(t)
spawnCalled := false
orig := spawnDaemon
spawnDaemon = func(string) error { spawnCalled = true; return nil }
t.Cleanup(func() { spawnDaemon = orig })

var out bytes.Buffer
err := runLogStartRecording(cfg, &out)
if err == nil {
t.Fatal("expected an error when the transcriber toolchain is unavailable")
}
if !errors.Is(err, tt.transcriberErr) {
t.Errorf("err = %v, want %v", err, tt.transcriberErr)
}
if spawnCalled {
t.Error("spawnDaemon should not be called when the transcriber preflight check fails")
}
if _, lockErr := audio.ReadLock(); !errors.Is(lockErr, os.ErrNotExist) {
t.Error("no lockfile should be written when the transcriber preflight check fails")
}
if len(notifier.Calls) != 1 {
t.Fatalf("expected 1 failure notification, got %d: %+v", len(notifier.Calls), notifier.Calls)
}
if notifier.Calls[0].Title != "✕ journal log failed" {
t.Errorf("notification title = %q, want %q", notifier.Calls[0].Title, "✕ journal log failed")
}
if notifier.Calls[0].Message != tt.transcriberErr.Error() {
t.Errorf("notification message = %q, want %q", notifier.Calls[0].Message, tt.transcriberErr.Error())
}
})
}
}

func TestLogStartRecordingTranscriberAvailablePassesThrough(t *testing.T) {
cfg := testRepo(t, nil)
isolateLock(t)
stubFfmpegAvailable(t, nil)
stubTranscriberAvailable(t, nil)
gotWAV := stubSpawnDaemon(t, true)

var out bytes.Buffer
if err := runLogStartRecording(cfg, &out); err != nil {
t.Fatal(err)
}
if *gotWAV == "" {
t.Error("spawnDaemon should be called when the transcriber preflight check passes")
}
}

func TestLogStopRecordingNoActiveRecording(t *testing.T) {
cfg := testRepo(t, nil)
isolateLock(t)
Expand Down
17 changes: 17 additions & 0 deletions internal/log/transcribe.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ func (w *WhisperCPP) Transcribe(ctx context.Context, audioPath string) (string,
return strings.TrimSpace(string(out)), dur, nil
}

// CheckAvailable verifies the whisper.cpp binary resolves on PATH and the
// configured model file exists, without running transcription. It performs
// the same checks Transcribe does lazily, so callers can preflight the
// toolchain (e.g. before starting a recording) and fail fast with the same
// actionable message rather than discovering the problem only after
// transcription is attempted.
func CheckAvailable(modelDir, model string) error {
modelPath := filepath.Join(modelDir, model+".bin")
if _, err := os.Stat(modelPath); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("model file not found at %q: run `journal models pull`", modelPath)
}
if _, err := findWhisperBin(); err != nil {
return err
}
return nil
}

// findWhisperBin looks for the whisper.cpp binary in PATH, trying common names.
func findWhisperBin() (string, error) {
for _, name := range []string{"whisper-cli", "whisper-cpp", "whisper"} {
Expand Down
76 changes: 76 additions & 0 deletions internal/log/transcribe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,82 @@ func TestWhisperCPPMissingModelFailsFast(t *testing.T) {
}
}

// writeFakeWhisperBin creates an executable file named whisper-cli inside a
// fresh temp dir and points PATH at it, so findWhisperBin resolves without
// depending on a real whisper.cpp install.
func writeFakeWhisperBin(t *testing.T) {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "whisper-cli")
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
}

func TestCheckAvailable(t *testing.T) {
writeModel := func(t *testing.T, modelDir, model string) {
t.Helper()
if err := os.WriteFile(filepath.Join(modelDir, model+".bin"), []byte("fake"), 0o644); err != nil {
t.Fatal(err)
}
}

tests := []struct {
name string
setup func(t *testing.T, modelDir, model string)
wantErr bool
wantMatch string
}{
{
name: "present",
setup: func(t *testing.T, modelDir, model string) {
writeModel(t, modelDir, model)
writeFakeWhisperBin(t)
},
wantErr: false,
},
{
name: "missing binary",
setup: func(t *testing.T, modelDir, model string) {
writeModel(t, modelDir, model)
t.Setenv("PATH", t.TempDir())
},
wantErr: true,
wantMatch: "whisper.cpp binary not found",
},
{
name: "missing model",
setup: func(t *testing.T, modelDir, model string) {
writeFakeWhisperBin(t)
},
wantErr: true,
wantMatch: "journal models pull",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modelDir := t.TempDir()
tt.setup(t, modelDir, "base.en")

err := CheckAvailable(modelDir, "base.en")
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantMatch) {
t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantMatch)
}
return
}
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
})
}
}

func TestWhisperCPPName(t *testing.T) {
w := NewWhisperCPP("/tmp", "base.en")
if got := w.Name(); got != "whisper.cpp/base.en" {
Expand Down
Loading