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,298 @@
|
|
|
1
|
+
import { request } from "gaxios"
|
|
2
|
+
import WebSocket from "ws"
|
|
3
|
+
|
|
4
|
+
import { escape } from 'html-escaper'
|
|
5
|
+
|
|
6
|
+
import splitBuffer from "buffer-split"
|
|
7
|
+
|
|
8
|
+
import * as AzureCognitiveServicesTTS from "./AzureCognitiveServicesTTS.js"
|
|
9
|
+
import * as FFMpegTranscoder from "../codecs/FFMpegTranscoder.js"
|
|
10
|
+
import { Logger } from "../utilities/Logger.js"
|
|
11
|
+
import { OpenPromise } from "../utilities/OpenPromise.js"
|
|
12
|
+
import { getRandomHexString, logToStderr } from "../utilities/Utilities.js"
|
|
13
|
+
import { getRawAudioDuration } from "../audio/AudioUtilities.js"
|
|
14
|
+
|
|
15
|
+
const traceEnabled = false
|
|
16
|
+
|
|
17
|
+
const log: typeof logToStderr = traceEnabled ? logToStderr : () => { }
|
|
18
|
+
|
|
19
|
+
export async function synthesize(
|
|
20
|
+
text: string,
|
|
21
|
+
trustedClientToken: string,
|
|
22
|
+
voice = "Microsoft Server Speech Text to Speech Voice (en-US, AriaNeural)",
|
|
23
|
+
ssmlPitchString = "+0Hz",
|
|
24
|
+
ssmlRateString = "+0%",
|
|
25
|
+
ssmlVolumeString = "+0%") {
|
|
26
|
+
|
|
27
|
+
const logger = new Logger()
|
|
28
|
+
logger.start("Request synthesis from Microsoft Edge cloud API")
|
|
29
|
+
|
|
30
|
+
const { audioData, events } = await requestSynthesis(text, trustedClientToken, voice, ssmlPitchString, ssmlRateString, ssmlVolumeString)
|
|
31
|
+
logger.end()
|
|
32
|
+
|
|
33
|
+
const rawAudio = await FFMpegTranscoder.decodeToChannels(audioData, 24000, 1)
|
|
34
|
+
|
|
35
|
+
logger.start("Convert boundary events to timeline")
|
|
36
|
+
const timeline = AzureCognitiveServicesTTS.boundaryEventsToTimeline(events, getRawAudioDuration(rawAudio))
|
|
37
|
+
logger.end()
|
|
38
|
+
|
|
39
|
+
return { rawAudio, timeline }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type SynthesisRequestResult = { audioData: Buffer, events: any[] }
|
|
43
|
+
|
|
44
|
+
async function requestSynthesis(
|
|
45
|
+
text: string,
|
|
46
|
+
trustedClientToken: string,
|
|
47
|
+
voice = "Microsoft Server Speech Text to Speech Voice (en-US, AriaNeural)",
|
|
48
|
+
ssmlPitchString = "+0Hz",
|
|
49
|
+
ssmlRateString = "+0%",
|
|
50
|
+
ssmlVolumeString = "+0%") {
|
|
51
|
+
|
|
52
|
+
const synthesisOpenPromise = new OpenPromise<SynthesisRequestResult>()
|
|
53
|
+
|
|
54
|
+
const requestId = getRandomHexString()
|
|
55
|
+
|
|
56
|
+
const webSocket = await initializeWebsocketConnection(trustedClientToken)
|
|
57
|
+
|
|
58
|
+
const receivedBinaryMessages: Buffer[] = []
|
|
59
|
+
const receivedEventMessages: any[] = []
|
|
60
|
+
const audioChunks: Buffer[] = []
|
|
61
|
+
|
|
62
|
+
const onMessage = (messageData: Buffer, isBinary: boolean) => {
|
|
63
|
+
if (isBinary) {
|
|
64
|
+
const [header, audioChunk] = parseBinaryMessage(messageData)
|
|
65
|
+
|
|
66
|
+
if (header['X-RequestId'] != requestId) {
|
|
67
|
+
log(`Ignoring binary message to different request Id: ${requestId}`)
|
|
68
|
+
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
log("\nReceived binary message:")
|
|
73
|
+
log(header)
|
|
74
|
+
|
|
75
|
+
receivedBinaryMessages.push(messageData)
|
|
76
|
+
receivedBinaryMessages.push(Buffer.from("\n\n"))
|
|
77
|
+
|
|
78
|
+
audioChunks.push(audioChunk)
|
|
79
|
+
} else {
|
|
80
|
+
const message = messageData.toString("utf8")
|
|
81
|
+
const [header, content] = parseTextMessage(message)
|
|
82
|
+
|
|
83
|
+
if (header['X-RequestId'] != requestId) {
|
|
84
|
+
log(`Ignoring text message to different request Id: ${requestId}`)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
log("\nReceived text message:")
|
|
89
|
+
log(header)
|
|
90
|
+
log(content)
|
|
91
|
+
|
|
92
|
+
if (header["Path"] == "turn.start") {
|
|
93
|
+
|
|
94
|
+
}
|
|
95
|
+
else if (header["Path"] == "audio.metadata") {
|
|
96
|
+
receivedEventMessages.push(content["Metadata"][0])
|
|
97
|
+
}
|
|
98
|
+
else if (header["Path"] == "turn.end") {
|
|
99
|
+
const result: SynthesisRequestResult = { audioData: Buffer.concat(audioChunks), events: receivedEventMessages }
|
|
100
|
+
|
|
101
|
+
webSocket.off("message", onMessage)
|
|
102
|
+
webSocket.off("error", onError)
|
|
103
|
+
webSocket.off("close", onClose)
|
|
104
|
+
|
|
105
|
+
synthesisOpenPromise.resolve(result)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const onError = (err: Error) => {
|
|
111
|
+
synthesisOpenPromise.reject(err)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const onClose = async (code: number, reason: Buffer) => {
|
|
115
|
+
log("WebSocket closed.")
|
|
116
|
+
log(code)
|
|
117
|
+
log(reason.toString())
|
|
118
|
+
|
|
119
|
+
if (reason.length > 0) {
|
|
120
|
+
synthesisOpenPromise.reject(`Websocket closed with code ${code}, reason: ${reason}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
webSocket.on("message", onMessage)
|
|
125
|
+
webSocket.on("error", onError)
|
|
126
|
+
webSocket.on("close", onClose)
|
|
127
|
+
|
|
128
|
+
const requestContentSSML =
|
|
129
|
+
`<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>` +
|
|
130
|
+
`<voice name='${voice}'>` +
|
|
131
|
+
`<prosody pitch='${ssmlPitchString}' rate='${ssmlRateString}' volume='${ssmlVolumeString}'>` +
|
|
132
|
+
escape(text) +
|
|
133
|
+
`</prosody>` +
|
|
134
|
+
`</voice>` +
|
|
135
|
+
`</speak>`
|
|
136
|
+
|
|
137
|
+
const bodyRequestString =
|
|
138
|
+
`X-RequestId:${requestId}\r\n` +
|
|
139
|
+
`Content-Type:application/ssml+xml\r\n` +
|
|
140
|
+
`X-Timestamp:${getTimestampString()}Z\r\n` + // The added "Z" recreates a Microsoft Edge bug.
|
|
141
|
+
`Path:ssml\r\n\r\n` +
|
|
142
|
+
requestContentSSML
|
|
143
|
+
|
|
144
|
+
log(bodyRequestString)
|
|
145
|
+
|
|
146
|
+
webSocket.send(bodyRequestString)
|
|
147
|
+
|
|
148
|
+
return synthesisOpenPromise.promise
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let existingWebSocketConnection: WebSocket | undefined = undefined
|
|
152
|
+
|
|
153
|
+
export async function initializeWebsocketConnection(trustedClientToken: string) {
|
|
154
|
+
const requestOpenPromise = new OpenPromise<WebSocket>()
|
|
155
|
+
|
|
156
|
+
if (existingWebSocketConnection && existingWebSocketConnection.readyState == WebSocket.OPEN) {
|
|
157
|
+
log(`Existing websocket connection is still open. Reusing it.`)
|
|
158
|
+
|
|
159
|
+
requestOpenPromise.resolve(existingWebSocketConnection)
|
|
160
|
+
|
|
161
|
+
return requestOpenPromise.promise
|
|
162
|
+
} else {
|
|
163
|
+
existingWebSocketConnection = undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const connectionId = getRandomHexString()
|
|
167
|
+
|
|
168
|
+
const requestURL = "wss://speech.platform.bing.com/" +
|
|
169
|
+
"consumer/speech/synthesize/readaloud/edge/v1" +
|
|
170
|
+
`?TrustedClientToken=${trustedClientToken}` +
|
|
171
|
+
"&ConnectionId=" + connectionId
|
|
172
|
+
|
|
173
|
+
log(requestURL)
|
|
174
|
+
log("")
|
|
175
|
+
|
|
176
|
+
const webSocket = new WebSocket(requestURL, {
|
|
177
|
+
headers: {
|
|
178
|
+
"Pragma": "no-cache",
|
|
179
|
+
"Cache-Control": "no-cache",
|
|
180
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44",
|
|
181
|
+
"Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
|
|
182
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
183
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const requestMetadata = {
|
|
188
|
+
"context": {
|
|
189
|
+
"synthesis": {
|
|
190
|
+
"audio": {
|
|
191
|
+
"metadataoptions": {
|
|
192
|
+
"sentenceBoundaryEnabled": "false",
|
|
193
|
+
"wordBoundaryEnabled": "true"
|
|
194
|
+
},
|
|
195
|
+
"outputFormat": "webm-24khz-16bit-mono-opus"
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const onOpen = () => {
|
|
202
|
+
const configRequestString =
|
|
203
|
+
`X-Timestamp:${getTimestampString()}\r\n` +
|
|
204
|
+
`Content-Type:application/json; charset=utf-8\r\n` +
|
|
205
|
+
`Path:speech.config\r\n\r\n` +
|
|
206
|
+
`${JSON.stringify(requestMetadata, null, 0)}\r\n`
|
|
207
|
+
|
|
208
|
+
log(configRequestString)
|
|
209
|
+
|
|
210
|
+
webSocket.send(configRequestString)
|
|
211
|
+
|
|
212
|
+
existingWebSocketConnection = webSocket
|
|
213
|
+
|
|
214
|
+
webSocket.off("open", onOpen)
|
|
215
|
+
webSocket.off("close", onClose)
|
|
216
|
+
webSocket.off("error", onError)
|
|
217
|
+
|
|
218
|
+
requestOpenPromise.resolve(webSocket)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const onClose = (code: number, reason: Buffer) => {
|
|
222
|
+
log("WebSocket closed.")
|
|
223
|
+
log(code)
|
|
224
|
+
log(reason.toString())
|
|
225
|
+
|
|
226
|
+
if (reason.length > 0) {
|
|
227
|
+
requestOpenPromise.reject(`Websocket closed with code ${code}, reason: ${reason}`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const onError = (err: Error) => {
|
|
232
|
+
requestOpenPromise.reject(err)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
webSocket.on("open", onOpen)
|
|
236
|
+
webSocket.on("close", onClose)
|
|
237
|
+
webSocket.on("error", onError)
|
|
238
|
+
|
|
239
|
+
return requestOpenPromise.promise
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function getVoiceList(trustedClientToken: string) {
|
|
243
|
+
const response = await request<any>({
|
|
244
|
+
method: "GET",
|
|
245
|
+
|
|
246
|
+
url: "https://speech.platform.bing.com/consumer/speech/synthesize/" +
|
|
247
|
+
`readaloud/voices/list?trustedclienttoken=${trustedClientToken}`,
|
|
248
|
+
|
|
249
|
+
headers: {
|
|
250
|
+
"sec-ch-ua": `" Not;A Brand";v="99", "Microsoft Edge";v="103", "Chromium";v="103"`,
|
|
251
|
+
"dnt": "1",
|
|
252
|
+
"sec-ch-ua-mobile": "?0",
|
|
253
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44",
|
|
254
|
+
"sec-ch-ua-platform": "Windows",
|
|
255
|
+
"accept": "*/*",
|
|
256
|
+
"sec-fetch-site": "none",
|
|
257
|
+
"sec-fetch-mode": "cors",
|
|
258
|
+
"sec-fetch-dest": "empty",
|
|
259
|
+
"accept-encoding": "gzip, deflate, br",
|
|
260
|
+
"accept-language": "en-US,en;q=0.9",
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
responseType: "json"
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
return response.data as any[]
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getTimestampString() {
|
|
270
|
+
const timestampString = new Date(new Date().toLocaleString("en-US", { timeZone: "UTC" })).toString().replace(/GMT.*/, "GMT+0000 (Coordinated Universal Time)")
|
|
271
|
+
return timestampString
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseHeaderString(headerString: string) {
|
|
275
|
+
const headers: any = {}
|
|
276
|
+
|
|
277
|
+
for (const headerLine of headerString.split("\r\n")) {
|
|
278
|
+
const [key, value] = headerLine.split(":")
|
|
279
|
+
headers[key] = value
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return headers
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseTextMessage(message: string) {
|
|
286
|
+
const [headerString, content] = message.split("\r\n\r\n")
|
|
287
|
+
|
|
288
|
+
return [parseHeaderString(headerString), JSON.parse(content)]
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseBinaryMessage(message: Buffer) {
|
|
292
|
+
const audioChunk = splitBuffer(message, Buffer.from("Path:audio\r\n"))[1]
|
|
293
|
+
const headerBuffer = message.subarray(2, message.length - audioChunk.length)
|
|
294
|
+
const headerString = headerBuffer.toString("utf8").trim()
|
|
295
|
+
|
|
296
|
+
return [parseHeaderString(headerString), audioChunk]
|
|
297
|
+
}
|
|
298
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { RawAudio } from '../audio/AudioUtilities.js'
|
|
2
|
+
import { Logger } from '../utilities/Logger.js'
|
|
3
|
+
|
|
4
|
+
// SAM (Software Automatic Mouth) speech synthesizer from 1982 (pure JS port)
|
|
5
|
+
//
|
|
6
|
+
// https://github.com/discordier/sam
|
|
7
|
+
//
|
|
8
|
+
// https://habr-com.translate.goog/ru/post/500764/?_x_tr_sl=auto&_x_tr_tl=en
|
|
9
|
+
|
|
10
|
+
export async function synthesize(text: string, pitch = 64, speed = 72, mouth = 128, throat = 128) {
|
|
11
|
+
const logger = new Logger()
|
|
12
|
+
logger.start("Initialize sam module")
|
|
13
|
+
|
|
14
|
+
const { default: SamJs } = await import('sam-js')
|
|
15
|
+
|
|
16
|
+
const sam = new SamJs({ pitch, speed, mouth, throat })
|
|
17
|
+
|
|
18
|
+
logger.start("Synthesize with sam")
|
|
19
|
+
const samples: Float32Array = sam.buf32(text) as Float32Array
|
|
20
|
+
|
|
21
|
+
if (!samples) {
|
|
22
|
+
throw new Error("Sam TTS failed")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rawAudio: RawAudio = { audioChannels: [samples], sampleRate: 22050 }
|
|
26
|
+
|
|
27
|
+
logger.end()
|
|
28
|
+
|
|
29
|
+
return { rawAudio }
|
|
30
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { SynthesisVoice } from "../api/API.js"
|
|
2
|
+
import { decodeToChannels } from "../audio/AudioBufferConversion.js"
|
|
3
|
+
import { RawAudio } from "../audio/AudioUtilities.js"
|
|
4
|
+
import { SampleFormat } from "../codecs/WaveCodec.js"
|
|
5
|
+
import { getShortLanguageCode, lcidToIsoLanguageCode } from "../utilities/Locale.js"
|
|
6
|
+
import { Logger } from "../utilities/Logger.js"
|
|
7
|
+
import { Timeline, TimelineEntry } from "../utilities/Timeline.js"
|
|
8
|
+
import { logToStderr } from "../utilities/Utilities.js"
|
|
9
|
+
|
|
10
|
+
const log = logToStderr
|
|
11
|
+
|
|
12
|
+
export function synthesize(text: string, voiceName: string, rate = 0, useSpeechPlatform = false) {
|
|
13
|
+
return new Promise<{ rawAudio: RawAudio, timeline: Timeline }>(async (resolve, reject) => {
|
|
14
|
+
const logger = new Logger()
|
|
15
|
+
logger.start("Initialize winax module")
|
|
16
|
+
|
|
17
|
+
const { default: WinAX } = await import("winax")
|
|
18
|
+
|
|
19
|
+
logger.start("Create SAPI COM object")
|
|
20
|
+
const sapiVoice = new global.ActiveXObject(useSpeechPlatform ? "Speech.SPVoice" : "SAPI.SPVoice")
|
|
21
|
+
sapiVoice.EventInterests = 33790
|
|
22
|
+
|
|
23
|
+
logger.start("Get SAPI voice list and select best match")
|
|
24
|
+
|
|
25
|
+
if (voiceName) {
|
|
26
|
+
const voiceObjects = sapiVoice.GetVoices()
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < voiceObjects.Count; i++) {
|
|
29
|
+
const voiceObject = voiceObjects.Item(i)
|
|
30
|
+
const candidateVoiceName = voiceObject.GetDescription()
|
|
31
|
+
|
|
32
|
+
if (candidateVoiceName == voiceName) {
|
|
33
|
+
sapiVoice.Voice = voiceObject
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
sapiVoice.Rate = rate
|
|
39
|
+
|
|
40
|
+
// Create phone converter for language
|
|
41
|
+
const sapiPhoneConverter = new global.ActiveXObject(useSpeechPlatform ? "Speech.SpPhoneConverter" : "SAPI.SpPhoneConverter")
|
|
42
|
+
const sapiLanguageCodeHex = sapiVoice.Voice.GetAttribute('Language')
|
|
43
|
+
const sapiLanguageCode = parseInt(sapiLanguageCodeHex, 16)
|
|
44
|
+
|
|
45
|
+
sapiPhoneConverter.LanguageId = sapiLanguageCode
|
|
46
|
+
|
|
47
|
+
logger.start("Synthesize with SAPI")
|
|
48
|
+
|
|
49
|
+
const sampleRate = 22050
|
|
50
|
+
const bytesPerSecond = sampleRate * 2
|
|
51
|
+
|
|
52
|
+
const sapiOutputStream = new global.ActiveXObject('SAPI.SpMemoryStream')
|
|
53
|
+
sapiOutputStream.Format.Type = 22 // format code code for SAFT22kHz16BitMono
|
|
54
|
+
sapiVoice.AudioOutputStream = sapiOutputStream
|
|
55
|
+
|
|
56
|
+
const dispatchMessagesInterval = setInterval(() => {
|
|
57
|
+
WinAX.peekAndDispatchMessages()
|
|
58
|
+
}, 50)
|
|
59
|
+
|
|
60
|
+
const connectionPoints = WinAX.getConnectionPoints(sapiVoice)
|
|
61
|
+
const connectionPoint = connectionPoints[0]
|
|
62
|
+
|
|
63
|
+
const methods = connectionPoint.getMethods()
|
|
64
|
+
|
|
65
|
+
const events: Timeline = []
|
|
66
|
+
let lastWordEvent: TimelineEntry | null = null
|
|
67
|
+
let lastWordCharPos = -1
|
|
68
|
+
|
|
69
|
+
connectionPoint.advise({
|
|
70
|
+
StartStream: () => {
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
Word: (streamId: number, streamPos: number, charPos: number, length: number) => {
|
|
74
|
+
if (lastWordCharPos == charPos) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const wordText = text.substring(charPos, charPos + length)
|
|
79
|
+
const startTime = streamPos / bytesPerSecond
|
|
80
|
+
|
|
81
|
+
const wordEvent = { type: "word", text: wordText, startTime, endTime: -1, timeline: [] } as TimelineEntry
|
|
82
|
+
events.push(wordEvent)
|
|
83
|
+
|
|
84
|
+
lastWordEvent = wordEvent
|
|
85
|
+
lastWordCharPos = charPos
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
Phoneme: (streamId: number, streamPos: number, duration: number, nextPhoneId: number, feature: number, currentPhoneId: number) => {
|
|
89
|
+
if (events.length == 0) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const phoneText = sapiPhoneConverter.IdToPhone(currentPhoneId)
|
|
94
|
+
|
|
95
|
+
if (phoneText == "," || phoneText == "_") {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const startTime = streamPos / bytesPerSecond
|
|
100
|
+
const endTime = startTime + (duration / 1000)
|
|
101
|
+
|
|
102
|
+
events.push({ type: "phone", text: phoneText, startTime, endTime })
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
EndStream: (streamId: number, streamPos: number) => {
|
|
106
|
+
clearInterval(dispatchMessagesInterval)
|
|
107
|
+
|
|
108
|
+
const audioData = Buffer.from(sapiOutputStream.GetData())
|
|
109
|
+
const audioChannels = decodeToChannels(audioData, 1, 16, SampleFormat.PCM)
|
|
110
|
+
|
|
111
|
+
WinAX.release(sapiOutputStream)
|
|
112
|
+
WinAX.release(sapiVoice)
|
|
113
|
+
|
|
114
|
+
logger.end()
|
|
115
|
+
|
|
116
|
+
resolve({ rawAudio: { audioChannels, sampleRate }, timeline: eventsToTimeline(events, audioChannels[0].length / sampleRate) })
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
sapiVoice.Speak(text)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function getVoiceList(useSpeechPlatform = false) {
|
|
125
|
+
const { default: WinAX } = await import("winax")
|
|
126
|
+
|
|
127
|
+
const sapiVoice = new global.ActiveXObject(useSpeechPlatform ? "Speech.SPVoice" : "SAPI.SPVoice")
|
|
128
|
+
|
|
129
|
+
const voiceObjects = sapiVoice.GetVoices()
|
|
130
|
+
|
|
131
|
+
const voices: SynthesisVoice[] = []
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < voiceObjects.Count; i++) {
|
|
134
|
+
const voiceObject = voiceObjects.Item(i)
|
|
135
|
+
const voiceName = voiceObject.GetDescription()
|
|
136
|
+
const voiceGender = voiceObject.GetAttribute("Gender")?.toLowerCase()
|
|
137
|
+
|
|
138
|
+
const sapiLanguageCodeHex = voiceObject.GetAttribute('Language')
|
|
139
|
+
const sapiLanguageCode = parseInt(sapiLanguageCodeHex, 16)
|
|
140
|
+
|
|
141
|
+
const languageCodes = await lcidToIsoLanguageCode(sapiLanguageCode)
|
|
142
|
+
|
|
143
|
+
if (!languageCodes) {
|
|
144
|
+
throw new Error(`Couldn't translate SAPI language code ${sapiLanguageCode} to ISO, for voice '${voiceName}'`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const resultLanguageCodes: string[] = []
|
|
148
|
+
|
|
149
|
+
for (const languageCode of languageCodes) {
|
|
150
|
+
if (!resultLanguageCodes.includes(languageCode)) {
|
|
151
|
+
resultLanguageCodes.push(languageCode)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const shortLanguageCode = getShortLanguageCode(languageCode)
|
|
155
|
+
if (!resultLanguageCodes.includes(shortLanguageCode)) {
|
|
156
|
+
resultLanguageCodes.push(shortLanguageCode)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
voices.push({
|
|
161
|
+
name: voiceName,
|
|
162
|
+
languages: resultLanguageCodes,
|
|
163
|
+
gender: voiceGender || "unknown"
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
WinAX.release(sapiVoice)
|
|
168
|
+
|
|
169
|
+
return voices
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function AssertSAPIAvailable(testForSpeechPlatform = false) {
|
|
173
|
+
if (process.platform != "win32") {
|
|
174
|
+
throw new Error(`SAPI is not available on your platform. SAPI is a Microsoft Windows technology that is only runs on a Windows OS.`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const { default: WinAX } = await import("winax")
|
|
179
|
+
} catch (e) {
|
|
180
|
+
throw new Error(`winax package, which is required for SAPI support, was not found. You can install it by running 'npm install winax -g'.`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const voice = new global.ActiveXObject("SAPI.SPVoice")
|
|
185
|
+
} catch (e) {
|
|
186
|
+
throw new Error(`Failed creating a SAPI instance: ${e}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const voice = new global.ActiveXObject("Speech.SPVoice")
|
|
191
|
+
} catch(e) {
|
|
192
|
+
throw new Error(`Failed creating an msspeech instance. Please ensure you installed the Microsoft Speech Platform runtime correctly.`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function eventsToTimeline(events: Timeline, totalDuration: number): Timeline {
|
|
197
|
+
const timeline: Timeline = []
|
|
198
|
+
|
|
199
|
+
for (const event of events) {
|
|
200
|
+
if (event.type == "word") {
|
|
201
|
+
timeline.push(event)
|
|
202
|
+
} else if (event.type == "phone") {
|
|
203
|
+
if (timeline.length == 0) {
|
|
204
|
+
throw new Error("Unexpected: phone event preceded a word event")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const lastWordEntry = timeline[timeline.length - 1]
|
|
208
|
+
|
|
209
|
+
lastWordEntry.endTime = event.endTime
|
|
210
|
+
|
|
211
|
+
const phoneTimeline = lastWordEntry.timeline!
|
|
212
|
+
|
|
213
|
+
phoneTimeline.push(event)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (timeline.length > 0 && timeline[timeline.length - 1].endTime == -1) {
|
|
218
|
+
timeline[timeline.length - 1].endTime = totalDuration
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return timeline
|
|
222
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { request } from "gaxios"
|
|
2
|
+
import { SynthesisVoice } from "../api/API.js"
|
|
3
|
+
import { trimAudioEnd } from "../audio/AudioUtilities.js"
|
|
4
|
+
import * as FFMpegTranscoder from "../codecs/FFMpegTranscoder.js"
|
|
5
|
+
import { Phrase, splitToFragments } from "../nlp/Segmentation.js"
|
|
6
|
+
import { Logger } from "../utilities/Logger.js"
|
|
7
|
+
import { concatFloat32Arrays, logToStderr } from "../utilities/Utilities.js"
|
|
8
|
+
import { Timeline } from "../utilities/Timeline.js"
|
|
9
|
+
|
|
10
|
+
const log = logToStderr
|
|
11
|
+
|
|
12
|
+
const maxTextLengthPerRequest = 200
|
|
13
|
+
|
|
14
|
+
export async function synthesizeLongText(text: string, voice: string, languageCode: string, sentenceEndPause = 0.75, segmentEndPause = 1.0) {
|
|
15
|
+
if (text.length == 0) {
|
|
16
|
+
throw new Error("Text is empty")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const logger = new Logger()
|
|
20
|
+
logger.start("Prepare and split text")
|
|
21
|
+
|
|
22
|
+
const fragments = await splitToFragments(text, maxTextLengthPerRequest, languageCode)
|
|
23
|
+
|
|
24
|
+
const audioFragments: Float32Array[] = []
|
|
25
|
+
let fragmentsSampleRate = 0
|
|
26
|
+
|
|
27
|
+
const timeline: Timeline = []
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < fragments.length; i++) {
|
|
30
|
+
const fragment = fragments[i]
|
|
31
|
+
|
|
32
|
+
logger.start(`Request synthesis for text fragment ${i + 1}/${fragments.length} from Streamslabs Polly`)
|
|
33
|
+
const fragmentMp3Stream = await synthesizeFragment(fragment.text, voice)
|
|
34
|
+
|
|
35
|
+
if (fragmentMp3Stream.length == 0) {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rawAudio = await FFMpegTranscoder.decodeToChannels(fragmentMp3Stream, 24000, 1)
|
|
40
|
+
fragmentsSampleRate = rawAudio.sampleRate
|
|
41
|
+
|
|
42
|
+
let targetEndingSilenceTime: number
|
|
43
|
+
|
|
44
|
+
if (fragment.lastSegment?.isSentenceFinalizer) {
|
|
45
|
+
targetEndingSilenceTime = sentenceEndPause
|
|
46
|
+
} else if (fragment.lastSegment instanceof Phrase) {
|
|
47
|
+
targetEndingSilenceTime = segmentEndPause
|
|
48
|
+
} else {
|
|
49
|
+
targetEndingSilenceTime = 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const trimmedAudio = trimAudioEnd(rawAudio.audioChannels[0], targetEndingSilenceTime * rawAudio.sampleRate)
|
|
53
|
+
|
|
54
|
+
audioFragments.push(trimmedAudio)
|
|
55
|
+
|
|
56
|
+
const startTime = timeline.length == 0 ? 0 : timeline[timeline.length - 1].endTime
|
|
57
|
+
const endTime = startTime + (trimmedAudio.length / fragmentsSampleRate)
|
|
58
|
+
|
|
59
|
+
timeline.push({
|
|
60
|
+
type: "segment",
|
|
61
|
+
text: fragment.text,
|
|
62
|
+
startTime,
|
|
63
|
+
endTime
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rawAudio = { audioChannels: [concatFloat32Arrays(audioFragments)], sampleRate: fragmentsSampleRate }
|
|
68
|
+
|
|
69
|
+
logger.end()
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
rawAudio,
|
|
73
|
+
timeline
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function synthesizeFragment(text: string, voice: string) {
|
|
78
|
+
const response = await request<any>({
|
|
79
|
+
url: `https://streamlabs.com/polly/speak`,
|
|
80
|
+
|
|
81
|
+
method: "POST",
|
|
82
|
+
|
|
83
|
+
data: {
|
|
84
|
+
voice,
|
|
85
|
+
text,
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
responseType: "json"
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const responseObject = response.data
|
|
92
|
+
const audioUrl = responseObject.speak_url
|
|
93
|
+
const audioUrlResponse = await request<ArrayBuffer>({ url: audioUrl, responseType: "arraybuffer" })
|
|
94
|
+
|
|
95
|
+
return Buffer.from(audioUrlResponse.data)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const voiceList: SynthesisVoice[] = [
|
|
99
|
+
{ name: "Brian", languages: ["en-GB", "en"], gender: "male" },
|
|
100
|
+
{ name: "Emma", languages: ["en-GB", "en"], gender: "female" },
|
|
101
|
+
{ name: "Russell", languages: ["en-AU", "en"], gender: "male" },
|
|
102
|
+
{ name: "Joey", languages: ["en-US", "en"], gender: "male" },
|
|
103
|
+
{ name: "Matthew", languages: ["en-US", "en"], gender: "male" },
|
|
104
|
+
{ name: "Joanna", languages: ["en-US", "en"], gender: "female" },
|
|
105
|
+
{ name: "Kimberly", languages: ["en-US", "en"], gender: "female" },
|
|
106
|
+
{ name: "Amy", languages: ["en-GB", "en"], gender: "female" },
|
|
107
|
+
{ name: "Geraint", languages: ["en-GB-WLS", "en-GB", "en"], gender: "male" },
|
|
108
|
+
{ name: "Nicole", languages: ["en-AU", "en"], gender: "female" },
|
|
109
|
+
{ name: "Justin", languages: ["en-US", "en"], gender: "male" },
|
|
110
|
+
{ name: "Ivy", languages: ["en-US", "en"], gender: "female" },
|
|
111
|
+
{ name: "Kendra", languages: ["en-US", "en"], gender: "female" },
|
|
112
|
+
{ name: "Salli", languages: ["en-US", "en"], gender: "female" },
|
|
113
|
+
{ name: "Raveena", languages: ["en-IN", "en"], gender: "female" },
|
|
114
|
+
]
|