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
+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)
}
}