refactor moving status to pipeline not daemon

This commit is contained in:
LeonardoTrapani
2025-08-14 15:06:34 +02:00
parent 4852ad7ac1
commit 743b103857
4 changed files with 101 additions and 177 deletions
+23 -97
View File
@@ -16,25 +16,14 @@ import (
"github.com/leonardotrapani/hyprvoice/internal/pipeline" "github.com/leonardotrapani/hyprvoice/internal/pipeline"
) )
type Status = pipeline.Status
const (
Idle = pipeline.Idle
Recording = pipeline.Recording
Transcribing = pipeline.Transcribing
Injecting = pipeline.Injecting
)
type Daemon struct { type Daemon struct {
mu sync.RWMutex mu sync.RWMutex
status Status
notifier notify.Notifier notifier notify.Notifier
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
pipeline pipeline.Pipeline pipeline pipeline.Pipeline
actionChannel chan<- pipeline.Action
} }
func New(n notify.Notifier) *Daemon { func New(n notify.Notifier) *Daemon {
@@ -46,48 +35,16 @@ func New(n notify.Notifier) *Daemon {
notifier: n, notifier: n,
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
status: Idle,
} }
return d return d
} }
func (d *Daemon) Status() Status { func (d *Daemon) status() pipeline.Status {
d.mu.RLock() if d.pipeline == nil {
defer d.mu.RUnlock() return pipeline.Idle
return d.status
}
func (d *Daemon) startStatusReader(ctx context.Context, statusCh <-chan Status) {
defer func() {
// Always clean up when this goroutine exits
d.mu.Lock()
d.status = Idle
d.pipeline = nil
d.actionChannel = nil
d.mu.Unlock()
}()
for {
select {
case status, ok := <-statusCh:
if !ok {
return // Channel closed
}
d.mu.Lock()
oldStatus := d.status
d.status = status
d.mu.Unlock()
if oldStatus != status {
log.Printf("Status changed: %s -> %s", oldStatus, status)
}
case <-ctx.Done():
return // Context cancelled
}
} }
return d.pipeline.Status()
} }
func (d *Daemon) Run() error { func (d *Daemon) Run() error {
@@ -158,7 +115,7 @@ func (d *Daemon) handle(c net.Conn) {
d.toggle() d.toggle()
fmt.Fprint(c, "OK toggled\n") fmt.Fprint(c, "OK toggled\n")
case 's': case 's':
status := d.Status() status := d.status()
fmt.Fprintf(c, "STATUS status=%s\n", status) fmt.Fprintf(c, "STATUS status=%s\n", status)
case 'v': case 'v':
fmt.Fprintf(c, "STATUS proto=%s\n", bus.ProtoVer) fmt.Fprintf(c, "STATUS proto=%s\n", bus.ProtoVer)
@@ -172,64 +129,33 @@ func (d *Daemon) handle(c net.Conn) {
} }
func (d *Daemon) toggle() { func (d *Daemon) toggle() {
// Capture current state and prepare action under lock
d.mu.Lock()
status := d.status
var pipelineToStop pipeline.Pipeline
var actionChan chan<- pipeline.Action var actionChan chan<- pipeline.Action
switch status { switch d.status() {
case Idle: case pipeline.Idle:
// Clean up any existing pipeline first p := pipeline.New()
p.Run(d.ctx)
d.pipeline = p
go d.notifier.RecordingStarted()
case pipeline.Recording:
go d.notifier.Aborted()
if d.pipeline != nil { if d.pipeline != nil {
pipelineToStop = d.pipeline d.pipeline.Stop() // aborted during recording (chunks not sent to transcriber yet)
d.pipeline = nil d.pipeline = nil
} }
// Start new pipeline case pipeline.Transcribing:
p := pipeline.New()
statusCh, actionCh := p.Run(d.ctx)
d.pipeline = p
d.actionChannel = actionCh
d.mu.Unlock()
// Stop old pipeline if needed (outside lock)
if pipelineToStop != nil {
pipelineToStop.Stop()
}
go d.notifier.RecordingStarted()
go d.startStatusReader(d.ctx, statusCh)
case Recording:
pipelineToStop = d.pipeline
d.mu.Unlock()
go d.notifier.RecordingEnded() go d.notifier.RecordingEnded()
if pipelineToStop != nil { actionChan = d.pipeline.Actions()
pipelineToStop.Stop() actionChan <- pipeline.Inject
}
case Transcribing: case pipeline.Injecting:
actionChan = d.actionChannel go d.notifier.Aborted()
d.mu.Unlock()
// Try to inject (non-blocking) if d.pipeline != nil {
if actionChan != nil { d.pipeline.Stop() // aborted during injection
select { d.pipeline = nil
case actionChan <- pipeline.Inject:
default:
}
}
case Injecting:
pipelineToStop = d.pipeline
d.mu.Unlock()
go d.notifier.RecordingEnded()
if pipelineToStop != nil {
pipelineToStop.Stop() // aborted during injection
} }
default: default:
+8
View File
@@ -8,6 +8,7 @@ import (
type Notifier interface { type Notifier interface {
RecordingStarted() RecordingStarted()
RecordingEnded() RecordingEnded()
Aborted()
Transcribing() Transcribing()
Error(msg string) Error(msg string)
} }
@@ -35,6 +36,13 @@ func (Desktop) Transcribing() {
} }
} }
func (Desktop) Aborted() {
cmd := exec.Command("notify-send", "-a", "Hyprvoice", "-u", "critical", "Hyprvoice: Aborted")
if err := cmd.Run(); err != nil {
log.Printf("Failed to send abort notification: %v", err)
}
}
func (Desktop) Error(msg string) { func (Desktop) Error(msg string) {
cmd := exec.Command("notify-send", "-a", "Hyprvoice", "-u", "critical", msg) cmd := exec.Command("notify-send", "-a", "Hyprvoice", "-u", "critical", msg)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
+34 -41
View File
@@ -10,6 +10,7 @@ import (
) )
type Status string type Status string
type Action string
const ( const (
Idle Status = "idle" Idle Status = "idle"
@@ -18,21 +19,19 @@ const (
Injecting Status = "injecting" Injecting Status = "injecting"
) )
// Action represents commands sent to the pipeline to drive state
// transitions or trigger work. Keeping this separate from Status
// preserves directionality: callers send Actions, pipeline emits Statuses.
type Action int
const ( const (
Inject Action = iota Inject Action = "inject"
) )
type Pipeline interface { type Pipeline interface {
Run(ctx context.Context) (<-chan Status, chan<- Action) Run(ctx context.Context)
Stop() Stop()
Status() Status
Actions() chan<- Action
} }
type pipeline struct { type pipeline struct {
status Status
actionCh chan Action actionCh chan Action
wg sync.WaitGroup wg sync.WaitGroup
cancel context.CancelFunc cancel context.CancelFunc
@@ -44,74 +43,75 @@ func New() Pipeline {
} }
} }
func (p *pipeline) Run(ctx context.Context) (<-chan Status, chan<- Action) { func (p *pipeline) Status() Status {
statusCh := make(chan Status, 1) return p.status
}
func (p *pipeline) Actions() chan<- Action {
return p.actionCh
}
func (p *pipeline) Stop() {
if p.cancel != nil {
p.cancel()
}
p.wg.Wait()
}
func (p *pipeline) Run(ctx context.Context) {
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
p.cancel = cancel p.cancel = cancel
p.wg.Add(1) p.wg.Add(1)
go p.run(runCtx, statusCh) go p.run(runCtx)
return statusCh, p.actionCh
} }
func (p *pipeline) run(ctx context.Context, statusCh chan<- Status) { func (p *pipeline) run(ctx context.Context) {
defer func() { defer func() {
// Ensure status channel is closed and wg is decremented
close(statusCh)
p.wg.Done() p.wg.Done()
}() }()
log.Printf("Pipeline: Starting recording") log.Printf("Pipeline: Starting recording")
statusCh <- Recording p.status = Recording
recorder := recording.NewDefaultRecorder() recorder := recording.NewDefaultRecorder()
frameCh, errCh, err := recorder.Start(ctx) frameCh, errCh, err := recorder.Start(ctx)
if err != nil { if err != nil {
log.Printf("Pipeline: Recording error: %v", err) log.Printf("Pipeline: Recording error: %v", err)
statusCh <- Idle p.status = Idle
return return
} }
defer recorder.Stop() defer recorder.Stop()
// Track statistics for logging
frameCount := 0 frameCount := 0
totalBytes := 0 totalBytes := 0
startTime := time.Now()
// Main event loop
for { for {
select { select {
case frame, ok := <-frameCh: case frame := <-frameCh:
if !ok {
// Frame channel closed, recording ended
log.Printf("Pipeline: Recording ended. Total frames: %d, Total bytes: %d, Duration: %v",
frameCount, totalBytes, time.Since(startTime))
statusCh <- Transcribing
return
}
// Log frame details
frameCount++ frameCount++
totalBytes += len(frame.Data) totalBytes += len(frame.Data)
log.Printf("Pipeline: Received frame #%d - Size: %d bytes, Timestamp: %v, Total bytes so far: %d", log.Printf("Pipeline: Received frame #%d - Size: %d bytes, Timestamp: %v, Total bytes so far: %d",
frameCount, len(frame.Data), frame.Timestamp.Format("15:04:05.000"), totalBytes) frameCount, len(frame.Data), frame.Timestamp.Format("15:04:05.000"), totalBytes)
// TODO: pass this channel to the transcriber
p.status = Transcribing
case err := <-errCh: case err := <-errCh:
if err != nil { if err != nil {
log.Printf("Pipeline: Recording error: %v", err) log.Printf("Pipeline: Recording error: %v", err)
statusCh <- Idle p.status = Idle
return return
} }
case action := <-p.actionCh: case action := <-p.actionCh:
log.Printf("Pipeline: Received action: %v", action) log.Printf("Pipeline: Received action: %v", action)
if action == Inject { switch action {
log.Printf("Pipeline: Inject action received, stopping recording") case Inject:
log.Printf("Pipeline: Inject action received, stopping recording (TODO: stop transcribing)")
if err := recorder.Stop(); err != nil { if err := recorder.Stop(); err != nil {
log.Printf("Pipeline: Error stopping recorder: %v", err) log.Printf("Pipeline: Error stopping recorder: %v", err)
} }
statusCh <- Injecting p.status = Injecting
return return
} }
@@ -121,10 +121,3 @@ func (p *pipeline) run(ctx context.Context, statusCh chan<- Status) {
} }
} }
} }
func (p *pipeline) Stop() {
if p.cancel != nil {
p.cancel()
}
p.wg.Wait()
}
+5 -8
View File
@@ -71,7 +71,6 @@ func (r *Recorder) Start(ctx context.Context) (<-chan AudioFrame, <-chan error,
return nil, nil, fmt.Errorf("PipeWire not available: %w", err) return nil, nil, fmt.Errorf("PipeWire not available: %w", err)
} }
// Create a cancellable context specific to this recording session.
recordingCtx, cancel := context.WithCancel(ctx) recordingCtx, cancel := context.WithCancel(ctx)
frameCh := make(chan AudioFrame, r.config.ChannelBufferSize) frameCh := make(chan AudioFrame, r.config.ChannelBufferSize)
@@ -150,7 +149,6 @@ func (r *Recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, e
r.cmd = cmd r.cmd = cmd
r.mu.Unlock() r.mu.Unlock()
// Log stderr lines to aid diagnostics.
go func() { go func() {
scanner := bufio.NewScanner(stderr) scanner := bufio.NewScanner(stderr)
for scanner.Scan() { for scanner.Scan() {
@@ -158,12 +156,15 @@ func (r *Recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, e
} }
}() }()
for {
select {
case <-ctx.Done():
return
default:
buffer := make([]byte, r.config.BufferSize) buffer := make([]byte, r.config.BufferSize)
var sentCount int var sentCount int
var droppedCount int var droppedCount int
lastDropLog := time.Now() lastDropLog := time.Now()
for {
n, readErr := stdout.Read(buffer) n, readErr := stdout.Read(buffer)
if n > 0 { if n > 0 {
frameData := make([]byte, n) frameData := make([]byte, n)
@@ -195,10 +196,6 @@ func (r *Recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, e
return return
} }
select {
case <-ctx.Done():
return
default:
} }
} }
} }