feat(dictate): add voice-reactive listening waveform
This commit is contained in:
@@ -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.
|
||||
|
||||
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...`
|
||||
- `listening...`
|
||||
- `stopping...`
|
||||
- `transcribing...`
|
||||
```text
|
||||
● listening ▁▂▄▇█▆▃▂▅▇▄▂
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -77,10 +77,17 @@ function createHarness() {
|
||||
const sockets: FakeSocket[] = [];
|
||||
const socketPaths: string[] = [];
|
||||
const statuses: Array<string | undefined> = [];
|
||||
const themeCalls: Array<{ color: string; text: string }> = [];
|
||||
const notifications: Array<{ message: string; type?: string }> = [];
|
||||
const pasted: string[] = [];
|
||||
const context = {
|
||||
ui: {
|
||||
theme: {
|
||||
fg: (color: string, text: string) => {
|
||||
themeCalls.push({ color, text });
|
||||
return text;
|
||||
},
|
||||
},
|
||||
setStatus: (_key: string, text: string | undefined) => statuses.push(text),
|
||||
notify: (message: string, type?: string) =>
|
||||
notifications.push({ message, type }),
|
||||
@@ -134,6 +141,7 @@ function createHarness() {
|
||||
sockets,
|
||||
socketPaths,
|
||||
statuses,
|
||||
themeCalls,
|
||||
notifications,
|
||||
pasted,
|
||||
context,
|
||||
@@ -213,7 +221,11 @@ test("rejects a mismatched protocol version before sending start", async () => {
|
||||
assert.ok(socket);
|
||||
socket.connect();
|
||||
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.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, [
|
||||
"loading model...",
|
||||
"listening...",
|
||||
"● listening ▁▁▁▁▁▁▁▁▁▁▁▁",
|
||||
"stopping...",
|
||||
"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 () => {
|
||||
const staleHarness = createHarness();
|
||||
const { socket: staleSocket, generationId } = await connectAndNegotiate(staleHarness);
|
||||
serviceFrame(staleSocket, "old-generation", "state", { state: "listening" });
|
||||
assert.deepEqual(staleHarness.statuses, []);
|
||||
serviceFrame(staleSocket, generationId, "state", { state: "listening" });
|
||||
assert.deepEqual(staleHarness.statuses, ["listening..."]);
|
||||
assert.deepEqual(staleHarness.statuses, ["● listening ▁▁▁▁▁▁▁▁▁▁▁▁"]);
|
||||
|
||||
const malformedHarness = createHarness();
|
||||
const malformed = await connectAndNegotiate(malformedHarness);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
||||
import { createConnection } from "node:net";
|
||||
|
||||
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_TRANSCRIPT_BYTES = 32 * 1024;
|
||||
export const MAX_ID_BYTES = 128;
|
||||
@@ -10,6 +10,8 @@ export const MAX_ID_BYTES = 128;
|
||||
const STATUS_KEY = "dictate";
|
||||
const MAX_ERROR_DETAIL_BYTES = 2 * 1024;
|
||||
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
||||
const WAVEFORM_BARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
|
||||
const WAVEFORM_SAMPLES = 12;
|
||||
|
||||
type DictateState =
|
||||
| "idle"
|
||||
@@ -97,6 +99,7 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
|
||||
let receiveBuffer = Buffer.alloc(0);
|
||||
let nextId = 0;
|
||||
let resultDelivered = false;
|
||||
let audioLevels: number[] = [];
|
||||
|
||||
const newId = (kind: string) => `${kind}-${++nextId}`;
|
||||
|
||||
@@ -104,6 +107,25 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
|
||||
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) => {
|
||||
try {
|
||||
if (force) current.destroy();
|
||||
@@ -121,6 +143,7 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
|
||||
negotiated = false;
|
||||
receiveBuffer = Buffer.alloc(0);
|
||||
resultDelivered = false;
|
||||
audioLevels = [];
|
||||
state = "idle";
|
||||
setStatus(undefined);
|
||||
activeContext = null;
|
||||
@@ -183,7 +206,8 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
|
||||
}
|
||||
if (serviceState === "listening" && state === "connecting") {
|
||||
state = "recording";
|
||||
setStatus("listening...");
|
||||
audioLevels = Array<number>(WAVEFORM_SAMPLES).fill(0);
|
||||
setListeningStatus();
|
||||
return true;
|
||||
}
|
||||
if (serviceState === "stopping" && state === "stopping") {
|
||||
@@ -267,6 +291,22 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
|
||||
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 (state !== "transcribing" || typeof value.transcript !== "string") {
|
||||
protocolError("result is invalid for the current state.");
|
||||
@@ -349,6 +389,7 @@ export function createDictateExtension(options: DictateExtensionOptions = {}) {
|
||||
helloRequestId = newId("hello");
|
||||
negotiated = false;
|
||||
resultDelivered = false;
|
||||
audioLevels = [];
|
||||
receiveBuffer = Buffer.alloc(0);
|
||||
|
||||
let current: DictateSocket;
|
||||
|
||||
Reference in New Issue
Block a user