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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,19 @@ jobs:

- name: Build
run: go build -o zee

integration:
runs-on: macos-latest
needs: test
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.24'

- name: Integration tests
env:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
run: make test-integration
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build build-linux-amd64 build-linux-arm64 test benchmark integration-test clean release
.PHONY: build build-linux-amd64 build-linux-arm64 test test-integration benchmark integration-test clean release

VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")

Expand All @@ -12,7 +12,7 @@ build-linux-arm64:
GOOS=linux GOARCH=arm64 go build -ldflags="-X main.version=$(VERSION) -s -w" -o zee-linux-arm64

test:
go test -v ./encoder/
go test -race -v ./...

integration-test:
@test -n "$(WAV)" || (echo "Usage: make integration-test WAV=file.wav" && exit 1)
Expand All @@ -25,6 +25,12 @@ benchmark: build
@if [ -f .env ]; then export $$(grep -v '^#' .env | xargs); fi; \
./zee -benchmark $(WAV) -runs $(or $(RUNS),3)

test-integration:
@tmp=$$(mktemp -d) && \
go build -o "$$tmp/zee-test-bin" . && \
ZEE_TEST_BIN="$$tmp/zee-test-bin" go test -race -tags integration -v -timeout 120s -count=1 ./test/ ; \
status=$$? ; rm -rf "$$tmp" ; exit $$status

clean:
rm -f zee

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ make benchmark WAV=file.wav RUNS=5 # multiple runs for timing
| `-mode` | fast | Transcription mode: `fast`, `balanced`, `precise` |
| `-setup` | false | Select microphone device |
| `-autopaste` | true | Auto-paste after transcription |
| `-stream` | false | Enable streaming transcription (Deepgram only) |
| `-hybrid` | false | Enable hybrid tap-to-toggle + hold-to-talk on the same hotkey |
| `-longpress` | 350ms | Threshold distinguishing tap vs hold (e.g., `300ms`) |
| `-lang` | (auto) | Language code for transcription (e.g., `en`, `es`, `fr`) |
Expand Down
2 changes: 2 additions & 0 deletions audio/audio.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package audio

const WAVHeaderSize = 44

type DataCallback func(data []byte, frameCount uint32)

type CaptureConfig struct {
Expand Down
164 changes: 164 additions & 0 deletions audio/fake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package audio

import (
"os"
"sync"
"time"
"zee/encoder"
)

const (
fakeFrameSize = 1024
fakeBytesPerFrame = 2 // 16-bit mono
)

type FakeContext struct {
pcm []byte
realtime bool
}

func NewFakeContext(wavPath string, realtime bool) (*FakeContext, error) {
data, err := os.ReadFile(wavPath)
if err != nil {
return nil, err
}
if len(data) > WAVHeaderSize {
data = data[WAVHeaderSize:]
}
return &FakeContext{pcm: data, realtime: realtime}, nil
}

func (f *FakeContext) Devices() ([]DeviceInfo, error) { return nil, nil }
func (f *FakeContext) Close() {}

func (f *FakeContext) NewCapture(_ *DeviceInfo, _ CaptureConfig) (CaptureDevice, error) {
return &FakeCapture{pcm: f.pcm, realtime: f.realtime, audioDone: make(chan struct{})}, nil
}

type FakeCapture struct {
pcm []byte
realtime bool
audioDone chan struct{}

mu sync.Mutex
cb DataCallback
stopCh chan struct{}
feedDone chan struct{}
}

func (f *FakeCapture) AudioDone() <-chan struct{} { return f.audioDone }

func (f *FakeCapture) SetCallback(cb DataCallback) {
f.mu.Lock()
f.cb = cb
f.mu.Unlock()
}

func (f *FakeCapture) ClearCallback() {
f.mu.Lock()
f.cb = nil
f.mu.Unlock()
}

func (f *FakeCapture) feedChunk(cb DataCallback, pos, chunkBytes int) int {
end := min(pos+chunkBytes, len(f.pcm))
chunk := make([]byte, end-pos)
copy(chunk, f.pcm[pos:end])
cb(chunk, uint32(len(chunk)/fakeBytesPerFrame))
return end
}

func (f *FakeCapture) Start() error {
f.stopCh = make(chan struct{})
f.feedDone = make(chan struct{})
// audioDone is NOT recreated here -- callers may already be waiting on it.
// It's reset in Stop() for replay.

chunkBytes := fakeFrameSize * fakeBytesPerFrame

if !f.realtime {
f.mu.Lock()
cb := f.cb
f.mu.Unlock()
if cb != nil {
for pos := 0; pos < len(f.pcm); {
pos = f.feedChunk(cb, pos, chunkBytes)
}
}
close(f.audioDone)

go func() {
defer close(f.feedDone)
silence := make([]byte, chunkBytes)
for {
select {
case <-f.stopCh:
return
case <-time.After(time.Millisecond):
}
f.mu.Lock()
cb := f.cb
f.mu.Unlock()
if cb != nil {
cb(silence, fakeFrameSize)
}
}
}()
} else {
interval := time.Duration(fakeFrameSize) * time.Second / time.Duration(encoder.SampleRate)
go func() {
defer close(f.feedDone)
pos := 0
silence := make([]byte, chunkBytes)
audioFinished := false

for {
select {
case <-f.stopCh:
return
default:
}

f.mu.Lock()
cb := f.cb
f.mu.Unlock()
if cb == nil {
time.Sleep(time.Millisecond)
continue
}

if pos < len(f.pcm) {
pos = f.feedChunk(cb, pos, chunkBytes)
} else {
if !audioFinished {
audioFinished = true
close(f.audioDone)
}
cb(silence, fakeFrameSize)
}

select {
case <-f.stopCh:
return
case <-time.After(interval):
}
}
}()
}

return nil
}

func (f *FakeCapture) Stop() {
select {
case <-f.stopCh:
default:
close(f.stopCh)
}
if f.feedDone != nil {
<-f.feedDone
}
f.audioDone = make(chan struct{}) // reset for replay
}

func (f *FakeCapture) Close() {}
4 changes: 4 additions & 0 deletions beep/beep.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package beep

var disabled bool

func Disable() { disabled = true }

const (
sampleRate = 44100

Expand Down
9 changes: 9 additions & 0 deletions beep/beep_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,16 +155,25 @@ func Init() {
}

func PlayStart() {
if disabled {
return
}
soundOnce.Do(initSound)
playBytes(startSamples)
}

func PlayEnd() {
if disabled {
return
}
soundOnce.Do(initSound)
playBytes(endSamples)
}

func PlayError() {
if disabled {
return
}
soundOnce.Do(initSound)
playBytes(errorSamples)
}
9 changes: 9 additions & 0 deletions beep/beep_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,25 @@ func Init() {
}

func PlayStart() {
if disabled {
return
}
soundOnce.Do(initSound)
go playSamples(startSamples)
}

func PlayEnd() {
if disabled {
return
}
soundOnce.Do(initSound)
go playSamples(endSamples)
}

func PlayError() {
if disabled {
return
}
soundOnce.Do(initSound)
go playSamples(errorSamples)
}
24 changes: 1 addition & 23 deletions clipboard/clipboard.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
package clipboard

import (
"fmt"
"time"

cb "github.com/atotto/clipboard"
)
import cb "github.com/atotto/clipboard"

func Read() (string, error) {
return cb.ReadAll()
Expand All @@ -14,20 +9,3 @@ func Read() (string, error) {
func Copy(text string) error {
return cb.WriteAll(text)
}

func CopyAndPasteWithPreserve(text string) error {
previous, _ := Read()
if err := Copy(text); err != nil {
return fmt.Errorf("copy: %w", err)
}
if err := Paste(); err != nil {
return fmt.Errorf("paste: %w", err)
}
if previous != "" {
go func() {
time.Sleep(800 * time.Millisecond) // allow paste to reach target app before restoring
Copy(previous)
}()
}
return nil
}
Loading