Skip to main content

Overview

The useTamboVoice hook provides browser-based audio recording with automatic transcription via the Tambo API. It handles microphone access, audio capture, and speech-to-text conversion.

How It Works

  1. User clicks a button to start recording
  2. Browser requests microphone access
  3. Audio is captured as WebM format
  4. When recording stops, audio is automatically sent to Tambo’s transcription API
  5. Transcript is returned and can be used as chat input
1
Import the Hook
2
import { useTamboVoice } from '@tambo-ai/react';
3
Create Voice Input Button
4
Basic implementation:
5
import { useTamboVoice, useTamboThreadInput } from '@tambo-ai/react';
import { Mic, MicOff } from 'lucide-react';

function VoiceInput() {
  const {
    startRecording,
    stopRecording,
    isRecording,
    isTranscribing,
    transcript,
    transcriptionError,
    mediaAccessError,
  } = useTamboVoice();
  
  const { setValue, submit } = useTamboThreadInput();
  
  const handleToggleRecording = () => {
    if (isRecording) {
      stopRecording();
    } else {
      startRecording();
    }
  };
  
  // Auto-fill input when transcript is ready
  useEffect(() => {
    if (transcript) {
      setValue(transcript);
    }
  }, [transcript, setValue]);
  
  return (
    <div>
      <button
        onClick={handleToggleRecording}
        disabled={isTranscribing}
        className="p-2 rounded-full hover:bg-gray-100"
      >
        {isRecording ? <MicOff className="w-5 h-5 text-red-500" /> : <Mic className="w-5 h-5" />}
      </button>
      
      {mediaAccessError && (
        <div className="text-red-500 text-sm mt-2">
          Microphone access denied: {mediaAccessError}
        </div>
      )}
      
      {transcriptionError && (
        <div className="text-red-500 text-sm mt-2">
          Transcription failed: {transcriptionError}
        </div>
      )}
      
      {isTranscribing && (
        <div className="text-gray-500 text-sm mt-2">
          Transcribing...
        </div>
      )}
    </div>
  );
}
6
Auto-Submit on Transcription
7
Automatically submit the transcript:
8
import { useEffect } from 'react';
import { useTamboVoice, useTamboThreadInput } from '@tambo-ai/react';

function VoiceInput() {
  const { transcript } = useTamboVoice();
  const { setValue, submit } = useTamboThreadInput();
  
  useEffect(() => {
    if (transcript) {
      setValue(transcript);
      submit(); // Auto-submit after transcription
    }
  }, [transcript, setValue, submit]);
  
  // ... rest of component
}

Return Values

Recording Controls

const {
  startRecording,  // Function to start audio recording
  stopRecording,   // Function to stop recording
  isRecording,     // Boolean: true while recording
} = useTamboVoice();

Transcription State

const {
  isTranscribing,        // Boolean: true while transcribing
  transcript,            // String: transcribed text (null until ready)
  transcriptionError,    // String: error message (null if no error)
} = useTamboVoice();

Error Handling

const {
  mediaAccessError,      // String: microphone access error (null if granted)
} = useTamboVoice();

Advanced Examples

Recording with Visual Feedback

components/voice-button.tsx
import { useTamboVoice } from '@tambo-ai/react';
import { Mic } from 'lucide-react';

function VoiceButton() {
  const {
    startRecording,
    stopRecording,
    isRecording,
    isTranscribing,
  } = useTamboVoice();
  
  return (
    <button
      onClick={isRecording ? stopRecording : startRecording}
      disabled={isTranscribing}
      className={`
        relative p-4 rounded-full transition-all duration-200
        ${
          isRecording
            ? 'bg-red-500 text-white scale-110'
            : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
        }
        disabled:opacity-50 disabled:cursor-not-allowed
      `}
    >
      <Mic className="w-6 h-6" />
      
      {isRecording && (
        <span className="absolute -top-1 -right-1 flex h-3 w-3">
          <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
          <span className="relative inline-flex rounded-full h-3 w-3 bg-red-500" />
        </span>
      )}
      
      {isTranscribing && (
        <div className="absolute inset-0 flex items-center justify-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
        </div>
      )}
    </button>
  );
}

Inline Chat Input with Voice

components/chat-input.tsx
import { useState, useEffect } from 'react';
import { useTamboVoice, useTamboThreadInput } from '@tambo-ai/react';
import { Mic, Send } from 'lucide-react';

function ChatInput() {
  const { value, setValue, submit, isPending } = useTamboThreadInput();
  const {
    startRecording,
    stopRecording,
    isRecording,
    isTranscribing,
    transcript,
  } = useTamboVoice();
  
  // Update input when transcript is ready
  useEffect(() => {
    if (transcript) {
      setValue(transcript);
    }
  }, [transcript, setValue]);
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (value.trim()) {
      await submit();
    }
  };
  
  return (
    <form onSubmit={handleSubmit} className="flex gap-2 items-center">
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder={isRecording ? 'Recording...' : isTranscribing ? 'Transcribing...' : 'Type a message...'}
        disabled={isPending || isRecording || isTranscribing}
        className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
      
      <button
        type="button"
        onClick={isRecording ? stopRecording : startRecording}
        disabled={isTranscribing || isPending}
        className={`p-2 rounded-lg ${
          isRecording ? 'bg-red-500 text-white' : 'bg-gray-100 hover:bg-gray-200'
        }`}
      >
        <Mic className="w-5 h-5" />
      </button>
      
      <button
        type="submit"
        disabled={!value.trim() || isPending || isRecording || isTranscribing}
        className="p-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        <Send className="w-5 h-5" />
      </button>
    </form>
  );
}

Recording Duration Display

import { useState, useEffect } from 'react';
import { useTamboVoice } from '@tambo-ai/react';

function VoiceRecorder() {
  const [duration, setDuration] = useState(0);
  const { isRecording, startRecording, stopRecording } = useTamboVoice();
  
  useEffect(() => {
    if (!isRecording) {
      setDuration(0);
      return;
    }
    
    const interval = setInterval(() => {
      setDuration((d) => d + 1);
    }, 1000);
    
    return () => clearInterval(interval);
  }, [isRecording]);
  
  const formatDuration = (seconds: number) => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };
  
  return (
    <div className="flex items-center gap-3">
      <button
        onClick={isRecording ? stopRecording : startRecording}
        className="p-3 rounded-full bg-red-500 text-white"
      >
        <Mic className="w-5 h-5" />
      </button>
      
      {isRecording && (
        <div className="flex items-center gap-2">
          <span className="text-red-500 font-mono">{formatDuration(duration)}</span>
          <div className="flex gap-1">
            <span className="w-1 h-4 bg-red-500 animate-pulse" />
            <span className="w-1 h-4 bg-red-500 animate-pulse" style={{ animationDelay: '0.1s' }} />
            <span className="w-1 h-4 bg-red-500 animate-pulse" style={{ animationDelay: '0.2s' }} />
          </div>
        </div>
      )}
    </div>
  );
}

Error Handling

Microphone Access Errors

function VoiceInput() {
  const { mediaAccessError, startRecording } = useTamboVoice();
  
  const handleStartRecording = () => {
    startRecording();
  };
  
  if (mediaAccessError) {
    return (
      <div className="text-red-500">
        <p>Cannot access microphone:</p>
        <p className="text-sm">{mediaAccessError}</p>
        <button onClick={handleStartRecording} className="mt-2 text-blue-500 underline">
          Try again
        </button>
      </div>
    );
  }
  
  // ... rest of component
}

Transcription Errors

function VoiceInput() {
  const { transcriptionError, startRecording } = useTamboVoice();
  
  return (
    <div>
      {transcriptionError && (
        <div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-2">
          <p className="text-red-800 font-medium">Transcription Failed</p>
          <p className="text-red-600 text-sm">{transcriptionError}</p>
          <button
            onClick={startRecording}
            className="mt-2 text-red-600 hover:text-red-800 underline text-sm"
          >
            Record again
          </button>
        </div>
      )}
      
      {/* Voice button */}
    </div>
  );
}

Best Practices

Show recording state prominently:
  • Use red color for recording
  • Add pulsing animation
  • Display recording duration
  • Show transcribing spinner
Microphone access requires user permission:
  • Explain why you need access
  • Provide clear error messages
  • Offer a retry button
  • Fall back to text input
Prevent issues during recording:
<input
  disabled={isRecording || isTranscribing}
  placeholder={isRecording ? 'Recording...' : 'Type a message'}
/>
<button type="submit" disabled={isRecording || isTranscribing}>
  Send
</button>
The hook automatically resets state when starting a new recording. You don’t need to manually clear the transcript.
  • Test on iOS Safari and Android Chrome
  • Ensure tap targets are large enough (min 44x44px)
  • Handle screen rotation
  • Consider battery usage for long recordings

Browser Support

Voice input requires:
  • MediaRecorder API (audio recording)
  • Microphone access permission
  • WebM audio support
Supported browsers:
  • Chrome 49+
  • Firefox 25+
  • Safari 14.1+
  • Edge 79+
iOS Safari requires iOS 14.3+ for MediaRecorder support.

Troubleshooting

  • Check browser permissions (chrome://settings/content/microphone)
  • Ensure HTTPS is used (required for secure contexts)
  • Try different browser
  • On mobile, check system permissions
  • Verify microphone is working in other apps
  • Check microphone is selected in browser settings
  • Ensure no other app is using the microphone
  • Try restarting the browser
  • Ensure audio is audible (check recording level)
  • Speak clearly and avoid background noise
  • Check network connection
  • Verify API key is valid
  • Check for JavaScript errors in console
  • Verify stopRecording isn’t called accidentally
  • Ensure component isn’t unmounting

Next Steps

Suggestions

Add AI-powered prompt suggestions

Thread Input Hook

Handle text input and submission

Message Images

Add image attachments

Error Handling

Handle errors properly

Build docs developers (and LLMs) love