From 30da1680f2cbd650c6fe96b0ceee84f7d30bcef8 Mon Sep 17 00:00:00 2001 From: Misaka Date: Wed, 17 Jun 2026 22:12:13 +0800 Subject: [PATCH] Add system mute control via setMute action Introduce a setMute protocol action and an NAudio-backed AudioControlService so the Android client can toggle the Windows render endpoint mute state over WebSocket. Co-Authored-By: Claude Opus 4.6 --- .../SyncMessageTests.cs | 43 ++++++++++++++- .../Models/SyncAction.cs | 3 +- .../Models/SyncActionJsonConverter.cs | 2 + .../Models/SyncMessage.cs | 3 ++ .../Services/AudioControlService.cs | 52 +++++++++++++++++++ .../Services/IAudioControlService.cs | 8 +++ .../Services/SyncMessageHandler.cs | 14 ++++- .../Tray/TrayApplicationContext.cs | 5 +- .../WirelessTextSyncer.Windows.csproj | 1 + 9 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 WirelessTextSyncer.Windows/Services/AudioControlService.cs create mode 100644 WirelessTextSyncer.Windows/Services/IAudioControlService.cs diff --git a/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs b/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs index 20f7407..f7f1f9f 100644 --- a/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs +++ b/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs @@ -46,7 +46,7 @@ public sealed class SyncMessageTests public void ReplaceAllAppendsSubmittedText() { var keyboard = new RecordingKeyboardInjectionService(); - var handler = new SyncMessageHandler(keyboard); + var handler = new SyncMessageHandler(keyboard, new RecordingAudioControlService()); handler.Handle(new SyncMessage { @@ -59,6 +59,33 @@ public sealed class SyncMessageTests keyboard.Calls); } + [TestMethod] + public void DeserializeAndroidSetMuteMessage() + { + var message = JsonSerializer.Deserialize( + """{"action":"setMute","muted":true}""", + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.IsNotNull(message); + Assert.AreEqual(SyncAction.SetMute, message.Action); + Assert.AreEqual(true, message.Muted); + } + + [TestMethod] + public void SetMuteForwardsToAudioService() + { + var audio = new RecordingAudioControlService(); + var handler = new SyncMessageHandler(new RecordingKeyboardInjectionService(), audio); + + handler.Handle(new SyncMessage + { + Action = SyncAction.SetMute, + Muted = true + }); + + CollectionAssert.AreEqual(new[] { "mute:True" }, audio.Calls); + } + [TestMethod] public void DiscoveryResponseContainsServiceEndpoint() { @@ -77,7 +104,7 @@ public sealed class SyncMessageTests [TestMethod] public void WebSocketServiceInfoContainsDeviceName() { - using var server = new WebSocketServerService(new SyncMessageHandler(new RecordingKeyboardInjectionService()), "Desktop-WIN11"); + using var server = new WebSocketServerService(new SyncMessageHandler(new RecordingKeyboardInjectionService(), new RecordingAudioControlService()), "Desktop-WIN11"); using var document = JsonDocument.Parse(server.BuildServiceInfoJson()); var root = document.RootElement; @@ -112,4 +139,16 @@ public sealed class SyncMessageTests Calls.Add("enter"); } } + + private sealed class RecordingAudioControlService : IAudioControlService + { + public List Calls { get; } = []; + + public bool IsMuted => Calls.Count > 0 && Calls[^1] == "mute:True"; + + public void SetMute(bool muted) + { + Calls.Add($"mute:{muted}"); + } + } } diff --git a/WirelessTextSyncer.Windows/Models/SyncAction.cs b/WirelessTextSyncer.Windows/Models/SyncAction.cs index 726b8fd..e1e9ebc 100644 --- a/WirelessTextSyncer.Windows/Models/SyncAction.cs +++ b/WirelessTextSyncer.Windows/Models/SyncAction.cs @@ -9,5 +9,6 @@ public enum SyncAction ReplaceAll, Backspace, Enter, - Ping + Ping, + SetMute } diff --git a/WirelessTextSyncer.Windows/Models/SyncActionJsonConverter.cs b/WirelessTextSyncer.Windows/Models/SyncActionJsonConverter.cs index 5131ddd..de3f3a5 100644 --- a/WirelessTextSyncer.Windows/Models/SyncActionJsonConverter.cs +++ b/WirelessTextSyncer.Windows/Models/SyncActionJsonConverter.cs @@ -19,6 +19,7 @@ public sealed class SyncActionJsonConverter : JsonConverter "backspace" => SyncAction.Backspace, "enter" => SyncAction.Enter, "ping" => SyncAction.Ping, + "setMute" => SyncAction.SetMute, _ => throw new JsonException($"Unsupported sync action: {value}") }; } @@ -35,6 +36,7 @@ public sealed class SyncActionJsonConverter : JsonConverter SyncAction.Backspace => "backspace", SyncAction.Enter => "enter", SyncAction.Ping => "ping", + SyncAction.SetMute => "setMute", _ => throw new JsonException($"Unsupported sync action: {value}") }; diff --git a/WirelessTextSyncer.Windows/Models/SyncMessage.cs b/WirelessTextSyncer.Windows/Models/SyncMessage.cs index fb3565d..fadbc5a 100644 --- a/WirelessTextSyncer.Windows/Models/SyncMessage.cs +++ b/WirelessTextSyncer.Windows/Models/SyncMessage.cs @@ -9,4 +9,7 @@ public sealed record SyncMessage [JsonPropertyName("text")] public string? Text { get; init; } + + [JsonPropertyName("muted")] + public bool? Muted { get; init; } } diff --git a/WirelessTextSyncer.Windows/Services/AudioControlService.cs b/WirelessTextSyncer.Windows/Services/AudioControlService.cs new file mode 100644 index 0000000..5dbd2f7 --- /dev/null +++ b/WirelessTextSyncer.Windows/Services/AudioControlService.cs @@ -0,0 +1,52 @@ +using System.Runtime.InteropServices; +using NAudio.CoreAudioApi; + +namespace WirelessTextSyncer.Windows.Services; + +public sealed class AudioControlService : IAudioControlService, IDisposable +{ + private readonly MMDeviceEnumerator enumerator = new(); + private bool disposed; + + public bool IsMuted + { + get + { + try + { + using var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + return device.AudioEndpointVolume.Mute; + } + catch (COMException exception) + { + AppLogger.Error("Failed to read audio mute state.", exception); + return false; + } + } + } + + public void SetMute(bool muted) + { + try + { + using var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + device.AudioEndpointVolume.Mute = muted; + AppLogger.Info($"Audio mute set to {muted}."); + } + catch (COMException exception) + { + AppLogger.Error($"Failed to set audio mute to {muted}.", exception); + } + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + enumerator.Dispose(); + } +} diff --git a/WirelessTextSyncer.Windows/Services/IAudioControlService.cs b/WirelessTextSyncer.Windows/Services/IAudioControlService.cs new file mode 100644 index 0000000..3cb1d4c --- /dev/null +++ b/WirelessTextSyncer.Windows/Services/IAudioControlService.cs @@ -0,0 +1,8 @@ +namespace WirelessTextSyncer.Windows.Services; + +public interface IAudioControlService +{ + bool IsMuted { get; } + + void SetMute(bool muted); +} diff --git a/WirelessTextSyncer.Windows/Services/SyncMessageHandler.cs b/WirelessTextSyncer.Windows/Services/SyncMessageHandler.cs index ea15181..8e28a5b 100644 --- a/WirelessTextSyncer.Windows/Services/SyncMessageHandler.cs +++ b/WirelessTextSyncer.Windows/Services/SyncMessageHandler.cs @@ -5,11 +5,13 @@ namespace WirelessTextSyncer.Windows.Services; public sealed class SyncMessageHandler { private readonly IKeyboardInjectionService keyboard; + private readonly IAudioControlService audio; private string remoteText = string.Empty; - public SyncMessageHandler(IKeyboardInjectionService keyboard) + public SyncMessageHandler(IKeyboardInjectionService keyboard, IAudioControlService audio) { this.keyboard = keyboard; + this.audio = audio; } public void Handle(SyncMessage message) @@ -38,11 +40,21 @@ public sealed class SyncMessageHandler break; case SyncAction.Ping: break; + case SyncAction.SetMute: + HandleSetMute(message); + break; default: throw new InvalidOperationException($"Unsupported sync action: {message.Action}"); } } + private void HandleSetMute(SyncMessage message) + { + var muted = message.Muted ?? false; + AppLogger.Info($"SetMute received muted={muted}."); + audio.SetMute(muted); + } + private void AppendSubmittedText(string text) { if (string.IsNullOrEmpty(text)) diff --git a/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs b/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs index 003ebf1..d6d59fd 100644 --- a/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs +++ b/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs @@ -5,6 +5,7 @@ namespace WirelessTextSyncer.Windows.Tray; public sealed class TrayApplicationContext : ApplicationContext { private readonly KeyboardInjectionService keyboard; + private readonly AudioControlService audio; private readonly WebSocketServerService server; private readonly DiscoveryResponderService discovery; private readonly NotifyIcon notifyIcon; @@ -21,7 +22,8 @@ public sealed class TrayApplicationContext : ApplicationContext AppLogger.Info("Tray application starting."); uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); keyboard = new KeyboardInjectionService(); - var handler = new SyncMessageHandler(keyboard); + audio = new AudioControlService(); + var handler = new SyncMessageHandler(keyboard, audio); server = new WebSocketServerService(handler); server.StatusChanged += (_, _) => uiContext.Post(_ => UpdateTrayStatus(), null); @@ -62,6 +64,7 @@ public sealed class TrayApplicationContext : ApplicationContext connectedIcon.Dispose(); waitIcon.Dispose(); discovery.Dispose(); + audio.Dispose(); server.Dispose(); } diff --git a/WirelessTextSyncer.Windows/WirelessTextSyncer.Windows.csproj b/WirelessTextSyncer.Windows/WirelessTextSyncer.Windows.csproj index 04c517d..cbccd3e 100644 --- a/WirelessTextSyncer.Windows/WirelessTextSyncer.Windows.csproj +++ b/WirelessTextSyncer.Windows/WirelessTextSyncer.Windows.csproj @@ -12,6 +12,7 @@ +