Shackless Claude Sonnet 4.6 commited on
Commit
c25e31a
·
1 Parent(s): 5aa947f

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 CHANGED
@@ -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
.gitignore CHANGED
@@ -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/
Dockerfile CHANGED
@@ -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
 
app/__init__.py CHANGED
@@ -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)
app/config.py CHANGED
@@ -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
  )
app/routes.py CHANGED
@@ -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('index.html', is_docker=Config.IS_DOCKER)
 
 
 
 
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': 'Missing JSON body'}), 400
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': str(e)}), 400
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
app/services/tts.py CHANGED
@@ -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: dict = {}
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
- Args:
137
- voice_id_or_path: Voice identifier (name, file path, or URL)
138
-
139
- Returns:
140
- Model state dictionary for the voice
141
 
142
- Raises:
143
- ValueError: If voice cannot be loaded
 
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
- # Check cache
 
152
  if resolved_key in self.voice_cache:
153
- logger.debug(f'Using cached voice state for: {resolved_key}')
154
- return self.voice_cache[resolved_key]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
- # Load voice
157
  logger.info(f'Loading voice: {resolved_key}')
158
  t0 = time.time()
159
 
160
  try:
161
- state = self.model.get_state_for_audio_prompt(resolved_key)
162
- self.voice_cache[resolved_key] = state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Args:
176
- voice_id_or_path: Voice identifier
177
 
178
- Returns:
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
- # Check if it's a built-in voice
196
  if voice_id_or_path.lower() in Config.BUILTIN_VOICES:
197
  return voice_id_or_path.lower()
198
 
199
- # Check voices directory
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
- # Return as-is, let pocket-tts handle it
218
- return voice_id_or_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) -> torch.Tensor:
257
- """
258
- Generate complete audio for given text.
259
 
260
- Args:
261
- voice_state: Model state from get_voice_state()
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
- t0 = time.time()
271
- audio = self.model.generate_audio(voice_state, text)
272
- gen_time = time.time() - t0
 
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) -> Iterator[torch.Tensor]:
278
- """
279
- Generate audio in streaming chunks.
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
- yield from self.model.generate_audio_stream(voice_state, text)
 
293
 
294
  def list_voices(self) -> list[dict]:
295
- """
296
- List all available voices.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
app/services/versions.py ADDED
@@ -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}
app/services/voice_cache.py ADDED
@@ -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
docker-compose.yml CHANGED
@@ -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
pyproject.toml CHANGED
@@ -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.2"
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",
pytest.ini ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ addopts = -v --tb=short
server.py CHANGED
@@ -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(
static/css/style.css CHANGED
@@ -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 {
static/js/app.js CHANGED
@@ -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
+ });
templates/index.html CHANGED
@@ -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
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -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
tests/test_config.py ADDED
@@ -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'
tests/test_routes.py ADDED
@@ -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
tests/test_tts_service.py ADDED
@@ -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']
tests/test_versions.py ADDED
@@ -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'}
tests/test_voice_cache.py ADDED
@@ -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