sp/src/components/Tools/FreeTextToSpeech.jsx

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;