diff --git a/README.md b/README.md index 11e7246..e66c866 100644 --- a/README.md +++ b/README.md @@ -116,20 +116,56 @@ volumes: ### 朗读(TTS) -用系统自带的语音合成,不需要联网、不额外占容器的体积,也不把你的书上传到任何服务。 +三种引擎,一套控制。用哪一个是 `auto` 决定的,也可以手动指定: + +| 引擎 | 声音来自 | 什么时候用 | +| --- | --- | --- | +| 系统语音(原生) | Android 外壳的 `TextToSpeech` | Android 客户端默认 | +| 系统语音(浏览器) | 浏览器 / WebView 的 `speechSynthesis` | Web 端默认 | +| HTTP 朗读 | 服务端 `TTS_URL` 指向的合成服务 | 前两者不可用时 | + +**为什么 Android 要用原生引擎**:WebView 自带的合成器和系统的是**两个引擎**, +手机上系统装好的中文语音经常不在 WebView 的语音列表里——于是读者听到英文或听不到声音, +而 Web 层拿到的列表本身就是缺的,无从修起。这是「朗读没有中文声音」最常见的原因。 + +**HTTP 朗读**:给没有 `speechSynthesis` 的浏览器(比如 Linux 上的 Firefox)、 +或者没有中文语音的设备用。服务端只做代理,不内置合成模型——容器体积和 NAS 的 CPU 预算 +都不该为它买单。在 `docker-compose.yml` 里加: + +```yaml +environment: + TTS_URL: "http://host.docker.internal:5002/tts" # edge-tts / Piper 等兼容服务 + # TTS_TOKEN: "..." # 需要鉴权的上游 + # TTS_VOICES_URL: "..." # 语音列表;默认 /voices + # TTS_CACHE_BYTES: "268435456" # 音频磁盘缓存上限,0 关闭 +``` + +**共同行为**: - **按句切分**:中文按 `。!?;…`,西文按 `. ! ?` 且避开 `Mr.` / `3.14` / `e.g.`。 逐句朗读才能做到朗读位置跟随显示高亮,也能让「暂停/继续」和「改语速」都只影响下一句。 - **从当前位置开始**:默认从句首读起;若先选中一段文字再按朗读,就从选中处开始。 - **句子级控制**:上一句 / 下一句 / 进度条直接跳句。 - **语速、音调、音量**:语速 0.5×–2.5×,改动即时生效(不重读整段,只重读当前句)。 -- **语音可选**:列出系统里所有可用语音(含语言标签),默认「跟随系统」。 + 音调只对系统/原生引擎显示——合成音频没有不改变语速的音调控制,所以那一行直接不出现。 +- **语音可选**:列出当前引擎所有可用语音(含语言标签),默认「跟随系统」。 - **章节播完**:可选「继续下一章」或「停在章末」——后者就是有声书的「一次一章」。 - **高亮**:正在朗读的句子用半透明底色标出,并用 `Range` 绘制,**不改写书的 DOM**, 所以开始朗读不会让章节重新分页。 -> 浏览器端朗读依赖 `speechSynthesis`(Chrome / Edge / Android WebView 都有)。 -> 没有该 API 的环境里,设置面板会直接说明,而不是给一个按了没反应的按钮。 +> 一个引擎都没有时,设置面板会直接说明原因和解决办法,而不是给一个按了没反应的按钮。 + +### 书架 + +- **排序**:最近更新 / 最近入库 / 书名 / 作者,书架上一键切换,并记住选择。 + 中文书名按数字大小排(`第9章` 在 `第10章` 前),不是按字符码。 +- **密度**:紧凑 / 适中 / 宽松,封面一行放几个。手机上从一行三个到一行两个。 +- **显示**:可关掉作者、可关掉封面上的进度条。书架安静下来之后更像书库,不像仪表盘。 +- **封面进度条**:读过的书在封面底部画一条,扫一眼就知道哪本在读。 +- **搜索**:输入即搜(防抖 250ms),带一键清空;回车立刻搜不等防抖。 +- **下拉刷新**:在顶部再往下拉一次,手动重新拉取(刚扫完目录时用)。 +- **离线优先**:先画本地镜像再换成服务端数据,离线时书架照常可用; + 离线下的排序规则与服务端一致,所以断网不会让书架重排。 --- @@ -270,6 +306,13 @@ EPUB 永远留在 WebView:文字排版是差异点,只能有一份实现。 | `REFRESH_TOKEN_TTL` | `31536000` | 刷新令牌有效期(秒) | | `READER_TOKEN_SECRET` | 自动生成 | 签名密钥。留空则生成并持久化到 `/data` | | `LOG_LEVEL` | `info` | 日志级别 | +| `CORS_ORIGINS` | 空 | 允许跨域调用的来源,逗号分隔。空表示反射任意来源 | +| `WEB_DIR` | `./web` | H5 客户端产物目录,镜像里已设好 | +| `TTS_URL` | 空 | HTTP 朗读上游地址。**不设则关闭 HTTP 朗读** | +| `TTS_TOKEN` | 空 | 转发给上游的 Bearer 令牌 | +| `TTS_VOICES_URL` | `/voices` | 上游语音列表地址 | +| `TTS_TIMEOUT_MS` | `20000` | 单次合成超时(毫秒) | +| `TTS_CACHE_BYTES` | `268435456` | 音频磁盘缓存上限,存放于 `DATA_DIR/tts-cache`;0 关闭 | > `READER_TOKEN_SECRET` 留空时会在首次启动生成并存到 `DATA_DIR/token.secret`。 > 只要 `/data` 还在,重启不会踢掉所有客户端。**删掉 `/data` 会清空所有账号和进度。** diff --git a/android/app/src/main/java/cool/cnb/reader/MainActivity.kt b/android/app/src/main/java/cool/cnb/reader/MainActivity.kt index a976fb7..aea97d0 100644 --- a/android/app/src/main/java/cool/cnb/reader/MainActivity.kt +++ b/android/app/src/main/java/cool/cnb/reader/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.core.view.WindowInsetsCompat import android.widget.FrameLayout import cool.cnb.reader.bridge.ConnectivityMonitor import cool.cnb.reader.bridge.ReaderBridge +import cool.cnb.reader.bridge.SpeechBridge import cool.cnb.reader.web.NativePageView import cool.cnb.reader.web.WebHost @@ -30,6 +31,13 @@ class MainActivity : AppCompatActivity() { private lateinit var connectivity: ConnectivityMonitor private var host: WebHost? = null private var pageView: NativePageView? = null + /** + * The platform speech engine, held so it can be shut down. + * + * `TextToSpeech` binds a service and holds the audio route; releasing it in + * `onDestroy` is what stops a backgrounded app from continuing to speak. + */ + private var speechBridge: SpeechBridge? = null /** * Current image-fit preference, pushed down by the web layer. @@ -97,6 +105,11 @@ class MainActivity : AppCompatActivity() { ), ) } + // The speech engine is a bound service and a speaker; it is released when + // the activity goes away rather than left to the process's own cleanup, + // which on Android can be much later — a reader who backgrounds the app + // while listening should not be read to from the past. + speechBridge = host?.speechBridge // Back should walk the reader's own history (shelf → book → shelf) rather // than exiting the app, and only leave when there is nothing left. @@ -115,6 +128,19 @@ class MainActivity : AppCompatActivity() { ) } + /** + * Releases the platform speech engine. + * + * `onDestroy` rather than `onStop`: a WebView page can survive a stop (the + * reader comes back to the same chapter), and shutting the engine down there + * would make the first sentence after returning fail. On destroy the page is + * gone, so there is nothing left for the engine to speak. + */ + override fun onDestroy() { + speechBridge?.shutdown() + super.onDestroy() + } + /** * Keeps the WebView clear of the status bar, the navigation bar and the * on-screen keyboard. diff --git a/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt b/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt index 9bf9738..cc8bb12 100644 --- a/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt +++ b/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt @@ -58,6 +58,16 @@ class ReaderBridge( private val mainHandler = Handler(Looper.getMainLooper()) + /** + * The platform speech engine, exposed as `window.ReaderAndroid.speech`. + * + * Constructed eagerly and lazily *initialised*: creating the object is free, + * whereas creating the `TextToSpeech` engine binds a service and is not — so + * the web layer decides when to pay that, by calling `init()` when the reader + * first opens the朗读 settings rather than on every app start. + */ + private val speechBridge = SpeechBridge(context, webView) + /** * Runs a UI-thread block and waits for its result. * @@ -260,6 +270,22 @@ class ReaderBridge( return false } + /** + * The native speech surface, as a nested object. + * + * `window.ReaderAndroid.speech` rather than seven more top-level methods, for + * a reason that matters on the client side: it is a *feature*, not a + * capability, and a shell without it is a perfectly good shell — the client + * falls back to the WebView's own `speechSynthesis`, which every Android + * WebView has. One property read is the whole feature check. + * + * See `SpeechBridge` for why the platform's `TextToSpeech` is worth having + * when the WebView already exposes one: it is a different engine, and the + * WebView's `getVoices()` routinely misses the voices the OS has installed. + */ + @JavascriptInterface + fun speech(): SpeechBridge = speechBridge + private fun appVersion(): String = runCatching { val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) packageInfo.versionName ?: "unknown" @@ -298,6 +324,16 @@ class ReaderBridge( /** Name the web client looks for on `window`. */ const val NAME = "ReaderAndroid" + /** + * Name of the nested speech interface. + * + * `@JavascriptInterface` can register objects, not properties of one, so + * "`ReaderAndroid.speech`" is really a second interface that the client + * re-parents onto the first. The name is therefore part of the contract: + * `web/src/core/android-platform.ts` reads exactly this key. + */ + const val SPEECH_NAME = "ReaderAndroidSpeech" + /** * Bump when a method is added that the client cannot work without, and * raise MIN_SHELL_VERSION in `web/src/core/android-platform.ts` to match. @@ -306,8 +342,15 @@ class ReaderBridge( * shell below 2 as "no native page renderer" and draws everything in the * WebView, so the version check is how an old APK degrades instead of * calling a method that does not exist. + * + * 3 added `speech` (native `TextToSpeech`). Unlike version 2 this is a + * *feature* rather than a capability: a shell below 3 simply reads with + * the WebView's own synthesizer, which is what happens today. The version + * still matters, because it is how the client knows whether to offer + * "系统语音(原生)" in the settings rather than discovering at play time + * that the method does not exist. */ - const val SHELL_VERSION = 2 + const val SHELL_VERSION = 3 /** * Callback names must be plain identifiers. diff --git a/android/app/src/main/java/cool/cnb/reader/bridge/SpeechBridge.kt b/android/app/src/main/java/cool/cnb/reader/bridge/SpeechBridge.kt new file mode 100644 index 0000000..18b3cae --- /dev/null +++ b/android/app/src/main/java/cool/cnb/reader/bridge/SpeechBridge.kt @@ -0,0 +1,405 @@ +package cool.cnb.reader.bridge + +import android.content.Context +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.speech.tts.Voice +import android.webkit.JavascriptInterface +import android.webkit.WebView +import org.json.JSONArray +import org.json.JSONObject +import java.util.Locale + +/** + * The platform's own `TextToSpeech`, exposed to the web layer as + * `window.ReaderAndroid.speech`. + * + * ## Why this exists when every Android WebView already has `speechSynthesis` + * + * Because they are not the same synthesizer, and the WebView's copy is the worse + * one on exactly the phones that need Chinese speech: + * + * - A voice installed for the *operating system* (a Chinese voice from the + * vendor's TTS pack, or one the reader installed from the Play Store) is + * routinely absent from the WebView's `getVoices()`. The reader hears English + * or hears nothing, and nothing in the web layer can fix it — the list it is + * given is simply missing the voice. + * - `getVoices()` in a WebView is also asynchronous in a way a cold start gets + * wrong: the first call returns an empty array, and the code that waits for + * `voiceschanged` is code a phone may never run if the reader taps play first. + * - `TextToSpeech` reports *why* it failed through `onError`, and that reason is + * what turns "朗读没有声音" into a message the reader can act on. + * + * ## What stays in JavaScript + * + * The queue, the cursor, the chapter boundary, the highlight. This class speaks + * one sentence and reports when it is done — the same contract the WebView engine + * has, which is what makes the two interchangeable. A native queue would be a + * second implementation of rules that already exist in one place, and the two + * would drift. + * + * ## Threading + * + * `@JavascriptInterface` calls arrive on a WebView-owned thread; `TextToSpeech` + * and `Handler` want the main looper. Every method here therefore posts to the + * main thread, and callbacks go back through `evaluateJavascript`, which is the + * only direction a WebView allows without a `WebMessagePort` handshake. + */ +class SpeechBridge( + private val context: Context, + private val webView: WebView, +) { + private val mainHandler = Handler(Looper.getMainLooper()) + private var tts: TextToSpeech? = null + private var ready = false + + private var rate = 1f + private var pitch = 1f + private var volume = 1f + private var voiceId = "" + + private var eventCallback: String? = null + private var voicesCallback: String? = null + + /** + * Id of the sentence currently being spoken. + * + * Supplied by the *client*, not minted here, and echoed back on every event. + * A late `onDone` from a sentence the reader has already moved past must not + * advance the web layer's cursor, and an id is the only way to tell the two + * apart: `UtteranceProgressListener` gives an id and nothing else. The client + * is the side that knows which sentence is on screen, so it is the side that + * names it. + */ + private var currentUtterance = "" + + private val progressListener = object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { + if (utteranceId == null || utteranceId != currentUtterance) return + // Emitted for parity with the WebView engine's `onstart`, and for + // diagnostics: a reader who sees "playing" but hears nothing can be + // told whether the engine ever started. + emit("start", null) + } + + override fun onDone(utteranceId: String?) { + if (utteranceId == null || utteranceId != currentUtterance) return + currentUtterance = "" + emit("done", null) + } + + @Deprecated("Required by the platform interface; onError(id, code) is the one that carries a reason") + override fun onError(utteranceId: String?) { + if (utteranceId != null && utteranceId != currentUtterance) return + currentUtterance = "" + emit("error", "朗读失败") + } + + override fun onError(utteranceId: String?, errorCode: Int) { + if (utteranceId != null && utteranceId != currentUtterance) return + currentUtterance = "" + emit("error", errorMessage(errorCode)) + } + } + + /** + * Creates the engine and loads voices. + * + * Idempotent. The voice list is pushed when the `OnInitListener` fires rather + * than returned from here, because initialisation is asynchronous and a + * synchronous return would be a lie. + */ + @JavascriptInterface + fun init() { + mainHandler.post { + if (tts != null) { + deliverVoices() + return@post + } + tts = TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + ready = true + tts?.setOnUtteranceProgressListener(progressListener) + configure() + deliverVoices() + emit("ready", null) + } else { + ready = false + emit("error", "系统语音引擎初始化失败") + } + } + } + } + + /** Whether the engine finished initialising. */ + @JavascriptInterface + fun available(): Boolean = ready + + /** + * Registers the callback that receives `ready` / `start` / `done` / `error`. + * + * One callback with a typed event rather than three named ones: the client's + * handler is a single `when` on the type either way, and three registrations + * would be three chances for one of them to be missed. + */ + @JavascriptInterface + fun onSpeechEvent(callbackName: String) { + if (!SAFE_IDENTIFIER.matches(callbackName)) return + eventCallback = callbackName + } + + /** Registers the callback that receives the voice list, JSON-encoded. */ + @JavascriptInterface + fun onVoices(callbackName: String) { + if (!SAFE_IDENTIFIER.matches(callbackName)) return + voicesCallback = callbackName + mainHandler.post { deliverVoices() } + } + + /** + * Speaks one sentence, replacing anything in flight. + * + * `QUEUE_FLUSH` rather than `QUEUE_ADD` is what makes the client's + * one-sentence-at-a-time queue work: the client already decided what to say + * next, so anything still queued in the engine is a leftover from a sentence + * the reader moved past. + */ + @JavascriptInterface + fun speak(text: String, utteranceId: String) { + val value = text.trim() + if (value.isEmpty()) return + mainHandler.post { + val engine = tts ?: return@post + if (!ready) return@post + // A very long utterance makes several vendor stacks go silent, exactly + // as it does in a WebView. The client splits far below this, but a + // defensive cut costs nothing and turns a hang into a short sentence. + val bounded = value.take(MAX_UTTERANCE) + // The client's id when it sent one, a minted one otherwise, so an + // older client that only sends text still gets its events matched + // against the sentence in flight rather than being ignored. + currentUtterance = utteranceId.ifBlank { "u" + System.nanoTime() }.take(ID_MAX_LENGTH) + val params = Bundle() + params.putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume) + // The id is the third argument on API 21+ and the params map on older + // releases; `speak(CharSequence, int, Bundle, String)` is the only + // overload this project targets (minSdk 24), so the deprecated forms + // are not needed. + val result = engine.speak(bounded, TextToSpeech.QUEUE_FLUSH, params, currentUtterance) + if (result == TextToSpeech.ERROR) { + currentUtterance = "" + emit("error", "朗读启动失败") + } + } + } + + /** + * Stops speaking. + * + * This is also what "pause" maps to (see `pause`), because + * `TextToSpeech` has no pause. + */ + @JavascriptInterface + fun stop() { + mainHandler.post { + currentUtterance = "" + tts?.stop() + } + } + + /** + * Pauses, by stopping. + * + * The web layer replays the current sentence on resume — the same behaviour + * the `