You can utilize the panel miniapp to develop and build an AI Audio Device Panel based on the Ray framework, implementing the following functionalities:
For more information, see Panel MiniApp > Set up environment.
A product defines the data points (DPs) of the associated panel and device. Before you develop a panel, you must create a product, define the required DPs, and then implement these DPs on the panel.
Register and log in to the Tuya Developer Platform and create a product.




Register and log in to the Smart MiniApp Developer Platform. For more information, see Create panel miniapp.
Open Tuya MiniApp IDE and create a panel miniapp project based on the AI headphone template.
For more information, see Initialize project.
 
 
  // Start recording
  const handleStartRecord = useCallback(
    (type: 'left' | 'right') => {
      const startRecordFn = async (type: 'left' | 'right') => {
        // Keep the device online
        if (!isOnline) return;
        try {
          showLoading({ title: '' });
          const config: any = {
            // The recording type. 0: call, 1: conference, 2: simultaneous interpretation, 3: face-to-face translation
            recordType: 3,
            // DP control timeout, in seconds
            controlTimeout: 5,
            // Streaming timeout, in seconds
            dataTimeout: 10,
            // 0: file transcription, 1: real-time transcription
            transferType: 1,
            // Indicate whether translation is required
            needTranslate: true,
            // Input language
            originalLanguage: type === 'left' ? leftLanguage : rightLanguage,
            // Output language
            targetLanguage: type === 'left' ? rightLanguage : leftLanguage,
            // The agent ID. Get agentId later based on the SDK provided
            agentId: '',
            // The recording channel. 0: Bluetooth low energy, 1: Bluetooth, 2: microphone
            recordChannel: isCardStyle || isDevOnline === false ? 2 : 1,
            // 0: left ear, 1: right ear
            f2fChannel: type === 'left' ? 0 : 1,
            // TTS stream encoding method. Write the stream to the headphone device after encoding. 0: opus_silk, 1: opus_celt
            ttsEncode: isOpusCelt ? 1 : 0,
            // Indicate whether TTS is required
            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]
  );
  // Pause
  const handlePauseRecord = async () => {
    try {
      const d = await tttPauseRecord(deviceId);
      setActiveType('');
      console.log('pauseRecord', d);
      setIntervals(undefined);
    } catch (error) {
      console.log('handlePauseRecord fail', error);
    }
  };
  // Resume recording
  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);
    }
  };
  // Stop recording
  const handleStopRecord = async () => {
    try {
      showLoading({ title: '' });
      const d = await tttStopRecord(deviceId);
      hideLoading();
      console.log('stopRecord', d);
      backToHome(fromType);
    } catch (error) {
      hideLoading();
    }
  };
  // Listen for ASR and translation results
  onRecordTransferRealTimeRecognizeStatusUpdateEvent(handleRecrodChange);
  // Handle ASR and translation
  const handleRecrodChange = d => {
    try {
      const {
        // The phase. 0: task, 4: ASR, 5: translation, 6: skill, 7: TTS
        phase,
        // The status of a specified stage. 0: not started, 1: in progress, 2: completed, 3: canceled
        status,
        requestId,
        // The transcribed text
        text,
        // The error code
        errorCode,
      } = d;
      // Receive and update requestId text in real time during the ASR phase
      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);
        }
        // In the translation return phase, receive and show the translation with a status value of 2, which means the completed translation.
      } 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);
            // When a numeric string like "111" is incorrectly identified as a JSON string by isJsonString, it will cause the .join() operation to fail.
            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);
    }
  };
      
 
 
  // Complete the recording configuration and start recording
  const startRcordFn = async () => {
    // Keep the device online
    if (!isOnline) return;
    // Show a prompt when translation is enabled but no target language is selected
    if (needTranslate && !translationLanguage) {
      showToast({
        icon: 'none',
        title: Strings.getLang('realtime_recording_translation_no_select_tip'),
      });
      return;
    }
    try {
      setControlBtnLoading(true);
      const config: any = {
        // The recording type. 0: call, 1: conference
        recordType: currRecordType,
        // DP control timeout, in seconds
        controlTimeout: 5,
        // Streaming timeout, in seconds
        dataTimeout: 10,
        // 0: file transcription, 1: real-time transcription
        transferType: 1,
        // Indicate whether translation is required
        needTranslate,
        // Input language
        originalLanguage: originLanguage,
        // The agent ID. Get agentId later based on the SDK provided
        agentId: '',
        // The recording channel. 0: Bluetooth low energy, 1: Bluetooth, 2: microphone
        recordChannel,
        // TTS stream encoding method. Write the stream to the headphone device after encoding. 0: opus_silk, 1: opus_celt
        ttsEncode: isOpusCelt ? 1 : 0,
      };
      if (needTranslate) {
        // The target language
        config.targetLanguage = translationLanguage;
      }
      await tttStartRecord({
        deviceId,
        config,
      });
      setInterval(1000);
      lastTimeRef.current = Date.now();
      setControlBtnLoading(false);
    } catch (error) {
      setControlBtnLoading(false);
    }
  };
  // The callback invoked when the Start Recording button is tapped
  const handleStartRecord = useCallback(async () => {
    // Request recording permission
    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,
  ]);
  // Pause
  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);
    }
  };
  // Resume recording
  const handleResumeRecord = async () => {
    if (controlBtnLoading) return;
    try {
      setControlBtnLoading(true);
      await tttResumeRecord(deviceId);
      setInterval(1000);
      lastTimeRef.current = Date.now();
      setControlBtnLoading(false);
    } catch (error) {
      setControlBtnLoading(false);
    }
  };
  // Stop
  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();
    }
  };
  // Listen for ASR and translation results
  onRecordTransferRealTimeRecognizeStatusUpdateEvent(handleRecrodChange);
  // Handle ASR and translation
  const handleRecrodChange = d => {
    try {
      const {
        // The phase. 0: task, 4: ASR, 5: translation, 6: skill, 7: TTS
        phase,
        // The status of a specified stage. 0: not started, 1: in progress, 2: completed, 3: canceled
        status,
        requestId,
        // The transcribed text
        text,
        // The error code
        errorCode,
      } = d;
      // Receive and update requestId text in real time during the ASR phase
      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);
        }
        // In the translation return phase, receive and show the translation with a status value of 2, which means the completed translation.
      } 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);
            // When a numeric string like "111" is incorrectly identified as a JSON string by isJsonString, it will cause the .join() operation to fail.
            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);
    }
  };
      
      
  // Complete the recording configuration and invoke app capabilities to start recording
  const startRecordFn = async () => {
    try {
      setControlBtnLoading(true);
      await tttStartRecord(
        {
          deviceId,
          config: {
            // Specify whether to keep the audio file when an error occurs
            saveDataWhenError: true,
            // The recording type. 0: call, 1: conference
            recordType: currRecordType,
            // DP control timeout, in seconds
            controlTimeout: 5,
            // Streaming timeout, in seconds
            dataTimeout: 10,
            // 0: file transcription, 1: real-time transcription
            transferType: 0,
            // The recording channel. 0: Bluetooth low energy, 1: Bluetooth, 2: microphone
            recordChannel,
            // TTS stream encoding method. Write the stream to the headphone device after encoding. 0: opus_silk, 1: opus_celt
            ttsEncode: isOpusCelt ? 1 : 0,
          },
        },
      );
      setInterval(1000);
      lastTimeRef.current = Date.now();
      setControlBtnLoading(false);
    } catch (error) {
      setControlBtnLoading(false);
    }
  };  
  // The callback invoked when the Start Recording button is tapped
  const handleStartRecord = useCallback(async () => {
    // Request permission
    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]);
// Pause
  const handlePauseRecord = async () => {
    try {
      setControlBtnLoading(true);
      await tttPauseRecord(deviceId);
      setInterval(undefined);
      setControlBtnLoading(false);
    } catch (error) {
      console.log('fail', error);
      setControlBtnLoading(false);
    }
  };
  // Resume recording
  const handleResumeRecord = async () => {
    try {
      setControlBtnLoading(true);
      await tttResumeRecord(deviceId);
      setInterval(1000);
      lastTimeRef.current = Date.now();
      setControlBtnLoading(false);
    } catch (error) {
      setControlBtnLoading(false);
    }
  };
  // Stop
  const handleStopRecord = async () => {
    try {
      ty.showLoading({ title: '' });
      await tttStopRecord(deviceId);
      setDuration(0);
      setInterval(undefined);
      ty.hideLoading();
      backToHome();
    } catch (error) {
      ty.hideLoading();
    }
  };
      
 
 
 
{/* Transcription results */}
<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={() => {
            // Select a template
            setShowTemplatePopup(true);
          }}
        >
          <Text className={styles.generateText}>{Strings.getLang('generate')}</Text>
        </Button>
      </>
    )}
</View>
  // Start transcription and summary
  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;
    }
  };
// Get transcription and summary details
const getFileDetail = async () => {
  // Loading
  const finishLoading = () => {
    ty.hideLoading();
    isLoading.current = false;
  };
  try {
    isLoading.current = true;
    ty.showLoading({ title: '' });
    const recordTransferId = currRecordTransferId.current;
    // Get the recording details
    const fileDetail = await tttGetFilesDetail({
      recordTransferId,
      amplitudeMaxCount: 100,
    });
    if (fileDetail) {
      setRecordFile(fileDetail);
      const { storageKey, transfer, visit, status, recordId, transferType } = fileDetail;
      if (!visit) {
        updateFileVisitStatus();
      }
      setTransferStatus(transfer);
      // Get real-time transcription data directly from the app interface
      if (transferType === TransferType.REALTIME) {
        // Get the transcription data
        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;
        // Get local summary data on the client
        tttGetRecordTransferSummaryResult({
          recordTransferId,
          from: 0,
        }).then((d: any) => {
          if (d?.text) {
            resolveSummaryText(d?.text);
          }
        });
        // Get summary data on the cloud
        tttGetRecordTransferSummaryResult({
          recordTransferId,
          from: 1, // The cloud
        }).then((d: any) => {
          if (d?.text) {
            tttSaveRecordTransferSummaryResult({ recordTransferId, text: d?.text });
            resolveSummaryText(d?.text);
          }
        });
      } else {
        // status indicates the file synchronization status. 0: not uploaded, 1: uploading, 2: successfully uploaded, 3: upload failed
        // transfer indicates the transcription status. 0: not transcribed, 1: transcribing, 2: successfully transcribed, 3: transcription failed
        if (status === 2 && transfer === 2) {
          // Get local transcription data on the client
          tttGetRecordTransferRecognizeResult({
            recordTransferId,
            from: 0, // Local
          }).then((d: any) => {
            if (d?.text) {
              resolveSttText(d?.text);
            }
          });
          // Get transcription data on the cloud
          tttGetRecordTransferRecognizeResult({
            recordTransferId,
            from: 1, // The cloud
          }).then((d: any) => {
            if (d?.text) {
              // Cache locally on the client
              tttSaveRecordTransferRecognizeResult({ recordTransferId, text: d?.text });
              resolveSttText(d?.text);
            }
          });
          // Get local summary data on the client
          tttGetRecordTransferSummaryResult({
            recordTransferId,
            from: 0,
          }).then((d: any) => {
            if (d?.text) {
              resolveSummaryText(d?.text);
            }
          });
          // Get summary data on the cloud
          tttGetRecordTransferSummaryResult({
            recordTransferId,
            from: 1, // The cloud
          }).then((d: any) => {
            if (d?.text) {
              tttSaveRecordTransferSummaryResult({ recordTransferId, text: d?.text });
              resolveSummaryText(d?.text);
            }
          });
        }
      }
      finishLoading();
    }
  } catch (error) {
    console.log('error', error);
    finishLoading();
  }
};