echogarden 0.11.12 → 0.11.13

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.
Files changed (122) hide show
  1. package/data/schemas/options.json +16 -0
  2. package/dist/api/Alignment.js +2 -2
  3. package/dist/api/Alignment.js.map +1 -1
  4. package/dist/api/Recognition.js +2 -2
  5. package/dist/api/Recognition.js.map +1 -1
  6. package/dist/api/Synthesis.js +5 -4
  7. package/dist/api/Synthesis.js.map +1 -1
  8. package/dist/api/Translation.js +2 -2
  9. package/dist/api/Translation.js.map +1 -1
  10. package/dist/audio/AudioUtilities.d.ts +1 -0
  11. package/dist/audio/AudioUtilities.js +25 -7
  12. package/dist/audio/AudioUtilities.js.map +1 -1
  13. package/dist/cli/CLI.js +2 -2
  14. package/dist/cli/CLI.js.map +1 -1
  15. package/dist/recognition/WhisperSTT.js +2 -2
  16. package/dist/recognition/WhisperSTT.js.map +1 -1
  17. package/dist/subtitles/Subtitles.d.ts +10 -7
  18. package/dist/subtitles/Subtitles.js +268 -207
  19. package/dist/subtitles/Subtitles.js.map +1 -1
  20. package/docs/Options.md +4 -2
  21. package/package.json +7 -6
  22. package/src/alignment/DTWMfccSequenceAlignment.ts +43 -0
  23. package/src/alignment/DTWSequenceAlignment.ts +121 -0
  24. package/src/alignment/DTWSequenceAlignmentWindowed.ts +210 -0
  25. package/src/alignment/LevenshteinSequenceAlignment.ts +126 -0
  26. package/src/alignment/SpeechAlignment.ts +488 -0
  27. package/src/api/API.ts +12 -0
  28. package/src/api/APIOptions.ts +15 -0
  29. package/src/api/Alignment.ts +329 -0
  30. package/src/api/Common.ts +16 -0
  31. package/src/api/Denoising.ts +120 -0
  32. package/src/api/LanguageDetection.ts +286 -0
  33. package/src/api/Recognition.ts +344 -0
  34. package/src/api/Synthesis.ts +1735 -0
  35. package/src/api/Translation.ts +143 -0
  36. package/src/api/Vad.ts +172 -0
  37. package/src/audio/AudioBufferConversion.ts +248 -0
  38. package/src/audio/AudioPlayer.ts +358 -0
  39. package/src/audio/AudioRecorder.ts +91 -0
  40. package/src/audio/AudioUtilities.ts +392 -0
  41. package/src/audio/SoxPath.ts +24 -0
  42. package/src/cli/CLI.ts +1360 -0
  43. package/src/cli/CLIConfigFile.ts +91 -0
  44. package/src/cli/CLILauncher.ts +26 -0
  45. package/src/cli/CLIOptionsSchema.ts +54 -0
  46. package/src/cli/CLIParser.ts +41 -0
  47. package/src/cli/CLIStarter.ts +40 -0
  48. package/src/codecs/FFMpegTranscoder.ts +214 -0
  49. package/src/codecs/TIMITCodec.ts +17 -0
  50. package/src/codecs/WaveCodec.ts +260 -0
  51. package/src/denoising/RNNoise.ts +95 -0
  52. package/src/dsp/BiquadFilter.ts +488 -0
  53. package/src/dsp/FFT.ts +187 -0
  54. package/src/dsp/MFCC.ts +227 -0
  55. package/src/dsp/MelSpectogram.ts +145 -0
  56. package/src/dsp/Rubberband.ts +249 -0
  57. package/src/dsp/Sonic.ts +59 -0
  58. package/src/dsp/SpeexResampler.ts +79 -0
  59. package/src/math/VectorMath.ts +812 -0
  60. package/src/nlp/ChineseSegmentation.ts +68 -0
  61. package/src/nlp/CompromiseNLP.ts +113 -0
  62. package/src/nlp/EspeakPhonemizer.ts +168 -0
  63. package/src/nlp/IPA.ts +139 -0
  64. package/src/nlp/JapaneseSegmentation.ts +53 -0
  65. package/src/nlp/Lexicon.ts +119 -0
  66. package/src/nlp/PhoneConversion.ts +508 -0
  67. package/src/nlp/Segmentation.ts +237 -0
  68. package/src/nlp/TextNormalizer.ts +160 -0
  69. package/src/recognition/AmazonTranscribeSTT.ts +112 -0
  70. package/src/recognition/AzureCognitiveServicesSTT.ts +76 -0
  71. package/src/recognition/GoogleCloudSTT.ts +92 -0
  72. package/src/recognition/SileroSTT.ts +173 -0
  73. package/src/recognition/VoskSTT.ts +112 -0
  74. package/src/recognition/WhisperSTT.ts +1518 -0
  75. package/src/server/Client.ts +297 -0
  76. package/src/server/Server.ts +178 -0
  77. package/src/server/ServerStarter.ts +12 -0
  78. package/src/server/Worker.ts +400 -0
  79. package/src/server/WorkerStarter.ts +38 -0
  80. package/src/speech-language-detection/SileroLanguageDetection.ts +105 -0
  81. package/src/subtitles/Subtitles.ts +478 -0
  82. package/src/synthesis/AwsPollyTTS.ts +78 -0
  83. package/src/synthesis/AzureCognitiveServicesTTS.ts +146 -0
  84. package/src/synthesis/CoquiServerTTS.ts +29 -0
  85. package/src/synthesis/ElevenLabsTTS.ts +104 -0
  86. package/src/synthesis/EspeakTTS.ts +552 -0
  87. package/src/synthesis/FliteTTS.ts +387 -0
  88. package/src/synthesis/GoogleCloudTTS.ts +112 -0
  89. package/src/synthesis/GoogleTranslateTTS.ts +210 -0
  90. package/src/synthesis/MicrosoftEdgeTTS.ts +298 -0
  91. package/src/synthesis/SamTTS.ts +30 -0
  92. package/src/synthesis/SapiTTS.ts +222 -0
  93. package/src/synthesis/StreamlabsPollyTTS.ts +114 -0
  94. package/src/synthesis/SvoxPicoTTS.ts +318 -0
  95. package/src/synthesis/VitsTTS.ts +734 -0
  96. package/src/tests/Test.ts +24 -0
  97. package/src/text-language-detection/FastTextLanguageDetection.ts +53 -0
  98. package/src/text-language-detection/TinyLDLanguageDetection.ts +16 -0
  99. package/src/typings/Fillers.d.ts +41 -0
  100. package/src/utilities/BinaryArrayConversion.ts +159 -0
  101. package/src/utilities/Compression.ts +91 -0
  102. package/src/utilities/FileDownloader.ts +201 -0
  103. package/src/utilities/FileSystem.ts +265 -0
  104. package/src/utilities/Hashing.ts +230 -0
  105. package/src/utilities/Locale.ts +119 -0
  106. package/src/utilities/Logger.ts +72 -0
  107. package/src/utilities/NdArrayUtilities.ts +31 -0
  108. package/src/utilities/ObjectUtilities.ts +169 -0
  109. package/src/utilities/OpenPromise.ts +13 -0
  110. package/src/utilities/PackageManager.ts +97 -0
  111. package/src/utilities/Queue.ts +17 -0
  112. package/src/utilities/RandomGenerator.ts +237 -0
  113. package/src/utilities/SignalChannel.ts +22 -0
  114. package/src/utilities/TarballMaker.ts +68 -0
  115. package/src/utilities/Timeline.ts +231 -0
  116. package/src/utilities/Timer.ts +93 -0
  117. package/src/utilities/Utilities.ts +574 -0
  118. package/src/utilities/WasmMemoryManager.ts +516 -0
  119. package/src/utilities/WebReader.ts +55 -0
  120. package/src/utilities/WikipediaReader.ts +41 -0
  121. package/src/voice-activity-detection/SileroVAD.ts +86 -0
  122. package/src/voice-activity-detection/WebRtcVAD.ts +76 -0
@@ -0,0 +1,1735 @@
1
+ import path from "node:path"
2
+
3
+ import { deepClone, extendDeep } from "../utilities/ObjectUtilities.js"
4
+
5
+ import * as FFMpegTranscoder from "../codecs/FFMpegTranscoder.js"
6
+
7
+ import { clip, convertHtmlToText, sha256AsHex, simplifyPunctuationCharacters, stringifyAndFormatJson, logToStderr, yieldToEventLoop } from "../utilities/Utilities.js"
8
+ import { RawAudio, concatAudioSegments, downmixToMono, encodeWaveBuffer, getAudioPeakDecibels, getEmptyRawAudio, getRawAudioDuration, normalizeAudioLevel, trimAudioEnd, trimAudioStart } from "../audio/AudioUtilities.js"
9
+ import { Logger } from "../utilities/Logger.js"
10
+
11
+ import { isWordOrSymbolWord, splitToParagraphs, splitToSentences } from "../nlp/Segmentation.js"
12
+ import { type RubberbandOptions } from "../dsp/Rubberband.js"
13
+ import { loadLexiconsForLanguage } from "../nlp/Lexicon.js"
14
+
15
+ import * as API from "./API.js"
16
+ import { Timeline, TimelineEntry, addTimeOffsetToTimeline, multiplyTimelineByFactor } from "../utilities/Timeline.js"
17
+ import { getAppDataDir, ensureDir, existsSync, isFileIsUpToDate, readAndParseJsonFile, writeFileSafe } from "../utilities/FileSystem.js"
18
+ import { formatLanguageCodeWithName, getShortLanguageCode, normalizeLanguageCode, defaultDialectForLanguageCode } from "../utilities/Locale.js"
19
+ import { loadPackage } from "../utilities/PackageManager.js"
20
+ import { EngineMetadata, appName } from "./Common.js"
21
+ import { shouldCancelCurrentTask } from "../server/Worker.js"
22
+ import chalk from "chalk"
23
+ import { SubtitlesConfig, defaultSubtitlesBaseConfig } from "../subtitles/Subtitles.js"
24
+ import { type EspeakOptions } from "../synthesis/EspeakTTS.js"
25
+
26
+ const log = logToStderr
27
+
28
+ /////////////////////////////////////////////////////////////////////////////////////////////
29
+ // Synthesis
30
+ /////////////////////////////////////////////////////////////////////////////////////////////
31
+ export async function synthesize(input: string | string[], options: SynthesisOptions, onSegment?: SynthesisSegmentEvent, onSentence?: SynthesisSegmentEvent): Promise<SynthesisResult> {
32
+ options = extendDeep(defaultSynthesisOptions, options)
33
+
34
+ let segments: string[]
35
+
36
+ if (Array.isArray(input)) {
37
+ segments = input
38
+ } else if (options.ssml) {
39
+ segments = [input]
40
+ } else {
41
+ const plainTextOptions = options.plainText!
42
+
43
+ segments = splitToParagraphs(input, plainTextOptions.paragraphBreaks!, plainTextOptions.whitespace!)
44
+ }
45
+
46
+ return synthesizeSegments(segments, options, onSegment, onSentence)
47
+ }
48
+
49
+ async function synthesizeSegments(segments: string[], options: SynthesisOptions, onSegment?: SynthesisSegmentEvent, onSentence?: SynthesisSegmentEvent): Promise<SynthesisResult> {
50
+ const logger = new Logger()
51
+ options = extendDeep(defaultSynthesisOptions, options)
52
+
53
+ if (!options.language && !options.voice) {
54
+ logger.start("No language or voice specified. Detecting language")
55
+
56
+ let segmentsPlainText = segments
57
+
58
+ if (options.ssml) {
59
+ segmentsPlainText= []
60
+
61
+ for (const segment of segments) {
62
+ segmentsPlainText.push(await convertHtmlToText(segment))
63
+ }
64
+ }
65
+
66
+ const { detectedLanguage } = await API.detectTextLanguage(segmentsPlainText.join("\n\n"), options.languageDetection || {})
67
+
68
+ options.language = detectedLanguage
69
+
70
+ logger.end()
71
+ logger.logTitledMessage('Language detected', formatLanguageCodeWithName(detectedLanguage))
72
+ }
73
+
74
+ if (!options.engine) {
75
+ if (options.voice) {
76
+ throw new Error(`Voice '${options.voice}' was specified but no engine was specified.`)
77
+ }
78
+
79
+ options.engine = await selectBestOfflineEngineForLanguage(options.language!)
80
+
81
+ logger.logTitledMessage('No engine specified. Auto-selected engine', options.engine)
82
+ }
83
+
84
+ logger.start(`Get voice list for ${options.engine}`)
85
+
86
+ const { bestMatchingVoice } = await requestVoiceList(options)
87
+
88
+ if (!bestMatchingVoice) {
89
+ throw new Error("No matching voice found")
90
+ }
91
+
92
+ options.voice = bestMatchingVoice.name
93
+
94
+ if (!options.language) {
95
+ options.language = bestMatchingVoice.languages[0]
96
+ }
97
+
98
+ logger.end()
99
+ logger.logTitledMessage('Selected voice', `'${options.voice}' (${formatLanguageCodeWithName(bestMatchingVoice.languages[0], 2)})`)
100
+
101
+ const segmentsRawAudio: RawAudio[] = []
102
+ const segmentsTimelines: Timeline[] = []
103
+
104
+ const timeline: Timeline = []
105
+
106
+ let peakDecibelsSoFar = -100
107
+
108
+ let timeOffset = 0
109
+
110
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
111
+ const segmentText = segments[segmentIndex].trim()
112
+
113
+ logger.log(`\n${chalk.magentaBright(`Synthesizing segment ${segmentIndex + 1}/${segments.length}`)}: "${segmentText}"`)
114
+
115
+ const segmentStartTime = timeOffset
116
+
117
+ const segmentEntry: TimelineEntry = {
118
+ type: "segment",
119
+ text: segmentText,
120
+ startTime: timeOffset,
121
+ endTime: -1,
122
+ timeline: []
123
+ }
124
+
125
+ let sentences: string[]
126
+
127
+ if ((options.splitToSentences || options.engine == "vits") && !options.ssml) {
128
+ sentences = splitToSentences(segmentText, options.language!)
129
+ sentences = sentences.filter(sentence => sentence.trim() != "")
130
+
131
+ if (sentences.length == 0) {
132
+ sentences = [""]
133
+ }
134
+ } else {
135
+ sentences = [segmentText]
136
+ }
137
+
138
+ const sentencesRawAudio: RawAudio[] = []
139
+ const sentencesTimelines: Timeline[] = []
140
+
141
+ for (let sentenceIndex = 0; sentenceIndex < sentences.length; sentenceIndex++) {
142
+ await yieldToEventLoop()
143
+
144
+ if (shouldCancelCurrentTask()) {
145
+ //log("\n\n\n\n\nCANCELED\n\n\n\n")
146
+ throw new Error("Canceled")
147
+ }
148
+
149
+ const sentenceText = sentences[sentenceIndex].trim()
150
+
151
+ logger.log(`\n${chalk.magentaBright(`Synthesizing sentence ${sentenceIndex + 1}/${sentences.length}`)}: "${sentenceText}"`)
152
+
153
+ const sentenceStartTime = timeOffset
154
+
155
+ let sentencetSynthesisOptions: SynthesisOptions = { postProcessing: { normalizeAudio: false } }
156
+ sentencetSynthesisOptions = extendDeep(options, sentencetSynthesisOptions)
157
+
158
+ const { synthesizedAudio: sentenceRawAudio, timeline: sentenceTimeline } = await synthesizeSegment(sentenceText, sentencetSynthesisOptions)
159
+
160
+ const endPause = sentenceIndex == sentences.length - 1 ? options.segmentEndPause! : options.sentenceEndPause!
161
+ sentenceRawAudio.audioChannels[0] = trimAudioEnd(sentenceRawAudio.audioChannels[0], endPause * sentenceRawAudio.sampleRate)
162
+
163
+ sentencesRawAudio.push(sentenceRawAudio)
164
+
165
+ if (sentenceTimeline.length > 0) {
166
+ sentencesTimelines.push(sentenceTimeline)
167
+ }
168
+
169
+ const sentenceAudioLength = sentenceRawAudio.audioChannels[0].length / sentenceRawAudio.sampleRate
170
+
171
+ timeOffset += sentenceAudioLength
172
+
173
+ const sentenceTimelineWithOffset = addTimeOffsetToTimeline(sentenceTimeline, sentenceStartTime)
174
+
175
+ const sentenceEndTime = timeOffset - endPause
176
+
177
+ segmentEntry.timeline!.push({
178
+ type: "sentence",
179
+ text: sentenceText,
180
+ startTime: sentenceStartTime,
181
+ endTime: sentenceEndTime,
182
+ timeline: sentenceTimelineWithOffset
183
+ })
184
+
185
+ peakDecibelsSoFar = Math.max(peakDecibelsSoFar, getAudioPeakDecibels(sentenceRawAudio.audioChannels))
186
+
187
+ const sentenceAudio = await convertToTargetCodecIfNeeded(sentenceRawAudio)
188
+
189
+ if (onSentence) {
190
+ await onSentence({
191
+ index: sentenceIndex,
192
+ total: sentences.length,
193
+ audio: sentenceAudio,
194
+ timeline: sentenceTimeline,
195
+ transcript: sentenceText,
196
+ language: options.language!,
197
+ peakDecibelsSoFar
198
+ })
199
+ }
200
+ }
201
+
202
+ segmentEntry.endTime = segmentEntry.timeline?.[segmentEntry.timeline.length - 1]?.endTime || timeOffset
203
+
204
+ logger.end()
205
+
206
+ logger.start(`Merge and postprocess sentences`)
207
+
208
+ let segmentRawAudio: RawAudio
209
+
210
+ if (sentencesRawAudio.length > 0) {
211
+ const joinedAudioBuffers = concatAudioSegments(sentencesRawAudio.map(part => part.audioChannels))
212
+ segmentRawAudio = { audioChannels: joinedAudioBuffers, sampleRate: sentencesRawAudio[0].sampleRate }
213
+ } else {
214
+ segmentRawAudio = getEmptyRawAudio(1, 24000)
215
+ }
216
+
217
+ segmentsRawAudio.push(segmentRawAudio)
218
+
219
+ timeline.push(segmentEntry)
220
+ const segmentTimelineWithoutOffset = addTimeOffsetToTimeline(segmentEntry.timeline!, -segmentStartTime)
221
+ segmentsTimelines.push(segmentTimelineWithoutOffset)
222
+
223
+ const segmentAudio = await convertToTargetCodecIfNeeded(segmentRawAudio)
224
+
225
+ logger.end()
226
+
227
+ if (onSegment) {
228
+ await onSegment({
229
+ index: segmentIndex,
230
+ total: segments.length,
231
+ audio: segmentAudio,
232
+ timeline: segmentTimelineWithoutOffset,
233
+ transcript: segmentText,
234
+ language: options.language!,
235
+ peakDecibelsSoFar
236
+ })
237
+ }
238
+ }
239
+
240
+ logger.start(`\nMerge and postprocess segments`)
241
+ let resultRawAudio: RawAudio
242
+
243
+ if (segmentsRawAudio.length > 0) {
244
+ const joinedAudioBuffers = concatAudioSegments(segmentsRawAudio.map(part => part.audioChannels))
245
+ resultRawAudio = { audioChannels: joinedAudioBuffers, sampleRate: segmentsRawAudio[0].sampleRate }
246
+
247
+ if (options.postProcessing!.normalizeAudio) {
248
+ resultRawAudio = normalizeAudioLevel(resultRawAudio, options.postProcessing!.targetPeakDb, options.postProcessing!.maxIncreaseDb)
249
+ }
250
+ } else {
251
+ resultRawAudio = getEmptyRawAudio(1, 24000)
252
+ }
253
+
254
+ async function convertToTargetCodecIfNeeded(rawAudio: RawAudio) {
255
+ const targetCodec = options.outputAudioFormat?.codec
256
+
257
+ let output: RawAudio | Buffer
258
+
259
+ if (targetCodec) {
260
+ logger.start(`Convert to ${targetCodec} codec`)
261
+
262
+ if (targetCodec == "wav") {
263
+ output = encodeWaveBuffer(rawAudio)
264
+ } else {
265
+ const ffmpegOptions = FFMpegTranscoder.getDefaultFFMpegOptionsForSpeech(targetCodec, options.outputAudioFormat?.bitrate)
266
+ output = await FFMpegTranscoder.encodeFromChannels(rawAudio, ffmpegOptions)
267
+ }
268
+ } else {
269
+ output = rawAudio
270
+ }
271
+
272
+ return output
273
+ }
274
+
275
+ const resultAudio = await convertToTargetCodecIfNeeded(resultRawAudio)
276
+
277
+ logger.end()
278
+
279
+ return {
280
+ audio: resultAudio,
281
+ timeline,
282
+ language: options.language,
283
+ voice: options.voice
284
+ }
285
+ }
286
+
287
+ export interface SynthesisResult {
288
+ audio: RawAudio | Buffer
289
+ timeline: Timeline
290
+ language: string
291
+ voice: string
292
+ }
293
+
294
+ async function synthesizeSegment(text: string, options: SynthesisOptions) {
295
+ const logger = new Logger()
296
+ const startTimestamp = logger.getTimestamp()
297
+
298
+ logger.start("Prepare for synthesis")
299
+
300
+ const simplifiedText = simplifyPunctuationCharacters(text)
301
+
302
+ const engine = options.engine
303
+
304
+ logger.start(`Get voice list for ${engine}`)
305
+
306
+ const { bestMatchingVoice } = await requestVoiceList(options)
307
+
308
+ if (!bestMatchingVoice) {
309
+ throw new Error("No matching voice found")
310
+ }
311
+
312
+ const selectedVoice = bestMatchingVoice
313
+
314
+ let voicePackagePath: string | undefined
315
+
316
+ if (selectedVoice.packageName) {
317
+ logger.end()
318
+
319
+ voicePackagePath = await loadPackage(selectedVoice.packageName)
320
+ }
321
+
322
+ logger.start(`Initialize ${engine} module`)
323
+
324
+ const voice = selectedVoice.name
325
+ const language = options.language ? normalizeLanguageCode(options.language) : selectedVoice.languages[0]
326
+ const voiceGender = selectedVoice.gender
327
+
328
+ const speed = clip(options.speed!, 0.1, 10.0)
329
+ const pitch = clip(options.pitch!, 0.1, 10.0)
330
+
331
+ const inputIsSSML = options.ssml!
332
+
333
+ let synthesizedAudio: RawAudio
334
+
335
+ let timeline: Timeline | undefined
336
+
337
+ let shouldPostprocessSpeed = false
338
+ let shouldPostprocessPitch = false
339
+
340
+ switch (engine) {
341
+ case "vits": {
342
+ if (inputIsSSML) {
343
+ throw new Error(`The VITS engine doesn't currently support SSML inputs`)
344
+ }
345
+
346
+ let vitsLanguage = language
347
+
348
+ if (vitsLanguage == "en") {
349
+ vitsLanguage = "en-us"
350
+ }
351
+
352
+ const vitsTTS = await import("../synthesis/VitsTTS.js")
353
+
354
+ const lengthScale = 1 / speed
355
+
356
+ const engineOptions = options.vits!
357
+
358
+ const speakerId = engineOptions.speakerId
359
+
360
+ if (speakerId != undefined) {
361
+ if (selectedVoice.speakerCount == undefined) {
362
+ if (speakerId != 0) {
363
+ throw new Error("Selected VITS model has only one speaker. Speaker ID must be 0 if specified.")
364
+ }
365
+ } else if (speakerId < 0 || speakerId >= selectedVoice.speakerCount) {
366
+ throw new Error(`Selected VITS model has ${selectedVoice.speakerCount} voices. Speaker ID should be in the range ${0} to ${selectedVoice.speakerCount - 1}`)
367
+ }
368
+ }
369
+
370
+ const lexicons = await loadLexiconsForLanguage(language, options.customLexiconPaths)
371
+
372
+ const modelPath = voicePackagePath!
373
+
374
+ logger.end()
375
+
376
+ const { rawAudio, timeline: outTimeline } = await vitsTTS.synthesizeSentence(text, voice, modelPath, lengthScale, speakerId, lexicons)
377
+
378
+ synthesizedAudio = rawAudio
379
+ timeline = outTimeline
380
+
381
+ shouldPostprocessPitch = true
382
+
383
+ logger.end()
384
+
385
+ break
386
+ }
387
+
388
+ case "pico": {
389
+ if (inputIsSSML) {
390
+ throw new Error(`The SVOX Pico engine doesn't currently support SSML inputs`)
391
+ }
392
+
393
+ const SvoxPicoTTS = await import("../synthesis/SvoxPicoTTS.js")
394
+
395
+ const picoSpeed = Math.round(speed * 1.0 * 100)
396
+ const picoPitch = Math.round(pitch * 1.0 * 100)
397
+ const picoVolume = 35.0
398
+
399
+ const preparedText = `<speed level="${picoSpeed}"><pitch level="${picoPitch}"><volume level="${picoVolume}">${simplifiedText}</volume></pitch></speed>`
400
+
401
+ logger.end()
402
+
403
+ const { textAnalysisFilename, signalGenerationFilename } = SvoxPicoTTS.getResourceFilenamesForLanguage(language)
404
+
405
+ const resourceFilePath = path.resolve(voicePackagePath!, textAnalysisFilename)
406
+ const signalGenerationFilePath = path.resolve(voicePackagePath!, signalGenerationFilename)
407
+
408
+ const { rawAudio } = await SvoxPicoTTS.synthesize(preparedText, resourceFilePath, signalGenerationFilePath)
409
+
410
+ synthesizedAudio = rawAudio
411
+
412
+ break
413
+ }
414
+
415
+ case "flite": {
416
+ if (inputIsSSML) {
417
+ throw new Error(`The Flite engine doesn't currently support SSML inputs`)
418
+ }
419
+
420
+ const FliteTTS = await import("../synthesis/FliteTTS.js")
421
+
422
+ logger.end()
423
+
424
+ const { rawAudio, events } = await FliteTTS.synthesize(simplifiedText, voice, voicePackagePath, speed)
425
+
426
+ synthesizedAudio = rawAudio
427
+
428
+ shouldPostprocessPitch = true
429
+
430
+ break
431
+ }
432
+
433
+ case "espeak": {
434
+ const EspeakTTS = await import("../synthesis/EspeakTTS.js")
435
+
436
+ const engineOptions = options.espeak!
437
+
438
+ const espeakVoice = voice
439
+ const espeakLanguage = selectedVoice.languages[0]
440
+ const espeakRate = engineOptions.rate || speed * 150
441
+ const espeakPitch = engineOptions.pitch || options.pitch! * 50
442
+ const espeakPitchRange = engineOptions.pitchRange || options.pitchVariation! * 50
443
+ const espeakUseKlatt = engineOptions.useKlatt || false
444
+
445
+ const espeakOptions: EspeakOptions = {
446
+ voice: espeakVoice,
447
+ ssml: inputIsSSML,
448
+ rate: espeakRate,
449
+ pitch: espeakPitch,
450
+ pitchRange: espeakPitchRange,
451
+ useKlatt: espeakUseKlatt
452
+ }
453
+
454
+ if (inputIsSSML) {
455
+ logger.end()
456
+
457
+ const { rawAudio } = await EspeakTTS.synthesize(text, espeakOptions)
458
+
459
+ synthesizedAudio = rawAudio
460
+ } else {
461
+ const lexicons = await loadLexiconsForLanguage(language, options.customLexiconPaths)
462
+
463
+ logger.end()
464
+
465
+ const { referenceSynthesizedAudio, referenceTimeline } = await EspeakTTS.preprocessAndSynthesize(text, espeakLanguage, espeakOptions, lexicons)
466
+
467
+ synthesizedAudio = referenceSynthesizedAudio
468
+ timeline = referenceTimeline.flatMap(clause => clause.timeline!)
469
+ }
470
+
471
+ break
472
+ }
473
+
474
+ case "sam": {
475
+ if (inputIsSSML) {
476
+ throw new Error(`The SAM engine doesn't support SSML inputs`)
477
+ }
478
+
479
+ const SamTTS = await import("../synthesis/SamTTS.js")
480
+
481
+ const engineOptions = options.sam!
482
+
483
+ const samPitch = clip(engineOptions.pitch || Math.round((1 / pitch) * 64), 0, 255)
484
+ const samSpeed = clip(engineOptions.speed || Math.round((1 / speed) * 72), 0, 255)
485
+ const samMouth = clip(engineOptions.mouth!, 0, 255)
486
+ const samThroat = clip(engineOptions.throat!, 0, 255)
487
+
488
+ logger.end()
489
+
490
+ const { rawAudio } = await SamTTS.synthesize(simplifiedText, samPitch, samSpeed, samMouth, samThroat)
491
+
492
+ synthesizedAudio = rawAudio
493
+
494
+ break
495
+ }
496
+
497
+ case "sapi": {
498
+ if (inputIsSSML) {
499
+ throw new Error(`The SAPI engine doesn't currently support SSML inputs`)
500
+ }
501
+
502
+ const SapiTTS = await import("../synthesis/SapiTTS.js")
503
+
504
+ await SapiTTS.AssertSAPIAvailable(false)
505
+
506
+ const engineOptions = options.sapi!
507
+
508
+ const sapiRate = engineOptions.rate || 0
509
+
510
+ logger.end()
511
+
512
+ const { rawAudio, timeline: outTimeline } = await SapiTTS.synthesize(text, voice, sapiRate, false)
513
+
514
+ synthesizedAudio = rawAudio
515
+ timeline = outTimeline
516
+
517
+ shouldPostprocessSpeed = true
518
+ shouldPostprocessPitch = true
519
+
520
+ break
521
+ }
522
+
523
+ case "msspeech": {
524
+ if (inputIsSSML) {
525
+ throw new Error(`The MSSpeech engine doesn't currently support SSML inputs`)
526
+ }
527
+
528
+ const SapiTTS = await import("../synthesis/SapiTTS.js")
529
+
530
+ await SapiTTS.AssertSAPIAvailable(true)
531
+
532
+ const engineOptions = options.msspeech!
533
+
534
+ const sapiRate = engineOptions.rate || 0
535
+
536
+ logger.end()
537
+
538
+ const { rawAudio, timeline: outTimeline } = await SapiTTS.synthesize(text, voice, sapiRate, true)
539
+
540
+ synthesizedAudio = rawAudio
541
+ timeline = outTimeline
542
+
543
+ shouldPostprocessSpeed = true
544
+ shouldPostprocessPitch = true
545
+
546
+ break
547
+ }
548
+
549
+ case "coqui-server": {
550
+ if (inputIsSSML) {
551
+ throw new Error(`The Coqui Server engine doesn't support SSML inputs`)
552
+ }
553
+
554
+ const CoquiServerTTS = await import("../synthesis/CoquiServerTTS.js")
555
+
556
+ const engineOptions = options.coquiServer!
557
+
558
+ const speakerId = engineOptions.speakerId!
559
+ const serverUrl = engineOptions.serverUrl
560
+
561
+ if (!serverUrl) {
562
+ throw new Error(`'coqui-server' requires a server URL`)
563
+ }
564
+
565
+ logger.end()
566
+
567
+ const { rawAudio } = await CoquiServerTTS.synthesize(simplifiedText, speakerId, serverUrl)
568
+
569
+ synthesizedAudio = rawAudio
570
+
571
+ shouldPostprocessSpeed = true
572
+ shouldPostprocessPitch = true
573
+
574
+ break
575
+ }
576
+
577
+ case "google-cloud": {
578
+ const GoogleCloudTTS = await import("../synthesis/GoogleCloudTTS.js")
579
+
580
+ const engineOptions = options.googleCloud!
581
+
582
+ const apiKey = engineOptions.apiKey
583
+
584
+ if (!apiKey) {
585
+ throw new Error(`No API key given`)
586
+ }
587
+
588
+ let pitchDeltaSemitones: number
589
+
590
+ // 1 semitone up = multiply by 1.05946
591
+ // 1 semitone down = divide by 1.05946
592
+ if (engineOptions.pitchDeltaSemitones != undefined) {
593
+ pitchDeltaSemitones = engineOptions.pitchDeltaSemitones
594
+ } else if (pitch >= 1.0) {
595
+ pitchDeltaSemitones = Math.round(17.3132 * Math.log(pitch))
596
+ } else {
597
+ pitchDeltaSemitones = Math.round(-17.3132 * Math.log(1 / pitch))
598
+ }
599
+
600
+ logger.end()
601
+
602
+ const { audioData, timepoints } = await GoogleCloudTTS.synthesize(text, apiKey, language, voice, speed, pitchDeltaSemitones, 0, inputIsSSML)
603
+ const rawAudio = await FFMpegTranscoder.decodeToChannels(audioData)
604
+
605
+ synthesizedAudio = rawAudio
606
+
607
+ break
608
+ }
609
+
610
+ case "microsoft-azure": {
611
+ const AzureCognitiveServicesTTS = await import("../synthesis/AzureCognitiveServicesTTS.js")
612
+
613
+ const engineOptions = options.microsoftAzure!
614
+
615
+ const subscriptionKey = engineOptions.subscriptionKey
616
+
617
+ if (!subscriptionKey) {
618
+ throw new Error(`No subscription key given`)
619
+ }
620
+
621
+ const serviceRegion = engineOptions!.serviceRegion
622
+
623
+ if (!serviceRegion) {
624
+ throw new Error(`No service region given`)
625
+ }
626
+
627
+ let ssmlPitch: string
628
+
629
+ if (engineOptions.pitchDeltaHz != undefined) {
630
+ if (engineOptions.pitchDeltaHz >= 0) {
631
+ ssmlPitch = `+${Math.abs(engineOptions.pitchDeltaHz)}Hz`
632
+ } else {
633
+ ssmlPitch = `-${Math.abs(engineOptions.pitchDeltaHz)}Hz`
634
+ }
635
+ } else {
636
+ ssmlPitch = convertPitchScaleToSSMLValueString(pitch, voiceGender)
637
+ }
638
+
639
+ const ssmlRate = convertSpeedScaleToSSMLValueString(speed)
640
+
641
+ logger.end()
642
+
643
+ const { rawAudio, timeline: outTimeline } = await AzureCognitiveServicesTTS.synthesize(text, subscriptionKey, serviceRegion, language, voice, inputIsSSML, ssmlPitch, ssmlRate)
644
+
645
+ synthesizedAudio = rawAudio
646
+ timeline = outTimeline
647
+
648
+ break
649
+ }
650
+
651
+ case "amazon-polly": {
652
+ const AwsPollyTTS = await import("../synthesis/AwsPollyTTS.js")
653
+
654
+ const engineOptions = options.amazonPolly!
655
+
656
+ const region = engineOptions.region
657
+
658
+ if (!region) {
659
+ throw new Error(`No region given`)
660
+ }
661
+
662
+ const accessKeyId = engineOptions.accessKeyId
663
+
664
+ if (!accessKeyId) {
665
+ throw new Error(`No access key id given`)
666
+ }
667
+
668
+ const secretAccessKey = engineOptions.secretAccessKey
669
+
670
+ if (!secretAccessKey) {
671
+ throw new Error(`No secret access key given`)
672
+ }
673
+
674
+ const pollyEngine = engineOptions.pollyEngine
675
+ const lexiconNames = engineOptions.lexiconNames
676
+
677
+ logger.end()
678
+
679
+ const { rawAudio } = await AwsPollyTTS.synthesize(text, undefined, voice, region, accessKeyId, secretAccessKey, pollyEngine, inputIsSSML, lexiconNames)
680
+
681
+ synthesizedAudio = rawAudio
682
+
683
+ shouldPostprocessSpeed = true
684
+ shouldPostprocessPitch = true
685
+
686
+ break
687
+ }
688
+
689
+ case "elevenlabs": {
690
+ if (inputIsSSML) {
691
+ throw new Error(`The Elevenlabs engine doesn't support SSML inputs`)
692
+ }
693
+
694
+ const ElevenLabsTTS = await import("../synthesis/ElevenLabsTTS.js")
695
+
696
+ const engineOptions = options.elevenlabs!
697
+
698
+ const apiKey = engineOptions.apiKey
699
+
700
+ if (!apiKey) {
701
+ throw new Error(`No ElevenLabs API key given`)
702
+ }
703
+
704
+ const voiceId = (selectedVoice as any)["elevenLabsVoiceId"]
705
+ const modelId = (selectedVoice as any)["elevenLabsModelId"]
706
+ const stability = engineOptions.stability!
707
+ const similarityBoost = engineOptions.similarityBoost!
708
+
709
+ logger.end()
710
+
711
+ const { rawAudio } = await ElevenLabsTTS.synthesize(text, voiceId, apiKey, modelId, stability, similarityBoost)
712
+
713
+ synthesizedAudio = rawAudio
714
+
715
+ shouldPostprocessSpeed = true
716
+ shouldPostprocessPitch = true
717
+
718
+ break
719
+ }
720
+
721
+ case "google-translate": {
722
+ if (inputIsSSML) {
723
+ throw new Error(`The Google Translate engine doesn't support SSML inputs`)
724
+ }
725
+
726
+ const GoogleTranslateTTS = await import("../synthesis/GoogleTranslateTTS.js")
727
+
728
+ logger.end()
729
+
730
+ const { rawAudio, timeline: segmentTimeline } = await GoogleTranslateTTS.synthesizeLongText(text, language, options.googleTranslate?.tld, options.sentenceEndPause, options.segmentEndPause)
731
+
732
+ synthesizedAudio = rawAudio
733
+
734
+ logger.start(`Generate word-level timestamps by individually aligning fragments`)
735
+ const alignmentOptions: API.AlignmentOptions = extendDeep(options.alignment, { language })
736
+
737
+ timeline = await API.alignSegments(synthesizedAudio, segmentTimeline, alignmentOptions)
738
+
739
+ shouldPostprocessSpeed = true
740
+ shouldPostprocessPitch = true
741
+
742
+ break
743
+ }
744
+
745
+ case "microsoft-edge": {
746
+ if (inputIsSSML) {
747
+ throw new Error(`The Microsoft Edge engine doesn't support SSML inputs`)
748
+ }
749
+
750
+ const MicrosoftEdgeTTS = await import("../synthesis/MicrosoftEdgeTTS.js")
751
+
752
+ const engineOptions = options.microsoftEdge!
753
+
754
+ const trustedClientToken = engineOptions.trustedClientToken
755
+
756
+ if (!trustedClientToken) {
757
+ throw new Error("No trusted client token provided.")
758
+ }
759
+
760
+ if (await sha256AsHex(trustedClientToken) != "558d7c6a7f7db444895946fe23a54ad172fd6d159f46cb34dd4db21bb27c07d7") {
761
+ throw new Error("Trusted client token is incorrect.")
762
+ }
763
+
764
+ let ssmlPitch: string
765
+
766
+ if (engineOptions.pitchDeltaHz != undefined) {
767
+ if (engineOptions.pitchDeltaHz >= 0) {
768
+ ssmlPitch = `+${Math.abs(engineOptions.pitchDeltaHz)}Hz`
769
+ } else {
770
+ ssmlPitch = `-${Math.abs(engineOptions.pitchDeltaHz)}Hz`
771
+ }
772
+ } else {
773
+ ssmlPitch = convertPitchScaleToSSMLValueString(pitch, voiceGender)
774
+ }
775
+
776
+ const ssmlRate = convertSpeedScaleToSSMLValueString(speed)
777
+
778
+ logger.end()
779
+
780
+ const { rawAudio, timeline: edgeTimeline } = await MicrosoftEdgeTTS.synthesize(text, trustedClientToken, voice, ssmlPitch, ssmlRate)
781
+
782
+ synthesizedAudio = rawAudio
783
+ timeline = edgeTimeline
784
+
785
+ break
786
+ }
787
+
788
+ case "streamlabs-polly": {
789
+ if (inputIsSSML) {
790
+ throw new Error(`The Streamlabs Polly Engine engine doesn't support SSML inputs`)
791
+ }
792
+
793
+ const StreamlabsPollyTTS = await import("../synthesis/StreamlabsPollyTTS.js")
794
+
795
+ logger.end()
796
+
797
+ const { rawAudio, timeline: segmentTimeline } = await StreamlabsPollyTTS.synthesizeLongText(text, voice, language, options.sentenceEndPause, options.segmentEndPause)
798
+
799
+ synthesizedAudio = rawAudio
800
+
801
+ logger.start(`Generate word-level timestamps by individually aligning fragments`)
802
+ const alignmentOptions: API.AlignmentOptions = extendDeep(options.alignment, { language })
803
+
804
+ timeline = await API.alignSegments(synthesizedAudio, segmentTimeline, alignmentOptions)
805
+
806
+ shouldPostprocessSpeed = true
807
+ shouldPostprocessPitch = true
808
+
809
+ break
810
+ }
811
+
812
+ default: {
813
+ throw new Error(`Engine '${options.engine}' is not supported`)
814
+ }
815
+ }
816
+
817
+ logger.start("Postprocess synthesized audio")
818
+ synthesizedAudio = downmixToMono(synthesizedAudio)
819
+
820
+ if (options.postProcessing!.normalizeAudio) {
821
+ synthesizedAudio = normalizeAudioLevel(synthesizedAudio, options.postProcessing!.targetPeakDb!, options.postProcessing!.maxIncreaseDb!)
822
+ }
823
+
824
+ const preTrimSampleCount = synthesizedAudio.audioChannels[0].length
825
+ synthesizedAudio.audioChannels[0] = trimAudioStart(synthesizedAudio.audioChannels[0])
826
+
827
+ if (timeline) {
828
+ const oldDuration = preTrimSampleCount / synthesizedAudio.sampleRate
829
+ const newDuration = synthesizedAudio.audioChannels[0].length / synthesizedAudio.sampleRate
830
+
831
+ timeline = addTimeOffsetToTimeline(timeline, newDuration - oldDuration)
832
+ }
833
+
834
+ if (!timeline) {
835
+ logger.start("Align synthesized audio with text")
836
+
837
+ let plainText = text
838
+
839
+ if (inputIsSSML) {
840
+ plainText = await convertHtmlToText(text)
841
+ }
842
+
843
+ const alignmentOptions = options.alignment!
844
+
845
+ alignmentOptions.language = language
846
+
847
+ if (!alignmentOptions.customLexiconPaths) {
848
+ alignmentOptions.customLexiconPaths = options.customLexiconPaths
849
+ }
850
+
851
+ if (alignmentOptions.dtw!.windowDuration == null) {
852
+ alignmentOptions.dtw!.windowDuration = Math.max(5, Math.ceil(0.2 * getRawAudioDuration(synthesizedAudio)))
853
+ }
854
+
855
+ const { wordTimeline } = await API.align(synthesizedAudio, plainText, alignmentOptions)
856
+
857
+ timeline = wordTimeline
858
+
859
+ logger.end()
860
+ }
861
+
862
+ const postProcessingOptions = options.postProcessing!
863
+
864
+ let timeStretchFactor = postProcessingOptions.speed
865
+
866
+ if (shouldPostprocessSpeed && timeStretchFactor == undefined) {
867
+ timeStretchFactor = speed
868
+ }
869
+
870
+ let pitchShiftFactor = postProcessingOptions.pitch
871
+
872
+ if (shouldPostprocessPitch && pitchShiftFactor == undefined) {
873
+ pitchShiftFactor = pitch
874
+ }
875
+
876
+ if ((timeStretchFactor != undefined && timeStretchFactor != 1.0) || (pitchShiftFactor != undefined && pitchShiftFactor != 1.0)) {
877
+ logger.start("Apply time and pitch shifting")
878
+
879
+ timeStretchFactor = timeStretchFactor || 1.0
880
+ pitchShiftFactor = pitchShiftFactor || 1.0
881
+
882
+ const timePitchShiftingMethod = postProcessingOptions.timePitchShiftingMethod
883
+
884
+ if (timePitchShiftingMethod == "sonic") {
885
+ const sonic = await import('../dsp/Sonic.js')
886
+ synthesizedAudio = await sonic.stretchTimePitch(synthesizedAudio, timeStretchFactor, pitchShiftFactor)
887
+ } else if (timePitchShiftingMethod == "rubberband") {
888
+ const rubberband = await import('../dsp/Rubberband.js')
889
+
890
+ const rubberbandOptions: RubberbandOptions = extendDeep(rubberband.defaultRubberbandOptions, postProcessingOptions.rubberband || {})
891
+
892
+ synthesizedAudio = await rubberband.stretchTimePitch(synthesizedAudio, timeStretchFactor, pitchShiftFactor, rubberbandOptions)
893
+ } else {
894
+ throw new Error(`'${timePitchShiftingMethod}' is not a valid time and pitch shifting method`)
895
+ }
896
+
897
+ if (timeStretchFactor != 1.0 && timeline) {
898
+ timeline = multiplyTimelineByFactor(timeline, 1 / timeStretchFactor)
899
+ }
900
+ }
901
+
902
+ if (timeline) {
903
+ timeline = timeline.filter(entry => isWordOrSymbolWord(entry.text))
904
+ }
905
+
906
+ logger.end()
907
+
908
+ logger.logDuration('Total synthesis time', startTimestamp, chalk.magentaBright)
909
+
910
+ return { synthesizedAudio, timeline }
911
+ }
912
+
913
+ function convertSpeedScaleToSSMLValueString(rate: number) {
914
+ if (rate >= 1.0) {
915
+ const ratePercentage = Math.floor((rate - 1) * 100)
916
+ return `+${ratePercentage}%`
917
+ } else {
918
+ const ratePercentage = Math.floor(((1 / rate) - 1) * 100)
919
+ return `-${ratePercentage}%`
920
+ }
921
+ }
922
+
923
+ function convertPitchScaleToSSMLValueString(pitch: number, voiceGender: VoiceGender) {
924
+ let fundementalFrequency
925
+ if (voiceGender == "male") {
926
+ // Use an estimate of the average male voice fundemental frequency
927
+ fundementalFrequency = 120
928
+ } else if (voiceGender == "female") {
929
+ // Use an estimate of the average female voice fundemental frequency
930
+ fundementalFrequency = 210
931
+ } else {
932
+ // (shouldn't occur since all voices should have a gender specified)
933
+ // Use the average of male and female voice frequency
934
+ fundementalFrequency = 165
935
+ }
936
+
937
+ if (pitch >= 1.0) {
938
+ const pitchDeltaHertz = Math.floor(pitch * fundementalFrequency) - fundementalFrequency
939
+ return `+${pitchDeltaHertz}Hz`
940
+ } else {
941
+ const pitchDeltaHertz = fundementalFrequency - Math.floor(pitch * fundementalFrequency)
942
+ return `-${pitchDeltaHertz}Hz`
943
+ }
944
+ }
945
+
946
+ export type SynthesisEngine = "vits" | "pico" | "flite" | "espeak" | "sam" | "sapi" | "msspeech" | "coqui-server" | "google-cloud" | "microsoft-azure" | "amazon-polly" | "elevenlabs" | "google-translate" | "microsoft-edge" | "streamlabs-polly"
947
+
948
+ export type TimePitchShiftingMethod = "sonic" | "rubberband"
949
+
950
+ export interface SynthesisOptions {
951
+ engine?: SynthesisEngine
952
+
953
+ language?: string
954
+ voice?: string
955
+ voiceGender?: VoiceGender
956
+
957
+ speed?: number
958
+ pitch?: number
959
+ pitchVariation?: number
960
+
961
+ splitToSentences?: boolean
962
+
963
+ ssml?: boolean
964
+
965
+ segmentEndPause?: number
966
+ sentenceEndPause?: number
967
+
968
+ customLexiconPaths?: string[]
969
+
970
+ plainText?: API.PlainTextOptions
971
+
972
+ alignment?: API.AlignmentOptions
973
+
974
+ postProcessing?: {
975
+ normalizeAudio?: boolean
976
+ targetPeakDb?: number
977
+ maxIncreaseDb?: number
978
+
979
+ speed?: number
980
+ pitch?: number
981
+
982
+ timePitchShiftingMethod?: TimePitchShiftingMethod,
983
+ rubberband?: RubberbandOptions
984
+ }
985
+
986
+ outputAudioFormat?: {
987
+ codec?: "wav" | "mp3" | "opus" | "m4a" | "ogg" | "flac"
988
+ bitrate?: number
989
+ }
990
+
991
+ languageDetection?: API.TextLanguageDetectionOptions
992
+
993
+ subtitles?: SubtitlesConfig
994
+
995
+ vits?: {
996
+ speakerId?: number
997
+ }
998
+
999
+ pico?: {
1000
+ }
1001
+
1002
+ flite?: {
1003
+ }
1004
+
1005
+ espeak?: {
1006
+ rate?: number
1007
+ pitch?: number
1008
+ pitchRange?: number
1009
+ useKlatt?: boolean
1010
+ }
1011
+
1012
+ sam?: {
1013
+ pitch?: number
1014
+ speed?: number
1015
+ mouth?: number
1016
+ throat?: number
1017
+ }
1018
+
1019
+ sapi?: {
1020
+ rate?: number
1021
+ }
1022
+
1023
+ msspeech?: {
1024
+ rate?: number
1025
+ }
1026
+
1027
+ coquiServer?: {
1028
+ serverUrl?: string
1029
+ speakerId?: string | null
1030
+ }
1031
+
1032
+ googleCloud?: {
1033
+ apiKey?: string,
1034
+ pitchDeltaSemitones?: number,
1035
+
1036
+ customVoice?: {
1037
+ model?: string
1038
+ reportedUsage?: string
1039
+ }
1040
+ }
1041
+
1042
+ microsoftAzure?: {
1043
+ subscriptionKey?: string
1044
+ serviceRegion?: string
1045
+ pitchDeltaHz?: number
1046
+ }
1047
+
1048
+ amazonPolly?: {
1049
+ region?: string
1050
+ accessKeyId?: string
1051
+ secretAccessKey?: string
1052
+ pollyEngine?: "standard" | "neural"
1053
+ lexiconNames?: string[]
1054
+ }
1055
+
1056
+ elevenlabs?: {
1057
+ apiKey?: string
1058
+ stability?: number
1059
+ similarityBoost?: number
1060
+ },
1061
+
1062
+ googleTranslate?: {
1063
+ tld?: string
1064
+ }
1065
+
1066
+ microsoftEdge?: {
1067
+ trustedClientToken?: string
1068
+ pitchDeltaHz?: number
1069
+ }
1070
+
1071
+ streamlabsPolly?: {
1072
+ },
1073
+ }
1074
+
1075
+ export const defaultSynthesisOptions: SynthesisOptions = {
1076
+ engine: undefined,
1077
+
1078
+ language: undefined,
1079
+
1080
+ voice: undefined,
1081
+ voiceGender: undefined,
1082
+
1083
+ speed: 1.0,
1084
+ pitch: 1.0,
1085
+ pitchVariation: 1.0,
1086
+
1087
+ ssml: false,
1088
+
1089
+ splitToSentences: true,
1090
+
1091
+ segmentEndPause: 1.0,
1092
+ sentenceEndPause: 0.75,
1093
+
1094
+ customLexiconPaths: undefined,
1095
+
1096
+ plainText: {
1097
+ paragraphBreaks: 'double',
1098
+ whitespace: 'collapse'
1099
+ },
1100
+
1101
+ alignment: {
1102
+ engine: "dtw",
1103
+
1104
+ dtw: {
1105
+ granularity: 'high'
1106
+ }
1107
+ },
1108
+
1109
+ postProcessing: {
1110
+ normalizeAudio: true,
1111
+ targetPeakDb: -3,
1112
+ maxIncreaseDb: 30,
1113
+
1114
+ speed: undefined,
1115
+ pitch: undefined,
1116
+
1117
+ timePitchShiftingMethod: "sonic",
1118
+ rubberband: {
1119
+ }
1120
+ },
1121
+
1122
+ outputAudioFormat: undefined,
1123
+
1124
+ languageDetection: undefined,
1125
+
1126
+ subtitles: defaultSubtitlesBaseConfig,
1127
+
1128
+ vits: {
1129
+ speakerId: undefined,
1130
+ },
1131
+
1132
+ pico: {
1133
+ },
1134
+
1135
+ flite: {
1136
+ },
1137
+
1138
+ espeak: {
1139
+ rate: undefined,
1140
+ pitch: undefined,
1141
+ pitchRange: undefined,
1142
+ useKlatt: false
1143
+ },
1144
+
1145
+ sam: {
1146
+ speed: undefined,
1147
+ pitch: undefined,
1148
+ mouth: 128,
1149
+ throat: 128
1150
+ },
1151
+
1152
+ sapi: {
1153
+ rate: 0,
1154
+ },
1155
+
1156
+ msspeech: {
1157
+ rate: 0,
1158
+ },
1159
+
1160
+ coquiServer: {
1161
+ serverUrl: "http://[::1]:5002",
1162
+ speakerId: null
1163
+ },
1164
+
1165
+ googleCloud: {
1166
+ apiKey: undefined,
1167
+
1168
+ pitchDeltaSemitones: undefined,
1169
+
1170
+ customVoice: {
1171
+ }
1172
+ },
1173
+
1174
+ microsoftAzure: {
1175
+ subscriptionKey: undefined,
1176
+ serviceRegion: undefined,
1177
+
1178
+ pitchDeltaHz: undefined
1179
+ },
1180
+
1181
+ amazonPolly: {
1182
+ region: undefined,
1183
+ accessKeyId: undefined,
1184
+ secretAccessKey: undefined,
1185
+ pollyEngine: undefined,
1186
+ lexiconNames: undefined,
1187
+ },
1188
+
1189
+ elevenlabs: {
1190
+ apiKey: undefined,
1191
+ stability: 0.5,
1192
+ similarityBoost: 0.5,
1193
+ },
1194
+
1195
+ googleTranslate: {
1196
+ tld: "us"
1197
+ },
1198
+
1199
+ microsoftEdge: {
1200
+ trustedClientToken: undefined,
1201
+
1202
+ pitchDeltaHz: undefined
1203
+ },
1204
+
1205
+ streamlabsPolly: {
1206
+ },
1207
+ }
1208
+
1209
+ /////////////////////////////////////////////////////////////////////////////////////////////
1210
+ // Voice list request
1211
+ /////////////////////////////////////////////////////////////////////////////////////////////
1212
+ export async function requestVoiceList(options: VoiceListRequestOptions): Promise<RequestVoiceListResult> {
1213
+ options = extendDeep(defaultVoiceListRequestOptions, options)
1214
+
1215
+ const cacheOptions = options.cache!
1216
+
1217
+ let cacheDir = cacheOptions?.path
1218
+
1219
+ if (!cacheDir) {
1220
+ const appDataDir = getAppDataDir(appName)
1221
+ cacheDir = path.join(appDataDir, 'voice-list-cache')
1222
+ await ensureDir(cacheDir)
1223
+ }
1224
+
1225
+ const cacheFilePath = path.join(cacheDir, `${options.engine}.voices.json`)
1226
+
1227
+ async function loadVoiceList() {
1228
+ let voiceList: SynthesisVoice[] = []
1229
+
1230
+ switch (options.engine) {
1231
+ case "espeak": {
1232
+ const EspeakTTS = await import("../synthesis/EspeakTTS.js")
1233
+
1234
+ const voices = await EspeakTTS.listVoices()
1235
+
1236
+ voiceList = voices.map(voice => {
1237
+ const languages = voice.languages.map(lang => normalizeLanguageCode(lang.name))
1238
+
1239
+ for (const language of languages) {
1240
+ const shortLanguageCode = getShortLanguageCode(language)
1241
+
1242
+ if (!languages.includes(shortLanguageCode)) {
1243
+ languages.push(shortLanguageCode)
1244
+ }
1245
+ }
1246
+
1247
+
1248
+ return {
1249
+ name: voice.identifier,
1250
+ languages,
1251
+ gender: "male"
1252
+ }
1253
+ })
1254
+
1255
+ break
1256
+ }
1257
+
1258
+ case "flite": {
1259
+ const FliteTTS = await import("../synthesis/FliteTTS.js")
1260
+
1261
+ voiceList = deepClone(FliteTTS.voiceList)
1262
+
1263
+ break
1264
+ }
1265
+
1266
+ case "pico": {
1267
+ const SvoxPicoTTS = await import("../synthesis/SvoxPicoTTS.js")
1268
+
1269
+ voiceList = SvoxPicoTTS.voiceList
1270
+
1271
+ break
1272
+ }
1273
+
1274
+ case "sam": {
1275
+ voiceList.push({
1276
+ name: "sam",
1277
+ languages: ["en-US", "en"],
1278
+ gender: "male"
1279
+ })
1280
+
1281
+ break
1282
+ }
1283
+
1284
+ case "vits": {
1285
+ const VitsTTS = await import("../synthesis/VitsTTS.js")
1286
+
1287
+ voiceList = VitsTTS.voiceList.map(entry => {
1288
+ return { ...entry, packageName: `vits-${entry.name}` }
1289
+ })
1290
+
1291
+ break
1292
+ }
1293
+
1294
+ case "sapi": {
1295
+ const SapiTTS = await import("../synthesis/SapiTTS.js")
1296
+
1297
+ await SapiTTS.AssertSAPIAvailable(false)
1298
+
1299
+ voiceList = await SapiTTS.getVoiceList(false)
1300
+
1301
+ break
1302
+ }
1303
+
1304
+ case "msspeech": {
1305
+ const SapiTTS = await import("../synthesis/SapiTTS.js")
1306
+
1307
+ await SapiTTS.AssertSAPIAvailable(true)
1308
+
1309
+ voiceList = await SapiTTS.getVoiceList(true)
1310
+
1311
+ break
1312
+ }
1313
+
1314
+ case "coqui-server": {
1315
+ voiceList = [{
1316
+ name: "coqui",
1317
+ languages: ["en-US"],
1318
+ gender: "unknown"
1319
+ }]
1320
+
1321
+ break
1322
+ }
1323
+
1324
+ case "google-cloud": {
1325
+ const GoogleCloudTTS = await import("../synthesis/GoogleCloudTTS.js")
1326
+
1327
+ const apiKey = options.googleCloud!.apiKey
1328
+
1329
+ if (!apiKey) {
1330
+ throw new Error(`No API key given`)
1331
+ }
1332
+
1333
+ const voices = await GoogleCloudTTS.getVoiceList(apiKey)
1334
+
1335
+ voiceList = voices.map(voice => ({
1336
+ name: voice.name,
1337
+ languages: [normalizeLanguageCode(voice.languageCodes[0]), getShortLanguageCode(voice.languageCodes[0])],
1338
+ gender: voice.ssmlGender.toLowerCase() as ("male" | "female"),
1339
+ }))
1340
+
1341
+ break
1342
+ }
1343
+
1344
+ case "microsoft-azure": {
1345
+ const AzureCognitiveServicesTTS = await import("../synthesis/AzureCognitiveServicesTTS.js")
1346
+
1347
+ const subscriptionKey = options.microsoftAzure!.subscriptionKey
1348
+
1349
+ if (!subscriptionKey) {
1350
+ throw new Error(`No subscription key given`)
1351
+ }
1352
+
1353
+ const serviceRegion = options.microsoftAzure!.serviceRegion
1354
+
1355
+ if (!serviceRegion) {
1356
+ throw new Error(`No service region given`)
1357
+ }
1358
+
1359
+ const voices = await AzureCognitiveServicesTTS.getVoiceList(subscriptionKey, serviceRegion)
1360
+
1361
+ for (const voice of voices) {
1362
+ voiceList.push({
1363
+ name: voice.name,
1364
+ languages: [normalizeLanguageCode(voice.locale), getShortLanguageCode(voice.locale)],
1365
+ gender: voice.gender == 1 ? "female" : "male"
1366
+ })
1367
+ }
1368
+
1369
+ break
1370
+ }
1371
+
1372
+ case "amazon-polly": {
1373
+ const AwsPollyTTS = await import("../synthesis/AwsPollyTTS.js")
1374
+
1375
+ const region = options.amazonPolly!.region
1376
+
1377
+ if (!region) {
1378
+ throw new Error(`No region given`)
1379
+ }
1380
+
1381
+ const accessKeyId = options.amazonPolly!.accessKeyId
1382
+
1383
+ if (!accessKeyId) {
1384
+ throw new Error(`No access key id given`)
1385
+ }
1386
+
1387
+ const secretAccessKey = options.amazonPolly!.secretAccessKey
1388
+
1389
+ if (!secretAccessKey) {
1390
+ throw new Error(`No secret access key given`)
1391
+ }
1392
+
1393
+ const voices = await AwsPollyTTS.getVoiceList(region, accessKeyId, secretAccessKey)
1394
+
1395
+ for (const voice of voices) {
1396
+ const languageCode = normalizeLanguageCode(voice.LanguageCode!)
1397
+ const languageCodes = [languageCode, getShortLanguageCode(languageCode)]
1398
+
1399
+ if (voice.AdditionalLanguageCodes) {
1400
+ for (const additionalLanguageCode of voice.AdditionalLanguageCodes) {
1401
+ languageCodes.push(
1402
+ normalizeLanguageCode(additionalLanguageCode),
1403
+ getShortLanguageCode(additionalLanguageCode)
1404
+ )
1405
+ }
1406
+ }
1407
+
1408
+ voiceList.push({
1409
+ name: voice.Id!,
1410
+ languages: languageCodes,
1411
+ gender: voice.Gender!.toLowerCase() as ("male" | "female")
1412
+ })
1413
+ }
1414
+
1415
+ break
1416
+ }
1417
+
1418
+ case "elevenlabs": {
1419
+ const ElevenLabsTTS = await import("../synthesis/ElevenLabsTTS.js")
1420
+
1421
+ const engineOptions = options.elevenlabs!
1422
+
1423
+ const apiKey = engineOptions.apiKey
1424
+
1425
+ if (!apiKey) {
1426
+ throw new Error(`No Elevenlabs API key given`)
1427
+ }
1428
+
1429
+ voiceList = await ElevenLabsTTS.getVoiceList(apiKey)
1430
+
1431
+ break
1432
+ }
1433
+
1434
+ case "google-translate": {
1435
+ const GoogleTranslateTTS = await import("../synthesis/GoogleTranslateTTS.js")
1436
+
1437
+ const langLookup = GoogleTranslateTTS.supportedLanguageLookup
1438
+
1439
+ for (const langCode in langLookup) {
1440
+ voiceList.push({
1441
+ name: langLookup[langCode],
1442
+ languages: langCode.includes("-") ? [normalizeLanguageCode(langCode), getShortLanguageCode(langCode)] : [normalizeLanguageCode(langCode)],
1443
+ gender: "unknown"
1444
+ })
1445
+ }
1446
+
1447
+ break
1448
+ }
1449
+
1450
+ case "microsoft-edge": {
1451
+ const MicrosoftEdgeTTS = await import("../synthesis/MicrosoftEdgeTTS.js")
1452
+
1453
+ const trustedClientToken = options.microsoftEdge?.trustedClientToken
1454
+
1455
+ if (!trustedClientToken) {
1456
+ throw new Error("No trusted client token provided")
1457
+ }
1458
+
1459
+ const voices = await MicrosoftEdgeTTS.getVoiceList(trustedClientToken)
1460
+
1461
+ voiceList = voices.map((voice: any) => ({
1462
+ name: voice.Name,
1463
+ languages: [normalizeLanguageCode(voice.Locale), getShortLanguageCode(voice.Locale)],
1464
+ gender: voice.Gender == "Male" ? "male" : "female",
1465
+ }))
1466
+
1467
+ break
1468
+ }
1469
+
1470
+ case "streamlabs-polly": {
1471
+ const StreamlabsPollyTTS = await import("../synthesis/StreamlabsPollyTTS.js")
1472
+
1473
+ voiceList = StreamlabsPollyTTS.voiceList
1474
+
1475
+ break
1476
+ }
1477
+ }
1478
+
1479
+
1480
+ if (cacheFilePath) {
1481
+ await writeFileSafe(cacheFilePath, stringifyAndFormatJson(voiceList))
1482
+ }
1483
+
1484
+ return voiceList
1485
+ }
1486
+
1487
+ let voiceList: SynthesisVoice[]
1488
+
1489
+ if (cacheFilePath && existsSync(cacheFilePath) && await isFileIsUpToDate(cacheFilePath, options.cache!.duration!)) {
1490
+ voiceList = await readAndParseJsonFile(cacheFilePath)
1491
+ } else {
1492
+ voiceList = await loadVoiceList()
1493
+ }
1494
+
1495
+ const languageCode = normalizeLanguageCode(options.language || "")
1496
+
1497
+ if (languageCode) {
1498
+ let filteredVoiceList = voiceList.filter(voice => voice.languages.includes(languageCode))
1499
+
1500
+ if (filteredVoiceList.length == 0 && languageCode.includes("-")) {
1501
+ const shortLanguageCode = getShortLanguageCode(languageCode)
1502
+
1503
+ filteredVoiceList = voiceList.filter(voice => voice.languages.includes(shortLanguageCode))
1504
+ }
1505
+
1506
+ voiceList = filteredVoiceList
1507
+ }
1508
+
1509
+ if (options.voiceGender) {
1510
+ const genderLowercase = options.voiceGender.toLowerCase()
1511
+ voiceList = voiceList.filter(voice => voice.gender == genderLowercase || voice.gender == "unknown")
1512
+ }
1513
+
1514
+ if (options.voice) {
1515
+ const namePatternLowerCase = options.voice.toLocaleLowerCase()
1516
+ const namePatternParts = namePatternLowerCase.split(/\b/g)
1517
+
1518
+ if (namePatternParts.length > 1) {
1519
+ voiceList = voiceList.filter(voice => voice.name.toLocaleLowerCase().includes(namePatternLowerCase))
1520
+ } else {
1521
+ voiceList = voiceList.filter(voice => {
1522
+ const name = voice.name.toLocaleLowerCase()
1523
+ const nameParts = name.split(/\b/g)
1524
+
1525
+ for (const namePart of nameParts) {
1526
+ if (namePart.startsWith(namePatternLowerCase)) {
1527
+ return true
1528
+ }
1529
+ }
1530
+
1531
+ return false
1532
+ })
1533
+ }
1534
+ }
1535
+
1536
+ let bestMatchingVoice = voiceList[0]
1537
+
1538
+ if (bestMatchingVoice && voiceList.length > 1 && defaultDialectForLanguageCode[languageCode]) {
1539
+ const expandedLanguageCode = defaultDialectForLanguageCode[languageCode]
1540
+
1541
+ for (const voice of voiceList) {
1542
+ if (voice.languages.includes(expandedLanguageCode)) {
1543
+ bestMatchingVoice = voice
1544
+ break
1545
+ }
1546
+ }
1547
+ }
1548
+
1549
+ return { voiceList, bestMatchingVoice }
1550
+ }
1551
+
1552
+ export interface RequestVoiceListResult {
1553
+ voiceList: API.SynthesisVoice[]
1554
+ bestMatchingVoice: API.SynthesisVoice
1555
+ }
1556
+
1557
+ export async function selectBestOfflineEngineForLanguage(language: string): Promise<SynthesisEngine> {
1558
+ language = normalizeLanguageCode(language)
1559
+
1560
+ const VitsTTS = await import("../synthesis/VitsTTS.js")
1561
+
1562
+ const vitsLanguages = getAllLangCodesFromVoiceList(VitsTTS.voiceList)
1563
+
1564
+ if (vitsLanguages.includes(language)) {
1565
+ return "vits"
1566
+ }
1567
+
1568
+ const FliteTTS = await import("../synthesis/FliteTTS.js")
1569
+
1570
+ const fliteLanguages = getAllLangCodesFromVoiceList(FliteTTS.voiceList)
1571
+
1572
+ if (fliteLanguages.includes(language)) {
1573
+ return "flite"
1574
+ }
1575
+
1576
+ const SvoxPicoTTS = await import("../synthesis/SvoxPicoTTS.js")
1577
+
1578
+ const picoLanguages = getAllLangCodesFromVoiceList(SvoxPicoTTS.voiceList)
1579
+
1580
+ if (picoLanguages.includes(language)) {
1581
+ return "pico"
1582
+ }
1583
+
1584
+ return "espeak"
1585
+ }
1586
+
1587
+ export function getAllLangCodesFromVoiceList(voiceList: SynthesisVoice[]) {
1588
+ const languageCodes = new Set<string>()
1589
+ const langList: string[] = []
1590
+
1591
+ for (const voice of voiceList) {
1592
+ for (const langCode of voice.languages) {
1593
+ if (languageCodes.has(langCode)) {
1594
+ continue
1595
+ }
1596
+
1597
+ langList.push(langCode)
1598
+ languageCodes.add(langCode)
1599
+ }
1600
+ }
1601
+
1602
+ return langList
1603
+ }
1604
+
1605
+ export interface VoiceListRequestOptions extends SynthesisOptions {
1606
+ cache?: {
1607
+ path?: string
1608
+ duration?: number
1609
+ }
1610
+ }
1611
+
1612
+ export const defaultVoiceListRequestOptions: VoiceListRequestOptions = {
1613
+ ...defaultSynthesisOptions,
1614
+
1615
+ cache: {
1616
+ path: undefined,
1617
+ duration: 60 * 1
1618
+ },
1619
+ }
1620
+
1621
+ export interface SynthesisSegmentEventData {
1622
+ index: number
1623
+ total: number
1624
+ audio: RawAudio | Buffer
1625
+ timeline: Timeline
1626
+ transcript: string
1627
+ language: string
1628
+ peakDecibelsSoFar: number
1629
+ }
1630
+
1631
+ export type SynthesisSegmentEvent = (data: SynthesisSegmentEventData) => Promise<void>
1632
+
1633
+ export interface SynthesisVoice {
1634
+ name: string
1635
+ languages: string[]
1636
+ gender: VoiceGender
1637
+ speakerCount?: number
1638
+ packageName?: string
1639
+ }
1640
+
1641
+ export type VoiceGender = "male" | "female" | "unknown"
1642
+
1643
+ export const synthesisEngines: EngineMetadata[] = [
1644
+ {
1645
+ id: 'vits',
1646
+ name: 'VITS',
1647
+ description: 'A high-quality end-to-end neural speech synthesis architecture.',
1648
+ type: 'local'
1649
+ },
1650
+ {
1651
+ id: 'pico',
1652
+ name: 'SVOX Pico',
1653
+ description: 'A legacy diphone-based synthesis engine.',
1654
+ type: 'local'
1655
+ },
1656
+ {
1657
+ id: 'flite',
1658
+ name: 'Flite',
1659
+ description: 'A legacy diphone-based synthesis engine.',
1660
+ type: 'local'
1661
+ },
1662
+ {
1663
+ id: 'espeak',
1664
+ name: 'eSpeak NG',
1665
+ description: 'A lightweight "robot" sounding formant-based synthesizer.',
1666
+ type: 'local'
1667
+ },
1668
+ {
1669
+ id: 'sam',
1670
+ name: 'SAM (Software Automatic Mouth)',
1671
+ description: 'A classic "robot" speech synthesizer from 1982.',
1672
+ type: 'local'
1673
+ },
1674
+ {
1675
+ id: 'sapi',
1676
+ name: 'SAPI',
1677
+ description: 'Microsoft Speech API (Windows only).',
1678
+ type: 'local'
1679
+ },
1680
+ {
1681
+ id: 'msspeech',
1682
+ name: 'Microsoft Speech Platform',
1683
+ description: 'Microsoft Server Speech API (Windows only).',
1684
+ type: 'local'
1685
+ },
1686
+ {
1687
+ id: 'coqui-server',
1688
+ name: 'Coqui TTS',
1689
+ description: 'A deep learning toolkit for Text-to-Speech.',
1690
+ type: 'server'
1691
+ },
1692
+ {
1693
+ id: 'google-cloud',
1694
+ name: 'Google Cloud',
1695
+ description: 'Google Cloud text-to-speech service.',
1696
+ type: 'cloud'
1697
+ },
1698
+ {
1699
+ id: 'microsoft-azure',
1700
+ name: 'Azure Cognitive Services',
1701
+ description: 'Microsoft Azure cloud text-to-speech service.',
1702
+ type: 'cloud'
1703
+ },
1704
+ {
1705
+ id: 'amazon-polly',
1706
+ name: 'Amazon Polly',
1707
+ description: 'Amazon Polly (also: AWS Polly) cloud text-to-speech.',
1708
+ type: 'cloud'
1709
+ },
1710
+ {
1711
+ id: 'elevenlabs',
1712
+ name: 'Elevenlabs',
1713
+ description: 'A generative AI text-to-speech cloud service.',
1714
+ type: 'cloud'
1715
+ },
1716
+ {
1717
+ id: 'google-translate',
1718
+ name: 'Google Translate',
1719
+ description: 'Unoffical text-to-speech API used by the Google Translate web interface.',
1720
+ type: 'cloud'
1721
+ },
1722
+ {
1723
+ id: 'microsoft-edge',
1724
+ name: 'Microsoft Edge',
1725
+ description: 'Unoffical text-to-speech API used by the Microsoft Edge browser.',
1726
+ type: 'cloud'
1727
+ },
1728
+ {
1729
+ id: 'streamlabs-polly',
1730
+ name: 'Streamlabs Polly',
1731
+ description: 'Unoffical text-to-speech API provided by Streamlabs.',
1732
+ type: 'cloud'
1733
+ },
1734
+ ]
1735
+