text_to_speech_plus 1.0.0 copy "text_to_speech_plus: ^1.0.0" to clipboard
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.

example/lib/main.dart

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)),
        ],
      ),
    );
  }
}
4
likes
140
points
103
downloads

Documentation

API reference

Publisher

verified publishershirsh.dev

Weekly Downloads

A modern Flutter plugin for Text-to-Speech (TTS) capabilities across Android, iOS, Web, Windows, and macOS.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, flutter_web_plugins

More

Packages that depend on text_to_speech_plus

Packages that implement text_to_speech_plus