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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user