文本转语音?我们来盘一盘(鸿蒙开发)

最近在开发鸿蒙app时刚好有文本转语音的需求,那今天我们来聊一聊如何实现

废话不多说,我们直接上代码,边看代码边聊

📚 官方参考文档

官方参考文档:文档中心

🚀 初始实现

一开始我直接将官方示例改造成工具类来使用时:

typescript 复制代码
import { textToSpeech } from '@kit.CoreSpeechKit';
import type { BusinessError } from '@kit.BasicServicesKit';

class TextSpeaker {
  ttsEngine: textToSpeech.TextToSpeechEngine;
  createCount: number = 0;

  createByCallback(originalText: string) {
    let extraParam: Record<string, string> = { 'style': 'interaction-broadcast', 'locate': 'CN', 'name': 'EngineName' };
    let initParamsInfo: textToSpeech.CreateEngineParams = {
      language: 'zh-CN',
      person: 0,
      online: 1,
      extraParams: extraParam
    };
    textToSpeech.createEngine(initParamsInfo,
      (err: BusinessError, textToSpeechEngine: textToSpeech.TextToSpeechEngine) => {
        if (!err) {
          this.ttsEngine = textToSpeechEngine;
          this.createCount++;
          this.speak(originalText);
        } else {
          console.log('Fail to createEngine.');
        }
      });
  };

  speak(originalText: string) {
    let speakListener: textToSpeech.SpeakListener = {
      onStart(requestId: string, response: textToSpeech.StartResponse) {
        console.log('onStart');
        AppStorage.setOrCreate('isPlaying', true);
      },
      onComplete(requestId: string, response: textToSpeech.CompleteResponse) {
        console.log('onComplete');
        AppStorage.setOrCreate('isPlaying', true);
      },
      onStop(requestId: string, response: textToSpeech.StopResponse) {
        console.log('onStop');
        AppStorage.setOrCreate('isPlaying', false);
        this.ttsEngine?.shutdown();
      },
      onData(requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) {
        console.log('onData');
      },
      onError(requestId: string, errorCode: number, errorMessage: string) {
        console.log('onError');
        AppStorage.setOrCreate('isPlaying', false);
        this.ttsEngine?.shutdown();
      }
    };
    this.ttsEngine?.setListener(speakListener);
    let extraParam: Record<string, string | number> = {
      'queueMode': 0,
      'speed': 1,
      'volume': 2,
      'pitch': 1,
      'languageContext': 'zh-CN',
      'audioType': 'pcm',
      'soundChannel': 3,
      'playType': 1
    }
    let speakParams: textToSpeech.SpeakParams = {
      requestId: '123456-a',
      extraParams: extraParam
    };
    this.ttsEngine?.speak(originalText, speakParams);
  };

  shutdown() {
    try {
      const isBusy = this.ttsEngine?.isBusy();
      if (isBusy) {
        this.ttsEngine?.stop();
        AppStorage.setOrCreate('isPlaying', false);
        this.ttsEngine?.shutdown();
      }
    } catch (err) {
      const error: BusinessError = err as BusinessError;
      console.error(`The ttsEngine invoke error, the code is ${error.code}, the message is ${error.message}`);
    }
  }
}

export const textSpeaker = new TextSpeaker()

❌ 遇到的问题

存在以下报错:

less 复制代码
日志显示如下错误:

08-25 12:23:46.643   4505-4771     C02100/ngtuzhi...awei/AI_INNER  hongtuzhi...a.huawei  E     [tts_speak_async_callback.cpp(OnError:80)]parse result OnError code 50011

08-25 12:23:46.644   4505-4505     C02100/ngtuzhi...uawei/AI_TEXT  hongtuzhi...a.huawei  I     [text_to_speech.cpp(operator():1866)]onError code: 50011 messge: service not initialized utteranceId:

08-25 12:23:46.644   4505-4505     A03D00/ngtuzhi....huawei/JSAPP  hongtuzhi...a.huawei  I     onError

08-25 12:23:47.432   4505-5735     C02504/ngtuzhi...wei/thp_extra  hongtuzhi...a.huawei  I     ThpExtraRunCommand[107]ver:5.0.9 ThpExtraRunCommand, cmd:THP_UpdateViewsLocation, param:thp#Location#1068,2519,1163,2681#851,2519,946,2681#590,2519,729,2681#278,2519,468,2681#61,2519,156,2681#1089,1566,1170,1647#1035,1321,1170,1445#1062,189,1170,297

08-25 12:23:49.010   4505-4505     C01406/ngtuzhi...awei/OHOS::RS  hongtuzhi...a.huawei  E     FlushImplicitTransaction return, [renderServiceClient_:1, transactionData empty:1]

🔍 问题原因与现象复盘

  • 从日志中看两次回调 onError code: 50011,message: service not initialized,说明第二次点击"播放"时,底层 TTS 服务未处于可用状态(通常是引擎已被关闭或 speak 在引擎尚未初始化完成时调用)。
  • 现有实现中有两个隐患:
    1. 在 SpeakListener 的 onStop/onError 回调里调用了 this.ttsEngine?.shutdown(),会把引擎直接关掉,导致再次点击时复用到了已关闭的引擎句柄,从而出现 service not initialized。
    2. createByCallback 每次都创建引擎,但在引擎初始化异步回调返回前如果再次点击,可能出现竞态或复用未初始化完成的引擎。

✅ 改进的工具类

更严格的TTS 引擎的生命周期管理

1. 引擎单例与创建状态

  • 将 ttsEngine 改为可选属性,并新增 creating 标志,防止多次快速点击导致重复创建或竞态。
  • 如果已有 ttsEngine,直接复用播放,不再重复创建。
  • 如果正在创建中,直接返回,等待首次创建完成后播放。

2. 播放监听器

  • SpeakListener 全部改为箭头函数,规避 this 指向问题。
  • 移除了 onStop/onError 中的 shutdown 调用,避免把引擎误关导致下次播放 service not initialized。
  • onStart/onComplete 仅维护 AppStorage 中的 isPlaying 状态。

3. speak 调用兜底

  • 如果 ttsEngine 尚未就绪,speak 会回退调用 createByCallback 再次创建,避免出现"还没初始化就 speak"的情况。

4. shutdown 行为

  • shutdown() 现在会:若忙则先 stop,再 shutdown,并将 this.ttsEngine 置为 undefined,确保下次点击会重新创建引擎;同时重置 isPlaying=false。

5. 异常处理

  • createEngine 失败和 speak 失败会打印错误并复位 isPlaying,避免 UI 卡在播放中状态。
typescript 复制代码
import { textToSpeech } from '@kit.CoreSpeechKit';
import type { BusinessError } from '@kit.BasicServicesKit';

class TextSpeaker {
  // 改为可选并新增创建中标记,避免重复创建
  ttsEngine?: textToSpeech.TextToSpeechEngine;
  createCount: number = 0;
  private creating: boolean = false;

  createByCallback(originalText: string) {
    // 如果已有引擎,直接复用播放
    if (this.ttsEngine) {
      this.speak(originalText);
      return;
    }
    // 避免在创建中重复点击导致多次创建
    if (this.creating) {
      return;
    }
    this.creating = true;

    const extraParam: Record<string, string> = { 'style': 'interaction-broadcast', 'locate': 'CN', 'name': 'EngineName' };
    const initParamsInfo: textToSpeech.CreateEngineParams = {
      language: 'zh-CN',
      person: 0,
      online: 1,
      extraParams: extraParam
    };
    textToSpeech.createEngine(initParamsInfo,
      (err: BusinessError, textToSpeechEngine: textToSpeech.TextToSpeechEngine) => {
        this.creating = false;
        if (!err) {
          this.ttsEngine = textToSpeechEngine;
          this.createCount++;
          this.speak(originalText);
        } else {
          console.error('Fail to createEngine.');
          AppStorage.setOrCreate('isPlaying', false);
        }
      });
  };

  speak(originalText: string) {
    const engine = this.ttsEngine;
    if (!engine) {
      // 引擎未就绪时兜底再创建
      this.createByCallback(originalText);
      return;
    }

    const speakListener: textToSpeech.SpeakListener = {
      onStart: (requestId: string, response: textToSpeech.StartResponse) => {
        console.log('onStart');
        AppStorage.setOrCreate('isPlaying', true);
      },
      onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => {
        console.log('onComplete');
        AppStorage.setOrCreate('isPlaying', false);
      },
      onStop: (requestId: string, response: textToSpeech.StopResponse) => {
        console.log('onStop');
        AppStorage.setOrCreate('isPlaying', false);
        // 不在回调里关闭引擎,避免下次播放 service not initialized
      },
      onData: (requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) => {
        console.log('onData');
      },
      onError: (requestId: string, errorCode: number, errorMessage: string) => {
        console.log('onError');
        AppStorage.setOrCreate('isPlaying', false);
        // 不在回调里关闭引擎,必要时由外部显式调用 shutdown()
      }
    };

    engine.setListener(speakListener);

    const extraParam: Record<string, string | number> = {
      'queueMode': 0,
      'speed': 1,
      'volume': 2,
      'pitch': 1,
      'languageContext': 'zh-CN',
      'audioType': 'pcm',
      'soundChannel': 3,
      'playType': 1
    }
    const speakParams: textToSpeech.SpeakParams = {
      requestId: 'hongtu'+Date.now(),
      extraParams: extraParam
    };

    try {
      engine.speak(originalText, speakParams);
    } catch (err) {
      const error: BusinessError = err as BusinessError;
      console.error(`Speak error, code ${error.code}, message ${error.message}`);
      AppStorage.setOrCreate('isPlaying', false);
    }
  };

  shutdown() {
    try {
      if (this.ttsEngine) {
        const isBusy: boolean = this.ttsEngine.isBusy();
        if (isBusy) {
          this.ttsEngine.stop();
        }
        AppStorage.setOrCreate('isPlaying', false);
        this.ttsEngine.shutdown();
        // 置空引用,确保下次点击会重新创建引擎
        this.ttsEngine = undefined;
      }
    } catch (err) {
      const error: BusinessError = err as BusinessError;
      console.error(`The ttsEngine invoke error, the code is ${error.code}, the message is ${error.message}`);
    }
  }
}

export const textSpeaker = new TextSpeaker()

📱 在目标页面中使用示例

scss 复制代码
import { FormData, iGRouter, textSpeaker } from "basic"//导入工具类,大家自行改为相应的模块或者路径即可
@Component
export struct Voice{
  @State text:string = "但使龙城飞将在,不教胡马度阴山"
  @StorageLink('isPlaying') isPlaying: boolean = false;

  aboutToDisappear() {
    textSpeaker.shutdown()
  }

  private toggleVoicePlay(): void {
    if (this.isPlaying) {
      textSpeaker.shutdown()
    } else {
      textSpeaker.speak(text)
    }
  }

  build() {
    Row(){
      Button(this.isPlaying ? '⏸️ 暂停播放' : '▶️ 开始播放')
                .fontSize(16)
                .fontWeight(FontWeight.Medium)
                .backgroundColor(this.isPlaying ?
                  'linear-gradient(135deg, #F44336 0%, #D32F2F 100%)' :
                  'linear-gradient(135deg, #4CAF50 0%, #388E3C 100%)')
                .fontColor('#FFFFFF')
                .onClick(() => this.toggleVoicePlay())
    }  
  }
}

🧹 Markdown文本过滤

但我播放的是markdown文本(AI的回复),我可不想用户在使用时听见一堆井号星号,所以我们还得添加一个文本过滤函数

typescript 复制代码
  // 将 Markdown 内容清洗为更适合 TTS 的可读文本
  private sanitizeMarkdownForTTS(input: string): string {
    let out: string = input

    // 移除代码块围栏及其内容
    out = out.replace(/```[\s\S]*?```/g, '')
    // 行内代码去掉反引号
    out = out.replace(/`([^`]+)`/g, '$1')
    // 图片语法:保留替代文本
    out = out.replace(/!\[([^\]]*)\]\([^\)]*\)/g, '$1')
    // 链接语法:仅保留可读文本
    out = out.replace(/\[([^\]]+)\]\([^\)]*\)/g, '$1')
    // 标题:去掉#号
    out = out.replace(/^\s*#{1,6}\s*/gm, '')
    // 引用块:去掉>
    out = out.replace(/^\s*>\s?/gm, '')
    // 加粗/斜体/删除线:去掉标记
    out = out.replace(/\*\*([^*]+)\*\*/g, '$1')
    out = out.replace(/__([^_]+)__/g, '$1')
    out = out.replace(/\*([^*]+)\*/g, '$1')
    out = out.replace(/_([^_]+)_/g, '$1')
    out = out.replace(/~~([^~]+)~~/g, '$1')
    // 列表项:统一用圆点开头
    out = out.replace(/^\s*[-+*]\s+/gm, '· ')
    out = out.replace(/^\s*\d+\.\s+/gm, '')
    // 表格:去掉分隔行,竖线替换为顿号
    out = out.replace(/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/gm, '')
    out = out.replace(/\s*\|\s*/g, ',')
    // HTML 标签
    out = out.replace(/<[^>]+>/g, '')
    // 常见实体
    out = out.replace(/&nbsp;/g, ' ')
      .replace(/&amp;/g, '&')
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
    // 规范空白与换行
    out = out.replace(/[\t\x0B\f\r]+/g, ' ')
    out = out.replace(/\n{3,}/g, '\n\n').trim()

    return out
  }

🔄 Tab页面切换时停止播放

如果播放语音的页面是一个tab页,期望在切换tab页时语音播放停止,仅靠

typescript 复制代码
aboutToDisappear() {
  textSpeaker.shutdown()
}

并无法实现,因为使用 Tabs 切换时组件只是隐藏,不会被卸载,因此不会触发 aboutToDisappear(),所需我们可以在tab导航设置页面添加事件订阅emitter来实现:

在tab导航设置页面添加:

scss 复制代码
@Builder
function EntryBuilder() {
  NavDestination() {
    Index()
  }
  .hideTitleBar(true) //取消自带的bar
}

@Entry
@Component
struct Index {
  @State currentIndex: number = 0
  tabList: iTabModel[] = [
    {
      text: '首页',
      src: $r("app.media.icon_home"),
      acSrc: $r("app.media.icon_home_active")
    },
    {
      text: '语音播放',
      src: $r("app.media.icon_voice"),
      acSrc: $r("app.media.icon_voice_active")
    },
  ]
  tabsController: TabsController = new TabsController()

  aboutToAppear(): void {
    // 监听停止播放的事件
    emitter.on('stopAIPlayback', () => {
      textSpeaker.shutdown()
    })
  }
  
  aboutToDisappear(): void {
    // 移除事件监听
    emitter.off('stopAIPlayback')
  }// 跳转页面入口函数

  build() {
    Navigation(iGRouter) {
      Column() {
        Tabs({ controller: this.tabsController }) {
          ForEach(this.tabList, (_: iTabModel, index) => {
            TabContent() {
              if (index == 0) {
                //放对应组件
                Home() //大家替换为自己编写/已存在的组件
              } else {
                Voice() //大家替换为自己编写/已存在的组件
              }
            }
          })
        }
        .onChange((index) => {
          // 如果从语音播放页切换到其他页面,停止语音播放
          if (this.currentIndex === 1 && index !== 1) {
            // 通过事件通知 Voice组件内的监听来停止播放(跨模块更可靠)
            emitter.emit('stopAIPlayback')
            // 本地兜底,若引用同一实例则直接停止
            textSpeaker.shutdown()
          }
          // 统一在 onChange 中更新 currentIndex,保证判断使用的是切换前的旧值
          this.currentIndex = index
        })
        .barHeight(0)
        .layoutWeight(1)

        Row() {
          ForEach(this.tabList, (item: iTabModel, index) => {
            this.barBuilder((() => {
              item.index = index
              return item
            })())
          })
        }
        .justifyContent(FlexAlign.SpaceAround)
        .width('100%')
        .shadow({ offsetY: -13, radius: 16, color: '#0A000000' })
      }
      .width('100%')
      .height('100%')

    }
    .mode(NavigationMode.Stack)
    .hideToolBar(true)
  }

  @Builder
  barBuilder(item: iTabModel) {
    Column({ space: 8 }) {
      Image(this.currentIndex == item.index ? item.acSrc : item.src)
        .width(24)
      Text(item.text)
        .fontSize(14)
        .lineHeight(16)
        .fontColor(this.currentIndex == item.index ? $r('app.color.tab_bar_active_color') :
        $r('app.color.tab_bar_default_color'))

    }
    .margin({ top: 8 })
    .onClick(() => {
      // 只触发切换,不在此处直接改 currentIndex,避免 onChange 中的离开判断失效
      this.tabsController.changeIndex(item.index!)
    })

  }
}

然后在voice组件中也添加事件订阅:

scss 复制代码
  aboutToAppear(): void {
    // 监听停止AI播放的事件
    emitter.on('stopAIPlayback', () => {
      textSpeaker.shutdown()
    })
  }

  aboutToDisappear() {
    textSpeaker.shutdown()
    // 移除事件监听
    emitter.off('stopAIPlayback')
  }
相关推荐
lpfasd12318 分钟前
鸿蒙OS与Rust整合开发流程
华为·rust·harmonyos
HarmonyOS_SDK3 小时前
云闪付联合HarmonyOS SDK打造更便捷安全的支付体验
harmonyos
鸿蒙先行者1 天前
鸿蒙应用开发问题之Ability生命周期管理问题
harmonyos
小小小小小星1 天前
鸿蒙FA/PA架构设计方法论与技术探索
架构·harmonyos
在下历飞雨1 天前
七夕到了,我让AI用Kuikly写了个“孤寡青蛙“App,一码五端真丝滑!
harmonyos
GitCode官方1 天前
直播预告|鸿蒙原生开发与智能工具实战
华为·harmonyos
Monkey-旭1 天前
鸿蒙 5.1 深度解析:ArkUI 4.1 升级与分布式开发新范式
分布式·wpf·harmonyos·arkts·openharmony·arkui
北京流年1 天前
鸿蒙banner页实现
华为·harmonyos
xq95271 天前
鸿蒙next 游戏授权登录教程王者归来
harmonyos