feat(dictate): add voice-reactive listening waveform
This commit is contained in:
@@ -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