diff --git a/PROGRESS.md b/PROGRESS.md
index ab74cc9..ae91aca 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -10,6 +10,36 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
+- **Windows done / Apple awaiting Mac build (2026-06-22):** **Event sound effects + optional
+ text-to-speech for all clients.** Clients now play a cue per session event and can optionally
+ speak it (TTS off by default; when on it announces joins/leaves and reads message/PM bodies).
+ One canonical event→sound mapping (defined off the shared C ABI `vc_event` stream) is mirrored
+ across all three clients; `self` vs others is `user_id == self_user_id`, and outgoing messages
+ echo back as events so sent/recv cues need no separate send-path hook. Conservative defaults
+ (join/leave, channel/PM sent+recv, login, logout, connection-lost, mic on/off ON; per-utterance
+ self voice-activity `va_start/va_stop` and the PTT cue OFF). WAVs ship from `assets/sounds/`.
+ - **Windows (built + verified):** new `VoiceCat.App/Notifications/` (`FeedbackSettings` →
+ `%AppData%\VoiceCat\feedback.json`, `SoundPlayerPool` via `System.Media.SoundPlayer`,
+ `SpeechAnnouncer` via the **Prismatoid** NuGet 0.3.0, `EventFeedback` dispatcher); hooks in
+ `Forms/MainForm.cs`; `Forms/NotificationSettingsForm.cs` under a new **Settings ▸ Notifications**
+ menu. `.csproj` adds the Prismatoid PackageRef and copies the WAVs into `sounds\`. `dotnet build`
+ clean; WAVs + `Prismatoid.dll` confirmed in output. Note: `SoundPlayer` has no gain control, so
+ volume is honoured as a mute gate (0 = silent) — swap to NAudio if finer/overlap control is needed.
+ - **macOS + iOS (written, NOT yet built — needs a Mac):** shared `Sources/VoiceCatCore/Feedback/`
+ (`SoundEvent`, `EventFeedback` = `AVAudioPlayer` pool + native `AVSpeechSynthesizer`,
+ `FeedbackSettings` over `UserDefaults`); WAVs copied into `Sources/VoiceCatCore/Sounds/` and
+ bundled via `Package.swift` `resources: [.process("Sounds")]` (`Bundle.module`). Hooks: iOS
+ `SessionState.handleEvent` (+ split `userJoined`/`userLeft`, added a `.disconnected` cue case),
+ `AppState` auth-success login cue, PTT cue in `setPushToTalk`; macOS `MainWindowController`
+ handlers + NSEvent PTT monitor. Settings UI: iOS `SettingsView` Notifications section
+ (`@AppStorage`), macOS `SettingsWindowController` checkboxes + volume slider. No `.pbxproj`
+ edits needed (shared files are SPM-managed; app files already in the projects).
+ - **Next:** on a Mac, `clients/apple/scripts/build-xcframework.sh --all` then build
+ VoiceCatMac/VoiceCatiOS; fix compile fallout. **Watch the iOS audio session:** cues/TTS play over
+ the live VPIO `playAndRecord` session — verify they mix and don't duck/interrupt the call or get
+ silenced by the mute switch (most likely bug site). Then run `ctest --preset dev` (unchanged —
+ no core/server code touched).
+
- **Done (2026-06-22):** **UDP media now shares the TCP port (self-host port-forward fix).** Symptom: a
remote self-hosted server (`iamtalon.me:8384`, TCP+UDP 8384 forwarded) accepted TCP connections but
passed no voice. Root cause: `Config::media_port` defaulted to `0` = OS-assigned, and `main.cpp`'s
diff --git a/assets/sounds/channel_join.wav b/assets/sounds/channel_join.wav
new file mode 100644
index 0000000..01e4a82
Binary files /dev/null and b/assets/sounds/channel_join.wav differ
diff --git a/assets/sounds/channel_leave.wav b/assets/sounds/channel_leave.wav
new file mode 100644
index 0000000..18f921f
Binary files /dev/null and b/assets/sounds/channel_leave.wav differ
diff --git a/assets/sounds/channel_recv.wav b/assets/sounds/channel_recv.wav
new file mode 100644
index 0000000..be9eb28
Binary files /dev/null and b/assets/sounds/channel_recv.wav differ
diff --git a/assets/sounds/channel_sent.wav b/assets/sounds/channel_sent.wav
new file mode 100644
index 0000000..4c23cc7
Binary files /dev/null and b/assets/sounds/channel_sent.wav differ
diff --git a/assets/sounds/connection_lost.wav b/assets/sounds/connection_lost.wav
new file mode 100644
index 0000000..1748fc4
Binary files /dev/null and b/assets/sounds/connection_lost.wav differ
diff --git a/assets/sounds/login.wav b/assets/sounds/login.wav
new file mode 100644
index 0000000..f2f45d9
Binary files /dev/null and b/assets/sounds/login.wav differ
diff --git a/assets/sounds/logout.wav b/assets/sounds/logout.wav
new file mode 100644
index 0000000..4e49272
Binary files /dev/null and b/assets/sounds/logout.wav differ
diff --git a/assets/sounds/pm_recv.wav b/assets/sounds/pm_recv.wav
new file mode 100644
index 0000000..7a52d19
Binary files /dev/null and b/assets/sounds/pm_recv.wav differ
diff --git a/assets/sounds/pm_sent.wav b/assets/sounds/pm_sent.wav
new file mode 100644
index 0000000..615d884
Binary files /dev/null and b/assets/sounds/pm_sent.wav differ
diff --git a/assets/sounds/ptt.wav b/assets/sounds/ptt.wav
new file mode 100644
index 0000000..1cf4d0d
Binary files /dev/null and b/assets/sounds/ptt.wav differ
diff --git a/assets/sounds/va_start.wav b/assets/sounds/va_start.wav
new file mode 100644
index 0000000..b64872a
Binary files /dev/null and b/assets/sounds/va_start.wav differ
diff --git a/assets/sounds/va_stop.wav b/assets/sounds/va_stop.wav
new file mode 100644
index 0000000..76481cd
Binary files /dev/null and b/assets/sounds/va_stop.wav differ
diff --git a/assets/sounds/voice_off.wav b/assets/sounds/voice_off.wav
new file mode 100644
index 0000000..a2063bd
Binary files /dev/null and b/assets/sounds/voice_off.wav differ
diff --git a/assets/sounds/voice_on.wav b/assets/sounds/voice_on.wav
new file mode 100644
index 0000000..87fb04d
Binary files /dev/null and b/assets/sounds/voice_on.wav differ
diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift
index b27ff4a..abf105f 100644
--- a/clients/apple/Package.swift
+++ b/clients/apple/Package.swift
@@ -40,7 +40,11 @@ let package = Package(
.target(
name: "VoiceCatCore",
dependencies: ["VoiceCatCoreXCF"],
- path: "Sources/VoiceCatCore"
+ path: "Sources/VoiceCatCore",
+ // Event-cue WAVs (shared with the Windows client) bundled into the package's
+ // resource bundle; EventFeedback loads them via Bundle.module. Copied from
+ // assets/sounds/ into Sources/VoiceCatCore/Sounds/.
+ resources: [.process("Sounds")]
),
// Smoke tests against a real voicecat-server — mirrors clients/windows/
// VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset
diff --git a/clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift b/clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift
new file mode 100644
index 0000000..dc25dba
--- /dev/null
+++ b/clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift
@@ -0,0 +1,105 @@
+// EventFeedback — shared audible + spoken feedback for session events, used by both the macOS
+// (AppKit) and iOS (SwiftUI) clients. Mirrors the Windows client's EventFeedback policy
+// (clients/windows/.../Notifications/EventFeedback.cs): the platform event handlers decide WHAT
+// to play (they own the model/nickname/channel context); this type owns the "should I, and how"
+// policy plus the AVFoundation playback/synthesis.
+//
+// Sound effects use AVAudioPlayer; spoken announcements use the OS-native AVSpeechSynthesizer.
+//
+// NOTE (iOS): on the voice path the app runs a play-and-record AVAudioSession (VPIO). Playing
+// these cues / speech over that session can interact with the live call (ducking, route, or the
+// mute switch). The session category should allow mixing — verify on device. This is the most
+// likely place for platform bugs to surface.
+
+import Foundation
+import AVFoundation
+
+/// User preferences for event sounds and spoken feedback, backed by UserDefaults so the macOS
+/// and iOS settings screens and this player share one source of truth.
+public struct FeedbackSettings: Sendable {
+ public var sounds: Bool
+ public var speech: Bool
+ public var volume: Float
+ public var selfTalkSounds: Bool
+ public var pttSound: Bool
+
+ static let keySounds = "feedback.sounds"
+ static let keySpeech = "feedback.speech"
+ static let keyVolume = "feedback.volume"
+ static let keySelfTalk = "feedback.selfTalk"
+ static let keyPtt = "feedback.ptt"
+
+ /// Default values registered with UserDefaults (so "unset" reads as the intended default
+ /// rather than false/0).
+ static let defaults: [String: Any] = [
+ keySounds: true,
+ keySpeech: false,
+ keyVolume: 1.0,
+ keySelfTalk: false,
+ keyPtt: false,
+ ]
+
+ /// The current settings, read live from UserDefaults.
+ public static var current: FeedbackSettings {
+ let d = UserDefaults.standard
+ d.register(defaults: defaults) // idempotent — ensures "unset" reads as the intended default
+ return FeedbackSettings(
+ sounds: d.bool(forKey: keySounds),
+ speech: d.bool(forKey: keySpeech),
+ volume: d.float(forKey: keyVolume),
+ selfTalkSounds: d.bool(forKey: keySelfTalk),
+ pttSound: d.bool(forKey: keyPtt))
+ }
+}
+
+@MainActor
+public final class EventFeedback {
+ public static let shared = EventFeedback()
+
+ private var players: [SoundEvent: AVAudioPlayer] = [:]
+ private let synthesizer = AVSpeechSynthesizer()
+
+ private init() {
+ UserDefaults.standard.register(defaults: FeedbackSettings.defaults)
+ }
+
+ // MARK: - Sounds
+
+ /// Play an event cue, honouring the user's settings. The two opt-in categories (your own
+ /// voice-activity, and the PTT cue) are gated by their own flags.
+ public func play(_ event: SoundEvent) {
+ let s = FeedbackSettings.current
+ guard s.sounds, s.volume > 0 else { return }
+ if (event == .vaStart || event == .vaStop), !s.selfTalkSounds { return }
+ if event == .ptt, !s.pttSound { return }
+
+ guard let player = player(for: event) else { return }
+ player.volume = s.volume
+ player.currentTime = 0
+ player.play()
+ }
+
+ /// Lazily load and cache an AVAudioPlayer for the event's bundled WAV. Returns nil (silent)
+ /// if the resource is missing or fails to load.
+ private func player(for event: SoundEvent) -> AVAudioPlayer? {
+ if let cached = players[event] { return cached }
+ guard let url = Bundle.module.url(forResource: event.resourceName, withExtension: "wav"),
+ let player = try? AVAudioPlayer(contentsOf: url) else {
+ return nil
+ }
+ player.prepareToPlay()
+ players[event] = player
+ return player
+ }
+
+ // MARK: - Speech
+
+ /// Speak `text` when spoken feedback is enabled. Utterances queue (do not interrupt prior
+ /// speech) so a burst of events is read in order.
+ public func speak(_ text: String) {
+ guard FeedbackSettings.current.speech else { return }
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ synthesizer.speak(AVSpeechUtterance(string: trimmed))
+ }
+}
diff --git a/clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift b/clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift
new file mode 100644
index 0000000..2b9cb32
--- /dev/null
+++ b/clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift
@@ -0,0 +1,43 @@
+// SoundEvent — the cross-platform set of audible event cues. Each maps to a WAV bundled as an
+// SPM resource (Sources/VoiceCatCore/Sounds/, copied from assets/sounds/). The same logical set
+// is mirrored in the Windows client (clients/windows/.../Notifications/SoundEvent.cs) so feedback
+// stays consistent across platforms.
+
+import Foundation
+
+public enum SoundEvent: CaseIterable, Sendable {
+ case channelJoin // another user joined my channel
+ case channelLeave // another user left my channel
+ case channelRecv // channel text message from someone else
+ case channelSent // channel text message I sent
+ case pmRecv // private message received
+ case pmSent // private message I sent
+ case login // connected / authenticated
+ case logout // clean disconnect
+ case connectionLost // unexpected disconnect
+ case voiceOn // my microphone stream started
+ case voiceOff // my microphone stream stopped
+ case vaStart // my voice-activity began (off by default)
+ case vaStop // my voice-activity ended (off by default)
+ case ptt // push-to-talk engaged (off by default)
+
+ /// Resource name (without extension) as bundled in Sources/VoiceCatCore/Sounds/.
+ var resourceName: String {
+ switch self {
+ case .channelJoin: return "channel_join"
+ case .channelLeave: return "channel_leave"
+ case .channelRecv: return "channel_recv"
+ case .channelSent: return "channel_sent"
+ case .pmRecv: return "pm_recv"
+ case .pmSent: return "pm_sent"
+ case .login: return "login"
+ case .logout: return "logout"
+ case .connectionLost: return "connection_lost"
+ case .voiceOn: return "voice_on"
+ case .voiceOff: return "voice_off"
+ case .vaStart: return "va_start"
+ case .vaStop: return "va_stop"
+ case .ptt: return "ptt"
+ }
+ }
+}
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav b/clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav
new file mode 100644
index 0000000..01e4a82
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav b/clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav
new file mode 100644
index 0000000..18f921f
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav b/clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav
new file mode 100644
index 0000000..be9eb28
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav b/clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav
new file mode 100644
index 0000000..4c23cc7
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav b/clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav
new file mode 100644
index 0000000..1748fc4
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/login.wav b/clients/apple/Sources/VoiceCatCore/Sounds/login.wav
new file mode 100644
index 0000000..f2f45d9
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/login.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/logout.wav b/clients/apple/Sources/VoiceCatCore/Sounds/logout.wav
new file mode 100644
index 0000000..4e49272
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/logout.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav b/clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav
new file mode 100644
index 0000000..7a52d19
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav b/clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav
new file mode 100644
index 0000000..615d884
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav b/clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav
new file mode 100644
index 0000000..1cf4d0d
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav b/clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav
new file mode 100644
index 0000000..b64872a
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav b/clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav
new file mode 100644
index 0000000..76481cd
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav b/clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav
new file mode 100644
index 0000000..a2063bd
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav differ
diff --git a/clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav b/clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav
new file mode 100644
index 0000000..87fb04d
Binary files /dev/null and b/clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav differ
diff --git a/clients/apple/iOS/VoiceCatiOS/AppState.swift b/clients/apple/iOS/VoiceCatiOS/AppState.swift
index 192446e..daf58c2 100644
--- a/clients/apple/iOS/VoiceCatiOS/AppState.swift
+++ b/clients/apple/iOS/VoiceCatiOS/AppState.swift
@@ -158,6 +158,8 @@ final class AppState {
connectStatus = ""
showPasswordPrompt = false
self.session = newSession
+ EventFeedback.shared.play(.login)
+ EventFeedback.shared.speak("Connected")
// Activate the audio session now, while connected — NOT lazily when the first
// remote stream arrives. The core opens its miniaudio playback device the moment
// a remote stream starts and only THEN emits .streamStarted; if we waited for
diff --git a/clients/apple/iOS/VoiceCatiOS/SessionState.swift b/clients/apple/iOS/VoiceCatiOS/SessionState.swift
index cddb569..116e7e0 100644
--- a/clients/apple/iOS/VoiceCatiOS/SessionState.swift
+++ b/clients/apple/iOS/VoiceCatiOS/SessionState.swift
@@ -92,7 +92,22 @@ final class SessionState {
case .channelList:
refreshChannels()
syncSelfChannel()
- case .userJoined, .userLeft:
+ case .userJoined:
+ // ev.text = nickname, ev.channelId = the channel they joined (per voicecat.h).
+ if ev.userId != selfUserId && ev.channelId == currentChannelId {
+ EventFeedback.shared.play(.channelJoin)
+ EventFeedback.shared.speak("\(ev.text ?? "Someone") joined")
+ }
+ refreshUsers()
+ syncSelfChannel()
+ case .userLeft:
+ // Capture the leaving user's prior nickname/channel before refreshUsers() drops them.
+ if ev.userId != selfUserId,
+ let gone = users.first(where: { $0.id == ev.userId }),
+ gone.channelId == currentChannelId {
+ EventFeedback.shared.play(.channelLeave)
+ EventFeedback.shared.speak("\(gone.nickname) left")
+ }
refreshUsers()
syncSelfChannel()
case .userUpdated:
@@ -103,13 +118,27 @@ final class SessionState {
}
case .textMessage:
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
+ let body = ev.text ?? ""
+ let isSelf = ev.userId == selfUserId
+ let isPrivate = ev.textScope == .private
messages.append(ChatMessage(
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
senderName: sender,
- text: ev.text ?? "",
+ text: body,
scope: ev.textScope))
+ EventFeedback.shared.play(isPrivate
+ ? (isSelf ? .pmSent : .pmRecv)
+ : (isSelf ? .channelSent : .channelRecv))
+ if !isSelf {
+ EventFeedback.shared.speak(isPrivate
+ ? "Private message from \(sender): \(body)"
+ : "\(sender): \(body)")
+ }
case .talkState:
let talking = ev.u32a != 0
+ if ev.userId == selfUserId {
+ EventFeedback.shared.play(talking ? .vaStart : .vaStop)
+ }
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
case .streamStarted:
@@ -155,6 +184,10 @@ final class SessionState {
}
case .accountList:
accounts = client.listAccounts()
+ case .disconnected:
+ // Audible cue only — session teardown is driven elsewhere (AppState / UI).
+ EventFeedback.shared.play(ev.result == .ok ? .logout : .connectionLost)
+ EventFeedback.shared.speak(ev.result == .ok ? "Disconnected" : "Connection lost")
default:
break
}
@@ -247,6 +280,7 @@ final class SessionState {
if result == .ok {
voiceState.micActive = true
voiceState.localStreamId = streamId
+ EventFeedback.shared.play(.voiceOn)
// Publish the active mic stream ID so IOSAudioRouter can reset the core's capture
// channel count when the user switches mono↔stereo (selectCaptureChannels /
// applyPreset). Without this, switching stereo→mono leaves the LocalStream's
@@ -291,6 +325,7 @@ final class SessionState {
client.stopStream(voiceState.localStreamId)
voiceState.localStreamId = 0
AudioSessionManager.shared.activeMicStreamId = nil
+ EventFeedback.shared.play(.voiceOff)
}
if wasVPIO {
client.setExternalPlayback(false)
@@ -367,8 +402,12 @@ final class SessionState {
voiceState.vadThreshold = threshold
}
+ private var pttEngaged = false
func setPushToTalk(_ active: Bool) {
client.setPushToTalk(active)
+ // Play the PTT cue only on the press transition (the gesture fires repeatedly while held).
+ if active && !pttEngaged { EventFeedback.shared.play(.ptt) }
+ pttEngaged = active
}
// MARK: - Text
diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
index 577b03c..c8b0c19 100644
--- a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
+++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
@@ -8,6 +8,14 @@ struct SettingsView: View {
@StateObject private var router = IOSAudioRouter.shared
@State private var showAdvanced = false
+ // Notification feedback prefs — keys shared with VoiceCatCore's FeedbackSettings, so the
+ // EventFeedback player reads the same values these toggles write.
+ @AppStorage("feedback.sounds") private var soundsEnabled = true
+ @AppStorage("feedback.speech") private var speechEnabled = false
+ @AppStorage("feedback.volume") private var soundsVolume = 1.0
+ @AppStorage("feedback.selfTalk") private var selfTalkEnabled = false
+ @AppStorage("feedback.ptt") private var pttSoundEnabled = false
+
var body: some View {
NavigationStack {
Form {
@@ -217,6 +225,27 @@ struct SettingsView: View {
}
}
+ // MARK: - Notifications
+ Section("Notifications") {
+ Toggle("Event sounds", isOn: $soundsEnabled)
+ .accessibilityLabel("Play event sounds")
+ if soundsEnabled {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Sound volume")
+ .font(.caption)
+ Slider(value: $soundsVolume, in: 0...1)
+ .accessibilityLabel("Sound volume")
+ }
+ }
+ Toggle("Speak events (text-to-speech)", isOn: $speechEnabled)
+ .accessibilityLabel("Speak events")
+ .accessibilityHint("Announces joins and leaves and reads message text aloud.")
+ Toggle("Your own voice-activity sounds", isOn: $selfTalkEnabled)
+ .accessibilityLabel("Voice activity sounds")
+ Toggle("Push-to-talk cue", isOn: $pttSoundEnabled)
+ .accessibilityLabel("Push to talk cue")
+ }
+
// MARK: - Admin
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
Section("Administration") {
diff --git a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
index dd873bd..03717c0 100644
--- a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
+++ b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
@@ -35,6 +35,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
private var screenAudioSelection: ScreenAudioSelection = .default
internal var pttKeyCode: UInt16 = 0x60 // F8
private var pttMonitor: Any?
+ private var pttEngaged = false // guards the PTT cue against key-repeat
private var serverMuted = false
private var serverDeafened = false
private var channelTree: [ChannelNode] = []
@@ -343,7 +344,11 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in
guard let self, self.selectedInputMode == .pushToTalk,
self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event }
- self.client.setPushToTalk(event.type == .keyDown)
+ let down = event.type == .keyDown
+ self.client.setPushToTalk(down)
+ // Cue only on the press transition — key-down auto-repeats while held.
+ if down && !self.pttEngaged { EventFeedback.shared.play(.ptt) }
+ self.pttEngaged = down
return nil
}
@@ -385,6 +390,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
buildMessagesMenu()
buildAdminMenu()
addActivity("Connected to server as \(nickname)")
+ EventFeedback.shared.play(.login)
+ EventFeedback.shared.speak("Connected")
}
// MARK: - Event handling
@@ -415,6 +422,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
refreshChannelTree(); refreshUserList()
if event.channelId == currentChannelId && event.userId != selfUserId {
addActivity("\(u.nickname) joined the channel")
+ EventFeedback.shared.play(.channelJoin)
+ EventFeedback.shared.speak("\(u.nickname) joined")
}
case .userLeft:
@@ -423,7 +432,11 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
users.removeValue(forKey: event.userId)
talkingUsers.remove(event.userId)
refreshChannelTree(); refreshUserList()
- if wasHere { addActivity("\(nick) left the channel") }
+ if wasHere {
+ addActivity("\(nick) left the channel")
+ EventFeedback.shared.play(.channelLeave)
+ EventFeedback.shared.speak("\(nick) left")
+ }
if let pmWin = pmWindows[event.userId] {
pmWin.appendActivity("\(nick) disconnected from server")
}
@@ -473,6 +486,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
let talking = event.u32a == 1
if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) }
refreshUserList()
+ if event.userId == selfUserId {
+ EventFeedback.shared.play(talking ? .vaStart : .vaStop)
+ }
if talking && event.userId != selfUserId,
let u = users[event.userId], u.channelId == currentChannelId {
addActivity("\(u.nickname) started talking")
@@ -521,21 +537,28 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
}
let sender = nickname(for: event.userId)
+ let isSelf = event.userId == selfUserId
+ let body = event.text ?? ""
if event.textScope == .private {
// Route PMs to per-conversation windows. For our own outgoing PM, ev.channelId
// carries the recipient user ID; for incoming, ev.userId is the sender.
- let otherId = event.userId == selfUserId ? event.channelId : event.userId
+ let otherId = isSelf ? event.channelId : event.userId
let win = getOrOpenPmWindow(otherId)
- win.appendMessage(time: time, isSelf: event.userId == selfUserId, sender: sender,
- text: event.text ?? "")
- if event.userId != selfUserId {
+ win.appendMessage(time: time, isSelf: isSelf, sender: sender, text: body)
+ EventFeedback.shared.play(isSelf ? .pmSent : .pmRecv)
+ if !isSelf {
addActivity("Private message from \(sender)")
+ EventFeedback.shared.speak("Private message from \(sender): \(body)")
}
} else {
- let line = "[\(time)] \(sender): \(event.text ?? "")\n"
+ let line = "[\(time)] \(sender): \(body)\n"
logTextView.textStorage?.append(NSAttributedString(string: line))
logTextView.scrollToEndOfDocument(nil)
+ EventFeedback.shared.play(isSelf ? .channelSent : .channelRecv)
+ if !isSelf {
+ EventFeedback.shared.speak("\(sender): \(body)")
+ }
}
}
@@ -607,6 +630,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server."
statusLabel.stringValue = msg
addActivity(msg)
+ EventFeedback.shared.play(event.result == .ok ? .logout : .connectionLost)
+ EventFeedback.shared.speak(event.result == .ok ? "Disconnected" : "Connection lost")
channelTree = []; channelOutlineView.reloadData()
displayedUsers = []; userTableView.reloadData()
users.removeAll(); talkingUsers.removeAll()
@@ -689,6 +714,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
}
setVoiceJoinedState(true)
addActivity("Joined voice — microphone active")
+ EventFeedback.shared.play(.voiceOn)
NSAccessibility.post(element: logTextView, notification: .announcementRequested,
userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium])
} else {
@@ -701,6 +727,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
settingsWindowController?.resetLevel()
setVoiceJoinedState(false)
addActivity("Left voice")
+ EventFeedback.shared.play(.voiceOff)
}
}
diff --git a/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift
index 0f0606c..0432888 100644
--- a/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift
+++ b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift
@@ -50,6 +50,21 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
// Cached VAD slider position so we can restore it when the window reopens.
private var vadSliderValue: Double = 50
+ // Notification feedback controls. Read/write UserDefaults with the same keys VoiceCatCore's
+ // FeedbackSettings reads, so EventFeedback honours these immediately.
+ private let soundsCheckbox = NSButton(checkboxWithTitle: "Event sounds", target: nil, action: nil)
+ private let soundsVolumeSlider: NSSlider = {
+ let s = NSSlider(value: 1, minValue: 0, maxValue: 1, target: nil, action: nil)
+ s.numberOfTickMarks = 0
+ return s
+ }()
+ private let speechCheckbox = NSButton(checkboxWithTitle: "Speak events (text-to-speech)",
+ target: nil, action: nil)
+ private let selfTalkCheckbox = NSButton(checkboxWithTitle: "Your own voice-activity sounds",
+ target: nil, action: nil)
+ private let pttSoundCheckbox = NSButton(checkboxWithTitle: "Push-to-talk cue",
+ target: nil, action: nil)
+
// MARK: - Init
init(client: VoiceCatClient, mainController: MainWindowController) {
@@ -57,13 +72,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
self.mainController = mainController
let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 380, height: 260),
+ contentRect: NSRect(x: 0, y: 0, width: 380, height: 420),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
- window.title = "Audio Settings"
- window.minSize = NSSize(width: 340, height: 220)
+ window.title = "Settings"
+ window.minSize = NSSize(width: 340, height: 380)
window.center()
super.init(window: window)
window.delegate = self
@@ -140,7 +155,31 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
levelRow.orientation = .horizontal
levelRow.spacing = 8
- let stack = NSStackView(views: [inputModeRow, vadRow, pttRow, deviceRow, levelRow])
+ // Notifications
+ let notificationsHeader = NSTextField(labelWithString: "Notifications")
+ notificationsHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
+
+ for box in [soundsCheckbox, speechCheckbox, selfTalkCheckbox, pttSoundCheckbox] {
+ box.target = self
+ box.action = #selector(notificationSettingChanged)
+ }
+ soundsCheckbox.setAccessibilityLabel("Play event sounds")
+ speechCheckbox.setAccessibilityLabel("Speak events")
+ selfTalkCheckbox.setAccessibilityLabel("Your own voice-activity sounds")
+ pttSoundCheckbox.setAccessibilityLabel("Push-to-talk cue")
+
+ let volumeLabel = NSTextField(labelWithString: "Sound volume:")
+ volumeLabel.setAccessibilityLabel("Sound volume")
+ soundsVolumeSlider.target = self
+ soundsVolumeSlider.action = #selector(notificationSettingChanged)
+ soundsVolumeSlider.setAccessibilityLabel("Sound volume")
+ let volumeRow = NSStackView(views: [volumeLabel, soundsVolumeSlider])
+ volumeRow.orientation = .horizontal
+ volumeRow.spacing = 8
+
+ let stack = NSStackView(views: [inputModeRow, vadRow, pttRow, deviceRow, levelRow,
+ notificationsHeader, soundsCheckbox, volumeRow,
+ speechCheckbox, selfTalkCheckbox, pttSoundCheckbox])
stack.orientation = .vertical
stack.spacing = 12
stack.alignment = .leading
@@ -157,7 +196,30 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
levelMeter.widthAnchor.constraint(equalToConstant: 200),
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
+ soundsVolumeSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
])
+
+ syncNotificationControls()
+ }
+
+ /// Load the notification checkbox/slider states from UserDefaults. Touches EventFeedback.shared
+ /// first so its default values are registered before we read them.
+ private func syncNotificationControls() {
+ let s = FeedbackSettings.current
+ soundsCheckbox.state = s.sounds ? .on : .off
+ speechCheckbox.state = s.speech ? .on : .off
+ selfTalkCheckbox.state = s.selfTalkSounds ? .on : .off
+ pttSoundCheckbox.state = s.pttSound ? .on : .off
+ soundsVolumeSlider.doubleValue = Double(s.volume)
+ }
+
+ @objc private func notificationSettingChanged() {
+ let d = UserDefaults.standard
+ d.set(soundsCheckbox.state == .on, forKey: "feedback.sounds")
+ d.set(speechCheckbox.state == .on, forKey: "feedback.speech")
+ d.set(selfTalkCheckbox.state == .on, forKey: "feedback.selfTalk")
+ d.set(pttSoundCheckbox.state == .on, forKey: "feedback.ptt")
+ d.set(soundsVolumeSlider.doubleValue, forKey: "feedback.volume")
}
// MARK: - Sync from MainWindowController
diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs
index 71072dc..f401a5e 100644
--- a/clients/windows/VoiceCat.App/Forms/MainForm.cs
+++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs
@@ -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);
}
diff --git a/clients/windows/VoiceCat.App/Forms/NotificationSettingsForm.cs b/clients/windows/VoiceCat.App/Forms/NotificationSettingsForm.cs
new file mode 100644
index 0000000..efcf88a
--- /dev/null
+++ b/clients/windows/VoiceCat.App/Forms/NotificationSettingsForm.cs
@@ -0,0 +1,128 @@
+using VoiceCat.App.Notifications;
+
+namespace VoiceCat.App.Forms;
+
+///
+/// Edits and persists (event sounds + spoken announcements).
+/// On OK the supplied is updated live and the settings saved.
+///
+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();
+ }
+}
diff --git a/clients/windows/VoiceCat.App/Notifications/EventFeedback.cs b/clients/windows/VoiceCat.App/Notifications/EventFeedback.cs
new file mode 100644
index 0000000..6eba513
--- /dev/null
+++ b/clients/windows/VoiceCat.App/Notifications/EventFeedback.cs
@@ -0,0 +1,42 @@
+namespace VoiceCat.App.Notifications;
+
+///
+/// Combines the sound pool and the speech announcer behind the user's .
+/// MainForm calls / 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.
+///
+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;
+
+ /// True when speech is both enabled and actually available on this machine.
+ 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);
+ }
+
+ /// Announce when spoken feedback is enabled.
+ public void Speak(string text)
+ {
+ if (!Settings.Speech) return;
+ _speech.Speak(text);
+ }
+
+ public void Dispose()
+ {
+ _sounds.Dispose();
+ _speech.Dispose();
+ }
+}
diff --git a/clients/windows/VoiceCat.App/Notifications/FeedbackSettings.cs b/clients/windows/VoiceCat.App/Notifications/FeedbackSettings.cs
new file mode 100644
index 0000000..61503e1
--- /dev/null
+++ b/clients/windows/VoiceCat.App/Notifications/FeedbackSettings.cs
@@ -0,0 +1,55 @@
+using System.Text.Json;
+
+namespace VoiceCat.App.Notifications;
+
+///
+/// User preferences for event sounds and spoken (text-to-speech) feedback. Persisted to
+/// %AppData%\VoiceCat\feedback.json — same pattern as :
+/// a missing or corrupt file yields defaults rather than throwing.
+///
+public sealed class FeedbackSettings
+{
+ /// Master switch for event sound effects.
+ public bool Sounds { get; set; } = true;
+
+ /// Master switch for spoken announcements (Prismatoid TTS). Off by default.
+ public bool Speech { get; set; } = false;
+
+ /// Sound-effects volume, 0..1. 0 mutes effects (SoundPlayer has no gain control,
+ /// so this is honoured as a mute gate — see ).
+ public float Volume { get; set; } = 1.0f;
+
+ /// Play va_start/va_stop for your own voice-activity transitions. Off by default
+ /// (fires on every utterance — noisy).
+ public bool SelfTalkSounds { get; set; } = false;
+
+ /// Play the ptt cue when push-to-talk is engaged. Off by default.
+ 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(json) ?? new FeedbackSettings();
+ }
+ catch
+ {
+ return new FeedbackSettings();
+ }
+ }
+
+ public void Save()
+ {
+ Directory.CreateDirectory(AppDataDir);
+ File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
+ }
+}
diff --git a/clients/windows/VoiceCat.App/Notifications/SoundEvent.cs b/clients/windows/VoiceCat.App/Notifications/SoundEvent.cs
new file mode 100644
index 0000000..5643b16
--- /dev/null
+++ b/clients/windows/VoiceCat.App/Notifications/SoundEvent.cs
@@ -0,0 +1,47 @@
+namespace VoiceCat.App.Notifications;
+
+///
+/// The set of audible event cues. Each maps to a WAV in the app's sounds\ folder
+/// (copied from assets/sounds/ at build time). The same logical set is mirrored in the
+/// macOS/iOS clients so feedback stays consistent across platforms.
+///
+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
+{
+ /// WAV file name (without directory) for each event, matching assets/sounds/.
+ 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),
+ };
+}
diff --git a/clients/windows/VoiceCat.App/Notifications/SoundPlayerPool.cs b/clients/windows/VoiceCat.App/Notifications/SoundPlayerPool.cs
new file mode 100644
index 0000000..495d1b7
--- /dev/null
+++ b/clients/windows/VoiceCat.App/Notifications/SoundPlayerPool.cs
@@ -0,0 +1,58 @@
+using System.Media;
+
+namespace VoiceCat.App.Notifications;
+
+///
+/// Plays short event cues from the app's sounds\ folder. Each WAV is loaded once into a
+/// cached (built into the Windows Desktop framework — no extra
+/// dependency). is non-blocking ( renders on a
+/// background thread).
+///
+/// Note: 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.
+///
+public sealed class SoundPlayerPool : IDisposable
+{
+ private static readonly string SoundsDir =
+ Path.Combine(AppContext.BaseDirectory, "sounds");
+
+ private readonly Dictionary _players = [];
+
+ /// 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.
+ 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();
+ }
+}
diff --git a/clients/windows/VoiceCat.App/Notifications/SpeechAnnouncer.cs b/clients/windows/VoiceCat.App/Notifications/SpeechAnnouncer.cs
new file mode 100644
index 0000000..c810280
--- /dev/null
+++ b/clients/windows/VoiceCat.App/Notifications/SpeechAnnouncer.cs
@@ -0,0 +1,56 @@
+using Prismatoid;
+
+namespace VoiceCat.App.Notifications;
+
+///
+/// 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.
+///
+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;
+ }
+ }
+
+ /// True when a speech backend is available on this machine.
+ public bool Available => _backend is not null;
+
+ /// Speak . Queued (does not interrupt prior speech) so a
+ /// burst of events is read in order.
+ 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();
+ }
+}
diff --git a/clients/windows/VoiceCat.App/VoiceCat.App.csproj b/clients/windows/VoiceCat.App/VoiceCat.App.csproj
index b19953a..970cc50 100644
--- a/clients/windows/VoiceCat.App/VoiceCat.App.csproj
+++ b/clients/windows/VoiceCat.App/VoiceCat.App.csproj
@@ -4,6 +4,21 @@
+
+
+
+
+
+
+
+
+ sounds\%(Filename)%(Extension)
+ PreserveNewest
+
+
+
WinExe
net10.0-windows