XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEToolBox

引入语音自动中继,提升通话可靠性

本次提交实现了语音通话的“自动语音中继”能力: - 服务端新增音频帧类型与校验,支持 call.audio 事件,按房间转发音频,限制帧大小与速率,独立速率控制,提升测试可见性。 - Web 前端通过 AudioWorklet 捕获 16kHz PCM 并自动切换 WebRTC/WSS,UI 实时反映链路状态,自动检测直连流量并切换,平滑播放中继音频,通话结束时释放资源。 - WPF 客户端支持收发中继音频帧,与 WebView2 通信,提升音频体验。 - 新增自动化测试,前端样式与文案突出自动中继能力。 显著增强复杂网络下的通话可用性与体验。

b5147e3
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

13 个文件 +740 -29
Modified XFEToolBox.Server.Test/Program.cs +209 -0
@@ -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 }
Modified XFEToolBox.Server/Realtime/ChatRealtimeBroker.cs +73 -5
@@ -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(); }
Modified XFEToolBox.Server/Realtime/ChatRealtimeConnection.cs +12 -0
@@ -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
Added XFEToolBox.Server/Realtime/ChatRelayAudioFrame.cs +36 -0
@@ -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 }
Modified XFEToolBox.Server/XFEToolBox.Server.csproj +4 -0
@@ -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>
Modified XFEToolBox/Resources/ChatCall/call.css +1 -0
@@ -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); }
Modified XFEToolBox/Resources/ChatCall/call.html +3 -3
@@ -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
Modified XFEToolBox/Resources/ChatCall/call.js +216 -15
@@ -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') {
Added XFEToolBox/Resources/ChatCall/relay-worklet.js +49 -0
@@ -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);
Modified XFEToolBox/Utilities/Chat/VoiceCallService.cs +70 -3
@@ -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 }
Modified XFEToolBox/Views/Windows/VoiceCallWindow.xaml.cs +65 -1
@@ -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 }
Modified XfeTestArtifacts/test-results.json +1 -1
@@ -441,4 +441,4 @@
441 441 "passed": 31,
442 442 "failed": 0,
443 443 "skipped": 0
444 }
444 }
Modified XfeTestArtifacts/test-results.xml +1 -1