feat(call): add streaming voice call experience
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user