From 39664b0c886c49bb536cb3477f605fb9833fb4e7 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 29 Dec 2025 13:29:39 +0200 Subject: [PATCH] narrator support --- frontend/src/hooks/useNarrator.ts | 149 ++++++++++++++++++++++++++++++ frontend/src/narrator.ts | 65 +++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 frontend/src/hooks/useNarrator.ts create mode 100644 frontend/src/narrator.ts diff --git a/frontend/src/hooks/useNarrator.ts b/frontend/src/hooks/useNarrator.ts new file mode 100644 index 0000000..86486e4 --- /dev/null +++ b/frontend/src/hooks/useNarrator.ts @@ -0,0 +1,149 @@ +import { useEffect, useRef } from 'react' +import { useStore } from '../store' +import * as narrator from '../narrator' + +function formatTime(secs: number): string { + const m = Math.floor(secs / 60) + const s = secs % 60 + if (m === 0) return `${s} second${s !== 1 ? 's' : ''}` + if (s === 0) return `${m} minute${m !== 1 ? 's' : ''}` + return `${m} minute${m !== 1 ? 's' : ''} ${s} second${s !== 1 ? 's' : ''}` +} + +export function useNarrator() { + const prevCell = useRef<{ row: number; col: number } | null>(null) + const prevDirection = useRef(null) + const prevEntries = useRef(null) + const prevActiveClue = useRef(null) + const prevSolved = useRef(false) + + // sync settings to narrator module whenever they change + const narratorSettings = useStore(s => s.narratorSettings) + useEffect(() => { + narrator.updateSettings(narratorSettings) + }, [narratorSettings]) + + // subscribe to store changes + useEffect(() => { + const unsub = useStore.subscribe((state, prev) => { + if (!state.narratorEnabled || !state.puzzle) return + const v = state.narratorSettings.verbosity + + // direction change + if (state.direction !== prevDirection.current && prevDirection.current !== null) { + narrator.speak(`Now solving ${state.direction}`) + } + prevDirection.current = state.direction + + // cell selection + if (state.selectedCell && + (state.selectedCell.row !== prevCell.current?.row || + state.selectedCell.col !== prevCell.current?.col)) { + + const { row, col } = state.selectedCell + const letter = state.entries[row]?.[col] || '' + const cellContent = letter ? letter : 'empty' + + if (v === 'brief') { + narrator.speak(`Row ${row + 1}, Column ${col + 1}`) + } else if (v === 'normal') { + narrator.speak(`Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`) + } else { + const puzzle = state.puzzle + let neighbors = '' + if (col > 0 && puzzle.cells[row][col - 1] !== 'black') { + const l = state.entries[row][col - 1] + neighbors += l ? `${l} to the left` : 'blank to the left' + } + if (row > 0 && puzzle.cells[row - 1]?.[col] !== 'black') { + const l = state.entries[row - 1]?.[col] + if (neighbors) neighbors += ', ' + neighbors += l ? `${l} above` : 'blank above' + } + let msg = `Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}` + if (neighbors) msg += `. Neighboring: ${neighbors}` + narrator.speak(msg) + } + } + prevCell.current = state.selectedCell + + // letter entry - detect new letter in the grid + if (prevEntries.current && state.entries !== prevEntries.current) { + for (let r = 0; r < state.entries.length; r++) { + for (let c = 0; c < state.entries[r].length; c++) { + const cur = state.entries[r][c] + const old = prevEntries.current[r]?.[c] || '' + if (cur && !old) { + if (v === 'brief') narrator.speak(cur, false) + else if (v === 'normal') narrator.speak(`Entered ${cur}`, false) + else narrator.speak(`Entered ${cur} at Row ${r + 1}, Column ${c + 1}`, false) + + // check word completion + const span = state.wordSpans.find(s => + s.cells.some(([sr, sc]) => sr === r && sc === c) + ) + if (span) { + const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc]) + if (allFilled) { + const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc]) + if (!wasFilled) { + const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('') + setTimeout(() => narrator.speak(`Word complete: ${word.split('').join(' ')}`, false), 300) + } + } + } + } + } + } + } + prevEntries.current = state.entries + + // clue focus change + if (state.activeClue !== null && state.activeClue !== prevActiveClue.current) { + const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down + const clue = clues.find(c => c.number === state.activeClue) + if (clue) { + const span = state.wordSpans.find(s => s.number === state.activeClue && s.direction === state.direction) + const len = span?.cells.length || 0 + const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0 + + if (v === 'brief') { + narrator.speak(`${state.activeClue} ${state.direction}: ${clue.text}`, false) + } else if (v === 'normal') { + narrator.speak(`${state.activeClue} ${state.direction}, ${len} letters: ${clue.text}`, false) + } else { + narrator.speak(`${state.activeClue} ${state.direction}, ${len} letters, ${filled} filled: ${clue.text}`, false) + } + } + } + prevActiveClue.current = state.activeClue + + // hint reveal - detect newly revealed cells + if (prev.cellStatus !== state.cellStatus && prevEntries.current) { + for (let r = 0; r < state.cellStatus.length; r++) { + for (let c = 0; c < state.cellStatus[r].length; c++) { + if (state.cellStatus[r][c] === 'revealed' && prev.cellStatus[r]?.[c] !== 'revealed') { + const letter = state.entries[r][c] + const span = state.wordSpans.find(s => + s.cells.some(([sr, sc]) => sr === r && sc === c) + ) + const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0 + if (letter && pos) { + narrator.speak(`Revealed letter ${letter} at position ${pos}`, false) + } + } + } + } + } + + // puzzle solved + if (state.solved && !prevSolved.current) { + const time = formatTime(state.elapsed) + narrator.speak(`Puzzle complete. Time: ${time}`) + } + prevSolved.current = state.solved + }) + + return unsub + }, []) +} diff --git a/frontend/src/narrator.ts b/frontend/src/narrator.ts new file mode 100644 index 0000000..d3e5863 --- /dev/null +++ b/frontend/src/narrator.ts @@ -0,0 +1,65 @@ +export interface NarratorSettings { + enabled: boolean + rate: number + pitch: number + voice: string + verbosity: 'brief' | 'normal' | 'verbose' + sounds: boolean +} + +export const defaultNarratorSettings: NarratorSettings = { + enabled: false, + rate: 1.0, + pitch: 1.0, + voice: '', + verbosity: 'normal', + sounds: true, +} + +let settings: NarratorSettings = { ...defaultNarratorSettings } + +export function updateSettings(s: NarratorSettings) { + settings = s +} + +export function isSupported(): boolean { + return 'speechSynthesis' in window +} + +export function getVoices(): SpeechSynthesisVoice[] { + if (!isSupported()) return [] + return window.speechSynthesis.getVoices() +} + +export function cancel() { + if (!isSupported()) return + window.speechSynthesis.cancel() +} + +export function speak(text: string, interrupt = true) { + if (!isSupported() || !settings.enabled) return + if (interrupt) cancel() + + const utterance = new SpeechSynthesisUtterance(text) + utterance.rate = settings.rate + utterance.pitch = settings.pitch + + if (settings.voice) { + const voices = getVoices() + const match = voices.find(v => v.name === settings.voice) + if (match) utterance.voice = match + } + + window.speechSynthesis.speak(utterance) +} + +// voices load async - call this early so they're ready +export function preloadVoices() { + if (!isSupported()) return + getVoices() + if (window.speechSynthesis.onvoiceschanged !== undefined) { + window.speechSynthesis.onvoiceschanged = () => {} + } +} + +preloadVoices()