podcue 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ name: publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ # Artifact upload needs actions: write. The workflow grant above is contents only.
15
+ permissions:
16
+ contents: read
17
+ actions: write
18
+ steps:
19
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
20
+ - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
21
+ - name: Build the sdist and wheel
22
+ run: |
23
+ uv build
24
+ rm -f dist/.gitignore
25
+ - name: Check the built version matches the release tag
26
+ if: github.event_name == 'release'
27
+ env:
28
+ RELEASE_TAG: ${{ github.ref_name }}
29
+ run: |
30
+ python3 - << 'PY'
31
+ import os
32
+ import pathlib
33
+ import sys
34
+ import tarfile
35
+ import zipfile
36
+
37
+ tag = os.environ["RELEASE_TAG"]
38
+ if tag.startswith("v"):
39
+ tag = tag[1:]
40
+ dist = pathlib.Path("dist")
41
+ files = [path for path in dist.iterdir() if path.is_file() and not path.name.startswith(".")]
42
+ wheels = [path for path in files if path.suffix == ".whl"]
43
+ sdists = [path for path in files if path.name.endswith(".tar.gz")]
44
+ if len(wheels) != 1 or len(sdists) != 1:
45
+ found = ", ".join(path.name for path in files) or "(empty)"
46
+ sys.exit(f"expected one sdist and one wheel in dist/, found {found}")
47
+
48
+ def metadata_version(text, name):
49
+ for line in text.splitlines():
50
+ if line.startswith("Version:"):
51
+ return line.split(":", 1)[1].strip()
52
+ sys.exit(f"{name} metadata has no Version")
53
+
54
+ def wheel_version(path):
55
+ with zipfile.ZipFile(path) as archive:
56
+ names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")]
57
+ if len(names) != 1:
58
+ sys.exit(f"{path.name} has no METADATA")
59
+ text = archive.read(names[0]).decode()
60
+ return metadata_version(text, path.name)
61
+
62
+ def sdist_version(path):
63
+ with tarfile.open(path) as archive:
64
+ members = [member for member in archive.getmembers() if member.name.endswith("/PKG-INFO")]
65
+ if len(members) != 1:
66
+ sys.exit(f"{path.name} has no PKG-INFO")
67
+ extracted = archive.extractfile(members[0])
68
+ if extracted is None:
69
+ sys.exit(f"{path.name} has no PKG-INFO")
70
+ text = extracted.read().decode()
71
+ return metadata_version(text, path.name)
72
+
73
+ built = ((wheels[0], wheel_version(wheels[0])), (sdists[0], sdist_version(sdists[0])))
74
+ for path, version in built:
75
+ if version != tag:
76
+ sys.exit(f"{path.name} version {version} does not match release tag {tag}")
77
+ print(f"built version {tag} matches the release tag")
78
+ PY
79
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
80
+ with:
81
+ name: dist
82
+ path: dist/
83
+ if-no-files-found: error
84
+
85
+ publish:
86
+ needs: build
87
+ runs-on: ubuntu-latest
88
+ environment:
89
+ name: pypi
90
+ url: https://pypi.org/p/podcue
91
+ # id-token is the OIDC credential. actions: read lets this job download the dist artifact.
92
+ permissions:
93
+ id-token: write
94
+ actions: read
95
+ steps:
96
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
97
+ with:
98
+ name: dist
99
+ path: dist/
100
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,17 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ unit:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python: ["3.12", "3.13", "3.14"]
14
+ steps:
15
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
16
+ - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
17
+ - run: uv run --python ${{ matrix.python }} pytest --ignore tests/test_smoke.py
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ .venv/
3
+ .pytest_cache/
4
+ dist/
podcue-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jianyu Zhao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
podcue-0.2.0/NOTICE ADDED
@@ -0,0 +1,77 @@
1
+ podcue
2
+ Copyright (c) 2026 Jianyu Zhao. MIT License, see LICENSE.
3
+
4
+ Parts of podcue are ported from PodDrill (MIT, Copyright (c) 2026 Jianyu Zhao).
5
+
6
+ Model weights and the speech-to-text packages are downloaded or installed
7
+ separately. They are not part of this package and are not covered by its license.
8
+
9
+ WeSpeaker ECAPA-TDNN512 (voxceleb_ECAPA512.onnx)
10
+ https://huggingface.co/Wespeaker/wespeaker-voxceleb-ecapa-tdnn512
11
+ Trained with WeSpeaker, https://github.com/wenet-e2e/wespeaker (code: Apache-2.0).
12
+ License: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). The model card
13
+ has no license field; WeSpeaker states that pretrained models follow the license
14
+ of their training data, and VoxCeleb is CC BY 4.0.
15
+ Training data: VoxCeleb (Nagrani, Chung, Xie, Zisserman),
16
+ https://www.robots.ox.ac.uk/~vgg/data/voxceleb/
17
+
18
+ pyannote segmentation-3.0 (segmentation.onnx)
19
+ pyannote segmentation-3.0 powerset model, ONNX export by onnx-community.
20
+ https://huggingface.co/onnx-community/pyannote-segmentation-3.0
21
+ License: MIT.
22
+
23
+ cactus-needle 3.2.0 (speech-to-text for en de fr es it nl pl)
24
+ https://github.com/cactus-compute/needle
25
+ License: Apache-2.0 (per the installed package metadata),
26
+ https://www.apache.org/licenses/LICENSE-2.0
27
+ It downloads its Whistle weights from https://huggingface.co/Cactus-Compute/whistle
28
+ under the terms on that page. podcue turns its usage telemetry off
29
+ (NEEDLE_TELEMETRY=0) unless the user has set NEEDLE_TELEMETRY.
30
+
31
+ Moonshine (ja, ko, zh only; optional `moonshine` extra)
32
+ Downloaded by the moonshine-voice package. The non-English Moonshine models are
33
+ released under the non-commercial Moonshine Community License:
34
+ https://www.moonshine.ai/license
35
+ Those terms apply only when podcue runs with --language ja, ko or zh. No other
36
+ language is routed to Moonshine. The recap extra does not use Moonshine.
37
+
38
+ Recap extra (`podcue[recap]`): Kokoro narration, soundfile, and yt-dlp.
39
+ These are not installed by a plain `podcue` install. Commercial use is allowed
40
+ for each direct dependency below. Two transitive packages are GPL-3.0-or-later;
41
+ distributing a combined work that links them carries GPL-3 obligations. podcue
42
+ does not vendor them.
43
+
44
+ kokoro 0.9.4 (narration; Python >=3.10, <3.13)
45
+ https://github.com/hexgrad/kokoro
46
+ License: Apache-2.0.
47
+ Voices and weights: hexgrad/Kokoro-82M, Apache-2.0,
48
+ https://huggingface.co/hexgrad/Kokoro-82M
49
+ Downloaded on first narration into the Hugging Face cache. Not part of this package.
50
+ Training audio under CC BY, used for a few hours of Kokoro v1.0: Koniwa `tnc`
51
+ (CC BY 3.0, under 1 hour) and SIWIS (CC BY 4.0, under 11 hours). See the model card.
52
+
53
+ misaki 0.9.4, extra `en` (grapheme-to-phoneme, required by kokoro)
54
+ https://github.com/hexgrad/misaki
55
+ License: Apache-2.0.
56
+ The `en` extra also installs:
57
+ phonemizer-fork (GPL-3.0-or-later), https://pypi.org/project/phonemizer-fork/
58
+ espeakng-loader, https://pypi.org/project/espeakng-loader/
59
+ The loader's package metadata declares no license. It redistributes the
60
+ espeak-ng shared library, which is GPL-3.0-or-later:
61
+ https://github.com/espeak-ng/espeak-ng/blob/master/COPYING
62
+ num2words (LGPL), https://github.com/savoirfairelinux/num2words
63
+ English narration tries espeak only for out-of-dictionary words and continues
64
+ if that fallback does not load.
65
+ torch (BSD-3-Clause) and transformers (Apache-2.0) are pulled in by kokoro.
66
+
67
+ soundfile 0.14.0
68
+ https://github.com/bastibe/python-soundfile
69
+ License: BSD-3-Clause.
70
+ Wheels bundle libsndfile, LGPL-2.1 or LGPL-3.0, loaded as a shared library:
71
+ https://github.com/libsndfile/libsndfile
72
+
73
+ yt-dlp 2026.10.26
74
+ https://github.com/yt-dlp/yt-dlp
75
+ License: The Unlicense (public domain). The PyPI wheel and the source
76
+ distribution are Unlicense. This extra does not install the PyInstaller
77
+ executable, which yt-dlp documents as GPL-3.0-or-later because of bundled code.
podcue-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,393 @@
1
+ Metadata-Version: 2.5
2
+ Name: podcue
3
+ Version: 0.2.0
4
+ Summary: Turn podcast audio into timed, speaker-grouped cues an agent can read.
5
+ Author: Jianyu Zhao
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ License-File: NOTICE
9
+ Requires-Python: <3.15,>=3.12
10
+ Requires-Dist: cactus-needle==3.2.0
11
+ Requires-Dist: numpy==2.5.4
12
+ Requires-Dist: onnxruntime==1.31.0
13
+ Requires-Dist: platformdirs==4.14.5
14
+ Requires-Dist: pyyaml==6.0.3
15
+ Provides-Extra: moonshine
16
+ Requires-Dist: moonshine-voice==0.1.5; extra == 'moonshine'
17
+ Provides-Extra: recap
18
+ Requires-Dist: kokoro==0.9.4; (python_version < '3.13') and extra == 'recap'
19
+ Requires-Dist: soundfile==0.14.0; extra == 'recap'
20
+ Requires-Dist: yt-dlp==2026.10.26; extra == 'recap'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # podcue
24
+
25
+ Turn podcast audio into timed, speaker-grouped cues, label the voices, and store summaries whose paragraphs cite those cues. The optional `recap` extra downloads an episode and stitches a narrated clip recap.
26
+
27
+ podcue is a local-only command line tool. Every command runs in the foreground, prints one JSON line, and exits. It runs no daemon, queue, or server. All state lives in files in one folder per episode. Agents can use it alongside the [PodBox skills](https://github.com/podbox-sh/skills), but they don't have to.
28
+
29
+ ## Requirements
30
+
31
+ - Python 3.12, 3.13 or 3.14 (onnxruntime 1.31.0 has no wheels for other versions). With [uv](https://docs.astral.sh/uv/) you don't need to install anything else in Python.
32
+ - `ffmpeg` on `PATH`.
33
+ - macOS or Linux (the episode lock uses `flock`).
34
+ - Network access on the first `start`, to download model weights. The first narration also downloads Kokoro weights.
35
+ - The `recap` extra for `fetch` of a page and for `build`: `kokoro`, `soundfile`, and `yt-dlp`. Kokoro 0.9.4 narrates on Python 3.12. Transcription on 3.13 and 3.14 does not install it.
36
+
37
+ ## Agent workflow
38
+
39
+ For an hour-long episode with ~700 cues, this is about 13 tool calls:
40
+
41
+ 1. `podcue start <file or URL>` returns when the cues and groups are ready. Keep `episode.episodeId`.
42
+ 2. `podcue cues <episodeId> --limit 200` reads the transcript. Pass `--after <last cue id>` for the next page (~4 pages).
43
+ 3. `podcue cues <episodeId> --group-id <groupId>` gathers evidence for one voice (~3 calls).
44
+ 4. `podcue label-groups <episodeId> '[{"groupId":…,"label":…}]'` names the voices. Stop when `labeledShare` is about 0.95.
45
+ 5. `podcue start-summary <episodeId>` returns a `summaryId`.
46
+ 6. `podcue post-paragraph <episodeId> <summaryId> '[{…},{…}]'` appends all paragraphs in one call. podcue fills the times.
47
+ 7. `podcue set-summary <episodeId> <summaryId> '{"summary":…}'` writes the summary.
48
+ 8. `podcue summary <episodeId> <summaryId>` reads the result back.
49
+
50
+ Each write is validated when it's made, so there is no fix loop. `podcue check` exists for summary files written by hand.
51
+
52
+ ## Recap
53
+
54
+ `fetch` and `build` need the `recap` extra. `start`, `cues`, and `label-groups` are unchanged; installing the extra is enough to run the whole recap, because the extra is a superset of the base package.
55
+
56
+ ```sh
57
+ uvx --python 3.12 --from "podcue[recap]==0.2.0" podcue fetch https://example.com/ep.mp3 --out ./ep
58
+ uvx --python 3.12 --from "podcue[recap]==0.2.0" podcue start ./ep/audio.wav
59
+ uvx --python 3.12 --from "podcue[recap]==0.2.0" podcue cues ep_3f9a1c2b7d4e5f60 --limit 200
60
+ uvx --python 3.12 --from "podcue[recap]==0.2.0" podcue label-groups ep_3f9a1c2b7d4e5f60 '[{"groupId":"gc0001","label":"Ada Lovelace"}]'
61
+ uvx --python 3.12 --from "podcue[recap]==0.2.0" podcue build ep_3f9a1c2b7d4e5f60 script.json --out recap.mp3
62
+ ```
63
+
64
+ Pass `--python 3.12`. Kokoro 0.9.4 declares `Requires-Python: <3.13`. A direct media URL downloads without yt-dlp; a page URL needs it, from the same extra. Plain `uvx podcue` does not install the extra, so transcription stays light.
65
+
66
+ ## Output
67
+
68
+ - stdout gets exactly one JSON line per command. Progress goes to stderr.
69
+ - A non-zero exit always prints `{"kind":"error","message":…}`. Some errors add a `reason` (`not-found`, `not-audio`, `unknown-language`, `running`, `limit`) or a `problems` list (`check`).
70
+ - An id the episode doesn't have prints `{"state":"unknown","episodeId":…}` and exits 0. A `groupId`, `cueId` or `summaryId` field names the unknown id.
71
+ - A command that needs cues the episode doesn't have yet (the start is still running, or it failed) prints `{"state":"not-ready","episodeId":…,"on":"cues"}` and exits 0. `podcue episode` shows the reason.
72
+ - `--help` and `--version` print plain text.
73
+
74
+ The episode root is `--root DIR` (accepted by every command), else `$PODCUE_HOME`, else `~/podcue`.
75
+
76
+ The examples below come from a short two-voice clip. The ids, paths and cosines are illustrative.
77
+
78
+ ## Commands
79
+
80
+ ### `podcue start <file or URL> [--language CODE]`
81
+
82
+ ```sh
83
+ uvx podcue start ep12.mp3
84
+ uvx podcue start https://example.com/ep12.mp3
85
+ uvx --from 'podcue[moonshine]' podcue start ep12.m4a --language ja
86
+ ```
87
+
88
+ ```json
89
+ {"kind": "started", "created": true, "episode": {"state": "present", "episodeId": "ep_3f9a1c2b7d4e5f60", "source": "ep12.mp3", "durationMs": 17664, "language": "en", "createdAt": "2026-11-05T10:12:03Z", "dir": "/Users/me/podcue/episodes/ep_3f9a1c2b7d4e5f60", "artifacts": [{"artifact": "transcript", "state": "ready", "progress": 100, "path": "/Users/me/podcue/episodes/ep_3f9a1c2b7d4e5f60/transcript.vtt"}, {"artifact": "cues", "state": "ready", "progress": 100, "path": "/Users/me/podcue/episodes/ep_3f9a1c2b7d4e5f60/cues.json"}], "groups": [{"groupId": "gc0001", "speakingMs": 9760, "cueCount": 4, "nearestGroupId": "gc0003", "nearestCosine": 0.1873, "label": null}, {"groupId": "gc0003", "speakingMs": 5840, "cueCount": 3, "nearestGroupId": "gc0001", "nearestCosine": 0.1873, "label": null}], "labeledShare": 0.0, "summaries": []}}
90
+ ```
91
+
92
+ - The source is any file ffmpeg can decode, or a direct `http(s)` link to one. podcue doesn't resolve web pages to audio. A local path is checked before any model loads or any download starts.
93
+ - The episode id is `ep_` plus the first 16 hex characters of the sha256 of the converted 16 kHz mono WAV. The same audio always maps to the same folder.
94
+ - Idempotent: when the folder already has `cues.json`, `start` returns it with `"created": false` and loads no model. `--language` is then ignored, and the episode keeps its first language.
95
+ - A start that failed or was interrupted is rerun by the next `start` of the same audio.
96
+ - A second `start` of the same episode while one is running exits 1 with `"reason": "running"`.
97
+ - `--language` is one of `en de fr es it nl pl` (cactus-needle), or `ja ko zh` (Moonshine, from the `moonshine` extra). The default is `en`. **`ja`, `ko` and `zh` are experimental.** In testing, Moonshine dropped the opening greeting 「こんにちは。」 of a Japanese clip and returned the rest as one cue without word times.
98
+
99
+ How it works: podcue converts the audio to 16 kHz mono WAV and diarizes the whole episode (pyannote segmentation plus ECAPA embeddings). It joins each speaker's turns into regions, transcribes the regions in slices of up to 28 seconds, and groups the words into cues. A cue ends at sentence punctuation, after 12 seconds, or where a speaker region ends. **Speech the diarizer misses (outside the detected speech regions) is not transcribed.** Each cue gets the group whose diarization turns overlap it most, or `ungrouped` when none does.
100
+
101
+ ### `podcue episode <episodeId>`
102
+
103
+ This has the same shape as `start`'s `episode`. While a start is running, both artifacts show `"state": "running"` with the stage (`weights`, `diarize`, `transcribe`, `write`) and progress:
104
+
105
+ ```json
106
+ {"state": "present", "episodeId": "ep_3f9a1c2b7d4e5f60", "source": "ep12.mp3", "durationMs": 17664, "language": "en", "createdAt": "2026-11-05T10:12:03Z", "dir": "/Users/me/podcue/episodes/ep_3f9a1c2b7d4e5f60", "artifacts": [{"artifact": "transcript", "state": "running", "stage": "transcribe", "progress": 40}, {"artifact": "cues", "state": "running", "stage": "transcribe", "progress": 40}], "summaries": []}
107
+ ```
108
+
109
+ A failed start shows `{"state": "failed", "message": …}`. A start that was killed shows the message `start was interrupted; run start again`. `groups` and `labeledShare` appear once the cues are ready.
110
+
111
+ ### `podcue cues <episodeId> [--limit N] [--after CUE_ID] [--group-id GROUP_ID] [--hosts]`
112
+
113
+ ```json
114
+ {"state": "present", "episodeId": "ep_3f9a1c2b7d4e5f60", "cues": [{"id": "c0003", "startMs": 5821, "endMs": 7021, "text": "Thanks for having me.", "groupId": "gc0003", "nearestGroupId": "gc0001", "nearestCosine": 0.1873}], "hostNames": ["Ada Lovelace", "Grace Hopper"]}
115
+ ```
116
+
117
+ - `--limit` is 1 through 200. The default is 50. A page shorter than the limit is the last one.
118
+ - `--after` starts after that cue. With `--group-id`, the cue must belong to that group.
119
+ - `--group-id` returns only that group's cues. `ungrouped` is always a valid id.
120
+ - `--hosts` adds `hostNames`: the person names in `labels.json`, leaving out `quote`, `ending` and `undetermined`.
121
+
122
+ ### `podcue label-groups <episodeId> '<json>'`
123
+
124
+ ```sh
125
+ podcue label-groups ep_3f9a1c2b7d4e5f60 '[{"groupId":"gc0001","label":"Ada Lovelace"},{"groupId":"gc0003","label":"Grace Hopper"}]'
126
+ ```
127
+
128
+ ```json
129
+ {"state": "present", "episodeId": "ep_3f9a1c2b7d4e5f60", "labels": [{"groupId": "gc0001", "label": "Ada Lovelace"}, {"groupId": "gc0003", "label": "Grace Hopper"}], "labeledShare": 1.0}
130
+ ```
131
+
132
+ - The body is a JSON array of `{groupId, label}`. A label is a person's name, `undetermined`, `quote` (a played clip) or `ending` (a sign-off or song). Send only the groups you are changing. They are merged into `labels.json`, and the last entry for a group wins.
133
+ - If any `groupId` is unknown, nothing is written.
134
+ - `labeledShare` is the share of total cue time whose group has a label other than `undetermined`: the sum of `endMs - startMs` over those cues, divided by the same sum over all cues, rounded to 4 places. It's the same measure as `groups[].speakingMs`. Stop labeling at about 0.95.
135
+
136
+ ### `podcue start-summary <episodeId>`
137
+
138
+ ```json
139
+ {"state": "present", "episodeId": "ep_3f9a1c2b7d4e5f60", "version": 1, "summaryId": "sm_8c41d07e2a9b3f15", "summary": "", "paragraphs": []}
140
+ ```
141
+
142
+ This creates `summaries/<summaryId>.yaml`. Each call starts a new summary.
143
+
144
+ ### `podcue post-paragraph <episodeId> <summaryId> '<json>'`
145
+
146
+ ```sh
147
+ podcue post-paragraph ep_3f9a1c2b7d4e5f60 sm_8c41d07e2a9b3f15 '[
148
+ {"text": "Ada asks why cities build so few homes near jobs.", "cues": ["c0002"], "part": "discussion"},
149
+ {"text": "Grace blames zoning.", "cues": ["c0005", "c0004"], "part": "claim"}]'
150
+ ```
151
+
152
+ ```json
153
+ {"state": "present", "episodeId": "ep_3f9a1c2b7d4e5f60", "version": 1, "summaryId": "sm_8c41d07e2a9b3f15", "summary": "", "paragraphs": [{"index": 1, "part": "discussion", "text": "Ada asks why cities build so few homes near jobs.", "cues": ["c0002"], "startMs": 1680, "endMs": 5600}, {"index": 2, "part": "claim", "text": "Grace blames zoning.", "cues": ["c0004", "c0005"], "startMs": 7341, "endMs": 12221}]}
154
+ ```
155
+
156
+ - The body is one paragraph `{text, cues, part?}`, or a JSON array of them, appended in order. Each paragraph is validated before any is written.
157
+ - `cues` are stored in transcript order. `startMs` is the start of the earliest cited cue and `endMs` is the end of the latest. Leave both out. If you send one, it must match.
158
+ - `part` is optional: one of `news`, `explanation`, `advice`, `claim`, `discussion`, `story`, `items`.
159
+ - Errors: an unknown cue, a cue listed twice, a time that doesn't match, an unknown `part`, an `index`, or any other key. In an array, the message starts with `paragraph N:`.
160
+
161
+ ### `podcue put-paragraph <episodeId> <summaryId> '<json>'`
162
+
163
+ This replaces an existing paragraph. It takes the same fields as `post-paragraph` plus `index`:
164
+
165
+ ```sh
166
+ podcue put-paragraph ep_3f9a1c2b7d4e5f60 sm_8c41d07e2a9b3f15 '{"index": 2, "text": "Grace blames zoning: most central land allows only single-family houses.", "cues": ["c0004", "c0005"], "part": "claim"}'
167
+ ```
168
+
169
+ It prints the whole summary, like `post-paragraph`. An index that doesn't exist exits 1 with `putParagraph index does not exist`.
170
+
171
+ ### `podcue set-summary <episodeId> <summaryId> '{"summary": "…"}'`
172
+
173
+ ```json
174
+ {"state": "present", "episodeId": "ep_3f9a1c2b7d4e5f60", "version": 1, "summaryId": "sm_8c41d07e2a9b3f15", "summary": "Grace Hopper says zoning keeps cities from building homes near jobs.", "paragraphs": [{"index": 1, "part": "discussion", "text": "Ada asks why cities build so few homes near jobs.", "cues": ["c0002"], "startMs": 1680, "endMs": 5600}, {"index": 2, "part": "claim", "text": "Grace blames zoning: most central land allows only single-family houses.", "cues": ["c0004", "c0005"], "startMs": 7341, "endMs": 12221}]}
175
+ ```
176
+
177
+ It rejects an empty summary, and a summary that has no paragraphs yet.
178
+
179
+ ### `podcue summary <episodeId> <summaryId>`
180
+
181
+ This prints the same object as `set-summary`, read back from the file.
182
+
183
+ ### `podcue check <episodeId> <summaryId or path>`
184
+
185
+ `check` strictly validates a summary file against the episode's `cues.json` and `labels.json`. The file can be one the commands wrote (pass its id) or one written by hand (pass a `.yaml` or `.json` path). It never changes the file.
186
+
187
+ ```json
188
+ {"state": "valid", "episodeId": "ep_3f9a1c2b7d4e5f60", "path": "/Users/me/podcue/episodes/ep_3f9a1c2b7d4e5f60/summaries/sm_8c41d07e2a9b3f15.yaml", "paragraphs": 2, "summaryId": "sm_8c41d07e2a9b3f15", "warnings": []}
189
+ ```
190
+
191
+ ```json
192
+ {"kind": "error", "message": "2 problem(s): paragraph 1: startMs 1 does not match c0004 startMs 7341; paragraph 1: endMs 2 does not match c0004 endMs 8781", "problems": ["paragraph 1: startMs 1 does not match c0004 startMs 7341", "paragraph 1: endMs 2 does not match c0004 endMs 8781"], "warnings": []}
193
+ ```
194
+
195
+ It fails, listing every problem, when:
196
+
197
+ - The file isn't a mapping of exactly `version` (must be `1`), `episodeId` (must be this episode), `summary` (a non-empty string), `paragraphs` (a non-empty list), and an optional `summaryId` (`sm_` + 16 hex).
198
+ - A paragraph has keys other than `index`, `part`, `text`, `cues`, `startMs`, `endMs`, or `index` isn't its 1-based position, or `text` is empty, or `part` isn't in the list above.
199
+ - `cues` is empty, names a cue that doesn't exist, lists a cue twice, or isn't in transcript order.
200
+ - `startMs` or `endMs` is missing, or doesn't equal the earliest cited cue's start or the latest cited cue's end.
201
+ - A group cited through a paragraph's cues has no entry in `labels.json`. `ungrouped` cues need no label, and `quote` and `ending` count as labels. A cited group labeled `undetermined` adds a `warnings` entry but doesn't fail the check, because labeling stops at about 95% of spoken time on purpose.
202
+
203
+ ### `podcue fetch <URL> [--out DIR]`
204
+
205
+ ```sh
206
+ uvx --python 3.12 --from "podcue[recap]==0.2.0" podcue fetch https://cdn.example.com/ep.mp3 --out ./ep
207
+ uvx --python 3.12 --from "podcue[recap]==0.2.0" podcue fetch https://www.youtube.com/watch?v=VIDEO --out ./ep
208
+ ```
209
+
210
+ ```json
211
+ {"kind": "fetched", "url": "https://cdn.example.com/ep.mp3", "path": "/Users/me/ep/audio.wav", "durationMs": 3600000, "via": "direct"}
212
+ ```
213
+
214
+ - Writes `<DIR>/audio.wav` (default directory: `.`): 16-bit mono 16 kHz PCM, the format `start` converts to. Pass that path to `podcue start`.
215
+ - `via` is `direct` when the URL path ends in a media suffix (`.mp3`, `.m4a`, `.wav`, `.mp4`, and the other audio and video suffixes), query string ignored. `via` is `yt-dlp` for any other `http(s)` URL. A non-URL exits 1.
216
+ - The direct path uses the standard library. The page path imports yt-dlp and exits 1, naming the `recap` extra, when that package is missing.
217
+ - ffmpeg converts the download. An empty or undecodable file exits 1 with `"reason": "not-audio"`.
218
+
219
+ ### `podcue build <episodeId> <script.json> [--out FILE]`
220
+
221
+ ```sh
222
+ uvx --python 3.12 --from "podcue[recap]==0.2.0" podcue build ep_3f9a1c2b7d4e5f60 script.json --out recap.mp3
223
+ ```
224
+
225
+ ```json
226
+ {"kind": "built", "episodeId": "ep_3f9a1c2b7d4e5f60", "output": "/Users/me/recap.mp3", "report": "/Users/me/report.md", "durationMs": 312000, "loudness": {"lufs": -16.0, "truePeak": -1.5}, "flags": [], "segments": [{"index": 1, "type": "narration", "text": "Intro.", "voice": "am_michael", "lufsIn": -23.0, "lufsOut": -16.0, "flags": []}]}
227
+ ```
228
+
229
+ Takes the episode `start` already transcribed. Clips are cut from `episode.json`'s `source`, which has to be a local file. An episode started from a URL has no audio left to cut; `fetch`, then `start` on `audio.wav`.
230
+
231
+ The output is 128 kbps MP3, or 24 kHz mono WAV when the path ends in `.wav`. `--out` wins. Otherwise the script's `output` is used, else `produced_summary.mp3`, resolved from the working directory. `report.md` is written next to that file. Flags are listed in the JSON and in the report. A flagged clip still writes the file and exits 0; fix the script and build again. Narration and clip wavs are reused from `<episode>/recap-cache/` when the segment is unchanged.
232
+
233
+ What it does:
234
+
235
+ 1. Snap each clip to cue word times (below), with a short fade.
236
+ 2. Narrate each narration segment with Kokoro at 24 kHz on CPU. The default voice is `am_michael`.
237
+ 3. Loudnorm each segment to -16 LUFS, true peak -1.5 dBTP, join them with 0.4 s of silence, loudnorm the mix, and encode it.
238
+ 4. Flag a clip whose snapped length is outside 10–20 seconds, a clip with no overlapping cue, and a clip with a second speaker. A second speaker counts when another labeled voice covers at least 1 second and 8% of the clip. Labels come from `labels.json`; an unlabeled group is named by its `groupId`. Two groups that share a label are one speaker.
239
+
240
+ Kokoro is imported only for a narration segment. On Python 3.13 or 3.14, or without the extra, that import exits 1 and names the 3.12 install command. The first narration downloads the Kokoro-82M weights from Hugging Face.
241
+
242
+ #### `script.json`
243
+
244
+ An object, or a bare array of segments. Unknown keys are ignored.
245
+
246
+ ```json
247
+ {
248
+ "voice": "am_michael",
249
+ "output": "produced_summary.mp3",
250
+ "segments": [
251
+ {"type": "narration", "text": "Intro."},
252
+ {"type": "clip", "start": "40:43.5", "end": "40:58.0", "speaker": "the guest"},
253
+ {"type": "narration", "text": "Bridge.", "voice": "american-female-heart"}
254
+ ]
255
+ }
256
+ ```
257
+
258
+ | Field | Where | Meaning |
259
+ | --- | --- | --- |
260
+ | `voice` | script or narration | Kokoro voice id. The default, when `voice` and `voice_preset` are omitted, is `am_michael`. `american-male-puck` maps to `am_puck` and `american-female-heart` maps to `af_heart`. Any other string is passed through as a Kokoro id. |
261
+ | `voice_preset` | script or narration | Same as `voice`, used when `voice` is absent. A narration's own `voice` or `voice_preset` wins over the script's. |
262
+ | `output` | script | File name used when `--out` is omitted. |
263
+ | `segments` | script | Non-empty array, in playback order. A bare array is the whole script. |
264
+ | `type` | segment | `narration` or `clip`. |
265
+ | `text` | narration | What Kokoro reads. A non-empty string. |
266
+ | `start`, `end` | clip | Seconds (`90.5` or `"90.5"`) or `[h:]mm:ss[.s]` (`"40:43.5"`, `"1:02:03"`). `end` must be after `start`. |
267
+ | `speaker` | clip | Name written into the report. Optional. The default is `?`. |
268
+
269
+ Cues are at most 12 seconds, so a 10–20 second clip spans two or three consecutive cues. `cues.json` has no per-word timestamps. Each cue's words are spread across that cue by character length, and each requested edge snaps to the nearest of those word times, then pads at most 0.25 s before the first word and 0.35 s after the last, stopping at the neighbouring word. The clip text in the report is those words. A clip that should be one speaker needs two or three consecutive cues from one group.
270
+
271
+ ### `podcue fetch-weights`
272
+
273
+ This is optional. It downloads the two pinned diarization weight files now instead of on the first `start`.
274
+
275
+ ```json
276
+ {"kind": "ready", "weights": [{"file": "voxceleb_ECAPA512.onnx", "path": "/Users/me/Library/Caches/podcue/weights/voxceleb_ECAPA512.onnx", "sha256": "f901ae3571912bad4e11e157744cb8b7d77a0b46923b83c410bdbb6c77d69736", "license": "CC BY 4.0"}, {"file": "segmentation.onnx", "path": "/Users/me/Library/Caches/podcue/weights/segmentation.onnx", "sha256": "057ee564753071c0b09b5b611648b50ac188d50846bff5f01e9f7bbf1591ea25", "license": "MIT"}]}
277
+ ```
278
+
279
+ ## The episode folder
280
+
281
+ ```
282
+ <root>/episodes/ep_3f9a1c2b7d4e5f60/
283
+ episode.json written when a start begins
284
+ transcript.vtt \
285
+ groups.json } written when the start succeeds; cues.json last, so it marks the episode ready
286
+ cues.json /
287
+ labels.json written by label-groups
288
+ summaries/
289
+ sm_8c41d07e2a9b3f15.yaml
290
+ recap-cache/ narration and clip wavs reused by a later build of the same segment
291
+ status.json only while a start runs; removed when it ends
292
+ failed.json only after a failed start; removed by the next start
293
+ start.lock holds the running start's pid (empty otherwise)
294
+ write.lock serializes label and summary writes
295
+ ```
296
+
297
+ Every file is written to a temp file and renamed into place, so a reader never sees half a file. The only coordination is the two lock files. `start.lock` is held with `flock` and holds the pid, so a start that crashed leaves a stale lock, which the OS has already released and the next start replaces.
298
+
299
+ | File | Schema |
300
+ | --- | --- |
301
+ | `episode.json` | `{episodeId, source, durationMs, language, createdAt}`. `source` is the path or URL as given. `createdAt` is ISO 8601 UTC from the first start. |
302
+ | `cues.json` | `[{id, startMs, endMs, text, groupId, nearestGroupId, nearestCosine}]` in time order. `id` is `c0001`, `c0002`, …. Times are ms from the start of the audio. `groupId` is `g` + the group's first cue id (for example `gc0001`), or `ungrouped`. `nearestGroupId`/`nearestCosine` name the other group whose ECAPA centroid is most similar to this cue's group. Both are `null` for `ungrouped` and when there is one group. A high cosine hints that two groups are one person. |
303
+ | `groups.json` | `[{groupId, speakingMs, cueCount, nearestGroupId, nearestCosine}]` in order of first cue. `speakingMs` is the sum of the group's cue durations. |
304
+ | `transcript.vtt` | WebVTT. The cue identifier is the cue id, and grouped cues carry a voice tag: `c0001` / `00:00:00.080 --> 00:00:01.280` / `<v gc0001>Welcome back to the show.` |
305
+ | `labels.json` | `{"groups": [{groupId, label}]}` sorted by `groupId`. |
306
+ | `summaries/sm_*.yaml` | `version: 1`, `episodeId`, `summaryId`, `summary`, and `paragraphs: [{index, part?, text, cues, startMs, endMs}]`. |
307
+ | `status.json` | `{pid, stage, progress}`. `stage` is `weights`, `diarize`, `transcribe` or `write`, and `progress` runs 0 to 99. |
308
+ | `failed.json` | `{message, stage, at}`. |
309
+ | `recap-cache/` | Wav segments from `build`. Deleting it only makes the next build render those segments again. |
310
+
311
+ A summary file:
312
+
313
+ ```yaml
314
+ version: 1
315
+ episodeId: ep_3f9a1c2b7d4e5f60
316
+ summaryId: sm_8c41d07e2a9b3f15
317
+ summary: Grace Hopper says zoning keeps cities from building homes near jobs.
318
+ paragraphs:
319
+ - index: 1
320
+ part: discussion
321
+ text: Ada asks why cities build so few homes near jobs.
322
+ cues:
323
+ - c0002
324
+ startMs: 1680
325
+ endMs: 5600
326
+ - index: 2
327
+ part: claim
328
+ text: 'Grace blames zoning: most central land allows only single-family houses.'
329
+ cues:
330
+ - c0004
331
+ - c0005
332
+ startMs: 7341
333
+ endMs: 12221
334
+ ```
335
+
336
+ ## Privacy and network
337
+
338
+ podcue runs locally. It uses the network only to download the input when you pass a URL to `start` or `fetch`, to download diarization weights on first use, and, with the recap extra, to download Kokoro weights the first time a narration is built. `fetch` of a page also uses the network through yt-dlp. Each direct download has a 60-second connect/read timeout, so a stalled connection fails instead of hanging.
339
+
340
+ cactus-needle sends anonymous usage counts by default. **podcue turns that off** by setting `NEEDLE_TELEMETRY=0` in its own process when `NEEDLE_TELEMETRY` is unset. If you set `NEEDLE_TELEMETRY` yourself, podcue leaves it alone. The same for Hugging Face when a narration loads Kokoro: `HF_HUB_DISABLE_TELEMETRY=1` unless you have set it.
341
+
342
+ ## Weights and licenses
343
+
344
+ On first use podcue downloads two pinned ONNX files into the user cache directory (`~/Library/Caches/podcue` on macOS, `~/.cache/podcue` on Linux). Each file is checked against its pinned size and SHA-256 before use. A file that fails the check is downloaded again.
345
+
346
+ | File | Source (pinned revision) | Bytes | SHA-256 | License |
347
+ | --- | --- | --- | --- | --- |
348
+ | `voxceleb_ECAPA512.onnx` | [Wespeaker/wespeaker-voxceleb-ecapa-tdnn512](https://huggingface.co/Wespeaker/wespeaker-voxceleb-ecapa-tdnn512) @ `89426e1` | 24,861,931 | `f901ae3571912bad4e11e157744cb8b7d77a0b46923b83c410bdbb6c77d69736` | CC BY 4.0 |
349
+ | `segmentation.onnx` | [onnx-community/pyannote-segmentation-3.0](https://huggingface.co/onnx-community/pyannote-segmentation-3.0) @ `733a93b` | 5,986,908 | `057ee564753071c0b09b5b611648b50ac188d50846bff5f01e9f7bbf1591ea25` | MIT |
350
+
351
+ - **ECAPA:** an ECAPA-TDNN512 trained with [WeSpeaker](https://github.com/wenet-e2e/wespeaker) (Apache-2.0 code) on VoxCeleb. Credit for the CC BY 4.0 weights goes to VoxCeleb (Nagrani, Chung, Xie, Zisserman), https://www.robots.ox.ac.uk/~vgg/data/voxceleb/. The model card has no license field. WeSpeaker says pretrained models follow the license of their training data, so the CC BY 4.0 attribution travels with the file.
352
+ - **Segmentation:** pyannote segmentation-3.0 powerset model, MIT license.
353
+ - **Speech-to-text (`en de fr es it nl pl`):** [cactus-needle](https://github.com/cactus-compute/needle) 3.2.0, licensed [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) per its package metadata. It downloads its Whistle weights from [Cactus-Compute/whistle](https://huggingface.co/Cactus-Compute/whistle) under the terms on that page.
354
+ - **Moonshine (`ja ko zh` only):** moonshine-voice 0.1.5, from the `moonshine` extra, downloads the model into `<cache>/moonshine` and verifies it against its own CRC32C catalog. The non-English Moonshine models are under the non-commercial [Moonshine Community License](https://www.moonshine.ai/license). Those terms apply only when you run with `--language ja|ko|zh`. No other language is routed to Moonshine.
355
+
356
+ The weights are not part of this package and are not covered by its MIT license. See [NOTICE](NOTICE).
357
+
358
+ ### Recap extra
359
+
360
+ Installed only with `podcue[recap]`. Plain `uvx podcue` does not install these.
361
+
362
+ | Piece | Version | License |
363
+ | --- | --- | --- |
364
+ | `kokoro` | 0.9.4 | Apache-2.0. Requires Python 3.10 or 3.12 (`Requires-Python: <3.13`). |
365
+ | Kokoro-82M weights | `hexgrad/Kokoro-82M` | Apache-2.0. Downloaded on first narration, not shipped here. A few hours of the training audio are CC BY (Koniwa, SIWIS); see [NOTICE](NOTICE). |
366
+ | `soundfile` | 0.14.0 | BSD-3-Clause. Its wheels ship libsndfile, LGPL-2.1-or-later, loaded as a shared library. |
367
+ | `yt-dlp` | 2026.10.26 | Unlicense. The PyPI wheel is Unlicense. The PyInstaller executables are not what this extra installs. |
368
+
369
+ `kokoro` depends on `misaki[en]` (Apache-2.0). That extra also installs `phonemizer-fork` (GPL-3.0-or-later) and `espeakng-loader`, which does not declare a license and bundles the espeak-ng shared library (GPL-3.0-or-later). English narration tries espeak only as a fallback for out-of-dictionary words and continues without it. Commercial use of those GPL pieces is allowed; distributing a combined work that links them is under GPL-3. podcue does not vendor them. `num2words`, also pulled in for English, is LGPL. `torch` is BSD-3-Clause and `transformers` is Apache-2.0.
370
+
371
+ The `moonshine` extra is separate and unchanged: its non-English models are non-commercial. The recap extra does not use Moonshine.
372
+
373
+ ## Development
374
+
375
+ ```sh
376
+ uv run pytest # unit tests + smoke test
377
+ uv run pytest --ignore tests/test_smoke.py # unit tests only
378
+ uv run --extra moonshine pytest tests/test_smoke.py # adds the Japanese Moonshine smoke case
379
+ ```
380
+
381
+ Unit tests do not install the `recap` extra. They stub Kokoro and the downloader. `build` and `fetch` still need ffmpeg only when those stubs are not in place.
382
+
383
+ The smoke test synthesizes a short two-voice clip and runs the whole flow through the real CLI and models: start, cues, label-groups, start-summary, post-paragraph, set-summary, summary, check. It uses macOS `say` (and expects two groups) or `espeak-ng` (whose voices usually cluster as one). It skips with the reason when ffmpeg or a synthesizer is missing.
384
+
385
+ ## Releasing
386
+
387
+ Bump `version` in `pyproject.toml` and `__version__` in `podcue/__init__.py` to the same value, then run `uv lock` so the lockfile matches. Merge that to `main`.
388
+
389
+ Create a GitHub release tagged `v` plus that version, for example `v0.2.0`. Publishing the release runs [publish.yml](.github/workflows/publish.yml). It builds the sdist and wheel, checks that the built version matches the tag with a leading `v` removed, and publishes to PyPI with trusted publishing. The workflow stores no PyPI token. Running the workflow by hand (`workflow_dispatch`) publishes the selected commit the same way, without the tag check.
390
+
391
+ ## License
392
+
393
+ MIT License. Copyright 2026 Jianyu Zhao. Model weights are separate; see [Weights and licenses](#weights-and-licenses) and [NOTICE](NOTICE).