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;
|
||||
|
||||
Reference in New Issue
Block a user