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.
- package/data/schemas/options.json +16 -0
- package/dist/api/Alignment.js +2 -2
- package/dist/api/Alignment.js.map +1 -1
- package/dist/api/Recognition.js +2 -2
- package/dist/api/Recognition.js.map +1 -1
- package/dist/api/Synthesis.js +5 -4
- package/dist/api/Synthesis.js.map +1 -1
- package/dist/api/Translation.js +2 -2
- package/dist/api/Translation.js.map +1 -1
- package/dist/audio/AudioUtilities.d.ts +1 -0
- package/dist/audio/AudioUtilities.js +25 -7
- package/dist/audio/AudioUtilities.js.map +1 -1
- package/dist/cli/CLI.js +2 -2
- package/dist/cli/CLI.js.map +1 -1
- package/dist/recognition/WhisperSTT.js +2 -2
- package/dist/recognition/WhisperSTT.js.map +1 -1
- package/dist/subtitles/Subtitles.d.ts +10 -7
- package/dist/subtitles/Subtitles.js +268 -207
- package/dist/subtitles/Subtitles.js.map +1 -1
- package/docs/Options.md +4 -2
- package/package.json +7 -6
- package/src/alignment/DTWMfccSequenceAlignment.ts +43 -0
- package/src/alignment/DTWSequenceAlignment.ts +121 -0
- package/src/alignment/DTWSequenceAlignmentWindowed.ts +210 -0
- package/src/alignment/LevenshteinSequenceAlignment.ts +126 -0
- package/src/alignment/SpeechAlignment.ts +488 -0
- package/src/api/API.ts +12 -0
- package/src/api/APIOptions.ts +15 -0
- package/src/api/Alignment.ts +329 -0
- package/src/api/Common.ts +16 -0
- package/src/api/Denoising.ts +120 -0
- package/src/api/LanguageDetection.ts +286 -0
- package/src/api/Recognition.ts +344 -0
- package/src/api/Synthesis.ts +1735 -0
- package/src/api/Translation.ts +143 -0
- package/src/api/Vad.ts +172 -0
- package/src/audio/AudioBufferConversion.ts +248 -0
- package/src/audio/AudioPlayer.ts +358 -0
- package/src/audio/AudioRecorder.ts +91 -0
- package/src/audio/AudioUtilities.ts +392 -0
- package/src/audio/SoxPath.ts +24 -0
- package/src/cli/CLI.ts +1360 -0
- package/src/cli/CLIConfigFile.ts +91 -0
- package/src/cli/CLILauncher.ts +26 -0
- package/src/cli/CLIOptionsSchema.ts +54 -0
- package/src/cli/CLIParser.ts +41 -0
- package/src/cli/CLIStarter.ts +40 -0
- package/src/codecs/FFMpegTranscoder.ts +214 -0
- package/src/codecs/TIMITCodec.ts +17 -0
- package/src/codecs/WaveCodec.ts +260 -0
- package/src/denoising/RNNoise.ts +95 -0
- package/src/dsp/BiquadFilter.ts +488 -0
- package/src/dsp/FFT.ts +187 -0
- package/src/dsp/MFCC.ts +227 -0
- package/src/dsp/MelSpectogram.ts +145 -0
- package/src/dsp/Rubberband.ts +249 -0
- package/src/dsp/Sonic.ts +59 -0
- package/src/dsp/SpeexResampler.ts +79 -0
- package/src/math/VectorMath.ts +812 -0
- package/src/nlp/ChineseSegmentation.ts +68 -0
- package/src/nlp/CompromiseNLP.ts +113 -0
- package/src/nlp/EspeakPhonemizer.ts +168 -0
- package/src/nlp/IPA.ts +139 -0
- package/src/nlp/JapaneseSegmentation.ts +53 -0
- package/src/nlp/Lexicon.ts +119 -0
- package/src/nlp/PhoneConversion.ts +508 -0
- package/src/nlp/Segmentation.ts +237 -0
- package/src/nlp/TextNormalizer.ts +160 -0
- package/src/recognition/AmazonTranscribeSTT.ts +112 -0
- package/src/recognition/AzureCognitiveServicesSTT.ts +76 -0
- package/src/recognition/GoogleCloudSTT.ts +92 -0
- package/src/recognition/SileroSTT.ts +173 -0
- package/src/recognition/VoskSTT.ts +112 -0
- package/src/recognition/WhisperSTT.ts +1518 -0
- package/src/server/Client.ts +297 -0
- package/src/server/Server.ts +178 -0
- package/src/server/ServerStarter.ts +12 -0
- package/src/server/Worker.ts +400 -0
- package/src/server/WorkerStarter.ts +38 -0
- package/src/speech-language-detection/SileroLanguageDetection.ts +105 -0
- package/src/subtitles/Subtitles.ts +478 -0
- package/src/synthesis/AwsPollyTTS.ts +78 -0
- package/src/synthesis/AzureCognitiveServicesTTS.ts +146 -0
- package/src/synthesis/CoquiServerTTS.ts +29 -0
- package/src/synthesis/ElevenLabsTTS.ts +104 -0
- package/src/synthesis/EspeakTTS.ts +552 -0
- package/src/synthesis/FliteTTS.ts +387 -0
- package/src/synthesis/GoogleCloudTTS.ts +112 -0
- package/src/synthesis/GoogleTranslateTTS.ts +210 -0
- package/src/synthesis/MicrosoftEdgeTTS.ts +298 -0
- package/src/synthesis/SamTTS.ts +30 -0
- package/src/synthesis/SapiTTS.ts +222 -0
- package/src/synthesis/StreamlabsPollyTTS.ts +114 -0
- package/src/synthesis/SvoxPicoTTS.ts +318 -0
- package/src/synthesis/VitsTTS.ts +734 -0
- package/src/tests/Test.ts +24 -0
- package/src/text-language-detection/FastTextLanguageDetection.ts +53 -0
- package/src/text-language-detection/TinyLDLanguageDetection.ts +16 -0
- package/src/typings/Fillers.d.ts +41 -0
- package/src/utilities/BinaryArrayConversion.ts +159 -0
- package/src/utilities/Compression.ts +91 -0
- package/src/utilities/FileDownloader.ts +201 -0
- package/src/utilities/FileSystem.ts +265 -0
- package/src/utilities/Hashing.ts +230 -0
- package/src/utilities/Locale.ts +119 -0
- package/src/utilities/Logger.ts +72 -0
- package/src/utilities/NdArrayUtilities.ts +31 -0
- package/src/utilities/ObjectUtilities.ts +169 -0
- package/src/utilities/OpenPromise.ts +13 -0
- package/src/utilities/PackageManager.ts +97 -0
- package/src/utilities/Queue.ts +17 -0
- package/src/utilities/RandomGenerator.ts +237 -0
- package/src/utilities/SignalChannel.ts +22 -0
- package/src/utilities/TarballMaker.ts +68 -0
- package/src/utilities/Timeline.ts +231 -0
- package/src/utilities/Timer.ts +93 -0
- package/src/utilities/Utilities.ts +574 -0
- package/src/utilities/WasmMemoryManager.ts +516 -0
- package/src/utilities/WebReader.ts +55 -0
- package/src/utilities/WikipediaReader.ts +41 -0
- package/src/voice-activity-detection/SileroVAD.ts +86 -0
- 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
|
+
|