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

> Learn to configure Web Speech API text-to-speech in Vue. Use a reactive service, provide/inject, and store preferences for seamless voice synthesis.

- Repository: [Zyronon/TypeWords](https://github.com/zyronon/TypeWords)
- Tags: how-to-guide
- Published: 2026-09-03

---

**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`](https://github.com/zyronon/TypeWords/blob/main/app/utils/tts.js) with reactive state and utility functions:

```javascript
// 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`](https://github.com/zyronon/TypeWords/blob/main/app/app.vue) as the root component. This is the ideal location to expose the service application-wide.

### Using Provide/Inject (Composition API)

```vue
<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:

```javascript
// 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`](https://github.com/zyronon/TypeWords/blob/main/app/pages/setting.vue) follows TypeWords' conventions for user-configurable options. Extend it with voice controls:

```vue
<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`](https://github.com/zyronon/TypeWords/blob/main/VolumeSettingMiniDialog.vue), ensuring visual consistency.

## Persisting User Preferences

Store configuration in [`app/store/index.ts`](https://github.com/zyronon/TypeWords/blob/main/app/store/index.ts) using Pinia with persistence:

```typescript
// 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`](https://github.com/zyronon/TypeWords/blob/main/WordTypingCore.vue) demonstrates practical usage. Inject the service and call `speak()` on relevant events:

```vue
<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`:

```javascript
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`](https://github.com/zyronon/TypeWords/blob/main/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`](https://github.com/zyronon/TypeWords/blob/main/app/utils/tts.js)** – Wrap `SpeechSynthesisUtterance` with reactive Vue refs for voice, rate, and pitch
- **Register in [`app/app.vue`](https://github.com/zyronon/TypeWords/blob/main/app/app.vue)** – Use `provide('$tts', tts)` or `globalProperties` for application-wide access
- **Extend [`app/pages/setting.vue`](https://github.com/zyronon/TypeWords/blob/main/app/pages/setting.vue)** – Add voice dropdown, rate/pitch sliders, and preview button
- **Persist in [`app/store/index.ts`](https://github.com/zyronon/TypeWords/blob/main/app/store/index.ts)** – Store selections in Pinia with `persist: true`
- **Consume in components** – Call `tts.speak(text)` from typing handlers like [`WordTypingCore.vue`](https://github.com/zyronon/TypeWords/blob/main/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`](https://github.com/zyronon/TypeWords/blob/main/tts.js), the `speak()` function returns early if the API is unavailable. For production applications, consider feature detection and graceful degradation:

```javascript
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`](https://github.com/zyronon/TypeWords/blob/main/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`](https://github.com/zyronon/TypeWords/blob/main/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.