omnius 1.0.639 → 1.0.641
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/dist/index.js +1512 -887
- package/dist/scripts/audio-speaker-embedding-worker.py +137 -31
- package/dist/scripts/live-whisper.py +41 -0
- package/dist/scripts/ocr-advanced.py +401 -139
- package/dist/scripts/transcribe-file.py +61 -6
- package/dist/update-worker.js +0 -1
- package/docs/DISCOVERY.json +33 -6
- package/docs/rest/endpoints/chat.md +6 -1
- package/docs/rest/endpoints/voice-vision.md +34 -23
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -6,9 +6,11 @@ WAV paths. It never records audio, installs dependencies, downloads models,
|
|
|
6
6
|
or enables CUDA. The parent runtime checksums the pinned ONNX artifact before
|
|
7
7
|
this worker can start.
|
|
8
8
|
|
|
9
|
-
The fbank path is
|
|
10
|
-
|
|
11
|
-
dither=0 and utterance CMN (without CVN).
|
|
9
|
+
The fbank path is a pinned, CPU-only NumPy implementation of the Kaldi
|
|
10
|
+
configuration used by WeSpeaker's ONNX example: 80 bins, 25 ms / 10 ms Hamming
|
|
11
|
+
frames, dither=0 and utterance CMN (without CVN). It intentionally imports
|
|
12
|
+
neither Torch nor Torchaudio, so an upstream Torchaudio binary can never bind
|
|
13
|
+
against or replace JetPack's vendor Torch ABI.
|
|
12
14
|
"""
|
|
13
15
|
from __future__ import annotations
|
|
14
16
|
|
|
@@ -22,8 +24,8 @@ import traceback
|
|
|
22
24
|
import wave
|
|
23
25
|
|
|
24
26
|
|
|
25
|
-
# Set before importing
|
|
26
|
-
#
|
|
27
|
+
# Set before importing ONNX Runtime so a globally configured Jetson CUDA device
|
|
28
|
+
# cannot become an accidental provider for speaker embeddings.
|
|
27
29
|
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
|
28
30
|
|
|
29
31
|
MODEL_NAME = "wespeaker-voxceleb-campplus"
|
|
@@ -34,6 +36,12 @@ MAX_DURATION_SECONDS = 30.0
|
|
|
34
36
|
MIN_RMS = 0.003
|
|
35
37
|
MIN_PEAK = 0.01
|
|
36
38
|
MAX_CLIPPED_FRACTION = 0.01
|
|
39
|
+
FBANK_IMPLEMENTATION = "kaldi-fbank-numpy-v1"
|
|
40
|
+
# A CPU FFT may differ in harmless last bits across x86/aarch64 and NumPy
|
|
41
|
+
# builds. Validate the public Kaldi configuration with numeric invariants,
|
|
42
|
+
# not a byte hash of implementation-dependent FFT output.
|
|
43
|
+
FBANK_VALIDATION = "kaldi-fbank-invariants-v2"
|
|
44
|
+
CALIBRATION_BAND_MEANS = (11.1405, 17.5231, 12.8845, 9.2557, 7.7213, 6.7842, 6.6405)
|
|
37
45
|
|
|
38
46
|
|
|
39
47
|
def emit(payload: dict) -> None:
|
|
@@ -98,18 +106,114 @@ def quality_gate(samples, metrics):
|
|
|
98
106
|
return "usable"
|
|
99
107
|
|
|
100
108
|
|
|
109
|
+
def kaldi_fbank80_numpy(waveform, np):
|
|
110
|
+
"""Kaldi-compatible fbank for the fixed WeSpeaker CAM++ ONNX contract.
|
|
111
|
+
|
|
112
|
+
This mirrors WeSpeaker's published Kaldi fbank configuration: mono 16k
|
|
113
|
+
input, snip_edges=True,
|
|
114
|
+
remove_dc_offset=True, preemphasis=0.97, round_to_power_of_two=True,
|
|
115
|
+
80 triangular 20Hz..Nyquist mel bins, Hamming window and log power. The
|
|
116
|
+
caller passes PCM-scaled float32 samples (not normalized [-1, 1] values).
|
|
117
|
+
"""
|
|
118
|
+
dtype = np.float32
|
|
119
|
+
waveform = np.ascontiguousarray(waveform, dtype=dtype)
|
|
120
|
+
frame_size = 400
|
|
121
|
+
frame_shift = 160
|
|
122
|
+
fft_size = 512
|
|
123
|
+
if waveform.ndim != 1 or waveform.size < frame_size:
|
|
124
|
+
raise RuntimeError("Kaldi fbank requires at least one 25 ms mono frame")
|
|
125
|
+
frame_count = 1 + (waveform.size - frame_size) // frame_shift
|
|
126
|
+
frames = np.lib.stride_tricks.as_strided(
|
|
127
|
+
waveform,
|
|
128
|
+
shape=(frame_count, frame_size),
|
|
129
|
+
strides=(frame_shift * waveform.strides[0], waveform.strides[0]),
|
|
130
|
+
writeable=False,
|
|
131
|
+
).copy()
|
|
132
|
+
# Published Kaldi fbank frame/window defaults.
|
|
133
|
+
frames -= np.mean(frames, axis=1, dtype=dtype, keepdims=True)
|
|
134
|
+
frames[:, 1:] -= dtype(0.97) * frames[:, :-1]
|
|
135
|
+
frames[:, 0] -= dtype(0.97) * frames[:, 0]
|
|
136
|
+
sample_index = np.arange(frame_size, dtype=dtype)
|
|
137
|
+
hamming = dtype(0.54) - dtype(0.46) * np.cos(
|
|
138
|
+
dtype(2.0 * np.pi) * sample_index / dtype(frame_size - 1)
|
|
139
|
+
)
|
|
140
|
+
frames *= hamming
|
|
141
|
+
padded = np.pad(frames, ((0, 0), (0, fft_size - frame_size)), mode="constant")
|
|
142
|
+
# NumPy's rfft may calculate internally at float64 precision. Cast its
|
|
143
|
+
# power spectrum back to float32 before the Kaldi-style matrix multiply.
|
|
144
|
+
spectrum = np.asarray(np.abs(np.fft.rfft(padded, axis=1)) ** 2, dtype=dtype)
|
|
145
|
+
|
|
146
|
+
nyquist = dtype(8000.0)
|
|
147
|
+
low_hz = dtype(20.0)
|
|
148
|
+
mel = lambda hz: dtype(1127.0) * np.log(dtype(1.0) + hz / dtype(700.0))
|
|
149
|
+
mel_low = mel(low_hz)
|
|
150
|
+
mel_high = mel(nyquist)
|
|
151
|
+
mel_step = (mel_high - mel_low) / dtype(81.0)
|
|
152
|
+
bin_index = np.arange(80, dtype=dtype)[:, None]
|
|
153
|
+
left = mel_low + bin_index * mel_step
|
|
154
|
+
center = mel_low + (bin_index + dtype(1.0)) * mel_step
|
|
155
|
+
right = mel_low + (bin_index + dtype(2.0)) * mel_step
|
|
156
|
+
fft_bin_hz = dtype(16000.0 / fft_size) * np.arange(fft_size // 2, dtype=dtype)[None, :]
|
|
157
|
+
fft_bin_mel = mel(fft_bin_hz)
|
|
158
|
+
up = (fft_bin_mel - left) / (center - left)
|
|
159
|
+
down = (right - fft_bin_mel) / (right - center)
|
|
160
|
+
banks = np.maximum(dtype(0.0), np.minimum(up, down))
|
|
161
|
+
banks = np.pad(banks, ((0, 0), (0, 1)), mode="constant")
|
|
162
|
+
energies = np.matmul(spectrum, np.asarray(banks.T, dtype=dtype))
|
|
163
|
+
return np.log(np.maximum(energies, np.finfo(dtype).eps)).astype(dtype, copy=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def validate_kaldi_fbank_numpy(np):
|
|
167
|
+
"""Validate the no-Torch preprocessing path before readiness is true.
|
|
168
|
+
|
|
169
|
+
The calibration intentionally asserts shape, finite/CMN invariants, the
|
|
170
|
+
expected 220/440 Hz Kaldi mel-band profile and energy range with tolerances
|
|
171
|
+
that are stable across supported CPU FFT implementations. It must reject a
|
|
172
|
+
changed window, frame layout, PCM scale, mel bank, pre-emphasis or CMN, but
|
|
173
|
+
must not reject Jetson due to last-bit numerical differences.
|
|
174
|
+
"""
|
|
175
|
+
t = np.arange(SAMPLE_RATE * 2, dtype=np.float32) / np.float32(SAMPLE_RATE)
|
|
176
|
+
samples = (
|
|
177
|
+
np.float32(0.075) * np.sin(np.float32(2.0 * np.pi * 220.0) * t)
|
|
178
|
+
+ np.float32(0.025) * np.sin(np.float32(2.0 * np.pi * 440.0) * t)
|
|
179
|
+
).astype(np.float32)
|
|
180
|
+
raw_features = kaldi_fbank80_numpy(samples * np.float32(1 << 15), np)
|
|
181
|
+
features = (
|
|
182
|
+
raw_features - np.mean(raw_features, axis=0, dtype=np.float32, keepdims=True)
|
|
183
|
+
).astype(np.float32)
|
|
184
|
+
if features.shape != (198, 80) or not np.all(np.isfinite(features)):
|
|
185
|
+
raise RuntimeError("NumPy Kaldi fbank calibration produced an invalid feature matrix")
|
|
186
|
+
cmn_abs_mean_max = float(np.max(np.abs(np.mean(features, axis=0))))
|
|
187
|
+
if cmn_abs_mean_max > 1e-3:
|
|
188
|
+
raise RuntimeError("NumPy Kaldi fbank calibration failed utterance CMN")
|
|
189
|
+
raw_min = float(np.min(raw_features))
|
|
190
|
+
raw_max = float(np.max(raw_features))
|
|
191
|
+
cmn_rms = float(np.sqrt(np.mean(np.square(features))))
|
|
192
|
+
band_means = raw_features.mean(axis=0)[[0, 5, 10, 20, 40, 60, 79]]
|
|
193
|
+
if not (4.5 < raw_min < 6.0 and 19.0 < raw_max < 21.5 and 0.55 < cmn_rms < 0.75):
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
"NumPy Kaldi fbank calibration energy/range invariant failed"
|
|
196
|
+
)
|
|
197
|
+
if not np.allclose(band_means, np.asarray(CALIBRATION_BAND_MEANS, dtype=np.float32), rtol=0.0, atol=0.25):
|
|
198
|
+
raise RuntimeError("NumPy Kaldi fbank calibration mel-band invariant failed")
|
|
199
|
+
return {
|
|
200
|
+
"validation": FBANK_VALIDATION,
|
|
201
|
+
"frames": int(features.shape[0]),
|
|
202
|
+
"bins": int(features.shape[1]),
|
|
203
|
+
"cmn_abs_mean_max": round(cmn_abs_mean_max, 8),
|
|
204
|
+
"cmn_rms": round(cmn_rms, 6),
|
|
205
|
+
"raw_feature_range": [round(raw_min, 6), round(raw_max, 6)],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
101
209
|
class WeSpeakerCamPlus:
|
|
102
210
|
def __init__(self, model_path: str):
|
|
103
211
|
started = time.perf_counter()
|
|
104
212
|
import numpy as np
|
|
105
213
|
import onnxruntime as ort
|
|
106
|
-
import torch
|
|
107
|
-
from torchaudio.compliance import kaldi
|
|
108
214
|
|
|
109
215
|
self.np = np
|
|
110
|
-
self.
|
|
111
|
-
self.kaldi = kaldi
|
|
112
|
-
torch.set_num_threads(1)
|
|
216
|
+
self.preprocessing_validation = validate_kaldi_fbank_numpy(np)
|
|
113
217
|
options = ort.SessionOptions()
|
|
114
218
|
options.inter_op_num_threads = 1
|
|
115
219
|
options.intra_op_num_threads = 1
|
|
@@ -134,27 +238,14 @@ class WeSpeakerCamPlus:
|
|
|
134
238
|
self.model_load_ms = (time.perf_counter() - started) * 1000
|
|
135
239
|
|
|
136
240
|
def features(self, samples):
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
waveform = self.torch.from_numpy(
|
|
141
|
-
self.np.ascontiguousarray(samples * (1 << 15), dtype=self.np.float32)
|
|
142
|
-
).unsqueeze(0)
|
|
143
|
-
mat = self.kaldi.fbank(
|
|
144
|
-
waveform,
|
|
145
|
-
num_mel_bins=80,
|
|
146
|
-
frame_length=25,
|
|
147
|
-
frame_shift=10,
|
|
148
|
-
dither=0.0,
|
|
149
|
-
sample_frequency=SAMPLE_RATE,
|
|
150
|
-
window_type="hamming",
|
|
151
|
-
use_energy=False,
|
|
152
|
-
)
|
|
241
|
+
# WeSpeaker's ONNX inference multiplies normalized PCM values by 2**15
|
|
242
|
+
# before Kaldi fbank. Decode follows the same convention above.
|
|
243
|
+
mat = kaldi_fbank80_numpy(samples * self.np.float32(1 << 15), self.np)
|
|
153
244
|
if mat.ndim != 2 or mat.shape[0] < 1 or mat.shape[1] != 80:
|
|
154
|
-
raise RuntimeError(f"
|
|
245
|
+
raise RuntimeError(f"NumPy Kaldi fbank produced an invalid shape: {tuple(mat.shape)}")
|
|
155
246
|
# CMN without CVN, over the full input utterance, per official script.
|
|
156
|
-
mat = mat - self.
|
|
157
|
-
return mat.
|
|
247
|
+
mat = mat - self.np.mean(mat, axis=0, dtype=self.np.float32, keepdims=True)
|
|
248
|
+
return mat[None, :, :].astype(self.np.float32, copy=False)
|
|
158
249
|
|
|
159
250
|
def embed(self, samples):
|
|
160
251
|
started = time.perf_counter()
|
|
@@ -186,9 +277,22 @@ class WeSpeakerCamPlus:
|
|
|
186
277
|
|
|
187
278
|
def main() -> int:
|
|
188
279
|
parser = argparse.ArgumentParser()
|
|
189
|
-
parser.add_argument("--model"
|
|
190
|
-
parser.add_argument("--model-digest"
|
|
280
|
+
parser.add_argument("--model")
|
|
281
|
+
parser.add_argument("--model-digest")
|
|
282
|
+
parser.add_argument("--preprocessing-probe", action="store_true")
|
|
191
283
|
args = parser.parse_args()
|
|
284
|
+
if args.preprocessing_probe:
|
|
285
|
+
import numpy as np
|
|
286
|
+
emit(
|
|
287
|
+
{
|
|
288
|
+
"type": "preprocessing_probe",
|
|
289
|
+
"implementation": FBANK_IMPLEMENTATION,
|
|
290
|
+
"validation": validate_kaldi_fbank_numpy(np),
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
return 0
|
|
294
|
+
if not args.model or not args.model_digest:
|
|
295
|
+
parser.error("--model and --model-digest are required unless --preprocessing-probe is used")
|
|
192
296
|
worker = WeSpeakerCamPlus(args.model)
|
|
193
297
|
worker.warm()
|
|
194
298
|
emit(
|
|
@@ -202,6 +306,8 @@ def main() -> int:
|
|
|
202
306
|
"dimension": EMBEDDING_DIMENSION,
|
|
203
307
|
"model_load_ms": round(worker.model_load_ms, 3),
|
|
204
308
|
"warmed": True,
|
|
309
|
+
"preprocessing": FBANK_IMPLEMENTATION,
|
|
310
|
+
"preprocessing_validation": worker.preprocessing_validation,
|
|
205
311
|
}
|
|
206
312
|
)
|
|
207
313
|
for line in sys.stdin:
|
|
@@ -463,6 +463,38 @@ def amplify(samples):
|
|
|
463
463
|
return samples
|
|
464
464
|
return np.clip(samples * gain, -1.0, 1.0)
|
|
465
465
|
|
|
466
|
+
|
|
467
|
+
def pcm16_signal_metrics(samples):
|
|
468
|
+
"""Authoritative stats for the raw PCM16 intake before any ASR call."""
|
|
469
|
+
count = int(len(samples))
|
|
470
|
+
if count <= 0:
|
|
471
|
+
return {
|
|
472
|
+
"sampleRateHz": SAMPLE_RATE, "channels": CHANNELS, "bitsPerSample": 16,
|
|
473
|
+
"sampleCount": 0, "durationMs": 0.0, "peakPcm16": 0,
|
|
474
|
+
"rmsPcm16": 0.0, "activeSampleCount": 0, "activeSampleRatio": 0.0,
|
|
475
|
+
}
|
|
476
|
+
# Input originates in int16 stdin. Rounding only restores that exact integer
|
|
477
|
+
# scale for reporting; it never modifies the samples supplied to Whisper.
|
|
478
|
+
pcm = np.rint(samples * 32768.0).astype(np.int32, copy=False)
|
|
479
|
+
magnitude = np.abs(pcm)
|
|
480
|
+
peak = int(np.max(magnitude))
|
|
481
|
+
active = int(np.count_nonzero(magnitude >= 8))
|
|
482
|
+
return {
|
|
483
|
+
"sampleRateHz": SAMPLE_RATE, "channels": CHANNELS, "bitsPerSample": 16,
|
|
484
|
+
"sampleCount": count, "durationMs": count * 1000.0 / SAMPLE_RATE,
|
|
485
|
+
"peakPcm16": peak, "rmsPcm16": float(np.sqrt(np.mean(pcm.astype(np.float64) ** 2))),
|
|
486
|
+
"activeSampleCount": active, "activeSampleRatio": active / count,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def digitally_silent_pcm16(samples):
|
|
491
|
+
metrics = pcm16_signal_metrics(samples)
|
|
492
|
+
return (
|
|
493
|
+
metrics["peakPcm16"] <= 4
|
|
494
|
+
and metrics["rmsPcm16"] <= 1.0
|
|
495
|
+
and metrics["activeSampleCount"] == 0
|
|
496
|
+
), metrics
|
|
497
|
+
|
|
466
498
|
# ---------------------------------------------------------------------------
|
|
467
499
|
# Main transcription loop — utterance FSM
|
|
468
500
|
# ---------------------------------------------------------------------------
|
|
@@ -546,6 +578,11 @@ def main():
|
|
|
546
578
|
})
|
|
547
579
|
|
|
548
580
|
def run_transcribe(m, samples):
|
|
581
|
+
# Every live PCM ASR call goes through this exact gate. It is stricter
|
|
582
|
+
# than VAD only for digital silence, so it cannot reject quiet speech.
|
|
583
|
+
silent, _ = digitally_silent_pcm16(samples)
|
|
584
|
+
if silent:
|
|
585
|
+
return {"text": "", "segments": []}
|
|
549
586
|
return m.transcribe(
|
|
550
587
|
samples,
|
|
551
588
|
fp16=fp16,
|
|
@@ -644,6 +681,10 @@ def main():
|
|
|
644
681
|
|
|
645
682
|
def transcribe_final(samples, speech_block_count=0, rms_values=None, peak=0.0):
|
|
646
683
|
nonlocal last_final_text
|
|
684
|
+
silent, signal = digitally_silent_pcm16(samples)
|
|
685
|
+
if silent:
|
|
686
|
+
emit({"type": "no_speech", "kind": "no_speech", "reason": "digital_silence", "text": "", "signal": signal})
|
|
687
|
+
return
|
|
647
688
|
if not utterance_quality_ok(samples, speech_block_count, rms_values or [], peak):
|
|
648
689
|
return
|
|
649
690
|
samples = amplify(samples)
|