From 4c536741bf80d27c197201b43f77b7892ce85e92 Mon Sep 17 00:00:00 2001 From: Hadrien Gardeur Date: Thu, 9 Jul 2026 14:54:31 +0200 Subject: [PATCH 1/6] Reworked the list of voices for Pocket TTS Avoided duplication across languages Added "otherLanguages" Changed quality to very high Dropped properties that are tied to the model rather than the voice --- app/data/voices/pocket/voices.json | 3090 ++-------------------------- 1 file changed, 128 insertions(+), 2962 deletions(-) diff --git a/app/data/voices/pocket/voices.json b/app/data/voices/pocket/voices.json index 1e92e0b..a75b5c5 100644 --- a/app/data/voices/pocket/voices.json +++ b/app/data/voices/pocket/voices.json @@ -1,3122 +1,288 @@ [ { - "source": "json", - "label": "Alba (English)", - "name": "pocket-en-alba", + "name": "Alba", "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:en-alba", - "language": "en", + "identifier": "urn:readium:tts:pocket:alba", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Anna (English)", - "name": "pocket-en-anna", + "name": "Anna", "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:en-anna", + "identifier": "urn:readium:tts:pocket:anna", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Azelma (English)", - "name": "pocket-en-azelma", + "name": "Azelma", "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:en-azelma", - "language": "en", + "identifier": "urn:readium:tts:pocket:azelma", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Bill Boerst (English)", - "name": "pocket-en-bill_boerst", + "name": "Bill Boerst", "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:en-bill_boerst", - "language": "en", + "identifier": "urn:readium:tts:pocket:bill-boerst", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Caro Davy (English)", - "name": "pocket-en-caro_davy", + "name": "Caro Davy", "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:en-caro_davy", - "language": "en", + "identifier": "urn:readium:tts:pocket:caro-davy", + "language": "en-GB", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Charles (English)", - "name": "pocket-en-charles", + "name": "Charles", "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:en-charles", + "identifier": "urn:readium:tts:pocket:charles", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Cosette (English)", - "name": "pocket-en-cosette", + "name": "Cosette", "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:en-cosette", - "language": "en", + "identifier": "urn:readium:tts:pocket:caro-davy", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Eponine (English)", - "name": "pocket-en-eponine", + "name": "Eponine", "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:en-eponine", - "language": "en", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (English)", - "name": "pocket-en-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:en-estelle", - "language": "en", + "identifier": "urn:readium:tts:pocket:eponine", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Eve (English)", - "name": "pocket-en-eve", + "name": "Eve", "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:en-eve", - "language": "en", + "identifier": "urn:readium:tts:pocket:eve", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Fantine (English)", - "name": "pocket-en-fantine", + "name": "Fantine", "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:en-fantine", + "identifier": "urn:readium:tts:pocket:fantine", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "George (English)", - "name": "pocket-en-george", + "name": "George", "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:en-george", - "language": "en", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (English)", - "name": "pocket-en-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:en-giovanni", - "language": "en", + "identifier": "urn:readium:tts:pocket:george", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Jane (English)", - "name": "pocket-en-jane", + "name": "Jane", "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:en-jane", - "language": "en", + "identifier": "urn:readium:tts:pocket:jane", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Javert (English)", - "name": "pocket-en-javert", + "name": "Javert", "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:en-javert", - "language": "en", + "identifier": "urn:readium:tts:pocket:javert", + "language": "en-US", + "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Jean (English)", - "name": "pocket-en-jean", + "name": "Jean", "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:en-jean", - "language": "en", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (English)", - "name": "pocket-en-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:en-juergen", - "language": "en", + "identifier": "urn:readium:tts:pocket:jean", + "language": "en-US", + "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Lola (English)", - "name": "pocket-en-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:en-lola", - "language": "en", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (English)", - "name": "pocket-en-marius", + "name": "Marius", "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:en-marius", - "language": "en", + "identifier": "urn:readium:tts:pocket:marius", + "language": "en-US", + "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Mary (English)", - "name": "pocket-en-mary", + "name": "Mary", "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:en-mary", - "language": "en", + "identifier": "urn:readium:tts:pocket:mary", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Michael (English)", - "name": "pocket-en-michael", + "name": "Michael", "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:en-michael", - "language": "en", + "identifier": "urn:readium:tts:pocket:michael", + "language": "en-US", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Paul (English)", - "name": "pocket-en-paul", + "name": "Paul", "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:en-paul", - "language": "en", + "identifier": "urn:readium:tts:pocket:paul", + "language": "en-GB", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Peter Yearsley (English)", - "name": "pocket-en-peter_yearsley", + "name": "Peter Yearsley", "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:en-peter_yearsley", - "language": "en", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (English)", - "name": "pocket-en-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:en-rafael", + "identifier": "urn:readium:tts:pocket:peter-yearsley", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Stuart Bell (English)", - "name": "pocket-en-stuart_bell", + "name": "Stuart Bell", "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:en-stuart_bell", - "language": "en", + "identifier": "urn:readium:tts:pocket:stuart-bell", + "language": "en-GB", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Vera (English)", - "name": "pocket-en-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:en-vera", + "name": "Vera", + "originalName": "Vera", + "identifier": "urn:readium:tts:pocket:vera", "language": "en", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (French)", - "name": "pocket-fr-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:fr-alba", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (French)", - "name": "pocket-fr-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:fr-anna", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (French)", - "name": "pocket-fr-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:fr-azelma", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (French)", - "name": "pocket-fr-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:fr-bill_boerst", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (French)", - "name": "pocket-fr-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:fr-caro_davy", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (French)", - "name": "pocket-fr-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:fr-charles", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (French)", - "name": "pocket-fr-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:fr-cosette", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (French)", - "name": "pocket-fr-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:fr-eponine", - "language": "fr", - "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Estelle (French)", - "name": "pocket-fr-estelle", + "name": "Estelle", "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:fr-estelle", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (French)", - "name": "pocket-fr-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:fr-eve", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (French)", - "name": "pocket-fr-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:fr-fantine", - "language": "fr", + "identifier": "urn:readium:tts:pocket:estelle", + "language": "fr-fr", + "otherLanguages": ["en", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (French)", - "name": "pocket-fr-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:fr-george", - "language": "fr", - "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Giovanni (French)", - "name": "pocket-fr-giovanni", + "name": "Giovanni", "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:fr-giovanni", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (French)", - "name": "pocket-fr-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:fr-jane", - "language": "fr", + "identifier": "urn:readium:tts:pocket:giovanni", + "language": "it", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (French)", - "name": "pocket-fr-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:fr-javert", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (French)", - "name": "pocket-fr-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:fr-jean", - "language": "fr", - "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Juergen (French)", - "name": "pocket-fr-juergen", + "name": "Juergen", "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:fr-juergen", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (French)", - "name": "pocket-fr-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:fr-lola", - "language": "fr", + "identifier": "urn:readium:tts:pocket:juergen", + "language": "de", + "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (French)", - "name": "pocket-fr-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:fr-marius", - "language": "fr", - "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Mary (French)", - "name": "pocket-fr-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:fr-mary", - "language": "fr", + "name": "Lola", + "originalName": "lola", + "identifier": "urn:readium:tts:pocket:lola", + "language": "es", + "otherLanguages": ["fr", "en", "de", "it", "pt-PT"], "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (French)", - "name": "pocket-fr-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:fr-michael", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (French)", - "name": "pocket-fr-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:fr-paul", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (French)", - "name": "pocket-fr-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:fr-peter_yearsley", - "language": "fr", - "gender": "male", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false }, { - "source": "json", - "label": "Rafael (French)", - "name": "pocket-fr-rafael", + "name": "Rafael", "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:fr-rafael", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (French)", - "name": "pocket-fr-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:fr-stuart_bell", - "language": "fr", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (French)", - "name": "pocket-fr-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:fr-vera", - "language": "fr", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (Italian)", - "name": "pocket-it-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:it-alba", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (Italian)", - "name": "pocket-it-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:it-anna", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (Italian)", - "name": "pocket-it-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:it-azelma", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (Italian)", - "name": "pocket-it-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:it-bill_boerst", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (Italian)", - "name": "pocket-it-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:it-caro_davy", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (Italian)", - "name": "pocket-it-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:it-charles", - "language": "it", + "identifier": "urn:readium:tts:pocket:rafael", + "language": "pt-PT", + "otherLanguages": ["fr", "en", "de", "it", "es"], "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (Italian)", - "name": "pocket-it-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:it-cosette", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (Italian)", - "name": "pocket-it-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:it-eponine", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (Italian)", - "name": "pocket-it-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:it-estelle", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (Italian)", - "name": "pocket-it-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:it-eve", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (Italian)", - "name": "pocket-it-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:it-fantine", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (Italian)", - "name": "pocket-it-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:it-george", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (Italian)", - "name": "pocket-it-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:it-giovanni", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (Italian)", - "name": "pocket-it-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:it-jane", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (Italian)", - "name": "pocket-it-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:it-javert", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (Italian)", - "name": "pocket-it-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:it-jean", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (Italian)", - "name": "pocket-it-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:it-juergen", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (Italian)", - "name": "pocket-it-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:it-lola", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (Italian)", - "name": "pocket-it-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:it-marius", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (Italian)", - "name": "pocket-it-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:it-mary", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (Italian)", - "name": "pocket-it-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:it-michael", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (Italian)", - "name": "pocket-it-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:it-paul", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (Italian)", - "name": "pocket-it-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:it-peter_yearsley", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (Italian)", - "name": "pocket-it-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:it-rafael", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (Italian)", - "name": "pocket-it-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:it-stuart_bell", - "language": "it", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (Italian)", - "name": "pocket-it-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:it-vera", - "language": "it", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (German)", - "name": "pocket-de-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:de-alba", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (German)", - "name": "pocket-de-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:de-anna", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (German)", - "name": "pocket-de-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:de-azelma", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (German)", - "name": "pocket-de-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:de-bill_boerst", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (German)", - "name": "pocket-de-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:de-caro_davy", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (German)", - "name": "pocket-de-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:de-charles", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (German)", - "name": "pocket-de-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:de-cosette", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (German)", - "name": "pocket-de-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:de-eponine", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (German)", - "name": "pocket-de-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:de-estelle", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (German)", - "name": "pocket-de-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:de-eve", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (German)", - "name": "pocket-de-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:de-fantine", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (German)", - "name": "pocket-de-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:de-george", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (German)", - "name": "pocket-de-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:de-giovanni", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (German)", - "name": "pocket-de-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:de-jane", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (German)", - "name": "pocket-de-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:de-javert", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (German)", - "name": "pocket-de-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:de-jean", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (German)", - "name": "pocket-de-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:de-juergen", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (German)", - "name": "pocket-de-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:de-lola", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (German)", - "name": "pocket-de-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:de-marius", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (German)", - "name": "pocket-de-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:de-mary", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (German)", - "name": "pocket-de-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:de-michael", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (German)", - "name": "pocket-de-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:de-paul", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (German)", - "name": "pocket-de-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:de-peter_yearsley", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (German)", - "name": "pocket-de-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:de-rafael", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (German)", - "name": "pocket-de-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:de-stuart_bell", - "language": "de", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (German)", - "name": "pocket-de-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:de-vera", - "language": "de", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (Spanish)", - "name": "pocket-es-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:es-alba", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (Spanish)", - "name": "pocket-es-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:es-anna", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (Spanish)", - "name": "pocket-es-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:es-azelma", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (Spanish)", - "name": "pocket-es-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:es-bill_boerst", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (Spanish)", - "name": "pocket-es-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:es-caro_davy", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (Spanish)", - "name": "pocket-es-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:es-charles", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (Spanish)", - "name": "pocket-es-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:es-cosette", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (Spanish)", - "name": "pocket-es-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:es-eponine", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (Spanish)", - "name": "pocket-es-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:es-estelle", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (Spanish)", - "name": "pocket-es-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:es-eve", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (Spanish)", - "name": "pocket-es-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:es-fantine", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (Spanish)", - "name": "pocket-es-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:es-george", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (Spanish)", - "name": "pocket-es-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:es-giovanni", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (Spanish)", - "name": "pocket-es-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:es-jane", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (Spanish)", - "name": "pocket-es-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:es-javert", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (Spanish)", - "name": "pocket-es-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:es-jean", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (Spanish)", - "name": "pocket-es-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:es-juergen", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (Spanish)", - "name": "pocket-es-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:es-lola", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (Spanish)", - "name": "pocket-es-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:es-marius", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (Spanish)", - "name": "pocket-es-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:es-mary", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (Spanish)", - "name": "pocket-es-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:es-michael", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (Spanish)", - "name": "pocket-es-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:es-paul", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (Spanish)", - "name": "pocket-es-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:es-peter_yearsley", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (Spanish)", - "name": "pocket-es-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:es-rafael", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (Spanish)", - "name": "pocket-es-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:es-stuart_bell", - "language": "es", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (Spanish)", - "name": "pocket-es-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:es-vera", - "language": "es", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Alba (Portuguese)", - "name": "pocket-pt-alba", - "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:pt-alba", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Anna (Portuguese)", - "name": "pocket-pt-anna", - "originalName": "anna", - "voiceURI": "urn:readium:tts:pocket:pt-anna", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "anna", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Azelma (Portuguese)", - "name": "pocket-pt-azelma", - "originalName": "azelma", - "voiceURI": "urn:readium:tts:pocket:pt-azelma", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "azelma", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Bill Boerst (Portuguese)", - "name": "pocket-pt-bill_boerst", - "originalName": "bill_boerst", - "voiceURI": "urn:readium:tts:pocket:pt-bill_boerst", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "bill_boerst", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Caro Davy (Portuguese)", - "name": "pocket-pt-caro_davy", - "originalName": "caro_davy", - "voiceURI": "urn:readium:tts:pocket:pt-caro_davy", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "caro_davy", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Charles (Portuguese)", - "name": "pocket-pt-charles", - "originalName": "charles", - "voiceURI": "urn:readium:tts:pocket:pt-charles", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "charles", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Cosette (Portuguese)", - "name": "pocket-pt-cosette", - "originalName": "cosette", - "voiceURI": "urn:readium:tts:pocket:pt-cosette", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "cosette", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eponine (Portuguese)", - "name": "pocket-pt-eponine", - "originalName": "eponine", - "voiceURI": "urn:readium:tts:pocket:pt-eponine", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eponine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Estelle (Portuguese)", - "name": "pocket-pt-estelle", - "originalName": "estelle", - "voiceURI": "urn:readium:tts:pocket:pt-estelle", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "estelle", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Eve (Portuguese)", - "name": "pocket-pt-eve", - "originalName": "eve", - "voiceURI": "urn:readium:tts:pocket:pt-eve", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "eve", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Fantine (Portuguese)", - "name": "pocket-pt-fantine", - "originalName": "fantine", - "voiceURI": "urn:readium:tts:pocket:pt-fantine", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "fantine", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "George (Portuguese)", - "name": "pocket-pt-george", - "originalName": "george", - "voiceURI": "urn:readium:tts:pocket:pt-george", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "george", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Giovanni (Portuguese)", - "name": "pocket-pt-giovanni", - "originalName": "giovanni", - "voiceURI": "urn:readium:tts:pocket:pt-giovanni", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "giovanni", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jane (Portuguese)", - "name": "pocket-pt-jane", - "originalName": "jane", - "voiceURI": "urn:readium:tts:pocket:pt-jane", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jane", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Javert (Portuguese)", - "name": "pocket-pt-javert", - "originalName": "javert", - "voiceURI": "urn:readium:tts:pocket:pt-javert", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "javert", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Jean (Portuguese)", - "name": "pocket-pt-jean", - "originalName": "jean", - "voiceURI": "urn:readium:tts:pocket:pt-jean", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "jean", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Juergen (Portuguese)", - "name": "pocket-pt-juergen", - "originalName": "juergen", - "voiceURI": "urn:readium:tts:pocket:pt-juergen", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "juergen", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Lola (Portuguese)", - "name": "pocket-pt-lola", - "originalName": "lola", - "voiceURI": "urn:readium:tts:pocket:pt-lola", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "lola", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Marius (Portuguese)", - "name": "pocket-pt-marius", - "originalName": "marius", - "voiceURI": "urn:readium:tts:pocket:pt-marius", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "marius", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Mary (Portuguese)", - "name": "pocket-pt-mary", - "originalName": "mary", - "voiceURI": "urn:readium:tts:pocket:pt-mary", - "language": "pt", - "gender": "female", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "mary", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Michael (Portuguese)", - "name": "pocket-pt-michael", - "originalName": "michael", - "voiceURI": "urn:readium:tts:pocket:pt-michael", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "michael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Paul (Portuguese)", - "name": "pocket-pt-paul", - "originalName": "paul", - "voiceURI": "urn:readium:tts:pocket:pt-paul", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "paul", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Peter Yearsley (Portuguese)", - "name": "pocket-pt-peter_yearsley", - "originalName": "peter_yearsley", - "voiceURI": "urn:readium:tts:pocket:pt-peter_yearsley", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "peter_yearsley", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Rafael (Portuguese)", - "name": "pocket-pt-rafael", - "originalName": "rafael", - "voiceURI": "urn:readium:tts:pocket:pt-rafael", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "rafael", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Stuart Bell (Portuguese)", - "name": "pocket-pt-stuart_bell", - "originalName": "stuart_bell", - "voiceURI": "urn:readium:tts:pocket:pt-stuart_bell", - "language": "pt", - "gender": "male", - "quality": "normal", - "preloaded": true, - "provider": "pocket", - "engineVoiceId": "stuart_bell", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], - "pitchControl": false - }, - { - "source": "json", - "label": "Vera (Portuguese)", - "name": "pocket-pt-vera", - "originalName": "vera", - "voiceURI": "urn:readium:tts:pocket:pt-vera", - "language": "pt", - "gender": "female", - "quality": "normal", + "quality": "veryHigh", "preloaded": true, - "provider": "pocket", - "engineVoiceId": "vera", - "sampleRate": 24000, - "mimeTypes": [ - "audio/mpeg", - "audio/wav", - "audio/ogg" - ], "pitchControl": false } -] +] \ No newline at end of file From 0eb9bf09009b6e120fad77ecd611b747ad472229 Mon Sep 17 00:00:00 2001 From: Hadrien Gardeur Date: Thu, 9 Jul 2026 15:04:29 +0200 Subject: [PATCH 2/6] Further simplified Pocket TTS voices --- app/data/voices/pocket/voices.json | 104 ++++++++--------------------- 1 file changed, 26 insertions(+), 78 deletions(-) diff --git a/app/data/voices/pocket/voices.json b/app/data/voices/pocket/voices.json index a75b5c5..9e588be 100644 --- a/app/data/voices/pocket/voices.json +++ b/app/data/voices/pocket/voices.json @@ -6,9 +6,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Anna", @@ -17,9 +15,7 @@ "language": "en", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Azelma", @@ -28,9 +24,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Bill Boerst", @@ -39,9 +33,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Caro Davy", @@ -50,9 +42,7 @@ "language": "en-GB", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Charles", @@ -61,9 +51,7 @@ "language": "en", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Cosette", @@ -72,9 +60,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Eponine", @@ -83,9 +69,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Eve", @@ -94,9 +78,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Fantine", @@ -105,9 +87,7 @@ "language": "en", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "George", @@ -116,9 +96,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Jane", @@ -127,9 +105,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Javert", @@ -138,9 +114,7 @@ "language": "en-US", "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Jean", @@ -149,9 +123,7 @@ "language": "en-US", "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Marius", @@ -160,9 +132,7 @@ "language": "en-US", "otherLanguages": ["fr-CA", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Mary", @@ -171,9 +141,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Michael", @@ -182,9 +150,7 @@ "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Paul", @@ -193,9 +159,7 @@ "language": "en-GB", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Peter Yearsley", @@ -204,9 +168,7 @@ "language": "en", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Stuart Bell", @@ -215,9 +177,7 @@ "language": "en-GB", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Vera", @@ -226,9 +186,7 @@ "language": "en", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Estelle", @@ -237,9 +195,7 @@ "language": "fr-fr", "otherLanguages": ["en", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Giovanni", @@ -248,9 +204,7 @@ "language": "it", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Juergen", @@ -259,9 +213,7 @@ "language": "de", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Lola", @@ -270,9 +222,7 @@ "language": "es", "otherLanguages": ["fr", "en", "de", "it", "pt-PT"], "gender": "female", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" }, { "name": "Rafael", @@ -281,8 +231,6 @@ "language": "pt-PT", "otherLanguages": ["fr", "en", "de", "it", "es"], "gender": "male", - "quality": "veryHigh", - "preloaded": true, - "pitchControl": false + "quality": "veryHigh" } ] \ No newline at end of file From 537ea0075b5f8947d61412a816cf786e04d672d4 Mon Sep 17 00:00:00 2001 From: Hadrien Gardeur Date: Thu, 9 Jul 2026 15:05:51 +0200 Subject: [PATCH 3/6] Fixed incorrect genders for voices --- app/data/voices/pocket/voices.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/data/voices/pocket/voices.json b/app/data/voices/pocket/voices.json index 9e588be..07b406e 100644 --- a/app/data/voices/pocket/voices.json +++ b/app/data/voices/pocket/voices.json @@ -203,7 +203,7 @@ "identifier": "urn:readium:tts:pocket:giovanni", "language": "it", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], - "gender": "female", + "gender": "male", "quality": "veryHigh" }, { @@ -212,7 +212,7 @@ "identifier": "urn:readium:tts:pocket:juergen", "language": "de", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], - "gender": "female", + "gender": "male", "quality": "veryHigh" }, { From 2216b0ee0b18616745372b5a9bd23dac1fa96b5b Mon Sep 17 00:00:00 2001 From: Roni bhakta <77425964+ronibhakta1@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:25:02 +0530 Subject: [PATCH 4/6] fix: correct identifiers and language casing in voices.json --- app/data/voices/pocket/voices.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/data/voices/pocket/voices.json b/app/data/voices/pocket/voices.json index 07b406e..776464f 100644 --- a/app/data/voices/pocket/voices.json +++ b/app/data/voices/pocket/voices.json @@ -56,7 +56,7 @@ { "name": "Cosette", "originalName": "cosette", - "identifier": "urn:readium:tts:pocket:caro-davy", + "identifier": "urn:readium:tts:pocket:cosette", "language": "en-US", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], "gender": "female", @@ -181,7 +181,7 @@ }, { "name": "Vera", - "originalName": "Vera", + "originalName": "vera", "identifier": "urn:readium:tts:pocket:vera", "language": "en", "otherLanguages": ["fr", "es", "de", "it", "pt-PT"], @@ -192,7 +192,7 @@ "name": "Estelle", "originalName": "estelle", "identifier": "urn:readium:tts:pocket:estelle", - "language": "fr-fr", + "language": "fr-FR", "otherLanguages": ["en", "es", "de", "it", "pt-PT"], "gender": "female", "quality": "veryHigh" @@ -233,4 +233,4 @@ "gender": "male", "quality": "veryHigh" } -] \ No newline at end of file +] From 0ce3dec36b10e20112d9e7892272c0a1d1d50460 Mon Sep 17 00:00:00 2001 From: Roni bhakta <77425964+ronibhakta1@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:25:17 +0530 Subject: [PATCH 5/6] fix: update Dockerfile to copy all scripts and adjust permissions --- Dockerfile | 4 +++- docker-compose.yml | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cde976b..8db4601 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,9 @@ WORKDIR /app COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv COPY --from=builder --chown=appuser:appuser /app/app /app/app COPY --from=builder --chown=appuser:appuser /app/pyproject.toml /app/pyproject.toml -COPY --chown=appuser:appuser scripts/entrypoint.sh /app/scripts/entrypoint.sh +# entrypoint.sh + prune_weights.py both run inside the container (the latter via +# `make prune-models`); configure.sh/pocket_plan.py are host-only but harmless here. +COPY --chown=appuser:appuser scripts/ /app/scripts/ RUN chmod +x /app/scripts/entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 57c152e..7d40c9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: image: ghcr.io/readium/speech-server:latest env_file: - .env + - pocket-tts.env environment: - HF_TOKEN=${HF_TOKEN:-} volumes: From f5080cd6af5a73ef48193c16a3e9ca72a3ab457f Mon Sep 17 00:00:00 2001 From: Roni bhakta <77425964+ronibhakta1@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:28:49 +0530 Subject: [PATCH 6/6] feat: implement Circuit Breaker pattern for service resilience - Add CircuitBreaker and CircuitBreakerRegistry classes to manage service call failures. - Implement logic to handle state transitions between CLOSED, OPEN, and HALF_OPEN. - Create tests for CircuitBreaker functionality to ensure reliability. feat: add voice loading functionality with language resolution - Introduce ControlsOverride and VoiceEntry models for voice configuration. - Implement functions to resolve install languages and plan installations based on enabled languages. - Create build_voice function to merge voice properties and defaults. feat: define service capabilities schema - Add OutputCapabilities, Limits, ProviderCapabilities, and ServiceCapabilities models to define service capabilities. - Ensure proper validation and structure for service responses. docs: document deployment process and provider capabilities - Create deployment.md to outline automated deployment steps and configuration management. - Add provider documentation for PocketTTS, detailing model-level defaults and audio output specifications. feat: implement pruning script for unused weight files - Add prune_weights.py script to clean up unused language models and embeddings based on current configuration. - Implement safety checks to prevent accidental deletion of shared blobs. test: add comprehensive tests for circuit breaker and voice loading - Create tests for CircuitBreaker to validate state transitions and failure handling. - Add tests for voice loading functions to ensure correct language resolution and installation planning. - Implement service tests to verify API responses and structure. --- .gitignore | 1 + Makefile | 8 +- README.md | 9 +- app/api/deps.py | 23 +- app/api/errors.py | 8 + app/api/routes/service.py | 55 ++ app/api/routes/synthesize.py | 22 +- app/api/routes/voices.py | 41 +- app/config/settings.py | 68 +- app/core/circuit_breaker.py | 52 ++ app/core/synthesizer.py | 36 +- app/core/voice_catalog.py | 26 +- app/main.py | 48 +- app/providers/base.py | 15 +- app/providers/fake.py | 25 +- app/providers/pocket_tts.py | 144 +++- app/providers/voice_loading.py | 117 +++ app/schemas/service.py | 26 + app/schemas/utterance.py | 6 +- app/schemas/voice.py | 52 +- app/static/demo.html | 420 ++++++---- docs/API.md | 112 ++- docs/configuration.md | 165 +++- docs/deployment.md | 75 ++ docs/development.md | 18 +- docs/providers/pocket.md | 55 ++ docs/voices.md | 46 +- .../templates/20-speech-server.conf.template | 6 + scripts/configure.sh | 716 ++++++++++++++---- scripts/pocket_plan.py | 161 ++++ scripts/prune_weights.py | 278 +++++++ tests/conftest.py | 2 + tests/contract/test_provider_contract.py | 18 +- tests/integration/test_pocket_tts.py | 13 +- tests/test_circuit_breaker.py | 77 ++ tests/test_errors.py | 4 +- tests/test_formats.py | 4 +- tests/test_health.py | 14 + tests/test_language_filter.py | 84 +- tests/test_service.py | 32 + tests/test_settings.py | 62 +- tests/test_synthesize.py | 24 +- tests/test_voice_loading.py | 170 +++++ tests/test_voices.py | 16 +- 44 files changed, 2785 insertions(+), 569 deletions(-) create mode 100644 app/api/routes/service.py create mode 100644 app/core/circuit_breaker.py create mode 100644 app/providers/voice_loading.py create mode 100644 app/schemas/service.py create mode 100644 docs/deployment.md create mode 100644 docs/providers/pocket.md create mode 100755 scripts/pocket_plan.py create mode 100644 scripts/prune_weights.py create mode 100644 tests/test_circuit_breaker.py create mode 100644 tests/test_service.py create mode 100644 tests/test_voice_loading.py diff --git a/.gitignore b/.gitignore index 2ccb6c0..7bf4350 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,7 @@ activemq-data/ # Environments .env +pocket-tts.env .envrc .venv env/ diff --git a/Makefile b/Makefile index cc365d1..04437f6 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ DC_RUN = $(DC) run --rm --no-deps app .PHONY: help build dev-docker dev-docker-build test-docker test-integration-docker \ lint-docker fmt-docker fmt-check-docker typecheck-docker ci-docker \ - logs start run stop clean configure \ + logs start run stop clean configure prune-models prune-models-apply \ sync test lint fmt typecheck ci help: ## Show this help @@ -55,6 +55,12 @@ stop: ## Stop the stack configure: ## Run the interactive setup script bash scripts/configure.sh +prune-models: ## Report weight files that don't match the current env config (dry-run) + docker compose exec app python scripts/prune_weights.py + +prune-models-apply: ## Delete weight files that don't match the current env config (frees disk) + docker compose exec app python scripts/prune_weights.py --apply + clean: ## Remove __pycache__ and .pyc files find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find . -name '*.pyc' -delete 2>/dev/null || true diff --git a/README.md b/README.md index e0e0ee6..499edcb 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ Designed to pair with [Readium Speech](https://github.com/readium/speech), Readi | | | |---|---| -| **API** | `GET /voices` · `POST /synthesize` | +| **API** | `GET /service` · `GET /voices` · `POST /synthesize` | | **Providers** | PocketTTS · ElevenLabs (planned) | | **Languages** | English · French · Italian · German · Spanish · Portuguese | -| **Formats** | MP3 · WAV · Opus | +| **Formats** | WAV (default) · MP3 · Opus | | **Word boundaries** | Planned (ElevenLabs) | | **Deployment** | Docker · CPU-only · Single named volume for model weights | @@ -42,8 +42,8 @@ Server: `http://localhost:8000` · Interactive docs: `http://localhost:8000/docs ```bash curl -s -X POST http://localhost:8000/synthesize \ -H 'Content-Type: application/json' \ - -d '{"text":"Hello world","voice":"urn:readium:tts:pocket:en-alba"}' \ - -o /tmp/speech.mp3 && open /tmp/speech.mp3 + -d '{"text":"Hello world","voice":"urn:readium:tts:pocket:alba"}' \ + -o /tmp/speech.wav && open /tmp/speech.wav ``` > **First start** downloads the selected language models (~240 MB each) into a persistent Docker volume. Every restart after that is instant — models are already cached. @@ -56,6 +56,7 @@ curl -s -X POST http://localhost:8000/synthesize \ - [Voices](docs/voices.md) - [Configuration](docs/configuration.md) - [Development](docs/development.md) +- [Deployment](docs/deployment.md) --- diff --git a/app/api/deps.py b/app/api/deps.py index 1a4ed81..4f6e764 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -2,11 +2,21 @@ from fastapi import Depends, Request +from app.api.errors import ServiceNotReady +from app.core.circuit_breaker import CircuitBreakerRegistry from app.core.registry import ProviderRegistry from app.core.synthesizer import Synthesizer from app.core.voice_catalog import VoiceCatalog +def require_ready(request: Request) -> None: + """503 until the background warmup (model loading) has finished. Lets the server + accept connections immediately at startup instead of blocking, so /healthz and + /readyz stay responsive while models load.""" + if not getattr(request.app.state, "ready", False): + raise ServiceNotReady("Service is starting up — models are still loading.") + + def get_registry(request: Request) -> ProviderRegistry: registry: ProviderRegistry = request.app.state.registry return registry @@ -17,9 +27,18 @@ def get_voice_catalog(request: Request) -> VoiceCatalog: return catalog -def get_synthesizer(catalog: Annotated[VoiceCatalog, Depends(get_voice_catalog)]) -> Synthesizer: - return Synthesizer(catalog) +def get_circuit_breakers(request: Request) -> CircuitBreakerRegistry: + breakers: CircuitBreakerRegistry = request.app.state.circuit_breakers + return breakers + + +def get_synthesizer( + catalog: Annotated[VoiceCatalog, Depends(get_voice_catalog)], + breakers: Annotated[CircuitBreakerRegistry, Depends(get_circuit_breakers)], +) -> Synthesizer: + return Synthesizer(catalog, breakers) VoiceCatalogDep = Annotated[VoiceCatalog, Depends(get_voice_catalog)] SynthesizerDep = Annotated[Synthesizer, Depends(get_synthesizer)] +RegistryDep = Annotated[ProviderRegistry, Depends(get_registry)] diff --git a/app/api/errors.py b/app/api/errors.py index 93ec2b6..c20400a 100644 --- a/app/api/errors.py +++ b/app/api/errors.py @@ -57,6 +57,14 @@ class UnsupportedFormat(AppError): title = "Unsupported Format" +class VoiceLanguageUnsupported(AppError): + # Requested voice (or voice+language) isn't served here. Deliberately neutral — + # it never reveals *why* (install/config state), only that it's unsupported. + status_code = 404 + code = "voice_language_unsupported" + title = "Voice or Language Not Supported" + + class PayloadTooLarge(AppError): status_code = 413 code = "payload_too_large" diff --git a/app/api/routes/service.py b/app/api/routes/service.py new file mode 100644 index 0000000..8a6a8a7 --- /dev/null +++ b/app/api/routes/service.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter + +from app.api.deps import RegistryDep +from app.config.settings import settings +from app.domain.enums import AudioFormat +from app.schemas.service import ( + Limits, + OutputCapabilities, + ProviderCapabilities, + ServiceCapabilities, +) + +router = APIRouter(tags=["service"]) + + +@router.get( + "/service", + response_model=ServiceCapabilities, + summary="Server-wide capabilities", + description=( + "Server-wide, per-provider **capabilities** — kept separate from `/voices` so this isn't " + "repeated on every voice: supported output formats + default, request limits, and per " + "provider the installed-language summary. The voices themselves are on `GET /voices`; " + "per-provider model details (quality, controls, output specs) live under `docs/providers/`." + ), + responses={ + 200: { + "content": { + "application/json": { + "example": { + "output": {"formats": ["wav", "mp3", "opus"], "default": "wav"}, + "limits": {"maxTextLength": 2000, "maxConcurrentSyntheses": 2}, + "providers": [{"id": "pocket", "installedLanguages": ["en", "fr"]}], + } + } + } + } + }, +) +async def get_service_capabilities(registry: RegistryDep) -> ServiceCapabilities: + providers = [ + ProviderCapabilities( + id=provider.id, + installedLanguages=sorted(provider.active_languages()), + ) + for provider in registry.all() + ] + return ServiceCapabilities( + output=OutputCapabilities(formats=list(AudioFormat), default=AudioFormat.WAV), + limits=Limits( + maxTextLength=settings.max_text_length, + maxConcurrentSyntheses=settings.max_concurrent_syntheses, + ), + providers=providers, + ) diff --git a/app/api/routes/synthesize.py b/app/api/routes/synthesize.py index dd82725..6cd6758 100644 --- a/app/api/routes/synthesize.py +++ b/app/api/routes/synthesize.py @@ -1,9 +1,9 @@ import base64 -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse, StreamingResponse -from app.api.deps import SynthesizerDep +from app.api.deps import SynthesizerDep, require_ready from app.api.errors import problem_response from app.schemas.utterance import SynthesizeRequest @@ -13,12 +13,13 @@ @router.post( "/synthesize", response_model=None, + dependencies=[Depends(require_ready)], summary="Synthesize speech from text or SSML", description=( "Accepts an utterance and returns an audio file. " "Set `boundary: true` to receive a JSON response with base64-encoded audio " "and word-level timing marks. " - "Supported formats: `mp3` (default), `wav`, `opus`." + "Supported formats: `wav` (default), `mp3`, `opus`." ), responses={ 200: { @@ -27,11 +28,12 @@ "or `application/json` with base64 audio + boundaries (`boundary: true`)." ), }, - 400: problem_response("Empty or whitespace text"), - 404: problem_response("Voice URI not found"), + 400: problem_response("Empty text, or no voice given and no default configured"), + 404: problem_response("Voice not found, or voice/language not supported here"), 413: problem_response("Text exceeds max length"), 415: problem_response("Unsupported audio format"), 422: problem_response("Request schema validation error"), + 503: problem_response("Service starting up (models loading), or provider unavailable"), }, openapi_extra={ "requestBody": { @@ -39,12 +41,12 @@ "application/json": { "examples": { "basic": { - "summary": "Basic request (mp3 default)", + "summary": "Basic request (wav default)", "value": { "id": "urn:uuid:019f1784-d800-7cc5-9f39-39c1e1ca6fdd", "text": "Hello, this is a test.", "language": "en", - "voice": "urn:readium:tts:pocket:en-alba", + "voice": "urn:readium:tts:pocket:alba", }, }, "opus_32kbps": { @@ -53,7 +55,7 @@ "id": "urn:uuid:019f1784-d800-7cc5-9f39-39c1e1ca6fdd", "text": "Hello, this is a test.", "language": "en", - "voice": "urn:readium:tts:pocket:en-alba", + "voice": "urn:readium:tts:pocket:alba", "output": {"format": "opus", "bitrate": 32}, }, }, @@ -63,7 +65,7 @@ "id": "urn:uuid:019f178c-cc7c-7bb3-a39b-d185f43d3cc4", "text": "Ceci est un test.", "language": "fr", - "voice": "urn:readium:tts:pocket:fr-estelle", + "voice": "urn:readium:tts:pocket:estelle", "boundary": True, }, }, @@ -72,7 +74,7 @@ "value": { "text": "She opened the door.", "language": "en", - "voice": "urn:readium:tts:pocket:en-alba", + "voice": "urn:readium:tts:pocket:alba", "prev_utterance": "It was a dark night.", "next_utterance": "The room was cold.", "output": {"format": "mp3", "speed": 0.9}, diff --git a/app/api/routes/voices.py b/app/api/routes/voices.py index 7f4f820..b5eaaa3 100644 --- a/app/api/routes/voices.py +++ b/app/api/routes/voices.py @@ -1,6 +1,6 @@ -from fastapi import APIRouter, Query, Response +from fastapi import APIRouter, Depends, Query, Response -from app.api.deps import VoiceCatalogDep +from app.api.deps import VoiceCatalogDep, require_ready from app.api.errors import problem_response from app.schemas.voice import Voice @@ -11,14 +11,18 @@ "/voices", response_model=list[Voice], response_model_exclude_none=True, + dependencies=[Depends(require_ready)], summary="List available TTS voices", description=( - "Returns voices registered across enabled providers, " - "optionally filtered by language or provider. " - "Supports pagination via `offset` and `limit`. " - "Response headers `X-Total-Count`, `X-Offset`, `X-Limit` reflect the full result set size." + "The voices **actually installed** on this deployment (realtime) — each voice's " + "`language` and `otherLanguages` reflect what's loaded now, bounded by `LANGUAGES` + " + "`VOICE_LANGUAGES`. Model-level `quality`/`controls` are merged in per voice; `controls` " + "lists only the enabled ones. Optionally filtered by language or provider; supports " + "pagination via `offset` and `limit`. Response headers `X-Total-Count`, `X-Offset`, " + "`X-Limit` reflect the full result set size." ), responses={ + 503: problem_response("Service starting up (models loading)"), 502: problem_response("Provider unavailable"), }, openapi_extra={ @@ -31,21 +35,20 @@ "summary": "All voices", "value": [ { - "source": "json", - "label": "Alba (English)", - "name": "pocket-en-alba", + "name": "Alba", "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:en-alba", - "language": "en", - "gender": "female", - "quality": "normal", - "pitchControl": False, - "preloaded": True, "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": ["audio/mpeg", "audio/wav", "audio/ogg"], - "boundary": False, + "identifier": "urn:readium:tts:pocket:alba", + "language": "en-US", + "otherLanguages": [], + "gender": "male", + "quality": "veryHigh", + "controls": { + "pitch": False, + "speed": False, + "ssml": False, + "boundary": False, + }, } ], } diff --git a/app/config/settings.py b/app/config/settings.py index 204c054..ef56abd 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -4,7 +4,10 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", + # .env: server/auth/concurrency/circuit-breaker — universal, provider-agnostic. + # pocket-tts.env: PocketTTS-scoped install config. Optional — later providers get + # their own file the same way; a missing file is silently skipped, not an error. + env_file=(".env", "pocket-tts.env"), env_file_encoding="utf-8", case_sensitive=False, extra="ignore", @@ -31,21 +34,74 @@ class Settings(BaseSettings): # Languages (global; providers filter to their supported subset via active_languages()) # Stored as comma-separated string to avoid pydantic-settings JSON-parsing list fields from env. - languages: str = "en" + # No hardcoded fallback: config is env-file driven (written by scripts/configure.sh). Unset = + # empty = the provider loads no language models (no voices served), rather than silently + # defaulting to English. + languages: str = "" hf_token: str = "" @property def language_list(self) -> list[str]: - langs = [v.strip().lower() for v in self.languages.split(",") if v.strip()] - return langs or ["en"] + return [v.strip().lower() for v in self.languages.split(",") if v.strip()] - # PocketTTS - pocket_default_voice: str = "alba" + # PocketTTS. Empty = no server-side default voice; a request must name one. + pocket_default_voice: str = "" + + # Per-voice cross-language install config. Generic across providers. Format: + # comma-separated "originalName:lang" pairs, "-" prefix to remove instead of add + # (e.g. "alba:fr,alba:de,-javert:es"). Additions still bounded by `languages` (never + # trigger a new base-model download) and must be a language that voice already + # declares in its own otherLanguages. A bare "*:*" token means "every voice, every + # declared otherLanguage that's enabled" (old install_mode="all"); its absence means + # "primary only" (old install_mode="primary") — either way, explicit pairs in the + # same list still layer on top as cherry-picks/prunes. + voice_languages: str = "" + + @property + def voice_install_all(self) -> bool: + return any(p.strip() == "*:*" for p in self.voice_languages.split(",")) + + @property + def voice_language_add_map(self) -> dict[str, frozenset[str]]: + return self._parse_voice_language_overrides()[0] + + @property + def voice_language_remove_map(self) -> dict[str, frozenset[str]]: + return self._parse_voice_language_overrides()[1] + + def _parse_voice_language_overrides( + self, + ) -> tuple[dict[str, frozenset[str]], dict[str, frozenset[str]]]: + add: dict[str, set[str]] = {} + remove: dict[str, set[str]] = {} + for pair in self.voice_languages.split(","): + pair = pair.strip() + if not pair or pair == "*:*": + continue + target = add + if pair.startswith("-"): + target = remove + pair = pair[1:] + if ":" not in pair: + continue + voice, lang = pair.split(":", 1) + voice, lang = voice.strip().lower(), lang.strip().lower() + if voice and lang: + target.setdefault(voice, set()).add(lang) + return ( + {k: frozenset(v) for k, v in add.items()}, + {k: frozenset(v) for k, v in remove.items()}, + ) # Audio / ffmpeg max_text_length: int = Field(default=2000, ge=1) ffmpeg_bin: str = "ffmpeg" + # Circuit breaker (per provider, wraps synthesize() calls) + circuit_breaker_enabled: bool = True + circuit_breaker_failure_threshold: int = Field(default=5, ge=1) + circuit_breaker_recovery_seconds: int = Field(default=30, ge=1) + @model_validator(mode="after") def validate_auth_and_providers(self) -> "Settings": if self.api_key_enabled and not self.api_key: diff --git a/app/core/circuit_breaker.py b/app/core/circuit_breaker.py new file mode 100644 index 0000000..c9dbe2b --- /dev/null +++ b/app/core/circuit_breaker.py @@ -0,0 +1,52 @@ +import time +from enum import Enum + + +class _State(Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +class CircuitBreaker: + """Per-provider failure gate. Trips OPEN after `failure_threshold` consecutive + failures; rejects calls until `recovery_seconds` has elapsed, then allows one + HALF_OPEN trial call — a success closes it, a failure re-opens it.""" + + def __init__(self, failure_threshold: int, recovery_seconds: float) -> None: + self._failure_threshold = failure_threshold + self._recovery_seconds = recovery_seconds + self._state = _State.CLOSED + self._failures = 0 + self._opened_at = 0.0 + + def allow(self) -> bool: + if self._state is _State.OPEN: + if time.monotonic() - self._opened_at >= self._recovery_seconds: + self._state = _State.HALF_OPEN + return True + return False + return True + + def record_success(self) -> None: + self._failures = 0 + self._state = _State.CLOSED + + def record_failure(self) -> None: + self._failures += 1 + if self._state is _State.HALF_OPEN or self._failures >= self._failure_threshold: + self._state = _State.OPEN + self._opened_at = time.monotonic() + + +class CircuitBreakerRegistry: + def __init__( + self, provider_ids: list[str], failure_threshold: int, recovery_seconds: float + ) -> None: + self._breakers = { + provider_id: CircuitBreaker(failure_threshold, recovery_seconds) + for provider_id in provider_ids + } + + def get(self, provider_id: str) -> CircuitBreaker: + return self._breakers[provider_id] diff --git a/app/core/synthesizer.py b/app/core/synthesizer.py index 53d6cdc..3cda899 100644 --- a/app/core/synthesizer.py +++ b/app/core/synthesizer.py @@ -1,8 +1,9 @@ import logging import struct -from app.api.errors import PayloadTooLarge, RequestValidationFailed +from app.api.errors import AppError, PayloadTooLarge, RequestValidationFailed, ServiceNotReady from app.config.settings import settings +from app.core.circuit_breaker import CircuitBreakerRegistry from app.core.voice_catalog import VoiceCatalog from app.domain.enums import AudioFormat from app.drivers import ffmpeg as ffmpeg_driver @@ -38,8 +39,9 @@ def _pcm_to_wav(pcm: bytes, sample_rate: int) -> bytes: class Synthesizer: - def __init__(self, catalog: VoiceCatalog) -> None: + def __init__(self, catalog: VoiceCatalog, breakers: CircuitBreakerRegistry) -> None: self._catalog = catalog + self._breakers = breakers async def synthesize( self, request: SynthesizeRequest @@ -54,13 +56,21 @@ async def synthesize( detail=f"received {len(text)}, limit is {settings.max_text_length}", ) - provider, voice_uri = self._catalog.resolve(request.voice) - boundaries_supported = provider.supports_boundaries + # POCKET_DEFAULT_VOICE is pocket-scoped by name but the only server-wide + # default today; a request may omit voice and inherit it. + voice_ref = request.voice or settings.pocket_default_voice + if not voice_ref: + raise RequestValidationFailed( + "no voice specified and no default voice configured (POCKET_DEFAULT_VOICE)" + ) + + provider, voice = self._catalog.resolve(voice_ref) + boundaries_supported = voice.controls.boundary out = request.output logger.info( "synthesize voice=%s provider=%s format=%s chars=%d boundary=%s", - voice_uri, + voice.identifier, provider.id, out.format.value, len(text), @@ -70,14 +80,26 @@ async def synthesize( text=text, ssml=request.ssml, language=request.language, - voice_uri=voice_uri, + voice_uri=voice.identifier, speed=out.speed, pitch=out.pitch, prev_utterance=request.prev_utterance, next_utterance=request.next_utterance, ) - result: AudioResult = await provider.synthesize(params) + breaker = self._breakers.get(provider.id) if settings.circuit_breaker_enabled else None + if breaker is not None and not breaker.allow(): + raise ServiceNotReady(f"Provider '{provider.id}' is temporarily unavailable") + + try: + result: AudioResult = await provider.synthesize(params) + except AppError: + if breaker is not None: + breaker.record_failure() + raise + else: + if breaker is not None: + breaker.record_success() if out.format == AudioFormat.WAV: audio = _pcm_to_wav(result.pcm, result.sample_rate) diff --git a/app/core/voice_catalog.py b/app/core/voice_catalog.py index 66b1f1b..60798a1 100644 --- a/app/core/voice_catalog.py +++ b/app/core/voice_catalog.py @@ -1,36 +1,46 @@ from app.api.errors import VoiceNotFound from app.core.registry import ProviderRegistry from app.providers.base import TTSProvider -from app.schemas.voice import Voice +from app.schemas.voice import Voice, voice_language_prefixes class VoiceCatalog: def __init__(self, registry: ProviderRegistry) -> None: self._registry = registry self._voices: list[Voice] = [] - self._index: dict[str, tuple[TTSProvider, str]] = {} + self._index: dict[str, tuple[TTSProvider, Voice]] = {} + self._name_index: dict[str, tuple[TTSProvider, Voice]] = {} async def load(self) -> None: + # The catalog serves GET /voices — the voices actually INSTALLED on this + # deployment (realtime: language + otherLanguages reflect what's loaded). + # resolve() maps a known voice to its provider for synthesis. voices: list[Voice] = [] - index: dict[str, tuple[TTSProvider, str]] = {} + index: dict[str, tuple[TTSProvider, Voice]] = {} + # provider exists today; revisit if a second one collides on a name. + name_index: dict[str, tuple[TTSProvider, Voice]] = {} for provider in self._registry.all(): for voice in await provider.list_voices(): voices.append(voice) - index[voice.voiceURI] = (provider, voice.voiceURI) + index[voice.identifier] = (provider, voice) + name_index[voice.originalName.lower()] = (provider, voice) self._voices = voices self._index = index + self._name_index = name_index def list(self, language: str | None = None, provider: str | None = None) -> list[Voice]: result = self._voices if language: lang_prefix = language.split("-")[0].lower() - result = [v for v in result if v.language.split("-")[0].lower() == lang_prefix] + result = [v for v in result if lang_prefix in voice_language_prefixes(v)] if provider: result = [v for v in result if v.provider == provider] return result - def resolve(self, voice_uri: str) -> tuple[TTSProvider, str]: - entry = self._index.get(voice_uri) + def resolve(self, identifier: str) -> tuple[TTSProvider, Voice]: + # Match the full identifier URI first, then fall back to originalName + # (case-insensitive) — lets POCKET_DEFAULT_VOICE=alba resolve without a URI. + entry = self._index.get(identifier) or self._name_index.get(identifier.lower()) if entry is None: - raise VoiceNotFound(f"Voice '{voice_uri}' not found.") + raise VoiceNotFound(f"Voice '{identifier}' not found.") return entry diff --git a/app/main.py b/app/main.py index f1a4d95..c021546 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,7 @@ +import asyncio +import logging from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI from fastapi.responses import FileResponse @@ -8,13 +10,17 @@ from app.api.errors import register_error_handlers from app.api.router import router from app.api.routes.health import router as health_router +from app.api.routes.service import router as service_router from app.config.settings import settings +from app.core.circuit_breaker import CircuitBreakerRegistry from app.core.concurrency import init_semaphore from app.core.registry import ProviderRegistry from app.core.voice_catalog import VoiceCatalog from app.logging.config import RequestLoggingMiddleware, configure_logging from app.providers.pocket_tts import PocketTTSProvider +logger = logging.getLogger(__name__) + def _build_registry() -> ProviderRegistry: registry = ProviderRegistry() @@ -30,19 +36,41 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: init_semaphore(settings.max_concurrent_syntheses) registry = _build_registry() - - for provider in registry.all(): - await provider.load() - catalog = VoiceCatalog(registry) - await catalog.load() + breakers = CircuitBreakerRegistry( + [p.id for p in registry.all()], + settings.circuit_breaker_failure_threshold, + settings.circuit_breaker_recovery_seconds, + ) app.state.registry = registry app.state.voice_catalog = catalog - app.state.ready = True - yield + app.state.circuit_breakers = breakers app.state.ready = False - log_listener.stop() # flush remaining records before process exits + + # Load models OFF the startup path — otherwise ASGI startup blocks and the + # server accepts no connections (not even /healthz, /readyz) until models are + # ready. Warmup runs in the background; readiness flips to True when it finishes. + # A failure here leaves the server up reporting 503 on /readyz, not crashed. + async def _warmup() -> None: + try: + for provider in registry.all(): + await provider.load() + await catalog.load() + app.state.ready = True + logger.info("Startup warmup complete — service ready") + except Exception: + logger.exception("Startup warmup failed — service will report not ready") + + warmup_task = asyncio.create_task(_warmup()) + try: + yield + finally: + app.state.ready = False + warmup_task.cancel() + with suppress(asyncio.CancelledError): + await warmup_task + log_listener.stop() # flush remaining records before process exits def create_app() -> FastAPI: @@ -55,6 +83,7 @@ def create_app() -> FastAPI: ), openapi_tags=[ {"name": "health", "description": "Liveness and readiness probes."}, + {"name": "service", "description": "Server-wide capabilities."}, {"name": "voices", "description": "List available TTS voices."}, {"name": "synthesize", "description": "Convert text/SSML to audio."}, ], @@ -73,6 +102,7 @@ def create_app() -> FastAPI: register_error_handlers(app) app.include_router(health_router) + app.include_router(service_router) app.include_router(router) @app.get("/demo", include_in_schema=False) diff --git a/app/providers/base.py b/app/providers/base.py index a94a888..a4fb2e2 100644 --- a/app/providers/base.py +++ b/app/providers/base.py @@ -2,8 +2,9 @@ from collections.abc import Sequence from typing import ClassVar +from app.domain.enums import Quality from app.schemas.audio import AudioResult, SynthesisParams -from app.schemas.voice import Voice +from app.schemas.voice import Controls, Voice, voice_language_prefixes class TTSProvider(ABC): @@ -15,8 +16,10 @@ class TTSProvider(ABC): # Empty (default) = language-agnostic; list_voices() returns all voices unfiltered. supported_languages: ClassVar[frozenset[str]] = frozenset() - # True when synthesize() populates AudioResult.boundaries with word timing marks. - supports_boundaries: ClassVar[bool] = False + # Model-level defaults merged into every voice this provider serves, unless a + # voice overrides them (see app/providers/voice_loading.py). + default_quality: ClassVar[Quality | None] = None + default_controls: ClassVar[Controls] = Controls() def active_languages(self) -> frozenset[str]: """Intersection of global LANGUAGES config and this provider's supported set. @@ -34,12 +37,14 @@ async def _all_voices(self) -> Sequence[Voice]: """All voices this provider knows about, regardless of language config.""" async def list_voices(self) -> Sequence[Voice]: - """Voices filtered to active_languages(). Providers do NOT override this.""" + """Voices actually INSTALLED (filtered to active_languages()), reflecting the + realtime install state — this is what `GET /voices` serves. Providers do NOT + override this.""" voices = await self._all_voices() if not self.supported_languages: return voices # language-agnostic provider: return all active = self.active_languages() - return [v for v in voices if v.language.split("-")[0].lower() in active] + return [v for v in voices if voice_language_prefixes(v) & active] @abstractmethod async def synthesize(self, params: SynthesisParams) -> AudioResult: diff --git a/app/providers/fake.py b/app/providers/fake.py index d56496f..6359e08 100644 --- a/app/providers/fake.py +++ b/app/providers/fake.py @@ -2,46 +2,35 @@ import math import struct from collections.abc import Sequence +from typing import ClassVar from app.core.concurrency import run_inference from app.domain.enums import Gender, Quality from app.providers.base import TTSProvider from app.schemas.audio import AudioResult, SynthesisParams -from app.schemas.voice import Voice +from app.schemas.voice import Controls, Voice _SAMPLE_RATE = 24000 # matches PocketTTS native output _FREQ = 440.0 # Hz _VOICES: list[Voice] = [ Voice( - source="json", - label="Fake Voice (English)", name="fake-en-US", originalName="fake-en-US", - voiceURI="urn:readium:tts:fake:en-US-standard", + provider="fake", + identifier="urn:readium:tts:fake:en-US-standard", language="en-US", gender=Gender.NEUTRAL, quality=Quality.NORMAL, - preloaded=True, - provider="fake", - engineVoiceId="fake-en", - sampleRate=_SAMPLE_RATE, - mimeTypes=["audio/mpeg", "audio/wav", "audio/ogg"], ), Voice( - source="json", - label="Fake Voice (French)", name="fake-fr-FR", originalName="fake-fr-FR", - voiceURI="urn:readium:tts:fake:fr-FR-standard", + provider="fake", + identifier="urn:readium:tts:fake:fr-FR-standard", language="fr-FR", gender=Gender.NEUTRAL, quality=Quality.NORMAL, - preloaded=True, - provider="fake", - engineVoiceId="fake-fr", - sampleRate=_SAMPLE_RATE, - mimeTypes=["audio/mpeg", "audio/wav", "audio/ogg"], ), ] @@ -54,6 +43,8 @@ def _generate_tone(sample_rate: int, duration: float) -> bytes: class FakeProvider(TTSProvider): id = "fake" + default_quality: ClassVar[Quality] = Quality.NORMAL + default_controls: ClassVar[Controls] = Controls() async def _all_voices(self) -> Sequence[Voice]: return _VOICES diff --git a/app/providers/pocket_tts.py b/app/providers/pocket_tts.py index 09e5d13..9e3a469 100644 --- a/app/providers/pocket_tts.py +++ b/app/providers/pocket_tts.py @@ -10,11 +10,14 @@ import anyio -from app.api.errors import ProviderError +from app.api.errors import ProviderError, VoiceLanguageUnsupported +from app.config.settings import settings from app.core.concurrency import run_inference +from app.domain.enums import Quality from app.providers.base import TTSProvider +from app.providers.voice_loading import VoiceEntry, build_voice, plan_install from app.schemas.audio import AudioResult, SynthesisParams -from app.schemas.voice import Voice +from app.schemas.voice import Controls, Voice logger = logging.getLogger(__name__) @@ -38,12 +41,17 @@ def _strip_ssml(text: str) -> str: class PocketTTSProvider(TTSProvider): id = "pocket" supported_languages: ClassVar[frozenset[str]] = frozenset(_LANG_MODEL.keys()) + default_quality: ClassVar[Quality] = Quality.VERY_HIGH + default_controls: ClassVar[Controls] = Controls( + pitch=False, speed=False, ssml=False, boundary=False + ) def __init__(self) -> None: self._models: dict[str, Any] = {} # lang_key → TTSModel self._model_locks: dict[str, threading.Lock] = {} # per-model lock; not thread-safe - self._voice_states: dict[str, Any] = {} # voiceURI → voice state - self._voice_lang: dict[str, str] = {} # voiceURI → lang_key + self._voice_states: dict[tuple[str, str], Any] = {} # (identifier, lang_key) → voice state + self._voice_default_lang: dict[str, str] = {} # identifier → default lang (request omits) + self._voice_langs: dict[str, frozenset[str]] = {} # identifier → all warmed lang_keys self._voices: list[Voice] = [] self._ready = False @@ -62,19 +70,63 @@ def _load_sync(self) -> bool: return False raw = json.loads(_VOICES_PATH.read_text()) - self._voices = [ - Voice(**{**v, "boundary": self.supports_boundaries}) - for v in raw - if v["language"].split("-")[0].lower() in enabled - ] - - lang_voices: dict[str, list[Voice]] = {} - for voice in self._voices: - key = voice.language.split("-")[0].lower() - lang_voices.setdefault(key, []).append(voice) - self._voice_lang[voice.voiceURI] = key - - for lang_key, voices in lang_voices.items(): + entries = [VoiceEntry.model_validate(v) for v in raw] + + install_all = settings.voice_install_all + add_map = settings.voice_language_add_map + remove_map = settings.voice_language_remove_map + known_keys = {e.originalName.lower() for e in entries} + for unknown in (set(add_map) | set(remove_map)) - known_keys: + logger.warning( + "VOICE_LANGUAGES references unknown voice '%s' — check spelling " + "against voices.json originalName", + unknown, + ) + + lang_voices: dict[str, list[tuple[VoiceEntry, str]]] = {} + self._voices = [] + for entry in entries: + key = entry.originalName.lower() + primary = entry.language.split("-")[0].lower() + no_op_add = add_map.get(key, frozenset()) & {primary} + if no_op_add: + logger.warning( + "VOICE_LANGUAGES add for '%s' includes %s, its own primary " + "language — no effect, already installed", + key, + sorted(no_op_add), + ) + plan = plan_install( + entry, + enabled, + install_all, + add_map.get(key, frozenset()), + remove_map.get(key, frozenset()), + ) + # A voice runs in any enabled language it has an embedding for, using ONLY + # that language's base model — its primary model is NOT required (see + # plan_install / pocket_tts get_state_for_audio_prompt). None = no enabled + # language applies; skip. + if plan is None: + if key in add_map or key in remove_map: + logger.warning( + "VOICE_LANGUAGES override for '%s' resolves to no enabled " + "language — nothing to install for it", + key, + ) + continue + default_lang, installed = plan + other_langs = installed - frozenset({primary}) + voice = build_voice( + entry, self.id, other_langs, self.default_quality, self.default_controls + ) + self._voices.append(voice) + self._voice_default_lang[voice.identifier] = default_lang + self._voice_langs[voice.identifier] = installed + for lang_key in installed: + lang_voices.setdefault(lang_key, []).append((entry, lang_key)) + + for lang_key, pairs in lang_voices.items(): model_id = _LANG_MODEL[lang_key] logger.info("Loading PocketTTS model: %s ...", model_id) model: Any = TTSModel.load_model(language=model_id) @@ -85,12 +137,25 @@ def _load_sync(self) -> bool: "Loaded %s (sample_rate=%d Hz); warming %d voice states...", model_id, model.sample_rate, - len(voices), + len(pairs), ) - for voice in voices: - self._voice_states[voice.voiceURI] = model.get_state_for_audio_prompt( - voice.engineVoiceId - ) + for entry, _ in pairs: + identifier = entry.identifier + # Warm each voice defensively — one voice that fails to load its + # embedding (bad name, missing file, network) must not abort startup + # for every other voice. The (voice, lang) simply stays uninstalled; + # synthesize() then returns a clean "not installed" error for it. + try: + self._voice_states[(identifier, lang_key)] = model.get_state_for_audio_prompt( + entry.originalName + ) + except Exception as exc: # noqa: BLE001 — never let one voice crash load + logger.warning( + "Skipping voice '%s' for '%s' — failed to warm: %s", + entry.originalName, + lang_key, + exc, + ) logger.info( "PocketTTS ready — %d voices across %d language models", @@ -102,9 +167,37 @@ def _load_sync(self) -> bool: async def _all_voices(self) -> Sequence[Voice]: return self._voices + def _resolve_lang_key(self, voice_uri: str, requested_language: str | None) -> str | None: + """The warmed language to synthesize in. A requested language the voice is + NOT installed for returns None — we never silently fall back to a different + language (that would speak the wrong language for a (voice, lang) that was + never downloaded). An omitted language uses the voice's default installed one.""" + warmed = self._voice_langs.get(voice_uri, frozenset()) + if requested_language: + requested = requested_language.split("-")[0].lower() + return requested if requested in warmed else None + return self._voice_default_lang[voice_uri] + async def synthesize(self, params: SynthesisParams) -> AudioResult: - if params.voice_uri not in self._voice_states: - raise ProviderError(f"Unknown PocketTTS voice: {params.voice_uri}") + # Neutral "unsupported" responses throughout — never reveal install/config + # state (whether a voice/language was downloaded), only that it isn't served. + if params.voice_uri not in self._voice_langs: + raise VoiceLanguageUnsupported(f"Voice '{params.voice_uri}' is not supported.") + + lang_key = self._resolve_lang_key(params.voice_uri, params.language) + if lang_key is None: + # Requested a language this voice isn't served in — never silently fall + # back to a different language. + raise VoiceLanguageUnsupported( + f"Voice '{params.voice_uri}' is not supported for language '{params.language}'." + ) + state_key = (params.voice_uri, lang_key) + if state_key not in self._voice_states: + # Safety net: language is in the voice's set but its embedding never warmed + # (a load-time failure that was logged and skipped). Still unsupported here. + raise VoiceLanguageUnsupported( + f"Voice '{params.voice_uri}' is not supported for language '{lang_key}'." + ) text = _strip_ssml(params.text) if params.ssml else params.text if not text: @@ -117,10 +210,9 @@ async def synthesize(self, params: SynthesisParams) -> AudioResult: params.pitch, ) - lang_key = self._voice_lang[params.voice_uri] model = self._models[lang_key] lock = self._model_locks[lang_key] - state = self._voice_states[params.voice_uri] + state = self._voice_states[state_key] sample_rate: int = model.sample_rate def _infer() -> bytes: diff --git a/app/providers/voice_loading.py b/app/providers/voice_loading.py new file mode 100644 index 0000000..512c496 --- /dev/null +++ b/app/providers/voice_loading.py @@ -0,0 +1,117 @@ +from pydantic import BaseModel + +from app.domain.enums import Gender, Quality +from app.schemas.voice import Controls, Voice + + +class ControlsOverride(BaseModel): + """Partial per-voice override of a provider's default Controls. Unset (None) + fields fall back to the provider default — see merge_controls().""" + + pitch: bool | None = None + speed: bool | None = None + ssml: bool | None = None + boundary: bool | None = None + + +class VoiceEntry(BaseModel): + """Raw per-voice shape read from a provider's voices.json — documents what's + POSSIBLE. otherLanguages here is aspirational, not what's actually installed.""" + + name: str + originalName: str + identifier: str + language: str + otherLanguages: list[str] = [] + gender: Gender | None = None + quality: Quality | None = None + controls: ControlsOverride | None = None + + +def _lang_prefix(lang: str) -> str: + return lang.split("-")[0].lower() + + +def resolve_install_languages( + entry: VoiceEntry, + enabled: frozenset[str], + install_all: bool, + add_langs: frozenset[str] = frozenset(), + remove_langs: frozenset[str] = frozenset(), +) -> tuple[str, frozenset[str]]: + """Returns (primary_lang_key, other_lang_keys) actually to install for this + voice — always bounded by `enabled` (the operator's configured LANGUAGES), so + this never triggers a new base-model download on its own. install_all=False + -> other_lang_keys starts empty; install_all=True -> starts as every declared + otherLanguage that's enabled (Settings.voice_install_all, the "*:*" wildcard). + + add_langs / remove_langs (Settings.voice_language_add_map/remove_map, looked up + per voice) then adjust that base set: add_langs can only pull in languages this + voice already declares in otherLanguages (still bounded by `enabled` — it can + promote a declared-but-not-installed language, never invent support voices.json + doesn't claim); remove_langs prunes any pair the base mode would have included. + The primary language is never affected by either.""" + primary = _lang_prefix(entry.language) + declared_other = frozenset(_lang_prefix(lang) for lang in entry.otherLanguages) + base_other = declared_other & enabled if install_all else frozenset() + other = (base_other | (add_langs & declared_other & enabled)) - remove_langs - {primary} + return primary, other + + +def plan_install( + entry: VoiceEntry, + enabled: frozenset[str], + install_all: bool, + add_langs: frozenset[str] = frozenset(), + remove_langs: frozenset[str] = frozenset(), +) -> tuple[str, frozenset[str]] | None: + """Decide what a voice actually installs. Unlike a naive "primary must be + enabled" rule, a voice may run in a non-primary language WITHOUT its primary + base model — pocket_tts loads a voice's speaker embedding from the *target* + language's model dir, not the primary's (see models/tts_model.py), so any + (voice, language) with an embedding works using only that language's model. + + Returns (default_lang, installed_langs), or None when no enabled language + applies (voice not installed at all). installed_langs is every enabled language + among {primary} ∪ resolved others. default_lang — used when a synth request + omits `language` — is the primary if it's installed, else the first installed + language (deterministic).""" + primary, other = resolve_install_languages(entry, enabled, install_all, add_langs, remove_langs) + installed = (frozenset({primary}) & enabled) | other + if not installed: + return None + default_lang = primary if primary in installed else min(installed) + return default_lang, installed + + +def merge_controls(default: Controls, override: ControlsOverride | None) -> Controls: + if override is None: + return default + return Controls( + pitch=override.pitch if override.pitch is not None else default.pitch, + speed=override.speed if override.speed is not None else default.speed, + ssml=override.ssml if override.ssml is not None else default.ssml, + boundary=override.boundary if override.boundary is not None else default.boundary, + ) + + +def build_voice( + entry: VoiceEntry, + provider_id: str, + installed_other: frozenset[str], + default_quality: Quality | None, + default_controls: Controls, +) -> Voice: + """The merge point where 'possible' (voices.json) becomes 'installed' + (served via /voices): otherLanguages is installed_other, not entry.otherLanguages.""" + return Voice( + name=entry.name, + originalName=entry.originalName, + provider=provider_id, + identifier=entry.identifier, + language=entry.language, + otherLanguages=sorted(installed_other), + gender=entry.gender, + quality=entry.quality or default_quality, + controls=merge_controls(default_controls, entry.controls), + ) diff --git a/app/schemas/service.py b/app/schemas/service.py new file mode 100644 index 0000000..f30121d --- /dev/null +++ b/app/schemas/service.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel + +from app.domain.enums import AudioFormat + + +class OutputCapabilities(BaseModel): + formats: list[AudioFormat] + default: AudioFormat + + +class Limits(BaseModel): + maxTextLength: int + maxConcurrentSyntheses: int + + +class ProviderCapabilities(BaseModel): + id: str + installedLanguages: list[str] + # Model-level quality/controls are NOT repeated here — they're merged into each + # voice on GET /voices and documented per provider under docs/providers/. + + +class ServiceCapabilities(BaseModel): + output: OutputCapabilities + limits: Limits + providers: list[ProviderCapabilities] diff --git a/app/schemas/utterance.py b/app/schemas/utterance.py index 74ebb3c..8643a4b 100644 --- a/app/schemas/utterance.py +++ b/app/schemas/utterance.py @@ -6,7 +6,7 @@ class OutputConfig(BaseModel): """Requested output parameters — nested under 'output' in the API body.""" - format: AudioFormat = AudioFormat.MP3 + format: AudioFormat = AudioFormat.WAV bitrate: int | None = None # kbps; only meaningful for mp3/opus sample_rate: int | None = None speed: float = 1.0 @@ -23,7 +23,9 @@ class Utterance(BaseModel): class SynthesizeRequest(Utterance): - voice: str # voiceURI — required + # Voice identifier (URI) or originalName. Optional: when omitted, the server + # falls back to POCKET_DEFAULT_VOICE; if that's unset too, the request is rejected. + voice: str | None = None prev_utterance: str | None = None # preceding text/ID for prosody context next_utterance: str | None = None # following text/ID for prosody context boundary: bool = False # if true, response is JSON with base64 audio + timing marks diff --git a/app/schemas/voice.py b/app/schemas/voice.py index d01e950..bd36e48 100644 --- a/app/schemas/voice.py +++ b/app/schemas/voice.py @@ -1,36 +1,42 @@ -from typing import Literal - -from pydantic import BaseModel +from pydantic import BaseModel, model_serializer from app.domain.enums import Gender, Quality +class Controls(BaseModel): + """Which prosody/format controls a voice accepts. Provider-level defaults, + overridable per voice (see app/providers/voice_loading.py). + + Serializes only the controls that are ENABLED — a control the voice doesn't + support is simply absent, not `false`. Keeps `/voices` and `/service` lean and + works the same for any provider (pocket → `{}`, an SSML voice → `{"ssml": true}`). + Internal Python access (e.g. `voice.controls.boundary`) still sees all fields.""" + + pitch: bool = False + speed: bool = False + ssml: bool = False + boundary: bool = False # true when the provider returns word-level timing marks + + @model_serializer + def _serialize_enabled_only(self) -> dict[str, bool]: + return {k: True for k, v in self.__dict__.items() if v} + + class Voice(BaseModel): # --- Readium ReadiumSpeechVoice-aligned fields --- - source: Literal["json", "browser"] = "json" - label: str name: str originalName: str - voiceURI: str - language: str # BCP-47 - localizedName: str | None = None # "android" | "apple" - altNames: list[str] | None = None - altLanguage: str | None = None - otherLanguages: list[str] | None = None - multiLingual: bool | None = None + identifier: str + language: str # BCP-47, primary + otherLanguages: list[str] = [] # ACTUALLY INSTALLED cross-language support, not the + # aspirational full list from voices.json (see app/providers/voice_loading.py) gender: Gender | None = None - children: bool | None = None quality: Quality | None = None - pitchControl: bool = False - pitch: float | None = None - rate: float | None = None - preloaded: bool = False - nativeID: str | list[str] | None = None - note: str | None = None # --- server extensions (not in ReadiumSpeechVoice) --- provider: str - engineVoiceId: str - sampleRate: int - mimeTypes: list[str] - boundary: bool = False # true when provider supports word-level timing marks + controls: Controls = Controls() + + +def voice_language_prefixes(voice: Voice) -> frozenset[str]: + return frozenset(lang.split("-")[0].lower() for lang in [voice.language, *voice.otherLanguages]) diff --git a/app/static/demo.html b/app/static/demo.html index 3b96441..e65dfb4 100644 --- a/app/static/demo.html +++ b/app/static/demo.html @@ -17,6 +17,7 @@ --mono-bg: #f2f2f7; --mono-text: #1c1c1e; --error: #ff3b30; + --ok: #34c759; } * { box-sizing: border-box; -webkit-font-smoothing: antialiased; } body { @@ -75,6 +76,10 @@ outline: 2px solid var(--accent); outline-offset: 1px; } + input:disabled, select:disabled { + opacity: 0.4; + cursor: not-allowed; + } textarea { resize: vertical; } button { margin-top: 1.4rem; @@ -89,10 +94,11 @@ cursor: pointer; transition: background 0.15s ease, transform 0.1s ease; } - button:hover { background: var(--accent-hover); } - button:active { transform: scale(0.97); } + button:hover:not(:disabled) { background: var(--accent-hover); } + button:active:not(:disabled) { transform: scale(0.97); } + button:disabled { opacity: 0.5; cursor: not-allowed; } #openBtn { background: var(--field); color: var(--accent); } - #openBtn:hover { background: var(--border); } + #openBtn:hover:not(:disabled) { background: var(--border); } pre { background: var(--mono-bg); color: var(--mono-text); @@ -112,9 +118,11 @@ details { margin-top: 1.4rem; border-top: 1px solid var(--border); padding-top: 0.9rem; } summary { cursor: pointer; font-weight: 600; font-size: 0.85rem; color: var(--muted); } details label { font-weight: 600; } + .hint { font-size: 0.75rem; color: var(--muted); font-weight: 400; margin-left: 0.35rem; } .checkbox-row { display: flex; align-items: center; gap: 0.6rem; margin-top: 1.1rem; } .checkbox-row input { width: auto; margin: 0; accent-color: var(--accent); } .checkbox-row label { margin: 0; font-weight: 500; color: var(--text); } + .checkbox-row.disabled label { opacity: 0.4; } .section-head { display: flex; align-items: baseline; justify-content: space-between; } .copy-btn { margin: 0; @@ -138,49 +146,67 @@ color: var(--accent); } .status-pill.error { background: color-mix(in srgb, var(--error) 14%, transparent); color: var(--error); } + /* loading indicator (replaces the player while synthesizing) */ + .loading { + display: none; + align-items: center; + gap: 0.6rem; + margin-top: 1.25rem; + color: var(--muted); + font-size: 0.9rem; + } + .loading.active { display: flex; } + .spinner { + width: 18px; height: 18px; + border: 2.5px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + /* metrics badges */ + .metrics { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.7rem; } + .metric { + font-size: 0.75rem; font-weight: 600; + padding: 0.15rem 0.55rem; border-radius: 999px; + background: var(--field); color: var(--mono-text); + } + .metric strong { color: var(--accent); } + .metric.rt strong { color: var(--ok); } TTS API Demo

Readium Speech Server

-

Talks to GET /voices and POST /synthesize on this same origin.

+

Talks to GET /service, GET /voices and POST /synthesize on this same origin.

- - -
- - -
-
- - + +
- - + +
- - + + - - + + - +
Synthesizing…
+
Developer tools — advanced request fields +

Fields the selected voice doesn't support are disabled.

@@ -188,26 +214,38 @@

Readium Speech Server

- +
- - + +
- - + +
-
+
- +
-
+
- +
@@ -215,6 +253,7 @@

Readium Speech Server

Response

+
(nothing yet)
@@ -227,39 +266,42 @@

diff --git a/docs/API.md b/docs/API.md index c58efa9..687413e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,6 +11,7 @@ Base path: `/`. All bodies are `application/json` unless noted. - [Authentication](#authentication) - [Errors](#errors) - [Health](#health) +- [`GET /service`](#get-service) - [`GET /voices`](#get-voices) - [`POST /synthesize`](#post-synthesize) - [Not implemented](#not-implemented) @@ -46,11 +47,12 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti | Status | `type` suffix | Raised when | |---|---|---| | 400 | `validation_failed` | `text` is empty or whitespace-only | -| 404 | `voice_not_found` | `voice` URI not in the registry | +| 404 | `voice_not_found` | `voice` identifier not in the registry | +| 404 | `voice_language_unsupported` | The requested `voice` (or `voice` + `language`) isn't served here. Deliberately neutral — it does not reveal *why* (whether a voice/language is installed), only that it's unsupported. Ask `/voices` for what this deployment actually serves | | 413 | `payload_too_large` | `text` exceeds `MAX_TEXT_LENGTH` | | 422 | `validation_failed` | Request body fails schema validation (wrong type, missing required field, invalid enum value) | | 502 | `provider_error` | Provider or ffmpeg failed (bad voice state, generation error, encode failure) | -| 503 | `service_not_ready` | `/readyz` only — models not loaded, ffmpeg missing, or a provider reports unhealthy | +| 503 | `service_not_ready` | **During startup** the server accepts connections immediately and loads models in the background — `/synthesize` and `/voices` return 503 until warmup finishes (`/healthz` and `/service` stay up throughout; `/readyz` flips to 200 when ready). Also 503 from `/readyz` if ffmpeg is missing or a provider is unhealthy, and from `/synthesize` when a provider's circuit breaker is open — see [configuration](configuration.md) for `CIRCUIT_BREAKER_*` | `unsupported_format` (415), `rate_limited` (429), and `provider_timeout` (504) are declared in `app/api/errors.py` for future providers but no current code path raises them — a `429`/`503` seen in production is nginx's own rate/connection limit, a plain nginx error page, not this JSON shape. @@ -65,6 +67,38 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti --- +## `GET /service` + +``` +GET /service +``` + +Server-wide, per-provider **capabilities** — kept separate from `/voices` so this isn't repeated on +every voice: supported output formats + default, request limits, and per provider the model-level +`quality`/`controls` and the installed-language summary. The voices themselves are on +[`GET /voices`](#get-voices). Per-provider details (output specs, voice notes) live in each +provider's README under `docs/providers/`. + +```json +{ + "output": {"formats": ["wav", "mp3", "opus"], "default": "wav"}, + "limits": {"maxTextLength": 2000, "maxConcurrentSyntheses": 2}, + "providers": [ + { + "id": "pocket", + "installedLanguages": ["en"] + } + ] +} +``` + +`providers[].installedLanguages` reflects `LANGUAGES` + `VOICE_LANGUAGES` as actually configured. +Model-level `quality`/`controls` aren't repeated here — they're merged into each voice on +[`GET /voices`](#get-voices) and documented per provider under `docs/providers/`. See +[configuration](configuration.md). + +--- + ## `GET /voices` ``` @@ -76,63 +110,62 @@ GET /voices?offset=0&limit=20 | Param | Type | Default | Description | |---|---|---|---| -| `language` | string | — | Filter by BCP-47 language prefix (`en`, `fr`, ...) | +| `language` | string | — | Filter by BCP-47 language prefix, matched against a voice's primary `language` **or** `otherLanguages` (`en`, `fr`, ...) | | `provider` | string | — | Filter by provider id (`pocket`) | | `offset` | int ≥ 0 | `0` | Voices to skip | | `limit` | int ≥ 1 | none | Max voices to return | -Response: `200`, `Voice[]`. Null-valued optional fields are omitted (`response_model_exclude_none`). +The voices **actually installed** on this deployment (realtime): each voice's `language` (primary) +and `otherLanguages` reflect what's loaded now, bounded by `LANGUAGES` + `VOICE_LANGUAGES`. Model-level +`quality`/`controls` are merged in per voice. A voice not in this list can't be synthesized here. + +Response: `200`, `Voice[]`. Headers: `X-Total-Count` (matches before pagination), `X-Offset`, `X-Limit` (omitted when `limit` unset). ### `Voice` +Model-level info (quality default, control support) is declared once per provider and **merged** +into every voice it serves; a voice only carries a field in `voices.json` when it's voice-specific +or needs to override that default. `otherLanguages` reflects languages **actually installed** for +that voice on this deployment, not every language the voice could theoretically support — see +[configuration](configuration.md). + ```json { - "source": "json", - "label": "Alba (English)", - "name": "pocket-en-alba", + "name": "Alba", "originalName": "alba", - "voiceURI": "urn:readium:tts:pocket:en-alba", - "language": "en", - "gender": "female", - "quality": "normal", - "pitchControl": false, - "preloaded": true, "provider": "pocket", - "engineVoiceId": "alba", - "sampleRate": 24000, - "mimeTypes": ["audio/mpeg", "audio/wav", "audio/ogg"], - "boundary": false + "identifier": "urn:readium:tts:pocket:alba", + "language": "en-US", + "otherLanguages": [], + "gender": "male", + "quality": "veryHigh", + "controls": {} } ``` +`controls` lists only the **enabled** controls — a control the voice doesn't support is absent +(pocket supports none, so `{}`). A voice that supported SSML would show `"controls": {"ssml": true}`. + **`ReadiumSpeechVoice`-aligned:** | Field | Type | Notes | |---|---|---| -| `source` | `"json" \| "browser"` | Always `"json"` — every voice here is server-hosted | -| `label` | string | Display name | -| `name` | string | Unique identifier within the Readium ecosystem | +| `name` | string | Display name | | `originalName` | string | Raw engine voice id | -| `voiceURI` | string | Send this as `SynthesizeRequest.voice` | -| `language` | string | BCP-47 | -| `localizedName`, `altNames`, `altLanguage`, `otherLanguages`, `multiLingual`, `children`, `nativeID`, `note` | — | Not populated by any current provider — always omitted | +| `language` | string | BCP-47, primary | +| `otherLanguages` | string[] | Additional languages this voice is actually installed for — empty by default (`VOICE_LANGUAGES` unset) | | `gender` | `"male" \| "female" \| "neutral" \| null` | | -| `quality` | `"veryLow"…"veryHigh" \| null` | PocketTTS voices are always `"normal"` | -| `pitchControl` | bool | `true` = provider accepts `output.pitch`. PocketTTS = `false` | -| `pitch`, `rate` | float or null | Recommended defaults, if the engine specifies any — currently always `null` | -| `preloaded` | bool | `true` = model weights already resident, ready without a cold-start delay | +| `quality` | `"veryLow"…"veryHigh" \| null` | Provider default unless a voice overrides it. PocketTTS voices are always `"veryHigh"` | **Server extensions (not in `ReadiumSpeechVoice`):** | Field | Type | Notes | |---|---|---| | `provider` | string | Backend serving this voice — `"pocket"` today | -| `engineVoiceId` | string | Opaque, internal — not for client use | -| `sampleRate` | int | Native PCM rate in Hz (`24000` for PocketTTS) | -| `mimeTypes` | string[] | Always `["audio/mpeg", "audio/wav", "audio/ogg"]` | -| `boundary` | bool | `true` = this voice's provider fills `boundaries` on synthesis. Check before setting `boundary: true` on the request — a `false` voice always gets `boundaries: null` back | +| `identifier` | string | Send this as `SynthesizeRequest.voice` | +| `controls` | object | `{pitch, speed, ssml, boundary}` booleans — what this voice accepts, merged from the provider's defaults with any voice-specific override. PocketTTS: all `false` | --- @@ -144,13 +177,13 @@ Headers: `X-Total-Count` (matches before pagination), `X-Offset`, `X-Limit` (omi "text": "Ceci est un test.", "ssml": false, "language": "fr", - "voice": "urn:readium:tts:pocket:fr-estelle", + "voice": "urn:readium:tts:pocket:estelle", "prev_utterance": "La nuit était sombre.", "next_utterance": "La pièce était froide.", "publication_id": "urn:isbn:9780000000000", "boundary": true, "output": { - "format": "mp3", + "format": "wav", "bitrate": 64, "sample_rate": null, "speed": 1.0, @@ -159,19 +192,19 @@ Headers: `X-Total-Count` (matches before pagination), `X-Offset`, `X-Limit` (omi } ``` -Only `text` and `voice` are required; everything else defaults as shown. +Only `text` is required; everything else defaults as shown (`voice` falls back to `POCKET_DEFAULT_VOICE`). | Field | Type | Default | Notes | |---|---|---|---| | `id` | string \| null | `null` | Client-generated UUID v7 URN. Parsed, logged, **not otherwise used** — no caching or idempotency yet ([roadmap](#not-implemented)) | | `text` | string | — | Max `MAX_TEXT_LENGTH` chars (2000 default). Rejected if empty/whitespace after trim | | `ssml` | bool | `false` | PocketTTS strips tags before synthesis (regex `<[^>]+>` removal) — no SSML-aware prosody | -| `language` | string \| null | `null` | Hint only; voice resolution is by `voiceURI`, not `language` | -| `voice` | string | — | Must exactly match a `voiceURI` from `/voices`. 404 if not found | +| `language` | string \| null | `null` | For voices installed across more than one language (`VOICE_LANGUAGES=*:*` or an explicit override), picks which installed language to synthesize in; falls back to the voice's primary language if unset or not installed for that voice | +| `voice` | string \| null | `null` | A voice `identifier` from `/voices`, or a raw `originalName`. 404 if not found. When omitted, falls back to `POCKET_DEFAULT_VOICE`; if that's unset too, `400` | | `prev_utterance` / `next_utterance` | string \| null | `null` | Accepted, passed into `SynthesisParams`; PocketTTS ignores both | | `publication_id` | string \| null | `null` | Accepted, currently unused (reserved for future cache scoping) | | `boundary` | bool | `false` | `true` → JSON response with base64 audio + timing marks instead of raw binary | -| `output.format` | `"mp3" \| "wav" \| "opus"` | `"mp3"` | `wav` bypasses ffmpeg (fastest); `mp3`/`opus` are ffmpeg-encoded | +| `output.format` | `"wav" \| "mp3" \| "opus"` | `"wav"` | `wav` bypasses ffmpeg and is highest quality (fastest, no lossy encoding); `mp3`/`opus` are ffmpeg-encoded on request — see [`GET /service`](#get-service) for what's available | | `output.bitrate` | int \| null | `null` | kbps for `mp3`/`opus`; ffmpeg default (~128 mp3, ~64 opus) if unset; ignored for `wav` | | `output.sample_rate` | int \| null | `null` | **Accepted but not applied** — output is always the model's native rate (24000 Hz for PocketTTS). No resampling happens today | | `output.speed` | float | `1.0` | **Accepted but ignored** by PocketTTS (logged at debug level) | @@ -200,7 +233,7 @@ Only `text` and `voice` are required; everything else defaults as shown. |---|---|---| | `audio` | string | Base64, encoded in `output.format` | | `format` | string | Echoes the requested/default format | -| `boundaries` | `TimingMark[] \| null` | `null` = voice's provider doesn't support timing (`Voice.boundary == false`) — currently true for **every** voice, since PocketTTS never populates this | +| `boundaries` | `TimingMark[] \| null` | `null` = this voice doesn't support timing (`Voice.controls.boundary == false`) — currently true for **every** voice, since PocketTTS never populates this | ### `TimingMark` @@ -222,9 +255,10 @@ No `end` field (next mark's `elapsedTime`, or total duration for the last word, Things the schema or config surfaces but the server doesn't actually do yet: - **Auth** — `API_KEY_ENABLED` is validated at startup but never enforced on requests. -- **Word boundaries** — no provider populates `TimingMark`s. Every voice reports `boundary: false`. +- **Word boundaries** — no provider populates `TimingMark`s. Every voice reports `controls.boundary: false`. - **`output.speed` / `output.pitch` / `output.sample_rate`** — accepted, validated, silently ignored by PocketTTS. - **`id` / `publication_id`** — parsed, not used for caching, idempotency, or dedup. - **SSML** — tags are stripped, not interpreted. No prosody control. - **MathML** — passed through as plain text; equations get spoken as raw markup. +- **Curated cross-language voice quality** — `otherLanguages` in `voices.json` documents what a voice is *capable* of, not whether it's a *good fit* (e.g. an English voice speaking French). No ranking/curation of this is implemented yet — deliberately conservative, pending a listening review. - **Providers beyond PocketTTS** — ElevenLabs is planned but not yet wired into `_build_registry()`. diff --git a/docs/configuration.md b/docs/configuration.md index 9fa75f2..bbb9931 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,10 +1,24 @@ # Configuration -Run `make configure` to generate `.env` via an interactive wizard, or run `bash scripts/configure.sh` directly. +Run `make configure` to generate config via an interactive wizard, or run `bash scripts/configure.sh` directly. + +Config is split across two files, both written by the wizard: + +- **`.env`** — server/auth/concurrency/circuit-breaker. Universal, provider-agnostic. +- **`pocket-tts.env`** — PocketTTS-scoped install config (which languages, which voices in which + languages). A later provider (Kokoro, etc.) would get its own equivalent file the same way, + since "which languages/voices does this provider install" is inherently per-provider, not + global. `pydantic-settings` reads both automatically (`app/config/settings.py`); `pocket-tts.env` + simply being absent is not an error. Existing installs are migrated automatically the next time + the wizard runs (it moves `LANGUAGES`/`VOICE_INSTALL_MODE`/`POCKET_DEFAULT_VOICE` out of `.env` + and into a newly created `pocket-tts.env`, once, silently). ## Setup wizard -`make configure` handles both first-time setup and ongoing management: +`make configure` handles both first-time setup and ongoing management. The top-level menu only +has universal actions; anything provider-scoped lives behind **Manage provider**, which lists +registered providers (just `pocket` today) and drops into a provider-specific submenu — the same +shape a second provider (Kokoro, etc.) would slot into later: ``` Readium Speech Server @@ -12,43 +26,162 @@ Readium Speech Server Current: languages=en workers=1 1) Show full config - 2) Add a language - 3) Remove a language - 4) Change workers - 5) Update HF token - 6) First-time setup (re-run / overwrite) - 7) Reset + 2) Manage provider + 3) Change workers + 4) Update HF token + 5) First-time setup (re-run / overwrite) + 6) Reset q) Quit ``` -Adding a language updates `.env` in place — only the new model is downloaded on next restart. Removing a language preserves the model files in the Docker volume; disk is reclaimed only if you choose to purge the volume. +**Manage provider → pocket:** (the live install plan is shown above the menu each time) + +``` +1) Languages +2) Voice coverage +3) Back +``` + +A linear two-step flow: pick languages first, then choose coverage over them. + +**Languages** re-runs the same multi-select as first-time setup — whatever you check becomes +`LANGUAGES` (the ceiling: the base models that load). Newly-added languages download once on next +restart (~219 MB for en/it/pt, ~672 MB for fr/de/es); removed languages keep their model files in +the Docker volume — see [Disk space](#disk-space) to reclaim selectively. + +**Voice coverage** chooses what installs on top of the native defaults (see +[Per-voice language overrides](#per-voice-language-overrides)). The native voices of your selected +languages are **always** installed — they're the best-matched model for each voice, so coverage +only ever *adds* cross-language support: + +- **default** — native voices only, each in its own language (`VOICE_LANGUAGES=`). +- **all** — every voice also speaks every selected language, including non-native ones + (`VOICE_LANGUAGES=*:*`). +- **custom** — defaults **plus** cross-language pairs you hand-pick. Pick any voice, then choose any + of the languages it can speak; each pick adds an `originalName:lang` pair and, if needed, pulls + that language into `LANGUAGES`. `VOICE_LANGUAGES` lists only the *extra* pairs — the defaults stay + implied by `LANGUAGES`. > **macOS only.** The configure script auto-downloads [gum](https://github.com/charmbracelet/gum) for its menus. On Linux, install gum manually before running it. -## Environment variables +## Environment variables — `.env` | Variable | Default | Description | |---|---|---| -| `LANGUAGES` | `en` | Comma-separated BCP-47 language codes to load. Supported: `en fr it de es pt` | -| `HF_TOKEN` | _(empty)_ | HuggingFace token. Optional — prevents rate-limiting on first-run model downloads | +| `HF_TOKEN` | _(empty)_ | HuggingFace token. Optional — prevents rate-limiting on first-run model downloads. Shared across any provider that pulls models from HF | | `WORKERS` | `1` | Uvicorn worker processes. Each loads a full copy of every active language model | | `MAX_CONCURRENT_SYNTHESES` | `2` | Max parallel CPU inference jobs per worker | +| `CIRCUIT_BREAKER_ENABLED` | `true` | Trip a provider's circuit breaker after repeated `synthesize()` failures, returning `503` immediately instead of hammering a broken provider | +| `CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive failures before a provider's breaker opens | +| `CIRCUIT_BREAKER_RECOVERY_SECONDS` | `30` | How long an open breaker waits before allowing one trial call | | `API_KEY_ENABLED` | `false` | Reserved — validated at startup but **not yet enforced** on any route | | `API_KEY` | _(empty)_ | Reserved, same caveat | | `LOG_LEVEL` | `INFO` | `DEBUG` · `INFO` · `WARNING` · `ERROR` | | `PORT` | `8000` | Listen port | | `MAX_TEXT_LENGTH` | `2000` | Maximum characters per synthesis request | | `FFMPEG_BIN` | `ffmpeg` | Path to ffmpeg binary (bundled in the Docker image) | -| `POCKET_DEFAULT_VOICE` | `alba` | Default voice when none is specified | | `ENABLED_PROVIDERS` | `pocket` | Comma-separated provider ids to register at startup. Only `pocket` exists today | | `DEFAULT_PROVIDER` | `pocket` | Must be one of `ENABLED_PROVIDERS` — validated at startup | | `DOMAIN` | _(empty)_ | Required when `APP_ENV=production` — used for `TrustedHostMiddleware` and nginx `server_name` | -## RAM estimate +## Environment variables — `pocket-tts.env` -`WORKERS × active languages × ~240 MB` +| Variable | Default | Description | +|---|---|---| +| `LANGUAGES` | _(empty)_ | Comma-separated BCP-47 language codes to **load as base models**. No hardcoded fallback — env-driven; unset means no base models load (no voices served). Size per language is *not* uniform: the 6-layer `en`/`it`/`pt` models are ~219 MB download / ~438 MB RAM; the 24-layer `fr`/`de`/`es` models are ~672 MB download / ~1344 MB RAM (loaded size ~2x the download, measured at startup). Supported: `en fr it de es pt`. This is the ceiling — nothing below can exceed it | +| `VOICE_LANGUAGES` | _(empty)_ | Which voices get warmed against which of those loaded models, beyond each voice's own primary — see [Per-voice language overrides](#per-voice-language-overrides) | +| `POCKET_DEFAULT_VOICE` | _(empty)_ | Default voice when none is specified. No hardcoded fallback — env-driven; empty means the setting is unset | + +`LANGUAGES` and `VOICE_LANGUAGES` operate at different levels and both matter: `LANGUAGES` +decides which base language models physically load into RAM at all — any voice whose *primary* +language isn't in this set is skipped entirely, no matter what `VOICE_LANGUAGES` says. +`VOICE_LANGUAGES` only fine-tunes which *already-loaded* models a voice also gets warmed +against. + +## Per-voice language overrides + +`VOICE_LANGUAGES` is one setting that does two things: + +- A bare `*:*` token = install every voice's declared `otherLanguages` that are in `LANGUAGES` + (old `VOICE_INSTALL_MODE=all`). Its absence = primary-only (old `VOICE_INSTALL_MODE=primary`, + still the default). +- Explicit `originalName:lang` pairs cherry-pick or prune on top of that base — e.g. install + French for Alba specifically without turning on `*:*` for every voice, or turn on `*:*` but + exclude one voice's Portuguese support. Use the wizard's "Voice coverage → custom", or set + directly: + +```dotenv +VOICE_LANGUAGES=alba:fr,alba:de,-javert:es +``` + +Comma-separated `originalName:lang` pairs (voice's `originalName` from `voices.json`, not its +`identifier`). A pair adds by default; a leading `-` removes instead. `*:*` can appear in the +same list as explicit pairs (`VOICE_LANGUAGES=*:*,-javert:es` = "everything except javert's +Spanish"). Rules, applied on top of the `*:*` base: + +1. An **add** pair can only promote a language the voice already declares in its own + `otherLanguages` in `voices.json` — it can't invent support voices.json doesn't claim. +2. Both directions are bounded by `LANGUAGES` — neither ever triggers downloading a new base + language model by itself; the language must already be enabled. +3. If the same pair appears both with and without `-`, **remove wins** — it's always the final + word for the exact pair it names. +4. A voice installs for whichever enabled languages apply — its primary if that's in + `LANGUAGES`, plus any added/`*:*` cross-languages. A voice can even run in a **non-primary** + language *without* its primary base model (e.g. `VOICE_LANGUAGES=estelle:en` with + `LANGUAGES=en` installs estelle in English only, no French model): pocket_tts loads a voice's + embedding from the target language's model, not the primary's. When a request omits `language`, + the voice's default is its primary if installed, else its first installed language. `/voices` + still reports the voice's true primary `language` (from `voices.json`); `/service` reports which + languages are actually installed. + +## Model sizes & RAM + +The wizard deliberately keeps sizes out of its prompts — they live here. All figures are for the +`kyutai/pocket-tts-without-voice-cloning` models (what the server loads without gated access) and +are approximate. + +**Base language model** — one per selected language, loaded once per worker. Two size classes: + +| Languages | Layers | Download (disk, once) | RAM (per worker) | +|---|---|---|---| +| `en` `it` `pt` | 6 | ~219 MB | ~438 MB | +| `fr` `de` `es` | 24 (`_24l`) | ~672 MB | ~1344 MB | + +RAM ≈ 2× the download (measured at startup). This is the dominant cost. + +**Voice embedding** — one per installed `(voice, language)`, small: ~6 MB into a 6-layer language, +~25–33 MB into a 24-layer language. Native voices carry one; each cross-language pair adds one more. + +**RAM formula:** `WORKERS × (sum of each active language's RAM size)`. +Example: 1 worker, English + French = `438 + 1344 ≈ 1782 MB` RAM (download ~891 MB base + a few +hundred MB of embeddings). Add a language, and its whole base model is added per worker. + +To see the exact **voice/pair count** for your config (not sizes), the wizard's config view uses +`scripts/pocket_plan.py`. + +## Disk space + +Changing `LANGUAGES` or `VOICE_LANGUAGES` doesn't free disk on its own — weights stay cached in the +`weights_cache` volume in case you re-add them later (and re-download automatically from the HF +cache on next start if you do). To reclaim space for what you no longer use, without purging +everything: + +```bash +make prune-models # dry-run report +make prune-models-apply # actually delete +``` -Example: 2 workers, English + French = `2 × 2 × 240 MB ≈ 960 MB` +It prunes to the **exact install plan** of your current `LANGUAGES` + `VOICE_LANGUAGES` (reusing +the same `plan_install` the server loads with, so it can't drift): it drops base models for +languages no longer enabled **and**, within kept languages, individual voice embeddings no longer +used (e.g. a removed cross-language pair). Empty `VOICE_LANGUAGES` (default coverage) correctly +keeps just the native voices. It only deletes a file's underlying blob once nothing else references +it — safe to re-run anytime. Under the hood it's `docker compose exec app python +scripts/prune_weights.py [--apply]`; pass `--keep en,fr` for a language-level-only override that +preserves every embedding. + +To reclaim everything at once instead, use the wizard's "Reset" → "purge all downloaded models" +option, which removes the whole `weights_cache` volume. ## Production vs development diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..780ed8f --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,75 @@ +# Deployment + +Automated: CI passes on `main` → `deploy.yml` builds + pushes the image to GHCR and +SSHes into the GCP VM to pull + restart. **Config (`.env`, `pocket-tts.env`) is +provisioned by the pipeline from GitHub Secrets — you never edit it on the VM by +hand, and you never run the wizard in production.** `configure.sh` is a *local / +first-time* convenience only. + +## How config flows + +``` +local .env / pocket-tts.env ──base64──▶ GitHub Secrets ──deploy.yml writes──▶ VM files ──▶ containers +``` + +`.env` and `pocket-tts.env` are gitignored (they hold secrets). The two GitHub +secrets `DOTENV_B64` / `POCKET_TTS_ENV_B64` are the single source of truth; the +deploy job decodes them onto the VM before `docker compose up`. + +## One-time setup + +### 1. Local — produce the env files +Generate them however you like (the wizard is easiest): +```bash +./scripts/configure.sh # or hand-edit .env and pocket-tts.env +``` +Then base64-encode each (copy the output): +```bash +base64 -i .env | pbcopy # macOS; or: base64 -w0 .env (Linux) +base64 -i pocket-tts.env | pbcopy +``` + +### 2. GitHub — create the secrets +Repo → **Settings → Secrets and variables → Actions → New repository secret**: + +| Secret | Value | +|---|---| +| `DOTENV_B64` | base64 of your `.env` | +| `POCKET_TTS_ENV_B64` | base64 of your `pocket-tts.env` | +| `GCP_VM_HOST` / `GCP_VM_USER` / `GCP_SSH_PRIVATE_KEY` | VM SSH access (already set) | + +### 3. VM — prepare once (no config edits) +```bash +git clone https://github.com/readium/speech-server ~/speech-server +# install docker + docker compose plugin +# if the GHCR image is private, authenticate so `docker compose pull` works: +echo "$GHCR_PAT" | docker login ghcr.io -u --password-stdin +``` +The `weights_cache` Docker volume persists across deploys, so models download only +once. Nginx serves TLS (see `nginx/` + `DOMAIN`). + +## Changing config (any env var, add or edit) — the whole point + +**No SSH, no wizard on the VM.** Update the source and redeploy: + +1. Edit `.env` / `pocket-tts.env` locally (or re-run the wizard). +2. Re-base64 and update the corresponding GitHub secret (`DOTENV_B64` / + `POCKET_TTS_ENV_B64`). +3. Push to `main` (or re-run the **Build & Deploy** workflow). The deploy job + rewrites the VM's env files and restarts. + +Adding a *new* var is the same flow — it's just part of the file. `entrypoint.sh` +validates required vars at container start and fails fast if one is missing, so a +bad/incomplete secret is caught immediately (the old container keeps running). + +## Notes + +- **Model/voice config** (`LANGUAGES`, `VOICE_LANGUAGES`) lives in `pocket-tts.env`. + Changing it and redeploying triggers new downloads on next start (cached after); + reclaim disk for removed ones with `make prune-models-apply`. +- **Secrets vs non-secrets:** bundling both files as base64 secrets is the pragmatic + choice for a single VM. To review config in PRs instead, graduate to a + SOPS/age-encrypted env committed to the repo and decrypted at deploy. +- **Rollback:** images are tagged by commit SHA (`ghcr.io/readium/speech-server:`); + pin that tag in compose and redeploy to roll back the app. Config rolls back by + restoring the previous secret value. diff --git a/docs/development.md b/docs/development.md index 0dee475..eee2797 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,11 +33,15 @@ uv run pytest tests/ -m 'not integration and not slow' -v ## Adding a provider 1. Create `app/providers/.py` implementing `TTSProvider` -2. Declare `id`, `supported_languages`, and `supports_boundaries` as class variables -3. Implement `_all_voices()` and `synthesize()` -4. Register in `app/main.py` `_build_registry()` +2. Declare `id`, `supported_languages`, `default_quality`, and `default_controls` as class variables +3. If voices come from a JSON file, reuse `app/providers/voice_loading.py` (`VoiceEntry`, + `resolve_install_languages`, `build_voice`) — the same install-mode bounding and + quality/controls merge PocketTTS uses, so it doesn't get reimplemented per provider +4. Implement `_all_voices()` and `synthesize()` +5. Register in `app/main.py` `_build_registry()` -No changes to routes, synthesizer, or voice catalog. Language filtering and boundary capability are inherited automatically from the base class. +No changes to routes, synthesizer, or voice catalog. Language filtering, quality/controls +merging, and circuit-breaking are inherited automatically from the base class and core layer. ## Architecture @@ -46,13 +50,15 @@ Client └─ POST /synthesize └─ Synthesizer ├─ validate text length + content - ├─ resolve voiceURI → (provider, voiceURI) (VoiceCatalog) + ├─ resolve voice identifier → (provider, Voice) (VoiceCatalog) + ├─ circuit breaker check ← per-provider, 503 fast if open ├─ provider.synthesize() ← runs in thread pool, bounded by semaphore │ └─ TTSModel.generate_audio() — CPU inference - └─ encode PCM → mp3/opus (ffmpeg driver) or wrap → wav + └─ encode PCM → mp3/opus (ffmpeg driver) or wrap → wav (default) ``` - Routes are `async`; all CPU-bound inference runs off the event loop via `anyio.to_thread.run_sync` - A semaphore (`MAX_CONCURRENT_SYNTHESES`) prevents model thrashing under concurrent load +- A per-provider circuit breaker (`CIRCUIT_BREAKER_*`) fails fast instead of repeatedly hitting a broken provider - Model throughput scales by adding worker processes (`WORKERS`), not threads - Model weights live in a named Docker volume — downloaded once, instant on every subsequent start diff --git a/docs/providers/pocket.md b/docs/providers/pocket.md new file mode 100644 index 0000000..785ffad --- /dev/null +++ b/docs/providers/pocket.md @@ -0,0 +1,55 @@ +# Provider: PocketTTS + +Model-level information for the `pocket` provider — the things that are true for the whole model, +so they're documented here once instead of repeated on every voice in `/voices`. The API still +merges the model-level defaults below into each voice (a voice may override them). + +Source: [kyutai/pocket-tts](https://github.com/kyutai-labs/pocket-tts) — a CPU TTS (flow-based LM + +Mimi neural codec). + +## Identity + +- **Provider id:** `pocket` +- **Voice identifier:** `urn:readium:tts:pocket:` (e.g. `urn:readium:tts:pocket:estelle`). + Requests combine the `identifier` with a `language` — voices are declared **once**, not per + language. The server picks the right language model behind the scenes. + +## Model-level defaults (merged into every voice) + +| Property | Value | Notes | +|---|---|---| +| `quality` | `veryHigh` | Quality of the **voice**, not the audio output. All pocket voices are `veryHigh`. | +| `controls.pitch` | `false` | Not supported. | +| `controls.speed` | `false` | Not supported (no native speed control). | +| `controls.ssml` | `false` | SSML not supported; tags are stripped, not rendered. | +| `controls.boundary` | `false` | No word-level timing marks. | + +A future voice that overrides one of these (e.g. `controls.ssml: true`) would carry that field in +`voices.json`; otherwise the value above applies. + +## Audio output + +PocketTTS renders **24 kHz · mono · 16-bit PCM**, returned as a standard **WAV** file with no +re-encoding (the default, and fastest — no ffmpeg). The server can transcode on request: + +- `wav` — native, lossless. **Default.** +- `opus` — compressed, high quality. Strong second choice. +- `mp3` — lossy; noticeably worse, not a good default. + +`output.sample_rate` is accepted but not applied — output is always the model's native 24 kHz. + +## Languages & voices + +- **Native languages:** `en` `fr` `it` `de` `es` `pt`. English ships many voices; each other + language ships one native voice. +- **Cross-language:** any voice can also speak the languages it declares in `otherLanguages`, + *if* that `(voice, language)` pair is installed (`VOICE_LANGUAGES`). A voice needs the target + language's base model **and** its per-language speaker embedding — see + [configuration → model sizes](../configuration.md#model-sizes--ram). +- What's **actually installed** on a deployment is what `GET /voices` returns (realtime). The full + declared list of possibilities lives in `app/data/voices/pocket/voices.json`. + +## Constraints + +- CPU only; torch ≥ 2.5 CPU build. `en`/`it`/`pt` are 6-layer models; `fr`/`de`/`es` are larger + 24-layer models (slower, higher quality). Throughput scales by worker processes. diff --git a/docs/voices.md b/docs/voices.md index 024589c..2241e32 100644 --- a/docs/voices.md +++ b/docs/voices.md @@ -1,6 +1,13 @@ # Voices -156 voices across 7 language variants (26 voice identities × 6 languages). Every voice is available in every language — `alba` speaking English, `alba` speaking French, `alba` speaking German, etc. Only languages listed in `LANGUAGES` are loaded at startup (~240 MB RAM per language per worker). +26 voice identities, each declared **once** — not duplicated per language. A voice has one +primary `language` and, optionally, `otherLanguages` it's also capable of speaking. The +identifier has no language segment: it's always `urn:readium:tts:pocket:`, and which +language to speak is chosen at request time via `SynthesizeRequest.language`. + +``` +urn:readium:tts:pocket:alba # one identifier for Alba, in any installed language +``` The 26 voice identities, sourced from [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices): @@ -15,18 +22,37 @@ The 26 voice identities, sourced from [kyutai/tts-voices](https://huggingface.co | estelle | female | Unmute production voices | | giovanni, lola, juergen, rafael | mixed | Kyutai (language reference voices) | -## Voice URIs +Supported language codes: `en`, `fr`, `it`, `de`, `es`, `pt` — derived directly from PocketTTS model names. -Voice URIs are language-scoped — the same speaker in different languages gets a distinct URI: +## Cross-language voices -``` -urn:readium:tts:pocket:en-alba # Alba speaking English -urn:readium:tts:pocket:fr-alba # Alba speaking French -urn:readium:tts:pocket:de-alba # Alba speaking German -``` +Every voice's `otherLanguages` in `voices.json` documents what it's *capable* of, not whether +it's a *good fit* — an English voice speaking French isn't necessarily natural-sounding. No +ranking or curation of this exists yet; treat it as aspirational, not a quality signal. -Supported language codes: `en`, `fr`, `it`, `de`, `es`, `pt` — derived directly from PocketTTS model names. +**Which cross-language support actually gets installed is controlled by `VOICE_LANGUAGES`** +(see [Configuration](configuration.md)): + +| Setting | Behavior | +|---|---| +| _(empty, default)_ | Each voice is only installed for its primary `language`. Smallest download/RAM footprint. | +| `*:*` | Every voice is also installed for its `otherLanguages` — but **only** for languages already in `LANGUAGES`. This never triggers downloading a language model you didn't already select; it just lets already-loaded models serve more voices. | + +For exact (voice, language) picks instead of the all-or-nothing `*:*` — e.g. only Alba in French, +or `*:*` minus one voice's Spanish — the same setting takes explicit `voice:lang` pairs +alongside (or instead of) the wildcard; see +[Configuration → per-voice language overrides](configuration.md#per-voice-language-overrides). + +`GET /voices` reflects what's actually **installed** on this deployment — its `otherLanguages` +is the installed subset, not the full aspirational list from `voices.json` (see +[API reference](API.md#get-voices)). `GET /service` summarizes each provider's installed +languages without repeating it per voice. ## How it works -PocketTTS pre-computes voice embeddings for every voice × language combination (stored as `.safetensors` files in `kyutai/pocket-tts-without-voice-cloning`). The voice sample is encoded once at model-load time — no per-request cloning overhead. +PocketTTS pre-computes voice embeddings per voice per language (stored as `.safetensors` files +under `kyutai/pocket-tts-without-voice-cloning`), sized by the target language's model: +~5–8 MB into the 6-layer `en`/`it`/`pt` models, ~24–33 MB into the 24-layer `fr`/`de`/`es`. The embedding is warmed once at +model-load time — no per-request cloning overhead — but only for the (voice, language) pairs +`VOICE_LANGUAGES` and `LANGUAGES` actually call for. Unused language weights can be reclaimed +from the Docker volume with `scripts/prune_weights.py` — see [Configuration → disk space](configuration.md). diff --git a/nginx/templates/20-speech-server.conf.template b/nginx/templates/20-speech-server.conf.template index 1494c35..521ad6c 100644 --- a/nginx/templates/20-speech-server.conf.template +++ b/nginx/templates/20-speech-server.conf.template @@ -64,6 +64,12 @@ server { limit_conn perip 10; include /etc/nginx/snippets/proxy-pass.conf; + + # TTS inference is CPU-bound and can be slow (24-layer models, long text, + # emulated arch) — give synthesis more than the shared 120s read timeout so + # a legitimately long generation isn't cut off with a 504. This override must + # come AFTER the include, whose 120s would otherwise win. + proxy_read_timeout 300s; } location / { diff --git a/scripts/configure.sh b/scripts/configure.sh index fb3b8a7..0ebed94 100755 --- a/scripts/configure.sh +++ b/scripts/configure.sh @@ -4,6 +4,9 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="$REPO_ROOT/.env" +POCKET_ENV_FILE="$REPO_ROOT/pocket-tts.env" +VOICES_JSON="$REPO_ROOT/app/data/voices/pocket/voices.json" +POCKET_PLAN="$REPO_ROOT/scripts/pocket_plan.py" # ── Colours ─────────────────────────────────────────────────────────────────── B=$'\033[1m' R=$'\033[0m' G=$'\033[32m' Y=$'\033[33m' C=$'\033[36m' RED=$'\033[31m' @@ -14,18 +17,23 @@ err() { printf '%s✗%s %s\n' "$RED" "$R" "$*" >&2; } hdr() { echo; gum style --bold --foreground 212 "── $* ──"; echo; } banner() { gum style --border rounded --border-foreground 212 --bold --padding "0 2" --margin "1 0" "Readium Speech Server"; } -# ── Portable .env read/write ────────────────────────────────────────────────── -get_env() { - grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true +# ── Portable env-file read/write ──────────────────────────────────────────────── +# .env: server/auth/concurrency/circuit-breaker — universal, provider-agnostic. +# pocket-tts.env: PocketTTS-scoped install config (LANGUAGES, VOICE_LANGUAGES, +# POCKET_DEFAULT_VOICE). Future providers get their own file the same way. +# get_env/set_env target .env; get_penv/set_penv target pocket-tts.env — both +# built on the same generic _get_kv/_set_kv. +_get_kv() { + grep -E "^${2}=" "$1" 2>/dev/null | cut -d= -f2- || true } -set_env() { - local key="$1" val="$2" tmp - if [[ ! -f "$ENV_FILE" ]]; then - printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" +_set_kv() { + local file="$1" key="$2" val="$3" tmp + if [[ ! -f "$file" ]]; then + printf '%s=%s\n' "$key" "$val" >> "$file" return fi - if grep -qE "^${key}=" "$ENV_FILE"; then + if grep -qE "^${key}=" "$file"; then tmp=$(mktemp) while IFS= read -r line; do if [[ "$line" == "${key}="* ]]; then @@ -33,15 +41,65 @@ set_env() { else printf '%s\n' "$line" fi - done < "$ENV_FILE" > "$tmp" - mv "$tmp" "$ENV_FILE" + done < "$file" > "$tmp" + mv "$tmp" "$file" else - printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + printf '%s=%s\n' "$key" "$val" >> "$file" fi } +get_env() { _get_kv "$ENV_FILE" "$1"; } +set_env() { _set_kv "$ENV_FILE" "$1" "$2"; } +get_penv() { _get_kv "$POCKET_ENV_FILE" "$1"; } +set_penv() { _set_kv "$POCKET_ENV_FILE" "$1" "$2"; } + +# ── Migrations (one-time, idempotent — safe to call every run) ───────────────── +_migrate_pocket_env() { + [[ -f "$ENV_FILE" ]] || return 0 + [[ -f "$POCKET_ENV_FILE" ]] && return 0 + local langs mode voice + langs=$(get_env LANGUAGES) + mode=$(get_env VOICE_INSTALL_MODE) + voice=$(get_env POCKET_DEFAULT_VOICE) + [[ -z "$langs" && -z "$mode" && -z "$voice" ]] && return 0 + + cat > "$POCKET_ENV_FILE" << EOF +# PocketTTS — provider-scoped install config. Migrated from .env by configure.sh. +LANGUAGES=${langs} +POCKET_DEFAULT_VOICE=${voice} +VOICE_LANGUAGES=$([ "$mode" = "all" ] && echo '*:*') +EOF + chmod 600 "$POCKET_ENV_FILE" + + local tmp + tmp=$(mktemp) + grep -vE '^(LANGUAGES|VOICE_INSTALL_MODE|POCKET_DEFAULT_VOICE)=' "$ENV_FILE" > "$tmp" + mv "$tmp" "$ENV_FILE" + + ok "Migrated LANGUAGES/VOICE_INSTALL_MODE/POCKET_DEFAULT_VOICE → pocket-tts.env" +} + +# Earlier versions of this script wrote VOICE_INSTALL_MODE into pocket-tts.env +# directly (before it merged into the VOICE_LANGUAGES wildcard). Fold it in. +_migrate_voice_install_mode() { + [[ -f "$POCKET_ENV_FILE" ]] || return 0 + local mode + mode=$(get_penv VOICE_INSTALL_MODE) + [[ -z "$mode" ]] && return 0 + + if [[ "$mode" == "all" ]]; then + set_penv VOICE_LANGUAGES "$(_voicelang_prepend_wildcard "$(get_penv VOICE_LANGUAGES)")" + fi + local tmp + tmp=$(mktemp) + grep -v '^VOICE_INSTALL_MODE=' "$POCKET_ENV_FILE" > "$tmp" + mv "$tmp" "$POCKET_ENV_FILE" + ok "Migrated VOICE_INSTALL_MODE → VOICE_LANGUAGES wildcard" +} + # ── Language helpers ────────────────────────────────────────────────────────── ALL_LANGS=(en fr it de es pt) +BASE_LANGS="" # protected language base while custom coverage is being edited lang_name() { case "$1" in @@ -54,7 +112,7 @@ lang_name() { current_langs() { local raw - raw=$(get_env LANGUAGES) + raw=$(get_penv LANGUAGES) echo "${raw:-en}" } @@ -70,6 +128,164 @@ lang_list_to_set() { echo "$out" } +# Multi-select the language set, with a confirm-and-retry loop. ENTER is easy to +# hit reflexively before pressing SPACE to check anything — the confirm guards +# against a stray ENTER silently locking in an empty/wrong pick. Sets global +# PICKED_LANGS (not echoed — gum/confirm output would pollute a $(...) capture). +# $1 = fallback CSV used when nothing is checked (keep-current / "en"). +_pick_languages() { + PICKED_LANGS="" + local fallback="${1:-en}" + local lang_opts=() code + for code in "${ALL_LANGS[@]}"; do + lang_opts+=("$code — $(lang_name "$code")") + done + while true; do + local choices=() line + while IFS= read -r line; do + [[ -n "$line" ]] && choices+=("$line") + done < <(printf '%s\n' "${lang_opts[@]}" | + gum choose --no-limit \ + --header "SPACE to check, ENTER when done (none checked → keep '$fallback')") + + local langs="" + if [[ ${#choices[@]} -gt 0 ]]; then + for line in "${choices[@]}"; do + langs="${langs:+${langs},}${line%% —*}" + done + fi + [[ -z "$langs" ]] && langs="$fallback" + langs=$(lang_list_to_set "$langs") + + printf '\nSelected:\n' + local c + IFS=',' read -ra _codes <<< "$langs" + for c in "${_codes[@]}"; do + printf ' %s✓%s %s — %s\n' "$G" "$R" "$c" "$(lang_name "$c")" + done + if gum confirm "Use these languages?"; then + PICKED_LANGS="$langs" + return + fi + printf '\n' + done +} + +# ── VOICE_LANGUAGES helpers ────────────────────────────────────────────────── +# Comma-separated "originalName:lang" pairs; a leading "-" on a pair means remove +# instead of add, e.g. "alba:fr,alba:de,-javert:es". A bare "*:*" token means +# "every voice, every declared+enabled otherLanguage" (old install_mode=all). +_voicelang_upsert() { + local list="$1" voice="$2" lang="$3" mode="$4" out="" p bare entry + # bash 3.2 (macOS default) treats "${arr[@]}" on an empty array as unbound + # under set -u — short-circuit rather than expand an array from "". + if [[ -n "$list" ]]; then + IFS=',' read -ra parts <<< "$list" + for p in "${parts[@]}"; do + [[ -z "$p" ]] && continue + bare="${p#-}" + [[ "$bare" != "${voice}:${lang}" ]] && out="${out:+${out},}${p}" + done + fi + entry="${voice}:${lang}" + [[ "$mode" == "remove" ]] && entry="-${entry}" + [[ -z "$out" ]] && { echo "$entry"; return; } + echo "${out},${entry}" +} + +_voicelang_strip_wildcard() { + local list="$1" out="" p + if [[ -n "$list" ]]; then + IFS=',' read -ra parts <<< "$list" + for p in "${parts[@]}"; do + [[ -z "$p" || "$p" == "*:*" ]] && continue + out="${out:+${out},}${p}" + done + fi + echo "$out" +} + +_voicelang_prepend_wildcard() { + local list + list=$(_voicelang_strip_wildcard "$1") + [[ -z "$list" ]] && { echo "*:*"; return; } + echo "*:*,${list}" +} + +# Prints "originalName|Display Name (gender, primaryLang)" per voice, one per +# line — e.g. "estelle|Estelle (female, fr)". Sorted primary-English voices +# first, then by language, matching how PocketTTS's own voice picker groups +# them (native-language voices called out distinctly from the English pool). +list_voice_choices() { + python3 - "$VOICES_JSON" "${1:-}" << 'PY' +import json, sys +data = json.load(open(sys.argv[1])) +# Optional arg2: comma-separated enabled langs — restrict to installable voices +# (primary language selected). Empty/absent = no filter. +raw = sys.argv[2] if len(sys.argv) > 2 else "" +langs = {x.strip().lower() for x in raw.split(",") if x.strip()} or None +def sort_key(v): + lang = v["language"].split("-")[0].lower() + return (0 if lang == "en" else 1, lang, v["name"]) +for v in sorted(data, key=sort_key): + lang = v["language"].split("-")[0].lower() + if langs is not None and lang not in langs: + continue + gender = v.get("gender") or "?" + print(f'{v["originalName"]}|{v["name"]} ({gender}, {lang})') +PY +} + +# Prints "primary|other1,other2,..." for a voice's originalName (case-insensitive +# match), empty if not found. Uses python3 (stdlib json) — more reliable than +# hand-rolled per-object regex extraction from multi-line JSON. +voice_info() { + python3 - "$VOICES_JSON" "$1" << 'PY' +import json, sys +path, name = sys.argv[1], sys.argv[2].lower() +data = json.load(open(path)) +for v in data: + if v["originalName"].lower() == name: + primary = v["language"].split("-")[0].lower() + others = ",".join(l.split("-")[0].lower() for l in v.get("otherLanguages", [])) + print(f"{primary}|{others}") + break +PY +} + +# Sets global PICKED_VOICE (empty if cancelled/none found). Not returned via +# command substitution — warn()/gum output would otherwise get captured too. +# Shows each voice annotated with gender + primary language so the primary +# language is visible right in the picker, not just something you find out +# after adding a now-obviously-redundant override. +_pick_voice() { + PICKED_VOICE="" + local names=() displays=() line orig disp + # All voices — custom can pull in a voice whose primary language isn't selected + # yet (adding a pair for it extends LANGUAGES to include that primary). + while IFS='|' read -r orig disp; do + [[ -z "$orig" ]] && continue + names+=("$orig") + displays+=("$disp") + done < <(list_voice_choices "") + if [[ ${#names[@]} -eq 0 ]]; then + warn "No installable voices for your selected languages ($(current_langs))." + return + fi + local picked + # Explicit exit row so you don't have to guess that ESC backs out. + local back="‹ Back (done)" + picked=$(printf '%s\n' "$back" "${displays[@]}" | gum choose --header "Voice (or Back)") || true + [[ -z "$picked" || "$picked" == "$back" ]] && return + local i + for i in "${!displays[@]}"; do + if [[ "${displays[$i]}" == "$picked" ]]; then + PICKED_VOICE="${names[$i]}" + return + fi + done +} + # ── Docker check ────────────────────────────────────────────────────────────── require_docker() { if ! command -v docker &>/dev/null; then @@ -134,7 +350,7 @@ require_gum() { } # ══════════════════════════════════════════════════════════════════════════════ -# Actions +# First-time setup # ══════════════════════════════════════════════════════════════════════════════ do_first_setup() { @@ -151,58 +367,31 @@ do_first_setup() { esac fi - hdr "Step 1/4 — Languages" - local lang_opts=() - for code in "${ALL_LANGS[@]}"; do - lang_opts+=("$code — $(lang_name "$code")") - done - - # Enter is easy to hit reflexively (it's the "confirm" key in most menus) - # before pressing Space to actually check anything — confirm-and-retry - # loop so a stray Enter doesn't silently lock in the wrong selection. - while true; do - local choices=() - # mapfile is bash4+ only; macOS ships bash 3.2 by default. Portable read loop instead. - while IFS= read -r line; do - [[ -n "$line" ]] && choices+=("$line") - done < <(printf '%s\n' "${lang_opts[@]}" | - gum choose --no-limit \ - --header "SPACE to check a language, then ENTER when done (default: English if none checked)") - - LANGS="" - # bash 3.2 (macOS default) treats "${arr[@]}" on an empty array as unbound - # under set -u — check length first rather than expanding directly. - if [[ ${#choices[@]} -gt 0 ]]; then - for choice in "${choices[@]}"; do - LANGS="${LANGS:+${LANGS},}${choice%% —*}" - done - fi - [[ -z "$LANGS" ]] && LANGS="en" - LANGS=$(lang_list_to_set "$LANGS") + hdr "Step 1/5 — Languages" + printf 'The languages PocketTTS installs voices for. English ships 21 voices; each\n' + printf 'other language ships 1 native voice. Coverage (native vs cross-language)\n' + printf 'is the next step. (Model sizes / RAM: see docs/configuration.md.)\n\n' + _pick_languages "en" + LANGS="$PICKED_LANGS" + ok "Languages: $LANGS" - printf '\nSelected:\n' - IFS=',' read -ra _selected_codes <<< "$LANGS" - for code in "${_selected_codes[@]}"; do - printf ' %s✓%s %s — %s\n' "$G" "$R" "$code" "$(lang_name "$code")" - done + # Persist pocket-tts.env now (with the chosen languages) so Step 2's coverage + # picker can read/write it and compute the plan against real config. + _write_pocket_env "$LANGS" - if gum confirm "Use these languages?"; then - break - fi - printf '\n' - done - ok "Languages: $LANGS" + hdr "Step 2/5 — Voice coverage" + _set_coverage - hdr "Step 2/4 — Workers" + hdr "Step 3/5 — Workers" WORKERS=$(gum input --placeholder "1" \ - --header "RAM per worker = ~240 MB × number of languages loaded") || true + --header "Uvicorn worker processes (throughput; RAM scales with count — see docs)") || true WORKERS="${WORKERS:-1}" case "$WORKERS" in ''|*[!0-9]*|0) warn "Invalid — using 1"; WORKERS=1 ;; esac ok "Workers: $WORKERS" - hdr "Step 3/4 — HuggingFace Token" + hdr "Step 4/5 — HuggingFace Token" HF_TOKEN=$(gum input --password --placeholder "hf_..." \ --header "Get a free read-only token at: https://huggingface.co/settings/tokens") || true if [[ -z "$HF_TOKEN" ]]; then @@ -211,7 +400,7 @@ do_first_setup() { ok "Token recorded" fi - hdr "Step 4/4 — Domain" + hdr "Step 5/5 — Domain" DOMAIN_INPUT=$(gum input --placeholder "tts.example.com" \ --header "Domain name nginx/FastAPI will serve") || true if [[ -z "$DOMAIN_INPUT" ]]; then @@ -223,6 +412,8 @@ do_first_setup() { cat > "$ENV_FILE" << 'EOF' # Readium Speech Server — environment variables # Generated by configure.sh — edit manually or re-run configure.sh +# Provider-specific install config (e.g. PocketTTS) lives in its own env file — +# see pocket-tts.env. # ── Server ── APP_ENV=production @@ -239,114 +430,332 @@ API_KEY= # ── Concurrency ── MAX_CONCURRENT_SYNTHESES=2 -# ── Languages (comma-separated BCP-47 prefixes; PocketTTS: en,fr,it,de,es,pt) ── -# Each language downloads ~240 MB on first start into the weights Docker volume. -LANGUAGES=en -HF_TOKEN= +# ── Circuit breaker (per provider, wraps synthesize() calls) ── +CIRCUIT_BREAKER_ENABLED=true +CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 +CIRCUIT_BREAKER_RECOVERY_SECONDS=30 # ── Providers ── ENABLED_PROVIDERS=pocket DEFAULT_PROVIDER=pocket -# ── PocketTTS ── -POCKET_DEFAULT_VOICE=alba +# ── HuggingFace (shared across any provider that pulls models from HF) ── +HF_TOKEN= # ── Audio / ffmpeg ── FFMPEG_BIN=ffmpeg MAX_TEXT_LENGTH=2000 EOF chmod 600 "$ENV_FILE" - set_env LANGUAGES "$LANGS" - set_env WORKERS "$WORKERS" - set_env HF_TOKEN "$HF_TOKEN" - set_env DOMAIN "$DOMAIN_INPUT" + set_env WORKERS "$WORKERS" + set_env HF_TOKEN "$HF_TOKEN" + set_env DOMAIN "$DOMAIN_INPUT" - ok "Written → .env" + ok "Written → .env, pocket-tts.env" _print_next_steps "$LANGS" } -do_add_language() { - _require_env - local current - current=$(current_langs) +# Writes pocket-tts.env with the given LANGUAGES; coverage (VOICE_LANGUAGES) is +# set separately by _set_coverage. Called early in first-setup so the coverage +# step has a real config file to read/mutate. +_write_pocket_env() { + local langs="$1" + cat > "$POCKET_ENV_FILE" << 'EOF' +# PocketTTS — provider-scoped install config. Full details + model sizes: +# docs/configuration.md. +# LANGUAGES: comma BCP-47 prefixes — the languages to install voices for. +# VOICE_LANGUAGES: cross-language pairs added on top of the native defaults. +# Format: "originalName:lang", "-" prefix to remove (e.g. "alba:fr,-javert:es"); +# a bare "*:*" = every voice in every selected language (the 'all' coverage mode). +LANGUAGES=en +POCKET_DEFAULT_VOICE= +VOICE_LANGUAGES= +EOF + chmod 600 "$POCKET_ENV_FILE" + set_penv LANGUAGES "$langs" +} - hdr "Add Language" - local available=() code choice - for code in "${ALL_LANGS[@]}"; do - if ! echo "$current" | grep -qE "(^|,)${code}(,|$)"; then - available+=("$code — $(lang_name "$code")") - fi - done - if [[ ${#available[@]} -eq 0 ]]; then - warn "All supported languages are already enabled." - return - fi - choice=$(printf '%s\n' "${available[@]}" | gum choose --header "Language to add") || true - if [[ -z "$choice" ]]; then - warn "Cancelled." +# ══════════════════════════════════════════════════════════════════════════════ +# PocketTTS provider actions +# ══════════════════════════════════════════════════════════════════════════════ + +# Live install plan for the current LANGUAGES/VOICE_LANGUAGES/WORKERS. Delegates +# what-installs view via scripts/pocket_plan.py, which reuses the provider's own +# resolver — so this can never drift from what the server actually loads. +_show_plan() { + local langs voice_lang + langs=$(current_langs) + voice_lang=$(get_penv VOICE_LANGUAGES) + python3 "$POCKET_PLAN" "$VOICES_JSON" "$langs" "$voice_lang" +} + +do_show_pocket_config() { + hdr "PocketTTS Configuration" + local default_voice + default_voice=$(get_penv POCKET_DEFAULT_VOICE) + printf ' %-24s %s%s%s\n' "Default voice:" "$C" "${default_voice:-(unset)}" "$R" + _show_plan + printf '\n' +} + +# Step 1 of the flow: pick the language set. Whatever you check becomes LANGUAGES — +# the languages PocketTTS installs voices for. Reuses the trap-guarded picker. +_menu_change_languages() { + hdr "Languages" + local current new + current=$(current_langs) + printf 'Languages to install voices for. English has many voices; each other\n' + printf 'language has one. Coverage (native vs cross-language) is the next step.\n' + printf 'This REPLACES the current set. Currently: %s%s%s\n\n' "$C" "$current" "$R" + _pick_languages "$current" + new="$PICKED_LANGS" + if [[ "$new" == "$current" ]]; then + warn "Unchanged ($current)." return fi - code="${choice%% —*}" - local new - new=$(lang_list_to_set "${current},${code}") - set_env LANGUAGES "$new" + set_penv LANGUAGES "$new" ok "LANGUAGES updated → $new" - printf '\n%sRestart the server to load the new language:%s\n' "$B" "$R" - printf ' make stop && make start\n\n' - printf '%sNote:%s first start downloads ~240 MB for %s%s%s. Cached after that.\n\n' \ - "$Y" "$R" "$B" "$code" "$R" + printf '\n%sNote:%s new languages download on next start; removed ones stay on disk\n' "$Y" "$R" + printf '(reclaim: %smake prune-models-apply%s). Restart to apply: %smake stop && make start%s\n\n' \ + "$C" "$R" "$C" "$R" + _show_plan + _show_plan } -do_remove_language() { - _require_env - local current - current=$(current_langs) - IFS=',' read -ra parts <<< "$current" +# Step 2 of the flow: choose how many voices/languages install. default and all +# work over your currently-selected languages; custom can pull in any voice and any +# of its languages, extending LANGUAGES to match (removes prune it back down). +_set_coverage() { + hdr "Voice coverage" + local langs + langs=$(current_langs) + printf 'Selected languages: %s%s%s\n\n' "$C" "$langs" "$R" + printf ' default — one voice per selected language, in its own language only.\n' + printf ' all — every installed voice also speaks every OTHER selected language.\n' + printf ' custom — hand-pick any voice + any of its languages; LANGUAGES follows.\n\n' + + local COV + COV=$(gum choose \ + "default — native voices only" \ + "all — every voice, every selected language" \ + "custom — per-voice, per-language (can add languages)" \ + "Cancel") || true + case "$COV" in + default*) + set_penv VOICE_LANGUAGES "" + ok "Coverage → default (native voices only)" + ;; + all*) + set_penv VOICE_LANGUAGES "*:*" + ok "Coverage → all (every voice, every selected language)" + ;; + custom*) + # Custom derives LANGUAGES from the picks: snapshot the current selection + # as the protected base (native voices you keep) — adds extend LANGUAGES, + # removes prune back to this base. Drop any "*:*" so picks are explicit. + BASE_LANGS=$(current_langs) + set_penv VOICE_LANGUAGES "$(_voicelang_strip_wildcard "$(get_penv VOICE_LANGUAGES)")" + _custom_voice_loop + _custom_sync_languages + ;; + *) warn "Cancelled."; return ;; + esac + printf '\nNow: %sLANGUAGES=%s%s | %sVOICE_LANGUAGES=%s%s\n\n' \ + "$C" "$(current_langs)" "$R" "$C" "$(get_penv VOICE_LANGUAGES)" "$R" + _show_plan + printf '\nRestart to apply: %smake stop && make start%s\n\n' "$C" "$R" +} + +# Prints one BCP-47 lang per line: every language this voice can ALSO speak (its +# declared otherLanguages, minus its own primary). Unbounded by LANGUAGES — custom +# can pull a new language in, and adding one syncs LANGUAGES to match. +_voice_xlang_candidates() { + local voice="$1" info primary others l + info=$(voice_info "$voice") + IFS='|' read -r primary others <<< "$info" + IFS=',' read -ra _o <<< "$others" + for l in "${_o[@]}"; do + [[ -z "$l" || "$l" == "$primary" ]] && continue + echo "$l" + done +} + +# Languages the explicit VOICE_LANGUAGES pairs require: each pair's target language, +# plus the primary language of each pair's voice (a voice can't run without its own +# base model). Wildcard/removal tokens are ignored. +_langs_used_by_pairs() { + local list="$1" p voice lang out="" + [[ -z "$list" ]] && { echo ""; return; } + IFS=',' read -ra _p <<< "$list" + for p in "${_p[@]}"; do + [[ -z "$p" || "$p" == "*:*" || "$p" == -* ]] && continue + voice="${p%%:*}"; lang="${p#*:}" + out="${out:+${out},}${lang},$(voice_info "$voice" | cut -d'|' -f1)" + done + lang_list_to_set "$out" +} + +# Re-derive LANGUAGES = protected base (the selection when custom started) ∪ every +# language the current pairs require. Adds extend it; removes prune back to base. +_custom_sync_languages() { + local base used + base="${BASE_LANGS:-$(current_langs)}" + used=$(_langs_used_by_pairs "$(get_penv VOICE_LANGUAGES)") + set_penv LANGUAGES "$(lang_list_to_set "${base}${used:+,$used}")" +} + +# Space-separated extra langs currently added for a voice (explicit non-removal +# pairs in VOICE_LANGUAGES). Custom mode has no "*:*", so these are the real adds. +_voice_added_langs() { + local voice="$1" list p out="" + list=$(get_penv VOICE_LANGUAGES) + [[ -z "$list" ]] && return + IFS=',' read -ra _p <<< "$list" + for p in "${_p[@]}"; do + [[ -z "$p" || "$p" == -* ]] && continue + [[ "$p" == "${voice}:"* ]] && out="${out:+${out} }${p#*:}" + done + echo "$out" +} - if [[ ${#parts[@]} -eq 1 ]]; then - err "Only one language configured (${current}). Cannot remove — server needs at least one." +# Delete any "voice:lang" pair (add or removal form) from a VOICE_LANGUAGES list. +_voicelang_delete() { + local list="$1" voice="$2" lang="$3" out="" p bare + [[ -z "$list" ]] && return + IFS=',' read -ra _p <<< "$list" + for p in "${_p[@]}"; do + [[ -z "$p" ]] && continue + bare="${p#-}" + [[ "$bare" == "${voice}:${lang}" ]] && continue + out="${out:+${out},}${p}" + done + echo "$out" +} + +# Per-voice cross-language editor. All steps are single-select (ENTER picks the +# highlighted row) — no SPACE-to-check trap, no silent dead-ends. +_custom_voice_loop() { + hdr "Custom cross-language" + printf 'Pick any voice, then add/remove the languages it should speak. Adding a\n' + printf 'language a voice needs pulls it into LANGUAGES automatically; removing it\n' + printf 'prunes back to your base selection when nothing else needs it.\n\n' + while true; do + _pick_voice + local voice="$PICKED_VOICE" + [[ -z "$voice" ]] && { warn "Done."; return; } + _custom_voice_one "$voice" + gum confirm "Configure another voice?" || return + done +} + +_custom_voice_one() { + local voice="$1" added primary ACT + primary=$(voice_info "$voice" | cut -d'|' -f1) + while true; do + added=$(_voice_added_langs "$voice") + printf '\n%s%s%s (native %s) — extra languages: %s%s%s\n' \ + "$B" "$voice" "$R" "$primary" "$C" "${added:-none}" "$R" + ACT=$(gum choose "Add a language" "Remove a language" "Done (this voice)") || true + case "$ACT" in + "Add a language") _custom_add "$voice" ;; + "Remove a language") _custom_remove "$voice" ;; + *) return ;; + esac + done +} + +_custom_add() { + local voice="$1" cand=() added l pick + added=" $(_voice_added_langs "$voice") " + while IFS= read -r l; do + [[ -z "$l" ]] && continue + [[ "$added" == *" $l "* ]] && continue + cand+=("$l — $(lang_name "$l")") + done < <(_voice_xlang_candidates "$voice") + if [[ ${#cand[@]} -eq 0 ]]; then + warn "No more languages to add for '$voice'." return fi + pick=$(printf '%s\n' "${cand[@]}" | gum choose --header "Add language for '$voice'") || true + [[ -z "$pick" ]] && { warn "Cancelled."; return; } + pick="${pick%% —*}" + set_penv VOICE_LANGUAGES "$(_voicelang_upsert "$(get_penv VOICE_LANGUAGES)" "$voice" "$pick" add)" + _custom_sync_languages + ok "Added: $voice → $pick (LANGUAGES now: $(current_langs))" +} - hdr "Remove Language" - local options=() p code choice - for p in "${parts[@]}"; do - options+=("$p — $(lang_name "$p")") +_custom_remove() { + local voice="$1" cand=() l pick + for l in $(_voice_added_langs "$voice"); do + cand+=("$l — $(lang_name "$l")") done - choice=$(printf '%s\n' "${options[@]}" | gum choose --header "Language to remove") || true - if [[ -z "$choice" ]]; then - warn "Cancelled." + if [[ ${#cand[@]} -eq 0 ]]; then + warn "'$voice' has no extra languages to remove." return fi - code="${choice%% —*}" + pick=$(printf '%s\n' "${cand[@]}" | gum choose --header "Remove language for '$voice'") || true + [[ -z "$pick" ]] && { warn "Cancelled."; return; } + pick="${pick%% —*}" + set_penv VOICE_LANGUAGES "$(_voicelang_delete "$(get_penv VOICE_LANGUAGES)" "$voice" "$pick")" + _custom_sync_languages + ok "Removed: $voice → $pick (LANGUAGES now: $(current_langs))" +} - local new="" p2 - for p2 in "${parts[@]}"; do - [[ "$p2" != "$code" ]] && new="${new:+${new},}${p2}" +do_pocket_menu() { + while true; do + clear + banner + hdr "PocketTTS" + + do_show_pocket_config + + printf '\n %sSteps:%s 1) set Languages first 2) pick Voice coverage.\n' "$B" "$R" + printf ' Coverage: %sdefault%s = native voices only · %sall%s = every voice/language ·\n' \ + "$C" "$R" "$C" "$R" + printf ' %scustom%s = default + cross-language pairs you add. Sizes/RAM: docs/configuration.md\n\n' \ + "$C" "$R" + + local OPT + OPT=$(gum choose \ + "Languages" \ + "Voice coverage" \ + "Back") || true + case "$OPT" in + "Languages") _menu_change_languages ;; + "Voice coverage") _set_coverage ;; + *) return ;; + esac + printf '\nPress Enter to continue...'; read -r _ done - set_env LANGUAGES "$new" - ok "LANGUAGES updated → $new" - printf '\n%sNote:%s model files for %s remain in the Docker volume (disk space not freed).\n' \ - "$Y" "$R" "$code" - printf 'To reclaim disk: %smake stop && docker volume rm speech-server_weights_cache && make start%s\n' \ - "$C" "$R" - printf '(Re-downloads active languages: %s)\n\n' "$new" - printf 'Restart to apply: %smake stop && make start%s\n\n' "$C" "$R" } +# ══════════════════════════════════════════════════════════════════════════════ +# Provider picker (ready to extend once a second provider exists) +# ══════════════════════════════════════════════════════════════════════════════ + +do_manage_provider() { + hdr "Providers" + local provider + provider=$(gum choose "pocket" --header "Provider") || true + [[ -z "$provider" ]] && { warn "Cancelled."; return; } + case "$provider" in + pocket) do_pocket_menu ;; + esac +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Universal (.env) actions +# ══════════════════════════════════════════════════════════════════════════════ + do_change_workers() { - _require_env local current current=$(get_env WORKERS) current="${current:-1}" hdr "Change Workers" - local langs - langs=$(current_langs) - local lang_count - lang_count=$(echo "$langs" | tr ',' '\n' | wc -l | tr -d ' ') - printf 'Current: %s%s%s workers | RAM per worker: ~%d MB (%d language(s) × 240 MB)\n\n' \ - "$C" "$current" "$R" $((lang_count * 240)) "$lang_count" + printf 'Current: %s%s%s workers. More workers = more throughput; each loads its own\n' \ + "$C" "$current" "$R" + printf 'copy of the models, so RAM scales with worker count (sizes: see docs).\n\n' WORKERS=$(gum input --placeholder "$current" --header "New WORKERS") || true WORKERS="${WORKERS:-$current}" case "$WORKERS" in @@ -354,15 +763,10 @@ do_change_workers() { esac set_env WORKERS "$WORKERS" ok "WORKERS updated → $WORKERS" - local total_ram=$(( lang_count * 240 * WORKERS )) - printf 'Estimated RAM: ~%d MB (%d workers × %d languages × 240 MB)\n\n' \ - "$total_ram" "$WORKERS" "$lang_count" printf 'Restart to apply: %smake stop && make start%s\n\n' "$C" "$R" } do_update_token() { - _require_env - hdr "Update HF_TOKEN" HF_TOKEN=$(gum input --password --placeholder "hf_..." \ --header "Get a free read-only token at: https://huggingface.co/settings/tokens") || true @@ -373,24 +777,14 @@ do_update_token() { } do_show_config() { - _require_env - hdr "Current Configuration" - local langs workers hf_token key_enabled api_key max_syn - langs=$(current_langs) + local workers hf_token key_enabled max_syn workers=$(get_env WORKERS); workers="${workers:-1}" hf_token=$(get_env HF_TOKEN) key_enabled=$(get_env API_KEY_ENABLED); key_enabled="${key_enabled:-false}" max_syn=$(get_env MAX_CONCURRENT_SYNTHESES); max_syn="${max_syn:-2}" - printf ' %-30s %s%s%s\n' "Languages:" "$C" "$langs" "$R" - - local lang_count - lang_count=$(echo "$langs" | tr ',' '\n' | wc -l | tr -d ' ') - local total_ram=$(( lang_count * 240 * workers )) - - printf ' %-30s %s%s%s (~%d MB total RAM)\n' \ - "Workers:" "$C" "$workers" "$R" "$total_ram" + printf ' %-30s %s%s%s\n' "Workers:" "$C" "$workers" "$R" printf ' %-30s %s%s%s\n' "Max concurrent syntheses:" "$C" "$max_syn" "$R" if [[ -n "$hf_token" ]]; then @@ -401,24 +795,29 @@ do_show_config() { fi printf ' %-30s %s%s%s\n' "API key auth:" "$C" "$key_enabled" "$R" - printf '\n Config file: %s\n\n' "$ENV_FILE" + printf '\n %sSee "Manage provider" for PocketTTS-specific config.%s\n' "$Y" "$R" + printf ' Config files: %s, %s\n\n' "$ENV_FILE" "$POCKET_ENV_FILE" } do_reset() { hdr "Reset" - warn "This will delete .env and stop the server." + warn "This will delete .env / pocket-tts.env and stop the server." CHOICE=$(gum choose \ - "Delete .env only (keep downloaded models)" \ - "Delete .env AND purge all downloaded models (frees disk)" \ + "Delete config only (keep downloaded models)" \ + "Delete config AND purge all downloaded models (frees disk)" \ "Cancel") || true case "$CHOICE" in - "Delete .env only"*) - [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" && ok ".env deleted" + "Delete config only"*) + [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" + [[ -f "$POCKET_ENV_FILE" ]] && rm "$POCKET_ENV_FILE" + ok "Config deleted" printf 'Run %sconfigure.sh%s to set up again.\n\n' "$C" "$R" ;; - "Delete .env AND purge"*) + "Delete config AND purge"*) require_docker - [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" && ok ".env deleted" + [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" + [[ -f "$POCKET_ENV_FILE" ]] && rm "$POCKET_ENV_FILE" + ok "Config deleted" local vol vol=$(docker volume ls -q | grep -E 'weights_cache$' | head -1 || true) if [[ -n "$vol" ]]; then @@ -436,13 +835,6 @@ do_reset() { } # ── Helpers ─────────────────────────────────────────────────────────────────── -_require_env() { - if [[ ! -f "$ENV_FILE" ]]; then - err ".env not found. Run option 1 (first-time setup) first." - exit 1 - fi -} - _print_next_steps() { local langs="$1" printf '\n%sAvailable commands:%s\n\n' "$B" "$R" @@ -462,6 +854,8 @@ main_menu() { while true; do clear banner + _migrate_pocket_env + _migrate_voice_install_mode if [[ -f "$ENV_FILE" ]]; then local langs workers OPT @@ -471,8 +865,7 @@ main_menu() { "$C" "$langs" "$R" "$C" "$workers" "$R" OPT=$(gum choose \ "Show full config" \ - "Add a language" \ - "Remove a language" \ + "Manage provider" \ "Change workers" \ "Update HF token" \ "First-time setup (re-run / overwrite)" \ @@ -480,8 +873,7 @@ main_menu() { "Quit") || true case "$OPT" in "Show full config") do_show_config ;; - "Add a language") do_add_language ;; - "Remove a language") do_remove_language ;; + "Manage provider") do_manage_provider ;; "Change workers") do_change_workers ;; "Update HF token") do_update_token ;; "First-time setup"*) do_first_setup ;; diff --git a/scripts/pocket_plan.py b/scripts/pocket_plan.py new file mode 100755 index 0000000..bc60c09 --- /dev/null +++ b/scripts/pocket_plan.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Print what the current env config (LANGUAGES + VOICE_LANGUAGES) installs. + +A plain "what will be installed" view for the wizard: the coverage mode +(default / all / custom), native voices per language, and how many cross-language +pairs are added on top. Sizes/RAM live in docs/configuration.md, not here — the +wizard stays readable. + +Reuses app.providers.voice_loading.plan_install so the picture can't drift from +what the server actually loads. + +Usage: pocket_plan.py + pocket_plan.py --selftest +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from app.providers.voice_loading import VoiceEntry, plan_install # noqa: E402 + +SUPPORTED = ("en", "fr", "it", "de", "es", "pt") + + +def _split(csv: str) -> list[str]: + return [p.strip().lower() for p in csv.split(",") if p.strip()] + + +# ponytail: mirrors Settings._parse_voice_language_overrides / voice_install_all +# (settings.py) — that's the source of truth; keep this tokenizer in sync if the +# VOICE_LANGUAGES grammar changes. Kept separate to avoid constructing Settings(). +def _parse_voice_languages( + raw: str, +) -> tuple[bool, dict[str, set[str]], dict[str, set[str]]]: + wildcard = False + add: dict[str, set[str]] = {} + remove: dict[str, set[str]] = {} + for tok in raw.split(","): + tok = tok.strip() + if not tok: + continue + if tok == "*:*": + wildcard = True + continue + target = add + if tok.startswith("-"): + target, tok = remove, tok[1:] + if ":" not in tok: + continue + voice, lang = tok.split(":", 1) + voice, lang = voice.strip().lower(), lang.strip().lower() + if voice and lang: + target.setdefault(voice, set()).add(lang) + return wildcard, add, remove + + +def plan(voices_path: str, langs_csv: str, voice_languages: str) -> dict[str, Any]: + entries = [VoiceEntry.model_validate(v) for v in json.loads(Path(voices_path).read_text())] + enabled = frozenset(_split(langs_csv)) & frozenset(SUPPORTED) + wildcard, add, remove = _parse_voice_languages(voice_languages) + + installed = 0 + cross_pairs = 0 + natives: dict[str, list[str]] = {lang: [] for lang in enabled} + for e in entries: + key = e.originalName.lower() + result = plan_install( + e, enabled, wildcard, + frozenset(add.get(key, set())), frozenset(remove.get(key, set())), + ) + if result is None: + continue + _, langs = result + installed += 1 + primary = e.language.split("-")[0].lower() + cross_pairs += len(langs - {primary}) + if primary in natives: + natives[primary].append(e.originalName) + + if wildcard: + mode = "all" + elif add or remove: + mode = "custom" + else: + mode = "default" + + return { + "mode": mode, + "languages": sorted(enabled), + "installed_voices": installed, + "cross_pairs": cross_pairs, + "natives": natives, + } + + +_MODE_BLURB = { + "default": "native voices of your selected languages only", + "all": "every voice also speaks every other selected language", + "custom": "native voices + your hand-picked cross-language pairs", +} + + +def render(p: dict[str, Any]) -> str: + lines = [ + f" Languages: {', '.join(p['languages']) or '(none)'}", + f" Coverage: {p['mode']} — {_MODE_BLURB[p['mode']]}", + f" Voices installed: {p['installed_voices']}", + ] + if p["cross_pairs"]: + lines.append(f" Cross-language: {p['cross_pairs']} pair(s) added on top of defaults") + lines.append(" Native voices per language:") + for lang in p["languages"]: + names = p["natives"][lang] + if not names: + shown = "(none)" + elif len(names) <= 3: + shown = ", ".join(names) + else: + shown = f"{names[0]} (+{len(names) - 1} more)" + lines.append(f" {lang}: {shown}") + return "\n".join(lines) + + +def _selftest() -> None: + root = Path(__file__).parent.parent + vj = str(root / "app" / "data" / "voices" / "pocket" / "voices.json") + + d = plan(vj, "en", "") + assert d["mode"] == "default" and d["installed_voices"] == 21 and d["cross_pairs"] == 0, d + + d = plan(vj, "en,fr", "") + assert d["installed_voices"] == 22 and d["cross_pairs"] == 0, d + + # all + en,fr: every voice that can speak a selected language installs — incl. + # non-native-primary ones (giovanni/lola/... speaking fr/en cross-language). + a = plan(vj, "en,fr", "*:*") + assert a["mode"] == "all" and a["installed_voices"] == 26 and a["cross_pairs"] == 28, a + + c = plan(vj, "en,fr", "alba:fr") + assert c["mode"] == "custom" and c["cross_pairs"] == 1, c + print("pocket_plan selftest OK") + + +def main() -> int: + if len(sys.argv) == 2 and sys.argv[1] == "--selftest": + _selftest() + return 0 + if len(sys.argv) != 4: + print(__doc__, file=sys.stderr) + return 2 + voices_path, langs, voice_languages = sys.argv[1:4] + print(render(plan(voices_path, langs, voice_languages))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prune_weights.py b/scripts/prune_weights.py new file mode 100644 index 0000000..4e6c587 --- /dev/null +++ b/scripts/prune_weights.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Prune PocketTTS weight files that don't match the current env config. + +By default this reads LANGUAGES + VOICE_LANGUAGES and prunes to the exact install +plan the server would load (reusing plan_install): it drops base language models +for languages no longer enabled AND, within kept languages, individual voice +embeddings no longer used (e.g. a removed cross-language pair). What's still needed +stays; anything re-added later re-downloads from the HF cache on next start. + +PocketTTS caches everything — base model, tokenizer, and per-voice embeddings — +under `languages//` inside two HuggingFace repos (`kyutai/pocket-tts` and +`kyutai/pocket-tts-without-voice-cloning`), sharing one snapshot revision per +repo (confirmed by reading pocket_tts's own config/*.yaml and utils/utils.py — +every language config references the same two revision hashes). That makes +per-language pruning safe: each language's files live under one exclusive path +prefix, distinct from every other language's. + +Content-addressed HF cache layout means each snapshot file is a symlink into +`blobs/`. Before deleting a blob, this script confirms no OTHER +surviving symlink (outside the languages being pruned) points to the same +blob — belt-and-braces, since these files shouldn't collide in practice, but a +wrong deletion here corrupts a live model. + +Default is a dry-run report. Pass --apply to actually delete. + +Usage (inside the container, where HF_HOME=/weights is mounted): + python scripts/prune_weights.py # report only (env config) + python scripts/prune_weights.py --apply # actually delete + python scripts/prune_weights.py --keep en,fr --apply # language-level override, + # keeps all embeddings +Or via make (from the host): `make prune-models` / `make prune-models-apply`. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +_REPOS = ["kyutai/pocket-tts", "kyutai/pocket-tts-without-voice-cloning"] + + +def _repo_cache_dir(hub_dir: Path, repo_id: str) -> Path: + # HF cache naming: "org/name" -> "models--org--name" + return hub_dir / ("models--" + repo_id.replace("/", "--")) + + +def _lang_folder_map() -> dict[str, str]: + """BCP-47 prefix -> PocketTTS language folder name. Reuses the app's own + mapping so this script can't silently drift from what the provider loads.""" + sys.path.insert(0, str(Path(__file__).parent.parent)) + from app.providers.pocket_tts import _LANG_MODEL # noqa: PLC0415 + + return dict(_LANG_MODEL) + + +def _dir_size(path: Path) -> int: + total = 0 + for f in path.rglob("*"): + if f.is_file(): + try: + total += f.stat().st_size + except OSError: + pass + return total + + +def _resolve_blob(symlink: Path) -> Path | None: + try: + target = symlink.resolve() + except OSError: + return None + return target if target.is_file() else None + + +def _blob_has_other_references( + blob: Path, snapshots_dir: Path, exclude_prefixes: list[Path] +) -> bool: + """True if any symlink under snapshots_dir, outside exclude_prefixes, + resolves to the same blob.""" + for f in snapshots_dir.rglob("*"): + if not f.is_symlink(): + continue + if any(str(f).startswith(str(p)) for p in exclude_prefixes): + continue + if _resolve_blob(f) == blob: + return True + return False + + +def _install_plan_from_env() -> tuple[set[str], set[tuple[str, str]]]: + """What to KEEP per the current env config (LANGUAGES + VOICE_LANGUAGES), + exactly matching what the server loads. Returns (language folder names, + {(folder, voice_originalName)} embeddings). Reuses plan_install so it can't + drift; empty VOICE_LANGUAGES = default = native voices only, handled naturally.""" + import json # noqa: PLC0415 + + sys.path.insert(0, str(Path(__file__).parent.parent)) + from app.config.settings import settings # noqa: PLC0415 + from app.providers.pocket_tts import _LANG_MODEL, _VOICES_PATH # noqa: PLC0415 + from app.providers.voice_loading import VoiceEntry, plan_install # noqa: PLC0415 + + enabled = frozenset(settings.language_list) & frozenset(_LANG_MODEL) + install_all = settings.voice_install_all + add = settings.voice_language_add_map + remove = settings.voice_language_remove_map + entries = [VoiceEntry.model_validate(v) for v in json.loads(_VOICES_PATH.read_text())] + + keep_folders: set[str] = set() + keep_emb: set[tuple[str, str]] = set() + for e in entries: + key = e.originalName.lower() + plan = plan_install( + e, enabled, install_all, add.get(key, frozenset()), remove.get(key, frozenset()) + ) + if plan is None: + continue + _, installed = plan + for lang in installed: + folder = _LANG_MODEL[lang] + keep_folders.add(folder) + keep_emb.add((folder, e.originalName)) + return keep_folders, keep_emb + + +def _delete_file_blob_safe(f: Path, snapshots_dir: Path) -> None: + """Delete a snapshot symlink and its blob, unless another symlink still uses it.""" + if f.is_symlink(): + blob = _resolve_blob(f) + if blob is not None and not _blob_has_other_references(blob, snapshots_dir, [f]): + blob.unlink(missing_ok=True) + f.unlink(missing_ok=True) + + +def _prune_embeddings( + lang_dir: Path, keep_emb: set[tuple[str, str]], snapshots_dir: Path, apply: bool +) -> int: + """Within a KEPT language dir, delete voice embeddings not in the install plan. + Returns bytes freed. Base model / tokenizer are never touched here.""" + folder = lang_dir.name + emb_dir = lang_dir / "embeddings" + if not emb_dir.is_dir(): + return 0 + freed = 0 + for emb in sorted(emb_dir.glob("*.safetensors")): + if (folder, emb.stem) in keep_emb: + continue + try: + fsize = emb.resolve().stat().st_size + except OSError: + fsize = 0 + act = "DELETE" if apply else "would-delete" + print(f" {act:12s} {folder:12s} embeddings/{emb.stem} {fsize / 1e6:7.2f} MB") + freed += fsize + if apply: + _delete_file_blob_safe(emb, snapshots_dir) + return freed + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--apply", action="store_true", help="Actually delete (default: dry-run report only)" + ) + parser.add_argument( + "--keep", + default=None, + help="Comma-separated BCP-47 prefixes to keep (default: LANGUAGES env var)", + ) + args = parser.parse_args() + + hf_home = Path(os.environ.get("HF_HOME", "/weights")) + hub_dir = hf_home / "hub" + if not hub_dir.is_dir(): + print(f"No HF cache found at {hub_dir} — nothing to prune.") + return 0 + + # --keep overrides to a language-level prune (keeps every embedding in kept + # languages). Default reads the full env config and prunes to the exact install + # plan — base models AND per-voice embeddings — matching what the server loads. + keep_emb: set[tuple[str, str]] | None + if args.keep is not None: + lang_map = _lang_folder_map() + keep = {v.strip().lower() for v in args.keep.split(",") if v.strip()} or {"en"} + keep_folders = {lang_map[p] for p in keep if p in lang_map} + keep_emb = None + unknown = keep - set(lang_map) + if unknown: + print(f"Warning: unsupported prefixes in --keep, ignored: {sorted(unknown)}") + print(f"HF cache: {hub_dir}") + print(f"Keeping languages (--keep): {sorted(keep)} -> folders {sorted(keep_folders)}") + else: + keep_folders, keep_emb = _install_plan_from_env() + print(f"HF cache: {hub_dir}") + print( + f"Keeping per env config: {len(keep_folders)} language(s), " + f"{len(keep_emb)} voice embedding(s)" + ) + print(f" languages: {sorted(keep_folders)}") + print() + + total_freed = 0 + total_kept = 0 + any_found = False + + for repo_id in _REPOS: + repo_dir = _repo_cache_dir(hub_dir, repo_id) + snapshots_dir = repo_dir / "snapshots" + if not snapshots_dir.is_dir(): + continue + + for snapshot in snapshots_dir.iterdir(): + langs_dir = snapshot / "languages" + if not langs_dir.is_dir(): + continue + + for lang_dir in sorted(langs_dir.iterdir()): + if not lang_dir.is_dir(): + continue + any_found = True + size = _dir_size(lang_dir) + + where = f"{repo_id:42s} languages/{lang_dir.name:15s}" + if lang_dir.name in keep_folders: + # Kept language — prune only its unused voice embeddings (env mode). + freed = ( + _prune_embeddings(lang_dir, keep_emb, snapshots_dir, args.apply) + if keep_emb is not None + else 0 + ) + kept_size = size - freed + total_freed += freed + total_kept += kept_size + suffix = f" (pruned {freed / 1e6:.1f} MB embeddings)" if freed else "" + print(f" KEEP {where} {kept_size / 1e6:8.1f} MB{suffix}") + continue + + action = "DELETE" if args.apply else "would-delete" + print(f" {action:12s} {where} {size / 1e6:8.1f} MB") + total_freed += size + + if not args.apply: + continue + + # Delete blobs first (while we can still resolve the symlinks), + # skipping any blob still referenced from outside this language dir. + exclude = [lang_dir] + for f in lang_dir.rglob("*"): + if not f.is_symlink(): + continue + blob = _resolve_blob(f) + if blob is None: + continue + if _blob_has_other_references(blob, snapshots_dir, exclude): + continue + blob.unlink(missing_ok=True) + + import shutil # noqa: PLC0415 + + shutil.rmtree(lang_dir, ignore_errors=True) + + print() + if not any_found: + print("No per-language weight directories found — nothing to prune.") + return 0 + + verb = "Freed" if args.apply else "Would free" + print(f"{verb}: {total_freed / 1e6:.1f} MB Kept: {total_kept / 1e6:.1f} MB") + if not args.apply: + print("Dry run — re-run with --apply to actually delete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index ea424ef..b98d647 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from app.core.circuit_breaker import CircuitBreakerRegistry from app.core.concurrency import init_semaphore from app.core.registry import ProviderRegistry from app.core.voice_catalog import VoiceCatalog @@ -50,6 +51,7 @@ async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]: await catalog.load() app.state.registry = registry app.state.voice_catalog = catalog + app.state.circuit_breakers = CircuitBreakerRegistry([p.id for p in registry.all()], 5, 30) app.state.ready = True async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: diff --git a/tests/contract/test_provider_contract.py b/tests/contract/test_provider_contract.py index 2b79f21..b7eefba 100644 --- a/tests/contract/test_provider_contract.py +++ b/tests/contract/test_provider_contract.py @@ -62,33 +62,31 @@ async def test_list_voices_nonempty_and_shaped(self, provider: TTSProvider) -> N voices = await provider.list_voices() assert len(voices) >= 1 for v in voices: - assert v.voiceURI and v.name and v.language + assert v.identifier and v.name and v.language assert v.provider == provider.id - assert v.engineVoiceId - assert v.sampleRate > 0 async def test_voiceuri_is_unique(self, provider: TTSProvider) -> None: - uris = [v.voiceURI for v in await provider.list_voices()] - assert len(uris) == len(set(uris)) + ids = [v.identifier for v in await provider.list_voices()] + assert len(ids) == len(set(ids)) async def test_synthesize_returns_valid_audio(self, provider: TTSProvider) -> None: v = (await provider.list_voices())[0] - res = await provider.synthesize(_params(v.voiceURI)) + res = await provider.synthesize(_params(v.identifier)) assert res.sample_rate > 0 assert_valid_pcm(res) async def test_longer_text_yields_longer_audio(self, provider: TTSProvider) -> None: v = (await provider.list_voices())[0] - short = await provider.synthesize(_params(v.voiceURI, "Hi.")) - long_ = await provider.synthesize(_params(v.voiceURI, "Hi. " * 20)) + short = await provider.synthesize(_params(v.identifier, "Hi.")) + long_ = await provider.synthesize(_params(v.identifier, "Hi. " * 20)) assert pcm_duration_seconds(long_) > pcm_duration_seconds(short) async def test_speed_affects_duration(self, provider: TTSProvider) -> None: if provider.id == "pocket": pytest.xfail("PocketTTS has no native speed param; ffmpeg atempo not yet implemented.") v = (await provider.list_voices())[0] - slow = await provider.synthesize(_params(v.voiceURI, speed=0.8)) - fast = await provider.synthesize(_params(v.voiceURI, speed=1.5)) + slow = await provider.synthesize(_params(v.identifier, speed=0.8)) + fast = await provider.synthesize(_params(v.identifier, speed=1.5)) assert pcm_duration_seconds(fast) < pcm_duration_seconds(slow) async def test_synthesize_is_async_non_blocking(self, provider: TTSProvider) -> None: diff --git a/tests/integration/test_pocket_tts.py b/tests/integration/test_pocket_tts.py index 35c2880..5abead5 100644 --- a/tests/integration/test_pocket_tts.py +++ b/tests/integration/test_pocket_tts.py @@ -28,16 +28,15 @@ async def test_list_voices_nonempty_and_shaped(pocket: PocketTTSProvider) -> Non voices = await pocket.list_voices() assert len(voices) >= 1 for v in voices: - assert v.voiceURI.startswith("urn:readium:tts:pocket:") + assert v.identifier.startswith("urn:readium:tts:pocket:") assert v.name and v.language assert v.provider == "pocket" - assert v.engineVoiceId - assert v.sampleRate > 0 + assert v.quality is not None async def test_voiceuri_unique(pocket: PocketTTSProvider) -> None: - uris = [v.voiceURI for v in await pocket.list_voices()] - assert len(uris) == len(set(uris)) + ids = [v.identifier for v in await pocket.list_voices()] + assert len(ids) == len(set(ids)) async def test_synthesize_returns_valid_pcm(pocket: PocketTTSProvider) -> None: @@ -46,7 +45,7 @@ async def test_synthesize_returns_valid_pcm(pocket: PocketTTSProvider) -> None: text="Hello world, this is a test.", ssml=False, language="en-US", - engineVoiceId=v.engineVoiceId, + voice_uri=v.identifier, speed=1.0, pitch=None, ) @@ -63,7 +62,7 @@ def params(text: str) -> SynthesisParams: text=text, ssml=False, language=None, - engineVoiceId=v.engineVoiceId, + voice_uri=v.identifier, speed=1.0, pitch=None, ) diff --git a/tests/test_circuit_breaker.py b/tests/test_circuit_breaker.py new file mode 100644 index 0000000..6baceb2 --- /dev/null +++ b/tests/test_circuit_breaker.py @@ -0,0 +1,77 @@ +import app.core.circuit_breaker as cb_mod +from app.core.circuit_breaker import CircuitBreaker, CircuitBreakerRegistry + + +def test_closed_allows_calls() -> None: + b = CircuitBreaker(failure_threshold=3, recovery_seconds=10) + assert b.allow() is True + + +def test_stays_closed_below_threshold() -> None: + b = CircuitBreaker(failure_threshold=3, recovery_seconds=10) + b.record_failure() + b.record_failure() + assert b.allow() is True + + +def test_opens_after_threshold_failures() -> None: + b = CircuitBreaker(failure_threshold=3, recovery_seconds=10) + b.record_failure() + b.record_failure() + b.record_failure() + assert b.allow() is False + + +def test_success_resets_failure_count() -> None: + b = CircuitBreaker(failure_threshold=3, recovery_seconds=10) + b.record_failure() + b.record_failure() + b.record_success() + b.record_failure() + b.record_failure() + assert b.allow() is True # only 2 consecutive since the reset + + +def test_open_rejects_until_recovery_window_elapses(monkeypatch) -> None: # type: ignore[no-untyped-def] + now = [0.0] + monkeypatch.setattr(cb_mod.time, "monotonic", lambda: now[0]) + b = CircuitBreaker(failure_threshold=1, recovery_seconds=10) + b.record_failure() + assert b.allow() is False + now[0] = 5 + assert b.allow() is False + now[0] = 10 + assert b.allow() is True # half-open trial allowed + + +def test_half_open_success_closes_breaker(monkeypatch) -> None: # type: ignore[no-untyped-def] + now = [0.0] + monkeypatch.setattr(cb_mod.time, "monotonic", lambda: now[0]) + b = CircuitBreaker(failure_threshold=2, recovery_seconds=10) + b.record_failure() + b.record_failure() + now[0] = 10 + assert b.allow() is True # half-open + b.record_success() + assert b.allow() is True + b.record_failure() + assert b.allow() is True # failure count truly reset — 1 < threshold of 2 + + +def test_half_open_failure_reopens_breaker(monkeypatch) -> None: # type: ignore[no-untyped-def] + now = [0.0] + monkeypatch.setattr(cb_mod.time, "monotonic", lambda: now[0]) + b = CircuitBreaker(failure_threshold=1, recovery_seconds=10) + b.record_failure() + now[0] = 10 + assert b.allow() is True # half-open + b.record_failure() + assert b.allow() is False # re-opened immediately, not another threshold count + + +def test_registry_gives_each_provider_an_independent_breaker() -> None: + registry = CircuitBreakerRegistry(["a", "b"], failure_threshold=2, recovery_seconds=10) + registry.get("a").record_failure() + registry.get("a").record_failure() + assert registry.get("a").allow() is False + assert registry.get("b").allow() is True diff --git a/tests/test_errors.py b/tests/test_errors.py index f767cdf..96d2997 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -32,12 +32,12 @@ async def test_unknown_route_returns_about_blank_problem(client: AsyncClient) -> @pytest.mark.route async def test_validation_error_includes_field_errors(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello"}) # missing required "voice" + resp = await client.post(_URL, json={"voice": _VOICE}) # missing required "text" assert resp.status_code == 422 body = resp.json() assert body["type"] == "https://readium.org/speech-server/error#validation_failed" assert body["title"] == "Invalid Request" - assert any(err["loc"] == ["body", "voice"] for err in body["errors"]) + assert any(err["loc"] == ["body", "text"] for err in body["errors"]) @pytest.mark.route diff --git a/tests/test_formats.py b/tests/test_formats.py index 2a988f7..45db77e 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -35,10 +35,10 @@ async def test_opus_returns_audio(client: AsyncClient) -> None: @pytest.mark.route -async def test_default_format_is_mp3(client: AsyncClient) -> None: +async def test_default_format_is_wav(client: AsyncClient) -> None: resp = await client.post(_SYNTH, json={"text": "Hello", "voice": _VOICE}) assert resp.status_code == 200 - assert resp.headers["content-type"] == "audio/mpeg" + assert resp.headers["content-type"] == "audio/wav" @pytest.mark.route diff --git a/tests/test_health.py b/tests/test_health.py index ff3df22..ba191a5 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -25,3 +25,17 @@ async def test_readyz_before_ready(app: FastAPI) -> None: resp = await c.get("/readyz") assert resp.status_code == 503 assert resp.json()["type"] == "https://readium.org/speech-server/error#service_not_ready" + + +@pytest.mark.route +async def test_readiness_gate_on_model_endpoints(app: FastAPI, client: AsyncClient) -> None: + """While models are still loading (ready=False), endpoints that NEED the models + return 503; model-independent ones stay up (the server never blocks at startup).""" + app.state.ready = False + # up regardless — liveness + config-only capabilities + assert (await client.get("/healthz")).status_code == 200 + assert (await client.get("/service")).status_code == 200 + # gated — need loaded models + assert (await client.get("/readyz")).status_code == 503 + assert (await client.get("/voices")).status_code == 503 + assert (await client.post("/synthesize", json={"text": "hi"})).status_code == 503 diff --git a/tests/test_language_filter.py b/tests/test_language_filter.py index a86fdb8..e60e5c8 100644 --- a/tests/test_language_filter.py +++ b/tests/test_language_filter.py @@ -27,6 +27,7 @@ def language_config(*langs: str) -> Generator[None, None, None]: def test_pocket_json_covers_all_supported_languages() -> None: + """Every supported language is some voice's PRIMARY language at least once.""" voices = json.loads(_POCKET_JSON.read_text()) present = {v["language"].split("-")[0].lower() for v in voices} assert present == _SUPPORTED @@ -35,7 +36,8 @@ def test_pocket_json_covers_all_supported_languages() -> None: def test_pocket_json_english_voice_count() -> None: voices = json.loads(_POCKET_JSON.read_text()) en = [v for v in voices if v["language"].split("-")[0].lower() == "en"] - assert len(en) == 26 # all 26 voices under language "en" + assert len(en) == 21 # voices whose PRIMARY language is English (not all 26 — each + # voice appears once now, not duplicated per language) def test_pocket_json_non_english_languages_each_have_voices() -> None: @@ -49,21 +51,25 @@ def test_pocket_json_non_english_languages_each_have_voices() -> None: def test_pocket_json_all_voices_have_required_fields() -> None: - required = { - "source", - "label", - "name", - "originalName", - "voiceURI", - "language", - "provider", - "engineVoiceId", - "sampleRate", - "mimeTypes", - } + required = {"name", "originalName", "identifier", "language"} for v in json.loads(_POCKET_JSON.read_text()): missing = required - v.keys() - assert not missing, f"{v.get('voiceURI')} missing: {missing}" + assert not missing, f"{v.get('identifier')} missing: {missing}" + + +def test_pocket_json_no_duplicate_identifiers() -> None: + voices = json.loads(_POCKET_JSON.read_text()) + ids = [v["identifier"] for v in voices] + assert len(ids) == len(set(ids)) + + +def test_pocket_json_original_names_are_lowercase() -> None: + """PocketTTS predefined-voice ids are lowercase; originalName is looked up + case-sensitively at warm time (get_state_for_audio_prompt). A stray capital + (e.g. "Vera") silently falls through to the unavailable voice-cloning path.""" + for v in json.loads(_POCKET_JSON.read_text()): + name = v["originalName"] + assert name == name.lower(), f"originalName must be lowercase: {name!r}" # ── active_languages() intersection logic ──────────────────────────────────── @@ -98,6 +104,12 @@ def test_active_languages_all_unsupported_returns_empty() -> None: # ── list_voices() filtering (no model load — injects Voice objects directly) ─ +# +# otherLanguages here is voices.json's full aspirational list, standing in for a +# scenario where every declared cross-language pair happens to be installed (e.g. +# VOICE_LANGUAGES=*:* with every supported language enabled). This exercises +# list_voices()'s voice_language_prefixes()-based filter directly, independent of +# install-mode bookkeeping (covered separately in tests/unit/test_voice_loading.py). def _loaded_provider(*langs: str): # type: ignore[no-untyped-def] @@ -106,7 +118,18 @@ def _loaded_provider(*langs: str): # type: ignore[no-untyped-def] from app.schemas.voice import Voice provider = PocketTTSProvider() - provider._voices = [Voice(**v) for v in json.loads(_POCKET_JSON.read_text())] # noqa: SLF001 + provider._voices = [ # noqa: SLF001 + Voice( + name=v["name"], + originalName=v["originalName"], + provider="pocket", + identifier=v["identifier"], + language=v["language"], + otherLanguages=v.get("otherLanguages", []), + gender=v.get("gender"), + ) + for v in json.loads(_POCKET_JSON.read_text()) + ] return provider, langs @@ -115,8 +138,8 @@ async def test_list_voices_filters_to_single_language() -> None: provider, langs = _loaded_provider("fr") with language_config(*langs): result = await provider.list_voices() - assert {v.language.split("-")[0].lower() for v in result} == {"fr"} - assert len(result) == 26 # all 26 voices available in French + assert all("fr" in _prefixes(v) for v in result) + assert len(result) == 26 # every voice declares French as primary or additional @pytest.mark.asyncio @@ -124,9 +147,8 @@ async def test_list_voices_multiple_languages() -> None: provider, langs = _loaded_provider("en", "fr") with language_config(*langs): result = await provider.list_voices() - langs_in_result = {v.language.split("-")[0].lower() for v in result} - assert langs_in_result == {"en", "fr"} - assert len(result) == 52 # 26 en + 26 fr + assert all({"en", "fr"} & _prefixes(v) for v in result) + assert len(result) == 26 # each voice counted once, not per matching language @pytest.mark.asyncio @@ -134,7 +156,7 @@ async def test_list_voices_all_languages_returns_all() -> None: provider, langs = _loaded_provider(*_SUPPORTED) with language_config(*langs): result = await provider.list_voices() - assert len(result) == 156 # 26 voices × 6 languages + assert len(result) == 26 # all voices, each still counted once @pytest.mark.asyncio @@ -144,3 +166,23 @@ async def test_list_voices_unsupported_config_returns_empty() -> None: with language_config(*langs): result = await provider.list_voices() assert list(result) == [] + + +def _prefixes(voice) -> set[str]: # type: ignore[no-untyped-def] + from app.schemas.voice import voice_language_prefixes + + return set(voice_language_prefixes(voice)) + + +def test_resolve_lang_key_never_falls_back_to_uninstalled_language() -> None: + """A requested language a voice ISN'T installed for must NOT silently fall back + to another language — pocket-tts needs the specific (voice, language) embedding, + so an uninstalled combo is genuinely unavailable, not a synonym for the default.""" + from app.providers.pocket_tts import PocketTTSProvider + + p = PocketTTSProvider() + p._voice_langs = {"v": frozenset({"en", "fr"})} # noqa: SLF001 + p._voice_default_lang = {"v": "en"} # noqa: SLF001 + assert p._resolve_lang_key("v", "fr") == "fr" # installed → used # noqa: SLF001 + assert p._resolve_lang_key("v", None) == "en" # omitted → default # noqa: SLF001 + assert p._resolve_lang_key("v", "es") is None # NOT installed → no fallback # noqa: SLF001 diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..dfffe38 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,32 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.route +async def test_service_shape(client: AsyncClient) -> None: + resp = await client.get("/service") + assert resp.status_code == 200 + body = resp.json() + assert set(body["output"]["formats"]) >= {"wav", "mp3", "opus"} + assert body["output"]["default"] == "wav" + assert body["limits"]["maxTextLength"] > 0 + assert body["limits"]["maxConcurrentSyntheses"] > 0 + assert any(p["id"] == "fake" for p in body["providers"]) + + +@pytest.mark.route +async def test_service_provider_capabilities_shape(client: AsyncClient) -> None: + resp = await client.get("/service") + provider = next(p for p in resp.json()["providers"] if p["id"] == "fake") + assert "installedLanguages" in provider + # model-level quality/controls aren't repeated here — they're on /voices per voice + assert "controls" not in provider + assert "quality" not in provider + + +@pytest.mark.route +async def test_service_has_no_per_voice_list(client: AsyncClient) -> None: + """The voice list lives on /voices; /service stays a server-wide summary.""" + resp = await client.get("/service") + provider = next(p for p in resp.json()["providers"] if p["id"] == "fake") + assert "voices" not in provider diff --git a/tests/test_settings.py b/tests/test_settings.py index 7f3cd17..ccce1b5 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,16 +3,72 @@ from app.config.settings import Settings +def _settings(**overrides: object) -> Settings: + # _env_file=None: don't let this repo's real .env/pocket-tts.env leak into + # tests expecting clean defaults — construct from kwargs only. + return Settings(_env_file=None, **overrides) # type: ignore[arg-type] + + def test_production_requires_domain() -> None: with pytest.raises(ValueError, match="DOMAIN must be set"): - Settings(app_env="production", domain="") + _settings(app_env="production", domain="") def test_production_with_domain_is_valid() -> None: - settings = Settings(app_env="production", domain="tts.example.com") + settings = _settings(app_env="production", domain="tts.example.com") assert settings.domain == "tts.example.com" def test_development_without_domain_is_valid() -> None: - settings = Settings(app_env="development", domain="") + settings = _settings(app_env="development", domain="") assert settings.domain == "" + + +def test_voice_language_add_map_parses_unprefixed_pairs() -> None: + settings = _settings( + app_env="development", domain="", voice_languages="alba:fr,alba:de,Estelle:EN" + ) + assert settings.voice_language_add_map == { + "alba": frozenset({"fr", "de"}), + "estelle": frozenset({"en"}), + } + assert settings.voice_language_remove_map == {} + + +def test_voice_language_remove_map_parses_minus_prefixed_pairs() -> None: + settings = _settings( + app_env="development", domain="", voice_languages="alba:fr,-javert:es,-javert:de" + ) + assert settings.voice_language_add_map == {"alba": frozenset({"fr"})} + assert settings.voice_language_remove_map == {"javert": frozenset({"es", "de"})} + + +def test_voice_language_maps_empty_by_default() -> None: + settings = _settings(app_env="development", domain="") + assert settings.voice_language_add_map == {} + assert settings.voice_language_remove_map == {} + + +def test_voice_language_maps_ignore_malformed_entries() -> None: + settings = _settings( + app_env="development", domain="", voice_languages="alba:fr,malformed,:missing,x:,-" + ) + assert settings.voice_language_add_map == {"alba": frozenset({"fr"})} + assert settings.voice_language_remove_map == {} + + +def test_voice_install_all_false_by_default() -> None: + settings = _settings(app_env="development", domain="") + assert settings.voice_install_all is False + + +def test_voice_install_all_true_with_wildcard() -> None: + settings = _settings(app_env="development", domain="", voice_languages="*:*") + assert settings.voice_install_all is True + + +def test_voice_install_all_wildcard_coexists_with_overrides() -> None: + settings = _settings(app_env="development", domain="", voice_languages="*:*,-javert:es") + assert settings.voice_install_all is True + assert settings.voice_language_remove_map == {"javert": frozenset({"es"})} + assert settings.voice_language_add_map == {} # "*:*" itself is not a voice:lang pair diff --git a/tests/test_synthesize.py b/tests/test_synthesize.py index f15551a..1ce754f 100644 --- a/tests/test_synthesize.py +++ b/tests/test_synthesize.py @@ -53,10 +53,32 @@ async def test_synthesize_invalid_format_returns_422(client: AsyncClient) -> Non @pytest.mark.route async def test_synthesize_schema_error_returns_422(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello", "output": {"format": "wav"}}) + resp = await client.post(_URL, json={"voice": _VOICE, "output": {"format": "wav"}}) # no text assert resp.status_code == 422 +@pytest.mark.route +async def test_synthesize_uses_default_voice_when_omitted( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + from app.config.settings import settings + + monkeypatch.setattr(settings, "pocket_default_voice", "fake-en-US") # originalName, not URI + resp = await client.post(_URL, json={"text": "Hello", **_WAV}) + assert resp.status_code == 200 + + +@pytest.mark.route +async def test_synthesize_no_voice_no_default_returns_400( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + from app.config.settings import settings + + monkeypatch.setattr(settings, "pocket_default_voice", "") + resp = await client.post(_URL, json={"text": "Hello", **_WAV}) + assert resp.status_code == 400 + + @pytest.mark.route async def test_synthesize_content_disposition(client: AsyncClient) -> None: resp = await client.post(_URL, json={"text": "Hello", "voice": _VOICE, **_WAV}) diff --git a/tests/test_voice_loading.py b/tests/test_voice_loading.py new file mode 100644 index 0000000..15ddff6 --- /dev/null +++ b/tests/test_voice_loading.py @@ -0,0 +1,170 @@ +from app.domain.enums import Gender, Quality +from app.providers.voice_loading import ( + ControlsOverride, + VoiceEntry, + build_voice, + plan_install, + resolve_install_languages, +) +from app.schemas.voice import Controls + + +def _entry(**overrides: object) -> VoiceEntry: + base: dict[str, object] = { + "name": "Test", + "originalName": "test", + "identifier": "urn:x:test", + "language": "en-US", + "otherLanguages": ["fr", "es"], + } + base.update(overrides) + return VoiceEntry(**base) # type: ignore[arg-type] + + +def test_resolve_primary_mode_ignores_other_languages() -> None: + primary, other = resolve_install_languages(_entry(), frozenset({"en", "fr", "es"}), False) + assert primary == "en" + assert other == frozenset() + + +def test_resolve_all_mode_bounded_by_enabled_languages() -> None: + """The crux of the install-mode design: cross-language support never exceeds + what's already enabled via LANGUAGES, even if voices.json declares more.""" + primary, other = resolve_install_languages(_entry(), frozenset({"en", "fr"}), True) + assert primary == "en" + assert other == frozenset({"fr"}) # "es" is declared but not enabled — excluded + + +def test_resolve_all_mode_never_installs_beyond_enabled_set() -> None: + entry = _entry(otherLanguages=["fr", "es", "de", "it", "pt"]) + _, other = resolve_install_languages(entry, frozenset({"en"}), True) + assert other == frozenset() # nothing else enabled — no new model gets pulled in + + +def test_resolve_excludes_primary_from_other_set() -> None: + entry = _entry(language="en-US", otherLanguages=["en", "fr"]) + _, other = resolve_install_languages(entry, frozenset({"en", "fr"}), True) + assert other == frozenset({"fr"}) # "en" is primary, not an "other" language + + +def test_resolve_add_promotes_declared_language_when_not_install_all() -> None: + """The per-voice override: cherry-pick an addition without switching install_all + on (which would pull in everything every voice declares).""" + entry = _entry(otherLanguages=["fr", "es"]) + _, other = resolve_install_languages( + entry, frozenset({"en", "fr", "es"}), False, add_langs=frozenset({"fr"}) + ) + assert other == frozenset({"fr"}) + + +def test_resolve_add_bounded_by_enabled_languages() -> None: + """Add can never exceed LANGUAGES either — same invariant as install_all=True.""" + entry = _entry(otherLanguages=["fr", "es"]) + _, other = resolve_install_languages( + entry, frozenset({"en", "fr"}), False, add_langs=frozenset({"fr", "es"}) + ) + assert other == frozenset({"fr"}) # "es" not enabled — dropped despite being in add_langs + + +def test_resolve_add_cannot_invent_undeclared_capability() -> None: + """Add can only promote a language the voice already declares in otherLanguages — + never a language voices.json doesn't claim that voice supports.""" + entry = _entry(otherLanguages=["fr"]) + _, other = resolve_install_languages( + entry, frozenset({"en", "fr", "de"}), False, add_langs=frozenset({"de"}) + ) + assert other == frozenset() # "de" not in this voice's otherLanguages — ignored + + +def test_resolve_remove_prunes_base_install_all() -> None: + entry = _entry(otherLanguages=["fr", "es"]) + _, other = resolve_install_languages( + entry, frozenset({"en", "fr", "es"}), True, remove_langs=frozenset({"fr"}) + ) + assert other == frozenset({"es"}) + + +def test_resolve_remove_wins_when_same_pair_also_added() -> None: + entry = _entry(otherLanguages=["fr", "es"]) + _, other = resolve_install_languages( + entry, + frozenset({"en", "fr", "es"}), + False, + add_langs=frozenset({"fr", "es"}), + remove_langs=frozenset({"fr"}), + ) + assert other == frozenset({"es"}) # fr added then removed — net result excludes it + + +# ── plan_install: a voice can run in a non-primary language without its primary ── + + +def test_plan_install_primary_enabled_default_mode() -> None: + # Normal case: primary enabled, no cross-language → installs primary only. + plan = plan_install(_entry(), frozenset({"en", "fr"}), False) + assert plan == ("en", frozenset({"en"})) + + +def test_plan_install_primary_not_enabled_but_added_other_installs() -> None: + # estelle-style: primary (en here) NOT enabled, but an explicit add pulls the + # voice in for a non-primary language using only that language's model. + entry = _entry(language="en-US", otherLanguages=["fr", "es"]) + plan = plan_install(entry, frozenset({"fr"}), False, add_langs=frozenset({"fr"})) + assert plan is not None + default, installed = plan + assert installed == frozenset({"fr"}) # installed for fr only — no en base model + assert default == "fr" # primary not installed → first installed language + + +def test_plan_install_no_enabled_language_returns_none() -> None: + # Primary not enabled and no cross-language applies → voice not installed. + entry = _entry(language="en-US", otherLanguages=["fr", "es"]) + assert plan_install(entry, frozenset({"de"}), False) is None + + +def test_plan_install_primary_preferred_as_default_when_installed() -> None: + entry = _entry(language="en-US", otherLanguages=["fr"]) + plan = plan_install(entry, frozenset({"en", "fr"}), True) # install_all + assert plan == ("en", frozenset({"en", "fr"})) # primary stays the default + + +def test_controls_serialize_only_enabled() -> None: + """A false control is absent from the payload; only enabled ones appear.""" + assert Controls().model_dump() == {} + assert Controls(ssml=True).model_dump() == {"ssml": True} + assert Controls(ssml=True, boundary=True).model_dump() == {"ssml": True, "boundary": True} + # internal attribute access is unaffected (all fields still exist) + assert Controls().boundary is False + + +def test_build_voice_uses_provider_defaults() -> None: + entry = _entry(gender=Gender.MALE) + voice = build_voice(entry, "pocket", frozenset({"fr"}), Quality.VERY_HIGH, Controls()) + assert voice.quality == Quality.VERY_HIGH + assert voice.otherLanguages == ["fr"] + assert voice.controls == Controls() + assert voice.provider == "pocket" + assert voice.gender == Gender.MALE + + +def test_build_voice_quality_override_wins_over_default() -> None: + entry = _entry(quality=Quality.LOW) + voice = build_voice(entry, "pocket", frozenset(), Quality.VERY_HIGH, Controls()) + assert voice.quality == Quality.LOW + + +def test_build_voice_partial_controls_override() -> None: + entry = _entry(controls=ControlsOverride(ssml=True)) + defaults = Controls(pitch=False, speed=False, ssml=False, boundary=False) + voice = build_voice(entry, "pocket", frozenset(), None, defaults) + assert voice.controls.ssml is True + assert voice.controls.pitch is False # untouched fields fall back to the default + assert voice.controls.boundary is False + + +def test_build_voice_other_languages_reflect_installed_not_aspirational() -> None: + """voices.json declares 2 other languages, but only 1 was actually resolved + for install — the served Voice must reflect what's installed.""" + entry = _entry(otherLanguages=["fr", "es"]) + voice = build_voice(entry, "pocket", frozenset({"fr"}), None, Controls()) + assert voice.otherLanguages == ["fr"] diff --git a/tests/test_voices.py b/tests/test_voices.py index 48da6d9..fe276cc 100644 --- a/tests/test_voices.py +++ b/tests/test_voices.py @@ -8,9 +8,9 @@ async def test_list_voices_returns_all(client: AsyncClient) -> None: assert resp.status_code == 200 voices = resp.json() assert len(voices) == 2 - uris = {v["voiceURI"] for v in voices} - assert "urn:readium:tts:fake:en-US-standard" in uris - assert "urn:readium:tts:fake:fr-FR-standard" in uris + ids = {v["identifier"] for v in voices} + assert "urn:readium:tts:fake:en-US-standard" in ids + assert "urn:readium:tts:fake:fr-FR-standard" in ids @pytest.mark.route @@ -18,16 +18,12 @@ async def test_list_voices_shape(client: AsyncClient) -> None: resp = await client.get("/voices") voice = resp.json()[0] required = ( - "source", - "label", "name", "originalName", - "voiceURI", - "language", "provider", - "engineVoiceId", - "sampleRate", - "mimeTypes", + "identifier", + "language", + "controls", ) for field in required: assert field in voice, f"Missing field: {field}"