mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Convert html to speech-file
This commit is contained in:
parent
5107bfd940
commit
62619115c8
1 changed files with 111 additions and 21 deletions
|
|
@ -1,9 +1,47 @@
|
|||
import { parseHTML } from 'linkedom'
|
||||
import * as _ from 'underscore'
|
||||
import { WordPunctTokenizer } from 'natural'
|
||||
import { htmlToText } from 'html-to-text'
|
||||
|
||||
// this code needs to be kept in sync with the
|
||||
// frontend code in: useReadingProgressAnchor
|
||||
|
||||
export interface Utterance {
|
||||
idx: number
|
||||
text: string
|
||||
wordOffset: number
|
||||
wordCount: number
|
||||
voice?: string
|
||||
}
|
||||
|
||||
export interface SpeechFile {
|
||||
wordCount: number
|
||||
averageWPM: number
|
||||
language: string
|
||||
defaultVoice: string
|
||||
utterances: Utterance[]
|
||||
}
|
||||
|
||||
export type SSMLItem = {
|
||||
open: string
|
||||
close: string
|
||||
textItems: string[]
|
||||
idx: number
|
||||
voice?: string
|
||||
}
|
||||
|
||||
export type SSMLOptions = {
|
||||
primaryVoice?: string
|
||||
secondaryVoice?: string
|
||||
rate?: number
|
||||
language?: string
|
||||
}
|
||||
|
||||
const WORDS_PER_MINUTE = 200
|
||||
const DEFAULT_LANGUAGE = 'en-US'
|
||||
const DEFAULT_VOICE = 'en-US-JennyNeural'
|
||||
const DEFAULT_RATE = 1.25
|
||||
|
||||
const ANCHOR_ELEMENTS_BLOCKED_ATTRIBUTES = [
|
||||
'omnivore-highlight-id',
|
||||
'data-twitter-tweet-id',
|
||||
|
|
@ -140,30 +178,16 @@ function emitElement(
|
|||
return Number(maxVisitedIdx)
|
||||
}
|
||||
|
||||
export type SSMLItem = {
|
||||
open: string
|
||||
close: string
|
||||
textItems: string[]
|
||||
idx: number
|
||||
voice?: string
|
||||
}
|
||||
|
||||
export type SSMLOptions = {
|
||||
primaryVoice: string
|
||||
secondaryVoice: string
|
||||
rate: number
|
||||
language: string
|
||||
}
|
||||
|
||||
export const startSsml = (
|
||||
element: Element | null,
|
||||
options: SSMLOptions
|
||||
): string => {
|
||||
export const startSsml = (options: SSMLOptions, element?: Element): string => {
|
||||
const voice =
|
||||
element?.nodeName === 'BLOCKQUOTE'
|
||||
? options.secondaryVoice
|
||||
: options.primaryVoice
|
||||
return `<speak xmlns="http://www.w3.org/2001/10/synthesis" version="1.0" xml:lang="${options.language}"><voice name="${voice}"><prosody rate="${options.rate}">`
|
||||
return `<speak xmlns="http://www.w3.org/2001/10/synthesis" version="1.0" xml:lang="${
|
||||
options.language || DEFAULT_LANGUAGE
|
||||
}"><voice name="${voice || DEFAULT_VOICE}"><prosody rate="${
|
||||
options.rate || DEFAULT_RATE
|
||||
}">`
|
||||
}
|
||||
|
||||
export const endSsml = (): string => {
|
||||
|
|
@ -210,7 +234,7 @@ export const htmlToSsmlItems = (
|
|||
const idx = i
|
||||
i = emitElement(textItems, node, true)
|
||||
items.push({
|
||||
open: startSsml(node, options),
|
||||
open: startSsml(options, node),
|
||||
close: endSsml(),
|
||||
textItems: textItems,
|
||||
idx,
|
||||
|
|
@ -222,3 +246,69 @@ export const htmlToSsmlItems = (
|
|||
|
||||
return items
|
||||
}
|
||||
|
||||
const htmlToUtterance = (
|
||||
tokenizer: WordPunctTokenizer,
|
||||
idx: number,
|
||||
htmlItems: string[],
|
||||
wordOffset: number,
|
||||
voice?: string
|
||||
): Utterance => {
|
||||
const text = htmlToText(htmlItems.join(''), { wordwrap: false })
|
||||
const wordCount = tokenizer.tokenize(text).length
|
||||
return {
|
||||
idx,
|
||||
text,
|
||||
wordOffset,
|
||||
wordCount,
|
||||
voice,
|
||||
}
|
||||
}
|
||||
|
||||
export const htmlToSpeechFile = (
|
||||
html: string,
|
||||
options: SSMLOptions
|
||||
): SpeechFile => {
|
||||
console.debug('creating speech file with options', options)
|
||||
|
||||
const dom = parseHTML(html)
|
||||
const body = dom.document.querySelector('#readability-page-1')
|
||||
if (!body) {
|
||||
throw new Error('Unable to parse HTML document')
|
||||
}
|
||||
|
||||
const parsedNodes = parseDomTree(body)
|
||||
if (parsedNodes.length < 1) {
|
||||
throw new Error('No HTML nodes found')
|
||||
}
|
||||
|
||||
const tokenizer = new WordPunctTokenizer()
|
||||
const utterances: Utterance[] = []
|
||||
let wordOffset = 0
|
||||
for (let i = 2; i < parsedNodes.length + 2; i++) {
|
||||
const textItems: string[] = []
|
||||
const node = parsedNodes[i - 2]
|
||||
|
||||
if (TOP_LEVEL_TAGS.includes(node.nodeName) || hasSignificantText(node)) {
|
||||
const idx = i
|
||||
i = emitElement(textItems, node, true)
|
||||
const utterance = htmlToUtterance(
|
||||
tokenizer,
|
||||
idx,
|
||||
textItems,
|
||||
wordOffset,
|
||||
node.nodeName === 'BLOCKQUOTE' ? options.secondaryVoice : undefined
|
||||
)
|
||||
utterances.push(utterance)
|
||||
wordOffset += utterance.wordCount
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wordCount: wordOffset,
|
||||
averageWPM: WORDS_PER_MINUTE,
|
||||
language: options.language || DEFAULT_LANGUAGE,
|
||||
defaultVoice: options.primaryVoice || DEFAULT_VOICE,
|
||||
utterances,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue