From fdec0b2037286abee38144b3fcf3aff38719ee4b Mon Sep 17 00:00:00 2001
From: John Factotum <50942278+johnfactotum@users.noreply.github.com>
Date: Thu, 28 Sep 2023 02:02:56 +0800
Subject: [PATCH] TTS: add starting/resuming speaking from mark/range
Also:
- Fix `ssml:alphabet` attribute being ignored when set on root
- Convert `
` to ``
---
tts.js | 6 +++++-
view.js | 38 ++++++++++++++++++++++++++++++++++----
2 files changed, 39 insertions(+), 5 deletions(-)
diff --git a/tts.js b/tts.js
index 5d56518..72ddb21 100644
--- a/tts.js
+++ b/tts.js
@@ -64,6 +64,8 @@ export const toSSML = doc => {
el.setAttribute('name', node.dataset.name)
}
else if (ps.includes(nodeName)) el = ssml.createElementNS(NS.SSML, 'p')
+ else if (nodeName === 'br')
+ el = ssml.createElementNS(NS.SSML, 'break')
else if (nodeName === 'em' || nodeName === 'strong')
el = ssml.createElementNS(NS.SSML, 'emphasis')
@@ -93,5 +95,7 @@ export const toSSML = doc => {
}
return el
}
- return convert(doc.body, ssml.documentElement)
+ convert(doc.body, ssml.documentElement,
+ doc.documentElement.getAttributeNS(NS.SSML, 'alphabet'))
+ return ssml
}
diff --git a/view.js b/view.js
index 2de3f04..367a04a 100644
--- a/view.js
+++ b/view.js
@@ -90,7 +90,11 @@ export class View extends HTMLElement {
#tocProgress
#pageProgress
#searchResults = new Map()
+ #ssml
+ #speechDoc
#speechRanges
+ #speechGranularity
+ #lastSpeechRange
isFixedLayout = false
lastLocation
history = new History()
@@ -409,17 +413,43 @@ export class View extends HTMLElement {
for (const item of list) this.deleteAnnotation(item)
this.#searchResults.clear()
}
- async speak(granularity) {
- const { insertMarks, toSSML } = await import('./tts.js')
+ async initSpeech(granularity) {
const doc = this.renderer.getContents()[0].doc
+ if (this.#speechDoc === doc && this.#speechGranularity === granularity)
+ return this.#ssml
+ const { insertMarks, toSSML } = await import('./tts.js')
const { doc: markedDoc, ranges } = insertMarks(textWalker, doc, granularity)
this.#speechRanges = new Map(ranges)
- const ssml = toSSML(markedDoc)
+ this.#speechDoc = doc
+ this.#speechGranularity = granularity
+ this.#ssml = toSSML(markedDoc)
+ return this.#ssml
+ }
+ speak(mark) {
+ if (!mark) return new XMLSerializer().serializeToString(this.#ssml)
+ const ssml = document.implementation.createDocument(
+ 'http://www.w3.org/2001/10/synthesis', 'speak')
+ ssml.documentElement.replaceWith(ssml.importNode(this.#ssml.documentElement, true))
+ let node = ssml.querySelector(`mark[name="${CSS.escape(mark)}"`)?.previousSibling
+ while (node) {
+ const next = node.previousSibling ?? node.parentNode?.previousSibling
+ node.parentNode.removeChild(node)
+ node = next
+ }
return new XMLSerializer().serializeToString(ssml)
}
+ getSpeechMarkBefore(range) {
+ if (range) for (const [name, range_] of this.#speechRanges.entries())
+ if (range.compareBoundaryPoints(Range.START_TO_START, range_) <= 0)
+ return name
+ }
+ resumeSpeech() {
+ return this.speak(this.getSpeechMarkBefore(this.#lastSpeechRange))
+ }
hightlightSpeechMark(name) {
const range = this.#speechRanges.get(name)
- if (range) this.renderer.scrollToAnchor(range, true)
+ this.#lastSpeechRange = range
+ if (range) this.renderer.scrollToAnchor(range.cloneRange(), true)
else console.warn('Mark not found')
}
}