diff --git a/packages/text-to-speech/package.json b/packages/text-to-speech/package.json index fa9db0f24..380c638e4 100644 --- a/packages/text-to-speech/package.json +++ b/packages/text-to-speech/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@types/node": "^14.11.2", + "@types/underscore": "^1.11.4", "eslint-plugin-prettier": "^4.0.0" }, "dependencies": { @@ -31,6 +32,7 @@ "dotenv": "^16.0.1", "jsonwebtoken": "^8.5.1", "linkedom": "^0.14.12", - "microsoft-cognitiveservices-speech-sdk": "^1.22.0" + "microsoft-cognitiveservices-speech-sdk": "^1.22.0", + "underscore": "^1.13.4" } } diff --git a/packages/text-to-speech/src/htmlToSsml.ts b/packages/text-to-speech/src/htmlToSsml.ts index 6d4fb8040..b90142f63 100644 --- a/packages/text-to-speech/src/htmlToSsml.ts +++ b/packages/text-to-speech/src/htmlToSsml.ts @@ -1,4 +1,5 @@ import { parseHTML } from 'linkedom' +import * as _ from 'underscore' // this code needs to be kept in sync with the // frontend code in: useReadingProgressAnchor @@ -16,6 +17,19 @@ function ssmlTagsForTopLevelElement() { } } +const TOP_LEVEL_TAGS = [ + 'P', + 'BLOCKQUOTE', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'UL', + 'OL', +] + function parseDomTree(pageNode: Element) { if (!pageNode || pageNode.childNodes.length == 0) { console.log(' no child nodes found') @@ -48,8 +62,9 @@ function parseDomTree(pageNode: Element) { visitedNodeList.shift() visitedNodeList.forEach((node, index) => { - // start from index 1, index 0 reserved for anchor unknown. - node.setAttribute('data-omnivore-anchor-idx', (index + 1).toString()) + // We start at index 2, because the frontend starts one node above us + // on the #readability-content element that wraps the entire content. + node.setAttribute('data-omnivore-anchor-idx', (index + 2).toString()) }) return visitedNodeList } @@ -59,7 +74,7 @@ function emit(textItems: string[], text: string) { } function cleanTextNode(textNode: ChildNode): string { - return (textNode.textContent ?? '').replace(/\s+/g, ' ') + return _.escape(textNode.textContent ?? ''.replace(/\s+/g, ' ')) } function emitTextNode( @@ -152,11 +167,23 @@ const endSsml = (): string => { return `` } +const hasSignificantText = (node: ChildNode): boolean => { + let text = '' + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === 3 /* Node.TEXT_NODE */) { + text += child.textContent + } + } + return text.trim().length > 0 +} + export const ssmlItemText = (item: SSMLItem): string => { return [item.open, ...item.textItems, item.close].join('') } export const htmlToSsml = (html: string, options: SSMLOptions): SSMLItem[] => { + console.log('creating ssml with options', options) + const dom = parseHTML(html) const body = dom.document.querySelector('#readability-page-1') if (!body) { @@ -164,21 +191,31 @@ export const htmlToSsml = (html: string, options: SSMLOptions): SSMLItem[] => { } const parsedNodes = parseDomTree(body) + Array.from(parsedNodes).map((n) => + console.log( + n.nodeName, + n.getAttribute('data-omnivore-anchor-idx'), + n.getAttribute('class') + ) + ) + if (parsedNodes.length < 1) { throw new Error('No HTML nodes found') } const items: SSMLItem[] = [] - for (let i = 1; i < parsedNodes.length + 1; i++) { + for (let i = 2; i < parsedNodes.length + 2; i++) { const textItems: string[] = [] - const node = parsedNodes[i - 1] + const node = parsedNodes[i - 2] - i = emitElement(textItems, node, true) - items.push({ - open: startSsml(node, options), - close: endSsml(), - textItems: textItems, - }) + if (TOP_LEVEL_TAGS.includes(node.nodeName) || hasSignificantText(node)) { + i = emitElement(textItems, node, true) + items.push({ + open: startSsml(node, options), + close: endSsml(), + textItems: textItems, + }) + } } return items diff --git a/packages/text-to-speech/src/index.ts b/packages/text-to-speech/src/index.ts index 2ff849856..ddf3183bf 100644 --- a/packages/text-to-speech/src/index.ts +++ b/packages/text-to-speech/src/index.ts @@ -217,9 +217,9 @@ const synthesizeTextToSpeech = async ( } } else { const ssmlItems = htmlToSsml(input.text, { - primaryVoice: speechConfig.speechSynthesisVoiceName, + primaryVoice: input.voice || 'en-US-JennyNeural', secondaryVoice: 'en-US-GuyNeural', - language: speechConfig.speechSynthesisLanguage, + language: input.languageCode || 'en-US', rate: '1', }) diff --git a/packages/text-to-speech/test/htmlToSsml.test.ts b/packages/text-to-speech/test/htmlToSsml.test.ts index c751058de..46bd0243c 100644 --- a/packages/text-to-speech/test/htmlToSsml.test.ts +++ b/packages/text-to-speech/test/htmlToSsml.test.ts @@ -1,8 +1,5 @@ import 'mocha' import { expect } from 'chai' - -import fs from 'fs' -import { glob } from 'glob' import { htmlToSsml } from '../src/htmlToSsml' describe('htmlToSsml', () => { @@ -10,64 +7,169 @@ describe('htmlToSsml', () => { primaryVoice: 'test-primary', secondaryVoice: 'test-secondary', language: 'en-US', - rate: '1' + rate: '1', } describe('a simple html file', () => { - it('should convert Html to SSML', async () => { - const ssml = htmlToSsml(` -
this is some text
+ xit('should convert Html to SSML', () => { + const ssml = htmlToSsml( + ` +this is some text
-this is in the first paragraph -this is in the second span -this is also in the first paragraph -
-+ this is in the first paragraph + this is in the second span + this is also in the first paragraph +
++ this is in the first paragraph + this is in the second span + this is also in the first paragraph +
+this is in the second paragraph
+ this is also in the first paragraph + ++ this is in the first paragraph + this is in the second paragraph + this is also in the first paragraph +
++ this is in the first paragraph +
+ this is in the first paragraph +
+ this is in the first paragraph +
this is a blockquote+ this is also in the first paragraph + +
first
-second-
third
-first
+second+
third
+