Voice Mode

Sprich mit deinem Assistenten. Acht audioreaktive Stile, von einem wolkigen Himmel und einem Ferrofluid-Blob über gedithertes Pixelwerk, ASCII, einen CRT-Planeten und Halbton-Punkte bis zu einem einzelnen Ring und einer weichen Aura, dazu vier reaktive Punkte. Eine Vollbild-Sitzung mit Stummschalten, Unterbrechen und Untertiteln, eine Voice-Pille im Chat, ein Stimmenwähler und eine Browser-Engine, die zuhört, wartet, bis du fertig bist, und antwortet.

Voice Mode ist das Visual samt Bedienelementen für Gespräche mit einem Assistenten. Wähle einen von acht Shader-Stilen (Sky, Dither, ASCII, CRT, Ferrofluid, Halftone, Ring oder Aura) oder vier einfache Punkte. Jeder hört zu, denkt nach und antwortet und bewegt sich dabei mit dem Ton; monochrome Stile folgen deinem hellen oder dunklen Theme.

Audiopegel werden geglättet und an einem gelernten Grundrauschen gemessen, sodass ein ruhiger Raum auch ruhig wirkt und die Bewegung weich bleibt. Das Rendering pausiert, wenn das Visual außerhalb des Bildschirms liegt oder der Tab verborgen ist, und bei reduzierter Bewegung stehen alle Stile still.

VoiceSession legt alles im Vollbild in Telefonbreite aus, mit Untertiteln, einer Statuszeile sowie Stummschalten und Beenden in Daumenreichweite. Tippe auf das Visual, drücke Space oder fang einfach an zu sprechen, um zu unterbrechen. VoiceComposer ist eine kompakte Version, die den Composer in einem Chat ersetzt.

useBrowserVoice führt eine komplette Schleife mit der eigenen Spracherkennung und Sprachausgabe des Browsers aus: Es wartet vor der Antwort auf eine Pause, kann schweigen und Antworten als Untertitel anzeigen und ignoriert seine eigenen Worte, wenn sie über die Lautsprecher zurückkommen. Bei Realtime-Voice-APIs gibst du Mikrofon- und Antwort-Streams an useAudioLevels weiter und setzt den Zustand aus den Events der Sitzung.

  1. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  3. Den Block hinzufügen

    pnpm dlx shadcn@latest add @hextaui-pro/voice-mode

Mit der Browserstimme

useBrowserVoice hört mit dem Mikrofon und der Spracherkennung des Browsers zu, wartet auf eine Pause, ruft respond mit dem Gesagten auf und antwortet mit der Sprachausgabe des Browsers. Gib die Antwort deines Modells von respond zurück.

"use client"

import { useBrowserVoice } from "@/components/blocks/voice-mode/use-browser-voice"
import { VoiceSession } from "@/components/blocks/voice-mode/voice-session"

export function VoiceChat({ onClose }: { onClose: () => void }) {
  const voice = useBrowserVoice({
    pauseMs: 900,
    respond: async (text, turns, signal) => {
      const response = await fetch("/api/voice", {
        method: "POST",
        body: JSON.stringify({ text, turns }),
        signal,
      })
      const { reply } = await response.json()
      return reply
    },
  })

  return (
    <VoiceSession
      state={voice.state}
      levels={voice.levels}
      muted={voice.muted}
      onMutedChange={voice.setMuted}
      onInterrupt={voice.interrupt}
      onEnd={() => {
        voice.end()
        onClose()
      }}
      captions={voice.captions}
      showCaptions
      error={voice.error}
    />
  )
}

Mit einer Realtime-Voice-API

Bei Speech-to-Speech-Modellen über WebRTC steuerst du den Zustand über die Events der Sitzung und gibst beide Audiostreams an useAudioLevels weiter, damit der Orb dir folgt, während du sprichst, und dem Modell, während es antwortet.

"use client"

import * as React from "react"

import { useAudioLevels } from "@/components/blocks/voice-mode/audio"
import type { VoiceState } from "@/components/blocks/voice-mode/voice-orb"
import { VoiceSession } from "@/components/blocks/voice-mode/voice-session"

export function RealtimeVoice({ token }: { token: string }) {
  const [state, setState] = React.useState<VoiceState>("connecting")
  const [mic, setMic] = React.useState<MediaStream | null>(null)
  const [reply, setReply] = React.useState<MediaStream | null>(null)
  const [muted, setMuted] = React.useState(false)
  const peer = React.useRef<RTCPeerConnection | null>(null)
  const listening = useAudioLevels(mic)
  const speaking = useAudioLevels(reply)

  React.useEffect(() => {
    const connection = new RTCPeerConnection()
    peer.current = connection
    connection.ontrack = (event) => setReply(event.streams[0])
    const events = connection.createDataChannel("oai-events")
    events.onmessage = (message) => {
      const event = JSON.parse(message.data)
      if (event.type === "input_audio_buffer.speech_started") setState("listening")
      if (event.type === "input_audio_buffer.committed") setState("thinking")
      if (event.type === "output_audio_buffer.started") setState("speaking")
      if (event.type === "output_audio_buffer.stopped") setState("listening")
    }
    navigator.mediaDevices.getUserMedia({ audio: true }).then(async (stream) => {
      setMic(stream)
      stream.getTracks().forEach((track) => connection.addTrack(track, stream))
      const offer = await connection.createOffer()
      await connection.setLocalDescription(offer)
      const answer = await fetch("https://api.openai.com/v1/realtime/calls", {
        method: "POST",
        body: offer.sdp,
        headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/sdp" },
      })
      await connection.setRemoteDescription({ type: "answer", sdp: await answer.text() })
      setState("listening")
    })
    return () => connection.close()
  }, [token])

  React.useEffect(() => {
    mic?.getAudioTracks().forEach((track) => (track.enabled = !muted))
  }, [mic, muted])

  return (
    <VoiceSession
      state={state}
      levels={state === "speaking" ? speaking : listening}
      muted={muted}
      onMutedChange={setMuted}
      onEnd={() => peer.current?.close()}
    />
  )
}

In einem Chat

VoiceComposer ersetzt den Composer, solange eine Sprachsitzung läuft, mit einem kleinen Orb, dem Status, einem Timer, Stummschalten und Beenden. Deine Worte und die Antworten können als Text in den Thread gestreamt werden.

"use client"

import { useBrowserVoice } from "@/components/blocks/voice-mode/use-browser-voice"
import { VoiceComposer } from "@/components/blocks/voice-mode/voice-session"

export function ChatVoice({ respond }: { respond: (text: string) => Promise<string> }) {
  const voice = useBrowserVoice({ respond: (text) => respond(text) })

  if (voice.state === "idle") {
    return <button onClick={() => void voice.start()}>Start voice</button>
  }

  return (
    <VoiceComposer
      state={voice.state}
      levels={voice.levels}
      muted={voice.muted}
      onMutedChange={voice.setMuted}
      onInterrupt={voice.interrupt}
      onEnd={voice.end}
      startedAt={voice.startedAt}
    />
  )
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
VoiceVisualJeder Stil je nach kind: sky, dither, ascii, crt, ferrofluid, halftone, ring, aura oder bars.
VoiceOrbDer Shader-Orb. Nimmt den Zustand, Stummschaltung und eine Levels-Ref entgegen.
VoiceBarsDas Vier-Punkte-Visual mit denselben Props.
VoiceShaderDie gemeinsame WebGL-Engine. Übergib deinen eigenen Fragment-Shader, um einen neuen Stil mit denselben Eingaben zu bauen: Zustand, geglätteter Pegel, vier Bänder und drei Farben.
VoiceSessionDas Vollbild-Layout: Aktionen, Visual, Untertitel, Status, Stummschalten und Beenden.
VoiceComposerDie kompakte Sprach-Pille für den Einsatz in einem Chat.
VoicePickerEin Popover mit Stimmen und getönten Orbs.
useBrowserVoiceEine komplette Sprachschleife auf Browser-APIs: zuhören, das Ende eines Redebeitrags erkennen, antworten, sprechen, unterbrechen.
useAudioLevelsLiest einen Pegel und vier Bänder aus einem beliebigen MediaStream oder Media-Element in eine Ref, ohne neu zu rendern.

VoiceVisual

Akzeptiert außerdem alle div-Props. Lege die Größe über eine Breitenklasse fest; er bleibt quadratisch.

PropTypStandard
kindWelcher Stil gerendert werden soll.
"sky" | "dither" | "ascii" | "crt" | "ferrofluid" | "halftone" | "ring" | "aura" | "bars""sky"
colorsDrei Farben für den Shader. Hex, jede CSS-Farbe oder currentColor. Jeder Stil bringt eigene mit.
[string, string, string]–
paletteSky-Farben, wie sie von den Stimmen des VoicePickers verwendet werden.
{ deep, sky, cloud }–
stateSteuert die Bewegung beim Zuhören, Nachdenken und Sprechen.
VoiceState–
levelsLive-Audio.
RefObject<VoiceLevels>–
mutedEntsättigt und reagiert nicht mehr. Der Ring wird gestrichelt.
booleanfalse

VoiceOrb

Akzeptiert außerdem alle div-Props. Lege die Größe über eine Breitenklasse fest; er bleibt quadratisch.

PropTypStandard
stateSteuert Wirbel, Strudel und Wellen.
"idle" | "connecting" | "listening" | "thinking" | "speaking" | "error""idle"
levelsLive-Audio, das in jedem Frame gelesen wird. Meist von useAudioLevels oder useBrowserVoice.
RefObject<VoiceLevels>–
mutedEntsättigt und reagiert nicht mehr.
booleanfalse
paletteDrei Hex-Farben.
{ deep, sky, cloud }blue sky
PropTypStandard
stateDer Gesprächszustand.
VoiceState–
levelsWird an das Visual weitergegeben.
RefObject<VoiceLevels>–
variantWelcher Stil angezeigt werden soll.
VoiceVisualKind"sky"
colorsÜberschreibt die Farben des Stils.
[string, string, string]–
mutedStummschalt-Zustand.
booleanfalse
onMutedChangeZeigt den Stummschalt-Button und das Kürzel M.
(muted: boolean) => void–
onInterruptMacht das Visual zu einem Button, während es nachdenkt oder spricht, dazu Space.
() => void–
onEndZeigt den Beenden-Button und Escape.
() => void–
captionsDie jüngsten Worte jeder Seite.
{ user, assistant }–
showCaptionsUntertitel unter dem Visual anzeigen.
booleanfalse
onShowCaptionsChangeZeigt den Untertitel-Schalter und C.
(show: boolean) => void–
statusErsetzt das Zustandslabel.
ReactNode–
footnoteZwischen den Buttons, etwa ein Timer oder die verbleibenden Minuten.
ReactNode–
errorWird anstelle des Status angezeigt.
string | null–
actionsBedienelemente oben rechts, etwa der VoicePicker.
ReactNode–
paletteOrb-Farben.
VoicePalette–
PropTypStandard
stateDer Gesprächszustand.
VoiceState–
levelsWird an den kleinen Orb weitergegeben.
RefObject<VoiceLevels>–
onEndBeendet die Sprache und bringt den Composer zurück.
() => void–
muted / onMutedChangeStummschalt-Button.
boolean / (muted) => void–
onInterruptTippe auf den Orb, um zu unterbrechen.
() => void–
startedAtStartzeit in ms, für den Timer.
number | null–

useBrowserVoice(options)

Gibt { state, muted, setMuted, start, end, interrupt, levels, turns, captions, error, startedAt } zurück.

PropTypStandard
respondWird aufgerufen, wenn du einen Satz beendest. Gib die Antwort zurück; das Signal bricht bei einer Unterbrechung ab.
(text, turns, signal) => Promise<string>–
pauseMsStille, bevor dein Redebeitrag endet.
number900
bargeInSprechen darf die Antwort unterbrechen.
booleantrue
voiceOutputAntworten laut vorlesen. Bei false erscheinen Antworten als Untertitel, während der Orb weiter animiert wird.
booleantrue
voiceBevorzugte Browserstimmen und Sprechweise.
{ names?, pitch?, rate? }–
greetingWird gesagt, wenn die Sitzung beginnt.
string–
langSprache für Erkennung und Sprachausgabe.
string"en-US"

useAudioLevels(source, target?)

Gibt eine Ref mit { level, bands } zurück, die in jedem Frame aktualisiert wird.

PropTypStandard
sourceMikrofon, entfernter WebRTC-Stream oder ein Audio-Element.
MediaStream | HTMLMediaElement | null–
targetIn eine bestehende Ref schreiben statt in eine neue.
RefObject<VoiceLevels>–
TasteAktion
SpaceUnterbricht, während es nachdenkt oder spricht.
MSchaltet dein Mikrofon stumm oder wieder ein.
CZeigt Untertitel an oder blendet sie aus.
EscBeendet die Sprachsitzung.
TabSpringt durch den Untertitel-Schalter, die Stimmauswahl, den Orb (wenn unterbrechbar), Stummschalten und Beenden.
  • Die Sitzung ist eine beschriftete Region, und die Statuszeile ist eine Live-Region mit polite-Priorität, sodass Zustandswechsel wie „Listening“ und „Thinking“ angesagt werden.
  • Das Visual ist ein echter Button mit dem Label „Interrupt“, solange es unterbrochen werden kann, und sonst deaktiviert.
  • Stummschalten nutzt aria-pressed, und jedes Bedienelement hat einen Tooltip mit seinem Tastenkürzel.
  • Untertitel bieten eine Textalternative zu allem Gesprochenen, und Antworten lassen sich auch ohne Ton anzeigen.
  • Tastenkürzel werden ignoriert, während du in ein Feld tippst, außer Escape.
  • Bei reduzierter Bewegung stehen Orb und Punkte still und ändern sich nur mit dem Zustand.
  • Ohne WebGL fällt jeder Shader-Stil auf einen statischen Verlauf zurück, und die Punkte funktionieren überall.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Voice Mode besteht. Jede lässt sich einzeln installieren.

Code

9 Dateien, hinzugefügt zu components/blocks/voice-mode.