@datalayer/agent-runtimes 1.5.44 → 1.5.45

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.
@@ -41,6 +41,9 @@ export const STREAM_FINAL_MS = 60_000;
41
41
  export const SERVER_HEARING_MODEL = 'whisper-large-v3-turbo';
42
42
  /** The shortest speech worth hearing, in samples: a word said alone. */
43
43
  const SHORTEST = HEARING_RATE * 0.2;
44
+ /** The speech service refused a press, in its own sentence (a limit, a visitor's day). */
45
+ export class ServerRefused extends Error {
46
+ }
44
47
  /**
45
48
  * Where a chat's voice hears (§5): in the browser unless the speech service
46
49
  * is named and the person asked for detection, the application says
@@ -308,7 +311,7 @@ export class ServerHearing {
308
311
  const final = await press.finish(audio);
309
312
  press.close();
310
313
  if (press.refused) {
311
- throw new Error(press.refused);
314
+ throw new ServerRefused(press.refused);
312
315
  }
313
316
  if (!final) {
314
317
  throw new Error("What you said could not be heard on Datalayer's servers: the stream ended before its words.");
@@ -339,7 +342,7 @@ export class ServerHearing {
339
342
  });
340
343
  const body = (await answered.json().catch(() => ({})));
341
344
  if (!answered.ok) {
342
- throw new Error(typeof body.detail === 'string' && body.detail
345
+ throw new ServerRefused(typeof body.detail === 'string' && body.detail
343
346
  ? body.detail
344
347
  : `What you said could not be heard on Datalayer's servers (${answered.status}).`);
345
348
  }
@@ -362,3 +365,76 @@ export class ServerHearing {
362
365
  };
363
366
  }
364
367
  }
368
+ /** Said when the device's model did not load and the server will hear instead. */
369
+ export const MOVED_TO_SERVER = "This device could not load its model: what you say will be heard on Datalayer's servers instead. Press the microphone again to agree.";
370
+ /**
371
+ * `where: auto` (§5): each press heard on the device when a model there
372
+ * hears the language and loads, on the speech service otherwise.
373
+ *
374
+ * The device's model failing to load moves the hearing to the server, and
375
+ * the press is refused in a sentence so that the server's own consent is
376
+ * asked at the next one. The server refusing a press — a limit, a visitor's
377
+ * day spent — moves it back to the device when a model there hears the
378
+ * language, and the press is heard there: the sound then stays in the page.
379
+ */
380
+ export class AutoHearing {
381
+ device;
382
+ server;
383
+ hearsHere;
384
+ place;
385
+ /** The device's model did not load: it is not tried again on this page. */
386
+ deviceBroken = false;
387
+ constructor(device, server, language, hearsHere = language => Boolean(transcriberFor(language))) {
388
+ this.device = device;
389
+ this.server = server;
390
+ this.hearsHere = hearsHere;
391
+ this.place = hearsHere(language) ? 'device' : 'server';
392
+ }
393
+ get where() {
394
+ return this.place;
395
+ }
396
+ get current() {
397
+ return this.place === 'device' ? this.device : this.server;
398
+ }
399
+ async ready(language, progress) {
400
+ if (this.place === 'server' &&
401
+ !this.deviceBroken &&
402
+ this.hearsHere(language)) {
403
+ // A language the device hears, chosen after a server-only one.
404
+ this.place = 'device';
405
+ }
406
+ if (this.place === 'device') {
407
+ try {
408
+ await this.device.ready(language, progress);
409
+ return;
410
+ }
411
+ catch {
412
+ this.place = 'server';
413
+ this.deviceBroken = true;
414
+ throw new Error(MOVED_TO_SERVER);
415
+ }
416
+ }
417
+ await this.server.ready(language, progress);
418
+ }
419
+ hearSoFar(audio, language) {
420
+ return this.current.hearSoFar(audio, language);
421
+ }
422
+ async hear(audio, language) {
423
+ if (this.place === 'device') {
424
+ return this.device.hear(audio, language);
425
+ }
426
+ try {
427
+ return await this.server.hear(audio, language);
428
+ }
429
+ catch (error) {
430
+ if (!(error instanceof ServerRefused) ||
431
+ this.deviceBroken ||
432
+ !this.hearsHere(language)) {
433
+ throw error;
434
+ }
435
+ this.place = 'device';
436
+ await this.device.ready(language);
437
+ return this.device.hear(audio, language);
438
+ }
439
+ }
440
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.5.44",
3
+ "version": "1.5.45",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",
@@ -92,6 +92,29 @@ def transcribe(
92
92
  return json.loads(done.stdout.strip().splitlines()[-1])
93
93
 
94
94
 
95
+ #: Spoken turns timed on the server (VO-47, decision 18): a 3 s fixture and the
96
+ #: e2e suite's 9 s question, each heard ``TURN_RUNS`` times told its language.
97
+ TURNS = {
98
+ "3s": VOICE_TESTS / "fixtures" / "en-6930-75918-0013.flac",
99
+ "9s": ROOT / "e2e" / "fixtures" / "weather-in-paris.wav",
100
+ }
101
+ TURN_RUNS = 5
102
+
103
+
104
+ def server_transcriber(model: Dict[str, Any], folder: Path, threads: int) -> Any:
105
+ """The speech service's own engine for a model, as the image runs it (``datalayer_ai_agents``)."""
106
+ from datalayer_ai_agents.speech.transcription import ( # the speech image's own
107
+ FasterWhisperTranscriber,
108
+ MoonshineTranscriber,
109
+ )
110
+
111
+ if model["engine"] == "faster-whisper":
112
+ return FasterWhisperTranscriber(folder, model["id"], threads=threads)
113
+ if model["engine"] == "onnxruntime":
114
+ return MoonshineTranscriber(folder, model["id"], threads=threads)
115
+ raise SystemExit(f"{model['id']}: no server engine {model['engine']!r}")
116
+
117
+
95
118
  def measure_server(
96
119
  store: Path,
97
120
  voices: Any,
@@ -99,37 +122,34 @@ def measure_server(
99
122
  wavs: Dict[str, Path],
100
123
  measured: Path,
101
124
  threads: int,
125
+ only: List[str],
102
126
  ) -> None:
103
127
  """Each server speech-to-text model on the fixtures, as the speech service runs it (VO-47).
104
128
 
105
- faster-whisper, 8-bit on the CPU with ``threads`` threads, told the
106
- language, one clip at a time: what it heard and how long it took.
129
+ The service's own engines (faster-whisper 8-bit, Moonshine through
130
+ onnxruntime) on the CPU with ``threads`` threads, told the language, one
131
+ clip at a time: what it heard and how long it took; then a 3 s and a 9 s
132
+ English turn, each heard ``TURN_RUNS`` times (decision 18).
107
133
  """
108
- from faster_whisper import WhisperModel # the speech image's own
109
-
110
134
  for model in voices.SPEECH_MODEL_CATALOGUE.values():
111
135
  if model["task"] != "stt" or model["where"] != ["server"]:
112
136
  continue
137
+ if only and model["id"] not in only:
138
+ continue
113
139
  started = time.monotonic()
114
- whisper = WhisperModel(
115
- str(store / model["provider"] / model["id"]),
116
- device="cpu",
117
- compute_type="int8",
118
- cpu_threads=threads,
140
+ engine = server_transcriber(
141
+ model, store / model["provider"] / model["id"], threads
119
142
  )
120
143
  load_ms = round((time.monotonic() - started) * 1000)
121
144
  by_language = {}
122
- for language in ("en", "fr"):
145
+ for language in [code for code in ("en", "fr") if code in model["languages"]]:
123
146
  clips = [clip for clip in fixtures if clip["language"] == language]
124
147
  print(f"{model['id']} on {len(clips)} {language} clips", flush=True)
125
148
  results = []
126
149
  for clip in clips:
127
150
  samples, _ = sf.read(wavs[clip["id"]], dtype="float32")
128
151
  started = time.monotonic()
129
- parts, _ = whisper.transcribe(
130
- samples, language=language, vad_filter=True
131
- )
132
- text = " ".join(part.text.strip() for part in parts).strip()
152
+ text = engine.transcribe(samples, language=language).text
133
153
  results.append(
134
154
  {
135
155
  "id": clip["id"],
@@ -139,12 +159,40 @@ def measure_server(
139
159
  }
140
160
  )
141
161
  by_language[language] = {"load_ms": load_ms, "clips": results}
162
+ turns = {}
163
+ if "en" in model["languages"]:
164
+ for name, path in TURNS.items():
165
+ samples, rate = sf.read(path, dtype="float32")
166
+ samples = resample(samples, rate)
167
+ engine.transcribe(samples, language="en") # warm
168
+ took = []
169
+ for _ in range(TURN_RUNS):
170
+ started = time.monotonic()
171
+ heard = engine.transcribe(samples, language="en").text
172
+ took.append(round((time.monotonic() - started) * 1000))
173
+ turns[name] = {
174
+ "file": path.name,
175
+ "audio_s": round(len(samples) / 16000, 2),
176
+ "ms": took,
177
+ "median_ms": sorted(took)[len(took) // 2],
178
+ "heard": heard,
179
+ }
180
+ print(
181
+ f"{model['id']} {name} turn: {turns[name]['median_ms']} ms",
182
+ flush=True,
183
+ )
184
+ runtime = (
185
+ f"faster-whisper, int8 on the CPU, {threads} threads"
186
+ if model["engine"] == "faster-whisper"
187
+ else f"onnxruntime {model['dtype']} on the CPU, greedy, {threads} threads"
188
+ )
142
189
  (measured / f"stt-{model['id']}.json").write_text(
143
190
  json.dumps(
144
191
  {
145
192
  "model": model["id"],
146
- "runtime": f"faster-whisper, int8 on the CPU, {threads} threads",
193
+ "runtime": runtime,
147
194
  "languages": by_language,
195
+ **({"turns": turns} if turns else {}),
148
196
  },
149
197
  ensure_ascii=False,
150
198
  indent=2,
@@ -165,7 +213,13 @@ def main() -> int:
165
213
  parser.add_argument(
166
214
  "--server",
167
215
  action="store_true",
168
- help="Measure the server's speech-to-text models instead, with faster-whisper (in the speech image)",
216
+ help="Measure the server's speech-to-text models instead, with the speech service's engines (in the speech image)",
217
+ )
218
+ parser.add_argument(
219
+ "--model",
220
+ action="append",
221
+ default=[],
222
+ help="With --server, only this model (repeatable)",
169
223
  )
170
224
  parser.add_argument(
171
225
  "--threads", type=int, default=4, help="CPU threads for --server"
@@ -195,7 +249,13 @@ def main() -> int:
195
249
  wavs[clip["id"]] = wav
196
250
  if args.server:
197
251
  measure_server(
198
- args.store, voices, fixtures, wavs, measured, args.threads
252
+ args.store,
253
+ voices,
254
+ fixtures,
255
+ wavs,
256
+ measured,
257
+ args.threads,
258
+ args.model,
199
259
  )
200
260
  return 0
201
261
  # --- speech to text, on the device's models ---------------------------------