2026-04-01 04:21:21 +00:00
package asr
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/pion/rtp"
"github.com/pion/webrtc/v3/pkg/media/oggwriter"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
)
type speechAccumulator struct {
writer * oggwriter . OggWriter
file string
lastAudioAt time . Time
mu sync . Mutex
closed bool
chatID string
speakerID string
sessionID string
channel string
}
func ( a * speechAccumulator ) Push ( chunk bus . AudioChunk ) {
a . mu . Lock ( )
defer a . mu . Unlock ( )
if a . closed {
return
}
a . lastAudioAt = time . Now ( )
pkt := & rtp . Packet {
Header : rtp . Header {
SequenceNumber : uint16 ( chunk . Sequence ) ,
Timestamp : chunk . Timestamp ,
SSRC : 1 , // Stable arbitrary dummy
} ,
Payload : chunk . Data ,
}
if err := a . writer . WriteRTP ( pkt ) ; err != nil {
logger . ErrorCF ( "voice-agent" , "Failed to write RTP" , map [ string ] any { "error" : err } )
}
}
func ( a * speechAccumulator ) Close ( ) {
a . mu . Lock ( )
defer a . mu . Unlock ( )
if ! a . closed {
a . writer . Close ( )
a . closed = true
}
}
type Agent struct {
bus * bus . MessageBus
transcriber Transcriber
mu sync . Mutex
sessions map [ string ] * speechAccumulator // keyed by sessionID_speakerID
}
func NewAgent ( mb * bus . MessageBus , t Transcriber ) * Agent {
return & Agent {
bus : mb ,
transcriber : t ,
sessions : make ( map [ string ] * speechAccumulator ) ,
}
}
func ( a * Agent ) Start ( ctx context . Context ) {
logger . InfoCF ( "voice-agent" , "Started Voice Agent orchestrator" , nil )
go a . listenChunks ( ctx )
go a . vadTick ( ctx )
// Cleanup sessions on shutdown
go func ( ) {
<- ctx . Done ( )
a . mu . Lock ( )
for key , acc := range a . sessions {
acc . Close ( )
os . Remove ( acc . file )
delete ( a . sessions , key )
}
a . mu . Unlock ( )
logger . InfoCF ( "voice-agent" , "Cleaned up voice sessions on shutdown" , nil )
} ( )
}
func ( a * Agent ) listenChunks ( ctx context . Context ) {
chunks := a . bus . AudioChunksChan ( )
for {
select {
case <- ctx . Done ( ) :
return
case chunk , ok := <- chunks :
if ! ok {
return
}
a . handleChunk ( chunk )
}
}
}
func ( a * Agent ) handleChunk ( chunk bus . AudioChunk ) {
// Only accept Opus-encoded audio
if chunk . Format != "opus" {
logger . DebugCF ( "voice-agent" , "Ignoring unsupported audio format" , map [ string ] any { "format" : chunk . Format } )
return
}
key := fmt . Sprintf ( "%s_%s" , chunk . SessionID , chunk . SpeakerID )
a . mu . Lock ( )
acc , exists := a . sessions [ key ]
if ! exists {
filename := filepath . Join ( os . TempDir ( ) , fmt . Sprintf ( "voice_%s_%d.ogg" , key , time . Now ( ) . UnixNano ( ) ) )
writer , err := oggwriter . New ( filename , uint32 ( chunk . SampleRate ) , uint16 ( chunk . Channels ) )
if err != nil {
a . mu . Unlock ( )
logger . ErrorCF ( "voice-agent" , "Failed to create OggWriter" , map [ string ] any { "error" : err } )
return
}
acc = & speechAccumulator {
writer : writer ,
file : filename ,
lastAudioAt : time . Now ( ) ,
chatID : chunk . ChatID ,
speakerID : chunk . SpeakerID ,
sessionID : chunk . SessionID ,
channel : chunk . Channel ,
}
a . sessions [ key ] = acc
logger . DebugCF ( "voice-agent" , "Started accumulating voice" , map [ string ] any { "key" : key , "file" : filename } )
}
a . mu . Unlock ( )
acc . Push ( chunk )
}
func ( a * Agent ) vadTick ( ctx context . Context ) {
ticker := time . NewTicker ( 500 * time . Millisecond )
defer ticker . Stop ( )
for {
select {
case <- ctx . Done ( ) :
return
case <- ticker . C :
a . checkSilence ( ctx )
}
}
}
func ( a * Agent ) checkSilence ( ctx context . Context ) {
a . mu . Lock ( )
now := time . Now ( )
var finished [ ] * speechAccumulator
for key , acc := range a . sessions {
acc . mu . Lock ( )
last := acc . lastAudioAt
acc . mu . Unlock ( )
if now . Sub ( last ) > 1500 * time . Millisecond {
acc . Close ( )
delete ( a . sessions , key )
finished = append ( finished , acc )
}
}
a . mu . Unlock ( )
for _ , acc := range finished {
go a . processUtterance ( ctx , acc )
}
}
func ( a * Agent ) processUtterance ( ctx context . Context , acc * speechAccumulator ) {
defer os . Remove ( acc . file )
logger . InfoCF ( "voice-agent" , "User finished speaking, transcribing..." , map [ string ] any { "file" : acc . file } )
if a . transcriber == nil {
logger . ErrorCF ( "voice-agent" , "No STT configured!" , nil )
return
}
res , err := a . transcriber . Transcribe ( ctx , acc . file )
if err != nil {
logger . ErrorCF ( "voice-agent" , "Transcription failed" , map [ string ] any { "error" : err } )
return
}
if res . Text == "" {
logger . DebugCF ( "voice-agent" , "Ignored empty transcription" , map [ string ] any { "file" : acc . file } )
return
}
logger . InfoCF ( "voice-agent" , "Transcription result" , map [ string ] any { "text" : res . Text , "duration" : res . Duration } )
channelType := acc . channel
if channelType == "" {
channelType = "discord" // fallback for legacy chunks
}
text := strings . ToLower ( strings . TrimSpace ( res . Text ) )
if strings . Contains ( text , "leave the voice channel" ) || strings . Contains ( text , "leave voice" ) ||
strings . Contains ( text , "disconnect voice" ) || strings . Contains ( text , "leave the channel" ) ||
strings . Contains ( text , "leave channel" ) {
logger . InfoCF ( "voice-agent" , "Voice command triggered: leave" , nil )
if err := a . bus . PublishVoiceControl ( ctx , bus . VoiceControl {
SessionID : acc . sessionID ,
Type : "command" ,
Action : "leave" ,
} ) ; err != nil {
logger . ErrorCF ( "voice-agent" , "Failed to publish leave control" , map [ string ] any { "error" : err } )
}
if err := a . bus . PublishOutbound ( ctx , bus . OutboundMessage {
2026-04-07 13:41:02 +00:00
Context : bus . NewOutboundContext ( channelType , acc . chatID , "" ) ,
2026-04-01 04:21:21 +00:00
Content : "Goodbye! Leaving the voice channel." ,
} ) ; err != nil {
logger . ErrorCF ( "voice-agent" , "Failed to publish goodbye message" , map [ string ] any { "error" : err } )
}
return
}
oralPrompt := "\n\n[SYSTEM]: The user just spoke this to you over voice chat. Please reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally."
if err := a . bus . PublishInbound ( ctx , bus . InboundMessage {
2026-04-07 13:41:02 +00:00
Context : bus . InboundContext {
Channel : channelType ,
ChatID : acc . chatID ,
ChatType : "channel" ,
SenderID : acc . speakerID ,
Raw : map [ string ] string {
"is_voice" : "true" ,
} ,
2026-04-01 04:21:21 +00:00
} ,
2026-04-07 13:41:02 +00:00
Content : res . Text + oralPrompt ,
2026-04-01 04:21:21 +00:00
} ) ; err != nil {
logger . ErrorCF ( "voice-agent" , "Failed to publish inbound message" , map [ string ] any { "error" : err } )
}
}