Skip to content

Commit 37cac4d

Browse files
authored
Merge pull request #2938 from GCWing/gcwing/realtime-voice-panel
feat(voice): unify realtime call panels and audio-reactive logo
2 parents 448a7b0 + 1c8700d commit 37cac4d

44 files changed

Lines changed: 1361 additions & 530 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.lab-voice-call-preview {
2+
display: flex;
3+
justify-content: center;
4+
align-items: center;
5+
inline-size: min(480px, 100%);
6+
block-size: 680px;
7+
}
8+
9+
.lab-voice-call-preview--compact { block-size: 440px; }
10+
11+
.lab-voice-particle-preview {
12+
inline-size: min(300px, 100%);
13+
block-size: 300px;
14+
border-radius: var(--openbitfun-radius-xl);
15+
background: color-mix(in srgb, var(--openbitfun-color-content-on-light) 80%, transparent);
16+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useState } from "react";
2+
import { Button, VoiceCallPanel, VoiceParticleLogo, type VoiceCallPhase } from "@openbitfun/ui";
3+
import { useI18n } from "../i18n";
4+
import "./VoiceCallPreview.css";
5+
6+
/** Both catalog and workbench consume the published components, without simulated audio. */
7+
export function VoiceCallPreview({ state = "live", compact = false }: { state?: string; compact?: boolean }) {
8+
const { t } = useI18n();
9+
const [muted, setMuted] = useState(state === "muted");
10+
const [closed, setClosed] = useState(false);
11+
const [settingsRequested, setSettingsRequested] = useState(false);
12+
const phase: VoiceCallPhase = state === "connecting" || state === "ending" || state === "error" ? state : "live";
13+
return <div className={compact ? "lab-voice-call-preview lab-voice-call-preview--compact" : "lab-voice-call-preview"}>
14+
{closed ? <Button onClick={() => setClosed(false)}>{t("voice.preview.reopen")}</Button> : <VoiceCallPanel
15+
title={t("voice.preview.title")}
16+
labels={{
17+
back: t("voice.preview.back"), close: t("components.preview.close"), mute: t("voice.preview.mute"),
18+
unmute: t("voice.preview.unmute"), settings: t("voice.preview.settings"), end: t("voice.preview.end")
19+
}}
20+
phase={phase} muted={muted} userTranscript={t("voice.preview.user")} assistantTranscript={t("voice.preview.assistant")}
21+
status={settingsRequested ? t("voice.preview.settingsRequested") : phase === "live" ? undefined : t(`voice.preview.${phase}`)}
22+
onBack={() => setClosed(true)} onClose={() => setClosed(true)} onEnd={() => setClosed(true)}
23+
onToggleMute={() => setMuted(value => !value)} onOpenSettings={() => setSettingsRequested(value => !value)}
24+
/>}
25+
</div>;
26+
}
27+
28+
export function VoiceParticlePreview({ active = true }: { active?: boolean }) {
29+
return <div className="lab-voice-particle-preview"><VoiceParticleLogo active={active} /></div>;
30+
}

‎design-system/apps/design-lab/src/i18n/componentMetadata.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const descriptionKeys: Readonly<Record<string, MessageKey>> = {
4141
Input: "component.Input.description",
4242
KeyHint: "component.KeyHint.description",
4343
LauncherButton: "component.LauncherButton.description",
44+
VoiceCallPanel: "component.VoiceCallPanel.description",
45+
VoiceParticleLogo: "component.VoiceParticleLogo.description",
4446
LoadingState: "component.LoadingState.description",
4547
Menu: "component.Menu.description",
4648
MobileActionSheet: "component.MobileActionSheet.description",

‎design-system/apps/design-lab/src/i18n/messages.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import type { DesignLabLocale } from "./core.mjs";
22

33
export const enUSMessages = {
4+
"component.VoiceCallPanel.description": "A controlled live-call surface with a particle logo, transcripts and call controls.",
5+
"component.VoiceParticleLogo.description": "A reusable particle logo that reacts separately to microphone and assistant playback audio.",
6+
"voice.preview.title": "Live Call",
7+
"voice.preview.back": "Back to chat",
8+
"voice.preview.mute": "Mute microphone",
9+
"voice.preview.unmute": "Unmute microphone",
10+
"voice.preview.settings": "Voice settings",
11+
"voice.preview.end": "End call",
12+
"voice.preview.reopen": "Open call preview",
13+
"voice.preview.user": "Hello, I want to make a portfolio website",
14+
"voice.preview.assistant": "Hello! I’d be happy to help you create a portfolio website.\nA strong portfolio usually includes:",
15+
"voice.preview.settingsRequested": "Settings action received by the preview host.",
16+
"voice.preview.connecting": "Connecting…",
17+
"voice.preview.ending": "Ending call…",
18+
"voice.preview.error": "Connection interrupted",
419
"app.brandSubtitle": "Independent Design Lab",
520
"app.closeNavigation": "Close navigation",
621
"app.openNavigation": "Open navigation",
@@ -747,6 +762,21 @@ export type MessageKey = keyof typeof enUSMessages;
747762
export type MessageCatalog = Readonly<Record<MessageKey, string>>;
748763

749764
export const zhCNMessages = {
765+
"component.VoiceCallPanel.description": "受控通话面板,包含粒子标志、转写文本和通话控件。",
766+
"component.VoiceParticleLogo.description": "可复用的粒子标志,分别响应麦克风与 AI 播放音频。",
767+
"voice.preview.title": "实时通话",
768+
"voice.preview.back": "返回聊天",
769+
"voice.preview.mute": "麦克风静音",
770+
"voice.preview.unmute": "取消麦克风静音",
771+
"voice.preview.settings": "语音设置",
772+
"voice.preview.end": "挂断通话",
773+
"voice.preview.reopen": "打开通话预览",
774+
"voice.preview.user": "你好,我想制作一个作品集网站",
775+
"voice.preview.assistant": "你好!很乐意帮你创建作品集网站。\n一个出色的作品集通常包括:",
776+
"voice.preview.settingsRequested": "预览宿主已收到设置操作。",
777+
"voice.preview.connecting": "正在连接…",
778+
"voice.preview.ending": "正在结束通话…",
779+
"voice.preview.error": "连接已中断",
750780
"app.brandSubtitle": "独立 Design Lab",
751781
"app.closeNavigation": "关闭导航",
752782
"app.openNavigation": "打开导航",
@@ -1491,6 +1521,21 @@ export const zhCNMessages = {
14911521

14921522
export const zhTWMessages = {
14931523
...zhCNMessages,
1524+
"component.VoiceCallPanel.description": "受控通話面板,包含粒子標誌、轉寫文字與通話控制項。",
1525+
"component.VoiceParticleLogo.description": "可重用的粒子標誌,分別回應麥克風與 AI 播放音訊。",
1526+
"voice.preview.title": "即時通話",
1527+
"voice.preview.back": "返回聊天",
1528+
"voice.preview.mute": "麥克風靜音",
1529+
"voice.preview.unmute": "取消麥克風靜音",
1530+
"voice.preview.settings": "語音設定",
1531+
"voice.preview.end": "掛斷通話",
1532+
"voice.preview.reopen": "開啟通話預覽",
1533+
"voice.preview.user": "你好,我想製作一個作品集網站",
1534+
"voice.preview.assistant": "你好!很樂意幫你建立作品集網站。\n一個出色的作品集通常包括:",
1535+
"voice.preview.settingsRequested": "預覽宿主已收到設定操作。",
1536+
"voice.preview.connecting": "正在連線…",
1537+
"voice.preview.ending": "正在結束通話…",
1538+
"voice.preview.error": "連線已中斷",
14941539
"patterns.workspace.parent": "父資料夾",
14951540
"patterns.workspace.name": "工作區名稱",
14961541
"patterns.workspace.fullPath": "完整路徑",

‎design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Fragment, useMemo, useState } from "react";
2+
import { VoiceCallPreview, VoiceParticlePreview } from "../components/VoiceCallPreview";
23
import { Clipboard, List } from "lucide-react";
34
import {
45
ActionCard,
@@ -497,6 +498,12 @@ export function ComponentDetailPage({
497498
if (component.name === "LauncherButton") {
498499
return 'import { Icon, LauncherButton } from "@openbitfun/ui";\n\n<LauncherButton leadingIcon={<Icon name="mic" />}>\n Hello\n</LauncherButton>';
499500
}
501+
if (component.name === "VoiceParticleLogo") {
502+
return 'import { VoiceParticleLogo } from "@openbitfun/ui";\n\n// Return live FFT byte bins from fftSize=256 analysers.\n<VoiceParticleLogo readAudio={() => ({\n user: microphoneFrequencyData,\n assistant: playbackFrequencyData,\n assistantSpeaking: isAudioPlaying,\n})} />';
503+
}
504+
if (component.name === "VoiceCallPanel") {
505+
return 'import { VoiceCallPanel } from "@openbitfun/ui";\n\n<VoiceCallPanel\n title="Live Call"\n labels={{ back: "Back to chat", close: "Close", mute: "Mute",\n unmute: "Unmute", settings: "Settings", end: "End call" }}\n phase="live"\n muted={muted}\n userTranscript={userTranscript}\n assistantTranscript={assistantTranscript}\n readAudio={readAudio}\n onBack={returnToChat}\n onClose={closeWindow}\n onToggleMute={toggleMute}\n onOpenSettings={openVoiceSettings}\n onEnd={endCall}\n/>';
506+
}
500507
if (component.name === "ActionItem") {
501508
const metadataProp = actionItemShowMetadata ? `\n metadata="12"` : "";
502509
return `import { Icon, ActionItem, KeyHint } from "@openbitfun/ui";\n\n<ActionItem\n actions={[\n { id: "add", icon: <Icon name="plus" />, label: "${t("components.preview.add")}" },\n { id: "more", icon: <Icon name="more" />, label: "${t("components.preview.more")}" },\n ]}\n leading={<Icon name="session" />}${metadataProp}\n shortcut={<KeyHint>K</KeyHint>}\n>\n ${t("components.preview.assistant")}\n</ActionItem>`;
@@ -892,6 +899,8 @@ export function ComponentDetailPage({
892899
previewVariant = variant,
893900
applyInspectorControls = false,
894901
) {
902+
if (component.name === "VoiceCallPanel") return <VoiceCallPreview key={state} state={state} />;
903+
if (component.name === "VoiceParticleLogo") return <VoiceParticlePreview active={state !== "paused" && state !== "reduced-motion"} />;
895904
if (isFlowChatComponent) {
896905
return (
897906
<FlowChatComponentPreview

‎design-system/apps/design-lab/src/pages/ComponentsPage.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AppWindow, Heading, Keyboard, List, Rows3, MousePointerClick, PanelTop, ToggleLeft } from "lucide-react";
2+
import { VoiceCallPreview, VoiceParticlePreview } from "../components/VoiceCallPreview";
23
import {
34
ActionCard,
45
ActionItem,
@@ -168,6 +169,8 @@ const componentIcons = {
168169

169170
function ComponentCardPreview({ component }: { component: ComponentMeta }) {
170171
const { t } = useI18n();
172+
if (component.name === "VoiceCallPanel") return <VoiceCallPreview compact />;
173+
if (component.name === "VoiceParticleLogo") return <VoiceParticlePreview />;
171174
if (component.name === "Combobox") return <Combobox label={t("components.preview.modalProviderName")} defaultValue="openbitfun" options={[{ value: "openbitfun", label: "OpenBitFun" }, { value: "custom", label: t("components.preview.add") }]} />;
172175
const flowChatPreview = getFlowChatPreviewDefinition(component.name);
173176

‎design-system/packages/design-tokens/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ tokens. The core interface roles are:
3333
| --- | --- | --- |
3434
| Page title | `type.heading.page` | Control, 24px, 700, 1.2, normal |
3535
| Compact page title | `type.heading.compactPage` | Control, 20px, 600, 1.2, normal |
36+
| Panel title | `type.heading.panel` | Control, 18px, 600, 1.2, normal |
3637
| Navigation title | `type.heading.navigation` | Control, 17px, 600, 1.2, normal |
3738
| Section title | `type.heading.section` | Control, 15px, 600, 1.2, normal |
3839
| Card title | `type.heading.card` | Control, 13px, 600, 1.2, normal |
3940
| Body copy | `type.body.sm` | Sans, 13px, 400, 1.5, normal |
41+
| Large body copy | `type.body.lg` | Sans, 15px, 400, 1.6, normal |
4042
| Supporting text | `type.support` | Control, 11px, 400, 1.55, normal |
4143
| Control label | `type.label.md` | Control, 13px, 400, 1.2, normal |
4244
| Selected control label | `type.label.selected` | Control, 13px, 600, 1.2, normal |
@@ -64,6 +66,8 @@ its established role. `type.overline.*` owns extra-small uppercase annotations;
6466
these modifiers keep product styles semantic without changing their resolved
6567
metrics during migration. `type.modifier.leading.support` provides the compact
6668
1.45 supporting-text rhythm used when an 11px role must align to a 16px line.
69+
`type.modifier.leading.tight` provides 1.2 leading for compact message bubbles:
70+
18px at the default 15px body size, scaling with user typography preferences.
6771

6872
`layout.searchDialog` owns the shared Lab/product search composition: 800 × 460
6973
when space permits, 20px inset and query-to-scope gap, and a 30px query row.

‎design-system/packages/design-tokens/src/system.tokens.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,14 @@
258258
"lineHeight": { "$value": "{lineHeight.tight}" },
259259
"letterSpacing": { "$value": "{letterSpacing.normal}" }
260260
},
261+
"panel": {
262+
"$description": "Compact panel titles, including VoiceCallPanel, use 18px between the 17px navigation and 20px compact-page roles.",
263+
"fontFamily": { "$value": "{font.family.control}" },
264+
"fontSize": { "$value": "{font.size.2xl}" },
265+
"fontWeight": { "$value": "{font.weight.semibold}" },
266+
"lineHeight": { "$value": "{lineHeight.tight}" },
267+
"letterSpacing": { "$value": "{letterSpacing.normal}" }
268+
},
261269
"section": {
262270
"fontFamily": { "$value": "{font.family.control}" },
263271
"fontSize": { "$value": "{font.size.lg}" },
@@ -321,6 +329,10 @@
321329
"$description": "Single-property semantic modifiers for intentional deviations from a complete typography role.",
322330
"leading": {
323331
"none": { "lineHeight": { "$value": "{lineHeight.none}" } },
332+
"tight": {
333+
"$description": "Compact text leading for message bubbles: 18px at the default 15px body size, scaling with user typography.",
334+
"lineHeight": { "$value": "{lineHeight.tight}" }
335+
},
324336
"dense": { "lineHeight": { "$value": "{lineHeight.dense}" } },
325337
"snug": { "lineHeight": { "$value": "{lineHeight.snug}" } },
326338
"balanced": { "lineHeight": { "$value": "{lineHeight.balanced}" } },

‎design-system/packages/ui/README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,43 @@ export function Example() {
1818

1919
The package owns component anatomy, behavior, accessibility, and stable variants. It does not own theme selection persistence, product state, routes, locale resources, or platform APIs.
2020

21+
## Voice calls
22+
23+
`VoiceCallPanel` owns the complete compact call surface: navigation, particle
24+
logo, scrolling transcripts and mute/settings/end controls. Pass localized
25+
`title`, `labels`, transcript strings, optional `status`, and callbacks from
26+
the host. Returning to chat, closing the window and ending a call are separate
27+
callbacks so the package never decides session or window lifetime.
28+
29+
Typography follows the shared `type.heading.panel` role for the title (18px,
30+
semibold) and `type.body.lg` for transcripts and status text (15px, regular,
31+
1.6 line height). Both use the canonical interface font stacks and follow user
32+
font-size preferences. Compact-height layouts keep the same typography roles.
33+
User bubbles apply `type.modifier.leading.tight` for 18px leading, with 12px
34+
padding on all sides and a 12px corner radius. They fit their content and wrap
35+
within the conversation width; a single line is 42px high at the default size.
36+
37+
`VoiceParticleLogo` is also exported independently. Its `readAudio` callback
38+
reads `{ user, assistant, assistantSpeaking }` once per animation frame. The
39+
two spectra are FFT byte bins from analysers configured with `fftSize = 256`;
40+
pass `null` for muted or unavailable audio. `assistantSpeaking` describes the
41+
audible playback clock, including queued audio that outlives a provider's
42+
completion event. Omit the callback for the calm resting motion.
43+
44+
The supplied Voice-Particles-Demo contour, sampling, force coefficients,
45+
envelopes, density and brightness profiles are preserved in a fixed simulation
46+
space; resizing changes only its projection. The host supplies real capture and
47+
playback, and keeps permission, network, transcription and audio cleanup outside
48+
the component. The canvas pauses offscreen, when inactive or hidden, and renders
49+
a still logo for reduced motion. Both components are registered in Design Lab;
50+
its presentation specimens do not capture or simulate speech.
51+
52+
The panel uses the semantic on-light/on-dark endpoints: 80% dark background,
53+
30% light user bubble, full light text and 20% light circular controls. Render
54+
it on a transparent host shell to avoid applying the background opacity twice.
55+
The host allocates its dimensions; the product uses the existing 480 × 680
56+
compact-window contract. Build and source tests do not prove visual fidelity.
57+
2158
## Buttons
2259

2360
Choose variants by action role: use `primary` for the main save, submit, create,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ComponentMeta } from "../../registry.types";
2+
3+
export const voiceCallPanelMeta = {
4+
category: "feedback",
5+
name: "VoiceCallPanel",
6+
description: "A controlled live-call surface with a reactive particle logo, transcripts, and accessible call controls.",
7+
maturity: "stable",
8+
props: [
9+
{ name: "title", type: "string" },
10+
{ name: "labels", type: "VoiceCallLabels" },
11+
{ name: "phase", type: "connecting | live | ending | error", defaultValue: "live" },
12+
{ name: "muted", type: "boolean", defaultValue: "false" },
13+
{ name: "userTranscript", type: "string" },
14+
{ name: "assistantTranscript", type: "string" },
15+
{ name: "status", type: "ReactNode" },
16+
{ name: "readAudio", type: "VoiceParticleAudioReader" },
17+
{ name: "onBack / onClose / onToggleMute / onOpenSettings / onEnd", type: "() => void" },
18+
],
19+
states: ["connecting", "live", "muted", "ending", "error"],
20+
tokens: [
21+
"color.content.onDark", "color.content.onLight",
22+
"type.heading.panel", "type.body.lg", "type.modifier.leading.tight",
23+
"space.3", "space.6", "space.8", "radius.lg",
24+
],
25+
} as const satisfies ComponentMeta;

0 commit comments

Comments
 (0)