返回提交历史
Modified
XFEToolBox.Server.Test/Program.cs
+209
-0
Modified
XFEToolBox.Server/Realtime/ChatRealtimeBroker.cs
+73
-5
Modified
XFEToolBox.Server/Realtime/ChatRealtimeConnection.cs
+12
-0
Added
XFEToolBox.Server/Realtime/ChatRelayAudioFrame.cs
+36
-0
Modified
XFEToolBox.Server/XFEToolBox.Server.csproj
+4
-0
Modified
XFEToolBox/Resources/ChatCall/call.css
+1
-0
Modified
XFEToolBox/Resources/ChatCall/call.html
+3
-3
Modified
XFEToolBox/Resources/ChatCall/call.js
+216
-15
Added
XFEToolBox/Resources/ChatCall/relay-worklet.js
+49
-0
Modified
XFEToolBox/Utilities/Chat/VoiceCallService.cs
+70
-3
Modified
XFEToolBox/Views/Windows/VoiceCallWindow.xaml.cs
+65
-1
Modified
XfeTestArtifacts/test-results.json
+1
-1
Modified
XfeTestArtifacts/test-results.xml
+1
-1
XFEstudio/XFEToolBox
引入语音自动中继,提升通话可靠性
本次提交实现了语音通话的“自动语音中继”能力: - 服务端新增音频帧类型与校验,支持 call.audio 事件,按房间转发音频,限制帧大小与速率,独立速率控制,提升测试可见性。 - Web 前端通过 AudioWorklet 捕获 16kHz PCM 并自动切换 WebRTC/WSS,UI 实时反映链路状态,自动检测直连流量并切换,平滑播放中继音频,通话结束时释放资源。 - WPF 客户端支持收发中继音频帧,与 WebView2 通信,提升音频体验。 - 新增自动化测试,前端样式与文案突出自动中继能力。 显著增强复杂网络下的通话可用性与体验。
b5147e3
代码差异
13 个文件
+740
-29
@@ -1,6 +1,8 @@
1
1
using System.Diagnostics;
2
2
using System.IO.Compression;
3
using System.Net.WebSockets;
3
4
using System.Security.Cryptography;
5
using System.Text;
4
6
using System.Text.Json;
5
7
using XFEToolBox.Core.Chat;
6
8
using XFEToolBox.Core.Tools;
@@ -31,6 +33,8 @@ var tests = new (string Name, Action Run)[]
31
33
("聊天附件分块传输校验完整性和访问权限", ChatAttachmentTransferIsAuthorizedAndVerified),
32
34
("实时票据绑定用途且只能消费一次", ChatRealtimeTicketIsAudienceBoundAndSingleUse),
33
35
("实时票据限制每用户和全局待使用数量", ChatRealtimeTicketLimitsAreEnforced),
36
("自动语音中继只接受固定低延迟 PCM 帧", ChatRelayAudioFramesAreStrictlyValidated),
37
("自动语音中继可在已鉴权通话的两端转发", ChatRelayAudioIsForwardedBetweenCallParticipants),
34
38
("EdgeOne 回源请求可解析真实客户端 IP", ForwardedClientIpIsResolved)
35
39
};
36
40
@@ -487,6 +491,130 @@ static void ChatRealtimeTicketLimitsAreEnforced()
487
491
Assert(store.OutstandingTicketCount == 3, "消费票据后没有释放全局配额。");
488
492
}
489
493
494
static void ChatRelayAudioFramesAreStrictlyValidated()
495
{
496
var pcm = new byte[ChatRelayAudioFrame.RequiredPcmByteCount];
497
RandomNumberGenerator.Fill(pcm);
498
var validPayload = JsonSerializer.SerializeToElement(new
499
{
500
sequence = 42,
501
sampleRate = ChatRelayAudioFrame.RequiredSampleRate,
502
pcmBase64 = Convert.ToBase64String(pcm)
503
});
504
Assert(ChatRelayAudioFrame.TryParse(validPayload, out var frame), "有效的 20 ms PCM 帧被拒绝。");
505
Assert(frame.Sequence == 42 && frame.SampleRate == 16_000, "中继语音帧字段解析错误。");
506
507
var wrongRate = JsonSerializer.SerializeToElement(new
508
{
509
sequence = 43,
510
sampleRate = 48_000,
511
pcmBase64 = Convert.ToBase64String(pcm)
512
});
513
Assert(!ChatRelayAudioFrame.TryParse(wrongRate, out _), "中继接受了错误采样率。");
514
515
var wrongSize = JsonSerializer.SerializeToElement(new
516
{
517
sequence = 44,
518
sampleRate = 16_000,
519
pcmBase64 = Convert.ToBase64String(new byte[638])
520
});
521
Assert(!ChatRelayAudioFrame.TryParse(wrongSize, out _), "中继接受了错误帧长度。");
522
}
523
524
static void ChatRelayAudioIsForwardedBetweenCallParticipants() => WithChatRepository((repository, attachmentRoot) =>
525
{
526
var friendRequest = repository.CreateFriendRequestAsync("alice", "bob", null).GetAwaiter().GetResult();
527
var accepted = repository.RespondToFriendRequestAsync(friendRequest.Id, "bob", accept: true).GetAwaiter().GetResult();
528
Assert(accepted.Status == ChatFriendRequestStatus.Accepted, "测试好友关系建立失败。");
529
var ticketStore = new ChatRealtimeTicketStore();
530
var broker = new ChatRealtimeBroker(repository, ticketStore);
531
var alice = new TestWebSocket();
532
var bob = new TestWebSocket();
533
try
534
{
535
RegisterTestSocket(broker, ticketStore, alice, "alice", "Alice");
536
RegisterTestSocket(broker, ticketStore, bob, "bob", "Bob");
537
const string callId = "relay-audio-test-call";
538
SendTestFrame(broker, alice, "call.invite", new
539
{
540
callId,
541
targetType = "user",
542
targetId = "bob",
543
media = "audio"
544
});
545
Assert(bob.WaitForMessage("call.invite", TimeSpan.FromSeconds(3)), "被叫端没有收到通话邀请。");
546
547
SendTestFrame(broker, bob, "call.accept", new { callId });
548
Assert(alice.WaitForMessage("call.accept", TimeSpan.FromSeconds(3)), "主叫端没有看到被叫端加入。");
549
var pcmBase64 = Convert.ToBase64String(new byte[ChatRelayAudioFrame.RequiredPcmByteCount]);
550
SendTestFrame(broker, alice, "call.audio", new
551
{
552
callId,
553
sequence = 7,
554
sampleRate = ChatRelayAudioFrame.RequiredSampleRate,
555
pcmBase64
556
});
557
558
Assert(bob.WaitForMessage("call.audio", TimeSpan.FromSeconds(3)), "中继语音没有到达另一端。");
559
var relayed = bob.Messages.Last(message => GetEnvelopeType(message) == "call.audio");
560
using var document = JsonDocument.Parse(relayed);
561
var payload = document.RootElement.GetProperty("payload");
562
Assert(payload.GetProperty("fromUserId").GetString() == "alice", "中继语音发送者被伪造或丢失。");
563
Assert(payload.GetProperty("sequence").GetInt64() == 7, "中继语音序号没有保留。");
564
Assert(!alice.Messages.Any(message => GetEnvelopeType(message) == "call.audio"), "服务器把中继语音回送给了发送者。");
565
566
for (var sequence = 8; sequence < 108; sequence++)
567
SendTestFrame(broker, alice, "call.audio", new
568
{
569
callId,
570
sequence,
571
sampleRate = ChatRelayAudioFrame.RequiredSampleRate,
572
pcmBase64
573
});
574
Assert(bob.WaitForMessageCount("call.audio", 101, TimeSpan.FromSeconds(3)),
575
"连续中继语音帧发生丢失或被普通事件限流误拦截。");
576
}
577
finally
578
{
579
broker.DisposeAsync().AsTask().GetAwaiter().GetResult();
580
alice.Dispose();
581
bob.Dispose();
582
GC.KeepAlive(attachmentRoot);
583
}
584
});
585
586
static void RegisterTestSocket(
587
ChatRealtimeBroker broker,
588
ChatRealtimeTicketStore ticketStore,
589
TestWebSocket socket,
590
string userId,
591
string displayName)
592
{
593
var issued = ticketStore.Issue(userId, userId, displayName, "server-test");
594
var uri = new Uri("wss://test.invalid/chat/realtime" +
595
$"?ticket={Uri.EscapeDataString(issued.Ticket)}" +
596
$"&audience={Uri.EscapeDataString(issued.Audience)}" +
597
$"&channel={Uri.EscapeDataString(issued.Channel)}");
598
Assert(broker.TryRegister(socket, uri, out var error), $"测试实时连接注册失败:{error}");
599
}
600
601
static void SendTestFrame(ChatRealtimeBroker broker, TestWebSocket socket, string type, object payload)
602
{
603
var envelope = new ChatRealtimeEnvelope
604
{
605
Type = type,
606
Payload = JsonSerializer.SerializeToElement(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web))
607
};
608
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions(JsonSerializerDefaults.Web));
609
broker.ReceiveFrame(socket, json, endOfMessage: true);
610
}
611
612
static string? GetEnvelopeType(string json)
613
{
614
using var document = JsonDocument.Parse(json);
615
return document.RootElement.TryGetProperty("type", out var type) ? type.GetString() : null;
616
}
617
490
618
static void ForwardedClientIpIsResolved()
491
619
{
492
620
Assert(
@@ -617,3 +745,84 @@ static void Assert(bool condition, string message)
617
745
{
618
746
if (!condition) throw new InvalidOperationException(message);
619
747
}
748
749
sealed class TestWebSocket : WebSocket
750
{
751
private readonly System.Collections.Concurrent.ConcurrentQueue<string> _messages = new();
752
private readonly AutoResetEvent _messageReceived = new(false);
753
private WebSocketState _state = WebSocketState.Open;
754
private WebSocketCloseStatus? _closeStatus;
755
private string? _closeStatusDescription;
756
757
public IReadOnlyList<string> Messages => _messages.ToArray();
758
759
public override WebSocketCloseStatus? CloseStatus => _closeStatus;
760
761
public override string? CloseStatusDescription => _closeStatusDescription;
762
763
public override WebSocketState State => _state;
764
765
public override string? SubProtocol => null;
766
767
public bool WaitForMessage(string type, TimeSpan timeout)
768
=> WaitForMessageCount(type, 1, timeout);
769
770
public bool WaitForMessageCount(string type, int expectedCount, TimeSpan timeout)
771
{
772
var deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency);
773
while (Stopwatch.GetTimestamp() < deadline)
774
{
775
if (_messages.Count(message => MessageHasType(message, type)) >= expectedCount) return true;
776
_messageReceived.WaitOne(TimeSpan.FromMilliseconds(20));
777
}
778
return _messages.Count(message => MessageHasType(message, type)) >= expectedCount;
779
}
780
781
private static bool MessageHasType(string json, string expectedType)
782
{
783
using var document = JsonDocument.Parse(json);
784
return document.RootElement.TryGetProperty("type", out var type) &&
785
string.Equals(type.GetString(), expectedType, StringComparison.Ordinal);
786
}
787
788
public override void Abort() => _state = WebSocketState.Aborted;
789
790
public override Task CloseAsync(
791
WebSocketCloseStatus closeStatus,
792
string? statusDescription,
793
CancellationToken cancellationToken)
794
{
795
_closeStatus = closeStatus;
796
_closeStatusDescription = statusDescription;
797
_state = WebSocketState.Closed;
798
return Task.CompletedTask;
799
}
800
801
public override Task CloseOutputAsync(
802
WebSocketCloseStatus closeStatus,
803
string? statusDescription,
804
CancellationToken cancellationToken) =>
805
CloseAsync(closeStatus, statusDescription, cancellationToken);
806
807
public override void Dispose()
808
{
809
_state = WebSocketState.Closed;
810
_messageReceived.Dispose();
811
}
812
813
public override Task<WebSocketReceiveResult> ReceiveAsync(
814
ArraySegment<byte> buffer,
815
CancellationToken cancellationToken) =>
816
throw new NotSupportedException();
817
818
public override Task SendAsync(
819
ArraySegment<byte> buffer,
820
WebSocketMessageType messageType,
821
bool endOfMessage,
822
CancellationToken cancellationToken)
823
{
824
_messages.Enqueue(Encoding.UTF8.GetString(buffer));
825
_messageReceived.Set();
826
return Task.CompletedTask;
827
}
828
}
@@ -7,8 +7,9 @@ using XFEToolBox.Server.Core.Chat;
7
7
namespace XFEToolBox.Server.Realtime;
8
8
9
9
/// <summary>
10
/// Authenticated WebSocket broker for durable chat event notifications and call
11
/// signalling. Audio never traverses this broker; WebRTC carries media directly.
10
/// Authenticated WebSocket broker for durable chat events and calls. WebRTC carries
11
/// media directly when possible; validated low-bitrate PCM is relayed automatically
12
/// when a peer-to-peer media path cannot be established.
12
13
/// </summary>
13
14
public sealed class ChatRealtimeBroker : IAsyncDisposable
14
15
{
@@ -19,6 +20,8 @@ public sealed class ChatRealtimeBroker : IAsyncDisposable
19
20
public const int MaximumCallInviteRecipients = 64;
20
21
public const int MaximumTextFrameBytes = 64 * 1024;
21
22
public const int MaximumFramesPerTenSeconds = 160;
23
public const int MaximumRelayAudioFrameBytes = 2048;
24
public const int MaximumRelayAudioFramesPerTenSeconds = 600;
22
25
public static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(20);
23
26
public static readonly TimeSpan StaleConnectionTimeout = TimeSpan.FromSeconds(75);
24
27
@@ -120,7 +123,7 @@ public sealed class ChatRealtimeBroker : IAsyncDisposable
120
123
serverTimeUtc = TimeProvider.GetUtcNow(),
121
124
heartbeatIntervalSeconds = (int)HeartbeatInterval.TotalSeconds,
122
125
maximumCallParticipants = MaximumCallParticipants,
123
mediaTransport = "webrtc"
126
mediaTransport = "webrtc+wss-audio-fallback"
124
127
}));
125
128
return true;
126
129
}
@@ -134,13 +137,18 @@ public sealed class ChatRealtimeBroker : IAsyncDisposable
134
137
}
135
138
136
139
connection.Touch();
137
if (!endOfMessage || Encoding.UTF8.GetByteCount(message) > MaximumTextFrameBytes)
140
var relayAudio = IsRelayAudioMessage(message);
141
var maximumFrameBytes = relayAudio ? MaximumRelayAudioFrameBytes : MaximumTextFrameBytes;
142
if (!endOfMessage || Encoding.UTF8.GetByteCount(message) > maximumFrameBytes)
138
143
{
139
144
_ = CloseAndUnregisterAsync(connection, WebSocketCloseStatus.MessageTooBig, "frame too large or fragmented");
140
145
return;
141
146
}
142
147
143
if (!connection.TryConsumeFrameQuota(MaximumFramesPerTenSeconds))
148
var withinRateLimit = relayAudio
149
? connection.TryConsumeRelayAudioFrameQuota(MaximumRelayAudioFramesPerTenSeconds)
150
: connection.TryConsumeFrameQuota(MaximumFramesPerTenSeconds);
151
if (!withinRateLimit)
144
152
{
145
153
_ = CloseAndUnregisterAsync(connection, WebSocketCloseStatus.PolicyViolation, "frame rate exceeded");
146
154
return;
@@ -310,6 +318,9 @@ public sealed class ChatRealtimeBroker : IAsyncDisposable
310
318
case "webrtc.ice":
311
319
await HandleWebRtcSignalAsync(connection, envelope, cancellationToken);
312
320
break;
321
case "call.audio":
322
await HandleCallAudioAsync(connection, envelope, cancellationToken);
323
break;
313
324
default:
314
325
await SendErrorAsync(
315
326
connection,
@@ -589,6 +600,60 @@ public sealed class ChatRealtimeBroker : IAsyncDisposable
589
600
await SendAckAsync(connection, envelope, duplicate: false, cancellationToken, callId);
590
601
}
591
602
603
private async Task HandleCallAudioAsync(
604
ChatRealtimeConnection connection,
605
ChatRealtimeEnvelope envelope,
606
CancellationToken cancellationToken)
607
{
608
if (!TryGetCall(envelope, out var callId, out var room) ||
609
!ChatRelayAudioFrame.TryParse(envelope.Payload, out var frame))
610
{
611
await SendErrorAsync(connection, "call.audio_invalid", "中继语音帧无效。", envelope.EventId, cancellationToken);
612
return;
613
}
614
615
string[] targetConnectionIds;
616
lock (room.SyncRoot)
617
{
618
if (!room.Participants.TryGetValue(connection.Claims.UserId, out var sender) ||
619
!string.Equals(sender.ConnectionId, connection.ConnectionId, StringComparison.Ordinal))
620
{
621
targetConnectionIds = [];
622
}
623
else
624
{
625
targetConnectionIds = room.Participants.Values
626
.Where(participant => !string.Equals(participant.UserId, connection.Claims.UserId, StringComparison.Ordinal))
627
.Select(participant => participant.ConnectionId)
628
.Distinct(StringComparer.Ordinal)
629
.ToArray();
630
room.Touch();
631
}
632
}
633
634
if (targetConnectionIds.Length == 0)
635
{
636
await SendErrorAsync(connection, "call.audio_not_allowed", "只能在已加入的通话中发送中继语音。", envelope.EventId, cancellationToken);
637
return;
638
}
639
640
var forwarded = ChatRealtimeProtocol.Create("call.audio", new
641
{
642
callId,
643
fromUserId = connection.Claims.UserId,
644
fromDisplayName = GetDisplayName(connection),
645
sequence = frame.Sequence,
646
sampleRate = frame.SampleRate,
647
pcmBase64 = frame.PcmBase64
648
}, connection.Claims.UserId, envelope.ConversationId);
649
var targets = targetConnectionIds
650
.Select(connectionId => _connectionsById.TryGetValue(connectionId, out var target) ? target : null)
651
.Where(static target => target is not null)
652
.Cast<ChatRealtimeConnection>()
653
.ToArray();
654
await SendToConnectionsAsync(targets, forwarded, cancellationToken);
655
}
656
592
657
private bool TryGetCall(ChatRealtimeEnvelope envelope, out string callId, out ChatCallRoom room)
593
658
{
594
659
if (ChatRealtimeProtocol.TryGetRequiredString(envelope.Payload, "callId", out callId) &&
@@ -885,6 +950,9 @@ public sealed class ChatRealtimeBroker : IAsyncDisposable
885
950
!string.IsNullOrWhiteSpace(value) && value.Length <= 64 &&
886
951
value.All(character => char.IsLetterOrDigit(character) || character is '-' or '_');
887
952
953
private static bool IsRelayAudioMessage(string message) =>
954
message.Contains("\"type\":\"call.audio\"", StringComparison.Ordinal);
955
888
956
private static void TryAbort(WebSocket socket)
889
957
{
890
958
try { socket.Abort(); }
@@ -22,6 +22,8 @@ internal sealed class ChatRealtimeConnection : IAsyncDisposable
22
22
private long _lastSeenTimestamp;
23
23
private long _rateWindow;
24
24
private int _framesInWindow;
25
private long _relayAudioRateWindow;
26
private int _relayAudioFramesInWindow;
25
27
private int _pendingSends;
26
28
private int _closing;
27
29
@@ -57,6 +59,16 @@ internal sealed class ChatRealtimeConnection : IAsyncDisposable
57
59
return Interlocked.Increment(ref _framesInWindow) <= maximumFramesPerTenSeconds;
58
60
}
59
61
62
public bool TryConsumeRelayAudioFrameQuota(int maximumFramesPerTenSeconds)
63
{
64
var window = TimeProvider.GetUtcNow().ToUnixTimeSeconds() / 10;
65
var observed = Interlocked.Read(ref _relayAudioRateWindow);
66
if (observed != window &&
67
Interlocked.CompareExchange(ref _relayAudioRateWindow, window, observed) == observed)
68
Interlocked.Exchange(ref _relayAudioFramesInWindow, 0);
69
return Interlocked.Increment(ref _relayAudioFramesInWindow) <= maximumFramesPerTenSeconds;
70
}
71
60
72
public bool TryQueue(string message) =>
61
73
Volatile.Read(ref _closing) == 0 && _inbound.Writer.TryWrite(message);
62
74
@@ -0,0 +1,36 @@
1
using System.Text.Json;
2
3
namespace XFEToolBox.Server.Realtime;
4
5
internal sealed record ChatRelayAudioFrame(long Sequence, int SampleRate, string PcmBase64)
6
{
7
public const int RequiredSampleRate = 16_000;
8
public const int RequiredPcmByteCount = 640;
9
public const int MaximumBase64Length = 1024;
10
11
public static bool TryParse(JsonElement payload, out ChatRelayAudioFrame frame)
12
{
13
frame = default!;
14
if (payload.ValueKind != JsonValueKind.Object ||
15
!payload.TryGetProperty("sequence", out var sequenceProperty) ||
16
!sequenceProperty.TryGetInt64(out var sequence) ||
17
sequence < 0 ||
18
!payload.TryGetProperty("sampleRate", out var sampleRateProperty) ||
19
!sampleRateProperty.TryGetInt32(out var sampleRate) ||
20
sampleRate != RequiredSampleRate ||
21
!payload.TryGetProperty("pcmBase64", out var pcmProperty) ||
22
pcmProperty.ValueKind != JsonValueKind.String)
23
return false;
24
25
var pcmBase64 = pcmProperty.GetString();
26
if (string.IsNullOrWhiteSpace(pcmBase64) || pcmBase64.Length > MaximumBase64Length)
27
return false;
28
Span<byte> decoded = stackalloc byte[RequiredPcmByteCount];
29
if (!Convert.TryFromBase64String(pcmBase64, decoded, out var bytesWritten) ||
30
bytesWritten != RequiredPcmByteCount)
31
return false;
32
33
frame = new ChatRelayAudioFrame(sequence, sampleRate, pcmBase64);
34
return true;
35
}
36
}
@@ -14,4 +14,8 @@
14
14
<ItemGroup>
15
15
<ProjectReference Include="..\XFEToolBox.Server.Core\XFEToolBox.Server.Core.csproj" />
16
16
</ItemGroup>
17
18
<ItemGroup>
19
<InternalsVisibleTo Include="XFEToolBox.Server.Test" />
20
</ItemGroup>
17
21
</Project>
@@ -23,6 +23,7 @@ h1 { margin: 8px 0 4px; font-size: 30px; letter-spacing: -.04em; }
23
23
.badge { padding: 7px 10px; border: 1px solid #3d4260; border-radius: 999px; background: rgba(42,46,63,.66); color: #cfd3ff; font-size: 10px; }
24
24
.badge.muted { color: #a9afbf; }
25
25
.badge.warning { border-color: #665133; color: #e8bd75; background: rgba(75,55,29,.5); }
26
.badge.relay { border-color: #3c7167; color: #8ee8cf; background: rgba(26,83,70,.45); }
26
27
27
28
.metrics { display: grid; grid-template-columns: repeat(4,minmax(120px,1fr)); gap: 10px; margin: 22px 0; }
28
29
.metrics div { border: 1px solid #292d3a; border-radius: 13px; padding: 15px 17px; background: rgba(24,27,36,.76); }
@@ -12,14 +12,14 @@
12
12
<main class="shell">
13
13
<section class="hero">
14
14
<div>
15
<div class="eyebrow">WEBRTC · OPUS · ENCRYPTED</div>
15
<div class="eyebrow">WEBRTC · WSS FALLBACK · ENCRYPTED</div>
16
16
<h1>语音通话</h1>
17
17
<p id="statusText">等待通话配置…</p>
18
18
</div>
19
19
<div class="badges">
20
<span id="meshBadge" class="badge">小群 Mesh · 最多 8 人</span>
20
<span id="meshBadge" class="badge">小群通话 · 最多 8 人</span>
21
21
<span id="noiseBadge" class="badge muted">正在检测语音隔离</span>
22
<span id="turnBadge" class="badge warning">未配置 TURN</span>
22
<span id="turnBadge" class="badge">WebRTC 优先 · 自动中继待命</span>
23
23
</div>
24
24
</section>
25
25
@@ -8,6 +8,10 @@ const state = {
8
8
sourceNode: null,
9
9
micGainNode: null,
10
10
limiterNode: null,
11
relayNode: null,
12
relaySinkNode: null,
13
relaySequence: 0,
14
relayCaptureActive: false,
11
15
peers: new Map(),
12
16
participantNames: new Map(),
13
17
micMuted: false,
@@ -78,6 +82,9 @@ async function handleHostMessage(message) {
78
82
case 'webrtc.signal':
79
83
await handleRemoteSignal(message.signalType, message.fromUserId, message.signal || {});
80
84
break;
85
case 'call.relay-audio':
86
await handleRelayAudio(message.fromUserId, message.sequence, message.sampleRate, message.pcmBase64);
87
break;
81
88
case 'realtime.state':
82
89
if (message.state !== 'Connected') reportStatus('信令连接暂时中断,正在恢复…');
83
90
break;
@@ -94,12 +101,10 @@ async function initialize(config) {
94
101
state.noiseSuppression = config.startWithNoiseSuppression !== false;
95
102
ui.noiseButton.classList.toggle('active', state.noiseSuppression);
96
103
ui.noiseButton.setAttribute('aria-pressed', String(state.noiseSuppression));
97
ui.meshBadge.textContent = `小群 Mesh · 最多 ${config.maximumParticipants} 人`;
104
ui.meshBadge.textContent = `小群通话 · 最多 ${config.maximumParticipants} 人`;
98
105
99
const urls = (config.iceServers || []).flatMap(item => Array.isArray(item.urls) ? item.urls : [item.urls]);
100
const hasTurn = urls.some(url => typeof url === 'string' && /^(turn|turns):/i.test(url));
101
ui.turnBadge.textContent = hasTurn ? 'TURN 中继已配置' : '未配置 TURN · 复杂 NAT 可能失败';
102
ui.turnBadge.classList.toggle('warning', !hasTurn);
106
ui.turnBadge.textContent = 'WebRTC 优先 · 自动中继待命';
107
ui.turnBadge.classList.remove('warning');
103
108
104
109
const supported = navigator.mediaDevices.getSupportedConstraints();
105
110
ui.noiseBadge.textContent = supported.voiceIsolation
@@ -139,6 +144,7 @@ async function acquireMicrophone() {
139
144
const oldRawStream = state.rawStream;
140
145
const oldProcessedStream = state.processedStream;
141
146
if (state.sourceNode) state.sourceNode.disconnect();
147
if (state.limiterNode) state.limiterNode.disconnect();
142
148
143
149
const source = state.audioContext.createMediaStreamSource(nextRawStream);
144
150
const highPass = state.audioContext.createBiquadFilter();
@@ -158,6 +164,8 @@ async function acquireMicrophone() {
158
164
limiter.release.value = .08;
159
165
const destination = state.audioContext.createMediaStreamDestination();
160
166
source.connect(highPass).connect(lowPass).connect(gain).connect(limiter).connect(destination);
167
await ensureRelayCapture();
168
limiter.connect(state.relayNode);
161
169
162
170
state.rawStream = nextRawStream;
163
171
state.processedStream = destination.stream;
@@ -175,6 +183,39 @@ async function acquireMicrophone() {
175
183
oldProcessedStream?.getTracks().forEach(track => track.stop());
176
184
}
177
185
186
async function ensureRelayCapture() {
187
if (state.relayNode) return;
188
await state.audioContext.audioWorklet.addModule('relay-worklet.js');
189
const relayNode = new AudioWorkletNode(state.audioContext, 'relay-pcm-processor');
190
const relaySinkNode = state.audioContext.createGain();
191
relaySinkNode.gain.value = 0;
192
relayNode.connect(relaySinkNode).connect(state.audioContext.destination);
193
relayNode.port.onmessage = event => {
194
if (!state.relayCaptureActive || state.disposed || !(event.data instanceof ArrayBuffer)) return;
195
const bytes = new Uint8Array(event.data);
196
if (bytes.byteLength !== 640) return;
197
post({
198
type: 'relay-audio',
199
callId: state.config.callId,
200
sequence: state.relaySequence++,
201
sampleRate: 16000,
202
pcmBase64: bytesToBase64(bytes)
203
});
204
};
205
state.relayNode = relayNode;
206
state.relaySinkNode = relaySinkNode;
207
relayNode.port.postMessage({ active: false });
208
}
209
210
function bytesToBase64(bytes) {
211
let binary = '';
212
for (let index = 0; index < bytes.length; index += 0x8000) {
213
const chunk = bytes.subarray(index, Math.min(index + 0x8000, bytes.length));
214
binary += String.fromCharCode(...chunk);
215
}
216
return btoa(binary);
217
}
218
178
219
async function refreshMicrophoneList() {
179
220
const devices = (await navigator.mediaDevices.enumerateDevices()).filter(item => item.kind === 'audioinput');
180
221
const activeDevice = state.rawStream?.getAudioTracks()[0]?.getSettings().deviceId || state.currentDeviceId;
@@ -200,7 +241,7 @@ function applyMicrophoneGain() {
200
241
201
242
function renderSelfCard() {
202
243
const card = createParticipantCard(state.config.selfUserId, state.config.selfDisplayName, true);
203
card.querySelector('.identity span').textContent = '本机 · WebRTC 音频处理';
244
card.querySelector('.identity span').textContent = '本机 · 降噪与自动中继就绪';
204
245
card.querySelector('.connection-dot').classList.add('connected');
205
246
ui.grid.prepend(card);
206
247
updateParticipantCount();
@@ -241,7 +282,7 @@ async function reconcileParticipants(participants) {
241
282
242
283
async function ensurePeer(userId, displayName, initiate) {
243
284
if (state.peers.has(userId)) return state.peers.get(userId);
244
if (state.peers.size >= state.config.maximumParticipants - 1) throw new Error(`小群 Mesh 最多支持 ${state.config.maximumParticipants} 人。`);
285
if (state.peers.size >= state.config.maximumParticipants - 1) throw new Error(`小群通话最多支持 ${state.config.maximumParticipants} 人。`);
245
286
246
287
const card = createParticipantCard(userId, displayName, false);
247
288
const pc = new RTCPeerConnection({
@@ -250,7 +291,25 @@ async function ensurePeer(userId, displayName, initiate) {
250
291
rtcpMuxPolicy: 'require',
251
292
iceCandidatePoolSize: 2
252
293
});
253
const peer = { userId, displayName, pc, card, gainNode: null, sourceNode: null, pendingIce: [], muted: false, volume: 1 };
294
const peer = {
295
userId,
296
displayName,
297
pc,
298
card,
299
gainNode: null,
300
sourceNode: null,
301
pendingIce: [],
302
muted: false,
303
volume: 1,
304
relayActive: false,
305
relayFallbackTimer: null,
306
relaySources: new Set(),
307
relayNextPlayTime: 0,
308
lastRelaySequence: -1,
309
directMediaReady: false,
310
directPacketsReceived: 0,
311
directPacketProgressAt: 0
312
};
254
313
state.peers.set(userId, peer);
255
314
256
315
const localTrack = state.processedStream.getAudioTracks()[0];
@@ -263,7 +322,9 @@ async function ensurePeer(userId, displayName, initiate) {
263
322
pc.onconnectionstatechange = () => updatePeerConnectionState(peer);
264
323
pc.oniceconnectionstatechange = () => updatePeerConnectionState(peer);
265
324
bindPeerControls(peer);
325
armRelayFallback(peer, 3500);
266
326
updateParticipantCount();
327
updateTransportBadge();
267
328
268
329
if (initiate) {
269
330
await pc.setLocalDescription(await pc.createOffer({ offerToReceiveAudio: true }));
@@ -287,12 +348,18 @@ function preferOpus(pc, sender) {
287
348
function attachRemoteAudio(peer, stream) {
288
349
if (peer.sourceNode) peer.sourceNode.disconnect();
289
350
const source = state.audioContext.createMediaStreamSource(stream);
351
source.connect(ensurePeerGain(peer));
352
peer.sourceNode = source;
353
peer.card.querySelector('.identity span').textContent = '语音轨道已接收';
354
}
355
356
function ensurePeerGain(peer) {
357
if (peer.gainNode) return peer.gainNode;
290
358
const gain = state.audioContext.createGain();
291
359
gain.gain.value = peer.muted ? 0 : peer.volume;
292
source.connect(gain).connect(state.audioContext.destination);
293
peer.sourceNode = source;
360
gain.connect(state.audioContext.destination);
294
361
peer.gainNode = gain;
295
peer.card.querySelector('.identity span').textContent = '语音轨道已接收';
362
return gain;
296
363
}
297
364
298
365
function bindPeerControls(peer) {
@@ -343,21 +410,134 @@ function sendSignal(signalType, targetUserId, detail) {
343
410
post({ type: 'signal', callId: state.config.callId, signalType, targetUserId, payload });
344
411
}
345
412
413
async function handleRelayAudio(fromUserId, sequence, sampleRate, pcmBase64) {
414
if (state.disposed || !fromUserId || fromUserId === state.config.selfUserId || sampleRate !== 16000 ||
415
!Number.isSafeInteger(sequence) || sequence < 0 || typeof pcmBase64 !== 'string') return;
416
const displayName = state.participantNames.get(fromUserId) || fromUserId;
417
const peer = await ensurePeer(fromUserId, displayName, false);
418
if (peer.directMediaReady || sequence <= peer.lastRelaySequence) return;
419
420
let bytes;
421
try {
422
const binary = atob(pcmBase64);
423
if (binary.length !== 640) return;
424
bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
425
} catch {
426
return;
427
}
428
429
peer.lastRelaySequence = sequence;
430
enablePeerRelay(peer);
431
const buffer = state.audioContext.createBuffer(1, 320, sampleRate);
432
const samples = buffer.getChannelData(0);
433
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
434
for (let index = 0; index < samples.length; index++)
435
samples[index] = view.getInt16(index * 2, true) / 32768;
436
437
const source = state.audioContext.createBufferSource();
438
source.buffer = buffer;
439
source.connect(ensurePeerGain(peer));
440
const now = state.audioContext.currentTime;
441
if (peer.relayNextPlayTime < now || peer.relayNextPlayTime > now + .35)
442
peer.relayNextPlayTime = now + .06;
443
source.start(peer.relayNextPlayTime);
444
peer.relayNextPlayTime += buffer.duration;
445
peer.relaySources.add(source);
446
source.onended = () => peer.relaySources.delete(source);
447
const dot = peer.card.querySelector('.connection-dot');
448
dot.classList.add('connected');
449
dot.classList.remove('failed');
450
peer.card.querySelector('.identity span').textContent = '已连接 · WSS 自动中继';
451
}
452
453
function armRelayFallback(peer, delay) {
454
if (peer.relayFallbackTimer != null || peer.relayActive || peer.directMediaReady) return;
455
peer.relayFallbackTimer = setTimeout(() => {
456
peer.relayFallbackTimer = null;
457
if (!peer.directMediaReady) enablePeerRelay(peer);
458
}, delay);
459
}
460
461
function enablePeerRelay(peer) {
462
if (peer.directMediaReady) return;
463
if (!peer.relayActive) {
464
peer.relayActive = true;
465
peer.card.querySelector('.identity span').textContent = '正在启用自动中继';
466
}
467
setRelayCaptureState();
468
updateTransportBadge();
469
}
470
471
function disablePeerRelay(peer) {
472
if (peer.relayFallbackTimer != null) {
473
clearTimeout(peer.relayFallbackTimer);
474
peer.relayFallbackTimer = null;
475
}
476
peer.relayActive = false;
477
peer.relayNextPlayTime = 0;
478
for (const source of peer.relaySources) {
479
try { source.stop(); } catch { }
480
}
481
peer.relaySources.clear();
482
setRelayCaptureState();
483
updateTransportBadge();
484
}
485
486
function setRelayCaptureState() {
487
const active = [...state.peers.values()].some(peer => peer.relayActive);
488
if (state.relayCaptureActive === active) return;
489
state.relayCaptureActive = active;
490
state.relayNode?.port.postMessage({ active });
491
}
492
493
function updateTransportBadge() {
494
const peers = [...state.peers.values()];
495
const relaying = peers.some(peer => peer.relayActive);
496
const direct = peers.length > 0 && peers.every(peer => peer.directMediaReady);
497
ui.turnBadge.textContent = relaying
498
? 'WSS 自动中继'
499
: direct
500
? 'WebRTC 直连'
501
: 'WebRTC 优先 · 自动中继待命';
502
ui.turnBadge.classList.toggle('relay', relaying);
503
ui.turnBadge.classList.remove('warning');
504
}
505
346
506
function updatePeerConnectionState(peer) {
347
507
const connectionState = peer.pc.connectionState;
348
508
const dot = peer.card.querySelector('.connection-dot');
349
509
dot.classList.toggle('connected', connectionState === 'connected');
350
510
dot.classList.toggle('failed', connectionState === 'failed' || connectionState === 'closed');
351
peer.card.querySelector('.identity span').textContent = ({
352
new: '等待协商', connecting: '正在建立低延迟链路', connected: '已连接 · Opus',
353
disconnected: '连接暂时中断', failed: '连接失败', closed: '已离开'
354
})[connectionState] || connectionState;
511
if (connectionState === 'connected') {
512
if (peer.directMediaReady) {
513
disablePeerRelay(peer);
514
peer.card.querySelector('.identity span').textContent = '已连接 · WebRTC/Opus';
515
} else {
516
armRelayFallback(peer, 2500);
517
if (!peer.relayActive) peer.card.querySelector('.identity span').textContent = '已连接 · 正在验证音频';
518
}
519
} else if (connectionState === 'failed') {
520
enablePeerRelay(peer);
521
} else if (connectionState === 'disconnected') {
522
armRelayFallback(peer, 800);
523
} else if (connectionState === 'new' || connectionState === 'connecting') {
524
armRelayFallback(peer, 3500);
525
}
526
if (!peer.relayActive && connectionState !== 'connected') {
527
peer.card.querySelector('.identity span').textContent = ({
528
new: '等待协商', connecting: '正在建立低延迟链路', connected: '已连接 · WebRTC/Opus',
529
disconnected: '连接暂时中断', failed: '正在切换自动中继', closed: '已离开'
530
})[connectionState] || connectionState;
531
}
532
updateTransportBadge();
355
533
}
356
534
357
535
function removePeer(userId) {
358
536
const peer = state.peers.get(userId);
359
537
if (!peer) return;
538
disablePeerRelay(peer);
360
539
peer.sourceNode?.disconnect();
540
peer.gainNode?.disconnect();
361
541
peer.pc.ontrack = null;
362
542
peer.pc.onicecandidate = null;
363
543
peer.pc.close();
@@ -365,6 +545,7 @@ function removePeer(userId) {
365
545
state.peers.delete(userId);
366
546
state.participantNames.delete(userId);
367
547
updateParticipantCount();
548
updateTransportBadge();
368
549
}
369
550
370
551
async function updateNetworkMetrics() {
@@ -375,15 +556,31 @@ async function updateNetworkMetrics() {
375
556
let received = 0;
376
557
for (const peer of state.peers.values()) {
377
558
const stats = await peer.pc.getStats();
559
let packetsReceived = 0;
378
560
stats.forEach(report => {
379
561
if (report.type === 'candidate-pair' && report.state === 'succeeded' && Number.isFinite(report.currentRoundTripTime))
380
562
maxRtt = Math.max(maxRtt || 0, report.currentRoundTripTime);
381
563
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
564
packetsReceived += Math.max(0, report.packetsReceived || 0);
382
565
if (Number.isFinite(report.jitter)) maxJitter = Math.max(maxJitter || 0, report.jitter);
383
566
lost += Math.max(0, report.packetsLost || 0);
384
567
received += Math.max(0, report.packetsReceived || 0);
385
568
}
386
569
});
570
if (packetsReceived > peer.directPacketsReceived) {
571
peer.directPacketsReceived = packetsReceived;
572
peer.directPacketProgressAt = performance.now();
573
if (!peer.directMediaReady) {
574
peer.directMediaReady = true;
575
disablePeerRelay(peer);
576
peer.card.querySelector('.identity span').textContent = '已连接 · WebRTC/Opus';
577
}
578
} else if (peer.directMediaReady && performance.now() - peer.directPacketProgressAt > 5000) {
579
peer.directMediaReady = false;
580
enablePeerRelay(peer);
581
} else if (!peer.directMediaReady) {
582
armRelayFallback(peer, 1200);
583
}
387
584
}
388
585
ui.roundTrip.textContent = maxRtt == null ? '—' : `${Math.round(maxRtt * 1000)} ms`;
389
586
ui.jitter.textContent = maxJitter == null ? '—' : `${Math.round(maxJitter * 1000)} ms`;
@@ -449,6 +646,8 @@ async function disposeCall() {
449
646
state.sourceNode?.disconnect();
450
647
state.micGainNode?.disconnect();
451
648
state.limiterNode?.disconnect();
649
state.relayNode?.disconnect();
650
state.relaySinkNode?.disconnect();
452
651
state.rawStream?.getTracks().forEach(track => track.stop());
453
652
state.processedStream?.getTracks().forEach(track => track.stop());
454
653
state.rawStream = null;
@@ -456,6 +655,8 @@ async function disposeCall() {
456
655
state.sourceNode = null;
457
656
state.micGainNode = null;
458
657
state.limiterNode = null;
658
state.relayNode = null;
659
state.relaySinkNode = null;
459
660
const context = state.audioContext;
460
661
state.audioContext = null;
461
662
if (context && context.state !== 'closed') {
@@ -0,0 +1,49 @@
1
'use strict';
2
3
const TARGET_SAMPLE_RATE = 16000;
4
const FRAME_SAMPLE_COUNT = 320;
5
6
class RelayPcmProcessor extends AudioWorkletProcessor {
7
constructor() {
8
super();
9
this.active = false;
10
this.pending = new Float32Array(FRAME_SAMPLE_COUNT);
11
this.pendingLength = 0;
12
this.resamplePosition = 0;
13
this.port.onmessage = event => {
14
this.active = event.data?.active === true;
15
if (!this.active) {
16
this.pendingLength = 0;
17
this.resamplePosition = 0;
18
}
19
};
20
}
21
22
process(inputs) {
23
if (!this.active) return true;
24
const input = inputs[0]?.[0];
25
if (!input?.length) return true;
26
27
const step = sampleRate / TARGET_SAMPLE_RATE;
28
while (this.resamplePosition < input.length) {
29
const index = Math.min(input.length - 1, Math.floor(this.resamplePosition));
30
this.pending[this.pendingLength++] = input[index];
31
this.resamplePosition += step;
32
if (this.pendingLength === FRAME_SAMPLE_COUNT) this.flushFrame();
33
}
34
this.resamplePosition -= input.length;
35
return true;
36
}
37
38
flushFrame() {
39
const pcm = new Int16Array(FRAME_SAMPLE_COUNT);
40
for (let index = 0; index < FRAME_SAMPLE_COUNT; index++) {
41
const sample = Math.max(-1, Math.min(1, this.pending[index]));
42
pcm[index] = sample < 0 ? Math.round(sample * 32768) : Math.round(sample * 32767);
43
}
44
this.pendingLength = 0;
45
this.port.postMessage(pcm.buffer, [pcm.buffer]);
46
}
47
}
48
49
registerProcessor('relay-pcm-processor', RelayPcmProcessor);
@@ -8,8 +8,9 @@ using XFEToolBox.Client.Views.Windows;
8
8
namespace XFEToolBox.Client.Utilities.Chat;
9
9
10
10
/// <summary>
11
/// Coordinates call state over the authenticated realtime client. WebRTC media and
12
/// per-peer audio processing remain isolated inside <see cref="VoiceCallWindow"/>.
11
/// Coordinates call state over the authenticated realtime client. WebRTC is the
12
/// primary media path; the same authenticated connection carries low-latency PCM
13
/// frames automatically when a peer-to-peer path cannot be established.
13
14
/// </summary>
14
15
public sealed class VoiceCallService : IAsyncDisposable
15
16
{
@@ -24,6 +25,7 @@ public sealed class VoiceCallService : IAsyncDisposable
24
25
private readonly ConcurrentDictionary<string, IReadOnlyList<VoiceCallParticipant>> _participantSnapshots = new(StringComparer.Ordinal);
25
26
private readonly ConcurrentDictionary<string, ConcurrentQueue<PendingVoiceCallSignal>> _pendingSignals = new(StringComparer.Ordinal);
26
27
private readonly SemaphoreSlim _operationLock = new(1, 1);
28
private readonly SemaphoreSlim _relaySendGate = new(1, 1);
27
29
private VoiceCallWindow? _callWindow;
28
30
private ActiveVoiceCall? _activeCall;
29
31
private int _disposed;
@@ -237,8 +239,15 @@ public sealed class VoiceCallService : IAsyncDisposable
237
239
throw new InvalidOperationException("实时通信服务连接超时,请检查网络后重试。");
238
240
}
239
241
240
private void RealtimeClient_EnvelopeReceived(object? sender, ChatRealtimeEnvelopeEventArgs e) =>
242
private void RealtimeClient_EnvelopeReceived(object? sender, ChatRealtimeEnvelopeEventArgs e)
243
{
244
if (e.Envelope.Type == "call.audio")
245
{
246
ForwardRelayAudioToWindow(e.Envelope);
247
return;
248
}
241
249
_ = HandleRealtimeEnvelopeSafeAsync(e.Envelope);
250
}
242
251
243
252
private async Task HandleRealtimeEnvelopeSafeAsync(ChatClientRealtimeEnvelope envelope)
244
253
{
@@ -380,6 +389,24 @@ public sealed class VoiceCallService : IAsyncDisposable
380
389
queue.Enqueue(new PendingVoiceCallSignal(envelope.Type, fromUserId, signal.Clone()));
381
390
}
382
391
392
private void ForwardRelayAudioToWindow(ChatClientRealtimeEnvelope envelope)
393
{
394
if (!TryGetString(envelope.Payload, "callId", out var callId) ||
395
!TryGetString(envelope.Payload, "fromUserId", out var fromUserId) ||
396
!envelope.Payload.TryGetProperty("sequence", out var sequenceProperty) ||
397
!sequenceProperty.TryGetInt64(out var sequence) ||
398
sequence < 0 ||
399
!envelope.Payload.TryGetProperty("sampleRate", out var sampleRateProperty) ||
400
!sampleRateProperty.TryGetInt32(out var sampleRate) ||
401
sampleRate != 16_000 ||
402
!TryGetString(envelope.Payload, "pcmBase64", out var pcmBase64) ||
403
pcmBase64.Length > 1024 ||
404
!IsActiveCall(callId) ||
405
_callWindow is not { } window)
406
return;
407
_ = window.ApplyRelayAudioAsync(fromUserId, sequence, sampleRate, pcmBase64);
408
}
409
383
410
private void HandleServerError(JsonElement payload)
384
411
{
385
412
var code = GetString(payload, "code") ?? "realtime.error";
@@ -451,6 +478,7 @@ public sealed class VoiceCallService : IAsyncDisposable
451
478
title,
452
479
_options);
453
480
_callWindow.SignalGenerated += CallWindow_SignalGenerated;
481
_callWindow.RelayAudioGenerated += CallWindow_RelayAudioGenerated;
454
482
_callWindow.LeaveRequested += CallWindow_LeaveRequested;
455
483
_callWindow.Closed += CallWindow_Closed;
456
484
_callWindow.Show();
@@ -483,6 +511,44 @@ public sealed class VoiceCallService : IAsyncDisposable
483
511
}
484
512
}
485
513
514
private void CallWindow_RelayAudioGenerated(object? sender, VoiceCallRelayAudioEventArgs e)
515
{
516
if (Volatile.Read(ref _disposed) != 0 || !_relaySendGate.Wait(0))
517
return;
518
_ = SendWindowRelayAudioSafeAsync(e);
519
}
520
521
private async Task SendWindowRelayAudioSafeAsync(VoiceCallRelayAudioEventArgs frame)
522
{
523
try
524
{
525
var active = _activeCall;
526
if (active is null || !string.Equals(active.CallId, frame.CallId, StringComparison.Ordinal))
527
return;
528
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
529
await _realtimeClient.SendAsync(
530
"call.audio",
531
new
532
{
533
callId = frame.CallId,
534
sequence = frame.Sequence,
535
sampleRate = frame.SampleRate,
536
pcmBase64 = frame.PcmBase64
537
},
538
active.ConversationId,
539
timeout.Token);
540
}
541
catch (Exception exception) when (
542
exception is OperationCanceledException or ObjectDisposedException or InvalidOperationException)
543
{
544
// A late or congested 20 ms frame is intentionally dropped.
545
}
546
finally
547
{
548
_relaySendGate.Release();
549
}
550
}
551
486
552
private void CallWindow_LeaveRequested(object? sender, EventArgs e) => _ = LeaveSafeAsync();
487
553
488
554
private async Task LeaveSafeAsync()
@@ -496,6 +562,7 @@ public sealed class VoiceCallService : IAsyncDisposable
496
562
if (sender is VoiceCallWindow window)
497
563
{
498
564
window.SignalGenerated -= CallWindow_SignalGenerated;
565
window.RelayAudioGenerated -= CallWindow_RelayAudioGenerated;
499
566
window.LeaveRequested -= CallWindow_LeaveRequested;
500
567
window.Closed -= CallWindow_Closed;
501
568
}
@@ -43,6 +43,8 @@ public partial class VoiceCallWindow : Window
43
43
44
44
public event EventHandler<VoiceCallSignalEventArgs>? SignalGenerated;
45
45
46
public event EventHandler<VoiceCallRelayAudioEventArgs>? RelayAudioGenerated;
47
46
48
public event EventHandler? LeaveRequested;
47
49
48
50
public Task UpdateParticipantsAsync(IReadOnlyList<VoiceCallParticipant> participants) =>
@@ -71,6 +73,21 @@ public partial class VoiceCallWindow : Window
71
73
signal
72
74
});
73
75
76
public Task ApplyRelayAudioAsync(
77
string fromUserId,
78
long sequence,
79
int sampleRate,
80
string pcmBase64) =>
81
PostHostMessageAsync(new
82
{
83
type = "call.relay-audio",
84
callId = _callId,
85
fromUserId,
86
sequence,
87
sampleRate,
88
pcmBase64
89
});
90
74
91
public async Task SetRealtimeStateAsync(string state, string? error)
75
92
{
76
93
await Dispatcher.InvokeAsync(() =>
@@ -96,7 +113,12 @@ public partial class VoiceCallWindow : Window
96
113
{
97
114
var userDataFolder = Path.Combine(AppPath.AppLocalData, "WebView2", "VoiceCall");
98
115
Directory.CreateDirectory(userDataFolder);
99
var environment = await CoreWebView2Environment.CreateAsync(userDataFolder: userDataFolder);
116
var environmentOptions = new CoreWebView2EnvironmentOptions(
117
"--autoplay-policy=no-user-gesture-required");
118
var environment = await CoreWebView2Environment.CreateAsync(
119
browserExecutableFolder: null,
120
userDataFolder: userDataFolder,
121
options: environmentOptions);
100
122
if (Volatile.Read(ref _webViewDisposeStarted) != 0) return;
101
123
await CallWebView.EnsureCoreWebView2Async(environment);
102
124
if (Volatile.Read(ref _webViewDisposeStarted) != 0) return;
@@ -168,6 +190,9 @@ public partial class VoiceCallWindow : Window
168
190
case "signal":
169
191
HandleGeneratedSignal(root);
170
192
break;
193
case "relay-audio":
194
HandleGeneratedRelayAudio(root);
195
break;
171
196
case "leave":
172
197
NotifyLeaveRequested();
173
198
break;
@@ -240,6 +265,30 @@ public partial class VoiceCallWindow : Window
240
265
JsonSerializer.SerializeToElement(normalizedPayload, JsonOptions)));
241
266
}
242
267
268
private void HandleGeneratedRelayAudio(JsonElement root)
269
{
270
if (!TryGetString(root, "callId", out var callId) ||
271
!string.Equals(callId, _callId, StringComparison.Ordinal) ||
272
!root.TryGetProperty("sequence", out var sequenceProperty) ||
273
!sequenceProperty.TryGetInt64(out var sequence) ||
274
sequence < 0 ||
275
!root.TryGetProperty("sampleRate", out var sampleRateProperty) ||
276
!sampleRateProperty.TryGetInt32(out var sampleRate) ||
277
sampleRate != 16_000 ||
278
!TryGetString(root, "pcmBase64", out var pcmBase64) ||
279
pcmBase64.Length > 1024)
280
return;
281
282
Span<byte> decoded = stackalloc byte[640];
283
if (!Convert.TryFromBase64String(pcmBase64, decoded, out var bytesWritten) || bytesWritten != decoded.Length)
284
return;
285
RelayAudioGenerated?.Invoke(this, new VoiceCallRelayAudioEventArgs(
286
callId,
287
sequence,
288
sampleRate,
289
pcmBase64));
290
}
291
243
292
private async Task PostHostMessageAsync(object message)
244
293
{
245
294
if (Volatile.Read(ref _webViewDisposeStarted) != 0) return;
@@ -369,3 +418,18 @@ public sealed class VoiceCallSignalEventArgs(
369
418
370
419
public JsonElement Payload { get; } = payload;
371
420
}
421
422
public sealed class VoiceCallRelayAudioEventArgs(
423
string callId,
424
long sequence,
425
int sampleRate,
426
string pcmBase64) : EventArgs
427
{
428
public string CallId { get; } = callId;
429
430
public long Sequence { get; } = sequence;
431
432
public int SampleRate { get; } = sampleRate;
433
434
public string PcmBase64 { get; } = pcmBase64;
435
}
@@ -441,4 +441,4 @@
441
441
"passed": 31,
442
442
"failed": 0,
443
443
"skipped": 0
444
}
444
}