text_to_speech_plus 1.0.0
text_to_speech_plus: ^1.0.0 copied to clipboard
A modern Flutter plugin for Text-to-Speech (TTS) capabilities across Android, iOS, Web, Windows, and macOS.
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:text_to_speech_plus/text_to_speech_plus.dart';
void main() {
runApp(const MaterialApp(
home: TextToSpeechPlusExample(),
debugShowCheckedModeBanner: false,
));
}
class TextToSpeechPlusExample extends StatefulWidget {
const TextToSpeechPlusExample({super.key});
@override
State<TextToSpeechPlusExample> createState() => _TextToSpeechPlusExampleState();
}
enum TtsState { playing, stopped, paused }
class _TextToSpeechPlusExampleState extends State<TextToSpeechPlusExample> {
late TextToSpeechPlus tts;
TtsState ttsState = TtsState.stopped;
// Configuration
String language = 'en-US';
Map<String, String>? selectedVoice;
double volume = 1.0;
double pitch = 1.0;
double rate = 0.5;
String? selectedStyle;
String? selectedEmotion;
bool useEnqueue = false;
bool useAutoDetect = false;
String selectedReadingMode = 'none';
String genderFilter = 'all'; // 'all', 'male', 'female'
String voiceSearchQuery = '';
// Data
List<Map<String, String>> allVoices = [];
List<String> languages = [];
// Real-time tracking
String currentWord = "";
String currentSentence = "";
double currentAmplitude = 0.0;
final TextEditingController _textController = TextEditingController(
text: "Hello! Welcome to Text to Speech Plus. This library supports SSML, "
"word highlighting, and emotional expression. Try changing the style below!",
);
@override
void initState() {
super.initState();
initTts();
}
void initTts() {
tts = TextToSpeechPlus();
tts.onStart(() => setState(() => ttsState = TtsState.playing));
tts.onComplete(() => setState(() {
ttsState = TtsState.stopped;
currentWord = "";
}));
tts.onPause(() => setState(() => ttsState = TtsState.paused));
tts.onCancel(() => setState(() => ttsState = TtsState.stopped));
tts.onQueueFinished(() => debugPrint("All speech queued has finished."));
tts.onError((msg) => debugPrint("TTS Error: $msg"));
// Word Timestamp API
tts.onProgress((text, start, end, word, type) {
setState(() {
currentWord = word;
});
});
tts.onSentenceStart((text, start, end, word, type) {
setState(() {
currentSentence = word;
});
});
// Audio Visualization API
tts.onAmplitude((amplitude) {
setState(() {
currentAmplitude = amplitude;
});
});
_loadVoices();
}
Future<void> _loadVoices() async {
final availableVoices = await tts.voices;
final availableLanguages = await tts.languages;
if (availableVoices != null) {
setState(() {
allVoices = availableVoices.map((v) => Map<String, String>.from(v)).toList();
languages = availableLanguages?.cast<String>() ?? [];
});
}
}
List<Map<String, String>> get filteredVoices {
return allVoices.where((v) {
final matchesGender = genderFilter == 'all' ||
(v['gender']?.toLowerCase() == genderFilter.toLowerCase());
final matchesSearch = voiceSearchQuery.isEmpty ||
(v['name']?.toLowerCase().contains(voiceSearchQuery.toLowerCase()) ?? false) ||
(v['locale']?.toLowerCase().contains(voiceSearchQuery.toLowerCase()) ?? false);
return matchesGender && matchesSearch;
}).toList();
}
Future<void> _speak() async {
await tts.setVolume(volume);
await tts.setRate(rate);
await tts.setPitch(pitch);
Map<String, dynamic>? readingOptions;
if (selectedReadingMode != 'none') {
readingOptions = {
'mode': selectedReadingMode,
'punctuationDelay': '300ms',
};
}
await tts.speak(
_textController.text,
enqueue: useEnqueue,
autoDetectLanguage: useAutoDetect,
style: selectedStyle,
emotion: selectedEmotion,
voice: selectedVoice,
readingOptions: readingOptions,
);
}
Future<void> _runConversationDemo() async {
if (allVoices.length < 2) return;
final voiceA = allVoices.firstWhere((v) => v['locale']?.startsWith('en') ?? false, orElse: () => allVoices[0]);
final voiceB = allVoices.lastWhere((v) => v['locale']?.startsWith('en') ?? false, orElse: () => allVoices[allVoices.length - 1]);
await tts.speak("Hey there! Have you tried the new multi-speaker feature?", voice: voiceA, emotion: "excited");
await tts.speak("Yes! It makes conversations sound so much more realistic.", voice: voiceB, enqueue: true, emotion: "friendly");
await tts.speak("And with streaming support, we don't have to wait for the whole text to be ready.", voice: voiceA, enqueue: true);
await tts.speak("Exactly! This is perfect for AI-driven apps.", voice: voiceB, enqueue: true, emotion: "cheerful");
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: const Text("Text to Speech Plus", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.deepPurple)),
centerTitle: true,
backgroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(icon: const Icon(Icons.refresh, color: Colors.deepPurple), onPressed: _loadVoices),
],
),
body: Column(
children: [
_buildVisualizer(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
_buildHighlightArea(),
const SizedBox(height: 16),
_buildInputArea(),
const SizedBox(height: 16),
_buildControls(),
const SizedBox(height: 16),
_buildTabs(),
const SizedBox(height: 32),
],
),
),
),
],
),
);
}
Widget _buildVisualizer() {
return Container(
height: 80,
width: double.infinity,
color: Colors.white,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(15, (index) {
double height = 10 + (currentAmplitude * 60 * (index % 5 + 1) / 5);
return AnimatedContainer(
duration: const Duration(milliseconds: 60),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 6,
height: ttsState == TtsState.playing ? height : 6,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.deepPurple.shade300, Colors.deepPurple.shade700],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
),
borderRadius: BorderRadius.circular(10),
),
);
}),
),
),
);
}
Widget _buildHighlightArea() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.deepPurple.shade700,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.deepPurple.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 4))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("LIVE TELEPROMPTER", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white70, letterSpacing: 1.2)),
if (ttsState == TtsState.playing)
const Icon(Icons.record_voice_over, color: Colors.greenAccent, size: 16),
],
),
const SizedBox(height: 8),
Text(
currentWord.isEmpty ? "Waiting to speak..." : currentWord.toUpperCase(),
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w900, color: Colors.white),
),
const SizedBox(height: 4),
Text(
currentSentence.isEmpty ? "Ready to synthesize" : currentSentence,
style: const TextStyle(fontSize: 14, color: Colors.white60),
maxLines: 2,
),
],
),
);
}
Widget _buildInputArea() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Text Input", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
TextField(
controller: _textController,
maxLines: 4,
style: const TextStyle(fontSize: 15),
decoration: InputDecoration(
hintText: "Enter plain text or SSML...",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.all(16),
),
),
],
);
}
Widget _buildControls() {
return Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: ttsState == TtsState.playing ? tts.pause : _speak,
icon: Icon(ttsState == TtsState.playing ? Icons.pause_rounded : Icons.play_arrow_rounded),
label: Text(ttsState == TtsState.playing ? "PAUSE" : "SPEAK"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 18),
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
const SizedBox(width: 12),
IconButton.filledTonal(
onPressed: tts.stop,
icon: const Icon(Icons.stop_rounded, size: 28),
padding: const EdgeInsets.all(14),
style: IconButton.styleFrom(backgroundColor: Colors.red.shade50, foregroundColor: Colors.red),
),
],
);
}
Widget _buildTabs() {
return DefaultTabController(
length: 4,
child: Column(
children: [
TabBar(
isScrollable: true,
labelColor: Colors.deepPurple,
unselectedLabelColor: Colors.grey,
indicator: UnderlineTabIndicator(
borderSide: BorderSide(width: 3.0, color: Colors.deepPurple.shade700),
insets: const EdgeInsets.symmetric(horizontal: 16.0),
),
tabs: const [
Tab(text: "Voices"),
Tab(text: "Expressions"),
Tab(text: "Modes"),
Tab(text: "Demos"),
],
),
SizedBox(
height: 420,
child: TabBarView(
children: [
_buildVoiceTab(),
_buildExpressionTab(),
_buildReadingTab(),
_buildDemoTab(),
],
),
),
],
),
);
}
Widget _buildVoiceTab() {
return ListView(
padding: const EdgeInsets.symmetric(vertical: 16),
children: [
SwitchListTile(
title: const Text("Auto Detect Language"),
subtitle: const Text("Analyses text to find best local voice"),
value: useAutoDetect,
activeColor: Colors.deepPurple,
onChanged: (v) => setState(() => useAutoDetect = v),
),
const Divider(),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextField(
decoration: const InputDecoration(hintText: "Search voices...", prefixIcon: Icon(Icons.search)),
onChanged: (v) => setState(() => voiceSearchQuery = v),
),
),
const SizedBox(width: 8),
DropdownButton<String>(
value: genderFilter,
items: const [
DropdownMenuItem(value: 'all', child: Text("All Genders")),
DropdownMenuItem(value: 'male', child: Text("Male")),
DropdownMenuItem(value: 'female', child: Text("Female")),
],
onChanged: (v) => setState(() => genderFilter = v!),
),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<Map<String, String>>(
decoration: const InputDecoration(labelText: "Selected Speaker"),
value: selectedVoice,
items: filteredVoices.map((v) => DropdownMenuItem(
value: v,
child: Text(
"${v['name']} (${v['locale']}) - ${v['gender'] ?? '?'}",
style: const TextStyle(fontSize: 11)
),
)).toList(),
onChanged: (v) => setState(() => selectedVoice = v),
),
],
),
),
_buildSlider("Volume", volume, (v) => setState(() => volume = v)),
_buildSlider("Rate", rate, (v) => setState(() => rate = v)),
_buildSlider("Pitch", pitch, (v) => setState(() => pitch = v), min: 0.5, max: 2.0),
],
);
}
Widget _buildExpressionTab() {
final items = ["none", "cheerful", "angry", "sad", "excited", "friendly", "whisper", "narration"];
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text("Voice Styles", style: TextStyle(fontWeight: FontWeight.bold)),
const Text("Request a specific tone for the synthesis (Engine dependent).", style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: items.map((s) => ChoiceChip(
label: Text(s),
selected: selectedStyle == s || (s == "none" && selectedStyle == null),
onSelected: (v) => setState(() => selectedStyle = (s == "none" ? null : s)),
)).toList(),
),
const SizedBox(height: 24),
const Text("Emotions", style: TextStyle(fontWeight: FontWeight.bold)),
const Text("Inject emotional context into the voice.", style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: items.where((s) => s != "none").map((s) => ChoiceChip(
label: Text(s),
selected: selectedEmotion == s,
onSelected: (v) => setState(() => selectedEmotion = v ? s : null),
)).toList(),
),
],
);
}
Widget _buildReadingTab() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text("Reading Curves", style: TextStyle(fontWeight: FontWeight.bold)),
const Text("Automatically adjusts prosody and punctuation pauses.", style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 16),
_buildReadingModeCard("Standard", "Normal text playback", "none", Icons.linear_scale),
_buildReadingModeCard("Storytelling", "Dynamic pitch and meaningful pauses", "storytelling", Icons.auto_stories),
_buildReadingModeCard("News Anchor", "Steady flow with formal authoritative tone", "news", Icons.newspaper),
_buildReadingModeCard("Secret Whisper", "Low volume, soft vocal impact", "whisper", Icons.record_voice_over),
],
);
}
Widget _buildReadingModeCard(String title, String desc, String mode, IconData icon) {
bool isSelected = selectedReadingMode == mode;
return Card(
elevation: isSelected ? 4 : 0,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: isSelected ? Colors.deepPurple : Colors.grey.shade200),
),
child: ListTile(
leading: Icon(icon, color: isSelected ? Colors.deepPurple : Colors.grey),
title: Text(title, style: TextStyle(fontWeight: isSelected ? FontWeight.bold : FontWeight.normal)),
subtitle: Text(desc, style: const TextStyle(fontSize: 11)),
trailing: isSelected ? const Icon(Icons.check_circle, color: Colors.deepPurple) : null,
onTap: () => setState(() => selectedReadingMode = mode),
),
);
}
Widget _buildDemoTab() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text("Quick Features Demo", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_buildDemoButton(
"Multi-Speaker Conversation",
"Automated dialog between two different voices.",
Icons.forum_rounded,
_runConversationDemo,
),
_buildDemoButton(
"Streaming (Chunked) Speech",
"Speak text chunks as they arrive for AI chat.",
Icons.bolt_rounded,
() async {
setState(() => useEnqueue = true);
await tts.speak("First chunk starting now.");
await tts.speak(" Second chunk enqueued immediately.", enqueue: true);
await tts.speak(" All chunks completed seamlessly.", enqueue: true);
},
),
_buildDemoButton(
"Advanced SSML Power",
"Control emphasis, breaks, and pronunciation.",
Icons.code_rounded,
() {
_textController.text = """
<speak>
Hello! <break time="1s"/>
I can speak with <emphasis level="strong">strength</emphasis>,
or <prosody rate="x-slow">extremely slowly</prosody>.
I can even say <sub alias="Speech Synthesis Markup Language">SSML</sub>.
</speak>""";
_speak();
},
),
],
);
}
Widget _buildDemoButton(String title, String desc, IconData icon, VoidCallback onTap) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade200)),
child: ListTile(
contentPadding: const EdgeInsets.all(12),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.deepPurple.shade50, shape: BoxShape.circle),
child: Icon(icon, color: Colors.deepPurple),
),
title: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
subtitle: Text(desc, style: const TextStyle(fontSize: 11)),
onTap: onTap,
),
);
}
Widget _buildSlider(String label, double value, Function(double) onChanged, {double min = 0.0, double max = 1.0}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
SizedBox(width: 60, child: Text(label, style: const TextStyle(fontSize: 12))),
Expanded(
child: Slider(
value: value,
min: min,
max: max,
onChanged: onChanged,
activeColor: Colors.deepPurple,
),
),
Text(value.toStringAsFixed(1), style: const TextStyle(fontSize: 11)),
],
),
);
}
}