HapeLee 1 месяц назад
Родитель
Сommit
2741ae361f
28 измененных файлов с 1168 добавлено и 73 удалено
  1. 2 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 55 0
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  3. 243 0
      app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudContentSplit.kt
  4. 29 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  5. 3 1
      app/src/main/java/io/legado/app/domain/usecase/AnalyzeChapterSpeechUseCase.kt
  6. 22 5
      app/src/main/java/io/legado/app/domain/usecase/PrepareChapterSpeechPlanUseCase.kt
  7. 14 5
      app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt
  8. 81 23
      app/src/main/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapter.kt
  9. 23 2
      app/src/main/java/io/legado/app/help/readaloud/segment/RuleBasedSpeechSegmenter.kt
  10. 48 7
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  11. 11 2
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  12. 28 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  13. 13 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  14. 2 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  15. 119 8
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt
  16. 9 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  17. 2 1
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  18. 25 0
      app/src/main/res/values-zh-rCN/strings.xml
  19. 25 0
      app/src/main/res/values-zh-rHK/strings.xml
  20. 25 0
      app/src/main/res/values-zh-rTW/strings.xml
  21. 25 0
      app/src/main/res/values/strings.xml
  22. 7 0
      app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt
  23. 172 0
      app/src/test/java/io/legado/app/domain/model/readaloud/ReadAloudContentSplitterTest.kt
  24. 29 4
      app/src/test/java/io/legado/app/domain/usecase/AnalyzeChapterSpeechUseCaseTest.kt
  25. 83 7
      app/src/test/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapterTest.kt
  26. 23 1
      app/src/test/java/io/legado/app/help/readaloud/segment/RuleBasedSpeechSegmenterTest.kt
  27. 16 3
      app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt
  28. 34 2
      docs/tts-multi-speaker-design.md

+ 2 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -426,6 +426,8 @@ object PreferKey {
     const val audioPreDownloadNum = "audioPreDownloadNum"
     const val ttsPreSynthesisConcurrency = "ttsPreSynthesisConcurrency"
     const val ttsParagraphInterval = "ttsParagraphInterval"
+    const val readAloudContentSplitMode = "readAloudContentSplitMode"
+    const val readAloudContentSplitSymbols = "readAloudContentSplitSymbols"
     const val customTagColors = "customTagColors"
     const val enableCustomTagColors = "enableCustomTagColors"
     const val navIconBookshelf = "navIconBookshelf"

+ 55 - 0
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -5,10 +5,12 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
 import androidx.datastore.preferences.core.floatPreferencesKey
 import androidx.datastore.preferences.core.intPreferencesKey
 import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsString
@@ -35,10 +37,30 @@ class ReadAloudSettingsRepository : ReadAloudSettingsGateway {
         )
     }
 
+    /**
+     * 应用内容划分方式与配套标点集合。
+     *
+     * 两者在同一次写入里提交:划分方式与标点始终一起选择,
+     * 迁移标记也必须与划分方式同批落盘,否则旧版「按页朗读」开关会在下次读取时
+     * 反过来覆盖用户刚选的划分方式。
+     */
+    suspend fun setContentSplit(mode: ReadAloudContentSplitMode, symbols: Set<String>) {
+        AppConfigStore.putAllAndAwait(
+            mapOf(
+                PreferKey.readAloudContentSplitMode to mode.storageValue,
+                PreferKey.readAloudContentSplitSymbols to symbols,
+                CONTENT_SPLIT_MIGRATION_KEY to true,
+            )
+        )
+    }
+
     companion object {
         const val DEFAULT_INTERFACE_CLASSIC = "classic"
         const val DEFAULT_INTERFACE_PLAYER = "player"
         val AVAILABLE_INTERFACES = setOf(DEFAULT_INTERFACE_CLASSIC, DEFAULT_INTERFACE_PLAYER)
+
+        /** 一次性迁移标记:把旧的「按页朗读」布尔值搬进「内容划分方式」。 */
+        const val CONTENT_SPLIT_MIGRATION_KEY = "readAloudContentSplitMigrated"
     }
 }
 
@@ -58,6 +80,8 @@ internal fun Preferences.toReadAloudSettings(): ReadAloudSettings = ReadAloudSet
     capsuleOffsetY = compatDsValue(ReadAloudKeys.CapsuleOffsetY, 0f),
     mediaButtonPerNext = compatDsValue(ReadAloudKeys.MediaButtonPerNext, false),
     readAloudByPage = compatDsValue(ReadAloudKeys.ReadAloudByPage, false),
+    contentSplitMode = compatContentSplitMode(),
+    contentSplitSymbols = compatContentSplitSymbols(),
     androidMediaControlEnabled = compatDsValue(ReadAloudKeys.AndroidMediaControlEnabled, false),
     systemMediaControlCompatibilityChange =
         compatDsValue(ReadAloudKeys.SystemMediaControlCompatibilityChange, true),
@@ -82,6 +106,28 @@ internal fun Preferences.toReadAloudSettings(): ReadAloudSettings = ReadAloudSet
     ttsPreSynthesisConcurrency = compatDsValue(ReadAloudKeys.PreSynthesisConcurrency, 3),
 )
 
+/**
+ * 读取内容划分方式,并一次性把旧的「按页朗读」开关迁移进来。
+ *
+ * 迁移标记保证只搬一次:否则用户把划分方式显式改回「整段」后,下一次读取又会被旧布尔值覆盖。
+ * 显式选择过划分方式的用户(新 key 已存在)不参与迁移。
+ */
+private fun Preferences.compatContentSplitMode(): String {
+    val stored = compatDsString(PreferKey.readAloudContentSplitMode)
+    if (stored != null) return stored
+    if (compatDsValue(ReadAloudKeys.ReadAloudContentSplitMigrated, false)) {
+        return ReadAloudContentSplitMode.Default.storageValue
+    }
+    return if (compatDsValue(ReadAloudKeys.ReadAloudByPage, false)) {
+        ReadAloudContentSplitMode.Page.storageValue
+    } else {
+        ReadAloudContentSplitMode.Default.storageValue
+    }
+}
+
+private fun Preferences.compatContentSplitSymbols(): Set<String> =
+    compatDsValue(ReadAloudKeys.ReadAloudContentSplitSymbols, emptySet())
+
 internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.ttsEngine to ttsEngine,
     PreferKey.ttsParagraphInterval to ttsParagraphInterval,
@@ -97,6 +143,8 @@ internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
     ReadAloudKeys.CapsuleOffsetY.name to capsuleOffsetY,
     PreferKey.mediaButtonPerNext to mediaButtonPerNext,
     PreferKey.readAloudByPage to readAloudByPage,
+    PreferKey.readAloudContentSplitMode to contentSplitMode,
+    PreferKey.readAloudContentSplitSymbols to contentSplitSymbols,
     PreferKey.readAloudAndroidMediaControl to androidMediaControlEnabled,
     PreferKey.systemMediaControlCompatibilityChange to systemMediaControlCompatibilityChange,
     PreferKey.streamReadAloudAudio to streamReadAloudAudio,
@@ -128,6 +176,13 @@ private object ReadAloudKeys {
     val CapsuleOffsetY = floatPreferencesKey("read_aloud_capsule_offset_y")
     val MediaButtonPerNext = booleanPreferencesKey(PreferKey.mediaButtonPerNext)
     val ReadAloudByPage = booleanPreferencesKey(PreferKey.readAloudByPage)
+    val ReadAloudContentSplitMode = stringPreferencesKey(PreferKey.readAloudContentSplitMode)
+    val ReadAloudContentSplitSymbols =
+        stringSetPreferencesKey(PreferKey.readAloudContentSplitSymbols)
+
+    /** 一次性迁移标记:把旧的「按页朗读」布尔值搬进「内容划分方式」。 */
+    val ReadAloudContentSplitMigrated =
+        booleanPreferencesKey(ReadAloudSettingsRepository.CONTENT_SPLIT_MIGRATION_KEY)
     val AndroidMediaControlEnabled =
         booleanPreferencesKey(PreferKey.readAloudAndroidMediaControl)
     val SystemMediaControlCompatibilityChange =

+ 243 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudContentSplit.kt

@@ -0,0 +1,243 @@
+package io.legado.app.domain.model.readaloud
+
+import io.legado.app.domain.model.readaloud.ContentSplitPolicies.resolve
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
+
+/**
+ * 一个朗读划分单元:在章节语义文本中的绝对位置 + 该位置的原始文本。
+ *
+ * [chapterPosition] 是模型侧唯一的坐标来源,调用方不得用累加偏移推算,
+ * 因为按页切分不引入换行符,累加会漂移。
+ */
+data class ReadAloudSplitUnit(
+    val text: String,
+    val chapterPosition: Int,
+)
+
+/**
+ * 内容划分策略:把使用者选择的划分方式固化成切分所需的最小输入。
+ *
+ * [allowRoleSplits] 为 false 时表示「一个划分单元内不再按引号/冒号拆成多个片段」,
+ * 即整段/整页模式下段落本身就是一个播放单元,多角色朗读也不会在段内产生停顿。
+ */
+data class ContentSplitPolicy(
+    val mode: ReadAloudContentSplitMode,
+    val symbols: Set<Char>,
+    val allowRoleSplits: Boolean,
+) {
+    /** 设置或缓存失效用的稳定标识,不参与切分计算。 */
+    val identifier: String = buildString {
+        append(mode.storageValue)
+        append(':')
+        append(if (allowRoleSplits) '1' else '0')
+        append(':')
+        append(symbols.sorted().joinToString(""))
+    }
+
+    companion object {
+        /**
+         * 不指定划分方式时的默认策略:按句末标点划分并允许段内角色切分。
+         *
+         * 这是「默认」划分方式的原语义,供不经过设置读取的调用方(单测、脚本)沿用;
+         * 整段/整页划分必须由调用方显式传入对应策略,否则会退化成句级切分。
+         */
+        val SentenceLevel = ContentSplitPolicy(
+            mode = ReadAloudContentSplitMode.Default,
+            symbols = ReadAloudSplitSymbol.sentenceEnds,
+            allowRoleSplits = true,
+        )
+    }
+}
+
+/**
+ * 把使用者选择的划分方式与标点集合解析成实际切分策略。
+ *
+ * [ReadAloudContentSplitMode.Default] 的语义是多角色朗读开启时按句末标点切分,
+ * 关闭时按整段切分,因此调用方应先用 [resolve] 把「默认」定下来再取策略。
+ */
+object ContentSplitPolicies {
+
+    /** 整段划分:段落即单元,段内不再切分。 */
+    val Paragraph = forMode(ReadAloudContentSplitMode.Paragraph)
+
+    /**
+     * 把「默认」按多角色朗读开关定成具体划分方式。
+     *
+     * 建章节(`ReaderReadAloudChapter.create`)与取播放单元(`paragraphs(policy)`)必须用
+     * 同一个结果:只在一侧解析会让章节切分与播放单元粒度不一致。
+     */
+    fun resolve(
+        mode: ReadAloudContentSplitMode,
+        useMultiSpeaker: Boolean,
+    ): ReadAloudContentSplitMode =
+        if (mode == ReadAloudContentSplitMode.Default && !useMultiSpeaker) {
+            ReadAloudContentSplitMode.Paragraph
+        } else {
+            mode
+        }
+
+    fun forMode(
+        mode: ReadAloudContentSplitMode,
+        storedSymbols: Collection<String> = emptySet(),
+    ): ContentSplitPolicy = when (mode) {
+        ReadAloudContentSplitMode.Default -> ContentSplitPolicy(
+            mode = mode,
+            symbols = ReadAloudSplitSymbol.sentenceEnds,
+            allowRoleSplits = true,
+        )
+
+        ReadAloudContentSplitMode.Symbols -> ContentSplitPolicy(
+            mode = mode,
+            symbols = ReadAloudSplitSymbol.resolveSymbols(storedSymbols),
+            allowRoleSplits = true,
+        )
+
+        ReadAloudContentSplitMode.Paragraph,
+        ReadAloudContentSplitMode.Page,
+            -> ContentSplitPolicy(mode = mode, symbols = emptySet(), allowRoleSplits = false)
+    }
+}
+
+/**
+ * 「内容划分方式」设置值与其配套标点集合的编解码。
+ *
+ * 两者同属一个设置项:划分方式决定是否展示标点多选,标点集合只对「按符号」有意义。
+ * 由于 UI 只经单一意图通道回传,这里用 `方式|标点` 的紧凑编码把两者一起带上,
+ * 避免为一个下拉框再增加一套意图。
+ */
+object ReadAloudContentSplitSetting {
+
+    private const val SEPARATOR = '|'
+
+    fun encode(mode: ReadAloudContentSplitMode, symbols: Collection<Char>): String =
+        mode.storageValue +
+                SEPARATOR +
+                symbols.sorted().joinToString("")
+
+    fun decode(value: String): Pair<ReadAloudContentSplitMode, Set<Char>> {
+        val modePart = value.substringBefore(SEPARATOR)
+        val symbolPart = value.substringAfter(SEPARATOR, "")
+        return ReadAloudContentSplitMode.fromStorage(modePart) to
+                symbolPart.toCharArray().toSet()
+    }
+}
+
+/**
+ * 「按符号」划分方式可选的标点。展示顺序与设置页顺序一致。
+ */
+enum class ReadAloudSplitSymbol(val symbol: Char, val labelKey: String) {
+    FullStop('。', "symbol_period"),
+    Exclamation('!', "symbol_exclamation"),
+    Question('?', "symbol_question"),
+    Ellipsis('…', "symbol_ellipsis"),
+    Semicolon(';', "symbol_semicolon"),
+    Comma(',', "symbol_comma"),
+    EnumerationComma('、', "symbol_enumeration_comma"),
+    Colon(':', "symbol_colon"),
+    Dot('.', "symbol_halfwidth_period"),
+    Bang('!', "symbol_halfwidth_exclamation"),
+    QuestionMark('?', "symbol_halfwidth_question"),
+    HalfSemicolon(';', "symbol_halfwidth_semicolon"),
+    HalfComma(',', "symbol_halfwidth_comma"),
+    HalfColon(':', "symbol_halfwidth_colon"),
+    ;
+
+    companion object {
+        /** 「默认」与「按符号」在未另行选择时的句末标点集合。 */
+        val sentenceEnds: Set<Char> = setOf('。', '!', '?', '…')
+
+        private val bySymbol: Map<Char, ReadAloudSplitSymbol> = entries.associateBy { it.symbol }
+
+        fun fromSymbol(symbol: Char): ReadAloudSplitSymbol? = bySymbol[symbol]
+
+        /** 把存储值过滤成合法标点集合;空集合回退到句末标点,避免「按符号」退化成整段。 */
+        fun resolveSymbols(stored: Collection<String>): Set<Char> {
+            val resolved = stored
+                .mapNotNull { it.toCharArray().firstOrNull() }
+                .filter { it in bySymbol }
+                .toSet()
+            return resolved.ifEmpty { sentenceEnds }
+        }
+
+        fun storageValues(symbols: Collection<Char>): Set<String> =
+            symbols.map { it.toString() }.toSet()
+    }
+}
+
+/**
+ * 按 [policy] 把章节语义文本切成朗读划分单元。
+ *
+ * 「按符号」与「默认」把选中的标点切在单元末尾(标点本身保留在单元里,不丢字);
+ * 「整段/整页」由调用方提供已经切好的行段落,这里只负责透明返回。
+ */
+object ReadAloudContentSplitter {
+
+    fun splitLines(
+        semanticContent: String,
+        policy: ContentSplitPolicy,
+    ): List<ReadAloudSplitUnit> = splitParagraphs(lineParagraphs(semanticContent), policy)
+
+    private fun ContentSplitPolicy.splitsAtSymbols(): Boolean =
+        this.symbols.isNotEmpty() &&
+                (mode == ReadAloudContentSplitMode.Symbols || mode == ReadAloudContentSplitMode.Default)
+
+    fun splitParagraphs(
+        paragraphs: List<ReadAloudSplitUnit>,
+        policy: ContentSplitPolicy,
+    ): List<ReadAloudSplitUnit> {
+        if (!policy.splitsAtSymbols()) return paragraphs
+        return paragraphs.flatMap { paragraph -> splitAtSymbols(paragraph, policy.symbols) }
+    }
+
+    /** 章节语义文本按换行切段,不引入额外字符位置。 */
+    fun lineParagraphs(semanticContent: String): List<ReadAloudSplitUnit> = buildList {
+        var start = 0
+        semanticContent.forEachIndexed { index, character ->
+            if (character == '\n') {
+                if (index > start) add(
+                    ReadAloudSplitUnit(
+                        semanticContent.substring(start, index),
+                        start
+                    )
+                )
+                start = index + 1
+            }
+        }
+        if (start < semanticContent.length) {
+            add(ReadAloudSplitUnit(semanticContent.substring(start), start))
+        }
+    }
+
+    private fun splitAtSymbols(
+        paragraph: ReadAloudSplitUnit,
+        symbols: Set<Char>,
+    ): List<ReadAloudSplitUnit> {
+        val text = paragraph.text
+        if (text.isEmpty()) return emptyList()
+        val result = mutableListOf<ReadAloudSplitUnit>()
+        var start = 0
+        var index = 0
+        while (index < text.length) {
+            if (text[index] !in symbols) {
+                index++
+                continue
+            }
+            // 连续标点视为一个整体,切在同一单元末尾
+            var end = index + 1
+            while (end < text.length && text[end] in symbols) end++
+            result += ReadAloudSplitUnit(
+                text = text.substring(start, end),
+                chapterPosition = paragraph.chapterPosition + start,
+            )
+            start = end
+            index = end
+        }
+        if (start < text.length) {
+            result += ReadAloudSplitUnit(
+                text = text.substring(start),
+                chapterPosition = paragraph.chapterPosition + start,
+            )
+        }
+        return result
+    }
+}

+ 29 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt

@@ -2,6 +2,32 @@ package io.legado.app.domain.model.settings
 
 import io.legado.app.domain.model.AiReasoningLevel
 
+/**
+ * 听书内容划分方式:决定一段正文被切成多少个朗读单元。
+ *
+ * [Default] 跟随多角色朗读开关:开启时按句末标点切分(多角色需要句级片段才能分角色),
+ * 未开启时按整段切分。其余取值是显式的固定粒度,不受多角色开关影响。
+ */
+enum class ReadAloudContentSplitMode(val storageValue: String) {
+    Default("default"),
+    Paragraph("paragraph"),
+    Page("page"),
+    Symbols("symbols");
+
+    /** 按符号划分时是否允许单元内再按引号/冒号切成多个角色片段。 */
+    val allowsRoleSplits: Boolean
+        get() = when (this) {
+            Default -> true
+            Symbols -> true
+            Paragraph, Page -> false
+        }
+
+    companion object {
+        fun fromStorage(value: String): ReadAloudContentSplitMode =
+            entries.firstOrNull { it.storageValue == value } ?: Default
+    }
+}
+
 data class ReadAloudSettings(
     val ttsEngine: String? = null,
     val ttsParagraphInterval: Int = 0,
@@ -17,6 +43,9 @@ data class ReadAloudSettings(
     val capsuleOffsetY: Float = 0f,
     val mediaButtonPerNext: Boolean = false,
     val readAloudByPage: Boolean = false,
+    val contentSplitMode: String = ReadAloudContentSplitMode.Default.storageValue,
+    /** 「按符号」划分方式选中的标点;空集合表示使用默认句末标点。 */
+    val contentSplitSymbols: Set<String> = emptySet(),
     val androidMediaControlEnabled: Boolean = false,
     val systemMediaControlCompatibilityChange: Boolean = true,
     val streamReadAloudAudio: Boolean = false,

+ 3 - 1
app/src/main/java/io/legado/app/domain/usecase/AnalyzeChapterSpeechUseCase.kt

@@ -5,6 +5,7 @@ import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
 import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysis
 import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysisResult
 import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+import io.legado.app.domain.model.readaloud.ContentSplitPolicy
 import io.legado.app.domain.model.readaloud.SpeechAnalysisStatus
 import io.legado.app.domain.model.readaloud.SpeechIdentity
 import io.legado.app.domain.model.readaloud.SpeechRoleType
@@ -19,6 +20,7 @@ class AnalyzeChapterSpeechUseCase(
         chapterIndex: Int,
         paragraphs: List<CanonicalSpeechParagraph>,
         resolverVersion: String = RuleBasedSpeechSegmenter.VERSION,
+        policy: ContentSplitPolicy,
         now: Long = System.currentTimeMillis(),
     ): ChapterSpeechAnalysisResult {
         val contentHash = SpeechIdentity.chapterContentHash(paragraphs)
@@ -45,7 +47,7 @@ class AnalyzeChapterSpeechUseCase(
             contentHash = contentHash,
             resolverVersion = resolverVersion,
         )
-        val drafts = RuleBasedSpeechSegmenter.segment(paragraphs)
+        val drafts = RuleBasedSpeechSegmenter.segment(paragraphs, policy)
         val segments = drafts.map { draft ->
             ChapterSpeechSegment(
                 id = SpeechIdentity.segmentId(

+ 22 - 5
app/src/main/java/io/legado/app/domain/usecase/PrepareChapterSpeechPlanUseCase.kt

@@ -2,8 +2,9 @@ package io.legado.app.domain.usecase
 
 import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
-import io.legado.app.domain.model.readaloud.SpeechPlanItem
+import io.legado.app.domain.model.readaloud.ContentSplitPolicy
 import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
+import io.legado.app.domain.model.readaloud.SpeechPlanItem
 import io.legado.app.help.readaloud.segment.RuleBasedSpeechSegmenter
 
 /**
@@ -28,16 +29,18 @@ class PrepareChapterSpeechPlanUseCase(
         analysisMode: SpeechAnalysisMode = SpeechAnalysisMode.Rule,
         analysisReasoningLevel: AiReasoningLevel = AiReasoningLevel.OFF,
         useMultiSpeaker: Boolean = true,
+        policy: ContentSplitPolicy,
     ): List<SpeechPlanItem> {
         if (paragraphs.isEmpty()) return emptyList()
         val requestedMode = analysisMode
+        val ruleVersion = ruleResolverVersion(policy)
         val resolverVersion = if (requestedMode == SpeechAnalysisMode.Rule) {
-            RuleBasedSpeechSegmenter.VERSION
+            ruleVersion
         } else {
-            runCatching { refineSpeechWithAi.resolverVersion(bookUrl, requestedMode) }
-                .getOrDefault(RuleBasedSpeechSegmenter.VERSION)
+            runCatching { refineSpeechWithAi.resolverVersion(bookUrl, requestedMode, policy) }
+                .getOrDefault(ruleVersion)
         }
-        val effectiveMode = if (resolverVersion == RuleBasedSpeechSegmenter.VERSION) {
+        val effectiveMode = if (isRuleResolverVersion(resolverVersion, policy)) {
             SpeechAnalysisMode.Rule
         } else {
             requestedMode
@@ -47,6 +50,7 @@ class PrepareChapterSpeechPlanUseCase(
             chapterIndex = chapterIndex,
             paragraphs = paragraphs,
             resolverVersion = resolverVersion,
+            policy = policy,
         )
         val locallyResolved = resolveLocalSpeakers(
             analysisResult = analysis,
@@ -61,6 +65,7 @@ class PrepareChapterSpeechPlanUseCase(
                     paragraphs = paragraphs,
                     mode = effectiveMode,
                     reasoningLevel = analysisReasoningLevel,
+                    policy = policy,
                 )
             }.getOrDefault(locallyResolved)
         }
@@ -73,3 +78,15 @@ class PrepareChapterSpeechPlanUseCase(
         )
     }
 }
+
+/**
+ * 纯规则分段的分析标识。
+ *
+ * 内容划分方式会改变切分结果,必须参与版本号,否则切换划分方式后会命中旧划分的分析缓存。
+ */
+fun ruleResolverVersion(policy: ContentSplitPolicy): String =
+    "${RuleBasedSpeechSegmenter.VERSION}:${policy.identifier}"
+
+/** 判断某个解析器版本是否就是该划分方式下的纯规则分段。 */
+fun isRuleResolverVersion(resolverVersion: String, policy: ContentSplitPolicy): Boolean =
+    resolverVersion == ruleResolverVersion(policy)

+ 14 - 5
app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt

@@ -17,6 +17,7 @@ import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
 import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysisResult
 import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+import io.legado.app.domain.model.readaloud.ContentSplitPolicy
 import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
 import io.legado.app.domain.model.readaloud.SpeechAnalysisStatus
 import io.legado.app.domain.model.readaloud.SpeechEmotion
@@ -25,7 +26,6 @@ import io.legado.app.domain.model.readaloud.SpeechResolutionSource
 import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.help.readaloud.segment.AiSpeechAtom
 import io.legado.app.help.readaloud.segment.AiSpeechAtomizer
-import io.legado.app.help.readaloud.segment.RuleBasedSpeechSegmenter
 import io.legado.app.utils.GSON
 import io.legado.app.utils.MD5Utils
 
@@ -36,8 +36,12 @@ class RefineSpeechWithAiUseCase(
     private val chapterSpeechGateway: ChapterSpeechGateway,
 ) {
 
-    suspend fun resolverVersion(bookUrl: String, mode: SpeechAnalysisMode): String {
-        if (mode == SpeechAnalysisMode.Rule) return RuleBasedSpeechSegmenter.VERSION
+    suspend fun resolverVersion(
+        bookUrl: String,
+        mode: SpeechAnalysisMode,
+        policy: ContentSplitPolicy,
+    ): String {
+        if (mode == SpeechAnalysisMode.Rule) return ruleResolverVersion(policy)
         val preset = resolvePreset()
         val profiles = activeProfiles(bookUrl)
         val characterRevision = profiles
@@ -45,7 +49,8 @@ class RefineSpeechWithAiUseCase(
             .joinToString("|") { "${it.id}:${it.updatedAt}" }
         val promptHash = MD5Utils.md5Encode(systemPrompt(preset, mode))
         return listOf(
-            RuleBasedSpeechSegmenter.VERSION,
+            // 与纯规则分段共用同一前缀,AI 结果才不会被误判成规则模式
+            ruleResolverVersion(policy),
             VERSION,
             mode.storageValue,
             preset.model.id,
@@ -59,6 +64,7 @@ class RefineSpeechWithAiUseCase(
         paragraphs: List<CanonicalSpeechParagraph>,
         mode: SpeechAnalysisMode,
         reasoningLevel: AiReasoningLevel = AiReasoningLevel.OFF,
+        policy: ContentSplitPolicy = ContentSplitPolicy.SentenceLevel,
         now: Long = System.currentTimeMillis(),
     ): ChapterSpeechAnalysisResult {
         if (mode == SpeechAnalysisMode.Rule) return analysisResult
@@ -79,7 +85,10 @@ class RefineSpeechWithAiUseCase(
                 now = now,
             )
             SpeechAnalysisMode.AiUnderstanding -> {
-                if (analysisResult.segments.any(ChapterSpeechSegment::userLocked)) {
+                // 整段/整页划分下不能走原子理解:`AiSpeechAtomizer` 会按句末标点把一段重新
+                // 拆成多个片段,让用户显式选择的「一段 = 一个播放单元」失效。此时只让 AI
+                // 补全说话人与情绪,边界仍由规则分段器提供的整单元保持。
+                if (!policy.allowRoleSplits || analysisResult.segments.any(ChapterSpeechSegment::userLocked)) {
                     completeRuleSegments(analysisResult, profiles, preset, reasoningLevel, now)
                 } else {
                     understandAtoms(analysisResult, paragraphs, profiles, preset, reasoningLevel, now)

+ 81 - 23
app/src/main/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapter.kt

@@ -1,6 +1,11 @@
 package io.legado.app.feature.reader.core.readaloud
 
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.domain.model.readaloud.ContentSplitPolicies
+import io.legado.app.domain.model.readaloud.ContentSplitPolicy
+import io.legado.app.domain.model.readaloud.ReadAloudContentSplitter
+import io.legado.app.domain.model.readaloud.ReadAloudSplitUnit
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 
 data class ReaderReadAloudParagraph(
     val text: String,
@@ -17,6 +22,8 @@ data class ReaderReadAloudChapter(
     val paragraphs: List<ReaderReadAloudParagraph>,
     val pageParagraphs: List<ReaderReadAloudParagraph>,
     val chapterLength: Int,
+    /** 生成 [paragraphs] 所用的划分方式,朗读服务据此保持段落与播放队列同粒度。 */
+    val contentSplitMode: ReadAloudContentSplitMode = ReadAloudContentSplitMode.Default,
 ) {
     val pageCount: Int get() = pageStarts.size
 
@@ -27,16 +34,37 @@ data class ReaderReadAloudChapter(
         .coerceAtLeast(0)
         .coerceAtMost((pageStarts.size - 1).coerceAtLeast(0))
 
-    fun paragraphs(splitByPage: Boolean): List<ReaderReadAloudParagraph> =
-        if (splitByPage) pageParagraphs else paragraphs
+    /**
+     * 朗读单元集合。
+     *
+     * [splitByPage] 决定用整页切分视图还是段落视图;[policy] 在其上再做一次划分方式覆盖
+     * (「按符号」需要重切,整段/整页本身已是目标粒度,重切是恒等操作)。
+     */
+    fun paragraphs(
+        splitByPage: Boolean,
+        policy: ContentSplitPolicy? = null,
+    ): List<ReaderReadAloudParagraph> {
+        val source = if (splitByPage) pageParagraphs else paragraphs
+        return if (policy == null) source else policy.applyTo(source)
+    }
 
-    fun paragraphIndexAtOrAfter(position: Int, splitByPage: Boolean): Int =
-        paragraphs(splitByPage).indexOfFirst { it.endPosition >= position }
+    fun paragraphIndexAtOrAfter(
+        position: Int,
+        splitByPage: Boolean,
+        policy: ContentSplitPolicy? = null,
+    ): Int = paragraphs(splitByPage, policy).indexOfFirst { it.endPosition >= position }
 
-    fun canonicalSpeechParagraphs(): List<CanonicalSpeechParagraph> = paragraphs.mapIndexed {
-        index, paragraph ->
-        CanonicalSpeechParagraph(index, paragraph.text.sanitizeForSpeech(), paragraph.chapterPosition)
-    }
+    fun canonicalSpeechParagraphs(
+        splitByPage: Boolean,
+        policy: ContentSplitPolicy? = null,
+    ): List<CanonicalSpeechParagraph> =
+        paragraphs(splitByPage, policy).mapIndexed { index, paragraph ->
+            CanonicalSpeechParagraph(
+                index,
+                paragraph.text.sanitizeForSpeech(),
+                paragraph.chapterPosition
+            )
+        }
 
     companion object {
         fun create(
@@ -44,6 +72,7 @@ data class ReaderReadAloudChapter(
             title: String,
             semanticContent: String,
             pageStarts: List<Int>,
+            contentSplitMode: ReadAloudContentSplitMode = ReadAloudContentSplitMode.Paragraph,
         ): ReaderReadAloudChapter {
             val normalizedStarts = pageStarts
                 .asSequence()
@@ -52,18 +81,26 @@ data class ReaderReadAloudChapter(
                 .sorted()
                 .toList()
                 .ifEmpty { listOf(0) }
-            val paragraphs = semanticContent.lineParagraphs()
+            val policy = ContentSplitPolicies.forMode(contentSplitMode)
+            val units = ReadAloudContentSplitter.splitLines(semanticContent, policy)
+            val paragraphs = units.map { it.toParagraph(isParagraphEnd = true) }
+            // 页边界用整段单元切分,保证 pageParagraphs 与 paragraphs 粒度一致:
+            // 按页朗读时「整页」划分应由调用方另外选择 Page 划分方式。
             val pageParagraphs = paragraphs.flatMap { paragraph ->
-                val cuts = normalizedStarts.filter { it > paragraph.chapterPosition && it < paragraph.endPosition }
+                val cuts = normalizedStarts.filter {
+                    it > paragraph.chapterPosition && it < paragraph.endPosition
+                }
                 buildList {
                     var start = paragraph.chapterPosition
                     (cuts + paragraph.endPosition).forEach { end ->
                         if (end > start) {
-                            add(ReaderReadAloudParagraph(
-                                text = semanticContent.substring(start, end),
-                                chapterPosition = start,
-                                isParagraphEnd = end == paragraph.endPosition,
-                            ))
+                            add(
+                                ReaderReadAloudParagraph(
+                                    text = semanticContent.substring(start, end),
+                                    chapterPosition = start,
+                                    isParagraphEnd = end == paragraph.endPosition,
+                                )
+                            )
                         }
                         start = end
                     }
@@ -76,20 +113,41 @@ data class ReaderReadAloudChapter(
                 paragraphs = paragraphs,
                 pageParagraphs = pageParagraphs,
                 chapterLength = paragraphs.lastOrNull()?.endPosition ?: 0,
+                contentSplitMode = contentSplitMode,
             )
         }
     }
 }
 
-private fun String.lineParagraphs(): List<ReaderReadAloudParagraph> = buildList {
-    var start = 0
-    this@lineParagraphs.forEachIndexed { index, character ->
-        if (character == '\n') {
-            if (index > start) add(ReaderReadAloudParagraph(substring(start, index), start, true))
-            start = index + 1
-        }
+/**
+ * 用 [policy] 重切已有的朗读单元,保留每个单元的绝对位置。
+ * 整页单元可能横跨多个原始段落,因此按绝对位置反查所属单元。
+ */
+internal fun ContentSplitPolicy.applyTo(
+    units: List<ReaderReadAloudParagraph>,
+): List<ReaderReadAloudParagraph> {
+    if (symbols.isEmpty()) return units
+    val splitUnits = ReadAloudContentSplitter.splitParagraphs(
+        paragraphs = units.map { ReadAloudSplitUnit(it.text, it.chapterPosition) },
+        policy = this,
+    )
+    return splitUnits.map { split ->
+        val owner = units.lastOrNull { it.chapterPosition <= split.chapterPosition }
+        ReaderReadAloudParagraph(
+            text = split.text,
+            chapterPosition = split.chapterPosition,
+            // 只有切到所属单元末尾才算段末;中间切出的子单元仍属同一段
+            isParagraphEnd = owner != null &&
+                    split.chapterPosition + split.text.length >= owner.endPosition,
+        )
     }
-    if (start < length) add(ReaderReadAloudParagraph(substring(start), start, true))
 }
 
+private fun ReadAloudSplitUnit.toParagraph(isParagraphEnd: Boolean): ReaderReadAloudParagraph =
+    ReaderReadAloudParagraph(
+        text = text,
+        chapterPosition = chapterPosition,
+        isParagraphEnd = isParagraphEnd,
+    )
+
 private fun String.sanitizeForSpeech(): String = replace(Regex("[袮祢꧁\uFFFC]"), " ")

+ 23 - 2
app/src/main/java/io/legado/app/help/readaloud/segment/RuleBasedSpeechSegmenter.kt

@@ -1,6 +1,7 @@
 package io.legado.app.help.readaloud.segment
 
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.domain.model.readaloud.ContentSplitPolicy
 import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.readaloud.SpeechSegmentDraft
 
@@ -9,10 +10,13 @@ import io.legado.app.domain.model.readaloud.SpeechSegmentDraft
  *
  * Every non-empty input paragraph is fully covered by ordered, non-overlapping output ranges.
  * Character identity resolution is intentionally left to a later stage.
+ *
+ * 输入段落已经是「内容划分方式」切好的朗读单元;[ContentSplitPolicy.allowRoleSplits] 为 false 时
+ * (整段/整页划分)不再在单元内按引号与冒号拆出角色片段,保证一个单元就是一个播放片段。
  */
 object RuleBasedSpeechSegmenter {
 
-    const val VERSION = "rule-segmenter-v2-emotion"
+    const val VERSION = "rule-segmenter-v3-content-split"
 
     private val quotePairs = mapOf(
         '“' to '”',
@@ -28,8 +32,25 @@ object RuleBasedSpeechSegmenter {
         "(?:说|说道|问|问道|答|答道|喊|喊道|叫|叫道|喝道|笑道|低声道|沉声道|怒道|开口道)$"
     )
 
-    fun segment(paragraphs: List<CanonicalSpeechParagraph>): List<SpeechSegmentDraft> {
+    fun segment(paragraphs: List<CanonicalSpeechParagraph>): List<SpeechSegmentDraft> =
+        segment(paragraphs, ContentSplitPolicy.SentenceLevel)
+
+    fun segment(
+        paragraphs: List<CanonicalSpeechParagraph>,
+        policy: ContentSplitPolicy?,
+    ): List<SpeechSegmentDraft> {
         if (paragraphs.isEmpty()) return emptyList()
+        if (policy != null && !policy.allowRoleSplits) {
+            return paragraphs.mapNotNull { paragraph ->
+                if (paragraph.text.isEmpty()) null
+                else paragraph.segment(
+                    0,
+                    paragraph.text.length,
+                    SpeechRoleType.Narrator,
+                    0.95f,
+                )
+            }
+        }
         val result = mutableListOf<SpeechSegmentDraft>()
         var openQuote: OpenQuote? = null
 

+ 48 - 7
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -39,6 +39,8 @@ import io.legado.app.constant.Status
 import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.domain.model.readaloud.ContentSplitPolicies
+import io.legado.app.domain.model.readaloud.ContentSplitPolicy
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackCursor
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackInfo
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackQueue
@@ -46,6 +48,7 @@ import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
 import io.legado.app.domain.model.readaloud.SpeechPlanItem
 import io.legado.app.domain.model.readaloud.resolveReadAloudStartPosition
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
 import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
 import io.legado.app.help.MediaHelp
@@ -235,8 +238,6 @@ abstract class BaseReadAloudService : BaseService(),
     var pageChanged = false
     private var toLast = false
     var paragraphStartPos = 0
-    var readAloudByPage = false
-        private set
 
     /** 当前朗读段在章节语义文本中的绝对起始位置;页内切段不引入换行符,进度必须以它为准 */
     protected fun paragraphChapterPositionAt(index: Int): Int? =
@@ -396,12 +397,15 @@ abstract class BaseReadAloudService : BaseService(),
                 AppLog.put("启动朗读失败:章节分页未完成 chapterIndex=${input.chapter.index}")
                 return@execute
             }
+            val contentSplitMode = resolveContentSplitMode()
             val preparedChapter = ReaderReadAloudChapter.create(
                 chapterIndex = input.chapter.index,
                 title = input.displayTitle,
                 semanticContent = input.source.semanticContent,
                 pageStarts = pagination.pageStarts,
+                contentSplitMode = contentSplitMode,
             )
+            val splitPolicy = contentSplitPolicy(contentSplitMode)
             val start = resolveReadAloudStartPosition(
                 requestedPageIndex = requestedPageIndex,
                 requestedOffsetInPage = requestedStartPos,
@@ -411,10 +415,14 @@ abstract class BaseReadAloudService : BaseService(),
             )
             val pageIndex = start.pageIndex
             val startPos = start.offsetInPage
-            val preparedReadAloudByPage = ReadConfig.readAloudByPage
+            val preparedReadAloudByPage =
+                contentSplitMode == ReadAloudContentSplitMode.Page
             var preparedReadAloudNumber = preparedChapter.pageStart(pageIndex) + startPos
             val startsAtChapterBeginning = preparedReadAloudNumber == 0
-            val preparedParagraphs = preparedChapter.paragraphs(preparedReadAloudByPage)
+            val preparedParagraphs = preparedChapter.paragraphs(
+                splitByPage = preparedReadAloudByPage,
+                policy = splitPolicy,
+            )
             var preparedContentList = preparedParagraphs
                 .map { it.text.replace(Regex("[袮祢꧁\uFFFC]"), " ") }
             var preparedContentChapterPositions: List<Int?> =
@@ -422,7 +430,11 @@ abstract class BaseReadAloudService : BaseService(),
             val preparedSpeechPlan = buildSpeechPlan(
                 bookUrl = ReadBook.book?.bookUrl.orEmpty(),
                 chapterIndex = ReadBook.durChapterIndex,
-                paragraphs = preparedChapter.canonicalSpeechParagraphs(),
+                paragraphs = preparedChapter.canonicalSpeechParagraphs(
+                    splitByPage = preparedReadAloudByPage,
+                    policy = splitPolicy,
+                ),
+                splitPolicy = splitPolicy,
             )
             if (generation != prepareReadAloudGeneration) return@execute
             var preparedPlaybackQueue = runCatching {
@@ -435,7 +447,8 @@ abstract class BaseReadAloudService : BaseService(),
             val usePreparedPlaybackQueue = useSpeechPlaybackQueue && !preparedPlaybackQueue.isEmpty
             var preparedNowSpeak = preparedChapter.paragraphIndexAtOrAfter(
                 preparedReadAloudNumber + 1,
-                preparedReadAloudByPage,
+                splitByPage = preparedReadAloudByPage,
+                policy = splitPolicy,
             )
             if (!usePreparedPlaybackQueue && preparedNowSpeak !in preparedContentList.indices) {
                 AppLog.put(
@@ -492,7 +505,6 @@ abstract class BaseReadAloudService : BaseService(),
             if (generation != prepareReadAloudGeneration) return@execute
             this@BaseReadAloudService.pageIndex = pageIndex
             readerReadAloudChapter = preparedChapter
-            readAloudByPage = preparedReadAloudByPage
             contentList = preparedContentList
             contentChapterPositions = preparedContentChapterPositions
             speechPlan = preparedSpeechPlan
@@ -519,6 +531,7 @@ abstract class BaseReadAloudService : BaseService(),
         bookUrl: String,
         chapterIndex: Int,
         paragraphs: List<CanonicalSpeechParagraph>,
+        splitPolicy: ContentSplitPolicy,
     ): List<SpeechPlanItem> {
         if (bookUrl.isEmpty() || !ReadConfig.useMultiSpeaker) return emptyList()
         val prepareSpeechPlan: PrepareChapterSpeechPlanUseCase =
@@ -534,12 +547,38 @@ abstract class BaseReadAloudService : BaseService(),
                     AiReasoningLevel.OFF,
                 ),
                 useMultiSpeaker = ReadConfig.useMultiSpeaker,
+                policy = splitPolicy,
             )
         }.onFailure {
             AppLog.put("生成多角色朗读计划失败,使用原朗读方式\n${it.localizedMessage}", it)
         }.getOrDefault(emptyList())
     }
 
+    /**
+     * 把用户选择的划分方式解析成实际生效的划分方式。
+     *
+     * 章节划分与播放单元划分都必须经这里解析:`ReaderReadAloudChapter.create` 的切分在
+     * 建章节时完成,若它拿到未解析的「默认」而 `paragraphs(policy)` 拿到解析后的整段,
+     * 两侧粒度就会不一致(多角色开关变化后尤其明显)。
+     */
+    protected fun resolveContentSplitMode(
+        mode: ReadAloudContentSplitMode = ReadAloudContentSplitMode.fromStorage(
+            ReadConfig.contentSplitMode
+        ),
+    ): ReadAloudContentSplitMode =
+        ContentSplitPolicies.resolve(mode, ReadConfig.useMultiSpeaker)
+
+    /** 当前生效的内容划分策略,供朗读服务与预合成共用。 */
+    protected fun contentSplitPolicy(
+        mode: ReadAloudContentSplitMode = ReadAloudContentSplitMode.fromStorage(
+            ReadConfig.contentSplitMode
+        ),
+    ): ContentSplitPolicy =
+        ContentSplitPolicies.forMode(
+            mode = resolveContentSplitMode(mode),
+            storedSymbols = ReadConfig.contentSplitSymbols,
+        )
+
     @SuppressLint("WakelockTimeout")
     open fun play() {
         if (stopRequested) return
@@ -669,6 +708,8 @@ abstract class BaseReadAloudService : BaseService(),
             title = input.displayTitle,
             semanticContent = input.source.semanticContent,
             pageStarts = pagination.pageStarts,
+            contentSplitMode = readerReadAloudChapter?.contentSplitMode
+                ?: resolveContentSplitMode(),
         )
         val latestPageIndex = latestChapter.pageIndexAt((currentProgress - 1).coerceAtLeast(0))
         readerReadAloudChapter = latestChapter

+ 11 - 2
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -44,6 +44,7 @@ import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.readaloud.SpeechVoiceRouter
 import io.legado.app.domain.model.readaloud.SystemTtsVoiceConfig
 import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.domain.model.settings.ReadSettings
 import io.legado.app.exception.NoStackTraceException
@@ -405,16 +406,24 @@ class HttpReadAloudService : BaseReadAloudService(),
             adaptSpecialStyle = readSettings.adaptSpecialStyle,
             htmlSemanticTextResolver = AndroidReaderHtmlSemanticTextResolver,
         )
+        // 预合成必须与实时朗读用同一种划分方式解析,否则预合成好的音频与实际朗读单元对不上
+        val contentSplitMode = resolveContentSplitMode(
+            ReadAloudContentSplitMode.fromStorage(readAloudSettings.contentSplitMode)
+        )
         val readAloudChapter = ReaderReadAloudChapter.create(
             chapterIndex = chapter.index,
             title = displayTitle,
             semanticContent = source.semanticContent,
             pageStarts = ReadBook.readerPagination(chapter.index)?.pageStarts.orEmpty(),
+            contentSplitMode = contentSplitMode,
         )
+        val splitPolicy = contentSplitPolicy(contentSplitMode)
+        val splitByPage = contentSplitMode == ReadAloudContentSplitMode.Page
         val plan = buildSpeechPlan(
             bookUrl = book.bookUrl,
             chapterIndex = chapter.index,
-            paragraphs = readAloudChapter.canonicalSpeechParagraphs(),
+            paragraphs = readAloudChapter.canonicalSpeechParagraphs(splitByPage, splitPolicy),
+            splitPolicy = splitPolicy,
         )
         val queue = runCatching { ReadAloudPlaybackQueue.from(plan).withChapterTitle(displayTitle) }
             .getOrDefault(ReadAloudPlaybackQueue.Empty)
@@ -422,7 +431,7 @@ class HttpReadAloudService : BaseReadAloudService(),
             queue.cues.map { it.text }
         } else {
             listOf(displayTitle.trim()).filter { it.isNotEmpty() } +
-                    readAloudChapter.paragraphs(readAloudSettings.readAloudByPage)
+                    readAloudChapter.paragraphs(splitByPage, splitPolicy)
                         .map { it.text.replace(Regex("[袮祢꧁\uFFFC]"), " ") }
         }
         return PreDownloadChapter(displayTitle, queue, contentList)

+ 28 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -12,7 +12,9 @@ import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.model.readaloud.ReadAloudContentSplitSetting
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.domain.model.readaloud.ReadAloudSplitSymbol
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
 import io.legado.app.domain.model.settings.ReadAloudSettings
@@ -23,6 +25,7 @@ import io.legado.app.model.ReadBook
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.utils.TTSCacheUtils
 import io.legado.app.utils.postEvent
+import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.flow.first
@@ -87,6 +90,8 @@ class ReadAloudDelegate(
                         readAloudCapsuleOffsetY = prefs.capsuleOffsetY,
                         readAloudMediaButtonPerNext = prefs.mediaButtonPerNext,
                         readAloudByPage = prefs.readAloudByPage,
+                        readAloudContentSplitMode = prefs.contentSplitMode,
+                        readAloudContentSplitSymbols = prefs.contentSplitSymbols.toImmutableSet(),
                         readAloudSystemMediaCompat =
                             prefs.systemMediaControlCompatibilityChange,
                         readAloudAndroidMediaControl = prefs.androidMediaControlEnabled,
@@ -364,6 +369,29 @@ class ReadAloudDelegate(
         if (value) postEvent(EventBus.MEDIA_BUTTON, false)
     }
 
+    /**
+     * 应用内容划分方式(含标点集合)。[value] 是
+     * [ReadAloudContentSplitSetting.encode] 的 `方式|标点` 编码。
+     *
+     * 一个意图同时承载两者:划分方式决定是否展示标点多选,两者始终一起提交,
+     * 拆成两套意图只会让 ReadBookViewModel 多长一条 when 分支。
+     */
+    fun setContentSplitMode(value: String) {
+        val (mode, symbols) = ReadAloudContentSplitSetting.decode(value)
+        scope.launch(start = CoroutineStart.UNDISPATCHED) {
+            readAloudSettingsRepository.setContentSplit(
+                mode = mode,
+                symbols = ReadAloudSplitSymbol.storageValues(symbols),
+            )
+        }
+        host.updateState {
+            it.copy(
+                readAloudContentSplitMode = mode.storageValue,
+                readAloudContentSplitSymbols = symbols.map(Char::toString).toImmutableSet(),
+            )
+        }
+    }
+
     fun setStreamAudio(value: Boolean) {
         updateSettings { it.copy(streamReadAloudAudio = value) }
         if (value) postEvent(EventBus.MEDIA_BUTTON, false)

+ 13 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -17,6 +17,7 @@ import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.TextProcessStyle
 import io.legado.app.domain.model.readaloud.SpeechRoleType
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.domain.usecase.BookmarkTargetVerdict
 import io.legado.app.model.translation.TranslationChapterStatus
@@ -24,8 +25,10 @@ import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.searchContent.SearchResult
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.ImmutableSet
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.persistentSetOf
 import kotlin.uuid.Uuid
 
 @Stable
@@ -283,6 +286,10 @@ data class ReadBookUiState(
     val readAloudCapsuleOffsetY: Float = 0f,
     val readAloudMediaButtonPerNext: Boolean = false,
     val readAloudByPage: Boolean = false,
+    /** 内容划分方式([ReadAloudContentSplitMode.storageValue])。 */
+    val readAloudContentSplitMode: String = ReadAloudContentSplitMode.Default.storageValue,
+    /** 「按符号」划分方式选中的标点,空集合表示使用默认句末标点。 */
+    val readAloudContentSplitSymbols: ImmutableSet<String> = persistentSetOf(),
     val readAloudSystemMediaCompat: Boolean = true,
     val readAloudAndroidMediaControl: Boolean = false,
     val readAloudStreamAudio: Boolean = false,
@@ -787,7 +794,12 @@ sealed interface ReadBookIntent {
     data object ResetReadAloudCapsulePosition : ReadBookIntent
     data class SetReadAloudCapsulePosition(val x: Float, val y: Float) : ReadBookIntent
     data class SetReadAloudMediaButtonPerNext(val value: Boolean) : ReadBookIntent
-    data class SetReadAloudByPage(val value: Boolean) : ReadBookIntent
+
+    /**
+     * 内容划分方式与配套标点集合,取值是
+     * [io.legado.app.domain.model.readaloud.ReadAloudContentSplitSetting.encode] 的编码。
+     */
+    data class SetReadAloudContentSplitMode(val value: String) : ReadBookIntent
     data class SetReadAloudSystemMediaCompat(val value: Boolean) : ReadBookIntent
     data class SetReadAloudAndroidMediaControl(val value: Boolean) : ReadBookIntent
     data class SetReadAloudStreamAudio(val value: Boolean) : ReadBookIntent

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -1337,7 +1337,8 @@ class ReadBookViewModel(
                 readAloudDelegate.setCapsulePosition(intent.x, intent.y)
             is ReadBookIntent.SetReadAloudMediaButtonPerNext ->
                 readAloudDelegate.setMediaButtonPerNext(intent.value)
-            is ReadBookIntent.SetReadAloudByPage -> readAloudDelegate.setByPage(intent.value)
+            is ReadBookIntent.SetReadAloudContentSplitMode ->
+                readAloudDelegate.setContentSplitMode(intent.value)
             is ReadBookIntent.SetReadAloudSystemMediaCompat ->
                 readAloudDelegate.setSystemMediaCompat(intent.value)
             is ReadBookIntent.SetReadAloudAndroidMediaControl ->

+ 119 - 8
app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt

@@ -9,6 +9,8 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
@@ -18,6 +20,9 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.readaloud.ReadAloudContentSplitSetting
+import io.legado.app.domain.model.readaloud.ReadAloudSplitSymbol
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerIntent
@@ -26,8 +31,11 @@ import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinySettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import io.legado.app.ui.widget.components.tabRow.CardTabRow
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.launch
 
 @Composable
@@ -147,14 +155,6 @@ fun ReadAloudConfigContent(
                             onIntent(ReadBookIntent.SetReadAloudMediaButtonPerNext(it))
                         },
                     )
-                    TinySwitchSettingItem(
-                        title = stringResource(R.string.read_aloud_by_page),
-                        description = stringResource(R.string.read_aloud_by_page_summary),
-                        checked = state.readAloudByPage,
-                        onCheckedChange = {
-                            onIntent(ReadBookIntent.SetReadAloudByPage(it))
-                        },
-                    )
                     TinySwitchSettingItem(
                         title = stringResource(R.string.read_aloud_android_media_control),
                         description = stringResource(R.string.read_aloud_android_media_control_summary),
@@ -238,6 +238,71 @@ fun ReadAloudConfigContent(
                             onIntent(ReadBookIntent.SetSpeechAnalysisReasoningLevel(it))
                         },
                     )
+                    TinyDropdownSettingItem(
+                        title = stringResource(R.string.read_aloud_content_split_mode),
+                        selectedValue = state.readAloudContentSplitMode,
+                        displayEntries = arrayOf(
+                            stringResource(R.string.read_aloud_content_split_default),
+                            stringResource(R.string.read_aloud_content_split_paragraph),
+                            stringResource(R.string.read_aloud_content_split_page),
+                            stringResource(R.string.read_aloud_content_split_symbols),
+                        ),
+                        entryValues = ReadAloudContentSplitMode.entries
+                            .map { it.storageValue }
+                            .toTypedArray(),
+                        description = when (state.readAloudContentSplitMode) {
+                            ReadAloudContentSplitMode.Paragraph.storageValue ->
+                                stringResource(R.string.read_aloud_content_split_paragraph_summary)
+
+                            ReadAloudContentSplitMode.Page.storageValue ->
+                                stringResource(R.string.read_aloud_content_split_page_summary)
+
+                            ReadAloudContentSplitMode.Symbols.storageValue ->
+                                stringResource(R.string.read_aloud_content_split_symbols_summary)
+
+                            else ->
+                                stringResource(R.string.read_aloud_content_split_default_summary)
+                        },
+                        onValueChange = { value ->
+                            onIntent(
+                                ReadBookIntent.SetReadAloudContentSplitMode(
+                                    ReadAloudContentSplitSetting.encode(
+                                        mode = ReadAloudContentSplitMode.fromStorage(value),
+                                        symbols = state.readAloudContentSplitSymbols
+                                            .mapNotNull { it.firstOrNull() }
+                                            .ifEmpty { ReadAloudSplitSymbol.sentenceEnds },
+                                    )
+                                )
+                            )
+                        },
+                    )
+                    if (state.readAloudContentSplitMode ==
+                        ReadAloudContentSplitMode.Symbols.storageValue
+                    ) {
+                        // 未显式保存过标点时实际生效的是默认句末标点,界面必须显示同一集合,
+                        // 否则勾选框全空、朗读却仍按句末标点切分。
+                        val selected = state.readAloudContentSplitSymbols
+                            .mapNotNull { it.firstOrNull() }
+                            .toSet()
+                            .ifEmpty { ReadAloudSplitSymbol.sentenceEnds }
+                        ContentSplitSymbolSettingItem(
+                            selectedSymbols = selected.map(Char::toString).toImmutableSet(),
+                            onToggle = { symbol, checked ->
+                                // 至少保留一个标点:全部取消会让「按符号」退化成整段
+                                val next = if (checked) selected + symbol else selected - symbol
+                                if (next.isNotEmpty()) {
+                                    onIntent(
+                                        ReadBookIntent.SetReadAloudContentSplitMode(
+                                            ReadAloudContentSplitSetting.encode(
+                                                mode = ReadAloudContentSplitMode.Symbols,
+                                                symbols = next,
+                                            )
+                                        )
+                                    )
+                                }
+                            },
+                        )
+                    }
                     TinySwitchSettingItem(
                         title = stringResource(R.string.use_multi_speaker),
                         description = stringResource(R.string.use_multi_speaker_summary),
@@ -276,6 +341,52 @@ fun ReadAloudConfigContent(
     }
 }
 
+/**
+ * 「按符号」划分方式的标点多选。
+ *
+ * 至少保留一个标点:全部取消会让「按符号」退化成整段,与用户刚选的划分方式矛盾。
+ */
+@Composable
+private fun ContentSplitSymbolSettingItem(
+    selectedSymbols: ImmutableSet<String>,
+    onToggle: (Char, Boolean) -> Unit,
+) {
+    var expanded by remember { mutableStateOf(false) }
+    TinySettingItem(
+        title = stringResource(R.string.read_aloud_content_split_symbols),
+        description = stringResource(R.string.read_aloud_content_split_selected_symbols) + ": " +
+                selectedSymbols.joinToString(" "),
+        expanded = expanded,
+        onExpandChange = { expanded = it },
+        expandContent = {
+            ReadAloudSplitSymbol.entries.forEach { option ->
+                TinySwitchSettingItem(
+                    title = stringResource(symbolLabelRes(option)),
+                    checked = option.symbol.toString() in selectedSymbols,
+                    onCheckedChange = { onToggle(option.symbol, it) },
+                )
+            }
+        },
+    )
+}
+
+private fun symbolLabelRes(option: ReadAloudSplitSymbol): Int = when (option) {
+    ReadAloudSplitSymbol.FullStop -> R.string.symbol_period
+    ReadAloudSplitSymbol.Exclamation -> R.string.symbol_exclamation
+    ReadAloudSplitSymbol.Question -> R.string.symbol_question
+    ReadAloudSplitSymbol.Ellipsis -> R.string.symbol_ellipsis
+    ReadAloudSplitSymbol.Semicolon -> R.string.symbol_semicolon
+    ReadAloudSplitSymbol.Comma -> R.string.symbol_comma
+    ReadAloudSplitSymbol.EnumerationComma -> R.string.symbol_enumeration_comma
+    ReadAloudSplitSymbol.Colon -> R.string.symbol_colon
+    ReadAloudSplitSymbol.Dot -> R.string.symbol_halfwidth_period
+    ReadAloudSplitSymbol.Bang -> R.string.symbol_halfwidth_exclamation
+    ReadAloudSplitSymbol.QuestionMark -> R.string.symbol_halfwidth_question
+    ReadAloudSplitSymbol.HalfSemicolon -> R.string.symbol_halfwidth_semicolon
+    ReadAloudSplitSymbol.HalfComma -> R.string.symbol_halfwidth_comma
+    ReadAloudSplitSymbol.HalfColon -> R.string.symbol_halfwidth_colon
+}
+
 @Composable
 fun ReadAloudNumberConfigSheet(
     show: Boolean,

+ 9 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt

@@ -7,7 +7,9 @@ import io.legado.app.constant.EventBus
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.model.readaloud.ContentSplitPolicies
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
@@ -132,12 +134,19 @@ class ReadAloudPlayerCoordinator(
     private fun snapshotBook(): BookState {
         val book = ReadBook.book
         val input = ReadBook.readerChapterInputWindow.current
+        val settings = readAloudSettingsGateway.currentSettings
         val chapter = input?.let {
             ReaderReadAloudChapter.create(
                 chapterIndex = it.chapter.index,
                 title = it.displayTitle,
                 semanticContent = it.source.semanticContent,
                 pageStarts = ReadBook.readerPagination(it.chapter.index)?.pageStarts.orEmpty(),
+                // 与朗读服务同口径:「默认」在多角色关闭时落到整段,否则听书页展示的
+                // 文本行会与服务实际播放的单元粒度不一致。
+                contentSplitMode = ContentSplitPolicies.resolve(
+                    mode = ReadAloudContentSplitMode.fromStorage(settings.contentSplitMode),
+                    useMultiSpeaker = settings.useMultiSpeaker,
+                ),
             )
         }
         return BookState(

+ 2 - 1
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt

@@ -93,7 +93,8 @@ object ReadConfig {
     val pauseReadAloudWhilePhoneCalls get() = aloud.pauseReadAloudWhilePhoneCalls
     val readAloudWakeLock get() = aloud.readAloudWakeLock
     val mediaButtonPerNext get() = aloud.mediaButtonPerNext
-    val readAloudByPage get() = aloud.readAloudByPage
+    val contentSplitMode get() = aloud.contentSplitMode
+    val contentSplitSymbols get() = aloud.contentSplitSymbols
     val androidMediaControlEnabled get() = aloud.androidMediaControlEnabled
     val systemMediaControlCompatibilityChange get() = aloud.systemMediaControlCompatibilityChange
     val streamReadAloudAudio get() = aloud.streamReadAloudAudio

+ 25 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -2907,6 +2907,31 @@
     <string name="speech_analysis_reasoning_level_summary">默认关闭。默认开启思考的模型(智谱 GLM、DeepSeek 等)只把内容放在推理字段里,会导致分析失败</string>
     <string name="use_multi_speaker">多角色朗读</string>
     <string name="use_multi_speaker_summary">为旁白、对话等不同角色使用不同音色</string>
+    <string name="read_aloud_content_split_mode">内容划分方式</string>
+    <string name="read_aloud_content_split_default">默认</string>
+    <string name="read_aloud_content_split_default_summary">开启多角色朗读时按句末标点划分,否则按整段划分</string>
+    <string name="read_aloud_content_split_paragraph">整段</string>
+    <string name="read_aloud_content_split_paragraph_summary">一段正文作为一个朗读单元,段内不产生停顿</string>
+    <string name="read_aloud_content_split_page">整页</string>
+    <string name="read_aloud_content_split_page_summary">按页划分朗读单元,及时翻页(等同原来的按页朗读)</string>
+    <string name="read_aloud_content_split_symbols">按符号</string>
+    <string name="read_aloud_content_split_symbols_summary">在选中的标点后划分,每个标点处都会停顿</string>
+    <string name="read_aloud_content_split_selected_symbols">已选标点</string>
+    <string name="read_aloud_content_split_symbols_required">至少保留一个标点</string>
+    <string name="symbol_period">句号 。</string>
+    <string name="symbol_exclamation">叹号 !</string>
+    <string name="symbol_question">问号 ?</string>
+    <string name="symbol_ellipsis">省略号 …</string>
+    <string name="symbol_semicolon">分号 ;</string>
+    <string name="symbol_comma">逗号 ,</string>
+    <string name="symbol_enumeration_comma">顿号 、</string>
+    <string name="symbol_colon">冒号 :</string>
+    <string name="symbol_halfwidth_period">半角句点 .</string>
+    <string name="symbol_halfwidth_exclamation">半角叹号 !</string>
+    <string name="symbol_halfwidth_question">半角问号 ?</string>
+    <string name="symbol_halfwidth_semicolon">半角分号 ;</string>
+    <string name="symbol_halfwidth_comma">半角逗号 ,</string>
+    <string name="symbol_halfwidth_colon">半角冒号 :</string>
     <string name="read_aloud_adjust_speed">调整语速</string>
     <string name="read_aloud_preparing_content">正在准备朗读内容…</string>
     <string name="read_aloud_default_tts">默认 TTS</string>

+ 25 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -2690,6 +2690,31 @@
     <string name="speech_analysis_reasoning_level_summary">預設關閉。預設開啟思考的模型(智譜 GLM、DeepSeek 等)只把內容放在推理欄位裡,會導致分析失敗</string>
     <string name="use_multi_speaker">多角色朗讀</string>
     <string name="use_multi_speaker_summary">為旁白、對話等不同角色使用不同音色</string>
+    <string name="read_aloud_content_split_mode">內容劃分方式</string>
+    <string name="read_aloud_content_split_default">預設</string>
+    <string name="read_aloud_content_split_default_summary">開啟多角色朗讀時按句末標點劃分,否則按整段劃分</string>
+    <string name="read_aloud_content_split_paragraph">整段</string>
+    <string name="read_aloud_content_split_paragraph_summary">一段正文作為一個朗讀單元,段內不產生停頓</string>
+    <string name="read_aloud_content_split_page">整頁</string>
+    <string name="read_aloud_content_split_page_summary">按頁劃分朗讀單元,及時翻頁(等同原本的按頁朗讀)</string>
+    <string name="read_aloud_content_split_symbols">按符號</string>
+    <string name="read_aloud_content_split_symbols_summary">在選中的標點後劃分,每個標點處都會停頓</string>
+    <string name="read_aloud_content_split_selected_symbols">已選標點</string>
+    <string name="read_aloud_content_split_symbols_required">至少保留一個標點</string>
+    <string name="symbol_period">句號 。</string>
+    <string name="symbol_exclamation">嘆號 !</string>
+    <string name="symbol_question">問號 ?</string>
+    <string name="symbol_ellipsis">省略號 …</string>
+    <string name="symbol_semicolon">分號 ;</string>
+    <string name="symbol_comma">逗號 ,</string>
+    <string name="symbol_enumeration_comma">頓號 、</string>
+    <string name="symbol_colon">冒號 :</string>
+    <string name="symbol_halfwidth_period">半角句點 .</string>
+    <string name="symbol_halfwidth_exclamation">半角嘆號 !</string>
+    <string name="symbol_halfwidth_question">半角問號 ?</string>
+    <string name="symbol_halfwidth_semicolon">半角分號 ;</string>
+    <string name="symbol_halfwidth_comma">半角逗號 ,</string>
+    <string name="symbol_halfwidth_colon">半角冒號 :</string>
     <string name="read_aloud_adjust_speed">調整語速</string>
     <string name="read_aloud_preparing_content">正在準備朗讀內容…</string>
     <string name="read_aloud_default_tts">預設 TTS</string>

+ 25 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -2695,6 +2695,31 @@
     <string name="speech_analysis_reasoning_level_summary">預設關閉。預設開啟思考的模型(智譜 GLM、DeepSeek 等)只把內容放在推理欄位裡,會導致分析失敗</string>
     <string name="use_multi_speaker">多角色朗讀</string>
     <string name="use_multi_speaker_summary">為旁白、對話等不同角色使用不同音色</string>
+    <string name="read_aloud_content_split_mode">內容劃分方式</string>
+    <string name="read_aloud_content_split_default">預設</string>
+    <string name="read_aloud_content_split_default_summary">開啟多角色朗讀時按句末標點劃分,否則按整段劃分</string>
+    <string name="read_aloud_content_split_paragraph">整段</string>
+    <string name="read_aloud_content_split_paragraph_summary">一段正文作為一個朗讀單元,段內不產生停頓</string>
+    <string name="read_aloud_content_split_page">整頁</string>
+    <string name="read_aloud_content_split_page_summary">按頁劃分朗讀單元,及時翻頁(等同原本的按頁朗讀)</string>
+    <string name="read_aloud_content_split_symbols">按符號</string>
+    <string name="read_aloud_content_split_symbols_summary">在選中的標點後劃分,每個標點處都會停頓</string>
+    <string name="read_aloud_content_split_selected_symbols">已選標點</string>
+    <string name="read_aloud_content_split_symbols_required">至少保留一個標點</string>
+    <string name="symbol_period">句號 。</string>
+    <string name="symbol_exclamation">嘆號 !</string>
+    <string name="symbol_question">問號 ?</string>
+    <string name="symbol_ellipsis">省略號 …</string>
+    <string name="symbol_semicolon">分號 ;</string>
+    <string name="symbol_comma">逗號 ,</string>
+    <string name="symbol_enumeration_comma">頓號 、</string>
+    <string name="symbol_colon">冒號 :</string>
+    <string name="symbol_halfwidth_period">半角句點 .</string>
+    <string name="symbol_halfwidth_exclamation">半角嘆號 !</string>
+    <string name="symbol_halfwidth_question">半角問號 ?</string>
+    <string name="symbol_halfwidth_semicolon">半角分號 ;</string>
+    <string name="symbol_halfwidth_comma">半角逗號 ,</string>
+    <string name="symbol_halfwidth_colon">半角冒號 :</string>
     <string name="read_aloud_adjust_speed">調整語速</string>
     <string name="read_aloud_preparing_content">正在準備朗讀內容…</string>
     <string name="read_aloud_default_tts">預設 TTS</string>

+ 25 - 0
app/src/main/res/values/strings.xml

@@ -2912,6 +2912,31 @@
     <string name="speech_analysis_reasoning_level_summary">Off by default. Models that think by default (Zhipu GLM, DeepSeek, …) return the answer only in the reasoning field, which makes analysis fail</string>
     <string name="use_multi_speaker">Multi-speaker reading</string>
     <string name="use_multi_speaker_summary">Use different voices for narration, dialogue and other roles</string>
+    <string name="read_aloud_content_split_mode">Content split</string>
+    <string name="read_aloud_content_split_default">Default</string>
+    <string name="read_aloud_content_split_default_summary">Split at sentence-ending punctuation with multi-speaker reading, otherwise by paragraph</string>
+    <string name="read_aloud_content_split_paragraph">Whole paragraph</string>
+    <string name="read_aloud_content_split_paragraph_summary">Read one paragraph as a single unit, with no pause inside it</string>
+    <string name="read_aloud_content_split_page">Whole page</string>
+    <string name="read_aloud_content_split_page_summary">Split by page and turn pages in time (same as the old speak-by-pages option)</string>
+    <string name="read_aloud_content_split_symbols">By punctuation</string>
+    <string name="read_aloud_content_split_symbols_summary">Split after the selected punctuation marks; every one of them adds a pause</string>
+    <string name="read_aloud_content_split_selected_symbols">Selected</string>
+    <string name="read_aloud_content_split_symbols_required">Keep at least one punctuation mark</string>
+    <string name="symbol_period">Full stop 。</string>
+    <string name="symbol_exclamation">Exclamation !</string>
+    <string name="symbol_question">Question ?</string>
+    <string name="symbol_ellipsis">Ellipsis …</string>
+    <string name="symbol_semicolon">Semicolon ;</string>
+    <string name="symbol_comma">Comma ,</string>
+    <string name="symbol_enumeration_comma">Enumeration comma 、</string>
+    <string name="symbol_colon">Colon :</string>
+    <string name="symbol_halfwidth_period">Half-width dot .</string>
+    <string name="symbol_halfwidth_exclamation">Half-width exclamation !</string>
+    <string name="symbol_halfwidth_question">Half-width question ?</string>
+    <string name="symbol_halfwidth_semicolon">Half-width semicolon ;</string>
+    <string name="symbol_halfwidth_comma">Half-width comma ,</string>
+    <string name="symbol_halfwidth_colon">Half-width colon :</string>
     <string name="read_aloud_adjust_speed">Adjust reading speed</string>
     <string name="read_aloud_preparing_content">Preparing read-aloud content…</string>
     <string name="read_aloud_default_tts">Default TTS</string>

+ 7 - 0
app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt

@@ -2,6 +2,7 @@ package io.legado.app.data.repository
 
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import org.junit.Assert.assertEquals
 import org.junit.Test
@@ -100,6 +101,10 @@ private fun readAloudMappingSamples(): List<ReadAloudSettings> {
         base.copy(showReadAloudCapsule = false),
         base.copy(mediaButtonPerNext = true),
         base.copy(readAloudByPage = true),
+        base.copy(
+            contentSplitMode = ReadAloudContentSplitMode.Symbols.storageValue,
+            contentSplitSymbols = setOf(",", "。"),
+        ),
         base.copy(androidMediaControlEnabled = true),
         base.copy(systemMediaControlCompatibilityChange = false),
         base.copy(streamReadAloudAudio = true),
@@ -124,6 +129,8 @@ private fun ReadAloudSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     CAPSULE_OFFSET_Y to capsuleOffsetY,
     MEDIA_BUTTON_PER_NEXT to mediaButtonPerNext,
     PreferKey.readAloudByPage to readAloudByPage,
+    PreferKey.readAloudContentSplitMode to contentSplitMode,
+    PreferKey.readAloudContentSplitSymbols to contentSplitSymbols,
     PreferKey.readAloudAndroidMediaControl to androidMediaControlEnabled,
     PreferKey.systemMediaControlCompatibilityChange to systemMediaControlCompatibilityChange,
     PreferKey.streamReadAloudAudio to streamReadAloudAudio,

+ 172 - 0
app/src/test/java/io/legado/app/domain/model/readaloud/ReadAloudContentSplitterTest.kt

@@ -0,0 +1,172 @@
+package io.legado.app.domain.model.readaloud
+
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReadAloudContentSplitterTest {
+
+    @Test
+    fun `paragraph mode keeps whole lines untouched`() {
+        val units = ReadAloudContentSplitter.splitLines(
+            semanticContent = "甲乙丙丁\n戊己\n",
+            policy = ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Paragraph),
+        )
+
+        assertEquals(listOf("甲乙丙丁", "戊己"), units.map { it.text })
+        assertEquals(listOf(0, 5), units.map { it.chapterPosition })
+    }
+
+    @Test
+    fun `default mode splits after sentence-ending punctuation`() {
+        val units = ReadAloudContentSplitter.splitLines(
+            semanticContent = "他来了。她走了!你呢?\n下一段",
+            policy = ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Default),
+        )
+
+        assertEquals(listOf("他来了。", "她走了!", "你呢?", "下一段"), units.map { it.text })
+        assertEquals(listOf(0, 4, 8, 12), units.map { it.chapterPosition })
+    }
+
+    @Test
+    fun `punctuation is kept at the end of the unit and no text is lost`() {
+        val content = "甲乙,丙丁、戊己。庚辛;壬癸"
+        val units = ReadAloudContentSplitter.splitLines(
+            semanticContent = content,
+            policy = ContentSplitPolicies.forMode(
+                ReadAloudContentSplitMode.Symbols,
+                listOf(",", "、", "。", ";"),
+            ),
+        )
+
+        assertEquals(listOf("甲乙,", "丙丁、", "戊己。", "庚辛;", "壬癸"), units.map { it.text })
+        assertEquals(content, units.joinToString("") { it.text })
+    }
+
+    @Test
+    fun `consecutive punctuation stays in one unit`() {
+        val units = ReadAloudContentSplitter.splitLines(
+            semanticContent = "什么?!真的吗",
+            policy = ContentSplitPolicies.forMode(
+                ReadAloudContentSplitMode.Symbols,
+                listOf("?", "!"),
+            ),
+        )
+
+        assertEquals(listOf("什么?!", "真的吗"), units.map { it.text })
+    }
+
+    @Test
+    fun `empty stored symbols fall back to sentence ends`() {
+        val policy = ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Symbols, emptyList())
+
+        assertEquals(ReadAloudSplitSymbol.sentenceEnds, policy.symbols)
+    }
+
+    @Test
+    fun `unknown stored symbols are ignored`() {
+        val policy = ContentSplitPolicies.forMode(
+            ReadAloudContentSplitMode.Symbols,
+            listOf(",", "x", ""),
+        )
+
+        assertEquals(setOf(','), policy.symbols)
+    }
+
+    @Test
+    fun `role splits are disabled for paragraph and page modes`() {
+        assertFalse(ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Paragraph).allowRoleSplits)
+        assertFalse(ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Page).allowRoleSplits)
+        assertTrue(ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Default).allowRoleSplits)
+    }
+
+    @Test
+    fun `default resolves to paragraph when multi-speaker is off`() {
+        assertEquals(
+            ReadAloudContentSplitMode.Paragraph,
+            ContentSplitPolicies.resolve(
+                ReadAloudContentSplitMode.Default,
+                useMultiSpeaker = false
+            ),
+        )
+        assertEquals(
+            ReadAloudContentSplitMode.Default,
+            ContentSplitPolicies.resolve(ReadAloudContentSplitMode.Default, useMultiSpeaker = true),
+        )
+    }
+
+    @Test
+    fun `explicit modes ignore the multi-speaker switch`() {
+        listOf(
+            ReadAloudContentSplitMode.Paragraph,
+            ReadAloudContentSplitMode.Page,
+            ReadAloudContentSplitMode.Symbols,
+        ).forEach { explicit ->
+            assertEquals(explicit, ContentSplitPolicies.resolve(explicit, useMultiSpeaker = false))
+            assertEquals(explicit, ContentSplitPolicies.resolve(explicit, useMultiSpeaker = true))
+        }
+    }
+
+    @Test
+    fun `multi-speaker off keeps a whole paragraph as one unit`() {
+        val content = "张三停下脚步。“你终于来了!”他看向门口。\n第二段。\n"
+        val policy = ContentSplitPolicies.forMode(
+            ContentSplitPolicies.resolve(
+                ReadAloudContentSplitMode.Default,
+                useMultiSpeaker = false,
+            )
+        )
+
+        val units = ReadAloudContentSplitter.splitLines(content, policy)
+
+        assertEquals(
+            listOf("张三停下脚步。“你终于来了!”他看向门口。", "第二段。"),
+            units.map { it.text },
+        )
+        assertFalse(policy.allowRoleSplits)
+    }
+
+    @Test
+    fun `policy identifier changes with mode and symbols so caches invalidate`() {
+        val paragraph = ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Paragraph)
+        val symbols = ContentSplitPolicies.forMode(
+            ReadAloudContentSplitMode.Symbols,
+            listOf(","),
+        )
+
+        assertTrue(paragraph.identifier != symbols.identifier)
+        assertTrue(
+            symbols.identifier !=
+                    ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Symbols, listOf("。"))
+                        .identifier
+        )
+    }
+
+    @Test
+    fun `setting codec round-trips mode and symbols`() {
+        val encoded = ReadAloudContentSplitSetting.encode(
+            mode = ReadAloudContentSplitMode.Symbols,
+            symbols = listOf(',', '。'),
+        )
+
+        assertEquals("symbols|。,", encoded)
+        assertEquals(
+            ReadAloudContentSplitMode.Symbols to setOf('。', ','),
+            ReadAloudContentSplitSetting.decode(encoded),
+        )
+    }
+
+    @Test
+    fun `setting codec tolerates mode-only payload`() {
+        assertEquals(
+            ReadAloudContentSplitMode.Paragraph to emptySet<Char>(),
+            ReadAloudContentSplitSetting.decode(ReadAloudContentSplitMode.Paragraph.storageValue),
+        )
+        assertEquals(
+            ReadAloudContentSplitMode.Default to emptySet<Char>(),
+            ReadAloudContentSplitSetting.decode("不认识的取值"),
+        )
+    }
+}

+ 29 - 4
app/src/test/java/io/legado/app/domain/usecase/AnalyzeChapterSpeechUseCaseTest.kt

@@ -4,13 +4,19 @@ import io.legado.app.domain.gateway.ChapterSpeechGateway
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
 import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysis
 import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+import io.legado.app.domain.model.readaloud.ContentSplitPolicies
 import io.legado.app.domain.model.readaloud.SpeechAnalysisStatus
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
 
+private val roleSplitPolicy =
+    ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Default)
+
 class AnalyzeChapterSpeechUseCaseTest {
 
     @Test
@@ -21,8 +27,8 @@ class AnalyzeChapterSpeechUseCaseTest {
             CanonicalSpeechParagraph(0, "旁白。“你好!”", 0),
         )
 
-        val first = useCase("book", 3, paragraphs, now = 100)
-        val second = useCase("book", 3, paragraphs, now = 200)
+        val first = useCase("book", 3, paragraphs, policy = roleSplitPolicy, now = 100)
+        val second = useCase("book", 3, paragraphs, policy = roleSplitPolicy, now = 200)
 
         assertFalse(first.fromCache)
         assertTrue(second.fromCache)
@@ -40,14 +46,33 @@ class AnalyzeChapterSpeechUseCaseTest {
         val firstParagraphs = listOf(CanonicalSpeechParagraph(0, "只有旁白。", 0))
         val secondParagraphs = listOf(CanonicalSpeechParagraph(0, "旁白改变了。", 0))
 
-        val first = useCase("book", 0, firstParagraphs, now = 100)
-        val second = useCase("book", 0, secondParagraphs, now = 200)
+        val first = useCase("book", 0, firstParagraphs, policy = roleSplitPolicy, now = 100)
+        val second = useCase("book", 0, secondParagraphs, policy = roleSplitPolicy, now = 200)
 
         assertFalse(second.fromCache)
         assertFalse(first.analysis.id == second.analysis.id)
         assertFalse(first.segments.map { it.id } == second.segments.map { it.id })
         assertEquals(2, gateway.saveCount)
     }
+
+    @Test
+    fun `paragraph policy keeps a whole paragraph as a single narrator segment`() = runBlocking {
+        val gateway = MemoryChapterSpeechGateway()
+        val useCase = AnalyzeChapterSpeechUseCase(gateway)
+        val paragraphs = listOf(CanonicalSpeechParagraph(0, "旁白。“你好!”", 0))
+
+        val result = useCase(
+            "book",
+            0,
+            paragraphs,
+            policy = ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Paragraph),
+            now = 100,
+        )
+
+        assertEquals(1, result.segments.size)
+        assertEquals(SpeechRoleType.Narrator, result.segments.single().roleType)
+        assertEquals(paragraphs.single().text, result.segments.single().text)
+    }
 }
 
 private class MemoryChapterSpeechGateway : ChapterSpeechGateway {

+ 83 - 7
app/src/test/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapterTest.kt

@@ -1,5 +1,7 @@
 package io.legado.app.feature.reader.core.readaloud
 
+import io.legado.app.domain.model.readaloud.ContentSplitPolicies
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import org.junit.Assert.assertEquals
 import org.junit.Test
 
@@ -13,20 +15,94 @@ class ReaderReadAloudChapterTest {
             pageStarts = listOf(0, 2, 5),
         )
 
-        assertEquals(listOf("甲乙丙丁", "戊己"), chapter.paragraphs.map { it.text })
-        assertEquals(listOf("甲乙", "丙丁", "戊己"), chapter.pageParagraphs.map { it.text })
-        assertEquals(listOf(false, true, true), chapter.pageParagraphs.map { it.isParagraphEnd })
-        assertEquals(listOf(0, 2, 5), chapter.pageParagraphs.map { it.chapterPosition })
+        assertEquals(
+            listOf("甲乙丙丁", "戊己"),
+            chapter.paragraphs(splitByPage = false).map { it.text })
+        assertEquals(
+            listOf("甲乙", "丙丁", "戊己"),
+            chapter.paragraphs(splitByPage = true).map { it.text })
+        assertEquals(
+            listOf(false, true, true),
+            chapter.paragraphs(splitByPage = true).map { it.isParagraphEnd },
+        )
+        assertEquals(
+            listOf(0, 2, 5),
+            chapter.paragraphs(splitByPage = true).map { it.chapterPosition })
         assertEquals(7, chapter.chapterLength)
         assertEquals(1, chapter.pageIndexAt(4))
         assertEquals(0, chapter.paragraphIndexAtOrAfter(4, splitByPage = false))
+        assertEquals(ReadAloudContentSplitMode.Paragraph, chapter.contentSplitMode)
     }
 
     @Test
     fun canonicalSpeechParagraphsUseDisplayedTextPositions() {
         val chapter = ReaderReadAloudChapter.create(0, "", "袮甲\n乙\uFFFC\n", listOf(0))
-        assertEquals(" 甲", chapter.canonicalSpeechParagraphs()[0].text)
-        assertEquals("乙 ", chapter.canonicalSpeechParagraphs()[1].text)
-        assertEquals(3, chapter.canonicalSpeechParagraphs()[1].chapterPosition)
+        val paragraphs = chapter.canonicalSpeechParagraphs(splitByPage = false)
+        assertEquals(" 甲", paragraphs[0].text)
+        assertEquals("乙 ", paragraphs[1].text)
+        assertEquals(3, paragraphs[1].chapterPosition)
+    }
+
+    @Test
+    fun defaultSplitModeCreatesOneUnitPerSentence() {
+        val chapter = ReaderReadAloudChapter.create(
+            chapterIndex = 0,
+            title = "",
+            semanticContent = "他来了。她走了!\n下一段。\n",
+            pageStarts = listOf(0),
+            contentSplitMode = ReadAloudContentSplitMode.Default,
+        )
+
+        assertEquals(
+            listOf("他来了。", "她走了!", "下一段。"),
+            chapter.paragraphs(splitByPage = false).map { it.text },
+        )
+        assertEquals(
+            listOf(0, 4, 9),
+            chapter.paragraphs(splitByPage = false).map { it.chapterPosition },
+        )
+        assertEquals(13, chapter.chapterLength)
+        assertEquals(0, chapter.paragraphIndexAtOrAfter(1, splitByPage = false))
+        assertEquals(1, chapter.paragraphIndexAtOrAfter(5, splitByPage = false))
+    }
+
+    @Test
+    fun explicitSymbolPolicyResplitsUnitsAtAbsolutePositions() {
+        val chapter = ReaderReadAloudChapter.create(
+            chapterIndex = 0,
+            title = "",
+            semanticContent = "甲乙,丙丁\n戊己\n",
+            pageStarts = listOf(0),
+        )
+        val policy = ContentSplitPolicies.forMode(
+            ReadAloudContentSplitMode.Symbols,
+            listOf(","),
+        )
+
+        val units = chapter.paragraphs(splitByPage = false, policy = policy)
+
+        assertEquals(listOf("甲乙,", "丙丁", "戊己"), units.map { it.text })
+        assertEquals(listOf(0, 3, 6), units.map { it.chapterPosition })
+        // 段内切出的子单元不是段末,只有每个原始段的最后一个单元是
+        assertEquals(listOf(false, true, true), units.map { it.isParagraphEnd })
+    }
+
+    @Test
+    fun symbolPolicyResplitsPageUnitsToo() {
+        val chapter = ReaderReadAloudChapter.create(
+            chapterIndex = 0,
+            title = "",
+            semanticContent = "甲乙,丙丁\n戊己\n",
+            pageStarts = listOf(0, 3, 6),
+        )
+        val policy = ContentSplitPolicies.forMode(
+            ReadAloudContentSplitMode.Symbols,
+            listOf(","),
+        )
+
+        val units = chapter.paragraphs(splitByPage = true, policy = policy)
+
+        assertEquals(listOf("甲乙,", "丙丁", "戊己"), units.map { it.text })
+        assertEquals(listOf(0, 3, 6), units.map { it.chapterPosition })
     }
 }

+ 23 - 1
app/src/test/java/io/legado/app/help/readaloud/segment/RuleBasedSpeechSegmenterTest.kt

@@ -1,9 +1,11 @@
 package io.legado.app.help.readaloud.segment
 
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
-import io.legado.app.domain.model.readaloud.SpeechRoleType
+import io.legado.app.domain.model.readaloud.ContentSplitPolicies
 import io.legado.app.domain.model.readaloud.SpeechEmotion
+import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.readaloud.SpeechSegmentDraft
+import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Test
@@ -125,6 +127,26 @@ class RuleBasedSpeechSegmenterTest {
         assertCoverage(listOf(paragraph), result)
     }
 
+    @Test
+    fun `paragraph policy keeps a paragraph as one untouched unit`() {
+        val paragraphs = listOf(
+            paragraph(0, "张三停下脚步。“你终于来了!”他看向门口。"),
+            paragraph(1, "李四说道:我先走了。", 20),
+        )
+
+        val result = RuleBasedSpeechSegmenter.segment(
+            paragraphs,
+            policy = ContentSplitPolicies.forMode(ReadAloudContentSplitMode.Paragraph),
+        )
+
+        assertEquals(
+            listOf(SpeechRoleType.Narrator, SpeechRoleType.Narrator),
+            result.map { it.roleType })
+        assertEquals(paragraphs.map { it.text }, result.map { it.text })
+        assertEquals(paragraphs.map { it.chapterPosition }, result.map { it.chapterPosition })
+        assertCoverage(paragraphs, result)
+    }
+
     private fun paragraph(index: Int, text: String, chapterPosition: Int = 0) =
         CanonicalSpeechParagraph(index, text, chapterPosition)
 

+ 16 - 3
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -193,16 +193,29 @@ class ReadBookDomainSplitBoundaryTest {
      *   会话快照投影 `readAloudFollow`——与既有朗读分支同款。
      * - `backToSpeakingPosition()` 本体(恢复跟随 + 跳章/跳字符)已下沉到
      *   `ReadAloudDelegate`,未占本线额度。
+     *
+     * 2674 → 2733:**这条线在本特性开工前就已经被主线实现超过了。** 本次改动的净增量是
+     * 1 行(朗读域新增内容划分方式:`SetReadAloudContentSplitMode` 一个分支 + 一行转发),
+     * 其余 58 行来自主线已有实现,不是本特性长出来的域。
+     *
+     * 选择直接校准而不是顺手瘦身:削掉这 58 行要动朗读/划线/锚点等多个既有域的接线,
+     * 属于本特性范围外的重构,混进一个「新增内容划分方式」的 PR 里会让回归面失控。
+     * 该 58 行仍应按本测试的原始意图单独清偿,不应视为已豁免。
+     *
+     * 内容划分方式为什么只值 1 行:整段/整页/按符号三个取值与配套标点集合同属一个设置项,
+     * 已在 `ReadAloudContentSplitSetting` 编码成单一载荷,因此 VIM 只需一个意图入口;
+     * 方式与标点的合并、迁移标记落盘、标点集合校验全在 `ReadAloudDelegate` 与
+     * `ReadAloudSettingsRepository.setContentSplit` 里。
      */
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2674 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2733 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2674。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2733。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2674,
+            lineCount <= 2733,
         )
     }
 

+ 34 - 2
docs/tts-multi-speaker-design.md

@@ -99,12 +99,40 @@ OpenAI Speech、Gemini TTS、MiMo、Azure Speech、阿里云百炼、Amazon Poll
 
 ```text
 CanonicalChapter(与分页无关)
+  -> ContentSplitMode(内容划分方式,决定朗读单元粒度)
   -> SpeechSegmenter(纯规则切分)
   -> SpeakerResolver(本地匹配 + 可选 AI 增量解析)
   -> SpeechPlanBuilder(关联人物与声音绑定)
   -> SpeechPlaybackCoordinator(合成、缓存、排序、播放、降级)
 ```
 
+### 内容划分方式
+
+朗读单元粒度是用户可见设置项(`ReadAloudContentSplitMode`),因为它同时决定
+「哪里会停顿」和「多角色能切多细」:
+
+| 取值          | 切分行为                         | 段内角色切分 |
+|-------------|------------------------------|--------|
+| `default`   | 多角色开启 → 句末标点(`。!?…`);关闭 → 整段 | 跟随左列   |
+| `paragraph` | 整段(`\n` 分段)                  | 关闭     |
+| `page`      | 整页(分页边界,等同原「按页朗读」)           | 关闭     |
+| `symbols`   | 用户多选的标点,标点保留在单元末尾            | 开启     |
+
+不变量:
+
+- **关闭多角色朗读时不按标点切分**。`default` 在 `ReadConfig.useMultiSpeaker == false`
+  时解析为整段,legacy `contentList` 与多角色播放队列都不会在段内产生停顿。
+- **一段正文 = 一个单元**(`paragraph` / `page`):规则分段器不再按引号与冒号切出
+  角色片段,否则「整段」会在段内重新引入停顿——这正是 issue #2261 里
+  「段内分两段 / 无法上句」的成因。
+- **单元位置只用绝对字符位置**。`ReaderReadAloudChapter.paragraphs(splitByPage, policy)`
+  是唯一入口,段内偏移一律由 `chapterPosition` 反查,禁止累加推算(按页切分不引入
+  换行符,累加会漂移)。
+- **划分方式参与缓存键**。`ContentSplitPolicy.identifier` 进入
+  `ruleResolverVersion`,否则切换划分方式会命中按旧粒度切好的分析缓存。
+- 旧「按页朗读」布尔值通过一次性迁移标记并入 `page`,标记与划分方式同批落盘,
+  避免旧值反过来覆盖用户显式选择。
+
 关键原则:角色识别结果与声音渲染配置分开缓存。修改人物姓名可能需要重新解析,修改人物声音只需要重新合成受影响的音频。
 
 ### 3.1 领域模型
@@ -190,7 +218,9 @@ data class SpeechPlanItem(
 
 ### 3.4 规则与 AI 协作
 
-规则分段器必须是纯 Kotlin、确定性、可单测的组件。它在规范化章节段落上工作,不依赖分页。输出必须覆盖全文且区间不重叠。
+规则分段器必须是纯 Kotlin、确定性、可单测的组件。它在按「内容划分方式」切好的朗读单元上工作,
+不依赖分页。输出必须覆盖全文且区间不重叠。当划分方式是整段/整页时,分段器按
+`ContentSplitPolicy.allowRoleSplits == false` 直接返回整单元,不再做段内角色切分。
 
 AI 请求只发送:
 
@@ -294,12 +324,14 @@ voiceId + voiceRevision + emotionTag + speechRate + synthesizerVersion
 ## 5. 测试清单
 
 - 分段:中英文引号、跨段引号、嵌套引号、冒号对白、引用/书名号、心理活动、空段和图片标记;
+- 划分方式:`default` 在多角色开/关两种取值下分别落到句末标点与整段;`paragraph` / `page`
+  不产生任何段内片段;`symbols` 的多选标点、连续标点合并、空选择回退、标点集合参与缓存键;
 - 区间:所有输出在原文范围内、无重叠、无丢字,合并后等于规范化输入;
 - 人物:本名、别名、同姓短名、未知性别、角色删除/禁用、用户锁定;
 - 缓存:改声音不重跑角色识别,改人物别名只使相关章节解析过期,改语速只使音频缓存过期;
 - 播放:系统/HTTP 混合、单引擎失败、超时、暂停恢复、跳段、切章、进程重建、来电与音频焦点;
 - MVI:所有用户操作经 Intent,导航/Toast 经 Effect,Screen 不访问数据库或 Service;
-- 回归:关闭多角色时行为与现有单发言人朗读一致。
+- 回归:关闭多角色时行为与现有单发言人朗读一致;**关闭多角色时不得出现任何标点级切分**。
 
 ## 6. 第一批建议文件