feat(mobile-app): prepare native app packaging
@@ -102,6 +102,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { startSpeechCapture } from '@/services/speech-capture';
|
||||
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean;
|
||||
@@ -127,7 +129,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'send', text: string): void;
|
||||
(e: 'voice-blob', blob: Blob): void;
|
||||
(e: 'voice-capture', capture: SpeechCapture): void;
|
||||
(e: 'pick-audio'): void;
|
||||
(e: 'pick-media'): void;
|
||||
(e: 'clear-media'): void;
|
||||
@@ -141,14 +143,10 @@ const voiceMode = ref(true);
|
||||
const panelOpen = ref(false);
|
||||
const recording = ref(false);
|
||||
|
||||
let recorder: MediaRecorder | null = null;
|
||||
let stream: MediaStream | null = null;
|
||||
let captureSession: SpeechCaptureSession | null = null;
|
||||
let holdGeneration = 0;
|
||||
let discardRecording = false;
|
||||
|
||||
const supportsRecording = () =>
|
||||
typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia) && typeof MediaRecorder !== 'undefined';
|
||||
|
||||
const toggleVoiceMode = () => {
|
||||
voiceMode.value = !voiceMode.value;
|
||||
panelOpen.value = false;
|
||||
@@ -174,69 +172,58 @@ const panelAction = (key: string) => {
|
||||
emit('panel', key);
|
||||
};
|
||||
|
||||
const stopTracks = () => {
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
};
|
||||
|
||||
const startHold = async () => {
|
||||
if (props.disabled || props.transcribing || recording.value) return;
|
||||
if (!supportsRecording()) {
|
||||
emit('pick-audio');
|
||||
return;
|
||||
}
|
||||
const generation = ++holdGeneration;
|
||||
discardRecording = false;
|
||||
let fallbackRequested = false;
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== holdGeneration) {
|
||||
mediaStream.getTracks().forEach((track) => track.stop());
|
||||
const session = await startSpeechCapture({
|
||||
onCapture: (capture) => {
|
||||
if (generation !== holdGeneration) return;
|
||||
captureSession = null;
|
||||
recording.value = false;
|
||||
if (!discardRecording) emit('voice-capture', capture);
|
||||
},
|
||||
onError: () => {
|
||||
if (generation !== holdGeneration) return;
|
||||
fallbackRequested = true;
|
||||
captureSession = null;
|
||||
recording.value = false;
|
||||
emit('pick-audio');
|
||||
}
|
||||
});
|
||||
if (!session || generation !== holdGeneration) {
|
||||
session?.cancel();
|
||||
if (generation === holdGeneration && !fallbackRequested) emit('pick-audio');
|
||||
return;
|
||||
}
|
||||
stream = mediaStream;
|
||||
const mediaRecorder = new MediaRecorder(mediaStream);
|
||||
const chunks: Blob[] = [];
|
||||
recorder = mediaRecorder;
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
recording.value = false;
|
||||
recorder = null;
|
||||
stopTracks();
|
||||
if (discardRecording) return;
|
||||
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
||||
if (blob.size) emit('voice-blob', blob);
|
||||
};
|
||||
mediaRecorder.start();
|
||||
captureSession = session;
|
||||
recording.value = true;
|
||||
} catch {
|
||||
stopTracks();
|
||||
captureSession = null;
|
||||
if (generation === holdGeneration) emit('pick-audio');
|
||||
}
|
||||
};
|
||||
|
||||
const endHold = () => {
|
||||
if (!recording.value) return;
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
||||
captureSession?.stop();
|
||||
};
|
||||
|
||||
const cancelHold = () => {
|
||||
if (!recording.value) return;
|
||||
discardRecording = true;
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
||||
captureSession?.cancel();
|
||||
captureSession = null;
|
||||
recording.value = false;
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
holdGeneration += 1;
|
||||
discardRecording = true;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onstop = null;
|
||||
recorder.stop();
|
||||
}
|
||||
recorder = null;
|
||||
stopTracks();
|
||||
captureSession?.cancel();
|
||||
captureSession = null;
|
||||
recording.value = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,103 @@
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
"app-plus": {
|
||||
"checkPermissionDenied": true,
|
||||
"screenOrientation": [
|
||||
"portrait-primary"
|
||||
],
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": false
|
||||
},
|
||||
"safearea": {
|
||||
"background": "#f7f8fa",
|
||||
"bottom": {
|
||||
"offset": "auto"
|
||||
}
|
||||
},
|
||||
"ssl": {
|
||||
"untrustedca": "refuse"
|
||||
},
|
||||
"distribute": {
|
||||
"icons": {
|
||||
"android": {
|
||||
"hdpi": "static/app/icons/android-hdpi.png",
|
||||
"xhdpi": "static/app/icons/android-xhdpi.png",
|
||||
"xxhdpi": "static/app/icons/android-xxhdpi.png",
|
||||
"xxxhdpi": "static/app/icons/android-xxxhdpi.png"
|
||||
},
|
||||
"ios": {
|
||||
"appstore": "static/app/icons/ios-appstore.png",
|
||||
"iphone": {
|
||||
"app@2x": "static/app/icons/ios-iphone-app-2x.png",
|
||||
"app@3x": "static/app/icons/ios-iphone-app-3x.png",
|
||||
"spotlight@2x": "static/app/icons/ios-iphone-spotlight-2x.png",
|
||||
"spotlight@3x": "static/app/icons/ios-iphone-spotlight-3x.png",
|
||||
"settings@2x": "static/app/icons/ios-iphone-settings-2x.png",
|
||||
"settings@3x": "static/app/icons/ios-iphone-settings-3x.png",
|
||||
"notification@2x": "static/app/icons/ios-iphone-notification-2x.png",
|
||||
"notification@3x": "static/app/icons/ios-iphone-notification-3x.png"
|
||||
},
|
||||
"ipad": {
|
||||
"app": "static/app/icons/ios-ipad-app.png",
|
||||
"app@2x": "static/app/icons/ios-ipad-app-2x.png",
|
||||
"proapp@2x": "static/app/icons/ios-ipad-pro-app-2x.png",
|
||||
"spotlight": "static/app/icons/ios-ipad-spotlight.png",
|
||||
"spotlight@2x": "static/app/icons/ios-ipad-spotlight-2x.png",
|
||||
"settings": "static/app/icons/ios-ipad-settings.png",
|
||||
"settings@2x": "static/app/icons/ios-ipad-settings-2x.png",
|
||||
"notification": "static/app/icons/ios-ipad-notification.png",
|
||||
"notification@2x": "static/app/icons/ios-ipad-notification-2x.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
"splashscreen": {
|
||||
"iosStyle": "default",
|
||||
"ios": {
|
||||
"iphone": {
|
||||
"retina40": "static/app/splash/ios-iphone-retina40.png",
|
||||
"retina47": "static/app/splash/ios-iphone-retina47.png",
|
||||
"retina55": "static/app/splash/ios-iphone-retina55.png",
|
||||
"iphonex": "static/app/splash/ios-iphone-x.png",
|
||||
"portrait-896h@2x": "static/app/splash/ios-iphone-896h-2x.png",
|
||||
"portrait-896h@3x": "static/app/splash/ios-iphone-896h-3x.png"
|
||||
},
|
||||
"ipad": {
|
||||
"portrait7": "static/app/splash/ios-ipad-portrait7.png",
|
||||
"portrait-retina7": "static/app/splash/ios-ipad-portrait-retina7.png",
|
||||
"portrait-1112h@2x": "static/app/splash/ios-ipad-1112h-2x.png",
|
||||
"portrait-1194h@2x": "static/app/splash/ios-ipad-1194h-2x.png",
|
||||
"portrait-1366h@2x": "static/app/splash/ios-ipad-1366h-2x.png"
|
||||
}
|
||||
},
|
||||
"androidStyle": "default",
|
||||
"android": {
|
||||
"hdpi": "static/app/splash/android-hdpi.png",
|
||||
"xhdpi": "static/app/splash/android-xhdpi.png",
|
||||
"xxhdpi": "static/app/splash/android-xxhdpi.png"
|
||||
},
|
||||
"androidTranslucent": false,
|
||||
"androidNavigationBarColor": "#F7F8FA"
|
||||
},
|
||||
"android": {
|
||||
"packagename": "com.yincheng.wygj",
|
||||
"abiFilters": [
|
||||
"armeabi-v7a",
|
||||
"arm64-v8a"
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"appid": "com.yincheng.wygj",
|
||||
"devices": "universal",
|
||||
"deploymentTarget": "15.0",
|
||||
"privacyDescription": {
|
||||
"NSMicrophoneUsageDescription": "用于录制训练、提问和面试回答,并将音频转写为文本。",
|
||||
"NSPhotoLibraryUsageDescription": "用于选择图片或视频作为本次问题、资料或成果投稿的附件。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
"router": {
|
||||
"base": "/h5/"
|
||||
|
||||
@@ -159,9 +159,11 @@ import {
|
||||
createSpeechPlaybackController,
|
||||
INTERVIEWER_VOICE_PROFILE,
|
||||
normalizeOssId,
|
||||
transcribeSpeechBlob,
|
||||
transcribeSpeechCapture,
|
||||
transcribeSpeechFile
|
||||
} from '@/services/speech';
|
||||
import { startSpeechCapture } from '@/services/speech-capture';
|
||||
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||
|
||||
const currentPath = '/pages/candidate/interview/index';
|
||||
const records = ref<InterviewRecord[]>([]);
|
||||
@@ -177,8 +179,7 @@ const message = ref('');
|
||||
const transcribingQuestionId = ref('');
|
||||
const recordingQuestionId = ref('');
|
||||
const recordingQuestionStarting = ref(false);
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const mediaStream = ref<MediaStream | null>(null);
|
||||
let questionCapture: SpeechCaptureSession | null = null;
|
||||
let questionCaptureGeneration = 0;
|
||||
let questionTranscriptionGeneration = 0;
|
||||
let abortQuestionTranscription: (() => void) | null = null;
|
||||
@@ -298,11 +299,6 @@ const interviewResultSpeech = () => {
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const stopQuestionMediaTracks = () => {
|
||||
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
||||
mediaStream.value = null;
|
||||
};
|
||||
|
||||
const cancelQuestionTranscription = () => {
|
||||
questionTranscriptionGeneration += 1;
|
||||
abortQuestionTranscription?.();
|
||||
@@ -314,14 +310,8 @@ const cancelQuestionRecording = () => {
|
||||
questionCaptureGeneration += 1;
|
||||
cancelQuestionTranscription();
|
||||
recordingQuestionStarting.value = false;
|
||||
const recorder = mediaRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onstop = null;
|
||||
recorder.stop();
|
||||
}
|
||||
mediaRecorder.value = null;
|
||||
stopQuestionMediaTracks();
|
||||
questionCapture?.cancel();
|
||||
questionCapture = null;
|
||||
recordingQuestionId.value = '';
|
||||
};
|
||||
|
||||
@@ -361,13 +351,13 @@ const transcribeQuestionFile = async (questionId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
|
||||
const transcribeQuestionCapture = async (questionId: string, capture: SpeechCapture) => {
|
||||
if (transcribingQuestionId.value) return;
|
||||
const generation = ++questionTranscriptionGeneration;
|
||||
transcribingQuestionId.value = questionId;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob, 'candidate-answer.webm', (abort) => {
|
||||
const result = await transcribeSpeechCapture(capture, 'candidate-answer.webm', (abort) => {
|
||||
if (generation === questionTranscriptionGeneration) abortQuestionTranscription = abort;
|
||||
});
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
@@ -385,42 +375,35 @@ const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
|
||||
|
||||
const startQuestionRecording = async (questionId: string) => {
|
||||
if (interviewBusy.value || recordingQuestionStarting.value || recordingQuestionId.value || transcribingQuestionId.value) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await transcribeQuestionFile(questionId);
|
||||
return;
|
||||
}
|
||||
const generation = ++questionCaptureGeneration;
|
||||
recordingQuestionStarting.value = true;
|
||||
let fallbackRequested = false;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== questionCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const session = await startSpeechCapture({
|
||||
onCapture: (capture) => {
|
||||
if (generation !== questionCaptureGeneration) return;
|
||||
questionCapture = null;
|
||||
recordingQuestionId.value = '';
|
||||
void transcribeQuestionCapture(questionId, capture);
|
||||
},
|
||||
onError: () => {
|
||||
if (generation !== questionCaptureGeneration) return;
|
||||
fallbackRequested = true;
|
||||
questionCapture = null;
|
||||
recordingQuestionId.value = '';
|
||||
void transcribeQuestionFile(questionId);
|
||||
}
|
||||
});
|
||||
if (!session || generation !== questionCaptureGeneration) {
|
||||
session?.cancel();
|
||||
if (generation === questionCaptureGeneration && !fallbackRequested) await transcribeQuestionFile(questionId);
|
||||
return;
|
||||
}
|
||||
mediaStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
mediaRecorder.value = recorder;
|
||||
questionCapture = session;
|
||||
recordingQuestionId.value = questionId;
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
mediaRecorder.value = null;
|
||||
recordingQuestionId.value = '';
|
||||
stopQuestionMediaTracks();
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||
if (!blob.size) {
|
||||
message.value = '未录到音频,请改用文字输入';
|
||||
return;
|
||||
}
|
||||
void transcribeQuestionBlob(questionId, blob);
|
||||
};
|
||||
recorder.start();
|
||||
message.value = '正在录音';
|
||||
} catch {
|
||||
recordingQuestionId.value = '';
|
||||
stopQuestionMediaTracks();
|
||||
if (generation === questionCaptureGeneration) await transcribeQuestionFile(questionId);
|
||||
} finally {
|
||||
if (generation === questionCaptureGeneration) recordingQuestionStarting.value = false;
|
||||
@@ -428,10 +411,9 @@ const startQuestionRecording = async (questionId: string) => {
|
||||
};
|
||||
|
||||
const stopQuestionRecording = () => {
|
||||
const recorder = mediaRecorder.value;
|
||||
if (!recordingQuestionId.value || !recorder || recorder.state === 'inactive') return;
|
||||
if (!recordingQuestionId.value || !questionCapture) return;
|
||||
message.value = '录音结束,正在转写';
|
||||
recorder.stop();
|
||||
questionCapture.stop();
|
||||
};
|
||||
|
||||
const toggleQuestionRecording = (questionId: string) => {
|
||||
|
||||
@@ -306,7 +306,7 @@
|
||||
:maxlength="500"
|
||||
:panel-items="practicePanelItems"
|
||||
@send="handleComposerSend"
|
||||
@voice-blob="handleSessionVoiceBlob"
|
||||
@voice-capture="handleSessionVoiceCapture"
|
||||
@pick-audio="composerPickAudio"
|
||||
@panel="handlePanelAction"
|
||||
/>
|
||||
@@ -397,7 +397,9 @@ import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/servic
|
||||
import { searchKnowledge } from '@/services/knowledge';
|
||||
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 { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||||
import { startSpeechCapture } from '@/services/speech-capture';
|
||||
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
import { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
|
||||
import { stripInternalCodes } from '@/services/text';
|
||||
@@ -473,8 +475,7 @@ const transcribing = ref(false);
|
||||
const draftAudioUrl = ref('');
|
||||
const draftAudioOssId = ref<string | undefined>();
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const mediaStream = ref<MediaStream | null>(null);
|
||||
let recordingCapture: SpeechCaptureSession | null = null;
|
||||
let recordingCaptureGeneration = 0;
|
||||
let transcriptionGeneration = 0;
|
||||
let abortTranscription: (() => void) | null = null;
|
||||
@@ -665,12 +666,12 @@ const submitTranscribed = async (text: string, audioUrl: string, ossId?: string)
|
||||
await submit();
|
||||
};
|
||||
|
||||
const handleSessionVoiceBlob = async (blob: Blob) => {
|
||||
const handleSessionVoiceCapture = async (capture: SpeechCapture) => {
|
||||
if (transcribing.value || busy.value) return;
|
||||
const generation = ++transcriptionGeneration;
|
||||
transcribing.value = true;
|
||||
try {
|
||||
const asr = await transcribeSpeechBlob(blob, 'practice-turn.webm', (abort) => {
|
||||
const asr = await transcribeSpeechCapture(capture, 'practice-turn.webm', (abort) => {
|
||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||
});
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
@@ -907,11 +908,6 @@ const clearDraftAudio = () => {
|
||||
draftAudioOssId.value = undefined;
|
||||
};
|
||||
|
||||
const stopMediaTracks = () => {
|
||||
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
||||
mediaStream.value = null;
|
||||
};
|
||||
|
||||
const cancelTranscription = () => {
|
||||
transcriptionGeneration += 1;
|
||||
abortTranscription?.();
|
||||
@@ -923,14 +919,8 @@ const cancelRecording = () => {
|
||||
recordingCaptureGeneration += 1;
|
||||
cancelTranscription();
|
||||
recordingStarting.value = false;
|
||||
const recorder = mediaRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onstop = null;
|
||||
recorder.stop();
|
||||
}
|
||||
mediaRecorder.value = null;
|
||||
stopMediaTracks();
|
||||
recordingCapture?.cancel();
|
||||
recordingCapture = null;
|
||||
recording.value = false;
|
||||
recordingAssignmentId.value = null;
|
||||
};
|
||||
@@ -965,13 +955,13 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi
|
||||
message.value = '每日一练语音已转成文字,请确认后提交';
|
||||
};
|
||||
|
||||
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
|
||||
const transcribeCapture = async (capture: SpeechCapture, assignmentId?: number) => {
|
||||
if (transcribing.value) return;
|
||||
const generation = ++transcriptionGeneration;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob, 'practice-audio.webm', (abort) => {
|
||||
const result = await transcribeSpeechCapture(capture, 'practice-audio.webm', (abort) => {
|
||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||
});
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
@@ -1023,45 +1013,40 @@ const pickAudio = async (assignmentId?: number) => {
|
||||
const startRecording = async (assignmentId?: number) => {
|
||||
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await pickAudio(assignmentId);
|
||||
return;
|
||||
}
|
||||
const generation = ++recordingCaptureGeneration;
|
||||
recordingStarting.value = true;
|
||||
let fallbackRequested = false;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== recordingCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const session = await startSpeechCapture({
|
||||
onCapture: (capture) => {
|
||||
if (generation !== recordingCaptureGeneration) return;
|
||||
recordingCapture = null;
|
||||
recording.value = false;
|
||||
const targetAssignmentId = assignmentId;
|
||||
recordingAssignmentId.value = null;
|
||||
void transcribeCapture(capture, targetAssignmentId);
|
||||
},
|
||||
onError: () => {
|
||||
if (generation !== recordingCaptureGeneration) return;
|
||||
fallbackRequested = true;
|
||||
recordingCapture = null;
|
||||
recording.value = false;
|
||||
recordingAssignmentId.value = null;
|
||||
void pickAudio(assignmentId);
|
||||
}
|
||||
});
|
||||
if (!session || generation !== recordingCaptureGeneration) {
|
||||
session?.cancel();
|
||||
if (generation === recordingCaptureGeneration && !fallbackRequested) await pickAudio(assignmentId);
|
||||
return;
|
||||
}
|
||||
mediaStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
mediaRecorder.value = recorder;
|
||||
recordingCapture = session;
|
||||
recordingAssignmentId.value = assignmentId ?? null;
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
recording.value = false;
|
||||
mediaRecorder.value = null;
|
||||
stopMediaTracks();
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||
if (!blob.size) {
|
||||
message.value = '未录到音频,请改用文字输入';
|
||||
return;
|
||||
}
|
||||
const targetAssignmentId = assignmentId;
|
||||
recordingAssignmentId.value = null;
|
||||
void transcribeBlob(blob, targetAssignmentId);
|
||||
};
|
||||
recorder.start();
|
||||
recording.value = true;
|
||||
message.value = '正在录音';
|
||||
} catch (error) {
|
||||
recordingAssignmentId.value = null;
|
||||
stopMediaTracks();
|
||||
recordingCapture = null;
|
||||
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
|
||||
} finally {
|
||||
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
|
||||
@@ -1069,10 +1054,9 @@ const startRecording = async (assignmentId?: number) => {
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
const recorder = mediaRecorder.value;
|
||||
if (!recording.value || !recorder || recorder.state === 'inactive') return;
|
||||
if (!recording.value || !recordingCapture) return;
|
||||
message.value = '录音结束,正在转写';
|
||||
recorder.stop();
|
||||
recordingCapture.stop();
|
||||
};
|
||||
|
||||
const toggleRecording = (assignmentId?: number) => {
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
:maxlength="500"
|
||||
:panel-items="composerPanelItems"
|
||||
@send="handleComposerSend"
|
||||
@voice-blob="handleVoiceBlob"
|
||||
@voice-capture="handleVoiceCapture"
|
||||
@pick-audio="pickVoiceAudio"
|
||||
@panel="handlePanelAction"
|
||||
/>
|
||||
@@ -239,9 +239,10 @@ import {
|
||||
chooseSpeechAudio,
|
||||
createSpeechPlaybackController,
|
||||
MENTOR_VOICE_PROFILE,
|
||||
transcribeSpeechBlob,
|
||||
transcribeSpeechCapture,
|
||||
transcribeSpeechFile
|
||||
} from '@/services/speech';
|
||||
import type { SpeechCapture } from '@/services/speech-capture';
|
||||
import type { SpeechPlaybackStatus } from '@/types/api';
|
||||
import {
|
||||
listMyWorkReports,
|
||||
@@ -473,13 +474,13 @@ const cancelVoiceTranscription = () => {
|
||||
voiceTranscribing.value = false;
|
||||
};
|
||||
|
||||
const handleVoiceBlob = async (blob: Blob) => {
|
||||
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||
if (voiceTranscribing.value || composerDisabled.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
const accountKey = currentAccountKey();
|
||||
voiceTranscribing.value = true;
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob, 'work-report.webm', (abort) => {
|
||||
const result = await transcribeSpeechCapture(capture, 'work-report.webm', (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (!requestIsCurrent(generation, voiceTranscriptionGeneration, accountKey)) return;
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
:tool-label="broadcastContext ? '' : defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况'"
|
||||
:panel-items="composerPanelItems"
|
||||
@send="handleComposerSend"
|
||||
@voice-blob="handleVoiceBlob"
|
||||
@voice-capture="handleVoiceCapture"
|
||||
@pick-audio="pickVoiceAudio"
|
||||
@pick-media="pickMedia"
|
||||
@clear-media="clearMedia"
|
||||
@@ -440,7 +440,8 @@ import {
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||
import { ensureEmployeePosition, fetchMobileIdentity } from '@/services/position';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||||
import type { SpeechCapture } from '@/services/speech-capture';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
import { getMemoryCandidates } from '@/services/personal-assistant';
|
||||
import { currentAccountKey } from '@/services/request-scope';
|
||||
@@ -1089,12 +1090,12 @@ const cancelVoiceTranscription = () => {
|
||||
voiceTranscribing.value = false;
|
||||
};
|
||||
|
||||
const handleVoiceBlob = async (blob: Blob) => {
|
||||
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||
if (voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob, 'sop-question.webm', (abort) => {
|
||||
const result = await transcribeSpeechCapture(capture, 'sop-question.webm', (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
@@ -1552,14 +1553,19 @@ onUnload(() => {
|
||||
}
|
||||
|
||||
.broadcast-context-action {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid #e7aeb7;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: #b51c2b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.broadcast-context-action.end {
|
||||
|
||||
@@ -211,7 +211,9 @@ import { getMobileHome } from '@/services/home';
|
||||
import { getPracticeAssignments } from '@/services/practice';
|
||||
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { chooseSpeechAudio, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||||
import { startSpeechCapture } from '@/services/speech-capture';
|
||||
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||
import { stripInternalCodes } from '@/services/text';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
@@ -393,8 +395,7 @@ const loadBroadcastUnreadCount = async () => {
|
||||
const recording = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const transcribing = ref(false);
|
||||
let recorder: MediaRecorder | null = null;
|
||||
let recordStream: MediaStream | null = null;
|
||||
let holdCapture: SpeechCaptureSession | null = null;
|
||||
let holdGeneration = 0;
|
||||
let discardRecording = false;
|
||||
let holdStartY = 0;
|
||||
@@ -411,57 +412,45 @@ const voiceSub = computed(() => {
|
||||
return '松开发送,上滑取消';
|
||||
});
|
||||
|
||||
const supportsRecording = () =>
|
||||
typeof navigator !== 'undefined'
|
||||
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
||||
&& typeof MediaRecorder !== 'undefined';
|
||||
|
||||
const stopRecordTracks = () => {
|
||||
recordStream?.getTracks().forEach((track) => track.stop());
|
||||
recordStream = null;
|
||||
};
|
||||
|
||||
const startHold = async (event?: TouchEvent | MouseEvent) => {
|
||||
if (recording.value || transcribing.value) return;
|
||||
if (!loggedIn.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
if (!supportsRecording()) {
|
||||
void pickAudioFallback();
|
||||
return;
|
||||
}
|
||||
const generation = ++holdGeneration;
|
||||
discardRecording = false;
|
||||
cancelling.value = false;
|
||||
let fallbackRequested = false;
|
||||
if (event && 'touches' in event && event.touches[0]) {
|
||||
holdStartY = event.touches[0].clientY;
|
||||
}
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== holdGeneration) {
|
||||
mediaStream.getTracks().forEach((track) => track.stop());
|
||||
const session = await startSpeechCapture({
|
||||
onCapture: (capture) => {
|
||||
if (generation !== holdGeneration) return;
|
||||
holdCapture = null;
|
||||
recording.value = false;
|
||||
if (!discardRecording) void handleVoiceCapture(capture);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (generation !== holdGeneration) return;
|
||||
fallbackRequested = true;
|
||||
holdCapture = null;
|
||||
recording.value = false;
|
||||
message.value = error.message;
|
||||
void pickAudioFallback();
|
||||
}
|
||||
});
|
||||
if (!session || generation !== holdGeneration) {
|
||||
session?.cancel();
|
||||
if (generation === holdGeneration && !fallbackRequested) void pickAudioFallback();
|
||||
return;
|
||||
}
|
||||
recordStream = mediaStream;
|
||||
const mediaRecorder = new MediaRecorder(mediaStream);
|
||||
const chunks: Blob[] = [];
|
||||
recorder = mediaRecorder;
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data?.size) chunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
recording.value = false;
|
||||
recorder = null;
|
||||
stopRecordTracks();
|
||||
if (discardRecording) return;
|
||||
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
||||
if (blob.size) void handleVoiceBlob(blob);
|
||||
};
|
||||
mediaRecorder.start();
|
||||
holdCapture = session;
|
||||
recording.value = true;
|
||||
} catch {
|
||||
stopRecordTracks();
|
||||
holdCapture = null;
|
||||
if (generation === holdGeneration) void pickAudioFallback();
|
||||
}
|
||||
};
|
||||
@@ -475,20 +464,22 @@ const moveHold = (event: TouchEvent) => {
|
||||
const endHold = () => {
|
||||
if (!recording.value) return;
|
||||
discardRecording = cancelling.value;
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
||||
holdCapture?.stop();
|
||||
};
|
||||
|
||||
const cancelHold = () => {
|
||||
if (!recording.value) return;
|
||||
discardRecording = true;
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
||||
holdCapture?.cancel();
|
||||
holdCapture = null;
|
||||
recording.value = false;
|
||||
};
|
||||
|
||||
const handleVoiceBlob = async (blob: Blob) => {
|
||||
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||
transcribing.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob, 'today-ask.webm');
|
||||
const result = await transcribeSpeechCapture(capture, 'today-ask.webm');
|
||||
const text = result.text?.trim();
|
||||
if (text) ask(text);
|
||||
else message.value = '没听清,请再说一次';
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
{ key: 'audio', icon: 'sound', label: '选音频转文字' }
|
||||
]"
|
||||
@send="handleComposerSend"
|
||||
@voice-blob="handleVoiceBlob"
|
||||
@voice-capture="handleVoiceCapture"
|
||||
@panel="handlePanelAction"
|
||||
@clear-media="clearImage"
|
||||
/>
|
||||
@@ -120,7 +120,8 @@ import AskSourceHeader from '@/components/chat/AskSourceHeader.vue';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { chooseKnowledgeMedia } from '@/services/knowledge';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||||
import type { SpeechCapture } from '@/services/speech-capture';
|
||||
import { getWebAiCapabilities, queryWebAi, queryWebAiWithImage } from '@/services/web-ai';
|
||||
import { renderMarkdown } from '@/utils/markdown';
|
||||
|
||||
@@ -290,12 +291,12 @@ const handleComposerSend = (text: string) => {
|
||||
void sendQuestion(text);
|
||||
};
|
||||
|
||||
const handleVoiceBlob = async (blob: Blob) => {
|
||||
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||
if (voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
try {
|
||||
const asr = await transcribeSpeechBlob(blob, 'web-ai-question.webm', (abort) => {
|
||||
const asr = await transcribeSpeechCapture(capture, 'web-ai-question.webm', (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ApiPayload } from '@/types/api';
|
||||
import { clearAuth, getAuth, redirectToLoginForAuthLoss } from './auth';
|
||||
import { isNativeAppRuntime } from './speech-capture';
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || '/dev-api';
|
||||
const configuredApiBase = import.meta.env.VITE_API_BASE;
|
||||
const currentApiBase = () => configuredApiBase
|
||||
|| (isNativeAppRuntime() ? 'https://peilian.njzhmj.top/dev-api' : '/dev-api');
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
@@ -31,7 +34,7 @@ export const isAuthApiError = (error: unknown): boolean =>
|
||||
error instanceof ApiError
|
||||
&& (error.statusCode === 401 || error.statusCode === 403 || error.bizCode === 401 || error.bizCode === 403);
|
||||
|
||||
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
|
||||
const normalizeUrl = (url: string) => `${currentApiBase()}${url.startsWith('/') ? url : `/${url}`}`;
|
||||
|
||||
export const apiUrl = normalizeUrl;
|
||||
|
||||
@@ -77,6 +80,8 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
|
||||
export const readTextPayload = <T>(statusCode: number, text: string): T =>
|
||||
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
|
||||
|
||||
const shouldUseXhr = () => !isNativeAppRuntime() && typeof XMLHttpRequest !== 'undefined';
|
||||
|
||||
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
||||
const method = options.method || 'GET';
|
||||
const hasBody = method !== 'GET' && options.data !== undefined;
|
||||
@@ -109,7 +114,7 @@ const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
||||
};
|
||||
|
||||
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
||||
if (typeof XMLHttpRequest !== 'undefined') {
|
||||
if (shouldUseXhr()) {
|
||||
return requestWithXhr<T>(options);
|
||||
}
|
||||
|
||||
@@ -133,7 +138,7 @@ export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
||||
|
||||
export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
||||
const fullUrl = normalizeUrl(url);
|
||||
if (typeof XMLHttpRequest !== 'undefined' && typeof URL !== 'undefined') {
|
||||
if (shouldUseXhr() && typeof URL !== 'undefined') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', fullUrl);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiRequest } from './api';
|
||||
import { isNativeAppRuntime } from './speech-capture';
|
||||
|
||||
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking';
|
||||
|
||||
@@ -122,7 +123,8 @@ export interface RealtimePracticeController {
|
||||
}
|
||||
|
||||
const isSupported = () =>
|
||||
typeof navigator !== 'undefined'
|
||||
!isNativeAppRuntime()
|
||||
&& typeof navigator !== 'undefined'
|
||||
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
||||
&& typeof RTCPeerConnection !== 'undefined';
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { SpeechSelectedFile } from '@/types/api';
|
||||
|
||||
export type SpeechCapture =
|
||||
| { kind: 'blob'; blob: Blob; filename: string }
|
||||
| { kind: 'file'; file: SpeechSelectedFile };
|
||||
|
||||
export interface SpeechCaptureSession {
|
||||
stop: () => void;
|
||||
cancel: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
interface AppRecorderResult {
|
||||
tempFilePath?: string;
|
||||
fileSize?: number;
|
||||
}
|
||||
|
||||
interface AppRecorderError {
|
||||
errMsg?: string;
|
||||
}
|
||||
|
||||
interface AppRecorderManager {
|
||||
start: (options?: { duration?: number; sampleRate?: number; format?: 'mp3' }) => void;
|
||||
stop: () => void;
|
||||
onStop: (callback: (result: AppRecorderResult) => void) => void;
|
||||
onError: (callback: (error: AppRecorderError) => void) => void;
|
||||
}
|
||||
|
||||
type UniWithRecorder = typeof uni & {
|
||||
getRecorderManager?: () => AppRecorderManager;
|
||||
};
|
||||
|
||||
interface SpeechCaptureHandlers {
|
||||
onCapture: (capture: SpeechCapture) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const isAppRuntime = () => Boolean((globalThis as typeof globalThis & { plus?: unknown }).plus);
|
||||
const supportsBrowserRecording = () =>
|
||||
typeof navigator !== 'undefined'
|
||||
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
||||
&& typeof MediaRecorder !== 'undefined';
|
||||
|
||||
let activeNativeSession: SpeechCaptureSession | null = null;
|
||||
|
||||
const startNativeCapture = (handlers: SpeechCaptureHandlers): SpeechCaptureSession | null => {
|
||||
const manager = (uni as UniWithRecorder).getRecorderManager?.();
|
||||
if (!manager) return null;
|
||||
|
||||
activeNativeSession?.cancel();
|
||||
let active = true;
|
||||
let discarded = false;
|
||||
const cleanup = () => {
|
||||
active = false;
|
||||
if (activeNativeSession === session) activeNativeSession = null;
|
||||
};
|
||||
const finishWithError = (error: Error) => {
|
||||
if (!active) return;
|
||||
cleanup();
|
||||
if (!discarded) handlers.onError?.(error);
|
||||
};
|
||||
const session: SpeechCaptureSession = {
|
||||
stop: () => {
|
||||
if (!active) return;
|
||||
try {
|
||||
manager.stop();
|
||||
} catch (error) {
|
||||
finishWithError(error instanceof Error ? error : new Error('停止录音失败'));
|
||||
}
|
||||
},
|
||||
cancel: () => {
|
||||
if (!active) return;
|
||||
discarded = true;
|
||||
cleanup();
|
||||
try {
|
||||
manager.stop();
|
||||
} catch {
|
||||
// The capture has already been discarded; there is nothing to surface.
|
||||
}
|
||||
},
|
||||
dispose: () => session.cancel()
|
||||
};
|
||||
|
||||
manager.onStop((result) => {
|
||||
if (!active) return;
|
||||
const path = String(result.tempFilePath || '');
|
||||
cleanup();
|
||||
if (discarded) return;
|
||||
if (!path) {
|
||||
handlers.onError?.(new Error('未获取到录音文件,请改用选择音频'));
|
||||
return;
|
||||
}
|
||||
handlers.onCapture({
|
||||
kind: 'file',
|
||||
file: {
|
||||
path,
|
||||
name: `recording-${Date.now()}.mp3`,
|
||||
size: Number(result.fileSize || 0)
|
||||
}
|
||||
});
|
||||
});
|
||||
manager.onError((error) => finishWithError(new Error(error.errMsg || '录音失败,请检查麦克风权限')));
|
||||
|
||||
try {
|
||||
manager.start({ duration: 600000, sampleRate: 16000, format: 'mp3' });
|
||||
activeNativeSession = session;
|
||||
return session;
|
||||
} catch (error) {
|
||||
finishWithError(error instanceof Error ? error : new Error('录音启动失败'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const startBrowserCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
|
||||
if (!supportsBrowserRecording()) return null;
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
let active = true;
|
||||
let discarded = false;
|
||||
const releaseStream = () => stream.getTracks().forEach((track) => track.stop());
|
||||
const session: SpeechCaptureSession = {
|
||||
stop: () => {
|
||||
if (active && recorder.state !== 'inactive') recorder.stop();
|
||||
},
|
||||
cancel: () => {
|
||||
if (!active) return;
|
||||
discarded = true;
|
||||
active = false;
|
||||
if (recorder.state !== 'inactive') recorder.stop();
|
||||
releaseStream();
|
||||
},
|
||||
dispose: () => session.cancel()
|
||||
};
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onerror = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
releaseStream();
|
||||
handlers.onError?.(new Error('录音失败,请检查麦克风权限'));
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
releaseStream();
|
||||
if (discarded) return;
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||
if (!blob.size) {
|
||||
handlers.onError?.(new Error('未录到音频,请改用选择音频'));
|
||||
return;
|
||||
}
|
||||
handlers.onCapture({ kind: 'blob', blob, filename: 'recording.webm' });
|
||||
};
|
||||
recorder.start();
|
||||
return session;
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a microphone capture on the current platform. App-Plus produces a
|
||||
* native temporary file; H5 preserves the existing MediaRecorder blob flow.
|
||||
*/
|
||||
export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
|
||||
if (isAppRuntime()) return startNativeCapture(handlers);
|
||||
return startBrowserCapture(handlers);
|
||||
};
|
||||
|
||||
export const isNativeAppRuntime = isAppRuntime;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, SpeechVoiceProfile, TtsResponse } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import type { SpeechCapture } from './speech-capture';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
@@ -91,6 +92,14 @@ export const transcribeSpeechBlob = (
|
||||
xhr.send(form);
|
||||
});
|
||||
|
||||
export const transcribeSpeechCapture = (
|
||||
capture: SpeechCapture,
|
||||
filename: string,
|
||||
registerAbort?: SpeechAbortRegistrar
|
||||
) => capture.kind === 'blob'
|
||||
? transcribeSpeechBlob(capture.blob, filename, registerAbort)
|
||||
: transcribeSpeechFile(capture.file, registerAbort);
|
||||
|
||||
export const synthesizeSpeech = (text: string, voiceProfile?: SpeechVoiceProfile) =>
|
||||
apiRequest<TtsResponse>({
|
||||
url: '/api/ai/tts',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// The uni-app App compiler currently imports these two runtime-core internals
|
||||
// from the public `vue` entry point. Keep this App-only shim minimal and do
|
||||
// not use it from product code.
|
||||
export * from '../../node_modules/vue/dist/vue.runtime.esm-bundler.js';
|
||||
|
||||
export const isInSSRComponentSetup = false;
|
||||
|
||||
export const injectHook = (type, hook, target, prepend = false) => {
|
||||
if (!target) return hook;
|
||||
const hooks = target[type] || [];
|
||||
if (prepend) hooks.unshift(hook);
|
||||
else hooks.push(hook);
|
||||
target[type] = hooks;
|
||||
return hook;
|
||||
};
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 651 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 945 B |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 143 KiB |