echogarden 0.11.11 → 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 (130) hide show
  1. package/data/schemas/options.json +16 -0
  2. package/dist/alignment/DTWMfccSequenceAlignment.js +2 -2
  3. package/dist/alignment/DTWMfccSequenceAlignment.js.map +1 -1
  4. package/dist/alignment/SpeechAlignment.d.ts +1 -1
  5. package/dist/alignment/SpeechAlignment.js +15 -3
  6. package/dist/alignment/SpeechAlignment.js.map +1 -1
  7. package/dist/api/Alignment.js +3 -3
  8. package/dist/api/Alignment.js.map +1 -1
  9. package/dist/api/LanguageDetection.js +1 -1
  10. package/dist/api/LanguageDetection.js.map +1 -1
  11. package/dist/api/Recognition.js +3 -3
  12. package/dist/api/Recognition.js.map +1 -1
  13. package/dist/api/Synthesis.js +7 -6
  14. package/dist/api/Synthesis.js.map +1 -1
  15. package/dist/api/Translation.js +3 -3
  16. package/dist/api/Translation.js.map +1 -1
  17. package/dist/audio/AudioUtilities.d.ts +5 -2
  18. package/dist/audio/AudioUtilities.js +50 -22
  19. package/dist/audio/AudioUtilities.js.map +1 -1
  20. package/dist/cli/CLI.js +2 -2
  21. package/dist/cli/CLI.js.map +1 -1
  22. package/dist/dsp/SpeexResampler.js +1 -1
  23. package/dist/recognition/WhisperSTT.js +2 -2
  24. package/dist/recognition/WhisperSTT.js.map +1 -1
  25. package/dist/subtitles/Subtitles.d.ts +10 -7
  26. package/dist/subtitles/Subtitles.js +268 -207
  27. package/dist/subtitles/Subtitles.js.map +1 -1
  28. package/docs/Options.md +4 -2
  29. package/package.json +12 -11
  30. package/src/alignment/DTWMfccSequenceAlignment.ts +43 -0
  31. package/src/alignment/DTWSequenceAlignment.ts +121 -0
  32. package/src/alignment/DTWSequenceAlignmentWindowed.ts +210 -0
  33. package/src/alignment/LevenshteinSequenceAlignment.ts +126 -0
  34. package/src/alignment/SpeechAlignment.ts +488 -0
  35. package/src/api/API.ts +12 -0
  36. package/src/api/APIOptions.ts +15 -0
  37. package/src/api/Alignment.ts +329 -0
  38. package/src/api/Common.ts +16 -0
  39. package/src/api/Denoising.ts +120 -0
  40. package/src/api/LanguageDetection.ts +286 -0
  41. package/src/api/Recognition.ts +344 -0
  42. package/src/api/Synthesis.ts +1735 -0
  43. package/src/api/Translation.ts +143 -0
  44. package/src/api/Vad.ts +172 -0
  45. package/src/audio/AudioBufferConversion.ts +248 -0
  46. package/src/audio/AudioPlayer.ts +358 -0
  47. package/src/audio/AudioRecorder.ts +91 -0
  48. package/src/audio/AudioUtilities.ts +392 -0
  49. package/src/audio/SoxPath.ts +24 -0
  50. package/src/cli/CLI.ts +1360 -0
  51. package/src/cli/CLIConfigFile.ts +91 -0
  52. package/src/cli/CLILauncher.ts +26 -0
  53. package/src/cli/CLIOptionsSchema.ts +54 -0
  54. package/src/cli/CLIParser.ts +41 -0
  55. package/src/cli/CLIStarter.ts +40 -0
  56. package/src/codecs/FFMpegTranscoder.ts +214 -0
  57. package/src/codecs/TIMITCodec.ts +17 -0
  58. package/src/codecs/WaveCodec.ts +260 -0
  59. package/src/denoising/RNNoise.ts +95 -0
  60. package/src/dsp/BiquadFilter.ts +488 -0
  61. package/src/dsp/FFT.ts +187 -0
  62. package/src/dsp/MFCC.ts +227 -0
  63. package/src/dsp/MelSpectogram.ts +145 -0
  64. package/src/dsp/Rubberband.ts +249 -0
  65. package/src/dsp/Sonic.ts +59 -0
  66. package/src/dsp/SpeexResampler.ts +79 -0
  67. package/src/math/VectorMath.ts +812 -0
  68. package/src/nlp/ChineseSegmentation.ts +68 -0
  69. package/src/nlp/CompromiseNLP.ts +113 -0
  70. package/src/nlp/EspeakPhonemizer.ts +168 -0
  71. package/src/nlp/IPA.ts +139 -0
  72. package/src/nlp/JapaneseSegmentation.ts +53 -0
  73. package/src/nlp/Lexicon.ts +119 -0
  74. package/src/nlp/PhoneConversion.ts +508 -0
  75. package/src/nlp/Segmentation.ts +237 -0
  76. package/src/nlp/TextNormalizer.ts +160 -0
  77. package/src/recognition/AmazonTranscribeSTT.ts +112 -0
  78. package/src/recognition/AzureCognitiveServicesSTT.ts +76 -0
  79. package/src/recognition/GoogleCloudSTT.ts +92 -0
  80. package/src/recognition/SileroSTT.ts +173 -0
  81. package/src/recognition/VoskSTT.ts +112 -0
  82. package/src/recognition/WhisperSTT.ts +1518 -0
  83. package/src/server/Client.ts +297 -0
  84. package/src/server/Server.ts +178 -0
  85. package/src/server/ServerStarter.ts +12 -0
  86. package/src/server/Worker.ts +400 -0
  87. package/src/server/WorkerStarter.ts +38 -0
  88. package/src/speech-language-detection/SileroLanguageDetection.ts +105 -0
  89. package/src/subtitles/Subtitles.ts +478 -0
  90. package/src/synthesis/AwsPollyTTS.ts +78 -0
  91. package/src/synthesis/AzureCognitiveServicesTTS.ts +146 -0
  92. package/src/synthesis/CoquiServerTTS.ts +29 -0
  93. package/src/synthesis/ElevenLabsTTS.ts +104 -0
  94. package/src/synthesis/EspeakTTS.ts +552 -0
  95. package/src/synthesis/FliteTTS.ts +387 -0
  96. package/src/synthesis/GoogleCloudTTS.ts +112 -0
  97. package/src/synthesis/GoogleTranslateTTS.ts +210 -0
  98. package/src/synthesis/MicrosoftEdgeTTS.ts +298 -0
  99. package/src/synthesis/SamTTS.ts +30 -0
  100. package/src/synthesis/SapiTTS.ts +222 -0
  101. package/src/synthesis/StreamlabsPollyTTS.ts +114 -0
  102. package/src/synthesis/SvoxPicoTTS.ts +318 -0
  103. package/src/synthesis/VitsTTS.ts +734 -0
  104. package/src/tests/Test.ts +24 -0
  105. package/src/text-language-detection/FastTextLanguageDetection.ts +53 -0
  106. package/src/text-language-detection/TinyLDLanguageDetection.ts +16 -0
  107. package/src/typings/Fillers.d.ts +41 -0
  108. package/src/utilities/BinaryArrayConversion.ts +159 -0
  109. package/src/utilities/Compression.ts +91 -0
  110. package/src/utilities/FileDownloader.ts +201 -0
  111. package/src/utilities/FileSystem.ts +265 -0
  112. package/src/utilities/Hashing.ts +230 -0
  113. package/src/utilities/Locale.ts +119 -0
  114. package/src/utilities/Logger.ts +72 -0
  115. package/src/utilities/NdArrayUtilities.ts +31 -0
  116. package/src/utilities/ObjectUtilities.ts +169 -0
  117. package/src/utilities/OpenPromise.ts +13 -0
  118. package/src/utilities/PackageManager.ts +97 -0
  119. package/src/utilities/Queue.ts +17 -0
  120. package/src/utilities/RandomGenerator.ts +237 -0
  121. package/src/utilities/SignalChannel.ts +22 -0
  122. package/src/utilities/TarballMaker.ts +68 -0
  123. package/src/utilities/Timeline.ts +231 -0
  124. package/src/utilities/Timer.ts +93 -0
  125. package/src/utilities/Utilities.ts +574 -0
  126. package/src/utilities/WasmMemoryManager.ts +516 -0
  127. package/src/utilities/WebReader.ts +55 -0
  128. package/src/utilities/WikipediaReader.ts +41 -0
  129. package/src/voice-activity-detection/SileroVAD.ts +86 -0
  130. package/src/voice-activity-detection/WebRtcVAD.ts +76 -0
@@ -0,0 +1,29 @@
1
+ import { request } from "gaxios"
2
+ import { decodeWaveBuffer } from "../audio/AudioUtilities.js"
3
+ import { Logger } from "../utilities/Logger.js"
4
+ import { logToStderr } from "../utilities/Utilities.js"
5
+ const log = logToStderr
6
+
7
+ export async function synthesize(text: string, speakerId: string | null, serverURL = "http://[::1]:5002") {
8
+ const logger = new Logger()
9
+ logger.start("Request synthesis from Coqui Server")
10
+
11
+ const response = await request<Buffer>({
12
+ url: `${serverURL}/api/tts`,
13
+
14
+ params: {
15
+ "text": text,
16
+ "speaker_id": speakerId
17
+ },
18
+
19
+ responseType: "arraybuffer"
20
+ })
21
+
22
+ const waveData = Buffer.from(response.data)
23
+
24
+ const rawAudio = decodeWaveBuffer(waveData).rawAudio
25
+
26
+ logger.end()
27
+
28
+ return { rawAudio }
29
+ }
@@ -0,0 +1,104 @@
1
+ import { GaxiosResponse, request } from "gaxios"
2
+ import { SynthesisVoice, VoiceGender } from "../api/API.js"
3
+ import * as FFMpegTranscoder from "../codecs/FFMpegTranscoder.js"
4
+ import { Logger } from "../utilities/Logger.js"
5
+ import { logToStderr } from "../utilities/Utilities.js"
6
+
7
+ const log = logToStderr
8
+
9
+ export async function synthesize(text: string, voiceId: string, apiKey: string, modelId: string, stability = 0, similarityBoost = 0) {
10
+ const logger = new Logger()
11
+ logger.start("Request synthesis from ElevenLabs")
12
+
13
+ let response: GaxiosResponse<any>
14
+
15
+ try {
16
+ response = await request<any>({
17
+ url: `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
18
+
19
+ method: "POST",
20
+
21
+ headers: {
22
+ "accept": "audio/mpeg",
23
+ "xi-api-key": apiKey,
24
+ },
25
+
26
+ data: {
27
+ text,
28
+
29
+ model_id: modelId,
30
+
31
+ voice_setting: {
32
+ stability,
33
+ similarity_boost: similarityBoost
34
+ }
35
+ },
36
+
37
+ responseType: "arraybuffer"
38
+ })
39
+ } catch (e: any) {
40
+ const response = e.response
41
+
42
+ if (response) {
43
+ logger.log(`Request failed with status code ${response.status}`)
44
+
45
+ if (response.data) {
46
+ logger.log(`Server responded with:`)
47
+ logger.log(response.data)
48
+ }
49
+ }
50
+
51
+ throw e
52
+ }
53
+
54
+ logger.start("Decode synthesized audio")
55
+ const rawAudio = await FFMpegTranscoder.decodeToChannels(Buffer.from(response.data))
56
+
57
+ logger.end()
58
+
59
+ return { rawAudio }
60
+ }
61
+
62
+ export async function getVoiceList(apiKey: string) {
63
+ const response = await request<any>({
64
+ method: "GET",
65
+
66
+ url: "https://api.elevenlabs.io/v1/voices",
67
+
68
+ headers: {
69
+ "accept": "accept: application/json",
70
+ "xi-api-key": apiKey
71
+ },
72
+
73
+ responseType: "json"
74
+ })
75
+
76
+ const elevenLabsVoices: any[] = response.data.voices
77
+
78
+ const voices: SynthesisVoice[] = elevenLabsVoices.map(elevenLabsVoice => {
79
+ const accent: string | undefined = elevenLabsVoice?.labels?.accent
80
+ const gender: VoiceGender = elevenLabsVoice?.labels?.gender || "unknown"
81
+
82
+ let language: string
83
+
84
+ if (!accent || accent.startsWith("american")) {
85
+ language = 'en-US'
86
+ } else if (accent.startsWith("british") || accent == "irish") {
87
+ language = 'en-GB'
88
+ } else if (accent == "australian") {
89
+ language = 'en-AU'
90
+ } else {
91
+ language = 'en-US'
92
+ }
93
+
94
+ return {
95
+ name: elevenLabsVoice.name,
96
+ languages: [language, 'en'],
97
+ gender,
98
+
99
+ elevenLabsVoiceId: elevenLabsVoice.voice_id,
100
+ elevenLabsModelId: elevenLabsVoice?.high_quality_base_model_ids?.[0] || 'eleven_monolingual_v1'
101
+ }})
102
+
103
+ return voices
104
+ }
@@ -0,0 +1,552 @@
1
+ import { concatFloat32Arrays, logToStderr, objToString, simplifyPunctuationCharacters } from "../utilities/Utilities.js"
2
+ import { int16PcmToFloat32 } from "../audio/AudioBufferConversion.js"
3
+ import { Logger } from '../utilities/Logger.js'
4
+ import { WasmMemoryManager } from "../utilities/WasmMemoryManager.js"
5
+ import { RawAudio, getEmptyRawAudio } from "../audio/AudioUtilities.js"
6
+ import { playAudioWithTimelinePhones } from "../audio/AudioPlayer.js"
7
+ import { getNormalizedFragmentsForSpeech } from "../nlp/TextNormalizer.js"
8
+ import { ipaPhoneToKirshenbaum } from "../nlp/PhoneConversion.js"
9
+ import { splitToWords } from "../nlp/Segmentation.js"
10
+ import { Lexicon, tryGetFirstLexiconSubstitution } from "../nlp/Lexicon.js"
11
+ import { phonemizeSentence } from "../nlp/EspeakPhonemizer.js"
12
+ import { Timeline, TimelineEntry } from "../utilities/Timeline.js"
13
+
14
+ const log = logToStderr
15
+
16
+ let espeakInstance: any
17
+ let espeakModule: any
18
+
19
+ export async function preprocessAndSynthesize(text: string, language: string, espeakOptions: EspeakOptions, lexicons: Lexicon[] = []) {
20
+ const logger = new Logger()
21
+
22
+ await logger.startAsync("Tokenize and analyze text")
23
+
24
+ let lowerCaseLanguageCode = language.toLowerCase()
25
+
26
+ if (lowerCaseLanguageCode == "en-gb") {
27
+ lowerCaseLanguageCode = "en-gb-x-rp"
28
+ }
29
+
30
+ let fragments: string[]
31
+ let preprocessedFragments: string[]
32
+ const phonemizedFragmentsSubstitutions = new Map<number, string[]>()
33
+
34
+ fragments = []
35
+ preprocessedFragments = []
36
+
37
+ const words = (await splitToWords(text, language)).filter(word => word.trim() != "")
38
+
39
+ const { normalizedFragments, referenceFragments } = getNormalizedFragmentsForSpeech(words, language)
40
+
41
+ const simplifiedFragments = normalizedFragments.map(word => simplifyPunctuationCharacters(word).toLocaleLowerCase())
42
+
43
+ for (let fragmentIndex = 0; fragmentIndex < normalizedFragments.length; fragmentIndex++) {
44
+ const fragment = normalizedFragments[fragmentIndex]
45
+
46
+ const substitutionPhonemes = tryGetFirstLexiconSubstitution(simplifiedFragments, fragmentIndex, lexicons, lowerCaseLanguageCode)
47
+
48
+ if (!substitutionPhonemes) {
49
+ continue
50
+ }
51
+
52
+ phonemizedFragmentsSubstitutions.set(fragmentIndex, substitutionPhonemes)
53
+ const referenceIPA = (await textToPhonemes(fragment, espeakOptions.voice, true)).replaceAll("_", " ")
54
+ const referenceKirshenbaum = (await textToPhonemes(fragment, espeakOptions.voice, false)).replaceAll("_", "")
55
+
56
+ const kirshenbaumPhonemes = substitutionPhonemes.map(phone => ipaPhoneToKirshenbaum(phone)).join("")
57
+
58
+ logger.logTitledMessage(`\nLexicon substitution for '${fragment}'`, `IPA: ${substitutionPhonemes.join(" ")} (original: ${referenceIPA}), Kirshenbaum: ${kirshenbaumPhonemes} (reference: ${referenceKirshenbaum})`)
59
+
60
+ const substitutionPhonemesFragment = ` [[${kirshenbaumPhonemes}]] `
61
+
62
+ normalizedFragments[fragmentIndex] = substitutionPhonemesFragment
63
+ }
64
+
65
+ fragments = referenceFragments
66
+ preprocessedFragments = normalizedFragments
67
+
68
+ logger.start("Synthesize preprocessed fragments with eSpeak")
69
+
70
+ const { rawAudio: referenceSynthesizedAudio, timeline: referenceTimeline } = await synthesizeFragments(preprocessedFragments, espeakOptions)
71
+
72
+ await logger.startAsync("Build phonemized tokens")
73
+
74
+ const phonemizedSentence: string[][][] = []
75
+
76
+ let wordIndex = 0
77
+ for (const phraseEntry of referenceTimeline) {
78
+ const phrase: string[][] = []
79
+
80
+ for (const wordEntry of phraseEntry.timeline!) {
81
+ wordEntry.text = fragments[wordIndex]
82
+
83
+ if (phonemizedFragmentsSubstitutions.has(wordIndex)) {
84
+ phrase.push(phonemizedFragmentsSubstitutions.get(wordIndex)!)
85
+ } else {
86
+ for (const tokenEntry of wordEntry.timeline!) {
87
+ const tokenPhonemes: string[] = []
88
+
89
+ for (const phoneme of tokenEntry.timeline!) {
90
+ if (phoneme.text) {
91
+ tokenPhonemes.push(phoneme.text)
92
+ }
93
+ }
94
+
95
+ if (tokenPhonemes.length > 0) {
96
+ phrase.push(tokenPhonemes)
97
+ }
98
+ }
99
+ }
100
+
101
+ wordIndex += 1
102
+ }
103
+
104
+ if (phrase.length > 0) {
105
+ phonemizedSentence.push(phrase)
106
+ }
107
+ }
108
+
109
+ logger.log(phonemizedSentence.map(phrase => phrase.map(word => word.join(" ")).join(" | ")).join(" || "))
110
+
111
+ logger.end()
112
+
113
+ return { referenceSynthesizedAudio, referenceTimeline, fragments, preprocessedFragments, phonemizedFragmentsSubstitutions, phonemizedSentence }
114
+ }
115
+
116
+ export async function synthesizeFragments(fragments: string[], espeakOptions: EspeakOptions, insertSeparators = false) {
117
+ const logger = new Logger()
118
+
119
+ const sampleRate = await getSampleRate()
120
+
121
+ //fragments = fragments.filter(fragment => fragment.trim() != "")
122
+
123
+ if (fragments.length == 0) {
124
+ return {
125
+ rawAudio: getEmptyRawAudio(1, sampleRate),
126
+ timeline: [] as Timeline,
127
+ events: [] as EspeakEvent[]
128
+ }
129
+ }
130
+
131
+ let textWithMarkers = '() | '
132
+
133
+ for (let i = 0; i < fragments.length; i++) {
134
+ let fragment = fragments[i]
135
+
136
+ fragment = simplifyPunctuationCharacters(fragment)
137
+
138
+ fragment = fragment
139
+ .replaceAll("<", "&lt;")
140
+ .replaceAll(">", "&gt;")
141
+
142
+ if (insertSeparators) {
143
+ textWithMarkers += `<mark name="s-${i}"/> | ${fragment} | <mark name="e-${i}"/>`
144
+ } else {
145
+ if (fragment.endsWith(".")) {
146
+ fragment += " ()"
147
+ }
148
+
149
+ textWithMarkers += `<mark name="s-${i}"/>${fragment}<mark name="e-${i}"/> `
150
+ }
151
+ }
152
+
153
+ //log(textWithMarkers)
154
+
155
+ const { rawAudio, events } = await synthesize(textWithMarkers, { ...espeakOptions, ssml: true })
156
+
157
+ // Build word timeline from events
158
+ const wordTimeline: Timeline = fragments.map(word => ({
159
+ type: "word",
160
+ text: word,
161
+ startTime: -1,
162
+ endTime: -1,
163
+ timeline: [{
164
+ type: "token",
165
+ text: "",
166
+ startTime: -1,
167
+ endTime: -1,
168
+ timeline: []
169
+ }]
170
+ }))
171
+
172
+ let wordIndex = 0
173
+
174
+ const clauseEndIndexes: number[] = []
175
+
176
+ for (const event of events) {
177
+ const eventTime = event.audio_position / 1000
178
+
179
+ const currentWordEntry = wordTimeline[wordIndex]
180
+
181
+ const currentTokenTimeline = currentWordEntry.timeline!
182
+ const currentTokenEntry = currentTokenTimeline[currentTokenTimeline.length - 1]
183
+
184
+ const currentPhoneTimeline = currentTokenEntry.timeline!
185
+ const lastPhoneEntry = currentPhoneTimeline[currentPhoneTimeline.length - 1]
186
+
187
+ if (lastPhoneEntry && lastPhoneEntry.endTime == -1) {
188
+ lastPhoneEntry.endTime = eventTime
189
+ }
190
+
191
+ if (event.type == "word") {
192
+ if (!event.id || currentPhoneTimeline.length == 0) {
193
+ continue
194
+ }
195
+
196
+ if (currentTokenEntry.endTime == -1) {
197
+ currentTokenEntry.endTime = eventTime
198
+ }
199
+
200
+ currentTokenTimeline.push({
201
+ type: "token",
202
+ text: "",
203
+ startTime: eventTime,
204
+ endTime: -1,
205
+ timeline: []
206
+ })
207
+ } else if (event.type == "phoneme") {
208
+ const phoneText = event.id as string
209
+
210
+ if (!phoneText || phoneText.startsWith("(")) {
211
+ continue
212
+ }
213
+
214
+ currentPhoneTimeline.push({
215
+ type: "phone",
216
+ text: phoneText,
217
+ startTime: eventTime,
218
+ endTime: -1
219
+ })
220
+
221
+ currentTokenEntry.text += phoneText
222
+ currentTokenEntry.startTime = currentPhoneTimeline[0].startTime
223
+ } else if (event.type == "mark") {
224
+ const markerName = event.id! as string
225
+
226
+ if (markerName.startsWith("s-")) {
227
+ const markerIndex = parseInt(markerName.substring(2))
228
+
229
+ if (markerIndex != wordIndex) {
230
+ throw new Error(`Word start marker for index ${wordIndex} is not consistent with word index. The words were: ${objToString(fragments)}`)
231
+ }
232
+
233
+ if (currentPhoneTimeline.length > 0) {
234
+ throw new Error(`Word entry ${wordIndex} already has phones before its start marker was seen. The words were: ${objToString(fragments)}`)
235
+ }
236
+
237
+ currentWordEntry.startTime = eventTime
238
+ currentTokenEntry.startTime = eventTime
239
+ } else if (markerName.startsWith("e-")) {
240
+ const markerIndex = parseInt(markerName.substring(2))
241
+
242
+ if (markerIndex != wordIndex) {
243
+ throw new Error(`Word end marker for index ${wordIndex} is not consistent with word index. The words were: ${objToString(fragments)}`)
244
+ }
245
+
246
+ currentWordEntry.startTime = currentTokenTimeline[0].startTime
247
+
248
+ currentWordEntry.endTime = eventTime
249
+ currentTokenEntry.endTime = eventTime
250
+
251
+ wordIndex += 1
252
+
253
+ if (wordIndex == wordTimeline.length) {
254
+ break
255
+ }
256
+ } else {
257
+ continue
258
+ }
259
+ } else if (event.type == "end") {
260
+ clauseEndIndexes.push(wordIndex)
261
+ }
262
+ }
263
+
264
+ clauseEndIndexes.push(wordTimeline.length)
265
+
266
+ // Split compound tokens
267
+ for (const [index, wordEntry] of wordTimeline.entries()) {
268
+ const tokenTimeline = wordEntry.timeline
269
+
270
+ if (index == 0) {
271
+ continue
272
+ }
273
+
274
+ if (!tokenTimeline || tokenTimeline.length == 0) {
275
+ throw new Error("Unexpected: token timeline should exist and have at least one token")
276
+ }
277
+
278
+ if (tokenTimeline[0].text != '') {
279
+ continue
280
+ }
281
+
282
+ const wordReferencePhonemes = (await textToPhonemes(wordEntry.text, espeakOptions.voice, true)).split("_")
283
+
284
+ const wordReferenceIPA = wordReferencePhonemes.join(" ")
285
+
286
+ if (wordReferenceIPA.trim().length == 0) {
287
+ continue
288
+ }
289
+
290
+ const wordReferenceIPAWithoutStress = wordReferenceIPA.replaceAll("ˈ", "").replaceAll("ˌ", "")
291
+
292
+ const previousWordEntry = wordTimeline[index - 1]
293
+
294
+ if (!previousWordEntry.timeline) {
295
+ continue
296
+ }
297
+
298
+ const previousWordTokenEntry = previousWordEntry.timeline[0]
299
+
300
+ if (!previousWordTokenEntry.timeline || previousWordTokenEntry.timeline.length <= wordReferencePhonemes.length) {
301
+ continue
302
+ }
303
+
304
+ const previousWordTokenIPAWithoutStress = previousWordTokenEntry.timeline.map(phoneEntry => phoneEntry.text.replaceAll("ˈ", "").replaceAll("ˌ", "")).join(" ")
305
+
306
+ if (!previousWordTokenIPAWithoutStress.endsWith(wordReferenceIPAWithoutStress)) {
307
+ continue
308
+ }
309
+
310
+ const tokenEntry = tokenTimeline[0]
311
+
312
+ tokenEntry.timeline = previousWordTokenEntry.timeline.splice(previousWordTokenEntry.timeline.length - wordReferencePhonemes.length)
313
+ tokenEntry.text = tokenEntry.timeline.map(phoneEntry => phoneEntry.text).join("")
314
+
315
+ tokenEntry.startTime = tokenEntry.timeline[0].startTime
316
+ tokenEntry.endTime = tokenEntry.timeline[tokenEntry.timeline.length - 1].endTime
317
+ wordEntry.startTime = tokenEntry.startTime
318
+ wordEntry.endTime = tokenEntry.endTime
319
+
320
+ previousWordTokenEntry.text = previousWordTokenEntry.timeline.map(phoneEntry => phoneEntry.text).join("")
321
+ previousWordTokenEntry.endTime = previousWordTokenEntry.timeline[previousWordTokenEntry.timeline.length - 1].endTime
322
+ previousWordEntry.endTime = previousWordTokenEntry.endTime
323
+ }
324
+
325
+ // Build clause timeline
326
+ const clauseTimeline: Timeline = []
327
+
328
+ let clauseStartIndex = 0
329
+
330
+ for (const clauseEndIndex of clauseEndIndexes) {
331
+ const newClause: TimelineEntry = {
332
+ type: "clause",
333
+ text: "",
334
+ startTime: -1,
335
+ endTime: -1,
336
+ timeline: []
337
+ }
338
+
339
+ for (let entryIndex = clauseStartIndex; entryIndex <= clauseEndIndex && entryIndex < wordTimeline.length; entryIndex++) {
340
+ const wordEntry = wordTimeline[entryIndex]
341
+ if (newClause.startTime == -1) {
342
+ newClause.startTime = wordEntry.startTime
343
+ }
344
+
345
+ newClause.endTime = wordEntry.endTime
346
+
347
+ newClause.text += `${wordEntry.text} `
348
+
349
+ newClause.timeline!.push(wordEntry)
350
+ }
351
+
352
+ if (newClause.timeline!.length > 0) {
353
+ clauseTimeline.push(newClause)
354
+ clauseStartIndex = clauseEndIndex + 1
355
+ }
356
+ }
357
+
358
+ return { rawAudio, timeline: clauseTimeline, events }
359
+ }
360
+
361
+ export async function synthesize(text: string, espeakOptions: EspeakOptions) {
362
+ const logger = new Logger()
363
+ logger.start("Get espeak WASM instance")
364
+
365
+ if (!espeakOptions.ssml) {
366
+ const { escape } = await import('html-escaper')
367
+
368
+ text = escape(text)
369
+ }
370
+
371
+ const { instance } = await getEspeakInstance()
372
+
373
+ const sampleChunks: Float32Array[] = []
374
+ const allEvents: EspeakEvent[] = []
375
+
376
+ logger.start("Synthesize with eSpeak")
377
+
378
+ if (espeakOptions.useKlatt) {
379
+ await setVoice(`${espeakOptions.voice}+klatt6`)
380
+ } else {
381
+ await setVoice(espeakOptions.voice)
382
+ }
383
+
384
+ await setRate(espeakOptions.rate)
385
+ await setPitch(espeakOptions.pitch)
386
+ await setPitchRange(espeakOptions.pitchRange)
387
+
388
+ instance.synthesize(text, (samples: Int16Array, events: EspeakEvent[]) => {
389
+ if (samples && samples.length > 0) {
390
+ sampleChunks.push(int16PcmToFloat32(samples))
391
+ }
392
+
393
+ for (const event of events) {
394
+ if (event.type == "word") {
395
+ const textPosition = event.text_position - 1;
396
+ (event as any)["text"] = text.substring(textPosition, textPosition + event.word_length)
397
+ }
398
+ }
399
+
400
+ allEvents.push(...events)
401
+ })
402
+
403
+ const concatenatedSamples = concatFloat32Arrays(sampleChunks)
404
+
405
+ const rawAudio: RawAudio = { audioChannels: [concatenatedSamples], sampleRate: 22050 }
406
+
407
+ logger.end()
408
+
409
+ return { rawAudio, events: allEvents }
410
+ }
411
+
412
+ export async function textToIPA(text: string, voice: string) {
413
+ await setVoice(voice)
414
+ const { instance } = await getEspeakInstance()
415
+ const ipa: string = (instance.synthesize_ipa(text).ipa as string).trim()
416
+
417
+ return ipa
418
+ }
419
+
420
+ export async function textToPhonemes(text: string, voice: string, useIPA = true) {
421
+ await setVoice(voice)
422
+ const { instance, module } = await getEspeakInstance()
423
+ const textPtr = instance.convert_to_phonemes(text, useIPA)
424
+
425
+ const wasmMemory = new WasmMemoryManager(module)
426
+
427
+ const resultRef = wasmMemory.wrapNullTerminatedUtf8String(textPtr.ptr)
428
+ const result = resultRef.getValue()
429
+
430
+ wasmMemory.freeAll()
431
+
432
+ return result
433
+ }
434
+
435
+ export async function setVoice(voiceId: string) {
436
+ const { instance } = await getEspeakInstance()
437
+
438
+ instance.set_voice(voiceId)
439
+ }
440
+
441
+ export async function setVolume(volume: number) {
442
+ const { instance } = await getEspeakInstance()
443
+
444
+ return instance.setVolume(volume)
445
+ }
446
+
447
+ export async function setRate(rate: number) {
448
+ const { instance } = await getEspeakInstance()
449
+
450
+ return instance.set_rate(rate)
451
+ }
452
+
453
+ export async function setPitch(pitch: number) {
454
+ const { instance } = await getEspeakInstance()
455
+
456
+ return instance.set_pitch(pitch)
457
+ }
458
+
459
+ export async function setPitchRange(pitchRange: number) {
460
+ const { instance } = await getEspeakInstance()
461
+
462
+ return instance.set_range(pitchRange)
463
+ }
464
+
465
+ export async function getSampleRate(): Promise<22050> {
466
+ return 22050
467
+ }
468
+
469
+ export async function listVoices() {
470
+ const { instance } = await getEspeakInstance()
471
+
472
+ const voiceList: {
473
+ identifier: string,
474
+ name: string,
475
+ languages: {
476
+ priority: number,
477
+ name: string
478
+ }[]
479
+ }[] = instance.list_voices()
480
+
481
+ return voiceList
482
+ }
483
+
484
+ async function getEspeakInstance() {
485
+ if (!espeakInstance) {
486
+ const { default: EspeakInitializer } = await import('@echogarden/espeak-ng-emscripten')
487
+
488
+ const m = await EspeakInitializer()
489
+ espeakInstance = await (new m.eSpeakNGWorker())
490
+ espeakModule = m
491
+ }
492
+
493
+ return { instance: espeakInstance, module: espeakModule }
494
+ }
495
+
496
+ export type EspeakEventType = "sentence" | "word" | "phoneme" | "end" | "mark" | "play" | "msg_terminated" | "list_terminated" | "samplerate"
497
+
498
+ export interface EspeakEvent {
499
+ audio_position: number
500
+ type: EspeakEventType
501
+ text_position: number
502
+ word_length: number
503
+ id?: string | number
504
+ }
505
+ export interface EspeakOptions {
506
+ voice: string
507
+ ssml: boolean
508
+ rate: number
509
+ pitch: number
510
+ pitchRange: number
511
+ useKlatt: boolean
512
+ }
513
+
514
+ export const defaultEspeakOptions: EspeakOptions = {
515
+ voice: 'en-us',
516
+ ssml: false,
517
+ rate: 1.0,
518
+ pitch: 1.0,
519
+ pitchRange: 1.0,
520
+ useKlatt: false
521
+ }
522
+
523
+ export async function testEspeakSynthesisWithPrePhonemizedInputs(text: string) {
524
+ const ipaPhonemizedSentence = (await phonemizeSentence(text, "en-us")).flatMap(clause => clause)
525
+ const kirshenbaumPhonemizedSentence = (await phonemizeSentence(text, "en-us", undefined, false)).flatMap(clause => clause)
526
+ log(kirshenbaumPhonemizedSentence)
527
+
528
+ const fragments = ipaPhonemizedSentence.map(word =>
529
+ word.map(phoneme =>
530
+ ipaPhoneToKirshenbaum(phoneme)).join("")).map(word => ` [[${word}]] `)
531
+
532
+ const { rawAudio, timeline } = await synthesizeFragments(fragments, defaultEspeakOptions)
533
+
534
+ await playAudioWithTimelinePhones(rawAudio, timeline)
535
+ }
536
+
537
+ export async function testKirshenbaumPhonemization(text: string) {
538
+ const ipaPhonemizedSentence = (await phonemizeSentence(text, "en-us")).flatMap(clause => clause)
539
+ const kirshenbaumPhonemizedSentence = (await phonemizeSentence(text, "en-us", undefined, false)).flatMap(clause => clause)
540
+
541
+ const ipaFragments = ipaPhonemizedSentence.map(word => word.join(""))
542
+
543
+ const kirshenbaumFragments = kirshenbaumPhonemizedSentence.map(word => word.join(""))
544
+
545
+ const fragments = ipaPhonemizedSentence.map(word =>
546
+ word.map(phoneme =>
547
+ ipaPhoneToKirshenbaum(phoneme)).join(""))
548
+
549
+ for (let i = 0; i < fragments.length; i++) {
550
+ log(`IPA: ${ipaFragments[i]} | converted: ${fragments[i]} | ground truth: ${kirshenbaumFragments[i]}`)
551
+ }
552
+ }