feat(speech): add role-aware emotional TTS profiles

This commit is contained in:
2026-07-18 18:27:14 +08:00
parent c0538ad8c3
commit 68d5600534
14 changed files with 228 additions and 45 deletions
@@ -156,6 +156,7 @@ import { resetPageScroll } from '@/services/navigation';
import {
chooseSpeechAudio,
createSpeechPlaybackController,
INTERVIEWER_VOICE_PROFILE,
normalizeOssId,
transcribeSpeechBlob,
transcribeSpeechFile
@@ -186,7 +187,8 @@ const speechPlayback = createSpeechPlaybackController(
(text) => {
message.value = text;
uni.showToast({ title: text, icon: 'none' });
}
},
INTERVIEWER_VOICE_PROFILE
);
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
+21 -11
View File
@@ -348,6 +348,7 @@ import type {
PracticePrepCard,
PracticeAssignment,
PracticeRole,
SpeechVoiceProfile,
SpeechPlaybackStatus,
PracticeTurn,
PracticeRecord
@@ -357,7 +358,7 @@ import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/servic
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { stripInternalCodes } from '@/services/text';
@@ -433,7 +434,8 @@ const speechPlayback = createSpeechPlaybackController(
(text) => {
message.value = text;
uni.showToast({ title: text, icon: 'none' });
}
},
MENTOR_VOICE_PROFILE
);
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
@@ -517,12 +519,19 @@ interface TurnVoice {
duration: number | null;
unread: boolean;
open: boolean;
profile: SpeechVoiceProfile;
}
const turnVoices = ref<Record<number, TurnVoice>>({});
const turnVoice = (index: number): TurnVoice =>
turnVoices.value[index] || { status: 'unavailable', duration: null, unread: false, open: false };
turnVoices.value[index] || {
status: 'unavailable',
duration: null,
unread: false,
open: false,
profile: customerVoiceProfile()
};
const patchTurnVoice = (index: number, patch: Partial<TurnVoice>) => {
turnVoices.value = { ...turnVoices.value, [index]: { ...turnVoice(index), ...patch } };
@@ -530,11 +539,12 @@ const patchTurnVoice = (index: number, patch: Partial<TurnVoice>) => {
/** Customer lines arrive as WeChat-style voice messages: pre-synthesize so the
* bubble shows a duration and an unread dot; fall back to text when TTS is off. */
const prepareTurnVoice = async (index: number, text: string) => {
const prepareTurnVoice = async (index: number, text: string, emotion = 0) => {
const spoken = (text || '').trim();
if (!spoken) return;
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false });
const source = await speechPlayback.preload(spoken);
const profile = customerVoiceProfile(emotion);
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false, profile });
const source = await speechPlayback.preload(spoken, profile);
if (!turnVoices.value[index]) return;
if (!source) {
patchTurnVoice(index, { status: 'unavailable', unread: false });
@@ -560,7 +570,7 @@ const playTurnVoice = (index: number, text: string) => {
return;
}
patchTurnVoice(index, { unread: false });
void speechPlayback.toggle(`turn-${index}`, (text || '').trim());
void speechPlayback.toggle(`turn-${index}`, (text || '').trim(), turnVoice(index).profile);
};
const toggleTurnTranscript = (index: number) => {
@@ -748,11 +758,11 @@ const requireLogin = () => {
return false;
};
const appendTurn = (role: PracticeRole, text?: string) => {
const appendTurn = (role: PracticeRole, text?: string, emotion = 0) => {
if (!text) return;
turns.value.push({ role, text });
if (role === 'customer') {
void prepareTurnVoice(turns.value.length - 1, text);
void prepareTurnVoice(turns.value.length - 1, text, emotion);
}
scrollThreadToBottom();
};
@@ -1146,7 +1156,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
emotionScore.value = data.emotion ?? 0;
trustScore.value = data.trust ?? 0;
sessionId.value = data.sessionId;
appendTurn('customer', data.customerText);
appendTurn('customer', data.customerText, data.emotion);
status.value = 'active';
} catch (error) {
if (!requests.isCurrent(request)) return;
@@ -1267,7 +1277,7 @@ const submit = async () => {
await finish(true);
return;
}
appendTurn('customer', data.customerText);
appendTurn('customer', data.customerText, data.emotion);
roundIndex.value = data.roundIndex;
status.value = 'active';
} catch (error) {
+3 -2
View File
@@ -261,7 +261,7 @@ import {
} from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { downloadSummaryCardImage } from '@/services/summary-card-image';
import { computed } from 'vue';
@@ -337,7 +337,8 @@ const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'
const speechPlayback = createSpeechPlaybackController(
(state) => { speechState.value = state; },
(text) => { uni.showToast({ title: text, icon: 'none' }); }
(text) => { uni.showToast({ title: text, icon: 'none' }); },
MENTOR_VOICE_PROFILE
);
const scrollToBottom = () => {
+3 -2
View File
@@ -117,7 +117,7 @@ import { onShow, onUnload } from '@dcloudio/uni-app';
import type { SpeechPlaybackStatus, WebAiCapabilities, WebAiResponse } from '@/types/api';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { chooseSpeechAudio, createSpeechPlaybackController, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
const path = '/pages/user/web-ai/index';
@@ -164,7 +164,8 @@ let abortVoiceTranscription: (() => void) | null = null;
const speechPlayback = createSpeechPlaybackController(
(state) => { speechState.value = state; },
(text) => { uni.showToast({ title: text, icon: 'none' }); }
(text) => { uni.showToast({ title: text, icon: 'none' }); },
MENTOR_VOICE_PROFILE
);
const scrollToBottom = () => {