Merge branch 'main' into add_news

This commit is contained in:
hrz
2025-05-09 15:25:15 +08:00
committed by GitHub
30 changed files with 432 additions and 313 deletions
@@ -177,5 +177,5 @@ public interface Constant {
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.4.1"; public static final String VERSION = "0.4.2";
} }
@@ -0,0 +1,43 @@
-- 添加百度ASR模型配置
delete from `ai_model_config` where `id` = 'ASR_BaiduASR';
INSERT INTO `ai_model_config` VALUES ('ASR_BaiduASR', 'ASR', 'BaiduASR', '百度语音识别', 0, 1, '{\"type\": \"baidu\", \"app_id\": \"\", \"api_key\": \"\", \"secret_key\": \"\", \"dev_pid\": 1537, \"output_dir\": \"tmp/\"}', NULL, NULL, 7, NULL, NULL, NULL, NULL);
-- 添加百度ASR供应器
delete from `ai_model_provider` where `id` = 'SYSTEM_ASR_BaiduASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_BaiduASR', 'ASR', 'baidu', '百度语音识别', '[{"key":"app_id","label":"应用AppID","type":"string"},{"key":"api_key","label":"API Key","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"dev_pid","label":"语言参数","type":"number"},{"key":"output_dir","label":"输出目录","type":"string"}]', 7, 1, NOW(), 1, NOW());
-- 更新百度ASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list',
`remark` = '百度ASR配置说明:
1. 访问 https://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list
2. 创建新应用
3. 获取AppID、API Key和Secret Key
4. 填入配置文件中
查看资源额度:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/overview/resource/list
语言参数说明:https://ai.baidu.com/ai-doc/SPEECH/0lbxfnc9b
' WHERE `id` = 'ASR_BaiduASR';
-- 更新豆包供应器字段
update `ai_model_provider` set `fields` =
'[{"key":"appid","label":"应用ID","type":"string"},{"key":"access_token","label":"访问令牌","type":"string"},{"key":"cluster","label":"集群","type":"string"},{"key":"boosting_table_name","label":"热词文件名称","type":"string"},{"key":"correct_table_name","label":"替换词文件名称","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]'
where `id` = 'SYSTEM_ASR_DoubaoASR';
-- 更新豆包ASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/speech/app',
`remark` = '豆包ASR配置说明:
1. 需要在火山引擎控制台创建应用并获取appid和access_token
2. 支持中文语音识别
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://console.volcengine.com/speech/app
2. 创建新应用
3. 获取appid和access_token
4. 填入配置文件中
如需设置热词,请参考:https://www.volcengine.com/docs/6561/155738
' WHERE `id` = 'ASR_DoubaoASR';
@@ -100,6 +100,13 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202505022134.sql path: classpath:db/changelog/202505022134.sql
- changeSet:
id: 202505081146
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505081146.sql
- changeSet: - changeSet:
id: 202505091409 id: 202505091409
author: hrz author: hrz
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible="visible" @close="handleClose" width="400px" center> <el-dialog :visible="visible" @close="handleClose" width="24%" center>
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="975px" center <el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="57%" center
custom-class="custom-dialog" :show-close="false" class="center-dialog"> custom-class="custom-dialog" :show-close="false" class="center-dialog">
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;"> <div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;"> <div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
@@ -54,7 +54,7 @@
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark" class="prop-remark"> <el-form-item label="备注" prop="remark" class="prop-remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入模型备注" <el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入模型备注" :autosize="{ minRows: 3, maxRows: 5 }"
class="custom-input-bg"></el-input> class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -271,7 +271,7 @@ export default {
} }
.center-dialog .el-dialog { .center-dialog .el-dialog {
margin: 4% 0 auto !important; margin: 0 0 auto !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen"> <el-dialog :visible="visible" @close="handleClose" width="25%" center @open="handleOpen">
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -10,7 +10,7 @@
</div> </div>
<div style="height: 1px;background: #e8f0ff;" /> <div style="height: 1px;background: #e8f0ff;" />
<div style="margin: 22px 15px;"> <div style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;"> <div style="font-weight: 400;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div> 智能体名称 <div style="color: red;display: inline-block;">*</div> 智能体名称
</div> </div>
<div class="input-46" style="margin-top: 12px;"> <div class="input-46" style="margin-top: 12px;">
@@ -1,6 +1,6 @@
<template> <template>
<form> <form>
<el-dialog :visible.sync="value" width="400px" center> <el-dialog :visible.sync="dialogVisible" width="24%" center>
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -60,11 +60,20 @@ export default {
}, },
data() { data() {
return { return {
dialogVisible: this.value,
oldPassword: "", oldPassword: "",
newPassword: "", newPassword: "",
confirmNewPassword: "" confirmNewPassword: ""
} }
}, },
watch: {
value(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('input', val);
}
},
methods: { methods: {
...mapActions(['logout']), // 引入Vuex的logout action ...mapActions(['logout']), // 引入Vuex的logout action
confirm() { confirm() {
@@ -101,7 +110,7 @@ export default {
this.$emit('input', false); this.$emit('input', false);
}, },
cancel() { cancel() {
this.$emit('input', false); this.dialogVisible = false;
this.resetForm(); this.resetForm();
}, },
resetForm() { resetForm() {
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose"> <el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="100px"> <el-form :model="form" :rules="rules" ref="form" label-width="100px">
<el-form-item label="字典标签" prop="dictLabel"> <el-form-item label="字典标签" prop="dictLabel">
<el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input> <el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input>
@@ -41,6 +41,7 @@ export default {
}, },
data() { data() {
return { return {
dialogVisible: this.visible,
form: { form: {
id: null, id: null,
dictTypeId: null, dictTypeId: null,
@@ -70,12 +71,18 @@ export default {
} }
}, },
immediate: true immediate: true
},
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
} }
}, },
methods: { methods: {
handleClose() { handleClose() {
this.$emit('update:visible', false) this.dialogVisible = false;
this.resetForm() this.resetForm();
}, },
resetForm() { resetForm() {
this.form = { this.form = {
@@ -102,4 +109,8 @@ export default {
.dialog-footer { .dialog-footer {
text-align: right; text-align: right;
} }
:deep(.el-dialog) {
border-radius: 15px;
}
</style> </style>
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose"> <el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="120px"> <el-form :model="form" :rules="rules" ref="form" label-width="120px">
<el-form-item label="字典类型名称" prop="dictName"> <el-form-item label="字典类型名称" prop="dictName">
<el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input> <el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input>
@@ -34,6 +34,7 @@ export default {
}, },
data() { data() {
return { return {
dialogVisible: this.visible,
form: { form: {
id: null, id: null,
dictName: '', dictName: '',
@@ -46,6 +47,12 @@ export default {
} }
}, },
watch: { watch: {
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
},
dictTypeData: { dictTypeData: {
handler(val) { handler(val) {
if (val) { if (val) {
@@ -57,7 +64,7 @@ export default {
}, },
methods: { methods: {
handleClose() { handleClose() {
this.$emit('update:visible', false) this.dialogVisible = false;
this.resetForm() this.resetForm()
}, },
resetForm() { resetForm() {
@@ -83,4 +90,8 @@ export default {
.dialog-footer { .dialog-footer {
text-align: right; text-align: right;
} }
:deep(.el-dialog) {
border-radius: 15px;
}
</style> </style>
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen"> <el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose" @open="handleOpen">
<el-form ref="form" :model="form" :rules="rules" label-width="100px"> <el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="固件名称" prop="firmwareName"> <el-form-item label="固件名称" prop="firmwareName">
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input> <el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
@@ -59,11 +59,13 @@ export default {
default: () => [] default: () => []
} }
}, },
data() { data() {
return { return {
uploadProgress: 0, uploadProgress: 0,
uploadStatus: '', uploadStatus: '',
isUploading: false, isUploading: false,
dialogVisible: this.visible,
rules: { rules: {
firmwareName: [ firmwareName: [
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' } { required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
@@ -90,10 +92,18 @@ export default {
created() { created() {
// 移除 getDictDataByType 调用 // 移除 getDictDataByType 调用
}, },
watch: {
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
},
},
methods: { methods: {
// 移除 getFirmwareTypes 方法 // 移除 getFirmwareTypes 方法
handleClose() { handleClose() {
this.$refs.form.clearValidate(); this.dialogVisible = false;
this.$emit('cancel'); this.$emit('cancel');
}, },
handleCancel() { handleCancel() {
@@ -201,13 +211,17 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
::v-deep .el-dialog {
border-radius: 20px;
}
.upload-demo { .upload-demo {
text-align: left; text-align: left;
} }
.el-upload__tip { .el-upload__tip {
line-height: 1.2; line-height: 1.2;
padding-top: 5px; padding-top: 2%;
color: #909399; color: #909399;
} }
@@ -1,6 +1,6 @@
<template> <template>
<el-dialog :visible.sync="dialogVisible" width="975px" center custom-class="custom-dialog" :show-close="false" <el-dialog :visible.sync="dialogVisible" width="57%" center custom-class="custom-dialog" :show-close="false"
class="center-dialog"> class="center-dialog" >
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;"> <div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;"> <div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
修改模型 修改模型
@@ -53,7 +53,7 @@
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark" class="prop-remark"> <el-form-item label="备注" prop="remark" class="prop-remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入模型备注" <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入模型备注" :autosize="{ minRows: 3, maxRows: 5 }"
class="custom-input-bg"></el-input> class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -296,7 +296,7 @@ export default {
}; };
</script> </script>
<style scoped> <style lang="scss" scoped>
.custom-dialog { .custom-dialog {
position: relative; position: relative;
border-radius: 20px; border-radius: 20px;
@@ -316,11 +316,6 @@ export default {
justify-content: center; justify-content: center;
} }
.center-dialog .el-dialog {
margin: 4% 0 auto !important;
display: flex;
flex-direction: column;
}
.custom-close-btn { .custom-close-btn {
position: absolute; position: absolute;
+1 -8
View File
@@ -487,7 +487,7 @@ export default {
}; };
</script> </script>
<style scoped> <style lang="scss" scoped>
::v-deep .el-dialog { ::v-deep .el-dialog {
border-radius: 8px !important; border-radius: 8px !important;
@@ -648,12 +648,6 @@ export default {
margin: 0 auto; margin: 0 auto;
} }
/* 新增按钮组样式 */
.action-buttons {
bottom: 20px;
padding-top: 10px;
}
.action-buttons .el-button { .action-buttons .el-button {
padding: 8px 15px; padding: 8px 15px;
font-size: 11px; font-size: 11px;
@@ -692,7 +686,6 @@ export default {
position: static; position: static;
padding: 15px 0; padding: 15px 0;
background: white; background: white;
box-shadow: 0 -2px 12px rgba(0,0,0,0.05);
} }
/* 输入框自适应 */ /* 输入框自适应 */
+32 -16
View File
@@ -49,9 +49,13 @@
v-loading="dictDataLoading" element-loading-text="拼命加载中" v-loading="dictDataLoading" element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)" element-loading-background="rgba(255, 255, 255, 0.7)"
@selection-change="handleDictDataSelectionChange" class="data-table" class="data-table"
header-row-class-name="table-header"> header-row-class-name="table-header">
<el-table-column type="selection" width="55" align="center"></el-table-column> <el-table-column label="选择" align="center" width="55">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="字典标签" prop="dictLabel" align="center"></el-table-column> <el-table-column label="字典标签" prop="dictLabel" align="center"></el-table-column>
<el-table-column label="字典值" prop="dictValue" align="center"></el-table-column> <el-table-column label="字典值" prop="dictValue" align="center"></el-table-column>
<el-table-column label="排序" prop="sort" align="center"></el-table-column> <el-table-column label="排序" prop="sort" align="center"></el-table-column>
@@ -153,7 +157,6 @@ export default {
// 字典数据相关 // 字典数据相关
dictDataList: [], dictDataList: [],
dictDataLoading: false, dictDataLoading: false,
selectedDictData: [],
isAllDictDataSelected: false, isAllDictDataSelected: false,
dictDataDialogVisible: false, dictDataDialogVisible: false,
dictDataDialogTitle: '新增字典数据', dictDataDialogTitle: '新增字典数据',
@@ -265,7 +268,10 @@ export default {
dictValue: '' dictValue: ''
}, ({ data }) => { }, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.dictDataList = data.data.list this.dictDataList = data.data.list.map(item => ({
...item,
selected: false
}))
this.total = data.data.total this.total = data.data.total
} else { } else {
this.$message.error(data.msg || '获取字典数据失败') this.$message.error(data.msg || '获取字典数据失败')
@@ -273,16 +279,11 @@ export default {
this.dictDataLoading = false this.dictDataLoading = false
}) })
}, },
handleDictDataSelectionChange(val) {
this.selectedDictData = val
this.isAllDictDataSelected = val.length === this.dictDataList.length
},
selectAllDictData() { selectAllDictData() {
if (this.isAllDictDataSelected) { this.isAllDictDataSelected = !this.isAllDictDataSelected
this.$refs.dictDataTable.clearSelection() this.dictDataList.forEach(row => {
} else { row.selected = this.isAllDictDataSelected
this.$refs.dictDataTable.toggleAllSelection() })
}
}, },
showAddDictDataDialog() { showAddDictDataDialog() {
if (!this.selectedDictType) { if (!this.selectedDictType) {
@@ -329,17 +330,18 @@ export default {
}) })
}, },
batchDeleteDictData() { batchDeleteDictData() {
if (this.selectedDictData.length === 0) { const selectedRows = this.dictDataList.filter(row => row.selected)
if (selectedRows.length === 0) {
this.$message.warning('请选择要删除的字典数据') this.$message.warning('请选择要删除的字典数据')
return return
} }
this.$confirm('确定要删除选中的字典数据吗?', '提示', { this.$confirm(`确定要删除选中的${selectedRows.length}字典数据吗?`, '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
const ids = this.selectedDictData.map(item => item.id) const ids = selectedRows.map(item => item.id)
dictApi.deleteDictData(ids, ({ data }) => { dictApi.deleteDictData(ids, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success('删除成功') this.$message.success('删除成功')
@@ -832,4 +834,18 @@ export default {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
} }
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
</style> </style>
+6 -7
View File
@@ -318,7 +318,6 @@ export default {
<style scoped> <style scoped>
.welcome { .welcome {
min-width: 900px; min-width: 900px;
min-height: 506px;
height: 100vh; height: 100vh;
display: flex; display: flex;
position: relative; position: relative;
@@ -334,7 +333,7 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 24px; padding: 1.5vh 24px;
} }
.page-title { .page-title {
@@ -344,7 +343,7 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 1vh 22px;
border-radius: 15px; border-radius: 15px;
height: calc(100vh - 24vh); height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
@@ -416,7 +415,7 @@ export default {
} }
.form-content { .form-content {
padding: 20px 0; padding: 2vh 0;
} }
.form-grid { .form-grid {
@@ -450,11 +449,11 @@ export default {
} }
.template-item { .template-item {
height: 37px; height: 4vh;
width: 76px; width: 76px;
border-radius: 8px; border-radius: 8px;
background: #e6ebff; background: #e6ebff;
line-height: 37px; line-height: 4vh;
font-weight: 400; font-weight: 400;
font-size: 11px; font-size: 11px;
text-align: center; text-align: center;
@@ -471,7 +470,7 @@ export default {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 20px; margin-top: 2vh;
align-items: center; align-items: center;
} }
+1 -1
View File
@@ -4,7 +4,7 @@ from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
from config.settings import check_config_file from config.settings import check_config_file
SERVER_VERSION = "0.4.1" SERVER_VERSION = "0.4.2"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
+16 -8
View File
@@ -138,6 +138,8 @@ class ConnectionHandler:
int(self.config.get("close_connection_no_voice_time", 120)) + 60 int(self.config.get("close_connection_no_voice_time", 120)) + 60
) # 在原来第一道关闭的基础上加60秒,进行二道关闭 ) # 在原来第一道关闭的基础上加60秒,进行二道关闭
self.audio_format = "opus"
async def handle_connection(self, ws): async def handle_connection(self, ws):
try: try:
# 获取并验证headers # 获取并验证headers
@@ -241,9 +243,12 @@ class ConnectionHandler:
def _initialize_components(self): def _initialize_components(self):
"""初始化组件""" """初始化组件"""
self.prompt = self.config["prompt"] if self.config.get("prompt") is not None:
self.change_system_prompt(self.prompt) self.prompt = self.config["prompt"]
self.logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {self.prompt[:50]}...") self.change_system_prompt(self.prompt)
self.logger.bind(tag=TAG).info(
f"初始化组件: prompt成功 {self.prompt[:50]}..."
)
"""初始化本地组件""" """初始化本地组件"""
if self.vad is None: if self.vad is None:
@@ -811,9 +816,12 @@ class ConnectionHandler:
f"TTS生成:文件路径: {tts_file}" f"TTS生成:文件路径: {tts_file}"
) )
if os.path.exists(tts_file): if os.path.exists(tts_file):
opus_datas, _ = self.tts.audio_to_opus_data(tts_file) if self.audio_format == "pcm":
audio_datas, _ = self.tts.audio_to_pcm_data(tts_file)
else:
audio_datas, _ = self.tts.audio_to_opus_data(tts_file)
# 在这里上报TTS数据(使用文件路径) # 在这里上报TTS数据(使用文件路径)
enqueue_tts_report(self, 2, text, opus_datas) enqueue_tts_report(self, 2, text, audio_datas)
else: else:
self.logger.bind(tag=TAG).error( self.logger.bind(tag=TAG).error(
f"TTS出错:文件不存在{tts_file}" f"TTS出错:文件不存在{tts_file}"
@@ -824,7 +832,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"TTS出错: {e}") self.logger.bind(tag=TAG).error(f"TTS出错: {e}")
if not self.client_abort: if not self.client_abort:
# 如果没有中途打断就发送语音 # 如果没有中途打断就发送语音
self.audio_play_queue.put((opus_datas, text, text_index)) self.audio_play_queue.put((audio_datas, text, text_index))
if ( if (
self.tts.delete_audio_file self.tts.delete_audio_file
and tts_file is not None and tts_file is not None
@@ -855,13 +863,13 @@ class ConnectionHandler:
text = None text = None
try: try:
try: try:
opus_datas, text, text_index = self.audio_play_queue.get(timeout=1) audio_datas, text, text_index = self.audio_play_queue.get(timeout=1)
except queue.Empty: except queue.Empty:
if self.stop_event.is_set(): if self.stop_event.is_set():
break break
continue continue
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
sendAudioMessage(self, opus_datas, text, text_index), self.loop sendAudioMessage(self, audio_datas, text, text_index), self.loop
) )
future.result() future.result()
except Exception as e: except Exception as e:
+10 -2
View File
@@ -1,5 +1,4 @@
import json import json
from config.logger import setup_logging
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
import shutil import shutil
@@ -20,7 +19,16 @@ WAKEUP_CONFIG = {
} }
async def handleHelloMessage(conn): async def handleHelloMessage(conn, msg_json):
"""处理hello消息"""
audio_params = msg_json.get("audio_params")
if audio_params:
format = audio_params.get("format")
conn.logger.bind(tag=TAG).info(f"客户端音频格式: {format}")
conn.audio_format = format
conn.asr.set_audio_format(format)
conn.welcome_msg["audio_params"] = audio_params
await conn.websocket.send(json.dumps(conn.welcome_msg)) await conn.websocket.send(json.dumps(conn.welcome_msg))
@@ -5,7 +5,7 @@ from core.handle.sendAudioHandle import send_stt_message
from core.handle.intentHandler import handle_user_intent from core.handle.intentHandler import handle_user_intent
from core.utils.output_counter import check_device_output_limit from core.utils.output_counter import check_device_output_limit
from core.handle.ttsReportHandle import enqueue_tts_report from core.handle.ttsReportHandle import enqueue_tts_report
from core.providers.tts.base import audio_to_opus_data from core.utils.util import audio_to_data
TAG = __name__ TAG = __name__
@@ -111,7 +111,7 @@ async def max_out_size(conn):
conn.tts_last_text_index = 0 conn.tts_last_text_index = 0
conn.llm_finish_task = True conn.llm_finish_task = True
file_path = "config/assets/max_output_size.wav" file_path = "config/assets/max_output_size.wav"
opus_packets, _ = audio_to_opus_data(file_path) opus_packets, _ = audio_to_data(file_path)
conn.audio_play_queue.put((opus_packets, text, 0)) conn.audio_play_queue.put((opus_packets, text, 0))
conn.close_after_chat = True conn.close_after_chat = True
@@ -133,7 +133,7 @@ async def check_bind_device(conn):
# 播放提示音 # 播放提示音
music_path = "config/assets/bind_code.wav" music_path = "config/assets/bind_code.wav"
opus_packets, _ = audio_to_opus_data(music_path) opus_packets, _ = audio_to_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0)) conn.audio_play_queue.put((opus_packets, text, 0))
# 逐个播放数字 # 逐个播放数字
@@ -141,7 +141,7 @@ async def check_bind_device(conn):
try: try:
digit = conn.bind_code[i] digit = conn.bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav" num_path = f"config/assets/bind_code/{digit}.wav"
num_packets, _ = audio_to_opus_data(num_path) num_packets, _ = audio_to_data(num_path)
conn.audio_play_queue.put((num_packets, None, i + 1)) conn.audio_play_queue.put((num_packets, None, i + 1))
except Exception as e: except Exception as e:
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}") conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
@@ -153,5 +153,5 @@ async def check_bind_device(conn):
conn.tts_last_text_index = 0 conn.tts_last_text_index = 0
conn.llm_finish_task = True conn.llm_finish_task = True
music_path = "config/assets/bind_not_found.wav" music_path = "config/assets/bind_not_found.wav"
opus_packets, _ = audio_to_opus_data(music_path) opus_packets, _ = audio_to_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0)) conn.audio_play_queue.put((opus_packets, text, 0))
@@ -115,7 +115,7 @@ async def send_tts_message(conn, state, text=None):
stop_tts_notify_voice = conn.config.get( stop_tts_notify_voice = conn.config.get(
"stop_tts_notify_voice", "config/assets/tts_notify.mp3" "stop_tts_notify_voice", "config/assets/tts_notify.mp3"
) )
audios, duration = conn.tts.audio_to_opus_data(stop_tts_notify_voice) audios, _ = conn.tts.audio_to_opus_data(stop_tts_notify_voice)
await sendAudio(conn, audios) await sendAudio(conn, audios)
# 清除服务端讲话状态 # 清除服务端讲话状态
conn.clearSpeakStatus() conn.clearSpeakStatus()
@@ -20,7 +20,7 @@ async def handleTextMessage(conn, message):
await conn.websocket.send(message) await conn.websocket.send(message)
return return
if msg_json["type"] == "hello": if msg_json["type"] == "hello":
await handleHelloMessage(conn) await handleHelloMessage(conn, msg_json)
elif msg_json["type"] == "abort": elif msg_json["type"] == "abort":
await handleAbortMessage(conn) await handleAbortMessage(conn)
elif msg_json["type"] == "listen": elif msg_json["type"] == "listen":
@@ -20,64 +20,78 @@ from core.providers.asr.base import ASRProviderBase
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class AccessToken: class AccessToken:
@staticmethod @staticmethod
def _encode_text(text): def _encode_text(text):
encoded_text = parse.quote_plus(text) encoded_text = parse.quote_plus(text)
return encoded_text.replace('+', '%20').replace('*', '%2A').replace('%7E', '~') return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
@staticmethod @staticmethod
def _encode_dict(dic): def _encode_dict(dic):
keys = dic.keys() keys = dic.keys()
dic_sorted = [(key, dic[key]) for key in sorted(keys)] dic_sorted = [(key, dic[key]) for key in sorted(keys)]
encoded_text = parse.urlencode(dic_sorted) encoded_text = parse.urlencode(dic_sorted)
return encoded_text.replace('+', '%20').replace('*', '%2A').replace('%7E', '~') return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
@staticmethod @staticmethod
def create_token(access_key_id, access_key_secret): def create_token(access_key_id, access_key_secret):
parameters = {'AccessKeyId': access_key_id, parameters = {
'Action': 'CreateToken', "AccessKeyId": access_key_id,
'Format': 'JSON', "Action": "CreateToken",
'RegionId': 'cn-shanghai', "Format": "JSON",
'SignatureMethod': 'HMAC-SHA1', "RegionId": "cn-shanghai",
'SignatureNonce': str(uuid.uuid1()), "SignatureMethod": "HMAC-SHA1",
'SignatureVersion': '1.0', "SignatureNonce": str(uuid.uuid1()),
'Timestamp': time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "SignatureVersion": "1.0",
'Version': '2019-02-28'} "Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"Version": "2019-02-28",
}
# 构造规范化的请求字符串 # 构造规范化的请求字符串
query_string = AccessToken._encode_dict(parameters) query_string = AccessToken._encode_dict(parameters)
# print('规范化的请求字符串: %s' % query_string) # print('规范化的请求字符串: %s' % query_string)
# 构造待签名字符串 # 构造待签名字符串
string_to_sign = 'GET' + '&' + AccessToken._encode_text('/') + '&' + AccessToken._encode_text(query_string) string_to_sign = (
"GET"
+ "&"
+ AccessToken._encode_text("/")
+ "&"
+ AccessToken._encode_text(query_string)
)
# print('待签名的字符串: %s' % string_to_sign) # print('待签名的字符串: %s' % string_to_sign)
# 计算签名 # 计算签名
secreted_string = hmac.new(bytes(access_key_secret + '&', encoding='utf-8'), secreted_string = hmac.new(
bytes(string_to_sign, encoding='utf-8'), bytes(access_key_secret + "&", encoding="utf-8"),
hashlib.sha1).digest() bytes(string_to_sign, encoding="utf-8"),
hashlib.sha1,
).digest()
signature = base64.b64encode(secreted_string) signature = base64.b64encode(secreted_string)
# print('签名: %s' % signature) # print('签名: %s' % signature)
# 进行URL编码 # 进行URL编码
signature = AccessToken._encode_text(signature) signature = AccessToken._encode_text(signature)
# print('URL编码后的签名: %s' % signature) # print('URL编码后的签名: %s' % signature)
# 调用服务 # 调用服务
full_url = 'http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s' % (signature, query_string) full_url = "http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s" % (
signature,
query_string,
)
# print('url: %s' % full_url) # print('url: %s' % full_url)
# 提交HTTP GET请求 # 提交HTTP GET请求
response = requests.get(full_url) response = requests.get(full_url)
if response.ok: if response.ok:
root_obj = response.json() root_obj = response.json()
key = 'Token' key = "Token"
if key in root_obj: if key in root_obj:
token = root_obj[key]['Id'] token = root_obj[key]["Id"]
expire_time = root_obj[key]['ExpireTime'] expire_time = root_obj[key]["ExpireTime"]
return token, expire_time return token, expire_time
# print(response.text) # print(response.text)
return None, None return None, None
class ASRProvider(ASRProviderBase): class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool): def __init__(self, config: dict, delete_audio_file: bool):
super().__init__()
"""阿里云ASR初始化""" """阿里云ASR初始化"""
# 新增空值判断逻辑 # 新增空值判断逻辑
self.access_key_id = config.get("access_key_id") self.access_key_id = config.get("access_key_id")
@@ -102,28 +116,23 @@ class ASRProvider(ASRProviderBase):
# 确保输出目录存在 # 确保输出目录存在
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
def _refresh_token(self): def _refresh_token(self):
"""刷新Token并记录过期时间""" """刷新Token并记录过期时间"""
if self.access_key_id and self.access_key_secret: if self.access_key_id and self.access_key_secret:
self.token, expire_time_str = AccessToken.create_token( self.token, expire_time_str = AccessToken.create_token(
self.access_key_id, self.access_key_id, self.access_key_secret
self.access_key_secret
) )
if not expire_time_str: if not expire_time_str:
raise ValueError("无法获取有效的Token过期时间") raise ValueError("无法获取有效的Token过期时间")
try: try:
#统一转换为字符串处理 # 统一转换为字符串处理
expire_str = str(expire_time_str).strip() expire_str = str(expire_time_str).strip()
if expire_str.isdigit(): if expire_str.isdigit():
expire_time = datetime.fromtimestamp(int(expire_str)) expire_time = datetime.fromtimestamp(int(expire_str))
else: else:
expire_time = datetime.strptime( expire_time = datetime.strptime(expire_str, "%Y-%m-%dT%H:%M:%SZ")
expire_str,
"%Y-%m-%dT%H:%M:%SZ"
)
self.expire_time = expire_time.timestamp() - 60 self.expire_time = expire_time.timestamp() - 60
except Exception as e: except Exception as e:
raise ValueError(f"无效的过期时间格式: {expire_str}") from e raise ValueError(f"无效的过期时间格式: {expire_str}") from e
@@ -145,9 +154,12 @@ class ASRProvider(ASRProviderBase):
# f"过期时间 {datetime.fromtimestamp(self.expire_time)} | " # f"过期时间 {datetime.fromtimestamp(self.expire_time)} | "
# f"剩余 {remaining:.2f}秒") # f"剩余 {remaining:.2f}秒")
return time.time() > self.expire_time return time.time() > self.expire_time
def generate_filename(self, extension=".wav"):
return os.path.join(self.output_file, f"tts-{__name__}{datetime.now().date()}@{uuid.uuid4().hex}{extension}")
def generate_filename(self, extension=".wav"):
return os.path.join(
self.output_file,
f"tts-{__name__}{datetime.now().date()}@{uuid.uuid4().hex}{extension}",
)
def _construct_request_url(self) -> str: def _construct_request_url(self) -> str:
"""构造请求URL,包含参数""" """构造请求URL,包含参数"""
@@ -159,20 +171,6 @@ class ASRProvider(ASRProviderBase):
request += "&enable_voice_detection=false" request += "&enable_voice_detection=false"
return request return request
def decode_opus(self, opus_data: List[bytes], session_id: str) -> List[bytes]:
"""将Opus数据解码为PCM"""
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
"""PCM数据保存为WAV文件""" """PCM数据保存为WAV文件"""
module_name = __name__.split(".")[-1] module_name = __name__.split(".")[-1]
@@ -183,7 +181,7 @@ class ASRProvider(ASRProviderBase):
wf.setnchannels(1) # 单声道 wf.setnchannels(1) # 单声道
wf.setsampwidth(2) # 16-bit wf.setsampwidth(2) # 16-bit
wf.setframerate(self.sample_rate) wf.setframerate(self.sample_rate)
wf.writeframes(b''.join(pcm_data)) wf.writeframes(b"".join(pcm_data))
logger.bind(tag=TAG).debug(f"音频文件已保存至: {file_path}") logger.bind(tag=TAG).debug(f"音频文件已保存至: {file_path}")
return file_path return file_path
@@ -193,9 +191,9 @@ class ASRProvider(ASRProviderBase):
try: try:
# 设置HTTP头 # 设置HTTP头
headers = { headers = {
'X-NLS-Token': self.token, "X-NLS-Token": self.token,
'Content-type': 'application/octet-stream', "Content-type": "application/octet-stream",
'Content-Length': str(len(pcm_data)) "Content-Length": str(len(pcm_data)),
} }
# 创建连接并发送请求 # 创建连接并发送请求
@@ -203,12 +201,12 @@ class ASRProvider(ASRProviderBase):
request_url = self._construct_request_url() request_url = self._construct_request_url()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: conn.request( await loop.run_in_executor(
method='POST', None,
url=request_url, lambda: conn.request(
body=pcm_data, method="POST", url=request_url, body=pcm_data, headers=headers
headers=headers ),
)) )
# 获取响应 # 获取响应
response = await loop.run_in_executor(None, conn.getresponse) response = await loop.run_in_executor(None, conn.getresponse)
@@ -218,10 +216,10 @@ class ASRProvider(ASRProviderBase):
# 解析响应 # 解析响应
try: try:
body_json = json.loads(body) body_json = json.loads(body)
status = body_json.get('status') status = body_json.get("status")
if status == 20000000: if status == 20000000:
result = body_json.get('result', '') result = body_json.get("result", "")
logger.bind(tag=TAG).debug(f"ASR结果: {result}") logger.bind(tag=TAG).debug(f"ASR结果: {result}")
return result return result
else: else:
@@ -236,7 +234,9 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(f"ASR请求失败: {e}", exc_info=True) logger.bind(tag=TAG).error(f"ASR请求失败: {e}", exc_info=True)
return None return None
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]: async def speech_to_text(
self, opus_data: List[bytes], session_id: str
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
if self._is_token_expired(): if self._is_token_expired():
logger.warning("Token已过期,正在自动刷新...") logger.warning("Token已过期,正在自动刷新...")
@@ -245,8 +245,11 @@ class ASRProvider(ASRProviderBase):
file_path = None file_path = None
try: try:
# 解码Opus为PCM # 解码Opus为PCM
pcm_data = self.decode_opus(opus_data, session_id) if self.audio_format == "pcm":
combined_pcm_data = b''.join(pcm_data) pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件 # 判断是否保存为WAV文件
if self.delete_audio_file: if self.delete_audio_file:
@@ -20,6 +20,7 @@ logger = setup_logging()
class ASRProvider(ASRProviderBase): class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool = True): def __init__(self, config: dict, delete_audio_file: bool = True):
super().__init__()
self.app_id = config.get("app_id") self.app_id = config.get("app_id")
self.api_key = config.get("api_key") self.api_key = config.get("api_key")
self.secret_key = config.get("secret_key") self.secret_key = config.get("secret_key")
@@ -49,22 +50,6 @@ class ASRProvider(ASRProviderBase):
return file_path return file_path
@staticmethod
def decode_opus(opus_data: List[bytes]) -> bytes:
"""将Opus音频数据解码为PCM数据"""
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
async def speech_to_text( async def speech_to_text(
self, opus_data: List[bytes], session_id: str self, opus_data: List[bytes], session_id: str
) -> Tuple[Optional[str], Optional[str]]: ) -> Tuple[Optional[str], Optional[str]]:
@@ -81,7 +66,10 @@ class ASRProvider(ASRProviderBase):
return None, file_path return None, file_path
# 将Opus音频数据解码为PCM # 将Opus音频数据解码为PCM
pcm_data = self.decode_opus(opus_data) if self.audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data) combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件 # 判断是否保存为WAV文件
+27 -2
View File
@@ -1,6 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
import opuslib_next
from config.logger import setup_logging from config.logger import setup_logging
TAG = __name__ TAG = __name__
@@ -8,12 +8,37 @@ logger = setup_logging()
class ASRProviderBase(ABC): class ASRProviderBase(ABC):
def __init__(self):
self.audio_format = "opus"
@abstractmethod @abstractmethod
def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
"""PCM数据保存为WAV文件""" """PCM数据保存为WAV文件"""
pass pass
@abstractmethod @abstractmethod
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]: async def speech_to_text(
self, opus_data: List[bytes], session_id: str
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
pass pass
def set_audio_format(self, format: str) -> None:
"""设置音频格式"""
self.audio_format = format
@staticmethod
def decode_opus(opus_data: List[bytes]) -> bytes:
"""将Opus音频数据解码为PCM数据"""
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
@@ -85,11 +85,12 @@ def parse_response(res):
class ASRProvider(ASRProviderBase): class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool): def __init__(self, config: dict, delete_audio_file: bool):
super().__init__()
self.appid = config.get("appid") self.appid = config.get("appid")
self.cluster = config.get("cluster") self.cluster = config.get("cluster")
self.access_token = config.get("access_token") self.access_token = config.get("access_token")
self.boosting_table_name = config.get("boosting_table_name") self.boosting_table_name = config.get("boosting_table_name", "")
self.correct_table_name = config.get("correct_table_name") self.correct_table_name = config.get("correct_table_name", "")
self.output_dir = config.get("output_dir") self.output_dir = config.get("output_dir")
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
@@ -226,21 +227,6 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(f"ASR request failed: {e}", exc_info=True) logger.bind(tag=TAG).error(f"ASR request failed: {e}", exc_info=True)
return None return None
@staticmethod
def decode_opus(opus_data: List[bytes], session_id: str) -> List[bytes]:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
@staticmethod @staticmethod
def slice_data(data: bytes, chunk_size: int) -> (list, bool): def slice_data(data: bytes, chunk_size: int) -> (list, bool):
""" """
@@ -265,7 +251,10 @@ class ASRProvider(ASRProviderBase):
file_path = None file_path = None
try: try:
# 合并所有opus数据包 # 合并所有opus数据包
pcm_data = self.decode_opus(opus_data, session_id) if self.audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data) combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件 # 判断是否保存为WAV文件
@@ -6,9 +6,7 @@ import io
from config.logger import setup_logging from config.logger import setup_logging
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
import uuid import uuid
import opuslib_next
from core.providers.asr.base import ASRProviderBase from core.providers.asr.base import ASRProviderBase
from funasr import AutoModel from funasr import AutoModel
from funasr.utils.postprocess_utils import rich_transcription_postprocess from funasr.utils.postprocess_utils import rich_transcription_postprocess
@@ -35,6 +33,7 @@ class CaptureOutput:
class ASRProvider(ASRProviderBase): class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool): def __init__(self, config: dict, delete_audio_file: bool):
super().__init__()
self.model_dir = config.get("model_dir") self.model_dir = config.get("model_dir")
self.output_dir = config.get("output_dir") # 修正配置键名 self.output_dir = config.get("output_dir") # 修正配置键名
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
@@ -46,7 +45,7 @@ class ASRProvider(ASRProviderBase):
model=self.model_dir, model=self.model_dir,
vad_kwargs={"max_single_segment_time": 30000}, vad_kwargs={"max_single_segment_time": 30000},
disable_update=True, disable_update=True,
hub="hf" hub="hf",
# device="cuda:0", # 启用GPU加速 # device="cuda:0", # 启用GPU加速
) )
@@ -64,27 +63,18 @@ class ASRProvider(ASRProviderBase):
return file_path return file_path
@staticmethod async def speech_to_text(
def decode_opus(opus_data: List[bytes], session_id: str) -> List[bytes]: self, opus_data: List[bytes], session_id: str
) -> Tuple[Optional[str], Optional[str]]:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
"""语音转文本主处理逻辑""" """语音转文本主处理逻辑"""
file_path = None file_path = None
try: try:
# 合并所有opus数据包 # 合并所有opus数据包
pcm_data = self.decode_opus(opus_data, session_id) if self.audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data) combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件 # 判断是否保存为WAV文件
@@ -103,7 +93,9 @@ class ASRProvider(ASRProviderBase):
batch_size_s=60, batch_size_s=60,
) )
text = rich_transcription_postprocess(result[0]["text"]) text = rich_transcription_postprocess(result[0]["text"])
logger.bind(tag=TAG).debug(f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}") logger.bind(tag=TAG).debug(
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
)
return text, file_path return text, file_path
@@ -9,22 +9,29 @@ import wave
import websockets import websockets
from config.logger import setup_logging from config.logger import setup_logging
import asyncio import asyncio
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class ASRProvider(ASRProviderBase): class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool): def __init__(self, config: dict, delete_audio_file: bool):
''' """
Initialize the ASRProvider with server configuration. Initialize the ASRProvider with server configuration.
:param config: Dictionary containing 'host', 'port', and 'is_ssl'. :param config: Dictionary containing 'host', 'port', and 'is_ssl'.
:param delete_audio_file: Boolean to indicate whether to delete audio files after processing. :param delete_audio_file: Boolean to indicate whether to delete audio files after processing.
''' """
self.host = config.get('host', 'localhost') super().__init__()
self.port = config.get('port', 10095) self.host = config.get("host", "localhost")
self.is_ssl = config.get('is_ssl', True) self.port = config.get("port", 10095)
self.is_ssl = config.get("is_ssl", True)
self.output_dir = config.get("output_dir") self.output_dir = config.get("output_dir")
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
self.uri = f"wss://{self.host}:{self.port}" if self.is_ssl else f"ws://{self.host}:{self.port}" self.uri = (
f"wss://{self.host}:{self.port}"
if self.is_ssl
else f"ws://{self.host}:{self.port}"
)
self.ssl_context = ssl.SSLContext() if self.is_ssl else None self.ssl_context = ssl.SSLContext() if self.is_ssl else None
if self.ssl_context: if self.ssl_context:
self.ssl_context.check_hostname = False self.ssl_context.check_hostname = False
@@ -44,28 +51,11 @@ class ASRProvider(ASRProviderBase):
return file_path return file_path
@staticmethod
def decode_opus(opus_data: List[bytes]) -> bytes:
"""将Opus音频数据解码为PCM数据"""
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
async def _receive_responses(self, ws) -> None: async def _receive_responses(self, ws) -> None:
''' """
Asynchronous generator to receive messages from the WebSocket. Asynchronous generator to receive messages from the WebSocket.
Yields each message as it is received. Yields each message as it is received.
''' """
text = "" text = ""
while True: while True:
try: try:
@@ -78,30 +68,35 @@ class ASRProvider(ASRProviderBase):
else: else:
text += response_data.get("text", "") text += response_data.get("text", "")
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.bind(tag=TAG).error("Timeout while waiting for response from WebSocket.") logger.bind(tag=TAG).error(
"Timeout while waiting for response from WebSocket."
)
break break
except websockets.exceptions.ConnectionClosed as e: except websockets.exceptions.ConnectionClosed as e:
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}") logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
break break
return text return text
async def _send_data(self, ws, pcm_data: bytes, session_id: str) -> tuple: async def _send_data(self, ws, pcm_data: bytes, session_id: str) -> tuple:
''' """
Internal method to handle WebSocket communication. Internal method to handle WebSocket communication.
Reuses the persistent WebSocket connection if available. Reuses the persistent WebSocket connection if available.
:param pcm_data: PCM audio data to send. :param pcm_data: PCM audio data to send.
:param session_id: Unique session identifier. :param session_id: Unique session identifier.
:return: Tuple containing recognized text and optional timestamp. :return: Tuple containing recognized text and optional timestamp.
''' """
# Send initial configuration message # Send initial configuration message
config_message = json.dumps({ config_message = json.dumps(
"mode": "offline", {
"chunk_size": [5, 10, 5], "mode": "offline",
"chunk_interval": 10, "chunk_size": [5, 10, 5],
"wav_name": session_id, "chunk_interval": 10,
"is_speaking": True, "wav_name": session_id,
"itn": False "is_speaking": True,
}) "itn": False,
}
)
await ws.send(config_message) await ws.send(config_message)
logger.bind(tag=TAG).debug(f"Sent configuration message: {config_message}") logger.bind(tag=TAG).debug(f"Sent configuration message: {config_message}")
@@ -114,16 +109,20 @@ class ASRProvider(ASRProviderBase):
await ws.send(end_message) await ws.send(end_message)
logger.bind(tag=TAG).debug(f"Sent end message: {end_message}") logger.bind(tag=TAG).debug(f"Sent end message: {end_message}")
async def speech_to_text(
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]: self, opus_data: List[bytes], session_id: str
''' ) -> Tuple[Optional[str], Optional[str]]:
"""
Convert speech data to text using FunASR. Convert speech data to text using FunASR.
:param opus_data: List of Opus-encoded audio data chunks. :param opus_data: List of Opus-encoded audio data chunks.
:param session_id: Unique session identifier. :param session_id: Unique session identifier.
:return: Tuple containing recognized text and optional timestamp. :return: Tuple containing recognized text and optional timestamp.
''' """
file_path = None file_path = None
pcm_data = self.decode_opus(opus_data) if self.audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data) combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件 # 判断是否保存为WAV文件
@@ -132,16 +131,19 @@ class ASRProvider(ASRProviderBase):
else: else:
file_path = self.save_audio_to_file(pcm_data, session_id) file_path = self.save_audio_to_file(pcm_data, session_id)
async with websockets.connect(self.uri, subprotocols=["binary"], ping_interval=None, ssl=self.ssl_context) as ws: async with websockets.connect(
self.uri, subprotocols=["binary"], ping_interval=None, ssl=self.ssl_context
) as ws:
try: try:
# Use asyncio to handle WebSocket communication # Use asyncio to handle WebSocket communication
send_task = asyncio.create_task(self._send_data(ws, combined_pcm_data, session_id)) send_task = asyncio.create_task(
self._send_data(ws, combined_pcm_data, session_id)
)
receive_task = asyncio.create_task(self._receive_responses(ws)) receive_task = asyncio.create_task(self._receive_responses(ws))
# Gather tasks with error handling # Gather tasks with error handling
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
[send_task, receive_task], [send_task, receive_task], return_when=asyncio.FIRST_EXCEPTION
return_when=asyncio.FIRST_EXCEPTION
) )
# Cancel any pending tasks # Cancel any pending tasks
@@ -155,11 +157,16 @@ class ASRProvider(ASRProviderBase):
# Get the result from the receive task # Get the result from the receive task
result = receive_task.result() result = receive_task.result()
return result, file_path # Return the recognized text and timestamp (if any) return (
result,
file_path,
) # Return the recognized text and timestamp (if any)
except websockets.exceptions.ConnectionClosed as e: except websockets.exceptions.ConnectionClosed as e:
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}") logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
return "", file_path return "", file_path
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error during speech-to-text conversion: {e}", exc_info=True) logger.bind(tag=TAG).error(
f"Error during speech-to-text conversion: {e}", exc_info=True
)
return "", file_path return "", file_path
@@ -37,6 +37,7 @@ class CaptureOutput:
class ASRProvider(ASRProviderBase): class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool): def __init__(self, config: dict, delete_audio_file: bool):
super().__init__()
self.model_dir = config.get("model_dir") self.model_dir = config.get("model_dir")
self.output_dir = config.get("output_dir") self.output_dir = config.get("output_dir")
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
@@ -97,21 +98,6 @@ class ASRProvider(ASRProviderBase):
return file_path return file_path
@staticmethod
def decode_opus(opus_data: List[bytes], session_id: str) -> List[bytes]:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
def read_wave(self, wave_filename: str) -> Tuple[np.ndarray, int]: def read_wave(self, wave_filename: str) -> Tuple[np.ndarray, int]:
""" """
Args: Args:
@@ -144,7 +130,10 @@ class ASRProvider(ASRProviderBase):
try: try:
# 保存音频文件 # 保存音频文件
start_time = time.time() start_time = time.time()
pcm_data = self.decode_opus(opus_data, session_id) if self.audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
file_path = self.save_audio_to_file(pcm_data, session_id) file_path = self.save_audio_to_file(pcm_data, session_id)
logger.bind(tag=TAG).debug( logger.bind(tag=TAG).debug(
f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}" f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}"
@@ -17,12 +17,14 @@ from config.logger import setup_logging
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class ASRProvider(ASRProviderBase): class ASRProvider(ASRProviderBase):
API_URL = "https://asr.tencentcloudapi.com" API_URL = "https://asr.tencentcloudapi.com"
API_VERSION = "2019-06-14" API_VERSION = "2019-06-14"
FORMAT = "pcm" # 支持的音频格式:pcm, wav, mp3 FORMAT = "pcm" # 支持的音频格式:pcm, wav, mp3
def __init__(self, config: dict, delete_audio_file: bool = True): def __init__(self, config: dict, delete_audio_file: bool = True):
super().__init__()
self.secret_id = config.get("secret_id") self.secret_id = config.get("secret_id")
self.secret_key = config.get("secret_key") self.secret_key = config.get("secret_key")
self.output_dir = config.get("output_dir") self.output_dir = config.get("output_dir")
@@ -45,23 +47,9 @@ class ASRProvider(ASRProviderBase):
return file_path return file_path
@staticmethod async def speech_to_text(
def decode_opus(opus_data: List[bytes]) -> bytes: self, opus_data: List[bytes], session_id: str
"""将Opus音频数据解码为PCM数据""" ) -> Tuple[Optional[str], Optional[str]]:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
if not opus_data: if not opus_data:
logger.bind(tag=TAG).warn("音频数据为空!") logger.bind(tag=TAG).warn("音频数据为空!")
@@ -75,7 +63,10 @@ class ASRProvider(ASRProviderBase):
return None, file_path return None, file_path
# 将Opus音频数据解码为PCM # 将Opus音频数据解码为PCM
pcm_data = self.decode_opus(opus_data) if self.audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data) combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件 # 判断是否保存为WAV文件
@@ -85,7 +76,7 @@ class ASRProvider(ASRProviderBase):
self.save_audio_to_file(pcm_data, session_id) self.save_audio_to_file(pcm_data, session_id)
# 将音频数据转换为Base64编码 # 将音频数据转换为Base64编码
base64_audio = base64.b64encode(combined_pcm_data).decode('utf-8') base64_audio = base64.b64encode(combined_pcm_data).decode("utf-8")
# 构建请求体 # 构建请求体
request_body = self._build_request_body(base64_audio) request_body = self._build_request_body(base64_audio)
@@ -98,7 +89,9 @@ class ASRProvider(ASRProviderBase):
result = self._send_request(request_body, timestamp, authorization) result = self._send_request(request_body, timestamp, authorization)
if result: if result:
logger.bind(tag=TAG).debug(f"腾讯云语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}") logger.bind(tag=TAG).debug(
f"腾讯云语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}"
)
return result, file_path return result, file_path
@@ -115,7 +108,7 @@ class ASRProvider(ASRProviderBase):
"SourceType": 1, # 音频数据来源为语音文件 "SourceType": 1, # 音频数据来源为语音文件
"VoiceFormat": self.FORMAT, # 音频格式 "VoiceFormat": self.FORMAT, # 音频格式
"Data": base64_audio, # Base64编码的音频数据 "Data": base64_audio, # Base64编码的音频数据
"DataLen": len(base64_audio) # 数据长度 "DataLen": len(base64_audio), # 数据长度
} }
return json.dumps(request_map) return json.dumps(request_map)
@@ -148,9 +141,11 @@ class ASRProvider(ASRProviderBase):
action = "SentenceRecognition" # 接口名称 action = "SentenceRecognition" # 接口名称
# 构建规范头部信息,注意顺序和格式 # 构建规范头部信息,注意顺序和格式
canonical_headers = f"content-type:{content_type.lower()}\n" + \ canonical_headers = (
f"host:{host.lower()}\n" + \ f"content-type:{content_type.lower()}\n"
f"x-tc-action:{action.lower()}\n" + f"host:{host.lower()}\n"
+ f"x-tc-action:{action.lower()}\n"
)
signed_headers = "content-type;host;x-tc-action" signed_headers = "content-type;host;x-tc-action"
@@ -158,21 +153,25 @@ class ASRProvider(ASRProviderBase):
payload_hash = self._sha256_hex(request_body) payload_hash = self._sha256_hex(request_body)
# 构建规范请求字符串 # 构建规范请求字符串
canonical_request = f"{http_request_method}\n" + \ canonical_request = (
f"{canonical_uri}\n" + \ f"{http_request_method}\n"
f"{canonical_query_string}\n" + \ + f"{canonical_uri}\n"
f"{canonical_headers}\n" + \ + f"{canonical_query_string}\n"
f"{signed_headers}\n" + \ + f"{canonical_headers}\n"
f"{payload_hash}" + f"{signed_headers}\n"
+ f"{payload_hash}"
)
# 计算规范请求的哈希值 # 计算规范请求的哈希值
hashed_canonical_request = self._sha256_hex(canonical_request) hashed_canonical_request = self._sha256_hex(canonical_request)
# 构建待签名字符串 # 构建待签名字符串
string_to_sign = f"{algorithm}\n" + \ string_to_sign = (
f"{timestamp}\n" + \ f"{algorithm}\n"
f"{credential_scope}\n" + \ + f"{timestamp}\n"
f"{hashed_canonical_request}" + f"{credential_scope}\n"
+ f"{hashed_canonical_request}"
)
# 计算签名密钥 # 计算签名密钥
secret_date = self._hmac_sha256(f"TC3{self.secret_key}", date) secret_date = self._hmac_sha256(f"TC3{self.secret_key}", date)
@@ -180,13 +179,17 @@ class ASRProvider(ASRProviderBase):
secret_signing = self._hmac_sha256(secret_service, "tc3_request") secret_signing = self._hmac_sha256(secret_service, "tc3_request")
# 计算签名 # 计算签名
signature = self._bytes_to_hex(self._hmac_sha256(secret_signing, string_to_sign)) signature = self._bytes_to_hex(
self._hmac_sha256(secret_signing, string_to_sign)
)
# 构建授权头 # 构建授权头
authorization = f"{algorithm} " + \ authorization = (
f"Credential={self.secret_id}/{credential_scope}, " + \ f"{algorithm} "
f"SignedHeaders={signed_headers}, " + \ + f"Credential={self.secret_id}/{credential_scope}, "
f"Signature={signature}" + f"SignedHeaders={signed_headers}, "
+ f"Signature={signature}"
)
return timestamp, authorization return timestamp, authorization
@@ -194,7 +197,9 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(f"生成认证头失败: {e}", exc_info=True) logger.bind(tag=TAG).error(f"生成认证头失败: {e}", exc_info=True)
raise RuntimeError(f"生成认证头失败: {e}") raise RuntimeError(f"生成认证头失败: {e}")
def _send_request(self, request_body: str, timestamp: str, authorization: str) -> Optional[str]: def _send_request(
self, request_body: str, timestamp: str, authorization: str
) -> Optional[str]:
"""发送请求到腾讯云API""" """发送请求到腾讯云API"""
headers = { headers = {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
@@ -203,7 +208,7 @@ class ASRProvider(ASRProviderBase):
"X-TC-Action": "SentenceRecognition", "X-TC-Action": "SentenceRecognition",
"X-TC-Version": self.API_VERSION, "X-TC-Version": self.API_VERSION,
"X-TC-Timestamp": timestamp, "X-TC-Timestamp": timestamp,
"X-TC-Region": "ap-shanghai" "X-TC-Region": "ap-shanghai",
} }
try: try:
@@ -234,16 +239,16 @@ class ASRProvider(ASRProviderBase):
def _sha256_hex(self, data: str) -> str: def _sha256_hex(self, data: str) -> str:
"""计算字符串的SHA256哈希值""" """计算字符串的SHA256哈希值"""
digest = hashlib.sha256(data.encode('utf-8')).digest() digest = hashlib.sha256(data.encode("utf-8")).digest()
return self._bytes_to_hex(digest) return self._bytes_to_hex(digest)
def _hmac_sha256(self, key, data: str) -> bytes: def _hmac_sha256(self, key, data: str) -> bytes:
"""计算HMAC-SHA256""" """计算HMAC-SHA256"""
if isinstance(key, str): if isinstance(key, str):
key = key.encode('utf-8') key = key.encode("utf-8")
return hmac.new(key, data.encode('utf-8'), hashlib.sha256).digest() return hmac.new(key, data.encode("utf-8"), hashlib.sha256).digest()
def _bytes_to_hex(self, bytes_data: bytes) -> str: def _bytes_to_hex(self, bytes_data: bytes) -> str:
"""字节数组转十六进制字符串""" """字节数组转十六进制字符串"""
return ''.join(f"{b:02x}" for b in bytes_data) return "".join(f"{b:02x}" for b in bytes_data)
@@ -3,7 +3,7 @@ from config.logger import setup_logging
import os import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from core.utils.tts import MarkdownCleaner from core.utils.tts import MarkdownCleaner
from core.utils.util import audio_to_opus_data from core.utils.util import audio_to_data
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -53,5 +53,10 @@ class TTSProviderBase(ABC):
async def text_to_speak(self, text, output_file): async def text_to_speak(self, text, output_file):
pass pass
def audio_to_pcm_data(self, audio_file_path):
"""音频文件转换为PCM编码"""
return audio_to_data(audio_file_path, is_opus=False)
def audio_to_opus_data(self, audio_file_path): def audio_to_opus_data(self, audio_file_path):
return audio_to_opus_data(audio_file_path) """音频文件转换为Opus编码"""
return audio_to_data(audio_file_path, is_opus=True)
+11 -9
View File
@@ -862,8 +862,7 @@ def analyze_emotion(text):
return top_emotions[0] # 如果都不在优先级列表里,返回第一个 return top_emotions[0] # 如果都不在优先级列表里,返回第一个
def audio_to_opus_data(audio_file_path): def audio_to_data(audio_file_path, is_opus=True):
"""音频文件转换为Opus编码"""
# 获取文件后缀名 # 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1] file_type = os.path.splitext(audio_file_path)[1]
if file_type: if file_type:
@@ -889,7 +888,7 @@ def audio_to_opus_data(audio_file_path):
frame_duration = 60 # 60ms per frame frame_duration = 60 # 60ms per frame
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
opus_datas = [] datas = []
# 按帧处理所有音频数据(包括最后一帧可能补零) # 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据 # 获取当前帧的二进制数据
@@ -899,14 +898,17 @@ def audio_to_opus_data(audio_file_path):
if len(chunk) < frame_size * 2: if len(chunk) < frame_size * 2:
chunk += b"\x00" * (frame_size * 2 - len(chunk)) chunk += b"\x00" * (frame_size * 2 - len(chunk))
# 转换为numpy数组处理 if is_opus:
np_frame = np.frombuffer(chunk, dtype=np.int16) # 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16)
# 编码Opus数据
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
else:
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
# 编码Opus数据 datas.append(frame_data)
opus_data = encoder.encode(np_frame.tobytes(), frame_size)
opus_datas.append(opus_data)
return opus_datas, duration return datas, duration
def check_vad_update(before_config, new_config): def check_vad_update(before_config, new_config):