DEV Community

Cover image for Vue.js Composables for Speech-to-Speech Translation: Building Reactive Real-Time Audio Applications
alfchee
alfchee

Posted on

Vue.js Composables for Speech-to-Speech Translation: Building Reactive Real-Time Audio Applications

Introduction: Why Vue.js Composables Changed Everything

When I first set out to build a real-time speech-to-speech translation service frontend, I had already implemented a React version using hooks. "How different could Vue be?" I thought. Turns out, Vue's Composition API offered a refreshingly elegant approach to managing WebSocket connections, audio streams, and reactive state that made me rethink how I structure real-time applications.

In this article, I'll share the lessons I learned building Vue.js composables for real-time speech services, the challenges I faced, and the patterns that emerged from managing reactive WebSocket connections and audio playback. Whether you're coming from React, Angular, or are a Vue veteran, there's something here for everyone building real-time audio applications.

The Challenge: Real-Time Audio Streaming in the Browser

Before we dive into the code, let's understand what we're building:

  1. WebSocket Connection Management: Establish and maintain a persistent connection to our FastAPI backend
  2. Audio Capture: Access the user's microphone and stream audio chunks
  3. Reactive State: Keep the UI in sync with connection status, transcription results, and errors
  4. Lifecycle Management: Properly clean up resources when components unmount
  5. Error Handling: Gracefully handle network failures, permission denials, and API errors

The beauty of Vue's Composition API is that we can encapsulate each of these concerns into reusable, testable composables.

Lesson 1: Composables Are More Than React Hooks with Different Syntax

Coming from React, I initially thought composables were just hooks with a different name. I was wrong. Here's what makes them different:

Vue Composables Have Direct Reactive References

// React Hook - needs useState
const [isConnected, setIsConnected] = useState(false);

// Vue Composable - direct reactive reference
const isConnected = ref(false);
Enter fullscreen mode Exit fullscreen mode

This might seem like a small difference, but it has profound implications:

  • No dependency arrays: Vue's reactivity system tracks dependencies automatically
  • No stale closures: You're always working with the current value
  • Simpler mental model: Just mutate the .value property

Lifecycle is More Explicit

// React needs useEffect with cleanup
useEffect(() => {
  // Setup
  return () => {
    // Cleanup
  };
}, []);

// Vue has dedicated lifecycle hooks
onUnmounted(() => {
  // Cleanup
});
Enter fullscreen mode Exit fullscreen mode

Lesson 2: Building the WebSocket Composable

Let me show you the core useTranscription composable I built. This handles all WebSocket communication with our FastAPI backend.

The Basic Structure

// composables/useTranscription.ts
import { ref, onUnmounted } from 'vue';
import { v4 as uuidv4 } from 'uuid';

export function useTranscription(apiUrl = 'ws://localhost:8000') {
  // Reactive state
  const ws = ref<WebSocket | null>(null);
  const sessionId = ref<string>('');
  const isConnected = ref(false);
  const isTranscribing = ref(false);
  const partialTranscript = ref('');
  const finalTranscript = ref('');
  const error = ref<string | null>(null);

  // Methods will go here...

  // Cleanup on unmount
  onUnmounted(() => {
    disconnect();
  });

  return {
    // State
    isConnected,
    isTranscribing,
    partialTranscript,
    finalTranscript,
    error,
    sessionId,

    // Methods
    connect,
    startTranscription,
    sendAudioChunk,
    stopTranscription,
    disconnect,
  };
}
Enter fullscreen mode Exit fullscreen mode

Key Design Decision: Promise-Based Connection

One mistake I made initially was making the connection synchronous. When the UI tried to start recording immediately after connecting, the WebSocket wasn't ready. Here's how I solved it:

const connect = async (): Promise<void> => {
  return new Promise((resolve, reject) => {
    try {
      sessionId.value = uuidv4();
      const url = `${apiUrl}/transcribe/${sessionId.value}`;

      ws.value = new WebSocket(url);

      ws.value.onopen = () => {
        console.log('WebSocket connected');
      };

      ws.value.onmessage = (event) => {
        const message = JSON.parse(event.data);

        if (message.event === 'connected') {
          isConnected.value = true;
          resolve(); // βœ… Resolve when truly ready
        }
        // Handle other messages...
      };

      ws.value.onerror = (err) => {
        error.value = 'WebSocket connection error';
        reject(err);
      };

      // Timeout after 10 seconds
      setTimeout(() => {
        if (!isConnected.value) {
          reject(new Error('Connection timeout'));
        }
      }, 10000);
    } catch (err) {
      reject(err);
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

Why this matters: The UI can now reliably wait for the connection:

// In your component
const handleConnect = async () => {
  try {
    await connect(); // βœ… Waits for actual connection
    await startTranscription(); // βœ… Safe to start now
  } catch (err) {
    console.error('Connection failed:', err);
  }
};
Enter fullscreen mode Exit fullscreen mode

Lesson 3: Reactive Message Handling

The beauty of Vue's reactivity shines when handling WebSocket messages. Here's how I structured message handling:

const handleTranscript = (message: TranscriptResult) => {
  if (message.type === 'partial') {
    partialTranscript.value = message.transcript || '';
  } else if (message.type === 'final') {
    finalTranscript.value += (message.transcript || '') + ' ';
    partialTranscript.value = ''; // Clear partial
  }
};
Enter fullscreen mode Exit fullscreen mode

In the template, this just works:

<template>
  <div class="transcripts">
    <!-- Updates automatically when partialTranscript changes -->
    <p class="partial">{{ partialTranscript }}</p>

    <!-- Updates automatically when finalTranscript changes -->
    <p class="final">{{ finalTranscript }}</p>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

No manual updates, no forceUpdate, no setState callbacks. Vue's reactivity system handles everything.

Lesson 4: Audio Capture Composable

The second major composable handles microphone access. Here's where browser APIs meet Vue's reactivity:

// composables/useAudioCapture.ts
import { ref, onUnmounted } from 'vue';

export function useAudioCapture() {
  const isRecording = ref(false);
  const mediaRecorder = ref<MediaRecorder | null>(null);
  const audioStream = ref<MediaStream | null>(null);

  const startRecording = async (
    onAudioData: (data: Blob) => void
  ): Promise<void> => {
    try {
      // Request microphone access
      audioStream.value = await navigator.mediaDevices.getUserMedia({
        audio: {
          channelCount: 1, // Mono
          sampleRate: 16000, // 16kHz for speech
          echoCancellation: true,
          noiseSuppression: true,
        },
      });

      mediaRecorder.value = new MediaRecorder(audioStream.value, {
        mimeType: 'audio/webm',
        audioBitsPerSecond: 16000,
      });

      // Stream audio chunks
      mediaRecorder.value.ondataavailable = (event) => {
        if (event.data.size > 0) {
          onAudioData(event.data); // Callback to send via WebSocket
        }
      };

      // Capture in 100ms chunks for real-time streaming
      mediaRecorder.value.start(100);
      isRecording.value = true;
    } catch (err) {
      console.error('Microphone access denied:', err);
      throw err;
    }
  };

  const stopRecording = () => {
    if (mediaRecorder.value && isRecording.value) {
      mediaRecorder.value.stop();
      isRecording.value = false;
    }

    // Important: Stop all tracks to release microphone
    if (audioStream.value) {
      audioStream.value.getTracks().forEach((track) => track.stop());
      audioStream.value = null;
    }
  };

  // Automatic cleanup
  onUnmounted(() => {
    stopRecording();
  });

  return {
    isRecording,
    startRecording,
    stopRecording,
  };
}
Enter fullscreen mode Exit fullscreen mode

Critical Lesson: Resource Cleanup

I learned this the hard way: failing to stop media tracks keeps the microphone active even after the component unmounts. Users see that scary "microphone is active" indicator in their browser, and they panic.

Always stop media tracks:

audioStream.value.getTracks().forEach((track) => track.stop());
Enter fullscreen mode Exit fullscreen mode

Lesson 5: Bringing It Together in a Component

Here's how beautifully these composables work together:

<template>
  <div class="transcription-app">
    <div class="status">
      <span :class="{ active: isConnected }">
        {{ isConnected ? '🟒 Connected' : 'πŸ”΄ Disconnected' }}
      </span>
      <span :class="{ active: isRecording }">
        {{ isRecording ? '🎀 Recording' : '⏸️ Stopped' }}
      </span>
    </div>

    <div class="controls">
      <button @click="handleStart" :disabled="isRecording">
        Start Recording
      </button>
      <button @click="handleStop" :disabled="!isRecording">
        Stop Recording
      </button>
    </div>

    <div v-if="error" class="error">⚠️ {{ error }}</div>

    <div class="transcripts">
      <p v-if="partialTranscript" class="partial">
        {{ partialTranscript }}
      </p>
      <p class="final">{{ finalTranscript }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { useTranscription } from '../composables/useTranscription';
import { useAudioCapture } from '../composables/useAudioCapture';

// Composables
const {
  isConnected,
  isTranscribing,
  partialTranscript,
  finalTranscript,
  error,
  connect,
  startTranscription,
  sendAudioChunk,
  stopTranscription,
} = useTranscription('ws://localhost:8000');

const { isRecording, startRecording, stopRecording } = useAudioCapture();

// Auto-connect on mount
onMounted(async () => {
  try {
    await connect();
  } catch (err) {
    console.error('Failed to connect:', err);
  }
});

// Start recording and transcription
const handleStart = async () => {
  try {
    await startTranscription({
      language_code: 'en-US',
      enable_automatic_punctuation: true,
    });

    await startRecording((audioBlob) => {
      sendAudioChunk(audioBlob);
    });
  } catch (err) {
    console.error('Failed to start:', err);
  }
};

// Stop everything
const handleStop = async () => {
  stopRecording();
  await stopTranscription();
};
</script>

<style scoped>
.status span.active {
  color: #00ff00;
}

.partial {
  color: #888;
  font-style: italic;
}

.final {
  color: #000;
  font-weight: bold;
}

.error {
  color: red;
  padding: 10px;
  background: #ffeeee;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Lesson 6: Composition API vs Options API

You might be wondering: "Why use the Composition API instead of the Options API?"

Here's what I discovered:

Composition API Wins:

  • Better code organization: Related logic stays together
  • Easier to test: Composables are just functions
  • TypeScript support: Inference works beautifully
  • Reusability: Share logic across components effortlessly

Options API was Better For:

  • Small, simple components: Sometimes data() and methods are clearer
  • Learning Vue: The structure is more explicit
  • Team familiarity: If your team knows Options API well

For real-time applications with complex state management, the Composition API is the clear winner.

Lesson 7: Error Handling and Edge Cases

Real-world applications need robust error handling. Here are the edge cases I encountered:

1. User Denies Microphone Permission

const handleStart = async () => {
  try {
    await startRecording(onAudioData);
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      error.value = 'Microphone permission denied. Please allow access.';
    } else {
      error.value = 'Failed to start recording';
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

2. WebSocket Connection Lost

ws.value.onclose = (event) => {
  isConnected.value = false;
  isTranscribing.value = false;

  if (!event.wasClean) {
    error.value = 'Connection lost. Please refresh and try again.';
  }
};
Enter fullscreen mode Exit fullscreen mode

3. Browser Compatibility

const checkBrowserSupport = () => {
  if (!('MediaRecorder' in window)) {
    throw new Error('MediaRecorder API not supported');
  }

  if (!('WebSocket' in window)) {
    throw new Error('WebSocket API not supported');
  }
};
Enter fullscreen mode Exit fullscreen mode

Lesson 8: Performance Optimization

Real-time applications demand excellent performance. Here's what worked:

1. Debounce UI Updates for Partial Transcripts

import { ref, watch } from 'vue';
import { debounce } from 'lodash-es';

const debouncedPartial = ref('');

watch(partialTranscript, debounce((newValue) => {
  debouncedPartial.value = newValue;
}, 50));
Enter fullscreen mode Exit fullscreen mode

2. Use shallowRef for Large Objects

import { shallowRef } from 'vue';

// Don't make every property reactive
const allTranscripts = shallowRef<TranscriptResult[]>([]);
Enter fullscreen mode Exit fullscreen mode

3. Lazy Load Audio Components

<script setup lang="ts">
import { defineAsyncComponent } from 'vue';

const AudioVisualizer = defineAsyncComponent(
  () => import('./AudioVisualizer.vue')
);
</script>
Enter fullscreen mode Exit fullscreen mode

Lesson 9: Testing Composables

The Composition API makes testing a joy. Here's how I test the WebSocket composable:

import { describe, it, expect, vi } from 'vitest';
import { useTranscription } from '../useTranscription';

describe('useTranscription', () => {
  it('should connect to WebSocket', async () => {
    const { connect, isConnected } = useTranscription();

    await connect();

    expect(isConnected.value).toBe(true);
  });

  it('should handle transcript messages', async () => {
    const { connect, partialTranscript } = useTranscription();

    await connect();

    // Simulate WebSocket message
    const message = {
      event: 'transcript',
      type: 'partial',
      transcript: 'Hello world',
    };

    // Emit message...

    expect(partialTranscript.value).toBe('Hello world');
  });
});
Enter fullscreen mode Exit fullscreen mode

Lesson 10: What I'd Do Differently

Looking back, here's what I'd change:

1. Add Retry Logic Earlier

Instead of failing on connection errors, implement exponential backoff:

const connectWithRetry = async (maxRetries = 3) => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      await connect();
      return;
    } catch (err) {
      if (i === maxRetries - 1) throw err;
      await new Promise(resolve => 
        setTimeout(resolve, Math.pow(2, i) * 1000)
      );
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

2. Use TypeScript from Day One

The type safety caught so many bugs during refactoring.

3. Implement Audio Queueing

For speech-to-speech translation, I should have queued audio output to prevent overlapping playback.

4. Add Connection State Machine

Managing connection states (connecting, connected, disconnecting, disconnected, error) would have simplified the logic.

Comparison: Vue Composables vs React Hooks

Having implemented both, here's my honest comparison:

Aspect Vue Composables React Hooks
Learning Curve Gentler for beginners Steeper (dependency arrays, closures)
Reactivity Automatic, magical ✨ Manual via setState
Ref Management Explicit .value access Implicit
Lifecycle Dedicated hooks useEffect with deps
Code Reuse Natural and intuitive Requires careful hook design
TypeScript Excellent inference Good, but needs more annotations
Performance Fine-grained reactivity Virtual DOM diffing
Debugging Vue DevTools is amazing React DevTools is great

My verdict: For real-time applications, Vue's automatic reactivity saves significant mental overhead. React hooks are more explicit and give you more control, but require more boilerplate.

Real-World Performance Results

Here's what I achieved with this Vue implementation:

  • Connection establishment: ~200ms average
  • Audio chunk transmission: 100ms intervals, <10ms latency
  • UI updates: 60fps even with rapid transcript updates
  • Memory footprint: ~15MB for the entire app
  • Bundle size: 45KB (gzipped, excluding Vue)

Conclusion: The Joy of Vue Composables

Building real-time speech applications with Vue.js composables has been a revelation. The Composition API's elegant reactivity system, combined with TypeScript's type safety, creates a developer experience that's hard to beat.

Key Takeaways:

  1. Composables promote clean separation of concerns - each composable handles one responsibility
  2. Reactivity just works - no manual state updates needed
  3. Lifecycle management is explicit - onUnmounted makes cleanup obvious
  4. Promise-based patterns integrate seamlessly with async/await
  5. Testing is straightforward - composables are just functions

If you're building real-time applications in Vue, embrace the Composition API. Your future self (and your team) will thank you.

Resources

Top comments (0)