feat(dictate): add voice-reactive listening waveform

This commit is contained in:
jb
2026-08-26 13:40:47 -04:00
parent e234468a73
commit d1a811a5c5
16 changed files with 383 additions and 73 deletions
@@ -0,0 +1,27 @@
# Stream bounded audio levels for the dictate indicator
Date: 26AUG2026
Status: Accepted
## Context
Pi Dictate previously exposed only static service states. Active recording was easy to miss, and the extension had no signal from which to render a voice-reactive waveform. Raw microphone access must remain isolated in the host service.
## Decision
Protocol version 2 adds a `level` event while the service is listening. The recorder converts each 100 ms PCM window to a normalized logarithmic RMS level. The service sends the current scalar to the owning client at no more than 10 Hz.
The extension renders a fixed-width footer status:
```text
● listening ▁▂▄▇█▆▃▂▅▇▄▂
```
The circle uses Pi's semantic error color. Twelve block cells show recent levels as a scrolling history. Loading, stopping, and transcription remain static text.
## Consequences
- The indicator reacts to speech without granting Pi microphone access or sending PCM over the socket.
- Output remains bounded by the existing per-connection queue and frame limits.
- The extension and installed service must upgrade together because protocol version 1 clients do not understand level events.
- Recording causes at most ten footer renders and protocol events per second.
+21 -11
View File
@@ -2,7 +2,7 @@
## Transport and version ## Transport and version
Protocol version `1` uses UTF-8, newline-delimited JSON over one persistent Unix stream socket. Each line contains one JSON object. The systemd socket unit owns the socket path and permissions. The service accepts the inherited listening file descriptor and does not bind or unlink the path. Protocol version `2` uses UTF-8, newline-delimited JSON over one persistent Unix stream socket. Each line contains one JSON object. The systemd socket unit owns the socket path and permissions. The service accepts the inherited listening file descriptor and does not bind or unlink the path.
Clients must send `hello` before any other request. A connection negotiates one version only. Coding terms are host-owned service configuration and never cross this protocol. Clients must send `hello` before any other request. A connection negotiates one version only. Coding terms are host-owned service configuration and never cross this protocol.
@@ -17,6 +17,7 @@ Clients must send `hello` before any other request. A connection negotiates one
| Simultaneous client connections | 16 | | Simultaneous client connections | 16 |
| Pending output per client | 65,536 bytes | | Pending output per client | 65,536 bytes |
| Recorder stderr tail | 65,536 bytes | | Recorder stderr tail | 65,536 bytes |
| Audio-level event rate | 10 per second |
| Mono 16 kHz signed-16 PCM | 9,600,000 bytes, five minutes | | Mono 16 kHz signed-16 PCM | 9,600,000 bytes, five minutes |
IDs are non-empty, opaque strings generated by the client. The service compares them only for request correlation, ownership, and stale-generation rejection. It does not interpret IDs as paths. Request objects reject unknown or missing fields. IDs are non-empty, opaque strings generated by the client. The service compares them only for request correlation, ownership, and stale-generation rejection. It does not interpret IDs as paths. Request objects reject unknown or missing fields.
@@ -30,7 +31,7 @@ All examples are one line when transmitted.
### `hello` ### `hello`
```json ```json
{"version":1,"type":"hello","requestId":"hello-1"} {"version":2,"type":"hello","requestId":"hello-1"}
``` ```
Exact fields: `version`, `type`, `requestId`. Exact fields: `version`, `type`, `requestId`.
@@ -40,7 +41,7 @@ A successful response is `pong` with the same `requestId`. `ready` reports wheth
### `ping` ### `ping`
```json ```json
{"version":1,"type":"ping","requestId":"ping-1"} {"version":2,"type":"ping","requestId":"ping-1"}
``` ```
Exact fields: `version`, `type`, `requestId`. `hello` is required first. The response is `pong` and may include `ready` and `busy`. Exact fields: `version`, `type`, `requestId`. `hello` is required first. The response is `pong` and may include `ready` and `busy`.
@@ -48,7 +49,7 @@ Exact fields: `version`, `type`, `requestId`. `hello` is required first. The res
### `start` ### `start`
```json ```json
{"version":1,"type":"start","requestId":"start-1","generationId":"generation-1"} {"version":2,"type":"start","requestId":"start-1","generationId":"generation-1"}
``` ```
Exact fields: `version`, `type`, `requestId`, `generationId`. The service accepts only one global generation. It first emits `state: loading` for a cold model or `state: listening` for a warm model. Recording starts only after model loading succeeds. Exact fields: `version`, `type`, `requestId`, `generationId`. The service accepts only one global generation. It first emits `state: loading` for a cold model or `state: listening` for a warm model. Recording starts only after model loading succeeds.
@@ -56,7 +57,7 @@ Exact fields: `version`, `type`, `requestId`, `generationId`. The service accept
### `stop` ### `stop`
```json ```json
{"version":1,"type":"stop","requestId":"stop-1","generationId":"generation-1"} {"version":2,"type":"stop","requestId":"stop-1","generationId":"generation-1"}
``` ```
Exact fields: `version`, `type`, `requestId`, `generationId`. Only the owning connection may stop the active listening generation. Exact fields: `version`, `type`, `requestId`, `generationId`. Only the owning connection may stop the active listening generation.
@@ -64,7 +65,7 @@ Exact fields: `version`, `type`, `requestId`, `generationId`. Only the owning co
### `cancel` ### `cancel`
```json ```json
{"version":1,"type":"cancel","requestId":"cancel-1","generationId":"generation-1"} {"version":2,"type":"cancel","requestId":"cancel-1","generationId":"generation-1"}
``` ```
Exact fields: `version`, `type`, `requestId`, `generationId`. A valid cancellation immediately emits `cancelled`. Cancellation tombstones the generation, so it can never emit a later `result` or begin recording after a cancelled load. Exact fields: `version`, `type`, `requestId`, `generationId`. A valid cancellation immediately emits `cancelled`. Cancellation tombstones the generation, so it can never emit a later `result` or begin recording after a cancelled load.
@@ -76,21 +77,29 @@ All service events contain `version`, `type`, and a bounded client-generated `re
### `pong` ### `pong`
```json ```json
{"version":1,"type":"pong","requestId":"hello-1","service":"pi-dictate","ready":false} {"version":2,"type":"pong","requestId":"hello-1","service":"pi-dictate","ready":false}
``` ```
### `state` ### `state`
```json ```json
{"version":1,"type":"state","requestId":"start-1","generationId":"generation-1","state":"loading"} {"version":2,"type":"state","requestId":"start-1","generationId":"generation-1","state":"loading"}
``` ```
`state` is one of `loading`, `listening`, `stopping`, `transcribing`, or `idle`. `state` is one of `loading`, `listening`, `stopping`, `transcribing`, or `idle`.
### `level`
```json
{"version":2,"type":"level","requestId":"start-1","generationId":"generation-1","level":0.625}
```
While listening, the service emits at most ten `level` events per second. `level` is a finite number from `0` through `1`, derived from the recorder's current 100 ms RMS window on a logarithmic -60 dBFS to 0 dBFS scale. The event carries no PCM samples and stops before recorder shutdown begins.
### `result` ### `result`
```json ```json
{"version":1,"type":"result","requestId":"stop-1","generationId":"generation-1","transcript":"example text"} {"version":2,"type":"result","requestId":"stop-1","generationId":"generation-1","transcript":"example text"}
``` ```
There is at most one result for a generation. An empty transcript is a successful result with `"transcript":""`. There is at most one result for a generation. An empty transcript is a successful result with `"transcript":""`.
@@ -98,7 +107,7 @@ There is at most one result for a generation. An empty transcript is a successfu
### `cancelled` ### `cancelled`
```json ```json
{"version":1,"type":"cancelled","requestId":"cancel-1","generationId":"generation-1"} {"version":2,"type":"cancelled","requestId":"cancel-1","generationId":"generation-1"}
``` ```
This is a terminal delivery guarantee: the generation cannot later produce a result. This is a terminal delivery guarantee: the generation cannot later produce a result.
@@ -106,7 +115,7 @@ This is a terminal delivery guarantee: the generation cannot later produce a res
### `error` ### `error`
```json ```json
{"version":1,"type":"error","requestId":"stop-1","generationId":"generation-1","code":"recorder_timeout","detail":"pw-record did not stop after SIGTERM and was killed"} {"version":2,"type":"error","requestId":"stop-1","generationId":"generation-1","code":"recorder_timeout","detail":"pw-record did not stop after SIGTERM and was killed"}
``` ```
Stable codes include: Stable codes include:
@@ -123,6 +132,7 @@ Stable codes include:
| no generation | owner `start` | `loading`, then `listening`; warm models may emit `listening` directly | | no generation | owner `start` | `loading`, then `listening`; warm models may emit `listening` directly |
| any active generation | another `start` | `busy`; connection stays open | | any active generation | another `start` | `busy`; connection stays open |
| `loading` | owner `cancel` | immediate `cancelled`; loading finishes in the worker but recording is suppressed | | `loading` | owner `cancel` | immediate `cancelled`; loading finishes in the worker but recording is suppressed |
| `listening` | recorder audio | rate-limited `level` events for the owning client |
| `listening` | owner `stop` | `stopping`; signal recorder, drain stdout to EOF, await child close, convert PCM, then `transcribing` | | `listening` | owner `stop` | `stopping`; signal recorder, drain stdout to EOF, await child close, convert PCM, then `transcribing` |
| `listening` | owner `cancel` or disconnect | stop and drain recorder, discard audio, never infer | | `listening` | owner `cancel` or disconnect | stop and drain recorder, discard audio, never infer |
| `stopping` | owner `cancel` | immediate `cancelled`; completed audio is discarded | | `stopping` | owner `cancel` | immediate `cancelled`; completed audio is discarded |
+2
View File
@@ -186,6 +186,8 @@ The current meter updates every 60 ms even when its rendered value has not chang
**Fix:** the extension renders state changes reported by the service and does not receive raw audio levels. Use static status text for `loading model...`, `listening...`, `stopping...`, and `transcribing...`. If a meter is wanted later, add a rate-limited level event only after measuring the need. **Fix:** the extension renders state changes reported by the service and does not receive raw audio levels. Use static status text for `loading model...`, `listening...`, `stopping...`, and `transcribing...`. If a meter is wanted later, add a rate-limited level event only after measuring the need.
**Superseded 26AUG2026:** active-listening feedback now needs a voice-reactive waveform. [The accepted follow-up decision](../adr/dictate/listening-level-events-26AUG2026.md) adds a bounded 10 Hz scalar level event without exposing raw audio to Pi.
### 12. Tests, installation, attribution, and user documentation are missing ### 12. Tests, installation, attribution, and user documentation are missing
**Severity:** medium **Severity:** medium
+17
View File
@@ -8,3 +8,20 @@ During Dictate Phase 0 on 26AUG2026, repeated overlapping throwaway benchmark pr
2. A later production-adapter check raised `ValueError: bad marshal data (invalid reference)` while importing `torch._tensor`. An immediate standalone `import torch` succeeded, and the cached `_tensor.cpython-312.pyc` passed `marshal.loads()`. 2. A later production-adapter check raised `ValueError: bad marshal data (invalid reference)` while importing `torch._tensor`. An immediate standalone `import torch` succeeded, and the cached `_tensor.cpython-312.pyc` passed `marshal.loads()`.
The production service serializes model loading and inference and therefore does not intentionally launch concurrent NeMo processes. Before treating concurrent workers as supported, build a repeatable cold-load stress loop, monitor process and cgroup memory, disable bytecode writes, and determine whether the failures come from NeMo/OmegaConf resource pressure or concurrent access to the external environment's bytecode cache. The production service serializes model loading and inference and therefore does not intentionally launch concurrent NeMo processes. Before treating concurrent workers as supported, build a repeatable cold-load stress loop, monitor process and cgroup memory, disable bytecode writes, and determine whether the failures come from NeMo/OmegaConf resource pressure or concurrent access to the external environment's bytecode cache.
## Repair nono self-diagnosis after terminal path drift
During Dictate waveform testing on 26AUG2026, every Unix socket `bind()` in `services/dictate/tests/test_unix_server.py` failed with `EACCES` or `EPERM`, including when `TMPDIR` pointed inside the repository worktree. The required diagnostic also failed:
```text
$ nono why --self --path /tmp --op readwrite
nono: Configuration parse error: sandbox state path drifted at reload: serialized resolved=/dev/pts/15, actual resolved=/dev/null
```
Reproduce in a non-interactive or reloaded Pi session, determine whether `/dev/pts/*` should be serialized by identity rather than resolved path, and make `nono why --self` report the active policy even after the controlling terminal changes. Also verify whether the Pi profile intentionally denies `AF_UNIX` socket creation in otherwise writable paths; the Dictate service loop tests need local throwaway sockets.
## Reuse the verified Dictate model during reinstall
The protocol-v2 reinstall on 26AUG2026 attempted to download the pinned model again even though the exact model already existed under `~/.local/share/pi-dictate/models/`. The sandbox correctly blocked unapproved access to `huggingface.co`, so installation had to be repeated with the installed model passed explicitly through `--model-source`.
Teach `services/dictate/install.sh` to checksum an existing model first and keep it when it matches `MODEL_SHA256`. Download only when the model is absent or invalid, while preserving the current atomic temporary-file replacement for new model sources.
+8 -5
View File
@@ -61,12 +61,15 @@ The active profile is repository-linked on the tested host. Start a new Pi sandb
Shortcuts are handled by Pi's main editor. They do not claim to work while a selector, confirmation, or custom modal owns input. If a modal opens after recording starts, a successful result still goes only to the main editor. Shortcuts are handled by Pi's main editor. They do not claim to work while a selector, confirmation, or custom modal owns input. If a modal opens after recording starts, a successful result still goes only to the main editor.
The footer shows static service states: The footer shows service state. While recording, a red circle and twelve-cell scrolling waveform make active capture visible and react to the microphone level:
- `loading model...` ```text
- `listening...` ● listening ▁▂▄▇█▆▃▂▅▇▄▂
- `stopping...` ```
- `transcribing...`
The circle uses Pi's semantic error color and the waveform uses its accent color. The other states remain `loading model...`, `stopping...`, and `transcribing...`. Audio levels are bounded scalar protocol events; raw audio remains inside the host service.
This indicator requires protocol version 2. Run the installer again after updating the repository so the installed host service and repository-linked extension upgrade together.
Only a non-empty finalized result for the current generation changes the editor. Empty speech, cancellation, stale results, disconnects, and failures do not insert text. Only a non-empty finalized result for the current generation changes the editor. Empty speech, cancellation, stale results, disconnects, and failures do not insert text.
+47 -3
View File
@@ -77,10 +77,17 @@ function createHarness() {
const sockets: FakeSocket[] = []; const sockets: FakeSocket[] = [];
const socketPaths: string[] = []; const socketPaths: string[] = [];
const statuses: Array<string | undefined> = []; const statuses: Array<string | undefined> = [];
const themeCalls: Array<{ color: string; text: string }> = [];
const notifications: Array<{ message: string; type?: string }> = []; const notifications: Array<{ message: string; type?: string }> = [];
const pasted: string[] = []; const pasted: string[] = [];
const context = { const context = {
ui: { ui: {
theme: {
fg: (color: string, text: string) => {
themeCalls.push({ color, text });
return text;
},
},
setStatus: (_key: string, text: string | undefined) => statuses.push(text), setStatus: (_key: string, text: string | undefined) => statuses.push(text),
notify: (message: string, type?: string) => notify: (message: string, type?: string) =>
notifications.push({ message, type }), notifications.push({ message, type }),
@@ -134,6 +141,7 @@ function createHarness() {
sockets, sockets,
socketPaths, socketPaths,
statuses, statuses,
themeCalls,
notifications, notifications,
pasted, pasted,
context, context,
@@ -213,7 +221,11 @@ test("rejects a mismatched protocol version before sending start", async () => {
assert.ok(socket); assert.ok(socket);
socket.connect(); socket.connect();
const hello = harness.sentFrames(socket)[0]; const hello = harness.sentFrames(socket)[0];
socket.frame({ version: 2, type: "pong", requestId: hello?.requestId }); socket.frame({
version: PROTOCOL_VERSION + 1,
type: "pong",
requestId: hello?.requestId,
});
assert.equal(harness.sentFrames(socket).length, 1); assert.equal(harness.sentFrames(socket).length, 1);
assert.match(harness.notifications[0]?.message ?? "", /version mismatch/i); assert.match(harness.notifications[0]?.message ?? "", /version mismatch/i);
@@ -266,19 +278,51 @@ test("renders authoritative service states and sends a bounded stop command", as
assert.deepEqual(harness.statuses, [ assert.deepEqual(harness.statuses, [
"loading model...", "loading model...",
"listening...", "● listening ▁▁▁▁▁▁▁▁▁▁▁▁",
"stopping...", "stopping...",
"transcribing...", "transcribing...",
]); ]);
}); });
test("scrolls bounded audio levels through the listening waveform", async () => {
const harness = createHarness();
const { socket, generationId } = await connectAndNegotiate(harness);
serviceFrame(socket, generationId, "state", { state: "listening" });
assert.deepEqual(harness.themeCalls, [
{ color: "error", text: "●" },
{ color: "dim", text: "listening" },
{ color: "accent", text: "▁▁▁▁▁▁▁▁▁▁▁▁" },
]);
serviceFrame(socket, generationId, "level", { level: 0.5 });
serviceFrame(socket, generationId, "level", { level: 1 });
assert.deepEqual(harness.statuses, [
"● listening ▁▁▁▁▁▁▁▁▁▁▁▁",
"● listening ▁▁▁▁▁▁▁▁▁▁▁▅",
"● listening ▁▁▁▁▁▁▁▁▁▁▅█",
]);
});
test("rejects audio levels outside listening or the normalized range", async () => {
const beforeListening = createHarness();
const pending = await connectAndNegotiate(beforeListening);
serviceFrame(pending.socket, pending.generationId, "level", { level: 0.5 });
assert.match(beforeListening.notifications[0]?.message ?? "", /audio level is invalid/i);
const outOfRange = createHarness();
const active = await connectAndNegotiate(outOfRange);
serviceFrame(active.socket, active.generationId, "state", { state: "listening" });
serviceFrame(active.socket, active.generationId, "level", { level: 1.1 });
assert.match(outOfRange.notifications[0]?.message ?? "", /audio level is invalid/i);
});
test("ignores stale generations but rejects malformed and oversized frames", async () => { test("ignores stale generations but rejects malformed and oversized frames", async () => {
const staleHarness = createHarness(); const staleHarness = createHarness();
const { socket: staleSocket, generationId } = await connectAndNegotiate(staleHarness); const { socket: staleSocket, generationId } = await connectAndNegotiate(staleHarness);
serviceFrame(staleSocket, "old-generation", "state", { state: "listening" }); serviceFrame(staleSocket, "old-generation", "state", { state: "listening" });
assert.deepEqual(staleHarness.statuses, []); assert.deepEqual(staleHarness.statuses, []);
serviceFrame(staleSocket, generationId, "state", { state: "listening" }); serviceFrame(staleSocket, generationId, "state", { state: "listening" });
assert.deepEqual(staleHarness.statuses, ["listening..."]); assert.deepEqual(staleHarness.statuses, ["● listening ▁▁▁▁▁▁▁▁▁▁▁▁"]);
const malformedHarness = createHarness(); const malformedHarness = createHarness();
const malformed = await connectAndNegotiate(malformedHarness); const malformed = await connectAndNegotiate(malformedHarness);
+43 -2
View File
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
import { createConnection } from "node:net"; import { createConnection } from "node:net";
export const DICTATE_SOCKET_PATH = "/run/user/1000/pi-dictate.sock"; export const DICTATE_SOCKET_PATH = "/run/user/1000/pi-dictate.sock";
export const PROTOCOL_VERSION = 1; export const PROTOCOL_VERSION = 2;
export const MAX_FRAME_BYTES = 64 * 1024; export const MAX_FRAME_BYTES = 64 * 1024;
export const MAX_TRANSCRIPT_BYTES = 32 * 1024; export const MAX_TRANSCRIPT_BYTES = 32 * 1024;
export const MAX_ID_BYTES = 128; export const MAX_ID_BYTES = 128;
@@ -10,6 +10,8 @@ export const MAX_ID_BYTES = 128;
const STATUS_KEY = "dictate"; const STATUS_KEY = "dictate";
const MAX_ERROR_DETAIL_BYTES = 2 * 1024; const MAX_ERROR_DETAIL_BYTES = 2 * 1024;
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
const WAVEFORM_BARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
const WAVEFORM_SAMPLES = 12;
type DictateState = type DictateState =
| "idle" | "idle"
@@ -97,6 +99,7 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
let receiveBuffer = Buffer.alloc(0); let receiveBuffer = Buffer.alloc(0);
let nextId = 0; let nextId = 0;
let resultDelivered = false; let resultDelivered = false;
let audioLevels: number[] = [];
const newId = (kind: string) => `${kind}-${++nextId}`; const newId = (kind: string) => `${kind}-${++nextId}`;
@@ -104,6 +107,25 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
activeContext?.ui.setStatus(STATUS_KEY, status); activeContext?.ui.setStatus(STATUS_KEY, status);
}; };
const setListeningStatus = () => {
if (!activeContext) return;
const theme = activeContext.ui.theme;
const waveform = audioLevels
.map(
(level) =>
WAVEFORM_BARS[
Math.min(
WAVEFORM_BARS.length - 1,
Math.floor(level * WAVEFORM_BARS.length),
)
],
)
.join("");
setStatus(
`${theme.fg("error", "●")} ${theme.fg("dim", "listening")} ${theme.fg("accent", waveform)}`,
);
};
const closeSocket = (current: DictateSocket, force: boolean) => { const closeSocket = (current: DictateSocket, force: boolean) => {
try { try {
if (force) current.destroy(); if (force) current.destroy();
@@ -121,6 +143,7 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
negotiated = false; negotiated = false;
receiveBuffer = Buffer.alloc(0); receiveBuffer = Buffer.alloc(0);
resultDelivered = false; resultDelivered = false;
audioLevels = [];
state = "idle"; state = "idle";
setStatus(undefined); setStatus(undefined);
activeContext = null; activeContext = null;
@@ -183,7 +206,8 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
} }
if (serviceState === "listening" && state === "connecting") { if (serviceState === "listening" && state === "connecting") {
state = "recording"; state = "recording";
setStatus("listening..."); audioLevels = Array<number>(WAVEFORM_SAMPLES).fill(0);
setListeningStatus();
return true; return true;
} }
if (serviceState === "stopping" && state === "stopping") { if (serviceState === "stopping" && state === "stopping") {
@@ -267,6 +291,22 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
return; return;
} }
if (value.type === "level") {
if (
state !== "recording" ||
typeof value.level !== "number" ||
!Number.isFinite(value.level) ||
value.level < 0 ||
value.level > 1
) {
protocolError("audio level is invalid for the current state.");
return;
}
audioLevels = [...audioLevels.slice(1), value.level];
setListeningStatus();
return;
}
if (value.type === "result") { if (value.type === "result") {
if (state !== "transcribing" || typeof value.transcript !== "string") { if (state !== "transcribing" || typeof value.transcript !== "string") {
protocolError("result is invalid for the current state."); protocolError("result is invalid for the current state.");
@@ -349,6 +389,7 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
helloRequestId = newId("hello"); helloRequestId = newId("hello");
negotiated = false; negotiated = false;
resultDelivered = false; resultDelivered = false;
audioLevels = [];
receiveBuffer = Buffer.alloc(0); receiveBuffer = Buffer.alloc(0);
let current: DictateSocket; let current: DictateSocket;
@@ -86,12 +86,13 @@ class NemoParakeetModel:
os.environ["NEMO_OFFLINE"] = "1" os.environ["NEMO_OFFLINE"] = "1"
import numpy import numpy
from nemo.collections.asr.models import ASRModel import torch
from nemo.collections.asr.models import EncDecRNNTBPEModel
from omegaconf import open_dict from omegaconf import open_dict
model = ASRModel.restore_from( model = EncDecRNNTBPEModel.restore_from(
restore_path=str(self._model_path), restore_path=str(self._model_path),
map_location="cuda", map_location=torch.device("cuda"),
) )
model.to("cuda") model.to("cuda")
model.eval() model.eval()
@@ -1,4 +1,4 @@
"""Bounded newline-delimited JSON protocol primitives for dictate protocol v1.""" """Bounded newline-delimited JSON protocol primitives for dictate protocol v2."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,7 @@ from dataclasses import dataclass
import json import json
from typing import Any, Mapping from typing import Any, Mapping
PROTOCOL_VERSION = 1 PROTOCOL_VERSION = 2
MAX_FRAME_BYTES = 64 * 1024 MAX_FRAME_BYTES = 64 * 1024
MAX_TRANSCRIPT_BYTES = 32 * 1024 MAX_TRANSCRIPT_BYTES = 32 * 1024
MAX_ERROR_DETAIL_BYTES = 2 * 1024 MAX_ERROR_DETAIL_BYTES = 2 * 1024
@@ -2,10 +2,13 @@
from __future__ import annotations from __future__ import annotations
from array import array
from dataclasses import dataclass from dataclasses import dataclass
import math
import os import os
import signal import signal
import subprocess import subprocess
import sys
import threading import threading
import time import time
from typing import BinaryIO, Callable, Protocol from typing import BinaryIO, Callable, Protocol
@@ -24,6 +27,7 @@ PW_RECORD_COMMAND = [
"-", "-",
] ]
MAX_STDERR_BYTES = 64 * 1024 MAX_STDERR_BYTES = 64 * 1024
AUDIO_LEVEL_WINDOW_BYTES = 3_200 # 100 ms of mono 16 kHz signed-16 PCM
class Process(Protocol): class Process(Protocol):
@@ -95,12 +99,18 @@ class PwRecorder:
self._duration_limited = False self._duration_limited = False
self._duration_stop_at: float | None = None self._duration_stop_at: float | None = None
self._reported = False self._reported = False
self._audio_level = 0.0
@property @property
def buffered_bytes(self) -> int: def buffered_bytes(self) -> int:
with self._lock: with self._lock:
return len(self._pcm) return len(self._pcm)
@property
def audio_level(self) -> float:
with self._lock:
return self._audio_level
def start(self) -> None: def start(self) -> None:
if self._process is not None: if self._process is not None:
raise RecorderError("invalid_state", "recorder is already started") raise RecorderError("invalid_state", "recorder is already started")
@@ -223,10 +233,15 @@ class PwRecorder:
def _drain_stdout(self) -> None: def _drain_stdout(self) -> None:
process = self._require_process() process = self._require_process()
level_window = bytearray()
while True: while True:
chunk = process.stdout.read(64 * 1024) chunk = process.stdout.read(AUDIO_LEVEL_WINDOW_BYTES)
if not chunk: if not chunk:
return return
level_window.extend(chunk)
while len(level_window) >= AUDIO_LEVEL_WINDOW_BYTES:
self._update_audio_level(level_window[:AUDIO_LEVEL_WINDOW_BYTES])
del level_window[:AUDIO_LEVEL_WINDOW_BYTES]
with self._lock: with self._lock:
if self._duration_limited: if self._duration_limited:
continue continue
@@ -240,6 +255,19 @@ class PwRecorder:
if process.poll() is None: if process.poll() is None:
self._signal_group(process, signal.SIGTERM) self._signal_group(process, signal.SIGTERM)
def _update_audio_level(self, chunk: bytes) -> None:
sample_bytes = len(chunk) - (len(chunk) % 2)
if sample_bytes == 0:
return
samples = array("h")
samples.frombytes(chunk[:sample_bytes])
if sys.byteorder != "little":
samples.byteswap()
rms = math.sqrt(sum(sample * sample for sample in samples) / len(samples)) / 32768.0
level = 0.0 if rms == 0 else max(0.0, min(1.0, (20 * math.log10(rms) + 60) / 60))
with self._lock:
self._audio_level = max(level, self._audio_level * 0.65)
def _drain_stderr(self) -> None: def _drain_stderr(self) -> None:
process = self._require_process() process = self._require_process()
while True: while True:
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from concurrent.futures import Executor, Future, ThreadPoolExecutor from concurrent.futures import Executor, Future, ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
import math
import os import os
from queue import Empty, SimpleQueue from queue import Empty, SimpleQueue
import selectors import selectors
@@ -30,9 +31,13 @@ from .recorder import Capture, PwRecorder, RecorderError
DEFAULT_IDLE_SECONDS = 15 * 60 DEFAULT_IDLE_SECONDS = 15 * 60
MAX_CLIENT_CONNECTIONS = 16 MAX_CLIENT_CONNECTIONS = 16
MAX_CONNECTION_OUTPUT_BYTES = MAX_FRAME_BYTES MAX_CONNECTION_OUTPUT_BYTES = MAX_FRAME_BYTES
LEVEL_EVENT_INTERVAL_SECONDS = 0.1
class Recorder(Protocol): class Recorder(Protocol):
@property
def audio_level(self) -> float: ...
def start(self) -> None: ... def start(self) -> None: ...
def stop(self) -> Capture: ... def stop(self) -> Capture: ...
def cancel(self) -> None: ... def cancel(self) -> None: ...
@@ -53,6 +58,7 @@ class _Generation:
state: str state: str
recorder: Recorder | None = None recorder: Recorder | None = None
tombstoned: bool = False tombstoned: bool = False
last_level_event_at: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -156,6 +162,8 @@ class DictateServiceCore:
if failure is not None: if failure is not None:
self._async_error(active, failure.code, failure.detail) self._async_error(active, failure.code, failure.detail)
self._finish_generation(active, notify_idle=True) self._finish_generation(active, notify_idle=True)
return
self._emit_audio_level(active)
def drain_events(self) -> list[Outbound]: def drain_events(self) -> list[Outbound]:
events, self._events = self._events, [] events, self._events = self._events, []
@@ -216,8 +224,31 @@ class DictateServiceCore:
self._finish_generation(active, notify_idle=True) self._finish_generation(active, notify_idle=True)
return return
active.recorder = recorder active.recorder = recorder
active.last_level_event_at = self._clock()
self._state(active, "listening") self._state(active, "listening")
def _emit_audio_level(self, active: _Generation) -> None:
now = self._clock()
if (
active.last_level_event_at is not None
and now - active.last_level_event_at < LEVEL_EVENT_INTERVAL_SECONDS
):
return
level = active.recorder.audio_level if active.recorder is not None else 0.0
if not math.isfinite(level):
level = 0.0
active.last_level_event_at = now
self._emit(
active.owner,
{
"version": PROTOCOL_VERSION,
"type": "level",
"requestId": active.request_id,
"generationId": active.generation_id,
"level": round(max(0.0, min(1.0, level)), 3),
},
)
def _stop(self, client_id: str, request: Request) -> None: def _stop(self, client_id: str, request: Request) -> None:
active = self._owned_generation(client_id, request) active = self._owned_generation(client_id, request)
if active is None: if active is None:
@@ -433,7 +464,7 @@ def inherited_listener(environ: dict[str, str] | None = None) -> socket.socket:
def serve(listener: socket.socket, core: DictateServiceCore) -> None: def serve(listener: socket.socket, core: DictateServiceCore) -> None:
"""Serve protocol v1 on an explicit listener until the core becomes idle.""" """Serve protocol v2 on an explicit listener until the core becomes idle."""
selector = selectors.DefaultSelector() selector = selectors.DefaultSelector()
connections: dict[str, _Connection] = {} connections: dict[str, _Connection] = {}
sequence = 0 sequence = 0
+17 -11
View File
@@ -35,13 +35,15 @@ class FakeNemoModel:
return [type("Hypothesis", (), {"text": "hello locally"})()] return [type("Hypothesis", (), {"text": "hello locally"})()]
class FakeASRModel: class FakeConcreteParakeetModel:
restored_path: str | None = None restored_path: str | None = None
restored_location: object | None = None
restored: FakeNemoModel | None = None restored: FakeNemoModel | None = None
@classmethod @classmethod
def restore_from(cls, restore_path: str, map_location: str) -> FakeNemoModel: def restore_from(cls, restore_path: str, map_location: object) -> FakeNemoModel:
cls.restored_path = restore_path cls.restored_path = restore_path
cls.restored_location = map_location
self = FakeNemoModel() self = FakeNemoModel()
cls.restored = self cls.restored = self
return self return self
@@ -69,28 +71,29 @@ class NemoParakeetModelTests(unittest.TestCase):
boost_alpha=0.5, boost_alpha=0.5,
expected_sha256=hashlib.sha256(b"model").hexdigest(), expected_sha256=hashlib.sha256(b"model").hexdigest(),
) )
self.assertIsNone(FakeASRModel.restored_path) self.assertIsNone(FakeConcreteParakeetModel.restored_path)
adapter.load() adapter.load()
transcript = adapter.transcribe([0.0, 0.5]) transcript = adapter.transcribe([0.0, 0.5])
self.assertEqual(os.environ["HF_HUB_OFFLINE"], "1") self.assertEqual(os.environ["HF_HUB_OFFLINE"], "1")
self.assertEqual(os.environ["TRANSFORMERS_OFFLINE"], "1") self.assertEqual(os.environ["TRANSFORMERS_OFFLINE"], "1")
self.assertEqual(FakeASRModel.restored_path, str(model_path)) self.assertEqual(FakeConcreteParakeetModel.restored_path, str(model_path))
self.assertTrue(FakeASRModel.restored.eval_called) self.assertEqual(FakeConcreteParakeetModel.restored_location, "device:cuda")
self.assertEqual(FakeASRModel.restored.to_device, "cuda") self.assertTrue(FakeConcreteParakeetModel.restored.eval_called)
self.assertEqual(FakeConcreteParakeetModel.restored.to_device, "cuda")
self.assertEqual(transcript, "hello locally") self.assertEqual(transcript, "hello locally")
self.assertEqual(FakeASRModel.restored.audio, [([0.0, 0.5], "float32")]) self.assertEqual(FakeConcreteParakeetModel.restored.audio, [([0.0, 0.5], "float32")])
self.assertEqual( self.assertEqual(
FakeASRModel.restored.decoding_config["greedy"]["boosting_tree"]["key_phrases_list"], FakeConcreteParakeetModel.restored.decoding_config["greedy"]["boosting_tree"]["key_phrases_list"],
["JSON", "npm test"], ["JSON", "npm test"],
) )
self.assertEqual( self.assertEqual(
FakeASRModel.restored.decoding_config["greedy"]["boosting_tree_alpha"], FakeConcreteParakeetModel.restored.decoding_config["greedy"]["boosting_tree_alpha"],
0.5, 0.5,
) )
self.assertEqual( self.assertEqual(
FakeASRModel.restored.transcribe_options, FakeConcreteParakeetModel.restored.transcribe_options,
{"batch_size": 1, "num_workers": 0, "verbose": False}, {"batch_size": 1, "num_workers": 0, "verbose": False},
) )
@@ -123,12 +126,14 @@ class NemoParakeetModelTests(unittest.TestCase):
collections = ModuleType("nemo.collections") collections = ModuleType("nemo.collections")
asr = ModuleType("nemo.collections.asr") asr = ModuleType("nemo.collections.asr")
models = ModuleType("nemo.collections.asr.models") models = ModuleType("nemo.collections.asr.models")
models.ASRModel = FakeASRModel models.EncDecRNNTBPEModel = FakeConcreteParakeetModel
asr.models = models asr.models = models
collections.asr = asr collections.asr = asr
nemo.collections = collections nemo.collections = collections
omegaconf = ModuleType("omegaconf") omegaconf = ModuleType("omegaconf")
omegaconf.open_dict = lambda _config: nullcontext() omegaconf.open_dict = lambda _config: nullcontext()
torch = ModuleType("torch")
torch.device = lambda name: f"device:{name}"
return { return {
"nemo": nemo, "nemo": nemo,
"nemo.collections": collections, "nemo.collections": collections,
@@ -136,6 +141,7 @@ class NemoParakeetModelTests(unittest.TestCase):
"nemo.collections.asr.models": models, "nemo.collections.asr.models": models,
"numpy": FakeNumpy("numpy"), "numpy": FakeNumpy("numpy"),
"omegaconf": omegaconf, "omegaconf": omegaconf,
"torch": torch,
} }
+13 -12
View File
@@ -7,6 +7,7 @@ from pi_dictate_service.protocol import (
MAX_FRAME_BYTES, MAX_FRAME_BYTES,
MAX_ID_BYTES, MAX_ID_BYTES,
MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_BYTES,
PROTOCOL_VERSION,
FrameDecoder, FrameDecoder,
ProtocolError, ProtocolError,
encode_frame, encode_frame,
@@ -18,10 +19,10 @@ class FrameDecoderTests(unittest.TestCase):
def test_decodes_fragmented_and_multiple_frames(self) -> None: def test_decodes_fragmented_and_multiple_frames(self) -> None:
decoder = FrameDecoder() decoder = FrameDecoder()
self.assertEqual(decoder.feed(b'{"version":1'), []) self.assertEqual(decoder.feed(f'{{"version":{PROTOCOL_VERSION}'.encode()), [])
frames = decoder.feed( frames = decoder.feed(
b',"type":"ping","requestId":"one"}\n' b',"type":"ping","requestId":"one"}\n'
b'{"version":1,"type":"ping","requestId":"two"}\n' + f'{{"version":{PROTOCOL_VERSION},"type":"ping","requestId":"two"}}\n'.encode()
) )
self.assertEqual([frame["requestId"] for frame in frames], ["one", "two"]) self.assertEqual([frame["requestId"] for frame in frames], ["one", "two"])
@@ -41,14 +42,14 @@ class FrameDecoderTests(unittest.TestCase):
class RequestValidationTests(unittest.TestCase): class RequestValidationTests(unittest.TestCase):
def test_requires_exact_fields_bounded_ids_and_supported_version(self) -> None: def test_requires_exact_fields_bounded_ids_and_supported_version(self) -> None:
request = parse_request( request = parse_request(
{"version": 1, "type": "start", "requestId": "r1", "generationId": "g1"} {"version": PROTOCOL_VERSION, "type": "start", "requestId": "r1", "generationId": "g1"}
) )
self.assertEqual(request.generation_id, "g1") self.assertEqual(request.generation_id, "g1")
with self.assertRaises(ProtocolError) as unknown: with self.assertRaises(ProtocolError) as unknown:
parse_request( parse_request(
{ {
"version": 1, "version": PROTOCOL_VERSION,
"type": "start", "type": "start",
"requestId": "r1", "requestId": "r1",
"generationId": "g1", "generationId": "g1",
@@ -61,7 +62,7 @@ class RequestValidationTests(unittest.TestCase):
with self.assertRaises(ProtocolError) as long_id: with self.assertRaises(ProtocolError) as long_id:
parse_request( parse_request(
{ {
"version": 1, "version": PROTOCOL_VERSION,
"type": "ping", "type": "ping",
"requestId": "é" * ((MAX_ID_BYTES // 2) + 1), "requestId": "é" * ((MAX_ID_BYTES // 2) + 1),
} }
@@ -69,7 +70,7 @@ class RequestValidationTests(unittest.TestCase):
self.assertEqual(long_id.exception.code, "invalid_request") self.assertEqual(long_id.exception.code, "invalid_request")
with self.assertRaises(ProtocolError) as unsupported: with self.assertRaises(ProtocolError) as unsupported:
parse_request({"version": 2, "type": "hello", "requestId": "r2"}) parse_request({"version": PROTOCOL_VERSION + 1, "type": "hello", "requestId": "r2"})
self.assertEqual(unsupported.exception.code, "unsupported_version") self.assertEqual(unsupported.exception.code, "unsupported_version")
self.assertTrue(unsupported.exception.close_connection) self.assertTrue(unsupported.exception.close_connection)
@@ -78,12 +79,12 @@ class RequestValidationTests(unittest.TestCase):
with self.subTest(request_type=request_type): with self.subTest(request_type=request_type):
with self.assertRaises(ProtocolError) as failed: with self.assertRaises(ProtocolError) as failed:
parse_request( parse_request(
{"version": 1, "type": request_type, "requestId": "request"} {"version": PROTOCOL_VERSION, "type": request_type, "requestId": "request"}
) )
self.assertEqual(failed.exception.code, "invalid_request") self.assertEqual(failed.exception.code, "invalid_request")
with self.assertRaises(ProtocolError) as surrogate: with self.assertRaises(ProtocolError) as surrogate:
parse_request({"version": 1, "type": "ping", "requestId": "\ud800"}) parse_request({"version": PROTOCOL_VERSION, "type": "ping", "requestId": "\ud800"})
self.assertEqual(surrogate.exception.request_id, "") self.assertEqual(surrogate.exception.request_id, "")
huge_integer = b'{"version":' + (b"9" * 5000) + b',"type":"hello","requestId":"r"}\n' huge_integer = b'{"version":' + (b"9" * 5000) + b',"type":"hello","requestId":"r"}\n'
@@ -96,13 +97,13 @@ class RequestValidationTests(unittest.TestCase):
oversized = "x" * (MAX_ID_BYTES + 1) oversized = "x" * (MAX_ID_BYTES + 1)
with self.assertRaises(ProtocolError) as unsupported: with self.assertRaises(ProtocolError) as unsupported:
parse_request( parse_request(
{"version": 2, "type": "hello", "requestId": oversized} {"version": PROTOCOL_VERSION + 1, "type": "hello", "requestId": oversized}
) )
self.assertEqual(unsupported.exception.request_id, "") self.assertEqual(unsupported.exception.request_id, "")
encoded = encode_frame( encoded = encode_frame(
{ {
"version": 1, "version": PROTOCOL_VERSION,
"type": "error", "type": "error",
"requestId": oversized, "requestId": oversized,
"generationId": "\ud800", "generationId": "\ud800",
@@ -118,7 +119,7 @@ class RequestValidationTests(unittest.TestCase):
with self.assertRaises(ProtocolError) as transcript: with self.assertRaises(ProtocolError) as transcript:
encode_frame( encode_frame(
{ {
"version": 1, "version": PROTOCOL_VERSION,
"type": "result", "type": "result",
"generationId": "g1", "generationId": "g1",
"transcript": "é" * ((MAX_TRANSCRIPT_BYTES // 2) + 1), "transcript": "é" * ((MAX_TRANSCRIPT_BYTES // 2) + 1),
@@ -128,7 +129,7 @@ class RequestValidationTests(unittest.TestCase):
encoded = encode_frame( encoded = encode_frame(
{ {
"version": 1, "version": PROTOCOL_VERSION,
"type": "error", "type": "error",
"requestId": "r1", "requestId": "r1",
"code": "failed", "code": "failed",
+69 -3
View File
@@ -1,19 +1,34 @@
import io import io
import math
import subprocess import subprocess
import threading import threading
import unittest import unittest
from pi_dictate_service.recorder import PW_RECORD_COMMAND, PwRecorder, RecorderError from pi_dictate_service.recorder import (
AUDIO_LEVEL_WINDOW_BYTES,
PW_RECORD_COMMAND,
PwRecorder,
RecorderError,
)
class TrackingStream(io.BytesIO): class TrackingStream(io.BytesIO):
def __init__(self, value: bytes, events: list[str], name: str) -> None: def __init__(
self,
value: bytes,
events: list[str],
name: str,
max_read_size: int | None = None,
) -> None:
super().__init__(value) super().__init__(value)
self._events = events self._events = events
self._name = name self._name = name
self._max_read_size = max_read_size
self.eof = threading.Event() self.eof = threading.Event()
def read(self, size: int = -1) -> bytes: def read(self, size: int = -1) -> bytes:
if self._max_read_size is not None:
size = min(size, self._max_read_size)
value = super().read(size) value = super().read(size)
if not value and not self.eof.is_set(): if not value and not self.eof.is_set():
self._events.append(f"{self._name}-eof") self._events.append(f"{self._name}-eof")
@@ -91,10 +106,11 @@ class PwRecorderTests(unittest.TestCase):
stderr_limit: int = 64 * 1024, stderr_limit: int = 64 * 1024,
clock: Clock | None = None, clock: Clock | None = None,
terminate_returncode: int = -15, terminate_returncode: int = -15,
stdout_read_size: int | None = None,
) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]: ) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]:
events: list[str] = [] events: list[str] = []
process = FakeProcess( process = FakeProcess(
TrackingStream(pcm, events, "stdout"), TrackingStream(pcm, events, "stdout", stdout_read_size),
TrackingStream(stderr, events, "stderr"), TrackingStream(stderr, events, "stderr"),
returncode=returncode, returncode=returncode,
timeout=timeout, timeout=timeout,
@@ -138,6 +154,56 @@ class PwRecorderTests(unittest.TestCase):
self.assertIn("stdout-eof", events) self.assertIn("stdout-eof", events)
self.assertIn("wait", events) self.assertIn("wait", events)
def test_tracks_normalized_audio_level_from_pcm_without_exposing_audio(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
pcm=b"\xff\x7f" * (AUDIO_LEVEL_WINDOW_BYTES // 2),
max_bytes=AUDIO_LEVEL_WINDOW_BYTES,
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
self.assertGreater(recorder.audio_level, 0.99)
self.assertLessEqual(recorder.audio_level, 1.0)
recorder.cancel()
def test_audio_level_accumulates_fragmented_reads_into_100ms_windows(self) -> None:
loud_sample = (32767).to_bytes(2, "little", signed=True)
pcm = (
loud_sample * (AUDIO_LEVEL_WINDOW_BYTES // 4)
+ b"\x00" * (AUDIO_LEVEL_WINDOW_BYTES // 2)
+ loud_sample * 400
)
recorder, process, _factory, _events = self.make_recorder(
pcm=pcm,
max_bytes=len(pcm),
stdout_read_size=800,
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
expected_rms = (0.5**0.5) * 32767 / 32768
expected_level = (20 * math.log10(expected_rms) + 60) / 60
self.assertAlmostEqual(recorder.audio_level, expected_level, places=3)
recorder.cancel()
def test_audio_level_maps_silence_known_rms_decay_and_partial_samples(self) -> None:
recorder = PwRecorder()
recorder._update_audio_level(b"\x00\x00" * 16)
self.assertEqual(recorder.audio_level, 0.0)
amplitude = 3277 # approximately -20 dBFS
recorder._update_audio_level(amplitude.to_bytes(2, "little", signed=True) * 16)
self.assertAlmostEqual(recorder.audio_level, 2 / 3, places=3)
recorder._update_audio_level(b"\x00\x00" * 16)
self.assertAlmostEqual(recorder.audio_level, (2 / 3) * 0.65, places=3)
previous = recorder.audio_level
recorder._update_audio_level(b"\xff")
self.assertEqual(recorder.audio_level, previous)
def test_accepts_pw_record_code_one_after_intentional_sigterm(self) -> None: def test_accepts_pw_record_code_one_after_intentional_sigterm(self) -> None:
recorder, process, _factory, _events = self.make_recorder(terminate_returncode=1) recorder, process, _factory, _events = self.make_recorder(terminate_returncode=1)
recorder.start() recorder.start()
+38 -5
View File
@@ -3,7 +3,7 @@ from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
import unittest import unittest
from pi_dictate_service.protocol import MAX_TRANSCRIPT_BYTES, parse_request from pi_dictate_service.protocol import MAX_TRANSCRIPT_BYTES, PROTOCOL_VERSION, parse_request
from pi_dictate_service.recorder import Capture, RecorderError from pi_dictate_service.recorder import Capture, RecorderError
from pi_dictate_service.server import DictateServiceCore, Outbound from pi_dictate_service.server import DictateServiceCore, Outbound
@@ -73,6 +73,7 @@ class FakeRecorder:
self.started = False self.started = False
self.cancelled = False self.cancelled = False
self.capture = Capture(b"\x00\x00\x00\x40", -15, 15, b"") self.capture = Capture(b"\x00\x00\x00\x40", -15, 15, b"")
self.audio_level = 0.0
self.stop_error: RecorderError | None = None self.stop_error: RecorderError | None = None
self.unexpected: RecorderError | None = None self.unexpected: RecorderError | None = None
@@ -124,7 +125,7 @@ class CoreTestCase(unittest.TestCase):
generation_id: str | None = None, generation_id: str | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
frame: dict[str, object] = { frame: dict[str, object] = {
"version": 1, "version": PROTOCOL_VERSION,
"type": request_type, "type": request_type,
"requestId": request_id, "requestId": request_id,
} }
@@ -217,6 +218,38 @@ class CancellationTests(CoreTestCase):
self.assertEqual(self.core.active_generation_id, None) self.assertEqual(self.core.active_generation_id, None)
class AudioLevelTests(CoreTestCase):
def test_emits_owned_normalized_audio_levels_at_no_more_than_ten_hertz(self) -> None:
recorder = self.load_to_listening()
recorder.audio_level = 0.625
events: list[Outbound] = []
for _ in range(40):
self.clock.advance(0.025)
self.core.poll()
events.extend(self.core.drain_events())
self.assertGreaterEqual(len(events), 9)
self.assertLessEqual(len(events), 10)
self.assertEqual({event.client_id for event in events}, {"a"})
self.assertEqual({event.frame["type"] for event in events}, {"level"})
self.assertEqual({event.frame["level"] for event in events}, {0.625})
def test_clamps_invalid_levels_and_stops_emitting_after_listening(self) -> None:
recorder = self.load_to_listening()
for raw_level, expected in ((2.0, 1.0), (-1.0, 0.0), (float("nan"), 0.0)):
recorder.audio_level = raw_level
self.clock.advance(0.1)
self.core.poll()
level = self.frames(self.core.drain_events(), "a")
self.assertEqual(level[0]["level"], expected)
self.request("a", "stop", "stop", "g1")
self.clock.advance(0.2)
self.core.poll()
self.assertEqual(self.core.drain_events(), [])
class StopAndInferenceTests(CoreTestCase): class StopAndInferenceTests(CoreTestCase):
def test_stop_drains_then_converts_before_inference_and_emits_result(self) -> None: def test_stop_drains_then_converts_before_inference_and_emits_result(self) -> None:
self.load_to_listening() self.load_to_listening()
@@ -271,11 +304,11 @@ class StopAndInferenceTests(CoreTestCase):
executor=self.executor, executor=self.executor,
clock=self.clock, clock=self.clock,
) )
core.handle_request("a", parse_request({"version": 1, "type": "hello", "requestId": "h"})) core.handle_request("a", parse_request({"version": PROTOCOL_VERSION, "type": "hello", "requestId": "h"}))
core.drain_events() core.drain_events()
core.handle_request("a", parse_request({"version": 1, "type": "start", "requestId": "s", "generationId": "g"})) core.handle_request("a", parse_request({"version": PROTOCOL_VERSION, "type": "start", "requestId": "s", "generationId": "g"}))
core.drain_events() core.drain_events()
core.handle_request("a", parse_request({"version": 1, "type": "stop", "requestId": "t", "generationId": "g"})) core.handle_request("a", parse_request({"version": PROTOCOL_VERSION, "type": "stop", "requestId": "t", "generationId": "g"}))
self.executor.run_next() self.executor.run_next()
core.poll() core.poll()
core.drain_events() core.drain_events()
+14 -14
View File
@@ -73,24 +73,24 @@ class UnixLoopTests(unittest.TestCase):
def test_accepts_fragmented_requests_and_writes_persistent_responses(self) -> None: def test_accepts_fragmented_requests_and_writes_persistent_responses(self) -> None:
with self.connect() as client: with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello",') client.sendall(b'{"version":2,"type":"hello",')
client.sendall(b'"requestId":"hello"}\n') client.sendall(b'"requestId":"hello"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong") self.assertEqual(self.receive_frame(client)["type"], "pong")
client.sendall(b'{"version":1,"type":"ping","requestId":"ping"}\n') client.sendall(b'{"version":2,"type":"ping","requestId":"ping"}\n')
pong = self.receive_frame(client) pong = self.receive_frame(client)
self.assertEqual(pong["requestId"], "ping") self.assertEqual(pong["requestId"], "ping")
self.assertEqual(pong["service"], "pi-dictate") self.assertEqual(pong["service"], "pi-dictate")
def test_valid_state_error_keeps_open_but_malformed_frame_errors_once_then_closes(self) -> None: def test_valid_state_error_keeps_open_but_malformed_frame_errors_once_then_closes(self) -> None:
with self.connect() as client: with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"hello"}\n') client.sendall(b'{"version":2,"type":"hello","requestId":"hello"}\n')
self.receive_frame(client) self.receive_frame(client)
client.sendall( client.sendall(
b'{"version":1,"type":"stop","requestId":"stop","generationId":"g"}\n' b'{"version":2,"type":"stop","requestId":"stop","generationId":"g"}\n'
) )
self.assertEqual(self.receive_frame(client)["code"], "invalid_state") self.assertEqual(self.receive_frame(client)["code"], "invalid_state")
client.sendall(b'{"version":1,"type":"ping","requestId":"ping"}\n') client.sendall(b'{"version":2,"type":"ping","requestId":"ping"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong") self.assertEqual(self.receive_frame(client)["type"], "pong")
client.sendall(b'{broken}\n') client.sendall(b'{broken}\n')
@@ -101,9 +101,9 @@ class UnixLoopTests(unittest.TestCase):
def test_malicious_json_values_do_not_crash_the_service(self) -> None: def test_malicious_json_values_do_not_crash_the_service(self) -> None:
with self.connect() as client: with self.connect() as client:
client.sendall(b'{"version":1,"type":[],"requestId":"bad-type"}\n') client.sendall(b'{"version":2,"type":[],"requestId":"bad-type"}\n')
self.assertEqual(self.receive_frame(client)["code"], "invalid_request") self.assertEqual(self.receive_frame(client)["code"], "invalid_request")
client.sendall(b'{"version":1,"type":"ping","requestId":"\\ud800"}\n') client.sendall(b'{"version":2,"type":"ping","requestId":"\\ud800"}\n')
self.assertEqual(self.receive_frame(client)["code"], "invalid_request") self.assertEqual(self.receive_frame(client)["code"], "invalid_request")
with self.connect() as client: with self.connect() as client:
@@ -115,14 +115,14 @@ class UnixLoopTests(unittest.TestCase):
self.assertEqual(client.recv(1), b"") self.assertEqual(client.recv(1), b"")
with self.connect() as client: with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"healthy"}\n') client.sendall(b'{"version":2,"type":"hello","requestId":"healthy"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong") self.assertEqual(self.receive_frame(client)["type"], "pong")
def test_closes_non_reading_client_before_output_queue_can_grow_unbounded(self) -> None: def test_closes_non_reading_client_before_output_queue_can_grow_unbounded(self) -> None:
with self.connect() as client: with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"hello"}\n') client.sendall(b'{"version":2,"type":"hello","requestId":"hello"}\n')
self.receive_frame(client) self.receive_frame(client)
ping = b'{"version":1,"type":"ping","requestId":"p"}\n' ping = b'{"version":2,"type":"ping","requestId":"p"}\n'
try: try:
client.sendall(ping * 1200) client.sendall(ping * 1200)
except BrokenPipeError: except BrokenPipeError:
@@ -139,7 +139,7 @@ class UnixLoopTests(unittest.TestCase):
self.assertLessEqual(len(received), 64 * 1024) self.assertLessEqual(len(received), 64 * 1024)
with self.connect() as client: with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"healthy"}\n') client.sendall(b'{"version":2,"type":"hello","requestId":"healthy"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong") self.assertEqual(self.receive_frame(client)["type"], "pong")
def test_rejects_connections_above_the_fixed_limit(self) -> None: def test_rejects_connections_above_the_fixed_limit(self) -> None:
@@ -148,7 +148,7 @@ class UnixLoopTests(unittest.TestCase):
time.sleep(0.1) time.sleep(0.1)
overflow = self.connect() overflow = self.connect()
try: try:
overflow.sendall(b'{"version":1,"type":"hello","requestId":"overflow"}\n') overflow.sendall(b'{"version":2,"type":"hello","requestId":"overflow"}\n')
with self.assertRaises((ConnectionResetError, BrokenPipeError)): with self.assertRaises((ConnectionResetError, BrokenPipeError)):
if overflow.recv(1) == b"": if overflow.recv(1) == b"":
raise ConnectionResetError raise ConnectionResetError
@@ -161,12 +161,12 @@ class UnixLoopTests(unittest.TestCase):
def test_client_reset_does_not_crash_the_service(self) -> None: def test_client_reset_does_not_crash_the_service(self) -> None:
reset_client = self.connect() reset_client = self.connect()
reset_client.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) reset_client.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
reset_client.sendall(b'{"version":1,"type":"hello","requestId":"abandoned"}\n') reset_client.sendall(b'{"version":2,"type":"hello","requestId":"abandoned"}\n')
reset_client.close() reset_client.close()
time.sleep(0.15) time.sleep(0.15)
with self.connect() as client: with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"hello"}\n') client.sendall(b'{"version":2,"type":"hello","requestId":"hello"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong") self.assertEqual(self.receive_frame(client)["type"], "pong")