feat(clients): event sound effects + optional text-to-speech

Add audible cues and optional spoken announcements for session events
(join/leave, channel + PM sent/recv, login, logout/connection-lost,
mic on/off, voice-activity, PTT) across all three clients, driven off
the shared C ABI vc_event stream so the mapping stays consistent.

TTS is off by default; when enabled it announces events and reads
message/PM bodies aloud. Master toggles + a sound-volume slider; the
per-utterance voice-activity and PTT cues default off. WAVs ship from
assets/sounds/.

Windows (built + verified): new VoiceCat.App/Notifications/ layer
(FeedbackSettings -> %AppData%\VoiceCat\feedback.json, SoundPlayerPool
via System.Media.SoundPlayer, SpeechAnnouncer via Prismatoid 0.3.0,
EventFeedback dispatcher); MainForm hooks; NotificationSettingsForm
under Settings > Notifications; csproj adds the Prismatoid PackageRef
and copies the WAVs into sounds\.

macOS + iOS (written, not yet built -- needs a Mac): shared
VoiceCatCore/Feedback/ (SoundEvent, EventFeedback = AVAudioPlayer pool
+ native AVSpeechSynthesizer, FeedbackSettings over UserDefaults); WAVs
bundled via Package.swift resources (.process). Hooks in SessionState/
AppState (iOS) and MainWindowController (macOS); settings UI in
SettingsView (iOS) and SettingsWindowController (macOS).

No core/server code touched; ctest --preset dev unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:20:11 +02:00
parent 725bd8e925
commit 50416c33a2
45 changed files with 812 additions and 19 deletions

View File

@@ -1,4 +1,5 @@
using VoiceCat.App.Audio;
using VoiceCat.App.Notifications;
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
@@ -12,6 +13,7 @@ public partial class MainForm : Form
private readonly uint _selfUserId;
private readonly string _nickname;
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
private readonly EventFeedback _feedback = new(FeedbackSettings.Load());
// Channel / user state
private uint _currentChannelId;
@@ -25,6 +27,7 @@ public partial class MainForm : Form
private uint _screenStreamId; // 0 = not sharing screen audio
private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode
private Keys _pttKey = Keys.F8;
private bool _pttEngaged; // guards the PTT cue against key-repeat
private bool _serverMuted;
private bool _serverDeafened;
@@ -118,6 +121,8 @@ public partial class MainForm : Form
RefreshUserList();
UpdateStatusLabel();
AddActivity($"Connected to server as {_nickname}");
_feedback.PlaySound(SoundEvent.Login);
_feedback.Speak("Connected");
}
protected override void OnLoad(EventArgs e)
@@ -161,6 +166,17 @@ public partial class MainForm : Form
messagesMenu.DropDownItems.Add(miNewPm);
menuStrip.Items.Add(messagesMenu);
// Settings menu — always visible
var settingsMenu = new ToolStripMenuItem("&Settings");
var miNotifications = new ToolStripMenuItem("&Notifications...");
miNotifications.Click += (_, _) =>
{
using var dlg = new NotificationSettingsForm(_feedback);
dlg.ShowDialog(this);
};
settingsMenu.DropDownItems.Add(miNotifications);
menuStrip.Items.Add(settingsMenu);
// Admin menu — only if permitted
if (_ownPermissions.CanAdminAccounts)
{
@@ -339,7 +355,11 @@ public partial class MainForm : Form
RefreshChannelTree();
RefreshUserList();
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
{
AddActivity($"{user.Nickname} joined the channel");
_feedback.PlaySound(SoundEvent.ChannelJoin);
_feedback.Speak($"{user.Nickname} joined");
}
}
private void HandleUserLeft(VoiceCatEvent ev)
@@ -350,7 +370,12 @@ public partial class MainForm : Form
_talkingUsers.Remove(ev.UserId);
RefreshChannelTree();
RefreshUserList();
if (wasHere) AddActivity($"{user.Nickname} left the channel");
if (wasHere)
{
AddActivity($"{user.Nickname} left the channel");
_feedback.PlaySound(SoundEvent.ChannelLeave);
_feedback.Speak($"{user.Nickname} left");
}
if (_pmWindows.TryGetValue(ev.UserId, out var pmWin))
pmWin.AppendActivity($"{user.Nickname} disconnected from server");
}
@@ -406,18 +431,23 @@ public partial class MainForm : Form
.LocalDateTime.ToString("HH:mm")
: DateTime.Now.ToString("HH:mm");
string sender = GetNickname(ev.UserId);
bool isSelf = ev.UserId == _selfUserId;
string body = ev.Text ?? "";
if (ev.TextScope == VcTextScope.Private)
{
// For our own outgoing PM, ev.ChannelId carries the recipient user ID.
uint otherUserId = ev.UserId == _selfUserId ? ev.ChannelId : ev.UserId;
uint otherUserId = isSelf ? ev.ChannelId : ev.UserId;
var win = GetOrOpenPmWindow(otherUserId);
bool isSelf = ev.UserId == _selfUserId;
win.AppendMessage(time, isSelf, sender, ev.Text ?? "");
win.AppendMessage(time, isSelf, sender, body);
_feedback.PlaySound(isSelf ? SoundEvent.PmSent : SoundEvent.PmRecv);
if (!isSelf) _feedback.Speak($"Private message from {sender}: {body}");
}
else
{
AppendChat(time, sender, ev.Text ?? "");
AppendChat(time, sender, body);
_feedback.PlaySound(isSelf ? SoundEvent.ChannelSent : SoundEvent.ChannelRecv);
if (!isSelf) _feedback.Speak($"{sender}: {body}");
}
}
@@ -427,6 +457,8 @@ public partial class MainForm : Form
if (talking) _talkingUsers.Add(ev.UserId);
else _talkingUsers.Remove(ev.UserId);
RefreshUserList();
if (ev.UserId == _selfUserId)
_feedback.PlaySound(talking ? SoundEvent.VaStart : SoundEvent.VaStop);
if (talking && ev.UserId != _selfUserId &&
_users.TryGetValue(ev.UserId, out var tUser) &&
tUser.ChannelId == _currentChannelId)
@@ -455,6 +487,16 @@ public partial class MainForm : Form
: $"Disconnected: {ev.Text}";
lblStatus.Text = msg;
AddActivity(msg);
if (ev.Result == VcResult.Ok)
{
_feedback.PlaySound(SoundEvent.Logout);
_feedback.Speak("Disconnected");
}
else
{
_feedback.PlaySound(SoundEvent.ConnectionLost);
_feedback.Speak("Connection lost");
}
tvChannels.Nodes.Clear();
lstUsers.Items.Clear();
_users.Clear();
@@ -621,6 +663,7 @@ public partial class MainForm : Form
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
SetVoiceJoinedState(true);
AddActivity("Joined voice — microphone active");
_feedback.PlaySound(SoundEvent.VoiceOn);
}
else
{
@@ -635,6 +678,7 @@ public partial class MainForm : Form
pbLevel.Value = 0;
SetVoiceJoinedState(false);
AddActivity("Left voice");
_feedback.PlaySound(SoundEvent.VoiceOff);
}
}
@@ -806,6 +850,11 @@ public partial class MainForm : Form
if (ActiveControl is TextBox or RichTextBox) return;
_client.SetPushToTalk(true);
lblPttKey.Text = $"({_pttKey} ▶)";
if (!_pttEngaged) // first key-down only, not auto-repeat
{
_pttEngaged = true;
_feedback.PlaySound(SoundEvent.Ptt);
}
e.Handled = true;
}
@@ -840,6 +889,7 @@ public partial class MainForm : Form
{
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
_client.SetPushToTalk(false);
_pttEngaged = false;
lblPttKey.Text = $"({_pttKey})";
e.Handled = true;
}
@@ -1103,6 +1153,7 @@ public partial class MainForm : Form
if (_micStreamId != 0) _client.StopStream(_micStreamId);
_client.Disconnect();
_client.Dispose();
_feedback.Dispose();
base.OnFormClosed(e);
}

View File

@@ -0,0 +1,128 @@
using VoiceCat.App.Notifications;
namespace VoiceCat.App.Forms;
/// <summary>
/// Edits and persists <see cref="FeedbackSettings"/> (event sounds + spoken announcements).
/// On OK the supplied <see cref="EventFeedback"/> is updated live and the settings saved.
/// </summary>
public sealed class NotificationSettingsForm : Form
{
private readonly EventFeedback _feedback;
private readonly CheckBox _chkSounds;
private readonly CheckBox _chkSpeech;
private readonly TrackBar _trkVolume;
private readonly CheckBox _chkSelfTalk;
private readonly CheckBox _chkPtt;
public NotificationSettingsForm(EventFeedback feedback)
{
_feedback = feedback;
var s = feedback.Settings;
Text = "Notification settings";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(360, 300);
_chkSounds = new CheckBox
{
Text = "Play event &sounds",
Location = new Point(12, 12),
AutoSize = true,
Checked = s.Sounds,
};
var lblVolume = new Label
{
Text = "Sound &volume:",
Location = new Point(12, 42),
AutoSize = true,
};
_trkVolume = new TrackBar
{
Location = new Point(12, 64),
Size = new Size(330, 45),
Minimum = 0,
Maximum = 100,
TickFrequency = 10,
Value = (int)Math.Round(Math.Clamp(s.Volume, 0f, 1f) * 100),
};
_trkVolume.AccessibleName = "Sound volume";
_chkSpeech = new CheckBox
{
Text = "&Speak events (text-to-speech)",
Location = new Point(12, 118),
AutoSize = true,
Checked = s.Speech,
};
var lblSpeechHint = new Label
{
Text = feedback.SpeechAvailable
? "Announces joins/leaves and reads message text aloud."
: "No speech engine available on this machine.",
Location = new Point(30, 142),
AutoSize = true,
ForeColor = SystemColors.GrayText,
};
var lblOptional = new Label
{
Text = "Optional sounds:",
Location = new Point(12, 174),
AutoSize = true,
};
_chkSelfTalk = new CheckBox
{
Text = "Your own voice-&activity start/stop",
Location = new Point(12, 198),
AutoSize = true,
Checked = s.SelfTalkSounds,
};
_chkPtt = new CheckBox
{
Text = "&Push-to-talk cue",
Location = new Point(12, 224),
AutoSize = true,
Checked = s.PttSound,
};
var btnOk = new Button
{
Text = "&OK",
DialogResult = DialogResult.OK,
Location = new Point(192, 262),
Size = new Size(75, 27),
};
var btnCancel = new Button
{
Text = "&Cancel",
DialogResult = DialogResult.Cancel,
Location = new Point(273, 262),
Size = new Size(75, 27),
};
AcceptButton = btnOk;
CancelButton = btnCancel;
Controls.AddRange([_chkSounds, lblVolume, _trkVolume, _chkSpeech, lblSpeechHint,
lblOptional, _chkSelfTalk, _chkPtt, btnOk, btnCancel]);
btnOk.Click += (_, _) => Apply();
}
private void Apply()
{
var s = _feedback.Settings;
s.Sounds = _chkSounds.Checked;
s.Speech = _chkSpeech.Checked;
s.Volume = _trkVolume.Value / 100f;
s.SelfTalkSounds = _chkSelfTalk.Checked;
s.PttSound = _chkPtt.Checked;
s.Save();
}
}

View File

@@ -0,0 +1,42 @@
namespace VoiceCat.App.Notifications;
/// <summary>
/// Combines the sound pool and the speech announcer behind the user's <see cref="FeedbackSettings"/>.
/// MainForm calls <see cref="PlaySound"/> / <see cref="Speak"/> from inside its existing event
/// handlers (which already resolve nicknames and channel membership), so this type only owns the
/// "should I, and how" policy — not the event routing.
/// </summary>
public sealed class EventFeedback : IDisposable
{
private readonly SoundPlayerPool _sounds = new();
private readonly SpeechAnnouncer _speech = new();
public FeedbackSettings Settings { get; set; }
public EventFeedback(FeedbackSettings settings) => Settings = settings;
/// <summary>True when speech is both enabled and actually available on this machine.</summary>
public bool SpeechAvailable => _speech.Available;
public void PlaySound(SoundEvent ev)
{
if (!Settings.Sounds || Settings.Volume <= 0f) return;
// The two opt-in categories are gated by their own flags.
if ((ev is SoundEvent.VaStart or SoundEvent.VaStop) && !Settings.SelfTalkSounds) return;
if (ev is SoundEvent.Ptt && !Settings.PttSound) return;
_sounds.Play(ev);
}
/// <summary>Announce <paramref name="text"/> when spoken feedback is enabled.</summary>
public void Speak(string text)
{
if (!Settings.Speech) return;
_speech.Speak(text);
}
public void Dispose()
{
_sounds.Dispose();
_speech.Dispose();
}
}

View File

@@ -0,0 +1,55 @@
using System.Text.Json;
namespace VoiceCat.App.Notifications;
/// <summary>
/// User preferences for event sounds and spoken (text-to-speech) feedback. Persisted to
/// %AppData%\VoiceCat\feedback.json — same pattern as <see cref="Models.ServerListStore"/>:
/// a missing or corrupt file yields defaults rather than throwing.
/// </summary>
public sealed class FeedbackSettings
{
/// <summary>Master switch for event sound effects.</summary>
public bool Sounds { get; set; } = true;
/// <summary>Master switch for spoken announcements (Prismatoid TTS). Off by default.</summary>
public bool Speech { get; set; } = false;
/// <summary>Sound-effects volume, 0..1. 0 mutes effects (SoundPlayer has no gain control,
/// so this is honoured as a mute gate — see <see cref="SoundPlayerPool"/>).</summary>
public float Volume { get; set; } = 1.0f;
/// <summary>Play va_start/va_stop for your own voice-activity transitions. Off by default
/// (fires on every utterance — noisy).</summary>
public bool SelfTalkSounds { get; set; } = false;
/// <summary>Play the ptt cue when push-to-talk is engaged. Off by default.</summary>
public bool PttSound { get; set; } = false;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static string AppDataDir => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
private static string FilePath => Path.Combine(AppDataDir, "feedback.json");
public static FeedbackSettings Load()
{
try
{
if (!File.Exists(FilePath)) return new FeedbackSettings();
string json = File.ReadAllText(FilePath);
return JsonSerializer.Deserialize<FeedbackSettings>(json) ?? new FeedbackSettings();
}
catch
{
return new FeedbackSettings();
}
}
public void Save()
{
Directory.CreateDirectory(AppDataDir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
}
}

View File

@@ -0,0 +1,47 @@
namespace VoiceCat.App.Notifications;
/// <summary>
/// The set of audible event cues. Each maps to a WAV in the app's <c>sounds\</c> folder
/// (copied from <c>assets/sounds/</c> at build time). The same logical set is mirrored in the
/// macOS/iOS clients so feedback stays consistent across platforms.
/// </summary>
public enum SoundEvent
{
ChannelJoin, // another user joined my channel
ChannelLeave, // another user left my channel
ChannelRecv, // channel text message from someone else
ChannelSent, // channel text message I sent
PmRecv, // private message received
PmSent, // private message I sent
Login, // connected / authenticated
Logout, // clean disconnect
ConnectionLost, // unexpected disconnect
VoiceOn, // my microphone stream started
VoiceOff, // my microphone stream stopped
VaStart, // my voice-activity began (off by default)
VaStop, // my voice-activity ended (off by default)
Ptt, // push-to-talk engaged (off by default)
}
internal static class SoundEventExtensions
{
/// <summary>WAV file name (without directory) for each event, matching assets/sounds/.</summary>
public static string FileName(this SoundEvent ev) => ev switch
{
SoundEvent.ChannelJoin => "channel_join.wav",
SoundEvent.ChannelLeave => "channel_leave.wav",
SoundEvent.ChannelRecv => "channel_recv.wav",
SoundEvent.ChannelSent => "channel_sent.wav",
SoundEvent.PmRecv => "pm_recv.wav",
SoundEvent.PmSent => "pm_sent.wav",
SoundEvent.Login => "login.wav",
SoundEvent.Logout => "logout.wav",
SoundEvent.ConnectionLost => "connection_lost.wav",
SoundEvent.VoiceOn => "voice_on.wav",
SoundEvent.VoiceOff => "voice_off.wav",
SoundEvent.VaStart => "va_start.wav",
SoundEvent.VaStop => "va_stop.wav",
SoundEvent.Ptt => "ptt.wav",
_ => throw new ArgumentOutOfRangeException(nameof(ev), ev, null),
};
}

View File

@@ -0,0 +1,58 @@
using System.Media;
namespace VoiceCat.App.Notifications;
/// <summary>
/// Plays short event cues from the app's <c>sounds\</c> folder. Each WAV is loaded once into a
/// cached <see cref="SoundPlayer"/> (built into the Windows Desktop framework — no extra
/// dependency). <see cref="Play"/> is non-blocking (<see cref="SoundPlayer.Play"/> renders on a
/// background thread).
///
/// Note: <see cref="SoundPlayer"/> exposes no gain control, so volume is honoured only as a mute
/// gate (volume == 0 → silent). If finer control or reliable overlapping playback is needed
/// later, swap this for NAudio.
/// </summary>
public sealed class SoundPlayerPool : IDisposable
{
private static readonly string SoundsDir =
Path.Combine(AppContext.BaseDirectory, "sounds");
private readonly Dictionary<SoundEvent, SoundPlayer?> _players = [];
/// <summary>Resolve, load and cache the player for an event. Returns null if the file is
/// missing or fails to load — playback then silently no-ops.</summary>
private SoundPlayer? Get(SoundEvent ev)
{
if (_players.TryGetValue(ev, out var cached)) return cached;
SoundPlayer? player = null;
try
{
string path = Path.Combine(SoundsDir, ev.FileName());
if (File.Exists(path))
{
player = new SoundPlayer(path);
player.Load();
}
}
catch
{
player = null; // unreadable/invalid WAV — degrade to silence
}
_players[ev] = player;
return player;
}
public void Play(SoundEvent ev)
{
try { Get(ev)?.Play(); }
catch { /* never let a notification sound surface as an error */ }
}
public void Dispose()
{
foreach (var p in _players.Values) p?.Dispose();
_players.Clear();
}
}

View File

@@ -0,0 +1,56 @@
using Prismatoid;
namespace VoiceCat.App.Notifications;
/// <summary>
/// Spoken event announcements via Prismatoid (bindings for the Prism speech library —
/// integrates with the active screen reader / system speech, no extra runtime deps).
///
/// All construction and calls are wrapped so a machine with no available speech backend simply
/// degrades to silence rather than crashing the app.
/// </summary>
public sealed class SpeechAnnouncer : IDisposable
{
private readonly PrismContext? _context;
private readonly object? _backend; // SpeechBackend; held as object to keep this resilient
public SpeechAnnouncer()
{
try
{
_context = new PrismContext();
_backend = _context.AcquireBestBackend();
}
catch
{
_context?.Dispose();
_context = null;
_backend = null;
}
}
/// <summary>True when a speech backend is available on this machine.</summary>
public bool Available => _backend is not null;
/// <summary>Speak <paramref name="text"/>. Queued (does not interrupt prior speech) so a
/// burst of events is read in order.</summary>
public void Speak(string text)
{
if (string.IsNullOrWhiteSpace(text)) return;
try
{
if (_backend is { } b)
((dynamic)b).Speak(text, interrupt: false);
}
catch
{
/* never surface a speech failure */
}
}
public void Dispose()
{
try { (_backend as IDisposable)?.Dispose(); } catch { /* ignore */ }
_context?.Dispose();
}
}

View File

@@ -4,6 +4,21 @@
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" />
</ItemGroup>
<!-- Prismatoid — .NET bindings for the Prism speech library, used for spoken event
announcements (text-to-speech). net10.0, no transitive dependencies. -->
<ItemGroup>
<PackageReference Include="Prismatoid" Version="0.3.0" />
</ItemGroup>
<!-- Event-cue WAVs (shared with the macOS/iOS clients) copied into sounds\ next to the exe;
SoundPlayerPool loads them from AppContext.BaseDirectory\sounds. -->
<ItemGroup>
<Content Include="..\..\..\assets\sounds\*.wav">
<Link>sounds\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>