diff --git a/README.md b/README.md index 48153ee..5590054 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ ## Requirements +- **Go 1.24.5+** (for building from source) - Wayland + **Hyprland** - **PipeWire** (audio capture) - **systemd --user** (service) @@ -47,11 +48,30 @@ bind = SUPER, R, exec, hyprvoice toggle ## Usage +### Basic Usage - Press your **toggle** key to start; press again to stop. - Audio streams to the cloud ASR while you speak. - On stop (or VAD endpoint), Hyprvoice **pastes once** into the focused window. - Injection flow: **save clipboard → copy final text → send Ctrl+V → restore clipboard**. +### CLI Commands +```bash +# Start the daemon +hyprvoice serve + +# Toggle recording on/off +hyprvoice toggle + +# Check current status +hyprvoice status + +# Get protocol version +hyprvoice version + +# Stop the daemon +hyprvoice stop +``` + --- ## Status @@ -118,8 +138,135 @@ idle --toggle--> recording --first frame--> transcribing --final--> injecting -- ```bash git clone https://github.com/leonardotrapani/hyprvoice.git cd hyprvoice + +# Build the binary CGO_ENABLED=1 go build -o hyprvoice ./cmd/hyprvoice + +# Run tests (when available) go test ./... + +# Install locally +sudo cp hyprvoice /usr/local/bin/ +``` + +### Dependencies +- **Cobra CLI** - Command-line interface framework +- **Go 1.24.5+** - Programming language runtime + +--- + +## Configuration + +### File Locations +- **Socket**: `~/.cache/hyprvoice/control.sock` - IPC communication +- **PID file**: `~/.cache/hyprvoice/hyprvoice.pid` - Process tracking + +### Systemd Service +The daemon runs as a user service. To create a systemd service file: + +```bash +# Create service file at ~/.config/systemd/user/hyprvoice.service +mkdir -p ~/.config/systemd/user +cat > ~/.config/systemd/user/hyprvoice.service << 'EOF' +[Unit] +Description=Hyprvoice daemon +After=pipewire.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/hyprvoice serve +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +EOF + +# Enable and start +systemctl --user daemon-reload +systemctl --user enable --now hyprvoice.service +``` + +--- + +## Development + +### Project Structure +``` +hyprvoice/ +├── cmd/hyprvoice/ # Main CLI application +├── internal/ +│ ├── bus/ # IPC communication (Unix sockets) +│ ├── daemon/ # Main daemon logic and state management +│ ├── notify/ # Desktop notifications +│ └── pipeline/ # Audio processing pipeline +├── go.mod # Go module definition +└── README.md +``` + +### State Machine +The daemon operates with these states: +- **idle** → **recording** → **transcribing** → **injecting** → **idle** + +### IPC Protocol +Single-character commands over Unix socket: +- `t` - Toggle recording +- `s` - Get status +- `v` - Get protocol version +- `q` - Quit daemon + +### Running in Development +```bash +# Terminal 1: Start daemon with logs +go run ./cmd/hyprvoice serve + +# Terminal 2: Test commands +go run ./cmd/hyprvoice toggle +go run ./cmd/hyprvoice status +``` + +--- + +## Troubleshooting + +### Common Issues + +**Daemon won't start** +```bash +# Check if already running +hyprvoice status + +# Check PID file +ls -la ~/.cache/hyprvoice/ + +# Remove stale files +rm ~/.cache/hyprvoice/hyprvoice.pid +rm ~/.cache/hyprvoice/control.sock +``` + +**No notifications** +```bash +# Test notify-send +notify-send "Test notification" + +# Check if libnotify is installed +which notify-send +``` + +**Permission errors** +```bash +# Check socket permissions +ls -la ~/.cache/hyprvoice/control.sock + +# Recreate cache directory +rm -rf ~/.cache/hyprvoice +mkdir -p ~/.cache/hyprvoice +``` + +### Debug Mode +```bash +# Run with verbose logging +hyprvoice serve 2>&1 | tee hyprvoice.log ``` --- @@ -127,6 +274,9 @@ go test ./... ## Contributing - All PRs and issues welcome. +- Follow existing code conventions +- Add tests for new functionality +- Update documentation for user-facing changes --- diff --git a/internal/recording/recording.go b/internal/recording/recording.go new file mode 100644 index 0000000..5474461 --- /dev/null +++ b/internal/recording/recording.go @@ -0,0 +1,286 @@ +package recording + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log" + "os/exec" + "strconv" + "sync" + "sync/atomic" + "time" +) + +type AudioFrame struct { + Data []byte + Timestamp time.Time +} + +type Config struct { + SampleRate int + Channels int + Format string + BufferSize int + Device string + ChannelBufferSize int +} + +func DefaultConfig() Config { + return Config{ + SampleRate: 16000, + Channels: 1, + Format: "s16le", + BufferSize: 4096, + Device: "", + ChannelBufferSize: 20, + } +} + +type Recorder struct { + config Config + recording atomic.Bool + + mu sync.Mutex // guards cmd and cancel + cmd *exec.Cmd + cancel context.CancelFunc + + wg sync.WaitGroup +} + +func NewRecorder(config Config) *Recorder { + return &Recorder{config: config} +} + +func (r *Recorder) IsRecording() bool { + return r.recording.Load() +} + +func (r *Recorder) Start(ctx context.Context) (<-chan AudioFrame, <-chan error, error) { + if r.recording.Load() { + return nil, nil, fmt.Errorf("already recording") + } + + if err := r.validateConfig(); err != nil { + return nil, nil, err + } + + if err := CheckPipeWireAvailable(ctx); err != nil { + return nil, nil, fmt.Errorf("PipeWire not available: %w", err) + } + + // Create a cancellable context specific to this recording session. + recordingCtx, cancel := context.WithCancel(ctx) + + frameCh := make(chan AudioFrame, r.config.ChannelBufferSize) + errCh := make(chan error, 1) + + r.mu.Lock() + r.cancel = cancel + r.mu.Unlock() + + r.recording.Store(true) + r.wg.Add(1) + go r.captureLoop(recordingCtx, frameCh, errCh) + + return frameCh, errCh, nil +} + +func (r *Recorder) Stop() error { + if !r.recording.Load() { + return nil + } + + r.mu.Lock() + cancel := r.cancel + r.cancel = nil + r.mu.Unlock() + + if cancel != nil { + cancel() + } + + return nil +} + +func (r *Recorder) Wait() { + r.wg.Wait() +} + +func (r *Recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, errCh chan<- error) { + defer func() { + close(frameCh) + close(errCh) + r.recording.Store(false) + + // Ensure any child process is reaped. + r.mu.Lock() + if r.cmd != nil { + _ = r.cmd.Wait() + r.cmd = nil + } + r.cancel = nil + r.mu.Unlock() + + r.wg.Done() + }() + + args := r.buildPwRecordArgs() + cmd := exec.CommandContext(ctx, "pw-record", args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + r.emitErr(errCh, fmt.Errorf("create stdout pipe: %w", err)) + r.requestCancel() + return + } + + stderr, err := cmd.StderrPipe() + if err != nil { + r.emitErr(errCh, fmt.Errorf("create stderr pipe: %w", err)) + r.requestCancel() + return + } + + r.mu.Lock() + r.cmd = cmd + r.mu.Unlock() + + if err := cmd.Start(); err != nil { + r.emitErr(errCh, fmt.Errorf("start pw-record: %w", err)) + r.requestCancel() + return + } + + // Log stderr lines to aid diagnostics. + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + log.Printf("Recording stderr: %s", scanner.Text()) + } + }() + + buffer := make([]byte, r.config.BufferSize) + var sentCount int + var droppedCount int + lastDropLog := time.Now() + + for { + n, readErr := stdout.Read(buffer) + if n > 0 { + frameData := make([]byte, n) + copy(frameData, buffer[:n]) + + frame := AudioFrame{Data: frameData, Timestamp: time.Now()} + + select { + case frameCh <- frame: + sentCount++ + case <-ctx.Done(): + // Context cancelled, stop cleanly after writing any pending frames. + return + default: + droppedCount++ + if time.Since(lastDropLog) > time.Second { + log.Printf("Recording: dropped %d frames due to backpressure", droppedCount) + lastDropLog = time.Now() + droppedCount = 0 + } + } + } + + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return + } + r.emitErr(errCh, fmt.Errorf("read audio: %w", readErr)) + r.requestCancel() + return + } + + select { + case <-ctx.Done(): + return + default: + } + } +} + +func (r *Recorder) requestCancel() { + r.mu.Lock() + cancel := r.cancel + r.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (r *Recorder) emitErr(errCh chan<- error, err error) { + select { + case errCh <- err: + default: + // Best-effort; avoid blocking + } + log.Printf("Recording error: %v", err) +} + +func (r *Recorder) buildPwRecordArgs() []string { + args := []string{ + "--format", r.config.Format, + "--rate", strconv.Itoa(r.config.SampleRate), + "--channels", strconv.Itoa(r.config.Channels), + "-", // stdout + } + if r.config.Device != "" { + args = append(args, "--target", r.config.Device) + } + return args +} + +func NewDefaultRecorder() *Recorder { return NewRecorder(DefaultConfig()) } + +func CheckPipeWireAvailable(ctx context.Context) error { + if _, err := exec.LookPath("pw-record"); err != nil { + return fmt.Errorf("pw-record not found: %w (install pipewire-tools)", err) + } + // Use a short timeout to avoid hangs on misconfigured systems. + if ctx == nil { + ctx = context.Background() + } + checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + cmd := exec.CommandContext(checkCtx, "pw-cli", "info") + if err := cmd.Run(); err != nil { + return fmt.Errorf("PipeWire not running or accessible: %w", err) + } + return nil +} + +func (r *Recorder) validateConfig() error { + if r.config.SampleRate <= 0 { + return fmt.Errorf("invalid SampleRate: %d", r.config.SampleRate) + } + if r.config.Channels <= 0 { + return fmt.Errorf("invalid Channels: %d", r.config.Channels) + } + if r.config.BufferSize <= 0 { + return fmt.Errorf("invalid BufferSize: %d", r.config.BufferSize) + } + if r.config.ChannelBufferSize <= 0 { + return fmt.Errorf("invalid ChannelBufferSize: %d", r.config.ChannelBufferSize) + } + if r.config.Format == "" { + return fmt.Errorf("invalid Format: empty") + } + // For s16le, sample frame size is 2 bytes per sample per channel. + if r.config.Format == "s16le" { + frameBytes := 2 * r.config.Channels + if r.config.BufferSize%frameBytes != 0 { + log.Printf("Recording: BufferSize %d not aligned to frame size %d; audio frames may split", + r.config.BufferSize, frameBytes) + } + } + return nil +}