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.
202 lines
5.7 KiB
TypeScript
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 };
|
|
}
|