speak 0.2.3
speak: ^0.2.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.
speak #
Text-to-speech for Flutter on iOS, macOS, Android and the web, made for short spoken prompts over other audio: turn-by-turn directions, workout cues, timers.
final speak = Speak(audio: SpeakAudio.navigation);
await speak('In two hundred metres, turn left.');
await speak('Turn left now.', queue: SpeakQueue.interrupt);
- Awaitable. Every call completes when the text has been spoken, or tells
you how it ended instead:
completed,interruptedorexpired. - Queue or interrupt. Utterances wait their turn by default. An urgent one
cuts in, and the ones it displaces complete as
interrupted. A distance announcement that waited too long can expire unsaid. - Plays well with other audio. Music is lowered (and podcasts paused) only while something is spoken, and comes back afterwards: iOS's audio session and Android's audio focus are claimed right before speaking and released shortly after. A short silent lead-in keeps Bluetooth headsets from eating the first syllable.
- Voices. List them with their language (BCP-47) and quality, pick one or
let
speakpick the best for a language, and check whether a language can be spoken before you try. - Word progress, pause and resume on every platform.
- No native code to write or set up: no Podfile, no Swift package, no
Kotlin. iOS and macOS call AVFoundation from Dart through FFI, Android calls
TextToSpeechthrough JNI, the web uses the Web Speech API. See How it works for what is compiled for you.
| Platform | Support |
|---|---|
| iOS 13+ | AVSpeechSynthesizer, AVAudioSession |
| macOS 10.15+ | AVSpeechSynthesizer |
| Android 7+ (API 24) | TextToSpeech, audio focus |
| Web | Web Speech API, also compiled to Wasm |
| Windows, Linux | Not supported: Speak.isSupported is false |
Install #
flutter pub add speak
This needs Flutter 3.38 or later, the first release where build hooks run for you.
- iOS: for speech to be heard while the app is in the background, add
the
audiobackground mode toInfo.plist(and see Running in the background):
The package does not link CallKit; it loads it at run time to ask about calls. For an app sold on the China storefront, where App Review rejects apps containing CallKit, pass<key>UIBackgroundModes</key> <array><string>audio</string></array>Speak(ios: IosSpeakOptions(callKit: false)); see Calls and other interruptions. - Android: nothing to add. The
TTS_SERVICEquery that Android 11+ needs to see the installed engines is merged into your manifest from the package. - macOS and web: nothing to add.
Usage #
Speak and wait #
final speak = Speak();
final outcome = await speak('Your timer is done.');
// SpeakOutcome.completed, or .interrupted if stop() or an interrupting
// utterance cut it off.
A Speak is one speaker. Create it once for the feature that talks, and
dispose() it when that feature goes away.
Queue, interrupt, expire #
speak('In 500 metres, turn right.'); // waits its turn
speak('In 200 metres, turn right.',
expireAfter: const Duration(seconds: 6)); // dropped if still waiting
speak('Turn right now.', queue: SpeakQueue.interrupt); // cuts in
await speak.stop(); // everything ends as interrupted
Voices and languages #
final german = await speak.voices(language: 'de'); // best first
await speak('Links abbiegen.', voice: german.first);
// Or let speak choose the best installed voice for a language:
await speak('Tournez à gauche.', language: 'fr-FR');
// Defaults for every call:
speak
..language = 'en-GB'
..rate = 1.1; // 1 is normal speed
A Voice has an id (store this to remember a choice), a name, a
language, a quality (low to premium), requiresNetwork and
isInstalled. When speak chooses for a language, it prefers the asked
region, voices that work offline, and better quality, in that order.
Is a language available? #
switch (await speak.availability('de-DE')) {
case VoiceAvailability.available:
break;
case VoiceAvailability.needsNetwork:
// Only an online voice speaks it.
case VoiceAvailability.notInstalled:
// Android: the voice data has to be downloaded in the system settings.
case VoiceAvailability.unsupported:
// No voice for it at all.
}
Speaking a language no installed voice speaks fails with
SpeakException(SpeakError.languageUnavailable).
Word progress #
await speak(
text,
onProgress: (progress) => setState(() => highlighted = progress),
);
// progress.start, progress.end: offsets into text; progress.word
Pause and resume #
await speak.pause();
await speak.resume();
Sharing audio with other apps #
final guidance = Speak(audio: SpeakAudio.navigation);
final custom = Speak(
audio: const SpeakAudio(
otherAudio: OtherAudio.pauseSpoken, // music plays on, podcasts pause
usage: AudioUsage.navigation,
leadIn: Duration(milliseconds: 500),
linger: Duration(seconds: 2),
),
);
The audio is claimed right before speaking and given back shortly after, as
navigation apps do: on iOS the session is activated (category first, then
setActive(true)) and later deactivated with notifyOthersOnDeactivation,
so other apps raise their volume or resume; on Android transient audio
focus is requested and then abandoned. Nothing is held while the app is
quiet, which is also what costs the least battery.
What happens to music and podcasts
OtherAudio |
iOS: music | iOS: podcasts, audiobooks | Android (music and podcasts alike) |
|---|---|---|---|
mix |
plays on at full volume | plays on | no audio focus: plays on |
duck (SpeakAudio.prompt, the default) |
lowered | lowered | lowered (AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) |
duckAndPauseSpoken (SpeakAudio.navigation) |
lowered | paused, resumed after | lowered; see below |
pauseSpoken |
plays on at full volume | paused, resumed after | lowered; see below |
pause (SpeakAudio.exclusive) |
paused, resumed after | paused, resumed after | paused, resumed after (AUDIOFOCUS_GAIN_TRANSIENT) |
On iOS the options are mixWithOthers, plus duckOthers for the modes that
lower, plus interruptSpokenAudioAndMixWithOthers for those that pause
spoken audio; pause sets no mixing option. Android has no "spoken audio"
category to pause: an app can only ask for focus that may duck. A podcast
player that prefers pausing to being lowered pauses itself
(setWillPauseWhenDucked), and nothing can make another app do so; the
rest are lowered. On macOS other apps are never affected. A web page cannot
touch other audio at all.
AudioUsage |
iOS mode | Android usage |
|---|---|---|
navigation |
voicePrompt |
USAGE_ASSISTANCE_NAVIGATION_GUIDANCE |
prompt (default) |
voicePrompt |
USAGE_ASSISTANT |
spokenContent |
spokenAudio |
USAGE_MEDIA |
On iOS speech uses the playback category, so it is heard with the
Ring/Silent switch on silent. On Android the attributes are always
CONTENT_TYPE_SPEECH.
Lead-in and linger: Bluetooth without lost syllables
A Bluetooth headset or car stereo mutes its stream while nothing plays and
takes a moment to open it again, so speech that starts at once loses its
first syllables. leadIn (350 ms by default) plays silence first, but
only where it helps, and only before the first utterance after the audio
is claimed.
Where: with leadInOnBluetoothOnly (on by default), only when the audio
goes to Bluetooth or a car. Right after claiming the audio, speak reads
the route:
| Lead-in when the output is | |
|---|---|
| iOS | Bluetooth A2DP, HFP or LE, or car audio (CarPlay) in the session's current route |
| Android 13+ | Bluetooth A2DP, SCO, LE headset or speaker, or a hearing aid, among the devices the speech's audio attributes route to |
| Android 7 to 12 | any such output connected (there is no asking where a stream would go) |
| macOS | never: there is no session to ask, so it counts as no Bluetooth |
| Web | never: there is no lead-in |
Through the phone's speaker or wired headphones speech starts at once.
Set leadInOnBluetoothOnly: false to play the lead-in everywhere. If the
route cannot be read, the lead-in is played: a short wait is better than a
lost word.
When: only before the first utterance after the audio is claimed: utterances
queued behind it, or arriving within linger, follow straight on. linger
(1.5 s by default) keeps the audio claimed after the last utterance, so
prompts close together are not each preceded by a lead-in, and music is not
raised and lowered again in between. stop(), pause() and dispose()
give the audio back at once. On iOS and macOS the lead-in is real silent
samples played through the session; on Android it is
playSilentUtterance; the web has none. It is not counted against an
utterance's timeout.
This is the recommended setup for navigation: the radio path is only open while speaking, plus a second and a half.
Holding the audio
await speak.holdAudio() claims the audio at once and keeps it until
releaseAudio(), however long the gaps. It costs battery: the audio path,
and on Bluetooth the A2DP stream, stays active the whole time, and other
audio stays lowered or paused the whole time. Use it only for a short
stretch of continuous prompts; the lead-in and a short linger cover the
rest.
Calls and other interruptions #
When a phone call, Siri, an alarm or another app takes the audio:
- what is being said is cut off and ends as
SpeakOutcome.interrupted, and so does anything waiting; - an utterance asked for while a call is going on (on Android also while
another app holds the focus for now) ends at once as
SpeakOutcome.blocked. Nothing is kept for after the call: an instruction from during it is out of date by then; - otherwise the next utterance claims the audio afresh, with the usual lead-in.
Only what the platform says at that moment blocks an utterance, never a remembered interruption: iOS does not promise an end for every beginning, and a flag left set would keep the rest of a ride silent.
| Interruption begins | Ends | Call in progress | |
|---|---|---|---|
| iOS | AVAudioSessionInterruptionNotification (began); from iOS 27 also DidBecomeInactive with an interruption context, reported once |
the notification's ended, or ResumptionRecommendation |
CallKit's CXCallObserver (phone calls and CallKit VoIP calls, no permission), asked before every utterance since the session is inactive between prompts; CallKit is loaded at run time, not linked, and never with IosSpeakOptions(callKit: false) (China storefront), when only the interruption notification catches a call |
| Android | our audio focus lost for now (AUDIOFOCUS_LOSS_TRANSIENT) |
the focus back (AUDIOFOCUS_GAIN) |
any audio mode but MODE_NORMAL (ringing, in a call, in a VoIP call), or our focus still taken |
| macOS, web | not reported |
On Android a permanent focus loss (another app starting music) cuts off
what is being said but blocks nothing after it. A short duck request from
another app (AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK, a notification sound or
another app's prompt) is no interruption: the system lowers the speech and
it carries on. With OtherAudio.mix no focus is requested, so Android
reports no interruptions; the call-mode check still applies.
A claim refused for any other reason does not block. Android 15 refuses
audio focus to an app that is in the background without a foreground
service (AUDIOFOCUS_REQUEST_FAILED; on the API 35 emulator only after a
grace of a few seconds, between 3 and 6 s after leaving the front); then,
with no call going on, the text is spoken without focus, as with
OtherAudio.mix, and other audio is not lowered for it. A navigation app that speaks with the screen off keeps
its focus by running a foreground service, as it does for its location
updates anyway. On iOS a session that cannot be activated (from the
background without the audio background mode, for instance) is tried
without it as well; whether a call is going on comes from CallKit, never
from the activation error.
SpeakAudio(duringCalls: SpeakDuringCalls.speak) speaks anyway: the call
is not asked about and a refused claim does not block. An interruption
still cuts off what it interrupted. On iOS the system decides whether the
speech is heard during a call.
Running in the background #
speak claims the audio only while it speaks, so it keeps nothing running
between prompts. Whether the app is still there to speak the next one is up
to the app.
- iOS: with the
audiobackground mode, speech is heard while the app is in the background or the screen is locked. But between prompts the session is given back, and an app that plays nothing is suspended a few seconds after it leaves the screen. Keep the app running with its own reason to (background location updates for navigation, a workout session), or use notifications for what it has to say while suspended. - Android: speaking works as long as the process lives. For a long
stretch in the background the app needs its own foreground service (type
locationfor navigation,mediaPlaybackfor reading). From Android 15 an app in the background without one gets no audio focus:speakthen speaks without it, so other audio is not lowered for the prompt. - macOS keeps running apps running; the web depends on the browser, which may suspend a tab in the background.
iOS: CallKit and the China storefront #
App Review rejects apps that contain CallKit on the China storefront. The
package never links it: tool/check_no_callkit.sh holds every build to
that, in CI. It loads the system framework at run time, on the first
utterance, only to ask whether a call is going on.
final speak = Speak(ios: const IosSpeakOptions(callKit: false));
With callKit: false it is never loaded by the package. Calls are then
caught only while speak holds the audio session (the interruption
notification), so a prompt due during a call that started between two
prompts may be spoken over it. (Once something is spoken, iOS's own speech
synthesis loads CallKit into the process itself: that is Apple's framework,
not part of the app's binary.)
Android: choose the engine #
final engines = await Speak.androidEngines(); // the user's default first
final speak = Speak(android: AndroidSpeakOptions(engine: engines.last.name));
Speak.androidEngines() returns an empty list on every other platform.
Errors #
Interruptions are outcomes, not errors. Failures complete the call with a
SpeakException whose error says what went wrong:
languageUnavailable, voiceUnavailable, engineUnavailable, notAllowed
(web, see below), timeout or synthesisFailed. A failed utterance never
blocks the ones queued after it. On Windows and Linux every call throws
UnsupportedError.
Coming from flutter_tts #
Most calls map one to one. The main difference: every speak(...) is
awaitable and tells you how it ended, so the completion, cancel and error
handlers go away.
| flutter_tts | speak |
|---|---|
final tts = FlutterTts(); |
final speak = Speak(); |
await tts.awaitSpeakCompletion(true); then await tts.speak(text) |
await speak(text), which returns a SpeakOutcome |
setCompletionHandler, setCancelHandler |
the outcome: completed, interrupted, expired |
setErrorHandler |
try / on SpeakException catch (e), with e.error |
setQueueMode(1) / the default flush |
queue: SpeakQueue.append (the default) / SpeakQueue.interrupt |
stop(), pause() |
stop(), pause() and resume() (also on Android) |
setLanguage('de-DE') |
speak.language = 'de-DE', or language: per call |
getVoices (a list of maps) |
voices({language}), a List<Voice>, best first |
setVoice({'name': ..., 'locale': ...}) |
speak.voice = voice, or voice: per call |
isLanguageAvailable, isLanguageInstalled |
availability(language) |
setSpeechRate, setPitch, setVolume |
rate, pitch, volume, as defaults or per call |
setProgressHandler |
onProgress: per call |
setIosAudioCategory, setSharedInstance, autoStopSharedSession, setAudioAttributesForNavigation |
Speak(audio: SpeakAudio.navigation), or another SpeakAudio |
getEngines, setEngine (Android) |
Speak.androidEngines(), Speak(android: AndroidSpeakOptions(engine: ...)) |
synthesizeToFile, Windows |
not supported |
Two things to watch when switching:
- Rate. flutter_tts passes each platform's own scale through (on iOS, 0.5 is normal speed). In speak, 1 is normal on every platform, so an iOS rate of 0.5 becomes 1.
- Audio session. Remove any code of your own that activates or deactivates the iOS audio session around speech: speak claims it right before speaking and gives it back afterwards.
Platform differences #
| iOS / macOS | Android | Web | |
|---|---|---|---|
| Word progress | every word | onRangeStart: Android 8+, and only from engines that report it |
boundary events where the browser sends them; when a browser gives only where a word starts, speak finds its end |
| Pause | native, at the end of a word | stops, and resumes from the start of the last word reported (from the beginning when none was) | native; in Chrome on Android pause() acts as a stop |
| Voice quality | compact = normal, enhanced, premium; novelty and Eloquence voices are low |
from the engine's quality grade | unknown |
| Online voices | none | requiresNetwork from the engine |
requiresNetwork for non-local voices |
| Voices not installed | not listed | listed, isInstalled: false |
not listed |
| Voice list right after start | as soon as the system answers (asked off the main thread); an utterance waits at most 1 s for it | voices() waits up to 10 s after the engine starts while it is still loading its voices; an utterance waits at most 1 s for the list, then is spoken in its language (while loading, in the engine's current voice) |
voices() waits up to 2 s for voiceschanged; an utterance at most 1 s |
| Gender | reported | unknown |
unknown |
| Other audio | lowered/paused (iOS); macOS has no session | lowered/paused through audio focus (API 26+ with attributes, older with the music stream) | not possible |
| Lead-in (on Bluetooth by default) | silent samples through the session; on macOS only with leadInOnBluetoothOnly: false |
playSilentUtterance |
none |
<, > and & in the text |
said as written; macOS and iOS before 27 read markup in plain text, so speak escapes them there |
said as written; a text that starts with < would be read as SSML by Google's engine, so speak sends it as an SSML document of its words, escaped |
said as written; on macOS escaped as there, except for a server voice; Safari before iOS 27 still loses words after a <; Chromium on Windows reads < and > as spaces |
rate is relative everywhere: 1 is the platform's normal speed. On iOS and
macOS it scales AVFoundation's default utterance rate, capped at its maximum
(about twice the normal speed).
Known issues #
- macOS: sandboxed debug builds under
flutter run/flutter test. The system voices are rendered by a speech audio unit that macOS runs as an app extension for the app. An app started by the flutter tool (or from a shell) is not launched through LaunchServices and has no app identity to host it with; AudioToolbox then tries to load the unit inside the app, which the App Sandbox refuses. Every utterance ends at once asSpeakException(synthesisFailed)(the log showsaudioUnitInstantiationError, OSStatus -3000). An app opened from the Finder, the Dock or withopenis not affected, sandboxed or not, and neither are release builds. For development, turn the sandbox off in the debug entitlements only, as the example does: inmacos/Runner/DebugProfile.entitlements,com.apple.security.app-sandboxfalse; keepRelease.entitlementssandboxed. - Web: a user gesture first. Browsers refuse to speak until the user has
interacted with the page. Before that, a call fails with
SpeakError.notAllowed. - Web: word progress depends on the browser's voice.
onProgressis only called for theboundaryevents the browser sends. Chrome on Linux sends none, so there the callback is never called (the text is still spoken, and pause still works). - Web: Chrome's online voices may stop after about 15 seconds of continuous speech. Short prompts are not affected; split long text into sentences.
- iOS Simulator has only compact voices installed.
How it works #
There is no plugin code of the usual kind: no method channel and no Swift, Kotlin or Objective-C to maintain. What does ship natively:
- iOS and macOS: the Dart bindings to AVFAudio are generated by
ffigen. For the synthesizer's delegate,ffigenalso generates a small Objective-C trampoline file (darwin/av_bindings.m). A build hook (hook/build.dart) compiles it for you during the app's build, aspackage:objective_cdoes for its own glue. - Android: the bindings to
TextToSpeechandAudioManagerare generated byjnigen. One hand-written Java file (android/src/main/java/com/orkitec/speak/SpeakPlugin.java) subclassesUtteranceProgressListener. Dart can implement Java interfaces but not extend abstract classes, so this class forwards each callback to an interface that Dart implements. Gradle compiles it with your app. - Web:
package:web, behind conditional imports, sodart:ffiand JNI never reach a web build.
Design notes #
Speakis an instance, not a static API. A speaker owns state: its queue, its engine (on Android, a boundTextToSpeechservice, possibly a non-default engine), its audio configuration and its defaults. Two features can each have their own speaker. Facts about the platform, which are the same for every speaker, are static:Speak.isSupportedandSpeak.androidEngines().- Calling the speaker speaks.
speak('text')reads as what it does, and everything else is a named method on the same object. - Outcomes are values, failures are exceptions. Being cut off by
stop()or by an urgent prompt is normal flow in a navigation app, and should not need atry. An engine that fails is not normal flow, so it throws aSpeakException. - One utterance on the engine at a time.
speakkeeps the queue itself and gives the engine the next utterance only when the previous one has ended. Interrupting, expiring and pausing then work the same on every platform. - "Interrupted" is decided in Dart, from the speaker's own
stop()or interrupting call, never from which callback the platform sends. After a stop, macOS reports "finished", not "cancelled". - Nothing waits forever. Each utterance has a timeout scaled to its
length and rate (10 s plus 150 ms per character at normal speed).
stop()gives the engine one second to confirm, then ends the utterance anyway. - The audio is held only while speaking, plus a short linger, unless
you ask for more with
holdAudio(). Whether a lead-in is due is decided in Dart from when the audio was claimed, so it is the same on every platform.
Testing #
flutter test # the core, against a fake engine
tool/web_test.sh # the same, plus the real engine, in Chrome
tool/web_test.sh --wasm # ... compiled to Wasm
DEVICE=<emulator> tool/android_integration_test.sh # Android
UDID=<simulator> tool/ios_integration_test.sh # iOS, in two app launches
tool/macos_integration_test.sh # macOS, in one
The scripts speak at volume 0 (the engines still synthesise and report as
at full volume); pass --loud first to hear them. CI runs at full volume.
SPEAK_TEST_TRACE=1 prints every scenario's engine trace, passed or not.
The device and browser tests run the same scenarios
(test/support/scenarios.dart) against each platform's real engine. A
scenario that fails prints what the engine was told and reported, with
times; one whose engine stalled or never answered is run once more, after a
GitHub ::warning::, and a wrong answer is never retried. To
regenerate the bindings, run tool/generate.sh (Xcode for ffigen; a JDK and
ANDROID_SDK_ROOT with platform 36 for jnigen).
Releasing #
Tagged versions are published by GitHub Actions; see RELEASING.md.
Contributing #
Issues and pull requests are welcome at github.com/orkitec/speak. See CONTRIBUTING.md.
License #
BSD-3-Clause. See LICENSE.
Built for Velorki, a free bike GPS planner and tracker: velorki.com