mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Add TTS
Add function to convert current page to SSML, with `<mark>`s inserted using `Intl.segmenter`. The actual speaking is up to the reader, who should call `hightlightSpeechMark()` upon mark events.
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
const NS = {
|
||||||
|
XML: 'http://www.w3.org/XML/1998/namespace',
|
||||||
|
SSML: 'http://www.w3.org/2001/10/synthesis',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const insertMarks = (textWalker, doc, granularity) => {
|
||||||
|
const lang = doc.lang || doc.documentElement.getAttributeNS(NS.XML, 'lang') || 'en'
|
||||||
|
const segmenter = new Intl.Segmenter(lang, { granularity })
|
||||||
|
|
||||||
|
const granularityIsWord = granularity === 'word'
|
||||||
|
const func = function* (strs, makeRange) {
|
||||||
|
const str = strs.join('')
|
||||||
|
let name = 0
|
||||||
|
let strIndex = -1
|
||||||
|
let sum = 0
|
||||||
|
for (const { index, segment, isWordLike } of segmenter.segment(str)) {
|
||||||
|
if (granularityIsWord && !isWordLike) continue
|
||||||
|
while (sum <= index) sum += strs[++strIndex].length
|
||||||
|
const startIndex = strIndex
|
||||||
|
const startOffset = index - (sum - strs[strIndex].length)
|
||||||
|
const end = index + segment.length
|
||||||
|
if (end < str.length) while (sum <= end) sum += strs[++strIndex].length
|
||||||
|
const endIndex = strIndex
|
||||||
|
const endOffset = end - (sum - strs[strIndex].length)
|
||||||
|
yield [(name++).toString(),
|
||||||
|
makeRange(startIndex, startOffset, endIndex, endOffset)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = document.implementation.createHTMLDocument()
|
||||||
|
clone.documentElement.replaceWith(clone.importNode(doc.documentElement, true))
|
||||||
|
|
||||||
|
// we need the ranges on both the original document (for highlighting)
|
||||||
|
// and the cloned document (for inserting marks)
|
||||||
|
// so unfortunately we need to do it twice, as you can't copy the ranges
|
||||||
|
// (not unless you serialize them, which is proly going to be even slower)
|
||||||
|
const items = [...textWalker(doc, func)]
|
||||||
|
const cloneItems = [...textWalker(clone, func)]
|
||||||
|
|
||||||
|
for (const [name, range] of cloneItems) {
|
||||||
|
const mark = clone.createElement('foliate-mark')
|
||||||
|
mark.dataset.name = name
|
||||||
|
range.insertNode(mark)
|
||||||
|
}
|
||||||
|
return { doc: clone, ranges: items }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toSSML = doc => {
|
||||||
|
const ssml = document.implementation.createDocument(NS.SSML, 'speak')
|
||||||
|
const lang = doc.lang || doc.documentElement.getAttributeNS(NS.XML, 'lang')
|
||||||
|
if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang)
|
||||||
|
|
||||||
|
const ps = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'dd']
|
||||||
|
|
||||||
|
const convert = (node, parent, inheritedAlphabet) => {
|
||||||
|
if (node.nodeType === 3) return ssml.createTextNode(node.textContent)
|
||||||
|
if (node.nodeType === 4) return ssml.createCDATASection(node.textContent)
|
||||||
|
if (node.nodeType !== 1) return
|
||||||
|
|
||||||
|
let el
|
||||||
|
const nodeName = node.nodeName.toLowerCase()
|
||||||
|
if (nodeName === 'foliate-mark') {
|
||||||
|
el = ssml.createElementNS(NS.SSML, 'mark')
|
||||||
|
el.setAttribute('name', node.dataset.name)
|
||||||
|
}
|
||||||
|
else if (ps.includes(nodeName)) el = ssml.createElementNS(NS.SSML, 'p')
|
||||||
|
else if (nodeName === 'em' || nodeName === 'strong')
|
||||||
|
el = ssml.createElementNS(NS.SSML, 'emphasis')
|
||||||
|
|
||||||
|
const lang = node.lang || node.getAttributeNS(NS.XML, 'lang')
|
||||||
|
if (lang) {
|
||||||
|
if (!el) el = ssml.createElementNS(NS.SSML, 'lang')
|
||||||
|
el.setAttributeNS(NS.XML, 'lang', lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
const alphabet = node.getAttributeNS(NS.SSML, 'alphabet') || inheritedAlphabet
|
||||||
|
if (!el) {
|
||||||
|
const ph = node.getAttributeNS(NS.SSML, 'ph')
|
||||||
|
if (ph) {
|
||||||
|
el = ssml.createElementNS(NS.SSML, 'phoneme')
|
||||||
|
if (alphabet) el.setAttribute('alphabet', alphabet)
|
||||||
|
el.setAttribute('ph', ph)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!el) el = parent
|
||||||
|
|
||||||
|
let child = node.firstChild
|
||||||
|
while (child) {
|
||||||
|
const childEl = convert(child, el, alphabet)
|
||||||
|
if (childEl && el !== childEl) el.append(childEl)
|
||||||
|
child = child.nextSibling
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
return convert(doc.body, ssml.documentElement)
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ export class View extends HTMLElement {
|
|||||||
#tocProgress
|
#tocProgress
|
||||||
#pageProgress
|
#pageProgress
|
||||||
#searchResults = new Map()
|
#searchResults = new Map()
|
||||||
|
#speechRanges
|
||||||
isFixedLayout = false
|
isFixedLayout = false
|
||||||
lastLocation
|
lastLocation
|
||||||
history = new History()
|
history = new History()
|
||||||
@@ -408,6 +409,19 @@ export class View extends HTMLElement {
|
|||||||
for (const item of list) this.deleteAnnotation(item)
|
for (const item of list) this.deleteAnnotation(item)
|
||||||
this.#searchResults.clear()
|
this.#searchResults.clear()
|
||||||
}
|
}
|
||||||
|
async speak(granularity) {
|
||||||
|
const { insertMarks, toSSML } = await import('./tts.js')
|
||||||
|
const doc = this.renderer.getContents()[0].doc
|
||||||
|
const { doc: markedDoc, ranges } = insertMarks(textWalker, doc, granularity)
|
||||||
|
this.#speechRanges = new Map(ranges)
|
||||||
|
const ssml = toSSML(markedDoc)
|
||||||
|
return new XMLSerializer().serializeToString(ssml)
|
||||||
|
}
|
||||||
|
hightlightSpeechMark(name) {
|
||||||
|
const range = this.#speechRanges.get(name)
|
||||||
|
if (range) this.renderer.scrollToAnchor(range, true)
|
||||||
|
else console.warn('Mark not found')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('foliate-view', View)
|
customElements.define('foliate-view', View)
|
||||||
|
|||||||
Reference in New Issue
Block a user