diff --git a/examples/ai-chatbot/README.md b/examples/ai-chatbot/README.md index c5a24c4f7..8956514da 100644 --- a/examples/ai-chatbot/README.md +++ b/examples/ai-chatbot/README.md @@ -1,7 +1,7 @@ # AI chatbot example A Next.js chatbot built with `stream-chat-react` and its `stream-chat-react/ai-components` entry point -(streaming messages, AI state indicator, AI message composer). Styles come from +(streaming messages, AI state indicator, AI message composer, voice agent visualizations). Styles come from `stream-chat-react/css/ai-components.css`. ## Getting started @@ -27,4 +27,10 @@ The hosted demo server only answers for channels of Stream's demo app. With your `STREAM_API_SECRET` the AI agent never joins your channels, so run your own AI agent server (it must expose `POST /start-ai-agent` and `POST /summarize`, as called from `src/components/api.ts`) and point `NEXT_PUBLIC_AI_SERVER_URL` at it. +## Voice mode + +The waveform button in the composer opens voice mode: a hands-free conversation in which the AI agent is shown as a `VoiceGlow` and your microphone as a `VoiceWave`. It listens to you, sends what you said, and reads the answer aloud before listening again. + +It needs a browser with the Web Speech API's `SpeechRecognition` (Chrome, Edge or Safari; the button is hidden elsewhere) and access to the microphone. Reading answers aloud uses `speechSynthesis`; where it is missing, the answer is only shown in the chat. + See [implementation.md](./implementation.md) for implementation notes. diff --git a/examples/ai-chatbot/implementation.md b/examples/ai-chatbot/implementation.md index c449dbab3..163b21bdb 100644 --- a/examples/ai-chatbot/implementation.md +++ b/examples/ai-chatbot/implementation.md @@ -340,6 +340,23 @@ examples/ai-chatbot/ - Posts to `/summarize` endpoint with OpenAI platform - Returns summary string from API response +### 18. **useSendAIMessage.ts** + +- The send path shared by `MessageInputBar` and voice mode: composes the message (with attachments), watches the channel if needed, starts the AI agent when it is not watching yet, sends, records the message against the rate limit and summarizes a new conversation +- Also holds `availableModels`, `defaultPlatformModel` and `isWatchedByAI` + +### 19. **VoiceMode** + +A hands-free conversation with the agent, built on `VoiceGlow`, `VoiceWave`, `useAudioLevel` and `useSpeechToText` from `stream-chat-react/ai-components`. + +- `VoiceModeButton` sits in the composer next to the dictation button and appears only where the browser has `SpeechRecognition` (Chrome, Edge, Safari) +- `VoiceModeProvider` (in `ChatContainer`) holds whether voice mode is open; `VoiceMode` is mounted inside `Channel` only while open, so the microphone is released when it closes +- The overlay shows a large `VoiceGlow` for the agent and a `VoiceWave` for the user's microphone, fed by `useAudioLevel` on the `getUserMedia` stream +- `useVoiceConversation` runs the turn loop: listen (dictation ends on silence), send through `useSendAIMessage`, wait for the agent's `ai_indicator` events, read the finished answer aloud with `speechSynthesis` (markdown stripped, code skipped), then listen again +- Voice agent state: `connecting` while the microphone is requested or the agent is being started, `listening` during the user's turn, `thinking` while the agent works, `speaking` while the answer is read aloud, `idle` when paused or stopped +- Pause/resume and end buttons; Escape closes it; the rate limit and a denied microphone stop it with a message +- `--str-chat__ai-voice-backdrop` is set to the demo background in `index.scss`, so the visualizations pick their light or dark look from the demo theme rather than the SDK theme + --- ## Next.js Architecture diff --git a/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.tsx b/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.tsx index 9ffa6bc1b..5e9027ef4 100644 --- a/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.tsx +++ b/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.tsx @@ -15,6 +15,7 @@ import { MessageBubble } from '../MessageBubble'; import { MessageInputBar } from '../MessageInputBar'; import { AIStateIndicator } from '../AIStateIndicator'; import { TopNavBar } from '../TopNavBar'; +import { VoiceMode, VoiceModeProvider } from '../VoiceMode'; import './ChatContainer.scss'; interface ChatContainerProps { @@ -50,14 +51,17 @@ export const ChatContainer = ({ onToggleSidebar }: ChatContainerProps) => { MessageComposerUI: MessageInputBar, }} > - - - - - - - - + + + + + + + + + + + ); diff --git a/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx index 3880f9988..f2cccf77f 100644 --- a/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx +++ b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx @@ -3,44 +3,26 @@ import { AIMessageComposer } from 'stream-chat-react/ai-components'; import { useEffect, useState } from 'react'; import { - type Channel, isImageFile, type LocalUploadAttachment, type UploadRequestFn, } from 'stream-chat'; import { useAttachmentsForPreview, - useChannelActionContext, useChannelStateContext, useChatContext, useMessageComposerController, } from 'stream-chat-react'; -import { startAiAgent, summarizeConversation } from '@/components/api'; -import { - checkRateLimit, - formatTimeRemaining, - recordMessage, -} from '@/components/rateLimitUtils'; +import { checkRateLimit, formatTimeRemaining } from '@/components/rateLimitUtils'; +import { availableModels, useSendAIMessage } from '@/components/useSendAIMessage'; +import { VoiceModeButton } from '@/components/VoiceMode'; import './MessageInputBar.scss'; -const isWatchedByAI = (channel: Channel) => - Object.keys(channel.state.watchers).some((watcher) => watcher.startsWith('ai-bot')); - -const availableModels = [ - { platform: 'openai', value: 'gpt-5.4-mini', label: 'GPT-5.4 mini' }, - { platform: 'openai', value: 'gpt-5.4-nano', label: 'GPT-5.4 nano' }, - { - platform: 'gemini', - value: 'gemini-flash-latest', - label: 'Gemini Flash (latest)', - }, -] as const; - export const MessageInputBar = () => { const { client } = useChatContext(); - const { updateMessage, sendMessage } = useChannelActionContext(); const { channel } = useChannelStateContext(); const composer = useMessageComposerController(); + const sendAIMessage = useSendAIMessage(); const { attachments } = useAttachmentsForPreview(); const [selectedPlatformModel, setSelectedPlatformModel] = useState(); @@ -124,46 +106,14 @@ export const MessageInputBar = () => { const platformModel = formData.get('platform-model'); setSelectedPlatformModel(platformModel as string); - composer.textComposer.setText(message as string); - - const composedData = await composer.compose(); - - if (!composedData) return; - - target.reset(); - composer.clear(); - - updateMessage(composedData?.localMessage); - - if (!channel.initialized) { - await channel.watch(); - } - - const [platform, model] = (platformModel as string).split('|'); - - if (!isWatchedByAI(channel)) { - await startAiAgent(channel, model, platform); - } - - await sendMessage(composedData); - - // Record message after successful send - recordMessage(channel.id!); + const sent = await sendAIMessage({ + onComposed: () => target.reset(), + platformModel: platformModel as string, + text: message as string, + }); // Update rate limit state - const newState = checkRateLimit(channel.id!); - setRateLimitState(newState); - - if (typeof channel.data?.summary !== 'string' || !channel.data.summary.length) { - const summary = await summarizeConversation(message as string).catch(() => { - console.warn('Failed to summarize conversation'); - return null; - }); - - if (typeof summary === 'string' && summary.length > 0) { - await channel.update({ summary }); - } - } + if (sent) setRateLimitState(checkRateLimit(channel.id!)); }} > @@ -200,6 +150,7 @@ export const MessageInputBar = () => {
+ = { + error: 'Voice mode stopped', + listening: 'Listening', + paused: 'Paused', + speaking: 'Speaking', + starting: 'Connecting', + waiting: 'Thinking', +}; + +const VoiceConversation = () => { + const { close } = useVoiceMode(); + const { agentState, error, micLevel, pause, phase, reply, resume, transcript } = + useVoiceConversation(); + const closeRef = useRef(null); + + useEffect(() => { + closeRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [close]); + + const paused = phase === 'paused'; + const caption = + phase === 'error' + ? error + : phase === 'speaking' + ? reply + : phase === 'listening' + ? transcript || 'Go ahead, I’m listening.' + : phase === 'waiting' + ? transcript + : ''; + + return ( +
+
+ +

+ {STATUS[phase]} +

+

{caption}

+
+ + {/* The glow is the agent; the wave under it is the user's microphone. */} + + +
+ + +
+
+ ); +}; + +/** + * A hands-free conversation with the agent over the chat: it listens, sends what was said, + * shows the agent thinking and reads its answer aloud. Mounted only while open, so the + * microphone is released as soon as it closes. + */ +export const VoiceMode = () => { + const { isOpen } = useVoiceMode(); + return isOpen ? : null; +}; diff --git a/examples/ai-chatbot/src/components/VoiceMode/VoiceModeButton.tsx b/examples/ai-chatbot/src/components/VoiceMode/VoiceModeButton.tsx new file mode 100644 index 000000000..c28a69f93 --- /dev/null +++ b/examples/ai-chatbot/src/components/VoiceMode/VoiceModeButton.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useVoiceMode } from './VoiceModeContext'; + +const supportsVoiceMode = () => + typeof window !== 'undefined' && + !!( + (window as Window & { SpeechRecognition?: unknown }).SpeechRecognition ?? + (window as Window & { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition + ) && + !!navigator.mediaDevices?.getUserMedia; + +/** Opens voice mode; shown only where the browser can listen (Chrome, Edge, Safari). */ +export const VoiceModeButton = () => { + const { isOpen, open } = useVoiceMode(); + // Support is known only in the browser, so the button appears after hydration. + const [isSupported, setIsSupported] = useState(false); + useEffect(() => setIsSupported(supportsVoiceMode()), []); + + if (!isSupported) return null; + + return ( + + ); +}; diff --git a/examples/ai-chatbot/src/components/VoiceMode/VoiceModeContext.tsx b/examples/ai-chatbot/src/components/VoiceMode/VoiceModeContext.tsx new file mode 100644 index 000000000..9018bc8a8 --- /dev/null +++ b/examples/ai-chatbot/src/components/VoiceMode/VoiceModeContext.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'; + +type VoiceModeContextValue = { + close: () => void; + isOpen: boolean; + open: () => void; +}; + +const VoiceModeContext = createContext(undefined); + +export const useVoiceMode = () => { + const context = useContext(VoiceModeContext); + if (!context) { + throw new Error('useVoiceMode must be used within VoiceModeProvider'); + } + return context; +}; + +export const VoiceModeProvider = ({ children }: { children: ReactNode }) => { + const [isOpen, setIsOpen] = useState(false); + + const value = useMemo( + () => ({ + close: () => setIsOpen(false), + isOpen, + open: () => setIsOpen(true), + }), + [isOpen], + ); + + return {children}; +}; diff --git a/examples/ai-chatbot/src/components/VoiceMode/index.ts b/examples/ai-chatbot/src/components/VoiceMode/index.ts new file mode 100644 index 000000000..cfe736dc0 --- /dev/null +++ b/examples/ai-chatbot/src/components/VoiceMode/index.ts @@ -0,0 +1,3 @@ +export * from './VoiceMode'; +export * from './VoiceModeButton'; +export * from './VoiceModeContext'; diff --git a/examples/ai-chatbot/src/components/VoiceMode/useVoiceConversation.ts b/examples/ai-chatbot/src/components/VoiceMode/useVoiceConversation.ts new file mode 100644 index 000000000..2723b0612 --- /dev/null +++ b/examples/ai-chatbot/src/components/VoiceMode/useVoiceConversation.ts @@ -0,0 +1,277 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Channel } from 'stream-chat'; +import { + AIStates, + useAIState, + useChannelStateContext, + useChatContext, +} from 'stream-chat-react'; +import { + useAudioLevel, + useSpeechToText, + type VoiceAgentState, +} from 'stream-chat-react/ai-components'; +import { checkRateLimit } from '@/components/rateLimitUtils'; +import { isWatchedByAI, useSendAIMessage } from '@/components/useSendAIMessage'; + +/** + * Where the conversation is: + * - `starting`: asking for the microphone; + * - `listening`: dictating the user's turn; + * - `waiting`: the turn was sent and the agent is working on its answer; + * - `speaking`: reading the answer aloud; + * - `paused`: the user muted voice mode; + * - `error`: it can't go on (no microphone, rate limit, ...). + */ +export type VoicePhase = + | 'starting' + | 'listening' + | 'waiting' + | 'speaking' + | 'paused' + | 'error'; + +const WORKING_STATES: string[] = [ + AIStates.Thinking, + AIStates.ExternalSources, + AIStates.Generating, +]; + +/** Long answers are cut, as browsers stop reading aloud after a while. */ +const MAX_SPOKEN_LENGTH = 1200; +/** How long the agent may take to start on an answer before the turn is given up. */ +const ANSWER_TIMEOUT = 20_000; + +const canSpeak = () => typeof window !== 'undefined' && 'speechSynthesis' in window; + +/** Markdown as plain prose for reading aloud: code is skipped, formatting dropped. */ +export const toSpeech = (markdown: string) => + markdown + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`]*)`/g, '$1') + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, '') + .replace(/[*_~|]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_SPOKEN_LENGTH); + +const lastAgentMessage = (channel: Channel, userId?: string) => { + const messages = channel.state.messages; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].user?.id !== userId) return messages[i]; + } + return undefined; +}; + +/** + * A hands-free conversation with the AI agent on the current channel: listen to the user, + * send what they said, wait for the agent's answer, read it aloud, then listen again. + */ +export const useVoiceConversation = () => { + const { client } = useChatContext(); + const { channel } = useChannelStateContext(); + const { aiState } = useAIState(channel); + const sendAIMessage = useSendAIMessage(); + + const [phase, setPhase] = useState('starting'); + const [mic, setMic] = useState(null); + const [transcript, setTranscript] = useState(''); + const [reply, setReply] = useState(''); + const [error, setError] = useState(null); + const [agentStarting, setAgentStarting] = useState(false); + + const phaseRef = useRef(phase); + useEffect(() => { + phaseRef.current = phase; + }, [phase]); + const transcriptRef = useRef(''); + const sawAgentWorkRef = useRef(false); + // The agent's last answer when a turn was sent, to tell a new answer from it. + const previousAnswerIdRef = useRef(undefined); + const wasListeningRef = useRef(false); + + const micLevel = useAudioLevel(mic); + + const fail = useCallback((message: string) => { + setError(message); + setPhase('error'); + }, []); + + const { isListening, isSupported, startListening, stopListening } = useSpeechToText({ + onError: (message) => { + // Silence and aborts end a turn on their own; anything else stops voice mode. + if (/no speech|aborted/i.test(message)) return; + fail(message); + }, + onTranscript: (text) => { + transcriptRef.current = text; + setTranscript(text); + }, + }); + + // The microphone, for the level of the user's voice; released when voice mode closes. + useEffect(() => { + let stream: MediaStream | undefined; + let cancelled = false; + if (!navigator.mediaDevices?.getUserMedia) { + fail('This browser cannot use a microphone.'); + return; + } + navigator.mediaDevices + .getUserMedia({ audio: true }) + .then((granted) => { + if (cancelled) { + granted.getTracks().forEach((track) => track.stop()); + return; + } + stream = granted; + setMic(granted); + setPhase((current) => (current === 'starting' ? 'listening' : current)); + }) + .catch(() => fail('Voice mode needs access to your microphone.')); + return () => { + cancelled = true; + stream?.getTracks().forEach((track) => track.stop()); + }; + }, [fail]); + + // Start dictating whenever it is the user's turn and recognition is not running. + useEffect(() => { + if (phase !== 'listening' || isListening || !isSupported) return; + // Recognition can refuse to restart in the same tick it ended. + const timeout = setTimeout(() => { + try { + transcriptRef.current = ''; + setTranscript(''); + startListening(); + } catch { + // Already started; the next end restarts it. + } + }, 150); + return () => clearTimeout(timeout); + }, [isListening, isSupported, phase, startListening]); + + const submit = useCallback( + (text: string) => { + if (checkRateLimit(channel.id!).isLimited) { + fail('Limit reached, 10 messages per conversation.'); + return; + } + sawAgentWorkRef.current = false; + previousAnswerIdRef.current = lastAgentMessage(channel, client.userID)?.id; + setReply(''); + setAgentStarting(!isWatchedByAI(channel)); + setPhase('waiting'); + sendAIMessage({ text }).catch(() => fail('Your message could not be sent.')); + }, + [channel, client.userID, fail, sendAIMessage], + ); + + // A turn ends when recognition stops: send what was said, or listen again after silence. + useEffect(() => { + if (isListening) { + wasListeningRef.current = true; + return; + } + if (!wasListeningRef.current) return; + wasListeningRef.current = false; + if (phaseRef.current !== 'listening') return; + const text = transcriptRef.current.trim(); + if (text) submit(text); + }, [isListening, submit]); + + const speak = useCallback((markdown: string) => { + const text = toSpeech(markdown); + if (!text || !canSpeak()) { + setPhase('listening'); + return; + } + setReply(text); + setPhase('speaking'); + const utterance = new SpeechSynthesisUtterance(text); + const done = () => { + if (phaseRef.current === 'speaking') setPhase('listening'); + }; + utterance.onend = done; + utterance.onerror = done; + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(utterance); + }, []); + + // The agent's indicator: working while it thinks and writes, idle again once the answer is in. + useEffect(() => { + if (phaseRef.current !== 'waiting') return; + if (WORKING_STATES.includes(aiState)) { + sawAgentWorkRef.current = true; + setAgentStarting(false); + return; + } + if (!sawAgentWorkRef.current) return; + speak(lastAgentMessage(channel, client.userID)?.text ?? ''); + }, [aiState, channel, client.userID, speak]); + + // A turn the agent is never seen working on (no indicator events, or missed ones) still ends: + // with its answer if one came in, otherwise with an error, rather than waiting forever. + useEffect(() => { + if (phase !== 'waiting') return; + const timeout = setTimeout(() => { + if (sawAgentWorkRef.current) return; + const answer = lastAgentMessage(channel, client.userID); + if (answer && answer.id !== previousAnswerIdRef.current) speak(answer.text ?? ''); + else fail('The agent did not answer.'); + }, ANSWER_TIMEOUT); + return () => clearTimeout(timeout); + }, [channel, client.userID, fail, phase, speak]); + + const pause = useCallback(() => { + setPhase('paused'); + stopListening(); + if (canSpeak()) window.speechSynthesis.cancel(); + }, [stopListening]); + + const resume = useCallback(() => setPhase('listening'), []); + + // Nothing keeps talking or listening once voice mode closes. + useEffect( + () => () => { + if (canSpeak()) window.speechSynthesis.cancel(); + }, + [], + ); + useEffect(() => { + if (phase === 'paused' || phase === 'error') stopListening(); + }, [phase, stopListening]); + + const agentState: VoiceAgentState = (() => { + switch (phase) { + case 'starting': + return 'connecting'; + case 'listening': + return 'listening'; + case 'waiting': + if (agentStarting) return 'connecting'; + // Without read-aloud, the answer being written is the agent speaking. + return aiState === AIStates.Generating && !canSpeak() ? 'speaking' : 'thinking'; + case 'speaking': + return 'speaking'; + default: + return 'idle'; + } + })(); + + return { + agentState, + error, + isSupported, + micLevel, + pause, + phase, + reply, + resume, + transcript, + }; +}; diff --git a/examples/ai-chatbot/src/components/index.scss b/examples/ai-chatbot/src/components/index.scss index 1cb4a39b0..578b1684a 100644 --- a/examples/ai-chatbot/src/components/index.scss +++ b/examples/ai-chatbot/src/components/index.scss @@ -96,6 +96,9 @@ --str-chat__ai-syntax-error: #f87171; --str-chat__ai-syntax-whitespace: hsla(0, 0%, 100%, 0.1); + /* VoiceWave and VoiceGlow sit on the demo's background, not the SDK theme's */ + --str-chat__ai-voice-backdrop: var(--ai-demo-bg-primary); + color-scheme: dark; color: var(--ai-demo-text-primary); background-color: var(--ai-demo-bg-primary); diff --git a/examples/ai-chatbot/src/components/useSendAIMessage.ts b/examples/ai-chatbot/src/components/useSendAIMessage.ts new file mode 100644 index 000000000..f18240112 --- /dev/null +++ b/examples/ai-chatbot/src/components/useSendAIMessage.ts @@ -0,0 +1,94 @@ +'use client'; + +import { useCallback } from 'react'; +import type { Channel } from 'stream-chat'; +import { + useChannelActionContext, + useChannelStateContext, + useMessageComposerController, +} from 'stream-chat-react'; +import { startAiAgent, summarizeConversation } from '@/components/api'; +import { recordMessage } from '@/components/rateLimitUtils'; + +export const availableModels = [ + { platform: 'openai', value: 'gpt-5.4-mini', label: 'GPT-5.4 mini' }, + { platform: 'openai', value: 'gpt-5.4-nano', label: 'GPT-5.4 nano' }, + { + platform: 'gemini', + value: 'gemini-flash-latest', + label: 'Gemini Flash (latest)', + }, +] as const; + +export const defaultPlatformModel = `${availableModels[0].platform}|${availableModels[0].value}`; + +export const isWatchedByAI = (channel: Channel) => + Object.keys(channel.state.watchers).some((watcher) => watcher.startsWith('ai-bot')); + +type SendAIMessageOptions = { + text: string; + /** `platform|model`, as the model select submits it. */ + platformModel?: string; + /** Called once the message is composed, before the agent is started and it is sent. */ + onComposed?: () => void; +}; + +/** + * Sends a message to the AI agent: composes it (with any attachments), starts the agent on the + * channel if it isn't watching yet, sends it, records it against the rate limit and gives a new + * conversation its summary. Shared by the composer and voice mode. + * + * Resolves to whether a message was sent. + */ +export const useSendAIMessage = () => { + const { updateMessage, sendMessage } = useChannelActionContext(); + const { channel } = useChannelStateContext(); + const composer = useMessageComposerController(); + + return useCallback( + async ({ + text, + platformModel = defaultPlatformModel, + onComposed, + }: SendAIMessageOptions) => { + composer.textComposer.setText(text); + + const composedData = await composer.compose(); + + if (!composedData) return false; + + onComposed?.(); + composer.clear(); + + updateMessage(composedData.localMessage); + + if (!channel.initialized) { + await channel.watch(); + } + + const [platform, model] = platformModel.split('|'); + + if (!isWatchedByAI(channel)) { + await startAiAgent(channel, model, platform); + } + + await sendMessage(composedData); + + recordMessage(channel.id!); + + if (typeof channel.data?.summary !== 'string' || !channel.data.summary.length) { + const summary = await summarizeConversation(text).catch(() => { + console.warn('Failed to summarize conversation'); + return null; + }); + + if (typeof summary === 'string' && summary.length > 0) { + await channel.update({ summary }); + } + } + + return true; + }, + [channel, composer, sendMessage, updateMessage], + ); +}; diff --git a/src/i18n/de.json b/src/i18n/de.json index dfee28918..449bc36c8 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -226,6 +226,7 @@ "aria/Unpin Message": "Anheftung aufheben", "aria/User selected: {{ user }}": "Benutzer ausgewählt: {{ user }}", "aria/video": "Video", + "aria/Voice agent: {{ state }}": "Sprachagent: {{ state }}", "aria/voice message": "Sprachnachricht", "aria/Voice message sent": "Sprachnachricht gesendet", "aria/Voice recording attached": "Sprachaufnahme angehängt", @@ -267,6 +268,7 @@ "Command not available while replying": "Befehl beim Antworten nicht verfügbar", "Commands": "Befehle", "Commands matching": "Übereinstimmende Befehle", + "Connecting": "Verbinde", "Connecting the dots": "Ich verbinde die Punkte", "Connection failure, reconnecting now...": "Verbindungsfehler, Wiederherstellung der Verbindung...", "Consulting the AI gods": "Ich befrage die KI-Götter", @@ -445,6 +447,7 @@ "Link": "Link", "linkCount_one": "Link", "linkCount_other": "{{ count }} Links", + "Listening": "Hört zu", "live": "live", "Live for {{duration}}": "Live für {{duration}}", "Live location": "Live-Standort", @@ -545,6 +548,7 @@ "Quote Reply": "Zitat-Antwort", "Reached the vote limit. Remove an existing vote first.": "Das Abstimmungslimit wurde erreicht. Entfernen Sie zuerst eine bestehende Stimme.", "Reading the digital tea leaves": "Ich lese die digitalen Teeblätter", + "Ready": "Bereit", "Recording format is not supported and cannot be reproduced": "Aufnahmeformat wird nicht unterstützt und kann nicht wiedergegeben werden", "Remind me": "Erinnern", "Remind Me": "Erinnern", @@ -613,6 +617,7 @@ "Slow Mode ON": "Langsamer Modus EIN", "Slow mode, wait {{ seconds }}s...": "Langsamer Modus, warte {{ seconds }}s...", "Some of the files will not be accepted": "Einige der Dateien werden nicht akzeptiert", + "Speaking": "Spricht", "Start typing to search": "Tippen Sie, um zu suchen", "Stop sharing": "Teilen beenden", "Submit": "Absenden", @@ -621,6 +626,7 @@ "Summoning my inner genius": "Ich beschwöre mein inneres Genie", "Tap to remove": "Tippen zum Entfernen", "Tap to remove: {{ reactionName }}": "Tippen zum Entfernen: {{ reactionName }}", + "Thinking": "Denkt nach", "Thinking really hard": "Ich denke angestrengt nach", "Thinking...": "Denken...", "this content could not be displayed": "Dieser Inhalt konnte nicht angezeigt werden", diff --git a/src/i18n/en.json b/src/i18n/en.json index 5adefcc7b..a9d61b90b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -226,6 +226,7 @@ "aria/Unpin Message": "Unpin Message", "aria/User selected: {{ user }}": "User selected: {{ user }}", "aria/video": "video", + "aria/Voice agent: {{ state }}": "Voice agent: {{ state }}", "aria/voice message": "voice message", "aria/Voice message sent": "Voice message sent", "aria/Voice recording attached": "Voice recording attached", @@ -267,6 +268,7 @@ "Command not available while replying": "Command not available while replying", "Commands": "Commands", "Commands matching": "Commands matching", + "Connecting": "Connecting", "Connecting the dots": "Connecting the dots", "Connection failure, reconnecting now...": "Connection failure, reconnecting now...", "Consulting the AI gods": "Consulting the AI gods", @@ -445,6 +447,7 @@ "Link": "Link", "linkCount_one": "Link", "linkCount_other": "{{ count }} links", + "Listening": "Listening", "live": "live", "Live for {{duration}}": "Live for {{duration}}", "Live location": "Live location", @@ -545,6 +548,7 @@ "Quote Reply": "Quote Reply", "Reached the vote limit. Remove an existing vote first.": "Reached the vote limit. Remove an existing vote first.", "Reading the digital tea leaves": "Reading the digital tea leaves", + "Ready": "Ready", "Recording format is not supported and cannot be reproduced": "Recording format is not supported and cannot be reproduced", "Remind me": "Remind me", "Remind Me": "Remind Me", @@ -613,6 +617,7 @@ "Slow Mode ON": "Slow Mode ON", "Slow mode, wait {{ seconds }}s...": "Slow mode, wait {{ seconds }}s...", "Some of the files will not be accepted": "Some of the files will not be accepted", + "Speaking": "Speaking", "Start typing to search": "Start typing to search", "Stop sharing": "Stop sharing", "Submit": "Submit", @@ -621,6 +626,7 @@ "Summoning my inner genius": "Summoning my inner genius", "Tap to remove": "Tap to remove", "Tap to remove: {{ reactionName }}": "Tap to remove: {{ reactionName }}", + "Thinking": "Thinking", "Thinking really hard": "Thinking really hard", "Thinking...": "Thinking...", "this content could not be displayed": "this content could not be displayed", diff --git a/src/i18n/es.json b/src/i18n/es.json index 773b86087..aff84ebbb 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -243,6 +243,7 @@ "aria/Unpin Message": "Desfijar mensaje", "aria/User selected: {{ user }}": "Usuario seleccionado: {{ user }}", "aria/video": "vídeo", + "aria/Voice agent: {{ state }}": "Agente de voz: {{ state }}", "aria/voice message": "mensaje de voz", "aria/Voice message sent": "Mensaje de voz enviado", "aria/Voice recording attached": "Grabación de voz adjuntada", @@ -284,6 +285,7 @@ "Command not available while replying": "Comando no disponible mientras se responde", "Commands": "Comandos", "Commands matching": "Coincidencia de comandos", + "Connecting": "Conectando", "Connecting the dots": "Conectando los puntos", "Connection failure, reconnecting now...": "Fallo de conexión, reconectando ahora...", "Consulting the AI gods": "Consultando a los dioses de la IA", @@ -465,6 +467,7 @@ "linkCount_one": "Enlace", "linkCount_many": "{{ count }} enlaces", "linkCount_other": "{{ count }} enlaces", + "Listening": "Escuchando", "live": "En vivo", "Live for {{duration}}": "En vivo durante {{duration}}", "Live location": "Ubicación en vivo", @@ -565,6 +568,7 @@ "Quote Reply": "Responder con cita", "Reached the vote limit. Remove an existing vote first.": "Se ha alcanzado el límite de votos. Elimina un voto existente primero.", "Reading the digital tea leaves": "Leyendo las hojas de té digitales", + "Ready": "Listo", "Recording format is not supported and cannot be reproduced": "El formato de grabación no es compatible y no se puede reproducir", "Remind me": "Recordarme", "Remind Me": "Recordarme", @@ -638,6 +642,7 @@ "Slow Mode ON": "Modo lento activado", "Slow mode, wait {{ seconds }}s...": "Modo lento, espera {{ seconds }} s...", "Some of the files will not be accepted": "Algunos archivos no serán aceptados", + "Speaking": "Hablando", "Start typing to search": "Empieza a escribir para buscar", "Stop sharing": "Dejar de compartir", "Submit": "Enviar", @@ -646,6 +651,7 @@ "Summoning my inner genius": "Invocando a mi genio interior", "Tap to remove": "Toca para quitar", "Tap to remove: {{ reactionName }}": "Toca para quitar: {{ reactionName }}", + "Thinking": "Pensando", "Thinking really hard": "Pensando muy fuerte", "Thinking...": "Pensando...", "this content could not be displayed": "Este contenido no se pudo mostrar", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 3c13008d0..d5120dfd3 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -243,6 +243,7 @@ "aria/Unpin Message": "Détacher le message", "aria/User selected: {{ user }}": "Utilisateur sélectionné : {{ user }}", "aria/video": "vidéo", + "aria/Voice agent: {{ state }}": "Agent vocal : {{ state }}", "aria/voice message": "message vocal", "aria/Voice message sent": "Message vocal envoyé", "aria/Voice recording attached": "Enregistrement vocal joint", @@ -284,6 +285,7 @@ "Command not available while replying": "Commande non disponible pendant la réponse", "Commands": "Commandes", "Commands matching": "Correspondance des commandes", + "Connecting": "Connexion", "Connecting the dots": "Je relie les points", "Connection failure, reconnecting now...": "Échec de la connexion, reconnexion en cours...", "Consulting the AI gods": "Je consulte les dieux de l'IA", @@ -465,6 +467,7 @@ "linkCount_one": "Lien", "linkCount_many": "{{ count }} liens", "linkCount_other": "{{ count }} liens", + "Listening": "À l’écoute", "live": "en direct", "Live for {{duration}}": "En direct pendant {{duration}}", "Live location": "Emplacement en direct", @@ -565,6 +568,7 @@ "Quote Reply": "Répondre par citation", "Reached the vote limit. Remove an existing vote first.": "La limite de votes a été atteinte. Supprimez d'abord un vote existant.", "Reading the digital tea leaves": "Je lis dans le marc de café numérique", + "Ready": "Prêt", "Recording format is not supported and cannot be reproduced": "Le format d'enregistrement n'est pas pris en charge et ne peut pas être reproduit", "Remind me": "Me rappeler", "Remind Me": "Me rappeler", @@ -638,6 +642,7 @@ "Slow Mode ON": "Mode lent activé", "Slow mode, wait {{ seconds }}s...": "Mode lent, attendez {{ seconds }} s...", "Some of the files will not be accepted": "Certains fichiers ne seront pas acceptés", + "Speaking": "Parle", "Start typing to search": "Commencez à taper pour rechercher", "Stop sharing": "Arrêter de partager", "Submit": "Envoyer", @@ -646,6 +651,7 @@ "Summoning my inner genius": "J'invoque mon génie intérieur", "Tap to remove": "Appuyez pour retirer", "Tap to remove: {{ reactionName }}": "Appuyez pour retirer: {{ reactionName }}", + "Thinking": "Réfléchit", "Thinking really hard": "Je réfléchis très fort", "Thinking...": "Réflexion...", "this content could not be displayed": "ce contenu n'a pas pu être affiché", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 515986503..9ec9fa6dc 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -226,6 +226,7 @@ "aria/Unpin Message": "संदेश अनपिन करें", "aria/User selected: {{ user }}": "उपयोगकर्ता चयनित: {{ user }}", "aria/video": "वीडियो", + "aria/Voice agent: {{ state }}": "वॉइस एजेंट: {{ state }}", "aria/voice message": "वॉइस मैसेज", "aria/Voice message sent": "वॉइस संदेश भेजा गया", "aria/Voice recording attached": "वॉइस रिकॉर्डिंग संलग्न की गई", @@ -267,6 +268,7 @@ "Command not available while replying": "उत्तर देते समय कमांड उपलब्ध नहीं है", "Commands": "कमांड", "Commands matching": "मेल खाती है", + "Connecting": "कनेक्ट हो रहा है", "Connecting the dots": "कड़ियाँ जोड़ रहा हूँ", "Connection failure, reconnecting now...": "कनेक्शन विफल रहा, अब पुनः कनेक्ट हो रहा है ...", "Consulting the AI gods": "एआई देवताओं से सलाह ले रहा हूँ", @@ -446,6 +448,7 @@ "Link": "लिंक", "linkCount_one": "1 लिंक", "linkCount_other": "{{ count }} लिंक", + "Listening": "सुन रहा है", "live": "लाइव", "Live for {{duration}}": "{{duration}} के लिए लाइव", "Live location": "लाइव स्थान", @@ -546,6 +549,7 @@ "Quote Reply": "उद्धरण जवाब", "Reached the vote limit. Remove an existing vote first.": "मतदान सीमा तक पहुंच गया। पहले एक मौजूदा वोट हटाएं।", "Reading the digital tea leaves": "डिजिटल चाय की पत्तियाँ पढ़ रहा हूँ", + "Ready": "तैयार", "Recording format is not supported and cannot be reproduced": "रेकॉर्डिंग फ़ॉर्मेट समर्थित नहीं है और पुनः उत्पन्न नहीं किया जा सकता", "Remind me": "मुझे याद दिलाएं", "Remind Me": "मुझे याद दिलाएं", @@ -614,6 +618,7 @@ "Slow Mode ON": "स्लो मोड ऑन", "Slow mode, wait {{ seconds }}s...": "स्लो मोड, {{ seconds }} सेकंड प्रतीक्षा करें...", "Some of the files will not be accepted": "कुछ फ़ाइलें स्वीकार नहीं की जाएंगी", + "Speaking": "बोल रहा है", "Start typing to search": "खोजने के लिए टाइप करना शुरू करें", "Stop sharing": "साझा करना बंद करें", "Submit": "जमा करें", @@ -622,6 +627,7 @@ "Summoning my inner genius": "अपने अंदर की प्रतिभा को बुला रहा हूँ", "Tap to remove": "हटाने के लिए टैप करें", "Tap to remove: {{ reactionName }}": "हटाने के लिए टैप करें: {{ reactionName }}", + "Thinking": "सोच रहा है", "Thinking really hard": "बहुत गहराई से सोच रहा हूँ", "Thinking...": "सोच रहा है...", "this content could not be displayed": "यह कॉन्टेंट लोड नहीं हो पाया", diff --git a/src/i18n/it.json b/src/i18n/it.json index 1ec723099..b170b818e 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -243,6 +243,7 @@ "aria/Unpin Message": "Rimuovi messaggio appuntato", "aria/User selected: {{ user }}": "Utente selezionato: {{ user }}", "aria/video": "video", + "aria/Voice agent: {{ state }}": "Agente vocale: {{ state }}", "aria/voice message": "messaggio vocale", "aria/Voice message sent": "Messaggio vocale inviato", "aria/Voice recording attached": "Registrazione vocale allegata", @@ -284,6 +285,7 @@ "Command not available while replying": "Comando non disponibile durante la risposta", "Commands": "Comandi", "Commands matching": "Comandi corrispondenti", + "Connecting": "Connessione", "Connecting the dots": "Collego i puntini", "Connection failure, reconnecting now...": "Errore di connessione, riconnessione in corso...", "Consulting the AI gods": "Consulto gli dei dell'IA", @@ -465,6 +467,7 @@ "linkCount_one": "Link", "linkCount_many": "{{ count }} link", "linkCount_other": "{{ count }} link", + "Listening": "In ascolto", "live": "live", "Live for {{duration}}": "Live per {{duration}}", "Live location": "Posizione live", @@ -565,6 +568,7 @@ "Quote Reply": "Rispondi con citazione", "Reached the vote limit. Remove an existing vote first.": "Raggiunto il limite di voti. Rimuovi prima un voto esistente.", "Reading the digital tea leaves": "Leggo i fondi di tè digitali", + "Ready": "Pronto", "Recording format is not supported and cannot be reproduced": "Il formato di registrazione non è supportato e non può essere riprodotto", "Remind me": "Promemoria", "Remind Me": "Ricordami", @@ -638,6 +642,7 @@ "Slow Mode ON": "Modalità lenta attivata", "Slow mode, wait {{ seconds }}s...": "Modalità lenta, attendi {{ seconds }} s...", "Some of the files will not be accepted": "Alcuni dei file non saranno accettati", + "Speaking": "Sta parlando", "Start typing to search": "Inizia a digitare per cercare", "Stop sharing": "Ferma condivisione", "Submit": "Invia", @@ -646,6 +651,7 @@ "Summoning my inner genius": "Evoco il mio genio interiore", "Tap to remove": "Tocca per rimuovere", "Tap to remove: {{ reactionName }}": "Tocca per rimuovere: {{ reactionName }}", + "Thinking": "Sta pensando", "Thinking really hard": "Sto pensando intensamente", "Thinking...": "Pensando...", "this content could not be displayed": "questo contenuto non può essere mostrato", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index fcac44336..fe6db9701 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -217,6 +217,7 @@ "aria/Unpin Message": "ピンを解除", "aria/User selected: {{ user }}": "選択したユーザー:{{ user }}", "aria/video": "動画", + "aria/Voice agent: {{ state }}": "音声エージェント: {{ state }}", "aria/voice message": "ボイスメッセージ", "aria/Voice message sent": "ボイスメッセージを送信しました", "aria/Voice recording attached": "ボイス録音を添付しました", @@ -258,6 +259,7 @@ "Command not available while replying": "返信中はコマンドを使用できません", "Commands": "コマンド", "Commands matching": "一致するコマンド", + "Connecting": "接続中", "Connecting the dots": "点と点をつなげています", "Connection failure, reconnecting now...": "接続が失敗しました。再接続中...", "Consulting the AI gods": "AIの神々に相談しています", @@ -433,6 +435,7 @@ "Limit votes per person": "1人あたりの投票数を制限する", "Link": "リンク", "linkCount_other": "{{ count }}件のリンク", + "Listening": "聞き取り中", "live": "ライブ", "Live for {{duration}}": "{{duration}}間ライブ", "Live location": "ライブ位置情報", @@ -533,6 +536,7 @@ "Quote Reply": "引用返信", "Reached the vote limit. Remove an existing vote first.": "投票制限に達しました。既存の投票を先に削除してください。", "Reading the digital tea leaves": "デジタルの茶葉を読み解いています", + "Ready": "準備完了", "Recording format is not supported and cannot be reproduced": "録音形式はサポートされておらず、再生できません", "Remind me": "リマインド", "Remind Me": "リマインダー", @@ -599,6 +603,7 @@ "Slow Mode ON": "スローモードオン", "Slow mode, wait {{ seconds }}s...": "スローモード、{{ seconds }}秒お待ちください...", "Some of the files will not be accepted": "一部のファイルは受け付けられません", + "Speaking": "話しています", "Start typing to search": "検索するには入力を開始してください", "Stop sharing": "共有を停止", "Submit": "送信", @@ -607,6 +612,7 @@ "Summoning my inner genius": "内なる天才を呼び覚ましています", "Tap to remove": "タップして削除", "Tap to remove: {{ reactionName }}": "タップして削除: {{ reactionName }}", + "Thinking": "考え中", "Thinking really hard": "一生懸命考えています", "Thinking...": "考え中...", "this content could not be displayed": "このコンテンツは表示できませんでした", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 59a3c12eb..57017eff1 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -217,6 +217,7 @@ "aria/Unpin Message": "핀 해제", "aria/User selected: {{ user }}": "선택한 사용자: {{ user }}", "aria/video": "동영상", + "aria/Voice agent: {{ state }}": "음성 에이전트: {{ state }}", "aria/voice message": "음성 메시지", "aria/Voice message sent": "음성 메시지 전송됨", "aria/Voice recording attached": "음성 녹음 첨부됨", @@ -258,6 +259,7 @@ "Command not available while replying": "답장 중에는 명령을 사용할 수 없습니다", "Commands": "명령어", "Commands matching": "일치하는 명령", + "Connecting": "연결 중", "Connecting the dots": "점들을 연결하는 중", "Connection failure, reconnecting now...": "연결 실패, 지금 다시 연결 중...", "Consulting the AI gods": "AI 신들께 여쭤보는 중", @@ -433,6 +435,7 @@ "Limit votes per person": "1인당 투표 수 제한", "Link": "링크", "linkCount_other": "링크 {{ count }}개", + "Listening": "듣는 중", "live": "라이브", "Live for {{duration}}": "{{duration}} 동안 라이브", "Live location": "라이브 위치", @@ -533,6 +536,7 @@ "Quote Reply": "인용 답장", "Reached the vote limit. Remove an existing vote first.": "투표 한도에 도달했습니다. 기존 투표를 먼저 제거하세요.", "Reading the digital tea leaves": "디지털 찻잎을 읽는 중", + "Ready": "준비됨", "Recording format is not supported and cannot be reproduced": "녹음 형식이 지원되지 않으므로 재생할 수 없습니다", "Remind me": "알림", "Remind Me": "알림 설정", @@ -599,6 +603,7 @@ "Slow Mode ON": "슬로우 모드 켜짐", "Slow mode, wait {{ seconds }}s...": "슬로우 모드, {{ seconds }}초 기다려 주세요...", "Some of the files will not be accepted": "일부 파일은 허용되지 않을 수 있습니다", + "Speaking": "말하는 중", "Start typing to search": "검색하려면 입력을 시작하세요", "Stop sharing": "공유 중지", "Submit": "제출", @@ -607,6 +612,7 @@ "Summoning my inner genius": "내면의 천재를 불러내는 중", "Tap to remove": "제거하려면 탭하세요", "Tap to remove: {{ reactionName }}": "제거하려면 탭하세요: {{ reactionName }}", + "Thinking": "생각 중", "Thinking really hard": "열심히 생각하는 중", "Thinking...": "생각 중...", "this content could not be displayed": "이 콘텐츠를 표시할 수 없습니다", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 317229391..e1db9c66b 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -226,6 +226,7 @@ "aria/Unpin Message": "Losmaken", "aria/User selected: {{ user }}": "Gebruiker geselecteerd: {{ user }}", "aria/video": "video", + "aria/Voice agent: {{ state }}": "Spraakassistent: {{ state }}", "aria/voice message": "spraakbericht", "aria/Voice message sent": "Spraakbericht verzonden", "aria/Voice recording attached": "Spraakopname bijgevoegd", @@ -267,6 +268,7 @@ "Command not available while replying": "Opdracht niet beschikbaar tijdens beantwoorden", "Commands": "Commando's", "Commands matching": "Bijpassende opdrachten", + "Connecting": "Verbinden", "Connecting the dots": "Ik verbind de puntjes", "Connection failure, reconnecting now...": "Verbindingsfout, opnieuw verbinden...", "Consulting the AI gods": "Ik raadpleeg de AI-goden", @@ -445,6 +447,7 @@ "Link": "Link", "linkCount_one": "Link", "linkCount_other": "{{ count }} links", + "Listening": "Luistert", "live": "live", "Live for {{duration}}": "Live voor {{duration}}", "Live location": "Live locatie", @@ -545,6 +548,7 @@ "Quote Reply": "Citaatantwoord", "Reached the vote limit. Remove an existing vote first.": "Stemlimiet bereikt. Verwijder eerst een bestaande stem.", "Reading the digital tea leaves": "Ik lees de digitale theeblaadjes", + "Ready": "Klaar", "Recording format is not supported and cannot be reproduced": "Opnameformaat wordt niet ondersteund en kan niet worden gereproduceerd", "Remind me": "Herinner me", "Remind Me": "Herinner mij", @@ -615,6 +619,7 @@ "Slow wait, wait {{ seconds }}s": "Langzame modus, wacht {{ seconds }}s", "Slow wait, wait {{ seconds }}s...": "Langzame modus, wacht {{ seconds }}s...", "Some of the files will not be accepted": "Sommige bestanden zullen niet worden geaccepteerd", + "Speaking": "Spreekt", "Start typing to search": "Begin met typen om te zoeken", "Stop sharing": "Delen stoppen", "Submit": "Versturen", @@ -623,6 +628,7 @@ "Summoning my inner genius": "Ik roep mijn innerlijke genie op", "Tap to remove": "Tik om te verwijderen", "Tap to remove: {{ reactionName }}": "Tik om te verwijderen: {{ reactionName }}", + "Thinking": "Denkt na", "Thinking really hard": "Ik denk heel diep na", "Thinking...": "Denken...", "this content could not be displayed": "Deze inhoud kan niet weergegeven worden", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 70fe0e6cd..b0e5c3192 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -243,6 +243,7 @@ "aria/Unpin Message": "Desfixar mensagem", "aria/User selected: {{ user }}": "Usuário selecionado: {{ user }}", "aria/video": "vídeo", + "aria/Voice agent: {{ state }}": "Agente de voz: {{ state }}", "aria/voice message": "mensagem de voz", "aria/Voice message sent": "Mensagem de voz enviada", "aria/Voice recording attached": "Gravação de voz anexada", @@ -284,6 +285,7 @@ "Command not available while replying": "Comando não disponível durante a resposta", "Commands": "Comandos", "Commands matching": "Comandos correspondentes", + "Connecting": "Conectando", "Connecting the dots": "Ligando os pontos", "Connection failure, reconnecting now...": "Falha de conexão, reconectando agora...", "Consulting the AI gods": "Consultando os deuses da IA", @@ -465,6 +467,7 @@ "linkCount_one": "Link", "linkCount_many": "{{ count }} links", "linkCount_other": "{{ count }} links", + "Listening": "Ouvindo", "live": "ao vivo", "Live for {{duration}}": "Ao vivo por {{duration}}", "Live location": "Localização ao vivo", @@ -565,6 +568,7 @@ "Quote Reply": "Responder com citação", "Reached the vote limit. Remove an existing vote first.": "Limite de votos atingido. Remova um voto existente primeiro.", "Reading the digital tea leaves": "Lendo as folhas de chá digitais", + "Ready": "Pronto", "Recording format is not supported and cannot be reproduced": "Formato de gravação não é suportado e não pode ser reproduzido", "Remind me": "Lembrar-me", "Remind Me": "Lembrar-me", @@ -638,6 +642,7 @@ "Slow Mode ON": "Modo lento LIGADO", "Slow mode, wait {{ seconds }}s...": "Modo lento, aguarde {{ seconds }} s...", "Some of the files will not be accepted": "Alguns arquivos não serão aceitos", + "Speaking": "Falando", "Start typing to search": "Comece a digitar para pesquisar", "Stop sharing": "Parar de compartilhar", "Submit": "Enviar", @@ -646,6 +651,7 @@ "Summoning my inner genius": "Invocando meu gênio interior", "Tap to remove": "Toque para remover", "Tap to remove: {{ reactionName }}": "Toque para remover: {{ reactionName }}", + "Thinking": "Pensando", "Thinking really hard": "Pensando bem forte", "Thinking...": "Pensando...", "this content could not be displayed": "este conteúdo não pôde ser exibido", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index be09be9de..bdb9f115e 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -261,6 +261,7 @@ "aria/Unpin Message": "Открепить сообщение", "aria/User selected: {{ user }}": "Выбран пользователь: {{ user }}", "aria/video": "видео", + "aria/Voice agent: {{ state }}": "Голосовой агент: {{ state }}", "aria/voice message": "голосовое сообщение", "aria/Voice message sent": "Голосовое сообщение отправлено", "aria/Voice recording attached": "Голосовая запись прикреплена", @@ -302,6 +303,7 @@ "Command not available while replying": "Команда недоступна при ответе", "Commands": "Команды", "Commands matching": "Соответствие команд", + "Connecting": "Подключение", "Connecting the dots": "Соединяю точки", "Connection failure, reconnecting now...": "Ошибка соединения, переподключение...", "Consulting the AI gods": "Советуюсь с богами ИИ", @@ -489,6 +491,7 @@ "linkCount_few": "{{ count }} линка", "linkCount_many": "{{ count }} линков", "linkCount_other": "{{ count }} линков", + "Listening": "Слушает", "live": "В прямом эфире", "Live for {{duration}}": "В прямом эфире {{duration}}", "Live location": "Местоположение в прямом эфире", @@ -589,6 +592,7 @@ "Quote Reply": "Ответ со цитатой", "Reached the vote limit. Remove an existing vote first.": "Достигнут лимит голосов. Сначала удалите существующий голос.", "Reading the digital tea leaves": "Гадаю на цифровой чайной заварке", + "Ready": "Готов", "Recording format is not supported and cannot be reproduced": "Формат записи не поддерживается и не может быть воспроизведен", "Remind me": "Напомнить мне", "Remind Me": "Напомнить мне", @@ -667,6 +671,7 @@ "Slow Mode ON": "Медленный режим включен", "Slow mode, wait {{ seconds }}s...": "Медленный режим: подождите {{ seconds }} с...", "Some of the files will not be accepted": "Некоторые файлы не будут приняты", + "Speaking": "Говорит", "Start typing to search": "Начните вводить для поиска", "Stop sharing": "Прекратить делиться", "Submit": "Отправить", @@ -675,6 +680,7 @@ "Summoning my inner genius": "Пробуждаю внутреннего гения", "Tap to remove": "Нажмите, чтобы удалить", "Tap to remove: {{ reactionName }}": "Нажмите, чтобы удалить: {{ reactionName }}", + "Thinking": "Думает", "Thinking really hard": "Усердно думаю", "Thinking...": "Думаю...", "this content could not be displayed": "Этот контент не может быть отображен в данный момент", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index a0ae5d703..c94716817 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -226,6 +226,7 @@ "aria/Unpin Message": "Sabitlemeyi kaldır", "aria/User selected: {{ user }}": "Seçilen kullanıcı: {{ user }}", "aria/video": "video", + "aria/Voice agent: {{ state }}": "Sesli asistan: {{ state }}", "aria/voice message": "sesli mesaj", "aria/Voice message sent": "Sesli mesaj gönderildi", "aria/Voice recording attached": "Ses kaydı eklendi", @@ -267,6 +268,7 @@ "Command not available while replying": "Yanıtlama sırasında komut kullanılamaz", "Commands": "Komutlar", "Commands matching": "Eşleşen komutlar", + "Connecting": "Bağlanıyor", "Connecting the dots": "Noktaları birleştiriyorum", "Connection failure, reconnecting now...": "Bağlantı hatası, tekrar bağlanılıyor...", "Consulting the AI gods": "Yapay zekâ tanrılarına danışıyorum", @@ -445,6 +447,7 @@ "Link": "Bağlantı", "linkCount_one": "Bağlantı", "linkCount_other": "{{ count }} bağlantı", + "Listening": "Dinliyor", "live": "canlı", "Live for {{duration}}": "{{duration}} boyunca canlı", "Live location": "Canlı konum", @@ -545,6 +548,7 @@ "Quote Reply": "Alıntıyla yanıtla", "Reached the vote limit. Remove an existing vote first.": "Oylama sınırına ulaşıldı. Önce mevcut bir oyu kaldırın.", "Reading the digital tea leaves": "Dijital çay yapraklarını okuyorum", + "Ready": "Hazır", "Recording format is not supported and cannot be reproduced": "Kayıt formatı desteklenmiyor ve çoğaltılamıyor", "Remind me": "Bana hatırlat", "Remind Me": "Hatırlat", @@ -613,6 +617,7 @@ "Slow Mode ON": "Yavaş Mod Açık", "Slow mode, wait {{ seconds }}s...": "Yavaş mod, {{ seconds }} sn bekleyin...", "Some of the files will not be accepted": "Bazı dosyalar kabul edilmeyecek", + "Speaking": "Konuşuyor", "Start typing to search": "Aramak için yazmaya başlayın", "Stop sharing": "Paylaşımı durdur", "Submit": "Gönder", @@ -621,6 +626,7 @@ "Summoning my inner genius": "İçimdeki dahiyi uyandırıyorum", "Tap to remove": "Kaldırmak için dokunun", "Tap to remove: {{ reactionName }}": "Kaldırmak için dokunun: {{ reactionName }}", + "Thinking": "Düşünüyor", "Thinking really hard": "Çok derin düşünüyorum", "Thinking...": "Düşünüyor...", "this content could not be displayed": "bu içerik gösterilemiyor", diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md index 75fe053b7..966a2746a 100644 --- a/src/plugins/AIComponents/README.md +++ b/src/plugins/AIComponents/README.md @@ -29,9 +29,9 @@ Load `ai-components.css` in addition to `stream-chat-react/css/index.css`. From `stream-chat-react/ai-components`: -- Components: `AIMarkdown`, `AIMessageComposer` (with the static sub-components `FileInput`, `TextInput`, `SpeechToTextButton`, `SubmitButton`, `ModelSelect`, `AttachmentPreview`, `AttachmentPreview.Item`), `StreamingMessage`, `AIStateIndicator` -- Hooks: `useAttachments`, `useText`, `useIsDisabled`, `useAIMessageComposerContext`, `useSpeechToText` -- Types: `AIMessageComposerStore`, `StreamingMessageRef`, `UseMessageTextStreamingProps`, `ToolComponentProps`, `UseSpeechToTextOptions` +- Components: `AIMarkdown`, `AIMessageComposer` (with the static sub-components `FileInput`, `TextInput`, `SpeechToTextButton`, `SubmitButton`, `ModelSelect`, `AttachmentPreview`, `AttachmentPreview.Item`), `StreamingMessage`, `AIStateIndicator`, `VoiceWave`, `VoiceGlow` +- Hooks: `useAttachments`, `useText`, `useIsDisabled`, `useAIMessageComposerContext`, `useSpeechToText`, `useAudioLevel` +- Types: `AIMessageComposerStore`, `StreamingMessageRef`, `UseMessageTextStreamingProps`, `ToolComponentProps`, `UseSpeechToTextOptions`, `VoiceAgentState`, `VoiceGlowRendering`, `VoiceVisualizationProps`, `VoiceWaveProps`, `VoiceGlowProps` `useMessageTextStreaming` is not exported from here; import it from the main entry point (`stream-chat-react`). @@ -190,6 +190,8 @@ Precedence for every token except the code palette is: your `--str-chat__ai-*` o | `table-text` | table cell text | `text-secondary` | `#495057` | | `scrollbar-track` | attachment list scrollbar track | `background-core-surface-subtle` | `#f1f1f1` | | `scrollbar-thumb` | attachment list scrollbar thumb | `border-core-default` | `#ddd` | +| `voice-color` | `VoiceWave` / `VoiceGlow` color when `colors` is unset | `accent-primary` | `#005fff` | +| `voice-backdrop` | what `VoiceWave` / `VoiceGlow` sit on (light or dark) | `background-core-elevation-0` | `#ffffff` | | `syntax-*` | code palette (see below) | none | built-in palette | The code palette tokens are `--str-chat__ai-syntax-text`, `-selection-bg`, `-comment`, `-punctuation`, `-number`, `-keyword`, `-tag`, `-string`, `-function`, `-cyan`, `-error` and `-whitespace`. @@ -298,6 +300,73 @@ function MessageContent({ content }) { } ``` +### `VoiceWave` and `VoiceGlow` + +Two animated visualizations of a voice agent, the web counterparts of `AIVoiceWave` and `AIVoiceGlow` in the Android and iOS AI components, with the same motion. `VoiceWave` is a single string carrying a wave packet that erupts with speech; `VoiceGlow` is a soft globe whose color masses orbit inside it and swell with speech. + +Both draw on a `` inside a `div` with `role="img"` and an accessible name such as "Voice agent: Speaking". They have no size of their own and draw in the middle of the box they are given (`VoiceWave` about 80% as wide as the shorter side, `VoiceGlow` about half), so size them with CSS. The defaults are `width: 100%; height: 8rem` for the wave and `12rem` square for the glow. The wave's line is as thick as on Android and iOS, 0.6% of the shorter side and never under 1.5 device pixels, and drawn at the screen's own density up to 3x; the soft glow is drawn at 1x, which looks the same at a fraction of the cost. + +#### Props + +| Name | Type | Required | Description | +| -------------- | ------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `state` | `'idle' \| 'connecting' \| 'listening' \| 'thinking' \| 'speaking'` | yes | What the agent is doing. The states differ only in motion, never in color, and a change eases from one motion into the next. | +| `colors` | `string[]` | no | One to five CSS colors, flowing through the shape. Past five are ignored. When omitted or empty, `--str-chat__ai-voice-color` is used, which follows the SDK accent color. | +| `expressivity` | `number` | no | From 0 to 2: how far and how fast it moves. Defaults to 1. | +| `audioLevel` | `() => number \| null \| undefined` | no | The level of whoever is talking, from 0 to 1, read once a frame, so a level changing many times a second re-renders nothing. Returning `null` (or omitting it) animates speech on its own. | +| `rendering` | `'auto' \| 'css'` | no | `VoiceGlow` only. `auto` (default) draws with a WebGL shader and falls back to `css` when WebGL is unavailable or its context is lost; `css` builds the glow from CSS gradients animated by the browser's compositor (see below). | + +Any other `div` prop (`className`, `style`, event handlers, ...) is passed to the root. Only the shape takes the pointer, as on iOS: the globe (within 0.32 of the shorter side from the centre) or the string. Elsewhere in the box, clicks and hovers reach whatever is behind it. On the shape, pointer events are watched and never stopped: a tap bounces it and a hovering mouse or pen ripples it, while your own `onClick` on the root or a parent still fires. + +Behavior: + +- The animation runs only while the element is on screen and the tab is visible. +- Every WebGL glow on a page draws with one shared WebGL context, so the shader compiles once and a page with many glows stays under the browser's limit on live contexts. +- It fades in over 0.3 s once its first frame is drawn (instantly with reduced motion), so it never flashes empty while a shader compiles. +- With `prefers-reduced-motion: reduce`, the motion slows to a crawl and taps and hover are ignored. +- The palette and the light or dark look follow the theme (`--str-chat__ai-voice-color` and `--str-chat__ai-voice-backdrop`), re-read about once a second, so a theme switch needs no prop. +- `VoiceWave` moves as on Android and iOS, smoothed for speech: its height rises quickly and settles slowly, its syllables swing it a little less, and as it grows its crests spread out rather than steepen into spikes. While idle, connecting or thinking it is the native wave exactly. + +`rendering='css'`, also the fallback when WebGL is unavailable, composes the glow the way the shader does, as gradients that are painted once and animated only through `transform` and `opacity`, so the browser's compositor runs the motion. It keeps moving while the main thread is busy (for example while a long answer streams in), needs no canvas or WebGL, and runs no script per frame unless `audioLevel` is given. It lacks the shader's fine marbling and its second, fainter riding mass: a tap bounces the glow and sends out a ring instead of a ripple, and a hovering pointer does nothing. Off screen, its animations pause. It needs `@property`, container query units and `color-mix()` (Chrome 111, Safari 16.4, Firefox 128 and later). + +- Under SSR the markup is rendered and drawing starts after hydration. + +#### Example + +```tsx +import { useEffect, useState } from 'react'; +import { + useAudioLevel, + type VoiceAgentState, + VoiceGlow, + VoiceWave, +} from 'stream-chat-react/ai-components'; + +const VoiceAgent = ({ state }: { state: VoiceAgentState }) => { + const [mic, setMic] = useState(null); + const micLevel = useAudioLevel(mic); + + useEffect(() => { + let stream: MediaStream | undefined; + navigator.mediaDevices.getUserMedia({ audio: true }).then((granted) => { + stream = granted; + setMic(granted); + }); + return () => stream?.getTracks().forEach((track) => track.stop()); + }, []); + + return ( + <> + + + + ); +}; +``` + ### `StreamingMessage` The `StreamingMessage` is a component that displays text with a typewriter animation effect, similar to ChatGPT. It's ideal for streaming AI responses. It's a simplified wrapper with typewriter animation effect around the `AIMarkdown` component. @@ -507,6 +576,16 @@ function VoiceInput() { } ``` +### `useAudioLevel` + +Reads the live level of a `MediaStream`'s audio (a microphone from `getUserMedia({ audio: true })`, or a remote track) for the `audioLevel` prop of `VoiceWave` and `VoiceGlow`. + +```ts +const useAudioLevel: (stream: MediaStream | null | undefined) => () => number | null; +``` + +It returns a stable getter that measures the level when called, from 0 to 1, so nothing re-renders as the level changes. It measures over the last 2048 samples (about 43 ms at 48 kHz), a few pitch periods of a voice, so the level holds steady through a vowel. The getter returns `null` while there is no stream or no Web Audio support. The audio graph is torn down when the stream changes and on unmount; the stream's tracks are left running, since they belong to you. + ## Examples ### Basic Chat Interface diff --git a/src/plugins/AIComponents/VoiceVisualization/VoiceGlow.tsx b/src/plugins/AIComponents/VoiceVisualization/VoiceGlow.tsx new file mode 100644 index 000000000..2aa60a436 --- /dev/null +++ b/src/plugins/AIComponents/VoiceVisualization/VoiceGlow.tsx @@ -0,0 +1,54 @@ +import { useCallback, useState } from 'react'; +import clsx from 'clsx'; + +import { glowContains } from './hitArea'; +import { createGlowWebGLRenderer } from './renderers/glowWebGLRenderer'; +import type { VoiceGlowRendering, VoiceVisualizationProps } from './types'; +import { VoiceGlowCss } from './VoiceGlowCss'; +import { VoiceVisualizationView } from './VoiceVisualizationView'; + +export type VoiceGlowProps = VoiceVisualizationProps & { + /** How the glow is drawn. See `VoiceGlowRendering`. Defaults to `auto`. */ + rendering?: VoiceGlowRendering; +}; + +/** + * A voice agent as a soft, airy globe: color masses orbit inside it and swell with speech, and + * the gaps between them let the background show through. Tapping the globe bounces it; drawn + * with WebGL, it also ripples from the touch and under a hovering mouse or pen. + * + * It has no size of its own and draws in the middle of the box it is given, about half as wide + * as its shorter side, so give it a square. + */ +export const VoiceGlow = ({ + className, + rendering = 'auto', + ...props +}: VoiceGlowProps) => { + // Set when WebGL cannot start or its context is lost; the CSS glow takes over from then on. + const [webGLFailed, setWebGLFailed] = useState(false); + + const createRenderer = useCallback((canvas: HTMLCanvasElement) => { + const renderer = createGlowWebGLRenderer(canvas, () => setWebGLFailed(true)); + if (!renderer) setWebGLFailed(true); + return renderer; + }, []); + + if (rendering === 'css' || webGLFailed) { + return ; + } + + return ( + + ); +}; diff --git a/src/plugins/AIComponents/VoiceVisualization/VoiceGlowCss.tsx b/src/plugins/AIComponents/VoiceVisualization/VoiceGlowCss.tsx new file mode 100644 index 000000000..2f3299b8c --- /dev/null +++ b/src/plugins/AIComponents/VoiceVisualization/VoiceGlowCss.tsx @@ -0,0 +1,345 @@ +import { + type CSSProperties, + type PointerEvent, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { useReducedMotionPreference } from '../../../components/MessageList/hooks/useReducedMotionPreference'; +import { + CSS_GLOW_RATES, + cssGlowPalette, + cssGlowRateKind, + massBackground, + MASSES, + orbitDistance, + orbitMask, + ORBITS, + RIDING_BODY, +} from './cssGlow'; +import { ease, MIC, REDUCED, scales } from './driver'; +import { glowContains, toLocalPoint } from './hitArea'; +import { audioLevelInput, expressivityInput } from './math'; +import { FALLBACK_COLOR, readVoiceTheme, toHsvPalette } from './palette'; +import type { VoiceVisualizationProps } from './types'; +import { useVoiceStateLabel } from './useVoiceStateLabel'; + +/** How often the theme is read again, so a switch to dark mode is followed. */ +const THEME_INTERVAL = 1000; +const RIPPLE_DURATION = 900; + +const block = 'str-chat__ai-voice-glow'; + +const Layer = ({ + children, + className, + style, +}: { + children?: ReactNode; + className: string; + style?: CSSProperties; +}) => ( +
+ {children} +
+); + +const orbitStyle = (orbit: number) => + ({ + '--str-ai-glow-direction': ORBITS[orbit].direction, + '--str-ai-glow-period': `${ORBITS[orbit].period}s`, + '--str-ai-glow-start': `${ORBITS[orbit].start}deg`, + }) as CSSProperties; + +const swayStyle = (orbit: number) => + ({ '--str-ai-glow-sway-period': `${ORBITS[orbit].sway}s` }) as CSSProperties; + +/** The masses the palette fills: the fourth and fifth only with that many colors. */ +const massesFor = (colorCount: number) => + MASSES.filter((_, index) => index < 3 || index < colorCount); + +/** The opaque color field: a base and the masses mixed over it in order. */ +const ColorField = ({ colorCount }: { colorCount: number }) => ( + + + + {massesFor(colorCount).map((mass, index) => ( + + +
+ + + ))} + + +); + +/** + * The color field seen through opacity that rides the first orbit: the mask turns with the + * orbit, and the inverse turns inside it keep the colors where they are. + */ +const RidingBody = ({ colorCount }: { colorCount: number }) => { + const { alpha, orbit, reach } = RIDING_BODY; + return ( + + + +
+ + + + + + + +
+
+
+
+ ); +}; + +type Ripple = { id: number; x: number; y: number }; + +/** + * VoiceGlow drawn with CSS: the shader's composition, with every gradient painted once and only + * `transform` and `opacity` animated, so the browser's compositor runs the motion. It keeps + * moving while the page is busy, needs no canvas or WebGL, and costs no JavaScript per frame + * unless `audioLevel` is given. Off screen, its animations pause and its script rests. + * + * Script sets what the stylesheet cannot know: the palette (read from the theme when `colors` + * is not given), the playback rate of each animation for the state, and the live audio level. + */ +export const VoiceGlowCss = ({ + audioLevel, + className, + colors, + expressivity, + onPointerUp, + state, + style, + ...restProps +}: VoiceVisualizationProps) => { + const label = useVoiceStateLabel(state); + const reduceMotion = useReducedMotionPreference(); + const rootRef = useRef(null); + const bounceRef = useRef(null); + const audioLevelRef = useRef(audioLevel); + const rippleTimers = useRef(new Set>()); + const [ripples, setRipples] = useState([]); + const [onScreen, setOnScreen] = useState(true); + const { amplitude, speed } = scales(expressivityInput(expressivity)); + const colorsKey = (colors ?? []).join('|'); + const colorCount = Math.min(colors?.length || 1, 5); + const hasAudioLevel = !!audioLevel; + + useEffect(() => { + audioLevelRef.current = audioLevel; + }); + + // Off screen, the animations pause (see the stylesheet) and the script below rests. + useEffect(() => { + const root = rootRef.current; + if (!root || typeof IntersectionObserver !== 'function') return; + const observer = new IntersectionObserver((entries) => + setOnScreen(entries.some((entry) => entry.isIntersecting)), + ); + observer.observe(root); + return () => observer.disconnect(); + }, []); + + // The palette, from `colors` or the theme, and whether the glow sits on a dark backdrop. Read + // again about once a second while on screen, so a theme switch is followed without a prop. + useEffect(() => { + const root = rootRef.current; + if (!onScreen || !root || typeof getComputedStyle !== 'function') return; + const own = toHsvPalette(colorsKey ? colorsKey.split('|') : []); + let applied = ''; + const apply = () => { + const theme = readVoiceTheme(root); + const palette = own.length + ? own + : theme.palette.length + ? theme.palette + : toHsvPalette([FALLBACK_COLOR]); + const properties = cssGlowPalette(palette, theme.dark); + const next = JSON.stringify(properties) + theme.dark; + if (next === applied) return; + applied = next; + for (const [name, value] of Object.entries(properties)) { + root.style.setProperty(name, value); + } + root.setAttribute('data-dark', String(!!theme.dark)); + // Fades in once the palette is in, so the theme color it starts from never shows. + root.setAttribute('data-ready', 'true'); + }; + apply(); + const interval = setInterval(apply, THEME_INTERVAL); + const scheme = + typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: dark)') + : null; + scheme?.addEventListener?.('change', apply); + return () => { + clearInterval(interval); + scheme?.removeEventListener?.('change', apply); + }; + }, [colorsKey, onScreen]); + + // Each state plays the animations at its own rates. Changing a playback rate keeps an + // animation where it is, where changing its duration would make it jump; a turn and its + // inverse always get the same rate, so they stay in step. + useEffect(() => { + const root = rootRef.current; + if (!root || typeof root.getAnimations !== 'function') return; + const rates = CSS_GLOW_RATES[state]; + const slow = reduceMotion ? REDUCED : 1; + for (const animation of root.getAnimations({ subtree: true })) { + const kind = cssGlowRateKind((animation as CSSAnimation).animationName ?? ''); + if (kind) animation.updatePlaybackRate(rates[kind] * speed * slow); + } + // Masses come and go with the palette, so their animations need rates too. + }, [colorsKey, reduceMotion, speed, state]); + + // A live audio level, eased like the driver eases it, is the only value written per frame, + // and only while the glow is on screen. + useEffect(() => { + const root = rootRef.current; + if ( + !hasAudioLevel || + !onScreen || + !root || + typeof requestAnimationFrame !== 'function' + ) + return; + let frame = 0; + let last = 0; + let level = 0; + let written = -1; + let live: boolean | null = null; + const tick = (now: number) => { + const target = audioLevelInput(audioLevelRef.current?.()); + const dt = last ? Math.min((now - last) / 1000, 0.05) : 0; + last = now; + level += ((target ?? 0) - level) * ease(MIC, dt); + if (Math.abs(level - written) > 0.002) { + written = level; + root.style.setProperty('--str-ai-glow-level', level.toFixed(3)); + } + if (live !== (target !== null)) { + live = target !== null; + root.setAttribute('data-live', String(live)); + } + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(frame); + root.style.removeProperty('--str-ai-glow-level'); + root.removeAttribute('data-live'); + }; + }, [hasAudioLevel, onScreen]); + + useEffect(() => { + const timers = rippleTimers.current; + return () => timers.forEach(clearTimeout); + }, []); + + // A tap inside the globe bounces it and sends a ring out from the touch. Nothing is + // prevented or stopped, so a click handler on the glow or a parent still gets it. + const handlePointerUp = (event: PointerEvent) => { + onPointerUp?.(event); + if (reduceMotion) return; + const { x, y } = toLocalPoint(event); + if (!glowContains(x, y)) return; + const bounce = bounceRef.current; + if (bounce) { + bounce.classList.remove(`${block}__bounce--active`); + // Restarts the bounce when taps come faster than it lasts. + void bounce.offsetWidth; + bounce.classList.add(`${block}__bounce--active`); + } + const rect = event.currentTarget.getBoundingClientRect(); + const ripple = { + id: Date.now() + Math.random(), + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + setRipples((current) => [...current, ripple]); + const timer = setTimeout(() => { + rippleTimers.current.delete(timer); + setRipples((current) => current.filter(({ id }) => id !== ripple.id)); + }, RIPPLE_DURATION); + rippleTimers.current.add(timer); + }; + + return ( +
+ + +
+ + + + + +
+ +
+ +
+
+
+
+
+
+
+ {/* Only the globe takes the pointer; around it, events reach what is behind. */} + + ); +}; diff --git a/src/plugins/AIComponents/VoiceVisualization/VoiceVisualizationView.tsx b/src/plugins/AIComponents/VoiceVisualization/VoiceVisualizationView.tsx new file mode 100644 index 000000000..986851639 --- /dev/null +++ b/src/plugins/AIComponents/VoiceVisualization/VoiceVisualizationView.tsx @@ -0,0 +1,265 @@ +import { type PointerEvent, useEffect, useRef } from 'react'; +import clsx from 'clsx'; + +import { useReducedMotionPreference } from '../../../components/MessageList/hooks/useReducedMotionPreference'; +import { useStableCallback } from '../../../utils/useStableCallback'; +import { type VoiceDriverInput, VoiceVisualizationDriver } from './driver'; +import { audioLevelInput, expressivityInput } from './math'; +import { toLocalPoint } from './hitArea'; +import { FALLBACK_COLOR, readVoiceTheme, toHsvPalette } from './palette'; +import type { VoiceRenderer } from './renderers/types'; +import type { Hsv, VoiceVisualizationProps } from './types'; +import { useVoiceStateLabel } from './useVoiceStateLabel'; + +/** How many frames pass between two reads of the theme, about once a second at 60 Hz. */ +const THEME_FRAMES = 60; + +type ViewProps = VoiceVisualizationProps & { + /** Whether a point, centred and normalized to the shorter side with y up, is on the shape. */ + contains: (x: number, y: number) => boolean; + /** Makes the renderer for a freshly mounted canvas, or null when it cannot draw there. */ + createRenderer: (canvas: HTMLCanvasElement) => VoiceRenderer | null; + /** Remounts the canvas and remakes the renderer when it changes. */ + rendererKey: string; + /** + * The most device pixels per CSS pixel the canvas gets: a blurred shape looks the same at 1, + * at a quarter of the pixels of a 2x display, while a thin line needs the screen's own. + */ + maxPixelRatio: number; +}; + +/** + * What VoiceWave and VoiceGlow share: the motion advanced once a frame, the canvas kept at the + * element's size, the theme's colors, reduced motion, the pointer and the accessible name. + * + * The loop runs only while the element is on screen, reads `audioLevel` once a frame and + * redraws without re-rendering React. + */ +export const VoiceVisualizationView = ({ + audioLevel, + className, + colors, + contains, + createRenderer, + expressivity, + maxPixelRatio, + onPointerLeave, + onPointerMove, + onPointerUp, + rendererKey, + state, + ...restProps +}: ViewProps) => { + const label = useVoiceStateLabel(state); + const reduceMotion = useReducedMotionPreference(); + const rootRef = useRef(null); + const canvasRef = useRef(null); + const driverRef = useRef(null); + const audioLevelRef = useRef(audioLevel); + const themeRef = useRef<{ dark: number; palette: Hsv[] }>({ dark: 0, palette: [] }); + const paletteRef = useRef<{ key: string; palette: Hsv[] }>({ key: '', palette: [] }); + const propsRef = useRef({ colors, expressivity, reduceMotion, state }); + + /** The theme's accent color and whether it sits on a dark backdrop. */ + const readTheme = useStableCallback(() => { + const root = rootRef.current; + if (!root || typeof getComputedStyle !== 'function') return; + themeRef.current = readVoiceTheme(root); + }); + + /** The driver's input from the latest props and the theme, keeping the audio level it has. */ + const applyInput = useStableCallback(() => { + // The first input settles the driver, so read the theme before it starts on a fallback. + if (!driverRef.current) readTheme(); + const { colors, expressivity, reduceMotion, state } = propsRef.current; + const key = (colors ?? []).join('|'); + if (paletteRef.current.key !== key) { + paletteRef.current = { key, palette: toHsvPalette(colors ?? []) }; + } + const own = paletteRef.current.palette; + const theme = themeRef.current.palette; + const input: VoiceDriverInput = { + audioLevel: driverRef.current?.input.audioLevel ?? null, + colors: own.length ? own : theme.length ? theme : toHsvPalette([FALLBACK_COLOR]), + expressivity: expressivityInput(expressivity), + reduceMotion, + state, + }; + if (driverRef.current) driverRef.current.input = input; + else + driverRef.current = new VoiceVisualizationDriver({ + ...input, + audioLevel: audioLevelInput(audioLevelRef.current?.()), + }); + }); + + useEffect(() => { + audioLevelRef.current = audioLevel; + propsRef.current = { colors, expressivity, reduceMotion, state }; + applyInput(); + }); + + const makeRenderer = useStableCallback(createRenderer); + + useEffect(() => { + const root = rootRef.current; + const canvas = canvasRef.current; + if (!root || !canvas || typeof requestAnimationFrame !== 'function') return; + + const renderer = makeRenderer(canvas); + if (!renderer) return; + + const resize = () => { + const pixelRatio = Math.min(window.devicePixelRatio || 1, maxPixelRatio); + const width = Math.round(root.clientWidth * pixelRatio); + const height = Math.round(root.clientHeight * pixelRatio); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + }; + resize(); + const resizeObserver = + typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null; + resizeObserver?.observe(root); + + // A new pixel ratio alone (another monitor, browser zoom) needs a new backing size too. The + // query matches only the ratio it was made for, so it is made again after each change. + let ratioQuery: MediaQueryList | null = null; + const onRatioChange = () => { + resize(); + watchRatio(); + }; + const watchRatio = () => { + ratioQuery?.removeEventListener?.('change', onRatioChange); + ratioQuery = + typeof window.matchMedia === 'function' + ? window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`) + : null; + ratioQuery?.addEventListener?.('change', onRatioChange); + }; + watchRatio(); + + let frameId = 0; + let last = 0; + let frames = 0; + let visible = true; + + const tick = (now: number) => { + frameId = 0; + const driver = driverRef.current; + if (!driver) return; + const seconds = last === 0 ? 0 : (now - last) / 1000; + if (last !== 0) { + driver.input = { + ...driver.input, + audioLevel: audioLevelInput(audioLevelRef.current?.()), + }; + driver.advance(seconds); + } + last = now; + if (++frames % THEME_FRAMES === 0) { + readTheme(); + applyInput(); + } + renderer.draw({ + dark: themeRef.current.dark, + driver, + height: canvas.height, + seconds, + width: canvas.width, + }); + // The canvas fades in once it has something to show, as on iOS, rather than flashing + // empty while a shader compiles or the first frame lands. + if (!canvas.dataset.ready) canvas.dataset.ready = 'true'; + schedule(); + }; + + const schedule = () => { + if (visible && !frameId) frameId = requestAnimationFrame(tick); + }; + + const stop = () => { + if (frameId) cancelAnimationFrame(frameId); + frameId = 0; + // The first frame back shows where it was rather than leaping over the pause. + last = 0; + }; + + // Off screen, nothing is drawn; hidden tabs already pause requestAnimationFrame. + const intersectionObserver = + typeof IntersectionObserver === 'function' + ? new IntersectionObserver((entries) => { + visible = entries.some((entry) => entry.isIntersecting); + if (visible) schedule(); + else stop(); + }) + : null; + intersectionObserver?.observe(root); + + const colorScheme = + typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: dark)') + : null; + const onSchemeChange = () => { + readTheme(); + applyInput(); + }; + colorScheme?.addEventListener?.('change', onSchemeChange); + + schedule(); + + return () => { + stop(); + resizeObserver?.disconnect(); + ratioQuery?.removeEventListener?.('change', onRatioChange); + intersectionObserver?.disconnect(); + colorScheme?.removeEventListener?.('change', onSchemeChange); + renderer.dispose?.(); + }; + }, [applyInput, makeRenderer, maxPixelRatio, readTheme, rendererKey]); + + // Pointer events are only watched, never prevented or stopped, so a click handler on the + // element or a parent still gets them. They come only from the shape's hit area: the rest of + // the box lets them through to whatever is behind it. + const handlePointerUp = (event: PointerEvent) => { + onPointerUp?.(event); + const { x, y } = toLocalPoint(event); + if (contains(x, y)) driverRef.current?.tap(x, y); + }; + + const handlePointerMove = (event: PointerEvent) => { + onPointerMove?.(event); + // Only a hovering mouse or pen ripples it; a finger has no hover. + if (event.pointerType === 'touch') return; + const { x, y } = toLocalPoint(event); + const hovering = event.buttons === 0 && contains(x, y); + driverRef.current?.hover(hovering ? x : null, hovering ? y : null); + }; + + const handlePointerLeave = (event: PointerEvent) => { + onPointerLeave?.(event); + driverRef.current?.hover(null, null); + }; + + return ( +
+