-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathSipBridge.cs
More file actions
1837 lines (1704 loc) · 92.7 KB
/
Copy pathSipBridge.cs
File metadata and controls
1837 lines (1704 loc) · 92.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using AIOrchestrator;
using Concentus.Structs;
using Microsoft.Extensions.Configuration;
using SIPSorcery.Media;
using SIPSorcery.Net;
using SIPSorcery.SIP;
using SIPSorcery.SIP.App;
using SIPSorcery.Sys;
using SIPSorceryMedia.Abstractions;
// Localized strings: the generated resx class is aliased (it clashes with the generic
// System.Collections.Generic.Dictionary). NEVER inline user-facing text — see the banner below.
using Lang = AgentBridge.Resources.Dictionary;
// ═══════════════════════════════════════════════════════════════════════
// LOCALIZATION INVARIANT — project rule, ALWAYS visible (read before editing):
// NEVER put user-facing text in this file. Every string belongs in the language
// dictionaries (Resources/Dictionary.resx + Dictionary.{it,de,es,fr,ru}.resx)
// and MUST be read through Dictionary.<Key>, translated in ALL supported
// languages. The culture is set from the resolved call language — never a
// hardcoded pair. (The old `Italian ? "…" : "…"` announcements violated this
// and supported only 2 of the 5 languages.)
// ═══════════════════════════════════════════════════════════════════════
//
// SipBridge — SIP telephony for AgentBridge (see docs/sip.md)
//
// Turns the server into a phone endpoint:
// - incoming calls are auto-answered; the caller proves their identity with a
// DTMF PIN (5 digits, max N attempts, then a persisted 24 h lockout) or is
// accepted straight away when the P-Asserted-Identity (trusted provider) is
// on the allow-list;
// - outgoing calls via /sip call <sip-uri>;
// - while connected, the caller's speech (decoded from RTP G.711) is converted
// to text with the AIOffice.VoiceAgent --transcribe subprocess and fed to the
// SHARED agentic voice conversation (AIOrchestrator ExecuteActionStream with
// isVoiceChat — see VoiceConversation): same tools, same concise "speakable"
// prompt, same markdown/emoji stripping and sentence splitting as the AIOffice
// Voice panel. The agent replies are spoken back through the in-process Kokoro
// TTS. The PIN gate is the shared PinAuthGate (AIOrchestrator), wired here.
//
// Media: only G.711 (PCMU/PCMA) is offered — universal on every SIP endpoint and
// decodable byte-per-sample with the bundled codecs. RTP ports are configurable
// (RtpStartPort/RtpEndPort) for firewall deployments. One call at a time.
// ═══════════════════════════════════════════════════════════════════════
/// <summary>SIP telephony bridge: auto-answer + PIN gateway, outgoing calls, speech→agent→TTS loop.</summary>
public static class SipBridge
{
/// <summary>Appsettings "Sip" section (see appsettings.json and docs/sip.md).</summary>
public sealed class SipConfig
{
/// <summary>Master switch — the SIP server binds only when true.</summary>
public bool Enabled { get; set; }
/// <summary>UDP port for the SIP signalling (default 5060).</summary>
public int ListenPort { get; set; } = 5060;
/// <summary>Optional registrar/trunk (e.g. "sip:provider.example:5060"). When set the
/// server REGISTERs (Username/Password) and receives/places calls through the provider.</summary>
public string Registrar { get; set; } = "";
/// <summary>Username and Password for REGISTER and authenticated calls.</summary>
public string Username { get; set; } = "";
/// <summary>See <see cref="Username"/>.</summary>
public string Password { get; set; } = "";
/// <summary>Incoming-call gate: "pin" (default), "allowlist" (P-Asserted-Identity) or "none".</summary>
public string AnswerMode { get; set; } = "pin";
/// <summary>The 5-digit DTMF PIN.</summary>
public string Pin { get; set; } = "";
/// <summary>Wrong-PIN attempts before the 24 h lockout (default 3).</summary>
public int MaxPinAttempts { get; set; } = 3;
/// <summary>Lockout duration after the attempts are exhausted (hours, default 24).</summary>
public int LockoutHours { get; set; } = 24;
/// <summary>REGISTER expiry/refresh interval in seconds (default 60). Home NAT mappings
/// often time out well before the SIP default 300 s: with a too-long interval, inbound
/// calls between refreshes go unanswered — the entry point still holds the registration,
/// but the router has dropped the mapping and the INVITE never arrives. 60 s keeps the
/// pinhole alive on consumer routers.</summary>
public int RegisterExpiry { get; set; } = 60;
/// <summary>Seconds to wait for the PIN before the server hangs up the call (default 60,
/// min 10). A call that reaches the PIN gate and receives nothing ends instead of
/// staying open forever.</summary>
public int PinTimeoutSeconds { get; set; } = 60;
/// <summary>Seconds after the caller's speech ends before the processing indicator (the
/// looped "data processing" cue) starts playing (default 2). The cue is armed by the
/// subprocess VAD "end" event — i.e. the moment STT/LLM processing begins — so a caller
/// never stares at silence while the agent computes. Set higher to delay the cue (it can
/// sound noisy on a quiet line), lower to reassure sooner.</summary>
public int IndicatorDelaySeconds { get; set; } = 2;
/// <summary>Whisper model used by the STT subprocess (tiny/base/small/medium/largev2/
/// largev3).
/// ⚠️ "small" was the floor for a long time: tiny/base tested on real phone calls with the
/// OLD pipeline (pre-16 kHz-upsample/filters) failed to recognize speech (e.g. "il meteo"
/// → "villioni sul metto"). RE-TESTED 2026-08-22 with the current pipeline (8→16 kHz
/// upsample + adaptive VAD + q8_0/multicore), "base" transcribes real Italian correctly
/// ("che tempo fa a Milano mi racconti le previsioni del meteo" → verbatim) and is ~2x
/// FASTER than small-q8_0 (measured via e2e/VadTest: base-FP16 2.2–4.4 s vs small-q8_0
/// 7.0–8.3 s of whisper inference per 7.4 s utterance). "base" is the latency pick for
/// phone calls; "small" keeps the extra accuracy margin on very noisy lines. Never go
/// below base — tiny is unusable.</summary>
public string SttModel { get; set; } = "small";
/// <summary>Whisper quantization for the STT subprocess (empty/q4_0/q4_1/q5_0/q5_1/q8_0).
/// Default <c>q8_0</c>: "small-q8_0" is ~2-3x FASTER on CPU (measured STT 8.3 s → ~3-4 s)
/// with minimal accuracy loss — the latency fix for phone calls, where whisper is the
/// recognizer by necessity (RTP audio, not the mic → WinRT is not usable). Set empty to
/// keep the full FP16 model.</summary>
public string SttQuant { get; set; } = "q8_0";
/// <summary>STT accelerator selection passed to the voice subprocess as
/// AIOFFICE_WHISPER_DEVICE: "auto" (default) lets the whisper.net loader probe
/// Cuda → Cuda12 → Vulkan → CPU and falls back to CPU automatically when no usable GPU/
/// driver is present; "cuda"/"vulkan" force that backend; "cpu" skips the probe entirely
/// (avoids the one-time failed-probe delay on machines whose GPU cannot run whisper,
/// e.g. Turing sm_75). The CPU runtime is always bundled — GPU is an acceleration,
/// never a prerequisite: the distributed package works on every OS as-is.</summary>
public string SttDevice { get; set; } = "auto";
/// <summary>Trusted caller URIs (P-Asserted-Identity from an authenticating provider/trunk)
/// that skip the PIN. Matched on the full URI or on the user part alone.
/// SECURITY: PAI is honored only when the INVITE comes from <see cref="Registrar"/> — in
/// direct SIP (no registrar) the From header is client-controlled and must never grant
/// access; use the allow-list only behind an authenticating trunk.</summary>
public List<string> AllowedCallers { get; set; } = new();
/// <summary>Agent set used for the conversation ("default-agent", "multi-agent", ...).</summary>
public string Agent { get; set; } = "default-agent";
/// <summary>Two-letter ISO language for STT/TTS (default: system language).</summary>
public string Lang { get; set; } = "";
/// <summary>Path to the AIOffice.VoiceAgent executable (--transcribe). Default:
/// <server dir>\voiceagent-stt\AIOffice.VoiceAgent.exe.</summary>
public string SttExePath { get; set; } = "";
/// <summary>Optional fixed RTP port range ("start-end", e.g. 40000-41000) for firewalled
/// deployments; empty = ephemeral ports.</summary>
public string RtpPortRange { get; set; } = "";
}
/// <summary>Lifecycle phase of the active call, reported by /v1/sip/status.</summary>
public enum CallPhase
{
/// <summary>No active call.</summary>
Idle,
/// <summary>Outgoing call ringing.</summary>
Ringing,
/// <summary>Waiting for the DTMF PIN.</summary>
Pin,
/// <summary>Connected to the agent (speech loop active).</summary>
Conversation,
/// <summary>Call being torn down.</summary>
Ended
}
private sealed class CallContext
{
public required VoIPMediaSession Media;
public SIPServerUserAgent? Uas;
public required string RemoteUri;
public string? CallId; // SIP Call-ID of the INVITE that started the call
public volatile CallPhase Phase = CallPhase.Pin;
public ActiveSession? AgentSession;
public SipVoiceMedia? VoiceMedia; // IAudioMedia endpoint of this call
public CancellationTokenSource Cts = new();
public Task? Loop;
public volatile bool Validating;
public volatile bool MediaAttached; // set once the RTP capture is live (see the watchdog)
public DateTime PinDeadline; // moment the PIN gate gives up (EnforcePinTimeoutAsync)
}
/// <summary>
/// The SIP media endpoint: implements <see cref="IAudioMedia"/> so the WHOLE conversation is
/// driven by <see cref="VoiceConversation.RunConversationAsync"/> — the medium is a mere I/O
/// channel: RTP audio in (G.711 → VAD → whisper → <see cref="SpeechReceived"/>) and Kokoro TTS
/// out (raw PCM → RTP). No conversation logic lives here (see AIOrchestrator/docs-dev/ARCHITECTURE.md).
/// </summary>
private sealed class SipVoiceMedia : IAudioMedia
{
/// <summary>One TTS output unit: a real reply chunk or a processing-indicator piece
/// (tagged so the pump can discard queued indicator pieces the moment the reply starts).</summary>
private sealed record TtsChunk(byte[] Pcm, bool Indicator);
private readonly CallContext _call;
private readonly DtmfDetector _dtmf = new();
private readonly System.Threading.Channels.Channel<byte[]> _inputQueue = System.Threading.Channels.Channel.CreateUnbounded<byte[]>();
private readonly System.Threading.Channels.Channel<TtsChunk> _ttsQueue = System.Threading.Channels.Channel.CreateUnbounded<TtsChunk>();
private int _ttsPending;
private Task _inputPump = Task.CompletedTask;
private Task _ttsPump = Task.CompletedTask;
private volatile bool _speaking;
private int _activeSpeaks; // SpeakAsyncs in flight — capture unmutes only at zero
private volatile bool _conversationActive; // set by StartAsync: distinguishes conversation replies from pre-PIN announcements
private bool _pinAudioLogged;
private int _firstChunkLogged; // diagnostic: when the first TTS chunk hits RTP
// Processing indicator: a looped "data processing" cue played to the caller while the
// agent computes (STT/LLM/tools) — armed by the subprocess VAD "end" event (speech ended,
// processing began), starts IndicatorDelaySeconds later and stops the moment the first
// reply chunk arrives. Fills the latency void so the caller knows the line is working.
// Sent over the MEDIA (RTP) — never played locally. While the caller speaks again
// (VAD "speech"), the cue is paused so it never beeps over the caller's own voice.
private byte[]? _indicatorPcm = LoadIndicatorPcm();
private DateTime _processingSince;
private volatile bool _replyStarted;
private volatile bool _indicatorPaused;
private Task _indicatorLoop = Task.CompletedTask;
private const int IndicatorPieceMs = 400; // pieces this small bound the reply delay to ~400 ms
private const int IndicatorMaxSeconds = 25; // hard cap — a stalled STT/LLM must never beep forever
private const int PostTtsMuteMs = 500; // capture stays muted this long after the last TTS
// chunk: the caller's echo of the reply is delayed
// by network + phone latency, not real-time
// Wideband codec receive path: G.722 is a static payload type (RFC 3551); Opus is
// negotiated dynamically (RFC 7587) — the PT we OFFER (111) is what both sides use in
// SDP offer/answer, refreshed from the negotiated formats at Attach. G.722 and Opus
// decode to 16 kHz PCM — whisper's native rate — so the STT input is real 50 Hz–7 kHz
// audio (G.711 narrowband is still upsampled). Decoders keep per-stream state.
private const byte G722PayloadType = 9;
private const int MaxOpusFrameSamples = 960; // 60 ms @ 16 kHz = the largest Opus frame
private int _opusPayloadType = 111;
private G722Codec? _g722;
private G722CodecState? _g722State;
private OpusDecoder? _opusDecoder;
public SipVoiceMedia(CallContext call)
{
_call = call;
// In-band keypad tones (for clients that cannot emit RFC 4733 events or SIP INFO):
// fed to the same PIN gate as the RTP-event digits. Active ONLY while the call is
// in the PIN phase — once the conversation starts, speech must never be misread
// as tones (the detector is disabled).
_dtmf.DigitDetected += d =>
{
Log.LogStep($"SIP in-band DTMF: {d}");
HandleDtmfDigit(d, 0);
};
// STT lives in the persistent subprocess (VAD + whisper); transcripts arrive here.
SipVoiceAgent.Transcript += OnSubprocessTranscript;
// The subprocess VAD reports "speech"/"end" — the indicator is armed at "end"
// (processing start) and paused at "speech" (the caller is talking, not waiting).
SipVoiceAgent.VadState += OnVadState;
}
public string Language => VoiceConversation.ResolveLang(Cfg.Lang);
public event Action<string>? SpeechReceived;
public Task StartAsync(CancellationToken ct = default)
{
// RTP capture is attached at call setup (Attach) so the in-band DTMF detector also
// hears the PIN phase; here only the conversation-specific wiring is added.
_conversationActive = true;
Log.LogStep("SIP RTP capture attached (conversation media loop started)");
return Task.CompletedTask;
}
/// <summary>Attaches the RTP capture for the WHOLE call (PIN phase included): the
/// in-band DTMF detector needs the caller's audio before the conversation starts, and the
/// TTS pump must be live from the first announcement (the spoken welcome precedes the
/// conversation, which is when VoiceConversation calls StartAsync).</summary>
public void Attach()
{
_call.Media.OnRtpPacketReceived += OnRtpAudio;
_dtmf.Reset();
StartPumps();
// The Opus payload type is dynamic (RFC 7587): after negotiation the common formats
// (with the REMOTE's payload IDs) land in the local track's capabilities — read the
// Opus PT from there; the offer default (111) remains the fallback.
try
{
var caps = _call.Media.AudioStream?.LocalTrack?.Capabilities ?? [];
foreach (var f in caps)
Log.LogStep($"SIP negotiated audio: pt {f.ID} {f.Name()}");
var wideband = caps.Any(f => string.Equals(f.Name(), "opus", StringComparison.OrdinalIgnoreCase) ||
string.Equals(f.Name(), "G722", StringComparison.OrdinalIgnoreCase));
if (!wideband)
Log.LogStep("SIP warning: call negotiated narrowband only (PCMU/PCMA, 8 kHz) — enable G.722/Opus on the client for much better STT accuracy (whisper is trained on 16 kHz; the 300 Hz–3.4 kHz G.711 band cuts the fricative energy)");
// The list elements are structs: with no match FirstOrDefault returns the default
// struct (ID 0, _isEmpty false, Name()="PCMU") — the final NAME check tells a
// real opus entry apart from that default, so the offer PT (111) stays.
var opus = caps.FirstOrDefault(f => string.Equals(f.Name(), "opus", StringComparison.OrdinalIgnoreCase));
if (opus is { } fmt && string.Equals(fmt.Name(), "opus", StringComparison.OrdinalIgnoreCase))
{
_opusPayloadType = fmt.ID;
Log.LogStep($"SIP Opus negotiated on payload type {_opusPayloadType}");
}
}
catch { }
}
public Task StopAsync()
{
_call.Media.OnRtpPacketReceived -= OnRtpAudio;
SipVoiceAgent.Transcript -= OnSubprocessTranscript;
SipVoiceAgent.VadState -= OnVadState;
_inputQueue.Writer.TryComplete();
_ttsQueue.Writer.TryComplete();
_dtmf.Reset();
_conversationActive = false;
return Task.CompletedTask;
}
// Decodes the negotiated codec to 16-bit PCM and feeds the STT (conversation) or the
// in-band DTMF detector (PIN phase). Wideband (G.722/Opus, 16 kHz) goes to whisper
// untouched; narrowband G.711 (8 kHz) is upsampled. RFC 4733 DTMF events (negotiated
// dynamic payload type) are skipped — they never reach the STT. Capture is paused
// while the TTS reply plays (no barge-in: a hands-free caller would otherwise echo the
// reply back into the subprocess STT).
private void OnRtpAudio(IPEndPoint _, SDPMediaTypesEnum __, RTPPacket packet)
{
if (_speaking) return;
var phase = _call.Phase;
if (phase != CallPhase.Pin && phase != CallPhase.Conversation) return;
var payload = packet.Payload;
if (payload == null || payload.Length == 0) return;
var pt = packet.Header.PayloadType;
byte[] pcm;
int rate;
switch (pt)
{
case PcmuPayloadType:
case PcmaPayloadType:
// Narrowband G.711 (300 Hz–3.4 kHz): 1 byte = 1 sample @ 8 kHz.
pcm = new byte[payload.Length * 2];
for (int i = 0; i < payload.Length; i++)
{
var sample = pt == PcmuPayloadType
? MuLawDecoder.MuLawToLinearSample(payload[i])
: ALawDecoder.ALawToLinearSample(payload[i]);
pcm[i * 2] = (byte)(sample & 0xFF);
pcm[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
rate = 8000;
break;
case G722PayloadType:
// Wideband G.722 (50 Hz–7 kHz): 2 PCM16 samples per byte @ 16 kHz.
_g722 ??= new G722Codec();
_g722State ??= new G722CodecState(64000, G722Flags.None);
var g722out = new short[payload.Length * 2];
var g722samples = _g722.Decode(_g722State, g722out, payload, payload.Length);
pcm = ShortsToPcm16(g722out, g722samples);
rate = 16000;
break;
default:
if (pt != _opusPayloadType) return; // RFC 4733 DTMF events + anything unnegotiated
// Opus (RFC 7587): decode at 16 kHz mono directly — Concentus resamples
// from the stream's native rate, so whisper sees its training bandwidth.
_opusDecoder ??= new OpusDecoder(16000, 1);
var opusOut = new short[MaxOpusFrameSamples];
var opusSamples = _opusDecoder.Decode(payload, 0, payload.Length, opusOut, 0, MaxOpusFrameSamples);
if (opusSamples <= 0) return;
pcm = ShortsToPcm16(opusOut, opusSamples);
rate = 16000;
break;
}
if (phase == CallPhase.Pin)
{
if (!_pinAudioLogged)
{
_pinAudioLogged = true;
Log.LogStep($"SIP pin-phase audio capture active (pt {pt}, {rate} Hz, {pcm.Length} bytes first packet)");
}
_dtmf.Feed(pcm, rate); // in-band keypad tones only (RFC 4733 handled by OnDtmfTone)
}
else
{
_inputQueue.Writer.TryWrite(rate == 8000 ? Upsample8kTo16k(pcm) : pcm); // 16 kHz speech → subprocess STT
}
}
/// <summary>PCM16 (short[]) → little-endian byte[] — the format the subprocess expects.</summary>
private static byte[] ShortsToPcm16(short[] samples, int count)
{
var b = new byte[count * 2];
for (int i = 0; i < count; i++)
{
b[i * 2] = (byte)(samples[i] & 0xFF);
b[i * 2 + 1] = (byte)((samples[i] >> 8) & 0xFF);
}
return b;
}
/// <summary>Linear 8→16 kHz upsample of PCM16 (whisper is trained on 16 kHz).</summary>
private static byte[] Upsample8kTo16k(byte[] pcm8k)
{
var samples = pcm8k.Length / 2;
var outPcm = new byte[samples * 4];
for (int i = 0; i < samples; i++)
{
var s = (short)(pcm8k[i * 2] | pcm8k[i * 2 + 1] << 8);
var next = i + 1 < samples ? (short)(pcm8k[(i + 1) * 2] | pcm8k[(i + 1) * 2 + 1] << 8) : s;
var mid = (short)((s + next) / 2);
var o = i * 4;
outPcm[o] = (byte)(s & 0xFF); outPcm[o + 1] = (byte)((s >> 8) & 0xFF);
outPcm[o + 2] = (byte)(mid & 0xFF); outPcm[o + 3] = (byte)((mid >> 8) & 0xFF);
}
return outPcm;
}
/// <summary>Transcript from the persistent subprocess (VAD done there).</summary>
private void OnSubprocessTranscript(string text)
{
if (!_conversationActive) return;
// Whisper labels non-speech segments as "[Musica]"/"[Rumore]"/"[Applausi]" etc. When
// the whole utterance is such a placeholder (background music, line noise), it must
// never reach the LLM — the agent would "answer the music". Mixed text is kept.
if (IsNoiseOnlyTranscript(text))
{
// Nothing to process → cancel the cue armed at VAD "end" (it must not beep for
// background music the agent will never answer).
_indicatorPaused = true;
return;
}
Log.LogStep($"SIP caller said: {text}");
// The caller's utterance is now being processed (STT done → LLM/tools may take a
// while): arm the processing indicator — it starts after IndicatorDelaySeconds and
// loops until the first reply chunk arrives. Re-arming here is a safety net: the
// primary arming happened at the VAD "end" event (before whisper ran).
_processingSince = DateTime.UtcNow;
_replyStarted = false;
_indicatorPaused = false;
EnsureIndicatorLoop();
SpeechReceived?.Invoke(text);
}
/// <summary>VAD transitions from the subprocess: "speech" = the caller started talking
/// (pause the cue — they are speaking, not waiting), "end" = the utterance closed and
/// transcription began (arm the cue — processing has started). "end" always follows a
/// "speech", so a cue never starts while the caller is still talking.</summary>
private void OnVadState(string state)
{
if (!_conversationActive) return;
switch (state)
{
case "speech":
_indicatorPaused = true; // never beep over the caller's own voice
break;
case "end":
_processingSince = DateTime.UtcNow;
_replyStarted = false;
_indicatorPaused = false;
EnsureIndicatorLoop();
break;
}
}
/// <summary>Drains queued conversation PCM into the persistent subprocess, in order.</summary>
private void StartPumps()
{
if (!_inputPump.IsCompleted) return;
_inputPump = Task.Run(async () =>
{
await foreach (var pcm in _inputQueue.Reader.ReadAllAsync())
await SipVoiceAgent.SendAudioAsync(pcm, _call.Cts.Token);
});
if (!_ttsPump.IsCompleted) return;
_ttsPump = Task.Run(async () =>
{
await foreach (var chunk in _ttsQueue.Reader.ReadAllAsync())
{
// Indicator pieces are discarded the moment the real reply starts (bounded to
// ~1 in-flight piece = IndicatorPieceMs, so the reply is not delayed by the
// cue) and while the cue is paused (the caller is talking again).
if (chunk.Indicator && (_replyStarted || _indicatorPaused)) { Interlocked.Decrement(ref _ttsPending); continue; }
try
{
await _call.Media.AudioExtrasSource.SendAudioFromStream(new MemoryStream(chunk.Pcm), AudioSamplingRatesEnum.Rate24kHz);
}
catch { }
Interlocked.Decrement(ref _ttsPending);
}
});
}
/// <summary>Loops the processing cue over RTP: starts <see cref="SipConfig.IndicatorDelaySeconds"/>
/// after the utterance was acquired and keeps pushing 400 ms pieces until the first reply
/// chunk arrives (then the pump discards whatever is left). Pauses while the caller speaks
/// again; hard-stops after <see cref="IndicatorMaxSeconds"/> so a stalled STT/LLM can never
/// beep forever.</summary>
private void EnsureIndicatorLoop()
{
if (_indicatorPcm == null) return;
if (!_indicatorLoop.IsCompleted) return;
_indicatorLoop = Task.Run(async () =>
{
var pieceBytes = IndicatorPieceMs * 48; // 24 kHz × 2 B = 48 B/ms
DateTime? sentSince = null;
try
{
while (_conversationActive && !_replyStarted && !_call.Cts.IsCancellationRequested)
{
if (!_indicatorPaused &&
(DateTime.UtcNow - _processingSince).TotalSeconds >= Math.Max(1, Cfg.IndicatorDelaySeconds))
{
sentSince ??= DateTime.UtcNow;
if ((DateTime.UtcNow - sentSince.Value).TotalSeconds >= IndicatorMaxSeconds) return;
for (int off = 0; off < _indicatorPcm.Length; off += pieceBytes)
{
if (_indicatorPaused || _replyStarted || _call.Cts.IsCancellationRequested) break;
var piece = _indicatorPcm.AsSpan(off, Math.Min(pieceBytes, _indicatorPcm.Length - off)).ToArray();
_ttsQueue.Writer.TryWrite(new TtsChunk(piece, true));
Interlocked.Increment(ref _ttsPending);
// Play the piece in real time (48 B per ms) before the next one.
await Task.Delay(piece.Length / 48, _call.Cts.Token);
if (_replyStarted || _call.Cts.IsCancellationRequested) return;
}
}
await Task.Delay(150, _call.Cts.Token);
}
}
catch (OperationCanceledException) { }
catch (Exception ex) { Log.LogStep($"SIP processing indicator error: {ex.Message}"); }
});
}
/// <summary>True when the transcript contains ONLY non-speech placeholders (whisper's
/// "[Musica]", "[Rumore]", "[Music]", "[Noise]", ...) — i.e. the utterance carried no
/// real speech and must not be fed to the LLM.</summary>
private static bool IsNoiseOnlyTranscript(string text)
{
foreach (var part in text.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (!(part.Length > 2 && part[0] == '[' && part[^1] == ']'))
return false;
return true;
}
/// <summary>Loads the processing-indicator cue (assets/processing-indicator.wav — 24 kHz
/// mono PCM16) as raw PCM for looping over RTP. The cue is an EMBEDDED resource (see
/// AgentBridge.csproj): a copied content file proved unreliable on the OneDrive-synced
/// bin folder (it vanished, silently disabling the indicator — "asset not found" on real
/// calls). Null when the resource is missing (the indicator is simply skipped).</summary>
private static byte[]? LoadIndicatorPcm()
{
try
{
using var stream = typeof(SipBridge).Assembly.GetManifestResourceStream("AgentBridge.assets.processing-indicator.wav");
if (stream == null)
{
Log.LogStep("SIP processing indicator: embedded asset missing (AgentBridge.assets.processing-indicator.wav)");
return null;
}
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var off = 12;
while (off + 8 <= bytes.Length)
{
var id = System.Text.Encoding.ASCII.GetString(bytes, off, 4);
var size = BitConverter.ToInt32(bytes, off + 4);
if (id == "data") return NormalizePeak(bytes.AsSpan(off + 8, Math.Min(size, bytes.Length - off - 8)).ToArray());
off += 8 + size + (size % 2);
}
Log.LogStep("SIP processing indicator: no data chunk in the embedded asset");
return null;
}
catch (Exception ex)
{
Log.LogStep($"SIP processing indicator load failed: {ex.Message}");
return null;
}
}
/// <summary>Amplifies the cue to near-full-scale (peak ≈ 0.9 × int16 max) so the caller
/// hears it clearly — RTP has no volume knob, the only "playback volume" is the PCM
/// amplitude we send. The gain is capped (a silent asset must not be boosted into noise)
/// and samples are clamped to avoid clipping distortion.</summary>
private static byte[] NormalizePeak(byte[] pcm)
{
int peak = 1;
for (int i = 0; i + 1 < pcm.Length; i += 2)
{
var s = Math.Abs((short)(pcm[i] | pcm[i + 1] << 8));
if (s > peak) peak = s;
}
var gain = Math.Min(0.9 * short.MaxValue / peak, 8.0);
if (gain <= 1.01) return pcm;
for (int i = 0; i + 1 < pcm.Length; i += 2)
{
var s = (int)Math.Round((short)(pcm[i] | pcm[i + 1] << 8) * gain);
s = Math.Clamp(s, short.MinValue, short.MaxValue);
pcm[i] = (byte)(s & 0xFF);
pcm[i + 1] = (byte)((s >> 8) & 0xFF);
}
Log.LogStep($"SIP processing indicator: amplified ×{gain:F2} (peak {peak} → {0.9 * short.MaxValue:F0})");
return pcm;
}
/// <summary>Renders speakable text to the caller: the persistent voice subprocess renders
/// Kokoro/SAPI PCM (streamed sentence by sentence) → raw PCM → RTP. Media is I/O only —
/// no TTS engine lives here (see docs-dev/ARCHITECTURE.md).</summary>
public async Task SpeakAsync(string text, bool isLast, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(text)) { _replyStarted = true; return; } // empty turn → stop the indicator
if (_call.Media.IsClosed) { _replyStarted = true; return; }
if (!SipVoiceAgent.IsReady) { _replyStarted = true; return; }
if (_conversationActive) Log.LogStep($"SIP agent replied: {text}");
Interlocked.Increment(ref _activeSpeaks);
_speaking = true;
_firstChunkLogged = 0; // measure time-to-first-audio of THIS reply
_replyStarted = true; // stop the processing indicator — the real reply is here
try
{
if (ct.IsCancellationRequested) return;
// The WHOLE chunk goes in ONE subprocess speak: the subprocess splits it into
// sentences internally and streams each PCM piece as it is synthesized, so the
// next sentence renders while the previous plays (no client-side per-sentence
// IPC round-trips that would add render gaps).
await SipVoiceAgent.SpeakAsync(text, Language, pcm =>
{
if (_firstChunkLogged == 0) { _firstChunkLogged = 1; Log.LogStep($"SIP TTS first-chunk t={DateTime.UtcNow:HH:mm:ss.fff}"); }
_ttsQueue.Writer.TryWrite(new TtsChunk(pcm, false));
Interlocked.Increment(ref _ttsPending);
}, ct);
// The queue is drained only at the END so capture stays paused until all RTP is sent.
while (Volatile.Read(ref _ttsPending) > 0 && !ct.IsCancellationRequested)
await Task.Delay(15, ct);
}
finally
{
// Post-TTS echo guard: the caller's phone plays the reply through its speaker, and
// the echo comes BACK delayed by network + phone latency. Keep capture muted
// PostTtsMuteMs after the last chunk so the subprocess VAD never hears our own
// answer (recognition itself is continuous — mute, don't stop/start). Only the
// LAST SpeakAsync to finish unmutes: another reply may still be rendering (an
// agent-initiative turn racing the reply loop) — its chunks would otherwise be
// heard by the VAD as caller input.
if (Interlocked.Decrement(ref _activeSpeaks) == 0)
{
if (!ct.IsCancellationRequested) await Task.Delay(PostTtsMuteMs, CancellationToken.None);
if (Volatile.Read(ref _activeSpeaks) == 0)
{
_speaking = false;
// The agentic reply is streamed in CHUNKS (the LLM produces the next part
// with seconds of latency in between): re-arm the processing indicator so
// the caller knows the turn is NOT over while the next part is generated.
// Only the FINAL chunk (isLast — or the empty end-of-turn signal) leaves
// it stopped: the cue stopping for good is the "the turn is over" cue.
if (!isLast && !ct.IsCancellationRequested)
{
_processingSince = DateTime.UtcNow;
_replyStarted = false;
EnsureIndicatorLoop();
}
}
}
}
}
}
/// <summary>Goertzel-based in-band DTMF detector on PCM16, sample-rate agnostic (8 kHz for
/// G.711, 16 kHz for G.722/Opus — the frame duration stays ~25.6 ms and the Goertzel k
/// index scales with the rate, so the same tone pairs are detected on either). Emits each
/// digit once after ~50 ms of a steady tone pair and requires a ~75 ms gap before accepting
/// the next digit (a held key never repeats). Used ONLY during the PIN phase — SipVoiceMedia
/// stops feeding it once the conversation starts, so speech is never misread as keypad tones.</summary>
private sealed class DtmfDetector
{
private const int MinValidFrames = 2; // ~51 ms steady tone before emitting
private const int GapFrames = 3; // ~77 ms silence/detune before re-arming
private const double AbsThreshold = 0.015; // normalized power floor for row+col
private const double RatioThreshold = 3.0; // chosen freq must beat its group's second
private static readonly double[] RowFreqs = { 697, 770, 852, 941 };
private static readonly double[] ColFreqs = { 1209, 1336, 1477, 1633 };
private static readonly string[] Matrix = { "123A", "456B", "789C", "*0#D" };
private readonly double[] _coeffs = new double[8];
private readonly double[] _q1 = new double[8];
private readonly double[] _q2 = new double[8];
private readonly short[] _frame = new short[512]; // max frame: ~25.6 ms @ 16 kHz = 410 samples
private int _frameSamples = 205; // ~25.6 ms at the current sample rate
private int _sampleRate; // 0 = unconfigured (the ctor runs Configure(8000))
private int _frameLen;
private int _validCount;
private int _gapCount;
private byte? _lastDigit;
public event Action<byte>? DigitDetected;
public DtmfDetector() => Configure(8000);
private void Configure(int sampleRate)
{
if (sampleRate == _sampleRate) return;
_sampleRate = sampleRate;
_frameSamples = (int)Math.Round(sampleRate * 0.0256); // ~25.6 ms per frame (205 @ 8 kHz, 410 @ 16 kHz)
for (int i = 0; i < 8; i++)
{
var f = i < 4 ? RowFreqs[i] : ColFreqs[i - 4];
// k = round(N·f/sr) — the Goertzel bin whose center is nearest to the tone.
// (The old "0.5 +" offset shifted the 1336 Hz column to bin 35 (center 1366 Hz)
// and the 941 Hz row to bin 25 (center 976 Hz), leaving those tones off-center
// with their energy split — the digits of that column/row were never detected.)
var k = (int)Math.Round(_frameSamples * f / sampleRate);
_coeffs[i] = 2.0 * Math.Cos(2.0 * Math.PI * k / _frameSamples);
}
Reset();
}
public void Reset()
{
_frameLen = 0;
_validCount = 0;
_gapCount = 0;
_lastDigit = null;
Array.Clear(_q1);
Array.Clear(_q2);
}
public void Feed(ReadOnlySpan<byte> pcm, int sampleRate)
{
Configure(sampleRate);
for (int i = 0; i + 1 < pcm.Length; i += 2)
{
_frame[_frameLen++] = (short)(pcm[i] | pcm[i + 1] << 8);
if (_frameLen == _frameSamples)
{
DetectFrame();
_frameLen = 0;
}
}
}
private void DetectFrame()
{
// Goertzel recurrence over the frame, once per candidate frequency.
Array.Clear(_q1);
Array.Clear(_q2);
for (int n = 0; n < _frameSamples; n++)
{
var x = _frame[n] / 32768.0;
for (int i = 0; i < 8; i++)
{
var s = x + _coeffs[i] * _q1[i] - _q2[i];
_q2[i] = _q1[i];
_q1[i] = s;
}
}
var norm = (_frameSamples / 2.0) * (_frameSamples / 2.0);
double[] power = new double[8];
for (int i = 0; i < 8; i++)
power[i] = (_q2[i] * _q2[i] + _q1[i] * _q1[i] - _coeffs[i] * _q1[i] * _q2[i]) / norm;
var row = Best(power, 0, 4);
var col = Best(power, 4, 8);
var valid = row.Power >= AbsThreshold && col.Power >= AbsThreshold &&
row.Power >= RatioThreshold * row.Second && col.Power >= RatioThreshold * col.Second;
if (valid)
{
// col.Index is relative to the FULL power array (Best(power, 4, 8) → 4..7):
// shift it back to the group so the 4x4 matrix indexes stay in range. The matrix
// holds display chars ("123A"...): emit the KEYPAD VALUE (0-9, '*'/'#' → 10/11)
// like the RFC 4733 events do — HandleDtmfDigit expects tone ≤ 9, and the raw
// ASCII codes ('1' = 49) would be silently dropped.
var c = Matrix[row.Index][col.Index - 4];
var digit = (byte)(c == '*' ? 10 : c == '#' ? 11 : c - '0');
if (_lastDigit != digit)
{
_lastDigit = digit;
_validCount = 1;
_gapCount = 0;
}
else if (++_validCount == MinValidFrames)
{
DigitDetected?.Invoke(digit);
}
}
else
{
_gapCount++;
if (_gapCount >= GapFrames)
{
_validCount = 0;
_gapCount = 0;
_lastDigit = null;
}
}
}
private static (int Index, double Power, double Second) Best(double[] power, int from, int to)
{
// i1 = largest, i2 = second largest (must be DISTINCT indices: the group-ratio
// check compares the winner against the runner-up, never against itself).
int i1 = from, i2 = from + 1;
if (power[i2] > power[i1]) (i1, i2) = (i2, i1);
for (int i = from + 2; i < to; i++)
{
if (power[i] > power[i1]) { i2 = i1; i1 = i; }
else if (power[i] > power[i2]) i2 = i;
}
return (i1, power[i1], power[i2]);
}
}
private static readonly object Sync = new();
private static readonly object ValidateLock = new(); // serializes the "start validation" check-and-set (OnDtmfTone vs the chained finally)
private static readonly SemaphoreSlim CallGate = new(1, 1);
private static SipConfig Cfg = new();
private static string StartupProvider = "DeepSeekBridge";
private static bool Anonymize;
private static SIPTransport? Transport;
private static SIPUserAgent? Ua;
private static SIPRegistrationUserAgent? Registration;
private static Timer? HealthTimer;
private static bool AnswerEnabled = true;
private static CallContext? Call;
// PIN gate (AIOrchestrator): reusable across voice media; wired here for SIP DTMF. Lockout
// persistence is delegated to SipBridge (sipstate.json).
private static PinAuthGate Gate = new("", 3, TimeSpan.FromHours(24));
private static readonly string StatePath =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "agent", "sipstate.json");
// G.711 static payload types (RFC 3551); RFC 4733 DTMF events use a dynamic type (usually 101).
private const byte PcmuPayloadType = 0;
private const byte PcmaPayloadType = 8;
private const int MaxAgentIterations = 20;
private const int RingTimeoutSeconds = 60;
/// <summary>True when the SIP server is configured (appsettings Sip:Enabled).</summary>
public static bool IsEnabled => Cfg.Enabled;
/// <summary>Whether the server currently auto-answers incoming calls (the live toggle,
/// independent of the configured <see cref="SipConfig.AnswerMode"/>). Read by the TUI
/// SIP panel to show the current answer state.</summary>
public static bool IsAnswerEnabled => AnswerEnabled;
/// <summary>True when the SIP signalling channel is bound.</summary>
public static bool IsListening => Transport != null;
/// <summary>Machine-readable status consumed by GET /v1/sip/status and the TUI.</summary>
public static object Status
{
get
{
CallContext? call;
lock (Sync) call = Call;
var phase = call?.Phase ?? CallPhase.Idle;
return new
{
enabled = Cfg.Enabled,
listening = Transport != null,
answer_enabled = AnswerEnabled,
answer_mode = Cfg.AnswerMode,
registered = Registration?.IsRegistered ?? false,
call_active = call != null,
phase = phase.ToString().ToLowerInvariant(),
remote = call?.RemoteUri,
pin_remaining = Gate.IsLocked ? 0 : Math.Max(0, Cfg.MaxPinAttempts - Gate.Attempts),
locked_until = Gate.LockedUntilUtc,
stt_available = SipVoiceAgent.IsReady,
tts_available = SipVoiceAgent.IsReady,
rtp_port_range = Cfg.RtpPortRange,
};
}
}
/// <summary>Loads the configuration and the persisted lockout state.</summary>
public static void Init(IConfiguration config, string startupProvider, bool anonymize)
{
StartupProvider = startupProvider;
Anonymize = anonymize;
Cfg = config.GetSection("Sip").Get<SipConfig>() ?? new SipConfig();
Cfg.Pin = (Cfg.Pin ?? "").Trim();
Cfg.MaxPinAttempts = Math.Max(1, Cfg.MaxPinAttempts);
Cfg.LockoutHours = Math.Max(1, Cfg.LockoutHours);
Gate = new PinAuthGate(Cfg.Pin, Cfg.MaxPinAttempts, TimeSpan.FromHours(Cfg.LockoutHours));
LoadLockout();
}
/// <summary>Starts the SIP signalling channel + REGISTER (when configured). Returns null on
/// success, or the error message (e.g. port already in use).</summary>
public static async Task<string?> StartAsync()
{
if (!Cfg.Enabled || Transport != null) return null;
try
{
// The persistent voice subprocess (VAD + whisper STT + Kokoro/SAPI TTS) is started
// once for the whole server lifetime: the whisper model stays loaded → persistent
// STT. Both the announcements and the conversation go through it (media = I/O only).
SipVoiceAgent.ExePath = ResolveSttExe();
SipVoiceAgent.SttModel = Cfg.SttModel;
SipVoiceAgent.SttQuant = Cfg.SttQuant;
SipVoiceAgent.SttDevice = Cfg.SttDevice;
await SipVoiceAgent.StartAsync(Cfg.Lang);
var transport = new SIPTransport();
transport.AddSIPChannel(new SIPUDPChannel(new IPEndPoint(IPAddress.Any, Cfg.ListenPort)));
var ua = CreateUserAgent(transport);
SIPRegistrationUserAgent? registration = null;
if (!string.IsNullOrWhiteSpace(Cfg.Registrar) && !string.IsNullOrWhiteSpace(Cfg.Username))
{
registration = new SIPRegistrationUserAgent(transport, Cfg.Username, Cfg.Password,
Cfg.Registrar, Math.Max(30, Cfg.RegisterExpiry), exitOnUnequivocalFailure: false);
registration.RegistrationFailed += (_, _, err) => Log.LogStep($"SIP registration failed: {err}");
registration.RegistrationSuccessful += (_, _) => Log.LogStep("SIP registration successful");
registration.Start();
}
Transport = transport;
Ua = ua;
Registration = registration;
// Self-heal: the shared SIPUserAgent occasionally fails to clear its internal
// dialog state after a server-initiated hangup, which would silently drop every
// later INVITE. When no call is active but the agent still thinks one is, rebuild it.
HealthTimer = new Timer(_ => EnsureUserAgentHealthy(), null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
if (Cfg.AnswerMode == "allowlist" && string.IsNullOrWhiteSpace(Cfg.Registrar))
Log.LogStep("SIP warning: allow-list mode without a registrar — the caller identity (From header) is NOT authenticated and can be spoofed; run allow-list only behind an authenticating trunk");
if (!SipVoiceAgent.IsReady)
Log.LogStep("SIP warning: voice subprocess unavailable (AIOffice.VoiceAgent missing in voiceagent-stt/) — announcements and agent replies will be silent for callers");
Log.LogStep($"SIP listening on UDP {Cfg.ListenPort} (answer mode '{Cfg.AnswerMode}', agent '{Cfg.Agent}')");
return null;
}
catch (Exception ex)
{
Log.LogStep($"SIP start failed: {ex.Message}");
try { Transport?.Shutdown(); } catch { }
Transport = null;
Ua = null;
return ex.Message;
}
}
/// <summary>Stops the SIP server (unregisters + closes the channel).</summary>
public static void Stop()
{
HealthTimer?.Dispose();
HealthTimer = null;
try { Registration?.Stop(); } catch { }
Registration = null;
try { Transport?.Shutdown(); } catch { }
Transport = null;
Ua = null;
EndCall("shutdown");
SipVoiceAgent.Stop();
}
// ─── Config management (TUI ↔ appsettings.json "Sip" section) ─────────
/// <summary>Keys whose change requires a SIP transport restart (bind/REGISTER are
/// transport-level). Every other key is read per call and applies to the next one.</summary>
private static readonly HashSet<string> RestartKeys = new(StringComparer.OrdinalIgnoreCase)
{
nameof(SipConfig.Enabled), nameof(SipConfig.ListenPort), nameof(SipConfig.Registrar),
nameof(SipConfig.Username), nameof(SipConfig.Password), nameof(SipConfig.RtpPortRange),
nameof(SipConfig.RegisterExpiry),
};
/// <summary>Keys cached by the PIN gate (rebuilt when they change).</summary>
private static readonly HashSet<string> GateKeys = new(StringComparer.OrdinalIgnoreCase)
{
nameof(SipConfig.Pin), nameof(SipConfig.MaxPinAttempts), nameof(SipConfig.LockoutHours),
};
/// <summary>Read-only snapshot of the effective SIP configuration, secrets masked
/// (Pin/Password report only whether they are set). Consumed by GET /v1/sip/config.</summary>
public static object ConfigSnapshot
{
get
{
var c = Cfg;
return new
{
enabled = c.Enabled,
listen_port = c.ListenPort,
registrar = c.Registrar,
username = c.Username,
password_set = !string.IsNullOrEmpty(c.Password),
answer_mode = c.AnswerMode,
pin_set = !string.IsNullOrEmpty(c.Pin),
max_pin_attempts = c.MaxPinAttempts,
lockout_hours = c.LockoutHours,
register_expiry = c.RegisterExpiry,
pin_timeout_seconds = c.PinTimeoutSeconds,
indicator_delay_seconds = c.IndicatorDelaySeconds,
allowed_callers = c.AllowedCallers,
agent = c.Agent,
lang = c.Lang,
stt_exe_path = c.SttExePath,
stt_model = c.SttModel,
stt_quant = c.SttQuant,
stt_device = c.SttDevice,
rtp_port_range = c.RtpPortRange,
};
}
}
/// <summary>Sets one SIP config key, persists the whole "Sip" section back to
/// appsettings.json and applies the change. Returns an error message, whether a transport
/// restart was needed/applied, and a human-readable outcome.
/// The key is matched case-insensitively ignoring underscores, so both the property name
/// ("IndicatorDelaySeconds") and the snake_case name shown by GET /v1/sip/config
/// ("indicator_delay_seconds") are accepted.</summary>
public static async Task<(string? Error, bool RestartRequired, string Message)> SetConfigAsync(string key, string? value)
{
// Normalize: strip underscores, lowercase — "indicator_delay_seconds" == "IndicatorDelaySeconds".
static string Normalize(string s) => string.Concat(s.Where(c => c != '_')).ToLowerInvariant();
var normalized = Normalize(key);
var prop = typeof(SipConfig).GetProperties()
.FirstOrDefault(p => Normalize(p.Name) == normalized);
if (prop == null) return ($"unknown SIP config key: {key}", false, "");