Files
go-sip/internal/callflow/capture.go
T

112 lines
2.7 KiB
Go

package callflow
import (
"context"
"encoding/binary"
"errors"
"time"
)
// CaptureConfig mirrors the real Cell turn boundary: wait for speech, keep
// collecting until bounded duration or end-of-speech silence, then send one
// canonical PCM16 turn to the AI adapter.
type CaptureConfig struct {
FirstSpeechTimeout time.Duration
MaxDuration time.Duration
EndSilence time.Duration
VoiceThreshold int
MaxTurns int
}
func captureTurn(ctx context.Context, session MediaSession, cfg CaptureConfig) ([]byte, error) {
if cfg.FirstSpeechTimeout <= 0 {
cfg.FirstSpeechTimeout = 5 * time.Second
}
if cfg.MaxDuration <= 0 {
cfg.MaxDuration = cfg.FirstSpeechTimeout
}
if cfg.EndSilence < 0 {
cfg.EndSilence = 0
}
startedAt := time.Now()
firstDeadline := startedAt.Add(cfg.FirstSpeechTimeout)
maxDeadline := startedAt.Add(cfg.MaxDuration)
started := cfg.VoiceThreshold <= 0
lastVoice := time.Time{}
var frames []byte
for {
now := time.Now()
if !started && !now.Before(firstDeadline) {
return nil, errors.New("no speech detected before capture timeout")
}
if !maxDeadline.After(now) {
break
}
readDeadline := maxDeadline
if !started && firstDeadline.Before(readDeadline) {
readDeadline = firstDeadline
}
if started && cfg.EndSilence > 0 && !lastVoice.IsZero() {
silenceDeadline := lastVoice.Add(cfg.EndSilence)
if silenceDeadline.Before(readDeadline) {
readDeadline = silenceDeadline
}
}
readCtx, cancel := context.WithDeadline(ctx, readDeadline)
payload, err := session.ReadPayload(readCtx)
cancel()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
if started && cfg.EndSilence > 0 && !lastVoice.IsZero() && !time.Now().Before(lastVoice.Add(cfg.EndSilence)) {
break
}
continue
}
if errors.Is(err, context.Canceled) && ctx.Err() == nil {
continue
}
return nil, err
}
if len(payload) == 0 {
continue
}
if cfg.VoiceThreshold > 0 {
if pcm16VoiceLevel(payload) >= cfg.VoiceThreshold {
started = true
lastVoice = time.Now()
}
if started {
frames = append(frames, payload...)
}
} else {
started = true
lastVoice = time.Now()
frames = append(frames, payload...)
}
if started && cfg.EndSilence > 0 && !lastVoice.IsZero() && !time.Now().Before(lastVoice.Add(cfg.EndSilence)) {
break
}
}
if len(frames) == 0 {
return nil, errors.New("captured audio is empty")
}
return frames, nil
}
func pcm16VoiceLevel(pcm []byte) int {
if len(pcm) < 2 {
return 0
}
var sum uint64
count := len(pcm) / 2
for i := 0; i < count; i++ {
value := int64(int16(binary.LittleEndian.Uint16(pcm[i*2 : i*2+2])))
if value < 0 {
value = -value
}
sum += uint64(value)
}
return int(sum / uint64(count))
}