Fix build error
Browse files- app.py +11 -1
- backend/omnivoice_adapter.py +7 -11
- backend/voice_design.py +55 -0
- backend/voice_presets.py +32 -0
- frontend/app.js +58 -19
- frontend/styles.css +16 -0
- tests/test_voice_presets.py +39 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 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:
|
| 22 |
cloneConsent: false,
|
| 23 |
cloneSampleFile: null,
|
| 24 |
cloneReferenceText: "",
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
diffusionSteps: 32,
|
| 27 |
speed: 1,
|
| 28 |
statusMessage: "",
|
|
@@ -38,7 +46,7 @@ const state = {
|
|
| 38 |
exportMetadata: {
|
| 39 |
title: "",
|
| 40 |
author: "",
|
| 41 |
-
narrator:
|
| 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:
|
| 382 |
};
|
| 383 |
}
|
| 384 |
return {
|
|
@@ -868,11 +877,29 @@ function renderVoiceMode() {
|
|
| 868 |
`;
|
| 869 |
}
|
| 870 |
if (state.voiceMode === "design") {
|
|
|
|
| 871 |
return `
|
| 872 |
-
<
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" && !
|
| 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)
|