mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
voice setup slopcoding
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DNxiEuzr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Djpv3bOo.css">
|
||||
<script type="module" crossorigin src="/assets/index-D3PfbwGG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BDdrX06B.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import SocketConnectionPill from '../../components/SocketConnectionPill/index.jsx';
|
||||
import useScannerSpeech from './useScannerSpeech.js';
|
||||
import ScannerVoiceSetup from './ScannerVoiceSetup.jsx';
|
||||
|
||||
const EMPTY_SCANNER_STATE = {
|
||||
beepAllowed: false,
|
||||
@@ -43,6 +44,7 @@ function playSubmitBeep() {
|
||||
|
||||
export default function ScannerContent() {
|
||||
const socket = useSocket();
|
||||
const setupMode = new URLSearchParams(window.location.search).get('setup') === 'voice';
|
||||
const inputRef = useRef(null);
|
||||
const flashTimerRef = useRef(null);
|
||||
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
||||
@@ -112,6 +114,10 @@ export default function ScannerContent() {
|
||||
const lastScan = scannerState.lastScan;
|
||||
const label = lastScan?.label || 'waiting';
|
||||
|
||||
if (setupMode) {
|
||||
return <ScannerVoiceSetup />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`flex min-h-screen cursor-default flex-col items-center justify-center overflow-hidden px-6 text-center ${
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Scanner Voice Setup
|
||||
// Purpose: Lets the scanner computer pick one exact browser TTS voice and store it locally.
|
||||
// Scope: Keeps voice selection out of the rover-facing scanner screen while avoiding unreliable voice-name guessing.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SCANNER_VOICE_STORAGE_KEY } from './useScannerSpeech.js';
|
||||
|
||||
function describeVoice(voice) {
|
||||
const parts = [voice?.name || 'unknown voice'];
|
||||
if (voice?.lang) parts.push(voice.lang);
|
||||
if (voice?.default) parts.push('default');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function speakVoiceSample(voice) {
|
||||
const synth = window.speechSynthesis;
|
||||
if (!synth || typeof window.SpeechSynthesisUtterance !== 'function') return;
|
||||
|
||||
// Setup speech is intentionally direct and uncached. The operator is testing
|
||||
// individual installed voices, so each button should cancel the previous
|
||||
// sample and immediately speak with the candidate voice.
|
||||
synth.cancel();
|
||||
const utterance = new window.SpeechSynthesisUtterance('object 1');
|
||||
utterance.voice = voice;
|
||||
utterance.lang = voice?.lang || 'en-US';
|
||||
utterance.rate = 1;
|
||||
utterance.pitch = 1;
|
||||
utterance.volume = 1;
|
||||
synth.speak(utterance);
|
||||
}
|
||||
|
||||
function readBrowserVoices() {
|
||||
const synth = window.speechSynthesis;
|
||||
return typeof synth?.getVoices === 'function' ? synth.getVoices() : [];
|
||||
}
|
||||
|
||||
export default function ScannerVoiceSetup() {
|
||||
const [voices, setVoices] = useState(() => readBrowserVoices());
|
||||
const [selectedVoiceName, setSelectedVoiceName] = useState(() =>
|
||||
String(window.localStorage.getItem(SCANNER_VOICE_STORAGE_KEY) || '').trim(),
|
||||
);
|
||||
|
||||
const refreshVoices = useCallback(() => {
|
||||
const nextVoices = readBrowserVoices();
|
||||
setVoices(nextVoices);
|
||||
// Logging the exact voice list is useful on Linux because Firefox exposes
|
||||
// whatever the system speech stack provides, and those names are the only
|
||||
// reliable identifiers we can save for later scanner sessions.
|
||||
console.info('[scanner voice setup] available voices', nextVoices.map(describeVoice));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshTimer = window.setTimeout(refreshVoices, 0);
|
||||
const synth = window.speechSynthesis;
|
||||
if (!synth) {
|
||||
return () => {
|
||||
window.clearTimeout(refreshTimer);
|
||||
};
|
||||
}
|
||||
synth.addEventListener?.('voiceschanged', refreshVoices);
|
||||
synth.onvoiceschanged = refreshVoices;
|
||||
return () => {
|
||||
window.clearTimeout(refreshTimer);
|
||||
synth.removeEventListener?.('voiceschanged', refreshVoices);
|
||||
if (synth.onvoiceschanged === refreshVoices) {
|
||||
synth.onvoiceschanged = null;
|
||||
}
|
||||
};
|
||||
}, [refreshVoices]);
|
||||
|
||||
const saveVoice = useCallback((voice) => {
|
||||
const voiceName = String(voice?.name || '').trim();
|
||||
if (!voiceName) return;
|
||||
window.localStorage.setItem(SCANNER_VOICE_STORAGE_KEY, voiceName);
|
||||
setSelectedVoiceName(voiceName);
|
||||
speakVoiceSample(voice);
|
||||
}, []);
|
||||
|
||||
const clearVoice = useCallback(() => {
|
||||
window.localStorage.removeItem(SCANNER_VOICE_STORAGE_KEY);
|
||||
setSelectedVoiceName('');
|
||||
const synth = window.speechSynthesis;
|
||||
if (synth) synth.cancel();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen overflow-y-auto bg-black px-6 py-6 text-white">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 border-b border-white/30 pb-4">
|
||||
<h1 className="text-5xl font-black leading-none tracking-normal">scanner voice</h1>
|
||||
<p className="text-2xl leading-tight text-white">
|
||||
Pick the exact voice this scanner computer should use.
|
||||
</p>
|
||||
<p className="text-xl leading-tight text-white/80">
|
||||
Selected: {selectedVoiceName || 'automatic fallback'}
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="border-2 border-white bg-white px-4 py-3 text-2xl font-bold text-black"
|
||||
onClick={refreshVoices}
|
||||
>
|
||||
refresh voices
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border-2 border-white px-4 py-3 text-2xl font-bold text-white"
|
||||
onClick={clearVoice}
|
||||
>
|
||||
use automatic fallback
|
||||
</button>
|
||||
<a
|
||||
className="border-2 border-white px-4 py-3 text-2xl font-bold text-white"
|
||||
href="/scanner"
|
||||
>
|
||||
back to scanner
|
||||
</a>
|
||||
</div>
|
||||
<section className="grid gap-3">
|
||||
{voices.length ? voices.map((voice) => {
|
||||
const selected = String(voice?.name || '') === selectedVoiceName;
|
||||
return (
|
||||
<article
|
||||
key={`${voice.name}-${voice.lang}`}
|
||||
className={`grid gap-3 border-2 p-4 ${selected ? 'border-white bg-white text-black' : 'border-white/50 bg-black text-white'}`}
|
||||
>
|
||||
<div className="text-3xl font-black leading-tight tracking-normal">
|
||||
{describeVoice(voice)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={`border-2 px-4 py-3 text-2xl font-bold ${selected ? 'border-black text-black' : 'border-white text-white'}`}
|
||||
onClick={() => speakVoiceSample(voice)}
|
||||
>
|
||||
test
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`border-2 px-4 py-3 text-2xl font-bold ${selected ? 'border-black bg-black text-white' : 'border-white bg-white text-black'}`}
|
||||
onClick={() => saveVoice(voice)}
|
||||
>
|
||||
select
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}) : (
|
||||
<div className="border-2 border-white/50 p-4 text-3xl font-black">
|
||||
no browser voices loaded
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react';
|
||||
|
||||
const SPEECH_START_TIMEOUT_MS = 1500;
|
||||
const SPEECH_RETRY_DELAY_MS = 700;
|
||||
export const SCANNER_VOICE_STORAGE_KEY = 'scanner.preferredVoiceName';
|
||||
const PREFERRED_VOICE_PATTERN = /female|samantha|victoria|allison|zira|karen|moira|serena|ava|susan|hazel|google (us|uk) english/i;
|
||||
const NOVELTY_VOICE_PATTERN = /whisper|bubbles|bells|boing|bad news|bahh|cellos|deranged|good news|hysterical|pipe organ|trinoids|zarvox/i;
|
||||
|
||||
@@ -16,6 +17,15 @@ function pickScannerVoice(synth) {
|
||||
const voices = typeof synth?.getVoices === 'function' ? synth.getVoices() : [];
|
||||
if (!voices.length) return null;
|
||||
|
||||
const savedVoiceName =
|
||||
typeof window !== 'undefined'
|
||||
? String(window.localStorage.getItem(SCANNER_VOICE_STORAGE_KEY) || '').trim()
|
||||
: '';
|
||||
const savedVoice = savedVoiceName
|
||||
? voices.find((voice) => String(voice?.name || '') === savedVoiceName)
|
||||
: null;
|
||||
if (savedVoice) return savedVoice;
|
||||
|
||||
const usableVoices = voices.filter((voice) => !isNoveltyVoice(voice));
|
||||
const defaultVoice = usableVoices.find((voice) => voice?.default) || null;
|
||||
const englishVoices = usableVoices.filter((voice) => String(voice?.lang || '').toLowerCase().startsWith('en'));
|
||||
|
||||
Reference in New Issue
Block a user