feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { AsrResponse, SpeechSelectedFile } from '@/types/api';
|
||||
import { apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, TtsResponse } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
@@ -27,9 +27,11 @@ export const chooseSpeechAudio = () =>
|
||||
});
|
||||
});
|
||||
|
||||
export const transcribeSpeechFile = (file: SpeechSelectedFile) =>
|
||||
export type SpeechAbortRegistrar = (abort: (() => void) | null) => void;
|
||||
|
||||
export const transcribeSpeechFile = (file: SpeechSelectedFile, registerAbort?: SpeechAbortRegistrar) =>
|
||||
new Promise<AsrResponse>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
const task = uni.uploadFile({
|
||||
url: apiUrl('/api/ai/asr'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
@@ -41,11 +43,17 @@ export const transcribeSpeechFile = (file: SpeechSelectedFile) =>
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('语音上传失败'))
|
||||
fail: () => reject(new Error('语音上传失败')),
|
||||
complete: () => registerAbort?.(null)
|
||||
});
|
||||
registerAbort?.(() => task.abort());
|
||||
});
|
||||
|
||||
export const transcribeSpeechBlob = (blob: Blob, filename = 'practice-audio.webm') =>
|
||||
export const transcribeSpeechBlob = (
|
||||
blob: Blob,
|
||||
filename = 'practice-audio.webm',
|
||||
registerAbort?: SpeechAbortRegistrar
|
||||
) =>
|
||||
new Promise<AsrResponse>((resolve, reject) => {
|
||||
const form = new FormData();
|
||||
form.append('file', blob, filename);
|
||||
@@ -62,6 +70,144 @@ export const transcribeSpeechBlob = (blob: Blob, filename = 'practice-audio.webm
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('语音上传失败'));
|
||||
xhr.onabort = () => reject(new Error('语音转写已取消'));
|
||||
xhr.ontimeout = () => reject(new Error('语音转写超时'));
|
||||
xhr.onloadend = () => registerAbort?.(null);
|
||||
registerAbort?.(() => xhr.abort());
|
||||
xhr.send(form);
|
||||
});
|
||||
|
||||
export const synthesizeSpeech = (text: string) =>
|
||||
apiRequest<TtsResponse>({
|
||||
url: '/api/ai/tts',
|
||||
method: 'POST',
|
||||
data: { text: text.trim().slice(0, 300) },
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
export interface SpeechPlaybackSnapshot {
|
||||
key: string;
|
||||
status: SpeechPlaybackStatus;
|
||||
}
|
||||
|
||||
export const createSpeechPlaybackController = (
|
||||
onState: (snapshot: SpeechPlaybackSnapshot) => void,
|
||||
onError: (message: string) => void
|
||||
) => {
|
||||
let audio: ReturnType<typeof uni.createInnerAudioContext> | null = null;
|
||||
let browserUtterance: SpeechSynthesisUtterance | null = null;
|
||||
let activeKey = '';
|
||||
let generation = 0;
|
||||
const sourceCache = new Map<string, string>();
|
||||
const maxCachedSources = 4;
|
||||
|
||||
const publish = (key = '', status: SpeechPlaybackStatus = 'idle') => {
|
||||
activeKey = key;
|
||||
onState({ key, status });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
generation += 1;
|
||||
if (audio) {
|
||||
audio.stop();
|
||||
audio.destroy();
|
||||
audio = null;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.speechSynthesis && browserUtterance) {
|
||||
window.speechSynthesis.cancel();
|
||||
browserUtterance = null;
|
||||
}
|
||||
publish();
|
||||
};
|
||||
|
||||
const playWithBrowserSpeech = (key: string, value: string, currentGeneration: number) => {
|
||||
if (
|
||||
typeof window === 'undefined'
|
||||
|| !window.speechSynthesis
|
||||
|| typeof window.SpeechSynthesisUtterance !== 'function'
|
||||
) return false;
|
||||
|
||||
const utterance = new window.SpeechSynthesisUtterance(value.slice(0, 600));
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.rate = 0.95;
|
||||
utterance.onend = () => {
|
||||
if (currentGeneration === generation && activeKey === key) stop();
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
if (currentGeneration !== generation || activeKey !== key) return;
|
||||
stop();
|
||||
onError('当前设备语音播报不可用');
|
||||
};
|
||||
browserUtterance = utterance;
|
||||
publish(key, 'playing');
|
||||
window.speechSynthesis.speak(utterance);
|
||||
return true;
|
||||
};
|
||||
|
||||
const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => {
|
||||
const context = uni.createInnerAudioContext();
|
||||
audio = context;
|
||||
const isCurrent = () => currentGeneration === generation && audio === context && activeKey === key;
|
||||
context.onPlay(() => {
|
||||
if (isCurrent()) publish(key, 'playing');
|
||||
});
|
||||
context.onEnded(() => {
|
||||
if (isCurrent()) stop();
|
||||
});
|
||||
context.onStop(() => {
|
||||
if (isCurrent()) publish();
|
||||
});
|
||||
context.onError(() => {
|
||||
if (!isCurrent()) return;
|
||||
sourceCache.delete(cacheKey);
|
||||
stop();
|
||||
onError('语音播放失败,请稍后重试');
|
||||
});
|
||||
context.src = source;
|
||||
context.play();
|
||||
};
|
||||
|
||||
const toggle = async (key: string, text: string) => {
|
||||
if (activeKey === key) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
const value = text.trim();
|
||||
if (!value) return;
|
||||
const currentGeneration = generation;
|
||||
publish(key, 'loading');
|
||||
try {
|
||||
const cachedSource = sourceCache.get(value);
|
||||
if (cachedSource) {
|
||||
sourceCache.delete(value);
|
||||
sourceCache.set(value, cachedSource);
|
||||
playAudioSource(key, cachedSource, value, currentGeneration);
|
||||
return;
|
||||
}
|
||||
const result = await synthesizeSpeech(value);
|
||||
if (currentGeneration !== generation || activeKey !== key) return;
|
||||
const source = result.inlineAudioUrl || result.audioUrl;
|
||||
if (!source) throw new Error('语音合成未返回音频');
|
||||
sourceCache.set(value, source);
|
||||
while (sourceCache.size > maxCachedSources) {
|
||||
const oldestKey = sourceCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
sourceCache.delete(oldestKey);
|
||||
}
|
||||
playAudioSource(key, source, value, currentGeneration);
|
||||
} catch (error) {
|
||||
if (currentGeneration !== generation) return;
|
||||
if (playWithBrowserSpeech(key, value, currentGeneration)) return;
|
||||
stop();
|
||||
onError(error instanceof Error ? error.message : '语音生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
stop();
|
||||
sourceCache.clear();
|
||||
};
|
||||
|
||||
return { toggle, stop, destroy };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user