add recording using pipewire

This commit is contained in:
LeonardoTrapani
2025-08-13 19:02:01 +02:00
parent 7ed9ea1900
commit d28214faad
2 changed files with 436 additions and 0 deletions
+150
View File
@@ -19,6 +19,7 @@
## Requirements ## Requirements
- **Go 1.24.5+** (for building from source)
- Wayland + **Hyprland** - Wayland + **Hyprland**
- **PipeWire** (audio capture) - **PipeWire** (audio capture)
- **systemd --user** (service) - **systemd --user** (service)
@@ -47,11 +48,30 @@ bind = SUPER, R, exec, hyprvoice toggle
## Usage ## Usage
### Basic Usage
- Press your **toggle** key to start; press again to stop. - Press your **toggle** key to start; press again to stop.
- Audio streams to the cloud ASR while you speak. - Audio streams to the cloud ASR while you speak.
- On stop (or VAD endpoint), Hyprvoice **pastes once** into the focused window. - On stop (or VAD endpoint), Hyprvoice **pastes once** into the focused window.
- Injection flow: **save clipboard → copy final text → send Ctrl+V → restore clipboard**. - 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 ## Status
@@ -118,8 +138,135 @@ idle --toggle--> recording --first frame--> transcribing --final--> injecting --
```bash ```bash
git clone https://github.com/leonardotrapani/hyprvoice.git git clone https://github.com/leonardotrapani/hyprvoice.git
cd hyprvoice cd hyprvoice
# Build the binary
CGO_ENABLED=1 go build -o hyprvoice ./cmd/hyprvoice CGO_ENABLED=1 go build -o hyprvoice ./cmd/hyprvoice
# Run tests (when available)
go test ./... 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 ## Contributing
- All PRs and issues welcome. - All PRs and issues welcome.
- Follow existing code conventions
- Add tests for new functionality
- Update documentation for user-facing changes
--- ---
+286
View File
@@ -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
}