mattkevan commited on
Commit
29f61a5
·
1 Parent(s): 5b38545

Fix build error

Browse files
app.py CHANGED
@@ -1,4 +1,5 @@
1
  import os
 
2
  import shutil
3
  import socket
4
  from pathlib import Path
@@ -23,6 +24,8 @@ from backend.omnivoice_adapter import OmniVoiceAdapter
23
  from backend.render_pipeline import RenderPipeline
24
  from backend.session_store import SessionStore
25
  from backend.types import VoiceConfig
 
 
26
 
27
 
28
  ROOT = Path(__file__).parent
@@ -197,7 +200,14 @@ def export_audiobook_api(
197
 
198
  @app.get("/", response_class=HTMLResponse)
199
  async def homepage() -> HTMLResponse:
200
- return HTMLResponse((FRONTEND_DIR / "index.html").read_text(encoding="utf-8"))
 
 
 
 
 
 
 
201
 
202
 
203
  @app.get("/health")
 
1
  import os
2
+ import json
3
  import shutil
4
  import socket
5
  from pathlib import Path
 
24
  from backend.render_pipeline import RenderPipeline
25
  from backend.session_store import SessionStore
26
  from backend.types import VoiceConfig
27
+ from backend.voice_design import VOICE_DESIGN_OPTIONS
28
+ from backend.voice_presets import VOICE_PRESETS
29
 
30
 
31
  ROOT = Path(__file__).parent
 
200
 
201
  @app.get("/", response_class=HTMLResponse)
202
  async def homepage() -> HTMLResponse:
203
+ html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
204
+ preset_script = (
205
+ "<script>"
206
+ f"window.__VOICE_PRESETS__ = {json.dumps(VOICE_PRESETS)};"
207
+ f"window.__VOICE_DESIGN_OPTIONS__ = {json.dumps(VOICE_DESIGN_OPTIONS)};"
208
+ "</script>"
209
+ )
210
+ return HTMLResponse(html.replace("</body>", f" {preset_script}\n </body>"))
211
 
212
 
213
  @app.get("/health")
backend/omnivoice_adapter.py CHANGED
@@ -6,6 +6,7 @@ import numpy as np
6
  import soundfile as sf
7
 
8
  from backend.types import VoiceConfig
 
9
 
10
  try:
11
  import spaces
@@ -22,15 +23,6 @@ except ImportError:
22
 
23
  spaces = _SpacesShim()
24
 
25
-
26
- NARRATOR_PRESETS = {
27
- "brother-anselm": "male, warm baritone, measured, british accent",
28
- "dame-eglantine": "female, bright contralto, lyrical, british accent",
29
- "the-reeve": "male, gravelly bass, wry, british accent",
30
- "sister-cecilia": "female, clear soprano, gentle, british accent",
31
- }
32
-
33
-
34
  class OmniVoiceAdapter:
35
  def __init__(
36
  self,
@@ -99,7 +91,9 @@ class OmniVoiceAdapter:
99
  elif voice_config.mode == "design":
100
  kwargs["instruct"] = voice_config.design_prompt
101
  elif voice_config.narrator_id:
102
- kwargs["instruct"] = NARRATOR_PRESETS.get(voice_config.narrator_id)
 
 
103
 
104
  audio = model.generate(**kwargs)
105
  waveform = np.asarray(audio[0], dtype=np.float32)
@@ -129,7 +123,9 @@ class OmniVoiceAdapter:
129
  elif voice_config.mode == "clone":
130
  base_freq = 140.0
131
  elif voice_config.narrator_id:
132
- base_freq = 160.0 + (list(NARRATOR_PRESETS.keys()).index(voice_config.narrator_id) * 20)
 
 
133
 
134
  timeline = np.linspace(0, duration_seconds, total_samples, endpoint=False)
135
  waveform = (
 
6
  import soundfile as sf
7
 
8
  from backend.types import VoiceConfig
9
+ from backend.voice_presets import VOICE_PRESET_BY_ID
10
 
11
  try:
12
  import spaces
 
23
 
24
  spaces = _SpacesShim()
25
 
 
 
 
 
 
 
 
 
 
26
  class OmniVoiceAdapter:
27
  def __init__(
28
  self,
 
91
  elif voice_config.mode == "design":
92
  kwargs["instruct"] = voice_config.design_prompt
93
  elif voice_config.narrator_id:
94
+ preset = VOICE_PRESET_BY_ID.get(voice_config.narrator_id)
95
+ if preset:
96
+ kwargs["instruct"] = preset["instruct"]
97
 
98
  audio = model.generate(**kwargs)
99
  waveform = np.asarray(audio[0], dtype=np.float32)
 
123
  elif voice_config.mode == "clone":
124
  base_freq = 140.0
125
  elif voice_config.narrator_id:
126
+ preset_ids = list(VOICE_PRESET_BY_ID.keys())
127
+ if voice_config.narrator_id in preset_ids:
128
+ base_freq = 160.0 + (preset_ids.index(voice_config.narrator_id) * 20)
129
 
130
  timeline = np.linspace(0, duration_seconds, total_samples, endpoint=False)
131
  waveform = (
backend/voice_design.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ VOICE_DESIGN_OPTIONS = [
2
+ {
3
+ "key": "gender",
4
+ "label": "Gender",
5
+ "options": [
6
+ {"value": "male", "label": "Male"},
7
+ {"value": "female", "label": "Female"},
8
+ ],
9
+ },
10
+ {
11
+ "key": "age",
12
+ "label": "Age",
13
+ "options": [
14
+ {"value": "child", "label": "Child"},
15
+ {"value": "teenager", "label": "Teenager"},
16
+ {"value": "young adult", "label": "Young Adult"},
17
+ {"value": "middle-aged", "label": "Middle-aged"},
18
+ {"value": "elderly", "label": "Elderly"},
19
+ ],
20
+ },
21
+ {
22
+ "key": "pitch",
23
+ "label": "Pitch",
24
+ "options": [
25
+ {"value": "very low pitch", "label": "Very Low Pitch"},
26
+ {"value": "low pitch", "label": "Low Pitch"},
27
+ {"value": "moderate pitch", "label": "Moderate Pitch"},
28
+ {"value": "high pitch", "label": "High Pitch"},
29
+ {"value": "very high pitch", "label": "Very High Pitch"},
30
+ ],
31
+ },
32
+ {
33
+ "key": "style",
34
+ "label": "Style",
35
+ "options": [
36
+ {"value": "whisper", "label": "Whisper"},
37
+ ],
38
+ },
39
+ {
40
+ "key": "accent",
41
+ "label": "Accent",
42
+ "options": [
43
+ {"value": "american accent", "label": "American Accent"},
44
+ {"value": "australian accent", "label": "Australian Accent"},
45
+ {"value": "british accent", "label": "British Accent"},
46
+ {"value": "canadian accent", "label": "Canadian Accent"},
47
+ {"value": "chinese accent", "label": "Chinese Accent"},
48
+ {"value": "indian accent", "label": "Indian Accent"},
49
+ {"value": "japanese accent", "label": "Japanese Accent"},
50
+ {"value": "korean accent", "label": "Korean Accent"},
51
+ {"value": "portuguese accent", "label": "Portuguese Accent"},
52
+ {"value": "russian accent", "label": "Russian Accent"},
53
+ ],
54
+ },
55
+ ]
backend/voice_presets.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ VOICE_PRESETS = [
2
+ {
3
+ "id": "british-male",
4
+ "name": "British Male",
5
+ "desc": "Voice design preset · male · low pitch · british accent",
6
+ "initial": "B",
7
+ "instruct": "male, low pitch, british accent",
8
+ },
9
+ {
10
+ "id": "british-female",
11
+ "name": "British Female",
12
+ "desc": "Voice design preset · female · moderate pitch · british accent",
13
+ "initial": "B",
14
+ "instruct": "female, moderate pitch, british accent",
15
+ },
16
+ {
17
+ "id": "american-male",
18
+ "name": "American Male",
19
+ "desc": "Voice design preset · male · middle-aged · american accent",
20
+ "initial": "A",
21
+ "instruct": "male, middle-aged, american accent",
22
+ },
23
+ {
24
+ "id": "canadian-female",
25
+ "name": "Canadian Female",
26
+ "desc": "Voice design preset · female · young adult · canadian accent",
27
+ "initial": "C",
28
+ "instruct": "female, young adult, canadian accent",
29
+ },
30
+ ]
31
+
32
+ VOICE_PRESET_BY_ID = {preset["id"]: preset for preset in VOICE_PRESETS}
frontend/app.js CHANGED
@@ -1,11 +1,13 @@
1
  import { Client, handle_file } from "https://esm.sh/@gradio/client";
2
 
3
- const NARRATORS = [
4
- { id: "brother-anselm", name: "The Archivist", desc: "OmniVoice preset · warm baritone · measured", initial: "A" },
5
- { id: "dame-eglantine", name: "Lady Meridian", desc: "OmniVoice preset · bright contralto · lyrical", initial: "M" },
6
- { id: "the-reeve", name: "North Bell", desc: "OmniVoice preset · gravelled bass · wry", initial: "N" },
7
- { id: "sister-cecilia", name: "Cecilia of the Lamp", desc: "OmniVoice preset · clear soprano · gentle", initial: "C" },
8
- ];
 
 
9
 
10
  const VOICE_WARNING =
11
  "Use clone mode only with audio you have the right to use. Unauthorized voice cloning, impersonation, fraud, or scams are prohibited.";
@@ -18,11 +20,17 @@ const state = {
18
  chapters: [],
19
  currentChapterId: null,
20
  voiceMode: "auto",
21
- narratorId: "brother-anselm",
22
  cloneConsent: false,
23
  cloneSampleFile: null,
24
  cloneReferenceText: "",
25
- designPrompt: "",
 
 
 
 
 
 
26
  diffusionSteps: 32,
27
  speed: 1,
28
  statusMessage: "",
@@ -38,7 +46,7 @@ const state = {
38
  exportMetadata: {
39
  title: "",
40
  author: "",
41
- narrator: "Brother Anselm (OmniVoice)",
42
  genre: "Audiobook",
43
  },
44
  };
@@ -106,6 +114,11 @@ function handleChange(event) {
106
  if (target.matches("#embed-markers")) {
107
  state.embedMarkers = target.checked;
108
  render();
 
 
 
 
 
109
  }
110
  }
111
 
@@ -125,10 +138,6 @@ function handleInput(event) {
125
  state.cloneReferenceText = target.value;
126
  return;
127
  }
128
- if (target.matches("#design-prompt")) {
129
- state.designPrompt = target.value;
130
- return;
131
- }
132
  if (target.matches("#clone-consent")) {
133
  state.cloneConsent = target.checked;
134
  render();
@@ -378,7 +387,7 @@ function buildVoiceConfig() {
378
  if (state.voiceMode === "design") {
379
  return {
380
  mode: "design",
381
- designPrompt: state.designPrompt,
382
  };
383
  }
384
  return {
@@ -868,11 +877,29 @@ function renderVoiceMode() {
868
  `;
869
  }
870
  if (state.voiceMode === "design") {
 
871
  return `
872
- <label style="display:block;">
873
- <div class="smallcaps" style="margin-bottom:7px;">Describe the voice you want</div>
874
- <textarea id="design-prompt" class="textarea" placeholder="A warm, unhurried narrator — low pitch, faintly British, the kind of voice that belongs by a fireplace…">${escapeHtml(state.designPrompt)}</textarea>
875
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876
  `;
877
  }
878
  return `
@@ -939,10 +966,22 @@ function canStartRender() {
939
  if (!state.book) return false;
940
  if (!selectedChapterIds().length) return false;
941
  if (state.voiceMode === "clone" && (!state.cloneSampleFile || !state.cloneConsent)) return false;
942
- if (state.voiceMode === "design" && !state.designPrompt.trim()) return false;
943
  return true;
944
  }
945
 
 
 
 
 
 
 
 
 
 
 
 
 
946
  function activeNarratorName() {
947
  const narrator = NARRATORS.find((item) => item.id === state.narratorId);
948
  return narrator?.name || "Custom narrator";
 
1
  import { Client, handle_file } from "https://esm.sh/@gradio/client";
2
 
3
+ const NARRATORS = globalThis.__VOICE_PRESETS__ || [];
4
+ const VOICE_DESIGN_OPTIONS = globalThis.__VOICE_DESIGN_OPTIONS__ || [];
5
+ const DEFAULT_NARRATOR = NARRATORS[0] || {
6
+ id: "british-male",
7
+ name: "British Male",
8
+ desc: "Voice design preset · male · low pitch · british accent",
9
+ initial: "B",
10
+ };
11
 
12
  const VOICE_WARNING =
13
  "Use clone mode only with audio you have the right to use. Unauthorized voice cloning, impersonation, fraud, or scams are prohibited.";
 
20
  chapters: [],
21
  currentChapterId: null,
22
  voiceMode: "auto",
23
+ narratorId: DEFAULT_NARRATOR.id,
24
  cloneConsent: false,
25
  cloneSampleFile: null,
26
  cloneReferenceText: "",
27
+ designSelections: {
28
+ gender: "",
29
+ age: "",
30
+ pitch: "",
31
+ style: "",
32
+ accent: "",
33
+ },
34
  diffusionSteps: 32,
35
  speed: 1,
36
  statusMessage: "",
 
46
  exportMetadata: {
47
  title: "",
48
  author: "",
49
+ narrator: `${DEFAULT_NARRATOR.name} (OmniVoice)`,
50
  genre: "Audiobook",
51
  },
52
  };
 
114
  if (target.matches("#embed-markers")) {
115
  state.embedMarkers = target.checked;
116
  render();
117
+ return;
118
+ }
119
+ if (target.matches("[data-design-field]")) {
120
+ state.designSelections[target.dataset.designField] = target.value;
121
+ render();
122
  }
123
  }
124
 
 
138
  state.cloneReferenceText = target.value;
139
  return;
140
  }
 
 
 
 
141
  if (target.matches("#clone-consent")) {
142
  state.cloneConsent = target.checked;
143
  render();
 
387
  if (state.voiceMode === "design") {
388
  return {
389
  mode: "design",
390
+ designPrompt: buildDesignPrompt(),
391
  };
392
  }
393
  return {
 
877
  `;
878
  }
879
  if (state.voiceMode === "design") {
880
+ const prompt = buildDesignPrompt();
881
  return `
882
+ <div class="smallcaps" style="margin-bottom:10px;">Build a supported OmniVoice design prompt</div>
883
+ <div class="design-grid">
884
+ ${VOICE_DESIGN_OPTIONS.map((category) => `
885
+ <label style="display:block;">
886
+ <div class="smallcaps" style="margin-bottom:7px;">${escapeHtml(category.label)}</div>
887
+ <select class="field" data-design-field="${escapeAttr(category.key)}">
888
+ <option value="">None</option>
889
+ ${category.options.map((option) => `
890
+ <option value="${escapeAttr(option.value)}" ${state.designSelections[category.key] === option.value ? "selected" : ""}>
891
+ ${escapeHtml(option.label)}
892
+ </option>
893
+ `).join("")}
894
+ </select>
895
+ </label>
896
+ `).join("")}
897
+ </div>
898
+ <div class="design-preview">
899
+ <div class="smallcaps" style="margin-bottom:7px;">Resulting prompt</div>
900
+ <div class="field design-output">${escapeHtml(prompt || "Choose one or more supported voice traits.")}</div>
901
+ <div class="chapter-meta" style="margin-top:8px;">Use English-only OmniVoice design tokens. Accent affects English speech only.</div>
902
+ </div>
903
  `;
904
  }
905
  return `
 
966
  if (!state.book) return false;
967
  if (!selectedChapterIds().length) return false;
968
  if (state.voiceMode === "clone" && (!state.cloneSampleFile || !state.cloneConsent)) return false;
969
+ if (state.voiceMode === "design" && !buildDesignPrompt()) return false;
970
  return true;
971
  }
972
 
973
+ function buildDesignPrompt() {
974
+ return [
975
+ state.designSelections.gender,
976
+ state.designSelections.age,
977
+ state.designSelections.pitch,
978
+ state.designSelections.style,
979
+ state.designSelections.accent,
980
+ ]
981
+ .filter(Boolean)
982
+ .join(", ");
983
+ }
984
+
985
  function activeNarratorName() {
986
  const narrator = NARRATORS.find((item) => item.id === state.narratorId);
987
  return narrator?.name || "Custom narrator";
frontend/styles.css CHANGED
@@ -645,6 +645,22 @@ select {
645
  resize: vertical;
646
  }
647
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  .dial-grid {
649
  display: grid;
650
  grid-template-columns: repeat(2, minmax(0, 1fr));
 
645
  resize: vertical;
646
  }
647
 
648
+ .design-grid {
649
+ display: grid;
650
+ grid-template-columns: repeat(2, minmax(0, 1fr));
651
+ gap: 14px;
652
+ }
653
+
654
+ .design-preview {
655
+ margin-top: 16px;
656
+ }
657
+
658
+ .design-output {
659
+ min-height: 48px;
660
+ display: flex;
661
+ align-items: center;
662
+ }
663
+
664
  .dial-grid {
665
  display: grid;
666
  grid-template-columns: repeat(2, minmax(0, 1fr));
tests/test_voice_presets.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import importlib
3
+ import sys
4
+
5
+ from omnivoice.utils.voice_design import _INSTRUCT_VALID_EN
6
+
7
+ from backend.voice_design import VOICE_DESIGN_OPTIONS
8
+ from backend.voice_presets import VOICE_PRESETS
9
+
10
+
11
+ def test_voice_presets_use_only_supported_omnivoice_design_tokens() -> None:
12
+ for preset in VOICE_PRESETS:
13
+ instruct = preset["instruct"]
14
+ parts = [part.strip().lower() for part in instruct.split(",")]
15
+ assert all(parts), f"Preset {preset['id']} contains an empty instruct token"
16
+ assert all(
17
+ part in _INSTRUCT_VALID_EN for part in parts
18
+ ), f"Preset {preset['id']} has unsupported instruct tokens: {instruct}"
19
+
20
+
21
+ def test_homepage_injects_voice_presets_for_frontend() -> None:
22
+ sys.modules.pop("app", None)
23
+ module = importlib.import_module("app")
24
+
25
+ response = asyncio.run(module.homepage())
26
+ html = response.body.decode("utf-8")
27
+
28
+ assert "window.__VOICE_PRESETS__" in html
29
+ assert "window.__VOICE_DESIGN_OPTIONS__" in html
30
+
31
+
32
+ def test_voice_design_options_use_only_supported_omnivoice_design_tokens() -> None:
33
+ option_values = [
34
+ option["value"]
35
+ for category in VOICE_DESIGN_OPTIONS
36
+ for option in category["options"]
37
+ ]
38
+ assert option_values
39
+ assert all(value in _INSTRUCT_VALID_EN for value in option_values)