WORKFLOW-APP/lib/voice/useSpeech.ts

157 lines
4.6 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.

// 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 };
}