Runtime model switching, per-model voice caching, thread-safety fix (#12)
Browse files* test: add pytest harness for TTS server tests
* feat(config): add supported languages, aliases, cache dir
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(voice-cache): add per-model voice path resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(voice-cache): add stem listing and mtime staleness
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): track active model state with boot snapshot
Add _lock, _loading, _active, _boot_active to TTSService.__init__,
rewrite load_model to record active model state and snapshot boot config.
Change voice_cache type from dict to OrderedDict.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): wire per-model cache dir and voice resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): add reload_model with rollback-on-failure
Add TTSService.reload_model(language, quantize) with mutual-exclusion
guard (_loading flag), boot-source check, SUPPORTED_LANGUAGES validation,
and exception-safe rollback of model + _active on failure. Five new
TDD tests cover the happy path, all rejection cases, and rollback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): gen lock, _loading short-circuit, tagged cache save+stale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): collapse voice list by stem
Replace per-file iteration in list_voices with list_voice_stems so
each unique voice name appears exactly once regardless of how many
file variants (wav, safetensors, tagged cache) exist on disk.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add cached version helper for UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(routes): GET /v1/model returns state + versions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(routes): POST /v1/model kicks off async reload
Add POST /v1/model endpoint that validates language, guards against
model_path lock / in-flight reload, and delegates to a new
reload_model_async helper (daemon thread, fire-and-forget).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(routes): 503 while loading + voice_model_mismatch error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(routes): /health includes active_model; pass versions to home
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(server): validate --language against supported list
* feat(ui): header version strip + GitHub link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ui): model settings panel markup + styles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ui): model settings state machine + polling
Appends the Model Settings panel JS to static/js/app.js: modelUI
element references, updateUIForState, fetchModelState, startPolling,
applyModel, and event-listener wiring for the language select,
quantize toggle, and Apply button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(docker): add pockettts-voice-cache named volume
* feat: pre-create voice cache dir at server boot
* style: ruff check --fix + ruff format across suite
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version to 2.5.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): version fallback, hidden indicators, btn-secondary, panel padding
- Hardcode app.__version__ as fallback so dev runs (no pip install -e)
no longer show 'Server vunknown'.
- Override [hidden] on flex elements so the loading spinner actually
disappears when not loading.
- Add .btn-secondary styles for the Apply button.
- Restore top padding inside the model settings body so the Language
label has breathing room.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): bring select chevron next to content
The native browser chevron pins to the right edge of the select box,
leaving a wide gap on full-width selects. Replace with a custom inline
SVG chevron positioned 14px from the right edge with 36px padding, so
the dropdown indicator visually anchors near the content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): override inline background to make custom chevron visible
#format-select's inline `background: var(--input-bg)` was resetting
background-image to none, hiding the custom chevron. Add !important to
the background- rules so the chevron survives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): more breathing room around Apply button and info banners
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: ignore docs/superpowers/ (local design specs / plans)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(voice-cache): resolve files tagged with the language alias
Files written as <voice>.<alias>.safetensors (e.g. emma.english.safetensors)
were never resolved because we only checked the canonical tag
(emma.english_2026-04.safetensors). Add an alias-tagged fallback after the
canonical lookup in both cache_dir and voices_dir so externally-written or
older caches still resolve. Canonical remains the preferred name; new caches
continue to be written with the canonical tag.
Reported by GitHub Copilot review on PR #12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tts): treat existing relative paths as local for cache + staleness
get_voice_state only set resolved_path when os.path.isabs() was true. For
local dev runs where voices_dir/cache_dir is configured as a relative path
(e.g. ./voices), resolve_voice_path returns a relative string — silently
skipping truncate=True, the disk cache save, and stale-cache regeneration.
Treat any existing filesystem path as local, while still preserving the
passthrough for built-in names and hf:// URLs.
Reported by GitHub Copilot review on PR #12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): normalize active language and clear stale error banner
Three related UI bugs surfaced by GitHub Copilot review on PR #12:
1. When the server boots without --language, /v1/model returns
active.value=null. The dropdown wasn't being set to match, and
updateApplyButton compared targetLang !== null, so Apply was enabled
on every fresh load even with no real change pending.
2. Same null comparison made unnecessary reloads possible if the user
clicked Apply with the default selection.
3. The error banner was set when state.last_error was truthy but never
cleared when a subsequent successful reload set it back to null,
leaving stale errors visible indefinitely.
Add effectiveActiveValue() that maps null → 'english' (the actual
default per pocket-tts), use it for the label, dropdown, and diff
comparison. Add the missing else branch on the error banner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tts): hold lock around voice state calls; restore filename-with-ext support
Two bugs reported by GitHub Copilot review on PR #12:
1. get_voice_state called model.get_state_for_audio_prompt without
acquiring self._lock, racing with concurrent generation/reload —
pocket-tts v2 TTSModel is not thread-safe. Wrap the model call and
the LRU cache mutation under the lock and short-circuit with the
same _loading 503-style RuntimeError as generate_audio.
2. _resolve_voice_path no longer accepted full filenames in voices_dir
like 'emma.wav'. The new stem-based resolve_voice_path would join
it with another extension ('emma.wav.wav') and fail. Added an early
exact-match check in cache_dir / voices_dir before delegating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(tts): cover filename-with-extension resolution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(routes): validate quantize is a JSON boolean
bool('false') is True, which silently enabled quantization for any
client passing a string. Reject non-bool quantize with 400 instead.
Reported by GitHub Copilot review on PR #12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): use normalized active value when reverting after failed apply
The error path was reverting the dropdown using
currentModelState.active.value, which can be null for the default
model — the same case effectiveActiveValue() normalizes to 'english'
in the rest of the UI. Use it here too so the select never lands on
an empty value.
Reported by GitHub Copilot review on PR #12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tts): atomic reload claim, race-safe cache hit, dotted-stem parsing
Three bugs found in proper code review of the runtime model switching PR:
1. Reload claim was racy. `if self._loading: raise` followed by
`self._loading = True` could be passed by two threads simultaneously
when concurrent POSTs hit /v1/model. The losing reload would silently
start after the winner completed. Split into atomic `_claim_loading`
helper guarded by a new `_state_lock` (separate from `_lock` so the
claim doesn't wait on long-running generation), and refactor
`reload_model_async` to return False on a lost race so the route can
map directly to 409.
2. The `voice_cache` cache-hit fast path could KeyError if a concurrent
`reload_model` cleared the dict between the `in` check and the
`move_to_end` / read. Catch and fall through to re-encode.
3. The staleness regeneration extracted the voice stem with
`resolved_path.stem.split('.', 1)[0]`, which mangles names containing
dots ('John.Doe.<tag>.safetensors' → 'John' instead of 'John.Doe').
Use `parse_safetensors_name` with `known_model_tags()` so stem and
tag are extracted by the same logic the rest of the cache uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(routes): validate JSON body shape and harden mismatch detection
1. `data = request.json or {}` would let JSON values like `42` or
- .env.example +5 -0
- .gitignore +5 -0
- Dockerfile +3 -0
- app/__init__.py +7 -0
- app/config.py +29 -0
- app/routes.py +119 -4
- app/services/tts.py +281 -115
- app/services/versions.py +26 -0
- app/services/voice_cache.py +122 -0
- docker-compose.yml +5 -0
- pyproject.toml +1 -1
- pytest.ini +4 -0
- server.py +8 -0
- static/css/style.css +180 -0
- static/js/app.js +193 -0
- templates/index.html +61 -0
- tests/__init__.py +0 -0
- tests/conftest.py +41 -0
- tests/test_config.py +44 -0
- tests/test_routes.py +196 -0
- tests/test_tts_service.py +375 -0
- tests/test_versions.py +25 -0
- tests/test_voice_cache.py +212 -0
|
@@ -20,3 +20,8 @@ POCKET_TTS_STREAM_DEFAULT=true
|
|
| 20 |
# Hugging Face token for voice cloning (optional)
|
| 21 |
# Get your token from: https://huggingface.co/settings/tokens
|
| 22 |
# HF_TOKEN=hf_xxxxxxxxxxxxx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
# Hugging Face token for voice cloning (optional)
|
| 21 |
# Get your token from: https://huggingface.co/settings/tokens
|
| 22 |
# HF_TOKEN=hf_xxxxxxxxxxxxx
|
| 23 |
+
|
| 24 |
+
# Writable cache for per-model cloned voice safetensors
|
| 25 |
+
# Defaults to <app_base>/voice_cache. In Docker this is /app/voice_cache backed
|
| 26 |
+
# by the `pockettts-voice-cache` named volume.
|
| 27 |
+
# POCKET_TTS_VOICE_CACHE_DIR=/path/to/cache
|
|
@@ -185,3 +185,8 @@ cython_debug/
|
|
| 185 |
.cursorindexingignore
|
| 186 |
|
| 187 |
.DS_Store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
.cursorindexingignore
|
| 186 |
|
| 187 |
.DS_Store
|
| 188 |
+
|
| 189 |
+
voice_cache/
|
| 190 |
+
|
| 191 |
+
# Internal design specs and implementation plans (kept locally, not in git)
|
| 192 |
+
docs/superpowers/
|
|
@@ -51,6 +51,9 @@ RUN chown pockettts:pockettts /app && mkdir -p /app/logs && chown pockettts:pock
|
|
| 51 |
RUN mkdir -p /home/pockettts/.cache/huggingface && \
|
| 52 |
chown -R pockettts:pockettts /home/pockettts/.cache
|
| 53 |
|
|
|
|
|
|
|
|
|
|
| 54 |
# Switch to non-root user
|
| 55 |
USER pockettts
|
| 56 |
|
|
|
|
| 51 |
RUN mkdir -p /home/pockettts/.cache/huggingface && \
|
| 52 |
chown -R pockettts:pockettts /home/pockettts/.cache
|
| 53 |
|
| 54 |
+
# Create voice cache directory with correct ownership for the named volume
|
| 55 |
+
RUN mkdir -p /app/voice_cache && chown pockettts:pockettts /app/voice_cache
|
| 56 |
+
|
| 57 |
# Switch to non-root user
|
| 58 |
USER pockettts
|
| 59 |
|
|
@@ -4,6 +4,10 @@ PocketTTS OpenAI-Compatible Server
|
|
| 4 |
Flask application factory and initialization.
|
| 5 |
"""
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from flask import Flask
|
| 8 |
|
| 9 |
from app.config import Config
|
|
@@ -71,6 +75,9 @@ def init_tts_service(
|
|
| 71 |
# Load model
|
| 72 |
tts.load_model(model_path=model_path, language=language, quantize=quantize)
|
| 73 |
|
|
|
|
|
|
|
|
|
|
| 74 |
# Set voices directory
|
| 75 |
if voices_dir:
|
| 76 |
tts.set_voices_dir(voices_dir)
|
|
|
|
| 4 |
Flask application factory and initialization.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
# Keep in sync with pyproject.toml — used as the version fallback when the
|
| 8 |
+
# package isn't installed via pip (e.g. running directly from a clone).
|
| 9 |
+
__version__ = '2.5.3'
|
| 10 |
+
|
| 11 |
from flask import Flask
|
| 12 |
|
| 13 |
from app.config import Config
|
|
|
|
| 75 |
# Load model
|
| 76 |
tts.load_model(model_path=model_path, language=language, quantize=quantize)
|
| 77 |
|
| 78 |
+
# Pre-create the voice cache directory (or log warning if not writable)
|
| 79 |
+
tts._ensure_cache_dir()
|
| 80 |
+
|
| 81 |
# Set voices directory
|
| 82 |
if voices_dir:
|
| 83 |
tts.set_voices_dir(voices_dir)
|
|
@@ -35,6 +35,35 @@ class Config:
|
|
| 35 |
MODEL_PATH = os.environ.get('POCKET_TTS_MODEL_PATH', None)
|
| 36 |
LANGUAGE = os.environ.get('POCKET_TTS_LANGUAGE', None)
|
| 37 |
QUANTIZE = os.environ.get('POCKET_TTS_QUANTIZE', 'false').lower() == 'true'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
DEFAULT_VOICE = os.environ.get(
|
| 39 |
'POCKET_TTS_DEFAULT_VOICE', 'hf://kyutai/tts-voices/alba-mackenna/casual.wav'
|
| 40 |
)
|
|
|
|
| 35 |
MODEL_PATH = os.environ.get('POCKET_TTS_MODEL_PATH', None)
|
| 36 |
LANGUAGE = os.environ.get('POCKET_TTS_LANGUAGE', None)
|
| 37 |
QUANTIZE = os.environ.get('POCKET_TTS_QUANTIZE', 'false').lower() == 'true'
|
| 38 |
+
|
| 39 |
+
# Supported languages (pocket-tts v2.0.0 predefined model YAMLs)
|
| 40 |
+
SUPPORTED_LANGUAGES = [
|
| 41 |
+
'english', # alias for english_2026-04 (default)
|
| 42 |
+
'english_2026-01',
|
| 43 |
+
'english_2026-04',
|
| 44 |
+
'french_24l', # no bare `french` — upstream raises
|
| 45 |
+
'german',
|
| 46 |
+
'german_24l',
|
| 47 |
+
'italian',
|
| 48 |
+
'italian_24l',
|
| 49 |
+
'portuguese',
|
| 50 |
+
'portuguese_24l',
|
| 51 |
+
'spanish',
|
| 52 |
+
'spanish_24l',
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
# Canonicalize equivalent model IDs so tagged caches dedupe.
|
| 56 |
+
LEGACY_MODEL_ALIASES = {
|
| 57 |
+
'english': 'english_2026-04',
|
| 58 |
+
'english_2026-01': 'english_2026-04',
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# Writable voice cache dir for tagged .safetensors clones.
|
| 62 |
+
VOICE_CACHE_DIR = os.environ.get(
|
| 63 |
+
'POCKET_TTS_VOICE_CACHE_DIR',
|
| 64 |
+
str(BASE_PATH / 'voice_cache'),
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
DEFAULT_VOICE = os.environ.get(
|
| 68 |
'POCKET_TTS_DEFAULT_VOICE', 'hf://kyutai/tts-voices/alba-mackenna/casual.wav'
|
| 69 |
)
|
|
@@ -14,6 +14,7 @@ from flask import (
|
|
| 14 |
stream_with_context,
|
| 15 |
)
|
| 16 |
|
|
|
|
| 17 |
from app.logging_config import get_logger
|
| 18 |
from app.services.audio import (
|
| 19 |
convert_audio,
|
|
@@ -24,6 +25,7 @@ from app.services.audio import (
|
|
| 24 |
)
|
| 25 |
from app.services.preprocess import TextPreprocessor
|
| 26 |
from app.services.tts import get_tts_service
|
|
|
|
| 27 |
|
| 28 |
logger = get_logger('routes')
|
| 29 |
|
|
@@ -48,7 +50,11 @@ def home():
|
|
| 48 |
"""Serve the web interface."""
|
| 49 |
from app.config import Config
|
| 50 |
|
| 51 |
-
return render_template(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
@api.route('/health', methods=['GET'])
|
|
@@ -71,6 +77,7 @@ def health():
|
|
| 71 |
'sample_rate': tts.sample_rate if tts.is_loaded else None,
|
| 72 |
'voices_dir': tts.voices_dir,
|
| 73 |
'voice_check': {'valid': voice_valid, 'message': voice_msg},
|
|
|
|
| 74 |
}
|
| 75 |
), 200 if tts.is_loaded else 503
|
| 76 |
|
|
@@ -101,6 +108,84 @@ def list_voices():
|
|
| 101 |
)
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
@api.route('/v1/audio/speech', methods=['POST'])
|
| 105 |
def generate_speech():
|
| 106 |
"""
|
|
@@ -120,8 +205,8 @@ def generate_speech():
|
|
| 120 |
|
| 121 |
data = request.json
|
| 122 |
|
| 123 |
-
if not data:
|
| 124 |
-
return jsonify({'error': '
|
| 125 |
|
| 126 |
text = data.get('input')
|
| 127 |
if not text:
|
|
@@ -135,6 +220,9 @@ def generate_speech():
|
|
| 135 |
|
| 136 |
tts = get_tts_service()
|
| 137 |
|
|
|
|
|
|
|
|
|
|
| 138 |
# Validate voice first
|
| 139 |
is_valid, msg = tts.validate_voice(voice)
|
| 140 |
if not is_valid:
|
|
@@ -172,8 +260,35 @@ def generate_speech():
|
|
| 172 |
return _generate_file(tts, voice_state, text, target_format)
|
| 173 |
|
| 174 |
except ValueError as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
logger.warning(f'Voice loading failed: {e}')
|
| 176 |
-
return jsonify({'error':
|
| 177 |
except Exception as e:
|
| 178 |
logger.exception('Generation failed')
|
| 179 |
return jsonify({'error': str(e)}), 500
|
|
|
|
| 14 |
stream_with_context,
|
| 15 |
)
|
| 16 |
|
| 17 |
+
from app.config import Config
|
| 18 |
from app.logging_config import get_logger
|
| 19 |
from app.services.audio import (
|
| 20 |
convert_audio,
|
|
|
|
| 25 |
)
|
| 26 |
from app.services.preprocess import TextPreprocessor
|
| 27 |
from app.services.tts import get_tts_service
|
| 28 |
+
from app.services.versions import get_versions
|
| 29 |
|
| 30 |
logger = get_logger('routes')
|
| 31 |
|
|
|
|
| 50 |
"""Serve the web interface."""
|
| 51 |
from app.config import Config
|
| 52 |
|
| 53 |
+
return render_template(
|
| 54 |
+
'index.html',
|
| 55 |
+
is_docker=Config.IS_DOCKER,
|
| 56 |
+
versions=get_versions(),
|
| 57 |
+
)
|
| 58 |
|
| 59 |
|
| 60 |
@api.route('/health', methods=['GET'])
|
|
|
|
| 77 |
'sample_rate': tts.sample_rate if tts.is_loaded else None,
|
| 78 |
'voices_dir': tts.voices_dir,
|
| 79 |
'voice_check': {'valid': voice_valid, 'message': voice_msg},
|
| 80 |
+
'active_model': tts._active,
|
| 81 |
}
|
| 82 |
), 200 if tts.is_loaded else 503
|
| 83 |
|
|
|
|
| 108 |
)
|
| 109 |
|
| 110 |
|
| 111 |
+
@api.route('/v1/model', methods=['GET'])
|
| 112 |
+
def get_model():
|
| 113 |
+
"""Return the active model state, boot snapshot, and supported languages."""
|
| 114 |
+
tts = get_tts_service()
|
| 115 |
+
|
| 116 |
+
active = tts._active or {'source': 'default', 'value': None, 'quantize': False}
|
| 117 |
+
boot = tts._boot_active or active
|
| 118 |
+
differs = active != boot
|
| 119 |
+
model_path_locked = boot.get('source') == 'model_path'
|
| 120 |
+
versions = get_versions()
|
| 121 |
+
|
| 122 |
+
return jsonify(
|
| 123 |
+
{
|
| 124 |
+
'active': active,
|
| 125 |
+
'boot': boot,
|
| 126 |
+
'differs_from_boot': differs,
|
| 127 |
+
'loading': tts._loading,
|
| 128 |
+
'loading_target': getattr(tts, '_loading_target', None),
|
| 129 |
+
'last_error': getattr(tts, '_last_reload_error', None),
|
| 130 |
+
'model_path_locked': model_path_locked,
|
| 131 |
+
'available_languages': list(Config.SUPPORTED_LANGUAGES),
|
| 132 |
+
'server_version': versions['server'],
|
| 133 |
+
'pocket_tts_version': versions['pocket_tts'],
|
| 134 |
+
}
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@api.route('/v1/model', methods=['POST'])
|
| 139 |
+
def post_model():
|
| 140 |
+
"""Request a runtime model switch. Returns 202; UI polls GET for completion."""
|
| 141 |
+
data = request.json
|
| 142 |
+
if not isinstance(data, dict):
|
| 143 |
+
return jsonify({'error': 'Request body must be a JSON object'}), 400
|
| 144 |
+
|
| 145 |
+
language = data.get('language')
|
| 146 |
+
|
| 147 |
+
# Reject non-bool `quantize` rather than coercing — `bool('false')` is True,
|
| 148 |
+
# which would silently enable quantization for any client sending a string.
|
| 149 |
+
quantize = False
|
| 150 |
+
if 'quantize' in data:
|
| 151 |
+
if not isinstance(data['quantize'], bool):
|
| 152 |
+
return jsonify({'error': "Field 'quantize' must be a boolean"}), 400
|
| 153 |
+
quantize = data['quantize']
|
| 154 |
+
|
| 155 |
+
if not language:
|
| 156 |
+
return jsonify({'error': "Missing required field 'language'"}), 400
|
| 157 |
+
|
| 158 |
+
if language not in Config.SUPPORTED_LANGUAGES:
|
| 159 |
+
return jsonify(
|
| 160 |
+
{
|
| 161 |
+
'error': f"Unknown language: '{language}'",
|
| 162 |
+
'available': list(Config.SUPPORTED_LANGUAGES),
|
| 163 |
+
}
|
| 164 |
+
), 400
|
| 165 |
+
|
| 166 |
+
tts = get_tts_service()
|
| 167 |
+
|
| 168 |
+
if tts._boot_active and tts._boot_active.get('source') == 'model_path':
|
| 169 |
+
return jsonify(
|
| 170 |
+
{
|
| 171 |
+
'error': 'Language switching disabled: server started with --model-path.',
|
| 172 |
+
}
|
| 173 |
+
), 403
|
| 174 |
+
|
| 175 |
+
# `reload_model_async` does the atomic check-and-claim, so the 409 race
|
| 176 |
+
# window between `if tts._loading` and `start()` is gone.
|
| 177 |
+
started = tts.reload_model_async(language=language, quantize=quantize)
|
| 178 |
+
if not started:
|
| 179 |
+
return jsonify({'error': 'A model reload is already in progress.'}), 409
|
| 180 |
+
|
| 181 |
+
return jsonify(
|
| 182 |
+
{
|
| 183 |
+
'status': 'accepted',
|
| 184 |
+
'loading_target': {'value': language, 'quantize': quantize},
|
| 185 |
+
}
|
| 186 |
+
), 202
|
| 187 |
+
|
| 188 |
+
|
| 189 |
@api.route('/v1/audio/speech', methods=['POST'])
|
| 190 |
def generate_speech():
|
| 191 |
"""
|
|
|
|
| 205 |
|
| 206 |
data = request.json
|
| 207 |
|
| 208 |
+
if not isinstance(data, dict):
|
| 209 |
+
return jsonify({'error': 'Request body must be a JSON object'}), 400
|
| 210 |
|
| 211 |
text = data.get('input')
|
| 212 |
if not text:
|
|
|
|
| 220 |
|
| 221 |
tts = get_tts_service()
|
| 222 |
|
| 223 |
+
if tts._loading:
|
| 224 |
+
return jsonify({'error': 'Model is reloading; retry shortly.'}), 503
|
| 225 |
+
|
| 226 |
# Validate voice first
|
| 227 |
is_valid, msg = tts.validate_voice(voice)
|
| 228 |
if not is_valid:
|
|
|
|
| 260 |
return _generate_file(tts, voice_state, text, target_format)
|
| 261 |
|
| 262 |
except ValueError as e:
|
| 263 |
+
msg = str(e)
|
| 264 |
+
# Detect the legacy-unlabeled-safetensors mismatch pattern. Re-resolving
|
| 265 |
+
# can itself raise (e.g. SSRF protection on http:// URLs); treat any
|
| 266 |
+
# failure here as "not a mismatch" and fall through to the generic 400.
|
| 267 |
+
try:
|
| 268 |
+
resolved = tts._resolve_voice_path(voice) if not tts._loading else ''
|
| 269 |
+
except Exception:
|
| 270 |
+
resolved = ''
|
| 271 |
+
is_legacy_st = resolved.endswith('.safetensors') and not any(
|
| 272 |
+
resolved.endswith(f'.{tag}.safetensors') for tag in Config.SUPPORTED_LANGUAGES
|
| 273 |
+
)
|
| 274 |
+
mismatch_markers = ('size mismatch', 'Error(s) in loading state_dict', 'shape')
|
| 275 |
+
if is_legacy_st and any(m in msg for m in mismatch_markers):
|
| 276 |
+
return jsonify(
|
| 277 |
+
{
|
| 278 |
+
'error': 'voice_model_mismatch',
|
| 279 |
+
'message': (
|
| 280 |
+
f"Voice '{voice}' appears to have been cloned for a different "
|
| 281 |
+
f'model. Upload the original audio (.wav/.mp3/.flac) to '
|
| 282 |
+
f're-clone for the active model, or switch to the model it '
|
| 283 |
+
f'was generated for.'
|
| 284 |
+
),
|
| 285 |
+
'voice': voice,
|
| 286 |
+
'active_model': (tts._active or {}).get('value'),
|
| 287 |
+
}
|
| 288 |
+
), 400
|
| 289 |
+
|
| 290 |
logger.warning(f'Voice loading failed: {e}')
|
| 291 |
+
return jsonify({'error': msg}), 400
|
| 292 |
except Exception as e:
|
| 293 |
logger.exception('Generation failed')
|
| 294 |
return jsonify({'error': str(e)}), 500
|
|
@@ -4,11 +4,8 @@ TTS Service - handles model loading, voice management, and audio generation.
|
|
| 4 |
|
| 5 |
import os
|
| 6 |
import time
|
| 7 |
-
from collections.abc import Iterator
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
-
import torch
|
| 11 |
-
|
| 12 |
from app.config import Config
|
| 13 |
from app.logging_config import get_logger
|
| 14 |
|
|
@@ -16,16 +13,19 @@ logger = get_logger('tts')
|
|
| 16 |
|
| 17 |
# Lazy import pocket_tts to allow for better error handling
|
| 18 |
TTSModel = None
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def _ensure_pocket_tts():
|
| 22 |
"""Ensure pocket-tts is imported."""
|
| 23 |
-
global TTSModel
|
| 24 |
if TTSModel is None:
|
| 25 |
try:
|
| 26 |
from pocket_tts import TTSModel as _TTSModel
|
|
|
|
| 27 |
|
| 28 |
TTSModel = _TTSModel
|
|
|
|
| 29 |
except ImportError as exc:
|
| 30 |
raise ImportError('pocket-tts not found. Install with: pip install pocket-tts') from exc
|
| 31 |
|
|
@@ -37,11 +37,60 @@ class TTSService:
|
|
| 37 |
"""
|
| 38 |
|
| 39 |
def __init__(self):
|
|
|
|
|
|
|
|
|
|
| 40 |
self.model = None
|
| 41 |
-
self.voice_cache:
|
| 42 |
self.voices_dir: str | None = None
|
| 43 |
self._model_loaded = False
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
@property
|
| 46 |
def is_loaded(self) -> bool:
|
| 47 |
"""Check if the model is loaded."""
|
|
@@ -66,6 +115,7 @@ class TTSService:
|
|
| 66 |
model_path: str | None = None,
|
| 67 |
language: str | None = None,
|
| 68 |
quantize: bool = False,
|
|
|
|
| 69 |
) -> None:
|
| 70 |
"""
|
| 71 |
Load the TTS model.
|
|
@@ -75,17 +125,17 @@ class TTSService:
|
|
| 75 |
language: Optional language identifier (e.g., english, french_24l).
|
| 76 |
Incompatible with model_path.
|
| 77 |
quantize: If True, apply dynamic int8 quantization to reduce memory.
|
|
|
|
|
|
|
| 78 |
"""
|
| 79 |
_ensure_pocket_tts()
|
| 80 |
|
| 81 |
logger.info('Loading Pocket TTS model...')
|
| 82 |
t0 = time.time()
|
| 83 |
|
| 84 |
-
# Determine model path
|
| 85 |
effective_path = model_path
|
| 86 |
|
| 87 |
if not effective_path:
|
| 88 |
-
# Check for bundled model in frozen executable
|
| 89 |
_, bundle_model = Config.get_bundle_paths()
|
| 90 |
if bundle_model and os.path.isfile(bundle_model):
|
| 91 |
effective_path = bundle_model
|
|
@@ -95,14 +145,21 @@ class TTSService:
|
|
| 95 |
if effective_path:
|
| 96 |
logger.info(f'Loading model from: {effective_path}')
|
| 97 |
self.model = TTSModel.load_model(config=effective_path, quantize=quantize)
|
|
|
|
| 98 |
elif language:
|
| 99 |
logger.info(f'Loading model with language: {language}')
|
| 100 |
self.model = TTSModel.load_model(language=language, quantize=quantize)
|
|
|
|
| 101 |
else:
|
| 102 |
logger.info('Loading default model from HuggingFace...')
|
| 103 |
self.model = TTSModel.load_model(quantize=quantize)
|
|
|
|
| 104 |
|
| 105 |
self._model_loaded = True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
load_time = time.time() - t0
|
| 107 |
logger.info(
|
| 108 |
f'Model loaded in {load_time:.2f}s. '
|
|
@@ -113,6 +170,95 @@ class TTSService:
|
|
| 113 |
logger.error(f'Failed to load model: {e}')
|
| 114 |
raise
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def set_voices_dir(self, voices_dir: str | None) -> None:
|
| 117 |
"""
|
| 118 |
Set the directory for custom voice files.
|
|
@@ -130,36 +276,87 @@ class TTSService:
|
|
| 130 |
self.voices_dir = None
|
| 131 |
|
| 132 |
def get_voice_state(self, voice_id_or_path: str) -> dict:
|
| 133 |
-
"""
|
| 134 |
-
Resolve voice ID to a model state with caching.
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
Returns:
|
| 140 |
-
Model state dictionary for the voice
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
|
|
|
| 144 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
if not self.is_loaded:
|
| 146 |
raise RuntimeError('Model not loaded. Call load_model() first.')
|
| 147 |
|
| 148 |
-
# Resolve the voice path
|
| 149 |
resolved_key = self._resolve_voice_path(voice_id_or_path)
|
| 150 |
|
| 151 |
-
#
|
|
|
|
| 152 |
if resolved_key in self.voice_cache:
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
# Load voice
|
| 157 |
logger.info(f'Loading voice: {resolved_key}')
|
| 158 |
t0 = time.time()
|
| 159 |
|
| 160 |
try:
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
load_time = time.time() - t0
|
| 164 |
logger.info(f'Voice loaded in {load_time:.2f}s: {resolved_key}')
|
| 165 |
return state
|
|
@@ -169,53 +366,53 @@ class TTSService:
|
|
| 169 |
raise ValueError(f"Voice '{voice_id_or_path}' could not be loaded: {e}") from e
|
| 170 |
|
| 171 |
def _resolve_voice_path(self, voice_id_or_path: str) -> str:
|
|
|
|
|
|
|
|
|
|
| 172 |
"""
|
| 173 |
-
Resolve a voice identifier to its actual path or ID.
|
| 174 |
|
| 175 |
-
|
| 176 |
-
voice_id_or_path: Voice identifier
|
| 177 |
|
| 178 |
-
|
| 179 |
-
Resolved path or identifier
|
| 180 |
-
|
| 181 |
-
Raises:
|
| 182 |
-
ValueError: If unsafe URL scheme is used
|
| 183 |
-
"""
|
| 184 |
-
# Block potentially dangerous URL schemes (SSRF protection)
|
| 185 |
if voice_id_or_path.startswith(('http://', 'https://')):
|
| 186 |
raise ValueError(
|
| 187 |
f'URL scheme not allowed for security reasons: {voice_id_or_path[:50]}. '
|
| 188 |
"Use 'hf://' for HuggingFace models or provide a local file path."
|
| 189 |
)
|
| 190 |
|
| 191 |
-
# Allow HuggingFace URLs
|
| 192 |
if voice_id_or_path.startswith('hf://'):
|
| 193 |
return voice_id_or_path
|
| 194 |
|
| 195 |
-
#
|
| 196 |
if voice_id_or_path.lower() in Config.BUILTIN_VOICES:
|
| 197 |
return voice_id_or_path.lower()
|
| 198 |
|
| 199 |
-
#
|
| 200 |
-
if self.voices_dir:
|
| 201 |
-
for ext in Config.VOICE_EXTENSIONS:
|
| 202 |
-
# Try exact match first
|
| 203 |
-
possible_path = os.path.join(self.voices_dir, voice_id_or_path)
|
| 204 |
-
if os.path.exists(possible_path):
|
| 205 |
-
return os.path.abspath(possible_path)
|
| 206 |
-
|
| 207 |
-
# Try with extension
|
| 208 |
-
if not voice_id_or_path.endswith(ext):
|
| 209 |
-
possible_path = os.path.join(self.voices_dir, voice_id_or_path + ext)
|
| 210 |
-
if os.path.exists(possible_path):
|
| 211 |
-
return os.path.abspath(possible_path)
|
| 212 |
-
|
| 213 |
-
# Check if it's an absolute path that exists
|
| 214 |
if os.path.isabs(voice_id_or_path) and os.path.exists(voice_id_or_path):
|
| 215 |
return voice_id_or_path
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
def validate_voice(self, voice_id_or_path: str) -> tuple[bool, str]:
|
| 221 |
"""
|
|
@@ -253,84 +450,53 @@ class TTSService:
|
|
| 253 |
|
| 254 |
return False, f'Voice not found: {voice_id_or_path}'
|
| 255 |
|
| 256 |
-
def generate_audio(self, voice_state: dict, text: str)
|
| 257 |
-
"""
|
| 258 |
-
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
text: Text to synthesize
|
| 263 |
-
|
| 264 |
-
Returns:
|
| 265 |
-
Audio tensor
|
| 266 |
-
"""
|
| 267 |
if not self.is_loaded:
|
| 268 |
raise RuntimeError('Model not loaded')
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
| 273 |
|
| 274 |
logger.info(f'Generated {len(text)} chars in {gen_time:.2f}s')
|
| 275 |
return audio
|
| 276 |
|
| 277 |
-
def generate_audio_stream(self, voice_state: dict, text: str)
|
| 278 |
-
"""
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
Args:
|
| 282 |
-
voice_state: Model state from get_voice_state()
|
| 283 |
-
text: Text to synthesize
|
| 284 |
-
|
| 285 |
-
Yields:
|
| 286 |
-
Audio tensor chunks
|
| 287 |
-
"""
|
| 288 |
if not self.is_loaded:
|
| 289 |
raise RuntimeError('Model not loaded')
|
| 290 |
|
| 291 |
logger.info(f'Starting streaming generation for {len(text)} chars')
|
| 292 |
-
|
|
|
|
| 293 |
|
| 294 |
def list_voices(self) -> list[dict]:
|
| 295 |
-
"""
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
-
Returns:
|
| 299 |
-
List of voice dictionaries with 'id' and 'name' keys
|
| 300 |
-
"""
|
| 301 |
-
voices = []
|
| 302 |
-
|
| 303 |
-
# Built-in voices (sorted alphabetically)
|
| 304 |
-
builtin_sorted = sorted(Config.BUILTIN_VOICES)
|
| 305 |
-
for voice in builtin_sorted:
|
| 306 |
-
voices.append({'id': voice, 'name': voice.capitalize(), 'type': 'builtin'})
|
| 307 |
-
|
| 308 |
-
# Custom voices from directory
|
| 309 |
-
custom_voices = []
|
| 310 |
-
if self.voices_dir and os.path.isdir(self.voices_dir):
|
| 311 |
-
voice_dir = Path(self.voices_dir)
|
| 312 |
-
|
| 313 |
-
# Collect all valid files
|
| 314 |
-
voice_files = []
|
| 315 |
-
for ext in Config.VOICE_EXTENSIONS:
|
| 316 |
-
voice_files.extend(voice_dir.glob(f'*{ext}'))
|
| 317 |
-
|
| 318 |
-
# Sort alphabetically by filename
|
| 319 |
-
voice_files.sort(key=lambda f: f.name.lower())
|
| 320 |
-
|
| 321 |
-
for voice_file in voice_files:
|
| 322 |
-
# Format name: "bobby_mcfern" -> "Bobby Mcfern"
|
| 323 |
-
clean_name = voice_file.stem.replace('_', ' ').replace('-', ' ').title()
|
| 324 |
-
|
| 325 |
-
custom_voices.append(
|
| 326 |
-
{
|
| 327 |
-
'id': voice_file.name,
|
| 328 |
-
'name': clean_name,
|
| 329 |
-
'type': 'custom',
|
| 330 |
-
}
|
| 331 |
-
)
|
| 332 |
-
|
| 333 |
-
voices.extend(custom_voices)
|
| 334 |
return voices
|
| 335 |
|
| 336 |
|
|
|
|
| 4 |
|
| 5 |
import os
|
| 6 |
import time
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
|
|
|
|
|
|
| 9 |
from app.config import Config
|
| 10 |
from app.logging_config import get_logger
|
| 11 |
|
|
|
|
| 13 |
|
| 14 |
# Lazy import pocket_tts to allow for better error handling
|
| 15 |
TTSModel = None
|
| 16 |
+
export_model_state = None
|
| 17 |
|
| 18 |
|
| 19 |
def _ensure_pocket_tts():
|
| 20 |
"""Ensure pocket-tts is imported."""
|
| 21 |
+
global TTSModel, export_model_state
|
| 22 |
if TTSModel is None:
|
| 23 |
try:
|
| 24 |
from pocket_tts import TTSModel as _TTSModel
|
| 25 |
+
from pocket_tts.models.tts_model import export_model_state as _export_state
|
| 26 |
|
| 27 |
TTSModel = _TTSModel
|
| 28 |
+
export_model_state = _export_state
|
| 29 |
except ImportError as exc:
|
| 30 |
raise ImportError('pocket-tts not found. Install with: pip install pocket-tts') from exc
|
| 31 |
|
|
|
|
| 37 |
"""
|
| 38 |
|
| 39 |
def __init__(self):
|
| 40 |
+
import threading
|
| 41 |
+
from collections import OrderedDict
|
| 42 |
+
|
| 43 |
self.model = None
|
| 44 |
+
self.voice_cache: OrderedDict = OrderedDict()
|
| 45 |
self.voices_dir: str | None = None
|
| 46 |
self._model_loaded = False
|
| 47 |
|
| 48 |
+
# Concurrency + reload state.
|
| 49 |
+
# _lock is held for the duration of model operations (load, generate);
|
| 50 |
+
# _state_lock is held only briefly to mutate the loading flag and
|
| 51 |
+
# related state, so concurrent reload claims are atomic without
|
| 52 |
+
# waiting for in-flight generation to finish.
|
| 53 |
+
self._lock = threading.Lock()
|
| 54 |
+
self._state_lock = threading.Lock()
|
| 55 |
+
self._loading = False # fast-path flag; read without lock, written under _state_lock
|
| 56 |
+
self._active: dict | None = None
|
| 57 |
+
self._boot_active: dict | None = None
|
| 58 |
+
self._loading_target: dict | None = None
|
| 59 |
+
self._last_reload_error: str | None = None
|
| 60 |
+
|
| 61 |
+
self.cache_dir: Path | None = Path(Config.VOICE_CACHE_DIR)
|
| 62 |
+
|
| 63 |
+
def _ensure_cache_dir(self) -> None:
|
| 64 |
+
"""Create the voice cache directory on first need. Tolerate read-only FS."""
|
| 65 |
+
if self.cache_dir is None:
|
| 66 |
+
return
|
| 67 |
+
try:
|
| 68 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
except OSError as e:
|
| 70 |
+
logger.warning(
|
| 71 |
+
f'Voice cache dir {self.cache_dir} is not writable ({e}); '
|
| 72 |
+
f'cache persistence disabled.'
|
| 73 |
+
)
|
| 74 |
+
self.cache_dir = None
|
| 75 |
+
|
| 76 |
+
def _save_cloned_state(self, state: dict, audio_path) -> None:
|
| 77 |
+
"""Persist a freshly-cloned state as <stem>.<active_tag>.safetensors."""
|
| 78 |
+
|
| 79 |
+
from app.services.voice_cache import active_model_tag
|
| 80 |
+
|
| 81 |
+
self._ensure_cache_dir()
|
| 82 |
+
if self.cache_dir is None:
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
audio_path = Path(audio_path)
|
| 86 |
+
tag = active_model_tag((self._active or {}).get('value') or 'english')
|
| 87 |
+
target = self.cache_dir / f'{audio_path.stem}.{tag}.safetensors'
|
| 88 |
+
try:
|
| 89 |
+
export_model_state(state, target)
|
| 90 |
+
logger.info(f'Saved cloned voice state to {target}')
|
| 91 |
+
except OSError as e:
|
| 92 |
+
logger.warning(f'Could not save voice cache to {target}: {e}')
|
| 93 |
+
|
| 94 |
@property
|
| 95 |
def is_loaded(self) -> bool:
|
| 96 |
"""Check if the model is loaded."""
|
|
|
|
| 115 |
model_path: str | None = None,
|
| 116 |
language: str | None = None,
|
| 117 |
quantize: bool = False,
|
| 118 |
+
_is_boot: bool = True,
|
| 119 |
) -> None:
|
| 120 |
"""
|
| 121 |
Load the TTS model.
|
|
|
|
| 125 |
language: Optional language identifier (e.g., english, french_24l).
|
| 126 |
Incompatible with model_path.
|
| 127 |
quantize: If True, apply dynamic int8 quantization to reduce memory.
|
| 128 |
+
_is_boot: Internal; True only for the first boot-time load. Controls
|
| 129 |
+
whether _boot_active is initialized.
|
| 130 |
"""
|
| 131 |
_ensure_pocket_tts()
|
| 132 |
|
| 133 |
logger.info('Loading Pocket TTS model...')
|
| 134 |
t0 = time.time()
|
| 135 |
|
|
|
|
| 136 |
effective_path = model_path
|
| 137 |
|
| 138 |
if not effective_path:
|
|
|
|
| 139 |
_, bundle_model = Config.get_bundle_paths()
|
| 140 |
if bundle_model and os.path.isfile(bundle_model):
|
| 141 |
effective_path = bundle_model
|
|
|
|
| 145 |
if effective_path:
|
| 146 |
logger.info(f'Loading model from: {effective_path}')
|
| 147 |
self.model = TTSModel.load_model(config=effective_path, quantize=quantize)
|
| 148 |
+
active = {'source': 'model_path', 'value': effective_path, 'quantize': quantize}
|
| 149 |
elif language:
|
| 150 |
logger.info(f'Loading model with language: {language}')
|
| 151 |
self.model = TTSModel.load_model(language=language, quantize=quantize)
|
| 152 |
+
active = {'source': 'language', 'value': language, 'quantize': quantize}
|
| 153 |
else:
|
| 154 |
logger.info('Loading default model from HuggingFace...')
|
| 155 |
self.model = TTSModel.load_model(quantize=quantize)
|
| 156 |
+
active = {'source': 'default', 'value': None, 'quantize': quantize}
|
| 157 |
|
| 158 |
self._model_loaded = True
|
| 159 |
+
self._active = active
|
| 160 |
+
if _is_boot:
|
| 161 |
+
self._boot_active = dict(active)
|
| 162 |
+
|
| 163 |
load_time = time.time() - t0
|
| 164 |
logger.info(
|
| 165 |
f'Model loaded in {load_time:.2f}s. '
|
|
|
|
| 170 |
logger.error(f'Failed to load model: {e}')
|
| 171 |
raise
|
| 172 |
|
| 173 |
+
def _validate_reload(self, language: str) -> None:
|
| 174 |
+
"""Pre-flight checks shared by sync and async reload paths."""
|
| 175 |
+
if self._boot_active and self._boot_active['source'] == 'model_path':
|
| 176 |
+
raise RuntimeError(
|
| 177 |
+
'Cannot switch language: server was started with a custom model_path.'
|
| 178 |
+
)
|
| 179 |
+
if language not in Config.SUPPORTED_LANGUAGES:
|
| 180 |
+
raise ValueError(f'Unsupported language: {language!r}')
|
| 181 |
+
|
| 182 |
+
def _claim_loading(self, language: str, quantize: bool) -> bool:
|
| 183 |
+
"""Atomically check-and-set the loading flag.
|
| 184 |
+
|
| 185 |
+
Returns True if this caller owns the reload slot, False if another
|
| 186 |
+
reload is already in progress. Held briefly under _state_lock so
|
| 187 |
+
concurrent claims race-free without waiting on the long-held _lock.
|
| 188 |
+
"""
|
| 189 |
+
with self._state_lock:
|
| 190 |
+
if self._loading:
|
| 191 |
+
return False
|
| 192 |
+
self._loading = True
|
| 193 |
+
self._loading_target = {'value': language, 'quantize': quantize}
|
| 194 |
+
self._last_reload_error = None
|
| 195 |
+
return True
|
| 196 |
+
|
| 197 |
+
def _release_loading(self) -> None:
|
| 198 |
+
with self._state_lock:
|
| 199 |
+
self._loading = False
|
| 200 |
+
self._loading_target = None
|
| 201 |
+
|
| 202 |
+
def _do_reload(self, language: str, quantize: bool) -> None:
|
| 203 |
+
"""Perform the actual model swap. Caller must already hold the loading
|
| 204 |
+
slot via `_claim_loading`. Restores the previous model on failure."""
|
| 205 |
+
with self._lock:
|
| 206 |
+
previous_model = self.model
|
| 207 |
+
previous_active = self._active
|
| 208 |
+
try:
|
| 209 |
+
self.load_model(language=language, quantize=quantize, _is_boot=False)
|
| 210 |
+
self.voice_cache.clear()
|
| 211 |
+
except Exception:
|
| 212 |
+
# Restore previous state on failure so the server remains usable.
|
| 213 |
+
self.model = previous_model
|
| 214 |
+
self._active = previous_active
|
| 215 |
+
raise
|
| 216 |
+
|
| 217 |
+
def reload_model(self, language: str, quantize: bool) -> None:
|
| 218 |
+
"""Reload the model synchronously.
|
| 219 |
+
|
| 220 |
+
Validates, atomically claims the reload slot, then performs the swap
|
| 221 |
+
while holding the model lock. Pocket-tts v2 `TTSModel` is not
|
| 222 |
+
thread-safe so generation is serialized via the same lock.
|
| 223 |
+
|
| 224 |
+
Raises:
|
| 225 |
+
ValueError: unknown language.
|
| 226 |
+
RuntimeError: model_path locked, already loading, or load failure.
|
| 227 |
+
"""
|
| 228 |
+
self._validate_reload(language)
|
| 229 |
+
if not self._claim_loading(language, quantize):
|
| 230 |
+
raise RuntimeError('already loading')
|
| 231 |
+
try:
|
| 232 |
+
self._do_reload(language, quantize)
|
| 233 |
+
finally:
|
| 234 |
+
self._release_loading()
|
| 235 |
+
|
| 236 |
+
def reload_model_async(self, language: str, quantize: bool) -> bool:
|
| 237 |
+
"""Atomically claim the reload slot and start a worker thread.
|
| 238 |
+
|
| 239 |
+
Returns True if the claim succeeded (worker started), False if a
|
| 240 |
+
reload was already in progress. Validation errors are still raised
|
| 241 |
+
synchronously so the caller can surface them as 400/403.
|
| 242 |
+
"""
|
| 243 |
+
import threading
|
| 244 |
+
|
| 245 |
+
self._validate_reload(language)
|
| 246 |
+
if not self._claim_loading(language, quantize):
|
| 247 |
+
return False
|
| 248 |
+
|
| 249 |
+
def _worker():
|
| 250 |
+
try:
|
| 251 |
+
self._do_reload(language, quantize)
|
| 252 |
+
except Exception as e:
|
| 253 |
+
with self._state_lock:
|
| 254 |
+
self._last_reload_error = f'{type(e).__name__}: {e}'
|
| 255 |
+
logger.error(f'Reload failed: {self._last_reload_error}')
|
| 256 |
+
finally:
|
| 257 |
+
self._release_loading()
|
| 258 |
+
|
| 259 |
+
threading.Thread(target=_worker, daemon=True, name='tts-reload').start()
|
| 260 |
+
return True
|
| 261 |
+
|
| 262 |
def set_voices_dir(self, voices_dir: str | None) -> None:
|
| 263 |
"""
|
| 264 |
Set the directory for custom voice files.
|
|
|
|
| 276 |
self.voices_dir = None
|
| 277 |
|
| 278 |
def get_voice_state(self, voice_id_or_path: str) -> dict:
|
| 279 |
+
"""Resolve a voice ID to a cached model state.
|
|
|
|
| 280 |
|
| 281 |
+
When the resolved path is raw audio, encode it against the active model
|
| 282 |
+
and persist the result as <stem>.<active_tag>.safetensors in cache_dir.
|
| 283 |
+
If a tagged cache exists but its source audio is newer, regenerate.
|
|
|
|
|
|
|
| 284 |
|
| 285 |
+
Pocket-tts v2 `TTSModel` is not thread-safe, so model invocations are
|
| 286 |
+
serialized under `self._lock` (the same lock that protects generation
|
| 287 |
+
and reload).
|
| 288 |
"""
|
| 289 |
+
|
| 290 |
+
from app.services.voice_cache import (
|
| 291 |
+
AUDIO_EXTENSIONS,
|
| 292 |
+
cache_is_stale,
|
| 293 |
+
known_model_tags,
|
| 294 |
+
parse_safetensors_name,
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
if self._loading:
|
| 298 |
+
raise RuntimeError('model reloading')
|
| 299 |
if not self.is_loaded:
|
| 300 |
raise RuntimeError('Model not loaded. Call load_model() first.')
|
| 301 |
|
|
|
|
| 302 |
resolved_key = self._resolve_voice_path(voice_id_or_path)
|
| 303 |
|
| 304 |
+
# Cache hit fast path. The dict can be cleared concurrently by
|
| 305 |
+
# `reload_model`, so guard against a KeyError between `in` and access.
|
| 306 |
if resolved_key in self.voice_cache:
|
| 307 |
+
try:
|
| 308 |
+
self.voice_cache.move_to_end(resolved_key)
|
| 309 |
+
logger.debug(f'Using in-memory voice state for: {resolved_key}')
|
| 310 |
+
return self.voice_cache[resolved_key]
|
| 311 |
+
except KeyError:
|
| 312 |
+
pass # raced with cache clear; fall through and re-encode
|
| 313 |
+
|
| 314 |
+
# If resolved to a tagged cache, check staleness against raw-audio source.
|
| 315 |
+
# Treat any existing filesystem path as local — relative paths from a
|
| 316 |
+
# configured `voices_dir` (common in local dev) must still get the
|
| 317 |
+
# truncate=True clone path and disk caching.
|
| 318 |
+
resolved_path = (
|
| 319 |
+
Path(resolved_key)
|
| 320 |
+
if os.path.isabs(resolved_key) or os.path.exists(resolved_key)
|
| 321 |
+
else None
|
| 322 |
+
)
|
| 323 |
+
regenerate_from_source: Path | None = None
|
| 324 |
+
|
| 325 |
+
if resolved_path and resolved_path.suffix == '.safetensors' and self.voices_dir:
|
| 326 |
+
# Use the same parser the cache module uses so stems containing
|
| 327 |
+
# dots (e.g. "John.Doe.english_2026-04.safetensors" → "John.Doe")
|
| 328 |
+
# are extracted correctly.
|
| 329 |
+
stem, _tag = parse_safetensors_name(resolved_path.name, known_model_tags())
|
| 330 |
+
for ext in AUDIO_EXTENSIONS:
|
| 331 |
+
source = Path(self.voices_dir) / f'{stem}{ext}'
|
| 332 |
+
if cache_is_stale(cache_path=resolved_path, source_path=source):
|
| 333 |
+
regenerate_from_source = source
|
| 334 |
+
break
|
| 335 |
|
|
|
|
| 336 |
logger.info(f'Loading voice: {resolved_key}')
|
| 337 |
t0 = time.time()
|
| 338 |
|
| 339 |
try:
|
| 340 |
+
with self._lock:
|
| 341 |
+
if regenerate_from_source:
|
| 342 |
+
logger.info(f'Regenerating stale cache from {regenerate_from_source}')
|
| 343 |
+
state = self.model.get_state_for_audio_prompt(
|
| 344 |
+
regenerate_from_source, truncate=True
|
| 345 |
+
)
|
| 346 |
+
self._save_cloned_state(state, regenerate_from_source)
|
| 347 |
+
elif resolved_path and resolved_path.suffix.lower() in AUDIO_EXTENSIONS:
|
| 348 |
+
state = self.model.get_state_for_audio_prompt(resolved_path, truncate=True)
|
| 349 |
+
self._save_cloned_state(state, resolved_path)
|
| 350 |
+
else:
|
| 351 |
+
# Pre-made .safetensors OR built-in OR hf:// — let pocket-tts handle it.
|
| 352 |
+
state = self.model.get_state_for_audio_prompt(resolved_key)
|
| 353 |
+
|
| 354 |
+
# LRU insert (under the lock for consistency with the dict mutation
|
| 355 |
+
# in `reload_model.voice_cache.clear()`).
|
| 356 |
+
self.voice_cache[resolved_key] = state
|
| 357 |
+
if len(self.voice_cache) > 32:
|
| 358 |
+
self.voice_cache.popitem(last=False)
|
| 359 |
+
|
| 360 |
load_time = time.time() - t0
|
| 361 |
logger.info(f'Voice loaded in {load_time:.2f}s: {resolved_key}')
|
| 362 |
return state
|
|
|
|
| 366 |
raise ValueError(f"Voice '{voice_id_or_path}' could not be loaded: {e}") from e
|
| 367 |
|
| 368 |
def _resolve_voice_path(self, voice_id_or_path: str) -> str:
|
| 369 |
+
"""Resolve a voice identifier using per-model cache preference.
|
| 370 |
+
|
| 371 |
+
Raises ValueError on unsafe URL schemes (retained from previous behavior).
|
| 372 |
"""
|
|
|
|
| 373 |
|
| 374 |
+
from app.services.voice_cache import resolve_voice_path
|
|
|
|
| 375 |
|
| 376 |
+
# Retain SSRF protection.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
if voice_id_or_path.startswith(('http://', 'https://')):
|
| 378 |
raise ValueError(
|
| 379 |
f'URL scheme not allowed for security reasons: {voice_id_or_path[:50]}. '
|
| 380 |
"Use 'hf://' for HuggingFace models or provide a local file path."
|
| 381 |
)
|
| 382 |
|
|
|
|
| 383 |
if voice_id_or_path.startswith('hf://'):
|
| 384 |
return voice_id_or_path
|
| 385 |
|
| 386 |
+
# Built-in names pass through untouched (pocket-tts handles resolution).
|
| 387 |
if voice_id_or_path.lower() in Config.BUILTIN_VOICES:
|
| 388 |
return voice_id_or_path.lower()
|
| 389 |
|
| 390 |
+
# Absolute path hit.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
if os.path.isabs(voice_id_or_path) and os.path.exists(voice_id_or_path):
|
| 392 |
return voice_id_or_path
|
| 393 |
|
| 394 |
+
voices_path = Path(self.voices_dir) if self.voices_dir else None
|
| 395 |
+
|
| 396 |
+
# Backwards-compat: accept full filenames (e.g. `emma.wav`) by checking
|
| 397 |
+
# for an exact match in cache_dir / voices_dir before falling through
|
| 398 |
+
# to stem-based resolution. Without this, `voice_id_or_path` containing
|
| 399 |
+
# an extension would never match anything and pocket-tts would receive
|
| 400 |
+
# the raw string.
|
| 401 |
+
for directory in (self.cache_dir, voices_path):
|
| 402 |
+
if directory:
|
| 403 |
+
exact = directory / voice_id_or_path
|
| 404 |
+
if exact.exists():
|
| 405 |
+
return str(exact)
|
| 406 |
+
|
| 407 |
+
active_model = (self._active or {}).get('value') or 'english'
|
| 408 |
+
|
| 409 |
+
resolved = resolve_voice_path(
|
| 410 |
+
voice_id=voice_id_or_path,
|
| 411 |
+
active_model=active_model,
|
| 412 |
+
voices_dir=voices_path,
|
| 413 |
+
cache_dir=self.cache_dir,
|
| 414 |
+
)
|
| 415 |
+
return str(resolved) if isinstance(resolved, Path) else resolved
|
| 416 |
|
| 417 |
def validate_voice(self, voice_id_or_path: str) -> tuple[bool, str]:
|
| 418 |
"""
|
|
|
|
| 450 |
|
| 451 |
return False, f'Voice not found: {voice_id_or_path}'
|
| 452 |
|
| 453 |
+
def generate_audio(self, voice_state: dict, text: str):
|
| 454 |
+
"""Generate complete audio for given text."""
|
| 455 |
+
import torch # noqa: F401 — kept for return-type doc
|
| 456 |
|
| 457 |
+
if self._loading:
|
| 458 |
+
raise RuntimeError('model reloading')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
if not self.is_loaded:
|
| 460 |
raise RuntimeError('Model not loaded')
|
| 461 |
|
| 462 |
+
with self._lock:
|
| 463 |
+
t0 = time.time()
|
| 464 |
+
audio = self.model.generate_audio(voice_state, text)
|
| 465 |
+
gen_time = time.time() - t0
|
| 466 |
|
| 467 |
logger.info(f'Generated {len(text)} chars in {gen_time:.2f}s')
|
| 468 |
return audio
|
| 469 |
|
| 470 |
+
def generate_audio_stream(self, voice_state: dict, text: str):
|
| 471 |
+
"""Generate audio in streaming chunks. Holds the lock for the entire stream."""
|
| 472 |
+
if self._loading:
|
| 473 |
+
raise RuntimeError('model reloading')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
if not self.is_loaded:
|
| 475 |
raise RuntimeError('Model not loaded')
|
| 476 |
|
| 477 |
logger.info(f'Starting streaming generation for {len(text)} chars')
|
| 478 |
+
with self._lock:
|
| 479 |
+
yield from self.model.generate_audio_stream(voice_state, text)
|
| 480 |
|
| 481 |
def list_voices(self) -> list[dict]:
|
| 482 |
+
"""List built-in voices and custom voices (one entry per stem)."""
|
| 483 |
+
|
| 484 |
+
from app.services.voice_cache import list_voice_stems
|
| 485 |
+
|
| 486 |
+
voices: list[dict] = []
|
| 487 |
+
|
| 488 |
+
# Built-in voices (sorted).
|
| 489 |
+
for name in sorted(Config.BUILTIN_VOICES):
|
| 490 |
+
voices.append({'id': name, 'name': name.capitalize(), 'type': 'builtin'})
|
| 491 |
+
|
| 492 |
+
voices_path = Path(self.voices_dir) if self.voices_dir else None
|
| 493 |
+
stems = list_voice_stems(voices_dir=voices_path, cache_dir=self.cache_dir)
|
| 494 |
+
|
| 495 |
+
for stem in stems:
|
| 496 |
+
# Format name: "bobby_mcfern" -> "Bobby Mcfern"
|
| 497 |
+
clean_name = stem.replace('_', ' ').replace('-', ' ').title()
|
| 498 |
+
voices.append({'id': stem, 'name': clean_name, 'type': 'custom'})
|
| 499 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
return voices
|
| 501 |
|
| 502 |
|
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cached version lookup for the web UI."""
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
from importlib.metadata import PackageNotFoundError, version
|
| 5 |
+
|
| 6 |
+
from app import __version__ as _SERVER_VERSION_FALLBACK
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@lru_cache(maxsize=1)
|
| 10 |
+
def get_versions() -> dict[str, str]:
|
| 11 |
+
"""Return {'server': ..., 'pocket_tts': ...}.
|
| 12 |
+
|
| 13 |
+
Falls back to the hardcoded `app.__version__` for the server when the
|
| 14 |
+
package isn't installed via pip. `pocket_tts` falls back to 'unknown'.
|
| 15 |
+
"""
|
| 16 |
+
try:
|
| 17 |
+
server = version('pocket-tts-openai-server')
|
| 18 |
+
except PackageNotFoundError:
|
| 19 |
+
server = _SERVER_VERSION_FALLBACK
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
pocket_tts = version('pocket-tts')
|
| 23 |
+
except PackageNotFoundError:
|
| 24 |
+
pocket_tts = 'unknown'
|
| 25 |
+
|
| 26 |
+
return {'server': server, 'pocket_tts': pocket_tts}
|
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure filename and path logic for per-model cached voice states.
|
| 2 |
+
|
| 3 |
+
Kept separate from tts.py so it can be exercised without loading pocket-tts.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from app.config import Config
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def active_model_tag(raw_model: str) -> str:
|
| 12 |
+
"""Normalize a language identifier for use as a filename tag.
|
| 13 |
+
|
| 14 |
+
Equivalent names (english, english_2026-01, english_2026-04) map to a
|
| 15 |
+
single canonical tag so cache files don't duplicate.
|
| 16 |
+
"""
|
| 17 |
+
return Config.LEGACY_MODEL_ALIASES.get(raw_model, raw_model)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def known_model_tags() -> set[str]:
|
| 21 |
+
"""Filename tags we treat as model identifiers during parsing.
|
| 22 |
+
|
| 23 |
+
Includes both the raw supported languages and the alias-target canonicals.
|
| 24 |
+
"""
|
| 25 |
+
return set(Config.SUPPORTED_LANGUAGES) | set(Config.LEGACY_MODEL_ALIASES.values())
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parse_safetensors_name(filename: str, tags: set[str]) -> tuple[str, str | None]:
|
| 29 |
+
"""Split `stem.model_tag.safetensors` into (stem, tag).
|
| 30 |
+
|
| 31 |
+
Returns (stem, None) when the filename is unlabeled or the final segment
|
| 32 |
+
is not a recognized tag.
|
| 33 |
+
"""
|
| 34 |
+
base = Path(filename).stem # strips .safetensors
|
| 35 |
+
if '.' in base:
|
| 36 |
+
stem, tag = base.rsplit('.', 1)
|
| 37 |
+
if tag in tags:
|
| 38 |
+
return stem, tag
|
| 39 |
+
return base, None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def resolve_voice_path(
|
| 43 |
+
voice_id: str,
|
| 44 |
+
active_model: str,
|
| 45 |
+
voices_dir: Path | None,
|
| 46 |
+
cache_dir: Path | None,
|
| 47 |
+
) -> Path | str:
|
| 48 |
+
"""Resolve a voice identifier to its on-disk source.
|
| 49 |
+
|
| 50 |
+
Preference order (within each directory, the canonical-tagged filename
|
| 51 |
+
is checked first; if `active_model` is an alias, the alias-tagged
|
| 52 |
+
filename is checked as a fallback so files written under either name
|
| 53 |
+
resolve correctly):
|
| 54 |
+
1. cache_dir/<voice_id>.<canonical_tag>.safetensors
|
| 55 |
+
2. cache_dir/<voice_id>.<active_model>.safetensors (alias fallback)
|
| 56 |
+
3. voices_dir/<voice_id>.<canonical_tag>.safetensors
|
| 57 |
+
4. voices_dir/<voice_id>.<active_model>.safetensors (alias fallback)
|
| 58 |
+
5. voices_dir/<voice_id>.{wav,mp3,flac}
|
| 59 |
+
6. voices_dir/<voice_id>.safetensors (legacy unlabeled)
|
| 60 |
+
7. The bare voice_id string — pocket-tts will resolve (e.g. built-ins).
|
| 61 |
+
|
| 62 |
+
New caches are always written using the canonical tag (see
|
| 63 |
+
`_save_cloned_state` in tts.py), so the alias-tagged paths exist only
|
| 64 |
+
for files placed by external tools or by users running an older version.
|
| 65 |
+
"""
|
| 66 |
+
canonical_tag = active_model_tag(active_model)
|
| 67 |
+
candidates = [f'{voice_id}.{canonical_tag}.safetensors']
|
| 68 |
+
if active_model != canonical_tag:
|
| 69 |
+
candidates.append(f'{voice_id}.{active_model}.safetensors')
|
| 70 |
+
|
| 71 |
+
for directory in (cache_dir, voices_dir):
|
| 72 |
+
if not directory:
|
| 73 |
+
continue
|
| 74 |
+
for name in candidates:
|
| 75 |
+
p = directory / name
|
| 76 |
+
if p.exists():
|
| 77 |
+
return p
|
| 78 |
+
|
| 79 |
+
if voices_dir:
|
| 80 |
+
for ext in ('.wav', '.mp3', '.flac'):
|
| 81 |
+
p = voices_dir / f'{voice_id}{ext}'
|
| 82 |
+
if p.exists():
|
| 83 |
+
return p
|
| 84 |
+
|
| 85 |
+
p = voices_dir / f'{voice_id}.safetensors'
|
| 86 |
+
if p.exists():
|
| 87 |
+
return p
|
| 88 |
+
|
| 89 |
+
return voice_id
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
AUDIO_EXTENSIONS = ('.wav', '.mp3', '.flac')
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def list_voice_stems(
|
| 96 |
+
voices_dir: Path | None,
|
| 97 |
+
cache_dir: Path | None,
|
| 98 |
+
) -> list[str]:
|
| 99 |
+
"""Return the unique voice stems present across voices_dir and cache_dir."""
|
| 100 |
+
stems: set[str] = set()
|
| 101 |
+
tags = known_model_tags()
|
| 102 |
+
|
| 103 |
+
for directory in (voices_dir, cache_dir):
|
| 104 |
+
if not directory or not directory.is_dir():
|
| 105 |
+
continue
|
| 106 |
+
for ext in AUDIO_EXTENSIONS:
|
| 107 |
+
for f in directory.glob(f'*{ext}'):
|
| 108 |
+
stems.add(f.stem)
|
| 109 |
+
for f in directory.glob('*.safetensors'):
|
| 110 |
+
stem, _tag = parse_safetensors_name(f.name, tags)
|
| 111 |
+
stems.add(stem)
|
| 112 |
+
|
| 113 |
+
return sorted(stems)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def cache_is_stale(cache_path: Path, source_path: Path) -> bool:
|
| 117 |
+
"""True when `source_path` exists and is newer than `cache_path`."""
|
| 118 |
+
if not source_path.exists():
|
| 119 |
+
return False
|
| 120 |
+
if not cache_path.exists():
|
| 121 |
+
return False
|
| 122 |
+
return source_path.stat().st_mtime > cache_path.stat().st_mtime
|
|
@@ -30,6 +30,7 @@ services:
|
|
| 30 |
- POCKET_TTS_LANGUAGE=${POCKET_TTS_LANGUAGE:-}
|
| 31 |
# Enable int8 quantization for lower memory usage and improved speed
|
| 32 |
- POCKET_TTS_QUANTIZE=${POCKET_TTS_QUANTIZE:-false}
|
|
|
|
| 33 |
# Hugging Face token for voice cloning (optional)
|
| 34 |
- HF_TOKEN=${HF_TOKEN:-}
|
| 35 |
|
|
@@ -40,6 +41,8 @@ services:
|
|
| 40 |
- ./logs:/app/logs
|
| 41 |
# Cache HuggingFace models to avoid re-downloading
|
| 42 |
- pockettts-cache:/home/pockettts/.cache/huggingface
|
|
|
|
|
|
|
| 43 |
|
| 44 |
restart: unless-stopped
|
| 45 |
|
|
@@ -67,3 +70,5 @@ services:
|
|
| 67 |
volumes:
|
| 68 |
pockettts-cache:
|
| 69 |
name: pockettts-huggingface-cache
|
|
|
|
|
|
|
|
|
| 30 |
- POCKET_TTS_LANGUAGE=${POCKET_TTS_LANGUAGE:-}
|
| 31 |
# Enable int8 quantization for lower memory usage and improved speed
|
| 32 |
- POCKET_TTS_QUANTIZE=${POCKET_TTS_QUANTIZE:-false}
|
| 33 |
+
- POCKET_TTS_VOICE_CACHE_DIR=/app/voice_cache
|
| 34 |
# Hugging Face token for voice cloning (optional)
|
| 35 |
- HF_TOKEN=${HF_TOKEN:-}
|
| 36 |
|
|
|
|
| 41 |
- ./logs:/app/logs
|
| 42 |
# Cache HuggingFace models to avoid re-downloading
|
| 43 |
- pockettts-cache:/home/pockettts/.cache/huggingface
|
| 44 |
+
# Writable cache for per-model cloned voice safetensors
|
| 45 |
+
- pockettts-voice-cache:/app/voice_cache
|
| 46 |
|
| 47 |
restart: unless-stopped
|
| 48 |
|
|
|
|
| 70 |
volumes:
|
| 71 |
pockettts-cache:
|
| 72 |
name: pockettts-huggingface-cache
|
| 73 |
+
pockettts-voice-cache:
|
| 74 |
+
name: pockettts-voice-cache
|
|
@@ -4,7 +4,7 @@ license = {text = "MIT"}
|
|
| 4 |
name = "pocket-tts-openai-server"
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.10"
|
| 7 |
-
version = "2.5.
|
| 8 |
|
| 9 |
dependencies = [
|
| 10 |
"flask>=3.0.0",
|
|
|
|
| 4 |
name = "pocket-tts-openai-server"
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.10"
|
| 7 |
+
version = "2.5.3"
|
| 8 |
|
| 9 |
dependencies = [
|
| 10 |
"flask>=3.0.0",
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
python_files = test_*.py
|
| 4 |
+
addopts = -v --tb=short
|
|
@@ -126,6 +126,14 @@ def main():
|
|
| 126 |
logger.error('--language and --model-path are mutually exclusive. Use one or the other.')
|
| 127 |
sys.exit(1)
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
# Initialize TTS service
|
| 130 |
try:
|
| 131 |
init_tts_service(
|
|
|
|
| 126 |
logger.error('--language and --model-path are mutually exclusive. Use one or the other.')
|
| 127 |
sys.exit(1)
|
| 128 |
|
| 129 |
+
# Validate --language against supported list (prevents cryptic pocket-tts errors).
|
| 130 |
+
if args.language and args.language not in Config.SUPPORTED_LANGUAGES:
|
| 131 |
+
logger.error(
|
| 132 |
+
f"Unknown language '{args.language}'. "
|
| 133 |
+
f'Supported: {", ".join(Config.SUPPORTED_LANGUAGES)}'
|
| 134 |
+
)
|
| 135 |
+
sys.exit(1)
|
| 136 |
+
|
| 137 |
# Initialize TTS service
|
| 138 |
try:
|
| 139 |
init_tts_service(
|
|
@@ -109,6 +109,21 @@ select {
|
|
| 109 |
box-shadow 0.2s;
|
| 110 |
}
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
input[type='file'] {
|
| 113 |
width: 100%;
|
| 114 |
padding: 0.5rem;
|
|
@@ -622,6 +637,171 @@ audio {
|
|
| 622 |
transform: translateY(1px);
|
| 623 |
}
|
| 624 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
/* Responsive adjustments */
|
| 626 |
@media (max-width: 600px) {
|
| 627 |
.params-table {
|
|
|
|
| 109 |
box-shadow 0.2s;
|
| 110 |
}
|
| 111 |
|
| 112 |
+
/* Replace native select chevron with a custom one positioned right next to
|
| 113 |
+
the content area instead of floating at the far edge of the box.
|
| 114 |
+
`!important` on background-image is needed because #format-select has an
|
| 115 |
+
inline `background: var(--input-bg)` that would otherwise reset the image. */
|
| 116 |
+
select {
|
| 117 |
+
appearance: none;
|
| 118 |
+
-webkit-appearance: none;
|
| 119 |
+
-moz-appearance: none;
|
| 120 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%238b949e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M1 1l5 5 5-5'/%3E%3C/svg%3E") !important;
|
| 121 |
+
background-repeat: no-repeat !important;
|
| 122 |
+
background-position: right 14px center !important;
|
| 123 |
+
background-size: 12px 8px !important;
|
| 124 |
+
padding-right: 36px !important;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
input[type='file'] {
|
| 128 |
width: 100%;
|
| 129 |
padding: 0.5rem;
|
|
|
|
| 637 |
transform: translateY(1px);
|
| 638 |
}
|
| 639 |
|
| 640 |
+
.version-strip {
|
| 641 |
+
color: var(--text-secondary);
|
| 642 |
+
font-size: 0.8em;
|
| 643 |
+
margin-top: 4px;
|
| 644 |
+
margin-bottom: 0;
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.version-strip a {
|
| 648 |
+
color: var(--text-secondary);
|
| 649 |
+
text-decoration: none;
|
| 650 |
+
border-bottom: 1px dotted currentColor;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.version-strip a:hover {
|
| 654 |
+
color: var(--text-color);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.model-settings {
|
| 658 |
+
margin-bottom: 20px;
|
| 659 |
+
border: 1px solid var(--border-color);
|
| 660 |
+
border-radius: 6px;
|
| 661 |
+
background: var(--input-bg);
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
.model-settings-summary {
|
| 665 |
+
padding: 10px 14px;
|
| 666 |
+
cursor: pointer;
|
| 667 |
+
display: flex;
|
| 668 |
+
align-items: center;
|
| 669 |
+
gap: 8px;
|
| 670 |
+
font-weight: 500;
|
| 671 |
+
list-style: none;
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.model-settings-summary::-webkit-details-marker {
|
| 675 |
+
display: none;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
.disclosure-caret {
|
| 679 |
+
display: inline-block;
|
| 680 |
+
transition: transform 0.15s ease;
|
| 681 |
+
color: var(--text-secondary);
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
.model-settings[open] .disclosure-caret {
|
| 685 |
+
transform: rotate(90deg);
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
.summary-label #active-model-label {
|
| 689 |
+
font-family: monospace;
|
| 690 |
+
color: var(--text-color);
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.model-badge {
|
| 694 |
+
font-size: 0.75em;
|
| 695 |
+
padding: 2px 6px;
|
| 696 |
+
border-radius: 3px;
|
| 697 |
+
background: var(--border-color);
|
| 698 |
+
color: var(--text-secondary);
|
| 699 |
+
text-transform: uppercase;
|
| 700 |
+
letter-spacing: 0.5px;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
.session-badge {
|
| 704 |
+
background: #b45309;
|
| 705 |
+
color: #fff;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.loading-indicator {
|
| 709 |
+
display: inline-flex;
|
| 710 |
+
align-items: center;
|
| 711 |
+
gap: 6px;
|
| 712 |
+
font-size: 0.9em;
|
| 713 |
+
color: var(--text-secondary);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.spinner-small {
|
| 717 |
+
width: 12px;
|
| 718 |
+
height: 12px;
|
| 719 |
+
border: 2px solid var(--border-color);
|
| 720 |
+
border-top-color: var(--text-color);
|
| 721 |
+
border-radius: 50%;
|
| 722 |
+
display: inline-block;
|
| 723 |
+
animation: spin 0.8s linear infinite;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
.model-settings-body {
|
| 727 |
+
padding: 14px 14px 14px 14px;
|
| 728 |
+
border-top: 1px solid var(--border-color);
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
/* HTML [hidden] is otherwise overridden by display: inline-flex etc. */
|
| 732 |
+
.model-settings [hidden] {
|
| 733 |
+
display: none !important;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.btn-secondary {
|
| 737 |
+
padding: 8px 18px;
|
| 738 |
+
margin-top: 14px;
|
| 739 |
+
border: 1px solid var(--border-color);
|
| 740 |
+
border-radius: 6px;
|
| 741 |
+
background: transparent;
|
| 742 |
+
color: var(--text-color);
|
| 743 |
+
font-weight: 500;
|
| 744 |
+
cursor: pointer;
|
| 745 |
+
transition:
|
| 746 |
+
background 0.15s,
|
| 747 |
+
border-color 0.15s,
|
| 748 |
+
opacity 0.15s;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.btn-secondary:hover:not(:disabled) {
|
| 752 |
+
background: var(--input-bg);
|
| 753 |
+
border-color: var(--accent-color);
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.btn-secondary:active:not(:disabled) {
|
| 757 |
+
transform: translateY(1px);
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
.btn-secondary:disabled {
|
| 761 |
+
opacity: 0.45;
|
| 762 |
+
cursor: not-allowed;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
.inline-label {
|
| 766 |
+
display: inline-flex;
|
| 767 |
+
align-items: center;
|
| 768 |
+
gap: 8px;
|
| 769 |
+
cursor: pointer;
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
.info-banner {
|
| 773 |
+
padding: 10px 12px;
|
| 774 |
+
border-radius: 4px;
|
| 775 |
+
margin-top: 14px;
|
| 776 |
+
font-size: 0.9em;
|
| 777 |
+
line-height: 1.4;
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
.info-banner code {
|
| 781 |
+
font-family: monospace;
|
| 782 |
+
background: rgba(255,255,255,0.06);
|
| 783 |
+
padding: 1px 4px;
|
| 784 |
+
border-radius: 2px;
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.info-banner.info {
|
| 788 |
+
background: rgba(59,130,246,0.08);
|
| 789 |
+
border-left: 3px solid #3b82f6;
|
| 790 |
+
color: var(--text-color);
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
.info-banner.warning {
|
| 794 |
+
background: rgba(234,179,8,0.08);
|
| 795 |
+
border-left: 3px solid #eab308;
|
| 796 |
+
color: var(--text-color);
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
.info-banner.error {
|
| 800 |
+
background: rgba(239,68,68,0.08);
|
| 801 |
+
border-left: 3px solid #ef4444;
|
| 802 |
+
color: var(--text-color);
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
/* Responsive adjustments */
|
| 806 |
@media (max-width: 600px) {
|
| 807 |
.params-table {
|
|
@@ -485,3 +485,196 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
| 485 |
// Initial load
|
| 486 |
await loadVoices();
|
| 487 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
// Initial load
|
| 486 |
await loadVoices();
|
| 487 |
});
|
| 488 |
+
|
| 489 |
+
// ============================================================
|
| 490 |
+
// Model Settings panel
|
| 491 |
+
// ============================================================
|
| 492 |
+
|
| 493 |
+
const modelUI = {
|
| 494 |
+
activeLabel: document.getElementById('active-model-label'),
|
| 495 |
+
quantizeBadge: document.getElementById('quantize-badge'),
|
| 496 |
+
sessionBadge: document.getElementById('session-badge'),
|
| 497 |
+
loadingIndicator: document.getElementById('loading-indicator'),
|
| 498 |
+
loadingTargetLabel: document.getElementById('loading-target-label'),
|
| 499 |
+
languageSelect: document.getElementById('language-select'),
|
| 500 |
+
quantizeToggle: document.getElementById('quantize-toggle'),
|
| 501 |
+
applyBtn: document.getElementById('apply-model-btn'),
|
| 502 |
+
nonEnglishWarning: document.getElementById('non-english-warning'),
|
| 503 |
+
modelPathLockedNotice: document.getElementById('model-path-locked-notice'),
|
| 504 |
+
sessionOnlyNotice: document.getElementById('session-only-notice'),
|
| 505 |
+
applyError: document.getElementById('apply-error'),
|
| 506 |
+
generateBtn: document.getElementById('generate-btn'),
|
| 507 |
+
};
|
| 508 |
+
|
| 509 |
+
let currentModelState = null;
|
| 510 |
+
let pollTimer = null;
|
| 511 |
+
let pollDeadline = 0;
|
| 512 |
+
|
| 513 |
+
function populateLanguageOptions(languages) {
|
| 514 |
+
if (modelUI.languageSelect.options.length > 0) return; // already populated
|
| 515 |
+
for (const lang of languages) {
|
| 516 |
+
const opt = document.createElement('option');
|
| 517 |
+
opt.value = lang;
|
| 518 |
+
opt.textContent = lang;
|
| 519 |
+
modelUI.languageSelect.appendChild(opt);
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
function setHidden(el, hidden) {
|
| 524 |
+
if (hidden) { el.setAttribute('hidden', ''); }
|
| 525 |
+
else { el.removeAttribute('hidden'); }
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
// Backend reports `value: null` when the server was started without a
|
| 529 |
+
// --language flag. Pocket-tts treats that as "english" internally, so we
|
| 530 |
+
// surface the same string in the UI to keep the dropdown, label, and Apply
|
| 531 |
+
// diff comparison consistent.
|
| 532 |
+
function effectiveActiveValue(state) {
|
| 533 |
+
return state.active.value || 'english';
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
function updateUIForState(state) {
|
| 537 |
+
currentModelState = state;
|
| 538 |
+
populateLanguageOptions(state.available_languages);
|
| 539 |
+
|
| 540 |
+
const activeLang = effectiveActiveValue(state);
|
| 541 |
+
|
| 542 |
+
// Header labels
|
| 543 |
+
modelUI.activeLabel.textContent = activeLang;
|
| 544 |
+
setHidden(modelUI.quantizeBadge, !state.active.quantize);
|
| 545 |
+
setHidden(modelUI.sessionBadge, !state.differs_from_boot);
|
| 546 |
+
|
| 547 |
+
// Loading indicator
|
| 548 |
+
if (state.loading && state.loading_target) {
|
| 549 |
+
modelUI.loadingTargetLabel.textContent = `→ ${state.loading_target.value}`;
|
| 550 |
+
setHidden(modelUI.loadingIndicator, false);
|
| 551 |
+
} else {
|
| 552 |
+
setHidden(modelUI.loadingIndicator, true);
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
// Dropdown reflects active (not the pending target).
|
| 556 |
+
if (modelUI.languageSelect.value !== activeLang) {
|
| 557 |
+
modelUI.languageSelect.value = activeLang;
|
| 558 |
+
}
|
| 559 |
+
modelUI.quantizeToggle.checked = state.active.quantize;
|
| 560 |
+
|
| 561 |
+
// Lock state
|
| 562 |
+
const locked = state.model_path_locked;
|
| 563 |
+
modelUI.languageSelect.disabled = locked || state.loading;
|
| 564 |
+
modelUI.quantizeToggle.disabled = locked || state.loading;
|
| 565 |
+
setHidden(modelUI.modelPathLockedNotice, !locked);
|
| 566 |
+
|
| 567 |
+
// Warnings
|
| 568 |
+
const selectedLang = modelUI.languageSelect.value;
|
| 569 |
+
const isEnglishVariant = selectedLang && selectedLang.startsWith('english');
|
| 570 |
+
setHidden(modelUI.nonEnglishWarning, locked || isEnglishVariant);
|
| 571 |
+
|
| 572 |
+
// Session-only notice
|
| 573 |
+
setHidden(modelUI.sessionOnlyNotice, !state.differs_from_boot);
|
| 574 |
+
|
| 575 |
+
// Apply button
|
| 576 |
+
updateApplyButton();
|
| 577 |
+
|
| 578 |
+
// Error banner from last failed reload — also clear it once the backend
|
| 579 |
+
// reports no error (e.g. after a successful subsequent reload).
|
| 580 |
+
if (state.last_error) {
|
| 581 |
+
modelUI.applyError.textContent = state.last_error;
|
| 582 |
+
setHidden(modelUI.applyError, false);
|
| 583 |
+
} else {
|
| 584 |
+
modelUI.applyError.textContent = '';
|
| 585 |
+
setHidden(modelUI.applyError, true);
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
// Generate button disabled during load
|
| 589 |
+
modelUI.generateBtn.disabled = state.loading;
|
| 590 |
+
modelUI.generateBtn.title = state.loading ? 'Model is loading…' : '';
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
function updateApplyButton() {
|
| 594 |
+
if (!currentModelState) return;
|
| 595 |
+
const { active, model_path_locked, loading } = currentModelState;
|
| 596 |
+
const activeLang = effectiveActiveValue(currentModelState);
|
| 597 |
+
const targetLang = modelUI.languageSelect.value;
|
| 598 |
+
const targetQuantize = modelUI.quantizeToggle.checked;
|
| 599 |
+
const differs = targetLang !== activeLang || targetQuantize !== active.quantize;
|
| 600 |
+
modelUI.applyBtn.disabled = loading || model_path_locked || !differs;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
async function fetchModelState() {
|
| 604 |
+
try {
|
| 605 |
+
const resp = await fetch('/v1/model');
|
| 606 |
+
if (!resp.ok) throw new Error(`GET /v1/model → ${resp.status}`);
|
| 607 |
+
const state = await resp.json();
|
| 608 |
+
updateUIForState(state);
|
| 609 |
+
|
| 610 |
+
if (!state.loading && pollTimer) {
|
| 611 |
+
clearInterval(pollTimer);
|
| 612 |
+
pollTimer = null;
|
| 613 |
+
}
|
| 614 |
+
} catch (err) {
|
| 615 |
+
console.warn('Failed to fetch model state:', err);
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
function startPolling() {
|
| 620 |
+
if (pollTimer) return;
|
| 621 |
+
pollDeadline = Date.now() + 120_000; // 2 min timeout
|
| 622 |
+
pollTimer = setInterval(() => {
|
| 623 |
+
if (Date.now() > pollDeadline) {
|
| 624 |
+
clearInterval(pollTimer);
|
| 625 |
+
pollTimer = null;
|
| 626 |
+
modelUI.applyError.textContent =
|
| 627 |
+
'Model load timed out after 2 minutes. Check server logs.';
|
| 628 |
+
setHidden(modelUI.applyError, false);
|
| 629 |
+
return;
|
| 630 |
+
}
|
| 631 |
+
fetchModelState();
|
| 632 |
+
}, 1000);
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
async function applyModel() {
|
| 636 |
+
setHidden(modelUI.applyError, true);
|
| 637 |
+
modelUI.applyBtn.disabled = true;
|
| 638 |
+
|
| 639 |
+
try {
|
| 640 |
+
const resp = await fetch('/v1/model', {
|
| 641 |
+
method: 'POST',
|
| 642 |
+
headers: {'Content-Type': 'application/json'},
|
| 643 |
+
body: JSON.stringify({
|
| 644 |
+
language: modelUI.languageSelect.value,
|
| 645 |
+
quantize: modelUI.quantizeToggle.checked,
|
| 646 |
+
}),
|
| 647 |
+
});
|
| 648 |
+
if (resp.status === 202) {
|
| 649 |
+
startPolling();
|
| 650 |
+
// Immediately refresh to show loading state.
|
| 651 |
+
fetchModelState();
|
| 652 |
+
} else {
|
| 653 |
+
const body = await resp.json();
|
| 654 |
+
modelUI.applyError.textContent =
|
| 655 |
+
body.error || `Server returned ${resp.status}`;
|
| 656 |
+
setHidden(modelUI.applyError, false);
|
| 657 |
+
// Revert dropdown to the normalized active value so we never
|
| 658 |
+
// leave the select on an empty string when the backend reports
|
| 659 |
+
// the default model with value=null.
|
| 660 |
+
if (currentModelState) {
|
| 661 |
+
modelUI.languageSelect.value =
|
| 662 |
+
effectiveActiveValue(currentModelState);
|
| 663 |
+
}
|
| 664 |
+
updateApplyButton();
|
| 665 |
+
}
|
| 666 |
+
} catch (err) {
|
| 667 |
+
modelUI.applyError.textContent = `Apply failed: ${err.message}`;
|
| 668 |
+
setHidden(modelUI.applyError, false);
|
| 669 |
+
updateApplyButton();
|
| 670 |
+
}
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
modelUI.languageSelect.addEventListener('change', updateApplyButton);
|
| 674 |
+
modelUI.quantizeToggle.addEventListener('change', updateApplyButton);
|
| 675 |
+
modelUI.applyBtn.addEventListener('click', applyModel);
|
| 676 |
+
|
| 677 |
+
// Kick off on page load.
|
| 678 |
+
fetchModelState().then(() => {
|
| 679 |
+
if (currentModelState?.loading) startPolling();
|
| 680 |
+
});
|
|
@@ -29,9 +29,70 @@
|
|
| 29 |
/>
|
| 30 |
<h1>Pocket TTS</h1>
|
| 31 |
<p class="subtitle">Real-time local speech synthesis & voice cloning</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
</header>
|
| 33 |
|
| 34 |
<div class="content">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
<div class="control-group">
|
| 36 |
<label for="text-input">Text Prompt</label>
|
| 37 |
<textarea
|
|
|
|
| 29 |
/>
|
| 30 |
<h1>Pocket TTS</h1>
|
| 31 |
<p class="subtitle">Real-time local speech synthesis & voice cloning</p>
|
| 32 |
+
<p
|
| 33 |
+
class="version-strip"
|
| 34 |
+
data-server-version="{{ versions.server }}"
|
| 35 |
+
data-pocket-tts-version="{{ versions.pocket_tts }}"
|
| 36 |
+
>
|
| 37 |
+
Server v{{ versions.server }}
|
| 38 |
+
· pocket-tts v{{ versions.pocket_tts }}
|
| 39 |
+
· <a
|
| 40 |
+
href="https://github.com/teddybear082/pocket-tts-openai_streaming_server"
|
| 41 |
+
target="_blank"
|
| 42 |
+
rel="noopener"
|
| 43 |
+
>GitHub ↗</a>
|
| 44 |
+
</p>
|
| 45 |
</header>
|
| 46 |
|
| 47 |
<div class="content">
|
| 48 |
+
<details class="model-settings" id="model-settings">
|
| 49 |
+
<summary class="model-settings-summary">
|
| 50 |
+
<span class="disclosure-caret" aria-hidden="true">▸</span>
|
| 51 |
+
<span class="summary-label">
|
| 52 |
+
Model: <span id="active-model-label">loading…</span>
|
| 53 |
+
</span>
|
| 54 |
+
<span class="model-badge" id="quantize-badge" hidden>int8</span>
|
| 55 |
+
<span class="model-badge session-badge" id="session-badge" hidden>session override</span>
|
| 56 |
+
<span class="loading-indicator" id="loading-indicator" hidden>
|
| 57 |
+
<span class="spinner-small"></span>
|
| 58 |
+
<span id="loading-target-label"></span>
|
| 59 |
+
</span>
|
| 60 |
+
</summary>
|
| 61 |
+
<div class="model-settings-body">
|
| 62 |
+
<div class="control-group">
|
| 63 |
+
<label for="language-select">Language</label>
|
| 64 |
+
<select id="language-select">
|
| 65 |
+
<!-- Populated by JS from /v1/model -->
|
| 66 |
+
</select>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="control-group">
|
| 69 |
+
<label class="inline-label">
|
| 70 |
+
<input type="checkbox" id="quantize-toggle" />
|
| 71 |
+
Int8 quantization (lower memory, ~30% faster on CPU)
|
| 72 |
+
</label>
|
| 73 |
+
</div>
|
| 74 |
+
<div id="non-english-warning" class="info-banner warning" hidden>
|
| 75 |
+
Built-in voices are English only. For best results in other languages,
|
| 76 |
+
upload a reference audio sample that matches the target language.
|
| 77 |
+
Cloned voices are cached per-model, so switching back to a previously-used
|
| 78 |
+
model is instant.
|
| 79 |
+
</div>
|
| 80 |
+
<div id="model-path-locked-notice" class="info-banner info" hidden>
|
| 81 |
+
Server started with a custom model path. Restart without
|
| 82 |
+
<code>--model-path</code> to enable language switching.
|
| 83 |
+
</div>
|
| 84 |
+
<button type="button" id="apply-model-btn" class="btn-secondary" disabled>
|
| 85 |
+
Apply
|
| 86 |
+
</button>
|
| 87 |
+
<div id="session-only-notice" class="info-banner info" hidden>
|
| 88 |
+
ⓘ Session-only change. On server restart, the model will revert to the
|
| 89 |
+
startup configuration (<code>POCKET_TTS_LANGUAGE</code> env var or
|
| 90 |
+
<code>--language</code> CLI flag).
|
| 91 |
+
</div>
|
| 92 |
+
<div id="apply-error" class="info-banner error" hidden></div>
|
| 93 |
+
</div>
|
| 94 |
+
</details>
|
| 95 |
+
|
| 96 |
<div class="control-group">
|
| 97 |
<label for="text-input">Text Prompt</label>
|
| 98 |
<textarea
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared pytest fixtures."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from unittest.mock import MagicMock
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def tmp_voices(tmp_path: Path) -> Path:
|
| 11 |
+
"""Temporary voices directory (simulates user-provided audio)."""
|
| 12 |
+
d = tmp_path / 'voices'
|
| 13 |
+
d.mkdir()
|
| 14 |
+
return d
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def tmp_cache(tmp_path: Path) -> Path:
|
| 19 |
+
"""Temporary voice cache directory (simulates writable cache volume)."""
|
| 20 |
+
d = tmp_path / 'voice_cache'
|
| 21 |
+
d.mkdir()
|
| 22 |
+
return d
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.fixture(autouse=True)
|
| 26 |
+
def reset_tts_singleton():
|
| 27 |
+
"""Reset the TTSService singleton between tests."""
|
| 28 |
+
import app.services.tts as tts_module
|
| 29 |
+
|
| 30 |
+
tts_module._tts_service = None
|
| 31 |
+
yield
|
| 32 |
+
tts_module._tts_service = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture
|
| 36 |
+
def mock_tts_model():
|
| 37 |
+
"""Mock pocket-tts TTSModel for unit tests."""
|
| 38 |
+
model = MagicMock()
|
| 39 |
+
model.sample_rate = 24000
|
| 40 |
+
model.device = 'cpu'
|
| 41 |
+
return model
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for app.config constants."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from app.config import Config
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_supported_languages_contains_all_yaml_configs():
|
| 9 |
+
"""All 12 language YAMLs from pocket-tts v2.0.0 must be listed."""
|
| 10 |
+
expected = {
|
| 11 |
+
'english',
|
| 12 |
+
'english_2026-01',
|
| 13 |
+
'english_2026-04',
|
| 14 |
+
'french_24l',
|
| 15 |
+
'german',
|
| 16 |
+
'german_24l',
|
| 17 |
+
'italian',
|
| 18 |
+
'italian_24l',
|
| 19 |
+
'portuguese',
|
| 20 |
+
'portuguese_24l',
|
| 21 |
+
'spanish',
|
| 22 |
+
'spanish_24l',
|
| 23 |
+
}
|
| 24 |
+
assert set(Config.SUPPORTED_LANGUAGES) == expected
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_legacy_aliases_canonicalize_english_variants():
|
| 28 |
+
"""english and english_2026-01 both resolve to english_2026-04 for cache tagging."""
|
| 29 |
+
assert Config.LEGACY_MODEL_ALIASES == {
|
| 30 |
+
'english': 'english_2026-04',
|
| 31 |
+
'english_2026-01': 'english_2026-04',
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_alias_targets_are_supported_languages():
|
| 36 |
+
"""Every alias target must itself be a valid supported language."""
|
| 37 |
+
for target in Config.LEGACY_MODEL_ALIASES.values():
|
| 38 |
+
assert target in Config.SUPPORTED_LANGUAGES
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_voice_cache_dir_defaults_to_base_path_subdir():
|
| 42 |
+
"""Default cache dir sits under BASE_PATH."""
|
| 43 |
+
cache_dir = Path(Config.VOICE_CACHE_DIR)
|
| 44 |
+
assert cache_dir.name == 'voice_cache'
|
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for /v1/model routes."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app import create_app
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def app():
|
| 12 |
+
return create_app()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def client(app):
|
| 17 |
+
return app.test_client()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def mock_tts_service():
|
| 22 |
+
"""Install a mock TTSService for route tests."""
|
| 23 |
+
import app.services.tts as tts_module
|
| 24 |
+
|
| 25 |
+
service = MagicMock()
|
| 26 |
+
service.is_loaded = True
|
| 27 |
+
service.sample_rate = 24000
|
| 28 |
+
service.device = 'cpu'
|
| 29 |
+
service.voices_dir = None
|
| 30 |
+
service._active = {'source': 'language', 'value': 'english', 'quantize': False}
|
| 31 |
+
service._boot_active = {'source': 'language', 'value': 'english', 'quantize': False}
|
| 32 |
+
service._loading = False
|
| 33 |
+
service._last_reload_error = None
|
| 34 |
+
service._loading_target = None
|
| 35 |
+
service.validate_voice.return_value = (True, 'ok')
|
| 36 |
+
tts_module._tts_service = service
|
| 37 |
+
yield service
|
| 38 |
+
tts_module._tts_service = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_get_model_returns_active_state(client, mock_tts_service):
|
| 42 |
+
resp = client.get('/v1/model')
|
| 43 |
+
assert resp.status_code == 200
|
| 44 |
+
body = resp.get_json()
|
| 45 |
+
assert body['active'] == {'source': 'language', 'value': 'english', 'quantize': False}
|
| 46 |
+
assert body['boot'] == {'source': 'language', 'value': 'english', 'quantize': False}
|
| 47 |
+
assert body['differs_from_boot'] is False
|
| 48 |
+
assert body['loading'] is False
|
| 49 |
+
assert body['model_path_locked'] is False
|
| 50 |
+
assert 'english' in body['available_languages']
|
| 51 |
+
assert len(body['available_languages']) == 12
|
| 52 |
+
assert 'server_version' in body
|
| 53 |
+
assert 'pocket_tts_version' in body
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_get_model_reports_differs_from_boot(client, mock_tts_service):
|
| 57 |
+
mock_tts_service._active = {'source': 'language', 'value': 'german_24l', 'quantize': True}
|
| 58 |
+
resp = client.get('/v1/model')
|
| 59 |
+
assert resp.get_json()['differs_from_boot'] is True
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_get_model_reports_model_path_locked(client, mock_tts_service):
|
| 63 |
+
mock_tts_service._boot_active = {'source': 'model_path', 'value': '/x.yaml', 'quantize': False}
|
| 64 |
+
mock_tts_service._active = mock_tts_service._boot_active
|
| 65 |
+
resp = client.get('/v1/model')
|
| 66 |
+
assert resp.get_json()['model_path_locked'] is True
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_post_model_returns_202_and_starts_load(client, mock_tts_service):
|
| 70 |
+
resp = client.post('/v1/model', json={'language': 'german_24l', 'quantize': True})
|
| 71 |
+
assert resp.status_code == 202
|
| 72 |
+
body = resp.get_json()
|
| 73 |
+
assert body['loading_target'] == {'value': 'german_24l', 'quantize': True}
|
| 74 |
+
mock_tts_service.reload_model_async.assert_called_once_with(
|
| 75 |
+
language='german_24l',
|
| 76 |
+
quantize=True,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_post_model_rejects_unknown_language(client, mock_tts_service):
|
| 81 |
+
resp = client.post('/v1/model', json={'language': 'klingon', 'quantize': False})
|
| 82 |
+
assert resp.status_code == 400
|
| 83 |
+
assert 'klingon' in resp.get_json()['error']
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_post_model_409_when_already_loading(client, mock_tts_service):
|
| 87 |
+
"""Route relies on reload_model_async's atomic claim returning False
|
| 88 |
+
when a reload is already in progress, not on its own pre-check."""
|
| 89 |
+
mock_tts_service.reload_model_async.return_value = False
|
| 90 |
+
resp = client.post('/v1/model', json={'language': 'german_24l', 'quantize': False})
|
| 91 |
+
assert resp.status_code == 409
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_post_model_403_when_model_path_locked(client, mock_tts_service):
|
| 95 |
+
mock_tts_service._boot_active = {'source': 'model_path', 'value': '/x', 'quantize': False}
|
| 96 |
+
resp = client.post('/v1/model', json={'language': 'german_24l', 'quantize': False})
|
| 97 |
+
assert resp.status_code == 403
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_post_model_400_on_missing_language(client, mock_tts_service):
|
| 101 |
+
resp = client.post('/v1/model', json={'quantize': True})
|
| 102 |
+
assert resp.status_code == 400
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_post_model_400_on_empty_body(client, mock_tts_service):
|
| 106 |
+
resp = client.post('/v1/model', json={})
|
| 107 |
+
assert resp.status_code == 400
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_post_model_400_on_non_dict_body(client, mock_tts_service):
|
| 111 |
+
"""A JSON value that isn't an object (e.g. a number, string, list) must be
|
| 112 |
+
rejected without raising AttributeError on .get()."""
|
| 113 |
+
resp = client.post('/v1/model', data='42', content_type='application/json')
|
| 114 |
+
assert resp.status_code == 400
|
| 115 |
+
assert 'object' in resp.get_json()['error'].lower()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_speech_400_on_non_dict_body(client, mock_tts_service):
|
| 119 |
+
resp = client.post('/v1/audio/speech', data='"hello"', content_type='application/json')
|
| 120 |
+
assert resp.status_code == 400
|
| 121 |
+
assert 'object' in resp.get_json()['error'].lower()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_post_model_400_on_non_bool_quantize(client, mock_tts_service):
|
| 125 |
+
"""JSON booleans only — string 'false' would be truthy under bool() coercion."""
|
| 126 |
+
resp = client.post('/v1/model', json={'language': 'german_24l', 'quantize': 'false'})
|
| 127 |
+
assert resp.status_code == 400
|
| 128 |
+
assert 'boolean' in resp.get_json()['error'].lower()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_post_model_accepts_omitted_quantize(client, mock_tts_service):
|
| 132 |
+
"""Omitted quantize defaults to False without triggering the type check."""
|
| 133 |
+
resp = client.post('/v1/model', json={'language': 'german_24l'})
|
| 134 |
+
assert resp.status_code == 202
|
| 135 |
+
mock_tts_service.reload_model_async.assert_called_once_with(
|
| 136 |
+
language='german_24l', quantize=False
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_speech_returns_503_when_loading(client, mock_tts_service):
|
| 141 |
+
mock_tts_service._loading = True
|
| 142 |
+
resp = client.post('/v1/audio/speech', json={'input': 'hi', 'voice': 'alba'})
|
| 143 |
+
assert resp.status_code == 503
|
| 144 |
+
assert 'reloading' in resp.get_json()['error'].lower()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_speech_does_not_500_when_resolve_raises_in_mismatch_path(client, mock_tts_service):
|
| 148 |
+
"""If get_voice_state raises ValueError AND _resolve_voice_path itself
|
| 149 |
+
raises (e.g. SSRF protection on http://), the mismatch detection must
|
| 150 |
+
not propagate the second exception — it should fall through to a clean
|
| 151 |
+
400 with the original error message."""
|
| 152 |
+
mock_tts_service.validate_voice.return_value = (True, 'ok')
|
| 153 |
+
mock_tts_service.get_voice_state.side_effect = ValueError('Voice could not be loaded: nope')
|
| 154 |
+
mock_tts_service._resolve_voice_path.side_effect = ValueError('URL scheme not allowed')
|
| 155 |
+
|
| 156 |
+
resp = client.post(
|
| 157 |
+
'/v1/audio/speech', json={'input': 'hi', 'voice': 'http://evil.example/voice.wav'}
|
| 158 |
+
)
|
| 159 |
+
assert resp.status_code == 400
|
| 160 |
+
assert resp.get_json()['error'].startswith('Voice could not be loaded')
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_speech_voice_model_mismatch_returns_400_with_code(client, mock_tts_service, tmp_path):
|
| 164 |
+
"""When the resolved voice is a legacy unlabeled .safetensors and pocket-tts
|
| 165 |
+
raises a shape-mismatch during load, the route surfaces a helpful error."""
|
| 166 |
+
# Simulate: validate_voice succeeds, get_voice_state raises.
|
| 167 |
+
legacy = tmp_path / 'emma.safetensors'
|
| 168 |
+
legacy.write_bytes(b'x')
|
| 169 |
+
mock_tts_service.validate_voice.return_value = (True, 'ok')
|
| 170 |
+
mock_tts_service._resolve_voice_path.return_value = str(legacy)
|
| 171 |
+
mock_tts_service.get_voice_state.side_effect = ValueError(
|
| 172 |
+
"Voice 'emma' could not be loaded: RuntimeError: size mismatch for layer.0.weight"
|
| 173 |
+
)
|
| 174 |
+
resp = client.post('/v1/audio/speech', json={'input': 'hi', 'voice': 'emma'})
|
| 175 |
+
assert resp.status_code == 400
|
| 176 |
+
body = resp.get_json()
|
| 177 |
+
assert body['error'] == 'voice_model_mismatch'
|
| 178 |
+
assert body['voice'] == 'emma'
|
| 179 |
+
assert body['active_model'] == 'english'
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_health_includes_active_model(client, mock_tts_service):
|
| 183 |
+
resp = client.get('/health')
|
| 184 |
+
body = resp.get_json()
|
| 185 |
+
assert body['active_model'] == {
|
| 186 |
+
'source': 'language',
|
| 187 |
+
'value': 'english',
|
| 188 |
+
'quantize': False,
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_home_passes_versions_to_template(client, mock_tts_service):
|
| 193 |
+
resp = client.get('/')
|
| 194 |
+
assert resp.status_code == 200
|
| 195 |
+
html = resp.data.decode()
|
| 196 |
+
assert 'data-server-version=' in html
|
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for TTSService state and load_model."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app.services.tts import TTSService
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def service():
|
| 12 |
+
return TTSService()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_service_has_lock_and_flag_on_init(service):
|
| 16 |
+
import threading
|
| 17 |
+
|
| 18 |
+
# threading.Lock() returns a _thread.lock instance; check via acquire/release protocol
|
| 19 |
+
assert isinstance(service._lock, type(threading.Lock()))
|
| 20 |
+
assert service._loading is False
|
| 21 |
+
assert service._active is None
|
| 22 |
+
assert service._boot_active is None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 26 |
+
def test_load_model_with_language_populates_active(_ensure, service, mock_tts_model):
|
| 27 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 28 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 29 |
+
service.load_model(language='german_24l', quantize=True)
|
| 30 |
+
|
| 31 |
+
assert service._active == {
|
| 32 |
+
'source': 'language',
|
| 33 |
+
'value': 'german_24l',
|
| 34 |
+
'quantize': True,
|
| 35 |
+
}
|
| 36 |
+
MockModel.load_model.assert_called_once_with(language='german_24l', quantize=True)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 40 |
+
def test_boot_load_snapshots_active_to_boot_active(_ensure, service, mock_tts_model):
|
| 41 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 42 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 43 |
+
service.load_model(language='english', quantize=False)
|
| 44 |
+
|
| 45 |
+
assert service._boot_active == service._active
|
| 46 |
+
assert service._boot_active['value'] == 'english'
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 50 |
+
def test_non_boot_load_does_not_mutate_boot_active(_ensure, service, mock_tts_model):
|
| 51 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 52 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 53 |
+
service.load_model(language='english', quantize=False)
|
| 54 |
+
boot_snapshot = dict(service._boot_active)
|
| 55 |
+
|
| 56 |
+
# Second load with _is_boot=False (reload flow).
|
| 57 |
+
service.load_model(language='german_24l', quantize=True, _is_boot=False)
|
| 58 |
+
|
| 59 |
+
assert service._boot_active == boot_snapshot
|
| 60 |
+
assert service._active['value'] == 'german_24l'
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 64 |
+
def test_load_model_with_model_path_sets_source(_ensure, service, mock_tts_model):
|
| 65 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 66 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 67 |
+
service.load_model(model_path='/custom/path.yaml', quantize=False)
|
| 68 |
+
|
| 69 |
+
assert service._active['source'] == 'model_path'
|
| 70 |
+
assert service._active['value'] == '/custom/path.yaml'
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 74 |
+
def test_load_model_default_has_default_source(_ensure, service, mock_tts_model):
|
| 75 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 76 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 77 |
+
service.load_model(quantize=False)
|
| 78 |
+
|
| 79 |
+
assert service._active['source'] == 'default'
|
| 80 |
+
assert service._active['value'] is None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_service_initializes_cache_dir(tmp_path, monkeypatch):
|
| 84 |
+
cache = tmp_path / 'voice_cache'
|
| 85 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 86 |
+
service = TTSService()
|
| 87 |
+
# Lazy create on first use is fine — just verify the path is stored
|
| 88 |
+
assert service.cache_dir == cache
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_service_cache_dir_mkdir_on_first_save(tmp_path, monkeypatch):
|
| 92 |
+
cache = tmp_path / 'voice_cache' # does not exist yet
|
| 93 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 94 |
+
service = TTSService()
|
| 95 |
+
service._ensure_cache_dir()
|
| 96 |
+
assert cache.is_dir()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_service_cache_dir_read_only_is_tolerated(tmp_path, monkeypatch, caplog):
|
| 100 |
+
"""If mkdir raises (read-only FS), log and disable caching."""
|
| 101 |
+
import os
|
| 102 |
+
|
| 103 |
+
ro_parent = tmp_path / 'ro'
|
| 104 |
+
ro_parent.mkdir()
|
| 105 |
+
os.chmod(ro_parent, 0o500) # r-x, not writable
|
| 106 |
+
cache = ro_parent / 'voice_cache'
|
| 107 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 108 |
+
service = TTSService()
|
| 109 |
+
service._ensure_cache_dir()
|
| 110 |
+
assert service.cache_dir is None
|
| 111 |
+
os.chmod(ro_parent, 0o700) # restore for cleanup
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 115 |
+
def test_resolve_voice_path_uses_active_model(_ensure, tmp_path, monkeypatch, mock_tts_model):
|
| 116 |
+
voices = tmp_path / 'voices'
|
| 117 |
+
voices.mkdir()
|
| 118 |
+
cache = tmp_path / 'voice_cache'
|
| 119 |
+
cache.mkdir()
|
| 120 |
+
(cache / 'emma.german_24l.safetensors').write_bytes(b'x')
|
| 121 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 122 |
+
|
| 123 |
+
service = TTSService()
|
| 124 |
+
service.set_voices_dir(str(voices))
|
| 125 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 126 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 127 |
+
service.load_model(language='german_24l', quantize=False)
|
| 128 |
+
|
| 129 |
+
resolved = service._resolve_voice_path('emma')
|
| 130 |
+
assert resolved == str(cache / 'emma.german_24l.safetensors')
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 134 |
+
def test_resolve_voice_path_accepts_filename_with_extension(
|
| 135 |
+
_ensure, tmp_path, monkeypatch, mock_tts_model
|
| 136 |
+
):
|
| 137 |
+
"""Backwards-compat: passing 'emma.wav' (with extension) should resolve to
|
| 138 |
+
the existing file, not get joined with another extension."""
|
| 139 |
+
voices = tmp_path / 'voices'
|
| 140 |
+
voices.mkdir()
|
| 141 |
+
cache = tmp_path / 'voice_cache'
|
| 142 |
+
cache.mkdir()
|
| 143 |
+
(voices / 'emma.wav').write_bytes(b'fake-audio')
|
| 144 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 145 |
+
|
| 146 |
+
service = TTSService()
|
| 147 |
+
service.set_voices_dir(str(voices))
|
| 148 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 149 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 150 |
+
service.load_model(language='english', quantize=False)
|
| 151 |
+
|
| 152 |
+
resolved = service._resolve_voice_path('emma.wav')
|
| 153 |
+
assert resolved == str(voices / 'emma.wav')
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 157 |
+
def test_reload_model_swaps_and_clears_voice_cache(_ensure, service, mock_tts_model):
|
| 158 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 159 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 160 |
+
service.load_model(language='english', quantize=False)
|
| 161 |
+
service.voice_cache['stale'] = {'fake': 'state'}
|
| 162 |
+
|
| 163 |
+
new_model = MagicMock()
|
| 164 |
+
new_model.sample_rate = 24000
|
| 165 |
+
new_model.device = 'cpu'
|
| 166 |
+
MockModel.load_model.return_value = new_model
|
| 167 |
+
service.reload_model(language='german_24l', quantize=True)
|
| 168 |
+
|
| 169 |
+
assert service.model is new_model
|
| 170 |
+
assert service.voice_cache == {}
|
| 171 |
+
assert service._active['value'] == 'german_24l'
|
| 172 |
+
assert service._active['quantize'] is True
|
| 173 |
+
assert service._boot_active['value'] == 'english' # unchanged
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 177 |
+
def test_reload_model_rejects_if_boot_used_model_path(_ensure, service, mock_tts_model):
|
| 178 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 179 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 180 |
+
service.load_model(model_path='/custom/x.yaml', quantize=False)
|
| 181 |
+
|
| 182 |
+
with pytest.raises(RuntimeError, match='model_path'):
|
| 183 |
+
service.reload_model(language='german_24l', quantize=False)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 187 |
+
def test_reload_model_rejects_unknown_language(_ensure, service, mock_tts_model):
|
| 188 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 189 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 190 |
+
service.load_model(language='english', quantize=False)
|
| 191 |
+
|
| 192 |
+
with pytest.raises(ValueError, match='klingon'):
|
| 193 |
+
service.reload_model(language='klingon', quantize=False)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 197 |
+
def test_reload_model_rejects_if_already_loading(_ensure, service, mock_tts_model):
|
| 198 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 199 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 200 |
+
service.load_model(language='english', quantize=False)
|
| 201 |
+
|
| 202 |
+
service._loading = True
|
| 203 |
+
with pytest.raises(RuntimeError, match='already loading'):
|
| 204 |
+
service.reload_model(language='german_24l', quantize=False)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 208 |
+
def test_reload_model_async_returns_false_when_already_loading(_ensure, service, mock_tts_model):
|
| 209 |
+
"""Async API uses an atomic check-and-set so concurrent callers can't both
|
| 210 |
+
win the claim. The loser gets False instead of an exception so the route
|
| 211 |
+
can map it directly to 409."""
|
| 212 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 213 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 214 |
+
service.load_model(language='english', quantize=False)
|
| 215 |
+
|
| 216 |
+
service._loading = True
|
| 217 |
+
started = service.reload_model_async(language='german_24l', quantize=False)
|
| 218 |
+
assert started is False
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 222 |
+
def test_reload_model_async_validation_still_raises(_ensure, service, mock_tts_model):
|
| 223 |
+
"""Validation errors are still raised synchronously so the route can
|
| 224 |
+
return 400/403 instead of 409 — only the in-progress check is silent."""
|
| 225 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 226 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 227 |
+
service.load_model(language='english', quantize=False)
|
| 228 |
+
|
| 229 |
+
with pytest.raises(ValueError, match='klingon'):
|
| 230 |
+
service.reload_model_async(language='klingon', quantize=False)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 234 |
+
def test_reload_model_restores_previous_on_failure(_ensure, service, mock_tts_model):
|
| 235 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 236 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 237 |
+
service.load_model(language='english', quantize=False)
|
| 238 |
+
original_model = service.model
|
| 239 |
+
|
| 240 |
+
MockModel.load_model.side_effect = RuntimeError('weights corrupted')
|
| 241 |
+
with pytest.raises(RuntimeError, match='weights corrupted'):
|
| 242 |
+
service.reload_model(language='german_24l', quantize=False)
|
| 243 |
+
|
| 244 |
+
assert service.model is original_model
|
| 245 |
+
assert service._active['value'] == 'english'
|
| 246 |
+
assert service._loading is False
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 250 |
+
def test_generate_audio_raises_when_loading(_ensure, service, mock_tts_model):
|
| 251 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 252 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 253 |
+
service.load_model(language='english', quantize=False)
|
| 254 |
+
service._loading = True
|
| 255 |
+
with pytest.raises(RuntimeError, match='model reloading'):
|
| 256 |
+
service.generate_audio(voice_state={}, text='hi')
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 260 |
+
def test_get_voice_state_saves_clone_to_cache(_ensure, tmp_path, monkeypatch, mock_tts_model):
|
| 261 |
+
voices = tmp_path / 'voices'
|
| 262 |
+
voices.mkdir()
|
| 263 |
+
cache = tmp_path / 'voice_cache'
|
| 264 |
+
(voices / 'emma.wav').write_bytes(b'fake-audio')
|
| 265 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 266 |
+
|
| 267 |
+
service = TTSService()
|
| 268 |
+
service.set_voices_dir(str(voices))
|
| 269 |
+
mock_tts_model.get_state_for_audio_prompt.return_value = {'fake': 'state'}
|
| 270 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 271 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 272 |
+
service.load_model(language='english', quantize=False)
|
| 273 |
+
|
| 274 |
+
with patch('app.services.tts.export_model_state') as mock_export:
|
| 275 |
+
state = service.get_voice_state('emma')
|
| 276 |
+
|
| 277 |
+
assert state == {'fake': 'state'}
|
| 278 |
+
mock_export.assert_called_once()
|
| 279 |
+
# Called with state and expected cache path
|
| 280 |
+
args = mock_export.call_args.args
|
| 281 |
+
assert args[0] == {'fake': 'state'}
|
| 282 |
+
assert str(args[1]).endswith('emma.english_2026-04.safetensors')
|
| 283 |
+
assert (cache).is_dir()
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 287 |
+
def test_get_voice_state_regenerates_when_source_newer(
|
| 288 |
+
_ensure, tmp_path, monkeypatch, mock_tts_model
|
| 289 |
+
):
|
| 290 |
+
import os
|
| 291 |
+
import time
|
| 292 |
+
|
| 293 |
+
voices = tmp_path / 'voices'
|
| 294 |
+
voices.mkdir()
|
| 295 |
+
cache = tmp_path / 'voice_cache'
|
| 296 |
+
cache.mkdir()
|
| 297 |
+
stale = cache / 'emma.english_2026-04.safetensors'
|
| 298 |
+
stale.write_bytes(b'old')
|
| 299 |
+
os.utime(stale, (time.time() - 100, time.time() - 100))
|
| 300 |
+
(voices / 'emma.wav').write_bytes(b'new-audio') # mtime = now
|
| 301 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 302 |
+
|
| 303 |
+
service = TTSService()
|
| 304 |
+
service.set_voices_dir(str(voices))
|
| 305 |
+
mock_tts_model.get_state_for_audio_prompt.return_value = {'fresh': 'state'}
|
| 306 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 307 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 308 |
+
service.load_model(language='english', quantize=False)
|
| 309 |
+
|
| 310 |
+
with patch('app.services.tts.export_model_state') as mock_export:
|
| 311 |
+
state = service.get_voice_state('emma')
|
| 312 |
+
|
| 313 |
+
assert state == {'fresh': 'state'}
|
| 314 |
+
# Should have called get_state_for_audio_prompt with the .wav file, not the stale cache
|
| 315 |
+
call_arg = mock_tts_model.get_state_for_audio_prompt.call_args.args[0]
|
| 316 |
+
assert str(call_arg).endswith('emma.wav')
|
| 317 |
+
mock_export.assert_called_once()
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 321 |
+
def test_get_voice_state_stem_with_dot_regenerates_correctly(
|
| 322 |
+
_ensure, tmp_path, monkeypatch, mock_tts_model
|
| 323 |
+
):
|
| 324 |
+
"""A voice stem containing dots (e.g. 'John.Doe') must be parsed via the
|
| 325 |
+
known_model_tags helper so the staleness check finds the right source
|
| 326 |
+
file. Naive split('.', 1)[0] would yield 'John' and miss 'John.Doe.wav'."""
|
| 327 |
+
import os
|
| 328 |
+
import time
|
| 329 |
+
|
| 330 |
+
voices = tmp_path / 'voices'
|
| 331 |
+
voices.mkdir()
|
| 332 |
+
cache = tmp_path / 'voice_cache'
|
| 333 |
+
cache.mkdir()
|
| 334 |
+
stale = cache / 'John.Doe.english_2026-04.safetensors'
|
| 335 |
+
stale.write_bytes(b'old')
|
| 336 |
+
os.utime(stale, (time.time() - 100, time.time() - 100))
|
| 337 |
+
(voices / 'John.Doe.wav').write_bytes(b'new-audio')
|
| 338 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 339 |
+
|
| 340 |
+
service = TTSService()
|
| 341 |
+
service.set_voices_dir(str(voices))
|
| 342 |
+
mock_tts_model.get_state_for_audio_prompt.return_value = {'fresh': 'state'}
|
| 343 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 344 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 345 |
+
service.load_model(language='english', quantize=False)
|
| 346 |
+
|
| 347 |
+
with patch('app.services.tts.export_model_state'):
|
| 348 |
+
service.get_voice_state('John.Doe')
|
| 349 |
+
|
| 350 |
+
call_arg = mock_tts_model.get_state_for_audio_prompt.call_args.args[0]
|
| 351 |
+
assert str(call_arg).endswith('John.Doe.wav')
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
@patch('app.services.tts._ensure_pocket_tts')
|
| 355 |
+
def test_list_voices_collapses_per_stem(_ensure, tmp_path, monkeypatch, mock_tts_model):
|
| 356 |
+
voices = tmp_path / 'voices'
|
| 357 |
+
voices.mkdir()
|
| 358 |
+
cache = tmp_path / 'voice_cache'
|
| 359 |
+
cache.mkdir()
|
| 360 |
+
(voices / 'emma.wav').write_bytes(b'a')
|
| 361 |
+
(voices / 'emma.safetensors').write_bytes(b'a')
|
| 362 |
+
(cache / 'emma.english_2026-04.safetensors').write_bytes(b'a')
|
| 363 |
+
(cache / 'emma.german_24l.safetensors').write_bytes(b'a')
|
| 364 |
+
(voices / 'morgan.mp3').write_bytes(b'a')
|
| 365 |
+
monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
|
| 366 |
+
|
| 367 |
+
service = TTSService()
|
| 368 |
+
service.set_voices_dir(str(voices))
|
| 369 |
+
with patch('app.services.tts.TTSModel') as MockModel:
|
| 370 |
+
MockModel.load_model.return_value = mock_tts_model
|
| 371 |
+
service.load_model(language='english', quantize=False)
|
| 372 |
+
|
| 373 |
+
voices_list = service.list_voices()
|
| 374 |
+
custom_ids = [v['id'] for v in voices_list if v['type'] == 'custom']
|
| 375 |
+
assert custom_ids == ['emma', 'morgan']
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for version helpers."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import patch
|
| 4 |
+
|
| 5 |
+
from app.services.versions import get_versions
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_get_versions_returns_both():
|
| 9 |
+
result = get_versions()
|
| 10 |
+
assert 'server' in result
|
| 11 |
+
assert 'pocket_tts' in result
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_get_versions_handles_missing_package():
|
| 15 |
+
"""When importlib metadata is unavailable, server falls back to the
|
| 16 |
+
hardcoded `app.__version__` constant; pocket_tts has no such fallback
|
| 17 |
+
and reports 'unknown'."""
|
| 18 |
+
from importlib.metadata import PackageNotFoundError
|
| 19 |
+
|
| 20 |
+
from app import __version__ as server_fallback
|
| 21 |
+
|
| 22 |
+
get_versions.cache_clear()
|
| 23 |
+
with patch('app.services.versions.version', side_effect=PackageNotFoundError):
|
| 24 |
+
result = get_versions()
|
| 25 |
+
assert result == {'server': server_fallback, 'pocket_tts': 'unknown'}
|
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for voice cache filename parsing and resolution."""
|
| 2 |
+
|
| 3 |
+
from app.services.voice_cache import (
|
| 4 |
+
active_model_tag,
|
| 5 |
+
cache_is_stale,
|
| 6 |
+
known_model_tags,
|
| 7 |
+
list_voice_stems,
|
| 8 |
+
parse_safetensors_name,
|
| 9 |
+
resolve_voice_path,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_active_model_tag_returns_raw_when_not_aliased():
|
| 14 |
+
assert active_model_tag('german_24l') == 'german_24l'
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_active_model_tag_canonicalizes_english_alias():
|
| 18 |
+
assert active_model_tag('english') == 'english_2026-04'
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_active_model_tag_canonicalizes_english_2026_01():
|
| 22 |
+
assert active_model_tag('english_2026-01') == 'english_2026-04'
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_known_model_tags_includes_all_supported_plus_alias_targets():
|
| 26 |
+
tags = known_model_tags()
|
| 27 |
+
assert 'english_2026-04' in tags
|
| 28 |
+
assert 'german_24l' in tags
|
| 29 |
+
assert 'french_24l' in tags
|
| 30 |
+
# Alias keys are also included so users can name files with them.
|
| 31 |
+
assert 'english' in tags
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_parse_safetensors_name_recognizes_tagged_file():
|
| 35 |
+
tags = known_model_tags()
|
| 36 |
+
stem, tag = parse_safetensors_name('Emma Watson.english_2026-04.safetensors', tags)
|
| 37 |
+
assert stem == 'Emma Watson'
|
| 38 |
+
assert tag == 'english_2026-04'
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_parse_safetensors_name_legacy_unlabeled():
|
| 42 |
+
tags = known_model_tags()
|
| 43 |
+
stem, tag = parse_safetensors_name('legacy.safetensors', tags)
|
| 44 |
+
assert stem == 'legacy'
|
| 45 |
+
assert tag is None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_parse_safetensors_name_dot_in_stem_but_unknown_tag():
|
| 49 |
+
"""A filename like 'my.voice.safetensors' has 'voice' as the final segment —
|
| 50 |
+
but 'voice' isn't a known tag, so we treat the whole thing as the stem."""
|
| 51 |
+
tags = known_model_tags()
|
| 52 |
+
stem, tag = parse_safetensors_name('my.voice.safetensors', tags)
|
| 53 |
+
assert stem == 'my.voice'
|
| 54 |
+
assert tag is None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_resolve_voice_path_prefers_tagged_cache(tmp_voices, tmp_cache):
|
| 58 |
+
"""Preference: cache_dir tagged > voices_dir tagged > raw audio > legacy."""
|
| 59 |
+
(tmp_voices / 'emma.wav').write_bytes(b'fake-audio')
|
| 60 |
+
(tmp_cache / 'emma.english_2026-04.safetensors').write_bytes(b'fake-st')
|
| 61 |
+
|
| 62 |
+
result = resolve_voice_path(
|
| 63 |
+
'emma',
|
| 64 |
+
active_model='english_2026-04',
|
| 65 |
+
voices_dir=tmp_voices,
|
| 66 |
+
cache_dir=tmp_cache,
|
| 67 |
+
)
|
| 68 |
+
assert result == tmp_cache / 'emma.english_2026-04.safetensors'
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_resolve_voice_path_falls_back_to_raw_audio(tmp_voices, tmp_cache):
|
| 72 |
+
(tmp_voices / 'emma.wav').write_bytes(b'fake-audio')
|
| 73 |
+
result = resolve_voice_path(
|
| 74 |
+
'emma',
|
| 75 |
+
active_model='english_2026-04',
|
| 76 |
+
voices_dir=tmp_voices,
|
| 77 |
+
cache_dir=tmp_cache,
|
| 78 |
+
)
|
| 79 |
+
assert result == tmp_voices / 'emma.wav'
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_resolve_voice_path_legacy_unlabeled(tmp_voices, tmp_cache):
|
| 83 |
+
(tmp_voices / 'emma.safetensors').write_bytes(b'fake-st')
|
| 84 |
+
result = resolve_voice_path(
|
| 85 |
+
'emma',
|
| 86 |
+
active_model='english_2026-04',
|
| 87 |
+
voices_dir=tmp_voices,
|
| 88 |
+
cache_dir=tmp_cache,
|
| 89 |
+
)
|
| 90 |
+
assert result == tmp_voices / 'emma.safetensors'
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_resolve_voice_path_passthrough_for_builtin_name(tmp_voices, tmp_cache):
|
| 94 |
+
"""Built-in names (no matching file anywhere) pass through untouched —
|
| 95 |
+
pocket-tts handles them via HuggingFace."""
|
| 96 |
+
result = resolve_voice_path(
|
| 97 |
+
'alba',
|
| 98 |
+
active_model='english_2026-04',
|
| 99 |
+
voices_dir=tmp_voices,
|
| 100 |
+
cache_dir=tmp_cache,
|
| 101 |
+
)
|
| 102 |
+
assert result == 'alba'
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_resolve_voice_path_respects_alias(tmp_voices, tmp_cache):
|
| 106 |
+
"""Asking for 'english' should find a cache tagged 'english_2026-04'."""
|
| 107 |
+
(tmp_cache / 'emma.english_2026-04.safetensors').write_bytes(b'fake-st')
|
| 108 |
+
result = resolve_voice_path(
|
| 109 |
+
'emma',
|
| 110 |
+
active_model='english', # alias
|
| 111 |
+
voices_dir=tmp_voices,
|
| 112 |
+
cache_dir=tmp_cache,
|
| 113 |
+
)
|
| 114 |
+
assert result == tmp_cache / 'emma.english_2026-04.safetensors'
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_resolve_voice_path_finds_alias_tagged_file(tmp_voices, tmp_cache):
|
| 118 |
+
"""Files tagged with the alias itself (e.g. emma.english.safetensors) should
|
| 119 |
+
resolve when canonical-tagged file is absent — supports caches written by
|
| 120 |
+
older versions or by external tools using the alias."""
|
| 121 |
+
(tmp_cache / 'emma.english.safetensors').write_bytes(b'fake-st')
|
| 122 |
+
result = resolve_voice_path(
|
| 123 |
+
'emma',
|
| 124 |
+
active_model='english',
|
| 125 |
+
voices_dir=tmp_voices,
|
| 126 |
+
cache_dir=tmp_cache,
|
| 127 |
+
)
|
| 128 |
+
assert result == tmp_cache / 'emma.english.safetensors'
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_resolve_voice_path_canonical_preferred_over_alias(tmp_voices, tmp_cache):
|
| 132 |
+
"""When both canonical and alias-tagged files exist, prefer canonical."""
|
| 133 |
+
(tmp_cache / 'emma.english.safetensors').write_bytes(b'fake-st')
|
| 134 |
+
(tmp_cache / 'emma.english_2026-04.safetensors').write_bytes(b'fake-st')
|
| 135 |
+
result = resolve_voice_path(
|
| 136 |
+
'emma',
|
| 137 |
+
active_model='english',
|
| 138 |
+
voices_dir=tmp_voices,
|
| 139 |
+
cache_dir=tmp_cache,
|
| 140 |
+
)
|
| 141 |
+
assert result == tmp_cache / 'emma.english_2026-04.safetensors'
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_resolve_voice_path_voices_dir_tagged_cache(tmp_voices, tmp_cache):
|
| 145 |
+
"""A tagged cache dropped directly into voices_dir (e.g. by WingmanAI) is honored."""
|
| 146 |
+
(tmp_voices / 'emma.german_24l.safetensors').write_bytes(b'fake-st')
|
| 147 |
+
result = resolve_voice_path(
|
| 148 |
+
'emma',
|
| 149 |
+
active_model='german_24l',
|
| 150 |
+
voices_dir=tmp_voices,
|
| 151 |
+
cache_dir=tmp_cache,
|
| 152 |
+
)
|
| 153 |
+
assert result == tmp_voices / 'emma.german_24l.safetensors'
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_list_voice_stems_collapses_duplicates(tmp_voices, tmp_cache):
|
| 157 |
+
(tmp_voices / 'emma.wav').write_bytes(b'a')
|
| 158 |
+
(tmp_voices / 'emma.safetensors').write_bytes(b'a')
|
| 159 |
+
(tmp_cache / 'emma.english_2026-04.safetensors').write_bytes(b'a')
|
| 160 |
+
(tmp_cache / 'emma.german_24l.safetensors').write_bytes(b'a')
|
| 161 |
+
(tmp_voices / 'morgan.mp3').write_bytes(b'a')
|
| 162 |
+
|
| 163 |
+
stems = list_voice_stems(voices_dir=tmp_voices, cache_dir=tmp_cache)
|
| 164 |
+
assert stems == ['emma', 'morgan']
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_list_voice_stems_empty(tmp_voices, tmp_cache):
|
| 168 |
+
assert list_voice_stems(voices_dir=tmp_voices, cache_dir=tmp_cache) == []
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_list_voice_stems_ignores_unknown_extensions(tmp_voices, tmp_cache):
|
| 172 |
+
(tmp_voices / 'notes.txt').write_bytes(b'a')
|
| 173 |
+
(tmp_voices / 'emma.wav').write_bytes(b'a')
|
| 174 |
+
stems = list_voice_stems(voices_dir=tmp_voices, cache_dir=tmp_cache)
|
| 175 |
+
assert stems == ['emma']
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_cache_is_stale_true_when_source_newer(tmp_voices, tmp_cache):
|
| 179 |
+
import os
|
| 180 |
+
import time
|
| 181 |
+
|
| 182 |
+
cache = tmp_cache / 'emma.english_2026-04.safetensors'
|
| 183 |
+
cache.write_bytes(b'old')
|
| 184 |
+
old_time = time.time() - 100
|
| 185 |
+
os.utime(cache, (old_time, old_time))
|
| 186 |
+
|
| 187 |
+
source = tmp_voices / 'emma.wav'
|
| 188 |
+
source.write_bytes(b'new') # mtime = now
|
| 189 |
+
|
| 190 |
+
assert cache_is_stale(cache_path=cache, source_path=source) is True
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_cache_is_stale_false_when_cache_newer(tmp_voices, tmp_cache):
|
| 194 |
+
source = tmp_voices / 'emma.wav'
|
| 195 |
+
source.write_bytes(b'old')
|
| 196 |
+
import os
|
| 197 |
+
import time
|
| 198 |
+
|
| 199 |
+
old_time = time.time() - 100
|
| 200 |
+
os.utime(source, (old_time, old_time))
|
| 201 |
+
|
| 202 |
+
cache = tmp_cache / 'emma.english_2026-04.safetensors'
|
| 203 |
+
cache.write_bytes(b'new')
|
| 204 |
+
|
| 205 |
+
assert cache_is_stale(cache_path=cache, source_path=source) is False
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_cache_is_stale_false_when_source_missing(tmp_voices, tmp_cache):
|
| 209 |
+
cache = tmp_cache / 'emma.english_2026-04.safetensors'
|
| 210 |
+
cache.write_bytes(b'cached')
|
| 211 |
+
source = tmp_voices / 'emma.wav' # does not exist
|
| 212 |
+
assert cache_is_stale(cache_path=cache, source_path=source) is False
|