各位读者朋友,大家好。
今天这篇博文想从一个有点“中二”的项目标题说起:“列车即将到站,下一站——至冬!”
我第一次看到这句话时,第一反应是某个游戏剧情预告,又或者是某个漫展的主题活动。但冷静下来一想,这句话本身其实是一个非常典型的轨道交通语音播报场景:列车即将进站,系统需要动态计算出“当前位置”和“下一站”,然后生成类似“列车即将到站,下一站——XX”的播报信息。如果把这个场景落地成一套可运行的代码,就是一个很有意思的实战项目。
这篇文章我打算就用“至冬”这个虚构站名作为演示数据,带大家从零实现一套列车到站播报系统。系统会包含:
- 站台数据建模与线路管理;
- 到站信息计算与播报文案生成;
- 中文语音合成播报;
- Web API 查询接口;
- 定时任务自动播报;
- 常见问题排查与工程化建议。
无论你是 Python 初学者,还是工作中需要做语音提醒、定时播报、信息提示类小系统的开发者,都可以从这篇文章里找到可复用的思路。代码我会尽量给全,并且保证在本地可以跑起来。
接下来我们正式开工。
1. 背景与核心概念
1.1 什么是列车到站播报系统
列车到站播报系统,简单来说就是一套“根据列车实时位置,计算下一站信息,并生成语音/文字提示”的程序。它在现实生活中非常常见:
- 地铁车厢内的“列车即将到站,下一站:人民广场”;
- 公交车的“下一站,XX站,请下车的乘客做好准备”;
- 高铁、机场摆渡车、景区观光车上的到站提醒;
- 企业内部班车、校园摆渡车的到站通知。
这类系统虽然业务规模不大,但背后涉及的逻辑很完整:线路数据管理、位置状态维护、文案生成、语音合成、信息发布。作为一个练手项目,它能很好地锻炼数据建模、模块拆分和工程化能力。
1.2 系统核心组成
从软件架构角度看,一个最小可用的到站播报系统包含以下部分:
| 模块 | 职责 |
|---|---|
| 线路数据模块 | 定义线路名称、站点列表、站点顺序、换乘信息 |
| 位置状态模块 | 记录列车当前在哪个站,或刚从哪个站出发 |
| 文案生成模块 | 根据当前位置生成“下一站”播报文本 |
| 语音合成模块 | 将文本转为语音并播放 |
| 定时任务模块 | 按照时间间隔自动触发播报 |
| 对外接口模块 | 提供查询下一站/触发播报的 API |
1.3 为什么自己做一套,而不是直接用现成的
有人可能会问:地铁里已经有现成的播报系统了,自己写一套有什么意义?
其实意义在于,这种“信息播报类”系统在很多非公共交通场景下是没有现成产品的。比如:
- 公司班车到站提醒;
- 学校图书馆闭馆提醒;
- 仓库叉车调度提示;
- 展会摆渡车播报;
- 个人开发的小型语音助手。
在这些场景里,你需要的往往是“一套轻量、可配置、能快速接入业务数据”的播报服务,而不是地铁级的信号控制系统。我们用 Python 写一个简化版,正好覆盖了这类需求的核心逻辑。
2. 环境准备与版本说明
2.1 运行环境
本文示例代码使用 Python 3 开发,主要依赖以下库:
| 库名 | 用途 |
|---|---|
pyttsx3 | 离线文字转语音,支持 Windows/macOS/Linux |
edge-tts | 微软 Edge 在线语音合成,音质更好 |
Flask | 提供 Web API 查询接口 |
APScheduler | 定时触发播报任务 |
PyYAML | 读取 YAML 配置文件 |
需要说明的是:语音合成库的版本和音色在不同操作系统上差异较大,本文以“能跑通”为目标,版本不需要刻意追求最新。如果你本地环境没有这些库,可以直接用 pip 安装:
pip install pyttsx3 edge-tts flask apscheduler pyyaml如果你的网络环境无法安装edge-tts,系统会自动降级为pyttsx3控制台输出模式,不影响核心流程。
2.2 项目目录结构
我们先规划一个清晰的项目结构,方便后续扩展:
train-announcer/ ├── app/ │ ├── __init__.py │ ├── models.py # 数据模型:站点、线路 │ ├── announcer.py # 播报文案生成与语音播报 │ ├── scheduler.py # 定时任务 │ ├── web.py # Flask Web API │ └── config.py # 配置加载 ├── config/ │ └── line_config.yaml # 线路与站点配置 ├── main.py # 程序入口 ├── requirements.txt └── README.md这个结构不算复杂,但已经把“数据”“业务”“服务”三层分开了。
3. 核心模块设计与实现原理
3.1 站点与线路的数据建模
到站播报系统里,最重要的数据是“线路”和“站点”。一个合理的建模方式是:
- 每一条线路包含若干站点;
- 站点按顺序排列;
- 每个站点可能有英文名、拼音名、是否换乘站等属性;
- 列车当前状态记录的是“已经离开哪个站”,或者“当前正驶向哪个站”。
下面我们先用 Python 的dataclass定义站点和线路模型。代码路径:app/models.py
from dataclasses import dataclass, field from typing import List, Optional @dataclass class Station: """站点数据模型""" id: str # 站点唯一标识 name: str # 站点中文名 pinyin: str = "" # 站点拼音 english_name: str = "" # 站点英文名 is_transfer: bool = False # 是否换乘站 arrival_time: str = "" # 预计到达时间,例如 "10:30" @dataclass class Line: """线路数据模型""" name: str # 线路名 stations: List[Station] = field(default_factory=list) def get_station_by_id(self, station_id: str) -> Optional[Station]: """根据站点 ID 查找站点对象""" for station in self.stations: if station.id == station_id: return station return None def get_next_station(self, current_station_id: str) -> Optional[Station]: """ 返回当前站点的下一个站点。 如果当前站点是终点站,返回 None。 """ for index, station in enumerate(self.stations): if station.id == current_station_id: if index + 1 < len(self.stations): return self.stations[index + 1] return None return None这里需要注意:get_next_station是关键方法。它决定了播报系统能不能准确找到“下一站”。如果站点 ID 不存在,我们返回None,上层调用时要做空值判断,避免程序崩溃。
3.2 播报文案生成逻辑
有了线路模型之后,我们可以这样设计播报逻辑:
- 如果当前站是普通站,播放“列车即将到站,下一站——XX”;
- 如果当前站是终点站,播放“列车即将到达终点站:XX”;
- 如果下一站是换乘站,可以追加一句“可换乘 XX 号线”。
为了避免把文案写死在代码里,我建议用一个独立函数来生成播报文本,后续上线不同场景时可以直接替换。代码路径:app/announcer.py
from app.models import Line, Station def build_announcement(line: Line, current_station_id: str) -> str: """ 根据当前线路和当前站点 ID,生成播报文案。 返回字符串,如果找不到当前站点则返回空字符串。 """ current_station = line.get_station_by_id(current_station_id) if current_station is None: return "" next_station = line.get_next_station(current_station_id) if next_station is None: # 当前站是终点站 return f"列车即将到达终点站:{current_station.name},感谢您的乘坐!" # 常规到站播报 text = f"列车即将到站,下一站——{next_station.name}" if next_station.is_transfer: text += ",可换乘" + "、".join(next_station.transfer_lines) text += "!" return text这里为了演示换乘信息,我在 Station 模型里加了一个transfer_lines字段。你可以在实际项目中补充这个字段,这里不额外展开。
3.3 语音合成方案对比
语音播报是整个系统的灵魂。在 Python 里,常用的方案有三种:
| 方案 | 离线/在线 | 音质 | 跨平台 | 适用场景 |
|---|---|---|---|---|
pyttsx3 | 离线 | 一般 | 好 | 本地快速测试、离线环境 |
edge-tts | 在线 | 较好 | 好 | 对音质有要求,可以联网 |
| 商用 TTS SDK | 在线 | 最好 | 一般 | 生产环境、携带品牌音色 |
我的推荐是:本地开发用pyttsx3跑通流程,音质敏感场景用edge-tts。edge-tts使用微软 Edge 的在线语音服务,音色自然,支持中文,代码也不复杂。
下面封装一个支持“自动降级”的播报类。代码路径:app/announcer.py
import asyncio import subprocess import sys class VoiceAnnouncer: """语音播报器,支持 pyttsx3 和 edge-tts 自动切换""" def __init__(self, engine="auto"): self.engine = engine self._tts_engine = None def _init_pyttsx3(self): try: import pyttsx3 engine = pyttsx3.init() # 尝试设置中文语音,不同平台 voice id 不同 voices = engine.getProperty("voices") for voice in voices: if "chinese" in voice.name.lower() or "zh" in voice.id.lower(): engine.setProperty("voice", voice.id) break return engine except Exception as e: print(f"[语音引擎] pyttsx3 初始化失败: {e}") return None def _play_pyttsx3(self, text: str): if self._tts_engine is None: self._tts_engine = self._init_pyttsx3() if self._tts_engine is None: # 最终降级方案:控制台输出 self._console_print(text) return self._tts_engine.say(text) self._tts_engine.runAndWait() async def _play_edge(self, text: str): try: import edge_tts communicate = edge_tts.Communicate(text, "zh-CN-XiaoxiaoNeural") await communicate.save("announcement.mp3") # 播放生成的 mp3 if sys.platform.startswith("win"): subprocess.run(["start", "announcement.mp3"], shell=True) elif sys.platform == "darwin": subprocess.run(["afplay", "announcement.mp3"]) else: subprocess.run(["mpg123", "announcement.mp3"]) except Exception as e: print(f"[语音引擎] edge-tts 播放失败: {e}") self._console_print(text) def _console_print(self, text: str): print(f"[播报] {text}") def announce(self, text: str): """对外统一播报入口""" print(f"[播报内容] {text}") if self.engine == "edge": asyncio.run(self._play_edge(text)) elif self.engine == "pyttsx3": self._play_pyttsx3(text) else: # auto 模式优先 edge,失败后降级 try: asyncio.run(self._play_edge(text)) except Exception: self._play_pyttsx3(text)这个类的设计思路是“入口统一,内部降级”。不管底层用哪个引擎,上层调用announce(text)都能完成播报,避免因为某个语音库不可用导致整个程序崩溃。
3.4 定时任务的实现
在真实场景中,播报不是手动的,而是根据列车运行计划自动触发。我们可以用APScheduler实现定时任务。代码路径:app/scheduler.py
from apscheduler.schedulers.blocking import BlockingScheduler from app.announcer import build_announcement, VoiceAnnouncer from app.models import Line class AnnounceScheduler: def __init__(self, line: Line, interval_seconds: int = 30): self.line = line self.interval = interval_seconds self.announcer = VoiceAnnouncer() self.scheduler = BlockingScheduler() # 记录当前模拟位置,从第 0 个站开始 self.current_index = 0 def job(self): current_station = self.line.stations[self.current_index] text = build_announcement(self.line, current_station.id) if text: self.announcer.announce(text) # 移动到下一个站,如果到达终点则回到起点,模拟循环运行 self.current_index = (self.current_index + 1) % len(self.line.stations) def start(self): self.scheduler.add_job( self.job, "interval", seconds=self.interval, id="announce_job", max_instances=1, coalesce=True, ) print(f"定时播报已启动,每 {self.interval} 秒触发一次。") self.scheduler.start()这里的current_index模拟列车当前位置。实际项目中,位置状态应该来自信号系统或数据库,而不是内存变量。本文用循环递增来演示“下一站”的动态变化。
4. 完整实战案例
为了让代码可以完整运行,我准备了一个示例线路配置:一条名为“冬旅线”的虚构线路,包含 5 个站点,最后一站命名为“至冬站”,呼应项目标题。
4.1 配置文件:config/line_config.yaml
line_name: "冬旅线" stations: - id: "D1" name: "始发广场" pinyin: "shifa guangchang" english_name: "Start Square" - id: "D2" name: "雪原路口" pinyin: "xueyuan lukou" english_name: "Snowfield Road" - id: "D3" name: "冰湖码头" pinyin: "binghu matou" english_name: "Ice Lake Pier" is_transfer: true transfer_lines: ["环湖线"] - id: "D4" name: "北风站" pinyin: "beifeng zhan" english_name: "Northwind Station" - id: "D5" name: "至冬站" pinyin: "zhidong zhan" english_name: "Snezhnaya Station" is_transfer: true transfer_lines: ["愚人众快线"]4.2 配置加载模块:app/config.py
import yaml from app.models import Line, Station def load_line_from_yaml(path: str) -> Line: with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) line_name = data.get("line_name", "未知线路") stations = [] for item in data.get("stations", []): station = Station( id=item["id"], name=item["name"], pinyin=item.get("pinyin", ""), english_name=item.get("english_name", ""), is_transfer=item.get("is_transfer", False), transfer_lines=item.get("transfer_lines", []), ) stations.append(station) return Line(name=line_name, stations=stations)注意:上面代码里用到了transfer_lines字段,所以我们需要在Station数据类中追加这个字段:
@dataclass class Station: """站点数据模型""" id: str name: str pinyin: str = "" english_name: str = "" is_transfer: bool = False transfer_lines: list = field(default_factory=list) arrival_time: str = ""4.3 命令行入口:main.py
import sys from app.config import load_line_from_yaml from app.announcer import build_announcement, VoiceAnnouncer def main(): config_path = "config/line_config.yaml" line = load_line_from_yaml(config_path) print(f"线路加载成功:{line.name}") print("站点列表:") for i, station in enumerate(line.stations): print(f" {i + 1}. {station.name}") if len(sys.argv) > 1: # 支持命令行指定当前站点 ID current_id = sys.argv[1] else: # 默认使用第一个站作为当前站 current_id = line.stations[0].id text = build_announcement(line, current_id) if not text: print("未找到当前站点,请检查站点 ID。") return print("生成播报文案:", text) announcer = VoiceAnnouncer(engine="auto") announcer.announce(text) if __name__ == "__main__": main()4.4 Web API 服务:app/web.py
除了命令行播报,我们再提供一个 Flask Web 接口,方便其他系统调用查询“下一站”信息。
from flask import Flask, jsonify, request from app.config import load_line_from_yaml from app.announcer import build_announcement, VoiceAnnouncer app = Flask(__name__) line = load_line_from_yaml("config/line_config.yaml") announcer = VoiceAnnouncer(engine="auto") @app.route("/api/next-station", methods=["GET"]) def get_next_station(): """查询下一站信息""" current_id = request.args.get("current_id", "") current_station = line.get_station_by_id(current_id) if current_station is None: return jsonify({"error": "station not found"}), 404 next_station = line.get_next_station(current_id) if next_station is None: return jsonify({ "current_station": current_station.name, "message": "当前站点是终点站", }) return jsonify({ "current_station": current_station.name, "next_station": next_station.name, "announcement": build_announcement(line, current_id), }) @app.route("/api/announce", methods=["POST"]) def trigger_announce(): """触发语音播报""" data = request.get_json(force=True, silent=True) or {} current_id = data.get("current_id", "") text = build_announcement(line, current_id) if not text: return jsonify({"error": "station not found"}), 404 # 异步播报,避免阻塞 HTTP 响应 import threading thread = threading.Thread(target=announcer.announce, args=(text,), daemon=True) thread.start() return jsonify({"status": "ok", "announcement": text}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)启动 Web 服务:
python -m app.web然后访问接口测试:
curl "http://127.0.0.1:5000/api/next-station?current_id=D4"预期返回:
{ "current_station": "北风站", "next_station": "至冬站", "announcement": "列车即将到站,下一站——至冬站,可换乘愚人众快线!" }4.5 运行与验证
场景一:命令行模式
python main.py D3预期输出:
线路加载成功:冬旅线 站点列表: 1. 始发广场 2. 雪原路口 3. 冰湖码头 4. 北风站 5. 至冬站 生成播报文案: 列车即将到站,下一站——北风站! [播报内容] 列车即将到站,下一站——北风站!场景二:定时任务模式
from app.scheduler import AnnounceScheduler from app.config import load_line_from_yaml line = load_line_from_yaml("config/line_config.yaml") scheduler = AnnounceScheduler(line, interval_seconds=10) scheduler.start()每 10 秒会模拟一次列车到站,并依次播报下一站,当到达终点站“至冬站”后会循环回始发站。
5. 常见问题与排查思路
在实际开发或者运行这个项目时,大家可能会遇到下面这些问题。我把常见现象、原因和解决思路整理成一张表,方便快速排查。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
提示ModuleNotFoundError: No module named 'pyttsx3' | 本地没有安装依赖 | 执行pip install pyttsx3 |
| pyttsx3 初始化失败 | Linux 缺少 espeak 或 libespeak1 | Ubuntu 执行sudo apt-get install espeak |
| 中文语音不生效 | 系统语音库中没有中文音色 | 遍历engine.getProperty("voices")找到中文语音 ID 并设置 |
| Windows 控制台输出中文乱码 | 控制台编码不是 UTF-8 | 在代码开头设置sys.stdout.reconfigure(encoding="utf-8") |
| edge-tts 无法生成语音 | 网络无法访问微软服务 | 切换为 pyttsx3 或检查网络代理设置 |
| Flask 接口返回 404 | 请求的站点 ID 不存在 | 检查配置文件和请求参数是否一致 |
| 定时任务不触发 | BlockingScheduler被其他代码阻塞 | 确保scheduler.start()是主线程最后调用的方法 |
| 音频播放没有声音 | 系统没有安装播放器 | Linux 安装mpg123,或改用自己的播放逻辑 |
这里重点说两个高频问题。
5.1 pyttsx3 在 Linux 上不发声
pyttsx3在 Linux 上依赖espeak。如果系统没有安装,执行时可能不报错,但也没有声音。解决方法是安装 espeak:
sudo apt-get update sudo apt-get install espeak如果你在容器环境里运行,还需要额外安装音频驱动相关的包。如果只是测试逻辑,不要求声音输出,可以临时把引擎改成console模式,也就是只打印文案。
5.2 Windows 下中文语音不生效
pyttsx3默认会使用系统第一个语音,可能是英文。我们需要手动选择中文语音。
可以使用下面这段调试代码来查看当前系统有哪些语音:
import pyttsx3 engine = pyttsx3.init() for voice in engine.getProperty("voices"): print(voice.id, voice.name)如果输出结果里有类似HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech\Voices\Tokens\TTS_MS_ZH-CN_HUIHUI_11.0这样的 ID,就可以用代码设置:
engine.setProperty("voice", "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Speech\\Voices\\Tokens\\TTS_MS_ZH-CN_HUIHUI_11.0")如果你的系统里完全没有中文语音包,需要先安装 Windows 的中文语音包,或者直接用edge-tts在线方案。
6. 最佳实践与工程建议
6.1 不要硬编码线路数据
我在文中把站点数据放在了 YAML 配置文件里,这是一个很好的习惯。硬编码的坏处很明显:
- 新增站点需要改代码、重新发布;
- 不同线路需要复制代码;
- 运维人员无法独立维护数据。
推荐的做法是:线路数据放数据库或配置文件,程序启动时加载,支持热更新。
6.2 语音引擎要做降级处理
语音合成服务可能因为网络、系统依赖、授权等问题随时不可用。我们要保证“语音挂了,播报系统不能挂”。建议实现降级链路:
edge-tts 在线合成 → 失败降级 pyttsx3 本地合成 → 再失败降级控制台日志输出这样即使没有任何发声设备,系统依然可以记录日志、输出文案,业务不受影响。
6.3 定时任务要防止重复播报
如果上一轮播报还没结束,下一轮任务又触发了,就会导致语音重叠。在使用APScheduler时,可以给任务添加max_instances=1和coalesce=True:
scheduler.add_job( job, "interval", seconds=30, max_instances=1, coalesce=True, )这两个参数的含义是:同一时间只允许一个实例运行;如果任务积压了多次触发,只合并执行一次。
6.4 对外接口注意权限控制
如果你的 Web API 暴露在公网,任何人都可以调用/api/announce接口触发播报,轻则骚扰,重则被刷流量。建议:
- 部署到内网,不直接暴露公网;
- 在 Nginx 层做 IP 白名单;
- API 增加简单的 Token 校验。
6.5 日志记录要完整
每次播报都应该记录以下信息:
- 播报时间;
- 当前站点;
- 下一站信息;
- 使用的语音引擎;
- 播报是否成功。
用 Python 的logging模块实现即可,不要用print代替。代码中可以用类似这样的写法:
import logging logger = logging.getLogger("train_announcer") def log_announcement(current_station, next_station_name, engine_type): logger.info( "播报成功 | 当前站: %s | 下一站: %s | 引擎: %s", current_station, next_station_name, engine_type, )6.6 生产环境进一步扩展方向
以上代码是一个 MVP(最小可行产品)版本。如果要在真实轨道交通或班车系统中落地,还需要考虑:
- 实时位置源接入(GPS、信号系统、刷卡数据);
- 多线路同时播报并发控制;
- 异常场景处理(列车晚点、跳过站点、终点站变更);
- 多语言播报切换;
- 前端大屏显示;
- 语音音量、语速动态调整。
这些方向都可以在现有代码基础上逐步叠加。
7. 总结与下一步学习建议
这篇文章围绕“列车即将到站,下一站——至冬!”这个场景,完整实现了一个轻量级的列车到站播报系统。我们完成了:
- 线路与站点的数据建模;
- “下一站”计算逻辑;
- 播报文案生成;
- 基于
pyttsx3和edge-tts的双引擎语音播报; - 基于 Flask 的 Web 查询接口;
- 基于 APScheduler 的定时任务;
- 常见问题排查与工程化建议。
如果你是从零跟着文章写到这里,建议你先在命令行模式跑通流程,然后把线路配置替换成自己熟悉的地铁线路或班车线路,再逐步加入 Web API 和定时任务。只有亲自动手改一遍,才能理解“模型、配置、播报逻辑、服务化”是怎么串起来的。
接下来你可以继续学习的方向包括:
- 用 FastAPI 替代 Flask,体验自动生成 OpenAPI 文档;
- 把线路数据存到 SQLite/MySQL,实现动态增删站点;
- 接入消息队列,让其他系统通过 MQTT/Redis 触发播报;
- 做一个简单的 Web 管理界面,可视化编辑线路和站点;
- 部署到树莓派 + 音箱,做成一个真正的“到站提醒机器人”。
如果这篇教程对你有帮助,可以收藏备用。后续我会继续分享语音合成、Flask 接口设计、定时任务调度的更多实战细节,欢迎在评论区留言交流你的想法和遇到的问题。