fix: 录音可靠性修复 + ASR 后端优化

前端:
- recorder.onError 时重置 recorder = null,避免错误后录音永久失效
- 增加 asrLoading 状态,ASR 处理中输入框 disabled 并显示'语音识别中...'
- 增加 skipAsr 标志,<1s 的录音跳过 ASR 上传
- uploadFile 增加 30s timeout

后端:
- ASR: OpenAI 失败后 fallback 到本地 whisper,日志级别降为 warn
- try/catch/finally 重构,确保临时文件在所有路径都清理
- OpenAI 和 whisper 结果分别 try,互不影响
This commit is contained in:
yuzhiran
2026-07-05 17:45:03 +08:00
parent 8152278b86
commit 48354e634b
2 changed files with 43 additions and 20 deletions
+16 -7
View File
@@ -69,7 +69,9 @@ export class TtsController {
} }
try { try {
let text = ''
if (process.env.OPENAI_API_KEY) { if (process.env.OPENAI_API_KEY) {
try {
const result = execSync( const result = execSync(
`curl -s -X POST https://api.openai.com/v1/audio/transcriptions \ `curl -s -X POST https://api.openai.com/v1/audio/transcriptions \
-H "Authorization: Bearer ${process.env.OPENAI_API_KEY}" \ -H "Authorization: Bearer ${process.env.OPENAI_API_KEY}" \
@@ -80,18 +82,25 @@ export class TtsController {
{ encoding: 'utf8', timeout: 30000 }, { encoding: 'utf8', timeout: 30000 },
) )
const parsed = JSON.parse(result) const parsed = JSON.parse(result)
if (parsed.text) return { text: parsed.text.trim() } if (parsed.text) text = parsed.text.trim()
} catch (e: any) {
this.logger.warn(`OpenAI ASR failed, falling back to local: ${e.message}`)
} }
const whisperResult = execSync(`python3 -c 'import sys, whisper; model = whisper.load_model("tiny"); print(model.transcribe(sys.argv[1], language="zh")["text"].strip())' "${wavPath}"`, { encoding: 'utf8', timeout: 60000 })
if (whisperResult && whisperResult.trim()) {
return { text: whisperResult.trim() }
} }
if (!text) {
const whisperResult = execSync(
`python3 -c 'import sys, whisper; model = whisper.load_model("tiny"); print(model.transcribe(sys.argv[1], language="zh")["text"].strip())' "${wavPath}"`,
{ encoding: 'utf8', timeout: 60000 },
)
if (whisperResult?.trim()) text = whisperResult.trim()
}
return { text }
} catch (e: any) { } catch (e: any) {
this.logger.error(`ASR failed: ${e?.message || e}`) this.logger.error(`ASR failed: ${e?.message || e}`)
} return { text: '' }
// 清理临时文件 } finally {
try { if (dest) fs.unlinkSync(dest) } catch {} try { if (dest) fs.unlinkSync(dest) } catch {}
try { if (wavPath && wavPath !== dest) fs.unlinkSync(wavPath) } catch {} try { if (wavPath && wavPath !== dest) fs.unlinkSync(wavPath) } catch {}
return { text: '' } }
} }
} }
+16 -2
View File
@@ -65,7 +65,7 @@
<text class="mic-label">{{ isRecording ? recordingDuration + 's' : '按住' }}</text> <text class="mic-label">{{ isRecording ? recordingDuration + 's' : '按住' }}</text>
</view> </view>
<view class="input-box"> <view class="input-box">
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading || isRecording" @confirm="sendAnswer" /> <textarea class="input-area" v-model="inputText" :placeholder="asrLoading ? '语音识别中...' : '输入你的回答...'" :auto-height="true" :maxlength="2000" :disabled="aiLoading || isRecording || asrLoading" @confirm="sendAnswer" />
</view> </view>
<view class="send-btn" :class="{ disabled: (!inputText.trim() && !isRecording) || aiLoading }" @click="sendAnswer"> <view class="send-btn" :class="{ disabled: (!inputText.trim() && !isRecording) || aiLoading }" @click="sendAnswer">
<text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text> <text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text>
@@ -153,8 +153,10 @@ const isSpeaking = ref(false)
const dhRef = ref(null) const dhRef = ref(null)
const isRecording = ref(false) const isRecording = ref(false)
const recordingDuration = ref(0) const recordingDuration = ref(0)
const asrLoading = ref(false)
let recorder = null let recorder = null
let recordTimer = null let recordTimer = null
let skipAsr = false
function initRecorder() { function initRecorder() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
@@ -169,24 +171,29 @@ function initRecorder() {
console.error('[Recorder] error:', JSON.stringify(err)) console.error('[Recorder] error:', JSON.stringify(err))
isRecording.value = false isRecording.value = false
recordingDuration.value = 0 recordingDuration.value = 0
asrLoading.value = false
if (recordTimer) { clearInterval(recordTimer); recordTimer = null } if (recordTimer) { clearInterval(recordTimer); recordTimer = null }
const msg = err?.errMsg?.includes('permission') ? '请允许录音权限' : (err?.errMsg || '录音失败,请重试') const msg = err?.errMsg?.includes('permission') ? '请允许录音权限' : (err?.errMsg || '录音失败,请重试')
uni.showToast({ title: msg, icon: 'none' }) uni.showToast({ title: msg, icon: 'none' })
recorder = null
}) })
recorder.onStop(async (res) => { recorder.onStop(async (res) => {
isRecording.value = false isRecording.value = false
recordingDuration.value = 0 recordingDuration.value = 0
if (recordTimer) { clearInterval(recordTimer); recordTimer = null } if (recordTimer) { clearInterval(recordTimer); recordTimer = null }
if (skipAsr) { skipAsr = false; return }
if (!res.tempFilePath) { if (!res.tempFilePath) {
uni.showToast({ title: '录音文件为空', icon: 'none' }) uni.showToast({ title: '录音文件为空', icon: 'none' })
return return
} }
asrLoading.value = true
try { try {
const uploadRes = await uni.uploadFile({ const uploadRes = await uni.uploadFile({
url: api(API_ENDPOINTS.TTS.ASR), url: api(API_ENDPOINTS.TTS.ASR),
filePath: res.tempFilePath, filePath: res.tempFilePath,
name: 'audio', name: 'audio',
header: { 'Authorization': `Bearer ${token()}` }, header: { 'Authorization': `Bearer ${token()}` },
timeout: 30000,
}) })
if (uploadRes.statusCode === 200 && uploadRes.data) { if (uploadRes.statusCode === 200 && uploadRes.data) {
const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data
@@ -200,6 +207,8 @@ function initRecorder() {
} }
} catch (e) { } catch (e) {
console.error('[ASR] upload error:', e?.message || e) console.error('[ASR] upload error:', e?.message || e)
} finally {
asrLoading.value = false
} }
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' }) uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
}) })
@@ -418,7 +427,7 @@ const confirmExit = () => {
} }
function startRecord() { function startRecord() {
if (aiLoading.value || isComplete.value) return if (aiLoading.value || isComplete.value || asrLoading.value) return
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
if (isRecording.value) return if (isRecording.value) return
isRecording.value = true isRecording.value = true
@@ -445,6 +454,11 @@ function stopRecord() {
if (!isRecording.value) return if (!isRecording.value) return
isRecording.value = false isRecording.value = false
if (!recorder) return if (!recorder) return
if (recordingDuration.value < 1) {
skipAsr = true
if (recordTimer) { clearInterval(recordTimer); recordTimer = null }
recordingDuration.value = 0
}
recorder.stop() recorder.stop()
} }
</script> </script>