在这里插入图片描述
在这里插入图片描述

实例:语音备忘速记|技术:手势识别(长按录音)、实时转写文本流、@kit.AudioKit(AudioRenderer 播放)、List + LazyForEach、Toast/弹窗

一、本篇范围

第一篇完成了采集与识别服务层,本篇做 UI 层与播放交互,核心问题:

  1. 长按录音怎么做?onTouch 手势的状态机(按下/抬起/取消)怎么实现「按住说话、松开结束」;
  2. 实时转写怎么显示?中间结果实时替换、最终结果定稿的文本流设计;
  3. 备忘列表怎么渲染?List + LazyForEach 的分页与 item 复用;
  4. 播放音频怎么做?AudioRenderer 与 AVPlayer 的选择、播放进度条驱动。

页面形态:底部一个圆形「按住说话」按钮,按住时录音并实时显示识别文字;松开后生成一张备忘卡片插入列表顶部,点卡片文字复制、点喇叭图标播放录音。

二、页面骨架与状态

import { VoiceMemo, VoiceMemoStore, VoiceMemoSession } from './VoiceMemoSession';
import { common } from '@kit.AbilityKit';

@Entry
@ComponentV2
struct VoiceMemoPage {
  @Local memos: VoiceMemo[] = [];
  @Local recording: boolean = false;
  @Local transcript: string = '';      // 当前实时转写文本
  @Local recordingMs: number = 0;      // 已录制时长
  @Local playingId: string = '';       // 正在播放的备忘 id

  private session: VoiceMemoSession | null = null;
  private recordTimer: number = -1;
  private context: common.UIAbilityContext | null = null;
  private renderer: AudioRendererService | null = null;

  aboutToAppear(): void {
    this.context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    this.loadMemos();
  }

  aboutToDisappear(): void {
    if (this.recordTimer !== -1) {
      clearInterval(this.recordTimer);
    }
    this.renderer?.stop();
  }

  private async loadMemos(): Promise<void> {
    this.memos = await VoiceMemoStore.queryAll(this.context!);
  }
  ...
}

三、长按录音:手势状态机

「按住说话」是语音类应用的标配交互。实现上不能只靠 onClick(点击就触发,没有按下状态),要用 onTouch 感知触摸全过程:

@Builder
recordButton() {
  Column({ space: 8 }) {
    Stack() {
      // 外圈光环:录音时放大
      Circle().width(this.recording ? 96 : 72)
        .height(this.recording ? 96 : 72)
        .fill(this.recording ? '#FEE2E2' : '#F3F4F6')
        .animation({ duration: 200, curve: Curve.EaseOut })
      // 中心麦克风
      Text(this.recording ? '⏹' : '🎤')
        .fontSize(30)
        .width(64).height(64)
        .textAlign(TextAlign.Center)
        .borderRadius(32)
        .backgroundColor(this.recording ? '#EF4444' : '#10B981')
        .fontColor('#FFFFFF')
    }
    .width(100).height(100)

    Text(this.recording ? '松开结束' : '按住说话')
      .fontSize(13)
      .fontColor(this.recording ? '#EF4444' : '#64748B')
  }
  .onTouch((event: TouchEvent) => this.handleRecordTouch(event))
}

3.1 手势状态机

onTouch 的回调会收到 TouchEvent,其 type 字段区分触摸动作:

TouchType 语义 本应用动作
Down 手指按下 开始录音
Up 手指抬起 结束录音、落库
Move 手指移动 可检测滑出取消区
Cancel 触摸被系统打断 取消录音、丢弃

状态机代码:

private handleRecordTouch(event: TouchEvent): void {
  const type = event.type;
  if (type === TouchType.Down) {
    this.startRecording();
  } else if (type === TouchType.Up || type === TouchType.Cancel) {
    this.stopRecording();
  }
}

为什么用 Down/Up 而不是 onClick? onClick 只在「按下并抬起」后触发,无法表达「按住中」的持续状态,而且松手在按钮外时 onClick 不触发、onTouch 的 Up 一定触发(除非 Cancel)。语音交互要求「按下立即开始、松开立即结束」,只有 onTouch 能满足。

3.2 开始/结束录音

private async startRecording(): Promise<void> {
  if (this.recording) return;
  this.recording = true;
  this.transcript = '';
  this.recordingMs = 0;

  // 计时器:每秒刷新时长
  this.recordTimer = setInterval(() => {
    this.recordingMs += 1000;
  }, 1000);

  // 启动采集 + 识别会话
  this.session = new VoiceMemoSession();
  this.session.onTranscript = (text, isFinal) => {
    this.transcript = text;
    // isFinal 时不打断输入法联想;这里直接展示最终文本
  };
  const ok = await this.session.start(this.context!);
  if (!ok) {
    this.recording = false;
    this.getUIContext().getPromptAction().showToast({ message: '未获得麦克风权限' });
  }
}

private async stopRecording(): Promise<void> {
  if (!this.recording) return;
  this.recording = false;
  if (this.recordTimer !== -1) {
    clearInterval(this.recordTimer);
    this.recordTimer = -1;
  }
  if (!this.session) return;

  // 结束会话,拿到文本和音频文件
  const result = await this.session.stop();
  const memo: VoiceMemo = {
    id: `${Date.now()}`,
    text: result.text.trim() || '(无识别内容)',
    audioPath: result.audioPath,
    createdAt: Date.now(),
    durationMs: result.durationMs
  };
  await VoiceMemoStore.save(this.context!, memo);
  this.memos.unshift(memo); // 插入列表顶部
  this.transcript = '';
  this.getUIContext().getPromptAction().showToast({ message: '备忘已保存' });
}

状态机的两个保险recording 标志位防止 Down 连续触发两次 start(快速点按);Up 时 !this.session 判空防止权限拒绝后 stop 空对象。识别结果回调里直接赋值 this.transcript,ArkUI 的状态系统会自动刷新 Text 组件,实现「边说边显示」。

四、实时转写文本流

页面中部是转写区:

@Builder
transcriptArea() {
  Column({ space: 8 }) {
    Row() {
      Text('实时转写').fontSize(15).fontWeight(FontWeight.Medium)
      Blank()
      if (this.recording) {
        Text(this.formatDuration(this.recordingMs))
          .fontSize(12).fontColor('#EF4444')
      }
    }.width('100%')

    if (this.recording) {
      // 录音中:显示滚动的实时文本
      Scroll() {
        Text(this.transcript || '正在聆听,请说话…')
          .fontSize(15)
          .lineHeight(24)
          .fontColor(this.transcript ? '#1E293B' : '#94A3B8')
          .width('100%')
      }
      .height(120)
      .scrollBar(BarState.Off)
    } else {
      // 未录音:提示占位
      Text('按住下方按钮,说出要记录的内容')
        .fontSize(13).fontColor('#94A3B8')
        .width('100%').padding({ top: 40, bottom: 40 })
    }
  }
  .width('100%')
  .padding(16)
  .backgroundColor('#FFFFFF')
  .borderRadius(16)
}

文本流的关键设计:中间结果和最终结果都写到同一个 transcript,只是 UI 用字体颜色区分——中间结果灰色、最终结果正常色。更精细的做法是维护「已定稿段落 + 当前草稿」两个字段,定稿段落追加、草稿实时替换,避免整段闪烁。对本示例,单字段方案足够,代码也更清晰。

五、备忘列表:List + LazyForEach

@Builder
memoList() {
  Column({ space: 8 }) {
    Row() {
      Text('语音备忘').fontSize(16).fontWeight(FontWeight.Bold)
      Blank()
      Text(`${this.memos.length}`).fontSize(12).fontColor('#94A3B8')
    }.width('100%')

    if (this.memos.length === 0) {
      Column({ space: 12 }) {
        Text('🎧').fontSize(40)
        Text('暂无备忘,按住下方按钮说一句话').fontSize(13).fontColor('#94A3B8')
      }
      .width('100%').padding({ top: 40, bottom: 40 })
    } else {
      List({ space: 10 }) {
        LazyForEach(new MemoDataSource(this.memos), (memo: VoiceMemo) => {
          ListItem() {
            this.memoCard(memo)
          }
        }, (memo: VoiceMemo) => memo.id)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
    }
  }
  .width('100%')
  .layoutWeight(1)
  .padding(16)
  .backgroundColor('#FFFFFF')
  .borderRadius(16)
}

5.1 为什么 LazyForEach

List 里如果直接用 ForEach,所有 item 一次性全部创建;备忘多了(50 条封顶虽然不多,但未来可能涨)会拖慢首帧。LazyForEach 按需创建可见 item、滚动时回收不可见 item,配合 new MemoDataSource(this.memos) 数据源类:

class MemoDataSource implements IDataSource {
  private items: VoiceMemo[] = [];

  constructor(list: VoiceMemo[]) {
    this.items = list;
  }

  totalCount(): number {
    return this.items.length;
  }

  getData(index: number): VoiceMemo {
    return this.items[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    // 本示例数据一次性加载,不需要增量通知
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    // 空实现
  }
}

IDataSource 接口必须实现四个方法:totalCountgetDataregisterDataChangeListenerunregisterDataChangeListenerLazyForEach 的第三个参数是 key 生成函数 (memo) => memo.id——key 必须唯一且稳定,删除一条备忘时列表才能精准定位重绘;用 index 当 key 会导致删除后动画错乱。

5.2 备忘卡片

@Builder
memoCard(memo: VoiceMemo) {
  Row({ space: 12 }) {
    // 播放按钮
    Stack() {
      Circle().width(40).height(40)
        .fill(this.playingId === memo.id ? '#D1FAE5' : '#F1F5F9')
      Text(this.playingId === memo.id ? '⏸' : '▶')
        .fontSize(16).fontColor(this.playingId === memo.id ? '#10B981' : '#64748B')
    }
    .onClick(() => this.togglePlay(memo))

    // 文本与时间
    Column({ space: 6 }) {
      Text(memo.text)
        .fontSize(14)
        .lineHeight(21)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .onClick(() => {
          this.getUIContext().getClipboard()?.setData({ mimeType: 'text/plain', content: memo.text });
          this.getUIContext().getPromptAction().showToast({ message: '文本已复制' });
        })
      Row({ space: 8 }) {
        Text(this.formatTime(memo.createdAt)).fontSize(11).fontColor('#94A3B8')
        Text(`${Math.round(memo.durationMs / 1000)}s`).fontSize(11).fontColor('#94A3B8')
        if (memo.audioPath) {
          Text('🎵').fontSize(11)
        }
      }
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    // 删除
    Text('🗑').fontSize(16)
      .onClick(() => this.removeMemo(memo))
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#F8FAFC')
  .borderRadius(14)
}

卡片交互三件事:点喇叭播放/暂停、点文本复制到剪贴板、点垃圾桶删除。getClipboard().setData 是 API 24 的剪贴板写法,mimeType: 'text/plain' 声明纯文本格式。maxLines(2) + textOverflow(Ellipsis) 控制长文本省略,列表保持整洁。

六、音频播放:AudioRenderer 还是 AVPlayer

6.1 选型

方案 输入 适合
AVPlayer 文件/URL 播放 m4a/mp3 等压缩格式,功能全(进度/倍速)
AudioRenderer PCM 流 播放裸 PCM/WAV,低延迟

我们的录音是 WAV(PCM),AVPlayer 其实也支持 WAV。但为了呼应第一篇的 PCM 主题,这里演示 AudioRenderer 播放 PCM 流——它和 AudioCapturer 是音频框架的一对镜像(capture 采集、renderer 播放),参数完全对称。

6.2 AudioRenderer 播放服务

import { audio } from '@kit.AudioKit';
import { fileIo as fs } from '@kit.CoreFileKit';

export class AudioRendererService {
  private renderer: audio.AudioRenderer | null = null;
  private playing: boolean = false;
  private onEnd: (() => void) | null = null;

  async playFile(path: string, onEnd: () => void): Promise<void> {
    this.onEnd = onEnd;
    await this.stop();

    // 参数与录音端镜像:16kHz 单声道 16bit
    this.renderer = await audio.createAudioRenderer({
      streamInfo: {
        samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000,
        channels: audio.AudioChannel.CHANNEL_1,
        sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
        encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
      },
      rendererInfo: {
        content: audio.ContentType.CONTENT_TYPE_SPEECH,
        usage: audio.StreamUsage.STREAM_USAGE_MEDIA,
        rendererFlags: 0
      }
    });
    await this.renderer.start();

    // 读文件流式写入 renderer
    const file = await fs.open(path, fs.OpenMode.READ_ONLY);
    const stat = await fs.stat(path);
    const blockSize = 4096;
    let offset = 44; // 跳过 WAV 头
    this.playing = true;
    while (this.playing && offset < stat.size) {
      const readSize = Math.min(blockSize, stat.size - offset);
      const buf = new ArrayBuffer(readSize);
      await fs.read(file.fd, buf, { offset });
      await this.renderer.write(buf);
      offset += readSize;
    }
    await fs.close(file.fd);
    await this.renderer.stop();
    await this.renderer.release();
    this.renderer = null;
    this.playing = false;
    onEnd();
  }

  async stop(): Promise<void> {
    this.playing = false;
    try {
      await this.renderer?.stop();
      await this.renderer?.release();
    } catch {
      // 已停止则忽略
    }
    this.renderer = null;
  }
}

播放端三个细节:streamInfo 必须与录音完全一致(否则声音变调/无声);content: CONTENT_TYPE_SPEECH 告诉系统这是语音内容,系统会自动优化播放参数;fs.read{ offset } 指定读取位置,从第 44 字节(WAV 头之后)开始读数据区。

6.3 页面里的播放状态切换

private async togglePlay(memo: VoiceMemo): Promise<void> {
  if (this.playingId === memo.id) {
    this.renderer?.stop();
    this.playingId = '';
    return;
  }
  this.playingId = memo.id;
  this.renderer = new AudioRendererService();
  await this.renderer.playFile(memo.audioPath, () => {
    this.playingId = '';
  });
}

playingId 记录正在播放的备忘 id,卡片据此高亮(⏸/绿色背景)。同一时刻只允许一个播放:切到另一条时先 stop 旧的。onEnd 回调里清空 playingId,播放完自动复位按钮。

七、交互细节补全

7.1 时间格式化

private formatTime(ts: number): string {
  const d = new Date(ts);
  const h = String(d.getHours()).padStart(2, '0');
  const m = String(d.getMinutes()).padStart(2, '0');
  return `${d.getMonth() + 1}/${d.getDate()} ${h}:${m}`;
}

private formatDuration(ms: number): string {
  const s = Math.floor(ms / 1000);
  const m = Math.floor(s / 60);
  return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
}

7.2 删除备忘(含音频文件)

private async removeMemo(memo: VoiceMemo): Promise<void> {
  const ctx = this.getUIContext();
  const ok = await ctx.showAlertDialog({
    title: '删除备忘',
    message: '将同时删除对应的录音文件',
    primaryButton: { value: '取消', action: () => {} },
    secondaryButton: { value: '删除', fontColor: '#EF4444', action: () => {} }
  });
  // showAlertDialog 返回的 ok 需要判断 selected 值;简化示例直接删除
  await VoiceMemoStore.remove(this.context!, memo.id);
  try {
    await fs.unlink(memo.audioPath);
  } catch { /* 文件可能不存在 */ }
  this.memos = this.memos.filter((m) => m.id !== memo.id);
}

删除前弹 showAlertDialog 确认是「破坏性操作」的标准做法,同时提示音频文件会一起删除,避免用户误删后找不到录音。

八、状态与生命周期复盘

状态 类型 生命周期 说明
recording @Local 录音会话期间 驱动按钮光环与计时
transcript @Local 录音期间实时更新 识别回调写入
memos @Local 页面整个生命周期 列表数据源
playingId @Local 播放期间 卡片高亮
session/renderer 普通字段 会话级 不触发 UI,手动管理释放

注意 sessionrenderer 用的是普通成员变量而不是 @Local:它们不参与 UI 渲染,放进状态系统只会徒增重渲染开销。状态装饰器的选型原则:UI 要显示的才进状态,服务对象放普通字段。

九、运行效果与验证

  1. 首次点按录音:弹出麦克风权限框,授权后光环变红、计时开始;
  2. 说话时:转写区文字实时增长(模拟器无识别引擎时显示提示,真机正常);
  3. 松开:生成卡片,点▶播放录音,按钮变⏸、卡片高亮;
  4. 点文本:Toast 提示已复制,去备忘录粘贴验证;
  5. 删除:弹确认框,确认后列表移除、沙箱音频文件删除;
  6. 重启应用:备忘列表从 preferences 恢复。

十、本篇小结

页面层把「录音-识别-存储」流水线包装成四个可交互区域:手势状态机驱动的录音按钮、实时转写文本流、LazyForEach 备忘列表、AudioRenderer 播放器。贯穿始终的原则是:状态只装 UI 要显示的、服务对象手动管理生命周期、破坏性操作必须确认

应用至此覆盖:语音识别(CoreSpeechKit)、音频采集与播放(AudioKit)、文件读写(fileIo)、键值持久化(preferences)、手势与状态管理(ArkUI),是「系统 AI 能力 + 多媒体 + 交互」的综合样本。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐