445 lines
14 KiB
JavaScript
445 lines
14 KiB
JavaScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { Button } from '../ui/button';
|
|
|
|
const FreeTextToSpeech = () => {
|
|
// State management
|
|
const [text, setText] = useState('');
|
|
const [voices, setVoices] = useState([]);
|
|
const [selectedVoice, setSelectedVoice] = useState('');
|
|
const [pitch, setPitch] = useState(1);
|
|
const [rate, setRate] = useState(1);
|
|
const [volume, setVolume] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const [status, setStatus] = useState('Enter text to convert to speech');
|
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
|
const [isPaused, setIsPaused] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const [debugLogs, setDebugLogs] = useState([]);
|
|
const [showDebug, setShowDebug] = useState(false);
|
|
|
|
// Refs
|
|
const synthRef = useRef(null);
|
|
const utteranceRef = useRef(null);
|
|
const audioRef = useRef(null);
|
|
const textareaRef = useRef(null);
|
|
|
|
// Debug logging
|
|
const addDebugLog = (message) => {
|
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
const logMessage = `${timestamp}: ${message}`;
|
|
setDebugLogs(prev => [...prev.slice(-100), logMessage]);
|
|
console.debug(logMessage);
|
|
};
|
|
|
|
// Initialize speech synthesis
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
synthRef.current = window.speechSynthesis;
|
|
|
|
const loadVoices = () => {
|
|
const availableVoices = synthRef.current.getVoices();
|
|
setVoices(availableVoices);
|
|
if (availableVoices.length > 0) {
|
|
setSelectedVoice(availableVoices[0].name);
|
|
}
|
|
addDebugLog(`Loaded ${availableVoices.length} voices`);
|
|
};
|
|
|
|
// Chrome loads voices asynchronously
|
|
synthRef.current.onvoiceschanged = loadVoices;
|
|
loadVoices();
|
|
|
|
return () => {
|
|
if (synthRef.current) {
|
|
synthRef.current.onvoiceschanged = null;
|
|
}
|
|
};
|
|
}
|
|
}, []);
|
|
|
|
// Handle speaking
|
|
const handleSpeak = () => {
|
|
if (!text.trim()) {
|
|
setError('Please enter some text to speak');
|
|
setStatus('Please enter some text to speak');
|
|
addDebugLog('No text provided for speech');
|
|
return;
|
|
}
|
|
|
|
if (!synthRef.current) {
|
|
setError('Speech synthesis not supported in this browser');
|
|
setStatus('Speech synthesis not supported');
|
|
addDebugLog('Speech synthesis API not available');
|
|
return;
|
|
}
|
|
|
|
if (isPaused) {
|
|
synthRef.current.resume();
|
|
setIsPaused(false);
|
|
setIsSpeaking(true);
|
|
setStatus('Resumed speaking');
|
|
addDebugLog('Resumed speech playback');
|
|
return;
|
|
}
|
|
|
|
// Cancel any current speech
|
|
synthRef.current.cancel();
|
|
|
|
// Create a new utterance
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
utteranceRef.current = utterance;
|
|
|
|
// Set utterance properties
|
|
const selectedVoiceObj = voices.find(v => v.name === selectedVoice);
|
|
if (selectedVoiceObj) {
|
|
utterance.voice = selectedVoiceObj;
|
|
addDebugLog(`Using voice: ${selectedVoiceObj.name} (${selectedVoiceObj.lang})`);
|
|
}
|
|
utterance.pitch = pitch;
|
|
utterance.rate = rate;
|
|
utterance.volume = volume;
|
|
|
|
// Event handlers
|
|
utterance.onstart = () => {
|
|
setIsSpeaking(true);
|
|
setIsPaused(false);
|
|
setStatus('Speaking...');
|
|
addDebugLog('Speech started');
|
|
};
|
|
|
|
utterance.onend = () => {
|
|
setIsSpeaking(false);
|
|
setIsPaused(false);
|
|
setStatus('Finished speaking');
|
|
addDebugLog('Speech completed');
|
|
};
|
|
|
|
utterance.onerror = (event) => {
|
|
setIsSpeaking(false);
|
|
setIsPaused(false);
|
|
setStatus(`Error occurred: ${event.error}`);
|
|
addDebugLog(`Speech error: ${event.error}`);
|
|
};
|
|
|
|
// Speak the utterance
|
|
synthRef.current.speak(utterance);
|
|
setStatus('Started speaking...');
|
|
addDebugLog('Initiated speech synthesis');
|
|
};
|
|
|
|
const handlePause = () => {
|
|
if (isSpeaking && !isPaused && synthRef.current) {
|
|
synthRef.current.pause();
|
|
setIsPaused(true);
|
|
setIsSpeaking(false);
|
|
setStatus('Paused');
|
|
addDebugLog('Speech paused');
|
|
}
|
|
};
|
|
|
|
const handleStop = () => {
|
|
if (synthRef.current) {
|
|
synthRef.current.cancel();
|
|
setIsSpeaking(false);
|
|
setIsPaused(false);
|
|
setStatus('Stopped speaking');
|
|
addDebugLog('Speech stopped');
|
|
}
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
setStatus('Download requires a TTS API service');
|
|
setError('Download functionality requires a TTS API service');
|
|
addDebugLog('Download attempted (not implemented)');
|
|
};
|
|
|
|
// Copy text to clipboard
|
|
const copyToClipboard = () => {
|
|
if (!text) return;
|
|
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
addDebugLog('Text copied to clipboard');
|
|
}).catch(err => {
|
|
const errorMsg = 'Failed to copy text to clipboard';
|
|
addDebugLog(`${errorMsg}: ${err.message}`);
|
|
setError(errorMsg);
|
|
});
|
|
};
|
|
|
|
// Clear all inputs and states
|
|
const clearAll = () => {
|
|
setText('');
|
|
setError(null);
|
|
setStatus('Enter text to convert to speech');
|
|
|
|
if (synthRef.current) {
|
|
synthRef.current.cancel();
|
|
}
|
|
|
|
setIsSpeaking(false);
|
|
setIsPaused(false);
|
|
|
|
addDebugLog('All inputs and states cleared');
|
|
};
|
|
|
|
// Helper functions
|
|
const clearDebugLogs = () => {
|
|
setDebugLogs([]);
|
|
addDebugLog('Debug logs cleared');
|
|
};
|
|
|
|
const toggleDebug = () => {
|
|
setShowDebug(!showDebug);
|
|
addDebugLog(`Debug panel ${showDebug ? 'hidden' : 'shown'}`);
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 max-w-4xl my-6">
|
|
<div className="bg-white rounded-lg shadow-md p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-800">Text to Speech (Using Google)</h1>
|
|
<Button
|
|
onClick={toggleDebug}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
{showDebug ? 'Hide Debug' : 'Show Debug'}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Text Input Section */}
|
|
<div className="mb-6">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<label htmlFor="tts-text" className="block text-sm font-medium text-gray-700">
|
|
Enter Text
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={copyToClipboard}
|
|
disabled={!text || isSpeaking}
|
|
>
|
|
{copied ? 'Copied!' : 'Copy Text'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={clearAll}
|
|
disabled={isSpeaking}
|
|
>
|
|
Clear All
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
ref={textareaRef}
|
|
id="tts-text"
|
|
rows={6}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:border-[#6d9e37] bg-white text-gray-800"
|
|
placeholder="Type or paste your text here..."
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
disabled={isSpeaking}
|
|
/>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="mb-6 p-4 border border-gray-200 rounded-lg">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* Voice Selection */}
|
|
<div>
|
|
<label htmlFor="voice-select" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Select Voice
|
|
</label>
|
|
<select
|
|
id="voice-select"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:border-[#6d9e37]"
|
|
value={selectedVoice}
|
|
onChange={(e) => setSelectedVoice(e.target.value)}
|
|
disabled={isSpeaking}
|
|
>
|
|
{voices.length > 0 ? (
|
|
voices.map((voice) => (
|
|
<option key={voice.name} value={voice.name}>
|
|
{voice.name} ({voice.lang})
|
|
</option>
|
|
))
|
|
) : (
|
|
<option value="">Loading voices...</option>
|
|
)}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Pitch Control */}
|
|
<div>
|
|
<label htmlFor="pitch" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Pitch: {pitch.toFixed(1)}
|
|
</label>
|
|
<input
|
|
type="range"
|
|
id="pitch"
|
|
min="0.5"
|
|
max="2"
|
|
step="0.1"
|
|
value={pitch}
|
|
onChange={(e) => setPitch(parseFloat(e.target.value))}
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
|
disabled={isSpeaking}
|
|
/>
|
|
</div>
|
|
|
|
{/* Rate Control */}
|
|
<div>
|
|
<label htmlFor="rate" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Speed: {rate.toFixed(1)}
|
|
</label>
|
|
<input
|
|
type="range"
|
|
id="rate"
|
|
min="0.5"
|
|
max="2"
|
|
step="0.1"
|
|
value={rate}
|
|
onChange={(e) => setRate(parseFloat(e.target.value))}
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
|
disabled={isSpeaking}
|
|
/>
|
|
</div>
|
|
|
|
{/* Volume Control */}
|
|
<div>
|
|
<label htmlFor="volume" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Volume: {volume.toFixed(1)}
|
|
</label>
|
|
<input
|
|
type="range"
|
|
id="volume"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
value={volume}
|
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
|
disabled={isSpeaking}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex flex-wrap gap-3 mb-6">
|
|
<Button
|
|
onClick={handleSpeak}
|
|
disabled={!text.trim() || (isSpeaking && !isPaused)}
|
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
|
>
|
|
{isPaused ? 'Resume' : 'Speak'}
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handlePause}
|
|
disabled={!isSpeaking || isPaused}
|
|
variant="outline"
|
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
|
>
|
|
Pause
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleStop}
|
|
disabled={!isSpeaking && !isPaused}
|
|
variant="outline"
|
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
|
>
|
|
Stop
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleDownload}
|
|
variant="outline"
|
|
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
|
>
|
|
Download Audio
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="mb-6 text-center">
|
|
<p className={`text-sm ${
|
|
error ? 'text-red-600' :
|
|
isSpeaking ? 'text-[#6d9e37]' :
|
|
isPaused ? 'text-amber-600' :
|
|
'text-gray-600'
|
|
}`}>
|
|
{status}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
{error && (
|
|
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
<div className="flex items-center text-red-600">
|
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<h3 className="font-medium">Error</h3>
|
|
</div>
|
|
<p className="mt-2 text-sm text-red-600">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Debug Section */}
|
|
{showDebug && (
|
|
<div className="mt-8 border rounded-lg overflow-hidden">
|
|
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
|
<h3 className="font-medium text-gray-700">Debug Logs</h3>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={clearDebugLogs}
|
|
className="text-sm">
|
|
Clear Logs
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const blob = new Blob([debugLogs.join('\n')], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `tts-debug-${new Date().toISOString()}.log`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}}
|
|
className="text-sm">
|
|
Export Logs
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 bg-white max-h-60 overflow-y-auto">
|
|
{debugLogs.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{debugLogs.map((log, index) => (
|
|
<div key={index} className="text-xs font-mono text-gray-800 border-b border-gray-100 pb-1">
|
|
{log}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-500">No debug logs yet. Interactions will appear here.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FreeTextToSpeech; |