ainouche-abderahmane commited on
Commit
1694b40
·
verified ·
1 Parent(s): 48df07c

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. README.md +11 -15
  2. config.json +10 -47
  3. export.stats.json +1 -1
  4. modeling_speech.py +2 -9
  5. synthesis.py +155 -90
README.md CHANGED
@@ -21,15 +21,13 @@ tags:
21
 
22
  # Matoub-4B
23
 
24
- A 4B-parameter neural speech synthesis and voice cloning foundation model for **Kabyle** (Taqbaylit, `kab`, Latin script), trained on 384,960 curated speech pairs from Common Voice Kabyle. Given Kabyle text and a short reference recording with its transcript, it synthesises broadcast-quality **24 kHz audio** that reproduces the vocal timbre, prosodic contours, and acoustic presence of the target speaker.
25
 
26
- Matoub-4B brings zero-shot, reference-conditioned voice cloning to Kabyle, supporting both native and cross-lingual cloning out of the box. A speaker recorded in English, French, Arabic, or any other supported language can speak fluent Taqbaylit with their natural vocal identity and expressive dynamics preserved. Generating speech across an 8-codebook discrete audio representation, the model faithfully captures Kabyle's rich phonology — including spirantised, pharyngealised, emphatic, and geminated consonants.
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
- - The generation pipeline can verify candidate outputs for transcript accuracy (Fadhma-300M CER), speaker similarity (WeSpeaker cosine), and audio quality (TorchAudio SQUIM) before returning the final waveform. Longer texts are handled via automatic sentence segmentation. All inference and sampling parameters are configured in `model.config.inference`.
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 each
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 with segment-level candidate scores, selection and verification results |
112
 
113
- The engine evaluates CER, speaker cosine and predicted SQUIM quality when
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 with merged LoRA adaptation and audio codec |
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). The supporting evaluation models ([Fadhma-300M](https://huggingface.co/agbalu/Fadhma-300M), [WeSpeaker](https://huggingface.co/Wespeaker/wespeaker-voxceleb-resnet34-LM)) retain their respective licences (Apache-2.0 and CC BY 4.0).
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
- "adapter_multiplier": 1.0,
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
- "top_p": 0.8
 
175
  },
176
- "strategies": [
177
- {
178
- "adapter_multiplier": 1.0,
179
- "decoding": {
180
- "alignment_strength": 0.0,
181
- "plausible_tokens": 25,
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": "47c6bd4e2bf8239229bd637d96bd3a2037a2555da82dbdde53b5a629707966c8", "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": "47c6bd4e2bf8239229bd637d96bd3a2037a2555da82dbdde53b5a629707966c8", "STAMP.json": "0d9ae3d170c158bacb7f5c9b407d1633460cf858105b297c9a2180fb01ca68ed", "adaptation.py": "3dceb0d9450775e21f0375781b102324d22c924e0a05e6599f87bb3b631da51a", "artifacts.py": "f7a68f94e56ee3e9394bc385215cb71d7fde9f5b5c14f40cfd3189006cf8479e", "audio.py": "1ca1c4984a6d43ae9137d15b7a3a1d9f300a868aaa8fa3b9369e30ab95edffeb", "backbone.py": "e31eff44ba894c4bfd332e35040b8f84be043a8dc136c090a423ccc664276b3f", "chat_template.jinja": "44d5f08f3f72b837eaad09f13a54c1f9f4eb58d75240334548b7fd52a5437fa5", "codec.py": "b65875c630d6b6e135510bbe1f761ad93d6c2e697c857ee30ef1845c73f1f894", "config.json": "b24120cd7d375136da3cab68478649d1b74c6ee900aa2513a49be4e1ff96eb39", "configuration.py": "1c54db62d2a97c0da43c9fbcfeb0698b9550325006304ff701e8b1a26cf1e857", "dataset.py": "afcd7b2f19ef7b479e4661ec0efd56138220b466605478e93d014e60f07db6c2", "evaluation.py": "364a7049804998d4857618f1a9061493f8b644f59e79be1466244b4f0fd6b74f", "inference.py": "db80183c086f071e290ef460c957a7e2546d9ba9fe6ae95b239562cc2ffef962", "layout.py": "34b4069f74cee213209101cc0cda3fc0fc2aa2e64db722321aba7e6c883298fb", "model.safetensors": "37c391ff7178ae6331ccbe1ab18bd9cd1f1fbdb8934a3d0021ffcc586f8ca4dd", "modeling_speech.py": "70a37da485d02dc2c1b4670f9bf9e8bf159531146aa35bb6d3fb90b88c1126b0", "objective.py": "5c72520a55bc0a698decf8f316a2172668a5f505eb720957ce4789679f2729c9", "pairs.py": "bb2dfe95b555bff7cf2ccadd3d62f524927dc4e9c658507175f595a2e4218216", "partition.py": "df3fd82e96330ee139c663bf88c383b0a2e7b714cd54ff95048b1cc898cfe619", "progress.py": "9c766bb4e107c63232f3a5abfd2debabe7c8bf6dabd806e00bbe6a62eb68934e", "provenance.py": "278abb15bd21575f409b7d55dcd1a0dc12526f81a9b90e6cc6de96db7543c236", "requirements.txt": "e56142d5c4b77b45b41e16c7d1edc6046491ed5d384e82537bc0a5ba3ac6c819", "synthesis.py": "76a377e8a193e218584206fc3fbfc6a11b2e7d0113fa9687bb51420cef4e53ac", "tokenizer.json": "eb883de2de5adc5113f1f02b54830a0ea7cd6ef191cde65c41aceb3737d4d1c1", "tokenizer_config.json": "b4d632e1239569fb1829bf0bfa3c674fa54f22c42e9cb2669d77c438d4b4e02c", "verification.py": "3df87f44f13411e2f9ce1cb3ce3c6c6490ed6c31eb6a6e956d823c84e1c2f7a2", "waveforms.py": "248c58c7818c8d258ac40fd0c0e98bc8fa607603f0c62e5da5fb413177ce7460"}, "git_sha": "4c0e119b42518dafee8be80d10d8dfdb13090295", "kind": "bf16", "source_weights_sha256": "37c391ff7178ae6331ccbe1ab18bd9cd1f1fbdb8934a3d0021ffcc586f8ca4dd", "version": 1, "version_tag": null, "weights_sha256": "37c391ff7178ae6331ccbe1ab18bd9cd1f1fbdb8934a3d0021ffcc586f8ca4dd"}
 
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, SpeechSynthesis, read_waveform
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 = engine.synthesize(request, settings, destination)
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 verified reference-conditioned speech with resumable candidate selection."""
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 INSTRUMENTS, 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,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
- started = time.monotonic()
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)