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
52 changes: 26 additions & 26 deletions cmd/vox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ import (
"vox/internal/inject"
"vox/internal/pipeline"
"vox/internal/prompt"
"vox/internal/sttmodel"
"vox/internal/transcribe"
"vox/internal/ui"
"vox/internal/userconfig"
"vox/internal/vocab"
"vox/internal/whispermodel"
"vox/internal/whisperserver"
)

Expand Down Expand Up @@ -160,13 +160,13 @@ func run() {
}

// Resolve selected whisper model + start the embedded whisper-server child.
selectedModel, ok := whispermodel.ByID(cfg.ModelID)
selectedModel, ok := sttmodel.ByID(cfg.ModelID)
if !ok {
selectedModel, _ = whispermodel.ByID(whispermodel.DefaultID)
selectedModel, _ = sttmodel.ByID(sttmodel.DefaultID)
}
if !whispermodel.IsInstalled(selectedModel) {
if !sttmodel.IsInstalled(selectedModel) {
fmt.Printf("Selected model %q is not installed, downloading...\n", selectedModel.ID)
if err := whispermodel.Download(ctx, selectedModel, nil); err != nil {
if err := sttmodel.Download(ctx, selectedModel, nil); err != nil {
fmt.Fprintf(os.Stderr, "Error downloading model %q: %v\n", selectedModel.ID, err)
os.Exit(1)
}
Expand All @@ -176,7 +176,7 @@ func run() {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
modelPath, err := whispermodel.Path(selectedModel)
modelPath, err := sttmodel.ResolvePath(selectedModel)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
Expand Down Expand Up @@ -365,23 +365,23 @@ func shutdownWatcher(cancel context.CancelFunc, logger *slog.Logger, recorder *a
}

func buildModelPresets() []ui.ModelPreset {
models := whispermodel.All()
models := sttmodel.All()
presets := make([]ui.ModelPreset, 0, len(models))
for _, m := range models {
presets = append(presets, ui.ModelPreset{
ID: m.ID,
Label: m.Label,
Installed: whispermodel.IsInstalled(m),
Installed: sttmodel.IsInstalled(m),
})
}
return presets
}

func buildModelRemovePresets() []ui.ModelRemovePreset {
var out []ui.ModelRemovePreset
nInstalled := whispermodel.InstalledCount()
for _, m := range whispermodel.All() {
if whispermodel.IsInstalled(m) {
nInstalled := sttmodel.InstalledCount()
for _, m := range sttmodel.All() {
if sttmodel.IsInstalled(m) {
label := m.Label
if nInstalled <= 1 {
label = m.Label + " (required)"
Expand All @@ -402,9 +402,9 @@ func refreshModelMenus() {
ui.SetModelRemovePresets(buildModelRemovePresets())
}

func activateWhisperModel(ctx context.Context, whisperClient *transcribe.Client, whisperSrv *whisperserver.Server, model whispermodel.Model) error {
if !whispermodel.IsInstalled(model) {
err := whispermodel.Download(ctx, model, func(downloaded, total int64) {
func activateWhisperModel(ctx context.Context, whisperClient *transcribe.Client, whisperSrv *whisperserver.Server, model sttmodel.Model) error {
if !sttmodel.IsInstalled(model) {
err := sttmodel.Download(ctx, model, func(downloaded, total int64) {
if total > 0 {
pct := (downloaded * 100) / total
ui.SetStatusLine(fmt.Sprintf("Status: Downloading %s (%d%%)…", model.ID, pct))
Expand All @@ -416,7 +416,7 @@ func activateWhisperModel(ctx context.Context, whisperClient *transcribe.Client,
return err
}
}
path, err := whispermodel.Path(model)
path, err := sttmodel.ResolvePath(model)
if err != nil {
return err
}
Expand All @@ -430,19 +430,19 @@ func activateWhisperModel(ctx context.Context, whisperClient *transcribe.Client,
return switchErr
}

func pickFallbackModel(excludeID string) (whispermodel.Model, error) {
func pickFallbackModel(excludeID string) (sttmodel.Model, error) {
// Prefer any installed model that isn't the one being excluded.
for _, m := range whispermodel.All() {
for _, m := range sttmodel.All() {
if m.ID == excludeID {
continue
}
if whispermodel.IsInstalled(m) {
if sttmodel.IsInstalled(m) {
return m, nil
}
}
// No installed fallback found — return an error rather than silently
// triggering a download for a non-installed model.
return whispermodel.Model{}, fmt.Errorf("no installed fallback model (excluding %s)", excludeID)
return sttmodel.Model{}, fmt.Errorf("no installed fallback model (excluding %s)", excludeID)
}

func whisperLogPath() string {
Expand Down Expand Up @@ -513,7 +513,7 @@ func modelChangeWatcher(ctx context.Context, logger *slog.Logger, client *transc
return
case modelID := <-ui.OnModelChange():
modelMu.Lock()
model, ok := whispermodel.ByID(modelID)
model, ok := sttmodel.ByID(modelID)
if !ok {
logger.Warn("unknown model selected", "id", modelID)
modelMu.Unlock()
Expand Down Expand Up @@ -552,12 +552,12 @@ func modelDeleteWatcher(ctx context.Context, logger *slog.Logger, whisperClient
return
case modelID := <-ui.OnModelDelete():
modelMu.Lock()
model, ok := whispermodel.ByID(modelID)
if !ok || !whispermodel.IsInstalled(model) {
model, ok := sttmodel.ByID(modelID)
if !ok || !sttmodel.IsInstalled(model) {
modelMu.Unlock()
continue
}
if whispermodel.InstalledCount() <= 1 {
if sttmodel.InstalledCount() <= 1 {
logger.Info("refusing remove: last downloaded model", "id", modelID)
ui.SetStatusLine("Status: Idle")
fmt.Printf("Can't remove your only downloaded model.\n")
Expand Down Expand Up @@ -592,7 +592,7 @@ func modelDeleteWatcher(ctx context.Context, logger *slog.Logger, whisperClient
}
}

if err := whispermodel.Remove(model); err != nil {
if err := sttmodel.Remove(model); err != nil {
logger.Warn("remove model file", "id", model.ID, "error", err)
ui.SetStatusLine("Status: Remove failed")
} else {
Expand Down Expand Up @@ -889,8 +889,8 @@ func handleStopAndProcess(
fmt.Println("Ready!")
}

// transcribeStage returns a pipeline stage that sends audio to the Whisper API.
func transcribeStage(client *transcribe.Client, opts transcribe.TranscribeOptions) pipeline.Stage {
// transcribeStage returns a pipeline stage that converts recorded audio to text.
func transcribeStage(client transcribe.Transcriber, opts transcribe.TranscribeOptions) pipeline.Stage {
return func(ctx context.Context, r *pipeline.Result) error {
text, err := client.Transcribe(ctx, r.RawAudio, opts)
if err != nil {
Expand Down
41 changes: 30 additions & 11 deletions cmd/vox/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,32 @@ import (
"os"
"testing"

"vox/internal/whispermodel"
"path/filepath"

"vox/internal/sttmodel"
)

func TestPickFallbackModel_MultipleInstalled(t *testing.T) {
t.Setenv("WHISPER_MODEL_DIR", t.TempDir())
t.Setenv("VOX_MODEL_DIR", t.TempDir())

// Install two models: tiny.en and base.en.
tiny, ok := whispermodel.ByID("tiny.en")
tiny, ok := sttmodel.ByID("tiny.en")
if !ok {
t.Fatal("ByID tiny.en")
}
base, ok := whispermodel.ByID("base.en")
base, ok := sttmodel.ByID("base.en")
if !ok {
t.Fatal("ByID base.en")
}
for _, m := range []whispermodel.Model{tiny, base} {
p, err := whispermodel.Path(m)
for _, m := range []sttmodel.Model{tiny, base} {
p, err := sttmodel.Path(m)
if err != nil {
t.Fatalf("Path %s: %v", m.ID, err)
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("MkdirAll %s: %v", m.ID, err)
}
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatalf("WriteFile %s: %v", m.ID, err)
}
Expand All @@ -50,16 +56,20 @@ func TestPickFallbackModel_MultipleInstalled(t *testing.T) {

func TestPickFallbackModel_OnlyOneInstalled(t *testing.T) {
t.Setenv("WHISPER_MODEL_DIR", t.TempDir())
t.Setenv("VOX_MODEL_DIR", t.TempDir())

// Install only base.en.
base, ok := whispermodel.ByID("base.en")
base, ok := sttmodel.ByID("base.en")
if !ok {
t.Fatal("ByID base.en")
}
p, err := whispermodel.Path(base)
p, err := sttmodel.Path(base)
if err != nil {
t.Fatalf("Path: %v", err)
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
Expand All @@ -73,6 +83,7 @@ func TestPickFallbackModel_OnlyOneInstalled(t *testing.T) {

func TestPickFallbackModel_NoneInstalled(t *testing.T) {
t.Setenv("WHISPER_MODEL_DIR", t.TempDir())
t.Setenv("VOX_MODEL_DIR", t.TempDir())

_, err := pickFallbackModel("base.en")
if err == nil {
Expand All @@ -82,16 +93,20 @@ func TestPickFallbackModel_NoneInstalled(t *testing.T) {

func TestBuildModelRemovePresets_LastModelProtected(t *testing.T) {
t.Setenv("WHISPER_MODEL_DIR", t.TempDir())
t.Setenv("VOX_MODEL_DIR", t.TempDir())

// Install one model.
base, ok := whispermodel.ByID("base.en")
base, ok := sttmodel.ByID("base.en")
if !ok {
t.Fatal("ByID base.en")
}
p, err := whispermodel.Path(base)
p, err := sttmodel.Path(base)
if err != nil {
t.Fatalf("Path: %v", err)
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
Expand All @@ -110,17 +125,21 @@ func TestBuildModelRemovePresets_LastModelProtected(t *testing.T) {

func TestBuildModelRemovePresets_MultipleRemovable(t *testing.T) {
t.Setenv("WHISPER_MODEL_DIR", t.TempDir())
t.Setenv("VOX_MODEL_DIR", t.TempDir())

// Install two models.
for _, id := range []string{"tiny.en", "base.en"} {
m, ok := whispermodel.ByID(id)
m, ok := sttmodel.ByID(id)
if !ok {
t.Fatalf("ByID %s", id)
}
p, err := whispermodel.Path(m)
p, err := sttmodel.Path(m)
if err != nil {
t.Fatalf("Path %s: %v", id, err)
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("MkdirAll %s: %v", m.ID, err)
}
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatalf("WriteFile %s: %v", id, err)
}
Expand Down
31 changes: 31 additions & 0 deletions cmd/vox/stages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,39 @@ import (
"testing"

"vox/internal/pipeline"
"vox/internal/transcribe"
)

type fakeTranscriber struct {
text string
err error
got []byte
}

func (f *fakeTranscriber) Transcribe(_ context.Context, wav []byte, _ transcribe.TranscribeOptions) (string, error) {
f.got = wav
return f.text, f.err
}

func TestTranscribeStageUsesInterface(t *testing.T) {
ft := &fakeTranscriber{text: "hello world"}
stage := transcribeStage(ft, transcribe.TranscribeOptions{})

r := &pipeline.Result{RawAudio: []byte("fake-wav")}
if err := stage(context.Background(), r); err != nil {
t.Fatalf("stage: %v", err)
}
if r.RawText != "hello world" {
t.Errorf("RawText = %q, want %q", r.RawText, "hello world")
}
if r.OutputText != "hello world" {
t.Errorf("OutputText = %q, want %q", r.OutputText, "hello world")
}
if string(ft.got) != "fake-wav" {
t.Errorf("transcriber got %q, want %q", ft.got, "fake-wav")
}
}

func TestFilterBlankStage_EmptyText(t *testing.T) {
stage := filterBlankStage()
r := &pipeline.Result{RawText: ""}
Expand Down
6 changes: 3 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"strings"

"vox/internal/hotkey"
"vox/internal/whispermodel"
"vox/internal/sttmodel"
)

// Config holds runtime configuration for the vox dictation tool.
Expand Down Expand Up @@ -63,8 +63,8 @@ func Load() Config {
if modelID == "" {
modelID = prefs.Model
}
if _, ok := whispermodel.ByID(modelID); !ok {
modelID = whispermodel.DefaultID
if _, ok := sttmodel.ByID(modelID); !ok {
modelID = sttmodel.DefaultID
}

return Config{
Expand Down
Loading
Loading