Text-to-Speech
Transformers
Safetensors
Kabyle
agbalu_speech
feature-extraction
kabyle
taqbaylit
berber
amazigh
speech-synthesis
voice-cloning
cross-lingual-voice-cloning
expressive-speech
low-resource
custom_code
Instructions to use agbalu/Matoub-4B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use agbalu/Matoub-4B with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-to-speech", model="agbalu/Matoub-4B", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("agbalu/Matoub-4B", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- README.md +11 -15
- config.json +10 -47
- export.stats.json +1 -1
- modeling_speech.py +2 -9
- synthesis.py +155 -90
README.md
CHANGED
|
@@ -21,15 +21,13 @@ tags:
|
|
| 21 |
|
| 22 |
# Matoub-4B
|
| 23 |
|
| 24 |
-
A 4B-parameter
|
| 25 |
|
| 26 |
-
|
| 27 |
|
| 28 |
## Results
|
| 29 |
|
| 30 |
-
Validation was conducted across 16 utterances spanning six speakers. Speaker similarity
|
| 31 |
-
is evaluated using WeSpeaker 256-dimensional speaker embeddings, and transcription fidelity
|
| 32 |
-
is verified using Fadhma-300M ASR.
|
| 33 |
|
| 34 |
| metric | value |
|
| 35 |
|---|---|
|
|
@@ -83,8 +81,10 @@ sf.write("speech.wav", speech.waveform[0].numpy(), speech.sampling_rate, subtype
|
|
| 83 |
- Use clear, continuous speech from one speaker, without background music, overlapping voices or heavy reverberation. No single optimal reference duration has been established for this release.
|
| 84 |
- `reference_text`: exact transcript of the words spoken in `reference_audio` in their original language.
|
| 85 |
- For an English or French reference, provide the exact English or French transcript; do not translate it into Kabyle.
|
|
|
|
|
|
|
| 86 |
- The returned waveform is a CPU float tensor sampled at 24,000 Hz.
|
| 87 |
-
-
|
| 88 |
|
| 89 |
### Generation settings and return value
|
| 90 |
|
|
@@ -100,19 +100,15 @@ model.config.inference["sampling"].update(
|
|
| 100 |
)
|
| 101 |
```
|
| 102 |
|
| 103 |
-
These are the packaged sampling defaults. `max_new_positions` limits
|
| 104 |
-
candidate's generated codec positions; it is not a waveform sample count.
|
| 105 |
-
The return value is `SpeechOutput`:
|
| 106 |
|
| 107 |
| Attribute | Value |
|
| 108 |
|---|---|
|
| 109 |
| `.waveform` | CPU float tensor with shape `[1, N]` |
|
| 110 |
| `.sampling_rate` | `24000` |
|
| 111 |
-
| `.report` | Generation audit
|
| 112 |
|
| 113 |
-
The
|
| 114 |
-
selecting candidates. These checks can reject a request; they do not guarantee
|
| 115 |
-
perfect pronunciation or voice identity.
|
| 116 |
|
| 117 |
### 4-bit Quantization (NF4)
|
| 118 |
|
|
@@ -220,7 +216,7 @@ distinct same-speaker reference recordings sharing no text or audio overlap.
|
|
| 220 |
|
| 221 |
| file | description |
|
| 222 |
|---|---|
|
| 223 |
-
| `model.safetensors` | Model weights
|
| 224 |
| `config.json` | Model configuration and AutoClass mapping |
|
| 225 |
| `tokenizer.json`, `tokenizer_config.json`, `chat_template.jinja` | Text tokenizer and template |
|
| 226 |
| `modeling_speech.py` and supporting modules | Transformers model implementation |
|
|
@@ -256,6 +252,6 @@ The naming is homage; it implies no affiliation or endorsement.
|
|
| 256 |
|
| 257 |
The model weights are distributed under the **Boson Higgs TTS 3 Research and Non-Commercial License**, inheriting the terms of the base model ([`bosonai/higgs-tts-3-4b`](https://huggingface.co/bosonai/higgs-tts-3-4b)). The weights are strictly for non-commercial research use.
|
| 258 |
|
| 259 |
-
The training data derives from Mozilla Common Voice Kabyle (CC0-1.0, public domain).
|
| 260 |
|
| 261 |
Part of [AƔBALU](https://huggingface.co/agbalu), a Kabyle and Amazigh corpus and model collection.
|
|
|
|
| 21 |
|
| 22 |
# Matoub-4B
|
| 23 |
|
| 24 |
+
A 4B-parameter text-to-speech model for **Kabyle** (Taqbaylit, `kab`, Latin script), trained on 384,960 curated speech pairs from Common Voice Kabyle. It synthesises broadcast-quality **24 kHz audio** from Kabyle text. Voice cloning is a feature of that model: a short reference recording, with its transcript kept in the original language, sets the speaker.
|
| 25 |
|
| 26 |
+
The reference can be Kabyle or another language. `generate()` produces one utterance at the trained adapter scale, for every reference language. An 8-codebook discrete audio representation carries Kabyle's phonology, including spirantised, pharyngealised, emphatic, and geminated consonants.
|
| 27 |
|
| 28 |
## Results
|
| 29 |
|
| 30 |
+
Validation was conducted across 16 utterances spanning six speakers. Each utterance was generated once with the packaged sampler. Speaker similarity is the cosine of WavLM embeddings (`microsoft/wavlm-base-plus-sv`), and transcription fidelity is measured with Fadhma-300M.
|
|
|
|
|
|
|
| 31 |
|
| 32 |
| metric | value |
|
| 33 |
|---|---|
|
|
|
|
| 81 |
- Use clear, continuous speech from one speaker, without background music, overlapping voices or heavy reverberation. No single optimal reference duration has been established for this release.
|
| 82 |
- `reference_text`: exact transcript of the words spoken in `reference_audio` in their original language.
|
| 83 |
- For an English or French reference, provide the exact English or French transcript; do not translate it into Kabyle.
|
| 84 |
+
- `generate()` writes one utterance. It does not search candidates or download an ASR or speaker verifier.
|
| 85 |
+
- A text that cannot fit in the 8,192-position context with its reference is split, then joined. Leading and trailing silence is removed, and an internal silence longer than 320 ms is shortened to 140 ms.
|
| 86 |
- The returned waveform is a CPU float tensor sampled at 24,000 Hz.
|
| 87 |
+
- Sampling, decoding and the adapter scale live in `model.config.inference`.
|
| 88 |
|
| 89 |
### Generation settings and return value
|
| 90 |
|
|
|
|
| 100 |
)
|
| 101 |
```
|
| 102 |
|
| 103 |
+
These are the packaged sampling defaults. `max_new_positions` limits generated codec positions; it is not a waveform sample count. The return value is `SpeechOutput`:
|
|
|
|
|
|
|
| 104 |
|
| 105 |
| Attribute | Value |
|
| 106 |
|---|---|
|
| 107 |
| `.waveform` | CPU float tensor with shape `[1, N]` |
|
| 108 |
| `.sampling_rate` | `24000` |
|
| 109 |
+
| `.report` | Generation audit: duration, checksum, sampler, and one trace per segment |
|
| 110 |
|
| 111 |
+
The audit records the sample that was produced. It does not select among several takes.
|
|
|
|
|
|
|
| 112 |
|
| 113 |
### 4-bit Quantization (NF4)
|
| 114 |
|
|
|
|
| 216 |
|
| 217 |
| file | description |
|
| 218 |
|---|---|
|
| 219 |
+
| `model.safetensors` | Model weights, the rank-32 LoRA factors at the trained scale, and the audio codec |
|
| 220 |
| `config.json` | Model configuration and AutoClass mapping |
|
| 221 |
| `tokenizer.json`, `tokenizer_config.json`, `chat_template.jinja` | Text tokenizer and template |
|
| 222 |
| `modeling_speech.py` and supporting modules | Transformers model implementation |
|
|
|
|
| 252 |
|
| 253 |
The model weights are distributed under the **Boson Higgs TTS 3 Research and Non-Commercial License**, inheriting the terms of the base model ([`bosonai/higgs-tts-3-4b`](https://huggingface.co/bosonai/higgs-tts-3-4b)). The weights are strictly for non-commercial research use.
|
| 254 |
|
| 255 |
+
The training data derives from Mozilla Common Voice Kabyle (CC0-1.0, public domain). Fadhma-300M, used for the transcription measurement, is Apache-2.0. The similarity figure uses WavLM (`microsoft/wavlm-base-plus-sv`).
|
| 256 |
|
| 257 |
Part of [AƔBALU](https://huggingface.co/agbalu), a Kabyle and Amazigh corpus and model collection.
|
config.json
CHANGED
|
@@ -150,57 +150,20 @@
|
|
| 150 |
"text_vocabulary": 151936
|
| 151 |
},
|
| 152 |
"inference": {
|
| 153 |
-
"
|
| 154 |
-
"candidates": 6,
|
| 155 |
-
"content_tolerance": 0.08,
|
| 156 |
-
"decoding": {
|
| 157 |
-
"alignment_strength": 0.0,
|
| 158 |
-
"plausible_tokens": 25,
|
| 159 |
-
"recover_loops": false
|
| 160 |
-
},
|
| 161 |
-
"fallback_candidates": 2,
|
| 162 |
-
"max_cer": 0.2,
|
| 163 |
-
"max_duration_ratio": 3.0,
|
| 164 |
-
"max_segment_characters": 160,
|
| 165 |
-
"min_generation_s": 10.0,
|
| 166 |
-
"min_predicted_mos": 3.5,
|
| 167 |
-
"min_voice_cosine": 0.21693290024995804,
|
| 168 |
-
"quality_tolerance": 0.25,
|
| 169 |
"sampling": {
|
| 170 |
-
"max_new_positions": 1500,
|
| 171 |
-
"seed": 20261102,
|
| 172 |
"temperature": 0.8,
|
|
|
|
| 173 |
"top_k": 30,
|
| 174 |
-
"
|
|
|
|
| 175 |
},
|
| 176 |
-
"
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
"recover_loops": false
|
| 183 |
-
}
|
| 184 |
-
},
|
| 185 |
-
{
|
| 186 |
-
"adapter_multiplier": 0.65,
|
| 187 |
-
"decoding": {
|
| 188 |
-
"alignment_strength": 0.0,
|
| 189 |
-
"plausible_tokens": 25,
|
| 190 |
-
"recover_loops": false
|
| 191 |
-
}
|
| 192 |
-
},
|
| 193 |
-
{
|
| 194 |
-
"adapter_multiplier": 1.0,
|
| 195 |
-
"decoding": {
|
| 196 |
-
"alignment_strength": 1.0,
|
| 197 |
-
"plausible_tokens": 25,
|
| 198 |
-
"recover_loops": false
|
| 199 |
-
}
|
| 200 |
-
}
|
| 201 |
-
],
|
| 202 |
-
"timeout_s": 840.0,
|
| 203 |
-
"version": 1
|
| 204 |
},
|
| 205 |
"model_type": "agbalu_speech",
|
| 206 |
"sampling_rate": 24000,
|
|
|
|
| 150 |
"text_vocabulary": 151936
|
| 151 |
},
|
| 152 |
"inference": {
|
| 153 |
+
"version": 1,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
"sampling": {
|
|
|
|
|
|
|
| 155 |
"temperature": 0.8,
|
| 156 |
+
"top_p": 0.8,
|
| 157 |
"top_k": 30,
|
| 158 |
+
"max_new_positions": 1500,
|
| 159 |
+
"seed": 20261102
|
| 160 |
},
|
| 161 |
+
"decoding": {
|
| 162 |
+
"alignment_strength": 0,
|
| 163 |
+
"plausible_tokens": 25,
|
| 164 |
+
"recover_loops": true
|
| 165 |
+
},
|
| 166 |
+
"adapter_multiplier": 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
},
|
| 168 |
"model_type": "agbalu_speech",
|
| 169 |
"sampling_rate": 24000,
|
export.stats.json
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"artifact_integrity_verified": true, "config_hash": "
|
|
|
|
| 1 |
+
{"artifact_integrity_verified": true, "card_sha256": "1ab9d6699d43d0b3e8a239e2f099d8ddb73780e237f54ad5a24842dfed5387de", "config_hash": "1ab9d6699d43d0b3e8a239e2f099d8ddb73780e237f54ad5a24842dfed5387de", "dirty": true, "dropped_native_keys": ["tied.embedding.modality_embeddings.0.model.semantic_model.masked_spec_embed", "tied.head.modality_heads.0.weight", "tied.head.text_head.weight"], "exported_tensor_count": 1430, "files": {"LICENSE": "0382189330b87c38517530e4e5bf0c9dfeb6c7bfbe62812577ddcb73e09a60c5", "NOTICE": "b2640a3741dc4035d9eb9e0c26696142a10f8caf208d0d8d6e2600947d6fc8d7", "README.md": "1ab9d6699d43d0b3e8a239e2f099d8ddb73780e237f54ad5a24842dfed5387de", "STAMP.json": "0d9ae3d170c158bacb7f5c9b407d1633460cf858105b297c9a2180fb01ca68ed", "adaptation.py": "3dceb0d9450775e21f0375781b102324d22c924e0a05e6599f87bb3b631da51a", "artifacts.py": "f7a68f94e56ee3e9394bc385215cb71d7fde9f5b5c14f40cfd3189006cf8479e", "audio.py": "1ca1c4984a6d43ae9137d15b7a3a1d9f300a868aaa8fa3b9369e30ab95edffeb", "backbone.py": "e31eff44ba894c4bfd332e35040b8f84be043a8dc136c090a423ccc664276b3f", "chat_template.jinja": "44d5f08f3f72b837eaad09f13a54c1f9f4eb58d75240334548b7fd52a5437fa5", "codec.py": "b65875c630d6b6e135510bbe1f761ad93d6c2e697c857ee30ef1845c73f1f894", "config.json": "7bee9b7ce2e05766f82fc75c470c6f53af717696b0c30b8851a341eb21a7c651", "configuration.py": "1c54db62d2a97c0da43c9fbcfeb0698b9550325006304ff701e8b1a26cf1e857", "dataset.py": "afcd7b2f19ef7b479e4661ec0efd56138220b466605478e93d014e60f07db6c2", "evaluation.py": "364a7049804998d4857618f1a9061493f8b644f59e79be1466244b4f0fd6b74f", "inference.py": "db80183c086f071e290ef460c957a7e2546d9ba9fe6ae95b239562cc2ffef962", "layout.py": "34b4069f74cee213209101cc0cda3fc0fc2aa2e64db722321aba7e6c883298fb", "model.safetensors": "37c391ff7178ae6331ccbe1ab18bd9cd1f1fbdb8934a3d0021ffcc586f8ca4dd", "modeling_speech.py": "f0d7b63f3b8145ffc0fd3b5c6aa150ce4583618b9c172ab7ad54240b7465ac1d", "objective.py": "5c72520a55bc0a698decf8f316a2172668a5f505eb720957ce4789679f2729c9", "pairs.py": "bb2dfe95b555bff7cf2ccadd3d62f524927dc4e9c658507175f595a2e4218216", "partition.py": "df3fd82e96330ee139c663bf88c383b0a2e7b714cd54ff95048b1cc898cfe619", "progress.py": "9c766bb4e107c63232f3a5abfd2debabe7c8bf6dabd806e00bbe6a62eb68934e", "provenance.py": "278abb15bd21575f409b7d55dcd1a0dc12526f81a9b90e6cc6de96db7543c236", "requirements.txt": "e56142d5c4b77b45b41e16c7d1edc6046491ed5d384e82537bc0a5ba3ac6c819", "synthesis.py": "7d3f3404e1bc151fc7a48cdee3761b2e9f918c09f439a02cfa8237b13acab21c", "tokenizer.json": "eb883de2de5adc5113f1f02b54830a0ea7cd6ef191cde65c41aceb3737d4d1c1", "tokenizer_config.json": "b4d632e1239569fb1829bf0bfa3c674fa54f22c42e9cb2669d77c438d4b4e02c", "verification.py": "3df87f44f13411e2f9ce1cb3ce3c6c6490ed6c31eb6a6e956d823c84e1c2f7a2", "waveforms.py": "248c58c7818c8d258ac40fd0c0e98bc8fa607603f0c62e5da5fb413177ce7460"}, "git_sha": "dd4ca358066559534565438f073227cbc275f57d", "inference_config_sha256": "d60a8f5d6bbdb05963a551dd7cebaa7c3935f5c3cb3c4e186706428fc0ffbf01", "kind": "bf16", "source_weights_sha256": "37c391ff7178ae6331ccbe1ab18bd9cd1f1fbdb8934a3d0021ffcc586f8ca4dd", "version": 1, "version_tag": null, "weights_sha256": "37c391ff7178ae6331ccbe1ab18bd9cd1f1fbdb8934a3d0021ffcc586f8ca4dd"}
|
modeling_speech.py
CHANGED
|
@@ -13,8 +13,7 @@ from .adaptation import inject_adapter
|
|
| 13 |
from .backbone import Generator
|
| 14 |
from .codec import Codec
|
| 15 |
from .configuration import Geometry
|
| 16 |
-
from .synthesis import InferenceSettings, SpeechRequest,
|
| 17 |
-
from .verification import SpeechVerifier
|
| 18 |
|
| 19 |
def compute_weight_identity(checkpoint: str, quantization: object, has_adapter: bool) -> str:
|
| 20 |
payload = {'checkpoint': checkpoint, 'quantization': quantization, 'has_adapter': has_adapter}
|
|
@@ -53,8 +52,6 @@ class SpeechModel(PreTrainedModel):
|
|
| 53 |
if config.has_adapter:
|
| 54 |
inject_adapter(self.generator)
|
| 55 |
self.audio_tokenizer = HiggsAudioV2TokenizerModel(HiggsAudioV2TokenizerConfig(**config.audio_configuration))
|
| 56 |
-
self._verifier: SpeechVerifier | None = None
|
| 57 |
-
self._verifier_device: torch.device | None = None
|
| 58 |
self.post_init()
|
| 59 |
|
| 60 |
def save_pretrained(self, save_directory: str | Path, **kwargs: object) -> None:
|
|
@@ -75,15 +72,11 @@ class SpeechModel(PreTrainedModel):
|
|
| 75 |
raise FileNotFoundError(message)
|
| 76 |
settings = InferenceSettings.model_validate(self.config.inference)
|
| 77 |
device = self.generator.audio_embedding.weight.device
|
| 78 |
-
if self._verifier is None or self._verifier_device != device:
|
| 79 |
-
self._verifier = SpeechVerifier(device)
|
| 80 |
-
self._verifier_device = device
|
| 81 |
commit: object = getattr(self.config, '_commit_hash', None)
|
| 82 |
tokenizer = AutoTokenizer.from_pretrained(self.config.name_or_path, revision=commit if isinstance(commit, str) else 'main', trust_remote_code=True)
|
| 83 |
-
engine = SpeechSynthesis(self.generator, Codec(self.audio_tokenizer, device), tokenizer, self._verifier, provenance_stamp=self.config.source_stamp)
|
| 84 |
with tempfile.TemporaryDirectory(prefix='speech-') as temporary:
|
| 85 |
destination = Path(output_path) if output_path is not None else Path(temporary) / 'speech.wav'
|
| 86 |
-
report =
|
| 87 |
waveform = torch.from_numpy(read_waveform(destination)).unsqueeze(0)
|
| 88 |
return SpeechOutput(waveform=waveform, sampling_rate=24000, report=report)
|
| 89 |
SpeechConfig.register_for_auto_class()
|
|
|
|
| 13 |
from .backbone import Generator
|
| 14 |
from .codec import Codec
|
| 15 |
from .configuration import Geometry
|
| 16 |
+
from .synthesis import InferenceSettings, SpeechRequest, read_waveform, render_speech
|
|
|
|
| 17 |
|
| 18 |
def compute_weight_identity(checkpoint: str, quantization: object, has_adapter: bool) -> str:
|
| 19 |
payload = {'checkpoint': checkpoint, 'quantization': quantization, 'has_adapter': has_adapter}
|
|
|
|
| 52 |
if config.has_adapter:
|
| 53 |
inject_adapter(self.generator)
|
| 54 |
self.audio_tokenizer = HiggsAudioV2TokenizerModel(HiggsAudioV2TokenizerConfig(**config.audio_configuration))
|
|
|
|
|
|
|
| 55 |
self.post_init()
|
| 56 |
|
| 57 |
def save_pretrained(self, save_directory: str | Path, **kwargs: object) -> None:
|
|
|
|
| 72 |
raise FileNotFoundError(message)
|
| 73 |
settings = InferenceSettings.model_validate(self.config.inference)
|
| 74 |
device = self.generator.audio_embedding.weight.device
|
|
|
|
|
|
|
|
|
|
| 75 |
commit: object = getattr(self.config, '_commit_hash', None)
|
| 76 |
tokenizer = AutoTokenizer.from_pretrained(self.config.name_or_path, revision=commit if isinstance(commit, str) else 'main', trust_remote_code=True)
|
|
|
|
| 77 |
with tempfile.TemporaryDirectory(prefix='speech-') as temporary:
|
| 78 |
destination = Path(output_path) if output_path is not None else Path(temporary) / 'speech.wav'
|
| 79 |
+
report = render_speech(self.generator, Codec(self.audio_tokenizer, device), tokenizer, request, settings, destination, provenance_stamp=self.config.source_stamp)
|
| 80 |
waveform = torch.from_numpy(read_waveform(destination)).unsqueeze(0)
|
| 81 |
return SpeechOutput(waveform=waveform, sampling_rate=24000, report=report)
|
| 82 |
SpeechConfig.register_for_auto_class()
|
synthesis.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
-
"""Produce
|
| 2 |
from __future__ import annotations
|
| 3 |
import hashlib
|
| 4 |
import json
|
| 5 |
-
import logging
|
| 6 |
import math
|
| 7 |
import re
|
| 8 |
import time
|
|
@@ -12,6 +11,7 @@ from typing import Protocol
|
|
| 12 |
import numpy as np
|
| 13 |
import soundfile as sf
|
| 14 |
from pydantic import Field, model_validator
|
|
|
|
| 15 |
from .adaptation import scaled_adapter
|
| 16 |
from .artifacts import atomic_binary, digest_file, provenance, run_lock, write_json
|
| 17 |
from .backbone import Generator
|
|
@@ -20,7 +20,7 @@ from .configuration import Specification
|
|
| 20 |
from .dataset import TextTokenizer
|
| 21 |
from .inference import MAX_CONTEXT_POSITIONS, Decoding, GenerationError, Sampling, generate_codes
|
| 22 |
from .layout import SPECIALS, SequenceLayout, prompt_layout
|
| 23 |
-
from .verification import
|
| 24 |
from .waveforms import Waveform, measure_signal, resample_waveform
|
| 25 |
DEFAULT_INFERENCE = Path('resources/codec_tts/inference.json')
|
| 26 |
STEREO_AXES = 2
|
|
@@ -147,6 +147,157 @@ def save_waveform(path: Path, waveform: Waveform) -> None:
|
|
| 147 |
with atomic_binary(path) as stream:
|
| 148 |
sf.write(stream, waveform, 24000, format='WAV', subtype='FLOAT')
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
def contrast_layout(prompt: SequenceLayout, specials: Mapping[str, int]) -> SequenceLayout:
|
| 151 |
keep = prompt.audio_mask.clone()
|
| 152 |
for token in SPECIALS:
|
|
@@ -206,90 +357,4 @@ class SpeechSynthesis:
|
|
| 206 |
return (value, False)
|
| 207 |
|
| 208 |
def synthesize(self, request: SpeechRequest, settings: InferenceSettings, output: Path) -> dict[str, object]:
|
| 209 |
-
|
| 210 |
-
spoken_text(request.reference_text)
|
| 211 |
-
specials = self.tokenizer.get_added_vocab()
|
| 212 |
-
for tag in re.findall('<\\|[^<>]*\\|>', request.text):
|
| 213 |
-
if tag not in specials or re.fullmatch('<\\|(?:emotion|style|prosody|sfx):[a-z_]+\\|>', tag) is None:
|
| 214 |
-
message = f'unsupported delivery tag {tag!r}'
|
| 215 |
-
raise ValueError(message)
|
| 216 |
-
parts = split_text(request.text, settings.max_segment_characters)
|
| 217 |
-
source = {name: digest_file(Path(__file__).with_name(name)) for name in ('synthesis.py', 'inference.py', 'adaptation.py', 'verification.py', 'layout.py')}
|
| 218 |
-
identity_data = {'request': request.model_dump(mode='json'), 'settings': settings.model_dump(), 'reference_sha256': digest_file(request.reference), 'source': source}
|
| 219 |
-
identity = hashlib.sha256(json.dumps(identity_data, sort_keys=True).encode()).hexdigest()
|
| 220 |
-
root, audit = (output.with_suffix('.candidates'), output.with_suffix('.stats.json'))
|
| 221 |
-
with run_lock(root):
|
| 222 |
-
marker = root / 'request.json'
|
| 223 |
-
if marker.exists() and json.loads(marker.read_bytes()).get('sha256') != identity:
|
| 224 |
-
message = f'different speech request already occupies {root}'
|
| 225 |
-
raise FileExistsError(message)
|
| 226 |
-
if not marker.exists() and (output.exists() or audit.exists()):
|
| 227 |
-
message = f'refusing to replace existing speech output {output}'
|
| 228 |
-
raise FileExistsError(message)
|
| 229 |
-
write_json(marker, {'sha256': identity})
|
| 230 |
-
reference = read_waveform(request.reference)
|
| 231 |
-
if measure_signal(reference, 24000).rms_dbfs is None:
|
| 232 |
-
message = f'reference audio is silent: {request.reference}'
|
| 233 |
-
raise ValueError(message)
|
| 234 |
-
ref_codes = self.codec.encode(reference)
|
| 235 |
-
ref_ids = self.tokenizer.encode(request.reference_text, add_special_tokens=False)
|
| 236 |
-
report: dict[str, object] = {**({**self.provenance_stamp, 'config_hash': identity} if self.provenance_stamp is not None else provenance(identity)), **identity_data, 'complete': False, 'attempts_this_run': 0, 'cached_attempts_this_run': 0, 'segments': [], 'instruments': INSTRUMENTS, 'output': str(output), 'audit': str(audit)}
|
| 237 |
-
segment_reports: list[dict[str, object]] = []
|
| 238 |
-
report['segments'] = segment_reports
|
| 239 |
-
write_json(audit, report)
|
| 240 |
-
waves: list[Waveform] = []
|
| 241 |
-
generated, cached, offset = (0, 0, 0)
|
| 242 |
-
prefix = re.match('(?:\\s*<\\|(?:emotion|style|prosody):(?!pause\\|>|long_pause\\|>)[^<>]+\\|>)*', request.text)
|
| 243 |
-
delivery = prefix.group() if prefix else ''
|
| 244 |
-
for number, text in enumerate(parts):
|
| 245 |
-
conditioning = (delivery if number else '') + text
|
| 246 |
-
prompt = prompt_layout(specials, self.tokenizer.encode(conditioning, add_special_tokens=False), ref_codes, ref_ids)
|
| 247 |
-
budget = MAX_CONTEXT_POSITIONS - prompt.positions
|
| 248 |
-
if settings.sampling.max_new_positions > budget:
|
| 249 |
-
message = f'reference/text exceeds context budget at character={offset}'
|
| 250 |
-
message += f': remaining={budget}'
|
| 251 |
-
raise ValueError(message)
|
| 252 |
-
values: list[Candidate] = []
|
| 253 |
-
paths: list[Path] = []
|
| 254 |
-
segment: dict[str, object] = {'text': text, 'char_start': offset, 'char_end': offset + len(text), 'candidates': [], 'selected': None}
|
| 255 |
-
segment_reports.append(segment)
|
| 256 |
-
for index in range(settings.candidates + settings.fallback_candidates):
|
| 257 |
-
if index >= settings.candidates and choose_candidate(values, settings) is not None:
|
| 258 |
-
break
|
| 259 |
-
active = candidate_settings(settings, text, reference.size / 24000, len(spoken_text(request.reference_text)), index)
|
| 260 |
-
initial = values[:len(settings.strategies)]
|
| 261 |
-
if settings.strategies and len(initial) == len(settings.strategies) and all((value.trace.get('terminated') is False for value in initial)):
|
| 262 |
-
active = active.model_copy(update={'decoding': Decoding(recover_loops=True)})
|
| 263 |
-
seed_index = index // max(1, len(settings.strategies))
|
| 264 |
-
seed = settings.sampling.seed + number * (settings.candidates + settings.fallback_candidates) + seed_index
|
| 265 |
-
path = root / f'segment-{number:04d}-candidate-{index:02d}.wav'
|
| 266 |
-
report.update(active_candidate=str(path), attempts_this_run=generated, cached_attempts_this_run=cached)
|
| 267 |
-
write_json(audit, report)
|
| 268 |
-
value, reused = self.candidate(text, prompt, reference, active, seed, path, identity, started + settings.timeout_s)
|
| 269 |
-
cached += int(reused)
|
| 270 |
-
generated += int(not reused)
|
| 271 |
-
logging.getLogger(__name__).info(json.dumps({'event': 'speech_candidate', 'segment': number, 'candidate': index, 'cached': reused, 'adapter_multiplier': active.adapter_multiplier, 'alignment_strength': active.decoding.alignment_strength, 'recover_loops': active.decoding.recover_loops, 'quality': value.quality.model_dump() if value.quality else None, 'error': value.error, 'elapsed_s': time.monotonic() - started}))
|
| 272 |
-
values.append(value)
|
| 273 |
-
paths.append(path)
|
| 274 |
-
segment['candidates'] = [{**item.model_dump(mode='json'), 'rejected': rejection_reasons(item.quality, settings) if item.quality else ()} for item in values]
|
| 275 |
-
report.update(attempts_this_run=generated, cached_attempts_this_run=cached)
|
| 276 |
-
write_json(audit, report)
|
| 277 |
-
chosen = choose_candidate(values, settings)
|
| 278 |
-
segment['selected'] = chosen
|
| 279 |
-
write_json(audit, report)
|
| 280 |
-
if chosen is None:
|
| 281 |
-
message = f'no candidate passed content, voice and quality limits at segment={number}'
|
| 282 |
-
message += f'; see {audit}'
|
| 283 |
-
raise RuntimeError(message)
|
| 284 |
-
waves.append(read_waveform(paths[chosen]))
|
| 285 |
-
offset += len(text)
|
| 286 |
-
joined = np.concatenate(waves).astype(np.float32)
|
| 287 |
-
if offset != len(request.text) or joined.size != sum((wave.size for wave in waves)):
|
| 288 |
-
message = 'speech text/sample conservation failed during assembly'
|
| 289 |
-
raise AssertionError(message)
|
| 290 |
-
peak = measure_signal(joined, 24000).peak
|
| 291 |
-
gain = min(1.0, 0.99 / peak) if peak else 1.0
|
| 292 |
-
save_waveform(output, np.asarray(joined * gain, dtype=np.float32))
|
| 293 |
-
report.update(complete=True, active_candidate=None, samples=joined.size, sample_rate_hz=24000, duration_s=joined.size / 24000, peak_gain=gain, output_sha256=digest_file(output), elapsed_s=time.monotonic() - started, naturalness='predicted quality only; no human rating')
|
| 294 |
-
write_json(audit, report)
|
| 295 |
-
return report
|
|
|
|
| 1 |
+
"""Produce one reference-conditioned utterance for any reference language."""
|
| 2 |
from __future__ import annotations
|
| 3 |
import hashlib
|
| 4 |
import json
|
|
|
|
| 5 |
import math
|
| 6 |
import re
|
| 7 |
import time
|
|
|
|
| 11 |
import numpy as np
|
| 12 |
import soundfile as sf
|
| 13 |
from pydantic import Field, model_validator
|
| 14 |
+
from torch import Tensor
|
| 15 |
from .adaptation import scaled_adapter
|
| 16 |
from .artifacts import atomic_binary, digest_file, provenance, run_lock, write_json
|
| 17 |
from .backbone import Generator
|
|
|
|
| 20 |
from .dataset import TextTokenizer
|
| 21 |
from .inference import MAX_CONTEXT_POSITIONS, Decoding, GenerationError, Sampling, generate_codes
|
| 22 |
from .layout import SPECIALS, SequenceLayout, prompt_layout
|
| 23 |
+
from .verification import Quality, spoken_text
|
| 24 |
from .waveforms import Waveform, measure_signal, resample_waveform
|
| 25 |
DEFAULT_INFERENCE = Path('resources/codec_tts/inference.json')
|
| 26 |
STEREO_AXES = 2
|
|
|
|
| 147 |
with atomic_binary(path) as stream:
|
| 148 |
sf.write(stream, waveform, 24000, format='WAV', subtype='FLOAT')
|
| 149 |
|
| 150 |
+
def trim_edges(audio: Waveform) -> Waveform:
|
| 151 |
+
"""Drop only the silence before the first sound and after the last."""
|
| 152 |
+
rate = 24000
|
| 153 |
+
frame = int(0.02 * rate)
|
| 154 |
+
hop = frame // 2
|
| 155 |
+
if audio.size <= frame:
|
| 156 |
+
return audio
|
| 157 |
+
count = 1 + (audio.size - frame) // hop
|
| 158 |
+
rms = np.empty(count, dtype=np.float32)
|
| 159 |
+
for index, start in enumerate(range(0, audio.size - frame + 1, hop)):
|
| 160 |
+
chunk = audio[start:start + frame]
|
| 161 |
+
rms[index] = float(np.sqrt(np.mean(np.square(chunk))))
|
| 162 |
+
threshold = max(0.005, float(np.percentile(rms, 90)) * 0.08)
|
| 163 |
+
active = np.flatnonzero(rms > threshold)
|
| 164 |
+
if active.size == 0:
|
| 165 |
+
return audio
|
| 166 |
+
margin = int(0.04 * rate)
|
| 167 |
+
first = max(0, int(active[0] * hop) - margin)
|
| 168 |
+
last = min(int(audio.size), int((active[-1] + 1) * hop + frame) + margin)
|
| 169 |
+
if last - first < frame:
|
| 170 |
+
return audio
|
| 171 |
+
return np.asarray(audio[first:last], dtype=np.float32)
|
| 172 |
+
|
| 173 |
+
def close_gaps(audio: Waveform) -> Waveform:
|
| 174 |
+
"""Shorten an internal silence longer than 320 ms to 140 ms."""
|
| 175 |
+
rate = 24000
|
| 176 |
+
frame = int(0.02 * rate)
|
| 177 |
+
hop = frame // 2
|
| 178 |
+
if audio.size <= frame:
|
| 179 |
+
return audio
|
| 180 |
+
count = 1 + (audio.size - frame) // hop
|
| 181 |
+
rms = np.empty(count, dtype=np.float32)
|
| 182 |
+
for index, start in enumerate(range(0, audio.size - frame + 1, hop)):
|
| 183 |
+
chunk = audio[start:start + frame]
|
| 184 |
+
rms[index] = float(np.sqrt(np.mean(np.square(chunk))))
|
| 185 |
+
threshold = max(0.005, float(np.percentile(rms, 90)) * 0.08)
|
| 186 |
+
silent = rms <= threshold
|
| 187 |
+
keep = int(0.14 * rate)
|
| 188 |
+
minimum = int(0.32 * rate)
|
| 189 |
+
spans: list[tuple[int, int]] = []
|
| 190 |
+
index = 0
|
| 191 |
+
while index < silent.size:
|
| 192 |
+
if not bool(silent[index]):
|
| 193 |
+
index += 1
|
| 194 |
+
continue
|
| 195 |
+
start_index = index
|
| 196 |
+
while index < silent.size and bool(silent[index]):
|
| 197 |
+
index += 1
|
| 198 |
+
start = int(start_index * hop)
|
| 199 |
+
end = min(int(audio.size), int((index - 1) * hop) + frame)
|
| 200 |
+
if start > hop and end < audio.size - hop and (end - start >= minimum):
|
| 201 |
+
spans.append((start, end))
|
| 202 |
+
if not spans:
|
| 203 |
+
return np.asarray(audio, dtype=np.float32)
|
| 204 |
+
pieces: list[Waveform] = []
|
| 205 |
+
cursor = 0
|
| 206 |
+
for start, end in spans:
|
| 207 |
+
pieces.append(np.asarray(audio[cursor:start], dtype=np.float32))
|
| 208 |
+
pieces.append(np.asarray(audio[start:start + keep], dtype=np.float32))
|
| 209 |
+
cursor = end
|
| 210 |
+
pieces.append(np.asarray(audio[cursor:], dtype=np.float32))
|
| 211 |
+
return join_segments(pieces)
|
| 212 |
+
|
| 213 |
+
def join_segments(waves: Sequence[Waveform]) -> Waveform:
|
| 214 |
+
if not waves:
|
| 215 |
+
message = 'speech assembly received no audio'
|
| 216 |
+
raise ValueError(message)
|
| 217 |
+
fade = int(0.01 * 24000)
|
| 218 |
+
joined = np.asarray(waves[0], dtype=np.float32)
|
| 219 |
+
for nxt in waves[1:]:
|
| 220 |
+
following = np.asarray(nxt, dtype=np.float32)
|
| 221 |
+
width = min(fade, joined.size // 2, following.size // 2)
|
| 222 |
+
if width < 2:
|
| 223 |
+
joined = np.concatenate((joined, following))
|
| 224 |
+
continue
|
| 225 |
+
ramp = np.linspace(0.0, 1.0, width, dtype=np.float32)
|
| 226 |
+
mixed = joined[-width:] * (1.0 - ramp) + following[:width] * ramp
|
| 227 |
+
joined = np.concatenate((joined[:-width], mixed, following[width:]))
|
| 228 |
+
return np.asarray(joined, dtype=np.float32)
|
| 229 |
+
|
| 230 |
+
def prompt_fits(tokenizer: TextTokenizer, specials: Mapping[str, int], text: str, reference_codes: Tensor, reference_ids: Sequence[int], max_new_positions: int) -> bool:
|
| 231 |
+
prompt = prompt_layout(specials, tokenizer.encode(text, add_special_tokens=False), reference_codes, reference_ids)
|
| 232 |
+
remaining = MAX_CONTEXT_POSITIONS - prompt.positions
|
| 233 |
+
return remaining >= 8 and min(max_new_positions, remaining) >= 8
|
| 234 |
+
|
| 235 |
+
def partition_text(tokenizer: TextTokenizer, specials: Mapping[str, int], text: str, reference_codes: Tensor, reference_ids: Sequence[int], max_new_positions: int) -> tuple[str, ...]:
|
| 236 |
+
if prompt_fits(tokenizer, specials, text, reference_codes, reference_ids, max_new_positions):
|
| 237 |
+
return (text,)
|
| 238 |
+
parts = split_text(text, max(32, len(text) // 2))
|
| 239 |
+
if len(parts) == 1:
|
| 240 |
+
message = 'speech text does not fit the context and has no split point'
|
| 241 |
+
raise ValueError(message)
|
| 242 |
+
return tuple((piece for part in parts for piece in partition_text(tokenizer, specials, part, reference_codes, reference_ids, max_new_positions)))
|
| 243 |
+
|
| 244 |
+
def speech_identity(request: SpeechRequest, settings: InferenceSettings, reference_sha256: str) -> str:
|
| 245 |
+
payload = {'request': request.model_dump(mode='json'), 'sampling': settings.sampling.model_dump(), 'decoding': settings.decoding.model_dump(), 'adapter_multiplier': settings.adapter_multiplier, 'reference_sha256': reference_sha256}
|
| 246 |
+
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|
| 247 |
+
|
| 248 |
+
def render_speech(model: Generator, codec: Codec, tokenizer: TextTokenizer, request: SpeechRequest, settings: InferenceSettings, output: Path, provenance_stamp: Mapping[str, object] | None=None) -> dict[str, object]:
|
| 249 |
+
"""Sample one utterance. The reference language does not change the decode."""
|
| 250 |
+
started = time.monotonic()
|
| 251 |
+
spoken_text(request.reference_text)
|
| 252 |
+
specials = tokenizer.get_added_vocab()
|
| 253 |
+
for tag in re.findall('<\\|[^<>]*\\|>', request.text):
|
| 254 |
+
if tag not in specials or re.fullmatch('<\\|(?:emotion|style|prosody|sfx):[a-z_]+\\|>', tag) is None:
|
| 255 |
+
message = f'unsupported delivery tag {tag!r}'
|
| 256 |
+
raise ValueError(message)
|
| 257 |
+
reference_sha = digest_file(request.reference)
|
| 258 |
+
identity = speech_identity(request, settings, reference_sha)
|
| 259 |
+
audit = output.with_suffix('.stats.json')
|
| 260 |
+
with run_lock(output.parent):
|
| 261 |
+
if audit.is_file():
|
| 262 |
+
previous = json.loads(audit.read_bytes())
|
| 263 |
+
if previous.get('identity') == identity and previous.get('complete') is True:
|
| 264 |
+
if not output.is_file() or digest_file(output) != previous.get('output_sha256'):
|
| 265 |
+
message = f'checksum mismatch at {output}'
|
| 266 |
+
raise ValueError(message)
|
| 267 |
+
return dict(previous)
|
| 268 |
+
if previous.get('identity') not in (None, identity):
|
| 269 |
+
message = f'different speech request already occupies {output}'
|
| 270 |
+
raise FileExistsError(message)
|
| 271 |
+
reference = read_waveform(request.reference)
|
| 272 |
+
if measure_signal(reference, 24000).rms_dbfs is None:
|
| 273 |
+
message = f'reference audio is silent: {request.reference}'
|
| 274 |
+
raise ValueError(message)
|
| 275 |
+
reference_codes = codec.encode(reference)
|
| 276 |
+
reference_ids = tokenizer.encode(request.reference_text, add_special_tokens=False)
|
| 277 |
+
parts = partition_text(tokenizer, specials, request.text, reference_codes, reference_ids, settings.sampling.max_new_positions)
|
| 278 |
+
prefix = re.match('(?:\\s*<\\|(?:emotion|style|prosody):(?!pause\\|>|long_pause\\|>)[^<>]+\\|>)*', request.text)
|
| 279 |
+
delivery = prefix.group() if prefix else ''
|
| 280 |
+
decoding = settings.decoding.model_copy(update={'alignment_strength': 0, 'recover_loops': True})
|
| 281 |
+
waves: list[Waveform] = []
|
| 282 |
+
segments: list[dict[str, object]] = []
|
| 283 |
+
for number, text in enumerate(parts):
|
| 284 |
+
conditioning = (delivery if number else '') + text
|
| 285 |
+
prompt = prompt_layout(specials, tokenizer.encode(conditioning, add_special_tokens=False), reference_codes, reference_ids)
|
| 286 |
+
remaining = MAX_CONTEXT_POSITIONS - prompt.positions
|
| 287 |
+
positions = min(settings.sampling.max_new_positions, remaining)
|
| 288 |
+
sampling = settings.sampling.model_copy(update={'max_new_positions': positions, 'seed': settings.sampling.seed + number})
|
| 289 |
+
with scaled_adapter(model, settings.adapter_multiplier):
|
| 290 |
+
codes, trace = generate_codes(model, prompt, sampling, decoding, None)
|
| 291 |
+
waves.append(np.asarray(codec.decode(codes).numpy(), dtype=np.float32))
|
| 292 |
+
segments.append({'text': text, 'seed': sampling.seed, **trace})
|
| 293 |
+
joined = close_gaps(trim_edges(join_segments(waves)))
|
| 294 |
+
peak = measure_signal(joined, 24000).peak
|
| 295 |
+
gain = min(1.0, 0.99 / peak) if peak else 1.0
|
| 296 |
+
save_waveform(output, np.asarray(joined * gain, dtype=np.float32))
|
| 297 |
+
report: dict[str, object] = {**({**provenance_stamp, 'config_hash': identity} if provenance_stamp is not None else provenance(identity)), 'identity': identity, 'complete': True, 'segments': segments, 'samples': joined.size, 'sample_rate_hz': 24000, 'duration_s': joined.size / 24000, 'peak_gain': gain, 'output': str(output), 'output_sha256': digest_file(output), 'elapsed_s': time.monotonic() - started, 'adapter_multiplier': settings.adapter_multiplier, 'sampling': settings.sampling.model_dump(), 'decoding': decoding.model_dump()}
|
| 298 |
+
write_json(audit, report)
|
| 299 |
+
return report
|
| 300 |
+
|
| 301 |
def contrast_layout(prompt: SequenceLayout, specials: Mapping[str, int]) -> SequenceLayout:
|
| 302 |
keep = prompt.audio_mask.clone()
|
| 303 |
for token in SPECIALS:
|
|
|
|
| 357 |
return (value, False)
|
| 358 |
|
| 359 |
def synthesize(self, request: SpeechRequest, settings: InferenceSettings, output: Path) -> dict[str, object]:
|
| 360 |
+
return render_speech(self.model, self.codec, self.tokenizer, request, settings, output, provenance_stamp=self.provenance_stamp)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|