add recording using pipewire
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user