From c029ca5b0df38c77dc7f187ffc0e6a7675ca8044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Mon, 9 Feb 2026 13:34:51 +0300 Subject: [PATCH 1/6] refactor(recording): improve recording handling - change handleRecording function to return a done channel and an error - add a mutex to protect access to the transcriptions slice - detect silence during recording and send a warning message - update the test environment to wait for the recording to finish - add a timeout to the HTTP client in the traced client - improve the recording loop to check for voice detection and silence - update the error handling in the recording function --- main.go | 74 ++++++++++++++++++++++++++++-------- testenv.go | 5 ++- transcriber/traced_client.go | 1 + 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/main.go b/main.go index 2194898..64e79cf 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ const voiceThreshold = 0.002 var activeTranscriber transcriber.Transcriber var autoPaste bool +var transcriptionsMu sync.Mutex var transcriptions []TranscriptionRecord var percentileStats PercentileStats var streamEnabled bool @@ -280,7 +281,7 @@ func run() { tuiSend(RecordingStartMsg{}) go beep.PlayStart() - err := handleRecording(captureDevice, hy.StopChan()) + _, err := handleRecording(captureDevice, hy.StopChan()) if err != nil { logToTUI("Error recording: %v", err) log.Errorf("recording error: %v", err) @@ -298,7 +299,7 @@ func run() { tuiSend(RecordingStartMsg{}) go beep.PlayStart() - err := handleRecording(captureDevice, hk.Keyup()) + _, err := handleRecording(captureDevice, hk.Keyup()) log.Info("hotkey_up") if err != nil { logToTUI("Error recording: %v", err) @@ -334,17 +335,16 @@ func handleDeviceSwitch(ctx audio.Context, captureConfig audio.CaptureConfig, ca } } -func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) error { +func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) (<-chan struct{}, error) { sess, err := activeTranscriber.NewSession(context.Background(), transcriber.SessionConfig{ Stream: streamEnabled, Format: activeFormat, Language: activeTranscriber.GetLanguage(), }) if err != nil { - return err + return nil, err } - // Read clipboard in background — we only need it after recording ends clipCh := make(chan string, 1) if autoPaste { go func() { @@ -370,6 +370,25 @@ func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) error { totalFrames, err := record(capture, keyup, sess) tuiSend(RecordingStopMsg{}) + + if err != nil { + sess.Close() + return nil, err + } + if totalFrames < uint64(encoder.SampleRate/10) { + sess.Close() + return nil, nil + } + + done := make(chan struct{}) + go func() { + finishTranscription(sess, clipCh) + close(done) + }() + return done, nil +} + +func finishTranscription(sess transcriber.Session, clipCh chan string) { result, closeErr := sess.Close() var clipPrev string @@ -378,11 +397,10 @@ func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) error { } tuiSend(LiveTranscriptionMsg{Text: ""}) - if err != nil { - return err - } - if totalFrames < uint64(encoder.SampleRate/10) { - return nil + if closeErr != nil { + log.Errorf("transcription error: %v", closeErr) + logToTUI("Error: %v", closeErr) + return } if !streamEnabled && result.HasText && autoPaste { @@ -422,8 +440,10 @@ func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) error { MemoryAllocMB: result.MemoryAllocMB, MemoryPeakMB: result.MemoryPeakMB, } + transcriptionsMu.Lock() transcriptions = append(transcriptions, record) updatePercentileStats() + transcriptionsMu.Unlock() log.TranscriptionMetrics(log.Metrics(record), activeFormat, activeFormat, activeTranscriber.Name(), bs.ConnReused, bs.TLSProtocol) log.Confidence(bs.Confidence) } @@ -446,11 +466,6 @@ func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) error { if !result.NoSpeech { log.TranscriptionText(result.Text) } - - if closeErr != nil { - log.Errorf("session close error: %v", closeErr) - } - return nil } func record(capture audio.CaptureDevice, keyup <-chan struct{}, sess transcriber.Session) (uint64, error) { @@ -459,6 +474,8 @@ func record(capture audio.CaptureDevice, keyup <-chan struct{}, sess transcriber var peakLevel float64 var noVoiceBeeped bool var stopped bool + var voiceDetected bool + var lastVoiceTime time.Time done := make(chan struct{}) capture.SetCallback(func(data []byte, frameCount uint32) { @@ -489,6 +506,12 @@ func record(capture audio.CaptureDevice, keyup <-chan struct{}, sess transcriber if rms > peakLevel { peakLevel = rms } + if rms >= voiceThreshold { + if !voiceDetected { + voiceDetected = true + } + lastVoiceTime = time.Now() + } bufMu.Unlock() } }) @@ -510,6 +533,12 @@ func record(capture audio.CaptureDevice, keyup <-chan struct{}, sess transcriber elapsed := time.Since(recordStart).Seconds() tuiSend(RecordingTickMsg{Duration: elapsed}) checkNoVoice(&bufMu, elapsed, &peakLevel, &noVoiceBeeped) + bufMu.Lock() + vd := voiceDetected + bufMu.Unlock() + if vd { + checkSilenceDuring(&bufMu, &lastVoiceTime) + } } } }() @@ -549,6 +578,21 @@ func checkNoVoice(mu *sync.Mutex, elapsed float64, peakLevel *float64, beeped *b } } +const silenceTimeout = 8 * time.Second + +func checkSilenceDuring(mu *sync.Mutex, lastVoiceTime *time.Time) { + mu.Lock() + shouldWarn := time.Since(*lastVoiceTime) > silenceTimeout + if shouldWarn { + *lastVoiceTime = time.Now() + } + mu.Unlock() + if shouldWarn { + tuiSend(NoVoiceWarningMsg{}) + beep.PlayError() + } +} + func updatePercentileStats() { n := len(transcriptions) if n == 0 { diff --git a/testenv.go b/testenv.go index 2fdee4a..a8a7f89 100644 --- a/testenv.go +++ b/testenv.go @@ -82,10 +82,13 @@ func runTestMode(wavPath string) { // Event loop -- same pattern as run() for { <-hk.Keydown() - err := handleRecording(capture, hk.Keyup()) + done, err := handleRecording(capture, hk.Keyup()) if err != nil { log.Errorf("recording error: %v", err) } + if done != nil { + <-done + } select { case recordingDone <- struct{}{}: default: diff --git a/transcriber/traced_client.go b/transcriber/traced_client.go index 3849d7b..28e23a3 100644 --- a/transcriber/traced_client.go +++ b/transcriber/traced_client.go @@ -21,6 +21,7 @@ func NewTracedClient(apiURL string) *TracedClient { } tc := &TracedClient{ client: &http.Client{ + Timeout: 2 * time.Minute, Transport: &http.Transport{ MaxIdleConns: 4, MaxIdleConnsPerHost: 4, From a222dbf47d984a7242307496c7c647a991b7fa3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Mon, 9 Feb 2026 13:41:39 +0300 Subject: [PATCH 2/6] increase record tail for langs like Turkish --- main.go | 2 +- transcriber/stream_session.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 64e79cf..9de19c1 100644 --- a/main.go +++ b/main.go @@ -69,7 +69,7 @@ func deviceLineText(dev *audio.DeviceInfo) string { return "mic: " + name + " (ctrl+g)" } -const recordTail = 250 * time.Millisecond +const recordTail = 500 * time.Millisecond func run() { benchmarkFile := flag.String("benchmark", "", "Run benchmark with WAV file instead of live recording") diff --git a/transcriber/stream_session.go b/transcriber/stream_session.go index 0add769..83ecf80 100644 --- a/transcriber/stream_session.go +++ b/transcriber/stream_session.go @@ -13,7 +13,7 @@ const ( streamChunkMs = 200 streamChunkBytes = encoder.SampleRate * encoder.Channels * (encoder.BitsPerSample / 8) * streamChunkMs / 1000 streamFinalizeIdle = 200 * time.Millisecond - streamFinalizeMax = 1000 * time.Millisecond + streamFinalizeMax = 2000 * time.Millisecond ) type rawStreamSession interface { From fab8021990742dc8ba1bbdcfabfc9d9e2d84b618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Mon, 9 Feb 2026 13:52:15 +0300 Subject: [PATCH 3/6] fix: clipboard preserver --- main.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 9de19c1..1a15855 100644 --- a/main.go +++ b/main.go @@ -400,10 +400,9 @@ func finishTranscription(sess transcriber.Session, clipCh chan string) { if closeErr != nil { log.Errorf("transcription error: %v", closeErr) logToTUI("Error: %v", closeErr) - return } - if !streamEnabled && result.HasText && autoPaste { + if closeErr == nil && !streamEnabled && result.HasText && autoPaste { clipboard.Copy(result.Text) clipboard.Paste() } @@ -415,6 +414,10 @@ func finishTranscription(sess transcriber.Session, clipCh chan string) { }() } + if closeErr != nil { + return + } + displayText := result.Text if result.NoSpeech { displayText = "(no speech detected)" From ea40173d3b17a72a4f10964afb0f7b337bbdc502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Mon, 9 Feb 2026 15:47:19 +0300 Subject: [PATCH 4/6] Here's the commit message: --- main.go | 41 +++++++++++++---- test/integration_test.go | 91 +++++++++++++++++++++++++++++++++++++- transcriber/transcriber.go | 8 ++++ tui.go | 35 +++++++++++---- 4 files changed, 157 insertions(+), 18 deletions(-) diff --git a/main.go b/main.go index 1a15855..6857865 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "runtime/debug" "sort" "sync" + "sync/atomic" "time" "zee/audio" @@ -300,7 +301,6 @@ func run() { go beep.PlayStart() _, err := handleRecording(captureDevice, hk.Keyup()) - log.Info("hotkey_up") if err != nil { logToTUI("Error recording: %v", err) log.Errorf("recording error: %v", err) @@ -353,9 +353,13 @@ func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) (<-chan }() } + var lastTranscript atomic.Int64 + updatesDone := make(chan struct{}) go func() { + defer close(updatesDone) var prev string for text := range sess.Updates() { + lastTranscript.Store(time.Now().UnixNano()) tuiSend(LiveTranscriptionMsg{Text: text}) if autoPaste { delta := text[len(prev):] @@ -368,8 +372,7 @@ func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) (<-chan } }() - totalFrames, err := record(capture, keyup, sess) - tuiSend(RecordingStopMsg{}) + totalFrames, err := record(capture, keyup, sess, &lastTranscript) if err != nil { sess.Close() @@ -382,14 +385,15 @@ func handleRecording(capture audio.CaptureDevice, keyup <-chan struct{}) (<-chan done := make(chan struct{}) go func() { - finishTranscription(sess, clipCh) + finishTranscription(sess, clipCh, updatesDone) close(done) }() return done, nil } -func finishTranscription(sess transcriber.Session, clipCh chan string) { +func finishTranscription(sess transcriber.Session, clipCh chan string, updatesDone <-chan struct{}) { result, closeErr := sess.Close() + <-updatesDone // wait for updates goroutine to drain var clipPrev string if autoPaste { @@ -409,7 +413,7 @@ func finishTranscription(sess transcriber.Session, clipCh chan string) { if autoPaste && clipPrev != "" { go func() { - time.Sleep(800 * time.Millisecond) + time.Sleep(600 * time.Millisecond) clipboard.Copy(clipPrev) }() } @@ -471,7 +475,7 @@ func finishTranscription(sess transcriber.Session, clipCh chan string) { } } -func record(capture audio.CaptureDevice, keyup <-chan struct{}, sess transcriber.Session) (uint64, error) { +func record(capture audio.CaptureDevice, keyup <-chan struct{}, sess transcriber.Session, lastTranscript *atomic.Int64) (uint64, error) { var bufMu sync.Mutex var totalFrames uint64 var peakLevel float64 @@ -542,12 +546,18 @@ func record(capture audio.CaptureDevice, keyup <-chan struct{}, sess transcriber if vd { checkSilenceDuring(&bufMu, &lastVoiceTime) } + if streamEnabled { + checkTranscriptSilence(lastTranscript) + } } } }() go func() { <-keyup + log.Info("hotkey_up") + tuiSend(RecordingStopMsg{}) + go beep.PlayEnd() if streamEnabled { time.Sleep(recordTail) } @@ -556,8 +566,6 @@ func record(capture audio.CaptureDevice, keyup <-chan struct{}, sess transcriber <-done capture.Stop() - log.Info("beep_end") - beep.PlayEnd() capture.ClearCallback() bufMu.Lock() @@ -576,6 +584,7 @@ func checkNoVoice(mu *sync.Mutex, elapsed float64, peakLevel *float64, beeped *b } mu.Unlock() if shouldWarn { + log.Info("no_voice_warning") tuiSend(NoVoiceWarningMsg{}) beep.PlayError() } @@ -591,11 +600,25 @@ func checkSilenceDuring(mu *sync.Mutex, lastVoiceTime *time.Time) { } mu.Unlock() if shouldWarn { + log.Info("silence_during_warning") tuiSend(NoVoiceWarningMsg{}) beep.PlayError() } } +func checkTranscriptSilence(lastTranscript *atomic.Int64) { + ts := lastTranscript.Load() + if ts == 0 { + return // no transcript received yet + } + if time.Since(time.Unix(0, ts)) > silenceTimeout { + lastTranscript.Store(time.Now().UnixNano()) + log.Info("transcript_silence_warning") + tuiSend(TranscriptSilenceMsg{}) + beep.PlayError() + } +} + func updatePercentileStats() { n := len(transcriptions) if n == 0 { diff --git a/test/integration_test.go b/test/integration_test.go index 0514910..d64e6e4 100644 --- a/test/integration_test.go +++ b/test/integration_test.go @@ -61,16 +61,32 @@ func cmds(parts ...string) string { return strings.Join(parts, "\n") + "\n" } +type runOpts struct { + env []string // extra KEY=VALUE pairs + wantErr bool // expect non-zero exit +} + func runZee(t *testing.T, stdin string, args ...string) (logDir string) { + t.Helper() + return runZeeOpts(t, stdin, runOpts{}, args...) +} + +func runZeeOpts(t *testing.T, stdin string, opts runOpts, args ...string) (logDir string) { t.Helper() logDir = t.TempDir() cmdArgs := append([]string{"-logpath", logDir}, args...) cmd := exec.Command(testBinary, cmdArgs...) cmd.Stdin = strings.NewReader(stdin) - cmd.Env = os.Environ() + cmd.Env = append(os.Environ(), opts.env...) out, err := cmd.CombinedOutput() + if opts.wantErr { + if err == nil { + t.Fatalf("expected zee to exit with error, but it succeeded\noutput: %s", out) + } + return logDir + } if err != nil { t.Fatalf("zee exited with error: %v\noutput: %s", err, out) } @@ -206,3 +222,76 @@ func TestClipboardRestore(t *testing.T) { t.Errorf("clipboard not restored: got %q, want %q", strings.TrimSpace(clip), sentinel) } } + +// --- Silence detection tests (no API key needed) --- + +func TestNoVoiceWarningBatch(t *testing.T) { + logDir := runZeeOpts(t, cmds("KEYDOWN", "SLEEP 1500", "KEYUP", "WAIT", "QUIT"), + runOpts{env: []string{"ZEE_FAKE_TEXT=hello", "GROQ_API_KEY=", "DEEPGRAM_API_KEY="}}, + "-test", "data/silence.wav") + diag := readLog(t, logDir, "diagnostics_log.txt") + if !strings.Contains(diag, "no_voice_warning") { + t.Errorf("expected 'no_voice_warning' in diagnostics, got: %q", diag) + } +} + +func TestNoVoiceWarningStream(t *testing.T) { + logDir := runZeeOpts(t, cmds("KEYDOWN", "SLEEP 1500", "KEYUP", "WAIT", "QUIT"), + runOpts{env: []string{"ZEE_FAKE_TEXT=hello", "GROQ_API_KEY=", "DEEPGRAM_API_KEY="}}, + "-test", "-stream", "data/silence.wav") + diag := readLog(t, logDir, "diagnostics_log.txt") + if !strings.Contains(diag, "no_voice_warning") { + t.Errorf("expected 'no_voice_warning' in diagnostics, got: %q", diag) + } +} + +func TestTranscriptSilenceStream(t *testing.T) { + logDir := runZeeOpts(t, cmds("KEYDOWN", "SLEEP 9000", "KEYUP", "WAIT", "QUIT"), + runOpts{env: []string{"ZEE_FAKE_TEXT=hello", "GROQ_API_KEY=", "DEEPGRAM_API_KEY="}}, + "-test", "-stream", "data/silence.wav") + diag := readLog(t, logDir, "diagnostics_log.txt") + if !strings.Contains(diag, "transcript_silence_warning") { + t.Errorf("expected 'transcript_silence_warning' in diagnostics, got: %q", diag) + } +} + +// --- Fake transcriber tests (no API key needed) --- + +func TestFakeTranscriberWords(t *testing.T) { + logDir := runZeeOpts(t, cmds("KEYDOWN", "KEYUP", "WAIT", "QUIT"), + runOpts{env: []string{"ZEE_FAKE_TEXT=hello world", "GROQ_API_KEY=", "DEEPGRAM_API_KEY="}}, + "-test", "data/short.wav") + text := readLog(t, logDir, "transcribe_log.txt") + if !strings.Contains(text, "hello world") { + t.Errorf("expected 'hello world' in transcribe log, got: %q", text) + } +} + +func TestFakeTranscriberError(t *testing.T) { + logDir := runZeeOpts(t, cmds("KEYDOWN", "KEYUP", "WAIT", "QUIT"), + runOpts{env: []string{"ZEE_FAKE_TEXT=test", "ZEE_FAKE_ERROR=1", "GROQ_API_KEY=", "DEEPGRAM_API_KEY="}}, + "-test", "data/short.wav") + diag := readLog(t, logDir, "diagnostics_log.txt") + if !strings.Contains(diag, "fake transcriber error") { + t.Errorf("expected 'fake transcriber error' in diagnostics, got: %q", diag) + } +} + +func TestClipboardRestoreOnError(t *testing.T) { + sentinel := fmt.Sprintf("zee-test-sentinel-%d", time.Now().UnixNano()) + if err := clipboard.Copy(sentinel); err != nil { + t.Skip("clipboard not available") + } + + _ = runZeeOpts(t, cmds("KEYDOWN", "KEYUP", "WAIT", "SLEEP 1200", "QUIT"), + runOpts{env: []string{"ZEE_FAKE_TEXT=test", "ZEE_FAKE_ERROR=1", "GROQ_API_KEY=", "DEEPGRAM_API_KEY="}}, + "-test", "data/short.wav") + + clip, err := clipboard.Read() + if err != nil { + t.Skip("clipboard not available") + } + if strings.TrimSpace(clip) != sentinel { + t.Errorf("clipboard not restored on error: got %q, want %q", strings.TrimSpace(clip), sentinel) + } +} diff --git a/transcriber/transcriber.go b/transcriber/transcriber.go index 0e431d4..b124ade 100644 --- a/transcriber/transcriber.go +++ b/transcriber/transcriber.go @@ -74,6 +74,14 @@ func (b *baseTranscriber) SetLanguage(lang string) { b.lang = lang } func (b *baseTranscriber) GetLanguage() string { return b.lang } func New() (Transcriber, error) { + if fakeText, ok := os.LookupEnv("ZEE_FAKE_TEXT"); ok { + var fakeErr error + if os.Getenv("ZEE_FAKE_ERROR") == "1" { + fakeErr = fmt.Errorf("simulated API failure") + } + return NewFake(fakeText, fakeErr), nil + } + dgKey := os.Getenv("DEEPGRAM_API_KEY") groqKey := os.Getenv("GROQ_API_KEY") diff --git a/tui.go b/tui.go index 8209d5b..8a76df3 100644 --- a/tui.go +++ b/tui.go @@ -29,7 +29,8 @@ type ModeLineMsg struct{ Text string } // Mode/provider info type DeviceLineMsg struct{ Text string } // Microphone device name type RateLimitMsg struct{ Text string } // Rate limit info type RequestDeviceSelectionMsg struct{} // Request to change microphone -type NoVoiceWarningMsg struct{} // No voice detected during recording +type NoVoiceWarningMsg struct{} // No voice detected during recording +type TranscriptSilenceMsg struct{} // No transcript updates from backend type HybridHelpMsg struct{ Enabled bool } // Whether hybrid tap+hold is enabled type tickMsg time.Time @@ -53,7 +54,8 @@ type tuiModel struct { frame int recordingDuration float64 audioLevel float64 - noVoiceWarning bool // show no voice warning + noVoiceWarning bool // show no voice warning + transcriptSilenceWarning bool // show transcript silence warning msgCount int width, height int modeLine string // "[fast | MP3@16kbps | deepgram]" @@ -186,13 +188,13 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.recordingDuration = 0 m.audioLevel = 0 m.noVoiceWarning = false + m.transcriptSilenceWarning = false m.liveText = "" m.clampViewIdx() case RecordingStopMsg: m.state = tuiStateIdle m.audioLevel = 0 - m.liveText = "" m.clampViewIdx() case RecordingTickMsg: @@ -213,13 +215,20 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case NoVoiceWarningMsg: m.noVoiceWarning = true + case TranscriptSilenceMsg: + m.transcriptSilenceWarning = true + case LiveTranscriptionMsg: m.liveText = msg.Text + if msg.Text != "" { + m.transcriptSilenceWarning = false + } m.clampViewIdx() case TranscriptionMsg: m.msgCount++ m.liveText = "" + m.transcriptSilenceWarning = false // Deep copy metrics slice to avoid aliasing metricsCopy := make([]string, len(msg.Metrics)) copy(metricsCopy, msg.Metrics) @@ -281,11 +290,17 @@ func (m tuiModel) View() string { Bold(true). Render(fmt.Sprintf("● REC %.1fs", m.recordingDuration)) infoLines = append(infoLines, status) - // Voice detection warning - if m.noVoiceWarning { + // Voice detection warning (transcript silence overrides no-voice) + var warnText string + if m.transcriptSilenceWarning { + warnText = " ⚠ are you talking?" + } else if m.noVoiceWarning { + warnText = " ⚠ no voice detected" + } + if warnText != "" { warn := lipgloss.NewStyle(). Foreground(lipgloss.Color("208")). - Render(" ⚠ no voice detected") + Render(warnText) infoLines = append(infoLines, warn) } } else { @@ -380,10 +395,14 @@ func (m tuiModel) View() string { metricsStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243")) var logLines []string - showLive := recording && strings.TrimSpace(m.liveText) != "" && m.viewIdx == 0 + showLive := strings.TrimSpace(m.liveText) != "" && m.viewIdx == 0 if showLive { - logLines = append(logLines, titleStyle.Render("Transcription (live)"), "") + liveLabel := "Transcription (live)" + if !recording { + liveLabel = "Transcription (processing...)" + } + logLines = append(logLines, titleStyle.Render(liveLabel), "") for _, line := range wrapText(m.liveText, wrapWidth) { logLines = append(logLines, textStyle.Render(line)) } From dbec91a030ba26bb0e599d36b0b3a8c9105cde57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Mon, 9 Feb 2026 16:39:05 +0300 Subject: [PATCH 5/6] add transcriver/fake --- transcriber/fake.go | 62 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 transcriber/fake.go diff --git a/transcriber/fake.go b/transcriber/fake.go new file mode 100644 index 0000000..2296916 --- /dev/null +++ b/transcriber/fake.go @@ -0,0 +1,62 @@ +package transcriber + +import ( + "context" + "fmt" + "time" +) + +type FakeTranscriber struct { + text string + err error + lang string +} + +func NewFake(text string, err error) *FakeTranscriber { + return &FakeTranscriber{text: text, err: err} +} + +func (f *FakeTranscriber) Name() string { return "fake" } +func (f *FakeTranscriber) SetLanguage(lang string) { f.lang = lang } +func (f *FakeTranscriber) GetLanguage() string { return f.lang } + +func (f *FakeTranscriber) NewSession(_ context.Context, cfg SessionConfig) (Session, error) { + updates := make(chan string, 1) + if cfg.Stream && f.text != "" { + go func() { + time.Sleep(100 * time.Millisecond) + updates <- f.text + close(updates) + }() + } else { + close(updates) + } + return &fakeSession{text: f.text, err: f.err, updates: updates}, nil +} + +type fakeSession struct { + text string + err error + updates chan string +} + +func (s *fakeSession) Feed([]byte) {} + +func (s *fakeSession) Updates() <-chan string { return s.updates } + +func (s *fakeSession) Close() (SessionResult, error) { + if s.err != nil { + return SessionResult{}, fmt.Errorf("fake transcriber error: %w", s.err) + } + r := SessionResult{ + Text: s.text, + HasText: s.text != "", + Batch: &BatchStats{ + AudioLengthS: 1.0, + TotalTimeMs: 10, + }, + Metrics: []string{"total: 10ms (fake)"}, + } + r.captureMemStats() + return r, nil +} From 78d566c98a2fe4c5a238773b0b46c4f265d1e167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Mon, 9 Feb 2026 16:56:54 +0300 Subject: [PATCH 6/6] add basic update support from GH releases --- main.go | 37 +++++++++++++++++++++++++++++++++++++ tui.go | 13 ++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 6857865..64d2747 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "zee/log" "zee/shutdown" "zee/transcriber" + "zee/update" ) var version = "dev" @@ -73,6 +74,38 @@ func deviceLineText(dev *audio.DeviceInfo) string { const recordTail = 500 * time.Millisecond func run() { + if len(os.Args) > 1 && os.Args[1] == "update" { + if version == "dev" { + fmt.Println("Dev build — cannot check for updates.") + os.Exit(0) + } + fmt.Printf("zee %s — checking for updates...\n", version) + rel, err := update.CheckLatest(version) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + if rel == nil { + 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) + os.Exit(0) + } + benchmarkFile := flag.String("benchmark", "", "Run benchmark with WAV file instead of live recording") benchmarkRuns := flag.Int("runs", 3, "Number of benchmark iterations") autoPasteFlag := flag.Bool("autopaste", true, "Auto-paste to focused window after transcription") @@ -238,6 +271,10 @@ func run() { <-tuiReady + update.StartBackgroundCheck(version, log.Dir(), func(rel update.Release) { + tuiSend(UpdateAvailableMsg{Version: rel.Version}) + }) + sigChan := make(chan os.Signal, 1) shutdown.Notify(sigChan) go func() { diff --git a/tui.go b/tui.go index 8a76df3..71ab86c 100644 --- a/tui.go +++ b/tui.go @@ -31,7 +31,8 @@ type RateLimitMsg struct{ Text string } // Rate limit info type RequestDeviceSelectionMsg struct{} // Request to change microphone type NoVoiceWarningMsg struct{} // No voice detected during recording type TranscriptSilenceMsg struct{} // No transcript updates from backend -type HybridHelpMsg struct{ Enabled bool } // Whether hybrid tap+hold is enabled +type HybridHelpMsg struct{ Enabled bool } // Whether hybrid tap+hold is enabled +type UpdateAvailableMsg struct{ Version string } // New version available type tickMsg time.Time type tuiState int @@ -66,6 +67,7 @@ type tuiModel struct { viewIdx int // 0 = newest, higher = older expertMode bool // show full TUI with HAL eye hybridEnabled bool // show hybrid help text when true + updateAvailable string } func (m tuiModel) maxViewIdx() int { @@ -256,6 +258,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case HybridHelpMsg: m.hybridEnabled = msg.Enabled + case UpdateAvailableMsg: + m.updateAvailable = msg.Version + case RequestDeviceSelectionMsg: select { case deviceSelectChan <- struct{}{}: @@ -370,6 +375,12 @@ func (m tuiModel) View() string { helpLine = helpStyle.Render("Hold ") + boldStyle.Render("Ctrl+Shift+Space") + helpStyle.Render(" to record") } infoLines = append(infoLines, helpLine) + if m.updateAvailable != "" { + updateLine := lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")). + Render(fmt.Sprintf("update available: %s (zee update)", m.updateAvailable)) + infoLines = append(infoLines, updateLine) + } infoLines = append(infoLines, helpStyle.Render("zee "+version)) // Append info to eye