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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Note:** zee - push-to-talk transcription app. Runs as a system tray icon on macOS.

## Rules
- **CHANGELOG.md** — only log code/behavior changes. No docs, README, or comment-only updates. Be concise.
- **CHANGELOG.md** — do not touch it. It is written only at release time, by hand. Never add an entry for a fix, a feature, or anything else during normal work.
- **docs/design-notes.md** — the *why* behind non-obvious choices, and the record of what was measured (including options tried and rejected). Read it before revisiting an engine/model/backend decision; a surprising line of code usually has its reason there. Add an entry when a decision rests on a measurement or a rejected alternative — facts, not narrative. When new evidence supersedes an entry, mark the old one superseded rather than deleting it; the code often still reflects why it *used* to be true.
- **No performance numbers in code comments** — they are machine-specific and go stale silently. Say *what* is slow and *why* ("-play blocks while it starts the audio hardware"), and put the measurement in `docs/design-notes.md`, where the hardware is on the record. If a number really must appear in a comment, name the machine with it (e.g. "~47 ms on an M5 Pro").
- **Clean package interface** — every package must expose a single, platform-neutral interface describing *what it provides*, defined once (typically in `<pkg>.go`). Public API, shared types, and guard logic live there; platform/provider files (build-tag variants) only implement the backend hooks. Never duplicate the public API across build-tag files (see `audio/`: `audio.go` owns the capture interface plus `PlayStart/PlayEnd/...`, platform files provide only the backends — `initSound`/`playOne` for playback, the malgo/pulse capture impls).

## Build & Run
Expand Down
28 changes: 23 additions & 5 deletions audio/beep_darwin.m
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
#import <AVFoundation/AVFoundation.h>

// Feedback tones via AVAudioPlayer: the OS owns the audio machinery, so a beep
// is fire-and-forget with no app-managed device to init, keep warm, or
// serialize against capture. Players are built once from in-memory WAV bytes;
// each play restarts from the top so a rapid start-then-end beep both sound.
// needs no app-managed device to init, keep warm, or serialize against capture.
// Players are built once from in-memory WAV bytes; each play restarts from the
// top so a rapid start-then-end beep both sound.

static AVAudioPlayer *_players[8];

// Serial, so two beeps never touch one player's currentTime concurrently —
// AVAudioPlayer is not documented thread-safe.
static dispatch_queue_t beepQueue(void) {
static dispatch_queue_t q;
static dispatch_once_t once;
dispatch_once(&once, ^{
q = dispatch_queue_create("dev.zee.beep", DISPATCH_QUEUE_SERIAL);
});
return q;
}

void zeeBeepLoad(int idx, const void *wav, int len) {
NSData *data = [NSData dataWithBytes:wav length:(NSUInteger)len];
AVAudioPlayer *p = [[AVAudioPlayer alloc] initWithData:data error:nil];
[p prepareToPlay];
_players[idx] = p;
}

// zeeBeepPlay dispatches instead of playing inline: -play blocks while it starts
// the audio hardware — prepareToPlay does not prevent it, and the hardware powers
// back down between beeps — and that landed on the push-to-talk path twice per
// dictation, at press and at release. The user hears the tone at the same moment;
// only the caller's wait is gone. Measured cost in docs/design-notes.md.
void zeeBeepPlay(int idx) {
AVAudioPlayer *p = _players[idx];
if (p == nil) return;
p.currentTime = 0;
[p play];
dispatch_async(beepQueue(), ^{
p.currentTime = 0;
[p play];
});
}
12 changes: 4 additions & 8 deletions clipboard/clipboard.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
package clipboard

import cb "github.com/atotto/clipboard"
// Read returns the clipboard's current text, or "" when it holds none.
func Read() (string, error) { return read() }

func Read() (string, error) {
return cb.ReadAll()
}

func Copy(text string) error {
return cb.WriteAll(text)
}
// Copy replaces the clipboard contents with text.
func Copy(text string) error { return write(text) }
65 changes: 29 additions & 36 deletions clipboard/clipboard_darwin.go
Original file line number Diff line number Diff line change
@@ -1,60 +1,53 @@
package clipboard

/*
#cgo LDFLAGS: -framework ApplicationServices
#cgo LDFLAGS: -framework AppKit -framework ApplicationServices
#include <stdlib.h>
#include <ApplicationServices/ApplicationServices.h>

int clipCopy(const char *utf8);
char *clipRead(void);
void clipPaste(void);

static int testAccessibility() {
return AXIsProcessTrusted();
}
*/
import "C"

import (
"os"
"strings"
"sync"

"github.com/micmonay/keybd_event"
"errors"
"unsafe"
)

var (
kb keybd_event.KeyBonding
kbOnce sync.Once
kbErr error
)
// Init is a no-op on macOS — NSPasteboard and CGEvent need no setup. It exists
// because the Linux backend must open /dev/uinput before the first paste.
func Init() error { return nil }

// ensureUTF8Locale guarantees pbcopy/pbpaste interpret text as UTF-8.
// GUI apps launched from Finder inherit no LANG/LC_CTYPE, so pbcopy falls
// back to a legacy encoding and mangles multi-byte characters (e.g. Turkish
// ğ ş ı İ ç ö ü). Setting LC_CTYPE to a UTF-8 locale fixes this for the child
// process that atotto/clipboard exec's. We only override when the current
// ctype locale is not already UTF-8, so an explicit tr_TR.UTF-8 etc. is kept.
func init() {
isUTF8 := func(v string) bool {
v = strings.ToLower(v)
return strings.Contains(v, "utf-8") || strings.Contains(v, "utf8")
func write(text string) error {
c := C.CString(text)
defer C.free(unsafe.Pointer(c))
if C.clipCopy(c) == 0 {
return errors.New("pasteboard rejected the write")
}
if isUTF8(os.Getenv("LC_ALL")) || isUTF8(os.Getenv("LC_CTYPE")) || isUTF8(os.Getenv("LANG")) {
return
}
os.Setenv("LC_CTYPE", "en_US.UTF-8")
return nil
}

func Init() error {
kbOnce.Do(func() {
kb, kbErr = keybd_event.NewKeyBonding()
})
return kbErr
func read() (string, error) {
c := C.clipRead()
if c == nil {
return "", nil // no text on the pasteboard; not an error
}
defer C.free(unsafe.Pointer(c))
return C.GoString(c), nil
}

// Paste fires Cmd+V. macOS reports nothing when Accessibility is missing — the
// events are simply dropped — so there is no error to return here; the setup
// wizard uses CheckAccessibility to catch that case up front.
func Paste() error {
if err := Init(); err != nil {
return err
}
kb.SetKeys(keybd_event.VK_V)
kb.HasSuper(true) // Cmd+V on macOS
return kb.Launching()
C.clipPaste()
return nil
}

func CheckAccessibility() bool {
Expand Down
55 changes: 55 additions & 0 deletions clipboard/clipboard_darwin.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#import <AppKit/AppKit.h>
#import <ApplicationServices/ApplicationServices.h>
#include <string.h>

// NSPasteboard + CGEvent, called from clipboard_darwin.go. (Objective-C can't
// live in a cgo preamble — it is compiled as C — so it goes here, mirroring
// permissions/permissions_darwin.m.)
//
// Both replace things that cost real felt latency: pbcopy/pbpaste fork(), and
// fork freezes every thread for O(resident memory), which is significant once a
// local model is resident — while keybd_event held Cmd+V down for a hardcoded
// 100 ms sleep. Measurements in docs/design-notes.md.

// clipCopy replaces the pasteboard with one UTF-8 text item. Returns 1 on
// success. No locale involved, unlike the pbcopy child process it replaces.
int clipCopy(const char *utf8) {
@autoreleasepool {
NSString *s = [NSString stringWithUTF8String:utf8];
if (s == nil) {
return 0;
}
NSPasteboard *pb = [NSPasteboard generalPasteboard];
[pb clearContents];
return [pb setString:s forType:NSPasteboardTypeString] ? 1 : 0;
}
}

// clipRead returns the pasteboard's text, malloc'd for the caller to free, or
// NULL when it holds no text (empty, or an image).
char *clipRead(void) {
@autoreleasepool {
NSString *s = [[NSPasteboard generalPasteboard] stringForType:NSPasteboardTypeString];
if (s == nil) {
return NULL;
}
return strdup([s UTF8String]);
}
}

// clipPaste synthesizes Cmd+V into whichever app has focus. Deliberately the
// same event mechanism as the keybd_event call it replaces — NULL source,
// annotated session tap, flags set explicitly so a physically-held modifier
// cannot leak in — minus the sleep between down and up. Requires Accessibility;
// without it macOS drops the events silently.
void clipPaste(void) {
const CGKeyCode kVK_V = 0x09;
CGEventRef down = CGEventCreateKeyboardEvent(NULL, kVK_V, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning — A NULL event-create result crashes the app instead of dropping the paste.

clipPaste never checks the results of CGEventCreateKeyboardEvent. When the function returns NULL (event source cannot be created under resource pressure or certain TCC/sandbox states), CGEventSetFlags(down, ...) is undefined and CFRelease(down)/CFRelease(up) on a NULL ref is a documented crash — so a failed event creation terminates the app on the paste path instead of a silently-dropped keystroke. Guard each ref (return early / skip CFRelease when NULL) as the replaced keybd_event path did internally.

CGEventRef up = CGEventCreateKeyboardEvent(NULL, kVK_V, false);
CGEventSetFlags(down, kCGEventFlagMaskCommand);
CGEventSetFlags(up, kCGEventFlagMaskCommand);
CGEventPost(kCGAnnotatedSessionEventTap, down);
CGEventPost(kCGAnnotatedSessionEventTap, up);
CFRelease(down);
CFRelease(up);
}
35 changes: 35 additions & 0 deletions clipboard/clipboard_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package clipboard

import "testing"

// Round-trips through the real pasteboard, restoring whatever was there. The
// Turkish characters are the point: the pbcopy backend mangled them unless
// LC_CTYPE was forced to UTF-8, and NSPasteboard takes an NSString directly, so
// the locale hack could go.
func TestCopyReadRoundTrip(t *testing.T) {
prev, err := Read()
if err != nil {
t.Fatalf("Read: %v", err)
}
t.Cleanup(func() { Copy(prev) })

const want = "zee ğşıİçöü — naïve 日本語"
if err := Copy(want); err != nil {
t.Fatalf("Copy: %v", err)
}
got, err := Read()
if err != nil {
t.Fatalf("Read: %v", err)
}
if got != want {
t.Errorf("round trip: got %q, want %q", got, want)
}
}

func BenchmarkCopy(b *testing.B) {
prev, _ := Read()
b.Cleanup(func() { Copy(prev) })
for b.Loop() {
Copy("the quick brown fox jumps over the lazy dog")
}
}
7 changes: 7 additions & 0 deletions clipboard/clipboard_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ import (
"sync"
"syscall"
"time"

cb "github.com/atotto/clipboard"
)

// xclip/xsel via atotto: the fork cost that made macOS go native is not worth
// a second native backend here, where no local model inflates resident memory.
func read() (string, error) { return cb.ReadAll() }
func write(text string) error { return cb.WriteAll(text) }

// ioctl constants from linux/uinput.h
const (
uiSetEvbit = 0x40045564 // UI_SET_EVBIT
Expand Down
7 changes: 6 additions & 1 deletion clipboard_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,23 @@ type clipboardSession struct {

var clip clipboardSession

func (c *clipboardSession) PasteText(text string) {
func (c *clipboardSession) PasteText(text string) (copyMs, keyMs float64) {
c.mu.Lock()
defer c.mu.Unlock()
// Log failures — a broken paste (revoked Accessibility, pbcopy error) is
// otherwise indistinguishable from "no text" for the user.
t := time.Now()
if err := clipboard.Copy(text); err != nil {
log.Warnf("paste: clipboard copy failed: %v", err)
return
}
copyMs = float64(time.Since(t).Microseconds()) / 1000
t = time.Now()
if err := clipboard.Paste(); err != nil {
log.Warnf("paste: keystroke failed (Accessibility?): %v", err)
}
keyMs = float64(time.Since(t).Microseconds()) / 1000
return copyMs, keyMs
}

func (c *clipboardSession) SaveCurrent() string {
Expand Down
Loading
Loading