前提条件

构建内容

您可以利用面板小程序开发构建出一个基于 Ray 框架的 AI 音频设备面板,并实现以下功能:

所需条件

详见 面板小程序 > 搭建环境

首先需要创建一个产品,定义产品有哪些功能点,然后再在面板中一一实现这些功能点。

注册登录 涂鸦开发者平台,并在平台创建产品:

  1. 单击页面左侧 产品 > 产品开发,在 产品开发 页面单击 创建产品

  1. 标准类目 下选择 影音穿戴 > AI 耳机

  1. 选择 智能化方式产品方案,完善产品信息,单击 创建产品

  1. 添加标准功能 页面,根据实际需求选择对应的功能点,单击 确定


开发者平台创建面板小程序

面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。

详细操作步骤,可以参考 面板小程序 > 创建面板小程序

IDE 基于模板创建项目工程

打开 IDE 创建一个基于 AI 耳机模板 的 AI 音频面板小程序项目,需要在 Tuya MiniApp IDE 上进行操作。

详细操作步骤,可以参考 面板小程序 > 初始化项目工程

功能展示

代码片段

  // 开始对话翻译录音(Pro:recordType=2;非 Pro:recordType=3)
  const startAudioRecording = async (type: 'left' | 'right' | 'auto') => {
    if (!isOnline) {
      ty.showToast({ title: Strings.getLang('device_offline'), icon: 'error' });
      return;
    }
    const audioSourceList = getAudioSourceList(customDevType, recordType);
    const config: any = {
      // 发起源,0:AI Note、1:AI Translate
      businessType: BusinessType.Note,
      // 多音源列表(替代旧版单一 audioSource/recordChannel)
      audioSourceList,
      saveDataWhenError: true,
      // 录音类型,2:对话翻译(Pro)、3:对话翻译
      recordType,
      controlTimeout: 5,
      dataTimeout: 10,
      // 0:文件转写、1:实时转写
      transferType: 1,
      needTranslate: true,
      // 起始/目标语言(左右耳互转)
      originalLanguage: isPro ? leftLanguage : type === 'left' ? leftLanguage : rightLanguage,
      targetLanguage: isPro ? rightLanguage : type === 'left' ? rightLanguage : leftLanguage,
      // 智能体 ID(由 atopGetAgentInfo/store 获取)
      agentId,
      ttsEncode: isOpusCelt ? 1 : 0,
      needTts: true,
      needAsr: true,
      // 是否开启自动断句识别
      needAutoRecognize: autoSpeek,
      ...(!isPro && {
        // 非 Pro:录音通道,0:蓝牙 LE、1:BT、2:Micro;f2fChannel,0:左耳、1:右耳
        recordChannel: isPhone ? 2 : 1,
        f2fChannel: type === 'left' ? 0 : 1,
      }),
      // TTS 输出配置列表(按音源生成)
      ttsConfigList: getTtsConfigList(audioSourceList, recordType),
    };
    await tttStartAudioRecording({ deviceId, config }, true);
    setActiveType(type);
    setInterval(1000);
    lastTimeRef.current = Date.now();
  };

  const handleStartRecord = async (type?: 'left' | 'right' | 'auto') => {
    // 先校验权益余额
    const balance = await checkBalance(homeId, deviceId);
    if (balance === 0 || balance === 2) return;
    ty.authorize({
      scope: 'scope.record',
      success: () => startAudioRecording(type),
      fail: () => {
        ty.showToast({ title: Strings.getLang('no_record_permisson'), icon: 'error' });
      },
    });
  };
  // 暂停/继续/停止(状态由全局录音任务事件同步)
  await tttPauseRecord(deviceId);
  await tttResumeRecord(deviceId);
  await tttStopRecord(deviceId);
  // 双通道 ASR:事件入队,由 useAsrTransferQueue 统一处理展示
  // phase,0:任务、4:ASR、5:翻译、6:Skill、7:TTS
  ty.wear.onRecordTransferRealTimeRecognizeStatusUpdateEvent(d => {
    if (!handleTttError(d, true)) return;
    pushEvent(d); // DualChannelASRList / useAsrTransferQueue
  });

  // 重新进入页面时可按 recordId 拉取已有实时结果
  const realTimeResult = await tttGetRecordTransferRealTimeResult({ recordId: task.recordId });
  resetTextList(parseRealTimerResult(realTimeResult));

功能展示

代码片段

  // 同声传译/实时转写共用 RealTimeRecording,配置后交给页面层 tttStartAudioRecording
  const recordingConfig = useMemo(() => {
    const audioSource = getAudioSource(customDevType, currRecordType, isCall);
    const config: any = {
      deviceId,
      config: {
        // 发起源,0: AI Note、1:AI Translate
        businessType: BusinessType.Note,
        // 音频输入源(手机/入门版/Pro 等)
        audioSource,
        // 录音类型,0:呼叫、1:会议(同声传译走会议)
        recordType: isCall ? 0 : 1,
        needAsr: true,
        // 同声传译默认开启翻译
        needTranslate,
        needTts,
        needAmplitude: false,
        originalLanguage: originLanguage,
        agentId,
        ttsEncode: isOpusCelt ? 1 : 0,
      },
    };
    if (needTts) {
      config.config.ttsConfig = {
        // 输出源为设备时传 deviceId;BT/MIC 传空
        devId:
          customDevType === DEVTYPES.entry ||
          customDevType === DEVTYPES.phone ||
          customDevType === DEVTYPES.card
            ? ''
            : deviceId,
        ttsOutput: getTtsOutput(audioSource),
        ttsEncode: isOpusCelt ? 2 : customDevType === DEVTYPES.pro ? 1 : 0,
        ttsOutputChannel: 0,
      };
    }
    if (needTranslate) {
      config.config.targetLanguage = translationLanguage;
    }
    return config;
  }, [deviceId, customDevType, needTranslate, originLanguage, translationLanguage, needTts, agentId]);

  // 语言变更时热更新参数(无需停录)
  tttUpdateParams(recordingConfig);

  // 页面层统一发起(含权益校验、权限、离线降级到手机音源)
  await tttStartAudioRecording(recordingConfig);
  // 暂停/继续/停止
  await tttPauseRecord(deviceId);
  await tttResumeRecord(deviceId);
  await tttStopRecord(deviceId);
  // 实时 ASR/翻译事件:只入队,展示由 useRealTimeTextQueue 处理
  // phase,0:任务、4:ASR、5:翻译、6:Skill、7:TTS
  ty.wear.onRecordTransferRealTimeRecognizeStatusUpdateEvent(d => {
    if (d?.errorCode === 10002 || d?.errorCode === '10002') {
      // 设备不支持,提示后返回
      return;
    }
    if (!handleTttError(d, true)) return;
    pushEvent(d);
  });

  // 已有任务时回填实时结果
  const realTimeResult = await tttGetRecordTransferRealTimeResult({ recordId: task.recordId });
  resetTextList(parseRealTimerResult(realTimeResult));

功能展示

代码片段

  // 现场录音配置(RecordingModes),由 recordingMode 页统一调用 tttStartAudioRecording
  const recordingConfig = {
    deviceId,
    config: {
      // 发起源,0: AI Note、1: AI Translate
      businessType: 0,
      // 音频输入源:按设备类型映射(phone、entry、proLive 等)
      audioSource: getAudioSource(customDevType, currRecordType, isCall),
      // 录音类型,0:呼叫、1:会议(现场录音为会议)
      recordType: currRecordType,
      needAsr: false,
      needTranslate: false,
      needTts: false,
      // 现场录音需要波形振幅
      needAmplitude: true,
    },
  };

  // 开始:校验在线/权限后发起(设备离线时可降级到手机音源)
  const handleStartRecord = async (recordingConfig: any) => {
    if (checkAndShowOfflineDialog()) return; // 电话模式才强校验 BT;现场可降级
    const needPermission =
      customDevType === DEVTYPES.phone ||
      customDevType === DEVTYPES.os ||
      customDevType === DEVTYPES.entry;
    if (needPermission) {
      ty.authorize({
        scope: 'scope.record',
        success: () => startRecordingProcess(recordingConfig),
        fail: () => {
          ty.showToast({ title: Strings.getLang('no_record_permisson'), icon: 'error' });
        },
      });
      return;
    }
    await startRecordingProcess(recordingConfig);
  };

  const startRecordingProcess = async (recordingConfig: any) => {
    await tttStartAudioRecording(recordingConfig);
    setInterval(1000);
    lastTimeRef.current = Date.now();
  };
  // 暂停/继续/停止
  await tttPauseRecord(deviceId);
  await tttResumeRecord(deviceId);
  await tttStopRecord(deviceId);
  // 结束后由全局 taskState === FINISH 回首页
  // 监听录音结束事件(异常码非 0 时提示)
  ty.wear.onRecordTransferFinishEvent(d => {
    if (d.deviceId !== deviceId) return;
    if (d.code !== 0) {
      handleTttError(d, true);
    }
    setInterval(undefined);
  });

功能展示

代码片段

// 任务类型,0:转写、1:总结、2:翻译
enum GenerateType {
  Transcribe,
  Summarize,
  Translate,
}

// 转写状态,0:未转写、1:转写中、2:成功、3:失败
// 总结状态,0:兼容老数据、1:未总结、2:总结中、3:成功、4:失败
  // 1. 进入详情:按 recordId 拉取文件详情,再按状态加载结果
  getFileDetail({
    recordId,
    amplitudeMaxCount: 100,
  }).then(async res => {
    // transfer === 2:转写成功,拉取转写结果
    if (res?.transfer === TransferStatus.Success) {
      await getTransferResult();
    }
    // summary === 3:总结成功,拉取总结结果
    if (res?.summary === SummaryStatus.Success) {
      await getSummaryResult();
    }
  });
  // 2. 确认模板后发起转写/总结(tttTransfer)
  const fetchTransferOrSummary = async () => {
    // 未转写时,无论入口是总结还是转写,都先走转写任务
    let taskType = generateType;
    if (fileDetail?.transfer === TransferStatus.Initial) {
      taskType = GenerateType.Transcribe;
    }

    const params: any = {
      recordTransferId: fileDetail?.recordTransferId,
      // 模板 ID;auto 时传空字符串
      template: selectedTemplate === 'auto' ? '' : selectedTemplate,
      // 转写源语言
      language: fileDetail?.originalLanguage || language,
      // 总结语言
      summaryLang,
      // 0:转写、1:总结、2:翻译
      taskType,
      // 是否区分说话人
      enableSpeaker,
    };

    // 翻译任务额外传入目标语言
    if (taskType === GenerateType.Translate) {
      params.transLang = translateLang;
    }

    return tttTransfer(params);
  };
  // 3. 监听上传进度:上传完成后,本地标记为转写中/总结中
  ty.wear.onRecordTransferFileUploadEvent(res => {
    const { fileId, progress, status } = res || {};
    // status:等待/上传中/完成 ...
    if (status === OfflineFileUploadStatus.Completed) {
      updateFileDetailLocal(prev =>
        prev
          ? { ...prev, transfer: TransferStatus.Process, summary: SummaryStatus.Process }
          : prev
      );
    }
  });
  // 4. 监听局部刷新事件:转写/总结完成后拉取结果(替代旧的本地 + 云端双拉)
  // 事件字段 transferStatus/summaryStatus 对应详情字段 transfer/summary
  const handlePartialRecordUpdate = (res: any) => {
    const currentFile = res?.updateList?.find(
      item => item?.recordId === fileDetailRef.current?.recordId
    );
    if (!currentFile) return;

    const { transferStatus: tStatus, summaryStatus: sStatus } = currentFile;

    if (tStatus === TransferStatus.Success) {
      updateFileDetailLocal(prev =>
        prev ? { ...prev, transfer: tStatus, cloudTranscription: true } : prev
      );
      getTransferResult();
    }
    if (sStatus === SummaryStatus.Success) {
      updateFileDetailLocal(prev => (prev ? { ...prev, summary: sStatus } : prev));
      getSummaryResult();
      refreshFileDetail();
    }
  };
  // 5. 获取转写结果
  const fetchRecordTransferResult = async () => {
    const {
      transfer,
      recordId,
      transferType,
      cloudTranscription,
      recordTransferId,
      isFromCloud,
      duration,
    } = fileDetailRef.current;

    // 实时转写且尚未云端转录:走实时结果接口
    if (transferType === TransferType.Realtime && !cloudTranscription && !isFromCloud) {
      const realTimeResult = await tttGetRecordTransferRealTimeResult({ recordId });
      return processRealtimeData(realTimeResult);
    }

    // 转写成功/已云端转录:读本地缓存的转写 JSON
    if (transfer === TransferStatus.Success || cloudTranscription || isFromCloud) {
      const cloudResult = await tttGetRecordTransferRecognizeResult({
        recordTransferId,
        from: 0, // 本地
      });
      const list = cloudResult?.text ? JSON.parse(cloudResult.text) : [];
      return processCloudTransferData(list, duration);
    }
    return [];
  };
  // 6. 获取总结结果(结构化 JSON)
  const fetchRecordSummaryResult = async () => {
    const { summary, recordTransferId } = fileDetailRef.current;
    if (summary !== SummaryStatus.Success) return;

    const result = await tttGetRecordTransferSummaryResult({
      recordTransferId,
      from: 0, // 本地
    });
    if (!result?.text) return;

    const data = JSON.parse(result.text);
    // data.summary:总结正文
    // data.title:总结标题(可回写文件名)
    // data.outline:章节大纲 JSON 字符串
    // data.question:预设问题 JSON 字符串
    // data.imageUrl:信息图地址(summaryImageStatus === Complete 时展示)
    setSummaryData(data?.summary || '');
  };
{/* 7. 详情 Tab:转写/总结/思维导图 */}
{activeTab === DetailTabType.Transcribe && (
  <TranscriptContent outlineList={outlineList} updateSummary={updateSummary} />
)}
{activeTab === DetailTabType.Summarize && (
  <SummaryContent
    summary={summaryData}
    updateSummary={updateSummary}
    summaryImageUrl={summaryImageUrl}
  />
)}
{activeTab === DetailTabType.MindMap && <MindMapContent summary={summaryData} />}

{/* 模板选择:推荐模板 + 总结语言 + 区分说话人 */}
<ChooseTransferTemplate
  show={selectTemplateShow}
  enableSpeaker={enableSpeaker}
  onSpeakerChange={setEnableSpeaker}
  onBottomBtnClick={handleConfirmTemplate}
/>

代码片段

  // 模式入口:仅专业版设备展示电话录音
  const modes = useMemo(() => {
    const data = [
      // ... 其他模式
      {
        name: Strings.getLang('mode_title_call_recording'),
        key: RecordMode.phoneRecording,
        icon: 'modeCallIcon',
      },
    ];
    if (deviceList.find(item => item.customDevType === 'pro_version')) {
      return data;
    }
    return data.filter(item => item.key !== RecordMode.phoneRecording);
  }, [deviceList]);
  // 电话录音配置,并调用 App 能力开始录音
  const recordingConfig = {
    deviceId,
    config: {
      // 发起源,0: AI Note、1: AI Translate
      businessType: 0,
      // 专业版电话音源
      audioSource: 21,
      // 录音类型,0:呼叫、1:会议
      recordType: 0,
      needAsr: false,
      needTranslate: false,
      needTts: false,
      needAmplitude: true,
    },
  };

  // 开始前检查设备在线与 BT 连接
  const checkAndShowOfflineDialog = (btStatus?: number) => {
    if (!isOnline) {
      // 提示连接耳机
      return true;
    }
    if (btStatus === 0) {
      // 提示蓝牙未连接
      return true;
    }
    return false;
  };

  await tttStartAudioRecording(recordingConfig, true);
  // 监听通话挂断事件(errorCode = 10022),自动结束录音
  const handleRealTimeRecognizeStatusUpdateEvent = (d: any) => {
    const { errorCode } = d || {};
    if (errorCode !== 10022) return;
    ty.showModal({
      title: Strings.getLang('tip'),
      content: Strings.getLang('phone_call_end_tip'),
      showCancel: false,
      confirmText: Strings.getLang('confirm'),
      success: ({ confirm }) => {
        if (!confirm) return;
        handleStopRecord();
      },
    });
  };
  ty.wear.onRecordTransferRealTimeRecognizeStatusUpdateEvent(
    handleRealTimeRecognizeStatusUpdateEvent
  );

代码片段

  // 点击 "导入音频" 模式,唤起系统文件选择并开始导入
  case RecordMode.importAudio: {
    if (isImporting) {
      showToast({ title: Strings.getLang('audio_importing_warn'), icon: 'none' });
      break;
    }
    await tttImportAudio();
    break;
  }
  // 监听导入状态事件
  // status,0:未导入、1:正在导入、2:导入成功、3:导入失败、4:分享导入异常
  const handleFileImportStatusEvent = (res: FileImportStatusResult) => {
    if (res.status === FileImportStatus.Imported) {
      // 刷新文件列表;若无失败文件则关闭导入弹窗
      dispatch(updateRecordTransferResultList({ source: 'history_file_imported' }));
      if (!res?.failedFiles?.length) {
        setIsShowImportModal(false);
      }
      setImportInfo(res);
      return;
    }
    setImportInfo(res);
  };
  ty.wear.onFileImportStatusEvent(handleFileImportStatusEvent);
  // 取消导入/失败重试
  await tttCancelImport();
  await tttRetryImport();

  // 查询当前导入状态(页面初始化时调用)
  const status = await tttGetAudioImportStatus();

代码片段

  // 查询卡片设备离线文件状态,若有待传文件则启动蓝牙 LE 传输
  const status = await getDeviceOfflineFileStatus(deviceId, true);
  if (status?.response?.total > 0) {
    await tttLoadOfflineFile({
      deviceId,
      sessionId: status.sessionId || 0,
      // 1:蓝牙 LE、2:Wi-Fi 快传
      channel: 1,
    });
  }
  // App 级监听离线文件传输进度(建议全局注册一次,避免页面切换互相覆盖)
  // status,0:未开始、1:下载中、2:结束
  const handleOfflineFilesProgress = (data: DeviceOfflineFileResult) => {
    const { status, errorCode, response } = data || {};
    // 转发进度到首页/传输页,更新同步中卡片信息
    if (status === 1) {
      // 传输中
    } else if (status === 2) {
      // 传输结束,可继续探测其他设备或关闭提示
    }
  };
  ty.wear.onOfflineFilesProgressEvent(handleOfflineFilesProgress);
  // 切换传输通道:Wi-Fi 快传 <-> 蓝牙
  await tttSwitchModeLoadOfflineFile({
    channel: 2, // 2:Wi-Fi 快传、1:蓝牙 LE
    deviceId,
  });

代码片段

  // 获取云同步开关状态,并初始化同步状态
  const data = await tttGetCloudSyncStatus();
  // data.enabled:是否开启
  // data.syncType:1:仅 Wi-Fi、其他为全部网络
  // data.modifyTime:上次同步时间
  // 开启/关闭云同步(Highway:m.wearable.sync.switch.save)
  await toggleCloudSync({
    enabled: true,
    syncType: 'wifi', // 'wifi':仅 Wi-Fi;'all':全部网络
  });
  // 手动同步 Note 记录(上传文件 + 音频,并下载云端 Note)
  await tttSyncNoteRecord();

  // 监听云同步开关变更
  ty.wear.onCloudSyncSwitchStatusEvent(res => {
    // res.enabled / res.syncType / res.modifyTime
  });

  // 监听文件列表刷新(含云同步上传进度)
  // syncBusinessType,0:其他业务(刷新列表)、1:上传业务(仅更新云同步卡片状态)
  ty.wear.onFileListUpdateStatusEvent(res => {
    if (res?.syncBusinessType === 1) {
      // 更新云同步卡片状态
    } else {
      // 刷新录音列表
    }
  });
  // 清理本地音频缓存(云端副本保留)
  await tttRemoveFiles({ fileIds: [], isDeleteAll: false });

  // 详情页按需下载云端音频
  await tttDownloadNoteAudio({ fileId, recordId });