echogarden 1.3.3 → 1.4.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,5 +1,3 @@
1
- import chalk from 'chalk'
2
- import { Logger } from '../utilities/Logger.js'
3
1
  import { logToStderr } from '../utilities/Utilities.js'
4
2
  import { AlignmentPath } from './SpeechAlignment.js'
5
3
 
@@ -11,7 +9,10 @@ export function alignDTWWindowed<T, U>(sequence1: T[], sequence2: U[], costFunct
11
9
  }
12
10
 
13
11
  if (sequence1.length == 0 || sequence2.length == 0) {
14
- return { path: [] as AlignmentPath, pathCost: 0 }
12
+ return {
13
+ path: [] as AlignmentPath,
14
+ pathCost: 0
15
+ }
15
16
  }
16
17
 
17
18
  // Compute accumulated cost matrix (transposed)
@@ -38,10 +39,10 @@ function computeAccumulatedCostMatrixTransposed<T, U>(sequence1: T[], sequence2:
38
39
 
39
40
  const accumulatedCostMatrixTransposed: Float32Array[] = new Array<Float32Array>(columnCount)
40
41
 
41
- // Initialize window start offsets array
42
+ // Initialize an array to store window start offsets
42
43
  const windowStartOffsets = new Int32Array(columnCount)
43
44
 
44
- // Compute matrix column by column
45
+ // Compute accumulated cost matrix column by column
45
46
  for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
46
47
  // Create new column and add it to the matrix
47
48
  const currentColumn = new Float32Array(rowCount)
@@ -65,13 +66,13 @@ function computeAccumulatedCostMatrixTransposed<T, U>(sequence1: T[], sequence2:
65
66
  windowStartOffset = windowEndOffset - rowCount
66
67
  }
67
68
 
68
- // Store the start offset in the array
69
+ // Store the start offset for this column
69
70
  windowStartOffsets[columnIndex] = windowStartOffset
70
71
 
71
72
  // Get target sequence1 value
72
73
  const targetSequence1Value = sequence1[columnIndex]
73
74
 
74
- // If first column, fill it only using the 'up' neighbor
75
+ // If this is the first column, fill it only using the 'up' neighbors
75
76
  if (columnIndex == 0) {
76
77
  for (let rowIndex = 1; rowIndex < rowCount; rowIndex++) {
77
78
  const cost = costFunction(targetSequence1Value, sequence2[windowStartOffset + rowIndex])
@@ -84,11 +85,15 @@ function computeAccumulatedCostMatrixTransposed<T, U>(sequence1: T[], sequence2:
84
85
  }
85
86
 
86
87
  // If not first column
88
+
89
+ // Store the column to the left
87
90
  const leftColumn = accumulatedCostMatrixTransposed[columnIndex - 1]
88
91
 
89
- // Compute the delta between the current window offset and previous column's window offset
92
+ // Compute the delta between the current window start offset
93
+ // and left column's window offset
90
94
  const windowOffsetDelta = windowStartOffset - windowStartOffsets[columnIndex - 1]
91
95
 
96
+ // Iterate over all rows in the window
92
97
  for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
93
98
  // Compute the cost for current cell
94
99
  const cost = costFunction(targetSequence1Value, sequence2[windowStartOffset + rowIndex])
@@ -115,12 +120,25 @@ function computeAccumulatedCostMatrixTransposed<T, U>(sequence1: T[], sequence2:
115
120
  upAndLeftCost = leftColumn[upAndLeftRowIndex]
116
121
  }
117
122
 
123
+ // Find the minimum of all neighbors
124
+ let minimumNeighborCost = minimumOf3(upCost, leftCost, upAndLeftCost)
125
+
126
+ // If all neighbors are infinity, then it means there is a "jump" between the window
127
+ // of the current column and the left column, and they don't have overlapping rows.
128
+ // In this case, only the cost of the current cell will be used
129
+ if (minimumNeighborCost === Infinity) {
130
+ minimumNeighborCost = 0
131
+ }
132
+
118
133
  // Write cost + minimum neighbor cost to the current column
119
- currentColumn[rowIndex] = cost + minimumOf3(upCost, leftCost, upAndLeftCost)
134
+ currentColumn[rowIndex] = cost + minimumNeighborCost
120
135
  }
121
136
  }
122
137
 
123
- return { accumulatedCostMatrixTransposed, windowStartOffsets }
138
+ return {
139
+ accumulatedCostMatrixTransposed,
140
+ windowStartOffsets
141
+ }
124
142
  }
125
143
 
126
144
  function computeBestPathTransposed(accumulatedCostMatrixTransposed: Float32Array[], windowStartOffsets: Int32Array) {
@@ -129,6 +147,8 @@ function computeBestPathTransposed(accumulatedCostMatrixTransposed: Float32Array
129
147
 
130
148
  const bestPath: AlignmentPath = []
131
149
 
150
+ // Start at the bottom right corner and find the best path
151
+ // towards the top left
132
152
  let columnIndex = columnCount - 1
133
153
  let rowIndex = rowCount - 1
134
154
 
@@ -136,19 +156,21 @@ function computeBestPathTransposed(accumulatedCostMatrixTransposed: Float32Array
136
156
  const windowStartIndex = windowStartOffsets[columnIndex]
137
157
  const windowStartDelta = columnIndex > 0 ? windowStartIndex - windowStartOffsets[columnIndex - 1] : 0
138
158
 
159
+ // Add the current cell to the best path
139
160
  bestPath.push({
140
161
  source: columnIndex,
141
162
  dest: windowStartIndex + rowIndex
142
163
  })
143
164
 
165
+ // Retrieve the cost for the 'up' (insertion) neighbor
144
166
  const upRowIndex = rowIndex - 1
145
- const upColumnIndex = columnIndex
146
167
  let upCost = Infinity
147
168
 
148
169
  if (upRowIndex >= 0) {
149
- upCost = accumulatedCostMatrixTransposed[upColumnIndex][upRowIndex] // insertion
170
+ upCost = accumulatedCostMatrixTransposed[columnIndex][upRowIndex] // insertion
150
171
  }
151
172
 
173
+ // Retrieve the cost for the 'left' (deletion) neighbor
152
174
  const leftRowIndex = rowIndex + windowStartDelta
153
175
  const leftColumnIndex = columnIndex - 1
154
176
  let leftCost = Infinity
@@ -157,6 +179,7 @@ function computeBestPathTransposed(accumulatedCostMatrixTransposed: Float32Array
157
179
  leftCost = accumulatedCostMatrixTransposed[leftColumnIndex][leftRowIndex] // deletion
158
180
  }
159
181
 
182
+ // Retrieve the cost for the 'up and left' (match) neighbor
160
183
  const upAndLeftRowIndex = rowIndex - 1 + windowStartDelta
161
184
  const upAndLeftColumnIndex = columnIndex - 1
162
185
  let upAndLeftCost = Infinity
@@ -165,25 +188,42 @@ function computeBestPathTransposed(accumulatedCostMatrixTransposed: Float32Array
165
188
  upAndLeftCost = accumulatedCostMatrixTransposed[upAndLeftColumnIndex][upAndLeftRowIndex] // match
166
189
  }
167
190
 
191
+ // If all neighbors have a cost of infinity, it means
192
+ // there is a "jump" between the window for the current and previous column
168
193
  if (upCost == Infinity && leftCost == Infinity && upAndLeftCost == Infinity) {
169
- const logger = new Logger()
170
-
171
- logger.setAsActiveLogger()
172
- logger.logTitledMessage(`computeBestPath`, `unexpected - all cost directions are equal to infinity (${columnIndex}, ${rowIndex}).`, chalk.yellowBright, 'warning')
173
- logger.unsetAsActiveLogger()
174
- }
175
-
176
- const smallestCostDirection = argIndexOfMinimumOf3(upCost, leftCost, upAndLeftCost)
177
-
178
- if (smallestCostDirection == 1) {
179
- rowIndex = upRowIndex
180
- columnIndex = upColumnIndex
181
- } else if (smallestCostDirection == 2) {
182
- rowIndex = leftRowIndex
183
- columnIndex = leftColumnIndex
194
+ // In that case:
195
+ //
196
+ // If there are rows above
197
+ if (upRowIndex >= 0) {
198
+ // Move upward
199
+ rowIndex = upRowIndex
200
+ } else if (leftColumnIndex >= 0) {
201
+ // Otherwise, move to the left
202
+ columnIndex = leftColumnIndex
203
+ } else {
204
+ // Since we know that either columnIndex > 0 or rowIndex > 0,
205
+ // one of these directions must be available.
206
+ // This error should never happen
207
+
208
+ throw new Error(`Unexpected state: columnIndex: ${columnIndex}, rowIndex: ${rowIndex}`)
209
+ }
184
210
  } else {
185
- rowIndex = upAndLeftRowIndex
186
- columnIndex = upAndLeftColumnIndex
211
+ // Choose the direction with the smallest cost
212
+ const smallestCostDirection = argIndexOfMinimumOf3(upCost, leftCost, upAndLeftCost)
213
+
214
+ if (smallestCostDirection == 1) {
215
+ // Move upward
216
+ rowIndex = upRowIndex
217
+ // The upper column index stays the same
218
+ } else if (smallestCostDirection == 2) {
219
+ // Move to the left
220
+ rowIndex = leftRowIndex
221
+ columnIndex = leftColumnIndex
222
+ } else {
223
+ // Move upward and to the left
224
+ rowIndex = upAndLeftRowIndex
225
+ columnIndex = upAndLeftColumnIndex
226
+ }
187
227
  }
188
228
  }
189
229
 
@@ -38,12 +38,12 @@ export async function alignUsingDtw(
38
38
  let relativeCenters: number[] | undefined
39
39
 
40
40
  for (let passIndex = 0; passIndex < windowDurations.length; passIndex++) {
41
+ const granularity = granularities[passIndex]
41
42
  const windowDuration = windowDurations[passIndex]
42
- const granularity = resolveAutoGranularityIfNeeded(granularities[passIndex], rawAudioDuration)
43
43
 
44
44
  logger.logTitledMessage(`\nStarting alignment pass ${passIndex + 1}/${windowDurations.length}`, `max window duration: ${windowDuration}s, granularity: ${granularity}`, chalk.magentaBright)
45
45
 
46
- const mfccOptions = extendDefaultMfccOptions({ ...getMfccOptionsForGranularity(granularity, rawAudioDuration), zeroFirstCoefficient: true }) as MfccOptions
46
+ const mfccOptions = extendDefaultMfccOptions({ ...getMfccOptionsForGranularity(granularity), zeroFirstCoefficient: true }) as MfccOptions
47
47
 
48
48
  framesPerSecond = 1 / mfccOptions.hopDuration!
49
49
 
@@ -67,7 +67,7 @@ export async function alignUsingDtw(
67
67
  }
68
68
  }
69
69
 
70
- logger.start('Align MFCC features using DTW')
70
+ logger.start('Align reference and source MFCC features using DTW')
71
71
  const dtwWindowLength = Math.floor(windowDuration * framesPerSecond)
72
72
 
73
73
  let centerIndexes: number[] | undefined
@@ -458,7 +458,7 @@ export async function createAlignmentReferenceUsingEspeakForFragments(fragments:
458
458
  progressLogger.start("Load espeak module")
459
459
  const Espeak = await import("../synthesis/EspeakTTS.js")
460
460
 
461
- progressLogger.start("Create alignment reference with eSpeak")
461
+ progressLogger.start("Synthesize alignment reference with eSpeak")
462
462
 
463
463
  const result = await Espeak.synthesizeFragments(fragments, espeakOptions)
464
464
 
@@ -476,7 +476,7 @@ export async function createAlignmentReferenceUsingEspeakForFragments(fragments:
476
476
  export async function createAlignmentReferenceUsingEspeak(transcript: string, language: string, plaintextOptions?: API.PlainTextOptions, customLexiconPaths?: string[], insertSeparators?: boolean) {
477
477
  const logger = new Logger()
478
478
 
479
- logger.start('Create alignment reference with eSpeak')
479
+ logger.start('Synthesize alignment reference with eSpeak')
480
480
 
481
481
  const synthesisOptions: API.SynthesisOptions = {
482
482
  engine: 'espeak',
@@ -544,25 +544,9 @@ function getMappedFrameIndexForPath(referenceFrameIndex: number, compactedPath:
544
544
  return mappedFrameIndex
545
545
  }
546
546
 
547
- function resolveAutoGranularityIfNeeded(granularity: DtwGranularity, audioDuration: number) {
548
- if (granularity != 'auto') {
549
- return granularity
550
- }
551
-
552
- if (audioDuration < 60) {
553
- return 'high'
554
- } else if (audioDuration < 60 * 10) {
555
- return 'medium'
556
- } else {
557
- return 'low'
558
- }
559
- }
560
-
561
- function getMfccOptionsForGranularity(granularity: DtwGranularity, audioDuration: number) {
547
+ function getMfccOptionsForGranularity(granularity: DtwGranularity) {
562
548
  let mfccOptions: MfccOptions
563
549
 
564
- granularity = resolveAutoGranularityIfNeeded(granularity, audioDuration)
565
-
566
550
  if (granularity == 'xx-low') {
567
551
  mfccOptions = { windowDuration: 0.400, hopDuration: 0.160, fftOrder: 8192 }
568
552
  } else if (granularity == 'x-low') {
@@ -595,4 +579,4 @@ export type CompactedPathEntry = {
595
579
  first: number, last: number
596
580
  }
597
581
 
598
- export type DtwGranularity = 'auto' | 'xx-low' | 'x-low' | 'low' | 'medium' | 'high' | 'x-high'
582
+ export type DtwGranularity = 'xx-low' | 'x-low' | 'low' | 'medium' | 'high' | 'x-high'
@@ -64,18 +64,6 @@ export async function align(input: AudioSourceParam, transcript: string, options
64
64
  sourceRawAudio = normalizeAudioLevel(sourceRawAudio)
65
65
  sourceRawAudio.audioChannels[0] = trimAudioEnd(sourceRawAudio.audioChannels[0])
66
66
 
67
- if (options.dtw!.windowDuration == null) {
68
- const sourceAudioDuration = getRawAudioDuration(sourceRawAudio)
69
-
70
- if (sourceAudioDuration < 5 * 60) { // If up to 5 minutes, set window to one minute
71
- options.dtw!.windowDuration = 60
72
- } else if (sourceAudioDuration < 60 * 60) { // If up to 1 hour, set window to 20% of total duration
73
- options.dtw!.windowDuration = Math.ceil(sourceAudioDuration * 0.2)
74
- } else { // If 1 hour or more, set window to 12 minutes
75
- options.dtw!.windowDuration = 12 * 60
76
- }
77
- }
78
-
79
67
  logger.end()
80
68
 
81
69
  let language: string
@@ -87,7 +75,7 @@ export async function align(input: AudioSourceParam, transcript: string, options
87
75
 
88
76
  logger.logTitledMessage('Language specified', formatLanguageCodeWithName(language))
89
77
  } else {
90
- logger.start('No language specified. Detecting language')
78
+ logger.start('No language specified. Detect language')
91
79
  const { detectedLanguage } = await API.detectTextLanguage(transcript, options.languageDetection || {})
92
80
 
93
81
  language = detectedLanguage
@@ -103,7 +91,9 @@ export async function align(input: AudioSourceParam, transcript: string, options
103
91
 
104
92
  const { alignUsingDtwWithRecognition, alignUsingDtw } = await import('../alignment/SpeechAlignment.js')
105
93
 
106
- function getDtwWindowDurationsAndGranularities() {
94
+ function getDtwWindowGranularitiesAndDurations() {
95
+ const sourceAudioDuration = getRawAudioDuration(sourceRawAudio)
96
+
107
97
  let granularities: DtwGranularity[]
108
98
  let windowDurations: number[]
109
99
 
@@ -112,25 +102,52 @@ export async function align(input: AudioSourceParam, transcript: string, options
112
102
  } else if (Array.isArray(options.dtw!.granularity)) {
113
103
  granularities = options.dtw!.granularity
114
104
  } else {
115
- granularities = ['auto']
105
+ if (sourceAudioDuration < 1 * 60) {
106
+ // If up to 1 minute, set granularity to high, single pass
107
+ granularities = ['high']
108
+ } else if (sourceAudioDuration < 5 * 60) {
109
+ // If up to 5 minutes, set granularity to medium, single pass
110
+ granularities = ['medium']
111
+ } else if (sourceAudioDuration < 30 * 60) {
112
+ // If up to 30 minutes, set granularity to low, single pass
113
+ granularities = ['low']
114
+ } else {
115
+ // Otherwisek, use multipass processing, first with xx-low granularity, then low
116
+ granularities = ['xx-low', 'low']
117
+ }
116
118
  }
117
119
 
118
- if (typeof options.dtw!.windowDuration == 'number') {
119
- if (granularities.length == 1) {
120
+ if (options.dtw!.windowDuration) {
121
+ if (typeof options.dtw!.windowDuration === 'number') {
120
122
  windowDurations = [options.dtw!.windowDuration]
121
- } else if (granularities.length == 2) {
122
- windowDurations = [options.dtw!.windowDuration, 15]
123
+ } else if (Array.isArray(options.dtw!.windowDuration)) {
124
+ windowDurations = options.dtw!.windowDuration
123
125
  } else {
124
- throw new Error(`More than two passes requested, this requires window durations to be explicitly specified for each pass. For example 'dtw.windowDuration=[600,60,10]'.`)
126
+ throw new Error(`'dtw.windowDuration' must be a number or an array of numbers.`)
125
127
  }
126
- } else if (Array.isArray(options.dtw!.windowDuration)) {
127
- windowDurations = options.dtw!.windowDuration
128
128
  } else {
129
- throw new Error('No window duration given')
129
+ if (granularities.length > 2) {
130
+ throw new Error(`More than two passes requested, this requires window durations to be explicitly specified for each pass. For example 'dtw.windowDuration=[600,60,10]'.`)
131
+ }
132
+
133
+ if (sourceAudioDuration < 5 * 60) {
134
+ // If up to 5 minutes, set window duration to one minute
135
+ windowDurations = [60]
136
+ } else if (sourceAudioDuration < 2.5 * 60 * 60) {
137
+ // If less than 2.5 hours, set window duration to 20% of total duration
138
+ windowDurations = [Math.ceil(sourceAudioDuration * 0.2)]
139
+ } else {
140
+ // Otherwise, set window duration to 30 minutes
141
+ windowDurations = [30 * 60]
142
+ }
143
+ }
144
+
145
+ if (granularities.length === 2 && windowDurations.length === 1) {
146
+ windowDurations = [windowDurations[0], 15]
130
147
  }
131
148
 
132
149
  if (granularities.length != windowDurations.length) {
133
- throw new Error(`Unequal element counts in options. 'dtw.granularity' has ${granularities.length} items, but 'dtw.windowDuration' has ${windowDurations.length} items. Can't infer what number of DTW passes were intended.`)
150
+ throw new Error(`The option 'dtw.granularity' has ${granularities.length} values, but 'dtw.windowDuration' has ${windowDurations.length} values. The lengths should be equal.`)
134
151
  }
135
152
 
136
153
  return { windowDurations, granularities }
@@ -140,6 +157,8 @@ export async function align(input: AudioSourceParam, transcript: string, options
140
157
 
141
158
  switch (options.engine) {
142
159
  case 'dtw': {
160
+ const { windowDurations, granularities } = getDtwWindowGranularitiesAndDurations()
161
+
143
162
  logger.end()
144
163
 
145
164
  const {
@@ -149,14 +168,14 @@ export async function align(input: AudioSourceParam, transcript: string, options
149
168
 
150
169
  logger.end()
151
170
 
152
- const { windowDurations, granularities } = getDtwWindowDurationsAndGranularities()
153
-
154
171
  mappedTimeline = await alignUsingDtw(sourceRawAudio, referenceRawAudio, referenceTimeline, granularities, windowDurations)
155
172
 
156
173
  break
157
174
  }
158
175
 
159
176
  case 'dtw-ra': {
177
+ const { windowDurations, granularities } = getDtwWindowGranularitiesAndDurations()
178
+
160
179
  logger.end()
161
180
 
162
181
  const recognitionOptions: API.RecognitionOptions =
@@ -181,8 +200,6 @@ export async function align(input: AudioSourceParam, transcript: string, options
181
200
 
182
201
  logger.end()
183
202
 
184
- const { windowDurations, granularities } = getDtwWindowDurationsAndGranularities()
185
-
186
203
  const phoneAlignmentMethod = options.dtw!.phoneAlignmentMethod!
187
204
 
188
205
  const espeakOptions: EspeakOptions = {
@@ -229,6 +246,8 @@ export async function align(input: AudioSourceParam, transcript: string, options
229
246
  }
230
247
  }
231
248
 
249
+ logger.start(`Postprocess timeline`)
250
+
232
251
  // If the audio was cropped before recognition, map the timestamps back to the original audio
233
252
  if (sourceUncropTimeline && sourceUncropTimeline.length > 0) {
234
253
  API.convertCroppedToUncroppedTimeline(mappedTimeline, sourceUncropTimeline)
@@ -354,7 +373,7 @@ export const defaultAlignmentOptions: AlignmentOptions = {
354
373
  },
355
374
 
356
375
  dtw: {
357
- granularity: 'auto',
376
+ granularity: undefined,
358
377
  windowDuration: undefined,
359
378
  phoneAlignmentMethod: 'dtw'
360
379
  },
@@ -257,7 +257,7 @@ export async function detectTextLanguage(input: string, options: TextLanguageDet
257
257
  case 'tinyld': {
258
258
  const { detectLanguage } = await import('../text-language-detection/TinyLDLanguageDetection.js')
259
259
 
260
- logger.start('Detecting text language using tinyld')
260
+ logger.start('Detect text language using tinyld')
261
261
 
262
262
  detectedLanguageProbabilities = await detectLanguage(input)
263
263
 
@@ -267,7 +267,7 @@ export async function detectTextLanguage(input: string, options: TextLanguageDet
267
267
  case 'fasttext': {
268
268
  const { detectLanguage } = await import('../text-language-detection/FastTextLanguageDetection.js')
269
269
 
270
- logger.start('Detecting text language using FastText')
270
+ logger.start('Detect text language using FastText')
271
271
 
272
272
  detectedLanguageProbabilities = await detectLanguage(input)
273
273
 
@@ -54,7 +54,7 @@ async function synthesizeSegments(segments: string[], options: SynthesisOptions,
54
54
  options = extendDeep(defaultSynthesisOptions, options)
55
55
 
56
56
  if (!options.language && !options.voice) {
57
- logger.start('No language or voice specified. Detecting language')
57
+ logger.start('No language or voice specified. Detect language')
58
58
 
59
59
  let segmentsPlainText = segments
60
60
 
@@ -44,7 +44,7 @@ export async function preprocessAndSynthesize(text: string, language: string, es
44
44
 
45
45
  for (let i = 0; i < words.length; i++) {
46
46
  const currentWord = words[i]
47
- const previousWord = words[i-1]
47
+ const previousWord = words[i - 1]
48
48
 
49
49
  if (i > 0 && currentWord == previousWord && !wordCharacterPattern.test(currentWord)) {
50
50
  wordsWithMerges[wordsWithMerges.length - 1] += currentWord
@@ -62,6 +62,12 @@ export async function preprocessAndSynthesize(text: string, language: string, es
62
62
 
63
63
  const simplifiedFragments = normalizedFragments.map(word => simplifyPunctuationCharacters(word).toLocaleLowerCase())
64
64
 
65
+ for (let i = 0; i < normalizedFragments.length; i++) {
66
+ if ([`'`, `"`].includes(simplifiedFragments[i])) {
67
+ normalizedFragments[i] = '()'
68
+ }
69
+ }
70
+
65
71
  for (let fragmentIndex = 0; fragmentIndex < normalizedFragments.length; fragmentIndex++) {
66
72
  const fragment = normalizedFragments[fragmentIndex]
67
73
 
@@ -110,3 +110,18 @@ export async function getDeflateCompressionMetricsForString(str: string) {
110
110
  ratio: originalStringBytes.length / compressedStringBytes.length
111
111
  }
112
112
  }
113
+
114
+ export function computeDeltas(data: Float32Array) {
115
+ const deltas = new Float32Array(data.length)
116
+
117
+ let val = 0
118
+
119
+ for (let i = 0; i < data.length; i++) {
120
+ const delta = Math.floor(data[i] - val)
121
+ deltas[i] = delta
122
+
123
+ val += delta
124
+ }
125
+
126
+ return deltas
127
+ }