Add pipeline code
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""Voice client: script segments -> wav pieces, via an OpenAI-compatible /v1/audio/speech endpoint (URL in settings).
|
||||
Pieces are sentence-sized. Output goes to scratch (work_dir), with a manifest the assembler reads."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
import bookio
|
||||
import settings
|
||||
|
||||
ABBREV = re.compile(r"^(Dr|Mr|Mrs|Ms|St|Mt|Jr|Sr|vs|[A-Z])\.$")
|
||||
|
||||
|
||||
def sentences(text):
|
||||
"""Split at . ! ? (optionally followed by a quote) and a space, but not after abbreviations like 'Dr.' or 'E.'."""
|
||||
out, start = [], 0
|
||||
for m in re.finditer(r"[.!?][\"']?\s+", text):
|
||||
before = text[start:m.start() + 1].split()[-1] if text[start:m.start() + 1].split() else ""
|
||||
if ABBREV.match(before):
|
||||
continue
|
||||
out.append(text[start:m.end()].strip())
|
||||
start = m.end()
|
||||
if text[start:].strip():
|
||||
out.append(text[start:].strip())
|
||||
return out
|
||||
|
||||
|
||||
def pieces(text, max_chars):
|
||||
"""Pack whole sentences into pieces of at most max_chars (a longer sentence stays whole)."""
|
||||
out, cur = [], ""
|
||||
for s in sentences(text):
|
||||
if cur and len(cur) + 1 + len(s) > max_chars:
|
||||
out.append(cur)
|
||||
cur = s
|
||||
else:
|
||||
cur = (cur + " " + s).strip()
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def plan(cfg, segs):
|
||||
"""Ordered items: {'text', 'gap_after_s'} for speech, {'cue': 'chime', 'gap_after_s'} for cues."""
|
||||
v = cfg["voice"]
|
||||
items = []
|
||||
for s in segs:
|
||||
if s["kind"] == "cue":
|
||||
items.append({"cue": "chime", "gap_after_s": v["gap_sentence_s"]})
|
||||
continue
|
||||
ps = pieces(s["text"], v["max_chunk_chars"])
|
||||
for i, p in enumerate(ps):
|
||||
items.append({"text": p, "gap_after_s": v["gap_paragraph_s"] if i == len(ps) - 1 else v["gap_sentence_s"]})
|
||||
return items
|
||||
|
||||
|
||||
def speak(cfg, text):
|
||||
v = cfg["voice"]
|
||||
body = {"model": v["model"], "input": text, "voice": v["voice"], "response_format": "wav"}
|
||||
body.update(v.get("extra") or {})
|
||||
req = urllib.request.Request(v["endpoint"], json.dumps(body).encode(), {"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=v["timeout_s"]) as r:
|
||||
data = r.read()
|
||||
if data[:4] != b"RIFF":
|
||||
raise RuntimeError("voice endpoint did not return a wav file")
|
||||
return data
|
||||
|
||||
|
||||
def work_dir_for(cfg, book_dir, name):
|
||||
return os.path.join(settings.resolve(cfg, "work_dir"), os.path.basename(book_dir), "audio", name)
|
||||
|
||||
|
||||
def run(cfg, book_dir, only=None):
|
||||
book = bookio.read_book(book_dir)
|
||||
for name in book["audio_files"]:
|
||||
if only and name != only:
|
||||
continue
|
||||
with open(os.path.join(book_dir, "script", name + ".json"), encoding="utf-8") as f:
|
||||
items = plan(cfg, json.load(f))
|
||||
out = work_dir_for(cfg, book_dir, name)
|
||||
os.makedirs(out, exist_ok=True)
|
||||
for i, it in enumerate(items):
|
||||
if "text" in it:
|
||||
it["wav"] = os.path.join(out, "%04d.wav" % i)
|
||||
if not os.path.exists(it["wav"]): # resume: finished pieces are kept
|
||||
data = speak(cfg, it["text"])
|
||||
with open(it["wav"], "wb") as f:
|
||||
f.write(data)
|
||||
with open(os.path.join(out, "manifest.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(items, f, indent=1, ensure_ascii=False)
|
||||
print("%s: %d pieces" % (name, len(items)))
|
||||
Reference in New Issue
Block a user