Skip to main content

Keep Text-to-Speech Aligned With an App's Language

2026-10-15

Keep Text-to-Speech Aligned With an App's Language

Text to speech localization fails when translated text is sent to the device's default voice. A French screen can speak with an English voice, or an Android device can reject a requested locale because the required voice data is missing. The visible copy is correct, but the audio is unusable. Fix this by resolving one spoken-content locale, selecting an installed voice explicitly, defining a safe fallback, and rebuilding speech state when the app language changes.

The implementation below covers the full iOS and Android lifecycle: locale ownership, voice matching, initialization, missing data, queued utterances, completion callbacks, accessible fallback, and release tests.

Define the spoken-language contract first

A speech button needs a language contract before it needs platform code. The app should know which locale the text was written for, which voice it selected, and what it will do when no acceptable voice exists.

Use this precedence unless the product has a separate, visible speech-language setting:

  1. The locale attached to the content being spoken.
  2. The app's effective language for interface copy.
  3. The device language only when the app follows the system.
  4. A documented source-language fallback, if speaking that fallback is useful and honest.

Do not infer the language from the characters in the text. Names, numbers, product codes, and mixed-script messages make language detection uncertain. Content generated by an API or CMS should carry its locale beside the text. Bundled UI copy should use the same effective locale that selected the resource.

Represent the decision explicitly:

SpeechRequest = {
  request_id,
  text,
  content_locale,
  requested_voice_id,
  app_locale_revision,
  created_at
}

ResolvedSpeech = {
  requested_locale,
  voice_locale,
  voice_id,
  match_level,
  fallback_reason
}

Keep requested_locale separate from voice_locale. A request for fr-CA may use a fr-CA voice, a product-approved fr-FR voice, or no voice. Collapsing those outcomes into one fr value hides fallback and makes failures difficult to test.

Match voices without silently changing language

Canonicalize locale tags before matching them. Both platforms expose language values that can be compared as BCP 47 tags, but spelling, case, script, and region still need normalization. Compare parsed language, script, and region fields rather than raw strings.

Use a short, documented match ladder:

  1. A saved voice identifier that still exists and supports the requested locale.
  2. An exact language, script, and region match.
  3. A product-approved same-language fallback with a compatible script.
  4. No spoken output.

If no approved match exists, do not speak. Reading Arabic text with an English voice is not a useful availability fallback. Keep the text visible, announce that spoken output is unavailable in the selected language, and leave another accessible path to the content.

A same-language regional fallback also needs product review. pt-BR and pt-PT are mutually understandable to many users, but their pronunciation and voice expectations differ. Chinese script and regional tags need tighter rules. Store the allowed fallback matrix with the locale mapping configuration rather than spreading string comparisons through screens.

Voice selection may change when the user installs a higher-quality voice or switches the default engine. Cache a selection only for a bounded session, then revalidate the saved voice identifier against the current installed set.

Configure iOS speech synthesis explicitly

Apple's AVSpeechSynthesisVoice model exposes voices by language and identifier. Create an AVSpeechUtterance, resolve a voice for the content locale, and assign it before calling speak.

import AVFAudio

struct VoiceResolution {
    let voice: AVSpeechSynthesisVoice
    let matchLevel: String
}

func resolveVoice(for tag: String) -> VoiceResolution? {
    let requested = Locale(identifier: tag)
    let voices = AVSpeechSynthesisVoice.speechVoices()

    if let exact = voices.first(where: {
        Locale(identifier: $0.language).identifier == requested.identifier
    }) {
        return VoiceResolution(voice: exact, matchLevel: "exact")
    }

    if let language = requested.language.languageCode?.identifier,
       let compatible = voices.first(where: {
           Locale(identifier: $0.language).language.languageCode?.identifier == language
       }) {
        return VoiceResolution(voice: compatible, matchLevel: "language")
    }

    return nil
}

func speak(_ text: String, localeTag: String, using synthesizer: AVSpeechSynthesizer) {
    guard let resolution = resolveVoice(for: localeTag) else {
        showSpeechUnavailable(localeTag)
        return
    }

    let utterance = AVSpeechUtterance(string: text)
    utterance.voice = resolution.voice
    synthesizer.speak(utterance)
}

Production code should apply the approved fallback matrix before accepting the language-only match. It should also prefer a saved voice identifier when that voice remains installed.

Assigning the voice is mandatory for predictable localization. Apple's AVSpeechUtterance.voice reference states that an utterance without a voice uses the system default. That default may not follow the in-app language.

Own one synthesizer at the feature or application layer instead of creating one for every button tap. Attach a delegate to record start, finish, cancel, and failure-equivalent product outcomes. Tie callbacks to request_id so completion from an old request cannot change the state of a newer screen.

When the app language changes, stop queued speech before allowing new requests. Decide whether interrupted speech ends immediately or at a word boundary, then use the same policy across screens. Increment app_locale_revision; ignore callbacks whose revision no longer matches.

Configure Android only after initialization

Android's TextToSpeech client binds to an engine asynchronously. Do not query language support or call speak until OnInitListener reports success. A practitioner question about checking availability before the engine is bound shows the concrete lifecycle error: the client tried to inspect language support before initialization completed.

Keep the engine behind a small state machine:

sealed interface TtsState {
    data object Starting : TtsState
    data class Ready(val engine: TextToSpeech) : TtsState
    data class Unavailable(val reason: String) : TtsState
}

fun resolveAndroidLocale(
    engine: TextToSpeech,
    requested: Locale
): Locale? {
    val result = engine.isLanguageAvailable(requested)

    return when (result) {
        TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE,
        TextToSpeech.LANG_COUNTRY_AVAILABLE -> requested
        TextToSpeech.LANG_AVAILABLE -> approvedLanguageFallback(requested)
        TextToSpeech.LANG_MISSING_DATA,
        TextToSpeech.LANG_NOT_SUPPORTED -> null
        else -> null
    }
}

Check the result from setLanguage as well. Availability can change between inspection and configuration, and engines do not all expose identical voice sets. If the product allows users to choose a voice, select from engine.voices, retain the stable voice name, and verify its locale before use.

LANG_MISSING_DATA and LANG_NOT_SUPPORTED need different product treatment. Missing data can sometimes be repaired through the platform's voice-data installation flow. Unsupported means the current engine cannot provide the requested language. A practitioner report about supported locales with speech data that was not downloaded shows why a language list alone is not enough to promise offline speech.

Do not open an installation screen without user action. Explain what is missing, let the user continue with visible text, and offer a button to install speech data where the platform and product support that path. Recheck availability when the app resumes.

Register an utterance progress listener and pass a unique utterance ID to speak. The callback should update only the matching request. Call stop() on a locale change and shutdown() when the owner is permanently destroyed. Leaking the engine can retain system resources and callbacks after the original screen has gone.

Handle language changes as a state transition

A locale change affects more than the next call to speak. It invalidates selected voices, queued text, pronunciation previews, cached availability, and UI labels that describe the active voice.

Use this transition sequence:

  1. Block new speech requests for the old locale.
  2. Stop or cancel queued utterances according to the product policy.
  3. Increment the locale revision and clear the resolved voice.
  4. Resolve the new effective app locale.
  5. Re-enumerate or revalidate available voices.
  6. Update voice controls and unavailable-state copy.
  7. Allow speech only after the adapter reaches ready for the new locale.

Preserve language-neutral state such as playback speed only if the selected voice supports it sensibly. Do not preserve a locale-specific voice identifier merely because it still exists.

For remote content, invalidate speech when either the app locale or the content locale changes. A French interface may intentionally speak an English article. The button label follows the interface locale; the voice follows the article locale. Recording both values prevents the app from treating that case as drift.

Design failure handling users can understand

Speech synthesis can fail because initialization failed, the requested language is unsupported, data is missing, the voice disappeared, the audio session is unavailable, or the request became stale after navigation. Map those technical outcomes to a small set of product states:

Product state User-facing behavior Recovery
Starting Disable repeat taps and show progress Wait for initialization
Ready Speak with the resolved voice Allow stop and replay
Voice data missing Keep text visible and explain the missing download Offer an explicit install action
Language unsupported Keep text visible without wrong-language audio Offer another approved language if useful
Request stale Ignore the callback Let the current screen issue a new request
Engine failed Keep the reading path usable Retry once after a deliberate user action

Never replace an unsupported voice with an unrelated default without telling the user. Never hide the written content because audio failed. Text-to-speech is an additional presentation channel, not the only route to the information.

Log requested locale, resolved locale, voice identifier, engine, match level, fallback reason, locale revision, and terminal outcome. Do not log sensitive text. These fields make wrong-language incidents reproducible without collecting the content that was spoken.

Verify the whole lifecycle

Unit tests should exercise locale resolution separately from platform speech. Feed the resolver exact matches, language-only matches, script differences, unsupported locales, removed saved voices, and an empty installed set. Assert both the chosen voice and the recorded fallback reason.

Run device tests across this matrix:

  • iOS with the app language different from the device language.
  • Android with the app language different from the engine default.
  • Exact regional voice installed and absent.
  • Android language supported with voice data missing.
  • App language changed while speech is queued.
  • Remote content whose locale differs from the interface locale.
  • Engine changed or voice installed while the app is backgrounded.
  • Offline launch and return from the voice-data installer.
  • Rapid stop, replay, navigation, and repeated language changes.

Listen to the output. A test that only checks callback success cannot detect an English voice reading French text. Include native-speaker review for launch locales and store the tested requested locale, resolved voice locale, voice identifier, and device configuration with the release result.

Add one release assertion for every supported app locale: the resolver must produce an approved voice or an intentional unavailable state. Run it against representative device inventories rather than assuming every device ships the same voices.

Start by extracting voice selection from the screen into one locale-bound adapter. Add exact matching, explicit fallback reasons, and a no-voice state before changing the UI. Then switch the app language during queued speech on one iOS device and one Android device. If the old voice continues or an unrelated default takes over, the adapter still has hidden locale state.

References