My Voices - Top bar with search, filter toggle (active-count badge), synth-source drawer, group and table buttons; filters/sort in a collapsible labelled panel; one-line sort header with select-all/none checkbox - Groups chip list is a collapsed accordion; bottom bar is Refresh/Sync/Import plus one Actions (N) menu (Edit / Audio / Share / Danger), disabled until voices are checked; touch targets stay 44px - Copy selected works again (copyText was no longer exported), shows "N voice names copied to clipboard" and falls back to a copy dialog when the async clipboard is unavailable TTS / engines - Health probe works for /v1 card URLs and, behind a LiteLLM gateway, checks the selected model's own upstream so stopped containers are not reported as available - Unhiding a voice publishes a missing entry to the engine's voices.json (no more silent default-voice fallback) - Status-bar TTS picker also drives the My Voices and STT-test selectors; Use as TTS refreshes the status bar Includes the engine connect/disconnect, custom TTS, STT footer and magazine auth work that was already in the working tree, with their tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
138 lines
8.0 KiB
Python
138 lines
8.0 KiB
Python
"""STT footer metadata uses card names and effective endpoints."""
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from fastapi import HTTPException
|
|
|
|
os.environ.setdefault('CONFIG_DIR', tempfile.mkdtemp(prefix='ttvc-stt-footer-tests-'))
|
|
from routes import stt
|
|
|
|
|
|
class SttFooterMetadataTests(unittest.TestCase):
|
|
def test_custom_models_use_own_legacy_name_with_stable_id_priority(self):
|
|
settings = {
|
|
'custom_engine_cards': [
|
|
{'id': 'first', 'name': 'First speech', 'url': 'http://shared', 'role': 'stt'},
|
|
{'id': 'second', 'name': 'Second speech', 'url': 'http://shared', 'role': 'stt'},
|
|
],
|
|
'engine_models': {'First speech': 'first-legacy', 'Second speech': 'second-legacy'},
|
|
}
|
|
self.assertEqual(stt._stt_backend_model('custom:first', settings), 'first-legacy')
|
|
self.assertEqual(stt._stt_backend_model('custom:second', settings), 'second-legacy')
|
|
settings['engine_models']['first'] = 'first-current'
|
|
self.assertEqual(stt._stt_backend_model('custom:first', settings), 'first-current')
|
|
settings['engine_models']['first'] = ''
|
|
self.assertEqual(stt._stt_backend_model('custom:first', settings), 'large-v3')
|
|
|
|
def test_native_selected_credentials_preserve_only_matching_global_fallback(self):
|
|
settings = {
|
|
'engine_active': {'faster_whisper_url': 'faster-whisper'},
|
|
'faster_whisper_url': 'http://gateway/v1/',
|
|
'whisper_url': 'http://gateway',
|
|
'whisper_api_key': 'global-test-key',
|
|
'engine_api_keys': {'faster-whisper': 'own-test-key', 'other': 'other-test-key'},
|
|
'custom_engine_cards': [{'id': 'other', 'url': 'http://gateway', 'role': 'stt'}],
|
|
}
|
|
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), 'own-test-key')
|
|
del settings['engine_api_keys']['faster-whisper']
|
|
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), 'global-test-key')
|
|
settings['faster_whisper_url'] = 'http://unrelated'
|
|
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), '')
|
|
settings['engine_active']['faster_whisper_url'] = 'other'
|
|
settings['faster_whisper_url'] = 'http://gateway'
|
|
del settings['engine_api_keys']['other']
|
|
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), '')
|
|
|
|
@patch('routes.stt._stt_backend_health', return_value={'ok': True, 'models': ['discovered']})
|
|
@patch('routes.stt._load_settings')
|
|
def test_disabled_native_does_not_disable_selected_custom_on_shared_url(self, load, health):
|
|
load.return_value = {
|
|
'whisper_url': 'http://shared/v1',
|
|
'nvidia_router_url': 'http://shared/v1',
|
|
'engine_active': {'whisper_url': 'realtime', 'nvidia_router_url': 'realtime'},
|
|
'engine_disabled': {'nvidia-parakeet-asr': True},
|
|
'custom_engine_cards': [{'id': 'realtime', 'name': 'Realtime', 'role': 'stt', 'url': 'http://shared/v1'}],
|
|
}
|
|
items = {item['id']: item for item in asyncio.run(stt.stt_backends())['backends']}
|
|
self.assertFalse(items['nvidia_router']['available'])
|
|
self.assertFalse(items['nvidia_router']['connected'])
|
|
self.assertTrue(items['custom:realtime']['available'])
|
|
self.assertTrue(items['custom:realtime']['connected'])
|
|
self.assertEqual(items['configured']['engine_id'], 'realtime')
|
|
self.assertEqual(items['custom:realtime']['engine_id'], 'realtime')
|
|
self.assertEqual(sum(call.args[0] == 'http://shared/v1' for call in health.call_args_list), 2)
|
|
with self.assertRaises(HTTPException) as caught:
|
|
stt._transcribe_audio(None, load.return_value, 'nvidia_router')
|
|
self.assertEqual(caught.exception.status_code, 409)
|
|
|
|
@patch('routes.stt._stt_backend_health', return_value={'ok': True, 'models': ['discovered']})
|
|
@patch('routes.stt._load_settings')
|
|
def test_legacy_native_slot_uses_known_endpoint_identity(self, load, health):
|
|
load.return_value = {
|
|
'nvidia_asr_url': 'http://native:8090',
|
|
'engine_disabled': {'nvidia-parakeet-asr': True},
|
|
}
|
|
items = {item['id']: item for item in asyncio.run(stt.stt_backends())['backends']}
|
|
item = items['nvidia_parakeet']
|
|
self.assertEqual(item['title'], 'NVIDIA Parakeet Router ASR')
|
|
self.assertEqual(item['engine_id'], 'nvidia-parakeet-asr')
|
|
self.assertFalse(item['available'])
|
|
self.assertFalse(item['connected'])
|
|
self.assertNotIn('http://native:8090', [call.args[0] for call in health.call_args_list])
|
|
with self.assertRaises(HTTPException) as caught:
|
|
stt._transcribe_audio(None, load.return_value, 'nvidia_parakeet')
|
|
self.assertEqual(caught.exception.status_code, 409)
|
|
|
|
@patch('routes.stt._stt_backend_health', return_value={'ok': True, 'models': ['discovered']})
|
|
@patch('routes.stt._load_settings')
|
|
def test_selected_alternate_native_uses_its_own_disconnect_state(self, load, health):
|
|
load.return_value = {
|
|
'nvidia_asr_url': 'http://rnnt:8092',
|
|
'engine_active': {'nvidia_asr_url': 'nvidia-parakeet-rnnt-nim'},
|
|
'engine_disabled': {'parakeet-stt-multi-9011': True},
|
|
}
|
|
items = {item['id']: item for item in asyncio.run(stt.stt_backends())['backends']}
|
|
self.assertTrue(items['nvidia_parakeet']['available'])
|
|
self.assertTrue(items['nvidia_parakeet']['connected'])
|
|
self.assertEqual(items['nvidia_parakeet']['engine_id'], 'nvidia-parakeet-rnnt-nim')
|
|
self.assertFalse(stt._stt_disconnected(load.return_value, 'nvidia_parakeet', 'http://rnnt:8092'))
|
|
load.return_value['engine_disabled']['nvidia-parakeet-rnnt-nim'] = True
|
|
self.assertTrue(stt._stt_disconnected(load.return_value, 'nvidia_parakeet', 'http://rnnt:8092'))
|
|
|
|
@patch('routes.stt._stt_backend_health', return_value={'ok': True, 'models': ['discovered']})
|
|
@patch('routes.stt._load_settings')
|
|
def test_card_titles_stable_identity_and_effective_endpoint(self, load, health):
|
|
load.return_value = {
|
|
'faster_whisper_url': 'http://native:9000/v1',
|
|
'engine_active': {'faster_whisper_url': 'stt-whisperx-blackwell'},
|
|
'custom_engine_cards': [
|
|
{'id': 'first', 'name': 'Renamed speech', 'url': 'http://stale', 'role': 'stt'},
|
|
{'id': 'second', 'label': 'Other speech', 'url': 'http://shared/v1', 'role': 'stt'},
|
|
{'id': 'off', 'name': 'Disconnected', 'url': 'http://off', 'role': 'stt'},
|
|
],
|
|
'engine_local_urls': {'first': 'http://old', 'dc-first': 'http://shared/v1'},
|
|
'engine_api_keys': {'first': 'first-test-key', 'second': 'second-test-key'},
|
|
'engine_models': {'first': 'first-model', 'second': 'second-model'},
|
|
'engine_disabled': {'off': True},
|
|
}
|
|
items = {item['id']: item for item in asyncio.run(stt.stt_backends())['backends']}
|
|
self.assertEqual(items['faster_whisper']['title'], 'WhisperX · faster-whisper (Blackwell GPU)')
|
|
self.assertEqual(items['custom:first']['title'], 'Renamed speech')
|
|
self.assertEqual(items['custom:second']['title'], 'Other speech')
|
|
self.assertEqual(items['custom:first']['url'], 'http://shared/v1')
|
|
self.assertEqual(stt._stt_backend_url(load.return_value, 'custom:first'), 'http://shared/v1')
|
|
self.assertEqual(items['custom:first']['model'], 'first-model')
|
|
self.assertEqual(items['custom:second']['model'], 'second-model')
|
|
self.assertTrue(items['custom:first']['available'])
|
|
health.assert_any_call('http://shared/v1', 'first-test-key')
|
|
health.assert_any_call('http://shared/v1', 'second-test-key')
|
|
self.assertFalse(items['custom:off']['available'])
|
|
self.assertFalse(items['custom:off']['connected'])
|
|
self.assertNotIn('http://off', [call.args[0] for call in health.call_args_list])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|