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 // maximum duration of one captured utterance CallDuration time.Duration // maximum duration of the entire approved call EndSilence time.Duration VoiceThreshold int MaxTurns int MaxPendingAudioChunks int } type capturedTurn struct { PCM []byte Window CaptureWindow } func captureTurn(ctx context.Context, session MediaSession, cfg CaptureConfig) (capturedTurn, 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 firstFrameAt, lastFrameAt time.Time var frames []byte for { if err := ctx.Err(); err != nil { return capturedTurn{}, err } now := time.Now() if !started && !now.Before(firstDeadline) { return capturedTurn{}, 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 := ctx.Err(); err != nil { return capturedTurn{}, err } 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 capturedTurn{}, err } if len(payload) == 0 { continue } receivedAt := time.Now() if cfg.VoiceThreshold > 0 { if pcm16VoiceLevel(payload) >= cfg.VoiceThreshold { started = true lastVoice = receivedAt } } else { started = true lastVoice = receivedAt } if started { if firstFrameAt.IsZero() { firstFrameAt = receivedAt } lastFrameAt = receivedAt frames = append(frames, payload...) } if started && cfg.EndSilence > 0 && !lastVoice.IsZero() && !time.Now().Before(lastVoice.Add(cfg.EndSilence)) { break } } if err := ctx.Err(); err != nil { return capturedTurn{}, err } if len(frames) == 0 { return capturedTurn{}, errors.New("captured audio is empty") } return capturedTurn{PCM: frames, Window: CaptureWindow{StartedAt: firstFrameAt, EndedAt: lastFrameAt}}, 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)) }