echogarden 0.11.12 → 0.11.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/data/schemas/options.json +16 -0
  2. package/dist/api/Alignment.js +2 -2
  3. package/dist/api/Alignment.js.map +1 -1
  4. package/dist/api/Recognition.js +2 -2
  5. package/dist/api/Recognition.js.map +1 -1
  6. package/dist/api/Synthesis.js +5 -4
  7. package/dist/api/Synthesis.js.map +1 -1
  8. package/dist/api/Translation.js +2 -2
  9. package/dist/api/Translation.js.map +1 -1
  10. package/dist/audio/AudioUtilities.d.ts +1 -0
  11. package/dist/audio/AudioUtilities.js +25 -7
  12. package/dist/audio/AudioUtilities.js.map +1 -1
  13. package/dist/cli/CLI.js +2 -2
  14. package/dist/cli/CLI.js.map +1 -1
  15. package/dist/recognition/WhisperSTT.js +2 -2
  16. package/dist/recognition/WhisperSTT.js.map +1 -1
  17. package/dist/subtitles/Subtitles.d.ts +10 -7
  18. package/dist/subtitles/Subtitles.js +268 -207
  19. package/dist/subtitles/Subtitles.js.map +1 -1
  20. package/docs/Options.md +4 -2
  21. package/package.json +7 -6
  22. package/src/alignment/DTWMfccSequenceAlignment.ts +43 -0
  23. package/src/alignment/DTWSequenceAlignment.ts +121 -0
  24. package/src/alignment/DTWSequenceAlignmentWindowed.ts +210 -0
  25. package/src/alignment/LevenshteinSequenceAlignment.ts +126 -0
  26. package/src/alignment/SpeechAlignment.ts +488 -0
  27. package/src/api/API.ts +12 -0
  28. package/src/api/APIOptions.ts +15 -0
  29. package/src/api/Alignment.ts +329 -0
  30. package/src/api/Common.ts +16 -0
  31. package/src/api/Denoising.ts +120 -0
  32. package/src/api/LanguageDetection.ts +286 -0
  33. package/src/api/Recognition.ts +344 -0
  34. package/src/api/Synthesis.ts +1735 -0
  35. package/src/api/Translation.ts +143 -0
  36. package/src/api/Vad.ts +172 -0
  37. package/src/audio/AudioBufferConversion.ts +248 -0
  38. package/src/audio/AudioPlayer.ts +358 -0
  39. package/src/audio/AudioRecorder.ts +91 -0
  40. package/src/audio/AudioUtilities.ts +392 -0
  41. package/src/audio/SoxPath.ts +24 -0
  42. package/src/cli/CLI.ts +1360 -0
  43. package/src/cli/CLIConfigFile.ts +91 -0
  44. package/src/cli/CLILauncher.ts +26 -0
  45. package/src/cli/CLIOptionsSchema.ts +54 -0
  46. package/src/cli/CLIParser.ts +41 -0
  47. package/src/cli/CLIStarter.ts +40 -0
  48. package/src/codecs/FFMpegTranscoder.ts +214 -0
  49. package/src/codecs/TIMITCodec.ts +17 -0
  50. package/src/codecs/WaveCodec.ts +260 -0
  51. package/src/denoising/RNNoise.ts +95 -0
  52. package/src/dsp/BiquadFilter.ts +488 -0
  53. package/src/dsp/FFT.ts +187 -0
  54. package/src/dsp/MFCC.ts +227 -0
  55. package/src/dsp/MelSpectogram.ts +145 -0
  56. package/src/dsp/Rubberband.ts +249 -0
  57. package/src/dsp/Sonic.ts +59 -0
  58. package/src/dsp/SpeexResampler.ts +79 -0
  59. package/src/math/VectorMath.ts +812 -0
  60. package/src/nlp/ChineseSegmentation.ts +68 -0
  61. package/src/nlp/CompromiseNLP.ts +113 -0
  62. package/src/nlp/EspeakPhonemizer.ts +168 -0
  63. package/src/nlp/IPA.ts +139 -0
  64. package/src/nlp/JapaneseSegmentation.ts +53 -0
  65. package/src/nlp/Lexicon.ts +119 -0
  66. package/src/nlp/PhoneConversion.ts +508 -0
  67. package/src/nlp/Segmentation.ts +237 -0
  68. package/src/nlp/TextNormalizer.ts +160 -0
  69. package/src/recognition/AmazonTranscribeSTT.ts +112 -0
  70. package/src/recognition/AzureCognitiveServicesSTT.ts +76 -0
  71. package/src/recognition/GoogleCloudSTT.ts +92 -0
  72. package/src/recognition/SileroSTT.ts +173 -0
  73. package/src/recognition/VoskSTT.ts +112 -0
  74. package/src/recognition/WhisperSTT.ts +1518 -0
  75. package/src/server/Client.ts +297 -0
  76. package/src/server/Server.ts +178 -0
  77. package/src/server/ServerStarter.ts +12 -0
  78. package/src/server/Worker.ts +400 -0
  79. package/src/server/WorkerStarter.ts +38 -0
  80. package/src/speech-language-detection/SileroLanguageDetection.ts +105 -0
  81. package/src/subtitles/Subtitles.ts +478 -0
  82. package/src/synthesis/AwsPollyTTS.ts +78 -0
  83. package/src/synthesis/AzureCognitiveServicesTTS.ts +146 -0
  84. package/src/synthesis/CoquiServerTTS.ts +29 -0
  85. package/src/synthesis/ElevenLabsTTS.ts +104 -0
  86. package/src/synthesis/EspeakTTS.ts +552 -0
  87. package/src/synthesis/FliteTTS.ts +387 -0
  88. package/src/synthesis/GoogleCloudTTS.ts +112 -0
  89. package/src/synthesis/GoogleTranslateTTS.ts +210 -0
  90. package/src/synthesis/MicrosoftEdgeTTS.ts +298 -0
  91. package/src/synthesis/SamTTS.ts +30 -0
  92. package/src/synthesis/SapiTTS.ts +222 -0
  93. package/src/synthesis/StreamlabsPollyTTS.ts +114 -0
  94. package/src/synthesis/SvoxPicoTTS.ts +318 -0
  95. package/src/synthesis/VitsTTS.ts +734 -0
  96. package/src/tests/Test.ts +24 -0
  97. package/src/text-language-detection/FastTextLanguageDetection.ts +53 -0
  98. package/src/text-language-detection/TinyLDLanguageDetection.ts +16 -0
  99. package/src/typings/Fillers.d.ts +41 -0
  100. package/src/utilities/BinaryArrayConversion.ts +159 -0
  101. package/src/utilities/Compression.ts +91 -0
  102. package/src/utilities/FileDownloader.ts +201 -0
  103. package/src/utilities/FileSystem.ts +265 -0
  104. package/src/utilities/Hashing.ts +230 -0
  105. package/src/utilities/Locale.ts +119 -0
  106. package/src/utilities/Logger.ts +72 -0
  107. package/src/utilities/NdArrayUtilities.ts +31 -0
  108. package/src/utilities/ObjectUtilities.ts +169 -0
  109. package/src/utilities/OpenPromise.ts +13 -0
  110. package/src/utilities/PackageManager.ts +97 -0
  111. package/src/utilities/Queue.ts +17 -0
  112. package/src/utilities/RandomGenerator.ts +237 -0
  113. package/src/utilities/SignalChannel.ts +22 -0
  114. package/src/utilities/TarballMaker.ts +68 -0
  115. package/src/utilities/Timeline.ts +231 -0
  116. package/src/utilities/Timer.ts +93 -0
  117. package/src/utilities/Utilities.ts +574 -0
  118. package/src/utilities/WasmMemoryManager.ts +516 -0
  119. package/src/utilities/WebReader.ts +55 -0
  120. package/src/utilities/WikipediaReader.ts +41 -0
  121. package/src/voice-activity-detection/SileroVAD.ts +86 -0
  122. package/src/voice-activity-detection/WebRtcVAD.ts +76 -0
@@ -0,0 +1,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
+ ]