ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ROS2机器人语音播放模块:pyttsx3离线TTS与多线程实践

ROS2机器人语音播放模块:pyttsx3离线TTS与多线程实践 1. 从哑巴机器人到会说话语音播放模块到底解决什么问题做自主导航机器人项目做到第15个模块前面已经搞定了建图、定位、路径规划、避障这些硬骨头机器人能自己从A点跑到B点了。但跑起来之后你会发现一个很尴尬的事——它全程一声不吭。你在调试的时候盯着终端看日志还行可一旦把机器人放到真实场景里比如让它去会议室送个文件或者在家里巡逻你根本不知道它现在是什么状态是在正常导航还是卡住了还是已经到目的地了这就是语音播放模块要解决的核心问题让机器人把内部状态说出来。具体来说这个模块要干三件事。第一把导航过程中的关键事件转成语音比如开始导航已到达目标点检测到障碍物正在重新规划。第二提供一个可复用的语音播放接口让其他模块比如视觉识别、电量管理也能调用。第三保证语音播放不阻塞主线程不能因为播报一句话就让导航卡住。关键词里出现了ROS2、pyttsx3、Python、ffmpeg这几个东西说明这个模块的技术路线大概是在ROS2框架下用Python写一个语音播放节点底层用pyttsx3做离线TTS合成可能还涉及ffmpeg处理音频格式。热搜词里还有ffmpeg推流到srs存在延迟这种说明有人可能想把语音也推到远程去听这个后面我会专门讲。适合谁来参考这篇内容如果你正在做ROS2机器人项目已经跑通了基本导航现在想加语音交互功能那这篇就是写给你的。如果你只是单纯想学Python怎么调TTS也能用但ROS2那部分可以跳过。我默认你有基本的Python基础和ROS2概念话题、节点、回调这些如果没有遇到不懂的术语可以先记下来后面我会尽量用大白话解释。2. 为什么选pyttsx3而不是在线TTS离线方案的真实取舍2.1 在线TTS和离线TTS的核心差异做语音播放第一个要做的决策就是用在线TTS还是离线TTS在线TTS就是调用云端接口把文字发过去云端返回音频文件或者直接返回音频流。优点是音质好、声音自然、支持多语言多音色。缺点也很明显必须联网。你的机器人在室内跑还好万一到了没有WiFi覆盖的区域或者网络抖动语音就哑了。而且每次播报都要发一次网络请求延迟不可控导航场景下这种不确定性很致命。离线TTS就是在本地把文字合成音频不依赖网络。pyttsx3就是Python里最常用的离线TTS库之一。它底层在Windows上调SAPI5在Linux上调espeak在macOS上调NSSpeechSynthesizer。音质确实不如在线TTS听起来有点机械但它零网络依赖、零延迟波动、零调用成本。对于自主导航机器人来说我强烈建议用离线方案。原因很简单导航是一个实时性要求很高的任务语音播报是辅助功能不能因为网络问题导致播报延迟甚至失败。你想想机器人已经到目标点了结果因为网络卡了过了五秒才说已到达这体验就很差。2.2 pyttsx3在ROS2环境下的实际表现pyttsx3的API非常简单基本就是三行代码import pyttsx3 engine pyttsx3.init() engine.say(开始导航) engine.runAndWait()但在ROS2节点里用有几个坑要注意。第一个坑runAndWait()是阻塞的。如果你在ROS2的回调函数里直接调它整个节点的回调队列都会被卡住。导航过程中如果同时有多个话题在发布消息你的节点就处理不过来了。解决方案后面会详细讲核心思路是把语音播放放到单独的线程里。第二个坑pyttsx3在Linux下依赖espeak而espeak的中文支持需要额外配置。默认安装的espeak可能只支持英文你说中文它读不出来或者读得乱七八糟。需要安装espeak-ng并配置中文语音包。第三个坑pyttsx3的init()在某些Linux发行版上会报错提示找不到驱动。这通常是因为没有安装libespeak-dev或者espeak本身。Ubuntu下的解决方法是sudo apt update sudo apt install espeak-ng espeak-ng-data libespeak-ng-dev安装完之后你可以在终端直接测试espeak-ng hello world espeak-ng -v zh 你好世界如果中文能读出来说明底层没问题pyttsx3就能正常工作。2.3 什么时候该考虑ffmpegffmpeg在这个模块里的角色主要是处理音频格式。pyttsx3默认输出的音频格式可能是WAV但如果你想把语音保存下来、或者推送到远程设备上播放就可能需要转成MP3或者其他格式。ffmpeg就是干这个的。另外热搜词里提到ffmpeg推流到srs存在延迟这其实是一个进阶需求把机器人的语音实时推送到远程服务器让操作员在远端也能听到机器人的播报。这个场景在远程监控机器人时很有用。但推流本身会引入延迟一般建议用低延迟的推流参数比如ffmpeg -f alsa -i default -c:a aac -b:a 64k -f flv rtmp://your-server/live/stream不过这个属于扩展功能核心的语音播放模块其实不需要ffmpeg也能跑。我建议先把基础功能做稳再考虑推流。3. 在ROS2里搭一个不卡导航的语音播放节点3.1 节点架构设计为什么必须用多线程前面说了pyttsx3的runAndWait()是阻塞的。在ROS2里如果你在订阅回调里直接调它会发生什么假设你的语音节点订阅了/navigation_status话题导航模块每秒钟发布一次状态。你的回调函数收到消息后调engine.say()和engine.runAndWait()。如果这句话要读3秒钟那这3秒内你的节点完全卡死后面来的消息全部堆积在队列里。如果队列满了消息就丢了。更严重的是如果你的节点还负责其他功能比如同时订阅了紧急停止话题紧急消息也处理不了。所以正确的做法是回调函数只负责把要播报的文字放进一个队列单独的线程从队列里取文字并播放。这样回调函数瞬间返回不会阻塞ROS2的executor。播放线程慢慢播播完一句再从队列取下一句。如果队列空了线程就等待。3.2 完整代码实现与逐段解读下面是我实际项目里用的代码基于ROS2 HumblePython实现。你可以直接复制到你的功能包里用。import rclpy from rclpy.node import Node from std_msgs.msg import String import pyttsx3 import threading import queue import time class VoicePlayerNode(Node): def __init__(self): super().__init__(voice_player_node) # 语音播放队列 self.speech_queue queue.Queue() # 初始化TTS引擎 self.engine pyttsx3.init() self.engine.setProperty(rate, 180) # 语速 self.engine.setProperty(volume, 1.0) # 音量 # 选择中文语音如果可用 voices self.engine.getProperty(voices) for voice in voices: if chinese in voice.name.lower() or zh in voice.id.lower(): self.engine.setProperty(voice, voice.id) break # 订阅语音播报话题 self.subscription self.create_subscription( String, /voice_play, self.voice_callback, 10 ) # 启动播放线程 self.play_thread threading.Thread(targetself.play_worker, daemonTrue) self.play_thread.start() self.get_logger().info(语音播放节点已启动) def voice_callback(self, msg): text msg.data.strip() if text: self.speech_queue.put(text) self.get_logger().info(f收到播报请求: {text}) def play_worker(self): while rclpy.ok(): try: text self.speech_queue.get(timeout1.0) self.engine.say(text) self.engine.runAndWait() self.speech_queue.task_done() except queue.Empty: continue except Exception as e: self.get_logger().error(f语音播放异常: {e}) def main(argsNone): rclpy.init(argsargs) node VoicePlayerNode() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ __main__: main()逐段说一下关键点。self.speech_queue queue.Queue()这是Python标准库的线程安全队列。为什么不用列表因为列表在多线程下需要自己加锁容易出bug。Queue自带锁put和get都是原子操作。self.engine.setProperty(rate, 180)语速设置。默认值一般是200但中文播报我觉得180更舒服太快了听不清太慢了显得机器人反应迟钝。你可以根据实际效果调。self.engine.setProperty(volume, 1.0)音量拉满。实际部署时如果觉得吵可以调到0.7左右。选择中文语音那段engine.getProperty(voices)返回一个列表每个元素有name和id。不同系统上中文语音的标识不一样所以用模糊匹配。如果找不到中文语音就会用默认的可能读英文或者读不出来。这就是为什么前面要装espeak-ng的中文包。self.play_thread threading.Thread(targetself.play_worker, daemonTrue)daemonTrue表示这个线程是守护线程主程序退出时它会自动结束不会阻止程序关闭。play_worker里的timeout1.0队列为空时最多等1秒然后继续循环检查rclpy.ok()。这样既能及时响应新消息又能在程序退出时快速结束线程。3.3 话题设计让其他模块方便调用语音播放节点订阅的是/voice_play话题消息类型是std_msgs/String。为什么用String而不是自定义消息因为语音播报本质上就是一段文字String足够了。自定义消息反而增加复杂度其他模块要调用还得依赖你的消息包。其他模块怎么调用比如导航模块到达目标点后想播报只需要from std_msgs.msg import String self.voice_pub self.create_publisher(String, /voice_play, 10) msg String() msg.data 已到达目标点 self.voice_pub.publish(msg)就这么简单。发布者不需要知道语音节点是怎么实现的只需要往话题里发文字就行。这就是ROS2话题机制的好处解耦。如果你想让语音节点支持优先级比如紧急消息插队播放可以再订阅一个/voice_play_urgent话题在回调里把消息放到队列头部。不过Python的Queue不支持直接插队需要用PriorityQueue或者自己维护一个双端队列。这个属于进阶需求基础版本用普通Queue就够了。4. 中文播报乱码、没声音、卡顿排查链路与修复方案4.1 中文读不出来或者读成英文这是最常见的问题。现象是你发开始导航机器人读出来的是一串奇怪的英文发音或者干脆没声音。根因espeak-ng默认语音是英文没有加载中文语音包。排查步骤第一步在终端直接测试espeak-ngespeak-ng -v zh 开始导航如果报错说找不到zh语音说明中文包没装。Ubuntu下安装sudo apt install espeak-ng-data第二步检查pyttsx3能不能找到中文voiceimport pyttsx3 engine pyttsx3.init() voices engine.getProperty(voices) for v in voices: print(v.id, v.name, v.languages)看看输出里有没有包含zh或者chinese的条目。如果没有说明pyttsx3没有识别到中文语音。这时候可以手动指定engine.setProperty(voice, zh)或者用espeak-ng的语音标识比如zhf3中文女声。第三步如果还是不行检查系统localelocale确保LANG和LC_ALL包含zh_CN.UTF-8或者至少是UTF-8。如果locale不对中文可能显示为乱码TTS引擎也读不对。注意有些ROS2的Docker镜像默认locale是POSIX中文支持很差。建议在Dockerfile里加上ENV LANGzh_CN.UTF-8和ENV LC_ALLzh_CN.UTF-8。4.2 播报时导航卡顿甚至死机现象机器人本来跑得好好的一播报语音就卡住rviz2里的位姿也不更新了。根因语音播放阻塞了ROS2的executor线程。排查检查你的回调函数里是不是直接调了engine.runAndWait()。如果是改成我前面给的队列线程方案。还有一个可能你的语音节点和导航节点在同一个进程里比如用了component语音播放阻塞导致整个进程卡住。解决方案是把语音节点单独作为一个进程启动或者确保用了多线程executor。ROS2的MultiThreadedExecutor可以让多个回调并行执行但pyttsx3的GIL锁可能还是会互相影响。最稳妥的方案还是独立线程队列。4.3 播放延迟越来越大现象刚开始播报很及时跑了一段时间后语音越来越滞后最后延迟好几秒。根因队列堆积。如果播报速度跟不上消息发布速度队列会越来越长。排查在play_worker里打印队列长度if self.speech_queue.qsize() 5: self.get_logger().warn(f语音队列积压: {self.speech_queue.qsize()})如果确实积压有两个解决方案。一是降低播报频率比如导航状态只在关键节点播报不要每秒钟都播。二是设置队列最大长度超过就丢弃旧消息if self.speech_queue.qsize() 10: self.speech_queue.put(text) else: self.get_logger().warn(语音队列已满丢弃消息)我一般用第二种因为导航场景下过期的语音播报没有意义不如丢掉。4.4 pyttsx3在程序退出时报错现象按CtrlC退出时终端报一堆异常比如RuntimeError: run loop already started。根因pyttsx3的引擎在退出时没有正确清理。解决方案在finally块里调用engine.stop()finally: node.engine.stop() node.destroy_node() rclpy.shutdown()如果还是报错可以在play_worker里捕获异常并忽略退出时的错误。这个不影响功能只是看着不舒服。5. 从能说到好用语音内容设计与工程化建议5.1 播报什么、不播报什么语音播放模块技术上跑通之后真正影响体验的是播报内容的设计。我见过很多机器人项目语音功能做了但播报的内容很糟糕要么太啰嗦每走一步都说我正在前进吵得人头疼要么太简略只报错误你根本不知道什么错误。我的经验是遵循关键节点必报过程状态少报错误信息详报的原则。关键节点包括开始导航、到达目标点、导航取消、重新规划路径。这些是用户需要知道的状态变化必须播报。过程状态比如正在前进正在转弯这些不需要播报因为用户能看到机器人在动。播报反而干扰。错误信息要详细比如检测到障碍物正在重新规划路径就比导航错误好得多。用户听到前者知道机器人会自己处理听到后者会以为机器人坏了。5.2 语音模板与参数化不要把播报文字硬编码在代码里。建议用一个配置文件或者参数服务器来管理语音模板。比如在ROS2的参数里定义self.declare_parameter(voice_templates.nav_start, 开始导航目标点距离{距离}米) self.declare_parameter(voice_templates.nav_arrive, 已到达目标点) self.declare_parameter(voice_templates.obstacle, 检测到障碍物正在重新规划)播报时动态填充template self.get_parameter(voice_templates.nav_start).value text template.format(距离round(distance, 1))这样做的好处是不同场景可以用不同的语音模板。比如在办公室用正式的语气在家里用轻松的语气改配置就行不用改代码。5.3 多语言支持与语音切换如果你的机器人可能面对不同语言的用户可以准备多套语音模板根据系统语言或者用户设置动态切换。pyttsx3支持切换voice但切换需要重新init()或者setProperty。实测下来setProperty在播放过程中切换可能不生效最好在播放下一句之前切换。def set_language(self, lang): voices self.engine.getProperty(voices) for voice in voices: if lang in voice.id.lower(): self.engine.setProperty(voice, voice.id) return True return False5.4 音量自适应机器人在不同环境下背景噪音不一样。如果固定音量在安静环境下可能太吵在嘈杂环境下可能听不清。进阶方案是用麦克风采集环境噪音动态调整TTS音量。但这个需要额外的音频处理复杂度较高。简单方案是提供几个音量档位根据场景手动切换。self.engine.setProperty(volume, 0.5) # 安静模式 self.engine.setProperty(volume, 1.0) # 正常模式5.5 语音播放的日志记录每次播报都应该记录日志方便排查问题。记录内容包括时间戳、播报文字、播报时长、是否成功。import time start time.time() self.engine.say(text) self.engine.runAndWait() duration time.time() - start self.get_logger().info(f播报完成: {text}, 耗时{duration:.2f}秒)如果某句话播报耗时异常长比如超过10秒可能是TTS引擎卡住了需要检查。6. 把语音推到远程ffmpeg推流的延迟问题与取舍6.1 为什么需要远程语音有些场景下操作员不在机器人旁边但需要听到机器人的语音播报。比如远程监控、远程调试、多机器人调度中心。这时候就需要把语音推送到远程服务器操作员从服务器拉流播放。热搜词里提到ffmpeg推流到srs存在延迟说明有人已经在做这个事了。SRS是一个开源的流媒体服务器ffmpeg可以把本地音频推送到SRS然后远程用播放器拉流。6.2 推流延迟的来源推流延迟主要来自几个方面。编码延迟ffmpeg把音频编码成AAC或者MP3需要时间。编码器有缓冲区缓冲区越大延迟越高。网络延迟数据从机器人传到服务器的时间。局域网一般几毫秒到几十毫秒公网可能几百毫秒。服务器缓冲SRS等流媒体服务器为了抗抖动会缓冲一定量的数据。缓冲越大延迟越高但越流畅。播放器缓冲远程播放器也会缓冲同样是为了抗抖动。综合下来端到端延迟通常在1到3秒。如果参数没调好可能到5秒以上。6.3 降低延迟的实操参数如果确实需要推流可以用以下参数降低延迟ffmpeg -f pulse -i default \ -c:a aac -b:a 64k -ar 16000 \ -fflags nobuffer -flags low_delay \ -f flv rtmp://server/live/stream关键参数解释-fflags nobuffer禁用输入缓冲-flags low_delay启用低延迟模式-ar 16000降低采样率减少数据量。但即使这样延迟也很难降到1秒以内。所以我的建议是如果只是本地播报不要推流。推流适合远程监控场景不适合实时交互场景。6.4 替代方案远程TTS合成如果远程端也需要语音但不想推流还有一个方案把播报文字通过网络发送到远程端远程端自己用TTS合成播放。这样延迟更低因为文字数据量极小而且远程端可以用更好的TTS引擎。这个方案的实现方式是机器人端发布一个/voice_text话题远程端订阅这个话题收到文字后用本地的pyttsx3或者在线TTS播放。本质上就是把TTS合成的工作从机器人端转移到了远程端。7. 我在实际部署中踩过的几个坑第一个坑espeak-ng和pyttsx3的版本不匹配。有一次在Ubuntu 22.04上pip安装的pyttsx3是2.90版本系统espeak-ng是1.50版本结果engine.say()没报错但也没声音。后来把pyttsx3升级到2.99就好了。所以如果你遇到没报错但没声音先检查版本。第二个坑Docker容器里没有音频设备。在Docker里跑ROS2节点默认没有/dev/snd设备pyttsx3初始化会失败。解决方案是启动容器时加--device /dev/snd或者用--group-add audio。如果宿主机没有声卡那就只能用文件输出模式把语音保存成WAV文件然后想办法播放。第三个坑中文语音的断句问题。espeak-ng读中文时断句不太自然有时候会把一个词拆开读。比如开始导航可能读成开始 导 航。解决方案是在文字里加空格或者标点引导断句。比如开始导航。比开始导航读起来更自然。第四个坑多线程下的GIL竞争。pyttsx3底层是C扩展调用时会释放GIL但runAndWait()本身是阻塞的。如果同时有多个线程调engine.say()可能会出问题。所以一定要保证只有一个线程操作engine。我的方案是队列单播放线程天然避免了这个问题。第五个坑语音播报和麦克风采集冲突。如果你的机器人同时有语音识别功能扬声器播放的声音可能被麦克风采集到导致自己听自己说话。解决方案是播放时暂停麦克风采集或者用回声消除算法。简单方案是播放时把麦克风音量调低。8. 后续可以怎么扩展这个模块基础版跑通之后有几个方向可以继续做。语音优先级队列用PriorityQueue替代普通Queue紧急消息插队播放。比如紧急停止应该比正在导航优先播报。语音播报与动作同步有些场景下语音播报需要和机器人动作配合。比如机器人转弯时说正在转弯这需要语音节点和运动控制节点做时间同步。多机器人语音协调多个机器人同时播报会互相干扰。可以设计一个中央语音调度节点统一分配播报时机。语音情感合成pyttsx3支持调整rate和volume可以通过参数变化模拟不同情感。比如错误时语速加快、音量提高正常时语速平缓。语音日志回放把每次播报的文字和时间戳保存到文件事后可以回放分析。这对调试和优化很有帮助。import json from datetime import datetime log_entry { time: datetime.now().isoformat(), text: text, duration: duration } with open(/tmp/voice_log.jsonl, a) as f: f.write(json.dumps(log_entry, ensure_asciiFalse) \n)这个日志文件可以用pandas分析看看哪些语音播报最频繁、哪些耗时最长针对性优化。最后分享一个实用技巧如果你觉得pyttsx3的音质太机械可以试试espeak-ng的变声参数。比如-v zhf3用中文女声-v zhm3用中文男声-s 150调语速-p 50调音高。在pyttsx3里可以通过setProperty(voice, zhf3)来指定。多试几个组合能找到相对自然的音色。
返回列表