You have a chat or generation UI and you want users to talk instead of type. By the end of this guide you'll capture microphone audio in the browser with useAudioRecorder, read the latest recording reactively, and send it straight into a chat message or a transcription request — with no transcoding and no extra dependencies.
useAudioRecorder wraps the browser's getUserMedia / MediaRecorder and returns the recorder's native output (audio/webm or audio/mp4).
Start with a button; end with a working recorder that toggles capture and hands you the result.
import { useAudioRecorder } from '@tanstack/ai-react'
function RecordButton() {
const { isRecording, isSupported, start, stop } = useAudioRecorder({
onError: (error) => console.error(error),
})
if (!isSupported) return <p>Recording is not supported in this browser.</p>
return (
<button onClick={() => (isRecording ? void stop() : void start())}>
{isRecording ? 'Stop' : 'Record'}
</button>
)
}import { useAudioRecorder } from '@tanstack/ai-react'
function RecordButton() {
const { isRecording, isSupported, start, stop } = useAudioRecorder({
onError: (error) => console.error(error),
})
if (!isSupported) return <p>Recording is not supported in this browser.</p>
return (
<button onClick={() => (isRecording ? void stop() : void start())}>
{isRecording ? 'Stop' : 'Record'}
</button>
)
}stop() resolves to an AudioRecording:
| Field | Type | Description |
|---|---|---|
| part | AudioPart | Ready-to-use content part: { type: 'audio', source: { type: 'data', value, mimeType } } |
| base64 | string | Raw base64 of the recorded bytes |
| blob | Blob | The raw recorded blob |
| mimeType | string | Native recorder type, e.g. audio/webm;codecs=opus |
| durationMs | number | Recording length in milliseconds |
Failures reach you through two channels — pick one, don't handle both:
So if you await start() / await stop(), wrap them in try/catch rather than discarding the promise with void. The recorder's native mimeType may differ from a requested mimeType (browsers ignore unsupported types), so read recording.mimeType if a downstream step requires a specific format.
The same value is also exposed as the reactive recording field, so you can render a preview without capturing stop()'s return value yourself. It's null until the first stop():
function Preview() {
const { recording, isRecording, start, stop } = useAudioRecorder()
// recording is AudioRecording | null
}function Preview() {
const { recording, isRecording, start, stop } = useAudioRecorder()
// recording is AudioRecording | null
}Across frameworks recording follows the same shape as the other reactive fields: an accessor in Solid (recording()), a readonly ref in Vue (recording.value), a getter in Svelte (recorder.recording), and a Signal in Angular (recording()).
Pass onComplete to turn the raw recording into whatever your app needs — a URL after upload, an encoded blob, or a custom object. Both stop() and the reactive recording field then resolve to your transformed value, and the transform can be async:
function Uploader() {
const { recording, stop } = useAudioRecorder({
onComplete: async (rec) => {
const res = await fetch('/api/upload', { method: 'POST', body: rec.blob })
const { url } = await res.json()
return url // `recording` and `stop()` now resolve to string
},
})
}function Uploader() {
const { recording, stop } = useAudioRecorder({
onComplete: async (rec) => {
const res = await fetch('/api/upload', { method: 'POST', body: rec.blob })
const { url } = await res.json()
return url // `recording` and `stop()` now resolve to string
},
})
}Return nothing (undefined) to keep the raw AudioRecording; any returned value — including null — is used as-is and re-types stop() and recording. This is similar to the onResult transform on the generation hooks, but is async-capable. (Unlike onResult, where null means "keep the previous value," only undefined keeps the raw recording here.)
The recording's part is already a chat content part, so it drops straight into sendMessage:
import {
useAudioRecorder,
useChat,
fetchServerSentEvents,
} from '@tanstack/ai-react'
function VoiceComposer() {
const { isRecording, start, stop } = useAudioRecorder()
const { sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
const toggle = async () => {
try {
if (!isRecording) {
await start()
return
}
const rec = await stop()
await sendMessage({ content: [rec.part] })
} catch (error) {
// start()/stop() reject on permission denial, recorder error, or cancel.
console.error(error)
}
}
return (
<button onClick={() => void toggle()}>
{isRecording ? 'Send' : 'Record'}
</button>
)
}import {
useAudioRecorder,
useChat,
fetchServerSentEvents,
} from '@tanstack/ai-react'
function VoiceComposer() {
const { isRecording, start, stop } = useAudioRecorder()
const { sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
const toggle = async () => {
try {
if (!isRecording) {
await start()
return
}
const rec = await stop()
await sendMessage({ content: [rec.part] })
} catch (error) {
// start()/stop() reject on permission denial, recorder error, or cancel.
console.error(error)
}
}
return (
<button onClick={() => void toggle()}>
{isRecording ? 'Send' : 'Record'}
</button>
)
}Wrap the recording as a data: URL so the provider receives the recorder's native content type — passing raw base64 makes the transcription adapter assume audio/mpeg and mislabel the webm/mp4 bytes. See Transcription for the matching server route.
import {
useAudioRecorder,
useTranscription,
fetchServerSentEvents,
} from '@tanstack/ai-react'
function Transcriber() {
const { isRecording, start, stop } = useAudioRecorder()
const { generate, result } = useTranscription({
connection: fetchServerSentEvents('/api/transcribe'),
})
const toggle = async () => {
try {
if (!isRecording) {
await start()
return
}
const rec = await stop()
// Wrap as a data URL so the provider gets the recorder's real content
// type. Passing raw base64 makes the transcription adapter assume
// `audio/mpeg`, which mislabels the native webm/mp4 bytes. Strip the
// `;codecs=...` parameter for a clean type.
const mimeType = rec.mimeType.split(';')[0]
await generate({ audio: `data:${mimeType};base64,${rec.base64}` })
} catch (error) {
console.error(error)
}
}
return (
<div>
<button onClick={() => void toggle()}>
{isRecording ? 'Stop' : 'Record'}
</button>
{result ? <p>{result.text}</p> : null}
</div>
)
}import {
useAudioRecorder,
useTranscription,
fetchServerSentEvents,
} from '@tanstack/ai-react'
function Transcriber() {
const { isRecording, start, stop } = useAudioRecorder()
const { generate, result } = useTranscription({
connection: fetchServerSentEvents('/api/transcribe'),
})
const toggle = async () => {
try {
if (!isRecording) {
await start()
return
}
const rec = await stop()
// Wrap as a data URL so the provider gets the recorder's real content
// type. Passing raw base64 makes the transcription adapter assume
// `audio/mpeg`, which mislabels the native webm/mp4 bytes. Strip the
// `;codecs=...` parameter for a clean type.
const mimeType = rec.mimeType.split(';')[0]
await generate({ audio: `data:${mimeType};base64,${rec.base64}` })
} catch (error) {
console.error(error)
}
}
return (
<div>
<button onClick={() => void toggle()}>
{isRecording ? 'Stop' : 'Record'}
</button>
{result ? <p>{result.text}</p> : null}
</div>
)
}The same recorder ships for every framework with idiomatic reactivity. Svelte uses the createAudioRecorder factory; because Svelte 5 runes can't register automatic teardown, call cancel() from your component cleanup if a recording may still be active:
<script lang="ts">
import {
createAudioRecorder,
createChat,
fetchServerSentEvents,
} from '@tanstack/ai-svelte'
const recorder = createAudioRecorder()
const chat = createChat({ connection: fetchServerSentEvents('/api/chat') })
async function toggle() {
if (!recorder.isRecording) {
await recorder.start()
return
}
const rec = await recorder.stop()
await chat.sendMessage({ content: [rec.part] })
}
</script>
<button onclick={toggle}>{recorder.isRecording ? 'Send' : 'Record'}</button><script lang="ts">
import {
createAudioRecorder,
createChat,
fetchServerSentEvents,
} from '@tanstack/ai-svelte'
const recorder = createAudioRecorder()
const chat = createChat({ connection: fetchServerSentEvents('/api/chat') })
async function toggle() {
if (!recorder.isRecording) {
await recorder.start()
return
}
const rec = await recorder.stop()
await chat.sendMessage({ content: [rec.part] })
}
</script>
<button onclick={toggle}>{recorder.isRecording ? 'Send' : 'Record'}</button>| Framework | Import | Function | Reactive fields |
|---|---|---|---|
| React | @tanstack/ai-react | useAudioRecorder | isRecording, recording (values) |
| Solid | @tanstack/ai-solid | useAudioRecorder | isRecording(), recording() (accessors) |
| Vue | @tanstack/ai-vue | useAudioRecorder | isRecording.value, recording.value (readonly refs) |
| Svelte | @tanstack/ai-svelte | createAudioRecorder | recorder.isRecording, recorder.recording (getters) |
| Angular | @tanstack/ai-angular | injectAudioRecorder | isRecording(), recording() (signals; call in an injection context) |
useAudioRecorder(options?) — and the createAudioRecorder / injectAudioRecorder equivalents — accept:
| Option | Type | Description |
|---|---|---|
| onComplete | (recording: AudioRecording) => T | Promise<T> | Optional transform. Its (awaited) return re-types stop() and recording. Return nothing to keep the raw recording |
| onError | (error: Error) => void | Called on permission denial or recorder error |
| audio | MediaTrackConstraints | boolean | Passed to getUserMedia({ audio }). Defaults to true |
| mimeType | string | Preferred recorder mime type; falls back to the browser default if unsupported |
And return:
| Property | Type | Description |
|---|---|---|
| recording | T | null | Latest recording (transformed if onComplete provided), reactive |
| isRecording | boolean | Whether capture is currently active |
| isSupported | boolean | Whether the browser supports recording |
| start | () => Promise<void> | Acquire the mic and begin recording |
| stop | () => Promise<T> | Stop, and resolve with the recording (transformed if applicable) |
| cancel | () => void | Discard the in-progress recording and release the mic |
Reactive shapes (recording, isRecording) vary per framework — see the table in Other frameworks. T is AudioRecording unless an onComplete transform changes it.