improve notifier + add transcriber
This commit is contained in:
@@ -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,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=
|
||||||
|
|||||||
@@ -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
@@ -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) {}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user