import AVFoundation import MediaPlayer import Tauri import UIKit // MARK: - Command arguments (camelCase, decoded from the Rust models) class SpeakArgs: Decodable { let text: String? let preload: Bool? } class SetRateArgs: Decodable { let rate: Float? } class SetPitchArgs: Decodable { let pitch: Float? } class SetVoiceArgs: Decodable { let voice: String? } class UpdateMediaSessionMetadataArgs: Decodable { let title: String? let artist: String? let album: String? let artwork: String? } class UpdateMediaSessionStateArgs: Decodable { let playing: Bool? let position: Double? // milliseconds let duration: Double? // milliseconds } class SetMediaSessionActiveArgs: Decodable { let active: Bool? let notificationTitle: String? let notificationText: String? let foregroundServiceTitle: String? let foregroundServiceText: String? } // MARK: - Command responses (camelCase, re-decoded by the Rust models) struct InitResponse: Encodable { let success: Bool } struct SpeakResponse: Encodable { let utteranceId: String } struct VoiceData: Encodable { let id: String let name: String let lang: String let disabled: Bool } struct GetVoicesResponse: Encodable { let voices: [VoiceData] } /// Native iOS Text-to-Speech backed by `AVSpeechSynthesizer`, mirroring the /// Android `NativeTTSPlugin` (Android `TextToSpeech`). The shared TypeScript /// `NativeTTSClient` drives both platforms through the same plugin command and /// `tts_events` channel contract. class NativeTTSPlugin: Plugin, AVSpeechSynthesizerDelegate { private let synthesizer = AVSpeechSynthesizer() // App-level controls. `rate` arrives pre-curved by the JS client (see // `avRate(from:)`); `pitch` is a direct multiplier (1.0 == normal). private var currentRate: Float = 1.0 private var currentPitch: Float = 1.0 private var currentVoiceId: String = "" // Maps a live utterance to the UUID the JS client awaits, so delegate // callbacks can route `tts_events` to the right async iterator. private var utteranceIds = [ObjectIdentifier: String]() // Remote command targets we registered. The lock-screen command center // (`MPRemoteCommandCenter.shared()`) is app-global and also used by // native-bridge's hardware media-key page-turn handler, so we keep the exact // tokens and remove only our own on teardown. private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] private var mediaSessionActive = false override init() { super.init() synthesizer.delegate = self } // MARK: - Lifecycle // The "init" command maps to the Objective-C selector `init:`; `init` is a // Swift reserved word, so the method is named `initialize` and exposed under // the expected selector. @objc(init:) public func initialize(_ invoke: Invoke) { // AVSpeechSynthesizer and its voice list are available synchronously; there // is no engine handshake to await as there is on Android. invoke.resolve(InitResponse(success: true)) } // MARK: - Speech @objc public func speak(_ invoke: Invoke) { do { let args = try invoke.parseArgs(SpeakArgs.self) let text = args.text ?? "" if text.isEmpty { invoke.reject("Text cannot be empty") return } let utteranceId = UUID().uuidString let rate = currentRate let pitch = currentPitch let voiceId = currentVoiceId // Resolve immediately with the id; events stream over `tts_events`. invoke.resolve(SpeakResponse(utteranceId: utteranceId)) DispatchQueue.main.async { let utterance = AVSpeechUtterance(string: text) utterance.rate = self.avRate(from: rate) utterance.pitchMultiplier = self.avPitch(from: pitch) // Each sentence is a separate utterance spoken after a gap, so the audio // route goes cold between them and the first word can be clipped. A small // pre-utterance delay plays silence first to warm the route. See #4676. utterance.preUtteranceDelay = 0.1 if !voiceId.isEmpty, let voice = AVSpeechSynthesisVoice(identifier: voiceId) { utterance.voice = voice } self.utteranceIds[ObjectIdentifier(utterance)] = utteranceId self.synthesizer.speak(utterance) } } catch { invoke.reject("Failed to start speaking: \(error.localizedDescription)") } } @objc public func pause(_ invoke: Invoke) { // Mirror Android: pause is implemented as stop. The JS client returns // `false` from pause(), so the controller stops and re-speaks the current // sentence on resume. DispatchQueue.main.async { self.synthesizer.stopSpeaking(at: .immediate) } invoke.resolve() } @objc public func resume(_ invoke: Invoke) { // No-op, mirroring Android; the controller re-speaks on resume. invoke.resolve() } @objc public func stop(_ invoke: Invoke) { DispatchQueue.main.async { self.synthesizer.stopSpeaking(at: .immediate) self.utteranceIds.removeAll() } invoke.resolve() } @objc public func set_rate(_ invoke: Invoke) { do { let args = try invoke.parseArgs(SetRateArgs.self) currentRate = args.rate ?? 1.0 invoke.resolve() } catch { invoke.reject("Exception setting rate: \(error.localizedDescription)") } } @objc public func set_pitch(_ invoke: Invoke) { do { let args = try invoke.parseArgs(SetPitchArgs.self) currentPitch = args.pitch ?? 1.0 invoke.resolve() } catch { invoke.reject("Exception setting pitch: \(error.localizedDescription)") } } @objc public func set_voice(_ invoke: Invoke) { do { let args = try invoke.parseArgs(SetVoiceArgs.self) currentVoiceId = args.voice ?? "" invoke.resolve() } catch { invoke.reject("Exception setting voice: \(error.localizedDescription)") } } @objc public func get_all_voices(_ invoke: Invoke) { let systemVoices = AVSpeechSynthesisVoice.speechVoices() // The JS layer groups voices by primary language (isSameLang), so the same // display name can appear twice in one "System TTS" list when a voice exists // in multiple regions of the same language (e.g. the Eloquence "Rocko" in // both en-US and en-GB). Count (primaryLanguage, displayName) pairs and, for // any that collide, append the region so users can tell them apart. var nameCounts: [String: Int] = [:] for voice in systemVoices { let key = "\(primaryLanguage(voice.language))|\(displayName(for: voice))" nameCounts[key, default: 0] += 1 } let voices = systemVoices.map { voice -> VoiceData in let baseName = displayName(for: voice) let key = "\(primaryLanguage(voice.language))|\(baseName)" let name = (nameCounts[key] ?? 0) > 1 ? "\(baseName) (\(regionDescription(for: voice.language)))" : baseName return VoiceData( id: voice.identifier, name: name, lang: voice.language, disabled: false ) } invoke.resolve(GetVoicesResponse(voices: voices)) } // MARK: - AVSpeechSynthesizerDelegate func speechSynthesizer( _ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance ) { if let id = utteranceIds[ObjectIdentifier(utterance)] { sendEvent(utteranceId: id, code: "boundary", message: "start") } } func speechSynthesizer( _ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance ) { if let id = utteranceIds.removeValue(forKey: ObjectIdentifier(utterance)) { sendEvent(utteranceId: id, code: "end") } } func speechSynthesizer( _ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance ) { // Cancellation comes from stop()/pause(). Do NOT emit "end": the controller // treats a finished utterance as a cue to advance, which must not happen on // a manual stop or pause (mirrors Android, where stop() emits no onDone). utteranceIds.removeValue(forKey: ObjectIdentifier(utterance)) } // MARK: - Media session (lock screen / now playing) @objc public func set_media_session_active(_ invoke: Invoke) { do { let args = try invoke.parseArgs(SetMediaSessionActiveArgs.self) let active = args.active ?? true DispatchQueue.main.async { if active { self.activateRemoteCommands() } else { self.deactivateRemoteCommands() } } invoke.resolve() } catch { invoke.reject("Failed to set media session active state: \(error.localizedDescription)") } } @objc public func update_media_session_state(_ invoke: Invoke) { do { let args = try invoke.parseArgs(UpdateMediaSessionStateArgs.self) let playing = args.playing ?? false let position = args.position let duration = args.duration DispatchQueue.main.async { var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]() info[MPNowPlayingInfoPropertyPlaybackRate] = playing ? 1.0 : 0.0 if let position = position { info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = position / 1000.0 } if let duration = duration, duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = duration / 1000.0 } MPNowPlayingInfoCenter.default().nowPlayingInfo = info } invoke.resolve() } catch { invoke.reject("Failed to update playback state: \(error.localizedDescription)") } } @objc public func update_media_session_metadata(_ invoke: Invoke) { do { let args = try invoke.parseArgs(UpdateMediaSessionMetadataArgs.self) let title = args.title ?? "" let artist = args.artist ?? "" let album = args.album ?? "" let artwork = args.artwork DispatchQueue.main.async { var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]() info[MPMediaItemPropertyTitle] = title info[MPMediaItemPropertyArtist] = artist info[MPMediaItemPropertyAlbumTitle] = album MPNowPlayingInfoCenter.default().nowPlayingInfo = info } // Artwork usually arrives as a base64 data URI; decode off the main thread // and apply it once ready so it does not block command handling. if let artwork = artwork { DispatchQueue.global(qos: .userInitiated).async { guard let image = self.loadImage(from: artwork) else { return } let mpArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } DispatchQueue.main.async { var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]() info[MPMediaItemPropertyArtwork] = mpArtwork MPNowPlayingInfoCenter.default().nowPlayingInfo = info } } } invoke.resolve() } catch { invoke.reject("Failed to update metadata: \(error.localizedDescription)") } } // MARK: - Permissions // iOS lock-screen / now-playing needs no runtime permission. Resolve the // Android-style postNotification permission as granted so the shared JS media // session code does not try to prompt. @objc public override func checkPermissions(_ invoke: Invoke) { invoke.resolve(["postNotification": "granted"]) } @objc public override func requestPermissions(_ invoke: Invoke) { invoke.resolve(["postNotification": "granted"]) } // MARK: - Helpers private func sendEvent( utteranceId: String, code: String, message: String? = nil, mark: String? = nil ) { var data: JSObject = ["utteranceId": utteranceId, "code": code] if let message = message { data["message"] = message } if let mark = mark { data["mark"] = mark } trigger("tts_events", data: data) } private func activateRemoteCommands() { if mediaSessionActive { return } mediaSessionActive = true let center = MPRemoteCommandCenter.shared() center.playCommand.isEnabled = true addRemoteTarget(center.playCommand) { [weak self] _ in self?.triggerMediaSession("media-session-play") return .success } center.pauseCommand.isEnabled = true addRemoteTarget(center.pauseCommand) { [weak self] _ in self?.triggerMediaSession("media-session-pause") return .success } // The lock screen shows a single play/pause button; the controller's "play" // handler toggles, so route the toggle command there too. center.togglePlayPauseCommand.isEnabled = true addRemoteTarget(center.togglePlayPauseCommand) { [weak self] _ in self?.triggerMediaSession("media-session-play") return .success } center.nextTrackCommand.isEnabled = true addRemoteTarget(center.nextTrackCommand) { [weak self] _ in self?.triggerMediaSession("media-session-next") return .success } center.previousTrackCommand.isEnabled = true addRemoteTarget(center.previousTrackCommand) { [weak self] _ in self?.triggerMediaSession("media-session-previous") return .success } } private func deactivateRemoteCommands() { for (command, token) in remoteCommandTargets { command.removeTarget(token) } remoteCommandTargets.removeAll() MPNowPlayingInfoCenter.default().nowPlayingInfo = nil mediaSessionActive = false } private func addRemoteTarget( _ command: MPRemoteCommand, handler: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus ) { let token = command.addTarget(handler: handler) remoteCommandTargets.append((command, token)) } private func triggerMediaSession(_ event: String) { trigger(event, data: JSObject()) } private func displayName(for voice: AVSpeechSynthesisVoice) -> String { switch voice.quality { case .enhanced: return "\(voice.name) (Enhanced)" case .premium: return "\(voice.name) (Premium)" default: return voice.name } } private func primaryLanguage(_ language: String) -> String { return String(language.split(separator: "-").first ?? "") } /// A human-readable region for disambiguating duplicate voice names, e.g. /// "en-US" -> "United States". Falls back to the raw subtag, then the full tag. private func regionDescription(for language: String) -> String { let parts = language.split(separator: "-") if parts.count > 1, let regionPart = parts.last { let region = String(regionPart) return Locale.current.localizedString(forRegionCode: region) ?? region } return language } /// Loads artwork from a base64 data URI, a remote URL, or a bundled asset. private func loadImage(from urlString: String) -> UIImage? { if urlString.hasPrefix("data:image") { guard let commaIndex = urlString.firstIndex(of: ",") else { return nil } let base64 = String(urlString[urlString.index(after: commaIndex)...]) guard let data = Data(base64Encoded: base64) else { return nil } return UIImage(data: data) } else if urlString.hasPrefix("http") { guard let url = URL(string: urlString), let data = try? Data(contentsOf: url) else { return nil } return UIImage(data: data) } else { return UIImage(named: urlString) } } /// AVSpeechUtterance rate runs 0...1 with ~0.5 as normal, while the JS client /// sends an Android-tuned `pow(userMultiplier, 2.5)` value where 1.0 is /// normal. Recover the multiplier and rescale onto the AVSpeechUtterance range. private func avRate(from jsRate: Float) -> Float { let userMultiplier = pow(max(jsRate, 0.0001), 1.0 / 2.5) let mapped = AVSpeechUtteranceDefaultSpeechRate * userMultiplier return min( max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate) } /// AVSpeechUtterance pitchMultiplier is clamped to [0.5, 2.0] (1.0 normal). private func avPitch(from jsPitch: Float) -> Float { return min(max(jsPitch, 0.5), 2.0) } } @_cdecl("init_plugin_native_tts") func initPlugin() -> Plugin { return NativeTTSPlugin() }