112 lines
2.7 KiB
Go
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))
|
|
}
|