diff --git a/README.md b/README.md index 58a0f7e..26ae657 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Local-first speech-to-text CLI. - indicator backends: - `hypr` notifications - `desktop` (freedesktop notifications, e.g. mako) -- optional WAV cue files for start/stop/complete/cancel +- embedded cue WAV assets for start/stop/complete/cancel (not user-configurable) +- built-in indicator localization scaffolding (English catalog currently shipped) - built-in environment diagnostics via `sotto doctor` ## Platform scope (current) diff --git a/apps/sotto/internal/config/defaults.go b/apps/sotto/internal/config/defaults.go index ce87203..3ce90e7 100644 --- a/apps/sotto/internal/config/defaults.go +++ b/apps/sotto/internal/config/defaults.go @@ -20,19 +20,12 @@ func Default() Config { }, Transcript: TranscriptConfig{TrailingSpace: true}, Indicator: IndicatorConfig{ - Enable: true, - Backend: "hypr", - DesktopAppName: "sotto-indicator", - SoundEnable: true, - SoundStartFile: "", - SoundStopFile: "", - SoundCompleteFile: "", - SoundCancelFile: "", - Height: 28, - TextRecording: "Recording…", - TextProcessing: "Transcribing…", - TextError: "Speech recognition error", - ErrorTimeoutMS: 1600, + Enable: true, + Backend: "hypr", + DesktopAppName: "sotto-indicator", + SoundEnable: true, + Height: 28, + ErrorTimeoutMS: 1600, }, Clipboard: CommandConfig{Raw: clipboard, Argv: mustParseArgv(clipboard)}, Vocab: VocabConfig{ diff --git a/apps/sotto/internal/config/parser_jsonc.go b/apps/sotto/internal/config/parser_jsonc.go index 82f220c..a117853 100644 --- a/apps/sotto/internal/config/parser_jsonc.go +++ b/apps/sotto/internal/config/parser_jsonc.go @@ -49,20 +49,12 @@ type jsoncTranscript struct { } type jsoncIndicator struct { - Enable *bool `json:"enable"` - Backend *string `json:"backend"` - DesktopAppName *string `json:"desktop_app_name"` - SoundEnable *bool `json:"sound_enable"` - SoundStartFile *string `json:"sound_start_file"` - SoundStopFile *string `json:"sound_stop_file"` - SoundCompleteFile *string `json:"sound_complete_file"` - SoundCancelFile *string `json:"sound_cancel_file"` - Height *int `json:"height"` - TextRecording *string `json:"text_recording"` - TextProcessing *string `json:"text_processing"` - TextTranscribing *string `json:"text_transcribing"` - TextError *string `json:"text_error"` - ErrorTimeoutMS *int `json:"error_timeout_ms"` + Enable *bool `json:"enable"` + Backend *string `json:"backend"` + DesktopAppName *string `json:"desktop_app_name"` + SoundEnable *bool `json:"sound_enable"` + Height *int `json:"height"` + ErrorTimeoutMS *int `json:"error_timeout_ms"` } type jsoncVocab struct { @@ -201,34 +193,9 @@ func (payload jsoncConfig) applyTo(cfg *Config) ([]Warning, error) { if payload.Indicator.SoundEnable != nil { cfg.Indicator.SoundEnable = *payload.Indicator.SoundEnable } - if payload.Indicator.SoundStartFile != nil { - cfg.Indicator.SoundStartFile = *payload.Indicator.SoundStartFile - } - if payload.Indicator.SoundStopFile != nil { - cfg.Indicator.SoundStopFile = *payload.Indicator.SoundStopFile - } - if payload.Indicator.SoundCompleteFile != nil { - cfg.Indicator.SoundCompleteFile = *payload.Indicator.SoundCompleteFile - } - if payload.Indicator.SoundCancelFile != nil { - cfg.Indicator.SoundCancelFile = *payload.Indicator.SoundCancelFile - } if payload.Indicator.Height != nil { cfg.Indicator.Height = *payload.Indicator.Height } - if payload.Indicator.TextRecording != nil { - cfg.Indicator.TextRecording = *payload.Indicator.TextRecording - } - if payload.Indicator.TextTranscribing != nil { - cfg.Indicator.TextProcessing = *payload.Indicator.TextTranscribing - warnings = append(warnings, Warning{Message: "indicator.text_transcribing is deprecated; use indicator.text_processing"}) - } - if payload.Indicator.TextProcessing != nil { - cfg.Indicator.TextProcessing = *payload.Indicator.TextProcessing - } - if payload.Indicator.TextError != nil { - cfg.Indicator.TextError = *payload.Indicator.TextError - } if payload.Indicator.ErrorTimeoutMS != nil { cfg.Indicator.ErrorTimeoutMS = *payload.Indicator.ErrorTimeoutMS } diff --git a/apps/sotto/internal/config/parser_legacy.go b/apps/sotto/internal/config/parser_legacy.go index 1e29543..b58faea 100644 --- a/apps/sotto/internal/config/parser_legacy.go +++ b/apps/sotto/internal/config/parser_legacy.go @@ -242,54 +242,12 @@ func applyRootKey(cfg *Config, key, value string) error { return fmt.Errorf("invalid bool for indicator.sound_enable: %w", err) } cfg.Indicator.SoundEnable = b - case "indicator.sound_start_file": - v, err := parseStringValue(value) - if err != nil { - return err - } - cfg.Indicator.SoundStartFile = v - case "indicator.sound_stop_file": - v, err := parseStringValue(value) - if err != nil { - return err - } - cfg.Indicator.SoundStopFile = v - case "indicator.sound_complete_file": - v, err := parseStringValue(value) - if err != nil { - return err - } - cfg.Indicator.SoundCompleteFile = v - case "indicator.sound_cancel_file": - v, err := parseStringValue(value) - if err != nil { - return err - } - cfg.Indicator.SoundCancelFile = v case "indicator.height": n, err := strconv.Atoi(value) if err != nil { return fmt.Errorf("invalid int for indicator.height: %w", err) } cfg.Indicator.Height = n - case "indicator.text_recording": - v, err := parseStringValue(value) - if err != nil { - return err - } - cfg.Indicator.TextRecording = v - case "indicator.text_processing", "indicator.text_transcribing": - v, err := parseStringValue(value) - if err != nil { - return err - } - cfg.Indicator.TextProcessing = v - case "indicator.text_error": - v, err := parseStringValue(value) - if err != nil { - return err - } - cfg.Indicator.TextError = v case "indicator.error_timeout_ms": n, err := strconv.Atoi(value) if err != nil { diff --git a/apps/sotto/internal/config/parser_test.go b/apps/sotto/internal/config/parser_test.go index 6b071cd..e7cdd6e 100644 --- a/apps/sotto/internal/config/parser_test.go +++ b/apps/sotto/internal/config/parser_test.go @@ -3,6 +3,8 @@ package config import ( "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestParseValidJSONCConfig(t *testing.T) { @@ -193,27 +195,6 @@ func TestParseIndicatorBackend(t *testing.T) { } } -func TestParseIndicatorTextTranscribingAliasWarning(t *testing.T) { - cfg, warnings, err := Parse(`{"indicator":{"text_transcribing":"Working..."}}`, Default()) - if err != nil { - t.Fatalf("Parse() error = %v", err) - } - if cfg.Indicator.TextProcessing != "Working..." { - t.Fatalf("unexpected text processing value: %q", cfg.Indicator.TextProcessing) - } - - found := false - for _, w := range warnings { - if strings.Contains(w.Message, "text_transcribing") { - found = true - break - } - } - if !found { - t.Fatalf("expected alias warning, warnings=%+v", warnings) - } -} - func TestParseIndicatorSoundEnable(t *testing.T) { cfg, _, err := Parse(`{"indicator":{"sound_enable":false}}`, Default()) if err != nil { @@ -224,33 +205,16 @@ func TestParseIndicatorSoundEnable(t *testing.T) { } } -func TestParseIndicatorSoundFiles(t *testing.T) { - cfg, _, err := Parse(` -{ - "indicator": { - "sound_start_file": "/tmp/start.wav", - "sound_stop_file": "/tmp/stop.wav", - "sound_complete_file": "/tmp/complete.wav", - "sound_cancel_file": "/tmp/cancel.wav" - } +func TestParseIndicatorTextKeysRejected(t *testing.T) { + _, _, err := Parse(`{"indicator":{"text_recording":"Recording"}}`, Default()) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown field") } -`, Default()) - if err != nil { - t.Fatalf("Parse() error = %v", err) - } - if cfg.Indicator.SoundStartFile != "/tmp/start.wav" { - t.Fatalf("unexpected start file: %q", cfg.Indicator.SoundStartFile) - } - if cfg.Indicator.SoundStopFile != "/tmp/stop.wav" { - t.Fatalf("unexpected stop file: %q", cfg.Indicator.SoundStopFile) - } - if cfg.Indicator.SoundCompleteFile != "/tmp/complete.wav" { - t.Fatalf("unexpected complete file: %q", cfg.Indicator.SoundCompleteFile) - } - if cfg.Indicator.SoundCancelFile != "/tmp/cancel.wav" { - t.Fatalf("unexpected cancel file: %q", cfg.Indicator.SoundCancelFile) - } +func TestParseIndicatorSoundFileKeysRejected(t *testing.T) { + _, _, err := Parse(`{"indicator":{"sound_start_file":"/tmp/start.wav"}}`, Default()) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown field") } func TestParseInitializesNilVocabMap(t *testing.T) { diff --git a/apps/sotto/internal/config/types.go b/apps/sotto/internal/config/types.go index 917a92a..5687a11 100644 --- a/apps/sotto/internal/config/types.go +++ b/apps/sotto/internal/config/types.go @@ -43,19 +43,12 @@ type TranscriptConfig struct { // IndicatorConfig controls visual indicator and audio cue behavior. type IndicatorConfig struct { - Enable bool - Backend string - DesktopAppName string - SoundEnable bool - SoundStartFile string - SoundStopFile string - SoundCompleteFile string - SoundCancelFile string - Height int - TextRecording string - TextProcessing string - TextError string - ErrorTimeoutMS int + Enable bool + Backend string + DesktopAppName string + SoundEnable bool + Height int + ErrorTimeoutMS int } // CommandConfig stores a raw command string and its parsed argv form. diff --git a/apps/sotto/internal/indicator/assets/cancel.wav b/apps/sotto/internal/indicator/assets/cancel.wav new file mode 100644 index 0000000..e43191f Binary files /dev/null and b/apps/sotto/internal/indicator/assets/cancel.wav differ diff --git a/apps/sotto/internal/indicator/assets/complete.wav b/apps/sotto/internal/indicator/assets/complete.wav new file mode 100644 index 0000000..42c8cf9 Binary files /dev/null and b/apps/sotto/internal/indicator/assets/complete.wav differ diff --git a/apps/sotto/internal/indicator/assets/toggle_off.wav b/apps/sotto/internal/indicator/assets/toggle_off.wav new file mode 100644 index 0000000..c9cac68 Binary files /dev/null and b/apps/sotto/internal/indicator/assets/toggle_off.wav differ diff --git a/apps/sotto/internal/indicator/assets/toggle_on.wav b/apps/sotto/internal/indicator/assets/toggle_on.wav new file mode 100644 index 0000000..219ff35 Binary files /dev/null and b/apps/sotto/internal/indicator/assets/toggle_on.wav differ diff --git a/apps/sotto/internal/indicator/indicator.go b/apps/sotto/internal/indicator/indicator.go index 3a71239..ddc9eed 100644 --- a/apps/sotto/internal/indicator/indicator.go +++ b/apps/sotto/internal/indicator/indicator.go @@ -27,8 +27,9 @@ type Controller interface { // HyprNotify is the concrete indicator implementation used by runtime sessions. // It can route notifications via Hyprland or desktop DBus based on config backend. type HyprNotify struct { - cfg config.IndicatorConfig - logger *slog.Logger + cfg config.IndicatorConfig + logger *slog.Logger + messages messages mu sync.Mutex focusedMonitor string @@ -38,18 +39,22 @@ type HyprNotify struct { // NewHyprNotify creates an indicator controller from config. func NewHyprNotify(cfg config.IndicatorConfig, logger *slog.Logger) *HyprNotify { - return &HyprNotify{cfg: cfg, logger: logger} + return &HyprNotify{ + cfg: cfg, + logger: logger, + messages: indicatorMessagesFromEnv(), + } } // ShowRecording signals recording start and emits the start cue. func (h *HyprNotify) ShowRecording(ctx context.Context) { - h.playCue(cueStart) + h.playCue(ctx, cueStart) if !h.cfg.Enable { return } h.ensureFocusedMonitor(ctx) h.run(ctx, func(ctx context.Context) error { - return h.notify(ctx, 1, 300000, "rgb(89b4fa)", h.cfg.TextRecording) + return h.notify(ctx, 1, 300000, "rgb(89b4fa)", h.messages.recording) }) } @@ -59,7 +64,7 @@ func (h *HyprNotify) ShowTranscribing(ctx context.Context) { return } h.run(ctx, func(ctx context.Context) error { - return h.notify(ctx, 1, 300000, "rgb(cba6f7)", h.cfg.TextProcessing) + return h.notify(ctx, 1, 300000, "rgb(cba6f7)", h.messages.processing) }) } @@ -69,7 +74,7 @@ func (h *HyprNotify) ShowError(ctx context.Context, text string) { return } if text == "" { - text = h.cfg.TextError + text = h.messages.errorText } timeout := h.cfg.ErrorTimeoutMS if timeout <= 0 { @@ -81,18 +86,18 @@ func (h *HyprNotify) ShowError(ctx context.Context, text string) { } // CueStop emits the stop cue. -func (h *HyprNotify) CueStop(context.Context) { - h.playCue(cueStop) +func (h *HyprNotify) CueStop(ctx context.Context) { + h.playCue(ctx, cueStop) } // CueComplete emits the successful-commit cue. -func (h *HyprNotify) CueComplete(context.Context) { - h.playCue(cueComplete) +func (h *HyprNotify) CueComplete(ctx context.Context) { + h.playCue(ctx, cueComplete) } // CueCancel emits the cancel cue. -func (h *HyprNotify) CueCancel(context.Context) { - h.playCue(cueCancel) +func (h *HyprNotify) CueCancel(ctx context.Context) { + h.playCue(ctx, cueCancel) } // Hide dismisses the active indicator surface. @@ -191,14 +196,17 @@ func (h *HyprNotify) run(ctx context.Context, fn func(context.Context) error) { } // playCue serializes cue playback and emits audio asynchronously. -func (h *HyprNotify) playCue(kind cueKind) { +func (h *HyprNotify) playCue(ctx context.Context, kind cueKind) { if !h.cfg.SoundEnable { return } + if ctx == nil { + ctx = context.Background() + } go func() { h.soundMu.Lock() defer h.soundMu.Unlock() - if err := emitCue(kind, h.cfg); err != nil { + if err := emitCue(ctx, kind); err != nil { h.log("indicator audio cue failed", err) } }() diff --git a/apps/sotto/internal/indicator/indicator_test.go b/apps/sotto/internal/indicator/indicator_test.go index 9d86349..43deafa 100644 --- a/apps/sotto/internal/indicator/indicator_test.go +++ b/apps/sotto/internal/indicator/indicator_test.go @@ -25,9 +25,6 @@ printf '%s\n' "$*" >> "${HYPR_ARGS_FILE}" cfg := config.Default().Indicator cfg.SoundEnable = false cfg.Enable = true - cfg.TextRecording = "Recording" - cfg.TextProcessing = "Transcribing" - cfg.TextError = "Speech error" notify := NewHyprNotify(cfg, nil) notify.ShowRecording(context.Background()) @@ -41,9 +38,9 @@ printf '%s\n' "$*" >> "${HYPR_ARGS_FILE}" require.NoError(t, err) lines := strings.Split(strings.TrimSpace(string(data)), "\n") require.Len(t, lines, 4) - require.Equal(t, "--quiet dispatch notify 1 300000 rgb(89b4fa) Recording", lines[0]) - require.Equal(t, "--quiet dispatch notify 1 300000 rgb(cba6f7) Transcribing", lines[1]) - require.Equal(t, "--quiet dispatch notify 3 1600 rgb(f38ba8) Speech error", lines[2]) + require.Equal(t, "--quiet dispatch notify 1 300000 rgb(89b4fa) Recording…", lines[0]) + require.Equal(t, "--quiet dispatch notify 1 300000 rgb(cba6f7) Transcribing…", lines[1]) + require.Equal(t, "--quiet dispatch notify 3 1600 rgb(f38ba8) Speech recognition error", lines[2]) require.Equal(t, "--quiet dispatch dismissnotify", lines[3]) } diff --git a/apps/sotto/internal/indicator/messages.go b/apps/sotto/internal/indicator/messages.go new file mode 100644 index 0000000..a6a29af --- /dev/null +++ b/apps/sotto/internal/indicator/messages.go @@ -0,0 +1,43 @@ +package indicator + +import ( + "os" + "strings" +) + +type locale string + +const ( + localeEnglish locale = "en" +) + +type messages struct { + recording string + processing string + errorText string +} + +func indicatorMessagesFromEnv() messages { + return indicatorMessages(resolveLocale(os.Getenv("LANG"))) +} + +func resolveLocale(raw string) locale { + raw = strings.ToLower(strings.TrimSpace(raw)) + if strings.HasPrefix(raw, "en") { + return localeEnglish + } + return localeEnglish +} + +func indicatorMessages(tag locale) messages { + switch tag { + case localeEnglish: + fallthrough + default: + return messages{ + recording: "Recording…", + processing: "Transcribing…", + errorText: "Speech recognition error", + } + } +} diff --git a/apps/sotto/internal/indicator/messages_test.go b/apps/sotto/internal/indicator/messages_test.go new file mode 100644 index 0000000..669d1a3 --- /dev/null +++ b/apps/sotto/internal/indicator/messages_test.go @@ -0,0 +1,19 @@ +package indicator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveLocaleDefaultsToEnglish(t *testing.T) { + require.Equal(t, localeEnglish, resolveLocale("en_US.UTF-8")) + require.Equal(t, localeEnglish, resolveLocale("fr_FR.UTF-8")) +} + +func TestIndicatorMessagesEnglish(t *testing.T) { + msg := indicatorMessages(localeEnglish) + require.Equal(t, "Recording…", msg.recording) + require.Equal(t, "Transcribing…", msg.processing) + require.Equal(t, "Speech recognition error", msg.errorText) +} diff --git a/apps/sotto/internal/indicator/sound.go b/apps/sotto/internal/indicator/sound.go index 8a0141a..865034a 100644 --- a/apps/sotto/internal/indicator/sound.go +++ b/apps/sotto/internal/indicator/sound.go @@ -1,17 +1,15 @@ package indicator import ( + "bytes" "context" + "embed" "fmt" "math" - "os" "os/exec" - "path/filepath" - "strings" "time" "github.com/jfreymuth/pulse" - "github.com/rbright/sotto/internal/config" ) // cueKind identifies each cue event used by the session lifecycle. @@ -34,6 +32,14 @@ type toneSpec struct { } var ( + //go:embed assets/toggle_on.wav assets/toggle_off.wav assets/complete.wav assets/cancel.wav + cueAssetFS embed.FS + + startCueWAV = mustCueWAV("assets/toggle_on.wav") + stopCueWAV = mustCueWAV("assets/toggle_off.wav") + completeCueWAV = mustCueWAV("assets/complete.wav") + cancelCueWAV = mustCueWAV("assets/cancel.wav") + startCuePCM = synthesizeCue([]toneSpec{ {frequencyHz: 880, duration: 70 * time.Millisecond, volume: 0.18}, {frequencyHz: 1175, duration: 70 * time.Millisecond, volume: 0.18}, @@ -51,14 +57,22 @@ var ( }) ) -// emitCue plays a configured WAV file when present, otherwise falls back to synthesis. -func emitCue(kind cueKind, cfg config.IndicatorConfig) error { - if path := cuePath(kind, cfg); path != "" { - if err := playCueFile(path); err == nil { +// emitCue plays an embedded WAV cue when available, then falls back to synthesis. +func emitCue(ctx context.Context, kind cueKind) error { + if ctx == nil { + ctx = context.Background() + } + + if data := cueWAV(kind); len(data) > 0 { + if err := playCueData(ctx, data); err == nil { return nil } } + if err := ctx.Err(); err != nil { + return err + } + samples := cueSamples(kind) if len(samples) == 0 { return nil @@ -67,59 +81,45 @@ func emitCue(kind cueKind, cfg config.IndicatorConfig) error { return playSynthCue(samples) } -// cuePath resolves the configured WAV path for one cue kind. -func cuePath(kind cueKind, cfg config.IndicatorConfig) string { - var raw string +func cueWAV(kind cueKind) []byte { switch kind { case cueStart: - raw = cfg.SoundStartFile + return startCueWAV case cueStop: - raw = cfg.SoundStopFile + return stopCueWAV case cueComplete: - raw = cfg.SoundCompleteFile + return completeCueWAV case cueCancel: - raw = cfg.SoundCancelFile + return cancelCueWAV default: - return "" + return nil } - return expandUserPath(raw) } -// expandUserPath expands `~` prefixes for user-provided cue file paths. -func expandUserPath(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if raw == "~" { - home, err := os.UserHomeDir() - if err != nil { - return raw - } - return home - } - if !strings.HasPrefix(raw, "~/") { - return raw - } - home, err := os.UserHomeDir() +func mustCueWAV(path string) []byte { + data, err := cueAssetFS.ReadFile(path) if err != nil { - return raw + panic(fmt.Sprintf("load embedded cue %q: %v", path, err)) } - return filepath.Join(home, strings.TrimPrefix(raw, "~/")) + return data } -// playCueFile plays a configured WAV file through pw-play. -func playCueFile(path string) error { - if _, err := os.Stat(path); err != nil { - return fmt.Errorf("stat cue file %q: %w", path, err) +// playCueData plays an embedded WAV payload through pw-play. +func playCueData(ctx context.Context, data []byte) error { + if len(data) == 0 { + return fmt.Errorf("embedded cue payload is empty") + } + if ctx == nil { + ctx = context.Background() } - ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + runCtx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "pw-play", "--media-role", "Notification", path) + cmd := exec.CommandContext(runCtx, "pw-play", "--media-role", "Notification", "-") + cmd.Stdin = bytes.NewReader(data) if err := cmd.Run(); err != nil { - return fmt.Errorf("play cue file %q: %w", path, err) + return fmt.Errorf("play embedded cue: %w", err) } return nil } diff --git a/apps/sotto/internal/indicator/sound_test.go b/apps/sotto/internal/indicator/sound_test.go index 6ac39a4..2ec30f9 100644 --- a/apps/sotto/internal/indicator/sound_test.go +++ b/apps/sotto/internal/indicator/sound_test.go @@ -1,11 +1,11 @@ package indicator import ( - "path/filepath" + "context" + "errors" "testing" "time" - "github.com/rbright/sotto/internal/config" "github.com/stretchr/testify/require" ) @@ -16,6 +16,13 @@ func TestCueSamplesPresent(t *testing.T) { require.NotEmpty(t, cueSamples(cueCancel)) } +func TestCueEmbeddedWAVPresent(t *testing.T) { + require.NotEmpty(t, cueWAV(cueStart)) + require.NotEmpty(t, cueWAV(cueStop)) + require.NotEmpty(t, cueWAV(cueComplete)) + require.NotEmpty(t, cueWAV(cueCancel)) +} + func TestSynthesizeToneDuration(t *testing.T) { got := synthesizeTone(toneSpec{frequencyHz: 440, duration: 100 * time.Millisecond, volume: 0.2}) want := samplesForDuration(100 * time.Millisecond) @@ -28,33 +35,16 @@ func TestSynthesizeToneInvalidSpecReturnsEmpty(t *testing.T) { require.Empty(t, synthesizeTone(toneSpec{frequencyHz: 440, duration: 100 * time.Millisecond, volume: 0})) } -func TestCuePathMapping(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - cfg := config.IndicatorConfig{ - SoundStartFile: "~/start.wav", - SoundStopFile: "/tmp/stop.wav", - SoundCompleteFile: "/tmp/complete.wav", - SoundCancelFile: "/tmp/cancel.wav", - } - - require.Equal(t, filepath.Join(home, "start.wav"), cuePath(cueStart, cfg)) - require.Equal(t, "/tmp/stop.wav", cuePath(cueStop, cfg)) - require.Equal(t, "/tmp/complete.wav", cuePath(cueComplete, cfg)) - require.Equal(t, "/tmp/cancel.wav", cuePath(cueCancel, cfg)) -} - -func TestExpandUserPath(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - require.Equal(t, home, expandUserPath("~")) - require.Equal(t, filepath.Join(home, "Downloads", "sound.wav"), expandUserPath("~/Downloads/sound.wav")) - require.Equal(t, "/tmp/sound.wav", expandUserPath("/tmp/sound.wav")) - require.Empty(t, expandUserPath(" ")) -} - func TestSamplesForDuration(t *testing.T) { require.Equal(t, 0, samplesForDuration(0)) require.Greater(t, samplesForDuration(25*time.Millisecond), 0) } + +func TestEmitCueRespectsCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := emitCue(ctx, cueStart) + require.Error(t, err) + require.True(t, errors.Is(err, context.Canceled)) +} diff --git a/docs/configuration.md b/docs/configuration.md index 0e0feac..c2cd935 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -82,17 +82,12 @@ Top-level object keys: | `indicator.backend` | `hypr` | `hypr` or `desktop` | | `indicator.desktop_app_name` | `sotto-indicator` | required for desktop backend | | `indicator.sound_enable` | `true` | cue sounds switch | -| `indicator.sound_start_file` | empty | optional WAV path | -| `indicator.sound_stop_file` | empty | optional WAV path | -| `indicator.sound_complete_file` | empty | optional WAV path | -| `indicator.sound_cancel_file` | empty | optional WAV path | | `indicator.height` | `28` | indicator size parameter | -| `indicator.text_recording` | `Recording…` | recording label | -| `indicator.text_processing` | `Transcribing…` | processing label | -| `indicator.text_transcribing` | alias | compatibility alias for `indicator.text_processing` | -| `indicator.text_error` | `Speech recognition error` | error label | | `indicator.error_timeout_ms` | `1600` | `>= 0` | +Indicator text and cue assets are now application-owned (embedded in the binary) and are not user-configurable. +Localization support exists in-code with an English catalog shipped by default. + ### command keys | Key | Default | Notes | @@ -166,13 +161,6 @@ default-timeout=0 "backend": "hypr", "desktop_app_name": "sotto-indicator", "sound_enable": true, - "sound_start_file": "", - "sound_stop_file": "", - "sound_complete_file": "", - "sound_cancel_file": "", - "text_recording": "Recording…", - "text_processing": "Transcribing…", - "text_error": "Speech recognition error", "error_timeout_ms": 1600 },