Skip to main content

speak 0.1.3 copy "speak: ^0.1.3" to clipboard
speak: ^0.1.3 copied to clipboard

Text-to-speech for Flutter on iOS, macOS, Android and web: awaitable prompts over other audio, no native code to set up. An alternative to flutter_tts.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:speak/speak.dart';

void main() => runApp(const SpeakDemoApp());

/// The `speak` demo: voices, availability, speak/stop/pause, word progress
/// and the audio modes.
class SpeakDemoApp extends StatelessWidget {
  /// Creates the app.
  const SpeakDemoApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'speak',
    theme: ThemeData(colorSchemeSeed: Colors.teal),
    home: const DemoPage(),
  );
}

/// The one page of the demo.
class DemoPage extends StatefulWidget {
  /// Creates the page.
  const DemoPage({super.key});

  @override
  State<DemoPage> createState() => _DemoPageState();
}

class _DemoPageState extends State<DemoPage> {
  static const _audioModes = {
    'Prompt: lower everything': SpeakAudio.prompt,
    'Navigation: lower music, pause podcasts': SpeakAudio.navigation,
    'Pause podcasts only': SpeakAudio(
      otherAudio: OtherAudio.pauseSpoken,
      usage: AudioUsage.navigation,
    ),
    'Plain: leave other audio alone': SpeakAudio.plain,
    'Exclusive: pause other audio': SpeakAudio.exclusive,
  };

  final _text = TextEditingController(
    text: 'In two hundred metres, turn left onto the river path.',
  );
  final _language = TextEditingController(text: 'en-US');

  Speak _speak = Speak();
  String _audioMode = 'Prompt: lower everything';
  double _leadInMs = SpeakAudio.defaultLeadIn.inMilliseconds.toDouble();
  double _lingerMs = SpeakAudio.defaultLinger.inMilliseconds.toDouble();
  List<AndroidEngine> _engines = const [];
  String? _engine;
  List<Voice> _voices = const [];
  Voice? _voice;
  double _rate = 1;
  double _pitch = 1;
  SpeechProgress? _progress;
  String? _availability;
  final _log = <String>[];

  @override
  void initState() {
    super.initState();
    if (Speak.isSupported) unawaited(_load());
  }

  @override
  void dispose() {
    unawaited(_speak.dispose());
    _text.dispose();
    _language.dispose();
    super.dispose();
  }

  Future<void> _load() async {
    final engines = await Speak.androidEngines();
    final voices = await _speak.voices();
    if (!mounted) return;
    setState(() {
      _engines = engines;
      _voices = voices;
      if (_voice != null && !voices.contains(_voice)) _voice = null;
    });
  }

  /// A new speaker for a new audio mode or engine: both are fixed for the
  /// life of a `Speak`.
  Future<void> _recreate() async {
    final old = _speak;
    _speak = Speak(
      audio: _audioModes[_audioMode]!.copyWith(
        leadIn: Duration(milliseconds: _leadInMs.round()),
        linger: Duration(milliseconds: _lingerMs.round()),
      ),
      android: AndroidSpeakOptions(engine: _engine),
    );
    await old.dispose();
    _voice = null;
    await _load();
  }

  void _note(String line) {
    if (!mounted) return;
    setState(() => _log.insert(0, line));
  }

  Future<void> _say(SpeakQueue queue) async {
    final text = _text.text;
    final language = _language.text.trim();
    try {
      final outcome = await _speak(
        text,
        voice: _voice,
        language: _voice == null && language.isNotEmpty ? language : null,
        rate: _rate,
        pitch: _pitch,
        queue: queue,
        onProgress: (progress) => setState(() => _progress = progress),
      );
      _note('${outcome.name}: ${_short(text)}');
    } on SpeakException catch (error) {
      _note('failed (${error.error.name}): ${error.message ?? ''}');
    }
    if (mounted) setState(() => _progress = null);
  }

  Future<void> _checkAvailability() async {
    final language = _language.text.trim();
    if (language.isEmpty) return;
    final availability = await _speak.availability(language);
    setState(() => _availability = '$language: ${availability.name}');
  }

  static String _short(String text) =>
      text.length > 40 ? '${text.substring(0, 40)}...' : text;

  @override
  Widget build(BuildContext context) {
    if (!Speak.isSupported) {
      return const Scaffold(
        body: Center(child: Text('Text-to-speech is not supported here.')),
      );
    }
    final theme = Theme.of(context);
    return Scaffold(
      appBar: AppBar(title: const Text('speak')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _spokenText(theme),
          const SizedBox(height: 12),
          TextField(
            controller: _text,
            minLines: 2,
            maxLines: 4,
            onChanged: (_) => setState(() {}),
            decoration: const InputDecoration(
              labelText: 'Text',
              border: OutlineInputBorder(),
            ),
          ),
          const SizedBox(height: 12),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              FilledButton(
                onPressed: () => _say(SpeakQueue.append),
                child: const Text('Speak'),
              ),
              FilledButton.tonal(
                onPressed: () => _say(SpeakQueue.interrupt),
                child: const Text('Speak now'),
              ),
              OutlinedButton(
                onPressed: () => _speak.stop(),
                child: const Text('Stop'),
              ),
              OutlinedButton(
                onPressed: () async {
                  await (_speak.isPaused ? _speak.resume() : _speak.pause());
                  setState(() {});
                },
                child: Text(_speak.isPaused ? 'Resume' : 'Pause'),
              ),
            ],
          ),
          if (_log.isNotEmpty)
            Padding(
              padding: const EdgeInsets.only(top: 8),
              child: Text(_log.first, style: theme.textTheme.bodySmall),
            ),
          const SizedBox(height: 16),
          Row(
            children: [
              Expanded(
                child: TextField(
                  controller: _language,
                  decoration: const InputDecoration(
                    labelText: 'Language (BCP-47), used without a voice',
                  ),
                ),
              ),
              TextButton(
                onPressed: _checkAvailability,
                child: const Text('Available?'),
              ),
            ],
          ),
          if (_availability case final availability?)
            Padding(
              padding: const EdgeInsets.only(top: 4),
              child: Text(availability),
            ),
          const SizedBox(height: 12),
          DropdownButtonFormField<Voice?>(
            initialValue: _voice,
            isExpanded: true,
            decoration: InputDecoration(
              labelText: 'Voice (${_voices.length} installed)',
            ),
            items: [
              const DropdownMenuItem(child: Text('Best for the language')),
              for (final voice in _voices)
                DropdownMenuItem(
                  value: voice,
                  child: Text(
                    '${voice.name} · ${voice.language} · ${voice.quality.name}'
                    '${voice.requiresNetwork ? ' · online' : ''}'
                    '${voice.isInstalled ? '' : ' · not installed'}',
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
            ],
            onChanged: (voice) => setState(() => _voice = voice),
          ),
          _slider('Rate', _rate, 0.25, 2, (v) => _rate = v),
          _slider('Pitch', _pitch, 0.5, 2, (v) => _pitch = v),
          const SizedBox(height: 8),
          DropdownButtonFormField<String>(
            initialValue: _audioMode,
            isExpanded: true,
            decoration: const InputDecoration(labelText: 'Other audio'),
            items: [
              for (final mode in _audioModes.keys)
                DropdownMenuItem(value: mode, child: Text(mode)),
            ],
            onChanged: (mode) {
              _audioMode = mode!;
              unawaited(_recreate());
            },
          ),
          _millisSlider('Lead-in', _leadInMs, 1000, (v) => _leadInMs = v),
          _millisSlider('Linger', _lingerMs, 5000, (v) => _lingerMs = v),
          if (_engines.isNotEmpty)
            DropdownButtonFormField<String?>(
              initialValue: _engine,
              isExpanded: true,
              decoration: const InputDecoration(labelText: 'Android engine'),
              items: [
                for (final engine in _engines)
                  DropdownMenuItem(
                    value: engine.isDefault ? null : engine.name,
                    child: Text(
                      '${engine.label}${engine.isDefault ? ' (default)' : ''}',
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
              ],
              onChanged: (engine) {
                _engine = engine;
                unawaited(_recreate());
              },
            ),
          const SizedBox(height: 16),
          Card(
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Text(
                kIsWeb
                    ? 'A web page cannot lower other audio: the audio mode '
                          'does nothing in the browser.'
                    : 'Try it with music: play music in another app, '
                          'choose "Prompt" and tap Speak. The music dips '
                          'while the text is spoken and comes back after '
                          'the linger. "Navigation" also pauses a podcast; '
                          '"Pause podcasts only" leaves music alone. On '
                          'Bluetooth, the lead-in keeps the first syllable.',
                style: theme.textTheme.bodySmall,
              ),
            ),
          ),
          const SizedBox(height: 8),
          for (final line in _log.skip(1))
            Text(line, style: theme.textTheme.bodySmall),
        ],
      ),
    );
  }

  /// The text with the word being spoken highlighted.
  Widget _spokenText(ThemeData theme) {
    final progress = _progress;
    final style = theme.textTheme.titleMedium!;
    if (progress == null) return Text(_text.text, style: style);
    return Text.rich(
      TextSpan(
        style: style,
        children: [
          TextSpan(text: progress.text.substring(0, progress.start)),
          TextSpan(
            text: progress.word,
            style: TextStyle(
              backgroundColor: theme.colorScheme.primaryContainer,
            ),
          ),
          TextSpan(text: progress.text.substring(progress.end)),
        ],
      ),
    );
  }

  /// A slider for a duration in milliseconds; the speaker is rebuilt with
  /// it when the thumb is let go.
  Widget _millisSlider(
    String label,
    double value,
    double max,
    void Function(double) set,
  ) => Row(
    children: [
      SizedBox(width: 64, child: Text(label)),
      Expanded(
        child: Slider(
          value: value,
          max: max,
          divisions: (max / 50).round(),
          label: '${value.round()} ms',
          onChanged: (v) => setState(() => set(v)),
          onChangeEnd: (_) => unawaited(_recreate()),
        ),
      ),
      SizedBox(width: 64, child: Text('${value.round()} ms')),
    ],
  );

  Widget _slider(
    String label,
    double value,
    double min,
    double max,
    void Function(double) set,
  ) => Row(
    children: [
      SizedBox(width: 48, child: Text(label)),
      Expanded(
        child: Slider(
          value: value,
          min: min,
          max: max,
          divisions: ((max - min) / 0.05).round(),
          label: value.toStringAsFixed(2),
          onChanged: (v) => setState(() => set(v)),
        ),
      ),
    ],
  );
}
1
likes
0
points
516
downloads

Publisher

verified publishervelorki.com

Weekly Downloads

Text-to-speech for Flutter on iOS, macOS, Android and web: awaitable prompts over other audio, no native code to set up. An alternative to flutter_tts.

Homepage
Repository (GitHub)
View/report issues

Topics

#tts #text-to-speech #speech #accessibility #audio

License

unknown (license)

Dependencies

clock, code_assets, ffi, flutter, hooks, jni, jni_flutter, meta, native_toolchain_c, objective_c, web

More

Packages that depend on speak

Packages that implement speak