更新记录

1.0.2(2026-06-17)

  • 优化

1.0.1(2026-05-21)

  • 支持纯血鸿蒙端

1.0.0(2026-05-19)

  • 新版发布
查看更多

平台兼容性

uni-app(4.87)

Vue2 Vue3 Chrome Safari app-vue app-nvue Android iOS 鸿蒙 鸿蒙插件版本
× × 1.0.1
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
× × × × × × × × × × × ×

uni-app x(4.87)

Chrome Safari Android iOS 鸿蒙 鸿蒙插件版本 微信小程序
× × 1.0.1 ×

录音 后台录音 播放 后台播放uts插件

基于原生api封装的UTS音频插件,提供 PCM/WAV 录音实时 PCM 帧回调PCM 文件播放 及播放进度/分贝回调。适用于 App 端(Android / iOS / HarmonyOS NEXT)语音采集、对讲、波形展示等场景。


功能概览

  • 录音:开始 / 暂停 / 继续 / 停止 / 取消;支持 pcmwav 格式
  • 录音回调:录制开始、实时 PCM 帧、暂停、取消、完成(返回文件路径)
  • 播放:按本地文件路径播放 PCM/WAV;支持暂停 / 继续 / 取消
  • 播放回调:当前时间、总时长、进度(0~1)、实时分贝(dBFS)
  • Android 后台模式isBackground: true 时通过前台服务在后台录音/播放(需相应系统权限)
  • iOS 后台模式:在 manifest 中配置后台运行能力 audioisBackground 参数对 iOS 无效
  • HarmonyOS 后台录音startRecording({ isBackground: true }) 时申请 AUDIO_RECORDING 长时任务(需 KEEP_BACKGROUND_RUNNING 等权限,见下文)

HarmonyOS 后台录音需配置后台功能。配置步骤如下: (不清楚进交流群询问作者)

1、点击运行->运行到手机/模拟器->运行到鸿蒙

2、运行成功会在项目根目录下生成unpackage文件夹,找到unpackage/dist/dev/app-harmony/entry,将该文件夹复制到项目根目录下的harmony-configs文件夹下

3、uniapp鸿蒙端普通授权和试用不可用,uniapp-x项目普通授权和试用时有效的 (官方规定与插件无关)

4、在harmony-configs/entry/src/main/module.json5文件中增加设置如一下代码

"abilities": [
{
...
  "exported": true,
  "backgroundModes": [
    "audioRecording"
   ],
 "continuable": true,
....
}
]

安装与试用

1. 安装插件

yt-recorder 导入项目的 uni_modules/ 下,例如:

your-project/
└── uni_modules/
    └── yt-recorder/

再在项目使用的地方引入插件*import as RecorderManager from '@/uni_modules/yt-recorder' 后重新打 自定义基座** 。

2. 权限说明

插件会合并以下原生配置,宿主 App 首次使用时会弹出授权:

Android(插件 AndroidManifest.xml 已声明,录音时插件内会申请 RECORD_AUDIO):

  • RECORD_AUDIO:麦克风
  • FOREGROUND_SERVICE / FOREGROUND_SERVICE_MICROPHONE / FOREGROUND_SERVICE_MEDIA_PLAYBACK:后台录音/播放前台服务
  • POST_NOTIFICATIONS:Android 13+ 通知(后台模式)

iOS(插件 info.plist 已配置):

  • NSMicrophoneUsageDescription:麦克风用途说明

若需自定义 iOS 麦克风文案,可在宿主 yt-recorderapp-ios中的info.plist → 隐私描述中覆盖。

建议:在宿主 manifest.jsonapp-ios.distribute 中配置 UIBackgroundModes: audio,以便后台音频场景更稳定。

HarmonyOS NEXT(插件 utssdk/app-harmony/module.json5 已声明,录音时插件内会申请麦克风):

权限 用途
ohos.permission.MICROPHONE 录音
ohos.permission.KEEP_BACKGROUND_RUNNING isBackground: true 时后台长时任务
ohos.permission.INTERNET 插件模块依赖(与网络能力声明一致)

模块内 Ability 已配置 backgroundModes: ["audioRecording"],与后台录音能力对齐。 运行到鸿蒙 后真机验证。

3. 试用流程

试用步骤

  1. 用 HBuilderX 新建项目或打开自己的项目,导入插件将 yt-recorder 导入项目的 uni_modules/ 下,再import引入插件
  2. 再重新打自定义基座,运行新基座到真机
  3. 允许麦克风权限
  4. 点击「开始」录音 →「停止」后复制页面上的文件路径
  5. 调用「播放」验证 PCM 回放与分贝

API 说明

以下方法均需先调用对应的 initRecord() / initPlayer(),并通过 recordLister / playerLinster 注册回调(方法名与源码保持一致)。

录音

方法 说明
initRecord() 初始化录音模块(建议在页面 onLoad 调用一次)
recordLister(lister: RecordListen) 注册录音状态与 PCM 帧回调
startRecording(para: RecordPara) 开始录音;内部会检查/申请麦克风权限
pauseRecording() 暂停录音
resumeRecording() 继续录音
stopRecording() 停止并保存文件,触发 onRecordComplete(path)
cancelRecording() 取消录音,删除临时文件,触发 onRecordCancelled

播放

方法 说明
initPlayer() 初始化播放模块
playerLinster(lister: PlayerLister) 注册播放进度与状态回调
play(para: PlayPara) 播放指定路径的 PCM 文件
pausePlayback() 暂停播放
resumePlayback() 继续播放
cancelPlayback() 取消播放

释放资源

方法 说明
release() 业务结束、且确认不在录音/播放时调用。重新使用录音、播放功能需要重新调用initRecord、recordLister、initPlayer、playerLinster

类型定义

RecordParastartRecording 参数)

字段 类型 默认值 说明
sampleRate number 16000 采样率(Hz),常见:8000 / 16000 / 44100
bitsPerSample number 16 位深,常见 16
numChannels number 1 声道数:1 单声道,2 立体声
format string "pcm" 输出格式:"pcm""wav"
isBackground boolean false Androidtrue 走前台服务后台录音;HarmonyOStrueAUDIO_RECORDING 长时任务;iOS:无效(靠 manifest audio 后台能力)

PlayParaplay 参数)

字段 类型 默认值 说明
path string 必填,本地音频文件绝对路径
sampleRate number 16000 须与录音时一致
bitsPerSample number 16 须与录音时一致
numChannels number 1 须与录音时一致
isBackground boolean false Androidtrue 走前台服务后台播放;HarmonyOS:本端无效,仅前台播放;iOS 无效(靠 manifest audio 后台能力

RecordListen(录音回调)

回调 参数 触发时机
onRecordBegin 开始录制
recordingAction value: ArrayBuffer \| number[], length: number 录制过程中多次回调 PCM 数据;HarmonyOSnumber[](跨桥二进制限制),Android/iOSArrayBuffer
onRecordPaused 暂停
onRecordCancelled 取消
onRecordComplete path: string 停止并成功保存

PlayerLister(播放回调)

回调 参数 触发时机
playingAction currentTime, totalTime, progress, decibelFS 播放中;progress 为 0~1,decibelFS 为 dBFS
onPlayerPaused 暂停
onPlayerCancelled 取消
onPlayerComplete 自然播放结束

类型可从插件导入:

import type { RecordListen, PlayerLister, RecordPara, PlayPara } from '@/uni_modules/yt-recorder'
// 或使用命名空间:RecorderManager.RecordListen 等

使用流程建议

initRecord() + recordLister()
        ↓
startRecording(RecordPara) → recordingAction 实时帧
        ↓
stopRecording() / cancelRecording()
        ↓
onRecordComplete(path) 得到文件路径
        ↓
initPlayer() + playerLinster()
        ↓
play({ path, sampleRate, ... })  // 播放参数须与录音一致
  1. 先注册监听,再开始录制/播放,避免漏掉首帧或状态回调。
  2. 播放时的 sampleRatebitsPerSamplenumChannels 必须与录音时一致,否则音调/速度异常。
  3. 录音文件名由插件内部以时间戳生成,完整路径在 onRecordComplete 中返回。
    • HarmonyOS{应用 cacheDir}/yt-recorder/{timestamp}.pcm.wav
    • Android / iOS:以各端实现为准(常见为应用沙箱或临时目录)
  4. 取消录音会删除临时文件;停止录音才会保留文件。
  5. HarmonyOS 使用 recordingAction 时,可将 number[] 转为 ArrayBuffer 再做波形等处理,并建议对 UI 回调做节流。

平台差异

能力 Android iOS HarmonyOS NEXT
前台录音 / 播放 ✅ 不支持后台播放
实时 PCM 帧回调 ArrayBuffer ArrayBuffer number[]
format: wav
isBackground 后台录音 ✅ 前台服务 manifest audio(参数无效) ✅ 长时任务 AUDIO_RECORDING
isBackground 后台播放 ✅ 前台服务 manifest audio(参数无效) ❌ 未实现
录音文件目录 各端实现 各端实现 {cacheDir}/yt-recorder/
采样率 按参数 按参数 映射为 8k / 16k / 44.1k / 48k 档位
采集位深 按参数 按参数 底层固定 16bit S16LE
麦克风权限 插件内申请 系统弹窗 插件内 requestPermissionsFromUser

常见问题

Q:没有声音或播放变调?
A:检查 play() 的采样率、位深、声道是否与 startRecording() 一致。

Q:用户拒绝麦克风权限?
A:Android 会在控制台输出提示;需引导用户到系统设置中手动开启麦克风权限后重试。

Q:recordingAction 回调很频繁,UI 卡顿?
A:在回调中只做轻量计算,波形绘制等建议节流或放到子线程处理(注意 UI 更新仍须在主线程)。

Q:Android 后台录音无效?
A:确认 startRecording({ isBackground: true }),并允许通知权限(Android 13+);部分机型需在系统设置中允许应用后台运行/自启动。

Q:HarmonyOS 后台录音无效或未触发 onRecordBegin
A:确认 startRecording({ isBackground: true })、已授予麦克风与后台运行相关权限;使用鸿蒙自定义基座真机调试;查看控制台是否出现 startBackgroundRunning / getWantAgent 错误码。后台任务启动失败时插件不会开始采集。

Q:HarmonyOS recordingAction 收到的是数字数组?
A:属预期行为。跨 UTS 插件桥接时 ArrayBuffer 字节无法可靠传递,鸿蒙端转为 number[],可用 new Uint8Array(value).buffer 还原(注意性能,建议节流)。

Q:HarmonyOS 播放变调或无声?
A:除采样率/声道与录音一致外,注意 WAV 会自动解析 fmt;裸 PCM 须与 play() 参数一致。非标准采样率会被映射到系统最近档位。


uniapp完整示例

<template>
    <scroll-view class="page" scroll-y="true">
        <!-- 录音 -->
        <view class="section">
            <text class="section-title">录音</text>
            <view class="card">
                <view class="status-row">
                    <text class="status-dot" :class="recordDotClass"></text>
                    <text class="status-text">{{ recordStatusText }}</text>
                </view>
                <text class="hint" v-if="recorderPath !== ''">最新文件(播放区将使用此路径)</text>
                <text class="path mono" selectable="true" v-if="recorderPath !== ''">{{ recorderPath }}</text>
                <text class="hint" v-else>停止录制成功后,路径会显示在此处。</text>

                <view class="btn-grid">
                    <view class="btn btn-primary" :class="{ disabled: !recordCanStart }" @click="onRecordStart">
                        <text class="btn-label">开始</text>
                    </view>
                    <view class="btn" @click="onRecordPause">
                        <text class="btn-label">暂停</text>
                    </view>
                    <view class="btn" @click="onRecordResume">
                        <text class="btn-label">继续</text>
                    </view>
                    <view class="btn" :class="{ disabled: !recordCanStop }" @click="onRecordStop">
                        <text class="btn-label">停止</text>
                    </view>
                    <view class="btn btn-danger" :class="{ disabled: !recordCanCancel }" @click="onRecordCancel">
                        <text class="btn-label">取消</text>
                    </view>
                </view>
            </view>
        </view>

        <!-- 播放 -->
        <view class="section">
            <text class="section-title">播放</text>
            <view class="card">
                <view class="status-row">
                    <text class="status-dot" :class="playerDotClass"></text>
                    <text class="status-text">{{ playerStatusText }}</text>
                </view>

                <view class="time-row">
                    <text class="time-main">{{ playCurrentTimeStr }}</text>
                    <text class="time-sep">/</text>
                    <text class="time-main muted">{{ playTotalTimeStr }}</text>
                </view>
                <text class="progress-text">进度 {{ playProgressPercentStr }}%</text>

                <view class="meter-row">
                    <text class="meter-title">实时分贝</text>
                    <text class="meter-value">{{ playDecibelStr }}</text>
                </view>

                <view class="btn-grid">
                    <view class="btn btn-primary" :class="{ disabled: !playCanPlay }" @click="onPlayStart">
                        <text class="btn-label">播放</text>
                    </view>
                    <view class="btn" @click="onPlayPause">
                        <text class="btn-label">暂停</text>
                    </view>
                    <view class="btn" @click="onPlayResume">
                        <text class="btn-label">继续</text>
                    </view>
                    <view class="btn btn-danger" @click="onPlayCancel">
                        <text class="btn-label">取消</text>
                    </view>
                </view>
            </view>
        </view>

        <view class="footer-space"></view>
    </scroll-view>
</template>

<script>
    import * as RecorderManager from '@/uni_modules/yt-recorder'

    export default {
        data() {
            return {
                recorderPath: '',
                recordStatusText: '待命',
                recordPhase: 'idle',

                playerStatusText: '未播放',
                playerPhase: 'idle',
                playbackActive: false,
                playCurrentTimeStr: '00:00',
                playTotalTimeStr: '00:00',
                playProgressPercentStr: '0',
                playDecibelStr: '—'
            }
        },
        computed: {
            recordDotClass() {
                if (this.recordPhase === 'recording') {
                    return 'dot-rec'
                }
                if (this.recordPhase === 'paused') {
                    return 'dot-warn'
                }
                return 'dot-idle'
            },
            recordCanStop() {
                return this.recordPhase === 'recording' || this.recordPhase === 'paused'
            },
            recordCanCancel() {
                return this.recordPhase === 'recording' || this.recordPhase === 'paused'
            },
            recordCanStart() {
                return this.recordPhase === 'idle'
            },
            playerDotClass() {
                if (this.playerPhase === 'playing') {
                    return 'dot-rec'
                }
                if (this.playerPhase === 'paused') {
                    return 'dot-warn'
                }
                return 'dot-idle'
            },
            playCanPlay() {
                return this.recorderPath !== '' && this.playerPhase === 'idle'
            }
        },
        onLoad() {
            RecorderManager.initRecord()
            RecorderManager.recordLister({
                onRecordBegin: () => {
                    this.recordPhase = 'recording'
                    this.recordStatusText = '录制中'
                },
                recordingAction: (_value, _length) => {
                    this.recordPhase = 'recording'
                    this.recordStatusText = '录制中(接收音频数据)'
                    if (_value instanceof ArrayBuffer) {
                        console.log(_value);
                    } else {
                        console.log(_value);
                    }

                },
                onRecordPaused: () => {
                    this.recordPhase = 'paused'
                    this.recordStatusText = '已暂停'
                },
                onRecordCancelled: () => {
                    this.recordPhase = 'idle'
                    this.recordStatusText = '已取消'
                },
                onRecordComplete: (path) => {
                    this.recordPhase = 'idle'
                    this.recordStatusText = '已完成'
                    this.recorderPath = path
                }
            })

            RecorderManager.initPlayer()
            RecorderManager.playerLinster({
                playingAction: (currentTime, totalTime, progress, decibelFS) => {
                    if (!this.playbackActive) {
                        return
                    }
                    this.playerPhase = 'playing'
                    this.playerStatusText = '播放中'
                    this.playCurrentTimeStr = this.formatTime(currentTime)
                    this.playTotalTimeStr = this.formatTime(totalTime)
                    let p = progress
                    if (p > 1) {
                        p = 1
                    }
                    if (p < 0) {
                        p = 0
                    }
                    const pct = p * 100
                    this.playProgressPercentStr = this.fixedNumber(pct, 0)
                    const db = typeof decibelFS === 'number' && !isNaN(decibelFS) ? decibelFS : 0
                    this.playDecibelStr = `${this.fixedNumber(db, 1)} dBFS`
                },
                onPlayerPaused: () => {
                    this.playerPhase = 'paused'
                    this.playerStatusText = '已暂停'
                },
                onPlayerCancelled: () => {
                    this.applyPlayerCancelledUi()
                },
                onPlayerComplete: () => {
                    this.playbackActive = false
                    this.resetPlayerUi(true)
                    this.playerStatusText = '播放完成'
                }
            })
        },
        methods: {
            fixedNumber(n, digits) {
                const p = Math.pow(10, digits)
                return (Math.round(n * p) / p).toString()
            },
            formatTime(s) {
                const m = Math.floor(s / 60)
                const sec = Math.floor(s % 60)
                const pad = (x) => x < 10 ? '0' + x : '' + x
                return pad(m) + ':' + pad(sec)
            },
            resetPlayerUi(keepTimes) {
                this.playerPhase = 'idle'
                if (!keepTimes) {
                    this.playCurrentTimeStr = '00:00'
                    this.playTotalTimeStr = '00:00'
                }
                this.playProgressPercentStr = '0'
                this.playDecibelStr = '—'
            },
            applyPlayerCancelledUi() {
                this.playbackActive = false
                this.resetPlayerUi(false)
                this.playerStatusText = '未播放'
            },
            onRecordStart() {
                if (!this.recordCanStart) {
                    return
                }
                this.recorderPath = ''
                this.recordPhase = 'recording'
                this.recordStatusText = '准备开始…'
                RecorderManager.startRecording({
                    format: "wav",
                    sampleRate: 16000,
                    bitsPerSample: 16,
                    numChannels: 1,
                    isBackground: true
                })
            },
            onRecordPause() {
                RecorderManager.pauseRecording()
            },
            onRecordResume() {
                RecorderManager.resumeRecording()
            },
            onRecordStop() {
                if (!this.recordCanStop) {
                    return
                }
                RecorderManager.stopRecording()
            },
            onRecordCancel() {
                if (!this.recordCanCancel) {
                    return
                }
                RecorderManager.cancelRecording()
            },
            onPlayStart() {
                if (!this.playCanPlay || this.recorderPath === '') {
                    return
                }
                this.playbackActive = true
                this.resetPlayerUi(false)
                this.playerStatusText = '缓冲…'
                RecorderManager.play({
                    path: this.recorderPath,
                    sampleRate: 16000, //需要和录音设置一致
                    bitsPerSample: 16, //需要和录音设置一致
                    numChannels: 1, //需要和录音设置一致
                    isBackground: true
                })
            },
            onPlayPause() {
                RecorderManager.pausePlayback()
            },
            onPlayResume() {
                RecorderManager.resumePlayback()
            },
            onPlayCancel() {
                this.applyPlayerCancelledUi()
                RecorderManager.cancelPlayback()
            }
        }
    }
</script>

<style>
    .page {
        flex: 1;
        background-color: #f0f2f5;
        padding: 24rpx;
        padding-bottom: 48rpx;
    }

    .section {
        margin-bottom: 32rpx;
    }

    .section-title {
        font-size: 34rpx;
        font-weight: 700;
        color: #1f2937;
        margin-bottom: 16rpx;
        margin-left: 4rpx;
    }

    .card {
        background-color: #ffffff;
        border-radius: 20rpx;
        padding: 28rpx;
        box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
        box-sizing: border-box;
    }

    .status-row {
        flex-direction: row;
        align-items: center;
        margin-bottom: 16rpx;
    }

    .status-dot {
        width: 16rpx;
        height: 16rpx;
        border-radius: 8rpx;
        margin-right: 12rpx;
    }

    .dot-idle {
        background-color: #94a3b8;
    }

    .dot-rec {
        background-color: #22c55e;
    }

    .dot-warn {
        background-color: #f59e0b;
    }

    .status-text {
        font-size: 30rpx;
        color: #111827;
        font-weight: 600;
    }

    .hint {
        font-size: 24rpx;
        color: #64748b;
        margin-bottom: 8rpx;
    }

    .path {
        font-size: 22rpx;
        color: #334155;
        line-height: 36rpx;
        margin-bottom: 20rpx;
    }

    .mono {
        font-family: monospace;
    }

    .btn-grid {
        display: flex;
        flex-direction: row;
        flex-wrap: wrap;
        margin-left: -12rpx;
        margin-right: -12rpx;
    }

    .btn {
        flex-grow: 1;
        /* min-width: 28%; */
        margin: 12rpx;
        padding: 20rpx 16rpx;
        border-radius: 14rpx;
        background-color: #e5e7eb;
        align-items: center;
        justify-content: center;
        box-sizing: border-box;
    }

    .btn-primary {
        background-color: #2563eb;
    }

    .btn-danger {
        background-color: #fecaca;
    }

    .btn.disabled {
        opacity: 0.45;
    }

    .btn-label {
        font-size: 26rpx;
        color: #111827;
        font-weight: 600;
    }

    .btn-primary .btn-label {
        color: #ffffff;
    }

    .btn-danger .btn-label {
        color: #991b1b;
    }

    .time-row {
        flex-direction: row;
        align-items: baseline;
        margin-top: 8rpx;
        margin-bottom: 8rpx;
    }

    .time-main {
        font-size: 44rpx;
        font-weight: 700;
        color: #0f172a;
        font-variant-numeric: tabular-nums;
    }

    .time-main.muted {
        color: #64748b;
        font-weight: 600;
        font-size: 36rpx;
    }

    .time-sep {
        font-size: 36rpx;
        color: #94a3b8;
        margin-left: 12rpx;
        margin-right: 12rpx;
    }

    .progress-text {
        font-size: 24rpx;
        color: #475569;
        font-variant-numeric: tabular-nums;
        margin-bottom: 20rpx;
    }

    .meter-row {
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        margin-bottom: 12rpx;
        padding: 16rpx 20rpx;
        border-radius: 14rpx;
        background-color: #f8fafc;
        box-sizing: border-box;
    }

    .meter-title {
        font-size: 26rpx;
        color: #475569;
        font-weight: 600;
    }

    .meter-value {
        font-size: 28rpx;
        color: #0f172a;
        font-weight: 700;
        font-variant-numeric: tabular-nums;
    }

    .footer-space {
        height: 48rpx;
    }
</style>

uniapp-x完整示例

<template>
    <scroll-view class="page" scroll-y="true">
        <!-- 录音 -->
        <view class="section">
            <text class="section-title">录音</text>
            <view class="card">
                <view class="status-row">
                    <text class="status-dot" :class="recordDotClass"></text>
                    <text class="status-text">{{ recordStatusText }}</text>
                </view>
                <text class="hint" v-if="recorderPath !== ''">最新文件(播放区将使用此路径)</text>
                <text class="path mono" selectable="true" v-if="recorderPath !== ''">{{ recorderPath }}</text>
                <text class="hint" v-else>停止录制成功后,路径会显示在此处。</text>

                <view class="btn-grid">
                    <view class="btn btn-primary" :class="{ disabled: !recordCanStart }" @click="onRecordStart">
                        <text class="btn-label">开始</text>
                    </view>
                    <view class="btn" @click="onRecordPause">
                        <text class="btn-label">暂停</text>
                    </view>
                    <view class="btn" @click="onRecordResume">
                        <text class="btn-label">继续</text>
                    </view>
                    <view class="btn" :class="{ disabled: !recordCanStop }" @click="onRecordStop">
                        <text class="btn-label">停止</text>
                    </view>
                    <view class="btn btn-danger" :class="{ disabled: !recordCanCancel }" @click="onRecordCancel">
                        <text class="btn-label">取消</text>
                    </view>
                </view>
            </view>
        </view>

        <!-- 播放 -->
        <view class="section">
            <text class="section-title">播放</text>
            <view class="card">
                <view class="status-row">
                    <text class="status-dot" :class="playerDotClass"></text>
                    <text class="status-text">{{ playerStatusText }}</text>
                </view>

                <view class="time-row">
                    <text class="time-main">{{ playCurrentTimeStr }}</text>
                    <text class="time-sep">/</text>
                    <text class="time-main muted">{{ playTotalTimeStr }}</text>
                </view>
                <text class="progress-text">进度 {{ playProgressPercentStr }}%</text>

                <view class="meter-row">
                    <text class="meter-title">实时分贝</text>
                    <text class="meter-value">{{ playDecibelStr }}</text>
                </view>

                <view class="btn-grid">
                    <view class="btn btn-primary" :class="{ disabled: !playCanPlay }" @click="onPlayStart">
                        <text class="btn-label">播放</text>
                    </view>
                    <view class="btn" @click="onPlayPause">
                        <text class="btn-label">暂停</text>
                    </view>
                    <view class="btn" @click="onPlayResume">
                        <text class="btn-label">继续</text>
                    </view>
                    <view class="btn btn-danger" @click="onPlayCancel">
                        <text class="btn-label">取消</text>
                    </view>
                </view>
            </view>
        </view>

        <view class="footer-space"></view>
    </scroll-view>
</template>

<script>
    import * as RecorderManager from '@/uni_modules/yt-recorder'

    export default {
        data() {
            return {
                recorderPath: '',
                recordStatusText: '待命',
                recordPhase: 'idle',

                playerStatusText: '未播放',
                playerPhase: 'idle',
                playbackActive: false,
                playCurrentTimeStr: '00:00',
                playTotalTimeStr: '00:00',
                playProgressPercentStr: '0',
                playDecibelStr: '—'
            }
        },
        computed: {
            recordDotClass() {
                if (this.recordPhase === 'recording') {
                    return 'dot-rec'
                }
                if (this.recordPhase === 'paused') {
                    return 'dot-warn'
                }
                return 'dot-idle'
            },
            recordCanStop() {
                return this.recordPhase === 'recording' || this.recordPhase === 'paused'
            },
            recordCanCancel() {
                return this.recordPhase === 'recording' || this.recordPhase === 'paused'
            },
            recordCanStart() {
                return this.recordPhase === 'idle'
            },
            playerDotClass() {
                if (this.playerPhase === 'playing') {
                    return 'dot-rec'
                }
                if (this.playerPhase === 'paused') {
                    return 'dot-warn'
                }
                return 'dot-idle'
            },
            playCanPlay() {
                return this.recorderPath !== '' && this.playerPhase === 'idle'
            }
        },
        onLoad() {
            RecorderManager.initRecord()
            RecorderManager.recordLister({
                onRecordBegin: () => {
                    this.recordPhase = 'recording'
                    this.recordStatusText = '录制中'
                },
                recordingAction: (_value, _length) => {
                    this.recordPhase = 'recording'
                    this.recordStatusText = '录制中(接收音频数据)'
                    if (_value instanceof ArrayBuffer) {
                        console.log('pcm len', _length, new Uint8Array(_value).slice(0, 8))
                    } else {
                        console.log(_value);
                    }
                },
                onRecordPaused: () => {
                    this.recordPhase = 'paused'
                    this.recordStatusText = '已暂停'
                },
                onRecordCancelled: () => {
                    this.recordPhase = 'idle'
                    this.recordStatusText = '已取消'
                },
                onRecordComplete: (path) => {
                    this.recordPhase = 'idle'
                    this.recordStatusText = '已完成'
                    this.recorderPath = path
                }
            } as RecorderManager.RecordListen)

            RecorderManager.initPlayer()
            RecorderManager.playerLinster({
                playingAction: (currentTime, totalTime, progress, decibelFS) => {
                    if (!this.playbackActive) {
                        return
                    }
                    // this.playerPhase = 'playing'
                    this.playerStatusText = '播放中'
                    this.playCurrentTimeStr = this.formatTime(currentTime)
                    this.playTotalTimeStr = this.formatTime(totalTime)
                    let p = progress
                    if (p > 1) {
                        p = 1
                    }
                    if (p < 0) {
                        p = 0
                    }
                    const pct = p * 100
                    this.playProgressPercentStr = this.fixedNumber(pct, 0)
                    const db = typeof decibelFS === 'number' && !isNaN(decibelFS) ? decibelFS : 0
                    this.playDecibelStr = `${this.fixedNumber(db, 1)} dBFS`
                },
                onPlayerPaused: () => {
                    this.playerPhase = 'paused'
                    this.playerStatusText = '已暂停'
                },
                onPlayerCancelled: () => {
                    this.applyPlayerCancelledUi()
                },
                onPlayerComplete: () => {
                    this.playbackActive = false
                    this.resetPlayerUi(true)
                    this.playerStatusText = '播放完成'
                }
            } as RecorderManager.PlayerLister)
        },
        methods: {
            fixedNumber(n : number, digits : number) {
                const p = Math.pow(10, digits)
                return (Math.round(n * p) / p).toString()
            },
            formatTime(s : number) : string {
                const m = Math.floor(s / 60)
                const sec = Math.floor(s % 60)
                const pad = (x : number) => x < 10 ? '0' + x : '' + x
                return pad(m) + ':' + pad(sec)
            },
            resetPlayerUi(keepTimes : boolean) {
                this.playerPhase = 'idle'
                if (!keepTimes) {
                    this.playCurrentTimeStr = '00:00'
                    this.playTotalTimeStr = '00:00'
                }
                this.playProgressPercentStr = '0'
                this.playDecibelStr = '—'
            },
            applyPlayerCancelledUi() {
                this.playbackActive = false
                this.resetPlayerUi(false)
                this.playerStatusText = '未播放'
            },
            onRecordStart() {
                if (!this.recordCanStart) {
                    return
                }
                this.recorderPath = ''
                this.recordPhase = 'recording'
                this.recordStatusText = '准备开始…'
                RecorderManager.startRecording({
                    format: "pcm",
                    sampleRate: 16000,
                    bitsPerSample: 16,
                    numChannels: 1,
                    isBackground: true
                } as RecorderManager.RecordPara)
            },
            onRecordPause() {
                RecorderManager.pauseRecording()
            },
            onRecordResume() {
                RecorderManager.resumeRecording()
            },
            onRecordStop() {
                if (!this.recordCanStop) {
                    return
                }
                RecorderManager.stopRecording()
            },
            onRecordCancel() {
                if (!this.recordCanCancel) {
                    return
                }
                RecorderManager.cancelRecording()
            },
            onPlayStart() {
                if (!this.playCanPlay || this.recorderPath === '') {
                    return
                }
                this.playbackActive = true
                this.resetPlayerUi(false)
                this.playerStatusText = '缓冲…'
                RecorderManager.play({
                    path: this.recorderPath,
                    sampleRate: 16000,
                    bitsPerSample: 16,
                    numChannels: 1,
                    isBackground: false
                } as RecorderManager.PlayPara)
            },
            onPlayPause() {
                RecorderManager.pausePlayback()
            },
            onPlayResume() {
                RecorderManager.resumePlayback()
            },
            onPlayCancel() {
                this.applyPlayerCancelledUi()
                RecorderManager.cancelPlayback()
            }
        }
    }
</script>

<style>
    .page {
        flex: 1;
        background-color: #f0f2f5;
        padding: 24rpx;
        padding-bottom: 48rpx;
    }

    .section {
        margin-bottom: 32rpx;
    }

    .section-title {
        font-size: 34rpx;
        font-weight: 700;
        color: #1f2937;
        margin-bottom: 16rpx;
        margin-left: 4rpx;
    }

    .card {
        background-color: #ffffff;
        border-radius: 20rpx;
        padding: 28rpx;
        box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
    }

    .status-row {
        flex-direction: row;
        align-items: center;
        margin-bottom: 16rpx;
    }

    .status-dot {
        width: 16rpx;
        height: 16rpx;
        border-radius: 8rpx;
        margin-right: 12rpx;
    }

    .dot-idle {
        background-color: #94a3b8;
    }

    .dot-rec {
        background-color: #22c55e;
    }

    .dot-warn {
        background-color: #f59e0b;
    }

    .status-text {
        font-size: 30rpx;
        color: #111827;
        font-weight: 600;
    }

    .hint {
        font-size: 24rpx;
        color: #64748b;
        margin-bottom: 8rpx;
    }

    .path {
        font-size: 22rpx;
        color: #334155;
        line-height: 36rpx;
        margin-bottom: 20rpx;
    }

    .mono {
        font-family: monospace;
    }

    .btn-grid {
        flex-direction: row;
        flex-wrap: wrap;
        margin-left: -12rpx;
        margin-right: -12rpx;
    }

    .btn {
        flex-grow: 1;
        min-width: 28%;
        margin: 12rpx;
        padding: 20rpx 16rpx;
        border-radius: 14rpx;
        background-color: #e5e7eb;
        align-items: center;
        justify-content: center;
    }

    .btn-primary {
        background-color: #2563eb;
    }

    .btn-danger {
        background-color: #fecaca;
    }

    .btn.disabled {
        opacity: 0.45;
    }

    .btn-label {
        font-size: 26rpx;
        color: #111827;
        font-weight: 600;
    }

    .btn-primary .btn-label {
        color: #ffffff;
    }

    .btn-danger .btn-label {
        color: #991b1b;
    }

    .time-row {
        flex-direction: row;
        align-items: baseline;
        margin-top: 8rpx;
        margin-bottom: 8rpx;
    }

    .time-main {
        font-size: 44rpx;
        font-weight: 700;
        color: #0f172a;
        font-variant-numeric: tabular-nums;
    }

    .time-main.muted {
        color: #64748b;
        font-weight: 600;
        font-size: 36rpx;
    }

    .time-sep {
        font-size: 36rpx;
        color: #94a3b8;
        margin-left: 12rpx;
        margin-right: 12rpx;
    }

    .progress-text {
        font-size: 24rpx;
        color: #475569;
        font-variant-numeric: tabular-nums;
        margin-bottom: 20rpx;
    }

    .meter-row {
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        margin-bottom: 12rpx;
        padding: 16rpx 20rpx;
        border-radius: 14rpx;
        background-color: #f8fafc;
    }

    .meter-title {
        font-size: 26rpx;
        color: #475569;
        font-weight: 600;
    }

    .meter-value {
        font-size: 28rpx;
        color: #0f172a;
        font-weight: 700;
        font-variant-numeric: tabular-nums;
    }

    .footer-space {
        height: 48rpx;
    }
</style>

隐私、权限声明

1. 本插件需要申请的系统权限列表:

iOS:NSMicrophoneUsageDescription Android:<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不采集任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率: