Spaces:
Sleeping
Sleeping
Commit ·
ccb0e29
1
Parent(s): 0abd888
Sync backend with LN-TTS: fix Chapter 1 scraper bug, add sentence offsets, improve error handling, update README
Browse files- README.md +32 -59
- backend/scraper.py +9 -4
- backend/server.py +30 -11
- backend/tts.py +138 -10
README.md
CHANGED
|
@@ -1,76 +1,49 @@
|
|
| 1 |
---
|
| 2 |
title: CoreReader
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
# CoreReader Backend
|
| 11 |
|
| 12 |
-
|
| 13 |
|
| 14 |
-
|
| 15 |
-
- Scrapes
|
| 16 |
-
-
|
| 17 |
-
- Streams
|
| 18 |
-
- one binary WebSocket message per sentence (with a short trailing pause)
|
| 19 |
-
- sentence audio includes a tiny fade-in/out to avoid boundary clicks
|
| 20 |
|
| 21 |
-
##
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
- GET /novel_details?url=... (best-effort cover URL)
|
| 27 |
-
- GET /novel_meta?url=...
|
| 28 |
-
- GET /novel_chapter?url=...&n=...
|
| 29 |
-
- WS /ws
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
## Notes
|
| 40 |
|
| 41 |
-
-
|
| 42 |
-
-
|
| 43 |
-
|
| 44 |
-
Protocol note:
|
| 45 |
-
- WS `chapter_info` includes `sentence_total` (best-effort) so the client can render accurate download progress rings.
|
| 46 |
-
- `/novel_index` chapter numbers are parsed from title or URL (more robust for Chapter 1 / Prologue edge-cases).
|
| 47 |
-
|
| 48 |
-
## Deploy to Azure (Container Apps)
|
| 49 |
-
|
| 50 |
-
This Docker image can be deployed to Azure Container Apps.
|
| 51 |
-
|
| 52 |
-
1) Create a resource group + registry:
|
| 53 |
-
|
| 54 |
-
- az group create -n corereader-rg -l westeurope
|
| 55 |
-
- az acr create -n <acrName> -g corereader-rg --sku Basic
|
| 56 |
-
|
| 57 |
-
2) Build + push to ACR:
|
| 58 |
-
|
| 59 |
-
- az acr build -r <acrName> -t corereader-backend:v1 .
|
| 60 |
-
|
| 61 |
-
3) Deploy a public Container App (binds to PORT, default 7860):
|
| 62 |
-
|
| 63 |
-
- az extension add --name containerapp --upgrade
|
| 64 |
-
- az containerapp env create -g corereader-rg -n corereader-env -l westeurope
|
| 65 |
-
- loginServer=$(az acr show -n <acrName> -g corereader-rg --query loginServer -o tsv)
|
| 66 |
-
- az containerapp create -g corereader-rg -n corereader-backend --environment corereader-env \
|
| 67 |
-
--image "$loginServer/corereader-backend:v1" \
|
| 68 |
-
--ingress external --target-port 7860 --registry-server "$loginServer"
|
| 69 |
-
|
| 70 |
-
4) Get the URL:
|
| 71 |
-
|
| 72 |
-
- fqdn=$(az containerapp show -g corereader-rg -n corereader-backend --query properties.configuration.ingress.fqdn -o tsv)
|
| 73 |
-
|
| 74 |
-
Paste into the Flutter app Settings:
|
| 75 |
-
|
| 76 |
-
- wss://$fqdn
|
|
|
|
| 1 |
---
|
| 2 |
title: CoreReader
|
| 3 |
+
emoji: 📖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# CoreReader Backend
|
| 11 |
|
| 12 |
+
A FastAPI backend for [CoreReader](https://github.com/user/LN-TTS) — a light-novel reader with AI text-to-speech.
|
| 13 |
|
| 14 |
+
This Space runs the backend server that:
|
| 15 |
+
- Scrapes novel chapters from NovelCool
|
| 16 |
+
- Synthesizes speech using [Kokoro ONNX](https://github.com/thewh1teagle/kokoro-onnx) TTS (50+ English voices, CPU)
|
| 17 |
+
- Streams sentence-atomic PCM16 audio over WebSocket
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
## Use from the Flutter App
|
| 20 |
|
| 21 |
+
1. Open the CoreReader app.
|
| 22 |
+
2. Go to **Settings** → **WebSocket base URL**.
|
| 23 |
+
3. Paste your Space URL:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
```
|
| 26 |
+
wss://<your-space>.hf.space
|
| 27 |
+
```
|
| 28 |
|
| 29 |
+
The app connects to `/ws` automatically.
|
| 30 |
|
| 31 |
+
## API Endpoints
|
| 32 |
|
| 33 |
+
| Method | Path | Description |
|
| 34 |
+
|--------|------|-------------|
|
| 35 |
+
| GET | `/health` | Server status |
|
| 36 |
+
| GET | `/voices` | Available TTS voices |
|
| 37 |
+
| GET | `/novel_index?url=...` | Chapter list for a novel |
|
| 38 |
+
| GET | `/novel_details?url=...` | Novel cover URL (best-effort) |
|
| 39 |
+
| GET | `/novel_meta?url=...` | Chapter count |
|
| 40 |
+
| GET | `/novel_chapter?url=...&n=...` | Resolve chapter by number |
|
| 41 |
+
| WS | `/ws` | Audio streaming WebSocket |
|
| 42 |
+
| GET | `/docs` | Interactive API documentation |
|
| 43 |
+
| GET | `/info` | Runtime status (JSON) |
|
| 44 |
|
| 45 |
## Notes
|
| 46 |
|
| 47 |
+
- Models are downloaded on first boot (~30s).
|
| 48 |
+
- Free Spaces sleep after ~15 min of inactivity. First request after sleeping takes 30–60s.
|
| 49 |
+
- The WS protocol supports `realtime: false` for offline chapter downloads.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/scraper.py
CHANGED
|
@@ -111,6 +111,7 @@ class NovelCoolScraper:
|
|
| 111 |
|
| 112 |
def parse_chapter_number(title: str, url: str) -> int | None:
|
| 113 |
t = (title or '').strip()
|
|
|
|
| 114 |
m = re.search(r"(?:\bChapter\b|\bCh\.?\b|\bC\b)\s*(\d+)", t, flags=re.IGNORECASE)
|
| 115 |
if m:
|
| 116 |
try:
|
|
@@ -119,6 +120,8 @@ class NovelCoolScraper:
|
|
| 119 |
except Exception:
|
| 120 |
pass
|
| 121 |
|
|
|
|
|
|
|
| 122 |
u = (url or '')
|
| 123 |
m = re.search(r"(?:chapter|ch)[^0-9]{0,12}(\d+)", u, flags=re.IGNORECASE)
|
| 124 |
if m:
|
|
@@ -138,15 +141,17 @@ class NovelCoolScraper:
|
|
| 138 |
abs_url = urljoin(novel_url, href)
|
| 139 |
if abs_url in seen:
|
| 140 |
continue
|
| 141 |
-
seen.add(abs_url)
|
| 142 |
title = a.get_text(' ', strip=True)
|
| 143 |
if not title:
|
| 144 |
-
# Some chapter links have empty text (icons). Skip
|
|
|
|
| 145 |
continue
|
|
|
|
| 146 |
n = parse_chapter_number(title, abs_url)
|
| 147 |
links.append({"n": n, "title": title, "url": abs_url})
|
| 148 |
|
| 149 |
-
# Sort by chapter number when possible, but preserve stable ordering
|
|
|
|
| 150 |
def chapter_key(item):
|
| 151 |
n = item.get('n')
|
| 152 |
if isinstance(n, int):
|
|
@@ -159,7 +164,7 @@ class NovelCoolScraper:
|
|
| 159 |
async def scrape_novel_details(self, novel_url: str):
|
| 160 |
"""Scrape a NovelCool novel page and return lightweight metadata.
|
| 161 |
|
| 162 |
-
|
| 163 |
- title: best-effort title
|
| 164 |
- cover_url: absolute URL to the cover image, when detectable
|
| 165 |
"""
|
|
|
|
| 111 |
|
| 112 |
def parse_chapter_number(title: str, url: str) -> int | None:
|
| 113 |
t = (title or '').strip()
|
| 114 |
+
# Best-effort chapter number parsing from visible text.
|
| 115 |
m = re.search(r"(?:\bChapter\b|\bCh\.?\b|\bC\b)\s*(\d+)", t, flags=re.IGNORECASE)
|
| 116 |
if m:
|
| 117 |
try:
|
|
|
|
| 120 |
except Exception:
|
| 121 |
pass
|
| 122 |
|
| 123 |
+
# Fallback: parse from URL, e.g.
|
| 124 |
+
# /chapter/<Novel>-Chapter-15/<id>/ or .../Chapter_15/... etc.
|
| 125 |
u = (url or '')
|
| 126 |
m = re.search(r"(?:chapter|ch)[^0-9]{0,12}(\d+)", u, flags=re.IGNORECASE)
|
| 127 |
if m:
|
|
|
|
| 141 |
abs_url = urljoin(novel_url, href)
|
| 142 |
if abs_url in seen:
|
| 143 |
continue
|
|
|
|
| 144 |
title = a.get_text(' ', strip=True)
|
| 145 |
if not title:
|
| 146 |
+
# Some chapter links have empty text (icons). Skip but do NOT
|
| 147 |
+
# mark as seen — the real link with text may appear later.
|
| 148 |
continue
|
| 149 |
+
seen.add(abs_url)
|
| 150 |
n = parse_chapter_number(title, abs_url)
|
| 151 |
links.append({"n": n, "title": title, "url": abs_url})
|
| 152 |
|
| 153 |
+
# Sort by chapter number when possible, but preserve stable ordering
|
| 154 |
+
# for unknowns (avoid pushing an unparsed Chapter 1 to the end).
|
| 155 |
def chapter_key(item):
|
| 156 |
n = item.get('n')
|
| 157 |
if isinstance(n, int):
|
|
|
|
| 164 |
async def scrape_novel_details(self, novel_url: str):
|
| 165 |
"""Scrape a NovelCool novel page and return lightweight metadata.
|
| 166 |
|
| 167 |
+
Currently returns:
|
| 168 |
- title: best-effort title
|
| 169 |
- cover_url: absolute URL to the cover image, when detectable
|
| 170 |
"""
|
backend/server.py
CHANGED
|
@@ -52,6 +52,10 @@ app.add_middleware(
|
|
| 52 |
allow_headers=["*"],
|
| 53 |
)
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def _space_runtime_info(request: Request) -> dict:
|
| 56 |
tts = getattr(app.state, "tts", None)
|
| 57 |
voices = []
|
|
@@ -125,7 +129,7 @@ async def root(request: Request):
|
|
| 125 |
<li><code>/health</code></li>
|
| 126 |
<li><code>/voices</code></li>
|
| 127 |
<li><code>/novel_index?url=<novel_url></code></li>
|
| 128 |
-
|
| 129 |
<li><code>/novel_meta?url=<novel_url></code></li>
|
| 130 |
<li><code>/novel_chapter?url=<novel_url>&n=<chapter_number></code></li>
|
| 131 |
<li><code>/ws</code> (WebSocket)</li>
|
|
@@ -366,6 +370,8 @@ async def websocket_endpoint(websocket: WebSocket):
|
|
| 366 |
"encoding": "pcm_s16le",
|
| 367 |
"sample_rate": app.state.tts.sample_rate,
|
| 368 |
"channels": 1,
|
|
|
|
|
|
|
| 369 |
"frame_ms": frame_ms,
|
| 370 |
"chunking": "sentence",
|
| 371 |
},
|
|
@@ -373,9 +379,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|
| 373 |
)
|
| 374 |
|
| 375 |
last_key = None
|
| 376 |
-
# Cumulative samples sent so far — used to stamp ms_start on each
|
| 377 |
-
# sentence event so the client can fire highlights at the right
|
| 378 |
-
# playback position rather than at message-arrival time.
|
| 379 |
cumulative_samples = 0
|
| 380 |
sample_rate = app.state.tts.sample_rate
|
| 381 |
try:
|
|
@@ -397,7 +400,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
|
| 397 |
elif cmd == "stop":
|
| 398 |
cancel_event.set()
|
| 399 |
|
| 400 |
-
async for p_idx, s_idx, sentence, audio_chunk in app.state.tts.generate_audio_stream_paragraphs_sentence_chunks(
|
| 401 |
paragraphs_slice,
|
| 402 |
voice=voice,
|
| 403 |
speed=speed,
|
|
@@ -434,8 +437,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|
| 434 |
key = (p_idx + start_paragraph, s_idx, sentence)
|
| 435 |
if key != last_key:
|
| 436 |
last_key = key
|
| 437 |
-
# ms_start lets the client fire this highlight exactly when
|
| 438 |
-
# the audio reaches this sentence, regardless of buffering.
|
| 439 |
ms_start = (cumulative_samples * 1000) // sample_rate
|
| 440 |
await websocket.send_json(
|
| 441 |
{
|
|
@@ -444,16 +445,25 @@ async def websocket_endpoint(websocket: WebSocket):
|
|
| 444 |
"paragraph_index": int(p_idx + start_paragraph),
|
| 445 |
"sentence_index": int(s_idx),
|
| 446 |
"ms_start": ms_start,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
}
|
| 448 |
)
|
| 449 |
await websocket.send_bytes(audio_chunk)
|
| 450 |
-
# Track cumulative audio sent (int16 = 2 bytes per sample).
|
| 451 |
cumulative_samples += len(audio_chunk) // 2
|
| 452 |
|
| 453 |
# Optional realtime pacing.
|
|
|
|
|
|
|
| 454 |
if realtime:
|
| 455 |
expected_s = cumulative_samples / float(sample_rate)
|
| 456 |
elapsed_s = time.monotonic() - stream_t0
|
|
|
|
|
|
|
| 457 |
ahead_s = 0.10
|
| 458 |
sleep_s = (expected_s - elapsed_s) - ahead_s
|
| 459 |
if sleep_s > 0:
|
|
@@ -471,17 +481,26 @@ async def websocket_endpoint(websocket: WebSocket):
|
|
| 471 |
)
|
| 472 |
except Exception as e:
|
| 473 |
logger.error(f"Play stream error: {e}")
|
| 474 |
-
|
|
|
|
|
|
|
|
|
|
| 475 |
|
| 476 |
else:
|
| 477 |
await websocket.send_json({"error": "Unknown command"})
|
| 478 |
|
| 479 |
except json.JSONDecodeError:
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
| 481 |
except Exception as e:
|
| 482 |
logger.error(f"Error processing message: {e}")
|
| 483 |
traceback.print_exc()
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
| 485 |
|
| 486 |
except WebSocketDisconnect:
|
| 487 |
logger.info("Client disconnected")
|
|
|
|
| 52 |
allow_headers=["*"],
|
| 53 |
)
|
| 54 |
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# HuggingFace Space landing page & info endpoint
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
def _space_runtime_info(request: Request) -> dict:
|
| 60 |
tts = getattr(app.state, "tts", None)
|
| 61 |
voices = []
|
|
|
|
| 129 |
<li><code>/health</code></li>
|
| 130 |
<li><code>/voices</code></li>
|
| 131 |
<li><code>/novel_index?url=<novel_url></code></li>
|
| 132 |
+
<li><code>/novel_details?url=<novel_url></code></li>
|
| 133 |
<li><code>/novel_meta?url=<novel_url></code></li>
|
| 134 |
<li><code>/novel_chapter?url=<novel_url>&n=<chapter_number></code></li>
|
| 135 |
<li><code>/ws</code> (WebSocket)</li>
|
|
|
|
| 370 |
"encoding": "pcm_s16le",
|
| 371 |
"sample_rate": app.state.tts.sample_rate,
|
| 372 |
"channels": 1,
|
| 373 |
+
# For backward-compatibility, keep frame_ms but note that
|
| 374 |
+
# the stream is now sentence-chunked.
|
| 375 |
"frame_ms": frame_ms,
|
| 376 |
"chunking": "sentence",
|
| 377 |
},
|
|
|
|
| 379 |
)
|
| 380 |
|
| 381 |
last_key = None
|
|
|
|
|
|
|
|
|
|
| 382 |
cumulative_samples = 0
|
| 383 |
sample_rate = app.state.tts.sample_rate
|
| 384 |
try:
|
|
|
|
| 400 |
elif cmd == "stop":
|
| 401 |
cancel_event.set()
|
| 402 |
|
| 403 |
+
async for p_idx, s_idx, sentence, audio_chunk, cs, ce in app.state.tts.generate_audio_stream_paragraphs_sentence_chunks(
|
| 404 |
paragraphs_slice,
|
| 405 |
voice=voice,
|
| 406 |
speed=speed,
|
|
|
|
| 437 |
key = (p_idx + start_paragraph, s_idx, sentence)
|
| 438 |
if key != last_key:
|
| 439 |
last_key = key
|
|
|
|
|
|
|
| 440 |
ms_start = (cumulative_samples * 1000) // sample_rate
|
| 441 |
await websocket.send_json(
|
| 442 |
{
|
|
|
|
| 445 |
"paragraph_index": int(p_idx + start_paragraph),
|
| 446 |
"sentence_index": int(s_idx),
|
| 447 |
"ms_start": ms_start,
|
| 448 |
+
"char_start": int(cs),
|
| 449 |
+
"char_end": int(ce),
|
| 450 |
+
# Size of the *next* binary message for this sentence in samples/bytes.
|
| 451 |
+
# Helps clients associate metadata with audio even if transport splits chunks.
|
| 452 |
+
"chunk_samples": int(len(audio_chunk) // 2),
|
| 453 |
+
"chunk_bytes": int(len(audio_chunk)),
|
| 454 |
}
|
| 455 |
)
|
| 456 |
await websocket.send_bytes(audio_chunk)
|
|
|
|
| 457 |
cumulative_samples += len(audio_chunk) // 2
|
| 458 |
|
| 459 |
# Optional realtime pacing.
|
| 460 |
+
# - streaming: send roughly in-time to reduce client buffer bloat.
|
| 461 |
+
# - downloads: realtime=false sends as fast as synthesis allows.
|
| 462 |
if realtime:
|
| 463 |
expected_s = cumulative_samples / float(sample_rate)
|
| 464 |
elapsed_s = time.monotonic() - stream_t0
|
| 465 |
+
# Let the stream run slightly ahead to avoid stutter from
|
| 466 |
+
# small scheduling/network jitter.
|
| 467 |
ahead_s = 0.10
|
| 468 |
sleep_s = (expected_s - elapsed_s) - ahead_s
|
| 469 |
if sleep_s > 0:
|
|
|
|
| 481 |
)
|
| 482 |
except Exception as e:
|
| 483 |
logger.error(f"Play stream error: {e}")
|
| 484 |
+
try:
|
| 485 |
+
await websocket.send_json({"type": "error", "message": str(e)})
|
| 486 |
+
except Exception:
|
| 487 |
+
pass # Client already disconnected
|
| 488 |
|
| 489 |
else:
|
| 490 |
await websocket.send_json({"error": "Unknown command"})
|
| 491 |
|
| 492 |
except json.JSONDecodeError:
|
| 493 |
+
try:
|
| 494 |
+
await websocket.send_json({"error": "Invalid JSON"})
|
| 495 |
+
except Exception:
|
| 496 |
+
pass
|
| 497 |
except Exception as e:
|
| 498 |
logger.error(f"Error processing message: {e}")
|
| 499 |
traceback.print_exc()
|
| 500 |
+
try:
|
| 501 |
+
await websocket.send_json({"error": "Internal server error"})
|
| 502 |
+
except Exception:
|
| 503 |
+
pass
|
| 504 |
|
| 505 |
except WebSocketDisconnect:
|
| 506 |
logger.info("Client disconnected")
|
backend/tts.py
CHANGED
|
@@ -17,6 +17,20 @@ class TTSEngine:
|
|
| 17 |
model_path: str = "models/kokoro-v1.0.onnx",
|
| 18 |
voices_path: str = "models/voices-v1.0.bin",
|
| 19 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
# Ensure models exist
|
| 21 |
if not os.path.exists(model_path):
|
| 22 |
raise FileNotFoundError(f"Model not found at {model_path}. Run download_models.py first.")
|
|
@@ -34,10 +48,38 @@ class TTSEngine:
|
|
| 34 |
# CPU-only mode for maximum compatibility.
|
| 35 |
self.providers = ["CPUExecutionProvider"]
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
# kokoro_onnx API varies by version; try passing providers if supported.
|
| 38 |
kokoro_sig = inspect.signature(Kokoro)
|
|
|
|
| 39 |
if "providers" in kokoro_sig.parameters:
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
else:
|
| 42 |
self.kokoro = Kokoro(self.model_path, self.voices_path)
|
| 43 |
|
|
@@ -100,6 +142,56 @@ class TTSEngine:
|
|
| 100 |
sentences = re.split(r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s+", text)
|
| 101 |
return [s.strip() for s in sentences if s and s.strip()]
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def split_paragraphs(self, paragraphs: List[str]) -> List[tuple[int, int, str, bool]]:
|
| 104 |
"""Flatten paragraphs into (paragraph_index, sentence_index, sentence_text, is_last_in_paragraph)."""
|
| 105 |
out: List[tuple[int, int, str, bool]] = []
|
|
@@ -114,6 +206,31 @@ class TTSEngine:
|
|
| 114 |
out.append((p_idx, s_idx, s, s_idx == (len(sentences) - 1)))
|
| 115 |
return out
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
def _iter_pcm_frames(self, pcm16: bytes, frame_bytes: int) -> Iterable[bytes]:
|
| 118 |
if frame_bytes <= 0:
|
| 119 |
yield pcm16
|
|
@@ -122,7 +239,11 @@ class TTSEngine:
|
|
| 122 |
yield pcm16[i : i + frame_bytes]
|
| 123 |
|
| 124 |
def _apply_edge_fade_pcm16(self, pcm16: bytes, *, fade_ms: int = 6) -> bytes:
|
| 125 |
-
"""Apply a short fade-in/out to reduce boundary clicks.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
if not pcm16 or fade_ms <= 0:
|
| 127 |
return pcm16
|
| 128 |
|
|
@@ -136,6 +257,7 @@ class TTSEngine:
|
|
| 136 |
if fade_samples < 2:
|
| 137 |
return pcm16
|
| 138 |
|
|
|
|
| 139 |
x = samples.astype(np.float32)
|
| 140 |
ramp = np.linspace(0.0, 1.0, fade_samples, endpoint=False, dtype=np.float32)
|
| 141 |
x[:fade_samples] *= ramp
|
|
@@ -294,14 +416,20 @@ class TTSEngine:
|
|
| 294 |
pause_question_ms: int = 260,
|
| 295 |
pause_paragraph_extra_ms: int = 240,
|
| 296 |
fade_ms: int = 6,
|
| 297 |
-
) -> AsyncIterator[tuple[int, int, str, bytes]]:
|
| 298 |
"""Yield sentence-atomic PCM chunks.
|
| 299 |
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
"""
|
| 302 |
|
| 303 |
-
segments = self.
|
| 304 |
-
queue: asyncio.Queue[Optional[tuple[int, int, str, bytes, int]]] = asyncio.Queue(
|
| 305 |
maxsize=max(1, prefetch_sentences)
|
| 306 |
)
|
| 307 |
|
|
@@ -320,7 +448,7 @@ class TTSEngine:
|
|
| 320 |
|
| 321 |
async def producer() -> None:
|
| 322 |
try:
|
| 323 |
-
for p_idx, s_idx, s, is_last in segments:
|
| 324 |
if cancel_event is not None and cancel_event.is_set():
|
| 325 |
break
|
| 326 |
if not s:
|
|
@@ -329,7 +457,7 @@ class TTSEngine:
|
|
| 329 |
if fade_ms and fade_ms > 0:
|
| 330 |
pcm16 = self._apply_edge_fade_pcm16(pcm16, fade_ms=int(fade_ms))
|
| 331 |
pause_ms = pause_ms_for(s, is_last)
|
| 332 |
-
await queue.put((p_idx, s_idx, s, pcm16, pause_ms))
|
| 333 |
finally:
|
| 334 |
await queue.put(None)
|
| 335 |
|
|
@@ -339,7 +467,7 @@ class TTSEngine:
|
|
| 339 |
item = await queue.get()
|
| 340 |
if item is None:
|
| 341 |
break
|
| 342 |
-
p_idx, s_idx, sentence, pcm16, pause_ms = item
|
| 343 |
if cancel_event is not None and cancel_event.is_set():
|
| 344 |
return
|
| 345 |
|
|
@@ -349,7 +477,7 @@ class TTSEngine:
|
|
| 349 |
chunk = pcm16 + (b"\x00" * silence_bytes)
|
| 350 |
else:
|
| 351 |
chunk = pcm16
|
| 352 |
-
yield (p_idx, s_idx, sentence, chunk)
|
| 353 |
finally:
|
| 354 |
producer_task.cancel()
|
| 355 |
with contextlib.suppress(Exception):
|
|
|
|
| 17 |
model_path: str = "models/kokoro-v1.0.onnx",
|
| 18 |
voices_path: str = "models/voices-v1.0.bin",
|
| 19 |
):
|
| 20 |
+
# Resolve relative paths against this backend module directory, not the
|
| 21 |
+
# process working directory (important for serverless/ASGI hosts).
|
| 22 |
+
base_dir = Path(__file__).resolve().parent
|
| 23 |
+
mp = Path(model_path)
|
| 24 |
+
if not mp.is_absolute():
|
| 25 |
+
candidate = (base_dir / mp).resolve()
|
| 26 |
+
if candidate.exists():
|
| 27 |
+
model_path = str(candidate)
|
| 28 |
+
vp = Path(voices_path)
|
| 29 |
+
if not vp.is_absolute():
|
| 30 |
+
candidate = (base_dir / vp).resolve()
|
| 31 |
+
if candidate.exists():
|
| 32 |
+
voices_path = str(candidate)
|
| 33 |
+
|
| 34 |
# Ensure models exist
|
| 35 |
if not os.path.exists(model_path):
|
| 36 |
raise FileNotFoundError(f"Model not found at {model_path}. Run download_models.py first.")
|
|
|
|
| 48 |
# CPU-only mode for maximum compatibility.
|
| 49 |
self.providers = ["CPUExecutionProvider"]
|
| 50 |
|
| 51 |
+
# ONNX Runtime performance tuning (CPU).
|
| 52 |
+
# Keep defaults conservative; allow override via env for deployments.
|
| 53 |
+
sess_options = None
|
| 54 |
+
try:
|
| 55 |
+
sess_options = ort.SessionOptions()
|
| 56 |
+
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
| 57 |
+
# Thread counts: 0 means ORT will choose (often = physical cores).
|
| 58 |
+
intra = int(os.getenv("ORT_INTRA_OP_THREADS", "0") or "0")
|
| 59 |
+
inter = int(os.getenv("ORT_INTER_OP_THREADS", "1") or "1")
|
| 60 |
+
if intra >= 0:
|
| 61 |
+
sess_options.intra_op_num_threads = intra
|
| 62 |
+
if inter >= 0:
|
| 63 |
+
sess_options.inter_op_num_threads = inter
|
| 64 |
+
sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
| 65 |
+
sess_options.add_session_config_entry("session.intra_op.allow_spinning", os.getenv("ORT_ALLOW_SPINNING", "1"))
|
| 66 |
+
except Exception:
|
| 67 |
+
sess_options = None
|
| 68 |
+
|
| 69 |
# kokoro_onnx API varies by version; try passing providers if supported.
|
| 70 |
kokoro_sig = inspect.signature(Kokoro)
|
| 71 |
+
kokoro_kwargs = {}
|
| 72 |
if "providers" in kokoro_sig.parameters:
|
| 73 |
+
kokoro_kwargs["providers"] = self.providers
|
| 74 |
+
# Newer versions may support passing ORT session options.
|
| 75 |
+
if sess_options is not None:
|
| 76 |
+
for k in ("sess_options", "session_options", "ort_session_options"):
|
| 77 |
+
if k in kokoro_sig.parameters:
|
| 78 |
+
kokoro_kwargs[k] = sess_options
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
if kokoro_kwargs:
|
| 82 |
+
self.kokoro = Kokoro(self.model_path, self.voices_path, **kokoro_kwargs)
|
| 83 |
else:
|
| 84 |
self.kokoro = Kokoro(self.model_path, self.voices_path)
|
| 85 |
|
|
|
|
| 142 |
sentences = re.split(r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s+", text)
|
| 143 |
return [s.strip() for s in sentences if s and s.strip()]
|
| 144 |
|
| 145 |
+
def split_sentences_with_offsets(self, text: str) -> List[tuple[str, int, int]]:
|
| 146 |
+
"""Split `text` into sentences and return (sentence, char_start, char_end).
|
| 147 |
+
|
| 148 |
+
Offsets are relative to the provided `text` (typically a paragraph).
|
| 149 |
+
The returned span is trimmed for leading/trailing whitespace so clients
|
| 150 |
+
can highlight the exact sentence substring without `indexOf`.
|
| 151 |
+
"""
|
| 152 |
+
if not text:
|
| 153 |
+
return []
|
| 154 |
+
|
| 155 |
+
# Match the whitespace boundary *after* sentence punctuation.
|
| 156 |
+
boundary = re.compile(r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s+")
|
| 157 |
+
out: List[tuple[str, int, int]] = []
|
| 158 |
+
start = 0
|
| 159 |
+
for m in boundary.finditer(text):
|
| 160 |
+
end = m.start()
|
| 161 |
+
if end <= start:
|
| 162 |
+
start = m.end()
|
| 163 |
+
continue
|
| 164 |
+
seg_start, seg_end = start, end
|
| 165 |
+
# Trim whitespace within the segment and adjust offsets.
|
| 166 |
+
while seg_start < seg_end and text[seg_start].isspace():
|
| 167 |
+
seg_start += 1
|
| 168 |
+
while seg_end > seg_start and text[seg_end - 1].isspace():
|
| 169 |
+
seg_end -= 1
|
| 170 |
+
if seg_end > seg_start:
|
| 171 |
+
out.append((text[seg_start:seg_end], seg_start, seg_end))
|
| 172 |
+
start = m.end()
|
| 173 |
+
|
| 174 |
+
# Tail segment.
|
| 175 |
+
if start < len(text):
|
| 176 |
+
seg_start, seg_end = start, len(text)
|
| 177 |
+
while seg_start < seg_end and text[seg_start].isspace():
|
| 178 |
+
seg_start += 1
|
| 179 |
+
while seg_end > seg_start and text[seg_end - 1].isspace():
|
| 180 |
+
seg_end -= 1
|
| 181 |
+
if seg_end > seg_start:
|
| 182 |
+
out.append((text[seg_start:seg_end], seg_start, seg_end))
|
| 183 |
+
|
| 184 |
+
# Fallback: if boundary regex didn't match but text has content.
|
| 185 |
+
if not out:
|
| 186 |
+
seg_start, seg_end = 0, len(text)
|
| 187 |
+
while seg_start < seg_end and text[seg_start].isspace():
|
| 188 |
+
seg_start += 1
|
| 189 |
+
while seg_end > seg_start and text[seg_end - 1].isspace():
|
| 190 |
+
seg_end -= 1
|
| 191 |
+
if seg_end > seg_start:
|
| 192 |
+
out.append((text[seg_start:seg_end], seg_start, seg_end))
|
| 193 |
+
return out
|
| 194 |
+
|
| 195 |
def split_paragraphs(self, paragraphs: List[str]) -> List[tuple[int, int, str, bool]]:
|
| 196 |
"""Flatten paragraphs into (paragraph_index, sentence_index, sentence_text, is_last_in_paragraph)."""
|
| 197 |
out: List[tuple[int, int, str, bool]] = []
|
|
|
|
| 206 |
out.append((p_idx, s_idx, s, s_idx == (len(sentences) - 1)))
|
| 207 |
return out
|
| 208 |
|
| 209 |
+
def split_paragraphs_with_offsets(self, paragraphs: List[str]) -> List[tuple[int, int, str, bool, int, int]]:
|
| 210 |
+
"""Flatten paragraphs into (p_idx, s_idx, sentence, is_last, char_start, char_end)."""
|
| 211 |
+
out: List[tuple[int, int, str, bool, int, int]] = []
|
| 212 |
+
for p_idx, raw in enumerate(paragraphs):
|
| 213 |
+
p = raw or ""
|
| 214 |
+
if not p.strip():
|
| 215 |
+
continue
|
| 216 |
+
parts = self.split_sentences_with_offsets(p)
|
| 217 |
+
if not parts:
|
| 218 |
+
# Whole paragraph as one sentence.
|
| 219 |
+
seg = p
|
| 220 |
+
# Trim offsets to first/last non-space.
|
| 221 |
+
seg_start, seg_end = 0, len(seg)
|
| 222 |
+
while seg_start < seg_end and seg[seg_start].isspace():
|
| 223 |
+
seg_start += 1
|
| 224 |
+
while seg_end > seg_start and seg[seg_end - 1].isspace():
|
| 225 |
+
seg_end -= 1
|
| 226 |
+
if seg_end > seg_start:
|
| 227 |
+
out.append((p_idx, 0, seg[seg_start:seg_end], True, seg_start, seg_end))
|
| 228 |
+
continue
|
| 229 |
+
|
| 230 |
+
for s_idx, (s, cs, ce) in enumerate(parts):
|
| 231 |
+
out.append((p_idx, s_idx, s, s_idx == (len(parts) - 1), cs, ce))
|
| 232 |
+
return out
|
| 233 |
+
|
| 234 |
def _iter_pcm_frames(self, pcm16: bytes, frame_bytes: int) -> Iterable[bytes]:
|
| 235 |
if frame_bytes <= 0:
|
| 236 |
yield pcm16
|
|
|
|
| 239 |
yield pcm16[i : i + frame_bytes]
|
| 240 |
|
| 241 |
def _apply_edge_fade_pcm16(self, pcm16: bytes, *, fade_ms: int = 6) -> bytes:
|
| 242 |
+
"""Apply a short fade-in/out to reduce boundary clicks.
|
| 243 |
+
|
| 244 |
+
Kokoro is synthesized per sentence, so concatenation (or appending silence)
|
| 245 |
+
can produce discontinuities. A tiny edge fade is a minimal, cheap fix.
|
| 246 |
+
"""
|
| 247 |
if not pcm16 or fade_ms <= 0:
|
| 248 |
return pcm16
|
| 249 |
|
|
|
|
| 257 |
if fade_samples < 2:
|
| 258 |
return pcm16
|
| 259 |
|
| 260 |
+
# Work in float for clean scaling then back to int16.
|
| 261 |
x = samples.astype(np.float32)
|
| 262 |
ramp = np.linspace(0.0, 1.0, fade_samples, endpoint=False, dtype=np.float32)
|
| 263 |
x[:fade_samples] *= ramp
|
|
|
|
| 416 |
pause_question_ms: int = 260,
|
| 417 |
pause_paragraph_extra_ms: int = 240,
|
| 418 |
fade_ms: int = 6,
|
| 419 |
+
) -> AsyncIterator[tuple[int, int, str, bytes, int, int]]:
|
| 420 |
"""Yield sentence-atomic PCM chunks.
|
| 421 |
|
| 422 |
+
Returns (paragraph_index, sentence_index, sentence_text, pcm16_bytes).
|
| 423 |
+
|
| 424 |
+
Each yielded `pcm16_bytes` contains the full sentence audio (smoothed by
|
| 425 |
+
a short fade-in/out) *plus* a short silence pause appended.
|
| 426 |
+
|
| 427 |
+
This is designed so that if buffering is needed, playback can only pause
|
| 428 |
+
between sentences (at the end of the current chunk), not mid-sentence.
|
| 429 |
"""
|
| 430 |
|
| 431 |
+
segments = self.split_paragraphs_with_offsets(paragraphs)
|
| 432 |
+
queue: asyncio.Queue[Optional[tuple[int, int, str, bytes, int, int, int]]] = asyncio.Queue(
|
| 433 |
maxsize=max(1, prefetch_sentences)
|
| 434 |
)
|
| 435 |
|
|
|
|
| 448 |
|
| 449 |
async def producer() -> None:
|
| 450 |
try:
|
| 451 |
+
for p_idx, s_idx, s, is_last, cs, ce in segments:
|
| 452 |
if cancel_event is not None and cancel_event.is_set():
|
| 453 |
break
|
| 454 |
if not s:
|
|
|
|
| 457 |
if fade_ms and fade_ms > 0:
|
| 458 |
pcm16 = self._apply_edge_fade_pcm16(pcm16, fade_ms=int(fade_ms))
|
| 459 |
pause_ms = pause_ms_for(s, is_last)
|
| 460 |
+
await queue.put((p_idx, s_idx, s, pcm16, pause_ms, int(cs), int(ce)))
|
| 461 |
finally:
|
| 462 |
await queue.put(None)
|
| 463 |
|
|
|
|
| 467 |
item = await queue.get()
|
| 468 |
if item is None:
|
| 469 |
break
|
| 470 |
+
p_idx, s_idx, sentence, pcm16, pause_ms, cs, ce = item
|
| 471 |
if cancel_event is not None and cancel_event.is_set():
|
| 472 |
return
|
| 473 |
|
|
|
|
| 477 |
chunk = pcm16 + (b"\x00" * silence_bytes)
|
| 478 |
else:
|
| 479 |
chunk = pcm16
|
| 480 |
+
yield (p_idx, s_idx, sentence, chunk, cs, ce)
|
| 481 |
finally:
|
| 482 |
producer_task.cancel()
|
| 483 |
with contextlib.suppress(Exception):
|