Zum Inhalt springen
UI Kit
Accent

Sprachassistent

Sprachoberflächen in vier Formen. Ein Gespräch um eine Kugel, die zuhört, nachdenkt und spricht, mit Live-Untertiteln, die Mitschrift eines Anrufs mit Wiedergabe und Zusammenfassung, eine Stimme mit Hörproben wählen und Halten zum Sprechen mit Sprachnachrichten.

Eine von 2 Components in KI-Assistenten

4 Variants

Variants

Sprachgespräch

Eine ruhige dunkle Bühne. Die Kugel wirft Wellen, wenn sie zuhört, dreht sich beim Nachdenken und pulsiert beim Sprechen. Untertitel schreiben mit und lassen sich ausschalten, Mikrofon und Beenden sind ein Tipp.

Im Studio öffnen

Anrufmitschrift

Ein Anruf, den der Assistent angenommen hat, mit Wellenform mit Wiedergabe und Suchen, die Mitschrift folgt dem Ton, und jede Zeile springt zu ihrer Stelle. Ergebnis und Vereinbartes stehen daneben.

Im Studio öffnen

Stimme wählen

Stimmen zur Auswahl mit Charakter, Sprachen und Beispielsatz. Abspielen bewegt die Wellenform, Tempo und Tonhöhe stimmen die gewählte fein ab.

Im Studio öffnen

Halten zum Sprechen

Für Hände, die beschäftigt sind. Knopf oder Leertaste halten, loslassen zum Senden. Sprachnachrichten zeigen Länge und Text, der sich ein- und ausblenden lässt.

Im Studio öffnen

Details

Was Sprachassistent mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/voice-assistant.tsx
Bringt mit
utilskiticonrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 40 eigene Texte

Props

Die Schnittstelle VoiceAssistantProps aus components/ui/voice-assistant.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    VoiceAssistantVariant

    orbcall-transcriptvoice-pickerpush-to-talk

  • titleoptional
    string
  • nameoptional
    string

    Name of the assistant

  • linesoptional
    VoiceLine[]

    orb: what the assistant hears and says, played in turns

  • calloptional
    { caller: string; number?: string; duration: number; date: string; outcome?: string }

    call-transcript: caller and call facts

  • summaryoptional
    { label: string; value: string }[]

    call-transcript: what the assistant took from the call

  • voicesoptional
    VoiceOption[]

    voice-picker

  • defaultVoiceoptional
    string
  • exchangeoptional
    { heard: string; reply: string }

    push-to-talk: what the demo hears and answers when you let go. Connect real speech here.

  • noteoptional
    string

    Short line on what happens with audio

Eingebaute Texte

Diese Texte schreibt Sprachassistent selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 40 Texte anzeigen
listeningEnglisch ListeningDeutsch Hört zu
thinkingEnglisch ThinkingDeutsch Denkt nach
speakingEnglisch SpeakingDeutsch Spricht
idleEnglisch Tap to talkDeutsch Tippen und sprechen
pausedEnglisch Microphone offDeutsch Mikrofon aus
muteEnglisch Turn microphone offDeutsch Mikrofon ausschalten
unmuteEnglisch Turn microphone onDeutsch Mikrofon einschalten
endEnglisch End conversationDeutsch Gespräch beenden
captionsEnglisch CaptionsDeutsch Untertitel
youEnglisch YouDeutsch Du
restartEnglisch Start againDeutsch Neu starten
endedEnglisch Conversation ended. The transcript stays in your history.Deutsch Gespräch beendet. Die Mitschrift bleibt in deinem Verlauf.
playEnglisch Play recordingDeutsch Aufnahme abspielen
pauseEnglisch PauseDeutsch Pausieren
seekEnglisch Position in the recordingDeutsch Stelle in der Aufnahme
callerEnglisch CallerDeutsch Anrufer
assistantEnglisch AssistantDeutsch Assistent
summaryEnglisch What was agreedDeutsch Was vereinbart wurde
jumpEnglisch Play from {time}Deutsch Ab {time} abspielen
voiceEnglisch VoiceDeutsch Stimme
quoteOpenEnglisch “Deutsch „
quoteCloseEnglisch ”Deutsch “
previewEnglisch Play sample of {name}Deutsch Hörprobe von {name} abspielen
stopPreviewEnglisch Stop sampleDeutsch Hörprobe stoppen
speedEnglisch SpeedDeutsch Tempo
pitchEnglisch PitchDeutsch Tonhöhe
slowerEnglisch slowerDeutsch langsamer
fasterEnglisch fasterDeutsch schneller
lowerEnglisch lowerDeutsch tiefer
higherEnglisch higherDeutsch höher
useEnglisch Use this voiceDeutsch Diese Stimme nutzen
inUseEnglisch In useDeutsch In Nutzung
holdEnglisch Hold to talkDeutsch Halten und sprechen
releaseEnglisch Release to sendDeutsch Loslassen zum Senden
recordingEnglisch RecordingDeutsch Nimmt auf
showTextEnglisch Show textDeutsch Text zeigen
hideTextEnglisch Hide textDeutsch Text verbergen
spaceHintEnglisch Or hold the space barDeutsch Oder Leertaste halten
heardEnglisch Is the delivery for Kantstraße 12 still on for Thursday?Deutsch Bleibt die Lieferung für die Kantstraße 12 bei Donnerstag?
replyEnglisch Yes, Thursday between 8 and 10 am. The driver calls 30 minutes before.Deutsch Ja, Donnerstag zwischen 8 und 10 Uhr. Der Fahrer ruft 30 Minuten vorher an.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Sprachassistent reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-label, aria-live, aria-pressed und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio und radiogroup.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 683 Zeilen
components/ui/voice-assistant.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type VoiceAssistantVariant = 'orb' | 'call-transcript' | 'voice-picker' | 'push-to-talk';

export interface VoiceLine {
  id: string;
  speaker: 'assistant' | 'caller' | 'user';
  text: string;
  /** Seconds from the start */
  at?: number;
  /** Seconds of audio, for voice messages */
  duration?: number;
}

export interface VoiceOption {
  id: string;
  name: string;
  /** e.g. "Warm, calm" */
  character: string;
  languages: string[];
  /** A sentence the sample says */
  sample: string;
  tag?: string;
}

export interface VoiceAssistantProps {
  variant?: VoiceAssistantVariant;
  title?: string;
  /** Name of the assistant */
  name?: string;
  /** orb: what the assistant hears and says, played in turns */
  lines?: VoiceLine[];
  /** call-transcript: caller and call facts */
  call?: { caller: string; number?: string; duration: number; date: string; outcome?: string };
  /** call-transcript: what the assistant took from the call */
  summary?: { label: string; value: string }[];
  /** voice-picker */
  voices?: VoiceOption[];
  defaultVoice?: string;
  /** push-to-talk: what the demo hears and answers when you let go. Connect real speech here. */
  exchange?: { heard: string; reply: string };
  /** Short line on what happens with audio */
  note?: string;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    listening: 'Listening',
    thinking: 'Thinking',
    speaking: 'Speaking',
    idle: 'Tap to talk',
    paused: 'Microphone off',
    mute: 'Turn microphone off',
    unmute: 'Turn microphone on',
    end: 'End conversation',
    
    captions: 'Captions',
    you: 'You',
    restart: 'Start again',
    ended: 'Conversation ended. The transcript stays in your history.',
    play: 'Play recording',
    pause: 'Pause',
    seek: 'Position in the recording',
    caller: 'Caller',
    assistant: 'Assistant',
    summary: 'What was agreed',
    
    jump: 'Play from {time}',
    voice: 'Voice',
    quoteOpen: '“',
    quoteClose: '”',
    preview: 'Play sample of {name}',
    stopPreview: 'Stop sample',
    speed: 'Speed',
    pitch: 'Pitch',
    slower: 'slower',
    faster: 'faster',
    lower: 'lower',
    higher: 'higher',
    use: 'Use this voice',
    inUse: 'In use',
    hold: 'Hold to talk',
    release: 'Release to send',
    recording: 'Recording',
    
    
    showText: 'Show text',
    hideText: 'Hide text',
    spaceHint: 'Or hold the space bar',
    heard: 'Is the delivery for Kantstraße 12 still on for Thursday?',
    reply: 'Yes, Thursday between 8 and 10 am. The driver calls 30 minutes before.',
  },
  de: {
    listening: 'Hört zu',
    thinking: 'Denkt nach',
    speaking: 'Spricht',
    idle: 'Tippen und sprechen',
    paused: 'Mikrofon aus',
    mute: 'Mikrofon ausschalten',
    unmute: 'Mikrofon einschalten',
    end: 'Gespräch beenden',
    
    captions: 'Untertitel',
    you: 'Du',
    restart: 'Neu starten',
    ended: 'Gespräch beendet. Die Mitschrift bleibt in deinem Verlauf.',
    play: 'Aufnahme abspielen',
    pause: 'Pausieren',
    seek: 'Stelle in der Aufnahme',
    caller: 'Anrufer',
    assistant: 'Assistent',
    summary: 'Was vereinbart wurde',
    
    jump: 'Ab {time} abspielen',
    voice: 'Stimme',
    quoteOpen: '„',
    quoteClose: '“',
    preview: 'Hörprobe von {name} abspielen',
    stopPreview: 'Hörprobe stoppen',
    speed: 'Tempo',
    pitch: 'Tonhöhe',
    slower: 'langsamer',
    faster: 'schneller',
    lower: 'tiefer',
    higher: 'höher',
    use: 'Diese Stimme nutzen',
    inUse: 'In Nutzung',
    hold: 'Halten und sprechen',
    release: 'Loslassen zum Senden',
    recording: 'Nimmt auf',
    
    
    showText: 'Text zeigen',
    hideText: 'Text verbergen',
    spaceHint: 'Oder Leertaste halten',
    heard: 'Bleibt die Lieferung für die Kantstraße 12 bei Donnerstag?',
    reply: 'Ja, Donnerstag zwischen 8 und 10 Uhr. Der Fahrer ruft 30 Minuten vorher an.',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = VoiceAssistantProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;

const CSS =
  '@keyframes vca-up{from{opacity:0;transform:translateY(8px)}}@keyframes vca-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}@keyframes vca-ring{from{transform:scale(.9);opacity:.6}to{transform:scale(1.5);opacity:0}}@keyframes vca-spin{to{transform:rotate(360deg)}}@keyframes vca-bar{0%,100%{transform:scaleY(.25)}50%{transform:scaleY(1)}}' +
  '@media (prefers-reduced-motion:no-preference){.vca-up{animation:vca-up .45s var(--ease-kit) var(--vca-d,0ms) both}.vca-breathe{animation:vca-breathe 3.2s ease-in-out infinite}.vca-ring{animation:vca-ring 2s ease-out infinite}.vca-spin{animation:vca-spin 6s linear infinite}.vca-bar{animation:vca-bar var(--vca-t,.9s) ease-in-out var(--vca-d,0ms) infinite}}.vca-bar{transform-origin:50% 50%}';

function Style() {
  return (
    <style href="kit-voice-assistant" precedence="default">
      {CSS}
    </style>
  );
}

/** Bar heights, fixed so server and browser agree */
const wave = (n: number, seed = 1) => Array.from({ length: n }, (_, i) => round(0.2 + 0.8 * Math.abs(Math.sin(i * 0.9 * seed + seed) * Math.cos(i * 0.31 + seed * 2)), 2));

function Stage({ title, children, className, wide, dark }: { title?: string; children: React.ReactNode; className?: string; wide?: boolean; dark?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', dark && 'dark bg-background', className)}>
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[64rem]' : 'max-w-[44rem]')}>
          {title && (
            <Reveal className="mb-6 sm:mb-8">
              <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

const round_ = 'grid size-14 place-items-center rounded-full transition-[background-color,transform] duration-200 active:scale-95';

/* ------------------------------------------------------------------ */
/* orb                                                                 */
/* ------------------------------------------------------------------ */

type Phase = 'idle' | 'listening' | 'thinking' | 'speaking' | 'ended';

function Orb({ phase, level }: { phase: Phase; level: number }) {
  const live = phase !== 'idle' && phase !== 'ended';
  return (
    <div aria-hidden className="relative mx-auto grid size-56 place-items-center sm:size-64">
      {phase === 'listening' && (
        <>
          <span className="vca-ring absolute inset-6 rounded-full border border-[color-mix(in_oklab,var(--primary)_50%,transparent)]" />
          <span className="vca-ring absolute inset-6 rounded-full border border-[color-mix(in_oklab,var(--primary)_40%,transparent)] [animation-delay:1s]" />
        </>
      )}
      <span
        className={cn('absolute inset-8 rounded-full transition-[transform,opacity] duration-500 ease-[var(--ease-kit)]', live ? 'vca-breathe' : 'opacity-70')}
        style={{
          background: 'radial-gradient(circle at 35% 30%, color-mix(in oklab, var(--primary) 30%, white) 0%, var(--primary) 38%, color-mix(in oklab, var(--primary) 55%, black) 100%)',
          boxShadow: '0 30px 80px -20px color-mix(in oklab, var(--primary) 70%, transparent), inset 0 -12px 40px color-mix(in oklab, black 30%, transparent)',
          transform: `scale(${round(0.92 + level * 0.12, 3)})`,
        }}
      />
      <span className={cn('absolute inset-14 rounded-full border border-[color-mix(in_oklab,var(--primary-foreground)_28%,transparent)]', phase === 'thinking' && 'vca-spin border-dashed')} />
      {phase === 'speaking' && (
        <span className="relative flex h-10 items-center gap-1">
          {wave(9, 2).map((h, i) => (
            <span key={i} className="vca-bar w-1.5 rounded-full bg-[color-mix(in_oklab,var(--primary-foreground)_90%,transparent)]" style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${i * 80}ms`, '--vca-t': `${700 + (i % 3) * 160}ms` } as React.CSSProperties} />
          ))}
        </span>
      )}
    </div>
  );
}

function OrbView({ t, title, name, lines = [], note, className }: P) {
  const [phase, setPhase] = useState<Phase>('idle');
  const [i, setI] = useState(0);
  const [shown, setShown] = useState('');
  const [muted, setMuted] = useState(false);
  const [level, setLevel] = useState(0);
  const [captions, setCaptions] = useState(true);
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
  const started = useRef(false);

  // Plays the lines in turns: the user is heard, the assistant thinks, then speaks
  useEffect(() => {
    if (phase === 'idle' || phase === 'ended' || muted) return;
    const line = lines[i];
    if (!line) return setPhase('ended');
    if (prefersReducedMotion()) {
      setShown(line.text);
      const id = window.setTimeout(() => (setI((n) => n + 1), setPhase(lines[i + 1]?.speaker === 'assistant' ? 'speaking' : 'listening')), 1200);
      return () => window.clearTimeout(id);
    }
    if (phase === 'thinking') {
      const id = window.setTimeout(() => setPhase('speaking'), 900);
      return () => window.clearTimeout(id);
    }
    const words = line.text.split(' ');
    let n = 0;
    let next = 0;
    setShown('');
    const id = window.setInterval(() => {
      n++;
      setShown(words.slice(0, n).join(' '));
      setLevel(round(0.3 + 0.7 * Math.abs(Math.sin(n * 1.7)), 2));
      if (n >= words.length) {
        window.clearInterval(id);
        next = window.setTimeout(() => {
          setLevel(0);
          setI((k) => k + 1);
          const next = lines[i + 1];
          setPhase(!next ? 'ended' : next.speaker === 'assistant' ? 'thinking' : 'listening');
        }, 900);
      }
    }, 190);
    // Muting or ending in the pause after a line must not start the next one
    return () => (window.clearInterval(id), window.clearTimeout(next));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phase, i, muted]);

  useEffect(() => {
    if (!inView || started.current || prefersReducedMotion()) return;
    // The lock is set when the timer fires: strict mode clears the first timer and
    // starts a second one, and scrolling away and back never restarts the talk
    const id = window.setTimeout(() => {
      started.current = true;
      setPhase(lines[0]?.speaker === 'assistant' ? 'speaking' : 'listening');
    }, 800);
    return () => window.clearTimeout(id);
  }, [inView, lines]);

  const start = () => (setI(0), setShown(''), setMuted(false), setPhase(lines[0]?.speaker === 'assistant' ? 'speaking' : 'listening'));
  const line = lines[Math.min(i, lines.length - 1)];
  const status = muted ? t.paused : phase === 'idle' ? t.idle : phase === 'ended' ? t.ended : t[phase];
  const who = phase === 'listening' ? t.you : name;
  return (
    <Stage title={title} className={className} dark>
      <div ref={ref} className="relative overflow-hidden rounded-[28px] border border-border bg-[radial-gradient(120%_80%_at_50%_0%,color-mix(in_oklab,var(--primary)_18%,var(--background)),var(--background)_60%)] px-5 pb-6 pt-8 text-center shadow-[var(--shadow-lg)] sm:px-10 sm:pt-10">
        <p className="text-[13px] font-medium text-muted-foreground">{name}</p>
        <p className="mt-1 text-[15px] font-medium text-foreground" aria-live="polite">
          {status}
        </p>
        <button type="button" onClick={phase === 'idle' || phase === 'ended' ? start : () => setMuted((m) => !m)} aria-label={phase === 'idle' || phase === 'ended' ? t.idle : muted ? t.unmute : t.mute} className="mx-auto mt-2 block rounded-full focus-visible:outline-offset-[-12px]">
          <Orb phase={muted ? 'idle' : phase} level={level} />
        </button>
        <div className="mx-auto mt-2 min-h-[5.5rem] max-w-[30rem]" aria-live="polite">
          {captions && phase !== 'idle' && phase !== 'ended' && line && (
            <>
              <p className="text-[12px] font-medium text-subtle-foreground">{who}</p>
              <p className={cn('mt-1 text-balance text-[17px] leading-relaxed sm:text-[19px]', phase === 'listening' ? 'text-muted-foreground' : 'text-foreground')}>
                {phase === 'thinking' ? '' : shown}
                {phase !== 'thinking' && <span aria-hidden className="ml-0.5 inline-block h-[1em] w-0.5 translate-y-[0.15em] animate-kit-pulse bg-primary" />}
              </p>
            </>
          )}
          {phase === 'ended' && <p className="vca-up text-[14px] text-muted-foreground">{t.ended}</p>}
        </div>
        <div className="mt-4 flex items-center justify-center gap-4">
          <button type="button" aria-label={t.captions} aria-pressed={captions} onClick={() => setCaptions((c) => !c)} className={cn(round_, 'size-12 border', captions ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground' : 'border-border bg-card text-muted-foreground hover:bg-accent')}>
            <Icon name="captions" size={20} />
          </button>
          {phase === 'ended' || phase === 'idle' ? (
            <button type="button" onClick={start} aria-label={t.restart} className={cn(round_, 'bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]')}>
              <Icon name="mic" size={24} />
            </button>
          ) : (
            <button type="button" aria-pressed={muted} aria-label={muted ? t.unmute : t.mute} onClick={() => setMuted((m) => !m)} className={cn(round_, muted ? 'bg-foreground text-background' : 'border border-border bg-card text-foreground hover:bg-accent')}>
              <Icon name={muted ? 'volume-x' : 'mic'} size={24} />
            </button>
          )}
          <button type="button" aria-label={t.end} onClick={() => setPhase('ended')} disabled={phase === 'ended' || phase === 'idle'} className={cn(round_, 'size-12 bg-destructive text-destructive-foreground disabled:opacity-40')}>
            <Icon name="x" size={20} />
          </button>
        </div>
        {note && (
          <p className="mx-auto mt-6 flex max-w-[28rem] items-start justify-center gap-1.5 text-[12.5px] leading-snug text-muted-foreground">
            <Icon name="lock" size={13} className="mt-px shrink-0" />
            {note}
          </p>
        )}
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* call-transcript                                                     */
/* ------------------------------------------------------------------ */

const WAVE = wave(96, 1.3);

function CallTranscript({ t, title, name, lines = [], call, summary = [], className }: P) {
  const total = call?.duration ?? 60;
  const [pos, setPos] = useState(0);
  const [playing, setPlaying] = useState(false);
  const list = useRef<HTMLOListElement>(null);
  useEffect(() => {
    if (!playing) return;
    const id = window.setInterval(() => setPos((p) => (p >= total ? (setPlaying(false), total) : p + 1)), prefersReducedMotion() ? 1000 : 250);
    return () => window.clearInterval(id);
  }, [playing, total]);
  const current = [...lines].reverse().find((l) => (l.at ?? 0) <= pos)?.id;
  useEffect(() => {
    if (!playing || !current) return;
    const el = list.current?.querySelector<HTMLElement>(`[data-line="${current}"]`);
    if (el && list.current) list.current.scrollTo({ top: el.offsetTop - list.current.offsetTop - 12, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  }, [current, playing]);
  const share = pos / total;
  return (
    <Stage title={title} className={className} wide>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start lg:gap-6">
        <Reveal delay={60}>
          <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
            <div className="flex items-center gap-3 border-b border-border p-4 sm:px-5">
              <button type="button" aria-label={playing ? t.pause : t.play} onClick={() => (pos >= total && setPos(0), setPlaying((p) => !p))} className="grid size-11 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]">
                <Icon name={playing ? 'pause' : 'play'} size={18} />
              </button>
              <div className="min-w-0 flex-1">
                <div className="relative flex h-10 items-center gap-[2px]" aria-hidden>
                  {WAVE.map((h, i) => (
                    <span key={i} className={cn('w-full rounded-full transition-colors duration-200', i / WAVE.length <= share ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]', i % 2 && 'max-sm:hidden')} style={{ height: `${Math.round(h * 100)}%` }} />
                  ))}
                </div>
                <input type="range" min={0} max={total} value={pos} onChange={(e) => setPos(Number(e.target.value))} aria-label={t.seek} aria-valuetext={`${clock(pos)} / ${clock(total)}`} className="mt-1 h-5 w-full cursor-pointer accent-[var(--primary)]" />
              </div>
              <span className="w-20 shrink-0 text-right text-[12.5px] tabular-nums text-muted-foreground">
                {clock(pos)} / {clock(total)}
              </span>
            </div>
            <ol ref={list} className="kit-scrollbar relative max-h-[26rem] space-y-1 overflow-y-auto p-2 pb-6 [mask-image:linear-gradient(to_bottom,black_calc(100%-1.5rem),transparent)] sm:p-3 sm:pb-6">
              {lines.map((l) => {
                const on = l.id === current && pos > 0;
                const assistant = l.speaker === 'assistant';
                return (
                  <li key={l.id} data-line={l.id}>
                    <button type="button" aria-label={fill(t.jump, { time: clock(l.at ?? 0) })} onClick={() => setPos(l.at ?? 0)} className={cn('grid w-full grid-cols-[3rem_minmax(0,1fr)] gap-3 rounded-xl px-2 py-2.5 text-left transition-colors duration-300 sm:px-3', on ? 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))]' : 'hover:bg-accent/60')}>
                      <span className="pt-0.5 text-[12px] tabular-nums text-subtle-foreground">{clock(l.at ?? 0)}</span>
                      <span className="min-w-0">
                        <span className={cn('block text-[12px] font-medium', assistant ? 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'text-muted-foreground')}>{assistant ? name ?? t.assistant : call?.caller ?? t.caller}</span>
                        <span className={cn('mt-0.5 block text-pretty text-[14.5px] leading-relaxed', on ? 'text-foreground' : 'text-muted-foreground')}>{l.text}</span>
                      </span>
                    </button>
                  </li>
                );
              })}
            </ol>
          </div>
        </Reveal>
        <Reveal delay={160} className="space-y-4 lg:sticky lg:top-6">
          {call && (
            <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
              <div className="flex items-center gap-3">
                <span aria-hidden className="grid size-10 place-items-center rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground">
                  <Icon name="phone-call" size={18} />
                </span>
                <div className="min-w-0">
                  <p className="truncate text-[14.5px] font-medium text-foreground">{call.caller}</p>
                  <p className="text-[12.5px] tabular-nums text-muted-foreground">
                    {[call.number, call.date].filter(Boolean).join(' · ')}
                  </p>
                </div>
              </div>
              {call.outcome && (
                <p className="mt-3 flex items-center gap-1.5 rounded-lg bg-[color-mix(in_oklab,var(--success)_12%,transparent)] px-3 py-2 text-[13px] font-medium text-success-foreground">
                  <Icon name="check-circle" size={15} />
                  {call.outcome}
                </p>
              )}
            </div>
          )}
          {summary.length > 0 && (
            <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
              <p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-foreground">
                <Icon name="sparkle" size={14} className="text-primary" />
                {t.summary}
              </p>
              <Stagger as="ul" step={70} className="divide-y divide-border">
                {summary.map((s) => (
                  <li key={s.label} className="flex justify-between gap-3 py-2 text-[13px]">
                    <span className="text-muted-foreground">{s.label}</span>
                    <span className="text-right text-foreground">{s.value}</span>
                  </li>
                ))}
              </Stagger>
            </div>
          )}
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* voice-picker                                                        */
/* ------------------------------------------------------------------ */

function VoicePicker({ t, title, voices = [], defaultVoice, note, className }: P) {
  const locale = useLocale();
  const [voice, setVoice] = useState(defaultVoice ?? voices[0]?.id);
  const [used, setUsed] = useState(defaultVoice ?? voices[0]?.id);
  const [playing, setPlaying] = useState<string | null>(null);
  const [speed, setSpeed] = useState(100);
  const [pitch, setPitch] = useState(0);
  const refs = useRef<(HTMLDivElement | null)[]>([]);
  useEffect(() => {
    if (!playing) return;
    const id = window.setTimeout(() => setPlaying(null), 3200);
    return () => window.clearTimeout(id);
  }, [playing]);
  const cur = voices.find((v) => v.id === voice);
  const nf = (n: number) => (n / 100).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
  const slider = (label: string, value: number, set: (n: number) => void, min: number, max: number, lo: string, hi: string, show: string) => (
    <label className="block">
      <span className="flex items-baseline justify-between text-[13px]">
        <span className="font-medium text-foreground">{label}</span>
        <span className="tabular-nums text-muted-foreground">{show}</span>
      </span>
      <input type="range" min={min} max={max} value={value} onChange={(e) => set(Number(e.target.value))} className="mt-2 h-6 w-full cursor-pointer accent-[var(--primary)]" />
      <span className="flex justify-between text-[11.5px] text-subtle-foreground">
        <span>{lo}</span>
        <span>{hi}</span>
      </span>
    </label>
  );
  return (
    <Stage title={title} className={className} wide>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start lg:gap-6">
        <Stagger role="radiogroup" aria-label={t.voice} step={80} className="grid grid-cols-1 gap-3 sm:grid-cols-2">
          {voices.map((v, i) => {
            const on = v.id === voice;
            const live = playing === v.id;
            return (
              <div
                key={v.id}
                ref={(el) => {
                  refs.current[i] = el;
                }}
                role="radio"
                aria-checked={on}
                tabIndex={on ? 0 : -1}
                onClick={() => setVoice(v.id)}
                onKeyDown={(e) => {
                  const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                  if (e.key === ' ' || e.key === 'Enter') (e.preventDefault(), setVoice(v.id));
                  if (!d) return;
                  e.preventDefault();
                  const n = (i + d + voices.length) % voices.length;
                  setVoice(voices[n].id);
                  refs.current[n]?.focus();
                }}
                className={cn('cursor-pointer rounded-2xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : 'border-border hover:border-border-strong')}
              >
                <div className="flex items-start gap-3">
                  <button
                    type="button"
                    aria-label={live ? t.stopPreview : fill(t.preview, { name: v.name })}
                    onClick={(e) => (e.stopPropagation(), setPlaying(live ? null : v.id))}
                    className={cn('grid size-11 shrink-0 place-items-center rounded-full transition-colors', live ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_20%,var(--card))]')}
                  >
                    <Icon name={live ? 'pause' : 'play'} size={17} />
                  </button>
                  <div className="min-w-0 flex-1">
                    <p className="flex items-center gap-2 text-[15px] font-semibold text-foreground">
                      {v.name}
                      {v.tag && <span className="rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] px-1.5 py-px text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_72%,var(--foreground))]">{v.tag}</span>}
                    </p>
                    <p className="text-[12.5px] text-muted-foreground">{v.character}</p>
                  </div>
                  <span className={cn('grid size-6 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong text-transparent')}>
                    <Icon name="check" size={13} strokeWidth={2.4} />
                  </span>
                </div>
                <div className="mt-3 flex h-8 items-center gap-[3px]" aria-hidden>
                  {wave(40, i + 1.4).map((h, k) => (
                    <span key={k} className={cn('w-full rounded-full', live ? 'vca-bar bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_16%,transparent)]')} style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${(k % 8) * 70}ms` } as React.CSSProperties} />
                  ))}
                </div>
                <p className={cn('mt-2 text-pretty text-[13px] italic leading-relaxed transition-colors', live ? 'text-foreground' : 'text-muted-foreground')}>{t.quoteOpen}{v.sample}{t.quoteClose}</p>
                <p className="mt-2 text-[12px] text-subtle-foreground">{v.languages.join(' · ')}</p>
              </div>
            );
          })}
        </Stagger>
        <Reveal delay={160} className="lg:sticky lg:top-6">
          <div className="space-y-5 rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
            <p className="text-[13px] font-medium text-foreground">{cur?.name}</p>
            {slider(t.speed, speed, setSpeed, 75, 150, t.slower, t.faster, `${nf(speed)}×`)}
            {slider(t.pitch, pitch, setPitch, -4, 4, t.lower, t.higher, pitch > 0 ? `+${pitch}` : String(pitch))}
            <button type="button" onClick={() => setUsed(voice)} disabled={used === voice} aria-live="polite" className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-primary text-[13.5px] font-medium text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] disabled:text-success-foreground sm:h-9">
              <Icon name={used === voice ? 'check-circle' : 'check'} size={15} />
              {used === voice ? t.inUse : t.use}
            </button>
          </div>
          {note && <p className="mt-3 px-1 text-[12.5px] leading-relaxed text-muted-foreground">{note}</p>}
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* push-to-talk                                                        */
/* ------------------------------------------------------------------ */

function PushToTalk({ t, title, name, lines: initial = [], exchange, note, className }: P) {
  const [lines, setLines] = useState(initial);
  const [rec, setRec] = useState(false);
  const [secs, setSecs] = useState(0);
  const [open, setOpen] = useState<string[]>(initial.slice(-1).map((l) => l.id));
  const [playing, setPlaying] = useState<string | null>(null);
  const seq = useRef(0);
  const list = useRef<HTMLOListElement>(null);
  useEffect(() => {
    if (!rec) return;
    setSecs(0);
    const id = window.setInterval(() => setSecs((s) => s + 1), 1000);
    return () => window.clearInterval(id);
  }, [rec]);
  useEffect(() => {
    if (!playing) return;
    const id = window.setTimeout(() => setPlaying(null), 2400);
    return () => window.clearTimeout(id);
  }, [playing]);
  useEffect(() => {
    const el = list.current;
    if (el) el.scrollTo({ top: el.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  }, [lines.length]);
  const begin = () => setRec(true);
  const finish = () => {
    if (!rec) return;
    setRec(false);
    const a = `u${++seq.current}`;
    const b = `a${seq.current}`;
    setLines((s) => [...s, { id: a, speaker: 'user', text: exchange?.heard ?? t.heard, duration: Math.max(2, secs) }]);
    setOpen((o) => [...o, a]);
    window.setTimeout(() => (setLines((s) => [...s, { id: b, speaker: 'assistant', text: exchange?.reply ?? t.reply, duration: 4 }]), setOpen((o) => [...o, b])), prefersReducedMotion() ? 0 : 1100);
  };
  return (
    <Stage title={title} className={className}>
      <Reveal effect="scale" delay={60}>
        <div className="mx-auto max-w-[26rem] overflow-hidden rounded-[28px] border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
          <div className="flex items-center gap-3 border-b border-border px-4 py-3">
            <span aria-hidden className="grid size-9 place-items-center rounded-full bg-primary text-primary-foreground">
              <Icon name="headset" size={17} />
            </span>
            <p className="text-[14.5px] font-semibold text-foreground">{name}</p>
          </div>
          <ol ref={list} className="kit-scrollbar relative h-[22rem] space-y-3 overflow-y-auto px-3 py-4">
            {lines.map((l) => {
              const mine = l.speaker === 'user';
              const shown = open.includes(l.id);
              const live = playing === l.id;
              return (
                <li key={l.id} className={cn('vca-up flex', mine ? 'justify-end' : 'justify-start')}>
                  <div className={cn('w-[82%] rounded-2xl px-3 py-2.5', mine ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] text-foreground')}>
                    <div className="flex items-center gap-2">
                      <button type="button" aria-label={live ? t.pause : t.play} onClick={() => setPlaying(live ? null : l.id)} className={cn('grid size-11 shrink-0 place-items-center rounded-full sm:size-9', mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_20%,transparent)]' : 'bg-card text-foreground shadow-[var(--shadow-sm)]')}>
                        <Icon name={live ? 'pause' : 'play'} size={15} />
                      </button>
                      <span className="flex h-7 min-w-0 flex-1 items-center gap-[2px]" aria-hidden>
                        {wave(28, l.text.length % 7 + 1).map((h, k) => (
                          <span key={k} className={cn('w-full rounded-full', mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_80%,transparent)]' : 'bg-[color-mix(in_oklab,var(--foreground)_40%,transparent)]', live && 'vca-bar')} style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${(k % 6) * 80}ms` } as React.CSSProperties} />
                        ))}
                      </span>
                      <span className="text-[11.5px] tabular-nums opacity-80">{clock(l.duration ?? 3)}</span>
                    </div>
                    {shown && <p className="mt-2 text-pretty text-[13.5px] leading-snug">{l.text}</p>}
                    <button type="button" onClick={() => setOpen((o) => (shown ? o.filter((x) => x !== l.id) : [...o, l.id]))} className={cn('-mb-1.5 mt-0.5 inline-flex min-h-11 items-center text-[11.5px] font-medium underline-offset-2 hover:underline sm:mb-0 sm:mt-1 sm:min-h-8', mine ? 'text-[color-mix(in_oklab,var(--primary-foreground)_85%,transparent)]' : 'text-muted-foreground')}>
                      {shown ? t.hideText : t.showText}
                    </button>
                  </div>
                </li>
              );
            })}
          </ol>
          <div className="border-t border-border px-4 pb-5 pt-4 text-center">
            <p className={cn('mb-3 text-[13px] font-medium tabular-nums', rec ? 'text-destructive' : 'text-muted-foreground')} aria-live="polite">
              {rec ? `${t.recording} ${clock(secs)}` : t.hold}
            </p>
            <button
              type="button"
              aria-pressed={rec}
              aria-label={rec ? t.release : t.hold}
              onPointerDown={(e) => (e.preventDefault(), begin())}
              onPointerUp={finish}
              onPointerLeave={finish}
              onKeyDown={(e) => (e.key === ' ' || e.key === 'Enter') && !e.repeat && (e.preventDefault(), begin())}
              onKeyUp={(e) => (e.key === ' ' || e.key === 'Enter') && finish()}
              className={cn('relative mx-auto grid size-20 touch-none select-none place-items-center rounded-full text-primary-foreground shadow-[var(--shadow-lg)] transition-[transform,background-color] duration-200', rec ? 'scale-110 bg-destructive' : 'bg-primary hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]')}
            >
              {rec && <span aria-hidden className="vca-ring absolute inset-0 rounded-full border-2 border-destructive" />}
              <Icon name="mic" size={30} />
            </button>
            <p className="mt-3 text-[12px] text-subtle-foreground max-sm:hidden">{t.spaceHint}</p>
          </div>
        </div>
        {note && <p className="mx-auto mt-3 max-w-[26rem] px-1 text-center text-[12.5px] text-muted-foreground">{note}</p>}
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Voice interfaces: a full conversation around an orb that listens, thinks
 * and speaks with live captions, the transcript of a call the assistant took
 * with playback and summary, choosing a voice with samples, and push to talk
 * with voice messages and their text.
 */
export function VoiceAssistant({ variant = 'orb', labels, ...props }: VoiceAssistantProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const key = JSON.stringify(props.lines ?? props.voices ?? null);
  const body = (() => {
    switch (variant) {
      case 'call-transcript':
        return <CallTranscript {...p} />;
      case 'voice-picker':
        return <VoicePicker key={key} {...p} />;
      case 'push-to-talk':
        return <PushToTalk key={key} {...p} />;
      default:
        return <OrbView key={key} {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Häufige Fragen

Wie installiere ich Sprachassistent?

Mit aiw add @ai-website/voice-assistant. Der Befehl legt die Datei components/ui/voice-assistant.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und motion. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Sprachassistent?

4 Varianten, Sprachgespräch, Anrufmitschrift, Stimme wählen und Halten zum Sprechen. Du wählst sie mit der Prop variant, zum Beispiel variant="call-transcript".

Kann ich die Texte von Sprachassistent übersetzen?

Ja. Sprachassistent bringt 40 eigene Texte auf Deutsch und Englisch mit, etwa „Hört zu“, „Denkt nach“ und „Spricht“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Sprachassistent einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Sprachassistent mit aiw_components und installieren sie mit aiw_add @ai-website/voice-assistant. Nenn im Prompt die Variante, etwa variant="orb", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Sprachassistent ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden VoiceAssistant-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert