SupPepper commited on
Commit
9a33481
Β·
2 Parent(s): fa489d911083b8

Merge branch 'main' of https://huggingface.co/spaces/build-small-hackathon/Hackathon-IA-VisualNovel

Browse files
.gitignore CHANGED
@@ -1,6 +1,6 @@
1
  # Secrets β€” never commit
2
- .env
3
  .env.local
 
4
 
5
  # Python
6
  __pycache__/
@@ -29,3 +29,5 @@ memory/
29
  # macOS
30
  .DS_Store
31
  .python-version
 
 
 
1
  # Secrets β€” never commit
 
2
  .env.local
3
+ .env.secret
4
 
5
  # Python
6
  __pycache__/
 
29
  # macOS
30
  .DS_Store
31
  .python-version
32
+
33
+ uv.lock
app.py CHANGED
@@ -104,12 +104,19 @@ if not config.USE_MOCK:
104
  except ImportError:
105
  _missing.append("transformers")
106
 
107
- if config.IMAGE_BACKEND == "local":
108
  try:
109
  import diffusers # noqa: F401
110
  except ImportError:
111
  _missing.append("image")
112
 
 
 
 
 
 
 
 
113
  if config.LLM_BACKEND == "modal" or config.IMAGE_BACKEND == "modal":
114
  try:
115
  import modal # noqa: F401
 
104
  except ImportError:
105
  _missing.append("transformers")
106
 
107
+ if config.IMAGE_BACKEND in ("local", "lightning"):
108
  try:
109
  import diffusers # noqa: F401
110
  except ImportError:
111
  _missing.append("image")
112
 
113
+ if config.TTS_BACKEND == "kokoro":
114
+ try:
115
+ import kokoro_onnx # noqa: F401
116
+ import soundfile # noqa: F401
117
+ except ImportError:
118
+ _missing.append("tts")
119
+
120
  if config.LLM_BACKEND == "modal" or config.IMAGE_BACKEND == "modal":
121
  try:
122
  import modal # noqa: F401
frontend/index.html CHANGED
@@ -143,6 +143,9 @@
143
  @keyframes pulse-rec{0%,100%{opacity:1}50%{opacity:.6}}
144
  #mic.live{background:var(--rose); animation:pulse-rec 1.2s ease-in-out infinite}
145
  #mic.waiting{background:rgba(255,255,255,.12); color:var(--ink-dim); cursor:wait}
 
 
 
146
  /* ---- book intro overlay ---- */
147
  #intro-book{
148
  position:fixed; inset:0; z-index:30; display:flex; align-items:center; justify-content:center;
@@ -354,6 +357,7 @@
354
  <input id="say" placeholder="say something…" autocomplete="off" />
355
  <button id="send">βœ“</button>
356
  <button id="mic" class="ghost" title="hold to speak">πŸŽ™οΈ</button>
 
357
  </div>
358
  </div>
359
  </div>
@@ -567,6 +571,7 @@ function render(v){
567
  $("place").textContent = v.place || "";
568
  $("speaker").textContent = v.speaker || "";
569
  typeOut($("line"), v.dialogue || "");
 
570
  if (v.backdrop_url && v.backdrop_url !== lastBg){
571
  const bg = $("backdrop");
572
  bg.classList.remove("on");
@@ -725,6 +730,30 @@ function busy(state){
725
  document.querySelectorAll(".target-chip").forEach(b => b.disabled = state);
726
  }
727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
  // --- book overlay dismiss ---
729
  $("book-close").addEventListener("click", () => {
730
  $("intro-book").classList.add("hidden");
@@ -797,10 +826,10 @@ $("mic").addEventListener("click", async () => {
797
  try {
798
  const file = new File([blob], "speech.webm", { type: "audio/webm" });
799
  const { data } = await client.predict("/transcribe", { audio: handle_file(file) });
800
- micIdle();
801
- if (data[0]?.text) { $("say").value = data[0].text; sendTurn(); return; }
802
  } catch (e) { console.error("[stt] transcribe error", e); }
803
- // fallback: restore input if no text or error
804
  mic.disabled = false; $("say").disabled = false; $("send").disabled = false;
805
  };
806
  rec.start(100);
 
143
  @keyframes pulse-rec{0%,100%{opacity:1}50%{opacity:.6}}
144
  #mic.live{background:var(--rose); animation:pulse-rec 1.2s ease-in-out infinite}
145
  #mic.waiting{background:rgba(255,255,255,.12); color:var(--ink-dim); cursor:wait}
146
+ #tts-toggle{min-width:42px; background:transparent; color:var(--ink-dim);
147
+ border:1px solid rgba(255,255,255,.22); font-size:1.1rem}
148
+ #tts-toggle.active{color:var(--gold); border-color:rgba(232,193,114,.5)}
149
  /* ---- book intro overlay ---- */
150
  #intro-book{
151
  position:fixed; inset:0; z-index:30; display:flex; align-items:center; justify-content:center;
 
357
  <input id="say" placeholder="say something…" autocomplete="off" />
358
  <button id="send">βœ“</button>
359
  <button id="mic" class="ghost" title="hold to speak">πŸŽ™οΈ</button>
360
+ <button id="tts-toggle" title="toggle voice output">πŸ”‡</button>
361
  </div>
362
  </div>
363
  </div>
 
571
  $("place").textContent = v.place || "";
572
  $("speaker").textContent = v.speaker || "";
573
  typeOut($("line"), v.dialogue || "");
574
+ playTTS(v.audio_b64);
575
  if (v.backdrop_url && v.backdrop_url !== lastBg){
576
  const bg = $("backdrop");
577
  bg.classList.remove("on");
 
730
  document.querySelectorAll(".target-chip").forEach(b => b.disabled = state);
731
  }
732
 
733
+ // --- TTS toggle ---
734
+ let ttsEnabled = true;
735
+ const ttsBtn = $("tts-toggle");
736
+ ttsBtn.textContent = "πŸ”Š";
737
+ ttsBtn.classList.add("active");
738
+ ttsBtn.addEventListener("click", () => {
739
+ ttsEnabled = !ttsEnabled;
740
+ ttsBtn.textContent = ttsEnabled ? "πŸ”Š" : "πŸ”‡";
741
+ ttsBtn.classList.toggle("active", ttsEnabled);
742
+ if (!ttsEnabled) { const a = $("vn-audio"); if (a) { a.pause(); a.src = ""; } }
743
+ });
744
+
745
+ function playTTS(audio_b64) {
746
+ if (!ttsEnabled || !audio_b64) return;
747
+ let audio = $("vn-audio");
748
+ if (!audio) {
749
+ audio = document.createElement("audio");
750
+ audio.id = "vn-audio";
751
+ document.body.appendChild(audio);
752
+ }
753
+ audio.src = audio_b64;
754
+ audio.play().catch(() => {}); // swallow autoplay policy errors silently
755
+ }
756
+
757
  // --- book overlay dismiss ---
758
  $("book-close").addEventListener("click", () => {
759
  $("intro-book").classList.add("hidden");
 
826
  try {
827
  const file = new File([blob], "speech.webm", { type: "audio/webm" });
828
  const { data } = await client.predict("/transcribe", { audio: handle_file(file) });
829
+ micIdle(); mic.disabled = false; $("say").disabled = false; $("send").disabled = false;
830
+ if (data[0]?.text) { $("say").value = data[0].text; $("say").focus(); return; }
831
  } catch (e) { console.error("[stt] transcribe error", e); }
832
+ // fallback: restore input on error
833
  mic.disabled = false; $("say").disabled = false; $("send").disabled = false;
834
  };
835
  rec.start(100);
pyproject.toml CHANGED
@@ -27,11 +27,13 @@ llamacpp = ["llama-cpp-python>=0.3.0"]
27
  transformers = ["transformers>=4.44", "accelerate>=0.33", "torch>=2.3"]
28
  image = ["diffusers>=0.38.0", "transformers>=4.44", "accelerate>=0.33", "torch>=2.3", "safetensors>=0.4", "rembg>=2.0", "peft>=0.11"]
29
  stt = ["faster-whisper>=1.0", "sounddevice>=0.5.5"]
 
30
  matting = ["transformers>=4.44", "torch>=2.3"] # BiRefNet for transparent sprites (Phase 2)
31
  spaces = ["spaces>=0.30"] # ZeroGPU decorator on HF Spaces
32
  modal = ["modal>=1.0"] # cloud GPU via Modal (LLM + painter)
33
  dev = ["ruff>=0.6", "pytest>=8.0", "matplotlib>=3.9"]
34
 
 
35
  [tool.ruff]
36
  line-length = 100
37
  target-version = "py311"
 
27
  transformers = ["transformers>=4.44", "accelerate>=0.33", "torch>=2.3"]
28
  image = ["diffusers>=0.38.0", "transformers>=4.44", "accelerate>=0.33", "torch>=2.3", "safetensors>=0.4", "rembg>=2.0", "peft>=0.11"]
29
  stt = ["faster-whisper>=1.0", "sounddevice>=0.5.5"]
30
+ tts = ["kokoro-onnx>=0.3", "soundfile>=0.12"]
31
  matting = ["transformers>=4.44", "torch>=2.3"] # BiRefNet for transparent sprites (Phase 2)
32
  spaces = ["spaces>=0.30"] # ZeroGPU decorator on HF Spaces
33
  modal = ["modal>=1.0"] # cloud GPU via Modal (LLM + painter)
34
  dev = ["ruff>=0.6", "pytest>=8.0", "matplotlib>=3.9"]
35
 
36
+
37
  [tool.ruff]
38
  line-length = 100
39
  target-version = "py311"
uv.lock CHANGED
@@ -294,6 +294,15 @@ wheels = [
294
  { url = "https://files.pythonhosted.org/packages/5f/af/0a6e1d2a845988039f6c197fa7269b5e9abbe17354fb41cc9d75bb260fcb/av-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a87a42c36e29f75e7dff7281944f2a6876a2c8875e225ccbf6c1ae62748b4caa", size = 22072676, upload-time = "2026-04-18T17:12:31.836Z" },
295
  ]
296
 
 
 
 
 
 
 
 
 
 
297
  [[package]]
298
  name = "brotli"
299
  version = "1.2.0"
@@ -661,6 +670,26 @@ wheels = [
661
  { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
662
  ]
663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  [[package]]
665
  name = "ctranslate2"
666
  version = "4.7.2"
@@ -804,6 +833,28 @@ wheels = [
804
  { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
805
  ]
806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
807
  [[package]]
808
  name = "fastapi"
809
  version = "0.136.3"
@@ -1165,6 +1216,10 @@ transformers = [
1165
  { name = "torch" },
1166
  { name = "transformers" },
1167
  ]
 
 
 
 
1168
 
1169
  [package.dev-dependencies]
1170
  dev = [
@@ -1180,6 +1235,7 @@ requires-dist = [
1180
  { name = "faster-whisper", marker = "extra == 'stt'", specifier = ">=1.0" },
1181
  { name = "gradio", specifier = ">=5.0" },
1182
  { name = "huggingface-hub", specifier = ">=1.17.0" },
 
1183
  { name = "llama-cpp-python", marker = "extra == 'llamacpp'", specifier = ">=0.3.0" },
1184
  { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.9" },
1185
  { name = "modal", marker = "extra == 'modal'", specifier = ">=1.0" },
@@ -1193,6 +1249,7 @@ requires-dist = [
1193
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
1194
  { name = "safetensors", marker = "extra == 'image'", specifier = ">=0.4" },
1195
  { name = "sounddevice", marker = "extra == 'stt'", specifier = ">=0.5.5" },
 
1196
  { name = "spaces", marker = "extra == 'spaces'", specifier = ">=0.30" },
1197
  { name = "torch", marker = "extra == 'image'", specifier = ">=2.3" },
1198
  { name = "torch", marker = "extra == 'matting'", specifier = ">=2.3" },
@@ -1201,7 +1258,7 @@ requires-dist = [
1201
  { name = "transformers", marker = "extra == 'matting'", specifier = ">=4.44" },
1202
  { name = "transformers", marker = "extra == 'transformers'", specifier = ">=4.44" },
1203
  ]
1204
- provides-extras = ["llamacpp", "transformers", "image", "stt", "matting", "spaces", "modal", "dev"]
1205
 
1206
  [package.metadata.requires-dev]
1207
  dev = [
@@ -1364,6 +1421,15 @@ wheels = [
1364
  { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
1365
  ]
1366
 
 
 
 
 
 
 
 
 
 
1367
  [[package]]
1368
  name = "jinja2"
1369
  version = "3.1.6"
@@ -1376,6 +1442,15 @@ wheels = [
1376
  { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
1377
  ]
1378
 
 
 
 
 
 
 
 
 
 
1379
  [[package]]
1380
  name = "jsonschema"
1381
  version = "4.26.0"
@@ -1509,6 +1584,30 @@ wheels = [
1509
  { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
1510
  ]
1511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1512
  [[package]]
1513
  name = "lazy-loader"
1514
  version = "0.5"
@@ -2337,6 +2436,22 @@ wheels = [
2337
  { url = "https://files.pythonhosted.org/packages/e8/b6/f54d676ed93cc2dd2234c3b172ea9c8c3d7d29361e66b1b23dec57a67465/peft-0.19.1-py3-none-any.whl", hash = "sha256:2113f72a81621b5913ef28f9022204c742df111890c5f49d812716a4a301e356", size = 680692, upload-time = "2026-04-16T15:46:42.886Z" },
2338
  ]
2339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2340
  [[package]]
2341
  name = "pillow"
2342
  version = "12.2.0"
@@ -2888,6 +3003,18 @@ wheels = [
2888
  { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
2889
  ]
2890
 
 
 
 
 
 
 
 
 
 
 
 
 
2891
  [[package]]
2892
  name = "referencing"
2893
  version = "0.37.0"
@@ -3040,6 +3167,15 @@ wheels = [
3040
  { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
3041
  ]
3042
 
 
 
 
 
 
 
 
 
 
3043
  [[package]]
3044
  name = "rich"
3045
  version = "15.0.0"
@@ -3389,6 +3525,19 @@ wheels = [
3389
  { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
3390
  ]
3391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3392
  [[package]]
3393
  name = "semantic-version"
3394
  version = "2.10.0"
@@ -3441,6 +3590,27 @@ wheels = [
3441
  { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" },
3442
  ]
3443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3444
  [[package]]
3445
  name = "spaces"
3446
  version = "0.50.2"
@@ -3495,6 +3665,15 @@ wheels = [
3495
  { url = "https://files.pythonhosted.org/packages/57/ea/531a6ea751cbd989da386144810b1b8f529b0aae8c1a9beda8b40966c9c2/synchronicity-0.12.3-py3-none-any.whl", hash = "sha256:e476818cd14102136f41622c619de548f0000c024485fc18521c8fe908ea7574", size = 40982, upload-time = "2026-05-28T12:33:49.125Z" },
3496
  ]
3497
 
 
 
 
 
 
 
 
 
 
3498
  [[package]]
3499
  name = "tifffile"
3500
  version = "2026.3.3"
@@ -3741,6 +3920,15 @@ wheels = [
3741
  { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
3742
  ]
3743
 
 
 
 
 
 
 
 
 
 
3744
  [[package]]
3745
  name = "urllib3"
3746
  version = "2.7.0"
 
294
  { url = "https://files.pythonhosted.org/packages/5f/af/0a6e1d2a845988039f6c197fa7269b5e9abbe17354fb41cc9d75bb260fcb/av-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a87a42c36e29f75e7dff7281944f2a6876a2c8875e225ccbf6c1ae62748b4caa", size = 22072676, upload-time = "2026-04-18T17:12:31.836Z" },
295
  ]
296
 
297
+ [[package]]
298
+ name = "babel"
299
+ version = "2.18.0"
300
+ source = { registry = "https://pypi.org/simple" }
301
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
302
+ wheels = [
303
+ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
304
+ ]
305
+
306
  [[package]]
307
  name = "brotli"
308
  version = "1.2.0"
 
670
  { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
671
  ]
672
 
673
+ [[package]]
674
+ name = "csvw"
675
+ version = "4.0.0"
676
+ source = { registry = "https://pypi.org/simple" }
677
+ dependencies = [
678
+ { name = "babel" },
679
+ { name = "isodate" },
680
+ { name = "jsonschema" },
681
+ { name = "language-tags" },
682
+ { name = "python-dateutil" },
683
+ { name = "rdflib" },
684
+ { name = "rfc3986" },
685
+ { name = "termcolor" },
686
+ { name = "uritemplate" },
687
+ ]
688
+ sdist = { url = "https://files.pythonhosted.org/packages/33/50/ff0bd8c3663dad0dae929775a6078c5bb7f4038d93032369786b1fe6a91c/csvw-4.0.0.tar.gz", hash = "sha256:060e9bedf3c274d0fce6d6f7f892a16cc8c72f39c2bc49b69b5f0858fb2f6217", size = 83025, upload-time = "2026-05-05T06:25:26.787Z" }
689
+ wheels = [
690
+ { url = "https://files.pythonhosted.org/packages/3a/88/9713d1ecac111742d60e1d9c2c15fec56fd121940f97a73d014dc9a7d521/csvw-4.0.0-py2.py3-none-any.whl", hash = "sha256:df875fcb1505afd15061b5f370268522bf162640de0662a724453dcb4db6a88b", size = 69424, upload-time = "2026-05-05T06:25:24.646Z" },
691
+ ]
692
+
693
  [[package]]
694
  name = "ctranslate2"
695
  version = "4.7.2"
 
833
  { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
834
  ]
835
 
836
+ [[package]]
837
+ name = "dlinfo"
838
+ version = "2.0.0"
839
+ source = { registry = "https://pypi.org/simple" }
840
+ sdist = { url = "https://files.pythonhosted.org/packages/85/8e/8f2f94cd40af1b51e8e371a83b385d622170d42f98776441a6118f4dd682/dlinfo-2.0.0.tar.gz", hash = "sha256:88a2bc04f51d01bc604cdc9eb1c3cc0bde89057532ca6a3e71a41f6235433e17", size = 12727, upload-time = "2025-01-16T15:43:10.756Z" }
841
+ wheels = [
842
+ { url = "https://files.pythonhosted.org/packages/da/90/022c79d6e5e6f843268c10b84d4a021ee3afba0621d3c176d3ff2024bfc8/dlinfo-2.0.0-py3-none-any.whl", hash = "sha256:b32cc18e3ea67c0ca9ca409e5b41eed863bd1363dbc9dd3de90fedf11b61e7bc", size = 3654, upload-time = "2025-01-16T15:43:09.474Z" },
843
+ ]
844
+
845
+ [[package]]
846
+ name = "espeakng-loader"
847
+ version = "0.2.4"
848
+ source = { registry = "https://pypi.org/simple" }
849
+ wheels = [
850
+ { url = "https://files.pythonhosted.org/packages/f8/92/f44ed7f531143c3c6c97d56e2b0f9be8728dc05e18b96d46eb539230ed46/espeakng_loader-0.2.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b77477ae2ddf62a748e04e49714eabb2f3a24f344166200b00539083bd669904", size = 9938387, upload-time = "2025-01-17T01:22:42.064Z" },
851
+ { url = "https://files.pythonhosted.org/packages/a8/26/258c0cd43b9bc1043301c5f61767d6a6c3b679df82790c9cb43a3277b865/espeakng_loader-0.2.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d27cdca31112226e7299d8562e889d3e38a1e48055c9ee381b45d669072ee59f", size = 9892565, upload-time = "2025-01-17T01:22:40.365Z" },
852
+ { url = "https://files.pythonhosted.org/packages/de/1e/25ec5ab07528c0fbb215a61800a38eca05c8a99445515a02d7fa5debcb32/espeakng_loader-0.2.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08721baf27d13d461f6be6eed9a65277e70d68234ff484fd8b9897b222cdcb6d", size = 10078484, upload-time = "2025-01-17T01:22:43.373Z" },
853
+ { url = "https://files.pythonhosted.org/packages/d9/ad/1b768d8daffc2996e07bbcb6f534d8de3202cd75fce1f1c45eced1ce6465/espeakng_loader-0.2.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d1e798141b46a050cdb75fcf3c17db969bb2c40394f3f4a48910655d547508b9", size = 10037736, upload-time = "2025-01-17T01:22:42.576Z" },
854
+ { url = "https://files.pythonhosted.org/packages/9d/ed/a3d872fbad4f3a3f3db0e8c31768ab14e77cd77306de16b8b20b1e1df7ea/espeakng_loader-0.2.4-py3-none-win_amd64.whl", hash = "sha256:41f1e08ac9deda2efd1ea9de0b81dab9f5ae3c4b24284f76533d0a7b1dd7abd7", size = 9437292, upload-time = "2025-01-17T01:23:27.463Z" },
855
+ { url = "https://files.pythonhosted.org/packages/29/64/0b75bc50ec53b4e000bac913625511215aa96124adf5dba8c4baa17c02cd/espeakng_loader-0.2.4-py3-none-win_arm64.whl", hash = "sha256:d7a2928843eaeb2df82f99a370f44e8a630f59b02f9b0d1f168a03c4eeb76b89", size = 9426841, upload-time = "2025-01-17T01:23:21.766Z" },
856
+ ]
857
+
858
  [[package]]
859
  name = "fastapi"
860
  version = "0.136.3"
 
1216
  { name = "torch" },
1217
  { name = "transformers" },
1218
  ]
1219
+ tts = [
1220
+ { name = "kokoro-onnx" },
1221
+ { name = "soundfile" },
1222
+ ]
1223
 
1224
  [package.dev-dependencies]
1225
  dev = [
 
1235
  { name = "faster-whisper", marker = "extra == 'stt'", specifier = ">=1.0" },
1236
  { name = "gradio", specifier = ">=5.0" },
1237
  { name = "huggingface-hub", specifier = ">=1.17.0" },
1238
+ { name = "kokoro-onnx", marker = "extra == 'tts'", specifier = ">=0.3" },
1239
  { name = "llama-cpp-python", marker = "extra == 'llamacpp'", specifier = ">=0.3.0" },
1240
  { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.9" },
1241
  { name = "modal", marker = "extra == 'modal'", specifier = ">=1.0" },
 
1249
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
1250
  { name = "safetensors", marker = "extra == 'image'", specifier = ">=0.4" },
1251
  { name = "sounddevice", marker = "extra == 'stt'", specifier = ">=0.5.5" },
1252
+ { name = "soundfile", marker = "extra == 'tts'", specifier = ">=0.12" },
1253
  { name = "spaces", marker = "extra == 'spaces'", specifier = ">=0.30" },
1254
  { name = "torch", marker = "extra == 'image'", specifier = ">=2.3" },
1255
  { name = "torch", marker = "extra == 'matting'", specifier = ">=2.3" },
 
1258
  { name = "transformers", marker = "extra == 'matting'", specifier = ">=4.44" },
1259
  { name = "transformers", marker = "extra == 'transformers'", specifier = ">=4.44" },
1260
  ]
1261
+ provides-extras = ["llamacpp", "transformers", "image", "stt", "tts", "matting", "spaces", "modal", "dev"]
1262
 
1263
  [package.metadata.requires-dev]
1264
  dev = [
 
1421
  { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
1422
  ]
1423
 
1424
+ [[package]]
1425
+ name = "isodate"
1426
+ version = "0.7.2"
1427
+ source = { registry = "https://pypi.org/simple" }
1428
+ sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
1429
+ wheels = [
1430
+ { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
1431
+ ]
1432
+
1433
  [[package]]
1434
  name = "jinja2"
1435
  version = "3.1.6"
 
1442
  { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
1443
  ]
1444
 
1445
+ [[package]]
1446
+ name = "joblib"
1447
+ version = "1.5.3"
1448
+ source = { registry = "https://pypi.org/simple" }
1449
+ sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
1450
+ wheels = [
1451
+ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
1452
+ ]
1453
+
1454
  [[package]]
1455
  name = "jsonschema"
1456
  version = "4.26.0"
 
1584
  { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
1585
  ]
1586
 
1587
+ [[package]]
1588
+ name = "kokoro-onnx"
1589
+ version = "0.5.0"
1590
+ source = { registry = "https://pypi.org/simple" }
1591
+ dependencies = [
1592
+ { name = "espeakng-loader" },
1593
+ { name = "numpy" },
1594
+ { name = "onnxruntime" },
1595
+ { name = "phonemizer-fork" },
1596
+ ]
1597
+ sdist = { url = "https://files.pythonhosted.org/packages/6b/18/277bd18aeeceaf5c46a51bb50c1cbdccdad8cab7fd1d58f0173bbeeec708/kokoro_onnx-0.5.0.tar.gz", hash = "sha256:5beb15f085e2828ed8d493f792c079af857103ab2dceaa1e112b1760587ac96a", size = 84570, upload-time = "2026-01-30T03:05:45.6Z" }
1598
+ wheels = [
1599
+ { url = "https://files.pythonhosted.org/packages/0d/55/0bfcb4aa50033c89e5ac132af3d07fac0543824ce6eaefd4d1bfdcc3795b/kokoro_onnx-0.5.0-py3-none-any.whl", hash = "sha256:4e1c38a296db5dbc1f722f69f5e3c13f2e2877b3e5b145287f56ec057013e357", size = 17448, upload-time = "2026-01-30T03:05:46.931Z" },
1600
+ ]
1601
+
1602
+ [[package]]
1603
+ name = "language-tags"
1604
+ version = "1.3.1"
1605
+ source = { registry = "https://pypi.org/simple" }
1606
+ sdist = { url = "https://files.pythonhosted.org/packages/39/d1/b37165bed9016c19e2cdecf4176fbd902a014fb8a87dd44415438e190969/language_tags-1.3.1.tar.gz", hash = "sha256:b15f05505dad3ad296a1782d5a6083fd141309186094d1ab08095348f4203f37", size = 222642, upload-time = "2026-05-08T11:46:24.418Z" }
1607
+ wheels = [
1608
+ { url = "https://files.pythonhosted.org/packages/0a/21/b48b9e8408b3faec9fb7cb2f68352c7724add35a980c948eaceb29ac41e4/language_tags-1.3.1-py3-none-any.whl", hash = "sha256:f7db7ef8879523019603e09644a86d95ba60595ce5e5ea82d46e8971b0f68f7b", size = 216654, upload-time = "2026-05-08T11:46:17.836Z" },
1609
+ ]
1610
+
1611
  [[package]]
1612
  name = "lazy-loader"
1613
  version = "0.5"
 
2436
  { url = "https://files.pythonhosted.org/packages/e8/b6/f54d676ed93cc2dd2234c3b172ea9c8c3d7d29361e66b1b23dec57a67465/peft-0.19.1-py3-none-any.whl", hash = "sha256:2113f72a81621b5913ef28f9022204c742df111890c5f49d812716a4a301e356", size = 680692, upload-time = "2026-04-16T15:46:42.886Z" },
2437
  ]
2438
 
2439
+ [[package]]
2440
+ name = "phonemizer-fork"
2441
+ version = "3.3.2"
2442
+ source = { registry = "https://pypi.org/simple" }
2443
+ dependencies = [
2444
+ { name = "attrs" },
2445
+ { name = "dlinfo" },
2446
+ { name = "joblib" },
2447
+ { name = "segments" },
2448
+ { name = "typing-extensions" },
2449
+ ]
2450
+ sdist = { url = "https://files.pythonhosted.org/packages/42/fa/9294d2f11890ca49d0bdac7a4da60cbe5686629bfd4987cae0ad75e051cc/phonemizer_fork-3.3.2.tar.gz", hash = "sha256:10e16e827d0443b087062e21b55e805c00989cf1343b2e81e734cae5f6c0cf69", size = 300989, upload-time = "2025-01-30T13:02:31.201Z" }
2451
+ wheels = [
2452
+ { url = "https://files.pythonhosted.org/packages/64/f1/0dcce21b0ae16a82df4b6583f8f3ad8e55b35f7e98b6bf536a4dd225fa08/phonemizer_fork-3.3.2-py3-none-any.whl", hash = "sha256:97305c76f4183b3825dae8f4c032265fe78c9946ce58c47d4b62161349264b74", size = 82700, upload-time = "2025-01-30T13:02:28.667Z" },
2453
+ ]
2454
+
2455
  [[package]]
2456
  name = "pillow"
2457
  version = "12.2.0"
 
3003
  { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
3004
  ]
3005
 
3006
+ [[package]]
3007
+ name = "rdflib"
3008
+ version = "7.6.0"
3009
+ source = { registry = "https://pypi.org/simple" }
3010
+ dependencies = [
3011
+ { name = "pyparsing" },
3012
+ ]
3013
+ sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" }
3014
+ wheels = [
3015
+ { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" },
3016
+ ]
3017
+
3018
  [[package]]
3019
  name = "referencing"
3020
  version = "0.37.0"
 
3167
  { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
3168
  ]
3169
 
3170
+ [[package]]
3171
+ name = "rfc3986"
3172
+ version = "1.5.0"
3173
+ source = { registry = "https://pypi.org/simple" }
3174
+ sdist = { url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", size = 49378, upload-time = "2021-05-07T23:29:27.183Z" }
3175
+ wheels = [
3176
+ { url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97", size = 31976, upload-time = "2021-05-07T23:29:25.611Z" },
3177
+ ]
3178
+
3179
  [[package]]
3180
  name = "rich"
3181
  version = "15.0.0"
 
3525
  { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
3526
  ]
3527
 
3528
+ [[package]]
3529
+ name = "segments"
3530
+ version = "2.4.0"
3531
+ source = { registry = "https://pypi.org/simple" }
3532
+ dependencies = [
3533
+ { name = "csvw" },
3534
+ { name = "regex" },
3535
+ ]
3536
+ sdist = { url = "https://files.pythonhosted.org/packages/8b/57/85cac3a8e32370e88fa5fa92812edb6025db7fcbed51452bd56ee1524957/segments-2.4.0.tar.gz", hash = "sha256:bba71f5520ddd54c8aa2f4d765a60618c6862162d6e7356a4a097f2223166f5b", size = 18662, upload-time = "2026-03-07T10:01:28.925Z" }
3537
+ wheels = [
3538
+ { url = "https://files.pythonhosted.org/packages/be/60/eef9acce946177f92c9aabf432224d87ab908bafafac516a36ab924199f3/segments-2.4.0-py2.py3-none-any.whl", hash = "sha256:4021dc67f201cc03c864c74c618bdb163b1af629da3040babbaa37d8813f3db0", size = 16321, upload-time = "2026-03-07T10:01:27.885Z" },
3539
+ ]
3540
+
3541
  [[package]]
3542
  name = "semantic-version"
3543
  version = "2.10.0"
 
3590
  { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" },
3591
  ]
3592
 
3593
+ [[package]]
3594
+ name = "soundfile"
3595
+ version = "0.14.0"
3596
+ source = { registry = "https://pypi.org/simple" }
3597
+ dependencies = [
3598
+ { name = "cffi" },
3599
+ { name = "numpy" },
3600
+ { name = "typing-extensions" },
3601
+ ]
3602
+ sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" }
3603
+ wheels = [
3604
+ { url = "https://files.pythonhosted.org/packages/b1/d1/5e338af9ca6ed0786cd5bb03f6d60de1c325728c1189014f3b59aae7403c/soundfile-0.14.0-py2.py3-none-any.whl", hash = "sha256:8ba81ae3a89fd5ab3bef8a8eb481fbbe794e806309675a89b4df48b8d31908a8", size = 26799, upload-time = "2026-06-06T08:58:33.269Z" },
3605
+ { url = "https://files.pythonhosted.org/packages/7e/72/c6b21e58d3113596e7e8de0a08d6f1d95173492cfbca0a4db14148cbba2a/soundfile-0.14.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:19be05428da76ed61a4cad29b8e4bcf43a3e5c100089d2ec81dc961eed1b0dd4", size = 1144568, upload-time = "2026-06-06T08:58:35.231Z" },
3606
+ { url = "https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c", size = 1103726, upload-time = "2026-06-06T08:58:37.948Z" },
3607
+ { url = "https://files.pythonhosted.org/packages/4a/f8/fc39fad6f879633461d27394cd1ddaf1f769ffa0597dca35872f51b16461/soundfile-0.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e85724a90bc99a6e8062c0b4ddf725f53b2a3b70afd4da875e9d2cfc4e92f377", size = 1238050, upload-time = "2026-06-06T08:58:39.932Z" },
3608
+ { url = "https://files.pythonhosted.org/packages/7b/a2/70fd4432b924684c372df8b0a45708c36c057ef3596c9eb53e0a806b980b/soundfile-0.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1e38bac1853412871318e82a1ba69a8be677619b56025bbfcccdb41b6cafe82d", size = 1315963, upload-time = "2026-06-06T08:58:41.716Z" },
3609
+ { url = "https://files.pythonhosted.org/packages/d9/34/c9e80783d83eab739a9531fdee03675d53e0bf1b2ccb4bb3af5844675046/soundfile-0.14.0-py2.py3-none-win32.whl", hash = "sha256:0a6ae43c50c71b4e020cc55382925cb89451c1ed1a0c3d0f5d802da269226849", size = 902199, upload-time = "2026-06-06T08:58:43.289Z" },
3610
+ { url = "https://files.pythonhosted.org/packages/ed/97/b39c18ac1df45e755ca22b8b00e872929da5d107998a207a5e4ac831bfda/soundfile-0.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:299491d3499460fb1b74bb4bd78b57ffc2d243a5fafa7b6ec1b264875c78453e", size = 1021480, upload-time = "2026-06-06T08:58:45.016Z" },
3611
+ { url = "https://files.pythonhosted.org/packages/f4/83/55c65e61cf457805ce2ec157c1c6ae17715d0851aa2374422de0538838ca/soundfile-0.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:e090704718e124e7c844695236f1fce8d18a5e761eaf7c82dfcd124620805f98", size = 888858, upload-time = "2026-06-06T08:58:46.593Z" },
3612
+ ]
3613
+
3614
  [[package]]
3615
  name = "spaces"
3616
  version = "0.50.2"
 
3665
  { url = "https://files.pythonhosted.org/packages/57/ea/531a6ea751cbd989da386144810b1b8f529b0aae8c1a9beda8b40966c9c2/synchronicity-0.12.3-py3-none-any.whl", hash = "sha256:e476818cd14102136f41622c619de548f0000c024485fc18521c8fe908ea7574", size = 40982, upload-time = "2026-05-28T12:33:49.125Z" },
3666
  ]
3667
 
3668
+ [[package]]
3669
+ name = "termcolor"
3670
+ version = "3.3.0"
3671
+ source = { registry = "https://pypi.org/simple" }
3672
+ sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
3673
+ wheels = [
3674
+ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
3675
+ ]
3676
+
3677
  [[package]]
3678
  name = "tifffile"
3679
  version = "2026.3.3"
 
3920
  { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
3921
  ]
3922
 
3923
+ [[package]]
3924
+ name = "uritemplate"
3925
+ version = "4.2.0"
3926
+ source = { registry = "https://pypi.org/simple" }
3927
+ sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
3928
+ wheels = [
3929
+ { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
3930
+ ]
3931
+
3932
  [[package]]
3933
  name = "urllib3"
3934
  version = "2.7.0"
visualnovel/config.py CHANGED
@@ -44,15 +44,20 @@ USE_MOCK = os.getenv("VN_MOCK", "1") == "1"
44
  # VN_REAL_STT=1 -> use real Whisper even in mock mode (test STT without full model stack).
45
  REAL_STT = os.getenv("VN_REAL_STT", "0") == "1"
46
 
47
- # LLM runtime backend when not mocking. llamacpp = the llama.cpp bonus path.
48
- LLM_BACKEND = os.getenv("VN_LLM_BACKEND", "llamacpp") # llamacpp | transformers | modal | mock
 
 
 
 
 
49
 
50
  # Image generation backend when not mocking.
51
- # Defaults to "modal" when LLM_BACKEND is already modal, otherwise "local" (SDXL-Turbo on-device).
52
  IMAGE_BACKEND = os.getenv(
53
  "VN_IMAGE_BACKEND",
54
- "modal" if LLM_BACKEND == "modal" else "local",
55
- ) # modal | local
56
 
57
  # Set to a .jsonl path to record a full per-turn trace (Open-Trace bonus).
58
  TRACE_PATH = os.getenv("VN_TRACE") # None disables tracing
@@ -76,6 +81,8 @@ WHISPER_SIZE = os.getenv(
76
  "VN_WHISPER_SIZE", "large-v3-turbo"
77
  ) # tiny|base|small|medium|large-v3|large-v3-turbo
78
 
 
 
79
  # --------------------------------------------------------------------------- #
80
  # Generation / budget params
81
  # --------------------------------------------------------------------------- #
 
44
  # VN_REAL_STT=1 -> use real Whisper even in mock mode (test STT without full model stack).
45
  REAL_STT = os.getenv("VN_REAL_STT", "0") == "1"
46
 
47
+ # Detect HuggingFace Space runtime β€” SPACE_ID is always injected by the HF platform.
48
+ _ON_SPACE = bool(os.getenv("SPACE_ID"))
49
+
50
+ # LLM runtime backend when not mocking.
51
+ # Auto-selects transformers on HF Spaces (ZeroGPU); llamacpp everywhere else.
52
+ # Override with VN_LLM_BACKEND=modal for the Modal cloud path.
53
+ LLM_BACKEND = os.getenv("VN_LLM_BACKEND", "transformers" if _ON_SPACE else "llamacpp")
54
 
55
  # Image generation backend when not mocking.
56
+ # "lightning" = SDXL-Lightning (4-step, GPU-local) | "modal" = Modal cloud | "local" = SDXL-Turbo
57
  IMAGE_BACKEND = os.getenv(
58
  "VN_IMAGE_BACKEND",
59
+ "modal" if LLM_BACKEND == "modal" else "lightning",
60
+ )
61
 
62
  # Set to a .jsonl path to record a full per-turn trace (Open-Trace bonus).
63
  TRACE_PATH = os.getenv("VN_TRACE") # None disables tracing
 
81
  "VN_WHISPER_SIZE", "large-v3-turbo"
82
  ) # tiny|base|small|medium|large-v3|large-v3-turbo
83
 
84
+ TTS_BACKEND = os.getenv("VN_TTS_BACKEND", "mock") # mock | kokoro
85
+
86
  # --------------------------------------------------------------------------- #
87
  # Generation / budget params
88
  # --------------------------------------------------------------------------- #
visualnovel/engine.py CHANGED
@@ -14,6 +14,7 @@ sessions on a busy Space, key engines by session id instead.
14
 
15
  from __future__ import annotations
16
 
 
17
  from pathlib import Path
18
 
19
  from . import config, memory, orchestrator, state
@@ -23,6 +24,7 @@ from .painter import get_painter
23
  from .schemas import DirectorOutput, GameState, SetupForm, SpritePresence, Turn, ViewState
24
  from .stt import get_stt
25
  from .trace import Tracer
 
26
 
27
 
28
  class Engine:
@@ -30,6 +32,7 @@ class Engine:
30
  self.llm = get_llm()
31
  self.painter = get_painter()
32
  self.stt = get_stt()
 
33
  self.tracer = Tracer(config.TRACE_PATH)
34
  self.state: GameState | None = None
35
 
@@ -165,7 +168,18 @@ class Engine:
165
  )
166
  )
167
 
168
- speaker_name = s.characters[out.speaker].name if out.speaker in s.characters else "The wood"
 
 
 
 
 
 
 
 
 
 
 
169
  return ViewState(
170
  speaker=speaker_name,
171
  dialogue=out.dialogue,
@@ -180,6 +194,7 @@ class Engine:
180
  turn_index=s.turn_index,
181
  notifications=notifications or [],
182
  intro_text=intro_text,
 
183
  )
184
 
185
 
 
14
 
15
  from __future__ import annotations
16
 
17
+ import base64
18
  from pathlib import Path
19
 
20
  from . import config, memory, orchestrator, state
 
24
  from .schemas import DirectorOutput, GameState, SetupForm, SpritePresence, Turn, ViewState
25
  from .stt import get_stt
26
  from .trace import Tracer
27
+ from .tts import get_tts
28
 
29
 
30
  class Engine:
 
32
  self.llm = get_llm()
33
  self.painter = get_painter()
34
  self.stt = get_stt()
35
+ self.tts = get_tts()
36
  self.tracer = Tracer(config.TRACE_PATH)
37
  self.state: GameState | None = None
38
 
 
168
  )
169
  )
170
 
171
+ speaker_ch = s.characters.get(out.speaker)
172
+ speaker_name = speaker_ch.name if speaker_ch else "The wood"
173
+
174
+ audio_b64: str | None = None
175
+ if speaker_ch and speaker_ch.tts_voice_description:
176
+ with collector.measure("tts", turn=turn):
177
+ wav = self.tts.synthesize(
178
+ out.dialogue, speaker_ch.tts_voice_description, speaker_ch.sprite_seed
179
+ )
180
+ if wav:
181
+ audio_b64 = "data:audio/wav;base64," + base64.b64encode(wav).decode()
182
+
183
  return ViewState(
184
  speaker=speaker_name,
185
  dialogue=out.dialogue,
 
194
  turn_index=s.turn_index,
195
  notifications=notifications or [],
196
  intro_text=intro_text,
197
+ audio_b64=audio_b64,
198
  )
199
 
200
 
visualnovel/orchestrator.py CHANGED
@@ -93,6 +93,7 @@ def init_world(llm: LLMBackend, setup: SetupForm) -> tuple[GameState, DirectorOu
93
  traits=first.traits,
94
  goals=first.goals,
95
  sprite_seed=_seed_for(seed, first.id),
 
96
  )
97
  },
98
  flags=flags,
 
93
  traits=first.traits,
94
  goals=first.goals,
95
  sprite_seed=_seed_for(seed, first.id),
96
+ tts_voice_description=first.voice, # frozen at creation
97
  )
98
  },
99
  flags=flags,
visualnovel/schemas.py CHANGED
@@ -40,6 +40,7 @@ class Character(BaseModel):
40
  # Discovery tracking (not sent to the LLM β€” managed by state.apply_directives)
41
  discovered_traits: list[int] = Field(default_factory=list) # indices of unlocked traits
42
  goal_unlocked: bool = False
 
43
 
44
 
45
  class Scene(BaseModel):
@@ -166,3 +167,4 @@ class ViewState(BaseModel):
166
  turn_index: int = 0
167
  notifications: list[str] = Field(default_factory=list) # popup messages this turn
168
  intro_text: str = "" # shown once in the book overlay at game start; empty on subsequent turns
 
 
40
  # Discovery tracking (not sent to the LLM β€” managed by state.apply_directives)
41
  discovered_traits: list[int] = Field(default_factory=list) # indices of unlocked traits
42
  goal_unlocked: bool = False
43
+ tts_voice_description: str = "" # frozen at first introduction; never updated by directives
44
 
45
 
46
  class Scene(BaseModel):
 
167
  turn_index: int = 0
168
  notifications: list[str] = Field(default_factory=list) # popup messages this turn
169
  intro_text: str = "" # shown once in the book overlay at game start; empty on subsequent turns
170
+ audio_b64: str | None = None # data:audio/wav;base64,... β€” absent when TTS is mocked
visualnovel/state.py CHANGED
@@ -63,6 +63,7 @@ def apply_directives(state: GameState, out: DirectorOutput) -> list[str]:
63
  traits=nc.traits,
64
  goals=nc.goals,
65
  sprite_seed=_seed_for(state.seed, cid),
 
66
  )
67
  if cid not in state.scene.present:
68
  state.scene.present.append(cid)
 
63
  traits=nc.traits,
64
  goals=nc.goals,
65
  sprite_seed=_seed_for(state.seed, cid),
66
+ tts_voice_description=nc.voice, # frozen at creation; never updated
67
  )
68
  if cid not in state.scene.present:
69
  state.scene.present.append(cid)
visualnovel/tts.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Voice β€” text to speech.
2
+
3
+ Synthesises NPC dialogue using Kokoro-82M via ONNX (~82M params, no transformers dependency).
4
+
5
+ Design notes:
6
+ - Voice selection reads Character.tts_voice_description (frozen at creation) and scores every
7
+ Kokoro preset by keyword overlap β€” the best-scoring voice wins. sprite_seed breaks ties so
8
+ two characters with the same description still get different voices.
9
+ - `tts_voice_description` is frozen at character creation (see state.apply_directives and
10
+ orchestrator.init_world), ensuring voice identity is stable for the whole session.
11
+ - Results are cached in memory by SHA-256(text + voice_id + seed) so repeated lines are free.
12
+
13
+ Model files (~337 MB total) live in models/kokoro/ and are downloaded once:
14
+ curl -L -o models/kokoro/kokoro-v1.0.onnx \\
15
+ https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx
16
+ curl -L -o models/kokoro/voices-v1.0.bin \\
17
+ https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import io
24
+ from typing import Protocol
25
+
26
+ from . import config
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Voice profiles β€” (voice_id, gender, keyword_set)
30
+ # gender: "f" | "m"
31
+ # keyword_set: words in a voice descriptor that suggest this voice
32
+ # ---------------------------------------------------------------------------
33
+ _VOICE_PROFILES: list[tuple[str, str, frozenset[str]]] = [
34
+ # ── American Female ────────────────────────────────────────────────────
35
+ ("af_heart", "f", frozenset({"warm", "bright", "breathless", "tender", "sweet", "laughs", "light"})),
36
+ ("af_bella", "f", frozenset({"soft", "gentle", "quiet", "shy", "delicate", "whisper", "whispered"})),
37
+ ("af_sarah", "f", frozenset({"clear", "professional", "calm", "steady", "cool", "composed"})),
38
+ ("af_sky", "f", frozenset({"airy", "bubbly", "cheerful", "energetic", "lively", "peppy"})),
39
+ ("af_jessica", "f", frozenset({"bold", "confident", "sharp", "spirited", "assertive", "direct"})),
40
+ ("af_nicole", "f", frozenset({"rich", "smooth", "soothing", "mature", "warm", "velvet"})),
41
+ ("af_nova", "f", frozenset({"crisp", "precise", "intelligent", "focused", "analytical"})),
42
+ ("af_river", "f", frozenset({"flowing", "peaceful", "serene", "dreamy", "drifts", "trails"})),
43
+ ("af_kore", "f", frozenset({"strong", "powerful", "serious", "stern", "commanding"})),
44
+ ("af_aoede", "f", frozenset({"melodic", "musical", "lyrical", "poetic", "ethereal", "sings"})),
45
+ ("af_alloy", "f", frozenset({"neutral", "balanced", "modern", "even"})),
46
+ # ── British Female ─────────────────────────────────────────────────────
47
+ ("bf_alice", "f", frozenset({"british", "english", "uk", "posh", "accent", "clipped"})),
48
+ ("bf_emma", "f", frozenset({"british", "english", "uk", "homely", "friendly", "warm"})),
49
+ ("bf_isabella", "f", frozenset({"british", "english", "uk", "refined", "elegant", "sophisticated"})),
50
+ ("bf_lily", "f", frozenset({"british", "english", "uk", "gentle", "soft", "sweet"})),
51
+ # ── American Male ─────────────────────────────────────────────────────
52
+ ("am_adam", "m", frozenset({"confident", "deep", "steady", "bass", "strong", "rich"})),
53
+ ("am_echo", "m", frozenset({"resonant", "full", "commanding", "measured"})),
54
+ ("am_eric", "m", frozenset({"friendly", "casual", "warm", "approachable", "cheerful"})),
55
+ ("am_fenrir", "m", frozenset({"gruff", "rough", "intense", "dark", "rugged", "growl", "harsh"})),
56
+ ("am_liam", "m", frozenset({"young", "energetic", "bright", "enthusiastic", "lively"})),
57
+ ("am_michael", "m", frozenset({"mature", "calm", "authoritative", "professorial", "quiet"})),
58
+ ("am_onyx", "m", frozenset({"smooth", "velvet", "baritone", "low", "silky", "suave"})),
59
+ ("am_puck", "m", frozenset({"playful", "mischievous", "quick", "witty", "impish", "teasing"})),
60
+ # ── British Male ──────────────────────────────────────────────────────
61
+ ("bm_daniel", "m", frozenset({"british", "english", "uk", "smooth", "refined", "crisp"})),
62
+ ("bm_fable", "m", frozenset({"british", "english", "uk", "storyteller", "dramatic", "expressive"})),
63
+ ("bm_george", "m", frozenset({"british", "english", "uk", "distinguished", "formal", "noble"})),
64
+ ("bm_lewis", "m", frozenset({"british", "english", "uk", "young", "casual", "easy"})),
65
+ ]
66
+
67
+ _MALE_GENDER_HINTS = {"man", "male", "his", "him", "he", "boy", "lad", "gentleman"}
68
+ _FEMALE_GENDER_HINTS = {"woman", "female", "her", "she", "girl", "lady"}
69
+
70
+ _MODEL_PATH = config.MODELS_DIR / "kokoro" / "kokoro-v1.0.onnx"
71
+ _VOICES_PATH = config.MODELS_DIR / "kokoro" / "voices-v1.0.bin"
72
+
73
+
74
+ def _pick_voice(voice_description: str, sprite_seed: int) -> str:
75
+ """Score every Kokoro voice against the description; use sprite_seed to break ties."""
76
+ words = set(voice_description.lower().split())
77
+
78
+ # Detect gender from description
79
+ is_male = bool(words & _MALE_GENDER_HINTS)
80
+ is_female = bool(words & _FEMALE_GENDER_HINTS)
81
+ # If ambiguous, default to female (most anime VN characters)
82
+ target_gender = "m" if is_male and not is_female else "f"
83
+
84
+ candidates = [(vid, kws) for vid, g, kws in _VOICE_PROFILES if g == target_gender]
85
+
86
+ # Score: number of keyword matches, then deterministic tie-break by seed
87
+ scored = [(len(words & kws), vid) for vid, kws in candidates]
88
+ scored.sort(key=lambda x: x[0], reverse=True)
89
+
90
+ # Among voices tied at the top score, pick by sprite_seed
91
+ top_score = scored[0][0]
92
+ top_voices = [vid for score, vid in scored if score == top_score]
93
+ return top_voices[sprite_seed % len(top_voices)]
94
+
95
+
96
+ class TTSBackend(Protocol):
97
+ def synthesize(self, text: str, voice_description: str, seed: int) -> bytes | None: ...
98
+
99
+
100
+ class MockTTS:
101
+ def synthesize(self, text: str, voice_description: str, seed: int) -> bytes | None:
102
+ return None
103
+
104
+
105
+ class KokoroTTS:
106
+ def __init__(self) -> None:
107
+ from kokoro_onnx import Kokoro # noqa: PLC0415
108
+
109
+ if not _MODEL_PATH.exists() or not _VOICES_PATH.exists():
110
+ raise FileNotFoundError(
111
+ f"Kokoro model files missing from {_MODEL_PATH.parent}.\n"
112
+ "Download them with:\n"
113
+ " curl -L -o models/kokoro/kokoro-v1.0.onnx \\\n"
114
+ " https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx\n"
115
+ " curl -L -o models/kokoro/voices-v1.0.bin \\\n"
116
+ " https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
117
+ )
118
+
119
+ self._kokoro = Kokoro(str(_MODEL_PATH), str(_VOICES_PATH))
120
+ # Filter profiles to voices actually present in the loaded model
121
+ available = set(self._kokoro.get_voices())
122
+ self._available_voices = available
123
+ self._cache: dict[str, bytes] = {}
124
+
125
+ def synthesize(self, text: str, voice_description: str, seed: int) -> bytes | None:
126
+ import soundfile as sf # noqa: PLC0415
127
+
128
+ voice_id = _pick_voice(voice_description, seed)
129
+ # Fallback if the chosen voice isn't in this model build
130
+ if voice_id not in self._available_voices:
131
+ fallback = [vid for vid, g, _ in _VOICE_PROFILES if g == "f" and vid in self._available_voices]
132
+ voice_id = fallback[seed % len(fallback)] if fallback else next(iter(self._available_voices))
133
+
134
+ cache_key = hashlib.sha256(f"{text}\x00{voice_id}\x00{seed}".encode()).hexdigest()
135
+ if cache_key in self._cache:
136
+ return self._cache[cache_key]
137
+
138
+ samples, sample_rate = self._kokoro.create(text, voice=voice_id, speed=1.0, lang="en-us")
139
+
140
+ buf = io.BytesIO()
141
+ sf.write(buf, samples, sample_rate, format="WAV")
142
+ wav_bytes = buf.getvalue()
143
+
144
+ self._cache[cache_key] = wav_bytes
145
+ return wav_bytes
146
+
147
+
148
+ def get_tts() -> TTSBackend:
149
+ if config.TTS_BACKEND == "kokoro":
150
+ return KokoroTTS()
151
+ return MockTTS()