shreyas-joshi commited on
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
Files changed (4) hide show
  1. README.md +32 -59
  2. backend/scraper.py +9 -4
  3. backend/server.py +30 -11
  4. backend/tts.py +138 -10
README.md CHANGED
@@ -1,76 +1,49 @@
1
  ---
2
  title: CoreReader
3
- emoji: 📉
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- # CoreReader Backend (Docker)
11
 
12
- This repository is a backend-only deployment target for CoreReader / LN-TTS.
13
 
14
- It runs a FastAPI server that:
15
- - Scrapes NovelCool chapters + chapter index
16
- - Runs Kokoro ONNX TTS (CPU)
17
- - Streams **sentence-atomic** PCM16 mono audio over WebSocket
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
- ## Endpoints
22
 
23
- - GET /health
24
- - GET /voices
25
- - GET /novel_index?url=...
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
- ## Use from the Flutter app
 
 
32
 
33
- In Settings WebSocket base URL:
34
 
35
- - Hugging Face Spaces URL: wss://<space-subdomain>.hf.space
36
 
37
- The app connects to: wss://<space-subdomain>.hf.space/ws
 
 
 
 
 
 
 
 
 
 
38
 
39
  ## Notes
40
 
41
- - The container downloads models on startup via download_models.py.
42
- - Offline downloads in the app use WS play with realtime=false so synthesis runs faster than real-time.
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 for unknowns.
 
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
- Returns:
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=&lt;novel_url&gt;</code></li>
128
- <li><code>/novel_details?url=&lt;novel_url&gt;</code></li>
129
  <li><code>/novel_meta?url=&lt;novel_url&gt;</code></li>
130
  <li><code>/novel_chapter?url=&lt;novel_url&gt;&amp;n=&lt;chapter_number&gt;</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
- await websocket.send_json({"type": "error", "message": str(e)})
 
 
 
475
 
476
  else:
477
  await websocket.send_json({"error": "Unknown command"})
478
 
479
  except json.JSONDecodeError:
480
- await websocket.send_json({"error": "Invalid JSON"})
 
 
 
481
  except Exception as e:
482
  logger.error(f"Error processing message: {e}")
483
  traceback.print_exc()
484
- await websocket.send_json({"error": "Internal server error"})
 
 
 
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=&lt;novel_url&gt;</code></li>
132
+ <li><code>/novel_details?url=&lt;novel_url&gt;</code></li>
133
  <li><code>/novel_meta?url=&lt;novel_url&gt;</code></li>
134
  <li><code>/novel_chapter?url=&lt;novel_url&gt;&amp;n=&lt;chapter_number&gt;</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
- self.kokoro = Kokoro(self.model_path, self.voices_path, providers=self.providers)
 
 
 
 
 
 
 
 
 
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
- Each yielded binary chunk is full-sentence PCM16 plus a trailing pause.
 
 
 
 
 
 
301
  """
302
 
303
- segments = self.split_paragraphs(paragraphs)
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):