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