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