Files
readest/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/ios/Sources/NativeTTSPlugin.swift
T
Huang Xin ab628fe258 fix(android): background TTS media controls + lock-screen scrubber/seek + Edge click fix (#4994)
* 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>
2026-07-07 13:35:53 +02:00

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