@pyai/sdk 0.6.1 → 0.7.1
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/AGENT_GUIDE.md +5 -2
- package/CLI.md +6 -3
- package/CLI.schema.json +15 -0
- package/README.md +40 -19
- package/dist/cli-dx.js +1 -1
- package/dist/cli-init.d.ts +1 -1
- package/dist/cli-init.js +6 -3
- package/dist/cli-omni-template.d.ts +10 -0
- package/dist/cli-omni-template.js +11 -0
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/package.json +3 -3
- package/src/cli-dx.ts +1 -1
- package/src/cli-init.ts +8 -4
- package/src/cli-omni-template.ts +11 -0
- package/src/cli.ts +1 -1
- package/src/index.ts +2 -1
package/AGENT_GUIDE.md
CHANGED
|
@@ -11,7 +11,7 @@ Markdown and does not require the marketing website renderer.
|
|
|
11
11
|
## Availability and sources of truth
|
|
12
12
|
|
|
13
13
|
The CLI is included in `@pyai/sdk` 0.6.1. Install it with
|
|
14
|
-
`npm install -g @pyai/sdk@0.
|
|
14
|
+
`npm install -g @pyai/sdk@0.7.1`. Browser login supports existing PyAI accounts;
|
|
15
15
|
unattended agents should use an authorized environment key. Inspect the
|
|
16
16
|
installed command schema before acting.
|
|
17
17
|
|
|
@@ -35,7 +35,7 @@ do not invent an endpoint or present the feature as deployed.
|
|
|
35
35
|
Install the published CLI with Node.js 22 or newer recommended:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
npm install -g @pyai/sdk@0.
|
|
38
|
+
npm install -g @pyai/sdk@0.7.1
|
|
39
39
|
pyai --version
|
|
40
40
|
pyai schema --json
|
|
41
41
|
```
|
|
@@ -242,6 +242,7 @@ Treat that file as sensitive; never commit it or paste it into a model prompt.
|
|
|
242
242
|
|
|
243
243
|
```bash
|
|
244
244
|
pyai init voice-project
|
|
245
|
+
pyai init voice-omni --template omni
|
|
245
246
|
pyai init voice-ts --template typescript
|
|
246
247
|
pyai init voice-python --template python
|
|
247
248
|
pyai init voice-project --template agent --dry-run --json
|
|
@@ -252,6 +253,8 @@ present. Scaffolding is offline; it creates `PYAI.md` and starter assets without
|
|
|
252
253
|
installing packages, minting credentials, or making API calls. Every template includes `README.md`, `PYAI.md`, `.env.example`, and
|
|
253
254
|
`.gitignore`. The agent template adds `agent.json`, `speech.json`, and
|
|
254
255
|
`job.json`; TypeScript adds `main.ts` and `package.json`; Python adds `main.py`.
|
|
256
|
+
Omni adds a complete Node 20.19+/22 ESM example with `ws`, one paced audio
|
|
257
|
+
stream, a read-only tool, captured WAVs, and simulated interruption handling.
|
|
255
258
|
Follow the created README. JSON output includes `directory`, `template`, `files`,
|
|
256
259
|
`created`, and `next_steps`.
|
|
257
260
|
|
package/CLI.md
CHANGED
|
@@ -7,7 +7,7 @@ and Codex. `--json` provides structured results; `--help` and `schema` describe
|
|
|
7
7
|
the installed command surface.
|
|
8
8
|
|
|
9
9
|
**Version:** the expanded CLI is included in `@pyai/sdk` 0.6.1. Install it from
|
|
10
|
-
npm with `npm install -g @pyai/sdk@0.
|
|
10
|
+
npm with `npm install -g @pyai/sdk@0.7.1`, then run `pyai login` for browser
|
|
11
11
|
sign-in or provide `PYAI_API_KEY` for unattended automation.
|
|
12
12
|
|
|
13
13
|
## Contents
|
|
@@ -31,7 +31,7 @@ sign-in or provide `PYAI_API_KEY` for unattended automation.
|
|
|
31
31
|
Install the published package (Node.js 22 or newer recommended):
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
npm install -g @pyai/sdk@0.
|
|
34
|
+
npm install -g @pyai/sdk@0.7.1
|
|
35
35
|
pyai --version
|
|
36
36
|
pyai login
|
|
37
37
|
pyai speak "Hello from PyAI." -o hello.wav
|
|
@@ -436,7 +436,7 @@ The offline schema is authoritative for the installed build.
|
|
|
436
436
|
| Hear vocabulary | `vocabulary get`, `set` |
|
|
437
437
|
| AMD | `amd calls`, `get ID`, `config`, `configure` |
|
|
438
438
|
| Discovery | `schema`, `schema --openapi`, `recipes [NAME]`, `help` |
|
|
439
|
-
| Project setup | `init DIRECTORY --template agent\|typescript\|python` |
|
|
439
|
+
| Project setup | `init DIRECTORY --template agent\|typescript\|python\|omni` |
|
|
440
440
|
| Generic API | `request METHOD /PATH` |
|
|
441
441
|
| Diagnostics | `doctor`, `smoke` |
|
|
442
442
|
|
|
@@ -483,6 +483,7 @@ Create a starter directory without credentials or network calls:
|
|
|
483
483
|
|
|
484
484
|
```bash
|
|
485
485
|
pyai init voice-project
|
|
486
|
+
pyai init voice-omni --template omni
|
|
486
487
|
pyai init voice-ts --template typescript
|
|
487
488
|
pyai init voice-python --template python
|
|
488
489
|
pyai init voice-project --template agent --dry-run -j
|
|
@@ -493,6 +494,8 @@ existing parent; scaffolding
|
|
|
493
494
|
does not merge into or overwrite an existing project. Every template includes `README.md`, `PYAI.md`, `.env.example`, and
|
|
494
495
|
`.gitignore`. The agent template adds `agent.json`, `speech.json`, and
|
|
495
496
|
`job.json`; TypeScript adds `main.ts` and `package.json`; Python adds `main.py`.
|
|
497
|
+
Omni adds a complete Node 20.19+/22 ESM example with `ws`, one paced audio
|
|
498
|
+
stream, a read-only tool, captured WAVs, and simulated interruption handling.
|
|
496
499
|
`PYAI.md` provides an integration handoff for an engineer or coding agent. Follow the
|
|
497
500
|
starter's README for its files, runtime prerequisites, and next commands.
|
|
498
501
|
Scaffolding does not install dependencies, create an account, or make paid API
|
package/CLI.schema.json
CHANGED
|
@@ -1307,6 +1307,21 @@
|
|
|
1307
1307
|
".env.example",
|
|
1308
1308
|
".gitignore"
|
|
1309
1309
|
]
|
|
1310
|
+
},
|
|
1311
|
+
{
|
|
1312
|
+
"id": "omni",
|
|
1313
|
+
"description": "Runnable Node 20/22 Omni audio, read tool, capture and simulated interruption",
|
|
1314
|
+
"files": [
|
|
1315
|
+
"README.md",
|
|
1316
|
+
"package.json",
|
|
1317
|
+
"timing.mjs",
|
|
1318
|
+
"main.mjs",
|
|
1319
|
+
"session.mjs",
|
|
1320
|
+
"audio.mjs",
|
|
1321
|
+
".env.example",
|
|
1322
|
+
".gitignore",
|
|
1323
|
+
"PYAI.md"
|
|
1324
|
+
]
|
|
1310
1325
|
}
|
|
1311
1326
|
],
|
|
1312
1327
|
"output": {
|
package/README.md
CHANGED
|
@@ -78,29 +78,47 @@ the instant the socket opens and routes server frames to typed callbacks, so you
|
|
|
78
78
|
**can't** trip the #1 Omni integration bug (a hand-rolled `{"event":"configure"}`
|
|
79
79
|
is acked but silently dropped, giving you a connected session with zero turns):
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
<!-- omni-starter:start -->
|
|
82
|
+
Requires SDK 0.7.1 for `--template omni`. The runnable starter uses ESM,
|
|
83
|
+
Node 20.19+ with an explicit `ws` transport, or Node 22+. MCP 0.5.0
|
|
84
|
+
requires Node 22+. The SDK's REST client continues to support Node 18+.
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
npm install @pyai/sdk@0.7.1
|
|
88
|
+
npx pyai init voice-demo --template omni
|
|
89
|
+
cd voice-demo
|
|
90
|
+
npm install
|
|
91
|
+
```
|
|
85
92
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// Omni is zero-state: the key's org authorizes the session, nothing to create.
|
|
90
|
-
const omni = pyai.omni.connect({
|
|
91
|
-
webSocket: WebSocket, // Supplies the transport on Node 20.
|
|
92
|
-
rate: 16000, // 24000 browser · 16000 wideband telephony · 8000 G.711/Twilio
|
|
93
|
-
configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
|
|
94
|
-
onAudio: (chunk) => speaker.write(chunk), // binary agent audio, play it out
|
|
95
|
-
onTranscript: (f) => console.log(f.text),
|
|
96
|
-
onError: (e) => console.error(e),
|
|
97
|
-
});
|
|
93
|
+
Initialization is offline; installing dependencies and running `main.mjs` are
|
|
94
|
+
separate steps. For an existing checkout of this example, run `npm install`
|
|
95
|
+
in its directory instead.
|
|
98
96
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
Inject `PYAI_API_KEY` through your environment, then supply a 24 kHz PCM16 mono
|
|
98
|
+
WAV (at most 20 seconds) saying “Please look up the office opening time.”
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
node main.mjs caller.wav
|
|
102
|
+
# Optional interruption clip, sent while reply audio is queued:
|
|
103
|
+
node main.mjs caller.wav interruption.wav
|
|
102
104
|
```
|
|
103
105
|
|
|
106
|
+
The generated project contains the complete runnable source, including its
|
|
107
|
+
Node 20 WebSocket import, WAV reader, one paced input stream, read-only tool,
|
|
108
|
+
and bounded capture. It waits for configuration and greeting playback to drain.
|
|
109
|
+
It sends caller PCM or silence in each slot, never overlapping silence timers.
|
|
110
|
+
|
|
111
|
+
Running it consumes Omni and Hear usage under the injected key. It saves a
|
|
112
|
+
private report and WAV files. The report separates received audio from an
|
|
113
|
+
answer recovered by Hear from captured bytes; synthesis text alone cannot pass.
|
|
114
|
+
The playback sink is simulated. Interruption clears that queue; physical speaker
|
|
115
|
+
playback remains a separate test. Capture uses two seconds of quiet after the
|
|
116
|
+
queue drains, because the protocol has no reply-end marker.
|
|
117
|
+
|
|
118
|
+
This section and the CLI project are generated from the same tested example.
|
|
119
|
+
Release readiness: https://pyai.com/agents/bot-release.json
|
|
120
|
+
<!-- omni-starter:end -->
|
|
121
|
+
|
|
104
122
|
Live `0x02` transcript bodies are plain UTF-8 caller-text deltas, not JSON.
|
|
105
123
|
`onTranscript` receives the normalized
|
|
106
124
|
`{ event:"transcript", role:"user", text, final:false, mode:"delta" }` shape.
|
|
@@ -116,6 +134,9 @@ Caller transcripts continue to require the `0x02` carrier.
|
|
|
116
134
|
`rate` configures caller input. A `rate: 16000` session still receives 24 kHz
|
|
117
135
|
agent audio; `rate: 8000` receives 8 kHz. Read `hello.audio_out` before playback.
|
|
118
136
|
Omni has no commit frame—keep streaming silence during caller pauses.
|
|
137
|
+
Use one paced input stream: send caller PCM when available, otherwise silence.
|
|
138
|
+
Pause any separate silence timer while microphone or fixture frames are being
|
|
139
|
+
sent; never interleave extra silence with active input.
|
|
119
140
|
|
|
120
141
|
For a client-executed lookup, declare `side_effect: "read"` in its tool definition:
|
|
121
142
|
|
package/dist/cli-dx.js
CHANGED
|
@@ -34,5 +34,5 @@ export const recipes = [
|
|
|
34
34
|
notes: ["Local schema and recipes work without credentials. Live OpenAPI needs a network connection but no API key.", "The CLI manages Agent profiles; use the SDK or WebSocket API for a live Omni audio session."] },
|
|
35
35
|
{ name: "ci", title: "Use PyAI in a build", description: "Check access and produce an audio artifact with machine-readable output.",
|
|
36
36
|
commands: ["pyai whoami --json", 'pyai speak "Your build is ready" -o build.wav --json', "pyai doctor --json"],
|
|
37
|
-
notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.
|
|
37
|
+
notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.7.1 and pin that version in repeatable builds."] },
|
|
38
38
|
].map(recipe => ({ ...recipe, docs_url: "https://pyai.com/agents/speech-calling.md" }));
|
package/dist/cli-init.d.ts
CHANGED
package/dist/cli-init.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
4
|
+
import { omniTemplate } from "./cli-omni-template.js";
|
|
4
5
|
export class CliInitError extends Error {
|
|
5
6
|
code;
|
|
6
7
|
constructor(code, message) {
|
|
@@ -127,7 +128,7 @@ const install = `## Install the CLI
|
|
|
127
128
|
Install the published CLI with Node.js 22 or newer recommended:
|
|
128
129
|
|
|
129
130
|
\`\`\`bash
|
|
130
|
-
npm install -g @pyai/sdk@0.
|
|
131
|
+
npm install -g @pyai/sdk@0.7.1
|
|
131
132
|
pyai --version
|
|
132
133
|
\`\`\`
|
|
133
134
|
|
|
@@ -220,7 +221,7 @@ and product calls below are separate explicit steps. Read \`PYAI.md\` first.
|
|
|
220
221
|
Install the official SDK:
|
|
221
222
|
|
|
222
223
|
\`\`\`bash
|
|
223
|
-
npm install @pyai/sdk@0.
|
|
224
|
+
npm install @pyai/sdk@0.7.1
|
|
224
225
|
cp .env.example .env
|
|
225
226
|
\`\`\`
|
|
226
227
|
|
|
@@ -321,6 +322,7 @@ Check the live contract before expanding the example.
|
|
|
321
322
|
|
|
322
323
|
${install}`;
|
|
323
324
|
const content = Object.freeze({
|
|
325
|
+
omni: Object.freeze({ ...omniTemplate, "PYAI.md": context }),
|
|
324
326
|
agent: Object.freeze({ "README.md": agentReadme, "PYAI.md": context, "speech.json": speech, "job.json": job, "agent.json": agent, ".env.example": env, ".gitignore": ignore }),
|
|
325
327
|
typescript: Object.freeze({ "README.md": tsReadme, "PYAI.md": context, "main.ts": tsMain, "package.json": JSON.stringify({ name: "pyai-starter", version: "0.0.0", private: true, type: "module", engines: { node: ">=22.6.0" }, scripts: { speak: "node --experimental-strip-types --env-file=.env main.ts speak", hear: "node --experimental-strip-types --env-file=.env main.ts hear" } }, null, 2) + "\n", ".env.example": env, ".gitignore": ignore }),
|
|
326
328
|
python: Object.freeze({ "README.md": pythonReadme, "PYAI.md": context, "main.py": pythonMain, ".env.example": env, ".gitignore": ignore }),
|
|
@@ -330,6 +332,7 @@ export const CLI_TEMPLATES = Object.freeze([
|
|
|
330
332
|
{ id: "agent", description: "API context and JSON requests for coding agents" },
|
|
331
333
|
{ id: "typescript", description: "Runnable Node.js Speak and Hear examples using @pyai/sdk" },
|
|
332
334
|
{ id: "python", description: "Runnable Python Speak and Hear examples using pyai-sdk" },
|
|
335
|
+
{ id: "omni", description: "Runnable Node 20/22 Omni audio, read tool, capture and simulated interruption" },
|
|
333
336
|
].map((entry) => Object.freeze({ ...entry, files: Object.freeze(Object.keys(content[entry.id])) })));
|
|
334
337
|
function hasCode(error, code) {
|
|
335
338
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
@@ -350,7 +353,7 @@ async function exists(path) {
|
|
|
350
353
|
export async function scaffoldProject(options) {
|
|
351
354
|
const template = options.template ?? "agent";
|
|
352
355
|
if (!Object.hasOwn(content, template))
|
|
353
|
-
throw new CliInitError("invalid_template", "Choose agent, typescript, or
|
|
356
|
+
throw new CliInitError("invalid_template", "Choose agent, typescript, python, or omni with --template.");
|
|
354
357
|
if (typeof options.directory !== "string" || !options.directory.trim() || /[\p{Cc}]/u.test(options.directory)) {
|
|
355
358
|
throw new CliInitError("invalid_directory", "Provide a new project directory without control characters.");
|
|
356
359
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const omniTemplate: Readonly<{
|
|
2
|
+
"README.md": "# Runnable Omni starter\n\nRequires Node 20.19+ and ESM. Node 20 uses the included `ws` transport;\nNode 22 also works. The separate MCP server requires Node 22+.\nThis server-side example uses a synthetic read-only office-hours tool. Read\n`PYAI.md` when generating the project through the CLI.\n\n<!-- omni-install:start -->\n```sh\nnpm install @pyai/sdk@0.7.1\nnpx pyai init voice-demo --template omni\ncd voice-demo\nnpm install\n```\n\nInitialization is offline; installing dependencies and running `main.mjs` are\nseparate steps. For an existing checkout of this example, run `npm install`\nin its directory instead.\n<!-- omni-install:end -->\n\nInject `PYAI_API_KEY` through your secret manager or shell environment. Do not\npaste a real key into source, a prompt, or shell history. `.env.example` documents\nthe variable; this program does not load `.env` or saved CLI profiles.\n\nPrepare a 24 kHz PCM16 mono WAV, at most 20 seconds, saying “Please look up the\noffice opening time.” Then run:\n\n```sh\nnode main.mjs caller.wav\n```\n\nRunning the program explicitly opens one bounded Omni session and uploads its\ncaptured reply to Hear. Both consume your key's allowed usage. Automatic retries\nare disabled. No managed number is bought and no phone call is placed.\n\n`session.mjs` waits for configuration and the greeting to drain, sends exactly\none paced input stream (caller PCM or silence), returns the read tool's result,\nand captures output using the rate from `hello.audio_out`. Never run a separate\nsilence timer alongside caller audio. `timing.mjs` is generated from the existing\nOmni evaluation harness's timing utilities.\n\nThe new private `omni-run-*` folder contains `session.wav`, `reply.wav`, and\n`report.json`. The report separates received audio, an office-hours answer\nrecovered by Hear from captured audio, and physical playback (not tested).\nThe office-hours check accepts formatting such as `opens@9a.m`, `9 a.m.` and\n`nine in the morning` in a complete affirmative opening-time sentence. Wrong\ntimes, negation, conflicting or truncated text remain unverified. This is a\nconservative check for the example fact, not a general answer-quality score.\nA synthesis transcript is advisory; it is not evidence that speech arrived.\nCapture ends after the simulated queue drains and two seconds of quiet. Omni\nhas no protocol reply-end marker, so this is a bounded capture heuristic.\nListen to the saved file when qualifying actual sound quality or completeness.\n\nTo exercise interruption, supply a second WAV saying “Stop speaking now”:\n\n```sh\nnode main.mjs caller.wav interruption.wav\n```\n\nThe second clip starts while reply audio is queued. `onBargeIn` clears that\nsimulated queue, and capture observes at least five seconds after that clip ends.\nAn interrupted answer can correctly fail the full-answer check;\ninspect the separate interruption evidence. This is not a physical speaker test.\nFor a real playback adapter, cancel both queued and currently playing audio.\nReplace the example lookup with authorized application data before product use.\n\nOn a session error, the program exits with JSON containing a recognized machine\n`code` (for example `media_dead` or `invalid_configure`). Unknown server codes\nbecome `unrecognized_server_error`; transport or decoding errors without a\nrecognized transport code become `transport_or_protocol_error`. Raw error text,\ncall IDs and unknown code values are omitted. No automatic retry is attempted;\ninspect the code and your authorized diagnostics before deciding to retry.\n";
|
|
3
|
+
"package.json": "{\n \"name\": \"pyai-omni-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20.19.0\"\n },\n \"scripts\": {\n \"start\": \"node main.mjs\"\n },\n \"dependencies\": {\n \"@pyai/sdk\": \"0.7.1\",\n \"ws\": \"^8.18.3\"\n }\n}\n";
|
|
4
|
+
"timing.mjs": "// Monotonic capture timing. Arrival time is distinct from queued playback:\n// a single packet may contain seconds of audio and must finish before settling.\nexport const FRAME_MS = 20;\nexport const REALTIME_GAP_LIMIT_MS = 100;\n// Interruption tests need an observation window after caller transmission,\n// even when an earlier cue/response already finished. These are test bounds,\n// not an inferred engine reply-end or a naturalness threshold.\nexport const INTERRUPTION_OBSERVATION = Object.freeze({\n minimumAfterCallerStreamMs: 5000, quietMs: 2000, maximumAfterCallerStreamMs: 25000,\n});\n\n/** Approximate audible bounds; this is energy detection, not a speech/VAD claim. */\nexport function audibleBounds(pcm, rate, rmsFloor = 160) {\n const window = Math.max(1, Math.round(rate / 100)); // 10 ms windows\n let first = null;\n let last = null;\n for (let off = 0; off < pcm.length; off += window) {\n const end = Math.min(pcm.length, off + window);\n let energy = 0;\n for (let i = off; i < end; i++) energy += pcm[i] * pcm[i];\n if (Math.sqrt(energy / (end - off)) >= rmsFloor) {\n first ??= off;\n last = end;\n }\n }\n return { first, last };\n}\n\nexport function newAudioCapture(started) {\n return {\n started, firstPacketAt: null, lastPacketAt: null,\n firstAudioAt: null, lastAudioAt: null, playbackEndAt: null,\n samples: 0, pcm: [], turnBeginAt: null, eouMs: null,\n latestTurnBeginAt: null, postTurnBeginFirstPacketAt: null,\n postTurnBeginFirstAudioAt: null, lastAssistantTranscriptAt: null, lastCallerTranscriptAt: null,\n callerTranscriptEvents: [], assistantTranscriptEvents: [],\n turnBegins: [], tools: [], toolResults: [], kb: null, events: [],\n };\n}\n\nexport function recordTurnBegin(ctx, at, turn, origin = 0) {\n ctx.turnBeginAt ??= at;\n ctx.latestTurnBeginAt = at;\n // Earlier PCM can be a listening cue. A new reply is still pending even\n // when that cue has already finished playing; retain all of its PCM.\n ctx.postTurnBeginFirstPacketAt = null;\n ctx.postTurnBeginFirstAudioAt = null;\n ctx.turnBegins.push({ atMs: Math.round(at - origin), turn: turn ?? null });\n}\n\nexport function recordAudio(ctx, pcm, at, rate) {\n if (!(pcm instanceof Int16Array) || !pcm.length) return null;\n const copy = pcm.slice(); // Some transports reuse callback buffers.\n const playbackAt = Math.max(at, ctx.playbackEndAt ?? at);\n const endAt = playbackAt + (copy.length / rate) * 1000;\n const bounds = audibleBounds(copy, rate);\n ctx.firstPacketAt ??= at;\n ctx.lastPacketAt = at;\n ctx.playbackEndAt = endAt;\n const afterTurnBegin = ctx.latestTurnBeginAt != null && at >= ctx.latestTurnBeginAt;\n if (afterTurnBegin) ctx.postTurnBeginFirstPacketAt ??= at;\n if (bounds.first != null) {\n ctx.firstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n ctx.lastAudioAt = playbackAt + (bounds.last / rate) * 1000;\n if (afterTurnBegin) ctx.postTurnBeginFirstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n }\n ctx.samples += copy.length;\n ctx.pcm.push(copy);\n return { playbackAt, endAt, pcm: copy };\n}\n\n/** Preserve negative gaps: they are overlaps, never missing measurements. */\nexport function captureTiming(ctx, caller, origin = 0) {\n const relative = (at) => at == null ? null : Math.round(at - origin);\n const gap = (at) => at == null || caller.speechOffsetAt == null\n ? null : Math.round(at - caller.speechOffsetAt);\n return {\n ttfbMs: gap(ctx.firstAudioAt),\n anyAudioTtfbMs: gap(ctx.firstAudioAt),\n postTurnBeginTtfbMs: gap(ctx.postTurnBeginFirstAudioAt),\n turnMs: gap(ctx.lastAudioAt),\n packetTtfbMs: gap(ctx.firstPacketAt),\n callerStartMs: relative(caller.startedAt),\n callerSpeechOnsetMs: relative(caller.speechOnsetAt),\n callerSpeechOffsetMs: relative(caller.speechOffsetAt),\n callerStreamEndMs: relative(caller.streamEndAt),\n agentFirstPacketMs: relative(ctx.firstPacketAt),\n agentLastPacketMs: relative(ctx.lastPacketAt),\n agentSpeechOnsetMs: relative(ctx.firstAudioAt),\n agentSpeechOffsetMs: relative(ctx.lastAudioAt),\n agentPlaybackEndMs: relative(ctx.playbackEndAt),\n latestTurnBeginMs: relative(ctx.latestTurnBeginAt),\n postTurnBeginFirstPacketMs: relative(ctx.postTurnBeginFirstPacketAt),\n postTurnBeginSpeechOnsetMs: relative(ctx.postTurnBeginFirstAudioAt),\n lastAssistantTranscriptMs: relative(ctx.lastAssistantTranscriptAt),\n callerMaxFrameGapMs: Math.round(caller.maxFrameGapMs ?? 0),\n method: \"client-monotonic-queued-playback-energy-bounds\",\n };\n}\n\nexport async function streamPcmRealtime(omni, pcm, rate, clock, onFrame = () => {}) {\n const frameSize = Math.max(1, Math.round((rate * FRAME_MS) / 1000));\n const bounds = audibleBounds(pcm, rate);\n const startedAt = clock.now();\n let speechOnsetAt = null;\n let speechOffsetAt = null;\n let previousFrameEnd = startedAt;\n let maxFrameGapMs = 0;\n for (let off = 0; off < pcm.length; off += frameSize) {\n const frame = pcm.subarray(off, Math.min(off + frameSize, pcm.length));\n const at = clock.now();\n maxFrameGapMs = Math.max(maxFrameGapMs, at - previousFrameEnd);\n const durationMs = (frame.length / rate) * 1000;\n omni.sendAudio(frame);\n onFrame(frame, at);\n if (bounds.first != null && bounds.first >= off && bounds.first < off + frame.length) {\n speechOnsetAt = at + ((bounds.first - off) / rate) * 1000;\n }\n if (bounds.last != null && bounds.last > off && bounds.last <= off + frame.length) {\n speechOffsetAt = at + ((bounds.last - off) / rate) * 1000;\n }\n previousFrameEnd = at + durationMs;\n // Pace from the actual send. Never burst late frames to \"catch up\".\n await clock.sleep(Math.max(0, previousFrameEnd - clock.now()));\n }\n return { startedAt, speechOnsetAt, speechOffsetAt,\n streamEndAt: clock.now(), maxFrameGapMs };\n}\n\nexport async function streamSilenceWhile(omni, rate, shouldContinue, clock, onFrame = () => {}) {\n const silence = new Int16Array(Math.max(1, Math.round((rate * FRAME_MS) / 1000)));\n while (shouldContinue()) {\n const at = clock.now();\n omni.sendAudio(silence);\n onFrame(silence, at);\n await clock.sleep(FRAME_MS);\n }\n}\n\nexport async function waitForAgentSettle(getCtx, clock, opts = {}) {\n const started = clock.now();\n const observation = opts.callerStreamEndAt != null;\n if (observation && (!Number.isFinite(opts.callerStreamEndAt)\n || opts.callerStreamEndAt < 0 || opts.callerStreamEndAt > started)) {\n throw new Error(\"Invalid caller observation boundary\");\n }\n const timeoutMs = observation ? INTERRUPTION_OBSERVATION.maximumAfterCallerStreamMs : opts.timeoutMs ?? 25000;\n const settleMs = observation ? INTERRUPTION_OBSERVATION.quietMs : opts.settleMs ?? 2000;\n const deadlineAt = (observation ? opts.callerStreamEndAt : started) + timeoutMs;\n const minimumAt = observation ? opts.callerStreamEndAt + INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : -Infinity;\n while (true) {\n const ctx = getCtx();\n const at = clock.now();\n if (opts.isClosed?.()) return { reason: \"closed\", at };\n if (observation && at >= deadlineAt) return { reason: \"timeout\", at };\n const hasResponseAudio = !opts.requireTurnBegin || (ctx.latestTurnBeginAt != null\n && ctx.postTurnBeginFirstAudioAt != null);\n // Synthesis text is progress, not a completion event. It can arrive after\n // early/final audio, so extend the quiet floor without demanding another\n // packet after every advisory. The protocol still has no reply-end marker.\n const activityEnd = Math.max(ctx.playbackEndAt ?? -Infinity,\n ctx.latestTurnBeginAt ?? -Infinity, ctx.lastAssistantTranscriptAt ?? -Infinity,\n observation ? ctx.lastCallerTranscriptAt ?? -Infinity : -Infinity);\n // Do not demand a new post-caller turn_begin: an earlier turn can be\n // coalesced and speak later. This records a bounded quiet observation;\n // downstream response-timing checks can impose a stricter begin boundary.\n if (ctx.firstPacketAt != null && hasResponseAudio && at >= minimumAt && at >= activityEnd + settleMs) {\n return { reason: \"settled\", at };\n }\n if (opts.allowEmpty && ctx.firstPacketAt == null && at >= minimumAt && at - started >= (opts.emptyWaitMs ?? 800)) {\n return { reason: \"empty\", at };\n }\n if (at - started >= timeoutMs) return { reason: \"timeout\", at };\n await clock.sleep(observation ? Math.min(20, deadlineAt - at) : 20);\n }\n}\n\nexport async function withTimeout(promise, ms, message, clock) {\n let timer;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timer = clock.setTimeout(() => reject(new Error(message)), ms);\n }),\n ]);\n } finally {\n clock.clearTimeout(timer);\n }\n}\n";
|
|
5
|
+
"main.mjs": "import PyAI from \"@pyai/sdk\";\nimport WebSocket from \"ws\"; // Explicit transport makes this work on Node 20 too.\nimport { readFile, writeFile, mkdtemp } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { runSession } from \"./session.mjs\";\nimport { readWav, writeWav, assessAnswer } from \"./audio.mjs\";\n\nasync function main() {\n const [callerPath, interruptionPath, extra] = process.argv.slice(2);\n if (!callerPath || extra) throw new Error(\"Usage: node main.mjs caller.wav [interruption.wav]\");\n if (!process.env.PYAI_API_KEY) throw new Error(\"Inject PYAI_API_KEY through your environment first\");\n const caller = readWav(await readFile(callerPath));\n const interruption = interruptionPath ? readWav(await readFile(interruptionPath)) : undefined;\n const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY, maxRetries: 0,\n fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(30000) }),\n });\n const { report, audio, replyAudio } = await runSession({ pyai, webSocket: WebSocket, caller, interruption });\n const directory = await mkdtemp(join(process.cwd(), \"omni-run-\"));\n const save = (name, data) => writeFile(join(directory, name), data, { mode: 0o600, flag: \"wx\" });\n await save(\"session.wav\", writeWav(audio, report.output_rate ?? 24000));\n const reply = writeWav(replyAudio, report.output_rate ?? 24000);\n await save(\"reply.wav\", reply);\n // Hear the captured bytes, rather than trusting synthesis-advisory text.\n let capturedTranscript = \"\";\n if (report.reply_audio_bytes) {\n try {\n const heard = await pyai.audio.transcriptions.create({ file: new File([reply], \"reply.wav\", { type: \"audio/wav\" }), model: \"pyai-hear\" });\n capturedTranscript = heard.text;\n } catch { report.hear_error = \"Captured audio saved; Hear verification failed\"; }\n }\n const result = { ...report, captured_transcript: capturedTranscript, evidence: assessAnswer(report, capturedTranscript) };\n await save(\"report.json\", JSON.stringify(result, null, 2) + \"\\n\");\n console.log(JSON.stringify({ directory, ...result }, null, 2));\n if (result.evidence.captured_answer !== \"verified_by_hear\" || (interruption && result.evidence.interruption !== \"simulated_queue_cleared\")) process.exitCode = 1;\n}\n\nmain().catch(error => {\n const message = String(error.message).replaceAll(process.env.PYAI_API_KEY || \"\\0\", \"[REDACTED]\");\n console.error(JSON.stringify({ error: message, ...(error.omni_code ? { code: error.omni_code } : {}) })); process.exitCode = 1;\n});\n";
|
|
6
|
+
"session.mjs": "import { newAudioCapture, recordAudio, recordTurnBegin, streamPcmRealtime, INTERRUPTION_OBSERVATION } from \"./timing.mjs\";\n\nexport const realClock = {\n now: () => performance.now(),\n sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),\n};\n\n// Only recognized machine codes may leave the example. Error messages, call\n// IDs and arbitrary code values can contain credentials or customer content.\nconst publicErrorCodes = new Set([\n \"invalid_configure\", \"unsupported_tool_transport\", \"tool_configuration_locked\",\n \"media_dead\", \"unauthorized\", \"insufficient_scope\", \"rate_limit_exceeded\",\n \"concurrency_limit_exceeded\", \"daily_cap_exceeded\", \"credit_exhausted\",\n]);\nconst transportErrorCodes = new Set([\"ECONNRESET\", \"ECONNREFUSED\", \"ETIMEDOUT\", \"ENOTFOUND\", \"EAI_AGAIN\"]);\nexport function omniFailure(error) {\n const code = error instanceof Error\n ? (transportErrorCodes.has(error.code) ? error.code : \"transport_or_protocol_error\")\n : (publicErrorCodes.has(error?.code) ? error.code : \"unrecognized_server_error\");\n return Object.assign(new Error(`Omni failed (${code}); no automatic retry was attempted`), { omni_code: code });\n}\n\n/** One bounded, server-side example. Its playback queue is simulated. */\nexport async function runSession({ pyai, webSocket, caller, interruption, clock = realClock, timeoutMs = 60000 }) {\n const rate = 24000, frameSize = 480, silence = new Int16Array(frameSize);\n if (!(caller instanceof Int16Array) || !caller.length || caller.length > rate * 20) throw new Error(\"Provide 1–20 seconds of caller PCM16 at 24 kHz\");\n if (interruption && (!(interruption instanceof Int16Array) || !interruption.length || interruption.length > rate * 20)) throw new Error(\"Invalid interruption PCM\");\n const started = clock.now(), all = newAudioCapture(started), reply = newAudioCapture(started);\n const report = { input_rate: rate, output_rate: null, playback_sink: \"simulated\", tool_calls: 0, tool_executions: 0, duplicate_tool_calls: 0, flushes_after_interruption: 0, cleared_queue_ms: 0, interruption_requested: !!interruption, caller_started_ms: null, interruption_started_ms: null, advisory_transcripts: [], end_reason: \"timeout\" };\n let session, configuredAt = null, closed = false, failure = null, phase = \"greeting\", offset = 0, queueEnd = started, interruptionSent = false, inputEndAt = started;\n const handled = new Set();\n const fail = message => { failure ??= new Error(message); };\n try {\n session = pyai.omni.connect({\n webSocket, rate,\n configure: {\n voice_id: \"stock_amos_en_us\", language: \"en\", greeting: \"Hello. How can I help?\",\n persona: \"When asked about office hours, call lookup_office_hours. Never guess. After the result, say one short sentence giving its opening_time.\",\n tools: [{ name: \"lookup_office_hours\", description: \"Read the synthetic example office opening time.\", side_effect: \"read\", parameters: { type: \"object\", properties: {}, required: [], additionalProperties: false } }],\n },\n onHello: frame => {\n const match = /^pcm16@(8000|24000)$/.exec(frame.audio_out ?? \"\");\n if (!match) return fail(\"Unsupported Omni output format\");\n report.output_rate = Number(match[1]);\n },\n onConfigured: () => { configuredAt ??= clock.now(); },\n onAudio: chunk => {\n if (!report.output_rate) return fail(\"Audio arrived before its output format\");\n const bytes = chunk instanceof ArrayBuffer ? new Uint8Array(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);\n if (bytes.byteLength % 2) return fail(\"Invalid PCM16 output\");\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const pcm = Int16Array.from({ length: bytes.length / 2 }, (_, i) => view.getInt16(i * 2, true));\n const now = clock.now();\n recordAudio(all, pcm, now, report.output_rate);\n queueEnd = Math.max(now, queueEnd) + pcm.length / report.output_rate * 1000;\n if (report.tool_executions) recordAudio(reply, pcm, now, report.output_rate);\n },\n onEvent: frame => {\n if (frame.event === \"turn_begin\") recordTurnBegin(all, clock.now(), frame.turn);\n },\n onTranscript: frame => {\n // An assistant advisory is progress; never treat it as received speech.\n if (report.advisory_transcripts.length < 100) report.advisory_transcripts.push({ role: frame.role, text: frame.text });\n if (frame.role === \"assistant\") all.lastAssistantTranscriptAt = clock.now();\n },\n onToolCall: frame => {\n report.tool_calls++;\n if (handled.has(frame.call_id)) { report.duplicate_tool_calls++; return; }\n handled.add(frame.call_id);\n if (frame.name !== \"lookup_office_hours\") { session.toolResult(frame.call_id, { error: \"Unknown example tool\" }); return; }\n // Replace this read-only fixture with your authorized data lookup.\n report.tool_executions++;\n session.toolResult(frame.call_id, { result: { opening_time: \"9 a.m.\", source: \"synthetic example office\" } });\n },\n onBargeIn: () => {\n const now = clock.now(), queued = Math.max(0, queueEnd - now);\n if (report.interruption_started_ms != null) { report.flushes_after_interruption++; report.cleared_queue_ms += queued; }\n // A real speaker adapter must cancel scheduled and currently playing audio.\n queueEnd = now; all.playbackEndAt = now; reply.playbackEndAt = now;\n },\n onError: error => { failure ??= omniFailure(error); },\n onClose: () => { closed = true; },\n });\n while (clock.now() - started < timeoutMs) {\n if (failure) throw failure;\n if (closed) { report.end_reason = \"closed\"; break; }\n const now = clock.now();\n if (configuredAt == null || !report.output_rate) {\n if (now - started >= 10000) throw new Error(\"Omni connection/configuration timed out\");\n await clock.sleep(20); continue;\n }\n const quietAt = Math.max(queueEnd, all.lastPacketAt ?? now, all.lastAssistantTranscriptAt ?? 0, inputEndAt);\n if (phase === \"greeting\") {\n if (all.firstPacketAt != null && now - configuredAt >= 2000 && now >= quietAt + 800) {\n phase = \"caller\"; report.caller_started_ms = now - started;\n } else if (now - configuredAt > 15000) throw new Error(\"Greeting did not drain within 15 seconds\");\n }\n if (phase === \"reply\" && interruption && !interruptionSent && reply.samples && queueEnd - now >= 200) {\n phase = \"interruption\"; offset = 0; interruptionSent = true; report.interruption_started_ms = now - started;\n }\n const observationEnd = inputEndAt + (interruptionSent ? INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : 0);\n if (phase === \"reply\" && reply.samples && now >= quietAt + 2000 && now >= observationEnd && (!interruption || interruptionSent)) {\n report.end_reason = \"quiet_window\"; break;\n }\n // Exactly one producer: each 20 ms slot contains caller PCM OR silence.\n let pcm = silence;\n if (phase === \"caller\" || phase === \"interruption\") {\n const source = phase === \"caller\" ? caller : interruption;\n pcm = source.subarray(offset, offset + frameSize); offset += pcm.length;\n if (offset >= source.length) {\n inputEndAt = now + pcm.length / rate * 1000;\n report[phase === \"caller\" ? \"caller_completed_ms\" : \"interruption_completed_ms\"] = inputEndAt - started;\n phase = \"reply\"; offset = 0;\n }\n }\n await streamPcmRealtime(session, pcm, rate, clock);\n }\n report.audio_bytes = all.samples * 2;\n report.reply_audio_bytes = reply.samples * 2;\n report.elapsed_ms = Math.round(clock.now() - started);\n return { report, audio: all.pcm, replyAudio: reply.pcm };\n } finally { session?.close(); }\n}\n";
|
|
7
|
+
"audio.mjs": "// Small, strict WAV boundary: the wire receives samples, never WAV headers.\nexport function readWav(bytes) {\n const b = Buffer.from(bytes);\n if (b.length < 44 || b.toString(\"ascii\", 0, 4) !== \"RIFF\" || b.toString(\"ascii\", 8, 12) !== \"WAVE\") throw new Error(\"Expected a PCM16 mono WAV file\");\n const end = b.readUInt32LE(4) + 8;\n if (end > b.length) throw new Error(\"Truncated WAV file\");\n let rate, data;\n for (let at = 12; at + 8 <= end;) {\n const name = b.toString(\"ascii\", at, at + 4), size = b.readUInt32LE(at + 4);\n const start = at + 8;\n if (start + size > end) throw new Error(\"Truncated WAV chunk\");\n if (name === \"fmt \") {\n if (size < 16 || b.readUInt16LE(start) !== 1 || b.readUInt16LE(start + 2) !== 1 || b.readUInt16LE(start + 14) !== 16) throw new Error(\"Convert input to PCM16 mono WAV first\");\n rate = b.readUInt32LE(start + 4);\n } else if (name === \"data\") data = b.subarray(start, start + size);\n at = start + size + (size % 2);\n }\n if (rate !== 24000 || !data?.length || data.length % 2 || data.length > rate * 2 * 20) throw new Error(\"Use a non-empty 24 kHz PCM16 mono WAV, at most 20 seconds\");\n return Int16Array.from({ length: data.length / 2 }, (_, i) => data.readInt16LE(i * 2));\n}\n\nexport function writeWav(chunks, rate) {\n const samples = chunks.reduce((n, pcm) => n + pcm.length, 0);\n const b = Buffer.alloc(44 + samples * 2);\n b.write(\"RIFF\", 0); b.writeUInt32LE(b.length - 8, 4); b.write(\"WAVEfmt \", 8);\n b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);\n b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28);\n b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write(\"data\", 36);\n b.writeUInt32LE(samples * 2, 40);\n let at = 44;\n for (const pcm of chunks) for (const value of pcm) { b.writeInt16LE(value, at); at += 2; }\n return b;\n}\n\nexport function assessAnswer(report, capturedTranscript) {\n // Conservative fixture check, not a general semantic evaluator. Normalize\n // Hear's formatting without accepting negated, conflicting or partial claims.\n const text = String(capturedTranscript ?? \"\").normalize(\"NFKC\").toLowerCase()\n .replace(/@/g, \" at \")\n .replace(/(?<![a-z])([ap])\\s*\\.?\\s*m\\b\\.?/g, \" $1m \")\n .replace(/[.,!…]/g, \" \").replace(/\\s+/g, \" \").trim();\n const expected = /^(?:(?:sure|yes|okay|ok|certainly) )?(?:(?:the|our) )?office (?:opens|(?:is )?opening|will open) (?:at )?(?:nine|9(?::00)?) (?:am|in the morning)(?: today)?$/.test(text);\n return {\n audio_received: report.reply_audio_bytes > 0,\n captured_answer: report.reply_audio_bytes > 0 && report.end_reason === \"quiet_window\" && report.tool_executions === 1 && expected ? \"verified_by_hear\" : \"not_verified\",\n capture_boundary: \"bounded_quiet_window_no_protocol_reply_end\",\n physical_playback: \"not_tested\",\n interruption: report.interruption_requested ? (report.flushes_after_interruption > 0 && report.cleared_queue_ms > 0 ? \"simulated_queue_cleared\" : \"not_verified\") : \"not_tested\",\n };\n}\n";
|
|
8
|
+
".env.example": "PYAI_API_KEY=\n";
|
|
9
|
+
".gitignore": "node_modules/\n.env\nomni-run-*/\n*.wav\n";
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Generated by scripts/generate-omni-starter.mjs; edit examples/omni-starter instead.
|
|
2
|
+
export const omniTemplate = Object.freeze({
|
|
3
|
+
"README.md": "# Runnable Omni starter\n\nRequires Node 20.19+ and ESM. Node 20 uses the included `ws` transport;\nNode 22 also works. The separate MCP server requires Node 22+.\nThis server-side example uses a synthetic read-only office-hours tool. Read\n`PYAI.md` when generating the project through the CLI.\n\n<!-- omni-install:start -->\n```sh\nnpm install @pyai/sdk@0.7.1\nnpx pyai init voice-demo --template omni\ncd voice-demo\nnpm install\n```\n\nInitialization is offline; installing dependencies and running `main.mjs` are\nseparate steps. For an existing checkout of this example, run `npm install`\nin its directory instead.\n<!-- omni-install:end -->\n\nInject `PYAI_API_KEY` through your secret manager or shell environment. Do not\npaste a real key into source, a prompt, or shell history. `.env.example` documents\nthe variable; this program does not load `.env` or saved CLI profiles.\n\nPrepare a 24 kHz PCM16 mono WAV, at most 20 seconds, saying “Please look up the\noffice opening time.” Then run:\n\n```sh\nnode main.mjs caller.wav\n```\n\nRunning the program explicitly opens one bounded Omni session and uploads its\ncaptured reply to Hear. Both consume your key's allowed usage. Automatic retries\nare disabled. No managed number is bought and no phone call is placed.\n\n`session.mjs` waits for configuration and the greeting to drain, sends exactly\none paced input stream (caller PCM or silence), returns the read tool's result,\nand captures output using the rate from `hello.audio_out`. Never run a separate\nsilence timer alongside caller audio. `timing.mjs` is generated from the existing\nOmni evaluation harness's timing utilities.\n\nThe new private `omni-run-*` folder contains `session.wav`, `reply.wav`, and\n`report.json`. The report separates received audio, an office-hours answer\nrecovered by Hear from captured audio, and physical playback (not tested).\nThe office-hours check accepts formatting such as `opens@9a.m`, `9 a.m.` and\n`nine in the morning` in a complete affirmative opening-time sentence. Wrong\ntimes, negation, conflicting or truncated text remain unverified. This is a\nconservative check for the example fact, not a general answer-quality score.\nA synthesis transcript is advisory; it is not evidence that speech arrived.\nCapture ends after the simulated queue drains and two seconds of quiet. Omni\nhas no protocol reply-end marker, so this is a bounded capture heuristic.\nListen to the saved file when qualifying actual sound quality or completeness.\n\nTo exercise interruption, supply a second WAV saying “Stop speaking now”:\n\n```sh\nnode main.mjs caller.wav interruption.wav\n```\n\nThe second clip starts while reply audio is queued. `onBargeIn` clears that\nsimulated queue, and capture observes at least five seconds after that clip ends.\nAn interrupted answer can correctly fail the full-answer check;\ninspect the separate interruption evidence. This is not a physical speaker test.\nFor a real playback adapter, cancel both queued and currently playing audio.\nReplace the example lookup with authorized application data before product use.\n\nOn a session error, the program exits with JSON containing a recognized machine\n`code` (for example `media_dead` or `invalid_configure`). Unknown server codes\nbecome `unrecognized_server_error`; transport or decoding errors without a\nrecognized transport code become `transport_or_protocol_error`. Raw error text,\ncall IDs and unknown code values are omitted. No automatic retry is attempted;\ninspect the code and your authorized diagnostics before deciding to retry.\n",
|
|
4
|
+
"package.json": "{\n \"name\": \"pyai-omni-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20.19.0\"\n },\n \"scripts\": {\n \"start\": \"node main.mjs\"\n },\n \"dependencies\": {\n \"@pyai/sdk\": \"0.7.1\",\n \"ws\": \"^8.18.3\"\n }\n}\n",
|
|
5
|
+
"timing.mjs": "// Monotonic capture timing. Arrival time is distinct from queued playback:\n// a single packet may contain seconds of audio and must finish before settling.\nexport const FRAME_MS = 20;\nexport const REALTIME_GAP_LIMIT_MS = 100;\n// Interruption tests need an observation window after caller transmission,\n// even when an earlier cue/response already finished. These are test bounds,\n// not an inferred engine reply-end or a naturalness threshold.\nexport const INTERRUPTION_OBSERVATION = Object.freeze({\n minimumAfterCallerStreamMs: 5000, quietMs: 2000, maximumAfterCallerStreamMs: 25000,\n});\n\n/** Approximate audible bounds; this is energy detection, not a speech/VAD claim. */\nexport function audibleBounds(pcm, rate, rmsFloor = 160) {\n const window = Math.max(1, Math.round(rate / 100)); // 10 ms windows\n let first = null;\n let last = null;\n for (let off = 0; off < pcm.length; off += window) {\n const end = Math.min(pcm.length, off + window);\n let energy = 0;\n for (let i = off; i < end; i++) energy += pcm[i] * pcm[i];\n if (Math.sqrt(energy / (end - off)) >= rmsFloor) {\n first ??= off;\n last = end;\n }\n }\n return { first, last };\n}\n\nexport function newAudioCapture(started) {\n return {\n started, firstPacketAt: null, lastPacketAt: null,\n firstAudioAt: null, lastAudioAt: null, playbackEndAt: null,\n samples: 0, pcm: [], turnBeginAt: null, eouMs: null,\n latestTurnBeginAt: null, postTurnBeginFirstPacketAt: null,\n postTurnBeginFirstAudioAt: null, lastAssistantTranscriptAt: null, lastCallerTranscriptAt: null,\n callerTranscriptEvents: [], assistantTranscriptEvents: [],\n turnBegins: [], tools: [], toolResults: [], kb: null, events: [],\n };\n}\n\nexport function recordTurnBegin(ctx, at, turn, origin = 0) {\n ctx.turnBeginAt ??= at;\n ctx.latestTurnBeginAt = at;\n // Earlier PCM can be a listening cue. A new reply is still pending even\n // when that cue has already finished playing; retain all of its PCM.\n ctx.postTurnBeginFirstPacketAt = null;\n ctx.postTurnBeginFirstAudioAt = null;\n ctx.turnBegins.push({ atMs: Math.round(at - origin), turn: turn ?? null });\n}\n\nexport function recordAudio(ctx, pcm, at, rate) {\n if (!(pcm instanceof Int16Array) || !pcm.length) return null;\n const copy = pcm.slice(); // Some transports reuse callback buffers.\n const playbackAt = Math.max(at, ctx.playbackEndAt ?? at);\n const endAt = playbackAt + (copy.length / rate) * 1000;\n const bounds = audibleBounds(copy, rate);\n ctx.firstPacketAt ??= at;\n ctx.lastPacketAt = at;\n ctx.playbackEndAt = endAt;\n const afterTurnBegin = ctx.latestTurnBeginAt != null && at >= ctx.latestTurnBeginAt;\n if (afterTurnBegin) ctx.postTurnBeginFirstPacketAt ??= at;\n if (bounds.first != null) {\n ctx.firstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n ctx.lastAudioAt = playbackAt + (bounds.last / rate) * 1000;\n if (afterTurnBegin) ctx.postTurnBeginFirstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n }\n ctx.samples += copy.length;\n ctx.pcm.push(copy);\n return { playbackAt, endAt, pcm: copy };\n}\n\n/** Preserve negative gaps: they are overlaps, never missing measurements. */\nexport function captureTiming(ctx, caller, origin = 0) {\n const relative = (at) => at == null ? null : Math.round(at - origin);\n const gap = (at) => at == null || caller.speechOffsetAt == null\n ? null : Math.round(at - caller.speechOffsetAt);\n return {\n ttfbMs: gap(ctx.firstAudioAt),\n anyAudioTtfbMs: gap(ctx.firstAudioAt),\n postTurnBeginTtfbMs: gap(ctx.postTurnBeginFirstAudioAt),\n turnMs: gap(ctx.lastAudioAt),\n packetTtfbMs: gap(ctx.firstPacketAt),\n callerStartMs: relative(caller.startedAt),\n callerSpeechOnsetMs: relative(caller.speechOnsetAt),\n callerSpeechOffsetMs: relative(caller.speechOffsetAt),\n callerStreamEndMs: relative(caller.streamEndAt),\n agentFirstPacketMs: relative(ctx.firstPacketAt),\n agentLastPacketMs: relative(ctx.lastPacketAt),\n agentSpeechOnsetMs: relative(ctx.firstAudioAt),\n agentSpeechOffsetMs: relative(ctx.lastAudioAt),\n agentPlaybackEndMs: relative(ctx.playbackEndAt),\n latestTurnBeginMs: relative(ctx.latestTurnBeginAt),\n postTurnBeginFirstPacketMs: relative(ctx.postTurnBeginFirstPacketAt),\n postTurnBeginSpeechOnsetMs: relative(ctx.postTurnBeginFirstAudioAt),\n lastAssistantTranscriptMs: relative(ctx.lastAssistantTranscriptAt),\n callerMaxFrameGapMs: Math.round(caller.maxFrameGapMs ?? 0),\n method: \"client-monotonic-queued-playback-energy-bounds\",\n };\n}\n\nexport async function streamPcmRealtime(omni, pcm, rate, clock, onFrame = () => {}) {\n const frameSize = Math.max(1, Math.round((rate * FRAME_MS) / 1000));\n const bounds = audibleBounds(pcm, rate);\n const startedAt = clock.now();\n let speechOnsetAt = null;\n let speechOffsetAt = null;\n let previousFrameEnd = startedAt;\n let maxFrameGapMs = 0;\n for (let off = 0; off < pcm.length; off += frameSize) {\n const frame = pcm.subarray(off, Math.min(off + frameSize, pcm.length));\n const at = clock.now();\n maxFrameGapMs = Math.max(maxFrameGapMs, at - previousFrameEnd);\n const durationMs = (frame.length / rate) * 1000;\n omni.sendAudio(frame);\n onFrame(frame, at);\n if (bounds.first != null && bounds.first >= off && bounds.first < off + frame.length) {\n speechOnsetAt = at + ((bounds.first - off) / rate) * 1000;\n }\n if (bounds.last != null && bounds.last > off && bounds.last <= off + frame.length) {\n speechOffsetAt = at + ((bounds.last - off) / rate) * 1000;\n }\n previousFrameEnd = at + durationMs;\n // Pace from the actual send. Never burst late frames to \"catch up\".\n await clock.sleep(Math.max(0, previousFrameEnd - clock.now()));\n }\n return { startedAt, speechOnsetAt, speechOffsetAt,\n streamEndAt: clock.now(), maxFrameGapMs };\n}\n\nexport async function streamSilenceWhile(omni, rate, shouldContinue, clock, onFrame = () => {}) {\n const silence = new Int16Array(Math.max(1, Math.round((rate * FRAME_MS) / 1000)));\n while (shouldContinue()) {\n const at = clock.now();\n omni.sendAudio(silence);\n onFrame(silence, at);\n await clock.sleep(FRAME_MS);\n }\n}\n\nexport async function waitForAgentSettle(getCtx, clock, opts = {}) {\n const started = clock.now();\n const observation = opts.callerStreamEndAt != null;\n if (observation && (!Number.isFinite(opts.callerStreamEndAt)\n || opts.callerStreamEndAt < 0 || opts.callerStreamEndAt > started)) {\n throw new Error(\"Invalid caller observation boundary\");\n }\n const timeoutMs = observation ? INTERRUPTION_OBSERVATION.maximumAfterCallerStreamMs : opts.timeoutMs ?? 25000;\n const settleMs = observation ? INTERRUPTION_OBSERVATION.quietMs : opts.settleMs ?? 2000;\n const deadlineAt = (observation ? opts.callerStreamEndAt : started) + timeoutMs;\n const minimumAt = observation ? opts.callerStreamEndAt + INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : -Infinity;\n while (true) {\n const ctx = getCtx();\n const at = clock.now();\n if (opts.isClosed?.()) return { reason: \"closed\", at };\n if (observation && at >= deadlineAt) return { reason: \"timeout\", at };\n const hasResponseAudio = !opts.requireTurnBegin || (ctx.latestTurnBeginAt != null\n && ctx.postTurnBeginFirstAudioAt != null);\n // Synthesis text is progress, not a completion event. It can arrive after\n // early/final audio, so extend the quiet floor without demanding another\n // packet after every advisory. The protocol still has no reply-end marker.\n const activityEnd = Math.max(ctx.playbackEndAt ?? -Infinity,\n ctx.latestTurnBeginAt ?? -Infinity, ctx.lastAssistantTranscriptAt ?? -Infinity,\n observation ? ctx.lastCallerTranscriptAt ?? -Infinity : -Infinity);\n // Do not demand a new post-caller turn_begin: an earlier turn can be\n // coalesced and speak later. This records a bounded quiet observation;\n // downstream response-timing checks can impose a stricter begin boundary.\n if (ctx.firstPacketAt != null && hasResponseAudio && at >= minimumAt && at >= activityEnd + settleMs) {\n return { reason: \"settled\", at };\n }\n if (opts.allowEmpty && ctx.firstPacketAt == null && at >= minimumAt && at - started >= (opts.emptyWaitMs ?? 800)) {\n return { reason: \"empty\", at };\n }\n if (at - started >= timeoutMs) return { reason: \"timeout\", at };\n await clock.sleep(observation ? Math.min(20, deadlineAt - at) : 20);\n }\n}\n\nexport async function withTimeout(promise, ms, message, clock) {\n let timer;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timer = clock.setTimeout(() => reject(new Error(message)), ms);\n }),\n ]);\n } finally {\n clock.clearTimeout(timer);\n }\n}\n",
|
|
6
|
+
"main.mjs": "import PyAI from \"@pyai/sdk\";\nimport WebSocket from \"ws\"; // Explicit transport makes this work on Node 20 too.\nimport { readFile, writeFile, mkdtemp } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { runSession } from \"./session.mjs\";\nimport { readWav, writeWav, assessAnswer } from \"./audio.mjs\";\n\nasync function main() {\n const [callerPath, interruptionPath, extra] = process.argv.slice(2);\n if (!callerPath || extra) throw new Error(\"Usage: node main.mjs caller.wav [interruption.wav]\");\n if (!process.env.PYAI_API_KEY) throw new Error(\"Inject PYAI_API_KEY through your environment first\");\n const caller = readWav(await readFile(callerPath));\n const interruption = interruptionPath ? readWav(await readFile(interruptionPath)) : undefined;\n const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY, maxRetries: 0,\n fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(30000) }),\n });\n const { report, audio, replyAudio } = await runSession({ pyai, webSocket: WebSocket, caller, interruption });\n const directory = await mkdtemp(join(process.cwd(), \"omni-run-\"));\n const save = (name, data) => writeFile(join(directory, name), data, { mode: 0o600, flag: \"wx\" });\n await save(\"session.wav\", writeWav(audio, report.output_rate ?? 24000));\n const reply = writeWav(replyAudio, report.output_rate ?? 24000);\n await save(\"reply.wav\", reply);\n // Hear the captured bytes, rather than trusting synthesis-advisory text.\n let capturedTranscript = \"\";\n if (report.reply_audio_bytes) {\n try {\n const heard = await pyai.audio.transcriptions.create({ file: new File([reply], \"reply.wav\", { type: \"audio/wav\" }), model: \"pyai-hear\" });\n capturedTranscript = heard.text;\n } catch { report.hear_error = \"Captured audio saved; Hear verification failed\"; }\n }\n const result = { ...report, captured_transcript: capturedTranscript, evidence: assessAnswer(report, capturedTranscript) };\n await save(\"report.json\", JSON.stringify(result, null, 2) + \"\\n\");\n console.log(JSON.stringify({ directory, ...result }, null, 2));\n if (result.evidence.captured_answer !== \"verified_by_hear\" || (interruption && result.evidence.interruption !== \"simulated_queue_cleared\")) process.exitCode = 1;\n}\n\nmain().catch(error => {\n const message = String(error.message).replaceAll(process.env.PYAI_API_KEY || \"\\0\", \"[REDACTED]\");\n console.error(JSON.stringify({ error: message, ...(error.omni_code ? { code: error.omni_code } : {}) })); process.exitCode = 1;\n});\n",
|
|
7
|
+
"session.mjs": "import { newAudioCapture, recordAudio, recordTurnBegin, streamPcmRealtime, INTERRUPTION_OBSERVATION } from \"./timing.mjs\";\n\nexport const realClock = {\n now: () => performance.now(),\n sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),\n};\n\n// Only recognized machine codes may leave the example. Error messages, call\n// IDs and arbitrary code values can contain credentials or customer content.\nconst publicErrorCodes = new Set([\n \"invalid_configure\", \"unsupported_tool_transport\", \"tool_configuration_locked\",\n \"media_dead\", \"unauthorized\", \"insufficient_scope\", \"rate_limit_exceeded\",\n \"concurrency_limit_exceeded\", \"daily_cap_exceeded\", \"credit_exhausted\",\n]);\nconst transportErrorCodes = new Set([\"ECONNRESET\", \"ECONNREFUSED\", \"ETIMEDOUT\", \"ENOTFOUND\", \"EAI_AGAIN\"]);\nexport function omniFailure(error) {\n const code = error instanceof Error\n ? (transportErrorCodes.has(error.code) ? error.code : \"transport_or_protocol_error\")\n : (publicErrorCodes.has(error?.code) ? error.code : \"unrecognized_server_error\");\n return Object.assign(new Error(`Omni failed (${code}); no automatic retry was attempted`), { omni_code: code });\n}\n\n/** One bounded, server-side example. Its playback queue is simulated. */\nexport async function runSession({ pyai, webSocket, caller, interruption, clock = realClock, timeoutMs = 60000 }) {\n const rate = 24000, frameSize = 480, silence = new Int16Array(frameSize);\n if (!(caller instanceof Int16Array) || !caller.length || caller.length > rate * 20) throw new Error(\"Provide 1–20 seconds of caller PCM16 at 24 kHz\");\n if (interruption && (!(interruption instanceof Int16Array) || !interruption.length || interruption.length > rate * 20)) throw new Error(\"Invalid interruption PCM\");\n const started = clock.now(), all = newAudioCapture(started), reply = newAudioCapture(started);\n const report = { input_rate: rate, output_rate: null, playback_sink: \"simulated\", tool_calls: 0, tool_executions: 0, duplicate_tool_calls: 0, flushes_after_interruption: 0, cleared_queue_ms: 0, interruption_requested: !!interruption, caller_started_ms: null, interruption_started_ms: null, advisory_transcripts: [], end_reason: \"timeout\" };\n let session, configuredAt = null, closed = false, failure = null, phase = \"greeting\", offset = 0, queueEnd = started, interruptionSent = false, inputEndAt = started;\n const handled = new Set();\n const fail = message => { failure ??= new Error(message); };\n try {\n session = pyai.omni.connect({\n webSocket, rate,\n configure: {\n voice_id: \"stock_amos_en_us\", language: \"en\", greeting: \"Hello. How can I help?\",\n persona: \"When asked about office hours, call lookup_office_hours. Never guess. After the result, say one short sentence giving its opening_time.\",\n tools: [{ name: \"lookup_office_hours\", description: \"Read the synthetic example office opening time.\", side_effect: \"read\", parameters: { type: \"object\", properties: {}, required: [], additionalProperties: false } }],\n },\n onHello: frame => {\n const match = /^pcm16@(8000|24000)$/.exec(frame.audio_out ?? \"\");\n if (!match) return fail(\"Unsupported Omni output format\");\n report.output_rate = Number(match[1]);\n },\n onConfigured: () => { configuredAt ??= clock.now(); },\n onAudio: chunk => {\n if (!report.output_rate) return fail(\"Audio arrived before its output format\");\n const bytes = chunk instanceof ArrayBuffer ? new Uint8Array(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);\n if (bytes.byteLength % 2) return fail(\"Invalid PCM16 output\");\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const pcm = Int16Array.from({ length: bytes.length / 2 }, (_, i) => view.getInt16(i * 2, true));\n const now = clock.now();\n recordAudio(all, pcm, now, report.output_rate);\n queueEnd = Math.max(now, queueEnd) + pcm.length / report.output_rate * 1000;\n if (report.tool_executions) recordAudio(reply, pcm, now, report.output_rate);\n },\n onEvent: frame => {\n if (frame.event === \"turn_begin\") recordTurnBegin(all, clock.now(), frame.turn);\n },\n onTranscript: frame => {\n // An assistant advisory is progress; never treat it as received speech.\n if (report.advisory_transcripts.length < 100) report.advisory_transcripts.push({ role: frame.role, text: frame.text });\n if (frame.role === \"assistant\") all.lastAssistantTranscriptAt = clock.now();\n },\n onToolCall: frame => {\n report.tool_calls++;\n if (handled.has(frame.call_id)) { report.duplicate_tool_calls++; return; }\n handled.add(frame.call_id);\n if (frame.name !== \"lookup_office_hours\") { session.toolResult(frame.call_id, { error: \"Unknown example tool\" }); return; }\n // Replace this read-only fixture with your authorized data lookup.\n report.tool_executions++;\n session.toolResult(frame.call_id, { result: { opening_time: \"9 a.m.\", source: \"synthetic example office\" } });\n },\n onBargeIn: () => {\n const now = clock.now(), queued = Math.max(0, queueEnd - now);\n if (report.interruption_started_ms != null) { report.flushes_after_interruption++; report.cleared_queue_ms += queued; }\n // A real speaker adapter must cancel scheduled and currently playing audio.\n queueEnd = now; all.playbackEndAt = now; reply.playbackEndAt = now;\n },\n onError: error => { failure ??= omniFailure(error); },\n onClose: () => { closed = true; },\n });\n while (clock.now() - started < timeoutMs) {\n if (failure) throw failure;\n if (closed) { report.end_reason = \"closed\"; break; }\n const now = clock.now();\n if (configuredAt == null || !report.output_rate) {\n if (now - started >= 10000) throw new Error(\"Omni connection/configuration timed out\");\n await clock.sleep(20); continue;\n }\n const quietAt = Math.max(queueEnd, all.lastPacketAt ?? now, all.lastAssistantTranscriptAt ?? 0, inputEndAt);\n if (phase === \"greeting\") {\n if (all.firstPacketAt != null && now - configuredAt >= 2000 && now >= quietAt + 800) {\n phase = \"caller\"; report.caller_started_ms = now - started;\n } else if (now - configuredAt > 15000) throw new Error(\"Greeting did not drain within 15 seconds\");\n }\n if (phase === \"reply\" && interruption && !interruptionSent && reply.samples && queueEnd - now >= 200) {\n phase = \"interruption\"; offset = 0; interruptionSent = true; report.interruption_started_ms = now - started;\n }\n const observationEnd = inputEndAt + (interruptionSent ? INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : 0);\n if (phase === \"reply\" && reply.samples && now >= quietAt + 2000 && now >= observationEnd && (!interruption || interruptionSent)) {\n report.end_reason = \"quiet_window\"; break;\n }\n // Exactly one producer: each 20 ms slot contains caller PCM OR silence.\n let pcm = silence;\n if (phase === \"caller\" || phase === \"interruption\") {\n const source = phase === \"caller\" ? caller : interruption;\n pcm = source.subarray(offset, offset + frameSize); offset += pcm.length;\n if (offset >= source.length) {\n inputEndAt = now + pcm.length / rate * 1000;\n report[phase === \"caller\" ? \"caller_completed_ms\" : \"interruption_completed_ms\"] = inputEndAt - started;\n phase = \"reply\"; offset = 0;\n }\n }\n await streamPcmRealtime(session, pcm, rate, clock);\n }\n report.audio_bytes = all.samples * 2;\n report.reply_audio_bytes = reply.samples * 2;\n report.elapsed_ms = Math.round(clock.now() - started);\n return { report, audio: all.pcm, replyAudio: reply.pcm };\n } finally { session?.close(); }\n}\n",
|
|
8
|
+
"audio.mjs": "// Small, strict WAV boundary: the wire receives samples, never WAV headers.\nexport function readWav(bytes) {\n const b = Buffer.from(bytes);\n if (b.length < 44 || b.toString(\"ascii\", 0, 4) !== \"RIFF\" || b.toString(\"ascii\", 8, 12) !== \"WAVE\") throw new Error(\"Expected a PCM16 mono WAV file\");\n const end = b.readUInt32LE(4) + 8;\n if (end > b.length) throw new Error(\"Truncated WAV file\");\n let rate, data;\n for (let at = 12; at + 8 <= end;) {\n const name = b.toString(\"ascii\", at, at + 4), size = b.readUInt32LE(at + 4);\n const start = at + 8;\n if (start + size > end) throw new Error(\"Truncated WAV chunk\");\n if (name === \"fmt \") {\n if (size < 16 || b.readUInt16LE(start) !== 1 || b.readUInt16LE(start + 2) !== 1 || b.readUInt16LE(start + 14) !== 16) throw new Error(\"Convert input to PCM16 mono WAV first\");\n rate = b.readUInt32LE(start + 4);\n } else if (name === \"data\") data = b.subarray(start, start + size);\n at = start + size + (size % 2);\n }\n if (rate !== 24000 || !data?.length || data.length % 2 || data.length > rate * 2 * 20) throw new Error(\"Use a non-empty 24 kHz PCM16 mono WAV, at most 20 seconds\");\n return Int16Array.from({ length: data.length / 2 }, (_, i) => data.readInt16LE(i * 2));\n}\n\nexport function writeWav(chunks, rate) {\n const samples = chunks.reduce((n, pcm) => n + pcm.length, 0);\n const b = Buffer.alloc(44 + samples * 2);\n b.write(\"RIFF\", 0); b.writeUInt32LE(b.length - 8, 4); b.write(\"WAVEfmt \", 8);\n b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);\n b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28);\n b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write(\"data\", 36);\n b.writeUInt32LE(samples * 2, 40);\n let at = 44;\n for (const pcm of chunks) for (const value of pcm) { b.writeInt16LE(value, at); at += 2; }\n return b;\n}\n\nexport function assessAnswer(report, capturedTranscript) {\n // Conservative fixture check, not a general semantic evaluator. Normalize\n // Hear's formatting without accepting negated, conflicting or partial claims.\n const text = String(capturedTranscript ?? \"\").normalize(\"NFKC\").toLowerCase()\n .replace(/@/g, \" at \")\n .replace(/(?<![a-z])([ap])\\s*\\.?\\s*m\\b\\.?/g, \" $1m \")\n .replace(/[.,!…]/g, \" \").replace(/\\s+/g, \" \").trim();\n const expected = /^(?:(?:sure|yes|okay|ok|certainly) )?(?:(?:the|our) )?office (?:opens|(?:is )?opening|will open) (?:at )?(?:nine|9(?::00)?) (?:am|in the morning)(?: today)?$/.test(text);\n return {\n audio_received: report.reply_audio_bytes > 0,\n captured_answer: report.reply_audio_bytes > 0 && report.end_reason === \"quiet_window\" && report.tool_executions === 1 && expected ? \"verified_by_hear\" : \"not_verified\",\n capture_boundary: \"bounded_quiet_window_no_protocol_reply_end\",\n physical_playback: \"not_tested\",\n interruption: report.interruption_requested ? (report.flushes_after_interruption > 0 && report.cleared_queue_ms > 0 ? \"simulated_queue_cleared\" : \"not_verified\") : \"not_tested\",\n };\n}\n",
|
|
9
|
+
".env.example": "PYAI_API_KEY=\n",
|
|
10
|
+
".gitignore": "node_modules/\n.env\nomni-run-*/\n*.wav\n"
|
|
11
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -405,7 +405,7 @@ async function main() {
|
|
|
405
405
|
if (flags.json)
|
|
406
406
|
output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
|
|
407
407
|
else if (!spec && !group)
|
|
408
|
-
process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template
|
|
408
|
+
process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template omni Create a runnable Omni starter offline\n\nCalling\n pyai numbers list List owned phone numbers\n pyai numbers search --country US Find available numbers\n pyai calls list Inspect outbound calls\n pyai recipes calling --json Setup and preview examples\n pyai calls create --data @call.json --idempotency-key REQUEST_KEY --dry-run\n Preview a call without submitting it\n --dry-run previews a change without sending the request.\n Number purchases and outbound calls require --confirm and --idempotency-key.\n Use --confirm only after that purchase or call is authorized.\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd numbers calls omni\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
|
|
409
409
|
else
|
|
410
410
|
process.stdout.write(`PyAI CLI — speech, agents, and automation\n\n${visible.map(c => ` pyai ${c.command}${(c.args ?? []).map(a => c.optionalArgs ? ` [${a}${c.variadic ? "..." : ""}]` : ` <${a}>`).join("")}\n ${c.description}${c.flags ? `\n Options: ${Object.keys(c.flags).map(k => `--${k}${c.flags[k] === "boolean" ? "" : " VALUE"}`).join(" ")}` : ""}${c.example ? `\n ${c.example}` : ""}`).join("\n")}\n\nGlobal: --profile NAME --json --timeout SECONDS --retries N --dry-run\n --base-url URL --api-key KEY --help --version\nShort flags: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nAuth: pyai login or PYAI_API_KEY. Discover: pyai schema --json\n`);
|
|
411
411
|
return;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Thin, dependency-free wrapper over the public OpenAI-compatible surface at
|
|
5
5
|
* https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
|
|
6
|
-
* the browser and Node
|
|
6
|
+
* the browser and Node 18+ for REST. Realtime needs an injected WebSocket
|
|
7
|
+
* on Node 20; Node 22+ provides a global WebSocket. Keys are opaque, never parsed.
|
|
7
8
|
*/
|
|
8
9
|
export interface PyAIOptions {
|
|
9
10
|
/** A pyai_live_ or pyai_test_ key. */
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Thin, dependency-free wrapper over the public OpenAI-compatible surface at
|
|
5
5
|
* https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
|
|
6
|
-
* the browser and Node
|
|
6
|
+
* the browser and Node 18+ for REST. Realtime needs an injected WebSocket
|
|
7
|
+
* on Node 20; Node 22+ provides a global WebSocket. Keys are opaque, never parsed.
|
|
7
8
|
*/
|
|
8
9
|
/** Stable, machine-readable error. Branch on `code`, not `message`. */
|
|
9
10
|
export class PyAIError extends Error {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyai/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Official TypeScript/JavaScript SDK for PyAI, speech-to-text (Hear), text-to-speech (Speak), realtime voice agents (Omni), and call compliance (Trace).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"bugs": "https://github.com/atomsai/pyai-platform-backend/issues",
|
|
32
32
|
"author": "PyAI",
|
|
33
33
|
"scripts": {
|
|
34
|
-
"gen:cli-assets": "node scripts/generate-cli-assets.mjs",
|
|
35
|
-
"check:cli-assets": "node scripts/generate-cli-assets.mjs --check",
|
|
34
|
+
"gen:cli-assets": "node ../../scripts/generate-omni-starter.mjs && node scripts/generate-cli-assets.mjs",
|
|
35
|
+
"check:cli-assets": "node ../../scripts/generate-omni-starter.mjs --check && node scripts/generate-cli-assets.mjs --check",
|
|
36
36
|
"build": "tsc -p tsconfig.build.json",
|
|
37
37
|
"test": "node --test test/**/*.test.ts",
|
|
38
38
|
"typecheck": "tsc --noEmit"
|
package/src/cli-dx.ts
CHANGED
|
@@ -35,5 +35,5 @@ export const recipes: Recipe[] = [
|
|
|
35
35
|
notes: ["Local schema and recipes work without credentials. Live OpenAPI needs a network connection but no API key.", "The CLI manages Agent profiles; use the SDK or WebSocket API for a live Omni audio session."] },
|
|
36
36
|
{ name: "ci", title: "Use PyAI in a build", description: "Check access and produce an audio artifact with machine-readable output.",
|
|
37
37
|
commands: ["pyai whoami --json", 'pyai speak "Your build is ready" -o build.wav --json', "pyai doctor --json"],
|
|
38
|
-
notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.
|
|
38
|
+
notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.7.1 and pin that version in repeatable builds."] },
|
|
39
39
|
].map(recipe => ({ ...recipe, docs_url: "https://pyai.com/agents/speech-calling.md" }));
|
package/src/cli-init.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { constants, type Stats } from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { omniTemplate } from "./cli-omni-template.ts";
|
|
6
|
+
|
|
7
|
+
export type CliTemplate = "agent" | "typescript" | "python" | "omni";
|
|
6
8
|
export interface CliTemplateManifest {
|
|
7
9
|
id: CliTemplate;
|
|
8
10
|
description: string;
|
|
@@ -149,7 +151,7 @@ const install = `## Install the CLI
|
|
|
149
151
|
Install the published CLI with Node.js 22 or newer recommended:
|
|
150
152
|
|
|
151
153
|
\`\`\`bash
|
|
152
|
-
npm install -g @pyai/sdk@0.
|
|
154
|
+
npm install -g @pyai/sdk@0.7.1
|
|
153
155
|
pyai --version
|
|
154
156
|
\`\`\`
|
|
155
157
|
|
|
@@ -242,7 +244,7 @@ and product calls below are separate explicit steps. Read \`PYAI.md\` first.
|
|
|
242
244
|
Install the official SDK:
|
|
243
245
|
|
|
244
246
|
\`\`\`bash
|
|
245
|
-
npm install @pyai/sdk@0.
|
|
247
|
+
npm install @pyai/sdk@0.7.1
|
|
246
248
|
cp .env.example .env
|
|
247
249
|
\`\`\`
|
|
248
250
|
|
|
@@ -344,6 +346,7 @@ Check the live contract before expanding the example.
|
|
|
344
346
|
${install}`;
|
|
345
347
|
|
|
346
348
|
const content: Readonly<Record<CliTemplate, Readonly<Record<string, string>>>> = Object.freeze({
|
|
349
|
+
omni: Object.freeze({ ...omniTemplate, "PYAI.md": context }),
|
|
347
350
|
agent: Object.freeze({ "README.md": agentReadme, "PYAI.md": context, "speech.json": speech, "job.json": job, "agent.json": agent, ".env.example": env, ".gitignore": ignore }),
|
|
348
351
|
typescript: Object.freeze({ "README.md": tsReadme, "PYAI.md": context, "main.ts": tsMain, "package.json": JSON.stringify({ name: "pyai-starter", version: "0.0.0", private: true, type: "module", engines: { node: ">=22.6.0" }, scripts: { speak: "node --experimental-strip-types --env-file=.env main.ts speak", hear: "node --experimental-strip-types --env-file=.env main.ts hear" } }, null, 2) + "\n", ".env.example": env, ".gitignore": ignore }),
|
|
349
352
|
python: Object.freeze({ "README.md": pythonReadme, "PYAI.md": context, "main.py": pythonMain, ".env.example": env, ".gitignore": ignore }),
|
|
@@ -354,6 +357,7 @@ export const CLI_TEMPLATES: readonly CliTemplateManifest[] = Object.freeze(([
|
|
|
354
357
|
{ id: "agent", description: "API context and JSON requests for coding agents" },
|
|
355
358
|
{ id: "typescript", description: "Runnable Node.js Speak and Hear examples using @pyai/sdk" },
|
|
356
359
|
{ id: "python", description: "Runnable Python Speak and Hear examples using pyai-sdk" },
|
|
360
|
+
{ id: "omni", description: "Runnable Node 20/22 Omni audio, read tool, capture and simulated interruption" },
|
|
357
361
|
] as const).map((entry) => Object.freeze({ ...entry, files: Object.freeze(Object.keys(content[entry.id])) })));
|
|
358
362
|
|
|
359
363
|
function hasCode(error: unknown, code: string): boolean {
|
|
@@ -368,7 +372,7 @@ async function exists(path: string): Promise<boolean> {
|
|
|
368
372
|
/** Create a new project only. Never authenticates, installs packages, or runs a shell. */
|
|
369
373
|
export async function scaffoldProject(options: ScaffoldOptions): Promise<ScaffoldResult> {
|
|
370
374
|
const template = options.template ?? "agent";
|
|
371
|
-
if (!Object.hasOwn(content, template)) throw new CliInitError("invalid_template", "Choose agent, typescript, or
|
|
375
|
+
if (!Object.hasOwn(content, template)) throw new CliInitError("invalid_template", "Choose agent, typescript, python, or omni with --template.");
|
|
372
376
|
if (typeof options.directory !== "string" || !options.directory.trim() || /[\p{Cc}]/u.test(options.directory)) {
|
|
373
377
|
throw new CliInitError("invalid_directory", "Provide a new project directory without control characters.");
|
|
374
378
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Generated by scripts/generate-omni-starter.mjs; edit examples/omni-starter instead.
|
|
2
|
+
export const omniTemplate = Object.freeze({
|
|
3
|
+
"README.md": "# Runnable Omni starter\n\nRequires Node 20.19+ and ESM. Node 20 uses the included `ws` transport;\nNode 22 also works. The separate MCP server requires Node 22+.\nThis server-side example uses a synthetic read-only office-hours tool. Read\n`PYAI.md` when generating the project through the CLI.\n\n<!-- omni-install:start -->\n```sh\nnpm install @pyai/sdk@0.7.1\nnpx pyai init voice-demo --template omni\ncd voice-demo\nnpm install\n```\n\nInitialization is offline; installing dependencies and running `main.mjs` are\nseparate steps. For an existing checkout of this example, run `npm install`\nin its directory instead.\n<!-- omni-install:end -->\n\nInject `PYAI_API_KEY` through your secret manager or shell environment. Do not\npaste a real key into source, a prompt, or shell history. `.env.example` documents\nthe variable; this program does not load `.env` or saved CLI profiles.\n\nPrepare a 24 kHz PCM16 mono WAV, at most 20 seconds, saying “Please look up the\noffice opening time.” Then run:\n\n```sh\nnode main.mjs caller.wav\n```\n\nRunning the program explicitly opens one bounded Omni session and uploads its\ncaptured reply to Hear. Both consume your key's allowed usage. Automatic retries\nare disabled. No managed number is bought and no phone call is placed.\n\n`session.mjs` waits for configuration and the greeting to drain, sends exactly\none paced input stream (caller PCM or silence), returns the read tool's result,\nand captures output using the rate from `hello.audio_out`. Never run a separate\nsilence timer alongside caller audio. `timing.mjs` is generated from the existing\nOmni evaluation harness's timing utilities.\n\nThe new private `omni-run-*` folder contains `session.wav`, `reply.wav`, and\n`report.json`. The report separates received audio, an office-hours answer\nrecovered by Hear from captured audio, and physical playback (not tested).\nThe office-hours check accepts formatting such as `opens@9a.m`, `9 a.m.` and\n`nine in the morning` in a complete affirmative opening-time sentence. Wrong\ntimes, negation, conflicting or truncated text remain unverified. This is a\nconservative check for the example fact, not a general answer-quality score.\nA synthesis transcript is advisory; it is not evidence that speech arrived.\nCapture ends after the simulated queue drains and two seconds of quiet. Omni\nhas no protocol reply-end marker, so this is a bounded capture heuristic.\nListen to the saved file when qualifying actual sound quality or completeness.\n\nTo exercise interruption, supply a second WAV saying “Stop speaking now”:\n\n```sh\nnode main.mjs caller.wav interruption.wav\n```\n\nThe second clip starts while reply audio is queued. `onBargeIn` clears that\nsimulated queue, and capture observes at least five seconds after that clip ends.\nAn interrupted answer can correctly fail the full-answer check;\ninspect the separate interruption evidence. This is not a physical speaker test.\nFor a real playback adapter, cancel both queued and currently playing audio.\nReplace the example lookup with authorized application data before product use.\n\nOn a session error, the program exits with JSON containing a recognized machine\n`code` (for example `media_dead` or `invalid_configure`). Unknown server codes\nbecome `unrecognized_server_error`; transport or decoding errors without a\nrecognized transport code become `transport_or_protocol_error`. Raw error text,\ncall IDs and unknown code values are omitted. No automatic retry is attempted;\ninspect the code and your authorized diagnostics before deciding to retry.\n",
|
|
4
|
+
"package.json": "{\n \"name\": \"pyai-omni-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20.19.0\"\n },\n \"scripts\": {\n \"start\": \"node main.mjs\"\n },\n \"dependencies\": {\n \"@pyai/sdk\": \"0.7.1\",\n \"ws\": \"^8.18.3\"\n }\n}\n",
|
|
5
|
+
"timing.mjs": "// Monotonic capture timing. Arrival time is distinct from queued playback:\n// a single packet may contain seconds of audio and must finish before settling.\nexport const FRAME_MS = 20;\nexport const REALTIME_GAP_LIMIT_MS = 100;\n// Interruption tests need an observation window after caller transmission,\n// even when an earlier cue/response already finished. These are test bounds,\n// not an inferred engine reply-end or a naturalness threshold.\nexport const INTERRUPTION_OBSERVATION = Object.freeze({\n minimumAfterCallerStreamMs: 5000, quietMs: 2000, maximumAfterCallerStreamMs: 25000,\n});\n\n/** Approximate audible bounds; this is energy detection, not a speech/VAD claim. */\nexport function audibleBounds(pcm, rate, rmsFloor = 160) {\n const window = Math.max(1, Math.round(rate / 100)); // 10 ms windows\n let first = null;\n let last = null;\n for (let off = 0; off < pcm.length; off += window) {\n const end = Math.min(pcm.length, off + window);\n let energy = 0;\n for (let i = off; i < end; i++) energy += pcm[i] * pcm[i];\n if (Math.sqrt(energy / (end - off)) >= rmsFloor) {\n first ??= off;\n last = end;\n }\n }\n return { first, last };\n}\n\nexport function newAudioCapture(started) {\n return {\n started, firstPacketAt: null, lastPacketAt: null,\n firstAudioAt: null, lastAudioAt: null, playbackEndAt: null,\n samples: 0, pcm: [], turnBeginAt: null, eouMs: null,\n latestTurnBeginAt: null, postTurnBeginFirstPacketAt: null,\n postTurnBeginFirstAudioAt: null, lastAssistantTranscriptAt: null, lastCallerTranscriptAt: null,\n callerTranscriptEvents: [], assistantTranscriptEvents: [],\n turnBegins: [], tools: [], toolResults: [], kb: null, events: [],\n };\n}\n\nexport function recordTurnBegin(ctx, at, turn, origin = 0) {\n ctx.turnBeginAt ??= at;\n ctx.latestTurnBeginAt = at;\n // Earlier PCM can be a listening cue. A new reply is still pending even\n // when that cue has already finished playing; retain all of its PCM.\n ctx.postTurnBeginFirstPacketAt = null;\n ctx.postTurnBeginFirstAudioAt = null;\n ctx.turnBegins.push({ atMs: Math.round(at - origin), turn: turn ?? null });\n}\n\nexport function recordAudio(ctx, pcm, at, rate) {\n if (!(pcm instanceof Int16Array) || !pcm.length) return null;\n const copy = pcm.slice(); // Some transports reuse callback buffers.\n const playbackAt = Math.max(at, ctx.playbackEndAt ?? at);\n const endAt = playbackAt + (copy.length / rate) * 1000;\n const bounds = audibleBounds(copy, rate);\n ctx.firstPacketAt ??= at;\n ctx.lastPacketAt = at;\n ctx.playbackEndAt = endAt;\n const afterTurnBegin = ctx.latestTurnBeginAt != null && at >= ctx.latestTurnBeginAt;\n if (afterTurnBegin) ctx.postTurnBeginFirstPacketAt ??= at;\n if (bounds.first != null) {\n ctx.firstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n ctx.lastAudioAt = playbackAt + (bounds.last / rate) * 1000;\n if (afterTurnBegin) ctx.postTurnBeginFirstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n }\n ctx.samples += copy.length;\n ctx.pcm.push(copy);\n return { playbackAt, endAt, pcm: copy };\n}\n\n/** Preserve negative gaps: they are overlaps, never missing measurements. */\nexport function captureTiming(ctx, caller, origin = 0) {\n const relative = (at) => at == null ? null : Math.round(at - origin);\n const gap = (at) => at == null || caller.speechOffsetAt == null\n ? null : Math.round(at - caller.speechOffsetAt);\n return {\n ttfbMs: gap(ctx.firstAudioAt),\n anyAudioTtfbMs: gap(ctx.firstAudioAt),\n postTurnBeginTtfbMs: gap(ctx.postTurnBeginFirstAudioAt),\n turnMs: gap(ctx.lastAudioAt),\n packetTtfbMs: gap(ctx.firstPacketAt),\n callerStartMs: relative(caller.startedAt),\n callerSpeechOnsetMs: relative(caller.speechOnsetAt),\n callerSpeechOffsetMs: relative(caller.speechOffsetAt),\n callerStreamEndMs: relative(caller.streamEndAt),\n agentFirstPacketMs: relative(ctx.firstPacketAt),\n agentLastPacketMs: relative(ctx.lastPacketAt),\n agentSpeechOnsetMs: relative(ctx.firstAudioAt),\n agentSpeechOffsetMs: relative(ctx.lastAudioAt),\n agentPlaybackEndMs: relative(ctx.playbackEndAt),\n latestTurnBeginMs: relative(ctx.latestTurnBeginAt),\n postTurnBeginFirstPacketMs: relative(ctx.postTurnBeginFirstPacketAt),\n postTurnBeginSpeechOnsetMs: relative(ctx.postTurnBeginFirstAudioAt),\n lastAssistantTranscriptMs: relative(ctx.lastAssistantTranscriptAt),\n callerMaxFrameGapMs: Math.round(caller.maxFrameGapMs ?? 0),\n method: \"client-monotonic-queued-playback-energy-bounds\",\n };\n}\n\nexport async function streamPcmRealtime(omni, pcm, rate, clock, onFrame = () => {}) {\n const frameSize = Math.max(1, Math.round((rate * FRAME_MS) / 1000));\n const bounds = audibleBounds(pcm, rate);\n const startedAt = clock.now();\n let speechOnsetAt = null;\n let speechOffsetAt = null;\n let previousFrameEnd = startedAt;\n let maxFrameGapMs = 0;\n for (let off = 0; off < pcm.length; off += frameSize) {\n const frame = pcm.subarray(off, Math.min(off + frameSize, pcm.length));\n const at = clock.now();\n maxFrameGapMs = Math.max(maxFrameGapMs, at - previousFrameEnd);\n const durationMs = (frame.length / rate) * 1000;\n omni.sendAudio(frame);\n onFrame(frame, at);\n if (bounds.first != null && bounds.first >= off && bounds.first < off + frame.length) {\n speechOnsetAt = at + ((bounds.first - off) / rate) * 1000;\n }\n if (bounds.last != null && bounds.last > off && bounds.last <= off + frame.length) {\n speechOffsetAt = at + ((bounds.last - off) / rate) * 1000;\n }\n previousFrameEnd = at + durationMs;\n // Pace from the actual send. Never burst late frames to \"catch up\".\n await clock.sleep(Math.max(0, previousFrameEnd - clock.now()));\n }\n return { startedAt, speechOnsetAt, speechOffsetAt,\n streamEndAt: clock.now(), maxFrameGapMs };\n}\n\nexport async function streamSilenceWhile(omni, rate, shouldContinue, clock, onFrame = () => {}) {\n const silence = new Int16Array(Math.max(1, Math.round((rate * FRAME_MS) / 1000)));\n while (shouldContinue()) {\n const at = clock.now();\n omni.sendAudio(silence);\n onFrame(silence, at);\n await clock.sleep(FRAME_MS);\n }\n}\n\nexport async function waitForAgentSettle(getCtx, clock, opts = {}) {\n const started = clock.now();\n const observation = opts.callerStreamEndAt != null;\n if (observation && (!Number.isFinite(opts.callerStreamEndAt)\n || opts.callerStreamEndAt < 0 || opts.callerStreamEndAt > started)) {\n throw new Error(\"Invalid caller observation boundary\");\n }\n const timeoutMs = observation ? INTERRUPTION_OBSERVATION.maximumAfterCallerStreamMs : opts.timeoutMs ?? 25000;\n const settleMs = observation ? INTERRUPTION_OBSERVATION.quietMs : opts.settleMs ?? 2000;\n const deadlineAt = (observation ? opts.callerStreamEndAt : started) + timeoutMs;\n const minimumAt = observation ? opts.callerStreamEndAt + INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : -Infinity;\n while (true) {\n const ctx = getCtx();\n const at = clock.now();\n if (opts.isClosed?.()) return { reason: \"closed\", at };\n if (observation && at >= deadlineAt) return { reason: \"timeout\", at };\n const hasResponseAudio = !opts.requireTurnBegin || (ctx.latestTurnBeginAt != null\n && ctx.postTurnBeginFirstAudioAt != null);\n // Synthesis text is progress, not a completion event. It can arrive after\n // early/final audio, so extend the quiet floor without demanding another\n // packet after every advisory. The protocol still has no reply-end marker.\n const activityEnd = Math.max(ctx.playbackEndAt ?? -Infinity,\n ctx.latestTurnBeginAt ?? -Infinity, ctx.lastAssistantTranscriptAt ?? -Infinity,\n observation ? ctx.lastCallerTranscriptAt ?? -Infinity : -Infinity);\n // Do not demand a new post-caller turn_begin: an earlier turn can be\n // coalesced and speak later. This records a bounded quiet observation;\n // downstream response-timing checks can impose a stricter begin boundary.\n if (ctx.firstPacketAt != null && hasResponseAudio && at >= minimumAt && at >= activityEnd + settleMs) {\n return { reason: \"settled\", at };\n }\n if (opts.allowEmpty && ctx.firstPacketAt == null && at >= minimumAt && at - started >= (opts.emptyWaitMs ?? 800)) {\n return { reason: \"empty\", at };\n }\n if (at - started >= timeoutMs) return { reason: \"timeout\", at };\n await clock.sleep(observation ? Math.min(20, deadlineAt - at) : 20);\n }\n}\n\nexport async function withTimeout(promise, ms, message, clock) {\n let timer;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timer = clock.setTimeout(() => reject(new Error(message)), ms);\n }),\n ]);\n } finally {\n clock.clearTimeout(timer);\n }\n}\n",
|
|
6
|
+
"main.mjs": "import PyAI from \"@pyai/sdk\";\nimport WebSocket from \"ws\"; // Explicit transport makes this work on Node 20 too.\nimport { readFile, writeFile, mkdtemp } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { runSession } from \"./session.mjs\";\nimport { readWav, writeWav, assessAnswer } from \"./audio.mjs\";\n\nasync function main() {\n const [callerPath, interruptionPath, extra] = process.argv.slice(2);\n if (!callerPath || extra) throw new Error(\"Usage: node main.mjs caller.wav [interruption.wav]\");\n if (!process.env.PYAI_API_KEY) throw new Error(\"Inject PYAI_API_KEY through your environment first\");\n const caller = readWav(await readFile(callerPath));\n const interruption = interruptionPath ? readWav(await readFile(interruptionPath)) : undefined;\n const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY, maxRetries: 0,\n fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(30000) }),\n });\n const { report, audio, replyAudio } = await runSession({ pyai, webSocket: WebSocket, caller, interruption });\n const directory = await mkdtemp(join(process.cwd(), \"omni-run-\"));\n const save = (name, data) => writeFile(join(directory, name), data, { mode: 0o600, flag: \"wx\" });\n await save(\"session.wav\", writeWav(audio, report.output_rate ?? 24000));\n const reply = writeWav(replyAudio, report.output_rate ?? 24000);\n await save(\"reply.wav\", reply);\n // Hear the captured bytes, rather than trusting synthesis-advisory text.\n let capturedTranscript = \"\";\n if (report.reply_audio_bytes) {\n try {\n const heard = await pyai.audio.transcriptions.create({ file: new File([reply], \"reply.wav\", { type: \"audio/wav\" }), model: \"pyai-hear\" });\n capturedTranscript = heard.text;\n } catch { report.hear_error = \"Captured audio saved; Hear verification failed\"; }\n }\n const result = { ...report, captured_transcript: capturedTranscript, evidence: assessAnswer(report, capturedTranscript) };\n await save(\"report.json\", JSON.stringify(result, null, 2) + \"\\n\");\n console.log(JSON.stringify({ directory, ...result }, null, 2));\n if (result.evidence.captured_answer !== \"verified_by_hear\" || (interruption && result.evidence.interruption !== \"simulated_queue_cleared\")) process.exitCode = 1;\n}\n\nmain().catch(error => {\n const message = String(error.message).replaceAll(process.env.PYAI_API_KEY || \"\\0\", \"[REDACTED]\");\n console.error(JSON.stringify({ error: message, ...(error.omni_code ? { code: error.omni_code } : {}) })); process.exitCode = 1;\n});\n",
|
|
7
|
+
"session.mjs": "import { newAudioCapture, recordAudio, recordTurnBegin, streamPcmRealtime, INTERRUPTION_OBSERVATION } from \"./timing.mjs\";\n\nexport const realClock = {\n now: () => performance.now(),\n sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),\n};\n\n// Only recognized machine codes may leave the example. Error messages, call\n// IDs and arbitrary code values can contain credentials or customer content.\nconst publicErrorCodes = new Set([\n \"invalid_configure\", \"unsupported_tool_transport\", \"tool_configuration_locked\",\n \"media_dead\", \"unauthorized\", \"insufficient_scope\", \"rate_limit_exceeded\",\n \"concurrency_limit_exceeded\", \"daily_cap_exceeded\", \"credit_exhausted\",\n]);\nconst transportErrorCodes = new Set([\"ECONNRESET\", \"ECONNREFUSED\", \"ETIMEDOUT\", \"ENOTFOUND\", \"EAI_AGAIN\"]);\nexport function omniFailure(error) {\n const code = error instanceof Error\n ? (transportErrorCodes.has(error.code) ? error.code : \"transport_or_protocol_error\")\n : (publicErrorCodes.has(error?.code) ? error.code : \"unrecognized_server_error\");\n return Object.assign(new Error(`Omni failed (${code}); no automatic retry was attempted`), { omni_code: code });\n}\n\n/** One bounded, server-side example. Its playback queue is simulated. */\nexport async function runSession({ pyai, webSocket, caller, interruption, clock = realClock, timeoutMs = 60000 }) {\n const rate = 24000, frameSize = 480, silence = new Int16Array(frameSize);\n if (!(caller instanceof Int16Array) || !caller.length || caller.length > rate * 20) throw new Error(\"Provide 1–20 seconds of caller PCM16 at 24 kHz\");\n if (interruption && (!(interruption instanceof Int16Array) || !interruption.length || interruption.length > rate * 20)) throw new Error(\"Invalid interruption PCM\");\n const started = clock.now(), all = newAudioCapture(started), reply = newAudioCapture(started);\n const report = { input_rate: rate, output_rate: null, playback_sink: \"simulated\", tool_calls: 0, tool_executions: 0, duplicate_tool_calls: 0, flushes_after_interruption: 0, cleared_queue_ms: 0, interruption_requested: !!interruption, caller_started_ms: null, interruption_started_ms: null, advisory_transcripts: [], end_reason: \"timeout\" };\n let session, configuredAt = null, closed = false, failure = null, phase = \"greeting\", offset = 0, queueEnd = started, interruptionSent = false, inputEndAt = started;\n const handled = new Set();\n const fail = message => { failure ??= new Error(message); };\n try {\n session = pyai.omni.connect({\n webSocket, rate,\n configure: {\n voice_id: \"stock_amos_en_us\", language: \"en\", greeting: \"Hello. How can I help?\",\n persona: \"When asked about office hours, call lookup_office_hours. Never guess. After the result, say one short sentence giving its opening_time.\",\n tools: [{ name: \"lookup_office_hours\", description: \"Read the synthetic example office opening time.\", side_effect: \"read\", parameters: { type: \"object\", properties: {}, required: [], additionalProperties: false } }],\n },\n onHello: frame => {\n const match = /^pcm16@(8000|24000)$/.exec(frame.audio_out ?? \"\");\n if (!match) return fail(\"Unsupported Omni output format\");\n report.output_rate = Number(match[1]);\n },\n onConfigured: () => { configuredAt ??= clock.now(); },\n onAudio: chunk => {\n if (!report.output_rate) return fail(\"Audio arrived before its output format\");\n const bytes = chunk instanceof ArrayBuffer ? new Uint8Array(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);\n if (bytes.byteLength % 2) return fail(\"Invalid PCM16 output\");\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const pcm = Int16Array.from({ length: bytes.length / 2 }, (_, i) => view.getInt16(i * 2, true));\n const now = clock.now();\n recordAudio(all, pcm, now, report.output_rate);\n queueEnd = Math.max(now, queueEnd) + pcm.length / report.output_rate * 1000;\n if (report.tool_executions) recordAudio(reply, pcm, now, report.output_rate);\n },\n onEvent: frame => {\n if (frame.event === \"turn_begin\") recordTurnBegin(all, clock.now(), frame.turn);\n },\n onTranscript: frame => {\n // An assistant advisory is progress; never treat it as received speech.\n if (report.advisory_transcripts.length < 100) report.advisory_transcripts.push({ role: frame.role, text: frame.text });\n if (frame.role === \"assistant\") all.lastAssistantTranscriptAt = clock.now();\n },\n onToolCall: frame => {\n report.tool_calls++;\n if (handled.has(frame.call_id)) { report.duplicate_tool_calls++; return; }\n handled.add(frame.call_id);\n if (frame.name !== \"lookup_office_hours\") { session.toolResult(frame.call_id, { error: \"Unknown example tool\" }); return; }\n // Replace this read-only fixture with your authorized data lookup.\n report.tool_executions++;\n session.toolResult(frame.call_id, { result: { opening_time: \"9 a.m.\", source: \"synthetic example office\" } });\n },\n onBargeIn: () => {\n const now = clock.now(), queued = Math.max(0, queueEnd - now);\n if (report.interruption_started_ms != null) { report.flushes_after_interruption++; report.cleared_queue_ms += queued; }\n // A real speaker adapter must cancel scheduled and currently playing audio.\n queueEnd = now; all.playbackEndAt = now; reply.playbackEndAt = now;\n },\n onError: error => { failure ??= omniFailure(error); },\n onClose: () => { closed = true; },\n });\n while (clock.now() - started < timeoutMs) {\n if (failure) throw failure;\n if (closed) { report.end_reason = \"closed\"; break; }\n const now = clock.now();\n if (configuredAt == null || !report.output_rate) {\n if (now - started >= 10000) throw new Error(\"Omni connection/configuration timed out\");\n await clock.sleep(20); continue;\n }\n const quietAt = Math.max(queueEnd, all.lastPacketAt ?? now, all.lastAssistantTranscriptAt ?? 0, inputEndAt);\n if (phase === \"greeting\") {\n if (all.firstPacketAt != null && now - configuredAt >= 2000 && now >= quietAt + 800) {\n phase = \"caller\"; report.caller_started_ms = now - started;\n } else if (now - configuredAt > 15000) throw new Error(\"Greeting did not drain within 15 seconds\");\n }\n if (phase === \"reply\" && interruption && !interruptionSent && reply.samples && queueEnd - now >= 200) {\n phase = \"interruption\"; offset = 0; interruptionSent = true; report.interruption_started_ms = now - started;\n }\n const observationEnd = inputEndAt + (interruptionSent ? INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : 0);\n if (phase === \"reply\" && reply.samples && now >= quietAt + 2000 && now >= observationEnd && (!interruption || interruptionSent)) {\n report.end_reason = \"quiet_window\"; break;\n }\n // Exactly one producer: each 20 ms slot contains caller PCM OR silence.\n let pcm = silence;\n if (phase === \"caller\" || phase === \"interruption\") {\n const source = phase === \"caller\" ? caller : interruption;\n pcm = source.subarray(offset, offset + frameSize); offset += pcm.length;\n if (offset >= source.length) {\n inputEndAt = now + pcm.length / rate * 1000;\n report[phase === \"caller\" ? \"caller_completed_ms\" : \"interruption_completed_ms\"] = inputEndAt - started;\n phase = \"reply\"; offset = 0;\n }\n }\n await streamPcmRealtime(session, pcm, rate, clock);\n }\n report.audio_bytes = all.samples * 2;\n report.reply_audio_bytes = reply.samples * 2;\n report.elapsed_ms = Math.round(clock.now() - started);\n return { report, audio: all.pcm, replyAudio: reply.pcm };\n } finally { session?.close(); }\n}\n",
|
|
8
|
+
"audio.mjs": "// Small, strict WAV boundary: the wire receives samples, never WAV headers.\nexport function readWav(bytes) {\n const b = Buffer.from(bytes);\n if (b.length < 44 || b.toString(\"ascii\", 0, 4) !== \"RIFF\" || b.toString(\"ascii\", 8, 12) !== \"WAVE\") throw new Error(\"Expected a PCM16 mono WAV file\");\n const end = b.readUInt32LE(4) + 8;\n if (end > b.length) throw new Error(\"Truncated WAV file\");\n let rate, data;\n for (let at = 12; at + 8 <= end;) {\n const name = b.toString(\"ascii\", at, at + 4), size = b.readUInt32LE(at + 4);\n const start = at + 8;\n if (start + size > end) throw new Error(\"Truncated WAV chunk\");\n if (name === \"fmt \") {\n if (size < 16 || b.readUInt16LE(start) !== 1 || b.readUInt16LE(start + 2) !== 1 || b.readUInt16LE(start + 14) !== 16) throw new Error(\"Convert input to PCM16 mono WAV first\");\n rate = b.readUInt32LE(start + 4);\n } else if (name === \"data\") data = b.subarray(start, start + size);\n at = start + size + (size % 2);\n }\n if (rate !== 24000 || !data?.length || data.length % 2 || data.length > rate * 2 * 20) throw new Error(\"Use a non-empty 24 kHz PCM16 mono WAV, at most 20 seconds\");\n return Int16Array.from({ length: data.length / 2 }, (_, i) => data.readInt16LE(i * 2));\n}\n\nexport function writeWav(chunks, rate) {\n const samples = chunks.reduce((n, pcm) => n + pcm.length, 0);\n const b = Buffer.alloc(44 + samples * 2);\n b.write(\"RIFF\", 0); b.writeUInt32LE(b.length - 8, 4); b.write(\"WAVEfmt \", 8);\n b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);\n b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28);\n b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write(\"data\", 36);\n b.writeUInt32LE(samples * 2, 40);\n let at = 44;\n for (const pcm of chunks) for (const value of pcm) { b.writeInt16LE(value, at); at += 2; }\n return b;\n}\n\nexport function assessAnswer(report, capturedTranscript) {\n // Conservative fixture check, not a general semantic evaluator. Normalize\n // Hear's formatting without accepting negated, conflicting or partial claims.\n const text = String(capturedTranscript ?? \"\").normalize(\"NFKC\").toLowerCase()\n .replace(/@/g, \" at \")\n .replace(/(?<![a-z])([ap])\\s*\\.?\\s*m\\b\\.?/g, \" $1m \")\n .replace(/[.,!…]/g, \" \").replace(/\\s+/g, \" \").trim();\n const expected = /^(?:(?:sure|yes|okay|ok|certainly) )?(?:(?:the|our) )?office (?:opens|(?:is )?opening|will open) (?:at )?(?:nine|9(?::00)?) (?:am|in the morning)(?: today)?$/.test(text);\n return {\n audio_received: report.reply_audio_bytes > 0,\n captured_answer: report.reply_audio_bytes > 0 && report.end_reason === \"quiet_window\" && report.tool_executions === 1 && expected ? \"verified_by_hear\" : \"not_verified\",\n capture_boundary: \"bounded_quiet_window_no_protocol_reply_end\",\n physical_playback: \"not_tested\",\n interruption: report.interruption_requested ? (report.flushes_after_interruption > 0 && report.cleared_queue_ms > 0 ? \"simulated_queue_cleared\" : \"not_verified\") : \"not_tested\",\n };\n}\n",
|
|
9
|
+
".env.example": "PYAI_API_KEY=\n",
|
|
10
|
+
".gitignore": "node_modules/\n.env\nomni-run-*/\n*.wav\n"
|
|
11
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -270,7 +270,7 @@ async function main(): Promise<void> {
|
|
|
270
270
|
const grouped = group ? commands.filter(c => c.command.startsWith(`${group} `)) : commands;
|
|
271
271
|
const visible = spec ? [spec, ...commands.filter(c => c.command.startsWith(`${spec.command} `))] : grouped.length ? grouped : commands;
|
|
272
272
|
if (flags.json) output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
|
|
273
|
-
else if (!spec && !group) process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template
|
|
273
|
+
else if (!spec && !group) process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template omni Create a runnable Omni starter offline\n\nCalling\n pyai numbers list List owned phone numbers\n pyai numbers search --country US Find available numbers\n pyai calls list Inspect outbound calls\n pyai recipes calling --json Setup and preview examples\n pyai calls create --data @call.json --idempotency-key REQUEST_KEY --dry-run\n Preview a call without submitting it\n --dry-run previews a change without sending the request.\n Number purchases and outbound calls require --confirm and --idempotency-key.\n Use --confirm only after that purchase or call is authorized.\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd numbers calls omni\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
|
|
274
274
|
else process.stdout.write(`PyAI CLI — speech, agents, and automation\n\n${visible.map(c => ` pyai ${c.command}${(c.args ?? []).map(a => c.optionalArgs ? ` [${a}${c.variadic ? "..." : ""}]` : ` <${a}>`).join("")}\n ${c.description}${c.flags ? `\n Options: ${Object.keys(c.flags).map(k => `--${k}${c.flags![k] === "boolean" ? "" : " VALUE"}`).join(" ")}` : ""}${c.example ? `\n ${c.example}` : ""}`).join("\n")}\n\nGlobal: --profile NAME --json --timeout SECONDS --retries N --dry-run\n --base-url URL --api-key KEY --help --version\nShort flags: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nAuth: pyai login or PYAI_API_KEY. Discover: pyai schema --json\n`);
|
|
275
275
|
return;
|
|
276
276
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Thin, dependency-free wrapper over the public OpenAI-compatible surface at
|
|
5
5
|
* https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
|
|
6
|
-
* the browser and Node
|
|
6
|
+
* the browser and Node 18+ for REST. Realtime needs an injected WebSocket
|
|
7
|
+
* on Node 20; Node 22+ provides a global WebSocket. Keys are opaque, never parsed.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
export interface PyAIOptions {
|