How to Configure the Web Speech API for Text-to-Speech Synthesis in a Vue Application

Use the native browser window.speechSynthesis API wrapped in a reactive Vue service, expose it globally via provide/inject, and persist user voice preferences in your store.

The TypeWords project demonstrates a clean pattern for adding speech synthesis to Vue 3 applications. By wrapping the Web Speech API in a reusable helper and integrating it with the existing settings architecture, you can enable text-to-speech functionality without heavy dependencies. This guide walks through the exact implementation used in the zyronon/TypeWords codebase.

Creating a Speech Synthesis Service

The foundation is a thin abstraction around SpeechSynthesisUtterance that handles voice selection, rate/pitch controls, and the asynchronous nature of browser voice loading.

The TTS Helper Module

Create app/utils/tts.js with reactive state and utility functions:

// app/utils/tts.js
import { ref, computed } from 'vue'

const currentVoice = ref(null)
const rate = ref(1)
const pitch = ref(1)

export function getVoices() {
  return new Promise(resolve => {
    const synth = window.speechSynthesis
    const voices = synth.getVoices()
    if (voices.length) return resolve(voices)
    // Chrome loads voices asynchronously
    synth.onvoiceschanged = () => resolve(synth.getVoices())
  })
}

export function setVoiceByName(name) {
  getVoices().then(voices => {
    const v = voices.find(v => v.name === name)
    if (v) currentVoice.value = v
  })
}

export function speak(text, options = {}) {
  if (!window.speechSynthesis) return
  const utter = new SpeechSynthesisUtterance(text)
  utter.voice = options.voice || currentVoice.value
  utter.rate = options.rate ?? rate.value
  utter.pitch = options.pitch ?? pitch.value
  window.speechSynthesis.speak(utter)
  return utter
}

export function cancel() {
  window.speechSynthesis.cancel()
}

export const tts = {
  currentVoice,
  rate,
  pitch,
  getVoices,
  setVoiceByName,
  speak,
  cancel,
}

Key design decisions in this module:

  • Reactive exports – currentVoice, rate, and pitch are Vue refs, enabling automatic UI updates
  • Async voice loading – handles both synchronous and asynchronous getVoices() behavior across browsers
  • Cancellable utterances – exposes cancel() for immediate speech interruption

Registering TTS Globally in Vue

The TypeWords application structure uses app/app.vue as the root component. This is the ideal location to expose the service application-wide.

Using Provide/Inject (Composition API)

<script setup>
import { provide } from 'vue'
import { tts } from '@/utils/tts.js'

provide('$tts', tts)
</script>

<template>
  <NuxtPage />
</template>

Using Global Properties (Alternative)

For Options API compatibility or migration scenarios:

// Inside app.vue or main entry
export default {
  beforeCreate() {
    this.appContext.config.globalProperties.$tts = tts
  }
}

Either pattern makes $tts accessible throughout the component tree without repeated imports.

Building Voice Selection UI in Settings

The existing settings page at app/pages/setting.vue follows TypeWords' conventions for user-configurable options. Extend it with voice controls:

<template>
  <div class="tts-settings">
    <label>Voice
      <select v-model="selectedVoiceName" @change="onVoiceChange">
        <option v-for="v in voices" :key="v.name" :value="v.name">
          {{ v.name }}
        </option>
      </select>
    </label>

    <label>Rate
      <input 
        type="range" 
        min="0.5" 
        max="2" 
        step="0.1" 
        v-model.number="rate" 
        @input="onRateChange"
      />
      {{ rate }}×
    </label>

    <label>Pitch
      <input 
        type="range" 
        min="0" 
        max="2" 
        step="0.1" 
        v-model.number="pitch" 
        @input="onPitchChange"
      />
      {{ pitch }}
    </label>

    <button @click="preview">Preview Voice</button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { getVoices, setVoiceByName, speak, cancel, tts } from '@/utils/tts.js'
import { useStore } from '@/store'

const store = useStore()
const voices = ref([])
const selectedVoiceName = ref(store.ttsVoice || '')
const rate = ref(store.ttsRate ?? 1)
const pitch = ref(store.ttsPitch ?? 1)

onMounted(async () => {
  voices.value = await getVoices()
  if (!selectedVoiceName.value && voices.value.length) {
    selectedVoiceName.value = voices.value[0].name
    setVoiceByName(selectedVoiceName.value)
  }
})

function onVoiceChange() {
  setVoiceByName(selectedVoiceName.value)
  store.ttsVoice = selectedVoiceName.value
}

function onRateChange() {
  tts.rate.value = store.ttsRate = rate.value
}

function onPitchChange() {
  tts.pitch.value = store.ttsPitch = pitch.value
}

function preview() {
  cancel()
  speak('This is a preview of the selected voice.', {
    voice: tts.currentVoice.value,
    rate: tts.rate.value,
    pitch: tts.pitch.value,
  })
}
</script>

The UI pattern mirrors existing components like VolumeSettingMiniDialog.vue, ensuring visual consistency.

Persisting User Preferences

Store configuration in app/store/index.ts using Pinia with persistence:

// app/store/index.ts
import { defineStore } from 'pinia'

export const useSettingsStore = defineStore('settings', {
  state: () => ({
    ttsVoice: '',
    ttsRate: 1,
    ttsPitch: 1,
  }),
  persist: true, // Survives page reloads
})

This integration ensures speech settings survive browser sessions without additional localStorage handling.

Triggering Speech in Components

The core typing component WordTypingCore.vue demonstrates practical usage. Inject the service and call speak() on relevant events:

<script setup>
import { inject } from 'vue'

const tts = inject('$tts')

function onCorrectWord(word) {
  // Existing scoring logic...
  tts.speak(word) // Audible confirmation
}
</script>

For Options API components, access via this.$tts:

methods: {
  handleWordSuccess(word) {
    this.$tts.speak(word)
  }
}

Browser Compatibility and Edge Cases

The Web Speech API implementation varies across browsers:

Browser Voice Loading Default Voice
Chrome Asynchronous (onvoiceschanged) System default
Firefox Synchronous First available
Safari Asynchronous System default

The getVoices() Promise abstraction in tts.js normalizes these differences. Always check window.speechSynthesis availability before calling methods, as the API is absent in some environments.

Summary

  • Create app/utils/tts.js – Wrap SpeechSynthesisUtterance with reactive Vue refs for voice, rate, and pitch
  • Register in app/app.vue – Use provide('$tts', tts) or globalProperties for application-wide access
  • Extend app/pages/setting.vue – Add voice dropdown, rate/pitch sliders, and preview button
  • Persist in app/store/index.ts – Store selections in Pinia with persist: true
  • Consume in components – Call tts.speak(text) from typing handlers like WordTypingCore.vue

This architecture keeps speech logic decoupled from UI components while maintaining reactive bindings for settings synchronization.

Frequently Asked Questions

How do I handle browsers without Web Speech API support?

Check for window.speechSynthesis before initializing. In tts.js, the speak() function returns early if the API is unavailable. For production applications, consider feature detection and graceful degradation:

const hasTTS = 'speechSynthesis' in window
// Conditionally render TTS controls in UI

Why are voices empty on first load in Chrome?

Chrome loads voices asynchronously. The getVoices() function in tts.js waits for the onvoiceschanged event before resolving. Always use the Promise-based helper rather than calling getVoices() directly.

Can I change speech parameters mid-utterance?

No. The SpeechSynthesisUtterance object captures rate, pitch, and voice at construction time. To change parameters, call cancel() and create a new utterance. The tts.js helper handles this automatically on each speak() call.

How do I queue multiple utterances instead of canceling?

Remove or modify the cancel() call in speak(). By default, the browser queues utterances automatically. The current implementation cancels existing speech for immediate feedback in typing scenarios, but you can adjust this behavior per use case.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →