Metadata-Version: 2.4
Name: castmd
Version: 0.1.0
Summary: Render Podcast Markdown (a minimal dialogue DSL) into finished podcast audio via pluggable speech providers (ListenHub, ...)
Project-URL: Repository, https://github.com/memory-co/podcast.md
Author: twwyzh
License: MIT
Keywords: audio,listenhub,markdown,podcast,tts
Requires-Python: >=3.10
Requires-Dist: httpx>=0.24
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# castmd

**一份 Markdown,就是一期播客。**

`castmd` 是一个 Python 库:把 Podcast Markdown(一种刻意最小的对话 DSL)渲染成成品播客音频。人声通过可插拔的语音供应商生成(内置 [ListenHub](https://listenhub.ai)),背景音乐与音效由本地 ffmpeg 装配,最终产出 **音频文件 + 带时间戳和说话人标注的 transcript.json**。

```
episode.md ──► 解析 ──► 逐段人声渲染 (Provider) ──► 声音装配 (ffmpeg) ──► episode.mp3 + transcript.json
```

## 安装

```bash
pip install castmd      # 需要本机安装 ffmpeg
```

## 快速开始

```bash
export LISTENHUB_API_KEY=sk-...
castmd build examples/episode.md -o out/
```

或作为库使用:

```python
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)**。每个段独立渲染一次人声,段间顺序拼接。

````markdown
---
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](#鸟叫)

小雨: 幸存下来的,是那些体型小、什么都吃的家伙……
````

### 规则(全部规则)

1. **Frontmatter**(YAML):
   - `title` — 节目标题(可选);
   - `language` — 语言代码,如 `zh` / `en`(可选,传给供应商);
   - `speakers` — 署名 → 音色 id 的映射。音色 id 是不透明字符串,由所选供应商解释(ListenHub 即 speakerId,用 `castmd speakers` 查询);
   - `sounds` — 锚名 → 声音资源,四种形态:本地文件路径(相对 md 文件)、URL、特殊值 `silence`(静音)、**生成音乐**(写一段描述,由音乐供应商生成):

     ```yaml
     sounds:
       开场乐:
         music: 温暖轻快的钢琴播客片头,渐强开场
       爆炸声: assets/boom.wav
     ```
2. **分段 `---`**:每个段是一次独立的人声渲染单元,可增量重跑(见下文缓存)。
3. **台词行**:`署名: 台词内容`,署名必须在 `speakers` 中声明;中英文冒号均可;紧随台词行的普通文本行视为该行台词的续行。
4. **声音插入**,复用 Markdown 链接语法,两种形态:
   - **伴随式**:台词内 `[被说出的文字](#锚名)` — 这段文字**照常被念出来**,音效在文字出现的位置对齐**叠加**(靠字幕时间戳定位);
   - **独立式**:独立一行 `[时长](#锚名)`,如 `[1.2s](#鸟叫)` 或 `[500ms](#静音)` — 插入一段纯声音,人声暂停,时长即截取/补齐长度。
5. **没有别的语法**。不做:精细混音参数、SSML、多层轨道、整段铺底。需要时再扩展。

## 输出契约

```
render() → RenderedEpisode {
  audio_path:       成品音频(格式由输出扩展名决定:.mp3 / .ogg / .wav)
  transcript:       [{start_ms, end_ms, speaker, text}, ...]   # 装配后已修正时间戳
  duration_ms:      总时长
  transcript_path:  transcript.json 路径
}
```

下游(播放器、对话系统)只依赖这份契约,不感知 DSL、供应商与装配细节。

## 供应商抽象

人声渲染的唯一接口是 `SpeechProvider`:

```python
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

```bash
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](docs/design.md)。

## License

MIT
