Skip to content

Commit dcec67f

Browse files
committed
SIP latency fixes: arm the processing indicator on the subprocess VAD 'end' event (2 s configurable, cue normalized to near-full-scale, paused while the caller speaks), hold capture muted 500 ms after the last TTS chunk, and fix two call-killing races exposed by the smoke tests: overlapping subprocess speaks (PIN accepted mid-welcome threw, silently killing the conversation) and the self-heal watchdog clearing a call mid-setup (DTMF went dead)
1 parent 4264adb commit dcec67f

5 files changed

Lines changed: 219 additions & 52 deletions

File tree

SipBridge.cs

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)