evOo-local/apps/web/src/lib/dictation.ts
Ingo Wagner a3eac211bd fix: clean up remaining pre-existing TypeScript errors
- dictation.ts: Uint8Array cast for getByteTimeDomainData
- user-help-vibe.test.ts + user-profile-harvest.test.ts: computerHost 'evo' → null
- executor-utils.ts: import AgentRunRequest from adapter-kit, MessageBlock from contracts
- pi-model-config.ts: import AgentToolExecutionResult from adapter-kit

Results: apps/web 0 TS errors (was 4), packages/adapters 15→6 errors
2026-09-22 04:37:18 +00:00

508 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readBoundedResponseBytes } from "@rakazo/core";
import { selectedSpaceId, withSpaceHeaders } from "./rpc.js";
export type DictationMode = "hold" | "endpoint";
export type DictationSnapshot = {
status: "idle" | "listening" | "transcribing";
transcript: string;
/** Normalized mic energy 0–1 for voice presence visuals. */
energy: number;
error?: string;
};
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: { resultIndex: number; results: SpeechRecognitionResultList }) => void) | null;
onerror: ((event: { error?: string }) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
const IDLE: DictationSnapshot = { status: "idle", transcript: "", energy: 0 };
const ENDPOINT_TICK_MS = 80;
const SILENCE_RMS = 0.035;
export const TRANSCRIPTION_RESPONSE_TIMEOUT_MS = 70_000;
export const MAX_TRANSCRIPTION_RESPONSE_BYTES = 64 * 1024;
const ENDPOINT_UNSUPPORTED =
"This browser can't detect when you stop talking. Use Chrome, the desktop app, or hold-to-talk in the composer.";
const MIC_UNAVAILABLE =
"Microphone access is not available in this browser. Use Chrome, the desktop app, or check system settings.";
export function webSpeechAvailable(): boolean {
return Boolean(speechRecognitionCtor());
}
function speechRecognitionCtor(): SpeechRecognitionCtor | undefined {
if (typeof window === "undefined") return undefined;
const host = window as Window & {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return host.SpeechRecognition ?? host.webkitSpeechRecognition;
}
function audioContextCtor(): (new () => AudioContext) | undefined {
if (typeof AudioContext === "function") return AudioContext;
if (typeof window === "undefined") return undefined;
const host = window as Window & { webkitAudioContext?: typeof AudioContext };
return host.webkitAudioContext;
}
export class Dictation {
private snapshot: DictationSnapshot = IDLE;
private watchers = new Set<(s: DictationSnapshot) => void>();
private recognition: SpeechRecognitionLike | null = null;
private media: MediaRecorder | null = null;
private chunks: Blob[] = [];
private token = 0;
private silenceTimer: ReturnType<typeof setTimeout> | undefined;
private transcribeAbort: AbortController | null = null;
private audioContext: AudioContext | null = null;
private vadTimer: ReturnType<typeof setInterval> | undefined;
private micStream: MediaStream | null = null;
private onFinal: ((text: string) => void) | null = null;
subscribe(fn: (s: DictationSnapshot) => void): () => void {
this.watchers.add(fn);
fn(this.snapshot);
return () => {
this.watchers.delete(fn);
};
}
get state(): DictationSnapshot {
return this.snapshot;
}
private set(next: DictationSnapshot) {
this.snapshot = next;
for (const watcher of [...this.watchers]) watcher(next);
}
stop(reason: "submit" | "cancel" | "replace" = "cancel") {
this.token += 1;
this.transcribeAbort?.abort();
this.transcribeAbort = null;
this.stopVad();
clearTimeout(this.silenceTimer);
this.silenceTimer = undefined;
const rec = this.recognition;
this.recognition = null;
if (rec) {
rec.onresult = null;
rec.onerror = null;
rec.onend = null;
try {
if (reason === "submit") rec.stop();
else rec.abort();
} catch {
// already stopped
}
}
const media = this.media;
this.media = null;
if (media && media.state !== "inactive") {
try {
media.stop();
} catch {
// already stopped
}
}
this.chunks = [];
this.onFinal = null;
this.stopMicStream();
if (this.snapshot.status !== "idle" || this.snapshot.error) this.set(IDLE);
}
async listen(opts: {
mode: DictationMode;
transcribe?: boolean;
endpointMs?: number;
onFinal: (text: string) => void;
}): Promise<void> {
this.stop("replace");
const mine = this.token;
const spaceId = selectedSpaceId();
this.onFinal = opts.onFinal;
this.set({ status: "listening", transcript: "", energy: 0 });
if (webSpeechAvailable()) {
const stream = await this.openMic(mine);
if (!stream) return;
this.micStream = stream;
this.startMicEnergy(stream, mine);
this.listenWebSpeech(opts.mode, opts.endpointMs ?? 850, mine);
return;
}
if (opts.transcribe) {
await this.listenRecorder(mine, opts.mode, opts.endpointMs ?? 850, spaceId);
return;
}
this.set({
...IDLE,
error:
"This browser has no on-device dictation. Connect a voice provider with transcription, or use Chrome / the desktop app.",
});
}
private listenWebSpeech(mode: DictationMode, endpointMs: number, mine: number) {
const Ctor = speechRecognitionCtor();
if (!Ctor) return;
const rec = new Ctor();
rec.continuous = true;
rec.interimResults = true;
rec.lang = navigator.language || "en-US";
rec.onresult = (event) => {
if (this.token !== mine) return;
let transcript = "";
for (let i = 0; i < event.results.length; i += 1) {
transcript += event.results[i]?.[0]?.transcript ?? "";
}
this.set({
status: "listening",
transcript: transcript.trim(),
energy: this.snapshot.energy,
});
if (mode !== "endpoint") return;
clearTimeout(this.silenceTimer);
this.silenceTimer = setTimeout(() => {
if (this.token !== mine) return;
const text = this.snapshot.transcript.trim();
this.finish(text, mine);
}, endpointMs);
};
rec.onerror = (event) => {
if (this.token !== mine) return;
if (event.error === "aborted" || event.error === "no-speech") return;
this.set({
...IDLE,
error: event.error ? `Dictation failed: ${event.error}` : "Dictation failed.",
});
};
rec.onend = () => {
if (this.token !== mine) return;
if (this.snapshot.status !== "listening") return;
if (mode === "hold") {
this.finish(this.snapshot.transcript, mine);
return;
}
const text = this.snapshot.transcript.trim();
if (text) {
this.finish(text, mine);
return;
}
try {
rec.start();
} catch {
this.set({ ...IDLE, error: "Dictation ended unexpectedly." });
}
};
this.recognition = rec;
rec.start();
}
private async openMic(mine: number): Promise<MediaStream | null> {
if (!navigator.mediaDevices?.getUserMedia) {
if (this.token !== mine) return null;
this.set({ ...IDLE, error: MIC_UNAVAILABLE });
return null;
}
try {
return await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
if (this.token !== mine) return null;
this.set({ ...IDLE, error: micErrorMessage(error) });
return null;
}
}
private async listenRecorder(
mine: number,
mode: DictationMode,
endpointMs: number,
spaceId: string | null,
) {
const stream = await this.openMic(mine);
if (!stream) return;
if (this.token !== mine) {
for (const track of stream.getTracks()) track.stop();
return;
}
if (mode === "endpoint" && !this.armSilence(stream, mine, endpointMs)) {
for (const track of stream.getTracks()) track.stop();
this.set({ ...IDLE, error: ENDPOINT_UNSUPPORTED });
return;
}
const media = new MediaRecorder(stream);
this.media = media;
this.chunks = [];
media.ondataavailable = (event) => {
if (this.token !== mine) return;
if (event.data.size) this.chunks.push(event.data);
};
media.onstop = () => {
for (const track of stream.getTracks()) track.stop();
if (this.token !== mine) return;
this.stopVad();
void this.transcribeChunks(mine, spaceId);
};
media.start(mode === "endpoint" ? 250 : undefined);
}
private startMicEnergy(stream: MediaStream, mine: number): boolean {
const Ctor = audioContextCtor();
if (!Ctor) return false;
try {
const ctx = new Ctor();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
this.audioContext = ctx;
if (ctx.state === "suspended") void ctx.resume();
const data = new Uint8Array(analyser.fftSize);
this.vadTimer = setInterval(() => {
if (this.token !== mine) return;
const energy = rmsEnergyFromAnalyser(analyser, data);
if (this.snapshot.energy !== energy) {
this.set({ ...this.snapshot, energy });
}
}, ENDPOINT_TICK_MS);
return true;
} catch {
this.stopVad();
return false;
}
}
private armSilence(stream: MediaStream, mine: number, endpointMs: number): boolean {
const Ctor = audioContextCtor();
if (!Ctor) return false;
try {
const ctx = new Ctor();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
this.audioContext = ctx;
if (ctx.state === "suspended") void ctx.resume();
const data = new Uint8Array(analyser.fftSize);
let heardSpeech = false;
let silentFor = 0;
this.vadTimer = setInterval(() => {
const media = this.media;
if (this.token !== mine || !media || media.state === "inactive") return;
const rms = rmsFromAnalyser(analyser, data);
const energy = Math.min(1, Math.max(0, rms / 0.12));
if (this.snapshot.energy !== energy) {
this.set({ ...this.snapshot, energy });
}
if (rms > SILENCE_RMS) {
heardSpeech = true;
silentFor = 0;
return;
}
if (!heardSpeech) return;
silentFor += ENDPOINT_TICK_MS;
if (silentFor < endpointMs) return;
this.stopVad();
try {
media.stop();
} catch {
// already stopped
}
}, ENDPOINT_TICK_MS);
return true;
} catch {
this.stopVad();
return false;
}
}
private stopVad() {
clearInterval(this.vadTimer);
this.vadTimer = undefined;
const ctx = this.audioContext;
this.audioContext = null;
if (ctx) void ctx.close().catch(() => undefined);
}
private stopMicStream() {
const stream = this.micStream;
this.micStream = null;
if (!stream) return;
for (const track of stream.getTracks()) track.stop();
}
private async transcribeChunks(mine: number, spaceId: string | null) {
if (this.token !== mine) return;
const blob = new Blob(this.chunks, { type: this.chunks[0]?.type || "audio/webm" });
this.chunks = [];
if (!blob.size) {
this.set(IDLE);
return;
}
this.set({ status: "transcribing", transcript: this.snapshot.transcript, energy: 0 });
const abort = new AbortController();
this.transcribeAbort = abort;
const timer = setTimeout(
() => abort.abort(new Error("Transcription request timed out.")),
TRANSCRIPTION_RESPONSE_TIMEOUT_MS,
);
try {
const audioBase64 = await blobToBase64(blob);
if (this.token !== mine) return;
const res = await withAbort(
fetch("/api/voice/transcribe", {
method: "POST",
headers: withSpaceHeaders({ "content-type": "application/json" }, spaceId),
credentials: "include",
body: JSON.stringify({ audioBase64, mimeType: blob.type }),
signal: abort.signal,
}),
abort.signal,
);
if (this.token !== mine) {
cancelResponse(res);
return;
}
const body = await readTranscriptionBody(res, abort.signal);
if (this.token !== mine) return;
if (!res.ok) {
this.set({ ...IDLE, error: body.error ?? "Could not transcribe that recording." });
return;
}
this.finish(body.text ?? "", mine);
} catch (error) {
// User cancel bumps token in stop() before aborting, so a matching token
// means the deadline timer fired. Browsers may reject fetch as AbortError
// instead of signal.reason; surface the timeout either way.
if (this.token !== mine) return;
const timedOut =
(abort.signal.reason instanceof Error &&
abort.signal.reason.message === "Transcription request timed out.") ||
(error instanceof Error && error.message === "Transcription request timed out.");
if (timedOut || (error instanceof Error && error.name === "AbortError")) {
this.set({ ...IDLE, error: "Transcription request timed out." });
return;
}
this.set({
...IDLE,
error: error instanceof Error ? error.message : "Could not transcribe that recording.",
});
} finally {
clearTimeout(timer);
if (this.transcribeAbort === abort) this.transcribeAbort = null;
}
}
submitHold() {
if (this.media && this.media.state !== "inactive") {
this.media.stop();
return;
}
this.finish(this.snapshot.transcript, this.token);
}
private finish(text: string, mine: number) {
if (this.token !== mine) return;
const trimmed = text.trim();
const onFinal = this.onFinal;
this.stop("submit");
if (trimmed) onFinal?.(trimmed);
}
}
async function blobToBase64(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
async function readTranscriptionBody(
response: Response,
signal: AbortSignal,
): Promise<{ text?: string; error?: string }> {
const declared = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(declared) && declared > MAX_TRANSCRIPTION_RESPONSE_BYTES) {
cancelResponse(response);
throw new Error("Transcription response is too large.");
}
const bytes = await readBoundedResponseBytes(response, {
maxBytes: MAX_TRANSCRIPTION_RESPONSE_BYTES,
tooLargeMessage: "Transcription response is too large.",
read: (operation) => withAbort(operation(), signal),
});
try {
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
return parsed && typeof parsed === "object"
? (parsed as { text?: string; error?: string })
: {};
} catch {
return {};
}
}
function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Request aborted."));
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(signal.reason ?? new Error("Request aborted."));
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
// Prefer abort reason over a bare AbortError from fetch/read.
reject(signal.aborted ? (signal.reason ?? error) : error);
},
);
});
}
function cancelResponse(response: Response): void {
try {
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
} catch {
// Response cleanup is best-effort.
}
}
function rmsFromAnalyser(analyser: AnalyserNode, data: Uint8Array): number {
analyser.getByteTimeDomainData(data as unknown as Uint8Array<ArrayBuffer>);
let sum = 0;
for (const sample of data) {
const n = (sample - 128) / 128;
sum += n * n;
}
return Math.sqrt(sum / data.length);
}
function rmsEnergyFromAnalyser(analyser: AnalyserNode, data: Uint8Array): number {
return Math.min(1, Math.max(0, rmsFromAnalyser(analyser, data) / 0.12));
}
function micErrorMessage(error: unknown): string {
if (error instanceof DOMException) {
if (error.name === "NotAllowedError") {
return "Microphone access denied. Allow the microphone in your browser or system settings, then try again.";
}
if (error.name === "NotFoundError") {
return "No microphone found. Connect a microphone and try again.";
}
}
return error instanceof Error ? error.message : "Microphone failed";
}
export const dictation = new Dictation();