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
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
56 changes: 10 additions & 46 deletions apps/sotto/internal/config/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package config
import (
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestParseValidJSONCConfig(t *testing.T) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
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.
38 changes: 23 additions & 15 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,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)
})
}

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 All @@ -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.
Expand Down Expand Up @@ -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)
}
}()
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
Loading
Loading