echogarden 2.2.1 → 2.3.0

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.
@@ -1,4 +1,4 @@
1
- import { request } from 'gaxios'
1
+ import { GaxiosResponse, request } from 'gaxios'
2
2
  import { RawAudio } from '../audio/AudioUtilities.js'
3
3
  import { Logger } from '../utilities/Logger.js'
4
4
  import { extendDeep } from '../utilities/ObjectUtilities.js'
@@ -18,9 +18,9 @@ export async function recognize(rawAudio: RawAudio, languageCode: string | undef
18
18
 
19
19
  // Prepare API request
20
20
  const params: Record<string, string> = {
21
- model: options.model || 'whisper-large',
22
- encoding: 'flac',
23
- //punctuate: 'true', // Problem when enabled: words in timeline are not capitalized, causing errors
21
+ model: options.model!,
22
+ encoding: 'opus',
23
+ punctuate: options.punctuate ? 'true' : 'false',
24
24
  }
25
25
 
26
26
  // Set language or enable auto-detection
@@ -31,32 +31,49 @@ export async function recognize(rawAudio: RawAudio, languageCode: string | undef
31
31
  }
32
32
 
33
33
  // Set audio encoding parameters
34
- logger.start('Convert audio to FLAC format')
34
+ logger.start('Convert audio to Opus format')
35
35
 
36
36
  const audioData = await FFMpegTranscoder.encodeFromChannels(
37
37
  rawAudio,
38
- FFMpegTranscoder.getDefaultFFMpegOptionsForSpeech('flac')
38
+ FFMpegTranscoder.getDefaultFFMpegOptionsForSpeech('opus')
39
39
  )
40
40
 
41
41
  logger.start('Send request to Deepgram API')
42
42
 
43
- const response = await request<any>({
44
- method: 'POST',
43
+ let response: GaxiosResponse<any>
45
44
 
46
- url: 'https://api.deepgram.com/v1/listen',
45
+ try {
46
+ response = await request<any>({
47
+ method: 'POST',
47
48
 
48
- params,
49
+ url: 'https://api.deepgram.com/v1/listen',
49
50
 
50
- headers: {
51
- 'Authorization': `Token ${options.apiKey}`,
52
- 'Content-Type': 'audio/flac',
53
- 'Accept': 'application/json'
54
- },
51
+ params,
55
52
 
56
- body: audioData,
53
+ headers: {
54
+ 'Authorization': `Token ${options.apiKey}`,
55
+ 'Content-Type': 'audio/ogg',
56
+ 'Accept': 'application/json'
57
+ },
57
58
 
58
- responseType: 'json',
59
- })
59
+ body: audioData,
60
+
61
+ responseType: 'json',
62
+ })
63
+ } catch (e: any) {
64
+ const response = e.response
65
+
66
+ if (response) {
67
+ logger.log(`Request failed with status code ${response.status}`)
68
+
69
+ if (response.data) {
70
+ logger.log(`Server responded with:`)
71
+ logger.log(response.data)
72
+ }
73
+ }
74
+
75
+ throw e
76
+ }
60
77
 
61
78
  const deepgramResponse: DeepgramResponse = response.data
62
79
 
@@ -65,19 +82,37 @@ export async function recognize(rawAudio: RawAudio, languageCode: string | undef
65
82
  // Extract transcript and create timeline
66
83
  const transcript = firstAlternative?.transcript || ''
67
84
 
68
- let timeline: Timeline = []
69
-
70
85
  // Extract word-level timing information if available
71
86
  const words = firstAlternative?.words || []
72
87
 
73
- if (words.length > 0) {
74
- timeline = words.map((word: DeepgramWordEntry): TimelineEntry => ({
75
- type: 'word',
76
- text: word.word,
77
- startTime: word.start,
78
- endTime: word.end,
79
- confidence: word.confidence,
80
- }))
88
+ const timeline = words.map((wordEntry: DeepgramWordEntry) => ({
89
+ type: 'word',
90
+ text: wordEntry.word,
91
+ startTime: wordEntry.start,
92
+ endTime: wordEntry.end,
93
+ confidence: wordEntry.confidence,
94
+ } as TimelineEntry))
95
+
96
+ // If `punctuate` is set to `true`, modify the text of all words to match their exact case in the transcript.
97
+ // This is required, otherwise it would later fail deriving word offsets.
98
+ if (options.punctuate) {
99
+ const lowerCaseTranscript = transcript.toLocaleLowerCase()
100
+
101
+ let readOffset = 0
102
+
103
+ for (const wordEntry of timeline) {
104
+ const wordEntryTextLowercase = wordEntry.text.toLocaleLowerCase()
105
+
106
+ const matchPosition = lowerCaseTranscript.indexOf(wordEntryTextLowercase, readOffset)
107
+
108
+ if (matchPosition === -1) {
109
+ throw new Error(`Couldn't match the word '${wordEntry.text}' in the lowercase transcript`)
110
+ }
111
+
112
+ wordEntry.text = transcript.substring(matchPosition, matchPosition + wordEntryTextLowercase.length)
113
+
114
+ readOffset = matchPosition + wordEntry.text.length
115
+ }
81
116
  }
82
117
 
83
118
  logger.end()
@@ -88,11 +123,13 @@ export async function recognize(rawAudio: RawAudio, languageCode: string | undef
88
123
  export interface DeepgramSTTOptions {
89
124
  apiKey?: string
90
125
  model?: string
126
+ punctuate?: boolean
91
127
  }
92
128
 
93
129
  export const defaultDeepgramSTTOptions: DeepgramSTTOptions = {
94
130
  apiKey: undefined,
95
- model: 'nova-2'
131
+ model: 'nova-2',
132
+ punctuate: true,
96
133
  }
97
134
 
98
135
  interface DeepgramWordEntry {
@@ -0,0 +1,149 @@
1
+ import { GaxiosResponse, request } from 'gaxios'
2
+ import { SynthesisVoice } 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
+ import { extendDeep } from '../utilities/ObjectUtilities.js'
7
+
8
+ const log = logToStderr
9
+
10
+ export async function synthesize(text: string, modelId: string, options: DeepgramTTSOptions) {
11
+ const logger = new Logger()
12
+ logger.start('Request synthesis from Deepgram')
13
+
14
+ options = extendDeep(defaultDeepgramTTSOptions, options)
15
+
16
+ let response: GaxiosResponse<any>
17
+
18
+ try {
19
+ response = await request<any>({
20
+ url: `https://api.deepgram.com/v1/speak`,
21
+
22
+ params: {
23
+ model: modelId,
24
+ encoding: 'mp3',
25
+ bit_rate: 48000,
26
+ },
27
+
28
+ method: 'POST',
29
+
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'Authorization': `Token ${options.apiKey}`,
33
+ },
34
+
35
+ data: {
36
+ text,
37
+ },
38
+
39
+ responseType: 'arraybuffer'
40
+ })
41
+ } catch (e: any) {
42
+ const response = e.response
43
+
44
+ if (response) {
45
+ logger.log(`Request failed with status code ${response.status}`)
46
+
47
+ if (response.data) {
48
+ logger.log(`Server responded with:`)
49
+ logger.log(response.data)
50
+ }
51
+ }
52
+
53
+ throw e
54
+ }
55
+
56
+ logger.start('Decode synthesized audio')
57
+ const rawAudio = await FFMpegTranscoder.decodeToChannels(new Uint8Array(response.data))
58
+
59
+ logger.end()
60
+
61
+ return { rawAudio }
62
+ }
63
+
64
+ export async function getVoiceList() {
65
+ return voiceList
66
+ }
67
+
68
+ export interface DeepgramTTSOptions {
69
+ apiKey?: string
70
+ }
71
+
72
+ export const defaultDeepgramTTSOptions = {
73
+ apiKey: undefined,
74
+ }
75
+
76
+ export const voiceList: SynthesisVoice[] = [
77
+ {
78
+ name: 'Asteria',
79
+ deepgramModelId: 'aura-asteria-en',
80
+ languages: ['en-US', 'en'],
81
+ gender: 'female',
82
+ },
83
+ {
84
+ name: 'Luna',
85
+ deepgramModelId: 'aura-luna-en',
86
+ languages: ['en-US', 'en'],
87
+ gender: 'female',
88
+ },
89
+ {
90
+ name: 'Stella',
91
+ deepgramModelId: 'aura-stella-en',
92
+ languages: ['en-US', 'en'],
93
+ gender: 'female',
94
+ },
95
+ {
96
+ name: 'Athena',
97
+ deepgramModelId: 'aura-athena-en',
98
+ languages: ['en-GB', 'en'],
99
+ gender: 'female',
100
+ },
101
+ {
102
+ name: 'Hera',
103
+ deepgramModelId: 'aura-hera-en',
104
+ languages: ['en-US', 'en'],
105
+ gender: 'female',
106
+ },
107
+ {
108
+ name: 'Orion',
109
+ deepgramModelId: 'aura-orion-en',
110
+ languages: ['en-US', 'en'],
111
+ gender: 'male',
112
+ },
113
+ {
114
+ name: 'Arcas',
115
+ deepgramModelId: 'aura-arcas-en',
116
+ languages: ['en-US', 'en'],
117
+ gender: 'male',
118
+ },
119
+ {
120
+ name: 'Perseus',
121
+ deepgramModelId: 'aura-perseus-en',
122
+ languages: ['en-US', 'en'],
123
+ gender: 'male',
124
+ },
125
+ {
126
+ name: 'Angus',
127
+ deepgramModelId: 'aura-angus-en',
128
+ languages: ['en-US', 'en'],
129
+ gender: 'male',
130
+ },
131
+ {
132
+ name: 'Orpheus',
133
+ deepgramModelId: 'aura-orpheus-en',
134
+ languages: ['en-US', 'en'],
135
+ gender: 'male',
136
+ },
137
+ {
138
+ name: 'Helios',
139
+ deepgramModelId: 'aura-helios-en',
140
+ languages: ['en-US', 'en'],
141
+ gender: 'male',
142
+ },
143
+ {
144
+ name: 'Zeus',
145
+ deepgramModelId: 'aura-zeus-en',
146
+ languages: ['en-US', 'en'],
147
+ gender: 'male',
148
+ },
149
+ ]
@@ -4,10 +4,13 @@ import * as FFMpegTranscoder from '../codecs/FFMpegTranscoder.js'
4
4
  import { Logger } from '../utilities/Logger.js'
5
5
  import { logToStderr } from '../utilities/Utilities.js'
6
6
  import { extendDeep } from '../utilities/ObjectUtilities.js'
7
+ import { decodeBase64 } from '../encodings/Base64.js'
8
+ import { isWordOrSymbolWord, splitToWords } from '../nlp/Segmentation.js'
9
+ import { Timeline } from '../utilities/Timeline.js'
7
10
 
8
11
  const log = logToStderr
9
12
 
10
- export async function synthesize(text: string, voiceId: string, modelId: string, options: ElevenLabsTTSOptions) {
13
+ export async function synthesize(text: string, voiceId: string, language: string, options: ElevenLabsTTSOptions) {
11
14
  const logger = new Logger()
12
15
  logger.start('Request synthesis from ElevenLabs')
13
16
 
@@ -17,7 +20,7 @@ export async function synthesize(text: string, voiceId: string, modelId: string,
17
20
 
18
21
  try {
19
22
  response = await request<any>({
20
- url: `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
23
+ url: `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/with-timestamps`,
21
24
 
22
25
  method: 'POST',
23
26
 
@@ -26,20 +29,26 @@ export async function synthesize(text: string, voiceId: string, modelId: string,
26
29
  'xi-api-key': options.apiKey,
27
30
  },
28
31
 
32
+ params: {
33
+ output_format: 'mp3_44100_64',
34
+ },
35
+
29
36
  data: {
30
37
  text,
31
38
 
32
- model_id: modelId,
39
+ model_id: options.modelId,
33
40
 
34
41
  voice_setting: {
35
42
  stability: options.stability,
36
43
  similarity_boost: options.similarityBoost,
37
44
  style: options.style,
38
- use_speaker_boost: options.useSpeakerBoost
39
- }
45
+ use_speaker_boost: options.useSpeakerBoost,
46
+ },
47
+
48
+ seed: options.seed
40
49
  },
41
50
 
42
- responseType: 'arraybuffer'
51
+ responseType: 'json'
43
52
  })
44
53
  } catch (e: any) {
45
54
  const response = e.response
@@ -57,11 +66,43 @@ export async function synthesize(text: string, voiceId: string, modelId: string,
57
66
  }
58
67
 
59
68
  logger.start('Decode synthesized audio')
60
- const rawAudio = await FFMpegTranscoder.decodeToChannels(new Uint8Array(response.data))
69
+ const audioData = decodeBase64(response.data.audio_base64)
70
+ const rawAudio = await FFMpegTranscoder.decodeToChannels(audioData)
71
+
72
+ let timeline: Timeline | undefined
73
+
74
+ const characters: string[] = response.data.alignment?.characters
75
+ const characterStartTimes: number[] = response.data.alignment?.character_start_times_seconds
76
+ const characterEndTimes: number[] = response.data.alignment?.character_end_times_seconds
77
+
78
+ if (characters && characterStartTimes && characterEndTimes) {
79
+ logger.start('Create timeline from returned character timings')
80
+
81
+ const referenceText = characters.join('')
82
+ const words = (await splitToWords(referenceText, language)).filter(w => isWordOrSymbolWord(w))
83
+
84
+ timeline = []
85
+
86
+ let offset = 0
87
+
88
+ for (const word of words) {
89
+ const wordStartIndex = referenceText.indexOf(word, offset)
90
+ const wordEndIndex = wordStartIndex + word.length
91
+
92
+ timeline.push({
93
+ type: 'word',
94
+ text: word,
95
+ startTime: characterStartTimes[wordStartIndex],
96
+ endTime: characterEndTimes[wordEndIndex] ?? characterEndTimes[wordEndIndex - 1]
97
+ })
98
+
99
+ offset = wordEndIndex
100
+ }
101
+ }
61
102
 
62
103
  logger.end()
63
104
 
64
- return { rawAudio }
105
+ return { rawAudio, timeline }
65
106
  }
66
107
 
67
108
  export async function getVoiceList(apiKey: string) {
@@ -78,40 +119,36 @@ export async function getVoiceList(apiKey: string) {
78
119
  responseType: 'json'
79
120
  })
80
121
 
81
- const elevenlabsVoices: any[] = response.data.voices
122
+ const elevenLabsVoices: any[] = response.data.voices
82
123
 
83
- const voices: SynthesisVoice[] = elevenlabsVoices.map(elevenlabsVoice => {
84
- const modelId: string = elevenlabsVoice?.high_quality_base_model_ids?.[0] ?? 'eleven_monolingual_v1'
85
- const accent: string | undefined = elevenlabsVoice?.labels?.accent
86
- const gender: VoiceGender = elevenlabsVoice?.labels?.gender ?? 'unknown'
124
+ const voices: SynthesisVoice[] = elevenLabsVoices.map(elevenLabsVoice => {
125
+ const gender: VoiceGender = elevenLabsVoice?.labels?.gender ?? 'unknown'
87
126
 
88
127
  const supportedLanguages: string[] = []
89
128
 
90
- if (accent) {
91
- if (accent.startsWith('american')) {
92
- supportedLanguages.push('en-US')
93
- } else if (accent.startsWith('british')) {
94
- supportedLanguages.push('en-GB')
95
- } else if (accent === 'irish') {
96
- supportedLanguages.push('en-IE')
97
- } else if (accent == 'australian') {
98
- supportedLanguages.push('en-AU')
99
- }
100
- }
101
-
102
- if (modelId.includes('multilingual')) {
103
- supportedLanguages.push('en', ...supporteMultilingualLanguages)
129
+ let accent: string | undefined = elevenLabsVoice?.labels?.accent
130
+ accent = accent?.toLowerCase() ?? ''
131
+
132
+ if (accent.startsWith('american')) {
133
+ supportedLanguages.push('en-US')
134
+ } else if (accent.startsWith('british')) {
135
+ supportedLanguages.push('en-GB')
136
+ } else if (accent === 'irish') {
137
+ supportedLanguages.push('en-IE')
138
+ } else if (accent == 'australian') {
139
+ supportedLanguages.push('en-AU')
104
140
  } else {
105
141
  supportedLanguages.push('en')
106
142
  }
107
143
 
144
+ supportedLanguages.push(...supportedLanguagesInMultilingualModels)
145
+
108
146
  return {
109
- name: elevenlabsVoice.name,
147
+ name: elevenLabsVoice.name,
110
148
  languages: supportedLanguages,
111
149
  gender,
112
150
 
113
- elevenLabsVoiceId: elevenlabsVoice.voice_id,
114
- elevenLabsModelId: modelId
151
+ elevenLabsVoiceId: elevenLabsVoice.voice_id,
115
152
  }
116
153
  })
117
154
 
@@ -120,18 +157,58 @@ export async function getVoiceList(apiKey: string) {
120
157
 
121
158
  export interface ElevenLabsTTSOptions {
122
159
  apiKey?: string
160
+ modelId?: string
161
+
123
162
  stability?: number
124
163
  similarityBoost?: number
125
164
  style?: number
126
165
  useSpeakerBoost?: boolean
166
+
167
+ seed?: number
127
168
  }
128
169
 
129
170
  export const defaultElevenLabsTTSOptions = {
130
171
  apiKey: undefined,
172
+ modelId: 'eleven_multilingual_v2',
173
+
131
174
  stability: 0.5,
132
175
  similarityBoost: 0.5,
133
176
  style: 0,
134
- useSpeakerBoost: true
177
+ useSpeakerBoost: true,
178
+
179
+ seed: undefined,
135
180
  }
136
181
 
137
- export const supporteMultilingualLanguages = ['zh', 'ko', 'nl', 'tr', 'sv', 'id', 'tl', 'ja', 'uk', 'el', 'cs', 'fi', 'ro', 'ru', 'da', 'bg', 'ms', 'sk', 'hr', 'ar', 'ta', 'pl', 'de', 'es', 'fr', 'it', 'hi', 'pt']
182
+ export const supportedLanguagesInMultilingualModels = [
183
+ 'ja',
184
+ 'zh',
185
+ 'de',
186
+ 'hi',
187
+ 'fr',
188
+ 'ko',
189
+ 'pt',
190
+ 'it',
191
+ 'es',
192
+ 'id',
193
+ 'nl',
194
+ 'tr',
195
+ 'fil',
196
+ 'pl',
197
+ 'sv',
198
+ 'bg',
199
+ 'ro',
200
+ 'ar',
201
+ 'cs',
202
+ 'el',
203
+ 'fi',
204
+ 'hr',
205
+ 'ms',
206
+ 'sk',
207
+ 'da',
208
+ 'ta',
209
+ 'uk',
210
+ 'ru',
211
+ 'hu',
212
+ 'no',
213
+ 'vi',
214
+ ]
@@ -30,7 +30,7 @@ export function deepClone<T>(val: T) {
30
30
  }
31
31
 
32
32
  function clone<T>(val: T, deep = true, seenObjects: any[] = []): T {
33
- if (val == null || typeof val !== 'object') {
33
+ if (val === undefined || val === null || typeof val !== 'object') {
34
34
  return val
35
35
  }
36
36
 
@@ -57,71 +57,88 @@ function clone<T>(val: T, deep = true, seenObjects: any[] = []): T {
57
57
 
58
58
  seenObjects.pop()
59
59
 
60
- return <any>clonedArray
60
+ return clonedArray as any
61
61
  }
62
62
 
63
63
  case '[object ArrayBuffer]': {
64
64
  const clonedArray = new Uint8Array(obj.byteLength)
65
65
  clonedArray.set(new Uint8Array(obj))
66
- return <any>clonedArray.buffer
66
+
67
+ return clonedArray.buffer as any
67
68
  }
68
69
 
69
70
  case '[object Int8Array]': {
70
71
  const clonedArray = new Int8Array(obj.length)
71
72
  clonedArray.set(obj)
72
- return <any>clonedArray
73
+
74
+ return clonedArray as any
73
75
  }
74
76
 
75
77
  case '[object Uint8Array]': {
76
78
  const clonedArray = new Uint8Array(obj.length)
77
79
  clonedArray.set(obj)
78
- return <any>clonedArray
80
+
81
+ return clonedArray as any
79
82
  }
80
83
 
81
84
  case '[object Uint8ClampedArray]': {
82
85
  const clonedArray = new Uint8ClampedArray(obj.length)
83
86
  clonedArray.set(obj)
84
- return <any>clonedArray
87
+
88
+ return clonedArray as any
85
89
  }
86
90
 
87
91
  case '[object Int16Array]': {
88
92
  const clonedArray = new Int16Array(obj.length)
89
93
  clonedArray.set(obj)
90
- return <any>clonedArray
94
+
95
+ return clonedArray as any
91
96
  }
92
97
 
93
98
  case '[object Uint16Array]': {
94
99
  const clonedArray = new Uint16Array(obj.length)
95
100
  clonedArray.set(obj)
96
- return <any>clonedArray
101
+
102
+ return clonedArray as any
97
103
  }
98
104
 
99
105
  case '[object Int32Array]': {
100
106
  const clonedArray = new Int32Array(obj.length)
101
107
  clonedArray.set(obj)
102
- return <any>clonedArray
108
+
109
+ return clonedArray as any
103
110
  }
104
111
 
105
112
  case '[object Uint32Array]': {
106
113
  const clonedArray = new Uint32Array(obj.length)
107
114
  clonedArray.set(obj)
108
- return <any>clonedArray
115
+
116
+ return clonedArray as any
109
117
  }
110
118
 
111
119
  case '[object Float32Array]': {
112
120
  const clonedArray = new Float32Array(obj.length)
113
121
  clonedArray.set(obj)
114
- return <any>clonedArray
122
+
123
+ return clonedArray as any
115
124
  }
116
125
 
117
126
  case '[object Float64Array]': {
118
127
  const clonedArray = new Float64Array(obj.length)
119
128
  clonedArray.set(obj)
120
- return <any>clonedArray
129
+
130
+ return clonedArray as any
131
+ }
132
+
133
+ case '[object BigInt64Array]': {
134
+ const clonedArray = new BigInt64Array(obj.length)
135
+ clonedArray.set(obj)
136
+
137
+ return clonedArray as any
121
138
  }
122
139
 
123
140
  case '[object Date]': {
124
- return <any>new Date(obj.valueOf())
141
+ return new Date(obj.valueOf()) as any
125
142
  }
126
143
 
127
144
  case '[object RegExp]': {