feat: 添加声纹管理-声纹向量音频播放功能

This commit is contained in:
zhuoqinglian
2026-03-16 09:50:24 +08:00
parent 860cbc36fd
commit 286079c93a
9 changed files with 261 additions and 29 deletions
@@ -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,
},
})
}
+5
View File
@@ -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',
}
+5
View File
@@ -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',
}
+5
View File
@@ -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',
}
+5
View File
@@ -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',
}
+5
View File
@@ -492,4 +492,9 @@ export default {
'deviceConfig.afterConfigSuccessDeviceWillRestart': '配网成功后设备将自动重启',
'deviceConfig.audioPlaybackError': '音频播放错误',
'deviceConfig.playbackFailed': '播放失败',
// Voiceprint page
'voiceprint.audioNotExist': '该音频不存在',
'voiceprint.getAudioFailed': '获取音频失败',
'voiceprint.audioPlayFailed': '音频播放失败',
}
+5
View File
@@ -492,4 +492,9 @@ export default {
'deviceConfig.afterConfigSuccessDeviceWillRestart': '配網成功後設備將自動重啟',
'deviceConfig.audioPlaybackError': '音頻播放錯誤',
'deviceConfig.playbackFailed': '播放失敗',
// Voiceprint page
'voiceprint.audioNotExist': '該音頻不存在',
'voiceprint.getAudioFailed': '獲取音頻失敗',
'voiceprint.audioPlayFailed': '音頻播放失敗',
}
+81 -6
View File
@@ -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>