@datalayer/agent-runtimes 1.5.28 → 1.5.30

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.
@@ -35,7 +35,8 @@ export const DATALAYER_VOICE_ENGINES = {
35
35
  * - **Listening**: a microphone in the composer — click it and speak, the
36
36
  * words written in the box as they are heard, stopped by a second click
37
37
  * or when the person stops speaking; or held, and let go (VO-10, VO-11).
38
- * `hands_free` (VO-12) is not built: `checkAppspec` refuses it.
38
+ * `hands_free` (VO-12): the microphone, and *Talk with it* beside it; it
39
+ * implies answers said as they are written.
39
40
  * - **Speaking**: its answers said by Datalayer's speech service, on request
40
41
  * or always (VO-20, VO-21); refused here when its host says no service.
41
42
  */
@@ -49,8 +50,9 @@ export function chatVoiceOf(spec, place) {
49
50
  const language = spec.language || place.language || 'en-US';
50
51
  return {
51
52
  language,
52
- input: spec.input === 'off' ? 'off' : 'push_to_talk',
53
- output: spec.output,
53
+ input: spec.input,
54
+ // Hands-free implies the answers heard as they are written (VO-12).
55
+ output: spec.input === 'hands_free' ? 'always' : spec.output,
54
56
  voice: spec.voice || voiceFor(language)?.id || '',
55
57
  modelsUrl: place.modelsUrl || DATALAYER_SPEECH_MODELS_URL,
56
58
  engines: DATALAYER_VOICE_ENGINES,
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A conversation by voice, hands-free (VOICE.md VO-12, VO-13): what the
3
+ * composer is doing at every moment, and when a turn ends or the person
4
+ * speaks over the agent. Pure: the component (`HandsFree`) keeps the
5
+ * microphone and the clock, this decides.
6
+ *
7
+ * - **Listening**: the microphone is open; the turn ends after
8
+ * `turnSilenceMs` of silence once the person has spoken (700 ms unless
9
+ * the application or the person says otherwise), and is sent.
10
+ * - **Thinking**: the turn was sent and the agent is answering, nothing
11
+ * said yet.
12
+ * - **Speaking**: the answer is being said. The microphone stays open:
13
+ * speech heard now is barge-in (VO-13) — the voice stops at once, the run
14
+ * is stopped when the answer is still being written, and what was heard
15
+ * starts the next turn.
16
+ * - **Paused**: the person paused it (`Esc`, *Pause*), or nothing was said
17
+ * for `IDLE_PAUSE_MS`; nothing is heard until resumed.
18
+ *
19
+ * @module voice/handsFree
20
+ */
21
+ /** What hands-free is doing, shown at all times (VO-12). */
22
+ export type HandsFreeState = 'off' | 'listening' | 'thinking' | 'speaking' | 'paused';
23
+ /** The word for each state, as the composer shows it. */
24
+ export declare const HANDS_FREE_WORDS: Record<HandsFreeState, string>;
25
+ /** The silence after speech that ends a turn, in ms, unless said otherwise (VO-12). */
26
+ export declare const TURN_SILENCE_MS = 700;
27
+ /** Listening with nothing said for this long pauses it: a microphone is not left open on an empty room. */
28
+ export declare const IDLE_PAUSE_MS: number;
29
+ /** What decides the state. */
30
+ export interface HandsFreeInputs {
31
+ /** The toggle is on. */
32
+ on: boolean;
33
+ /** The person paused it, or the room went quiet. */
34
+ paused: boolean;
35
+ /** The agent's answer is being said. */
36
+ speaking: boolean;
37
+ /** A run is in progress: the agent is answering. */
38
+ running: boolean;
39
+ }
40
+ /** The state for what is known: speaking wins over thinking, pausing over both. */
41
+ export declare function handsFreeState(inputs: HandsFreeInputs): HandsFreeState;
42
+ /** What the microphone heard so far, as the hearing says it (`HeardSoFar`). */
43
+ export interface HeardSoFarLike {
44
+ spoke: boolean;
45
+ silentMs: number;
46
+ }
47
+ /**
48
+ * Whether the turn ended: the person spoke, and has been silent for the
49
+ * turn's silence since (VO-12).
50
+ */
51
+ export declare function turnEnded(heard: HeardSoFarLike, turnSilenceMs?: number): boolean;
52
+ /**
53
+ * Whether the person spoke over the agent (VO-13): speech found since the
54
+ * agent began answering, while it thinks or speaks. The microphone is opened
55
+ * anew when the agent begins, so what it holds is the person's, not the
56
+ * answer's echo — the browser's echo cancellation keeps that out.
57
+ */
58
+ export declare function bargedIn(state: HandsFreeState, heard: HeardSoFarLike): boolean;
@@ -0,0 +1,48 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /** The word for each state, as the composer shows it. */
6
+ export const HANDS_FREE_WORDS = {
7
+ off: 'Talk with it',
8
+ listening: 'Listening…',
9
+ thinking: 'Thinking…',
10
+ speaking: 'Speaking…',
11
+ paused: 'Paused',
12
+ };
13
+ /** The silence after speech that ends a turn, in ms, unless said otherwise (VO-12). */
14
+ export const TURN_SILENCE_MS = 700;
15
+ /** Listening with nothing said for this long pauses it: a microphone is not left open on an empty room. */
16
+ export const IDLE_PAUSE_MS = 5 * 60_000;
17
+ /** The state for what is known: speaking wins over thinking, pausing over both. */
18
+ export function handsFreeState(inputs) {
19
+ if (!inputs.on) {
20
+ return 'off';
21
+ }
22
+ if (inputs.paused) {
23
+ return 'paused';
24
+ }
25
+ if (inputs.speaking) {
26
+ return 'speaking';
27
+ }
28
+ if (inputs.running) {
29
+ return 'thinking';
30
+ }
31
+ return 'listening';
32
+ }
33
+ /**
34
+ * Whether the turn ended: the person spoke, and has been silent for the
35
+ * turn's silence since (VO-12).
36
+ */
37
+ export function turnEnded(heard, turnSilenceMs = TURN_SILENCE_MS) {
38
+ return heard.spoke && heard.silentMs >= turnSilenceMs;
39
+ }
40
+ /**
41
+ * Whether the person spoke over the agent (VO-13): speech found since the
42
+ * agent began answering, while it thinks or speaks. The microphone is opened
43
+ * anew when the agent begins, so what it holds is the person's, not the
44
+ * answer's echo — the browser's echo cancellation keeps that out.
45
+ */
46
+ export function bargedIn(state, heard) {
47
+ return (state === 'speaking' || state === 'thinking') && heard.spoke;
48
+ }
@@ -14,6 +14,8 @@ export * from './pinned';
14
14
  export * from './capture';
15
15
  export * from './hearing';
16
16
  export * from './serverHearing';
17
+ export * from './handsFree';
18
+ export { HandsFree, type HandsFreeProps } from './HandsFree';
17
19
  export * from './speaker';
18
20
  export * from './consent';
19
21
  export * from './settings';
@@ -47,8 +49,14 @@ export type { VoiceSpeaker } from './useVoiceSpeaker';
47
49
  export interface ChatVoice {
48
50
  /** The language listened to and spoken, BCP 47. */
49
51
  language: string;
50
- /** `push_to_talk`: the microphone in the composer; `off`: none. Hands-free is Phase V3. */
51
- input: 'off' | 'push_to_talk';
52
+ /**
53
+ * `push_to_talk`: the microphone in the composer; `hands_free`: that, and
54
+ * *Talk with it* beside it — a conversation by voice (VO-12, VO-13);
55
+ * `off`: none.
56
+ */
57
+ input: 'off' | 'push_to_talk' | 'hands_free';
58
+ /** The silence after speech that ends a hands-free turn, in ms (VO-12): 700 when unsaid. */
59
+ turnSilenceMs?: number;
52
60
  /**
53
61
  * When answers are heard: `always`, as they are written; `on_request`, a
54
62
  * *Read aloud* under each answer (VO-21); or `off`, the default.
@@ -8,6 +8,8 @@ export * from './pinned';
8
8
  export * from './capture';
9
9
  export * from './hearing';
10
10
  export * from './serverHearing';
11
+ export * from './handsFree';
12
+ export { HandsFree } from './HandsFree';
11
13
  export * from './speaker';
12
14
  export * from './consent';
13
15
  export * from './settings';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.5.28",
3
+ "version": "1.5.30",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",
@@ -92,6 +92,67 @@ def transcribe(
92
92
  return json.loads(done.stdout.strip().splitlines()[-1])
93
93
 
94
94
 
95
+ def measure_server(
96
+ store: Path,
97
+ voices: Any,
98
+ fixtures: List[Dict[str, Any]],
99
+ wavs: Dict[str, Path],
100
+ measured: Path,
101
+ threads: int,
102
+ ) -> None:
103
+ """Each server speech-to-text model on the fixtures, as the speech service runs it (VO-47).
104
+
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.
107
+ """
108
+ from faster_whisper import WhisperModel # the speech image's own
109
+
110
+ for model in voices.SPEECH_MODEL_CATALOGUE.values():
111
+ if model["task"] != "stt" or model["where"] != ["server"]:
112
+ continue
113
+ started = time.monotonic()
114
+ whisper = WhisperModel(
115
+ str(store / model["provider"] / model["id"]),
116
+ device="cpu",
117
+ compute_type="int8",
118
+ cpu_threads=threads,
119
+ )
120
+ load_ms = round((time.monotonic() - started) * 1000)
121
+ by_language = {}
122
+ for language in ("en", "fr"):
123
+ clips = [clip for clip in fixtures if clip["language"] == language]
124
+ print(f"{model['id']} on {len(clips)} {language} clips", flush=True)
125
+ results = []
126
+ for clip in clips:
127
+ samples, _ = sf.read(wavs[clip["id"]], dtype="float32")
128
+ 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()
133
+ results.append(
134
+ {
135
+ "id": clip["id"],
136
+ "heard": text,
137
+ "ms": round((time.monotonic() - started) * 1000),
138
+ "audio_s": round(len(samples) / 16000, 2),
139
+ }
140
+ )
141
+ by_language[language] = {"load_ms": load_ms, "clips": results}
142
+ (measured / f"stt-{model['id']}.json").write_text(
143
+ json.dumps(
144
+ {
145
+ "model": model["id"],
146
+ "runtime": f"faster-whisper, int8 on the CPU, {threads} threads",
147
+ "languages": by_language,
148
+ },
149
+ ensure_ascii=False,
150
+ indent=2,
151
+ )
152
+ + "\n"
153
+ )
154
+
155
+
95
156
  def main() -> int:
96
157
  parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
97
158
  parser.add_argument("--store", type=Path, required=True)
@@ -101,6 +162,14 @@ def main() -> int:
101
162
  default=None,
102
163
  help="Where @huggingface/transformers is installed, when not in this checkout",
103
164
  )
165
+ parser.add_argument(
166
+ "--server",
167
+ action="store_true",
168
+ help="Measure the server's speech-to-text models instead, with faster-whisper (in the speech image)",
169
+ )
170
+ parser.add_argument(
171
+ "--threads", type=int, default=4, help="CPU threads for --server"
172
+ )
104
173
  args = parser.parse_args()
105
174
  voices = catalogue()
106
175
  env = dict(os.environ)
@@ -124,6 +193,11 @@ def main() -> int:
124
193
  wav = Path(scratch) / f"{clip['id']}.wav"
125
194
  sf.write(wav, resample(samples, rate), 16000, subtype="PCM_16")
126
195
  wavs[clip["id"]] = wav
196
+ if args.server:
197
+ measure_server(
198
+ args.store, voices, fixtures, wavs, measured, args.threads
199
+ )
200
+ return 0
127
201
  # --- speech to text, on the device's models ---------------------------------
128
202
  for model in voices.SPEECH_MODEL_CATALOGUE.values():
129
203
  if model["task"] != "stt" or "device" not in model["where"]: