您可以利用面板小程序开发构建出一个基于 Ray 框架的 AI 音频设备面板,并实现以下功能:
详见 面板小程序 > 搭建环境。
首先需要创建一个产品,定义产品有哪些功能点,然后再在面板中一一实现这些功能点。
注册登录 涂鸦开发者平台,并在平台创建产品:
面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。
详细操作步骤,可以参考 面板小程序 > 创建面板小程序。
打开 IDE 创建一个基于 AI 耳机模版 的 AI 音频面板小程序项目,需要在 Tuya MiniApp IDE 上进行操作。
详细操作步骤,可以参考 面板小程序 > 初始化项目工程。
// 开始录音
const handleStartRecord = useCallback(
(type: 'left' | 'right') => {
const startRecordFn = async (type: 'left' | 'right') => {
// 需要设备在线
if (!isOnline) return;
try {
showLoading({ title: '' });
const config: any = {
// 录音类型,0:呼叫、1:会议、2:同声传译、3:面对面翻译
recordType: 3,
// DP 控制超时时间,单位秒
controlTimeout: 5,
// 灌流超时时间,单位秒
dataTimeout: 10,
// 0:文件转写、1:实时转写
transferType: 1,
// 是否需要翻译
needTranslate: true,
// 输入语言
originalLanguage: type === 'left' ? leftLanguage : rightLanguage,
// 输出语言
targetLanguage: type === 'left' ? rightLanguage : leftLanguage,
// 智能体 ID,后面具体根据提供的 SDK 获取 agentId。
agentId: '',
// 录音通道,0:BLE、1:Bt、2:micro
recordChannel: isCardStyle || isDevOnline === false ? 2 : 1,
// 0:代表左耳、1:代表右耳
f2fChannel: type === 'left' ? 0 : 1,
// TTS 流编码方式,通过编码后将流写入到耳机设备,0:opus_silk、1:opus_celt
ttsEncode: isOpusCelt ? 1 : 0,
// 是否需要 TTS
needTts: true,
};
await tttStartRecord(
{
deviceId,
config,
},
true
);
setActiveType(type);
hideLoading();
setIntervals(1000);
lastTimeRef.current = Date.now();
} catch (error) {
ty.showToast({
title: Strings.getLang('error_simultaneous_recording_start'),
icon: 'error',
});
hideLoading();
}
};
ty.authorize({
scope: 'scope.record',
success: () => {
startRecordFn(type);
},
fail: e => {
ty.showToast({ title: Strings.getLang('no_record_permisson'), icon: 'error' });
},
});
},
[deviceId, isOnline, rightLanguage, leftLanguage]
);
// 暂停
const handlePauseRecord = async () => {
try {
const d = await tttPauseRecord(deviceId);
setActiveType('');
console.log('pauseRecord', d);
setIntervals(undefined);
} catch (error) {
console.log('handlePauseRecord fail', error);
}
};
// 继续录音
const handleResumeRecord = async () => {
if (!isOnline) return;
try {
const d = await tttResumeRecord(deviceId);
setIntervals(1000);
lastTimeRef.current = Date.now();
} catch (error) {
console.log('handleResumeRecord fail', error);
}
};
// 停止录音
const handleStopRecord = async () => {
try {
showLoading({ title: '' });
const d = await tttStopRecord(deviceId);
hideLoading();
console.log('stopRecord', d);
backToHome(fromType);
} catch (error) {
hideLoading();
}
};
// 监听 ASR 和翻译返回
onRecordTransferRealTimeRecognizeStatusUpdateEvent(handleRecrodChange);
// 处理 ASR 和翻译
const handleRecrodChange = d => {
try {
const {
// 阶段,0:任务、4:ASR、5:翻译、6:skill、7:TTS
phase,
// 阶段状态,0:未开启、1:进行中、2:结束、3:取消
status,
requestId,
// 转写的文本
text,
// 错误码
errorCode,
} = d;
// ASR 阶段接收并实时更新对应 requestId 文本
if (phase === 4) {
const currTextItemIdx = currTextListRef.current.findIndex(item => item.id === requestId);
if (currTextItemIdx > -1) {
const newList = currTextListRef.current.map(item =>
item.id === requestId ? { ...item, text } : item
);
currTextListRef.current = newList;
setTextList(newList);
} else {
if (!text) return;
const newList = [
...currTextListRef.current,
{
id: requestId,
text,
},
];
currTextListRef.current = newList;
setTextList(newList);
}
// 翻译返回阶段,接收并展示 status=2 即已完成翻译的
} else if (phase === 5 && status === 2) {
let resText = '';
if (text && text !== 'null') {
if (isJsonString(text)) {
const textArr = JSON.parse(text);
const isArr = Array.isArray(textArr);
// 数字的 string 类型如 111,isJsonString 判断为 json 字符串,会导致 .join 失败
resText = isArr ? textArr?.join('\n') : textArr;
} else {
resText = text;
}
}
if (!resText) {
return;
}
const newList = currTextListRef.current.map(item => {
return item.id === requestId ? { ...item, text: `${item.text}\n${resText}` } : item;
});
currTextListRef.current = newList;
setTextList(newList);
}
} catch (error) {
console.warn(error);
}
};
// 录音相关配置,并开始录音
const startRcordFn = async () => {
// 设备需要在线
if (!isOnline) return;
// 开启了翻译,但没有选择翻译语言时,给出提示
if (needTranslate && !translationLanguage) {
showToast({
icon: 'none',
title: Strings.getLang('realtime_recording_translation_no_select_tip'),
});
return;
}
try {
setControlBtnLoading(true);
const config: any = {
// 录音类型,0:呼叫、1:会议
recordType: currRecordType,
// DP 控制超时时间,单位秒
controlTimeout: 5,
// 灌流超时时间,单位秒
dataTimeout: 10,
// 0:文件转写、1:实时转写
transferType: 1,
// 是否需要翻译
needTranslate,
// 输入语言
originalLanguage: originLanguage,
// 智能体 ID,后面具体根据提供的 SDK 获取 agentId
agentId: '',
// 录音通道,0:BLE、1:Bt、2:micro
recordChannel,
// TTS 流编码方式,通过编码后将流写入到耳机设备,0:opus_silk、1:opus_celt
ttsEncode: isOpusCelt ? 1 : 0,
};
if (needTranslate) {
// 目标语言
config.targetLanguage = translationLanguage;
}
await tttStartRecord({
deviceId,
config,
});
setInterval(1000);
lastTimeRef.current = Date.now();
setControlBtnLoading(false);
} catch (error) {
setControlBtnLoading(false);
}
};
// 点击开始录音对应的回调
const handleStartRecord = useCallback(async () => {
// 申请录音权限
if (isBtEntryVersion) {
ty.authorize({
scope: 'scope.record',
success: () => {
startRcordFn();
},
fail: e => {
ty.showToast({ title: Strings.getLang('no_record_permisson'), icon: 'error' });
console.log('cope.record: ', e);
},
});
return;
}
startRcordFn();
}, [
deviceId,
isOnline,
controlBtnLoading,
currRecordType,
needTranslate,
originLanguage,
translationLanguage,
recordChannel,
isBtEntryVersion,
offlineUsage,
]);
// 暂停
const handlePauseRecord = async () => {
if (controlBtnLoading) return;
try {
setControlBtnLoading(true);
const d = await tttPauseRecord(deviceId);
setInterval(undefined);
setControlBtnLoading(false);
} catch (error) {
console.log('fail', error);
setControlBtnLoading(false);
}
};
// 继续录音
const handleResumeRecord = async () => {
if (controlBtnLoading) return;
try {
setControlBtnLoading(true);
await tttResumeRecord(deviceId);
setInterval(1000);
lastTimeRef.current = Date.now();
setControlBtnLoading(false);
} catch (error) {
setControlBtnLoading(false);
}
};
// 停止
const handleStopRecord = async () => {
if (controlBtnLoading) return;
try {
ty.showLoading({ title: '' });
await tttStopRecord(deviceId);
setDuration(0);
setInterval(undefined);
ty.hideLoading({
complete: () => {
backToHome(fromType);
},
});
} catch (error) {
ty.hideLoading();
}
};
// 监听 ASR 和翻译返回
onRecordTransferRealTimeRecognizeStatusUpdateEvent(handleRecrodChange);
// 处理 ASR 和翻译
const handleRecrodChange = d => {
try {
const {
// 阶段,0:任务、4:ASR、5:翻译、6:skill、7:TTS
phase,
// 阶段状态,0:未开启、1:进行中、2:结束、3:取消
status,
requestId,
// 转写的文本
text,
// 错误码
errorCode,
} = d;
// ASR 阶段,接收并实时更新对应 requestId 文本
if (phase === 4) {
const currTextItemIdx = currTextListRef.current.findIndex(item => item.id === requestId);
if (currTextItemIdx > -1) {
const newList = currTextListRef.current.map(item =>
item.id === requestId ? { ...item, text } : item
);
currTextListRef.current = newList;
setTextList(newList);
} else {
if (!text) return;
const newList = [
...currTextListRef.current,
{
id: requestId,
text,
},
];
currTextListRef.current = newList;
setTextList(newList);
}
// 翻译返回阶段,接收并展示 status=2 即已完成翻译的
} else if (phase === 5 && status === 2) {
let resText = '';
if (text && text !== 'null') {
if (isJsonString(text)) {
const textArr = JSON.parse(text);
const isArr = Array.isArray(textArr);
// 数字的 string 类型如 111,isJsonString 判断为 json 字符串,会导致 .join 失败
resText = isArr ? textArr?.join('\n') : textArr;
} else {
resText = text;
}
}
if (!resText) {
return;
}
const newList = currTextListRef.current.map(item => {
return item.id === requestId ? { ...item, text: `${item.text}\n${resText}` } : item;
});
currTextListRef.current = newList;
setTextList(newList);
}
} catch (error) {
console.warn(error);
}
};
// 录音相关配置,并调用 App 能力开始录音
const startRecordFn = async () => {
try {
setControlBtnLoading(true);
await tttStartRecord(
{
deviceId,
config: {
// 出错时是否要保留音频文件
saveDataWhenError: true,
// 录音类型,0:呼叫、1:会议
recordType: currRecordType,
// DP 控制超时时间,单位秒
controlTimeout: 5,
// 灌流超时时间 单位秒
dataTimeout: 10,
// 0:文件转写、1:实时转写
transferType: 0,
// 录音通道,0:BLE、1:Bt、2:micro
recordChannel,
// TTS 流编码方式,通过编码后将流写入到耳机设备,0:opus_silk、1:opus_celt
ttsEncode: isOpusCelt ? 1 : 0,
},
},
);
setInterval(1000);
lastTimeRef.current = Date.now();
setControlBtnLoading(false);
} catch (error) {
setControlBtnLoading(false);
}
};
// 点击开始录音的回调
const handleStartRecord = useCallback(async () => {
// 申请权限
if (isBtEntryVersion) {
ty.authorize({
scope: 'scope.record',
success: () => {
startRecordFn();
},
fail: e => {
ty.showToast({ title: Strings.getLang('no_record_permisson'), icon: 'error' });
},
});
return;
}
startRecordFn();
}, [currRecordType, recordChannel, isBtEntryVersion]);
// 暂停
const handlePauseRecord = async () => {
try {
setControlBtnLoading(true);
await tttPauseRecord(deviceId);
setInterval(undefined);
setControlBtnLoading(false);
} catch (error) {
console.log('fail', error);
setControlBtnLoading(false);
}
};
// 继续录音
const handleResumeRecord = async () => {
try {
setControlBtnLoading(true);
await tttResumeRecord(deviceId);
setInterval(1000);
lastTimeRef.current = Date.now();
setControlBtnLoading(false);
} catch (error) {
setControlBtnLoading(false);
}
};
// 停止
const handleStopRecord = async () => {
try {
ty.showLoading({ title: '' });
await tttStopRecord(deviceId);
setDuration(0);
setInterval(undefined);
ty.hideLoading();
backToHome();
} catch (error) {
ty.hideLoading();
}
};
{/* 转录结果 */}
<View className={styles.content}>
<Tabs.SegmentedPicker
activeKey={currTab}
tabActiveTextStyle={{
color: 'rgba(54, 120, 227, 1)',
fontWeight: '600',
}}
style={{ backgroundColor: 'rgba(241, 241, 241, 1)' }}
onChange={activeKey => {
setCurrTab(activeKey);
}}
>
<Tabs.TabPanel tab={Strings.getLang('recording_detail_tab_stt')} tabKey="stt" />
<Tabs.TabPanel tab={Strings.getLang('recording_detail_tab_summary')} tabKey="summary" />
<Tabs.TabPanel
tab={Strings.getLang('recording_detail_tab_mind_map')}
tabKey="mindMap"
/>
</Tabs.SegmentedPicker>
{currTab === 'stt' && (
<SttContent
playerStatus={playerStatus}
wavFilePath={recordFile?.wavFilePath}
transferStatus={transferStatus}
sttData={sttData}
recordType={recordFile?.recordType}
currPlayTime={currPlayTime}
onChangePlayerStatus={status => {
setPlayerStatus(status);
}}
innerAudioContextRef={innerAudioContextRef}
isEditMode={isEditMode}
onUpdateSttData={handleUpdateSttData}
/>
)}
{currTab === 'summary' && (
<SummaryContent summary={summary} transferStatus={transferStatus} />
)}
{currTab === 'mindMap' && (
<MindMapContent summary={summary} transferStatus={transferStatus} />
)}
{(transferStatus === TRANSFER_STATUS.Initial ||
transferStatus === TRANSFER_STATUS.Failed) &&
!(currTab === 'stt' && recordFile?.transferType === TransferType.REALTIME) && (
<>
<EmptyContent type={EMPTY_TYPE.NO_TRANSCRIPTION} />
<Button
className={styles.generateButton}
onClick={() => {
// 先选择模版
setShowTemplatePopup(true);
}}
>
<Text className={styles.generateText}>{Strings.getLang('generate')}</Text>
</Button>
</>
)}
</View>
// 开始转录总结
const handleStartTransfer = async (selectTemplate: TRANSFER_TEMPLATE) => {
if (isLoading.current) return;
try {
isLoading.current = true;
ty.showLoading({ title: '' });
await tttTransfer({
recordTransferId: currRecordTransferId.current,
template: selectTemplate,
language: recordFile?.originalLanguage || language,
});
setTransferStatus(TRANSFER_STATUS.Processing);
const fileDetail: any = await tttGetFilesDetail({
recordTransferId: currRecordTransferId.current,
amplitudeMaxCount: 100,
});
setRecordFile(fileDetail);
dispatch(updateRecordTransferResultList());
ty.hideLoading();
isLoading.current = false;
} catch (error) {
console.log(error);
dispatch(updateRecordTransferResultList());
ty.hideLoading();
isLoading.current = false;
}
};
// 获取转录和转写详情
const getFileDetail = async () => {
// loading
const finishLoading = () => {
ty.hideLoading();
isLoading.current = false;
};
try {
isLoading.current = true;
ty.showLoading({ title: '' });
const recordTransferId = currRecordTransferId.current;
// 获取录音详情
const fileDetail = await tttGetFilesDetail({
recordTransferId,
amplitudeMaxCount: 100,
});
if (fileDetail) {
setRecordFile(fileDetail);
const { storageKey, transfer, visit, status, recordId, transferType } = fileDetail;
if (!visit) {
updateFileVisitStatus();
}
setTransferStatus(transfer);
// 实时转写直接取用 App 接口的转写数据
if (transferType === TransferType.REALTIME) {
// 获取转写数据
const realTimeResult: any = await tttGetRecordTransferRealTimeResult({
recordId,
});
const { list } = realTimeResult;
const newData = list
.filter(item => !!item?.asr && item?.asr !== 'null')
.map(item => ({
asrId: item.asrId,
startSecond: Math.floor(item.beginOffset / 1000),
endSecond: Math.floor(item.endOffset / 1000),
text: item.asr,
transText: item.translate,
channel: item.channel,
}));
setSttData(newData);
originSttData.current = newData;
// 获取客户端本地总结数据
tttGetRecordTransferSummaryResult({
recordTransferId,
from: 0,
}).then((d: any) => {
if (d?.text) {
resolveSummaryText(d?.text);
}
});
// 获取云端总结数据
tttGetRecordTransferSummaryResult({
recordTransferId,
from: 1, // 云端
}).then((d: any) => {
if (d?.text) {
tttSaveRecordTransferSummaryResult({ recordTransferId, text: d?.text });
resolveSummaryText(d?.text);
}
});
} else {
// status 文件同步状态,0:未上传、1:上传中、2:已上传、3:上传失败
// transfer 转录状态,0:未转录、1:转录中、2:已转录、3:转录失败
if (status === 2 && transfer === 2) {
// 获取客户端本地转写数据
tttGetRecordTransferRecognizeResult({
recordTransferId,
from: 0, // 本地
}).then((d: any) => {
if (d?.text) {
resolveSttText(d?.text);
}
});
// 获取云端转写数据
tttGetRecordTransferRecognizeResult({
recordTransferId,
from: 1, // 云端
}).then((d: any) => {
if (d?.text) {
// 缓存到客户端本地
tttSaveRecordTransferRecognizeResult({ recordTransferId, text: d?.text });
resolveSttText(d?.text);
}
});
// 获取客户端本地总结数据
tttGetRecordTransferSummaryResult({
recordTransferId,
from: 0,
}).then((d: any) => {
if (d?.text) {
resolveSummaryText(d?.text);
}
});
// 获取云端总结数据
tttGetRecordTransferSummaryResult({
recordTransferId,
from: 1, // 云端
}).then((d: any) => {
if (d?.text) {
tttSaveRecordTransferSummaryResult({ recordTransferId, text: d?.text });
resolveSummaryText(d?.text);
}
});
}
}
finishLoading();
}
} catch (error) {
console.log('error', error);
finishLoading();
}
};