echogarden 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/data/lexicons/heteronyms.en.json +327 -122
- package/data/lexicons/words.en.json +260 -0
- package/data/schemas/options.json +20 -4
- package/dist/api/Recognition.d.ts +3 -1
- package/dist/api/Recognition.js +17 -0
- package/dist/api/Recognition.js.map +1 -1
- package/dist/api/Synthesis.d.ts +2 -2
- package/dist/api/Synthesis.js +3 -3
- package/dist/api/Synthesis.js.map +1 -1
- package/dist/codecs/FFMpegTranscoder.js +2 -0
- package/dist/codecs/FFMpegTranscoder.js.map +1 -1
- package/dist/nlp/EspeakPhonemizer.d.ts +2 -2
- package/dist/nlp/EspeakPhonemizer.js +36 -36
- package/dist/nlp/Lexicon.d.ts +2 -0
- package/dist/nlp/Lexicon.js +19 -19
- package/dist/nlp/Lexicon.js.map +1 -1
- package/dist/nlp/PhoneConversion.d.ts +1 -0
- package/dist/nlp/PhoneConversion.js +151 -222
- package/dist/nlp/PhoneConversion.js.map +1 -1
- package/dist/recognition/DeepgramSTT.d.ts +11 -0
- package/dist/recognition/DeepgramSTT.js +64 -0
- package/dist/recognition/DeepgramSTT.js.map +1 -0
- package/dist/recognition/GoogleCloudSTT.js.map +1 -1
- package/dist/synthesis/{ElevenlabsTTS.d.ts → ElevenLabsTTS.d.ts} +3 -3
- package/dist/synthesis/{ElevenlabsTTS.js → ElevenLabsTTS.js} +3 -3
- package/dist/synthesis/{ElevenlabsTTS.js.map → ElevenLabsTTS.js.map} +1 -1
- package/dist/synthesis/EspeakTTS.d.ts +1 -1
- package/dist/synthesis/EspeakTTS.js +9 -8
- package/dist/synthesis/EspeakTTS.js.map +1 -1
- package/dist/synthesis/KokoroTTS.js +14 -14
- package/dist/synthesis/VitsTTS.js +9 -9
- package/dist/utilities/Utilities.d.ts +1 -0
- package/dist/utilities/Utilities.js +5 -0
- package/dist/utilities/Utilities.js.map +1 -1
- package/dist/utilities/WasmMemoryManager.d.ts +1 -1
- package/docs/CLI.md +1 -1
- package/docs/CUDA.md +17 -0
- package/docs/Development.md +10 -2
- package/docs/Engines.md +2 -1
- package/docs/Options.md +19 -14
- package/docs/Tasklist.md +0 -1
- package/docs/Technical.md +18 -11
- package/package.json +6 -6
- package/src/api/Recognition.ts +29 -1
- package/src/api/Synthesis.ts +7 -7
- package/src/codecs/FFMpegTranscoder.ts +2 -0
- package/src/nlp/EspeakPhonemizer.ts +36 -36
- package/src/nlp/Lexicon.ts +24 -21
- package/src/nlp/PhoneConversion.ts +176 -225
- package/src/recognition/DeepgramSTT.ts +136 -0
- package/src/recognition/GoogleCloudSTT.ts +0 -1
- package/src/synthesis/{ElevenlabsTTS.ts → ElevenLabsTTS.ts} +4 -4
- package/src/synthesis/EspeakTTS.ts +9 -8
- package/src/synthesis/KokoroTTS.ts +15 -15
- package/src/synthesis/VitsTTS.ts +9 -9
- package/src/utilities/Utilities.ts +6 -0
package/docs/Technical.md
CHANGED
|
@@ -2,36 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
* Echogarden is written in TypeScript and targets the Node.js platform.
|
|
4
4
|
* It uses ESM modules and latest ECMAScript and TypeScript features.
|
|
5
|
-
* It does not depend on essential binary executables. Instead, all of its engines either use pure JavaScript, WebAssembly, WASI, or the ONNX runtime, with some exceptions: the CLI does invoke a command line `ffmpeg` tool, auto-downloaded
|
|
5
|
+
* It does not depend on essential binary executables. Instead, all of its engines either use pure JavaScript, WebAssembly, WASI, or the ONNX runtime, with some exceptions: the CLI does invoke a command line `ffmpeg` tool, auto-downloaded using its internal package system.
|
|
6
6
|
* It does not depend on essential native node.js modules requiring install-time compilation with `node-gyp`. This greatly simplifies the installation experience for end-users (the ONNX runtime bundles precompiled NAPI modules for all supported platforms - it doesn't require any compilation during its installation).
|
|
7
7
|
|
|
8
8
|
## Package system
|
|
9
9
|
|
|
10
10
|
Echogarden uses its own package system to download and install various components as needed. These components can be TTS voices, STT models, or other types of models and data.
|
|
11
11
|
|
|
12
|
-
Packages are downloaded as `.tar.gz` files, and are extracted to `[data-folder]/packages/[package-
|
|
12
|
+
Packages are downloaded as `.tar.gz` files, and are extracted to `[data-folder]/packages/[package-id-string]`. Each package has its own subdirectory.
|
|
13
13
|
|
|
14
14
|
`[data-folder]` is located at:
|
|
15
15
|
* `%AppData%\Local\echogarden` on Windows
|
|
16
16
|
* `Users/User/Library/Application Support/echogarden` on macOS
|
|
17
17
|
* `/home/user/.local/share/echogarden` on Linux
|
|
18
18
|
|
|
19
|
+
`[package-id-string]` is formatted as:
|
|
20
|
+
```
|
|
21
|
+
[engine name]-[package id]-[date as yyyymmdd]
|
|
22
|
+
```
|
|
23
|
+
|
|
19
24
|
By using downloadable packages, the installed size is made significantly smaller and the installation faster. The total size of all available packages is currently about 50GB (may not be up-to-date).
|
|
20
25
|
|
|
21
|
-
The packages are currently hosted and downloaded from a dedicated
|
|
26
|
+
The packages are currently hosted and downloaded from a [dedicated Hugging Face repository](https://huggingface.co/echogarden/echogarden-packages).
|
|
22
27
|
|
|
23
28
|
## Can the base install size be made smaller?
|
|
24
29
|
|
|
25
|
-
The base installed (uncompressed) size, including dependencies, is around
|
|
30
|
+
The base installed (uncompressed) size, including dependencies, is around 400MB (may not be up-to-date). This is without any models or voices, which are downloaded as needed.
|
|
26
31
|
|
|
27
32
|
Currently, the largest contributors to the size are:
|
|
28
33
|
|
|
29
|
-
* `onnxruntime-node` (NAPI):
|
|
30
|
-
* `kuromoji` (JavaScript) 40MB
|
|
31
|
-
* `
|
|
32
|
-
* `
|
|
34
|
+
* `onnxruntime-node` (core inference runtime, NAPI): 180MB
|
|
35
|
+
* `kuromoji` (Japanese tokenizer, JavaScript) 40MB
|
|
36
|
+
* `espeak-ng-emscripten` (core synthesis and phonemization engine, WASM): 23MB
|
|
37
|
+
* `tiktoken` (tokenizer library, WASM): 22MB
|
|
38
|
+
* `flite-wasi` (synthesis engine, WASI): 20MB
|
|
39
|
+
* `jieba-wasm` (Chinese tokenizer, WASM): 14MB
|
|
33
40
|
|
|
34
|
-
`onnxruntime-node` is large because it bundles pre-compiled binaries for multiple platforms. `kuromoji` is large because of its dictionary files and some unessential test code it bundles. The other
|
|
41
|
+
`onnxruntime-node` is large because it bundles pre-compiled binaries for multiple platforms. `kuromoji` is large because of its dictionary files and some unessential test code it bundles. The other packages include large WASM binaries.
|
|
35
42
|
|
|
36
43
|
So, yes, in the future it may be possible to reduce the core installed size by dynamically installing some of these dependencies, or using modified, "slimmed-down" versions of some packages.
|
|
37
44
|
|
|
@@ -44,7 +51,7 @@ However, it is a lot of work, and only a subset of the engines can be supported
|
|
|
44
51
|
* Significantly slower inference when using CPU for ONNX models
|
|
45
52
|
* No cross-domain network connectivity - can't connect to Google Cloud, Microsoft, Amazon etc. without a proxy
|
|
46
53
|
* Large initial download size would make it too heavy and slow to load as part of a standard web page directly
|
|
47
|
-
* Large memory
|
|
54
|
+
* Large memory requirements for the various ONNX models, starting at about 800MB - 1GB, which is a bit too much for a browser
|
|
48
55
|
* Due to the high code complexity, data size, and memory consumption, it is unlikely that a browser extension, internally bundling some of the models, would be accepted to the Chrome and Firefox web stores
|
|
49
56
|
* Will require a virtual file system to store models and make use of downloadable packages
|
|
50
57
|
* Requires duplicating a lot of prior work, porting many node.js-only APIs, and increasing code complexity
|
|
@@ -71,5 +78,5 @@ Also, `option=value` is more similar to the syntax used in the configuration fil
|
|
|
71
78
|
|
|
72
79
|
* `src`: TypeScript source code
|
|
73
80
|
* `dist`: compiled JavaScript modules
|
|
74
|
-
* `data`: various data files, including phonetic lexicons and language code conversion tables. `data/schemas` stores JSON schemas for all configuration options, auto-generated using [`ts-json-schema-generator`](https://github.com/vega/ts-json-schema-generator) directly from the TypeScript code, and used by the CLI to parse and validate the options provided
|
|
81
|
+
* `data`: various data files, including phonetic lexicons and language code conversion tables. `data/schemas` stores JSON schemas for all configuration options, auto-generated using [`ts-json-schema-generator`](https://github.com/vega/ts-json-schema-generator) directly from the TypeScript code, and used by the CLI to parse and validate the options provided. `data/lexicons` contains phonetic lexicons used in speech synthesis and alignment
|
|
75
82
|
* `docs`: documentation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echogarden",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "An easy-to-use speech toolset. Includes tools for synthesis, recognition, alignment, speech translation, language detection, source separation and more.",
|
|
5
5
|
"author": "Rotem Dan",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"echogarden": "./dist/cli/CLILauncher.js"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@aws-sdk/client-polly": "^3.
|
|
56
|
-
"@aws-sdk/client-transcribe-streaming": "^3.
|
|
55
|
+
"@aws-sdk/client-polly": "^3.750.0",
|
|
56
|
+
"@aws-sdk/client-transcribe-streaming": "^3.750.0",
|
|
57
57
|
"@echogarden/audio-io": "^0.3.0",
|
|
58
58
|
"@echogarden/espeak-ng-emscripten": "^0.3.3",
|
|
59
59
|
"@echogarden/fasttext-wasm": "^0.1.0",
|
|
@@ -85,14 +85,14 @@
|
|
|
85
85
|
"microsoft-cognitiveservices-speech-sdk": "^1.42.0",
|
|
86
86
|
"msgpack-lite": "^0.1.26",
|
|
87
87
|
"onnxruntime-node": "^1.20.1",
|
|
88
|
-
"openai": "^4.
|
|
88
|
+
"openai": "^4.85.3",
|
|
89
89
|
"sam-js": "^0.3.1",
|
|
90
90
|
"strip-ansi": "^7.1.0",
|
|
91
91
|
"tar": "^7.4.3",
|
|
92
92
|
"tiktoken": "^1.0.20",
|
|
93
93
|
"tinyld": "^1.3.4",
|
|
94
94
|
"wasm-feature-detect": "^1.8.0",
|
|
95
|
-
"ws": "^8.18.
|
|
95
|
+
"ws": "^8.18.1",
|
|
96
96
|
"wtf_wikipedia": "^10.4.0"
|
|
97
97
|
},
|
|
98
98
|
"peerDependencies": {
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"@types/graceful-fs": "^4.1.9",
|
|
113
113
|
"@types/jsdom": "^21.1.7",
|
|
114
114
|
"@types/msgpack-lite": "^0.1.11",
|
|
115
|
-
"@types/node": "^22.13.
|
|
115
|
+
"@types/node": "^22.13.5",
|
|
116
116
|
"@types/tar": "^6.1.13",
|
|
117
117
|
"@types/ws": "^8.5.14",
|
|
118
118
|
"ts-json-schema-generator": "^2.3.0",
|
package/src/api/Recognition.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { type SubtitlesConfig } from '../subtitles/Subtitles.js'
|
|
|
15
15
|
import { type OpenAICloudSTTOptions } from '../recognition/OpenAICloudSTT.js'
|
|
16
16
|
import { type WhisperCppOptions } from '../recognition/WhisperCppSTT.js'
|
|
17
17
|
import { type SileroRecognitionOptions } from '../recognition/SileroSTT.js'
|
|
18
|
+
import { type DeepgramSTTOptions } from '../recognition/DeepgramSTT.js'
|
|
18
19
|
import { OnnxExecutionProvider } from '../utilities/OnnxUtilities.js'
|
|
19
20
|
|
|
20
21
|
const log = logToStderr
|
|
@@ -274,6 +275,22 @@ export async function recognize(input: AudioSourceParam, options: RecognitionOpt
|
|
|
274
275
|
break
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
case 'deepgram': {
|
|
279
|
+
const DeepgramSTT = await import('../recognition/DeepgramSTT.js')
|
|
280
|
+
|
|
281
|
+
const deepgramOptions = options.deepgram!
|
|
282
|
+
|
|
283
|
+
if (!deepgramOptions.apiKey) {
|
|
284
|
+
throw new Error(`No Deepgram API key provided`)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
logger.end();
|
|
288
|
+
|
|
289
|
+
({ transcript, timeline } = await DeepgramSTT.recognize(sourceRawAudio, options.language ? shortLanguageCode : undefined, deepgramOptions))
|
|
290
|
+
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
|
|
277
294
|
default: {
|
|
278
295
|
throw new Error(`Engine '${options.engine}' is not supported`)
|
|
279
296
|
}
|
|
@@ -330,7 +347,7 @@ export interface RecognitionResult {
|
|
|
330
347
|
backgroundRawAudio?: RawAudio
|
|
331
348
|
}
|
|
332
349
|
|
|
333
|
-
export type RecognitionEngine = 'whisper' | 'whisper.cpp' | 'vosk' | 'silero' | 'google-cloud' | 'microsoft-azure' | 'amazon-transcribe' | 'openai-cloud'
|
|
350
|
+
export type RecognitionEngine = 'whisper' | 'whisper.cpp' | 'vosk' | 'silero' | 'google-cloud' | 'microsoft-azure' | 'amazon-transcribe' | 'openai-cloud' | 'deepgram'
|
|
334
351
|
|
|
335
352
|
export interface RecognitionOptions {
|
|
336
353
|
engine?: RecognitionEngine
|
|
@@ -383,6 +400,8 @@ export interface RecognitionOptions {
|
|
|
383
400
|
}
|
|
384
401
|
|
|
385
402
|
openAICloud?: OpenAICloudSTTOptions
|
|
403
|
+
|
|
404
|
+
deepgram?: DeepgramSTTOptions
|
|
386
405
|
}
|
|
387
406
|
|
|
388
407
|
export const defaultRecognitionOptions: RecognitionOptions = {
|
|
@@ -443,6 +462,9 @@ export const defaultRecognitionOptions: RecognitionOptions = {
|
|
|
443
462
|
|
|
444
463
|
openAICloud: {
|
|
445
464
|
},
|
|
465
|
+
|
|
466
|
+
deepgram: {
|
|
467
|
+
}
|
|
446
468
|
}
|
|
447
469
|
|
|
448
470
|
export const recognitionEngines: API.EngineMetadata[] = [
|
|
@@ -494,4 +516,10 @@ export const recognitionEngines: API.EngineMetadata[] = [
|
|
|
494
516
|
description: 'OpenAI cloud speech-to-text service.',
|
|
495
517
|
type: 'cloud'
|
|
496
518
|
},
|
|
519
|
+
{
|
|
520
|
+
id: 'deepgram',
|
|
521
|
+
name: 'Deepgram',
|
|
522
|
+
description: 'Deepgram cloud speech-to-text service.',
|
|
523
|
+
type: 'cloud'
|
|
524
|
+
},
|
|
497
525
|
]
|
package/src/api/Synthesis.ts
CHANGED
|
@@ -20,8 +20,8 @@ import { shouldCancelCurrentTask } from '../server/Worker.js'
|
|
|
20
20
|
import chalk from 'chalk'
|
|
21
21
|
import { type SubtitlesConfig } from '../subtitles/Subtitles.js'
|
|
22
22
|
import { type EspeakOptions } from '../synthesis/EspeakTTS.js'
|
|
23
|
-
import { type OpenAICloudTTSOptions
|
|
24
|
-
import { type
|
|
23
|
+
import { type OpenAICloudTTSOptions } from '../synthesis/OpenAICloudTTS.js'
|
|
24
|
+
import { type ElevenLabsTTSOptions } from '../synthesis/ElevenLabsTTS.js'
|
|
25
25
|
import { OnnxExecutionProvider } from '../utilities/OnnxUtilities.js'
|
|
26
26
|
import { simplifyPunctuationCharacters } from '../nlp/TextNormalizer.js'
|
|
27
27
|
import { convertHtmlToText } from '../utilities/StringUtilities.js'
|
|
@@ -63,7 +63,7 @@ async function synthesizeSegments(segments: string[], options: SynthesisOptions,
|
|
|
63
63
|
let segmentsPlainText = segments
|
|
64
64
|
|
|
65
65
|
if (options.ssml) {
|
|
66
|
-
segmentsPlainText= []
|
|
66
|
+
segmentsPlainText = []
|
|
67
67
|
|
|
68
68
|
for (const segment of segments) {
|
|
69
69
|
segmentsPlainText.push(await convertHtmlToText(segment))
|
|
@@ -811,7 +811,7 @@ async function synthesizeSegment(text: string, options: SynthesisOptions) {
|
|
|
811
811
|
throw new Error(`The Elevenlabs engine doesn't support SSML inputs`)
|
|
812
812
|
}
|
|
813
813
|
|
|
814
|
-
const ElevenLabsTTS = await import('../synthesis/
|
|
814
|
+
const ElevenLabsTTS = await import('../synthesis/ElevenLabsTTS.js')
|
|
815
815
|
|
|
816
816
|
const engineOptions = options.elevenlabs!
|
|
817
817
|
|
|
@@ -1198,7 +1198,7 @@ export interface SynthesisOptions {
|
|
|
1198
1198
|
|
|
1199
1199
|
openAICloud?: OpenAICloudTTSOptions
|
|
1200
1200
|
|
|
1201
|
-
elevenlabs?:
|
|
1201
|
+
elevenlabs?: ElevenLabsTTSOptions,
|
|
1202
1202
|
|
|
1203
1203
|
googleTranslate?: {
|
|
1204
1204
|
tld?: string
|
|
@@ -1593,14 +1593,14 @@ export async function requestVoiceList(options: VoiceListRequestOptions): Promis
|
|
|
1593
1593
|
}
|
|
1594
1594
|
|
|
1595
1595
|
case 'elevenlabs': {
|
|
1596
|
-
const ElevenLabsTTS = await import('../synthesis/
|
|
1596
|
+
const ElevenLabsTTS = await import('../synthesis/ElevenLabsTTS.js')
|
|
1597
1597
|
|
|
1598
1598
|
const engineOptions = options.elevenlabs!
|
|
1599
1599
|
|
|
1600
1600
|
const apiKey = engineOptions.apiKey
|
|
1601
1601
|
|
|
1602
1602
|
if (!apiKey) {
|
|
1603
|
-
throw new Error(`No
|
|
1603
|
+
throw new Error(`No ElevenLabs API key provided`)
|
|
1604
1604
|
}
|
|
1605
1605
|
|
|
1606
1606
|
voiceList = await ElevenLabsTTS.getVoiceList(apiKey)
|
|
@@ -223,6 +223,7 @@ export function getDefaultFFMpegOptionsForSpeech(fileExtension: string, customBi
|
|
|
223
223
|
}
|
|
224
224
|
} else if (fileExtension == 'opus') {
|
|
225
225
|
ffmpegOptions = {
|
|
226
|
+
format: 'ogg',
|
|
226
227
|
codec: 'libopus',
|
|
227
228
|
bitrate: 48,
|
|
228
229
|
customOptions: []
|
|
@@ -236,6 +237,7 @@ export function getDefaultFFMpegOptionsForSpeech(fileExtension: string, customBi
|
|
|
236
237
|
}
|
|
237
238
|
} else if (fileExtension == 'ogg') {
|
|
238
239
|
ffmpegOptions = {
|
|
240
|
+
format: 'ogg',
|
|
239
241
|
codec: 'libvorbis',
|
|
240
242
|
bitrate: 48,
|
|
241
243
|
customOptions: []
|
|
@@ -7,14 +7,14 @@ const log = logToStderr
|
|
|
7
7
|
export async function phonemizeSentence(sentence: string, espeakVoice: string, substitutionMap?: Map<string, string[]>, useIpa = true) {
|
|
8
8
|
const ipaString = await EspeakTTS.textToPhonemes(sentence, espeakVoice, useIpa)
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const phraseStrings = ipaString.split(' | ')
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const phrases: string[][][] = []
|
|
13
13
|
|
|
14
|
-
for (let
|
|
15
|
-
const
|
|
14
|
+
for (let phraseIndex = 0; phraseIndex < phraseStrings.length; phraseIndex++) {
|
|
15
|
+
const phraseString = phraseStrings[phraseIndex]
|
|
16
16
|
|
|
17
|
-
const wordStrings =
|
|
17
|
+
const wordStrings = phraseString.trim().split(/ +/g)
|
|
18
18
|
const words: string[][] = []
|
|
19
19
|
|
|
20
20
|
for (let wordIndex = 0; wordIndex < wordStrings.length; wordIndex++) {
|
|
@@ -40,11 +40,11 @@ export async function phonemizeSentence(sentence: string, espeakVoice: string, s
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
if (words.length > 0) {
|
|
43
|
-
|
|
43
|
+
phrases.push(words)
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
return
|
|
47
|
+
return phrases
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export async function phonemizeText(text: string, voice: string, substitutionMap?: Map<string, string[]>) {
|
|
@@ -58,46 +58,46 @@ export async function phonemizeText(text: string, voice: string, substitutionMap
|
|
|
58
58
|
.replaceAll('»', ', ')
|
|
59
59
|
|
|
60
60
|
const segmentedText = await Segmentation.parse(text, voice)
|
|
61
|
-
const
|
|
62
|
-
const
|
|
61
|
+
const preparedPhrases: string[] = []
|
|
62
|
+
const phraseBreakers: string[] = []
|
|
63
63
|
|
|
64
64
|
for (const sentence of segmentedText) {
|
|
65
|
-
for (const
|
|
66
|
-
const words =
|
|
67
|
-
const
|
|
65
|
+
for (const phrase of sentence.phrases) {
|
|
66
|
+
const words = phrase.words.filter(wordObject => Segmentation.isWordOrSymbolWord(wordObject.text))
|
|
67
|
+
const preparedPhraseText = words.map(word => word.text.replace(/\./g, ' ')).join(' ')
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
preparedPhrases.push(preparedPhraseText)
|
|
70
70
|
|
|
71
|
-
const
|
|
72
|
-
const lastChar =
|
|
71
|
+
const trimmedPhraseText = phrase.text.trim()
|
|
72
|
+
const lastChar = trimmedPhraseText[trimmedPhraseText.length - 1]
|
|
73
73
|
|
|
74
|
-
if (
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
} else if (
|
|
78
|
-
|
|
74
|
+
if (phrase.isSentenceFinalizer) {
|
|
75
|
+
if (trimmedPhraseText.endsWith('?') || trimmedPhraseText.endsWith(`?"`)) {
|
|
76
|
+
phraseBreakers.push('?')
|
|
77
|
+
} else if (trimmedPhraseText.endsWith('!') || trimmedPhraseText.endsWith(`!"`)) {
|
|
78
|
+
phraseBreakers.push('!')
|
|
79
79
|
} else {
|
|
80
|
-
|
|
80
|
+
phraseBreakers.push('.')
|
|
81
81
|
}
|
|
82
82
|
} else {
|
|
83
83
|
if (lastChar == ':' || lastChar == ';') {
|
|
84
|
-
|
|
84
|
+
phraseBreakers.push(lastChar)
|
|
85
85
|
} else {
|
|
86
|
-
|
|
86
|
+
phraseBreakers.push(',')
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
return
|
|
92
|
+
return phonemizePhrases(preparedPhrases, voice, phraseBreakers, substitutionMap)
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export async function
|
|
96
|
-
if (
|
|
95
|
+
export async function phonemizePhrases(phrases: string[], voice: string, phraseBreakers: string[], substitutionMap?: Map<string, string[]>) {
|
|
96
|
+
if (phrases.length == 0) {
|
|
97
97
|
return []
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
const preparedText =
|
|
100
|
+
const preparedText = phrases.join('\n\n') // filter(phrase => phrase.trim().length > 0)
|
|
101
101
|
|
|
102
102
|
const ipaString = await EspeakTTS.textToIPA(preparedText, voice)
|
|
103
103
|
|
|
@@ -130,31 +130,31 @@ export async function phonemizeClauses(clauses: string[], voice: string, clauseB
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
-
if (ipaLines.length !=
|
|
134
|
-
log(
|
|
133
|
+
if (ipaLines.length != phraseBreakers.length) {
|
|
134
|
+
log(phrases)
|
|
135
135
|
log(ipaLines)
|
|
136
|
-
log(
|
|
136
|
+
log(phraseBreakers)
|
|
137
137
|
|
|
138
|
-
throw new Error(`Unexpected: IPA lines count (${ipaLines.length}) is not equal to
|
|
138
|
+
throw new Error(`Unexpected: IPA lines count (${ipaLines.length}) is not equal to phrase breakers count (${phraseBreakers.length})`)
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
for (let i = 0; i < phonemeLines.length; i++) {
|
|
142
142
|
const line = phonemeLines[i]
|
|
143
143
|
const lastWordInLine = line[line.length - 1]
|
|
144
144
|
|
|
145
|
-
lastWordInLine.push(
|
|
145
|
+
lastWordInLine.push(phraseBreakers[i])
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
return phonemeLines
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
export function
|
|
151
|
+
export function phonemizedPhrasesToSentences(phonemizedPhrases: string[][][]) {
|
|
152
152
|
let phonemizedSentences: string[][][] = [[]]
|
|
153
153
|
|
|
154
|
-
for (const
|
|
155
|
-
phonemizedSentences[phonemizedSentences.length - 1].push(...
|
|
154
|
+
for (const phonemizedPhrase of phonemizedPhrases) {
|
|
155
|
+
phonemizedSentences[phonemizedSentences.length - 1].push(...phonemizedPhrase)
|
|
156
156
|
|
|
157
|
-
const lastWord =
|
|
157
|
+
const lastWord = phonemizedPhrase[phonemizedPhrase.length - 1]
|
|
158
158
|
const lastPhoneme = lastWord[lastWord.length - 1]
|
|
159
159
|
|
|
160
160
|
if (['.', '?', '!'].includes(lastPhoneme)) {
|
package/src/nlp/Lexicon.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { getShortLanguageCode } from '../utilities/Locale.js'
|
|
|
3
3
|
import { resolveToModuleRootDir } from '../utilities/PathUtilities.js'
|
|
4
4
|
|
|
5
5
|
export function tryGetFirstLexiconSubstitution(sentenceWords: string[], wordIndex: number, lexicons: Lexicon[], languageCode: string) {
|
|
6
|
-
|
|
6
|
+
for (let i = lexicons.length - 1; i >= 0; i--) {
|
|
7
|
+
const lexicon = lexicons[i]
|
|
7
8
|
|
|
8
|
-
for (const lexicon of reversedLexicons) {
|
|
9
9
|
const match = tryGetLexiconSubstitution(sentenceWords, wordIndex, lexicon, languageCode)
|
|
10
10
|
|
|
11
11
|
if (match) {
|
|
@@ -20,51 +20,51 @@ export function tryGetLexiconSubstitution(sentenceWords: string[], wordIndex: nu
|
|
|
20
20
|
let word = sentenceWords[wordIndex]
|
|
21
21
|
|
|
22
22
|
if (!word) {
|
|
23
|
-
return
|
|
23
|
+
return undefined
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const shortLanguageCode = getShortLanguageCode(languageCode)
|
|
27
27
|
const lexiconForLanguage = lexicon[shortLanguageCode]
|
|
28
28
|
|
|
29
29
|
if (!lexiconForLanguage) {
|
|
30
|
-
return
|
|
30
|
+
return undefined
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const lexiconEntry = lexiconForLanguage[word]
|
|
34
34
|
|
|
35
35
|
if (!lexiconEntry) {
|
|
36
|
-
return
|
|
36
|
+
return undefined
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
for (let i = 0; i < lexiconEntry.length; i++) {
|
|
40
|
-
const
|
|
40
|
+
const candidateEntry = lexiconEntry[i]
|
|
41
41
|
|
|
42
|
-
const
|
|
42
|
+
const pronunciationPhonemesText = candidateEntry?.pronunciation?.espeak?.[languageCode]
|
|
43
43
|
|
|
44
|
-
if (!
|
|
44
|
+
if (!pronunciationPhonemesText) {
|
|
45
45
|
continue
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const precedingWord = sentenceWords[wordIndex - 1]
|
|
49
|
-
const
|
|
48
|
+
const precedingWord = sentenceWords[wordIndex - 1] ?? ''
|
|
49
|
+
const followingWord = sentenceWords[wordIndex + 1] ?? ''
|
|
50
50
|
|
|
51
|
-
const precededBy =
|
|
52
|
-
const notPrecededBy =
|
|
51
|
+
const precededBy = candidateEntry?.precededBy ?? []
|
|
52
|
+
const notPrecededBy = candidateEntry?.notPrecededBy ?? []
|
|
53
53
|
|
|
54
|
-
const
|
|
55
|
-
const
|
|
54
|
+
const followedBy = candidateEntry?.followedBy ?? candidateEntry?.succeededBy ?? []
|
|
55
|
+
const notFollowedBy = candidateEntry?.notFollowedBy ?? candidateEntry?.notSucceededBy ?? []
|
|
56
56
|
|
|
57
|
-
const hasNegativePattern = notPrecededBy.includes(precedingWord) ||
|
|
58
|
-
const hasPositivePattern = precededBy.includes(precedingWord) ||
|
|
57
|
+
const hasNegativePattern = notPrecededBy.includes(precedingWord) || notFollowedBy.includes(followingWord)
|
|
58
|
+
const hasPositivePattern = precededBy.includes(precedingWord) || followedBy.includes(followingWord)
|
|
59
59
|
|
|
60
|
-
if (i
|
|
61
|
-
const substitutionPhonemes =
|
|
60
|
+
if (i === lexiconEntry.length - 1 || (hasPositivePattern && !hasNegativePattern)) {
|
|
61
|
+
const substitutionPhonemes = pronunciationPhonemesText.split(/ +/g)
|
|
62
62
|
|
|
63
63
|
return substitutionPhonemes
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
return
|
|
67
|
+
return undefined
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export async function loadLexiconFile(jsonFilePath: string): Promise<Lexicon> {
|
|
@@ -115,8 +115,11 @@ export type LexiconEntry = {
|
|
|
115
115
|
precededBy?: string[]
|
|
116
116
|
notPrecededBy?: string[]
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
followedBy?: string[]
|
|
119
|
+
notFollowedBy?: string[]
|
|
120
|
+
|
|
121
|
+
succeededBy?: string[] // Deprecated. Replaced by 'followedBy'
|
|
122
|
+
notSucceededBy?: string[] // Deprecated. Replaced by 'notFollowedBy'
|
|
120
123
|
|
|
121
124
|
example?: string
|
|
122
125
|
}
|