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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Ctrl+Shift+Space keydown → record audio → encode (mode-based) → API call
- `device.go` - microphone picker with arrow-key navigation
- `vad.go` - voice activity detection using WebRTC VAD with debounced speech confirmation
- `silence.go` - silence monitoring with warnings, repeat beeps, and auto-close (toggle mode)
- `settings.go` - persistent settings (language, device, provider/model, auto-paste, auto-start) with JSON config file
- `log.go` - diagnostic logging and panic capture to `diagnostics_log.txt`

## Design Philosophy
Expand Down
13 changes: 13 additions & 0 deletions alert/alert_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ func Warn(msg string) {
show(msg, "caution")
}

func Info(msg string) {
show(msg, "note")
}

func Confirm(msg, action string) bool {
out, err := exec.Command("osascript", "-e",
`display dialog "`+msg+`" with title "Zee" buttons {"Cancel", "`+action+`"} default button "`+action+`" with icon note`).Output()
if err != nil {
return false
}
return string(out) != ""
}

func show(msg, icon string) {
exec.Command("osascript", "-e",
`display dialog "`+msg+`" with title "Zee" buttons {"OK"} default button "OK" with icon `+icon).Run()
Expand Down
6 changes: 4 additions & 2 deletions alert/alert_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@

package alert

func Error(_ string) {}
func Warn(_ string) {}
func Error(_ string) {}
func Warn(_ string) {}
func Info(_ string) {}
func Confirm(_, _ string) bool { return false }
128 changes: 77 additions & 51 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
_ "net/http/pprof"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"slices"
Expand Down Expand Up @@ -144,20 +145,9 @@ func run() {
fmt.Println("Already up to date.")
os.Exit(0)
}
fmt.Printf("Update available: %s -> %s\n", version, rel.Version)
fmt.Print("Continue? [y/N] ")
var answer string
fmt.Scanln(&answer)
if answer != "y" && answer != "Y" {
fmt.Println("Aborted.")
os.Exit(0)
}
fmt.Printf("Downloading %s...\n", rel.Version)
if err := update.Apply(rel); err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Updated to %s\n", rel.Version)
fmt.Printf("\nUpdate available: %s → %s\n\n", version, rel.Version)
fmt.Println("Homebrew: brew upgrade sumerc/tap/zee")
fmt.Printf("Download: %s\n", rel.URL)
os.Exit(0)
}

Expand Down Expand Up @@ -223,7 +213,24 @@ func run() {
}
os.Exit(doctor.Run(wavFile))
}
autoPaste = *autoPasteFlag
// Load persistent settings, merge with CLI flags
if err := loadSettings(); err != nil {
log.Warnf("settings: %v", err)
}
cfg := getSettings()
flagSet := map[string]bool{}
flag.Visit(func(f *flag.Flag) { flagSet[f.Name] = true })
if !flagSet["lang"] && cfg.Language != "" {
*langFlag = cfg.Language
}
if !flagSet["device"] && cfg.Device != "" {
*deviceFlag = cfg.Device
}
if !flagSet["autopaste"] {
autoPaste = cfg.AutoPaste
} else {
autoPaste = *autoPasteFlag
}
streamEnabled = *streamFlag

// Validate format
Expand All @@ -238,10 +245,26 @@ func run() {
log.Warn("format ignored in streaming mode")
}

var initErr error
activeTranscriber, initErr = transcriber.New()
if initErr != nil {
fatal("No API key set.\n\nSet GROQ_API_KEY, OPENAI_API_KEY, or DEEPGRAM_API_KEY.")
// Restore saved provider/model or fall back to auto-detection
if cfg.Provider != "" {
for _, p := range transcriber.Providers() {
if p.Name == cfg.Provider {
if key := os.Getenv(p.EnvKey); key != "" {
activeTranscriber = p.NewFn(key)
if cfg.Model != "" {
activeTranscriber.SetModel(cfg.Model)
}
}
break
}
}
}
if activeTranscriber == nil {
var initErr error
activeTranscriber, initErr = transcriber.New()
if initErr != nil {
fatal("No API key set.\n\nSet GROQ_API_KEY, OPENAI_API_KEY, or DEEPGRAM_API_KEY.")
}
}
streamEnabled = modelSupportsStream(activeTranscriber)
if *langFlag != "" {
Expand Down Expand Up @@ -348,6 +371,7 @@ func run() {
}
tray.SetDevices(names, preferredDevice, func(name string) {
preferredDevice = name
updateSettings(func(s *Settings) { s.Device = name })
if name == "" {
applyDeviceSwitch(ctx, captureConfig, &captureDevice, &selectedDevice, nil)
} else {
Expand All @@ -357,38 +381,20 @@ func run() {
}
tray.SetAutoPaste(autoPaste)

groqKey := os.Getenv("GROQ_API_KEY")
openaiKey := os.Getenv("OPENAI_API_KEY")
dgKey := os.Getenv("DEEPGRAM_API_KEY")
mistralKey := os.Getenv("MISTRAL_API_KEY")
elevenLabsKey := os.Getenv("ELEVENLABS_API_KEY")

type providerDef struct {
name, label, key string
models []transcriber.ModelInfo
newFn func() transcriber.Transcriber
}
providers := []providerDef{
{"groq", "Groq", groqKey, transcriber.GroqModels, func() transcriber.Transcriber { return transcriber.NewGroq(groqKey) }},
{"openai", "OpenAI", openaiKey, transcriber.OpenAIModels, func() transcriber.Transcriber { return transcriber.NewOpenAI(openaiKey) }},
{"deepgram", "Deepgram", dgKey, transcriber.DeepgramModels, func() transcriber.Transcriber { return transcriber.NewDeepgram(dgKey) }},
{"mistral", "Mistral", mistralKey, transcriber.MistralModels, func() transcriber.Transcriber { return transcriber.NewMistral(mistralKey) }},
{"elevenlabs", "ElevenLabs", elevenLabsKey, transcriber.ElevenLabsModels, func() transcriber.Transcriber { return transcriber.NewElevenLabs(elevenLabsKey) }},
}

var trayModels []tray.Model
modelIndex := map[string]transcriber.ModelInfo{}
for _, p := range providers {
for _, m := range p.models {
for _, p := range transcriber.Providers() {
key := os.Getenv(p.EnvKey)
for _, m := range p.Models {
trayModels = append(trayModels, tray.Model{
Provider: p.name,
ProviderLabel: p.label,
Provider: p.Name,
ProviderLabel: p.Label,
ModelID: m.ID,
Label: m.Label,
HasKey: p.key != "",
Active: activeTranscriber.Name() == p.name && activeTranscriber.GetModel() == m.ID,
HasKey: key != "",
Active: activeTranscriber.Name() == p.Name && activeTranscriber.GetModel() == m.ID,
})
modelIndex[p.name+":"+m.ID] = m
modelIndex[p.Name+":"+m.ID] = m
}
}

Expand All @@ -401,9 +407,11 @@ func run() {
currentLang := activeTranscriber.GetLanguage()

var newTr transcriber.Transcriber
for _, p := range providers {
if p.name == provider {
newTr = p.newFn()
for _, p := range transcriber.Providers() {
if p.Name == provider {
if key := os.Getenv(p.EnvKey); key != "" {
newTr = p.NewFn(key)
}
break
}
}
Expand All @@ -419,21 +427,25 @@ func run() {
activeFormat = *formatFlag
}

updateSettings(func(s *Settings) { s.Provider = provider; s.Model = model })
tray.SetLanguages(newTr.SupportedLanguages())
})

tray.SetLanguage(*langFlag, func(code string) {
configMu.Lock()
activeTranscriber.SetLanguage(code)
configMu.Unlock()
updateSettings(func(s *Settings) { s.Language = code })
})
tray.SetLogin(login.Enabled())
tray.SetVersion(version)

trayQuit := tray.Init()
tray.OnAutoPaste(func(on bool) {
configMu.Lock()
autoPaste = on
configMu.Unlock()
updateSettings(func(s *Settings) { s.AutoPaste = on })
})
tray.OnLogin(func(on bool) error {
var err error
Expand All @@ -445,6 +457,8 @@ func run() {
if err != nil {
log.Errorf("login toggle: %v", err)
tray.SetError(err.Error())
} else {
updateSettings(func(s *Settings) { s.AutoStart = on })
}
return err
})
Expand Down Expand Up @@ -484,9 +498,21 @@ func run() {
}
}()

update.StartBackgroundCheck(version, log.Dir(), func(rel update.Release) {
log.Info("update_available: " + rel.Version)
tray.SetUpdateAvailable(rel.Version)
tray.OnCheckUpdate(func() {
go func() {
rel, err := update.CheckLatest(version)
if err != nil {
alert.Warn("Could not check for updates:\n" + err.Error())
return
}
if rel == nil {
alert.Info("You're on the latest version (" + version + ")")
return
}
if alert.Confirm("Update available: "+version+" → "+rel.Version+"\n\nHomebrew:\nbrew upgrade sumerc/tap/zee", "Open Release Page") {
exec.Command("open", rel.URL).Start()
}
}()
})

sigChan := make(chan os.Signal, 1)
Expand Down
142 changes: 142 additions & 0 deletions settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"sync"

"zee/log"
)

type Settings struct {
Language string `json:"language"`
Device string `json:"device"`
Provider string `json:"provider"`
Model string `json:"model"`
AutoPaste bool `json:"auto_paste"`
AutoStart bool `json:"auto_start"`
}

const settingsFile = "config.json"

var (
settingsMu sync.Mutex
current Settings
cfgDir string
)

var settingsDefaults = Settings{
Language: "en",
AutoPaste: true,
}

func settingsDir() string {
if cfgDir != "" {
return cfgDir
}
home, err := os.UserHomeDir()
if err != nil {
return "."
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(home, "Library", "Application Support", "zee")
case "windows":
if v := os.Getenv("LOCALAPPDATA"); v != "" {
return filepath.Join(v, "zee")
}
return filepath.Join(home, "AppData", "Local", "zee")
default:
xdg := os.Getenv("XDG_CONFIG_HOME")
if xdg == "" {
xdg = filepath.Join(home, ".config")
}
return filepath.Join(xdg, "zee")
}
}

func settingsPath() string {
return filepath.Join(settingsDir(), settingsFile)
}

func loadSettings() error {
cfgDir = settingsDir()
current = settingsDefaults

data, err := os.ReadFile(settingsPath())
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}

var s Settings
if err := json.Unmarshal(data, &s); err != nil {
log.Warnf("settings: corrupt config.json, using defaults: %v", err)
return nil
}

current = s
if current.Language == "" {
current.Language = settingsDefaults.Language
}
return nil
}

func getSettings() Settings {
settingsMu.Lock()
s := current
settingsMu.Unlock()
return s
}

func updateSettings(fn func(*Settings)) {
settingsMu.Lock()
fn(&current)
s := current
settingsMu.Unlock()

saveSettings(s)
}

func saveSettings(s Settings) {
dir := cfgDir
if err := os.MkdirAll(dir, 0755); err != nil {
log.Warnf("settings: create dir: %v", err)
return
}

data, err := json.MarshalIndent(s, "", " ")
if err != nil {
log.Warnf("settings: marshal: %v", err)
return
}
data = append(data, '\n')

tmp, err := os.CreateTemp(dir, ".config-*.json")
if err != nil {
log.Warnf("settings: create temp: %v", err)
return
}
tmpPath := tmp.Name()

if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
log.Warnf("settings: write temp: %v", err)
return
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
log.Warnf("settings: close temp: %v", err)
return
}

if err := os.Rename(tmpPath, settingsPath()); err != nil {
os.Remove(tmpPath)
log.Warnf("settings: rename: %v", err)
}
}
Loading