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