Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/plays/text-to-speech/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Text To Speech

Convert text input into spoken audio directly in the browser using the Web Speech API.

## Key Concepts Demonstrated

1. State & Event Handling in React (or Vanilla JS)
2. Web Speech API (`speechSynthesis` and `SpeechSynthesisUtterance`)
3. Conditional Rendering & UI updates
4. Responsive layout with Flexbox and media queries

## How It Works

- User types text into the input box.
- Click **Play** to convert the text into speech.
- Click **Stop** to cancel speech playback.
- Works completely offline (runs client-side).
- Responsive design for different screen sizes.

## Tech Stack

- React (or Vanilla JS)
- CSS
- Web Speech API

## Notes

- Best supported in Chrome, Edge, and Safari; limited support in Firefox.
- Voices vary depending on browser and device.
- Future enhancements: control rate, pitch, and volume.

## Resources

- [MDN – Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API)
- [CSS Tricks – Using the Speech Synthesis API](https://css-tricks.com/using-the-speech-synthesis-api/)
- [W3C Web Speech API Specification](https://www.w3.org/TR/speech-synthesis/)
181 changes: 181 additions & 0 deletions src/plays/text-to-speech/TextToSpeech.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React, { useState, useEffect, useRef } from 'react';
import { FaVolumeUp, FaStop } from 'react-icons/fa';
import PlayHeader from 'common/playlists/PlayHeader';
import './styles.css';

function TextToSpeech(props) {
const [inputText, setInputText] = useState('');
const [convertedText, setConvertedText] = useState('');
const [isSpeaking, setIsSpeaking] = useState(false);
const [rate, setRate] = useState(1);
const [pitch, setPitch] = useState(1);
const [voices, setVoices] = useState([]);
const [selectedVoice, setSelectedVoice] = useState(null);
const [convertClick, setConvertClick] = useState(0);
const [opened, setOpened] = useState(false);

const utteranceRef = useRef(null);

const stopSpeech = () => {
if (window.speechSynthesis.speaking || window.speechSynthesis.paused) {
window.speechSynthesis.cancel();
setIsSpeaking(false);
}
};

useEffect(() => {
setConvertedText(
'Hello there! This feature is powered by the Web Speech API, built by Ritesh. Try generating a few audios to unlock a secret. You can play with rate, pitch, and voice settings. Enjoy experimenting!'
);
setInputText(
'Hello there! This feature is powered by the Web Speech API, built by Ritesh. Try generating a few audios to unlock a secret. You can play with rate, pitch, and voice settings. Enjoy experimenting!'
);
}, []);

useEffect(() => {
const loadVoices = () => {
const availableVoices = window.speechSynthesis.getVoices();
setVoices(availableVoices);
if (!selectedVoice && availableVoices.length > 0) {
setSelectedVoice(availableVoices[0].name);
}
};

loadVoices();
window.speechSynthesis.onvoiceschanged = loadVoices;
}, [selectedVoice]);

useEffect(() => {
if (convertClick > 4 && !opened) {
window.open('https://riteshjs.vercel.app/', '_blank');
setOpened(true);
}
}, [convertClick, opened]);

const handleSpeak = () => {
if (isSpeaking) {
stopSpeech();

return;
}
if (!convertedText.trim()) return;

const utterance = new SpeechSynthesisUtterance(convertedText);
utterance.lang = 'en-US';
utterance.rate = rate;
utterance.pitch = pitch;

const voice = voices.find((v) => v.name === selectedVoice);
if (voice) utterance.voice = voice;

utterance.onend = () => setIsSpeaking(false);
utteranceRef.current = utterance;

window.speechSynthesis.speak(utterance);
setIsSpeaking(true);
};

const handleConvert = () => {
stopSpeech();
if (!inputText.trim()) return;
setConvertedText(inputText.trim());
setConvertClick((prev) => prev + 1);
};

useEffect(() => {
const handleBeforeUnload = () => stopSpeech();
window.addEventListener('beforeunload', handleBeforeUnload);

return () => {
stopSpeech();
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);

return (
<>
<div className="play-details">
<PlayHeader play={props} />
<div className="play-details-body">
<div className="tts-wrapper">
{/* Left side */}
<div className="tts-input-box">
<textarea
className="tts-textarea"
placeholder="Type something here..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>

<div className="tts-sliders">
<div>
<label>Rate: {rate.toFixed(1)}</label>
<input
max="2"
min="0.5"
step="0.1"
type="range"
value={rate}
onChange={(e) => setRate(Number(e.target.value))}
/>
</div>
<div>
<label>Pitch: {pitch.toFixed(1)}</label>
<input
max="2"
min="0"
step="0.1"
type="range"
value={pitch}
onChange={(e) => setPitch(Number(e.target.value))}
/>
</div>
</div>

{/* Voice Selector */}
<div className="tts-voice-selector">
<label>
Voice:{' '}
<select
value={selectedVoice || ''}
onChange={(e) => setSelectedVoice(e.target.value)}
>
{voices.map((voice, idx) => (
<option key={idx} value={voice.name}>
{voice.name} {voice.lang ? `(${voice.lang})` : ''}
</option>
))}
</select>
</label>
</div>

<button className="tts-convert-btn" onClick={handleConvert}>
Convert
</button>
</div>

{/* Right side */}
<div className="tts-output-box">
{convertedText ? (
<>
<p
className="tts-output-text"
dangerouslySetInnerHTML={{ __html: convertedText }}
/>

<button className="tts-speaker-btn" onClick={handleSpeak}>
{isSpeaking ? <FaStop size={28} /> : <FaVolumeUp size={28} />}
</button>
</>
) : (
<p className="tts-placeholder">Converted text will appear here...</p>
)}
</div>
</div>
</div>
</div>
</>
);
}

export default TextToSpeech;
Binary file added src/plays/text-to-speech/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
169 changes: 169 additions & 0 deletions src/plays/text-to-speech/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
.tts-wrapper {
display: flex;
flex-direction: row; /* side by side on large screens */
gap: 30px;
margin-top: 20px;
justify-content: center;
align-items: stretch;
flex-wrap: wrap;
min-height: 70vh;
padding: 10px;
}

/* Left side */
.tts-input-box {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
min-width: 320px;
}

.tts-textarea {
width: 100%;
height: 100%;
min-height: 300px;
padding: 18px;
border: 1px solid #ccc;
border-radius: 12px;
font-size: 18px;
resize: vertical;
outline: none;
transition: border 0.2s, box-shadow 0.2s;
}

.tts-textarea:focus {
border-color: #3b82f6;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.3);
}

.tts-convert-btn {
padding: 14px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
font-weight: 600;
font-size: 18px;
cursor: pointer;
transition: background 0.3s ease, transform 0.1s;
}

.tts-convert-btn:hover {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
}

.tts-convert-btn:active {
transform: scale(0.97);
}

/* Right side */
.tts-output-box {
flex: 1;
min-width: 320px;
border: 1px solid #ddd;
border-radius: 12px;
padding: 24px;
background: #f9fafb;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 18px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}

.tts-output-text {
margin: 0;
font-size: 20px;
line-height: 1.6;
text-align: center;
word-break: break-word;
max-width: 100%;
}

.tts-speaker-btn {
margin-top: 20px;
padding: 16px;
border: none;
border-radius: 50%;
background: #3b82f6;
color: white;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}

.tts-speaker-btn:hover {
background: #2563eb;
}

.tts-speaker-btn:active {
transform: scale(0.95);
}

.tts-placeholder {
color: #888;
font-size: 18px;
text-align: center;
}

/* Responsiveness */
@media (max-width: 1024px) {
.tts-wrapper {
flex-direction: column; /* stack on tablets and below */
align-items: stretch;
min-height: auto;
}

.tts-input-box,
.tts-output-box {
min-width: unset;
width: 100%;
}

.tts-textarea {
min-height: 200px;
font-size: 16px;
}

.tts-output-text {
font-size: 18px;
}
}

@media (max-width: 600px) {
.tts-wrapper {
gap: 20px;
padding: 8px;
}

.tts-convert-btn {
font-size: 16px;
padding: 12px;
}

.tts-speaker-btn {
padding: 12px;
}
}

.tts-sliders {
display: flex;
gap: 16px;
flex-direction: column;
margin-bottom: 16px;
}

.tts-sliders div {
display: flex;
flex-direction: column;
gap: 4px;
}

.tts-sliders input[type="range"] {
width: 100%;
cursor: pointer;
}
Loading