feat(clients): event sound effects + optional text-to-speech
Add audible cues and optional spoken announcements for session events (join/leave, channel + PM sent/recv, login, logout/connection-lost, mic on/off, voice-activity, PTT) across all three clients, driven off the shared C ABI vc_event stream so the mapping stays consistent. TTS is off by default; when enabled it announces events and reads message/PM bodies aloud. Master toggles + a sound-volume slider; the per-utterance voice-activity and PTT cues default off. WAVs ship from assets/sounds/. Windows (built + verified): new VoiceCat.App/Notifications/ layer (FeedbackSettings -> %AppData%\VoiceCat\feedback.json, SoundPlayerPool via System.Media.SoundPlayer, SpeechAnnouncer via Prismatoid 0.3.0, EventFeedback dispatcher); MainForm hooks; NotificationSettingsForm under Settings > Notifications; csproj adds the Prismatoid PackageRef and copies the WAVs into sounds\. macOS + iOS (written, not yet built -- needs a Mac): shared VoiceCatCore/Feedback/ (SoundEvent, EventFeedback = AVAudioPlayer pool + native AVSpeechSynthesizer, FeedbackSettings over UserDefaults); WAVs bundled via Package.swift resources (.process). Hooks in SessionState/ AppState (iOS) and MainWindowController (macOS); settings UI in SettingsView (iOS) and SettingsWindowController (macOS). No core/server code touched; ctest --preset dev unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
30
PROGRESS.md
30
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
|
||||
|
||||
BIN
assets/sounds/channel_join.wav
Normal file
BIN
assets/sounds/channel_join.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_leave.wav
Normal file
BIN
assets/sounds/channel_leave.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_recv.wav
Normal file
BIN
assets/sounds/channel_recv.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_sent.wav
Normal file
BIN
assets/sounds/channel_sent.wav
Normal file
Binary file not shown.
BIN
assets/sounds/connection_lost.wav
Normal file
BIN
assets/sounds/connection_lost.wav
Normal file
Binary file not shown.
BIN
assets/sounds/login.wav
Normal file
BIN
assets/sounds/login.wav
Normal file
Binary file not shown.
BIN
assets/sounds/logout.wav
Normal file
BIN
assets/sounds/logout.wav
Normal file
Binary file not shown.
BIN
assets/sounds/pm_recv.wav
Normal file
BIN
assets/sounds/pm_recv.wav
Normal file
Binary file not shown.
BIN
assets/sounds/pm_sent.wav
Normal file
BIN
assets/sounds/pm_sent.wav
Normal file
Binary file not shown.
BIN
assets/sounds/ptt.wav
Normal file
BIN
assets/sounds/ptt.wav
Normal file
Binary file not shown.
BIN
assets/sounds/va_start.wav
Normal file
BIN
assets/sounds/va_start.wav
Normal file
Binary file not shown.
BIN
assets/sounds/va_stop.wav
Normal file
BIN
assets/sounds/va_stop.wav
Normal file
Binary file not shown.
BIN
assets/sounds/voice_off.wav
Normal file
BIN
assets/sounds/voice_off.wav
Normal file
Binary file not shown.
BIN
assets/sounds/voice_on.wav
Normal file
BIN
assets/sounds/voice_on.wav
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
105
clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift
Normal file
105
clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
43
clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift
Normal file
43
clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/login.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/login.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/logout.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/logout.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
128
clients/windows/VoiceCat.App/Forms/NotificationSettingsForm.cs
Normal file
128
clients/windows/VoiceCat.App/Forms/NotificationSettingsForm.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using VoiceCat.App.Notifications;
|
||||
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Edits and persists <see cref="FeedbackSettings"/> (event sounds + spoken announcements).
|
||||
/// On OK the supplied <see cref="EventFeedback"/> is updated live and the settings saved.
|
||||
/// </summary>
|
||||
public sealed class NotificationSettingsForm : Form
|
||||
{
|
||||
private readonly EventFeedback _feedback;
|
||||
|
||||
private readonly CheckBox _chkSounds;
|
||||
private readonly CheckBox _chkSpeech;
|
||||
private readonly TrackBar _trkVolume;
|
||||
private readonly CheckBox _chkSelfTalk;
|
||||
private readonly CheckBox _chkPtt;
|
||||
|
||||
public NotificationSettingsForm(EventFeedback feedback)
|
||||
{
|
||||
_feedback = feedback;
|
||||
var s = feedback.Settings;
|
||||
|
||||
Text = "Notification settings";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(360, 300);
|
||||
|
||||
_chkSounds = new CheckBox
|
||||
{
|
||||
Text = "Play event &sounds",
|
||||
Location = new Point(12, 12),
|
||||
AutoSize = true,
|
||||
Checked = s.Sounds,
|
||||
};
|
||||
|
||||
var lblVolume = new Label
|
||||
{
|
||||
Text = "Sound &volume:",
|
||||
Location = new Point(12, 42),
|
||||
AutoSize = true,
|
||||
};
|
||||
_trkVolume = new TrackBar
|
||||
{
|
||||
Location = new Point(12, 64),
|
||||
Size = new Size(330, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 100,
|
||||
TickFrequency = 10,
|
||||
Value = (int)Math.Round(Math.Clamp(s.Volume, 0f, 1f) * 100),
|
||||
};
|
||||
_trkVolume.AccessibleName = "Sound volume";
|
||||
|
||||
_chkSpeech = new CheckBox
|
||||
{
|
||||
Text = "&Speak events (text-to-speech)",
|
||||
Location = new Point(12, 118),
|
||||
AutoSize = true,
|
||||
Checked = s.Speech,
|
||||
};
|
||||
var lblSpeechHint = new Label
|
||||
{
|
||||
Text = feedback.SpeechAvailable
|
||||
? "Announces joins/leaves and reads message text aloud."
|
||||
: "No speech engine available on this machine.",
|
||||
Location = new Point(30, 142),
|
||||
AutoSize = true,
|
||||
ForeColor = SystemColors.GrayText,
|
||||
};
|
||||
|
||||
var lblOptional = new Label
|
||||
{
|
||||
Text = "Optional sounds:",
|
||||
Location = new Point(12, 174),
|
||||
AutoSize = true,
|
||||
};
|
||||
_chkSelfTalk = new CheckBox
|
||||
{
|
||||
Text = "Your own voice-&activity start/stop",
|
||||
Location = new Point(12, 198),
|
||||
AutoSize = true,
|
||||
Checked = s.SelfTalkSounds,
|
||||
};
|
||||
_chkPtt = new CheckBox
|
||||
{
|
||||
Text = "&Push-to-talk cue",
|
||||
Location = new Point(12, 224),
|
||||
AutoSize = true,
|
||||
Checked = s.PttSound,
|
||||
};
|
||||
|
||||
var btnOk = new Button
|
||||
{
|
||||
Text = "&OK",
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(192, 262),
|
||||
Size = new Size(75, 27),
|
||||
};
|
||||
var btnCancel = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
DialogResult = DialogResult.Cancel,
|
||||
Location = new Point(273, 262),
|
||||
Size = new Size(75, 27),
|
||||
};
|
||||
|
||||
AcceptButton = btnOk;
|
||||
CancelButton = btnCancel;
|
||||
Controls.AddRange([_chkSounds, lblVolume, _trkVolume, _chkSpeech, lblSpeechHint,
|
||||
lblOptional, _chkSelfTalk, _chkPtt, btnOk, btnCancel]);
|
||||
|
||||
btnOk.Click += (_, _) => Apply();
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
var s = _feedback.Settings;
|
||||
s.Sounds = _chkSounds.Checked;
|
||||
s.Speech = _chkSpeech.Checked;
|
||||
s.Volume = _trkVolume.Value / 100f;
|
||||
s.SelfTalkSounds = _chkSelfTalk.Checked;
|
||||
s.PttSound = _chkPtt.Checked;
|
||||
s.Save();
|
||||
}
|
||||
}
|
||||
42
clients/windows/VoiceCat.App/Notifications/EventFeedback.cs
Normal file
42
clients/windows/VoiceCat.App/Notifications/EventFeedback.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace VoiceCat.App.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Combines the sound pool and the speech announcer behind the user's <see cref="FeedbackSettings"/>.
|
||||
/// MainForm calls <see cref="PlaySound"/> / <see cref="Speak"/> from inside its existing event
|
||||
/// handlers (which already resolve nicknames and channel membership), so this type only owns the
|
||||
/// "should I, and how" policy — not the event routing.
|
||||
/// </summary>
|
||||
public sealed class EventFeedback : IDisposable
|
||||
{
|
||||
private readonly SoundPlayerPool _sounds = new();
|
||||
private readonly SpeechAnnouncer _speech = new();
|
||||
|
||||
public FeedbackSettings Settings { get; set; }
|
||||
|
||||
public EventFeedback(FeedbackSettings settings) => Settings = settings;
|
||||
|
||||
/// <summary>True when speech is both enabled and actually available on this machine.</summary>
|
||||
public bool SpeechAvailable => _speech.Available;
|
||||
|
||||
public void PlaySound(SoundEvent ev)
|
||||
{
|
||||
if (!Settings.Sounds || Settings.Volume <= 0f) return;
|
||||
// The two opt-in categories are gated by their own flags.
|
||||
if ((ev is SoundEvent.VaStart or SoundEvent.VaStop) && !Settings.SelfTalkSounds) return;
|
||||
if (ev is SoundEvent.Ptt && !Settings.PttSound) return;
|
||||
_sounds.Play(ev);
|
||||
}
|
||||
|
||||
/// <summary>Announce <paramref name="text"/> when spoken feedback is enabled.</summary>
|
||||
public void Speak(string text)
|
||||
{
|
||||
if (!Settings.Speech) return;
|
||||
_speech.Speak(text);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sounds.Dispose();
|
||||
_speech.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VoiceCat.App.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// User preferences for event sounds and spoken (text-to-speech) feedback. Persisted to
|
||||
/// %AppData%\VoiceCat\feedback.json — same pattern as <see cref="Models.ServerListStore"/>:
|
||||
/// a missing or corrupt file yields defaults rather than throwing.
|
||||
/// </summary>
|
||||
public sealed class FeedbackSettings
|
||||
{
|
||||
/// <summary>Master switch for event sound effects.</summary>
|
||||
public bool Sounds { get; set; } = true;
|
||||
|
||||
/// <summary>Master switch for spoken announcements (Prismatoid TTS). Off by default.</summary>
|
||||
public bool Speech { get; set; } = false;
|
||||
|
||||
/// <summary>Sound-effects volume, 0..1. 0 mutes effects (SoundPlayer has no gain control,
|
||||
/// so this is honoured as a mute gate — see <see cref="SoundPlayerPool"/>).</summary>
|
||||
public float Volume { get; set; } = 1.0f;
|
||||
|
||||
/// <summary>Play va_start/va_stop for your own voice-activity transitions. Off by default
|
||||
/// (fires on every utterance — noisy).</summary>
|
||||
public bool SelfTalkSounds { get; set; } = false;
|
||||
|
||||
/// <summary>Play the ptt cue when push-to-talk is engaged. Off by default.</summary>
|
||||
public bool PttSound { get; set; } = false;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
private static string AppDataDir => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
|
||||
|
||||
private static string FilePath => Path.Combine(AppDataDir, "feedback.json");
|
||||
|
||||
public static FeedbackSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath)) return new FeedbackSettings();
|
||||
string json = File.ReadAllText(FilePath);
|
||||
return JsonSerializer.Deserialize<FeedbackSettings>(json) ?? new FeedbackSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new FeedbackSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
Directory.CreateDirectory(AppDataDir);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
|
||||
}
|
||||
}
|
||||
47
clients/windows/VoiceCat.App/Notifications/SoundEvent.cs
Normal file
47
clients/windows/VoiceCat.App/Notifications/SoundEvent.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace VoiceCat.App.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// The set of audible event cues. Each maps to a WAV in the app's <c>sounds\</c> folder
|
||||
/// (copied from <c>assets/sounds/</c> at build time). The same logical set is mirrored in the
|
||||
/// macOS/iOS clients so feedback stays consistent across platforms.
|
||||
/// </summary>
|
||||
public enum SoundEvent
|
||||
{
|
||||
ChannelJoin, // another user joined my channel
|
||||
ChannelLeave, // another user left my channel
|
||||
ChannelRecv, // channel text message from someone else
|
||||
ChannelSent, // channel text message I sent
|
||||
PmRecv, // private message received
|
||||
PmSent, // private message I sent
|
||||
Login, // connected / authenticated
|
||||
Logout, // clean disconnect
|
||||
ConnectionLost, // unexpected disconnect
|
||||
VoiceOn, // my microphone stream started
|
||||
VoiceOff, // my microphone stream stopped
|
||||
VaStart, // my voice-activity began (off by default)
|
||||
VaStop, // my voice-activity ended (off by default)
|
||||
Ptt, // push-to-talk engaged (off by default)
|
||||
}
|
||||
|
||||
internal static class SoundEventExtensions
|
||||
{
|
||||
/// <summary>WAV file name (without directory) for each event, matching assets/sounds/.</summary>
|
||||
public static string FileName(this SoundEvent ev) => ev switch
|
||||
{
|
||||
SoundEvent.ChannelJoin => "channel_join.wav",
|
||||
SoundEvent.ChannelLeave => "channel_leave.wav",
|
||||
SoundEvent.ChannelRecv => "channel_recv.wav",
|
||||
SoundEvent.ChannelSent => "channel_sent.wav",
|
||||
SoundEvent.PmRecv => "pm_recv.wav",
|
||||
SoundEvent.PmSent => "pm_sent.wav",
|
||||
SoundEvent.Login => "login.wav",
|
||||
SoundEvent.Logout => "logout.wav",
|
||||
SoundEvent.ConnectionLost => "connection_lost.wav",
|
||||
SoundEvent.VoiceOn => "voice_on.wav",
|
||||
SoundEvent.VoiceOff => "voice_off.wav",
|
||||
SoundEvent.VaStart => "va_start.wav",
|
||||
SoundEvent.VaStop => "va_stop.wav",
|
||||
SoundEvent.Ptt => "ptt.wav",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(ev), ev, null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Media;
|
||||
|
||||
namespace VoiceCat.App.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Plays short event cues from the app's <c>sounds\</c> folder. Each WAV is loaded once into a
|
||||
/// cached <see cref="SoundPlayer"/> (built into the Windows Desktop framework — no extra
|
||||
/// dependency). <see cref="Play"/> is non-blocking (<see cref="SoundPlayer.Play"/> renders on a
|
||||
/// background thread).
|
||||
///
|
||||
/// Note: <see cref="SoundPlayer"/> exposes no gain control, so volume is honoured only as a mute
|
||||
/// gate (volume == 0 → silent). If finer control or reliable overlapping playback is needed
|
||||
/// later, swap this for NAudio.
|
||||
/// </summary>
|
||||
public sealed class SoundPlayerPool : IDisposable
|
||||
{
|
||||
private static readonly string SoundsDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "sounds");
|
||||
|
||||
private readonly Dictionary<SoundEvent, SoundPlayer?> _players = [];
|
||||
|
||||
/// <summary>Resolve, load and cache the player for an event. Returns null if the file is
|
||||
/// missing or fails to load — playback then silently no-ops.</summary>
|
||||
private SoundPlayer? Get(SoundEvent ev)
|
||||
{
|
||||
if (_players.TryGetValue(ev, out var cached)) return cached;
|
||||
|
||||
SoundPlayer? player = null;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(SoundsDir, ev.FileName());
|
||||
if (File.Exists(path))
|
||||
{
|
||||
player = new SoundPlayer(path);
|
||||
player.Load();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
player = null; // unreadable/invalid WAV — degrade to silence
|
||||
}
|
||||
|
||||
_players[ev] = player;
|
||||
return player;
|
||||
}
|
||||
|
||||
public void Play(SoundEvent ev)
|
||||
{
|
||||
try { Get(ev)?.Play(); }
|
||||
catch { /* never let a notification sound surface as an error */ }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var p in _players.Values) p?.Dispose();
|
||||
_players.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Prismatoid;
|
||||
|
||||
namespace VoiceCat.App.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Spoken event announcements via Prismatoid (bindings for the Prism speech library —
|
||||
/// integrates with the active screen reader / system speech, no extra runtime deps).
|
||||
///
|
||||
/// All construction and calls are wrapped so a machine with no available speech backend simply
|
||||
/// degrades to silence rather than crashing the app.
|
||||
/// </summary>
|
||||
public sealed class SpeechAnnouncer : IDisposable
|
||||
{
|
||||
private readonly PrismContext? _context;
|
||||
private readonly object? _backend; // SpeechBackend; held as object to keep this resilient
|
||||
|
||||
public SpeechAnnouncer()
|
||||
{
|
||||
try
|
||||
{
|
||||
_context = new PrismContext();
|
||||
_backend = _context.AcquireBestBackend();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_context?.Dispose();
|
||||
_context = null;
|
||||
_backend = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when a speech backend is available on this machine.</summary>
|
||||
public bool Available => _backend is not null;
|
||||
|
||||
/// <summary>Speak <paramref name="text"/>. Queued (does not interrupt prior speech) so a
|
||||
/// burst of events is read in order.</summary>
|
||||
public void Speak(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
try
|
||||
{
|
||||
if (_backend is { } b)
|
||||
((dynamic)b).Speak(text, interrupt: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* never surface a speech failure */
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { (_backend as IDisposable)?.Dispose(); } catch { /* ignore */ }
|
||||
_context?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,21 @@
|
||||
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Prismatoid — .NET bindings for the Prism speech library, used for spoken event
|
||||
announcements (text-to-speech). net10.0, no transitive dependencies. -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Prismatoid" Version="0.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Event-cue WAVs (shared with the macOS/iOS clients) copied into sounds\ next to the exe;
|
||||
SoundPlayerPool loads them from AppContext.BaseDirectory\sounds. -->
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\..\assets\sounds\*.wav">
|
||||
<Link>sounds\%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
|
||||
Reference in New Issue
Block a user