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.
- package/data/schemas/options.json +16 -0
- package/dist/alignment/DTWMfccSequenceAlignment.js +2 -2
- package/dist/alignment/DTWMfccSequenceAlignment.js.map +1 -1
- package/dist/alignment/SpeechAlignment.d.ts +1 -1
- package/dist/alignment/SpeechAlignment.js +15 -3
- package/dist/alignment/SpeechAlignment.js.map +1 -1
- package/dist/api/Alignment.js +3 -3
- package/dist/api/Alignment.js.map +1 -1
- package/dist/api/LanguageDetection.js +1 -1
- package/dist/api/LanguageDetection.js.map +1 -1
- package/dist/api/Recognition.js +3 -3
- package/dist/api/Recognition.js.map +1 -1
- package/dist/api/Synthesis.js +7 -6
- package/dist/api/Synthesis.js.map +1 -1
- package/dist/api/Translation.js +3 -3
- package/dist/api/Translation.js.map +1 -1
- package/dist/audio/AudioUtilities.d.ts +5 -2
- package/dist/audio/AudioUtilities.js +50 -22
- 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/dsp/SpeexResampler.js +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 +12 -11
- 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,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("<", "<")
|
|
140
|
+
.replaceAll(">", ">")
|
|
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
|
+
}
|