Workflow-App/Workflow/lib/voice/useSpeech.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

157 lines
4.6 KiB
TypeScript
Raw Permalink 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.

// lib/voice/useSpeech.ts
// React hook around window.speechSynthesis for text-to-speech output.
// Voice priority: en-ZA → en-GB → en-US → first available.
// Tracks speaking state so the UI can render a "speaking…" indicator.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export type SpeechState = {
/** True while the browser is currently speaking. */
speaking: boolean;
/** True when window.speechSynthesis is available. */
supported: boolean;
/** Selected voice display name (after voice pick). */
voiceName: string | null;
/** Last error string (e.g. "interrupted", "synthesis-unavailable"). */
error: string | null;
};
export type SpeechControls = SpeechState & {
speak: (text: string) => void;
cancel: () => void;
};
const VOICE_PRIORITY = ["en-ZA", "en-GB", "en-US", "en-AU", "en"];
function pickVoice(voices: SpeechSynthesisVoice[]): SpeechSynthesisVoice | null {
if (!voices.length) return null;
for (const lang of VOICE_PRIORITY) {
const exact = voices.find((v) => v.lang.toLowerCase().startsWith(lang.toLowerCase()));
if (exact) return exact;
}
return voices[0];
}
function supportsSynthesis(): boolean {
return typeof window !== "undefined" && "speechSynthesis" in window;
}
export interface UseSpeechOptions {
/** BCP-47 lang tag. Defaults to en-ZA. */
lang?: string;
/** Speech rate. 1.0 is normal. */
rate?: number;
/** Pitch. 1.0 is normal. */
pitch?: number;
/** Volume. 0–1. */
volume?: number;
}
/**
* Text-to-speech hook with SA English voice priority.
*
* Example:
* const { speak, speaking, supported } = useSpeech();
* <button onClick={() => speak("Hello, world")}>🔊</button>
* {speaking && <span>speaking…</span>}
*/
export function useSpeech(opts: UseSpeechOptions = {}): SpeechControls {
const { lang = "en-ZA", rate = 1, pitch = 1, volume = 1 } = opts;
const [speaking, setSpeaking] = useState(false);
const [supported, setSupported] = useState(false);
const [voiceName, setVoiceName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
const currentUtteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
// Detect support + load voices. Voices are populated async on Chrome.
useEffect(() => {
if (!supportsSynthesis()) {
setSupported(false);
return;
}
setSupported(true);
const synth = window.speechSynthesis;
const loadVoices = () => {
voicesRef.current = synth.getVoices();
const chosen = pickVoice(voicesRef.current);
setVoiceName(chosen?.name ?? null);
};
loadVoices();
synth.addEventListener?.("voiceschanged", loadVoices);
return () => {
synth.removeEventListener?.("voiceschanged", loadVoices);
};
}, []);
const cancel = useCallback(() => {
if (!supportsSynthesis()) return;
window.speechSynthesis.cancel();
setSpeaking(false);
currentUtteranceRef.current = null;
}, []);
const speak = useCallback(
(text: string) => {
if (!supportsSynthesis()) {
setError("Speech synthesis is not supported in this browser.");
return;
}
const cleaned = text?.trim();
if (!cleaned) return;
// Cancel any in-flight utterance so the new one plays immediately.
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(cleaned);
utterance.lang = lang;
utterance.rate = rate;
utterance.pitch = pitch;
utterance.volume = volume;
const chosen = pickVoice(voicesRef.current);
if (chosen) utterance.voice = chosen;
utterance.onstart = () => setSpeaking(true);
utterance.onend = () => {
setSpeaking(false);
if (currentUtteranceRef.current === utterance) {
currentUtteranceRef.current = null;
}
};
utterance.onerror = (ev) => {
// "interrupted" and "canceled" are not really errors.
if (ev.error !== "interrupted" && ev.error !== "canceled") {
setError(ev.error);
}
setSpeaking(false);
if (currentUtteranceRef.current === utterance) {
currentUtteranceRef.current = null;
}
};
currentUtteranceRef.current = utterance;
setError(null);
window.speechSynthesis.speak(utterance);
},
[lang, rate, pitch, volume]
);
// Cancel any in-flight speech when the component unmounts.
useEffect(() => {
return () => {
if (supportsSynthesis()) {
window.speechSynthesis.cancel();
}
};
}, []);
return { speaking, supported, voiceName, error, speak, cancel };
}