Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 6 additions & 13 deletions apps/sotto/internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
45 changes: 6 additions & 39 deletions apps/sotto/internal/config/parser_jsonc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
42 changes: 0 additions & 42 deletions apps/sotto/internal/config/parser_legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 14 additions & 44 deletions apps/sotto/internal/config/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,27 +193,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 {
Expand All @@ -224,32 +203,23 @@ 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"
}
}
`, 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)
func TestParseIndicatorTextKeysRejected(t *testing.T) {
_, _, err := Parse(`{"indicator":{"text_recording":"Recording"}}`, Default())
if err == nil {
t.Fatal("expected error for indicator.text_recording")
}
if cfg.Indicator.SoundStopFile != "/tmp/stop.wav" {
t.Fatalf("unexpected stop file: %q", cfg.Indicator.SoundStopFile)
if !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Indicator.SoundCompleteFile != "/tmp/complete.wav" {
t.Fatalf("unexpected complete file: %q", cfg.Indicator.SoundCompleteFile)
}

func TestParseIndicatorSoundFileKeysRejected(t *testing.T) {
_, _, err := Parse(`{"indicator":{"sound_start_file":"/tmp/start.wav"}}`, Default())
if err == nil {
t.Fatal("expected error for indicator.sound_start_file")
}
if cfg.Indicator.SoundCancelFile != "/tmp/cancel.wav" {
t.Fatalf("unexpected cancel file: %q", cfg.Indicator.SoundCancelFile)
if !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("unexpected error: %v", err)
}
}

Expand Down
19 changes: 6 additions & 13 deletions apps/sotto/internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file not shown.
Binary file added apps/sotto/internal/indicator/assets/complete.wav
Binary file not shown.
Binary file not shown.
Binary file not shown.
19 changes: 12 additions & 7 deletions apps/sotto/internal/indicator/indicator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,7 +39,11 @@ 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.
Expand All @@ -49,7 +54,7 @@ func (h *HyprNotify) ShowRecording(ctx context.Context) {
}
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)
})
}

Expand All @@ -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)
})
}

Expand All @@ -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 {
Expand Down Expand Up @@ -198,7 +203,7 @@ func (h *HyprNotify) playCue(kind cueKind) {
go func() {
h.soundMu.Lock()
defer h.soundMu.Unlock()
if err := emitCue(kind, h.cfg); err != nil {
if err := emitCue(kind); err != nil {
h.log("indicator audio cue failed", err)
}
}()
Expand Down
9 changes: 3 additions & 6 deletions apps/sotto/internal/indicator/indicator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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])
}

Expand Down
43 changes: 43 additions & 0 deletions apps/sotto/internal/indicator/messages.go
Original file line number Diff line number Diff line change
@@ -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",
}
}
}
Loading
Loading