mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 07:03:53 +08:00
feat: 添加声纹管理-声纹向量音频播放功能
This commit is contained in:
@@ -60,3 +60,13 @@ export function updateVoicePrint(data: VoicePrint) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取音频下载ID
|
||||
export function getAudioDownloadId(audioId: string) {
|
||||
return http.Post<string>(`/agent/audio/${audioId}`, {}, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -492,4 +492,9 @@ export default {
|
||||
'deviceConfig.afterConfigSuccessDeviceWillRestart': 'Nach erfolgreicher Konfiguration startet Gerät automatisch neu',
|
||||
'deviceConfig.audioPlaybackError': 'Audio-Wiedergabe-Fehler',
|
||||
'deviceConfig.playbackFailed': 'Wiedergabe fehlgeschlagen',
|
||||
|
||||
// Voiceprint page
|
||||
'voiceprint.audioNotExist': 'Audio existiert nicht',
|
||||
'voiceprint.getAudioFailed': 'Audio konnte nicht abgerufen werden',
|
||||
'voiceprint.audioPlayFailed': 'Audio-Wiedergabe fehlgeschlagen',
|
||||
}
|
||||
|
||||
@@ -492,4 +492,9 @@ export default {
|
||||
'deviceConfig.afterConfigSuccessDeviceWillRestart': 'After successful configuration, device will automatically restart',
|
||||
'deviceConfig.audioPlaybackError': 'Audio playback error',
|
||||
'deviceConfig.playbackFailed': 'Playback failed',
|
||||
|
||||
// Voiceprint page
|
||||
'voiceprint.audioNotExist': 'Audio does not exist',
|
||||
'voiceprint.getAudioFailed': 'Failed to get audio',
|
||||
'voiceprint.audioPlayFailed': 'Audio playback failed',
|
||||
}
|
||||
|
||||
@@ -492,4 +492,9 @@ export default {
|
||||
'deviceConfig.afterConfigSuccessDeviceWillRestart': 'Após a configuração bem-sucedida, o dispositivo reiniciará automaticamente',
|
||||
'deviceConfig.audioPlaybackError': 'Erro na reprodução de áudio',
|
||||
'deviceConfig.playbackFailed': 'Falha na reprodução',
|
||||
|
||||
// Voiceprint page
|
||||
'voiceprint.audioNotExist': 'O áudio não existe',
|
||||
'voiceprint.getAudioFailed': 'Falha ao obter áudio',
|
||||
'voiceprint.audioPlayFailed': 'Falha na reprodução de áudio',
|
||||
}
|
||||
|
||||
@@ -492,4 +492,9 @@ export default {
|
||||
'deviceConfig.afterConfigSuccessDeviceWillRestart': 'Sau khi cấu hình thành công, thiết bị sẽ tự động khởi động lại',
|
||||
'deviceConfig.audioPlaybackError': 'Lỗi phát âm thanh',
|
||||
'deviceConfig.playbackFailed': 'Phát thất bại',
|
||||
|
||||
// Voiceprint page
|
||||
'voiceprint.audioNotExist': 'Âm thanh không tồn tại',
|
||||
'voiceprint.getAudioFailed': 'Không thể lấy âm thanh',
|
||||
'voiceprint.audioPlayFailed': 'Phát âm thanh thất bại',
|
||||
}
|
||||
@@ -492,4 +492,9 @@ export default {
|
||||
'deviceConfig.afterConfigSuccessDeviceWillRestart': '配网成功后设备将自动重启',
|
||||
'deviceConfig.audioPlaybackError': '音频播放错误',
|
||||
'deviceConfig.playbackFailed': '播放失败',
|
||||
|
||||
// Voiceprint page
|
||||
'voiceprint.audioNotExist': '该音频不存在',
|
||||
'voiceprint.getAudioFailed': '获取音频失败',
|
||||
'voiceprint.audioPlayFailed': '音频播放失败',
|
||||
}
|
||||
|
||||
@@ -492,4 +492,9 @@ export default {
|
||||
'deviceConfig.afterConfigSuccessDeviceWillRestart': '配網成功後設備將自動重啟',
|
||||
'deviceConfig.audioPlaybackError': '音頻播放錯誤',
|
||||
'deviceConfig.playbackFailed': '播放失敗',
|
||||
|
||||
// Voiceprint page
|
||||
'voiceprint.audioNotExist': '該音頻不存在',
|
||||
'voiceprint.getAudioFailed': '獲取音頻失敗',
|
||||
'voiceprint.audioPlayFailed': '音頻播放失敗',
|
||||
}
|
||||
|
||||
@@ -110,6 +110,10 @@ const inputVisible = ref(false)
|
||||
const languageOptions = ref([])
|
||||
const isVisibleReport = ref(false)
|
||||
|
||||
// 音频播放相关
|
||||
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
|
||||
const playingVoiceId = ref<string>('')
|
||||
|
||||
// 使用插件store
|
||||
const pluginStore = usePluginStore()
|
||||
const speedPitchStore = useSpeedPitch()
|
||||
@@ -493,6 +497,57 @@ async function onPickerConfirm(type: string, value: any, name: string) {
|
||||
// 选择器取消
|
||||
function onPickerCancel(type: string) {
|
||||
pickerShow.value[type] = false
|
||||
// 关闭时停止播放
|
||||
if (type === 'voiceprint') {
|
||||
stopAudio()
|
||||
}
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
function playAudio(voiceDemo: string, voiceId: string, event: Event) {
|
||||
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
|
||||
|
||||
if (!voiceDemo) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在播放同一个音频,则停止
|
||||
if (playingVoiceId.value === voiceId) {
|
||||
stopAudio()
|
||||
return
|
||||
}
|
||||
|
||||
// 停止之前的音频
|
||||
stopAudio()
|
||||
|
||||
// 创建新的音频实例
|
||||
audioRef.value = uni.createInnerAudioContext()
|
||||
audioRef.value.src = voiceDemo
|
||||
playingVoiceId.value = voiceId
|
||||
|
||||
// 监听播放结束
|
||||
audioRef.value.onEnded(() => {
|
||||
playingVoiceId.value = ''
|
||||
})
|
||||
|
||||
// 监听播放错误
|
||||
audioRef.value.onError(() => {
|
||||
toast.error('音频播放失败')
|
||||
playingVoiceId.value = ''
|
||||
})
|
||||
|
||||
// 播放音频
|
||||
audioRef.value.play()
|
||||
}
|
||||
|
||||
// 停止音频
|
||||
function stopAudio() {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.stop()
|
||||
audioRef.value.destroy()
|
||||
audioRef.value = null
|
||||
}
|
||||
playingVoiceId.value = ''
|
||||
}
|
||||
|
||||
// 获取模型显示名称
|
||||
@@ -946,12 +1001,32 @@ onMounted(async () => {
|
||||
@select="({ item }) => onPickerConfirm('tts', item.value, item.name)"
|
||||
/>
|
||||
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.voiceprint"
|
||||
:actions="voiceOptions"
|
||||
@close="onPickerCancel('voiceprint')"
|
||||
@select="({ item }) => onPickerConfirm('voiceprint', item.value, item.name)"
|
||||
/>
|
||||
<!-- 自定义语音选择弹出层 -->
|
||||
<wd-popup v-model="pickerShow.voiceprint" position="bottom" @close="onPickerCancel('voiceprint')">
|
||||
<view class="bg-white rounded-t-[20rpx] pb-[env(safe-area-inset-bottom)]">
|
||||
<view class="flex items-center justify-between border-b border-[#eeeeee] p-[32rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">{{ t('agent.voiceprint') }}</text>
|
||||
<wd-icon name="close" size="20px" @click="onPickerCancel('voiceprint')" />
|
||||
</view>
|
||||
<view class="max-h-[600rpx] overflow-y-auto">
|
||||
<view
|
||||
v-for="voice in voiceOptions"
|
||||
:key="voice.value"
|
||||
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
|
||||
@click="onPickerConfirm('voiceprint', voice.value, voice.name)"
|
||||
>
|
||||
<text class="flex-1 text-[28rpx] text-[#232338]">{{ voice.name }}</text>
|
||||
<view v-if="voice.voiceDemo || voice.voice_demo" class="ml-[20rpx]" @click.stop="playAudio(voice.voiceDemo || voice.voice_demo, voice.value, $event)">
|
||||
<wd-icon
|
||||
:name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'"
|
||||
size="24px"
|
||||
:custom-class="playingVoiceId === voice.value ? 'text-[#336cff]' : 'text-[#9d9ea3]'"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.language"
|
||||
:actions="languageOptions"
|
||||
|
||||
@@ -3,22 +3,25 @@ import type { ChatHistory, CreateSpeakerData, VoicePrint } from '@/api/voiceprin
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { createVoicePrint, deleteVoicePrint, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
|
||||
import { createVoicePrint, deleteVoicePrint, getAudioDownloadId, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
|
||||
import { t } from '@/i18n'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
|
||||
defineOptions({
|
||||
name: 'VoicePrintManage',
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
agentId: 'default',
|
||||
})
|
||||
|
||||
const emits = defineEmits(['update-refresher-enabled'])
|
||||
|
||||
// 接收props
|
||||
interface Props {
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
agentId: 'default'
|
||||
})
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets: any
|
||||
let systemInfo: any
|
||||
@@ -50,6 +53,10 @@ const chatHistoryActions = ref<any[]>([])
|
||||
const swipeStates = ref<Record<string, 'left' | 'close' | 'right'>>({})
|
||||
const loading = ref(false)
|
||||
|
||||
// 音频播放相关
|
||||
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
|
||||
const playingAudioId = ref<string>('')
|
||||
|
||||
// 使用传入的智能体ID
|
||||
const currentAgentId = computed(() => {
|
||||
return props.agentId
|
||||
@@ -157,11 +164,13 @@ function openAddDialog() {
|
||||
introduce: '',
|
||||
}
|
||||
showAddDialog.value = true
|
||||
} catch (error: any) {
|
||||
}
|
||||
catch (error: any) {
|
||||
// 捕捉声纹接口未配置错误
|
||||
if (error.message && error.message.includes('请求错误[10054]')) {
|
||||
toast.error(t('voiceprint.voiceprintInterfaceNotConfigured'))
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// 其他错误,继续打开弹窗
|
||||
addForm.value = {
|
||||
agentId: currentAgentId.value,
|
||||
@@ -202,6 +211,11 @@ function selectAudioId({ item }: { item: any }) {
|
||||
showChatHistoryDialog.value = false
|
||||
}
|
||||
|
||||
// 点击选择
|
||||
function handleItemClick(item: any) {
|
||||
selectAudioId({ item })
|
||||
}
|
||||
|
||||
// 提交添加说话人
|
||||
async function submitAdd() {
|
||||
if (!addForm.value.sourceName.trim()) {
|
||||
@@ -274,6 +288,82 @@ async function handleDelete(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
async function playAudio(audioId: string, event: Event) {
|
||||
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
|
||||
|
||||
if (!audioId) {
|
||||
toast.warning(t('voiceprint.audioNotExist'))
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在播放同一个音频,则停止
|
||||
if (playingAudioId.value === audioId) {
|
||||
stopAudio()
|
||||
return
|
||||
}
|
||||
|
||||
// 停止之前的音频
|
||||
stopAudio()
|
||||
|
||||
try {
|
||||
// 先获取音频下载ID
|
||||
playingAudioId.value = audioId
|
||||
const downloadId = await getAudioDownloadId(audioId)
|
||||
|
||||
if (!downloadId) {
|
||||
toast.error(t('voiceprint.getAudioFailed'))
|
||||
playingAudioId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 获取baseURL
|
||||
const baseURL = getEnvBaseUrl()
|
||||
const audioUrl = `${baseURL}/agent/play/${downloadId}`
|
||||
|
||||
// 创建新的音频实例
|
||||
audioRef.value = uni.createInnerAudioContext()
|
||||
audioRef.value.src = audioUrl
|
||||
audioRef.value.autoplay = true
|
||||
|
||||
// 监听播放结束
|
||||
audioRef.value.onEnded(() => {
|
||||
playingAudioId.value = ''
|
||||
})
|
||||
|
||||
// 监听播放错误
|
||||
audioRef.value.onError((error) => {
|
||||
console.error('音频播放错误:', error)
|
||||
toast.error(t('voiceprint.audioPlayFailed'))
|
||||
playingAudioId.value = ''
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('播放音频失败:', error)
|
||||
toast.error(t('voiceprint.audioPlayFailed'))
|
||||
playingAudioId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 停止音频
|
||||
function stopAudio() {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.stop()
|
||||
audioRef.value.destroy()
|
||||
audioRef.value = null
|
||||
}
|
||||
playingAudioId.value = ''
|
||||
}
|
||||
|
||||
watch(() => [showAddDialog.value, showEditDialog.value], (newValues) => {
|
||||
if (newValues.some((value: boolean) => value)) {
|
||||
emits('update-refresher-enabled', false)
|
||||
}
|
||||
else {
|
||||
emits('update-refresher-enabled', true)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// 智能体已简化为默认
|
||||
|
||||
@@ -282,6 +372,8 @@ onMounted(async () => {
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
showAddDialog,
|
||||
showEditDialog,
|
||||
refresh,
|
||||
})
|
||||
</script>
|
||||
@@ -349,7 +441,7 @@ defineExpose({
|
||||
</view>
|
||||
|
||||
<!-- 浮动操作按钮 -->
|
||||
<wd-fab type="primary" size="small" :draggable="true" :expandable="false" @click="openAddDialog">
|
||||
<wd-fab custom-style="z-index:10" type="primary" size="small" :draggable="true" :expandable="false" @click="openAddDialog">
|
||||
<wd-icon name="add" />
|
||||
</wd-fab>
|
||||
|
||||
@@ -418,11 +510,11 @@ defineExpose({
|
||||
|
||||
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
|
||||
<wd-button type="info" custom-class="flex-1" @click="showAddDialog = false">
|
||||
{{ t('voiceprint.cancel') }}
|
||||
</wd-button>
|
||||
<wd-button type="primary" custom-class="flex-1" @click="submitAdd">
|
||||
{{ t('voiceprint.save') }}
|
||||
</wd-button>
|
||||
{{ t('voiceprint.cancel') }}
|
||||
</wd-button>
|
||||
<wd-button type="primary" custom-class="flex-1" @click="submitAdd">
|
||||
{{ t('voiceprint.save') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -488,20 +580,45 @@ defineExpose({
|
||||
|
||||
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
|
||||
<wd-button type="info" custom-class="flex-1" @click="showEditDialog = false">
|
||||
{{ t('voiceprint.cancel') }}
|
||||
</wd-button>
|
||||
<wd-button type="primary" custom-class="flex-1" @click="submitEdit">
|
||||
{{ t('voiceprint.save') }}
|
||||
</wd-button>
|
||||
{{ t('voiceprint.cancel') }}
|
||||
</wd-button>
|
||||
<wd-button type="primary" custom-class="flex-1" @click="submitEdit">
|
||||
{{ t('voiceprint.save') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- 语音对话记录选择动作面板 -->
|
||||
<wd-action-sheet
|
||||
v-model="showChatHistoryDialog" :actions="chatHistoryActions" :title="t('voiceprint.selectVector')"
|
||||
@select="selectAudioId"
|
||||
/>
|
||||
<!-- 自定义语音对话记录选择弹出层 -->
|
||||
<wd-popup v-model="showChatHistoryDialog" position="bottom" @close="stopAudio">
|
||||
<view class="rounded-t-[20rpx] bg-white pb-[env(safe-area-inset-bottom)]">
|
||||
<view class="flex items-center justify-between border-b border-[#eeeeee] p-[32rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('voiceprint.selectVector') }}
|
||||
</text>
|
||||
<wd-icon name="close" size="20px" @click="showChatHistoryDialog = false; stopAudio()" />
|
||||
</view>
|
||||
<view class="max-h-[600rpx] overflow-y-auto">
|
||||
<view
|
||||
v-for="item in chatHistoryActions"
|
||||
:key="item.audioId"
|
||||
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<text class="flex-1 text-[28rpx] text-[#232338]">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
<view class="ml-[20rpx]" @click.stop="playAudio(item.audioId, $event)">
|
||||
<wd-icon
|
||||
:name="playingAudioId === item.audioId ? 'pause-circle' : 'play-circle'"
|
||||
size="24px"
|
||||
:custom-class="playingAudioId === item.audioId ? 'text-[#336cff]' : 'text-[#9d9ea3]'"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user