Workflow-App/Workflow/lib/voice/useVoiceInput.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

202 lines
5.7 KiB
TypeScript

// lib/voice/useVoiceInput.ts
// React hook around the browser SpeechRecognition API for live transcription.
// Falls back gracefully where the API is unavailable (Firefox, Safari < 14.1, etc).
// Push-to-talk by default — call start() / stop() from a button click.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
// Minimum browser typings. SpeechRecognition is not in lib.dom.d.ts on TS < 5.4.
type SpeechRecognitionEventLike = {
results: {
length: number;
[index: number]: {
isFinal: boolean;
length: number;
[index: number]: { transcript: string; confidence: number };
};
};
resultIndex: number;
};
type SpeechRecognitionErrorEventLike = {
error: string;
message?: string;
};
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
maxAlternatives: number;
onresult: ((ev: SpeechRecognitionEventLike) => void) | null;
onerror: ((ev: SpeechRecognitionErrorEventLike) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
start: () => void;
stop: () => void;
abort: () => void;
}
function getSpeechRecognitionCtor(): (new () => SpeechRecognitionLike) | null {
if (typeof window === "undefined") return null;
const w = window as unknown as {
SpeechRecognition?: new () => SpeechRecognitionLike;
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
export type VoiceInputState = {
/** True while listening. */
listening: boolean;
/** True when the browser actually supports SpeechRecognition. */
supported: boolean;
/** The latest interim (not-yet-finalised) transcript; live updates while speaking. */
interim: string;
/** The committed (finalised) transcript — append each final result here. */
transcript: string;
/** Last error string, if any (e.g. "not-allowed", "no-speech"). */
error: string | null;
};
export type VoiceInputControls = VoiceInputState & {
start: () => void;
stop: () => void;
reset: () => void;
};
export interface UseVoiceInputOptions {
/** BCP-47 lang tag. Defaults to en-ZA so SA English is preferred. */
lang?: string;
/** Append mode (default true) vs replace mode. */
append?: boolean;
}
/**
* Live transcription hook. Renders a button that toggles listening; the
* current recognised text (interim + final) is returned in real time.
*
* Example:
* const { listening, transcript, interim, start, stop, supported } = useVoiceInput();
* <textarea value={transcript + interim} onChange={...} />
* <button onClick={listening ? stop : start}>🎤</button>
*/
export function useVoiceInput(opts: UseVoiceInputOptions = {}): VoiceInputControls {
const { lang = "en-ZA", append = true } = opts;
const CtorRef = useRef<(new () => SpeechRecognitionLike) | null>(null);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const [listening, setListening] = useState(false);
const [interim, setInterim] = useState("");
const [transcript, setTranscript] = useState("");
const [error, setError] = useState<string | null>(null);
const [supported, setSupported] = useState(false);
// Detect support once on the client.
useEffect(() => {
const Ctor = getSpeechRecognitionCtor();
CtorRef.current = Ctor;
setSupported(Ctor !== null);
}, []);
const reset = useCallback(() => {
setInterim("");
setTranscript("");
setError(null);
}, []);
const start = useCallback(() => {
setError(null);
const Ctor = CtorRef.current;
if (!Ctor) {
setError("Speech recognition is not supported in this browser.");
return;
}
// Tear down any prior instance — only one recognition session per page at a time.
if (recognitionRef.current) {
try {
recognitionRef.current.abort();
} catch {
/* noop */
}
recognitionRef.current = null;
}
const rec = new Ctor();
rec.lang = lang;
rec.continuous = true;
rec.interimResults = true;
rec.maxAlternatives = 1;
rec.onstart = () => setListening(true);
rec.onend = () => setListening(false);
rec.onerror = (ev) => {
// Common cases: "not-allowed" (mic blocked), "no-speech" (silence),
// "audio-capture" (no mic), "network" (online STT unreachable).
setError(ev.error);
setListening(false);
};
rec.onresult = (ev) => {
let interimChunk = "";
let finalChunk = "";
for (let i = ev.resultIndex; i < ev.results.length; i++) {
const result = ev.results[i];
const alt = result[0];
if (result.isFinal) {
finalChunk += alt.transcript;
} else {
interimChunk += alt.transcript;
}
}
if (finalChunk) {
setTranscript((prev) =>
append ? (prev ? prev + " " + finalChunk.trim() : finalChunk.trim()) : finalChunk.trim()
);
setInterim("");
} else {
setInterim(interimChunk);
}
};
try {
rec.start();
recognitionRef.current = rec;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
setListening(false);
}
}, [lang, append]);
const stop = useCallback(() => {
const rec = recognitionRef.current;
if (!rec) return;
try {
rec.stop();
} catch {
/* noop */
}
recognitionRef.current = null;
}, []);
// Clean up on unmount.
useEffect(() => {
return () => {
const rec = recognitionRef.current;
if (!rec) return;
try {
rec.abort();
} catch {
/* noop */
}
recognitionRef.current = null;
};
}, []);
return { listening, supported, interim, transcript, error, start, stop, reset };
}