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.
- podcue-0.2.0/.github/workflows/publish.yml +100 -0
- podcue-0.2.0/.github/workflows/test.yml +17 -0
- podcue-0.2.0/.gitignore +4 -0
- podcue-0.2.0/LICENSE +21 -0
- podcue-0.2.0/NOTICE +77 -0
- podcue-0.2.0/PKG-INFO +393 -0
- podcue-0.2.0/README.md +371 -0
- podcue-0.2.0/podcue/__init__.py +3 -0
- podcue-0.2.0/podcue/__main__.py +5 -0
- podcue-0.2.0/podcue/audio.py +58 -0
- podcue-0.2.0/podcue/cli.py +368 -0
- podcue-0.2.0/podcue/cues.py +176 -0
- podcue-0.2.0/podcue/diarize.py +327 -0
- podcue-0.2.0/podcue/fetch.py +130 -0
- podcue-0.2.0/podcue/groups.py +78 -0
- podcue-0.2.0/podcue/pipeline.py +130 -0
- podcue-0.2.0/podcue/recap.py +683 -0
- podcue-0.2.0/podcue/regions.py +78 -0
- podcue-0.2.0/podcue/store.py +154 -0
- podcue-0.2.0/podcue/summary.py +230 -0
- podcue-0.2.0/podcue/transcribe.py +110 -0
- podcue-0.2.0/podcue/weights.py +96 -0
- podcue-0.2.0/pyproject.toml +38 -0
- podcue-0.2.0/tests/conftest.py +74 -0
- podcue-0.2.0/tests/test_audio.py +66 -0
- podcue-0.2.0/tests/test_commands.py +302 -0
- podcue-0.2.0/tests/test_cues.py +115 -0
- podcue-0.2.0/tests/test_diarize.py +90 -0
- podcue-0.2.0/tests/test_recap.py +371 -0
- podcue-0.2.0/tests/test_regions.py +57 -0
- podcue-0.2.0/tests/test_smoke.py +141 -0
- podcue-0.2.0/tests/test_start.py +164 -0
- podcue-0.2.0/tests/test_transcribe.py +42 -0
- podcue-0.2.0/tests/test_weights.py +62 -0
- podcue-0.2.0/uv.lock +2447 -0
|
@@ -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
|
podcue-0.2.0/.gitignore
ADDED
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).
|