// 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)) } }