157 lines
4.6 KiB
TypeScript
157 lines
4.6 KiB
TypeScript
// 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 };
|
||
}
|