gavanduffy commited on
Commit ·
f6723e7
1
Parent(s): e87a9a5
Add custom voice presets (F6, F7, M6) from JSON style embeddings
Browse files- app/config.py +9 -0
- app/services/tts.py +63 -1
- voice_presets/F6.json +0 -0
- voice_presets/F7.json +0 -0
- voice_presets/M6.json +0 -0
app/config.py
CHANGED
|
@@ -41,6 +41,12 @@ class Config:
|
|
| 41 |
# Voice directory for custom voice samples
|
| 42 |
VOICES_DIR = os.environ.get('SUPERTONIC3_VOICES_DIR', None)
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
DEFAULT_VOICE = os.environ.get('SUPERTONIC3_DEFAULT_VOICE', 'M1')
|
| 45 |
|
| 46 |
# Built-in voice names (M1-M5 = male, F1-F5 = female)
|
|
@@ -49,6 +55,9 @@ class Config:
|
|
| 49 |
# Supported extensions for custom voice samples
|
| 50 |
VOICE_EXTENSIONS = ('.wav', '.mp3', '.flac')
|
| 51 |
|
|
|
|
|
|
|
|
|
|
| 52 |
# Streaming default
|
| 53 |
STREAM_DEFAULT = os.environ.get('SUPERTONIC3_STREAM_DEFAULT', 'false').lower() == 'true'
|
| 54 |
|
|
|
|
| 41 |
# Voice directory for custom voice samples
|
| 42 |
VOICES_DIR = os.environ.get('SUPERTONIC3_VOICES_DIR', None)
|
| 43 |
|
| 44 |
+
# Voice presets directory (JSON style embeddings, tracked in git)
|
| 45 |
+
VOICE_PRESETS_DIR = os.environ.get(
|
| 46 |
+
'SUPERTONIC3_VOICE_PRESETS_DIR',
|
| 47 |
+
str(BASE_PATH / 'voice_presets'),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
DEFAULT_VOICE = os.environ.get('SUPERTONIC3_DEFAULT_VOICE', 'M1')
|
| 51 |
|
| 52 |
# Built-in voice names (M1-M5 = male, F1-F5 = female)
|
|
|
|
| 55 |
# Supported extensions for custom voice samples
|
| 56 |
VOICE_EXTENSIONS = ('.wav', '.mp3', '.flac')
|
| 57 |
|
| 58 |
+
# Supported extensions for voice preset files (JSON style embeddings)
|
| 59 |
+
VOICE_PRESET_EXTENSIONS = ('.json',)
|
| 60 |
+
|
| 61 |
# Streaming default
|
| 62 |
STREAM_DEFAULT = os.environ.get('SUPERTONIC3_STREAM_DEFAULT', 'false').lower() == 'true'
|
| 63 |
|
app/services/tts.py
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
|
|
| 1 |
import os
|
| 2 |
import re
|
| 3 |
import time
|
| 4 |
from pathlib import Path
|
| 5 |
|
|
|
|
|
|
|
| 6 |
from app.config import Config
|
| 7 |
from app.logging_config import get_logger
|
| 8 |
|
| 9 |
logger = get_logger('tts')
|
| 10 |
|
| 11 |
TTS = None
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def _ensure_supertonic():
|
| 15 |
-
global TTS
|
| 16 |
if TTS is None:
|
| 17 |
try:
|
| 18 |
from supertonic import TTS as _TTS
|
|
|
|
| 19 |
TTS = _TTS
|
|
|
|
| 20 |
except ImportError as exc:
|
| 21 |
raise ImportError('supertonic not found. Install with: pip install supertonic') from exc
|
| 22 |
|
|
@@ -26,6 +32,9 @@ class TTSService:
|
|
| 26 |
self._tts = None
|
| 27 |
self._model_loaded = False
|
| 28 |
self._voices_dir: str | None = None
|
|
|
|
|
|
|
|
|
|
| 29 |
# Pre-parse text into segments for simulated streaming
|
| 30 |
self._sentence_splitter = re.compile(r'(?<=[.!?])\s+')
|
| 31 |
|
|
@@ -56,6 +65,8 @@ class TTSService:
|
|
| 56 |
logger.error(f'Failed to load SuperTonic3 model: {e}')
|
| 57 |
raise
|
| 58 |
|
|
|
|
|
|
|
| 59 |
def set_voices_dir(self, voices_dir: str | None) -> None:
|
| 60 |
if voices_dir and os.path.isdir(voices_dir):
|
| 61 |
self._voices_dir = voices_dir
|
|
@@ -66,16 +77,61 @@ class TTSService:
|
|
| 66 |
else:
|
| 67 |
self._voices_dir = None
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def get_voice_style(self, voice_id: str):
|
| 70 |
if not self.is_loaded:
|
| 71 |
raise RuntimeError('Model not loaded. Call load_model() first.')
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
voice_name = self._resolve_voice(voice_id)
|
| 74 |
t0 = time.time()
|
| 75 |
style = self._tts.get_voice_style(voice_name=voice_name)
|
| 76 |
logger.debug(f'Voice style loaded in {time.time() - t0:.2f}s: {voice_name}')
|
| 77 |
return style
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
def _resolve_voice(self, voice_id: str) -> str:
|
| 80 |
voice_lower = voice_id.lower()
|
| 81 |
if voice_lower in [v.lower() for v in Config.BUILTIN_VOICES]:
|
|
@@ -104,9 +160,13 @@ class TTSService:
|
|
| 104 |
False,
|
| 105 |
'HTTP/HTTPS URLs are not allowed for security reasons. Use hf:// for HuggingFace models.',
|
| 106 |
)
|
|
|
|
| 107 |
voice_lower = voice_id.lower()
|
| 108 |
if voice_lower in [v.lower() for v in Config.BUILTIN_VOICES]:
|
| 109 |
return True, f'Built-in voice: {voice_id}'
|
|
|
|
|
|
|
|
|
|
| 110 |
if self._voices_dir:
|
| 111 |
p = Path(self._voices_dir) / voice_id
|
| 112 |
if p.exists():
|
|
@@ -156,6 +216,8 @@ class TTSService:
|
|
| 156 |
voices: list[dict] = []
|
| 157 |
for name in Config.BUILTIN_VOICES:
|
| 158 |
voices.append({'id': name, 'name': name, 'type': 'builtin'})
|
|
|
|
|
|
|
| 159 |
if self._voices_dir:
|
| 160 |
for f in sorted(Path(self._voices_dir).iterdir()):
|
| 161 |
if f.suffix.lower() in Config.VOICE_EXTENSIONS:
|
|
|
|
| 1 |
+
import json
|
| 2 |
import os
|
| 3 |
import re
|
| 4 |
import time
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
from app.config import Config
|
| 10 |
from app.logging_config import get_logger
|
| 11 |
|
| 12 |
logger = get_logger('tts')
|
| 13 |
|
| 14 |
TTS = None
|
| 15 |
+
STYLE_CLS = None
|
| 16 |
|
| 17 |
|
| 18 |
def _ensure_supertonic():
|
| 19 |
+
global TTS, STYLE_CLS
|
| 20 |
if TTS is None:
|
| 21 |
try:
|
| 22 |
from supertonic import TTS as _TTS
|
| 23 |
+
from supertonic.core import Style as _Style
|
| 24 |
TTS = _TTS
|
| 25 |
+
STYLE_CLS = _Style
|
| 26 |
except ImportError as exc:
|
| 27 |
raise ImportError('supertonic not found. Install with: pip install supertonic') from exc
|
| 28 |
|
|
|
|
| 32 |
self._tts = None
|
| 33 |
self._model_loaded = False
|
| 34 |
self._voices_dir: str | None = None
|
| 35 |
+
self._voice_presets_dir: str | None = None
|
| 36 |
+
self._preset_voices: dict[str, str] = {}
|
| 37 |
+
self._style_cache: dict[str, object] = {}
|
| 38 |
# Pre-parse text into segments for simulated streaming
|
| 39 |
self._sentence_splitter = re.compile(r'(?<=[.!?])\s+')
|
| 40 |
|
|
|
|
| 65 |
logger.error(f'Failed to load SuperTonic3 model: {e}')
|
| 66 |
raise
|
| 67 |
|
| 68 |
+
self.discover_presets()
|
| 69 |
+
|
| 70 |
def set_voices_dir(self, voices_dir: str | None) -> None:
|
| 71 |
if voices_dir and os.path.isdir(voices_dir):
|
| 72 |
self._voices_dir = voices_dir
|
|
|
|
| 77 |
else:
|
| 78 |
self._voices_dir = None
|
| 79 |
|
| 80 |
+
def discover_presets(self) -> None:
|
| 81 |
+
self._preset_voices = {}
|
| 82 |
+
presets_dir = Config.VOICE_PRESETS_DIR
|
| 83 |
+
if not os.path.isdir(presets_dir):
|
| 84 |
+
logger.info(f'Voice presets directory not found: {presets_dir}')
|
| 85 |
+
return
|
| 86 |
+
self._voice_presets_dir = presets_dir
|
| 87 |
+
for f in sorted(Path(presets_dir).iterdir()):
|
| 88 |
+
if f.suffix.lower() in Config.VOICE_PRESET_EXTENSIONS and f.stem.isidentifier():
|
| 89 |
+
self._preset_voices[f.stem] = str(f)
|
| 90 |
+
if self._preset_voices:
|
| 91 |
+
logger.info(
|
| 92 |
+
f'Discovered {len(self._preset_voices)} voice presets: '
|
| 93 |
+
f'{", ".join(self._preset_voices.keys())}'
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
def get_voice_style(self, voice_id: str):
|
| 97 |
if not self.is_loaded:
|
| 98 |
raise RuntimeError('Model not loaded. Call load_model() first.')
|
| 99 |
|
| 100 |
+
# Check if it's a cached preset voice
|
| 101 |
+
if voice_id in self._style_cache:
|
| 102 |
+
return self._style_cache[voice_id]
|
| 103 |
+
|
| 104 |
+
# Check if it's a known preset voice (load from JSON)
|
| 105 |
+
if voice_id in self._preset_voices:
|
| 106 |
+
return self._load_preset_style(voice_id)
|
| 107 |
+
|
| 108 |
voice_name = self._resolve_voice(voice_id)
|
| 109 |
t0 = time.time()
|
| 110 |
style = self._tts.get_voice_style(voice_name=voice_name)
|
| 111 |
logger.debug(f'Voice style loaded in {time.time() - t0:.2f}s: {voice_name}')
|
| 112 |
return style
|
| 113 |
|
| 114 |
+
def _load_preset_style(self, voice_id: str):
|
| 115 |
+
filepath = self._preset_voices.get(voice_id)
|
| 116 |
+
if not filepath:
|
| 117 |
+
raise ValueError(f'Preset voice not found: {voice_id}')
|
| 118 |
+
|
| 119 |
+
t0 = time.time()
|
| 120 |
+
with open(filepath) as f:
|
| 121 |
+
data = json.load(f)
|
| 122 |
+
|
| 123 |
+
style_ttl = np.array(data['style_ttl']['data'], dtype=data['style_ttl']['type']).reshape(
|
| 124 |
+
data['style_ttl']['dims']
|
| 125 |
+
)
|
| 126 |
+
style_dp = np.array(data['style_dp']['data'], dtype=data['style_dp']['type']).reshape(
|
| 127 |
+
data['style_dp']['dims']
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
style = STYLE_CLS(style_ttl_onnx=style_ttl, style_dp_onnx=style_dp)
|
| 131 |
+
self._style_cache[voice_id] = style
|
| 132 |
+
logger.info(f'Loaded preset voice {voice_id} in {time.time() - t0:.2f}s')
|
| 133 |
+
return style
|
| 134 |
+
|
| 135 |
def _resolve_voice(self, voice_id: str) -> str:
|
| 136 |
voice_lower = voice_id.lower()
|
| 137 |
if voice_lower in [v.lower() for v in Config.BUILTIN_VOICES]:
|
|
|
|
| 160 |
False,
|
| 161 |
'HTTP/HTTPS URLs are not allowed for security reasons. Use hf:// for HuggingFace models.',
|
| 162 |
)
|
| 163 |
+
# Check built-in voices
|
| 164 |
voice_lower = voice_id.lower()
|
| 165 |
if voice_lower in [v.lower() for v in Config.BUILTIN_VOICES]:
|
| 166 |
return True, f'Built-in voice: {voice_id}'
|
| 167 |
+
# Check preset voices (JSON style embeddings)
|
| 168 |
+
if voice_id in self._preset_voices:
|
| 169 |
+
return True, f'Preset voice: {voice_id}'
|
| 170 |
if self._voices_dir:
|
| 171 |
p = Path(self._voices_dir) / voice_id
|
| 172 |
if p.exists():
|
|
|
|
| 216 |
voices: list[dict] = []
|
| 217 |
for name in Config.BUILTIN_VOICES:
|
| 218 |
voices.append({'id': name, 'name': name, 'type': 'builtin'})
|
| 219 |
+
for name in sorted(self._preset_voices):
|
| 220 |
+
voices.append({'id': name, 'name': name, 'type': 'preset'})
|
| 221 |
if self._voices_dir:
|
| 222 |
for f in sorted(Path(self._voices_dir).iterdir()):
|
| 223 |
if f.suffix.lower() in Config.VOICE_EXTENSIONS:
|
voice_presets/F6.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
voice_presets/F7.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
voice_presets/M6.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|