feat(call): add streaming voice call experience

This commit is contained in:
Codex
2026-07-24 16:36:36 +08:00
parent 9fbf180df6
commit 19b8fc51d6
19 changed files with 1108 additions and 5 deletions
+1
View File
@@ -5,6 +5,7 @@ NEXTAUTH_URL=https://frontend-test.banlv-ai.com
NEXTAUTH_URL_INTERNAL=http://localhost:3000 NEXTAUTH_URL_INTERNAL=http://localhost:3000
NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com
NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws
NEXT_PUBLIC_ENABLE_VOICE_CALL=false
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
+1
View File
@@ -10,6 +10,7 @@ NEXT_PUBLIC_API_BASE_URL=https://api.banlv-ai.com
# WebSocket URL(生产环境) # WebSocket URL(生产环境)
NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws
NEXT_PUBLIC_ENABLE_VOICE_CALL=false
# Stripe 支付(publishable key 可暴露在浏览器端) # Stripe 支付(publishable key 可暴露在浏览器端)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { VoiceCallPlayer } from "@/app/call/voice-call-player";
describe("VoiceCallPlayer", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("schedules decoded sentence audio on one continuous Web Audio timeline", async () => {
const starts: number[] = [];
const sources: Array<{ onended: (() => void) | null }> = [];
const context = {
state: "running",
currentTime: 0,
destination: {},
resume: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
decodeAudioData: vi.fn(async () => ({ duration: 1 })),
createBufferSource: vi.fn(() => {
const source = {
buffer: null,
onended: null as (() => void) | null,
connect: vi.fn(),
disconnect: vi.fn(),
stop: vi.fn(),
start: vi.fn((at: number) => starts.push(at)),
};
sources.push(source);
return source;
}),
};
vi.stubGlobal("AudioContext", function AudioContextMock() {
return context;
});
const player = new VoiceCallPlayer();
const gaps: number[] = [];
player.onGap = (milliseconds) => gaps.push(milliseconds);
const meta = {
callId: "call-1",
turnId: "turn-1",
index: 0,
mimeType: "audio/mpeg",
byteLength: 3,
};
player.enqueue(meta, new Uint8Array([1, 2, 3]).buffer);
player.enqueue({ ...meta, index: 1 }, new Uint8Array([4, 5, 6]).buffer);
await vi.waitFor(() => expect(starts).toHaveLength(2));
expect(starts).toEqual([0.025, 1.025]);
expect(gaps).toEqual([0]);
expect(sources).toHaveLength(2);
player.dispose();
});
});
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import { getCharacterRoutes } from "@/router/routes";
export default function LegacyCallPage() {
redirect(getCharacterRoutes(DEFAULT_CHARACTER_SLUG).call);
}
+101
View File
@@ -0,0 +1,101 @@
import type { CallAudioChunkMeta } from "@/core/net/voice-call-transport";
export class VoiceCallPlayer {
private context: AudioContext | null = null;
private sources = new Set<AudioBufferSourceNode>();
private decodeChain: Promise<void> = Promise.resolve();
private scheduledUntil = 0;
private pendingDecodes = 0;
private generation = 0;
onPlaying: (() => void) | null = null;
onIdle: (() => void) | null = null;
onGap: ((milliseconds: number) => void) | null = null;
onError: ((error: Error) => void) | null = null;
get isPlaying(): boolean {
return this.sources.size > 0 || this.pendingDecodes > 0;
}
async prepare(): Promise<void> {
const context = this.getContext();
if (context.state === "suspended") await context.resume();
}
enqueue(_meta: CallAudioChunkMeta, bytes: ArrayBuffer): void {
const generation = this.generation;
this.pendingDecodes += 1;
this.decodeChain = this.decodeChain
.then(async () => {
const context = this.getContext();
if (context.state === "suspended") await context.resume();
const audioBuffer = await context.decodeAudioData(bytes.slice(0));
if (generation !== this.generation) return;
this.schedule(context, audioBuffer);
})
.catch((error: unknown) => {
this.onError?.(error instanceof Error ? error : new Error(String(error)));
})
.finally(() => {
this.pendingDecodes = Math.max(0, this.pendingDecodes - 1);
this.notifyIdleIfNeeded();
});
}
stop(): void {
this.generation += 1;
for (const source of this.sources) {
source.onended = null;
try {
source.stop();
} catch {
// 已自然结束的 source 无需重复停止。
}
source.disconnect();
}
this.sources.clear();
this.scheduledUntil = 0;
this.onIdle?.();
}
dispose(): void {
this.stop();
const context = this.context;
this.context = null;
if (context && context.state !== "closed") void context.close();
}
private getContext(): AudioContext {
if (!this.context || this.context.state === "closed") {
this.context = new AudioContext({ latencyHint: "interactive" });
}
return this.context;
}
private schedule(context: AudioContext, buffer: AudioBuffer): void {
const leadSeconds = 0.025;
const earliestStart = context.currentTime + leadSeconds;
const hadPreviousSegment = this.scheduledUntil > 0;
const startAt = Math.max(earliestStart, this.scheduledUntil);
if (hadPreviousSegment) {
this.onGap?.(Math.max(0, (startAt - this.scheduledUntil) * 1000));
}
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
this.sources.add(source);
this.scheduledUntil = startAt + buffer.duration;
source.onended = () => {
source.disconnect();
this.sources.delete(source);
this.notifyIdleIfNeeded();
};
source.start(startAt);
this.onPlaying?.();
}
private notifyIdleIfNeeded(): void {
if (this.sources.size || this.pendingDecodes) return;
this.scheduledUntil = 0;
this.onIdle?.();
}
}
+47
View File
@@ -0,0 +1,47 @@
.screen {
position: relative;
display: flex;
min-height: var(--app-viewport-height, 100dvh);
overflow: hidden;
flex-direction: column;
color: #fff;
background: #090810;
}
.background { object-fit: cover; opacity: .48; }
.scrim { position: absolute; inset: 0; background: radial-gradient(circle at 50% 32%, rgba(239,83,155,.18), transparent 36%), linear-gradient(180deg, rgba(9,8,16,.28), rgba(9,8,16,.94)); }
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: calc(18px + var(--app-safe-top, 0px)) 20px 12px; }
.iconButton { display: inline-flex; width: 42px; height: 42px; cursor: pointer; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,.16); border-radius: 999px; color: #fff; background: rgba(10,9,18,.55); backdrop-filter: blur(14px); }
.balances { display: flex; gap: 8px; font-size: 12px; font-weight: 650; }
.balances span { padding: 7px 10px; border: 1px solid rgba(255,255,255,.12); border-radius: 999px; background: rgba(10,9,18,.58); backdrop-filter: blur(14px); }
.content { position: relative; z-index: 1; display: flex; min-height: 0; flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 24px 28px; text-align: center; }
.avatarRing { position: relative; display: grid; width: 174px; height: 174px; place-items: center; border-radius: 50%; background: linear-gradient(135deg, rgba(255,255,255,.24), rgba(239,83,155,.42)); box-shadow: 0 28px 72px rgba(0,0,0,.42); transition: transform .25s ease, box-shadow .25s ease; }
.avatarRing::after { position: absolute; inset: -10px; border: 1px solid rgba(255,255,255,.16); border-radius: inherit; content: ""; }
.avatarRing.listening, .avatarRing.speaking { animation: callPulse 1.8s ease-in-out infinite; box-shadow: 0 0 0 12px rgba(239,83,155,.08), 0 28px 72px rgba(0,0,0,.42); }
.avatar { width: 158px; height: 158px; border: 4px solid rgba(9,8,16,.8); border-radius: 50%; object-fit: cover; }
.content h1 { margin: 30px 0 8px; font-size: clamp(27px, 7vw, 36px); letter-spacing: -.025em; }
.state { display: flex; min-height: 28px; align-items: center; gap: 7px; color: rgba(255,255,255,.78); font-size: 15px; }
.lowBalance { margin: 12px 0 0; padding: 7px 11px; border-radius: 999px; color: #ffd6e8; background: rgba(210,48,121,.2); font-size: 12px; }
.transcript { width: min(100%, 440px); min-height: 105px; margin-top: 24px; text-align: left; }
.transcript p { margin: 0 0 10px; padding: 11px 13px; border: 1px solid rgba(255,255,255,.09); border-radius: 15px; color: rgba(255,255,255,.9); background: rgba(14,12,24,.48); font-size: 14px; line-height: 1.45; backdrop-filter: blur(12px); }
.transcript span { display: block; margin-bottom: 4px; color: #f3a3c8; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.error { max-width: 360px; margin: 8px 0 0; color: #ffc2ca; font-size: 13px; }
.footer { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; padding: 10px 24px calc(24px + var(--app-safe-bottom, 0px)); }
.footer p { margin: 12px 0 0; color: rgba(255,255,255,.52); font-size: 12px; }
.startButton { display: inline-flex; min-width: 170px; cursor: pointer; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 999px; padding: 15px 24px; color: #fff; background: linear-gradient(135deg, #ee579d, #ce357c); box-shadow: 0 16px 36px rgba(212,54,127,.32); font-size: 15px; font-weight: 750; }
.endButton { display: inline-flex; width: 66px; height: 66px; cursor: pointer; align-items: center; justify-content: center; border: 0; border-radius: 50%; color: #fff; background: #e24456; box-shadow: 0 15px 34px rgba(226,68,86,.34); }
.topUpBackdrop { position: absolute; z-index: 10; inset: 0; display: flex; align-items: flex-end; justify-content: center; padding: 18px; background: rgba(5,4,9,.68); backdrop-filter: blur(8px); }
.topUpDialog { width: min(100%, 460px); padding: 24px; border: 1px solid rgba(255,255,255,.12); border-radius: 24px; background: #17131f; box-shadow: 0 24px 70px rgba(0,0,0,.5); text-align: center; }
.topUpDialog h2 { margin: 0; font-size: 21px; }
.topUpDialog p { margin: 10px 0 20px; color: rgba(255,255,255,.66); font-size: 14px; line-height: 1.5; }
.topUpDialog button { width: 100%; cursor: pointer; border: 0; border-radius: 999px; padding: 13px 18px; color: #fff; background: #df478d; font-weight: 750; }
.topUpDialog .secondaryButton { margin-top: 8px; color: rgba(255,255,255,.72); background: transparent; }
@keyframes callPulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.025); } }
@media (max-height: 700px) {
.avatarRing { width: 138px; height: 138px; }
.avatar { width: 124px; height: 124px; }
.content h1 { margin-top: 20px; }
.transcript { min-height: 72px; margin-top: 16px; }
}
+280
View File
@@ -0,0 +1,280 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react";
import { Mic, PhoneOff, RotateCcw, Volume2, X } from "lucide-react";
import { MobileShell } from "@/app/_components/core";
import { getApiConfig } from "@/core/net/config/api_config";
import {
VoiceCallTransport,
type CallBillingStatusPayload,
} from "@/core/net/voice-call-transport";
import { VoiceCallAudioCapture } from "@/integrations/voice-call-audio";
import { getSessionAccessToken } from "@/lib/auth/auth_session";
import { useActiveCharacter, useActiveCharacterRoutes } from "@/providers/character-provider";
import { useAppNavigator } from "@/router/use-app-navigator";
import { VoiceCallPlayer } from "./voice-call-player";
import styles from "./voice-call-screen.module.css";
type CallUiState = "idle" | "connecting" | "listening" | "thinking" | "speaking" | "ended" | "error";
const STATE_COPY: Record<CallUiState, string> = {
idle: "Ready to call",
connecting: "Connecting…",
listening: "Listening…",
thinking: "Thinking…",
speaking: "Speaking…",
ended: "Call ended",
error: "Call unavailable",
};
async function getSessionToken(): Promise<string> {
const token = await getSessionAccessToken();
if (token) return token;
throw new Error("Please sign in before starting a call");
}
export function VoiceCallScreen() {
const character = useActiveCharacter();
const routes = useActiveCharacterRoutes();
const navigator = useAppNavigator();
const [uiState, setUiState] = useState<CallUiState>("idle");
const [userTranscript, setUserTranscript] = useState("");
const [aiTranscript, setAiTranscript] = useState("");
const [freeTurns, setFreeTurns] = useState(0);
const [balance, setBalance] = useState(0);
const [cost, setCost] = useState(2);
const [error, setError] = useState<string | null>(null);
const [showTopUp, setShowTopUp] = useState(false);
const transportRef = useRef<VoiceCallTransport | null>(null);
const captureRef = useRef<VoiceCallAudioCapture | null>(null);
const playerRef = useRef<VoiceCallPlayer | null>(null);
const callIdRef = useRef("");
const activeTurnRef = useRef("");
const reconnectTurnRef = useRef("");
const resumeVersionRef = useRef(0);
const endingRef = useRef(false);
const pendingTopUpRef = useRef(false);
const finishCall = useCallback((navigate = true) => {
if (endingRef.current) return;
endingRef.current = true;
captureRef.current?.dispose();
playerRef.current?.dispose();
if (callIdRef.current) transportRef.current?.endCall(callIdRef.current);
transportRef.current?.disconnect();
reconnectTurnRef.current = "";
resumeVersionRef.current = 0;
setUiState("ended");
if (navigate) navigator.push(routes.chat);
}, [navigator, routes.chat]);
const beginListening = useCallback((turnId: string) => {
const capture = captureRef.current;
const transport = transportRef.current;
const callId = callIdRef.current;
if (!capture || !transport || !callId || !turnId) return;
activeTurnRef.current = turnId;
reconnectTurnRef.current = turnId;
setUiState(playerRef.current?.isPlaying ? "speaking" : "listening");
const preRoll: ArrayBuffer[] = [];
let streamOpened = false;
capture.startTurn({
onChunk: (chunk) => {
if (streamOpened) {
transport.sendAudioChunk(chunk);
return;
}
preRoll.push(chunk);
if (preRoll.length > 5) preRoll.shift();
},
onSpeechStart: () => {
setError(null);
if (playerRef.current?.isPlaying) {
playerRef.current.stop();
transport.interrupt(callId);
}
streamOpened = transport.startAudio(
callId,
turnId,
capture.mimeType,
capture.sampleRate,
);
if (streamOpened) {
for (const chunk of preRoll) transport.sendAudioChunk(chunk);
preRoll.length = 0;
setUserTranscript("");
setAiTranscript("");
setUiState("listening");
}
},
onTurnEnd: (vadLatencyMs) => {
if (streamOpened) {
transport.endAudio(callId, turnId, vadLatencyMs);
setUiState("thinking");
}
},
onError: (captureError) => {
setError(captureError.message);
setUiState("error");
},
});
}, []);
const applyBilling = useCallback((status: CallBillingStatusPayload) => {
setBalance(status.creditBalance);
setFreeTurns(status.freeTurnsRemaining);
setCost(status.requiredCredits || 2);
if (status.canContinue && status.nextTurnId) {
beginListening(status.nextTurnId);
return;
}
captureRef.current?.stopTurn();
pendingTopUpRef.current = true;
if (!playerRef.current?.isPlaying) setShowTopUp(true);
}, [beginListening]);
const startCall = useCallback(async () => {
if (uiState !== "idle" && uiState !== "error") return;
endingRef.current = false;
pendingTopUpRef.current = false;
setError(null);
setUiState("connecting");
try {
const player = new VoiceCallPlayer();
playerRef.current = player;
const playerReady = player.prepare();
const capture = new VoiceCallAudioCapture();
await Promise.all([capture.prepare(), playerReady]);
captureRef.current = capture;
const token = await getSessionToken();
const transport = new VoiceCallTransport(getApiConfig().wsUrl, token);
transportRef.current = transport;
player.onPlaying = () => setUiState("speaking");
player.onIdle = () => {
if (pendingTopUpRef.current) setShowTopUp(true);
else if (activeTurnRef.current) setUiState("listening");
};
player.onError = (playbackError) => setError(playbackError.message);
player.onGap = (milliseconds) => {
transport.sendClientMetric("audioChunkGap", milliseconds);
};
transport.onOpen = () => {
if (!reconnectTurnRef.current) reconnectTurnRef.current = crypto.randomUUID();
transport.startCall(
character.id,
reconnectTurnRef.current,
resumeVersionRef.current || undefined,
);
};
transport.onReconnecting = () => {
capture.stopTurn();
player.stop();
callIdRef.current = "";
setUiState("connecting");
};
transport.onStarted = (started) => {
callIdRef.current = started.callId;
activeTurnRef.current = started.turnId;
reconnectTurnRef.current = started.turnId;
resumeVersionRef.current = started.resumeVersion;
setFreeTurns(started.freeTurnsRemaining);
setBalance(started.creditBalance);
setCost(started.costPerPaidTurn || 2);
beginListening(started.turnId);
};
transport.onState = setUiState;
transport.onTranscript = (text) => setUserTranscript(text);
transport.onAiSentence = (text) => {
setAiTranscript((current) => `${current}${current ? " " : ""}${text}`);
};
transport.onAudioChunk = (meta, bytes) => player.enqueue(meta, bytes);
transport.onBillingStatus = applyBilling;
transport.onEnded = () => setUiState("ended");
transport.onError = (code, message) => {
setError(message);
if (
activeTurnRef.current
&& (code === "CALL_TURN_FAILED" || code === "CALL_STT_FAILED")
) {
setUiState("listening");
} else {
setUiState("error");
}
};
transport.connect();
} catch (startError) {
captureRef.current?.dispose();
playerRef.current?.dispose();
setError(startError instanceof Error ? startError.message : String(startError));
setUiState("error");
}
}, [applyBilling, beginListening, character.id, uiState]);
useEffect(() => () => finishCall(false), [finishCall]);
const lowBalance = freeTurns === 0 && balance < cost * 2 && balance >= cost;
return (
<MobileShell background="#090810">
<main className={styles.screen}>
<Image src={character.assets.chatBackground} alt="" fill priority className={styles.background} />
<div className={styles.scrim} />
<header className={styles.header}>
<button type="button" className={styles.iconButton} onClick={() => finishCall()} aria-label="Close call">
<X size={22} aria-hidden="true" />
</button>
<div className={styles.balances}>
<span>{freeTurns} free</span>
<span>{balance} credits</span>
</div>
</header>
<section className={styles.content} aria-live="polite">
<div className={`${styles.avatarRing} ${styles[uiState]}`}>
<Image src={character.assets.avatar} alt={character.displayName} width={148} height={148} className={styles.avatar} priority />
</div>
<h1>{character.displayName}</h1>
<div className={styles.state}>
{uiState === "listening" ? <Mic size={18} aria-hidden="true" /> : null}
{uiState === "speaking" ? <Volume2 size={18} aria-hidden="true" /> : null}
<span>{STATE_COPY[uiState]}</span>
</div>
{lowBalance ? <p className={styles.lowBalance}>Your balance covers fewer than two paid turns.</p> : null}
<div className={styles.transcript}>
{userTranscript ? <p><span>You</span>{userTranscript}</p> : null}
{aiTranscript ? <p><span>{character.shortName}</span>{aiTranscript}</p> : null}
</div>
{error ? <p className={styles.error}>{error}</p> : null}
</section>
<footer className={styles.footer}>
{uiState === "idle" || uiState === "error" ? (
<button type="button" className={styles.startButton} onClick={() => void startCall()}>
{uiState === "error" ? <RotateCcw size={20} aria-hidden="true" /> : <Mic size={20} aria-hidden="true" />}
{uiState === "error" ? "Try again" : "Start call"}
</button>
) : (
<button type="button" className={styles.endButton} onClick={() => finishCall()} aria-label="End call">
<PhoneOff size={27} aria-hidden="true" />
</button>
)}
<p>Speak naturally you can interrupt at any time.</p>
</footer>
{showTopUp ? (
<div className={styles.topUpBackdrop} role="presentation">
<section className={styles.topUpDialog} role="dialog" aria-modal="true" aria-labelledby="call-topup-title">
<h2 id="call-topup-title">Not enough credits for another turn</h2>
<p>Your current reply has finished. Top up to keep the conversation going.</p>
<button type="button" onClick={() => navigator.openSubscription({ type: "topup", returnTo: "chat" })}>Top up credits</button>
<button type="button" className={styles.secondaryButton} onClick={() => finishCall()}>End call</button>
</section>
</div>
) : null}
</main>
</MobileShell>
);
}
@@ -0,0 +1,5 @@
import { VoiceCallScreen } from "@/app/call/voice-call-screen";
export default function CharacterCallPage() {
return <VoiceCallScreen />;
}
+20 -5
View File
@@ -5,7 +5,7 @@
* 顶部保持返回、首充优惠、收藏入口三段结构。 * 顶部保持返回、首充优惠、收藏入口三段结构。
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Lock } from "lucide-react"; import { Lock, Phone } from "lucide-react";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { FavoriteEntryButton } from "@/app/_components/core"; import { FavoriteEntryButton } from "@/app/_components/core";
@@ -27,6 +27,7 @@ export function ChatHeader({
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const character = useActiveCharacter(); const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const voiceCallEnabled = process.env.NEXT_PUBLIC_ENABLE_VOICE_CALL === "true";
return ( return (
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0"> <header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
@@ -58,10 +59,24 @@ export function ChatHeader({
<div className="flex min-w-0 justify-center">{offerBanner}</div> <div className="flex min-w-0 justify-center">{offerBanner}</div>
<FavoriteEntryButton <div className="flex items-center gap-2">
characterSlug={character.slug} {voiceCallEnabled ? (
tone="dark" <button
/> type="button"
className="inline-flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-white shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition hover:bg-[rgba(28,24,39,0.82)] active:scale-96"
aria-label={`Call ${character.shortName}`}
data-analytics-key="chat.start_voice_call"
data-analytics-label="Start voice call"
onClick={() => navigator.push(characterRoutes.call)}
>
<Phone size={19} strokeWidth={2.3} aria-hidden="true" />
</button>
) : null}
<FavoriteEntryButton
characterSlug={character.slug}
tone="dark"
/>
</div>
</div> </div>
</header> </header>
); );
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VoiceCallTransport } from "@/core/net/voice-call-transport";
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 3;
readyState = FakeWebSocket.CONNECTING;
binaryType = "blob";
sent: unknown[] = [];
onopen: (() => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;
onclose: ((event: { code: number; reason: string; wasClean: boolean }) => void) | null = null;
onerror: (() => void) | null = null;
constructor(readonly url: string) {}
send(data: unknown) { this.sent.push(data); }
close() { this.readyState = FakeWebSocket.CLOSED; }
open() { this.readyState = FakeWebSocket.OPEN; this.onopen?.(); }
receive(data: unknown) { this.onmessage?.({ data }); }
}
describe("VoiceCallTransport", () => {
let sockets: FakeWebSocket[];
beforeEach(() => {
sockets = [];
});
afterEach(() => {
vi.useRealTimers();
});
function create() {
return new VoiceCallTransport("wss://api.example/ws", "secret-token", {
webSocketFactory: (url) => {
const socket = new FakeWebSocket(url);
sockets.push(socket);
return socket as unknown as WebSocket;
},
});
}
it("uploads 100-200ms chunks as binary frames instead of base64 JSON", () => {
const transport = create();
transport.connect();
sockets[0].open();
transport.startCall("elio", "turn-1");
transport.startAudio("call-1", "turn-1", "audio/webm;codecs=opus", 48000);
const frame = new Uint8Array([1, 2, 3]).buffer;
transport.sendAudioChunk(frame);
transport.endAudio("call-1", "turn-1");
expect(JSON.parse(sockets[0].sent[0] as string)).toMatchObject({
type: "call_start",
protocolVersion: 2,
characterId: "elio",
turnId: "turn-1",
});
expect(JSON.parse(sockets[0].sent[1] as string)).toMatchObject({
type: "call_audio_start",
encoding: "opus",
});
expect(sockets[0].sent[2]).toBe(frame);
expect(JSON.parse(sockets[0].sent[3] as string).type).toBe("call_audio_end");
});
it("associates the next binary payload with its JSON audio metadata", () => {
const transport = create();
const received = vi.fn();
transport.onAudioChunk = received;
transport.connect();
sockets[0].open();
sockets[0].receive(JSON.stringify({
type: "call_audio_chunk_start",
protocolVersion: 2,
callId: "call-1",
turnId: "turn-1",
index: 0,
mimeType: "audio/mpeg",
byteLength: 3,
}));
const bytes = new Uint8Array([4, 5, 6]).buffer;
sockets[0].receive(bytes);
expect(received).toHaveBeenCalledWith(
expect.objectContaining({ turnId: "turn-1", index: 0, mimeType: "audio/mpeg" }),
bytes,
);
});
it("surfaces billing fields without treating a normal charge as an error", () => {
const transport = create();
const billing = vi.fn();
transport.onBillingStatus = billing;
transport.connect();
sockets[0].open();
sockets[0].receive(JSON.stringify({
type: "call_billing_status",
status: "settled",
creditsCharged: 2,
creditBalance: 8,
requiredCredits: 2,
shortfallCredits: 0,
canContinue: true,
nextTurnId: "turn-2",
}));
expect(billing).toHaveBeenCalledWith(expect.objectContaining({
creditsCharged: 2,
creditBalance: 8,
canContinue: true,
nextTurnId: "turn-2",
}));
});
it("starts a reconnect attempt within the two-second release gate", () => {
vi.useFakeTimers();
const transport = create();
const reconnecting = vi.fn();
transport.onReconnecting = reconnecting;
transport.connect();
sockets[0].open();
sockets[0].onclose?.({ code: 1006, reason: "network", wasClean: false });
expect(reconnecting).toHaveBeenCalledWith(400);
vi.advanceTimersByTime(400);
expect(sockets).toHaveLength(2);
sockets[1].open();
expect(JSON.parse(sockets[1].sent[0] as string)).toMatchObject({
type: "call_client_metric",
name: "reconnectLatency",
milliseconds: 400,
});
transport.disconnect();
});
});
+210
View File
@@ -0,0 +1,210 @@
import { Logger } from "@/utils/logger";
const log = new Logger("VoiceCallTransport");
const PROTOCOL_VERSION = 2 as const;
const WS_OPEN = 1;
const WS_CLOSING = 2;
export interface CallStartedPayload {
callId: string;
turnId: string;
characterId: string;
costPerPaidTurn: number;
freeTurnsRemaining: number;
creditBalance: number;
canContinue: boolean;
resumeVersion: number;
}
export interface CallBillingStatusPayload {
callId?: string;
turnId?: string;
nextTurnId?: string;
status: string;
creditsCharged: number;
creditBalance: number;
freeTurnsRemaining: number;
requiredCredits: number;
shortfallCredits: number;
canContinue: boolean;
}
export interface CallAudioChunkMeta {
callId: string;
turnId: string;
index: number;
mimeType: string;
byteLength: number;
}
export interface VoiceCallTransportOptions {
webSocketFactory?: (url: string) => WebSocket;
reconnectBaseMs?: number;
}
export class VoiceCallTransport {
private socket: WebSocket | null = null;
private disposed = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempt = 0;
private disconnectedAt = 0;
private pendingAudioMeta: CallAudioChunkMeta | null = null;
private readonly factory: (url: string) => WebSocket;
private readonly reconnectBaseMs: number;
onOpen: (() => void) | null = null;
onReconnecting: ((delayMs: number) => void) | null = null;
onStarted: ((payload: CallStartedPayload) => void) | null = null;
onState: ((state: "listening" | "thinking" | "speaking") => void) | null = null;
onTranscript: ((text: string, final: boolean) => void) | null = null;
onAiSentence: ((text: string, index: number) => void) | null = null;
onBillingStatus: ((payload: CallBillingStatusPayload) => void) | null = null;
onAudioChunk: ((meta: CallAudioChunkMeta, bytes: ArrayBuffer) => void) | null = null;
onInterrupted: (() => void) | null = null;
onEnded: (() => void) | null = null;
onError: ((code: string, message: string) => void) | null = null;
constructor(
private readonly serverUrl: string,
private readonly token: string,
options: VoiceCallTransportOptions = {},
) {
this.factory = options.webSocketFactory ?? ((url) => new WebSocket(url));
this.reconnectBaseMs = options.reconnectBaseMs ?? 400;
}
get isConnected(): boolean {
return this.socket?.readyState === WS_OPEN;
}
connect(): void {
if (this.disposed || (this.socket && this.socket.readyState < WS_CLOSING)) return;
const separator = this.serverUrl.includes("?") ? "&" : "?";
const url = `${this.serverUrl}${separator}token=${encodeURIComponent(this.token)}`;
const socket = this.factory(url);
socket.binaryType = "arraybuffer";
this.socket = socket;
socket.onopen = () => {
this.reconnectAttempt = 0;
if (this.disconnectedAt) {
this.sendClientMetric("reconnectLatency", Date.now() - this.disconnectedAt);
this.disconnectedAt = 0;
}
this.onOpen?.();
};
socket.onmessage = (event) => this.handleMessage(event.data);
socket.onerror = () => this.onError?.("CALL_NETWORK_ERROR", "通话网络连接异常");
socket.onclose = () => {
this.socket = null;
this.disconnectedAt = Date.now();
if (!this.disposed) this.scheduleReconnect();
};
}
startCall(characterId: string, turnId: string, resumeVersion?: number): boolean {
return this.sendJson({
type: "call_start", protocolVersion: PROTOCOL_VERSION, characterId, turnId,
...(resumeVersion ? { resumeVersion } : {}),
});
}
startAudio(callId: string, turnId: string, mimeType: string, sampleRate: number): boolean {
return this.sendJson({
type: "call_audio_start", protocolVersion: PROTOCOL_VERSION,
callId, turnId, encoding: "opus", mimeType, sampleRate,
});
}
sendAudioChunk(bytes: ArrayBuffer): boolean {
if (!this.isConnected) return false;
this.socket?.send(bytes);
return true;
}
endAudio(callId: string, turnId: string, vadLatency = 0): boolean {
return this.sendJson({
type: "call_audio_end", protocolVersion: PROTOCOL_VERSION, callId, turnId,
vadLatency,
});
}
sendClientMetric(name: "audioChunkGap" | "reconnectLatency", milliseconds: number): boolean {
return this.sendJson({
type: "call_client_metric",
protocolVersion: PROTOCOL_VERSION,
name,
milliseconds: Math.max(0, Math.min(60_000, milliseconds)),
});
}
interrupt(callId: string): boolean {
return this.sendJson({
type: "call_interrupt", protocolVersion: PROTOCOL_VERSION, callId,
});
}
endCall(callId: string): boolean {
return this.sendJson({
type: "call_end", protocolVersion: PROTOCOL_VERSION, callId,
});
}
disconnect(): void {
this.disposed = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
this.socket?.close();
this.socket = null;
}
private sendJson(payload: Record<string, unknown>): boolean {
if (!this.isConnected) return false;
this.socket?.send(JSON.stringify(payload));
return true;
}
private scheduleReconnect(): void {
const delayMs = Math.min(1600, this.reconnectBaseMs * 2 ** this.reconnectAttempt);
this.reconnectAttempt += 1;
this.onReconnecting?.(delayMs);
this.reconnectTimer = setTimeout(() => this.connect(), delayMs);
}
private handleMessage(data: unknown): void {
if (data instanceof ArrayBuffer) {
const meta = this.pendingAudioMeta;
this.pendingAudioMeta = null;
if (meta && meta.byteLength === data.byteLength) this.onAudioChunk?.(meta, data);
else if (meta) this.onError?.("CALL_AUDIO_FRAME_INVALID", "通话音频帧不完整");
return;
}
if (typeof data !== "string") return;
let payload: Record<string, unknown>;
try {
payload = JSON.parse(data) as Record<string, unknown>;
} catch (error) {
log.warn("Ignored malformed call event", error);
return;
}
const type = String(payload.type ?? "");
if (type === "call_started") {
this.onStarted?.(payload as unknown as CallStartedPayload);
} else if (type === "call_state") {
this.onState?.(String(payload.state) as "listening" | "thinking" | "speaking");
} else if (type === "call_stt_partial" || type === "call_stt_final") {
this.onTranscript?.(String(payload.text ?? ""), type === "call_stt_final");
} else if (type === "call_ai_sentence") {
this.onAiSentence?.(String(payload.text ?? ""), Number(payload.index ?? 0));
} else if (type === "call_billing_status") {
this.onBillingStatus?.(payload as unknown as CallBillingStatusPayload);
} else if (type === "call_audio_chunk_start") {
this.pendingAudioMeta = payload as unknown as CallAudioChunkMeta;
} else if (type === "call_interrupted") {
this.onInterrupted?.();
} else if (type === "call_ended") {
this.onEnded?.();
} else if (type === "call_error") {
this.onError?.(String(payload.errorCode ?? "CALL_ERROR"), String(payload.message ?? "通话失败"));
}
}
}
@@ -134,11 +134,26 @@ describe("User", () => {
photo: 40, photo: 40,
}, },
}, },
voiceCallBilling: {
enabled: true,
protocolVersion: 2,
chargeMode: "per_turn",
sharesNormalChatFreeDaily: true,
freeTurnsDaily: 30,
costPerPaidTurn: 2,
usageType: "voice_call_turn",
legacyMinuteCostUsed: false,
},
}); });
expect(entitlements.isVip).toBe(true); expect(entitlements.isVip).toBe(true);
expect(entitlements.creditBalance).toBe(120); expect(entitlements.creditBalance).toBe(120);
expect(entitlements.historyUnlock.costs.photo).toBe(40); expect(entitlements.historyUnlock.costs.photo).toBe(40);
expect(entitlements.voiceCallBilling).toMatchObject({
protocolVersion: 2,
chargeMode: "per_turn",
costPerPaidTurn: 2,
});
}); });
it("defaults nullable entitlement sections through schema helpers", () => { it("defaults nullable entitlement sections through schema helpers", () => {
@@ -49,6 +49,17 @@ const HISTORY_UNLOCK_DEFAULTS = {
costs: HISTORY_UNLOCK_COSTS_DEFAULTS, costs: HISTORY_UNLOCK_COSTS_DEFAULTS,
} as const; } as const;
const VOICE_CALL_BILLING_DEFAULTS = {
enabled: false,
protocolVersion: 2,
chargeMode: "per_turn",
sharesNormalChatFreeDaily: true,
freeTurnsDaily: 0,
costPerPaidTurn: 0,
usageType: "voice_call_turn",
legacyMinuteCostUsed: false,
} as const;
export const UserEntitlementsPolicySchema = z export const UserEntitlementsPolicySchema = z
.object({ .object({
membershipState: stringOrEmpty, membershipState: stringOrEmpty,
@@ -105,6 +116,20 @@ export const UserEntitlementsHistoryUnlockSchema = z
.passthrough() .passthrough()
.readonly(); .readonly();
export const VoiceCallBillingSchema = z
.object({
enabled: booleanOrFalse,
protocolVersion: numberOrZero,
chargeMode: stringOrEmpty,
sharesNormalChatFreeDaily: booleanOrFalse,
freeTurnsDaily: numberOrZero,
costPerPaidTurn: numberOrZero,
usageType: stringOrEmpty,
legacyMinuteCostUsed: booleanOrFalse,
})
.passthrough()
.readonly();
export const UserEntitlementsSchema = z export const UserEntitlementsSchema = z
.object({ .object({
userId: stringOrEmpty, userId: stringOrEmpty,
@@ -120,6 +145,10 @@ export const UserEntitlementsSchema = z
UserEntitlementsHistoryUnlockSchema, UserEntitlementsHistoryUnlockSchema,
HISTORY_UNLOCK_DEFAULTS, HISTORY_UNLOCK_DEFAULTS,
), ),
voiceCallBilling: schemaOr(
VoiceCallBillingSchema,
VOICE_CALL_BILLING_DEFAULTS,
),
}) })
.readonly(); .readonly();
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { AdaptiveVad } from "@/integrations/voice-call-audio";
describe("AdaptiveVad", () => {
it("adapts its silence window between 400 and 600ms", () => {
const quiet = new AdaptiveVad();
for (let i = 0; i < 20; i += 1) quiet.observe(0.005, i * 50);
expect(quiet.silenceWindowMs).toBeGreaterThanOrEqual(400);
expect(quiet.silenceWindowMs).toBeLessThanOrEqual(600);
const noisy = new AdaptiveVad();
for (let i = 0; i < 20; i += 1) noisy.observe(0.04, i * 50);
expect(noisy.silenceWindowMs).toBeGreaterThanOrEqual(quiet.silenceWindowMs);
expect(noisy.silenceWindowMs).toBeLessThanOrEqual(600);
});
it("ends a turn after speech followed by adaptive silence", () => {
const vad = new AdaptiveVad();
for (let i = 0; i < 10; i += 1) vad.observe(0.004, i * 50);
expect(vad.observe(0.2, 500).speechStarted).toBe(true);
let ended = false;
let silenceDurationMs = 0;
for (let t = 550; t <= 1200; t += 50) {
const observation = vad.observe(0.001, t);
ended ||= observation.turnEnded;
silenceDurationMs ||= observation.silenceDurationMs;
}
expect(ended).toBe(true);
expect(silenceDurationMs).toBeGreaterThanOrEqual(400);
expect(silenceDurationMs).toBeLessThanOrEqual(600);
});
});
+142
View File
@@ -0,0 +1,142 @@
export interface VadObservation {
speechStarted: boolean;
turnEnded: boolean;
silenceDurationMs: number;
}
export class AdaptiveVad {
private noiseFloor = 0.008;
private speaking = false;
private lastSpeechAt = 0;
get silenceWindowMs(): number {
return Math.max(400, Math.min(600, Math.round(400 + this.noiseFloor * 5000)));
}
observe(rms: number, nowMs: number): VadObservation {
const level = Math.max(0, Number.isFinite(rms) ? rms : 0);
if (!this.speaking) {
this.noiseFloor = this.noiseFloor * 0.9 + Math.min(level, 0.06) * 0.1;
}
const speechThreshold = Math.max(0.018, this.noiseFloor * 2.8);
const isSpeech = level >= speechThreshold;
let speechStarted = false;
let turnEnded = false;
let silenceDurationMs = 0;
if (isSpeech) {
speechStarted = !this.speaking;
this.speaking = true;
this.lastSpeechAt = nowMs;
} else if (this.speaking && nowMs - this.lastSpeechAt >= this.silenceWindowMs) {
this.speaking = false;
turnEnded = true;
silenceDurationMs = nowMs - this.lastSpeechAt;
}
return { speechStarted, turnEnded, silenceDurationMs };
}
reset(): void {
this.speaking = false;
this.lastSpeechAt = 0;
}
}
export interface VoiceCaptureCallbacks {
onChunk: (chunk: ArrayBuffer) => void;
onSpeechStart?: () => void;
onTurnEnd: (vadLatencyMs: number) => void;
onError: (error: Error) => void;
}
export class VoiceCallAudioCapture {
private stream: MediaStream | null = null;
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private recorder: MediaRecorder | null = null;
private vadTimer: number | null = null;
private pendingChunkReads: Promise<void> = Promise.resolve();
private readonly vad = new AdaptiveVad();
get mimeType(): string {
if (typeof MediaRecorder === "undefined") return "audio/webm;codecs=opus";
if (MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) return "audio/webm;codecs=opus";
if (MediaRecorder.isTypeSupported("audio/ogg;codecs=opus")) return "audio/ogg;codecs=opus";
return "audio/webm";
}
get sampleRate(): number {
return this.audioContext?.sampleRate ?? 48000;
}
async prepare(): Promise<void> {
if (this.stream) return;
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
},
});
this.audioContext = new AudioContext({ latencyHint: "interactive" });
await this.audioContext.resume();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 1024;
this.audioContext.createMediaStreamSource(this.stream).connect(this.analyser);
}
startTurn(callbacks: VoiceCaptureCallbacks): void {
if (!this.stream || !this.analyser) throw new Error("Microphone is not prepared");
if (this.recorder?.state === "recording") return;
this.vad.reset();
this.pendingChunkReads = Promise.resolve();
const recorder = new MediaRecorder(this.stream, { mimeType: this.mimeType });
this.recorder = recorder;
recorder.ondataavailable = (event) => {
if (event.data.size === 0) return;
this.pendingChunkReads = this.pendingChunkReads
.then(() => event.data.arrayBuffer())
.then(callbacks.onChunk)
.catch((error: unknown) => {
callbacks.onError(error instanceof Error ? error : new Error(String(error)));
});
};
recorder.onerror = () => callbacks.onError(new Error("Microphone recorder failed"));
recorder.start(120);
const samples = new Float32Array(this.analyser.fftSize);
this.vadTimer = window.setInterval(() => {
this.analyser?.getFloatTimeDomainData(samples);
let squareSum = 0;
for (const sample of samples) squareSum += sample * sample;
const rms = Math.sqrt(squareSum / samples.length);
const observation = this.vad.observe(rms, performance.now());
if (observation.speechStarted) callbacks.onSpeechStart?.();
if (observation.turnEnded) {
this.stopTurn(() => callbacks.onTurnEnd(observation.silenceDurationMs));
}
}, 50);
}
stopTurn(onStopped?: () => void): void {
if (this.vadTimer !== null) window.clearInterval(this.vadTimer);
this.vadTimer = null;
if (this.recorder?.state === "recording") {
this.recorder.onstop = () => {
void this.pendingChunkReads.then(() => onStopped?.());
};
this.recorder.stop();
} else {
onStopped?.();
}
this.recorder = null;
}
dispose(): void {
this.stopTurn();
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = null;
void this.audioContext?.close();
this.audioContext = null;
this.analyser = null;
}
}
+12
View File
@@ -14,3 +14,15 @@ export async function hasCompleteBusinessAuthSession(
const refreshTokenResult = await storage.getRefreshToken(); const refreshTokenResult = await storage.getRefreshToken();
return refreshTokenResult.success && Boolean(refreshTokenResult.data); return refreshTokenResult.success && Boolean(refreshTokenResult.data);
} }
/** 返回当前业务会话 token;真实用户优先,游客作为通话降级身份。 */
export async function getSessionAccessToken(
storage: IAuthStorage = AuthStorage.getInstance(),
): Promise<string | null> {
const loginTokenResult = await storage.getLoginToken();
if (loginTokenResult.success && loginTokenResult.data) {
return loginTokenResult.data;
}
const guestTokenResult = await storage.getGuestToken();
return guestTokenResult.success ? guestTokenResult.data : null;
}
+2
View File
@@ -26,11 +26,13 @@ describe("route meta", () => {
expect(routes).toEqual({ expect(routes).toEqual({
splash: "/characters/maya/splash", splash: "/characters/maya/splash",
chat: "/characters/maya/chat", chat: "/characters/maya/chat",
call: "/characters/maya/call",
privateZone: "/characters/maya/private-zone", privateZone: "/characters/maya/private-zone",
tip: "/characters/maya/tip", tip: "/characters/maya/tip",
}); });
expect(getRouteAccess(routes.splash)).toBe("authOnly"); expect(getRouteAccess(routes.splash)).toBe("authOnly");
expect(getRouteAccess(routes.chat)).toBe("guestEntry"); expect(getRouteAccess(routes.chat)).toBe("guestEntry");
expect(getRouteAccess(routes.call)).toBe("guestEntry");
expect(getRouteAccess(routes.privateZone)).toBe("guestEntry"); expect(getRouteAccess(routes.privateZone)).toBe("guestEntry");
expect(getRouteAccess(routes.tip)).toBe("public"); expect(getRouteAccess(routes.tip)).toBe("public");
}); });
+2
View File
@@ -12,6 +12,7 @@ const GLOBAL_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.splash]: "authOnly", [ROUTES.splash]: "authOnly",
[ROUTES.auth]: "authOnly", [ROUTES.auth]: "authOnly",
[ROUTES.chat]: "guestEntry", [ROUTES.chat]: "guestEntry",
[ROUTES.call]: "guestEntry",
[ROUTES.privateZone]: "guestEntry", [ROUTES.privateZone]: "guestEntry",
[ROUTES.tip]: "public", [ROUTES.tip]: "public",
[ROUTES.externalEntry]: "public", [ROUTES.externalEntry]: "public",
@@ -24,6 +25,7 @@ const GLOBAL_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
const CHARACTER_ROUTE_ACCESS: Readonly<Record<string, RouteAccess>> = { const CHARACTER_ROUTE_ACCESS: Readonly<Record<string, RouteAccess>> = {
splash: "authOnly", splash: "authOnly",
chat: "guestEntry", chat: "guestEntry",
call: "guestEntry",
"private-zone": "guestEntry", "private-zone": "guestEntry",
tip: "public", tip: "public",
}; };
+4
View File
@@ -21,6 +21,7 @@ export const ROUTES = {
root: "/", root: "/",
splash: "/splash", splash: "/splash",
chat: "/chat", chat: "/chat",
call: "/call",
privateZone: "/private-zone", privateZone: "/private-zone",
tip: "/tip", tip: "/tip",
externalEntry: "/external-entry", externalEntry: "/external-entry",
@@ -36,6 +37,7 @@ export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
export interface CharacterRoutes { export interface CharacterRoutes {
readonly splash: string; readonly splash: string;
readonly chat: string; readonly chat: string;
readonly call: string;
readonly privateZone: string; readonly privateZone: string;
readonly tip: string; readonly tip: string;
} }
@@ -47,6 +49,7 @@ export function getCharacterRoutes(
return { return {
splash: `${root}/splash`, splash: `${root}/splash`,
chat: `${root}/chat`, chat: `${root}/chat`,
call: `${root}/call`,
privateZone: `${root}/private-zone`, privateZone: `${root}/private-zone`,
tip: `${root}/tip`, tip: `${root}/tip`,
}; };
@@ -116,6 +119,7 @@ export const ROUTE_BUILDERS = {
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
ROUTES.splash, ROUTES.splash,
ROUTES.chat, ROUTES.chat,
ROUTES.call,
ROUTES.privateZone, ROUTES.privateZone,
ROUTES.tip, ROUTES.tip,
ROUTES.externalEntry, ROUTES.externalEntry,