improve notifier + add transcriber

This commit is contained in:
LeonardoTrapani
2025-08-16 16:17:48 +02:00
parent 688152ea13
commit feda62a7d4
8 changed files with 492 additions and 47 deletions
+1
View File
@@ -4,6 +4,7 @@ go 1.24.5
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/sashabaranov/go-openai v1.41.1 // indirect
github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
) )
+2
View File
@@ -2,6 +2,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4=
github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+23
View File
@@ -158,6 +158,7 @@ func (d *Daemon) toggle() {
d.mu.Unlock() d.mu.Unlock()
go d.notifier.RecordingStarted() go d.notifier.RecordingStarted()
go d.monitorPipelineErrors(p)
case pipeline.Recording: case pipeline.Recording:
d.stopPipeline() // aborted during recording (chunks not sent to transcriber yet) d.stopPipeline() // aborted during recording (chunks not sent to transcriber yet)
@@ -179,3 +180,25 @@ func (d *Daemon) toggle() {
go d.notifier.Aborted() go d.notifier.Aborted()
} }
} }
func (d *Daemon) monitorPipelineErrors(p pipeline.Pipeline) {
errorCh := p.GetErrorCh()
for {
select {
case pipelineErr := <-errorCh:
d.handlePipelineError(pipelineErr)
case <-d.ctx.Done():
return
}
}
}
func (d *Daemon) handlePipelineError(pipelineErr pipeline.PipelineError) {
message := pipelineErr.Message
if pipelineErr.Err != nil {
message = fmt.Sprintf("%s: %v", message, pipelineErr.Err)
}
d.notifier.Error(message)
}
+42 -18
View File
@@ -11,29 +11,21 @@ type Notifier interface {
Aborted() Aborted()
Transcribing() Transcribing()
Error(msg string) Error(msg string)
Notify(title, message string)
} }
type Desktop struct{} type Desktop struct{}
func (Desktop) RecordingStarted() { func (d Desktop) RecordingStarted() {
cmd := exec.Command("notify-send", "-a", "Hyprvoice", "Hyprvoice: Recording Started") d.Notify("Hyprvoice", "Recording Started")
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
}
} }
func (Desktop) RecordingEnded() { func (d Desktop) RecordingEnded() {
cmd := exec.Command("notify-send", "-a", "Hyprvoice", "Hyprvoice: Recording Ended") d.Notify("Hyprvoice", "Recording Ended")
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
}
} }
func (Desktop) Transcribing() { func (d Desktop) Transcribing() {
cmd := exec.Command("notify-send", "-a", "Hyprvoice", "Hyprvoice: Transcribing...") d.Notify("Hyprvoice", "Transcribing...")
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
}
} }
func (Desktop) Aborted() { func (Desktop) Aborted() {
@@ -44,14 +36,45 @@ func (Desktop) Aborted() {
} }
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", "Hyprvoice Error", msg)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
log.Printf("Failed to send error notification: %v", err) log.Printf("Failed to send error notification: %v", err)
} }
} }
// Nop is a Notifier that does absolutely nothing. func (Desktop) Notify(title, message string) {
// Useful in unit tests or headless builds. cmd := exec.Command("notify-send", "-a", "Hyprvoice", title, message)
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
}
}
type Log struct{}
func (l Log) RecordingStarted() {
l.Notify("Hyprvoice", "Recording Started")
}
func (l Log) RecordingEnded() {
l.Notify("Hyprvoice", "Recording Ended")
}
func (l Log) Transcribing() {
l.Notify("Hyprvoice", "Transcribing...")
}
func (l Log) Aborted() {
l.Notify("Hyprvoice", "Aborted")
}
func (l Log) Error(msg string) {
l.Notify("Hyprvoice Error", msg)
}
func (Log) Notify(title, message string) {
log.Printf("%s: %s", title, message)
}
type Nop struct{} type Nop struct{}
func (Nop) RecordingStarted() {} func (Nop) RecordingStarted() {}
@@ -59,3 +82,4 @@ func (Nop) RecordingEnded() {}
func (Nop) Aborted() {} func (Nop) Aborted() {}
func (Nop) Transcribing() {} func (Nop) Transcribing() {}
func (Nop) Error(msg string) {} func (Nop) Error(msg string) {}
func (Nop) Notify(title, message string) {}
+97 -19
View File
@@ -3,15 +3,24 @@ package pipeline
import ( import (
"context" "context"
"log" "log"
"os"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/recording" "github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
) )
type Status string type Status string
type Action string type Action string
type PipelineError struct {
Title string
Message string
Err error
}
const ( const (
Idle Status = "idle" Idle Status = "idle"
Recording Status = "recording" Recording Status = "recording"
@@ -28,24 +37,26 @@ type Pipeline interface {
Stop() Stop()
Status() Status Status() Status
GetActionCh() chan<- Action GetActionCh() chan<- Action
GetErrorCh() <-chan PipelineError
} }
type pipeline struct { type pipeline struct {
status Status status Status
actionCh chan Action actionCh chan Action
errorCh chan PipelineError
mu sync.RWMutex mu sync.RWMutex
wg sync.WaitGroup wg sync.WaitGroup
cancel context.CancelFunc cancel context.CancelFunc
stopOnce sync.Once stopOnce sync.Once
running bool
running int32
} }
func New() Pipeline { func New() Pipeline {
return &pipeline{ return &pipeline{
actionCh: make(chan Action, 1), actionCh: make(chan Action, 1),
errorCh: make(chan PipelineError, 10),
} }
} }
@@ -79,6 +90,26 @@ func (p *pipeline) GetActionCh() chan<- Action {
return p.actionCh return p.actionCh
} }
func (p *pipeline) GetErrorCh() <-chan PipelineError {
p.mu.RLock()
defer p.mu.RUnlock()
return p.errorCh
}
func (p *pipeline) sendError(title, message string, err error) {
pipelineErr := PipelineError{
Title: title,
Message: message,
Err: err,
}
select {
case p.errorCh <- pipelineErr:
default:
log.Printf("Pipeline: Error channel full, dropping error: %s", message)
}
}
func (p *pipeline) Stop() { func (p *pipeline) Stop() {
p.stopOnce.Do(func() { p.stopOnce.Do(func() {
cancel := p.getCancel() cancel := p.getCancel()
@@ -90,14 +121,10 @@ func (p *pipeline) Stop() {
} }
func (p *pipeline) Run(ctx context.Context) { func (p *pipeline) Run(ctx context.Context) {
p.mu.Lock() if !atomic.CompareAndSwapInt32(&p.running, 0, 1) {
if p.running {
p.mu.Unlock()
log.Printf("Pipeline: Already running, ignoring Run() call") log.Printf("Pipeline: Already running, ignoring Run() call")
return return
} }
p.running = true
p.mu.Unlock()
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
p.setCancel(cancel) p.setCancel(cancel)
@@ -108,10 +135,8 @@ func (p *pipeline) Run(ctx context.Context) {
func (p *pipeline) run(ctx context.Context) { func (p *pipeline) run(ctx context.Context) {
defer func() { defer func() {
log.Printf("Pipeline: defered run") atomic.StoreInt32(&p.running, 0)
p.mu.Lock() p.setStatus(Idle)
p.running = false
p.mu.Unlock()
p.wg.Done() p.wg.Done()
}() }()
@@ -122,10 +147,45 @@ func (p *pipeline) run(ctx context.Context) {
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)
p.setStatus(Idle) p.sendError("Recording Error", "Failed to start recording", err)
return return
} }
defer recorder.Stop()
defer func() {
if stopErr := recorder.Stop(); stopErr != nil {
log.Printf("Pipeline: Error stopping recorder: %v", stopErr)
p.sendError("Recording Error", "Failed to stop recorder cleanly", stopErr)
}
}()
config := transcriber.DefaultConfig()
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
config.APIKey = apiKey
}
t, err := transcriber.NewTranscriber(config)
if err != nil {
log.Printf("Pipeline: Failed to create transcriber: %v", err)
p.sendError("Transcription Error", "Failed to create transcriber", err)
return
}
log.Printf("Pipeline: Starting transcriber")
p.setStatus(Transcribing)
tErrCh, err := t.Start(ctx, frameCh)
if err != nil {
log.Printf("Pipeline: Transcriber error: %v", err)
p.sendError("Transcription Error", "Failed to start transcriber", err)
return
}
defer func() {
if stopErr := t.Stop(); stopErr != nil {
log.Printf("Pipeline: Error stopping transcriber: %v", stopErr)
p.sendError("Transcription Error", "Failed to stop transcriber cleanly", stopErr)
}
}()
frameCount := 0 frameCount := 0
totalBytes := 0 totalBytes := 0
@@ -138,12 +198,17 @@ func (p *pipeline) run(ctx context.Context) {
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)
p.setStatus(Transcribing) case err := <-tErrCh:
if err != nil {
log.Printf("Pipeline: Transcription error: %v", err)
p.sendError("Transcription Error", "Transcription processing error", err)
return
}
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)
p.setStatus(Idle) p.sendError("Recording Error", "Recording stream error", err)
return return
} }
@@ -151,18 +216,31 @@ func (p *pipeline) run(ctx context.Context) {
log.Printf("Pipeline: Received action: %v", action) log.Printf("Pipeline: Received action: %v", action)
switch action { switch action {
case Inject: case Inject:
log.Printf("Pipeline: Inject action received, stopping recording (TODO: stop transcribing)") if p.status != Transcribing {
log.Printf("Pipeline: Inject action received, but not in transcribing state, ignoring")
continue
}
log.Printf("Pipeline: Inject action received, stopping recording and getting transcription")
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)
p.sendError("Recording Error", "Failed to stop recorder during injection", err)
} }
p.setStatus(Injecting) p.setStatus(Injecting)
// Simulate injection work then return to idle transcriptionText, err := t.GetTranscription()
if err != nil {
log.Printf("Pipeline: Error getting transcription: %v", err)
p.sendError("Transcription Error", "Failed to retrieve transcription", err)
} else {
log.Printf("Pipeline: Transcription text: %s", transcriptionText)
}
log.Printf("Pipeline: Simulating injection work") log.Printf("Pipeline: Simulating injection work")
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
log.Printf("Pipeline: Injection work done, returning to idle") log.Printf("Pipeline: Injection work done, returning to idle")
p.setStatus(Idle)
return return
} }
+2 -2
View File
@@ -33,9 +33,9 @@ func DefaultConfig() Config {
SampleRate: 16000, SampleRate: 16000,
Channels: 1, Channels: 1,
Format: "s16", Format: "s16",
BufferSize: 4096, BufferSize: 8192,
Device: "", Device: "",
ChannelBufferSize: 20, ChannelBufferSize: 30,
} }
} }
+278
View File
@@ -0,0 +1,278 @@
package transcriber
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/sashabaranov/go-openai"
)
type OpenAITranscriber struct {
client *openai.Client
config Config
buffer *audioBuffer
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.Mutex
transcribing bool
transcriptionMu sync.RWMutex
transcriptionText strings.Builder
}
type audioBuffer struct {
data []byte
mu sync.Mutex
lastAdd time.Time
maxSize int
}
func NewOpenAITranscriber(config Config) *OpenAITranscriber {
client := openai.NewClient(config.APIKey)
buffer := &audioBuffer{
data: make([]byte, 0, config.ChunkSize*2),
maxSize: config.ChunkSize,
}
return &OpenAITranscriber{
client: client,
config: config,
buffer: buffer,
}
}
func (t *OpenAITranscriber) Start(ctx context.Context, frameCh <-chan recording.AudioFrame) (<-chan error, error) {
t.mu.Lock()
if t.transcribing {
t.mu.Unlock()
return nil, fmt.Errorf("transcriber: already transcribing")
}
t.transcribing = true
t.mu.Unlock()
transcribeCtx, cancel := context.WithCancel(ctx)
t.cancel = cancel
errCh := make(chan error, 1)
t.wg.Add(1)
go t.processFrames(transcribeCtx, frameCh, errCh)
return errCh, nil
}
func (t *OpenAITranscriber) Stop() error {
t.mu.Lock()
if !t.transcribing {
t.mu.Unlock()
return nil
}
cancel := t.cancel
t.mu.Unlock()
if cancel != nil {
cancel()
}
t.wg.Wait()
return nil
}
func (t *OpenAITranscriber) GetTranscription() (string, error) {
t.transcriptionMu.RLock()
defer t.transcriptionMu.RUnlock()
if t.transcriptionText.Len() == 0 {
return "", nil
}
return t.transcriptionText.String(), nil
}
func (t *OpenAITranscriber) processFrames(ctx context.Context, frameCh <-chan recording.AudioFrame, errCh chan<- error) {
defer func() {
close(errCh)
t.mu.Lock()
t.transcribing = false
t.cancel = nil
t.mu.Unlock()
t.wg.Done()
}()
ticker := time.NewTicker(t.config.BufferTime)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
if t.buffer.hasData() {
t.transcribeBuffer(ctx, errCh)
}
return
case frame, ok := <-frameCh:
if !ok {
if t.buffer.hasData() {
t.transcribeBuffer(ctx, errCh)
}
return
}
t.buffer.addFrame(frame)
case <-ticker.C:
if t.buffer.shouldFlush(t.config.BufferTime) {
t.transcribeBuffer(ctx, errCh)
}
}
}
}
func (t *OpenAITranscriber) transcribeBuffer(ctx context.Context, errCh chan<- error) {
audioData := t.buffer.flush()
if len(audioData) == 0 {
return
}
log.Printf("transcriber: sending %d bytes to OpenAI API", len(audioData))
wavData, err := t.convertToWAV(audioData)
if err != nil {
log.Printf("transcriber: failed to convert audio to WAV: %v", err)
select {
case errCh <- fmt.Errorf("transcriber: convert to WAV: %w", err):
default:
}
return
}
req := openai.AudioRequest{
Model: t.config.Model,
Reader: bytes.NewReader(wavData),
FilePath: "audio.wav",
Language: t.config.Language,
}
start := time.Now()
resp, err := t.client.CreateTranscription(ctx, req)
duration := time.Since(start)
if err != nil {
log.Printf("transcriber: API call failed after %v: %v", duration, err)
select {
case errCh <- fmt.Errorf("transcriber: transcription failed: %w", err):
default:
}
return
}
if resp.Text != "" {
log.Printf("transcriber: received result in %v: %q", duration, resp.Text)
t.transcriptionMu.Lock()
if t.transcriptionText.Len() > 0 {
t.transcriptionText.WriteString(" ")
}
t.transcriptionText.WriteString(strings.TrimSpace(resp.Text))
t.transcriptionMu.Unlock()
} else {
log.Printf("transcriber: received empty result after %v", duration)
}
}
func (t *OpenAITranscriber) convertToWAV(rawAudio []byte) ([]byte, error) {
var buf bytes.Buffer
const sampleRate = 16000
const channels = 1
const bitsPerSample = 16
const byteRate = sampleRate * channels * bitsPerSample / 8
const blockAlign = channels * bitsPerSample / 8
dataSize := len(rawAudio)
fileSize := 36 + dataSize
buf.WriteString("RIFF")
binary.Write(&buf, binary.LittleEndian, uint32(fileSize))
buf.WriteString("WAVE")
buf.WriteString("fmt ")
binary.Write(&buf, binary.LittleEndian, uint32(16))
binary.Write(&buf, binary.LittleEndian, uint16(1))
binary.Write(&buf, binary.LittleEndian, uint16(channels))
binary.Write(&buf, binary.LittleEndian, uint32(sampleRate))
binary.Write(&buf, binary.LittleEndian, uint32(byteRate))
binary.Write(&buf, binary.LittleEndian, uint16(blockAlign))
binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample))
buf.WriteString("data")
binary.Write(&buf, binary.LittleEndian, uint32(dataSize))
buf.Write(rawAudio)
return buf.Bytes(), nil
}
func (b *audioBuffer) addFrame(frame recording.AudioFrame) {
b.mu.Lock()
defer b.mu.Unlock()
b.data = append(b.data, frame.Data...)
b.lastAdd = frame.Timestamp
if len(b.data) > b.maxSize*2 {
b.data = b.data[len(b.data)-b.maxSize:]
}
}
func (b *audioBuffer) flush() []byte {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.data) == 0 {
return nil
}
result := make([]byte, len(b.data))
copy(result, b.data)
b.data = b.data[:0]
return result
}
func (b *audioBuffer) hasData() bool {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.data) > 0
}
func (b *audioBuffer) shouldFlush(bufferTime time.Duration) bool {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.data) == 0 {
return false
}
if len(b.data) >= b.maxSize {
return true
}
return time.Since(b.lastAdd) >= bufferTime
}
func NewTranscriber(config Config) (Transcriber, error) {
switch config.Provider {
case "openai":
if config.APIKey == "" {
return nil, fmt.Errorf("OpenAI API key required")
}
return NewOpenAITranscriber(config), nil
default:
return nil, fmt.Errorf("unsupported provider: %s", config.Provider)
}
}
+39
View File
@@ -0,0 +1,39 @@
package transcriber
import (
"context"
"time"
"github.com/leonardotrapani/hyprvoice/internal/recording"
)
type TranscriptionResult struct {
Text string
Timestamp time.Time
IsFinal bool
}
type Transcriber interface {
Start(ctx context.Context, frameCh <-chan recording.AudioFrame) (<-chan error, error)
Stop() error
GetTranscription() (string, error)
}
type Config struct {
Provider string
APIKey string
Language string
ChunkSize int
BufferTime time.Duration
Model string
}
func DefaultConfig() Config {
return Config{
Provider: "openai",
Language: "en",
ChunkSize: 16384,
BufferTime: 2 * time.Second,
Model: "whisper-1",
}
}