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:
@@ -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: '' }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user