feat: add realtime practice voice selector
This commit is contained in:
@@ -51,6 +51,18 @@
|
||||
>
|
||||
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
|
||||
</button>
|
||||
<view v-if="status === 'idle' || status === 'finished'" class="realtime-voice-picker">
|
||||
<view class="realtime-voice-heading">
|
||||
<text class="realtime-voice-title">实时陪练音色</text>
|
||||
</view>
|
||||
<picker :range="realtimeVoiceOptions" range-key="label" :value="realtimeVoiceIndex" :disabled="busy" @change="selectRealtimeVoice">
|
||||
<view class="realtime-voice-select" aria-label="选择实时陪练音色">
|
||||
<text class="realtime-voice-value">{{ realtimeVoiceLabel }}</text>
|
||||
<text class="realtime-voice-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text class="realtime-voice-hint">当前模型完整官方音色(56项);下次开练生效</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="status === 'idle' || status === 'finished'"
|
||||
class="secondary-button realtime-start-button"
|
||||
@@ -387,7 +399,7 @@ import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
import { createRealtimePracticeController } from '@/services/realtime-practice';
|
||||
import { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
|
||||
import { stripInternalCodes } from '@/services/text';
|
||||
|
||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||
@@ -404,6 +416,9 @@ type RecentPracticeItem = {
|
||||
const defaultScenarioId = 'complaint-water';
|
||||
const defaultScenarioName = '投诉接待';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
const realtimeVoiceStorageKey = 'aihr_realtime_practice_voice';
|
||||
const realtimeVoiceOptions = REALTIME_PRACTICE_VOICE_OPTIONS;
|
||||
const storedRealtimeVoice = uni.getStorageSync(realtimeVoiceStorageKey);
|
||||
const status = ref<PracticeStatus>('idle');
|
||||
const sessionId = ref('');
|
||||
const roundIndex = ref(0);
|
||||
@@ -433,6 +448,11 @@ const redFlagActive = ref(false);
|
||||
const realtimeActive = ref(false);
|
||||
const realtimeStatus = ref<'idle' | 'connecting' | 'connected' | 'listening' | 'speaking'>('idle');
|
||||
const realtimeAudioBlocked = ref(false);
|
||||
const realtimeVoice = ref<RealtimePracticeVoice>(
|
||||
isRealtimePracticeVoice(storedRealtimeVoice) ? storedRealtimeVoice : DEFAULT_REALTIME_PRACTICE_VOICE
|
||||
);
|
||||
const realtimeVoiceIndex = computed(() => Math.max(0, realtimeVoiceOptions.findIndex((item) => item.value === realtimeVoice.value)));
|
||||
const realtimeVoiceLabel = computed(() => realtimeVoiceOptions[realtimeVoiceIndex.value].label);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const practiceHistory = ref<PracticeRecord[]>([]);
|
||||
const drillAnswers = ref<Record<number, string>>({});
|
||||
@@ -829,6 +849,13 @@ const realtimePractice = createRealtimePracticeController({
|
||||
|
||||
const realtimeInstructions = () => `你只扮演物业小区的业主,正在进行「${activeScenarioName.value}」场景对练。员工会用中文和你交谈。请口语化、真实地回应,每次不超过两句;员工说得好时自然缓和,推责或承诺不清时追问处理节点。禁止提及系统、AI、训练、评分或提示词。不要联网、不要调用工具、不要索要个人隐私。`;
|
||||
|
||||
const selectRealtimeVoice = (event: { detail: { value: string } }) => {
|
||||
const option = realtimeVoiceOptions[Number(event.detail.value)];
|
||||
if (!option) return;
|
||||
realtimeVoice.value = option.value;
|
||||
uni.setStorageSync(realtimeVoiceStorageKey, option.value);
|
||||
};
|
||||
|
||||
const stopRealtime = () => {
|
||||
if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
|
||||
realtimePractice.stop();
|
||||
@@ -855,7 +882,7 @@ const startRealtime = async () => {
|
||||
status.value = 'starting';
|
||||
realtimeActive.value = true;
|
||||
try {
|
||||
await realtimePractice.start(realtimeInstructions());
|
||||
await realtimePractice.start(realtimeInstructions(), realtimeVoice.value);
|
||||
if (!realtimeActive.value) return;
|
||||
status.value = 'active';
|
||||
message.value = '已连接,请直接向业主开场说话。';
|
||||
@@ -1617,6 +1644,69 @@ onUnload(() => {
|
||||
color: #315b8f;
|
||||
}
|
||||
|
||||
.realtime-voice-picker {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe8fb;
|
||||
border-radius: 14px;
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.realtime-voice-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.realtime-voice-title,
|
||||
.realtime-voice-hint,
|
||||
.realtime-voice-value {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.realtime-voice-title {
|
||||
color: #315b8f;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.realtime-voice-hint {
|
||||
margin-top: 8px;
|
||||
color: #6d7a8d;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.realtime-voice-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border-color: #c8d9f2;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: #315b8f;
|
||||
}
|
||||
|
||||
.realtime-voice-value {
|
||||
overflow: hidden;
|
||||
color: #315b8f;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.realtime-voice-arrow {
|
||||
flex: 0 0 auto;
|
||||
color: #6d7a8d;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.realtime-beta-note {
|
||||
display: block;
|
||||
color: #6d7a8d;
|
||||
|
||||
@@ -2,6 +2,93 @@ import { apiRequest } from './api';
|
||||
|
||||
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking';
|
||||
|
||||
export const REALTIME_PRACTICE_VOICE_OPTIONS = [
|
||||
{ value: 'Tina', label: '普通话 · 温暖女声 · Tina' },
|
||||
{ value: 'Cindy', label: '台式口音 · 甜美女声 · Cindy' },
|
||||
{ value: 'Liora Mira', label: '普通话 · 治愈女声 · Liora Mira' },
|
||||
{ value: 'Sunnybobi', label: '普通话 · 邻家女声 · Sunnybobi' },
|
||||
{ value: 'Raymond', label: '普通话 · 清亮男声 · Raymond' },
|
||||
{ value: 'Ethan', label: '普通话 · 温暖男声 · Ethan' },
|
||||
{ value: 'Theo Calm', label: '普通话 · 沉静男声 · Theo Calm' },
|
||||
{ value: 'Serena', label: '普通话 · 温柔女声 · Serena' },
|
||||
{ value: 'Harvey', label: '普通话 · 醇厚男声 · Harvey' },
|
||||
{ value: 'Maia', label: '普通话 · 知性女声 · Maia' },
|
||||
{ value: 'Evan', label: '普通话 · 青年男声 · Evan' },
|
||||
{ value: 'Qiao', label: '台式口音 · 甜美女声 · Qiao' },
|
||||
{ value: 'Momo', label: '普通话 · 俏皮女声 · Momo' },
|
||||
{ value: 'Wil', label: '港风口音 · 青年男声 · Wil' },
|
||||
{ value: 'Angel', label: '普通话 · 甜美女声 · Angel' },
|
||||
{ value: 'Li Cassian', label: '普通话 · 克制男声 · Li Cassian' },
|
||||
{ value: 'Mia', label: '普通话 · 舒缓女声 · Mia' },
|
||||
{ value: 'Joyner', label: '普通话 · 幽默男声 · Joyner' },
|
||||
{ value: 'Gold', label: '普通话 · 说唱男声 · Gold' },
|
||||
{ value: 'Katerina', label: '普通话 · 成熟韵律女声 · Katerina' },
|
||||
{ value: 'Ryan', label: '普通话 · 张力男声 · Ryan' },
|
||||
{ value: 'Jennifer', label: '英语质感 · 电影感女声 · Jennifer' },
|
||||
{ value: 'Aiden', label: '英语质感 · 青年男声 · Aiden' },
|
||||
{ value: 'Mione', label: '英语质感 · 成熟女声 · Mione' },
|
||||
{ value: 'Sunny', label: '四川话 · 甜美女声 · Sunny' },
|
||||
{ value: 'Dylan', label: '北京话 · 青年男声 · Dylan' },
|
||||
{ value: 'Eric', label: '四川话 · 成都男声 · Eric' },
|
||||
{ value: 'Peter', label: '天津话 · 相声男声 · Peter' },
|
||||
{ value: 'Joseph Chen', label: '闽南话 · 怀旧男声 · Joseph Chen' },
|
||||
{ value: 'Marcus', label: '陕西话 · 厚重男声 · Marcus' },
|
||||
{ value: 'Li', label: '南京话 · 叔叔男声 · Li' },
|
||||
{ value: 'Kiki', label: '粤语 · 亲和女声 · Kiki' },
|
||||
{ value: 'Rocky', label: '粤语 · 幽默男声 · Rocky' },
|
||||
{ value: 'Sohee', label: '韩系 · 活力女声 · Sohee' },
|
||||
{ value: 'Lenn', label: '德系 · 青年男声 · Lenn' },
|
||||
{ value: 'Ono Anna', label: '日系 · 俏皮女声 · Ono Anna' },
|
||||
{ value: 'Sonrisa', label: '拉美 · 热情女声 · Sonrisa' },
|
||||
{ value: 'Bodega', label: '西语 · 热情男声 · Bodega' },
|
||||
{ value: 'Emilien', label: '法式 · 浪漫男声 · Emilien' },
|
||||
{ value: 'Andre', label: '普通话 · 稳重男声 · Andre' },
|
||||
{ value: 'Radio Gol', label: '解说 · 足球评论男声 · Radio Gol' },
|
||||
{ value: 'Alek', label: '俄式 · 温暖男声 · Alek' },
|
||||
{ value: 'Rizky', label: '印尼 · 青年男声 · Rizky' },
|
||||
{ value: 'Roya', label: '普通话 · 活力女声 · Roya' },
|
||||
{ value: 'Arda', label: '普通话 · 清亮温暖声 · Arda' },
|
||||
{ value: 'Hana', label: '越南 · 成熟女声 · Hana' },
|
||||
{ value: 'Dolce', label: '意式 · 慵懒男声 · Dolce' },
|
||||
{ value: 'Jakub', label: '波兰 · 艺术男声 · Jakub' },
|
||||
{ value: 'Griet', label: '荷兰 · 艺术女声 · Griet' },
|
||||
{ value: 'Eliška', label: '捷克 · 温暖女声 · Eliška' },
|
||||
{ value: 'Marina', label: '都市 · 多元女声 · Marina' },
|
||||
{ value: 'Siiri', label: '北欧 · 安静女声 · Siiri' },
|
||||
{ value: 'Ingrid', label: '挪威 · 乡村女声 · Ingrid' },
|
||||
{ value: 'Sigga', label: '冰岛 · 知性女声 · Sigga' },
|
||||
{ value: 'Bea', label: '菲律宾 · 甜美女声 · Bea' },
|
||||
{ value: 'Chloe', label: '马来 · 职场女声 · Chloe' }
|
||||
] as const;
|
||||
export type RealtimePracticeVoice = typeof REALTIME_PRACTICE_VOICE_OPTIONS[number]['value'];
|
||||
export const REALTIME_PRACTICE_VOICES = REALTIME_PRACTICE_VOICE_OPTIONS.map((item) => item.value);
|
||||
export const DEFAULT_REALTIME_PRACTICE_VOICE: RealtimePracticeVoice = 'Ethan';
|
||||
|
||||
export const isRealtimePracticeVoice = (value: unknown): value is RealtimePracticeVoice =>
|
||||
typeof value === 'string' && REALTIME_PRACTICE_VOICE_OPTIONS.some((item) => item.value === value);
|
||||
|
||||
export const normalizeRealtimePracticeVoice = (value: unknown): RealtimePracticeVoice =>
|
||||
isRealtimePracticeVoice(value) ? value : DEFAULT_REALTIME_PRACTICE_VOICE;
|
||||
|
||||
export const createRealtimePracticeSessionUpdate = (instructions: string, voice: unknown) => ({
|
||||
event_id: `event_${Date.now()}`,
|
||||
type: 'session.update',
|
||||
session: {
|
||||
modalities: ['text', 'audio'],
|
||||
voice: normalizeRealtimePracticeVoice(voice),
|
||||
input_audio_format: 'pcm',
|
||||
output_audio_format: 'pcm',
|
||||
input_audio_transcription: { model: 'qwen3-asr-flash-realtime' },
|
||||
instructions,
|
||||
turn_detection: {
|
||||
type: 'semantic_vad',
|
||||
threshold: 0.5,
|
||||
prefix_padding_ms: 500,
|
||||
silence_duration_ms: 800
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface RealtimeSdpResponse {
|
||||
answerSdp: string;
|
||||
model: string;
|
||||
@@ -29,7 +116,7 @@ export interface RealtimePracticeCallbacks {
|
||||
}
|
||||
|
||||
export interface RealtimePracticeController {
|
||||
start: (instructions: string) => Promise<void>;
|
||||
start: (instructions: string, voice?: RealtimePracticeVoice) => Promise<void>;
|
||||
stop: () => void;
|
||||
resumeAudio: () => Promise<boolean>;
|
||||
}
|
||||
@@ -87,26 +174,9 @@ export const createRealtimePracticeController = (callbacks: RealtimePracticeCall
|
||||
connection = null;
|
||||
};
|
||||
|
||||
const sendSessionUpdate = (channel: RTCDataChannel, instructions: string) => {
|
||||
const sendSessionUpdate = (channel: RTCDataChannel, instructions: string, voice: RealtimePracticeVoice) => {
|
||||
if (channel.readyState !== 'open') return;
|
||||
channel.send(JSON.stringify({
|
||||
event_id: `event_${Date.now()}`,
|
||||
type: 'session.update',
|
||||
session: {
|
||||
modalities: ['text', 'audio'],
|
||||
voice: 'Ethan',
|
||||
input_audio_format: 'pcm',
|
||||
output_audio_format: 'pcm',
|
||||
input_audio_transcription: { model: 'qwen3-asr-flash-realtime' },
|
||||
instructions,
|
||||
turn_detection: {
|
||||
type: 'semantic_vad',
|
||||
threshold: 0.5,
|
||||
prefix_padding_ms: 500,
|
||||
silence_duration_ms: 800
|
||||
}
|
||||
}
|
||||
}));
|
||||
channel.send(JSON.stringify(createRealtimePracticeSessionUpdate(instructions, voice)));
|
||||
};
|
||||
|
||||
const handleEvent = (raw: string, attempt: number, provider: ProviderEventHandlers) => {
|
||||
@@ -176,8 +246,9 @@ export const createRealtimePracticeController = (callbacks: RealtimePracticeCall
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (instructions: string) => {
|
||||
const start = async (instructions: string, voice: RealtimePracticeVoice = DEFAULT_REALTIME_PRACTICE_VOICE) => {
|
||||
if (!isSupported()) throw new Error('当前设备不支持实时语音,请使用录音对练');
|
||||
const selectedVoice = normalizeRealtimePracticeVoice(voice);
|
||||
stop();
|
||||
const attempt = ++activeAttempt;
|
||||
callbacks.onStatus('connecting');
|
||||
@@ -219,7 +290,7 @@ export const createRealtimePracticeController = (callbacks: RealtimePracticeCall
|
||||
onSessionCreated: () => {
|
||||
if (!isCurrent(attempt) || !localChannel || sessionUpdateSent) return;
|
||||
sessionUpdateSent = true;
|
||||
sendSessionUpdate(localChannel, instructions);
|
||||
sendSessionUpdate(localChannel, instructions, selectedVoice);
|
||||
},
|
||||
onSessionUpdated: () => {
|
||||
if (!isCurrent(attempt) || !sessionUpdateSent) return;
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
const realtimeVoiceRuntime = async () => {
|
||||
const service = await source('../src/services/realtime-practice.ts');
|
||||
const compiled = ts.transpileModule(service, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020
|
||||
}
|
||||
});
|
||||
const runtimeModule = { exports: {} };
|
||||
vm.runInNewContext(compiled.outputText, {
|
||||
Date,
|
||||
exports: runtimeModule.exports,
|
||||
module: runtimeModule,
|
||||
require: (specifier) => {
|
||||
if (specifier === './api') return { apiRequest: async () => ({}) };
|
||||
throw new Error(`Unexpected module: ${specifier}`);
|
||||
}
|
||||
});
|
||||
return runtimeModule.exports;
|
||||
};
|
||||
|
||||
test('实时陪练通过受认证 SDP 代理接入,前端不携带供应商密钥', async () => {
|
||||
const [service, page, backendService] = await Promise.all([
|
||||
source('../src/services/realtime-practice.ts'),
|
||||
@@ -17,10 +40,31 @@ test('实时陪练通过受认证 SDP 代理接入,前端不携带供应商密
|
||||
assert.match(service, /response\.audio_transcript\.done/);
|
||||
assert.match(service, /session\.created/);
|
||||
assert.match(service, /session\.updated/);
|
||||
assert.match(service, /REALTIME_PRACTICE_VOICES/);
|
||||
assert.match(service, /createRealtimePracticeSessionUpdate/);
|
||||
assert.match(service, /activeAttempt/);
|
||||
assert.match(service, /resumeAudio/);
|
||||
assert.doesNotMatch(service, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/);
|
||||
assert.match(backendService, /实时陪练限流暂时不可用/);
|
||||
assert.match(page, /实时陪练 Beta/);
|
||||
assert.match(page, /实时陪练音色/);
|
||||
assert.match(page, /<picker :range="realtimeVoiceOptions" range-key="label"/);
|
||||
assert.match(page, /aihr_realtime_practice_voice/);
|
||||
assert.match(page, /selectRealtimeVoice/);
|
||||
assert.match(page, /createRealtimePracticeController/);
|
||||
});
|
||||
|
||||
test('实时陪练音色白名单会写入会话配置,非法值回退默认音色', async () => {
|
||||
const runtime = await realtimeVoiceRuntime();
|
||||
const voices = Array.from(runtime.REALTIME_PRACTICE_VOICES);
|
||||
|
||||
assert.deepEqual(voices, [
|
||||
'Tina', 'Cindy', 'Liora Mira', 'Sunnybobi', 'Raymond', 'Ethan', 'Theo Calm', 'Serena', 'Harvey', 'Maia', 'Evan', 'Qiao', 'Momo', 'Wil', 'Angel', 'Li Cassian', 'Mia', 'Joyner', 'Gold', 'Katerina', 'Ryan', 'Jennifer', 'Aiden', 'Mione', 'Sunny', 'Dylan', 'Eric', 'Peter', 'Joseph Chen', 'Marcus', 'Li', 'Kiki', 'Rocky', 'Sohee', 'Lenn', 'Ono Anna', 'Sonrisa', 'Bodega', 'Emilien', 'Andre', 'Radio Gol', 'Alek', 'Rizky', 'Roya', 'Arda', 'Hana', 'Dolce', 'Jakub', 'Griet', 'Eliška', 'Marina', 'Siiri', 'Ingrid', 'Sigga', 'Bea', 'Chloe'
|
||||
]);
|
||||
for (const voice of voices) {
|
||||
assert.equal(runtime.normalizeRealtimePracticeVoice(voice), voice);
|
||||
assert.equal(runtime.createRealtimePracticeSessionUpdate('测试', voice).session.voice, voice);
|
||||
}
|
||||
assert.equal(runtime.normalizeRealtimePracticeVoice('untrusted-voice'), 'Ethan');
|
||||
assert.equal(runtime.createRealtimePracticeSessionUpdate('测试', 'untrusted-voice').session.voice, 'Ethan');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user