ab628fe258
* fix(android): keep background TTS media controls alive when backgrounded The media-session update commands pushed metadata and playback state to MediaPlaybackService via Context.startService(), which Android 8+ rejects with "app is in background" once the app leaves the foreground. Every per-sentence update then failed, so the lock-screen control went stale and the foreground service lost the notification refresh that keeps it alive, dropping background playback. Deliver updates to the running service in-process (the pattern the existing requestDeactivation() already uses) so they can never trigger a service start. Also request POST_NOTIFICATIONS whenever the session activates, not only when alwaysInForeground is set, so the foreground-service media notification (the lock-screen control) is not suppressed on Android 13+. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(android): show section duration and enable seek on the TTS media session The Edge/WebAudio TTS engine already reports an estimated section timeline (position and duration) to the media session on every sentence, but the Android service never surfaced it: the lock screen had no scrubber and no seek. Set METADATA_KEY_DURATION so the scrubber shows its length, add ACTION_SEEK_TO, and handle onSeekTo by handing the target back to the JS controller (seekToTime) through the existing media-session-seek event. Playback-state updates that only flip play/pause omit position and duration, so the service now preserves the last known values instead of resetting the scrubber to 0. Native TextToSpeech has no timeline (duration stays 0), so the scrubber simply does not appear there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(android): harden TTS foreground-service promotion + add diagnostics On MIUI the media service was reclaimed on idle ("Stopping service due to app idle"), which means startForeground never promoted it to a real foreground service. Promote with ServiceCompat and an explicit FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK type (robust on targetSdk 34+) and log any failure instead of swallowing it. Decouple the POST_NOTIFICATIONS request from the service start in setActive: a throwing or hung permission request no longer aborts set_media_session_active, so the foreground service always starts. Add trace logging (set_media_session_active, activateSession, startForeground) to pinpoint the promotion path on-device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(android): drop required keepAppInForeground so the TTS service starts The set_media_session_active payload required keep_app_in_foreground, but the media bridge activates with just { active: true }. Tauri rejected the invoke at the serde layer ("missing field keepAppInForeground") before the command ran, so the foreground service never started: no media notification, and Android 15 audio hardening then muted background playback. The flag was dead everywhere: no platform read it (the foreground service always starts and the POST_NOTIFICATIONS request is now unconditional). Remove it from the Rust, Kotlin, iOS, and TS payloads. Effective behavior is always-foreground. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tts): fade sentence-buffer edges to stop Edge TTS clicks The silence trim (findSpeechBounds) cuts at an amplitude threshold, not a zero crossing, so each Edge TTS sentence buffer begins and ends on a non-zero sample. An AudioBufferSourceNode steps straight from/to silence at its edges, and that discontinuity clicks/pops between sentences (WSOLA is not involved: it is a no-op at rate 1.0 and cross-fades its internal splices). Apply a ~3ms linear fade to each buffer's own copy after WSOLA so the edges ramp to zero. The trim and the controlled inter-sentence gap are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(android): remove the dead "Background Read Aloud" setting alwaysInForeground only drove the removed keepAppInForeground flag; nothing reads it now (the foreground service always starts, and POST_NOTIFICATIONS is requested at play time). Remove the setting, its default, the Android library-menu toggle (which just requested the notification permission), and prune the now-unused i18n key from all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
487 lines
16 KiB
Swift
487 lines
16 KiB
Swift
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()
|
|
}
|