@sdelsad/commodity-desk-daily 1.0.17 → 1.13.2
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.
- package/STYLE.md +439 -0
- package/__pycache__/build_page.cpython-314.pyc +0 -0
- package/__pycache__/chart.cpython-314.pyc +0 -0
- package/build_email.py +773 -0
- package/build_page.py +1237 -0
- package/build_post.py +291 -0
- package/chart.py +563 -0
- package/conversions.md +156 -0
- package/curriculum.md +294 -0
- package/ep01.md +144 -0
- package/ep01.mp3 +0 -0
- package/ep01.script.txt +71 -0
- package/ep02.md +141 -0
- package/ep02.script.txt +70 -0
- package/ep03.md +163 -0
- package/ep03.script.txt +61 -0
- package/ep04.md +192 -0
- package/ep04.mp3 +0 -0
- package/ep04.script.txt +69 -0
- package/fetch_context.py +123 -0
- package/generate_audio.py +120 -0
- package/package.json +12 -12
- package/publish_episode.py +367 -0
- package/setup.sh +23 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Soft Commodity Trading - publisher.
|
|
4
|
+
|
|
5
|
+
Publishes one episode to npm (which jsDelivr then serves as a public CDN),
|
|
6
|
+
carrying the podcast cover and RSS feed forward and prepending the new episode
|
|
7
|
+
to the feed. Handles version bumping off whatever is currently live, so it is
|
|
8
|
+
safe to run every morning without tracking state anywhere.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python3 publish_episode.py \
|
|
12
|
+
--mp3 ep02.mp3 --notes ep02.md \
|
|
13
|
+
--number 2 --title "Flat Price vs Basis" \
|
|
14
|
+
--description "Why physical traders don't bet on price." \
|
|
15
|
+
--duration 612 --pubdate "Tue, 11 Aug 2026 05:00:00 GMT"
|
|
16
|
+
|
|
17
|
+
Prints PUBLISHED_URL=... and PUBLISHED_VERSION=... on success.
|
|
18
|
+
Exit codes: 0 ok, 1 error.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tarfile
|
|
28
|
+
import tempfile
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
PKG = "@sdelsad/commodity-desk-daily"
|
|
32
|
+
SITE_URL = "https://storage.googleapis.com/podcast-audio-2647223968/index.html"
|
|
33
|
+
REGISTRY = "https://registry.npmjs.org"
|
|
34
|
+
CDN = "https://cdn.jsdelivr.net/npm"
|
|
35
|
+
|
|
36
|
+
FEED_SKELETON = """<?xml version="1.0" encoding="UTF-8"?>
|
|
37
|
+
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
|
38
|
+
<channel>
|
|
39
|
+
<title>Soft Commodity Trading</title>
|
|
40
|
+
<link>https://www.npmjs.com/package/{pkg}</link>
|
|
41
|
+
<description>A daily 10-minute briefing on physical commodity trading.</description>
|
|
42
|
+
<language>en-us</language>
|
|
43
|
+
<itunes:author>Sébastien Delsad</itunes:author>
|
|
44
|
+
<itunes:explicit>false</itunes:explicit>
|
|
45
|
+
<itunes:category text="Business"/>
|
|
46
|
+
</channel>
|
|
47
|
+
</rss>
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_json(url):
|
|
52
|
+
with urllib.request.urlopen(url, timeout=30) as resp:
|
|
53
|
+
return json.load(resp)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def latest_version():
|
|
57
|
+
try:
|
|
58
|
+
meta = fetch_json(f"{REGISTRY}/{PKG.replace('/', '%2f')}")
|
|
59
|
+
return meta["dist-tags"]["latest"]
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
print(f" ! could not read registry ({exc}); starting at 1.0.0")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def bump(version):
|
|
66
|
+
if not version:
|
|
67
|
+
return "1.0.0"
|
|
68
|
+
major, minor, patch = (version.split(".") + ["0", "0"])[:3]
|
|
69
|
+
return f"{major}.{minor}.{int(patch) + 1}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def carry_forward(version, workdir):
|
|
73
|
+
"""Extract cover.jpg / feed.xml from the currently published tarball."""
|
|
74
|
+
if not version:
|
|
75
|
+
return None
|
|
76
|
+
name = PKG.split("/")[-1]
|
|
77
|
+
url = f"{REGISTRY}/{PKG}/-/{name}-{version}.tgz"
|
|
78
|
+
try:
|
|
79
|
+
tgz = os.path.join(workdir, "prev.tgz")
|
|
80
|
+
urllib.request.urlretrieve(url, tgz)
|
|
81
|
+
with tarfile.open(tgz) as tar:
|
|
82
|
+
for member in tar.getmembers():
|
|
83
|
+
base = os.path.basename(member.name)
|
|
84
|
+
if base in ("cover.jpg", "feed.xml", "covered.md", "glossary.md"):
|
|
85
|
+
member.name = base
|
|
86
|
+
tar.extract(member, workdir)
|
|
87
|
+
os.unlink(tgz)
|
|
88
|
+
return True
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
print(f" ! could not carry forward assets ({exc})")
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_item(number, title, description, url, size, duration, pubdate, page_url=""):
|
|
95
|
+
"""One <item>. With page_url, the episode gets a clickable website link.
|
|
96
|
+
|
|
97
|
+
Apple Podcasts renders an item's <link> as the episode's website button, and
|
|
98
|
+
renders anchors inside a CDATA <description>. Both are set, because some
|
|
99
|
+
apps honour only one of the two.
|
|
100
|
+
"""
|
|
101
|
+
def esc(text):
|
|
102
|
+
return (text.replace("&", "&").replace("<", "<").replace(">", ">"))
|
|
103
|
+
|
|
104
|
+
link = f" <link>{esc(page_url)}</link>\n" if page_url else ""
|
|
105
|
+
if page_url:
|
|
106
|
+
body = (f"<![CDATA[<p>{esc(description)}</p>"
|
|
107
|
+
f'<p><a href="{esc(page_url)}">Read this episode, with the charts, '
|
|
108
|
+
f"the glossary and the quiz →</a></p>]]>")
|
|
109
|
+
summary = (f"{description}\n\nRead this episode: {page_url}")
|
|
110
|
+
else:
|
|
111
|
+
body = esc(description)
|
|
112
|
+
summary = description
|
|
113
|
+
|
|
114
|
+
return f""" <item>
|
|
115
|
+
<title>Ep {number} — {esc(title)}</title>
|
|
116
|
+
{link} <description>{body}</description>
|
|
117
|
+
<itunes:summary>{esc(summary)}</itunes:summary>
|
|
118
|
+
<enclosure url="{url}" length="{size}" type="audio/mpeg"/>
|
|
119
|
+
<guid isPermaLink="false">{url}</guid>
|
|
120
|
+
<pubDate>{pubdate}</pubDate>
|
|
121
|
+
<itunes:duration>{duration}</itunes:duration>
|
|
122
|
+
</item>
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def update_feed(feed_path, item_xml, number):
|
|
127
|
+
"""Prepend the new item; drop any existing item for the same episode."""
|
|
128
|
+
if os.path.exists(feed_path):
|
|
129
|
+
feed = open(feed_path, encoding="utf-8").read()
|
|
130
|
+
else:
|
|
131
|
+
feed = FEED_SKELETON.format(pkg=PKG)
|
|
132
|
+
# remove a previous build of this same episode, if any
|
|
133
|
+
feed = re.sub(
|
|
134
|
+
rf" <item>\s*<title>Ep {number} —.*?</item>\n",
|
|
135
|
+
"",
|
|
136
|
+
feed,
|
|
137
|
+
flags=re.DOTALL,
|
|
138
|
+
)
|
|
139
|
+
idx = feed.find("<item>")
|
|
140
|
+
if idx != -1:
|
|
141
|
+
insert_at = feed.rfind("\n", 0, idx) + 1
|
|
142
|
+
else:
|
|
143
|
+
insert_at = feed.rfind("</channel>")
|
|
144
|
+
if insert_at != -1:
|
|
145
|
+
insert_at = feed.rfind("\n", 0, insert_at) + 1
|
|
146
|
+
if insert_at == -1: # unrecognisable feed: rebuild from skeleton
|
|
147
|
+
feed = FEED_SKELETON.format(pkg=PKG)
|
|
148
|
+
insert_at = feed.rfind("\n", 0, feed.rfind("</channel>")) + 1
|
|
149
|
+
return feed[:insert_at] + item_xml + feed[insert_at:]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def set_author(feed: str, author: str) -> str:
|
|
153
|
+
"""Keep the podcast author consistent in the channel metadata."""
|
|
154
|
+
esc = author.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
155
|
+
feed = re.sub(r"<itunes:author>.*?</itunes:author>",
|
|
156
|
+
f"<itunes:author>{esc}</itunes:author>", feed, count=1, flags=re.DOTALL)
|
|
157
|
+
feed = re.sub(r"(<itunes:owner>\s*<itunes:name>).*?(</itunes:name>)",
|
|
158
|
+
rf"\1{esc}\2", feed, count=1, flags=re.DOTALL)
|
|
159
|
+
return feed
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def set_site_link(feed: str, site_url: str) -> str:
|
|
163
|
+
"""Point the channel (and its <image>) at the show's website.
|
|
164
|
+
|
|
165
|
+
Apple Podcasts turns the channel <link> into the show's website button, so
|
|
166
|
+
it must be a real page, not the package registry.
|
|
167
|
+
"""
|
|
168
|
+
esc = site_url.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
169
|
+
# the channel link is the first <link> in the document, before any <item>
|
|
170
|
+
head, sep, tail = feed.partition("<item>")
|
|
171
|
+
head = re.sub(r"<link>.*?</link>", f"<link>{esc}</link>", head, flags=re.DOTALL)
|
|
172
|
+
return head + sep + tail
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def update_covered(path, number, title, summary, pubdate):
|
|
176
|
+
"""Append this episode to the running log, replacing any earlier entry."""
|
|
177
|
+
header = "# Soft Commodity Trading — episodes aired\n\nRunning log. Read before writing a new episode: avoid repeating material, and only make callbacks to episodes listed here.\n"
|
|
178
|
+
lines = []
|
|
179
|
+
if os.path.exists(path):
|
|
180
|
+
existing = open(path, encoding="utf-8").read()
|
|
181
|
+
for line in existing.splitlines():
|
|
182
|
+
if line.startswith(f"- **Ep {number}**") or line.startswith("#") or not line.strip():
|
|
183
|
+
continue
|
|
184
|
+
if line.startswith("Running log."):
|
|
185
|
+
continue
|
|
186
|
+
lines.append(line)
|
|
187
|
+
entry = f"- **Ep {number}** ({pubdate.split(',')[0] if ',' in pubdate else pubdate}) — *{title}*: {summary}"
|
|
188
|
+
lines.append(entry)
|
|
189
|
+
|
|
190
|
+
def sort_key(line):
|
|
191
|
+
try:
|
|
192
|
+
return int(line.split("**Ep ")[1].split("**")[0])
|
|
193
|
+
except (IndexError, ValueError):
|
|
194
|
+
return 9999
|
|
195
|
+
|
|
196
|
+
lines.sort(key=sort_key)
|
|
197
|
+
return header + "\n" + "\n".join(lines) + "\n"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def update_glossary(path, entries, number):
|
|
201
|
+
"""Merge 'term = definition' entries into the cumulative glossary."""
|
|
202
|
+
header = ("# Soft Commodity Trading — glossary\n\nUnits, conventions and desk expressions, "
|
|
203
|
+
"accumulated as the show introduces them.\n")
|
|
204
|
+
known = {}
|
|
205
|
+
if os.path.exists(path):
|
|
206
|
+
for line in open(path, encoding="utf-8").read().splitlines():
|
|
207
|
+
m = re.match(r"- \*\*(.+?)\*\* — (.+?)(?: _\(ep \d+\)_)?$", line.strip())
|
|
208
|
+
if m:
|
|
209
|
+
known[m.group(1).strip().lower()] = (m.group(1).strip(), m.group(2).strip(),
|
|
210
|
+
line.strip())
|
|
211
|
+
for raw in entries.split(";"):
|
|
212
|
+
if "=" not in raw:
|
|
213
|
+
continue
|
|
214
|
+
term, _, definition = raw.partition("=")
|
|
215
|
+
term, definition = term.strip(), definition.strip()
|
|
216
|
+
if not term or not definition:
|
|
217
|
+
continue
|
|
218
|
+
key = term.lower()
|
|
219
|
+
if key not in known: # first definition wins; never rewrite history
|
|
220
|
+
known[key] = (term, definition, f"- **{term}** — {definition} _(ep {number})_")
|
|
221
|
+
lines = [v[2] for _, v in sorted(known.items())]
|
|
222
|
+
return header + "\n" + "\n".join(lines) + "\n"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main():
|
|
226
|
+
ap = argparse.ArgumentParser()
|
|
227
|
+
ap.add_argument("--mp3", help="local mp3 file to embed in the npm package (Kokoro path)")
|
|
228
|
+
ap.add_argument("--mp3-url", help="public URL of an externally hosted mp3 (Gemini/GCS path)")
|
|
229
|
+
ap.add_argument("--size-bytes", type=int, help="mp3 size in bytes; required with --mp3-url")
|
|
230
|
+
ap.add_argument("--notes", help="episode .md (transcript + quiz)")
|
|
231
|
+
ap.add_argument("--number", type=int, required=True)
|
|
232
|
+
ap.add_argument("--title", required=True)
|
|
233
|
+
ap.add_argument("--description", default="")
|
|
234
|
+
ap.add_argument("--page-url", default="",
|
|
235
|
+
help="episode page URL; becomes the episode's website link "
|
|
236
|
+
"in Apple Podcasts and a clickable link in the notes")
|
|
237
|
+
ap.add_argument("--site-url", default=SITE_URL,
|
|
238
|
+
help="the show's website, used as the feed's channel link")
|
|
239
|
+
ap.add_argument("--duration", type=int, required=True, help="seconds")
|
|
240
|
+
ap.add_argument("--pubdate", required=True, help="RFC-822, e.g. 'Tue, 11 Aug 2026 05:00:00 GMT'")
|
|
241
|
+
ap.add_argument("--covered", default="",
|
|
242
|
+
help="one line for covered.md: concepts, vocabulary and the worked example used")
|
|
243
|
+
ap.add_argument("--feed-out", default="",
|
|
244
|
+
help="also write the final feed.xml to this local path (to re-host it elsewhere)")
|
|
245
|
+
ap.add_argument("--glossary", default="",
|
|
246
|
+
help="new terms introduced today: 'term = definition; term2 = definition2'")
|
|
247
|
+
ap.add_argument("--glossary-out", default="",
|
|
248
|
+
help="write the cumulative glossary to this local path (for the email)")
|
|
249
|
+
ap.add_argument("--script", default="",
|
|
250
|
+
help="the segmented spoken script (script.txt) — archived so the audio "
|
|
251
|
+
"can be regenerated verbatim later with a different voice")
|
|
252
|
+
ap.add_argument("--author", default="Sébastien Delsad",
|
|
253
|
+
help="podcast author shown in podcast apps")
|
|
254
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
255
|
+
args = ap.parse_args()
|
|
256
|
+
|
|
257
|
+
if args.mp3_url:
|
|
258
|
+
if not args.size_bytes:
|
|
259
|
+
print("--size-bytes is required with --mp3-url", file=sys.stderr)
|
|
260
|
+
return 1
|
|
261
|
+
elif not args.mp3 or not os.path.exists(args.mp3):
|
|
262
|
+
print("Provide --mp3 <existing file> or --mp3-url + --size-bytes", file=sys.stderr)
|
|
263
|
+
return 1
|
|
264
|
+
|
|
265
|
+
current = latest_version()
|
|
266
|
+
version = bump(current)
|
|
267
|
+
nn = f"{args.number:02d}"
|
|
268
|
+
print(f"[publish] {current or 'none'} -> {version} (episode {nn})")
|
|
269
|
+
|
|
270
|
+
workdir = tempfile.mkdtemp(prefix="cdd-")
|
|
271
|
+
try:
|
|
272
|
+
carry_forward(current, workdir)
|
|
273
|
+
if args.notes and os.path.exists(args.notes):
|
|
274
|
+
shutil.copy(args.notes, os.path.join(workdir, f"ep{nn}.md"))
|
|
275
|
+
# Archive the exact spoken script: the source of truth for re-generating
|
|
276
|
+
# this episode's audio verbatim with another voice or engine.
|
|
277
|
+
if args.script and os.path.exists(args.script):
|
|
278
|
+
shutil.copy(args.script, os.path.join(workdir, f"ep{nn}.script.txt"))
|
|
279
|
+
|
|
280
|
+
if args.mp3_url:
|
|
281
|
+
url, size = args.mp3_url, args.size_bytes
|
|
282
|
+
else:
|
|
283
|
+
shutil.copy(args.mp3, os.path.join(workdir, f"ep{nn}.mp3"))
|
|
284
|
+
url = f"{CDN}/{PKG}@{version}/ep{nn}.mp3"
|
|
285
|
+
size = os.path.getsize(args.mp3)
|
|
286
|
+
feed_path = os.path.join(workdir, "feed.xml")
|
|
287
|
+
item = build_item(args.number, args.title, args.description, url, size,
|
|
288
|
+
args.duration, args.pubdate, args.page_url)
|
|
289
|
+
# Build the new feed BEFORE opening for write: opening truncates the file.
|
|
290
|
+
new_feed = set_site_link(
|
|
291
|
+
set_author(update_feed(feed_path, item, args.number), args.author),
|
|
292
|
+
args.site_url)
|
|
293
|
+
with open(feed_path, "w", encoding="utf-8") as fh:
|
|
294
|
+
fh.write(new_feed)
|
|
295
|
+
if args.feed_out:
|
|
296
|
+
with open(args.feed_out, "w", encoding="utf-8") as fh:
|
|
297
|
+
fh.write(new_feed)
|
|
298
|
+
|
|
299
|
+
# Cumulative glossary, carried forward and extended.
|
|
300
|
+
glossary_path = os.path.join(workdir, "glossary.md")
|
|
301
|
+
if args.glossary:
|
|
302
|
+
new_glossary = update_glossary(glossary_path, args.glossary, args.number)
|
|
303
|
+
with open(glossary_path, "w", encoding="utf-8") as fh:
|
|
304
|
+
fh.write(new_glossary)
|
|
305
|
+
if args.glossary_out and os.path.exists(glossary_path):
|
|
306
|
+
shutil.copy(glossary_path, args.glossary_out)
|
|
307
|
+
|
|
308
|
+
# Running log of what has aired, carried forward and appended.
|
|
309
|
+
covered_path = os.path.join(workdir, "covered.md")
|
|
310
|
+
summary = args.covered or args.description or args.title
|
|
311
|
+
new_covered = update_covered(covered_path, args.number, args.title, summary, args.pubdate)
|
|
312
|
+
with open(covered_path, "w", encoding="utf-8") as fh:
|
|
313
|
+
fh.write(new_covered)
|
|
314
|
+
|
|
315
|
+
json.dump(
|
|
316
|
+
{
|
|
317
|
+
"name": PKG,
|
|
318
|
+
"version": version,
|
|
319
|
+
"description": f"Soft Commodity Trading - Ep {args.number}: {args.title}",
|
|
320
|
+
"license": "CC-BY-4.0",
|
|
321
|
+
"keywords": ["podcast", "commodities", "trading", "education"],
|
|
322
|
+
},
|
|
323
|
+
open(os.path.join(workdir, "package.json"), "w"),
|
|
324
|
+
indent=2,
|
|
325
|
+
)
|
|
326
|
+
open(os.path.join(workdir, "README.md"), "w").write(
|
|
327
|
+
"# Soft Commodity Trading\n\nDaily 10-minute podcast on physical commodity trading.\n\n"
|
|
328
|
+
f"RSS feed: `{CDN}/{PKG}@latest/feed.xml`\n"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Project-level auth, written next to the package so npm picks it up
|
|
332
|
+
# from cwd. The token itself never appears here: npm expands NPM_TOKEN
|
|
333
|
+
# from the environment at publish time. (A user-level ~/.npmrc is not
|
|
334
|
+
# reliably interpolated when npm runs from a temp directory.)
|
|
335
|
+
with open(os.path.join(workdir, ".npmrc"), "w") as fh:
|
|
336
|
+
fh.write("//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n")
|
|
337
|
+
|
|
338
|
+
if args.dry_run:
|
|
339
|
+
print("[publish] dry run; files staged in", workdir)
|
|
340
|
+
print("\n".join(sorted(os.listdir(workdir))))
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
if not os.environ.get("NPM_TOKEN"):
|
|
344
|
+
print("[publish] NPM_TOKEN is not set in the environment", file=sys.stderr)
|
|
345
|
+
return 1
|
|
346
|
+
|
|
347
|
+
result = subprocess.run(
|
|
348
|
+
["npm", "publish", "--access", "public"],
|
|
349
|
+
cwd=workdir, capture_output=True, text=True,
|
|
350
|
+
)
|
|
351
|
+
if result.returncode != 0:
|
|
352
|
+
print(result.stdout[-1500:] + result.stderr[-1500:], file=sys.stderr)
|
|
353
|
+
print("[publish] FAILED", file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
|
|
356
|
+
print(f"[publish] ok")
|
|
357
|
+
print(f"PUBLISHED_VERSION={version}")
|
|
358
|
+
print(f"PUBLISHED_URL={url}")
|
|
359
|
+
print(f"FEED_URL={CDN}/{PKG}@latest/feed.xml")
|
|
360
|
+
return 0
|
|
361
|
+
finally:
|
|
362
|
+
if not args.dry_run:
|
|
363
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
sys.exit(main())
|
package/setup.sh
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Soft Commodity Trading - one-shot environment setup.
|
|
3
|
+
# Idempotent: safe to re-run; skips work already done.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
KDIR="${KOKORO_DIR:-$HOME/kokoro}"
|
|
6
|
+
BASE="https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
|
|
7
|
+
echo "[setup] installing python deps..."
|
|
8
|
+
pip install kokoro-onnx soundfile pillow --break-system-packages -q
|
|
9
|
+
mkdir -p "$KDIR"
|
|
10
|
+
fetch() { # fetch <filename> <min-bytes>
|
|
11
|
+
local f="$KDIR/$1" min="$2"
|
|
12
|
+
if [ -f "$f" ] && [ "$(stat -c%s "$f")" -ge "$min" ]; then
|
|
13
|
+
echo "[setup] $1 already present, skipping"
|
|
14
|
+
else
|
|
15
|
+
echo "[setup] downloading $1 ..."
|
|
16
|
+
curl -sL -o "$f" "$BASE/$1"
|
|
17
|
+
[ "$(stat -c%s "$f")" -ge "$min" ] || { echo "[setup] FAILED: $1 too small"; exit 1; }
|
|
18
|
+
fi
|
|
19
|
+
}
|
|
20
|
+
fetch kokoro-v1.0.onnx 300000000
|
|
21
|
+
fetch voices-v1.0.bin 20000000
|
|
22
|
+
command -v ffmpeg >/dev/null || { echo "[setup] WARNING: ffmpeg not found"; }
|
|
23
|
+
echo "[setup] OK -> $KDIR"
|