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:
42
clients/windows/VoiceCat.App/Notifications/EventFeedback.cs
Normal file
42
clients/windows/VoiceCat.App/Notifications/EventFeedback.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
47
clients/windows/VoiceCat.App/Notifications/SoundEvent.cs
Normal file
47
clients/windows/VoiceCat.App/Notifications/SoundEvent.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user