castmd
一份 Markdown,就是一期播客。
castmd 是一个 Python 库:把 Podcast Markdown(一种刻意最小的对话 DSL)渲染成成品播客音频。人声通过可插拔的语音供应商生成(内置 ListenHub),背景音乐与音效由本地 ffmpeg 装配,最终产出 音频文件 + 带时间戳和说话人标注的 transcript.json。
episode.md ──► 解析 ──► 逐段人声渲染 (Provider) ──► 声音装配 (ffmpeg) ──► episode.mp3 + transcript.json
安装
pip install castmd # 需要本机安装 ffmpeg
快速开始
export LISTENHUB_API_KEY=sk-...
castmd build examples/episode.md -o out/
或作为库使用:
import castmd
from castmd.providers.listenhub import ListenHubProvider
doc = castmd.parse_file("episode.md")
result = castmd.render(
doc,
provider=ListenHubProvider(), # 默认读 LISTENHUB_API_KEY 环境变量
output="out/episode.mp3",
)
print(result.audio_path, result.duration_ms)
for seg in result.transcript:
print(seg.start_ms, seg.speaker, seg.text)
Podcast Markdown DSL
整篇文档 = frontmatter + 若干个用 --- 分隔的段 (Section)。每个段独立渲染一次人声,段间顺序拼接。
---
title: 恐龙灭绝之谜 · 第3期
language: zh
speakers: # 台词署名 → 供应商音色 id
阿哲: awesome_voice_1
小雨: awesome_voice_2
sounds: # 本篇用到的声音资源:锚名 → 文件路径 / URL
开场乐: assets/theme.mp3
爆炸声: assets/boom.wav
鸟叫: assets/birds.mp3
换场乐: assets/transition.mp3
---
[4s](#开场乐)
阿哲: 大家好,欢迎回到恐龙台。今天聊一个大问题——恐龙到底是怎么灭绝的?
小雨: 随着[一颗小行星撞进地球](#爆炸声),白垩纪在那一天结束了。
---
[2s](#换场乐)
阿哲: 第二幕,我们把时间拨回撞击后的清晨。
[1.2s](#鸟叫)
小雨: 幸存下来的,是那些体型小、什么都吃的家伙……
规则(全部规则)
- Frontmatter(YAML):
-
title— 节目标题(可选); -
language— 语言代码,如zh/en(可选,传给供应商); -
speakers— 署名 → 音色 id 的映射。音色 id 是不透明字符串,由所选供应商解释(ListenHub 即 speakerId,用castmd speakers查询); -
sounds— 锚名 → 声音资源,四种形态:本地文件路径(相对 md 文件)、URL、特殊值silence(静音)、生成音乐(写一段描述,由音乐供应商生成):sounds: 开场乐: music: 温暖轻快的钢琴播客片头,渐强开场 爆炸声: assets/boom.wav
-
- 分段
---:每个段是一次独立的人声渲染单元,可增量重跑(见下文缓存)。 - 台词行:
署名: 台词内容,署名必须在speakers中声明;中英文冒号均可;紧随台词行的普通文本行视为该行台词的续行。 - 声音插入,复用 Markdown 链接语法,两种形态:
- 伴随式:台词内
[被说出的文字](#锚名)— 这段文字照常被念出来,音效在文字出现的位置对齐叠加(靠字幕时间戳定位); - 独立式:独立一行
[时长](#锚名),如[1.2s](#鸟叫)或[500ms](#静音)— 插入一段纯声音,人声暂停,时长即截取/补齐长度。
- 伴随式:台词内
- 没有别的语法。不做:精细混音参数、SSML、多层轨道、整段铺底。需要时再扩展。
输出契约
render() → RenderedEpisode {
audio_path: 成品音频(格式由输出扩展名决定:.mp3 / .ogg / .wav)
transcript: [{start_ms, end_ms, speaker, text}, ...] # 装配后已修正时间戳
duration_ms: 总时长
transcript_path: transcript.json 路径
}
下游(播放器、对话系统)只依赖这份契约,不感知 DSL、供应商与装配细节。
供应商抽象
人声渲染的唯一接口是 SpeechProvider:
from castmd import SpeechProvider, ScriptLine, SpeechResult
class MyProvider(SpeechProvider):
name = "my-tts"
def synthesize(self, script: list[ScriptLine], *, language=None) -> SpeechResult:
"""给我台词序列(voice + text),还我音频文件 + 带毫秒时间戳的字幕 cues。"""
契约要点:
ScriptLine.voice是不透明字符串,provider 自行解释(音色 id、voice name……);SpeechResult必须包含逐句/逐段的字幕 cues(伴随式音效对齐依赖它);- 除此之外,库不对 provider 做任何假设。SRT 解析、文本对齐、装配、缓存都在 provider 之外完成。
音乐生成是平行的第二个抽象 MusicProvider(generate(prompt) → 音频文件),给 sounds 里的 music: 声源用;生成结果按 prompt 哈希持久缓存,同一段描述只生成一次。
内置实现:
| Provider | 后端 | 说明 |
|---|---|---|
ListenHubProvider |
POST /v1/speech(同步多说话人 TTS) |
精确渲染台词,返回音频 + SRT;单段 ≤ 20,000 字符 |
ListenHubMusicProvider |
POST /v1/music/instrumental(异步任务) |
prompt → 纯音乐;轮询任务直到完成 |
段级缓存(增量重跑)
每段渲染结果按 hash(provider, language, 段台词) 缓存(默认 <输出目录>/.castmd-cache/)。重跑时:
- 台词没动的段 → 直接复用上次人声渲染(不再花供应商积分);
- 只改了声音链接 → 该段只重新装配;
- 动了台词 → 只重渲染该段。
CLI
castmd build episode.md -o out/ # 渲染,产出 <title>.mp3 + transcript.json
castmd build episode.md -o out/ep.ogg # 指定输出文件与格式
castmd build episode.md --no-cache # 忽略缓存,全部重渲染
castmd speakers --language zh # 列出 ListenHub 可用音色
castmd check episode.md # 只解析校验,不渲染
依赖
- Python ≥ 3.10;
pyyaml、httpx - 本机
ffmpeg/ffprobe(声音装配)
设计文档
架构、装配算法、字幕对齐策略、缓存键设计见 docs/design.md。
License
MIT
Metadata
Release files for castmd 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| castmd-0.1.0.tar.gz | 186.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| castmd-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 210.7 kB
Release files / castmd-0.1.0.tar.gz
| Download URL | castmd-0.1.0.tar.gz |
|---|---|
| Size | 186.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
41839b0f43262777fa8d65f9da517e18ff6cd419ba4fc998e27b07677de13478
|
|
BLAKE2b-256 checksum How to use checksums |
e0b3bbd2f1582e272fdab5b5ef4c1909d146949c527eb52f9dc74c541056159d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.2
|
Release files / castmd-0.1.0-py3-none-any.whl
| Download URL | castmd-0.1.0-py3-none-any.whl |
|---|---|
| Size | 24.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d0dfb2aad02659d1af53083590ee644d034330d2fcfe6c2616538abb1094ffd8
|
|
BLAKE2b-256 checksum How to use checksums |
dee300d030b3eca8d58547c2ad94762c31674a7635e11b46ce98356c8ca8050f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.2
|
Security for castmd 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.