@@ -83,6 +83,12 @@ public sealed class SipConfig
8383 /// min 10). A call that reaches the PIN gate and receives nothing ends instead of
8484 /// staying open forever.</summary>
8585 public int PinTimeoutSeconds { get ; set ; } = 60 ;
86+ /// <summary>Seconds after the caller's speech ends before the processing indicator (the
87+ /// looped "data processing" cue) starts playing (default 2). The cue is armed by the
88+ /// subprocess VAD "end" event — i.e. the moment STT/LLM processing begins — so a caller
89+ /// never stares at silence while the agent computes. Set higher to delay the cue (it can
90+ /// sound noisy on a quiet line), lower to reassure sooner.</summary>
91+ public int IndicatorDelaySeconds { get ; set ; } = 2 ;
8692 /// <summary>Whisper model used by the STT subprocess (tiny/base/small/medium/largev2/
8793 /// largev3).
8894 /// ⚠️ DO NOT GO BELOW "small": tiny/base were tested on real phone calls and their
@@ -142,6 +148,7 @@ private sealed class CallContext
142148 public CancellationTokenSource Cts = new ( ) ;
143149 public Task ? Loop ;
144150 public volatile bool Validating ;
151+ public volatile bool MediaAttached ; // set once the RTP capture is live (see the watchdog)
145152 public DateTime PinDeadline ; // moment the PIN gate gives up (EnforcePinTimeoutAsync)
146153 }
147154
@@ -170,14 +177,21 @@ private sealed record TtsChunk(byte[] Pcm, bool Indicator);
170177 private int _firstChunkLogged ; // diagnostic: when the first TTS chunk hits RTP
171178
172179 // Processing indicator: a looped "data processing" cue played to the caller while the
173- // agent computes (STT/LLM/tools) — starts 3 s after the caller's utterance was acquired
174- // and stops the moment the first reply chunk arrives. Fills the latency void so the
175- // caller knows the line is working. Sent over the MEDIA (RTP) — never played locally.
180+ // agent computes (STT/LLM/tools) — armed by the subprocess VAD "end" event (speech ended,
181+ // processing began), starts IndicatorDelaySeconds later and stops the moment the first
182+ // reply chunk arrives. Fills the latency void so the caller knows the line is working.
183+ // Sent over the MEDIA (RTP) — never played locally. While the caller speaks again
184+ // (VAD "speech"), the cue is paused so it never beeps over the caller's own voice.
176185 private byte [ ] ? _indicatorPcm = LoadIndicatorPcm ( ) ;
177186 private DateTime _processingSince ;
178187 private volatile bool _replyStarted ;
188+ private volatile bool _indicatorPaused ;
179189 private Task _indicatorLoop = Task . CompletedTask ;
180190 private const int IndicatorPieceMs = 400 ; // pieces this small bound the reply delay to ~400 ms
191+ private const int IndicatorMaxSeconds = 25 ; // hard cap — a stalled STT/LLM must never beep forever
192+ private const int PostTtsMuteMs = 500 ; // capture stays muted this long after the last TTS
193+ // chunk: the caller's echo of the reply is delayed
194+ // by network + phone latency, not real-time
181195
182196 public SipVoiceMedia ( CallContext call )
183197 {
@@ -193,6 +207,9 @@ public SipVoiceMedia(CallContext call)
193207 } ;
194208 // STT lives in the persistent subprocess (VAD + whisper); transcripts arrive here.
195209 SipVoiceAgent . Transcript += OnSubprocessTranscript ;
210+ // The subprocess VAD reports "speech"/"end" — the indicator is armed at "end"
211+ // (processing start) and paused at "speech" (the caller is talking, not waiting).
212+ SipVoiceAgent . VadState += OnVadState ;
196213 }
197214
198215 public string Language => VoiceConversation . ResolveLang ( Cfg . Lang ) ;
@@ -223,6 +240,7 @@ public Task StopAsync()
223240 {
224241 _call . Media . OnRtpPacketReceived -= OnRtpAudio ;
225242 SipVoiceAgent . Transcript -= OnSubprocessTranscript ;
243+ SipVoiceAgent . VadState -= OnVadState ;
226244 _inputQueue . Writer . TryComplete ( ) ;
227245 _ttsQueue . Writer . TryComplete ( ) ;
228246 _dtmf . Reset ( ) ;
@@ -294,16 +312,43 @@ private void OnSubprocessTranscript(string text)
294312 // Whisper labels non-speech segments as "[Musica]"/"[Rumore]"/"[Applausi]" etc. When
295313 // the whole utterance is such a placeholder (background music, line noise), it must
296314 // never reach the LLM — the agent would "answer the music". Mixed text is kept.
297- if ( ! IsNoiseOnlyTranscript ( text ) )
315+ if ( IsNoiseOnlyTranscript ( text ) )
298316 {
299- Log . LogStep ( $ "SIP caller said: { text } ") ;
300- // The caller's utterance is now being processed (STT done → LLM/tools may take a
301- // while): arm the processing indicator — it starts after 3 s and loops until the
302- // first reply chunk arrives.
303- _processingSince = DateTime . UtcNow ;
304- _replyStarted = false ;
305- EnsureIndicatorLoop ( ) ;
306- SpeechReceived ? . Invoke ( text ) ;
317+ // Nothing to process → cancel the cue armed at VAD "end" (it must not beep for
318+ // background music the agent will never answer).
319+ _indicatorPaused = true ;
320+ return ;
321+ }
322+ Log . LogStep ( $ "SIP caller said: { text } ") ;
323+ // The caller's utterance is now being processed (STT done → LLM/tools may take a
324+ // while): arm the processing indicator — it starts after IndicatorDelaySeconds and
325+ // loops until the first reply chunk arrives. Re-arming here is a safety net: the
326+ // primary arming happened at the VAD "end" event (before whisper ran).
327+ _processingSince = DateTime . UtcNow ;
328+ _replyStarted = false ;
329+ _indicatorPaused = false ;
330+ EnsureIndicatorLoop ( ) ;
331+ SpeechReceived ? . Invoke ( text ) ;
332+ }
333+
334+ /// <summary>VAD transitions from the subprocess: "speech" = the caller started talking
335+ /// (pause the cue — they are speaking, not waiting), "end" = the utterance closed and
336+ /// transcription began (arm the cue — processing has started). "end" always follows a
337+ /// "speech", so a cue never starts while the caller is still talking.</summary>
338+ private void OnVadState ( string state )
339+ {
340+ if ( ! _conversationActive ) return ;
341+ switch ( state )
342+ {
343+ case "speech" :
344+ _indicatorPaused = true ; // never beep over the caller's own voice
345+ break ;
346+ case "end" :
347+ _processingSince = DateTime . UtcNow ;
348+ _replyStarted = false ;
349+ _indicatorPaused = false ;
350+ EnsureIndicatorLoop ( ) ;
351+ break ;
307352 }
308353 }
309354
@@ -321,10 +366,10 @@ private void StartPumps()
321366 {
322367 await foreach ( var chunk in _ttsQueue . Reader . ReadAllAsync ( ) )
323368 {
324- // The moment a real reply starts, queued indicator pieces are discarded
325- // (bounded to ~1 in-flight piece = IndicatorPieceMs) so the reply is not
326- // delayed by the processing cue.
327- if ( chunk . Indicator && _replyStarted ) { Interlocked . Decrement ( ref _ttsPending ) ; continue ; }
369+ // Indicator pieces are discarded the moment the real reply starts (bounded to
370+ // ~1 in-flight piece = IndicatorPieceMs, so the reply is not delayed by the
371+ // cue) and while the cue is paused (the caller is talking again) .
372+ if ( chunk . Indicator && ( _replyStarted || _indicatorPaused ) ) { Interlocked . Decrement ( ref _ttsPending ) ; continue ; }
328373 try
329374 {
330375 await _call . Media . AudioExtrasSource . SendAudioFromStream ( new MemoryStream ( chunk . Pcm ) , AudioSamplingRatesEnum . Rate24kHz ) ;
@@ -335,24 +380,31 @@ private void StartPumps()
335380 } ) ;
336381 }
337382
338- /// <summary>Loops the processing cue over RTP: starts 3 s after the utterance was acquired
339- /// and keeps pushing 400 ms pieces until the first reply chunk arrives (then the pump
340- /// discards whatever is left).</summary>
383+ /// <summary>Loops the processing cue over RTP: starts <see cref="SipConfig.IndicatorDelaySeconds"/>
384+ /// after the utterance was acquired and keeps pushing 400 ms pieces until the first reply
385+ /// chunk arrives (then the pump discards whatever is left). Pauses while the caller speaks
386+ /// again; hard-stops after <see cref="IndicatorMaxSeconds"/> so a stalled STT/LLM can never
387+ /// beep forever.</summary>
341388 private void EnsureIndicatorLoop ( )
342389 {
343390 if ( _indicatorPcm == null ) return ;
344391 if ( ! _indicatorLoop . IsCompleted ) return ;
345392 _indicatorLoop = Task . Run ( async ( ) =>
346393 {
347394 var pieceBytes = IndicatorPieceMs * 48 ; // 24 kHz × 2 B = 48 B/ms
395+ DateTime ? sentSince = null ;
348396 try
349397 {
350398 while ( _conversationActive && ! _replyStarted && ! _call . Cts . IsCancellationRequested )
351399 {
352- if ( ( DateTime . UtcNow - _processingSince ) . TotalSeconds >= 3 )
400+ if ( ! _indicatorPaused &&
401+ ( DateTime . UtcNow - _processingSince ) . TotalSeconds >= Math . Max ( 1 , Cfg . IndicatorDelaySeconds ) )
353402 {
403+ sentSince ??= DateTime . UtcNow ;
404+ if ( ( DateTime . UtcNow - sentSince . Value ) . TotalSeconds >= IndicatorMaxSeconds ) return ;
354405 for ( int off = 0 ; off < _indicatorPcm . Length ; off += pieceBytes )
355406 {
407+ if ( _indicatorPaused || _replyStarted || _call . Cts . IsCancellationRequested ) break ;
356408 var piece = _indicatorPcm . AsSpan ( off , Math . Min ( pieceBytes , _indicatorPcm . Length - off ) ) . ToArray ( ) ;
357409 _ttsQueue . Writer . TryWrite ( new TtsChunk ( piece , true ) ) ;
358410 Interlocked . Increment ( ref _ttsPending ) ;
@@ -395,7 +447,7 @@ private static bool IsNoiseOnlyTranscript(string text)
395447 {
396448 var id = System . Text . Encoding . ASCII . GetString ( bytes , off , 4 ) ;
397449 var size = BitConverter . ToInt32 ( bytes , off + 4 ) ;
398- if ( id == "data" ) return bytes . AsSpan ( off + 8 , Math . Min ( size , bytes . Length - off - 8 ) ) . ToArray ( ) ;
450+ if ( id == "data" ) return NormalizePeak ( bytes . AsSpan ( off + 8 , Math . Min ( size , bytes . Length - off - 8 ) ) . ToArray ( ) ) ;
399451 off += 8 + size + ( size % 2 ) ;
400452 }
401453 Log . LogStep ( "SIP processing indicator: no data chunk in the asset WAV" ) ;
@@ -408,6 +460,31 @@ private static bool IsNoiseOnlyTranscript(string text)
408460 }
409461 }
410462
463+ /// <summary>Amplifies the cue to near-full-scale (peak ≈ 0.9 × int16 max) so the caller
464+ /// hears it clearly — RTP has no volume knob, the only "playback volume" is the PCM
465+ /// amplitude we send. The gain is capped (a silent asset must not be boosted into noise)
466+ /// and samples are clamped to avoid clipping distortion.</summary>
467+ private static byte [ ] NormalizePeak ( byte [ ] pcm )
468+ {
469+ int peak = 1 ;
470+ for ( int i = 0 ; i + 1 < pcm . Length ; i += 2 )
471+ {
472+ var s = Math . Abs ( ( short ) ( pcm [ i ] | pcm [ i + 1 ] << 8 ) ) ;
473+ if ( s > peak ) peak = s ;
474+ }
475+ var gain = Math . Min ( 0.9 * short . MaxValue / peak , 8.0 ) ;
476+ if ( gain <= 1.01 ) return pcm ;
477+ for ( int i = 0 ; i + 1 < pcm . Length ; i += 2 )
478+ {
479+ var s = ( int ) Math . Round ( ( short ) ( pcm [ i ] | pcm [ i + 1 ] << 8 ) * gain ) ;
480+ s = Math . Clamp ( s , short . MinValue , short . MaxValue ) ;
481+ pcm [ i ] = ( byte ) ( s & 0xFF ) ;
482+ pcm [ i + 1 ] = ( byte ) ( ( s >> 8 ) & 0xFF ) ;
483+ }
484+ Log . LogStep ( $ "SIP processing indicator: amplified ×{ gain : F2} (peak { peak } → { 0.9 * short . MaxValue : F0} )") ;
485+ return pcm ;
486+ }
487+
411488 /// <summary>Renders speakable text to the caller: the persistent voice subprocess renders
412489 /// Kokoro/SAPI PCM (streamed sentence by sentence) → raw PCM → RTP. Media is I/O only —
413490 /// no TTS engine lives here (see ARCHITECTURE.md).</summary>
@@ -439,6 +516,11 @@ await SipVoiceAgent.SpeakAsync(sentence, Language, pcm =>
439516 }
440517 finally
441518 {
519+ // Post-TTS echo guard: the caller's phone plays the reply through its speaker, and
520+ // the echo comes BACK delayed by network + phone latency. Keep capture muted
521+ // PostTtsMuteMs after the last chunk so the subprocess VAD never hears our own
522+ // answer (recognition itself is continuous — mute, don't stop/start).
523+ if ( ! ct . IsCancellationRequested ) await Task . Delay ( PostTtsMuteMs , CancellationToken . None ) ;
442524 _speaking = false ;
443525 }
444526 }
@@ -753,6 +835,7 @@ public static object ConfigSnapshot
753835 lockout_hours = c . LockoutHours ,
754836 register_expiry = c . RegisterExpiry ,
755837 pin_timeout_seconds = c . PinTimeoutSeconds ,
838+ indicator_delay_seconds = c . IndicatorDelaySeconds ,
756839 allowed_callers = c . AllowedCallers ,
757840 agent = c . Agent ,
758841 lang = c . Lang ,
@@ -929,6 +1012,10 @@ private static void EnsureUserAgentHealthy()
9291012 if ( call == null )
9301013 {
9311014 if ( ! ua . IsCallActive ) return ;
1015+ // A fresh INVITE is being set up right now (AcceptCall ran, but Call is not yet
1016+ // registered — a sub-millisecond window in OnIncomingCall): rebuilding the user
1017+ // agent mid-setup would kill the call. The gate is held for the whole setup.
1018+ if ( CallGate . CurrentCount == 0 ) return ;
9321019 Log . LogStep ( "SIP user agent cleanup failed — rebuilding it (stale dialog would drop new INVITEs)" ) ;
9331020 try { ua . Close ( ) ; } catch { }
9341021 Ua = CreateUserAgent ( transport ) ;
@@ -937,8 +1024,13 @@ private static void EnsureUserAgentHealthy()
9371024 // We still hold a call the user agent reports as inactive: the remote hangup reached
9381025 // the transport but OnCallHungup was missed. The stale internal dialog would silently
9391026 // drop every later INVITE — clear the orphan and rebuild now, instead of waiting for
940- // the next INVITE to trip the transport-level handler.
941- if ( ! ua . IsCallActive )
1027+ // the next INVITE to trip the transport-level handler. ONLY once the call is fully
1028+ // established (MediaAttached): between "Call registered" and "Answer/Attach complete"
1029+ // the dialog does not exist yet and a fresh call must never be misread as an orphan —
1030+ // clearing Call mid-setup makes HandleDtmfDigit drop every PIN digit and the call
1031+ // goes dead (reproduced by SipSmoke --voice-only's immediate call, which collides
1032+ // with the 5 s watchdog tick).
1033+ if ( ! ua . IsCallActive && call . MediaAttached )
9421034 {
9431035 Log . LogStep ( "SIP orphaned call state cleared by watchdog (user agent has no active dialog)" ) ;
9441036 lock ( Sync ) if ( Call == call ) Call = null ;
@@ -1083,6 +1175,7 @@ void OnFailed(ISIPClientUserAgent _, string err, SIPResponse __) =>
10831175 }
10841176
10851177 call . Phase = CallPhase . Conversation ;
1178+ call . MediaAttached = true ; // the outbound dialog is up — the watchdog may clear orphans now
10861179 StartConversation ( call ) ;
10871180 Log . LogStep ( $ "SIP outbound call answered: { uri } ") ;
10881181 return null ;
@@ -1126,6 +1219,7 @@ private static async void OnIncomingCall(SIPUserAgent ua, SIPRequest req)
11261219 lock ( Sync ) Call = call ;
11271220 await ua . Answer ( uas , media ) ;
11281221 call . VoiceMedia . Attach ( ) ; // RTP capture for the whole call (PIN phase included)
1222+ call . MediaAttached = true ; // the dialog is up — the watchdog may clear orphans now
11291223 Log . LogStep ( $ "SIP incoming call answered from { caller } ") ;
11301224 }
11311225 finally
0 commit comments