oddadmix commited on
Commit
53bf94f
ยท
verified ยท
1 Parent(s): 5a3ccad

Saudi dialect voice, trained on saudi-v2 (146 h)

Browse files
README.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language: [ar]
4
+ pipeline_tag: text-to-speech
5
+ tags: [tts, arabic, saudi, dialect, kokoro, styletts2, distillation, on-device]
6
+ ---
7
+
8
+ # Nabra-Saudi-7M
9
+
10
+ **Saudi-dialect Arabic text to speech in 7.48M parameters, 28.7 MB at full precision.** Distilled
11
+ from [Nabra-Saudi-82M](https://huggingface.co/oddadmix/Nabra-Saudi-82M), which is 11x larger.
12
+ Small enough to run on a phone, entirely offline.
13
+
14
+ | | |
15
+ |---|---|
16
+ | parameters | 7,477,702 |
17
+ | model file | 28.7 MB (fp32) |
18
+ | sample rate | 24 kHz |
19
+ | language | Arabic, Saudi dialect |
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from load_model import load
25
+ model, pipeline, voice = load()
26
+ audio = next(pipeline("ูŠุง ุฃู‡ู„ุงู‹ ูˆุณู‡ู„ุงู‹ุŒ ูƒูŠู ุญุงู„ูƒู…ุŸ", voice=voice))[2] # 24 kHz
27
+ ```
28
+
29
+ **Write dialect text as it is normally written, without full tashkeel.** Adding MSA diacritics
30
+ makes it worse: an MSA diacritizer imposes case endings a dialect speaker does not pronounce.
31
+
32
+ ## Why it was distilled rather than trained directly
33
+
34
+ Training a model this size directly on dialect recordings was tried first and did not work: with
35
+ durations from a forced aligner, the student inherits whatever error the aligner made, and no
36
+ change of architecture, sample rate or tashkeel policy recovered it.
37
+
38
+ Distillation removes the problem. The 82M teacher emits the per phoneme durations that generated
39
+ its own audio, so the student trains against an alignment that is correct by construction. The
40
+ same recipe produced [Nabra-7M-Distill](https://huggingface.co/oddadmix/Nabra-7M-Distill) and
41
+ [Kokoro-7M-Distill](https://huggingface.co/oddadmix/Kokoro-7M-Distill).
42
+
43
+ Objective: multi resolution STFT, duration L1, log mel L1, a silence term, and WavLM feature
44
+ matching, with multi period and multi resolution spectrogram discriminators. The silence term
45
+ stops the small decoder floating on a noise floor the teacher does not have; the WavLM term is
46
+ the only thing constraining phase, and without it the output stays subtly robotic.
47
+
48
+ ## Limitations
49
+
50
+ Single voice, Saudi dialect. Short sentences are noticeably stronger than long spontaneous
51
+ passages. Not intended for Modern Standard Arabic.
af_msa.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9324145629d870a899078ef0a70eabc0706c219ccbf002fa6a18278a201c9664
3
+ size 523887
arabic_g2p.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Arabic (MSA) G2P front-end for the Kokoro / StyleTTS2 pipeline
4
+ ==============================================================
5
+ Shared by prepare_dataset.py and prepare_training.py so the textโ†’phoneme
6
+ mapping is byte-for-byte identical for training transcripts, OOD texts, and
7
+ (by extension) inference. Keeping this in one place is what guarantees the
8
+ model is trained and queried with the same symbol set.
9
+
10
+ Per-sentence pipeline:
11
+ 1. normalize_text drop Latin-script loanwords (logged) + tidy whitespace
12
+ 2. diacritize restore MSA short vowels with camel-tools (optional)
13
+ 3. espeak-ng G2P misaki EspeakG2P(language="ar")
14
+ 4. clean_phonemes strip espeak syllable-boundary '.', apply vocab fixups
15
+
16
+ Why each step exists:
17
+ - espeak-ng's Arabic G2P guesses short vowels for unvowelized text, badly.
18
+ A real MSA diacritizer in front of it removes most of that guessing.
19
+ - espeak emits '.' as a *syllable boundary* mid-word (e.g. ส”arrหˆa.สคul), but
20
+ Kokoro treats '.' as a pause token, so those dots inject phantom pauses.
21
+ - A handful of espeak phonemes fall outside Kokoro's 178-token vocab. ส• (ุน)
22
+ and ฤง (ุญ) get dedicated embedding slots (EXTRA_SYMBOLS) so the MSA contrasts
23
+ ุน/ุก and ุญ/ู‡ survive; the rest collapse to their nearest in-vocab symbol.
24
+ """
25
+
26
+ import re
27
+
28
+ # โ”€โ”€ Out-of-vocab phonemes remapped to an in-vocab symbol โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
29
+ # espeak emits a few Arabic phonemes that are not in Kokoro's 178-token vocab.
30
+ # These get folded into their nearest in-vocab neighbour. NOTE: ส• and ฤง are
31
+ # intentionally absent here โ€” they are preserved via EXTRA_SYMBOLS below.
32
+ # Emphatics arrive as base + a pharyngealization/dental marker; stripping the
33
+ # marker collapses them onto the plain base consonant (sหคโ†’s, tหคโ†’t, โ€ฆ).
34
+ ARABIC_PHONEME_FIXUPS = {
35
+ "ฬช": "", # โ—Œฬช combining bridge below (dental) โ†’ strip
36
+ "หค": "", # หค pharyngealization marker โ†’ strip
37
+ "[": "", # espeak untranslatable-token bracket โ†’ strip
38
+ "]": "", # espeak untranslatable-token bracket โ†’ strip
39
+ "{": "", # espeak parenthetical/markup bracket โ†’ strip
40
+ "}": "", # espeak parenthetical/markup bracket โ†’ strip
41
+ }
42
+
43
+ # โ”€โ”€ Out-of-vocab phonemes KEPT via free Kokoro embedding slots โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
44
+ # Kokoro's 178-token table has 63 unused (gap) indices. We park the two most
45
+ # important MSA pharyngeals on stable gap slots so they get their own embedding
46
+ # (untrained in Kokoro โ†’ learned during fine-tuning) instead of being merged
47
+ # into ส” / h. prepare_training.py `patch-styletts2` writes these into the
48
+ # symbols list and `verify` treats them as known. Indices 7 & 8 are confirmed
49
+ # free in Kokoro-82M config.json.
50
+ EXTRA_SYMBOLS = {
51
+ "ส•": 7, # ุน voiced pharyngeal fricative (distinct from ุก/ส”)
52
+ "ฤง": 8, # ุญ voiceless pharyngeal fricative (distinct from ู‡/h)
53
+ }
54
+
55
+ # espeak emits '.' as a syllable boundary mid-word; strip only those (a phoneme
56
+ # char on both sides) and keep a sentence-final '.' for prosody.
57
+ _SYLLABLE_DOT = re.compile(r"(?<=\S)\.(?=\S)")
58
+ # Runs of Latin letters = loanwords espeak would mangle; dropped + counted.
59
+ _LATIN_RUN = re.compile(r"[A-Za-z]+")
60
+ # Bracketed citation/footnote markers in encyclopedic text โ€” e.g. [123],
61
+ # [132][133], [ูุฑ]. A narrator does not voice these, so dropping them keeps the
62
+ # phonemes aligned with the audio. (Square brackets only; parentheses are kept.)
63
+ _CITATION = re.compile(r"\[[^\]]*\]")
64
+ _WS = re.compile(r"\s+")
65
+
66
+ # Arabic tashkeel (harakat, tanwin, shadda, sukun, dagger alif) and base letters.
67
+ # Used to detect text the user already diacritized so we don't overwrite it.
68
+ _TASHKEEL = re.compile("[ู‹-ู’ูฐ]")
69
+ _ARABIC_LETTER = re.compile("[ุก-ูŠ]")
70
+
71
+
72
+ def is_diacritized(text: str, min_ratio: float = 0.2) -> bool:
73
+ """True if the text already carries enough tashkeel to trust and use as-is."""
74
+ letters = len(_ARABIC_LETTER.findall(text))
75
+ return letters > 0 and len(_TASHKEEL.findall(text)) / letters >= min_ratio
76
+
77
+
78
+ def normalize_text(text: str) -> tuple[str, int]:
79
+ """Drop citation markers + Latin-script loanwords and tidy whitespace.
80
+
81
+ Returns (cleaned_text, n_latin_runs_dropped).
82
+ """
83
+ text = _CITATION.sub(" ", text)
84
+ latin = _LATIN_RUN.findall(text)
85
+ if latin:
86
+ text = _LATIN_RUN.sub(" ", text)
87
+ text = _WS.sub(" ", text).strip()
88
+ return text, len(latin)
89
+
90
+
91
+ def clean_phonemes(ph: str) -> str:
92
+ """Strip espeak syllable-boundary dots and remap out-of-vocab phonemes."""
93
+ ph = _SYLLABLE_DOT.sub("", ph)
94
+ for old, new in ARABIC_PHONEME_FIXUPS.items():
95
+ ph = ph.replace(old, new)
96
+ return ph
97
+
98
+
99
+ class ArabicG2P:
100
+ """Text โ†’ IPA phonemes for MSA, with optional camel-tools diacritization.
101
+
102
+ Stateful counters (latin_dropped, diac_fallbacks) accumulate across calls
103
+ so the caller can print a single summary at the end.
104
+ """
105
+
106
+ def __init__(self, diacritize: bool = True):
107
+ from misaki import espeak
108
+
109
+ self._g2p = espeak.EspeakG2P(language="ar")
110
+ self._diac_enabled = diacritize
111
+ self._mle = None
112
+ self.latin_dropped = 0
113
+ self.diac_fallbacks = 0
114
+ self.diac_available = False
115
+ if diacritize:
116
+ self._init_diacritizer()
117
+
118
+ def _init_diacritizer(self):
119
+ try:
120
+ from camel_tools.disambig.mle import MLEDisambiguator
121
+
122
+ self._mle = MLEDisambiguator.pretrained()
123
+ self.diac_available = True
124
+ print("Diacritizer: camel-tools MLE (calima-msa-r13) loaded.")
125
+ except Exception as e:
126
+ print(f"WARNING: camel-tools diacritizer unavailable ({e}).")
127
+ print(" Falling back to RAW espeak (it will guess short vowels).")
128
+ print(" To enable: pip install camel-tools && "
129
+ "camel_data -i disambig-mle-calima-msa-r13")
130
+ self._mle = None
131
+
132
+ def diacritize(self, text: str) -> str:
133
+ """Restore MSA short vowels โ€” unless the text is already diacritized."""
134
+ # Respect user-provided tashkeel: re-diacritizing could change it and
135
+ # desync it from what the user intended.
136
+ if is_diacritized(text):
137
+ return text
138
+ if self._mle is None:
139
+ return text
140
+ from camel_tools.tokenizers.word import simple_word_tokenize
141
+
142
+ try:
143
+ tokens = simple_word_tokenize(text)
144
+ if not tokens:
145
+ return text
146
+ out = []
147
+ for d in self._mle.disambiguate(tokens):
148
+ if d.analyses:
149
+ out.append(d.analyses[0].analysis.get("diac", d.word))
150
+ else:
151
+ out.append(d.word)
152
+ return " ".join(out)
153
+ except Exception:
154
+ self.diac_fallbacks += 1
155
+ return text
156
+
157
+ def process(self, raw_text: str) -> tuple[str, str]:
158
+ """Full pipeline. Returns (display_text, phonemes).
159
+
160
+ display_text is the Latin-stripped (but NOT diacritized) text, suitable
161
+ for metadata.csv; phonemes are derived from the diacritized form.
162
+ """
163
+ text, n_latin = normalize_text(raw_text)
164
+ self.latin_dropped += n_latin
165
+ diac = self.diacritize(text) if self._diac_enabled else text
166
+ ph, _ = self._g2p(diac)
167
+ return text, clean_phonemes(ph)
168
+
169
+ def __call__(self, raw_text: str) -> str:
170
+ """Convenience: return just the phonemes."""
171
+ return self.process(raw_text)[1]
172
+
173
+ def summary(self) -> str:
174
+ bits = [f"Latin runs dropped: {self.latin_dropped:,}"]
175
+ if self._diac_enabled:
176
+ bits.append(
177
+ "diacritizer: "
178
+ + ("on" if self.diac_available else "UNAVAILABLE (raw espeak)")
179
+ )
180
+ if self.diac_fallbacks:
181
+ bits.append(f"diac fallbacks: {self.diac_fallbacks:,}")
182
+ else:
183
+ bits.append("diacritizer: off")
184
+ return " | ".join(bits)
config.json ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "istftnet": {
3
+ "upsample_kernel_sizes": [
4
+ 20,
5
+ 12
6
+ ],
7
+ "upsample_rates": [
8
+ 10,
9
+ 6
10
+ ],
11
+ "gen_istft_hop_size": 5,
12
+ "gen_istft_n_fft": 20,
13
+ "resblock_dilation_sizes": [
14
+ [
15
+ 1,
16
+ 3,
17
+ 5
18
+ ],
19
+ [
20
+ 1,
21
+ 3,
22
+ 5
23
+ ]
24
+ ],
25
+ "resblock_kernel_sizes": [
26
+ 3,
27
+ 7
28
+ ],
29
+ "upsample_initial_channel": 128,
30
+ "hidden_channels": 256,
31
+ "out_channels": 128
32
+ },
33
+ "dim_in": 64,
34
+ "dropout": 0.2,
35
+ "hidden_dim": 160,
36
+ "max_conv_dim": 512,
37
+ "max_dur": 50,
38
+ "multispeaker": true,
39
+ "n_layer": 3,
40
+ "n_mels": 80,
41
+ "n_token": 178,
42
+ "style_dim": 128,
43
+ "text_encoder_kernel_size": 5,
44
+ "plbert": {
45
+ "hidden_size": 192,
46
+ "num_attention_heads": 3,
47
+ "intermediate_size": 768,
48
+ "max_position_embeddings": 512,
49
+ "num_hidden_layers": 12,
50
+ "dropout": 0.1
51
+ },
52
+ "vocab": {
53
+ ";": 1,
54
+ ":": 2,
55
+ ",": 3,
56
+ ".": 4,
57
+ "!": 5,
58
+ "?": 6,
59
+ "โ€”": 9,
60
+ "โ€ฆ": 10,
61
+ "\"": 11,
62
+ "(": 12,
63
+ ")": 13,
64
+ "โ€œ": 14,
65
+ "โ€": 15,
66
+ " ": 16,
67
+ "ฬƒ": 17,
68
+ "สฃ": 18,
69
+ "สฅ": 19,
70
+ "สฆ": 20,
71
+ "สจ": 21,
72
+ "แต": 22,
73
+ "๊ญง": 23,
74
+ "A": 24,
75
+ "I": 25,
76
+ "O": 31,
77
+ "Q": 33,
78
+ "S": 35,
79
+ "T": 36,
80
+ "W": 39,
81
+ "Y": 41,
82
+ "แตŠ": 42,
83
+ "a": 43,
84
+ "b": 44,
85
+ "c": 45,
86
+ "d": 46,
87
+ "e": 47,
88
+ "f": 48,
89
+ "h": 50,
90
+ "i": 51,
91
+ "j": 52,
92
+ "k": 53,
93
+ "l": 54,
94
+ "m": 55,
95
+ "n": 56,
96
+ "o": 57,
97
+ "p": 58,
98
+ "q": 59,
99
+ "r": 60,
100
+ "s": 61,
101
+ "t": 62,
102
+ "u": 63,
103
+ "v": 64,
104
+ "w": 65,
105
+ "x": 66,
106
+ "y": 67,
107
+ "z": 68,
108
+ "ษ‘": 69,
109
+ "ษ": 70,
110
+ "ษ’": 71,
111
+ "รฆ": 72,
112
+ "ฮฒ": 75,
113
+ "ษ”": 76,
114
+ "ษ•": 77,
115
+ "รง": 78,
116
+ "ษ–": 80,
117
+ "รฐ": 81,
118
+ "สค": 82,
119
+ "ษ™": 83,
120
+ "ษš": 85,
121
+ "ษ›": 86,
122
+ "ษœ": 87,
123
+ "ษŸ": 90,
124
+ "ษก": 92,
125
+ "ษฅ": 99,
126
+ "ษจ": 101,
127
+ "ษช": 102,
128
+ "ส": 103,
129
+ "ษฏ": 110,
130
+ "ษฐ": 111,
131
+ "ล‹": 112,
132
+ "ษณ": 113,
133
+ "ษฒ": 114,
134
+ "ษด": 115,
135
+ "รธ": 116,
136
+ "ษธ": 118,
137
+ "ฮธ": 119,
138
+ "ล“": 120,
139
+ "ษน": 123,
140
+ "ษพ": 125,
141
+ "ษป": 126,
142
+ "ส": 128,
143
+ "ษฝ": 129,
144
+ "ส‚": 130,
145
+ "สƒ": 131,
146
+ "สˆ": 132,
147
+ "สง": 133,
148
+ "สŠ": 135,
149
+ "ส‹": 136,
150
+ "สŒ": 138,
151
+ "ษฃ": 139,
152
+ "ษค": 140,
153
+ "ฯ‡": 142,
154
+ "สŽ": 143,
155
+ "ส’": 147,
156
+ "ส”": 148,
157
+ "หˆ": 156,
158
+ "หŒ": 157,
159
+ "ห": 158,
160
+ "สฐ": 162,
161
+ "สฒ": 164,
162
+ "โ†“": 169,
163
+ "โ†’": 171,
164
+ "โ†—": 172,
165
+ "โ†˜": 173,
166
+ "แตป": 177
167
+ }
168
+ }
kokoro_patched/kokoro/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __version__ = '0.9.4'
2
+
3
+ from loguru import logger
4
+ import sys
5
+
6
+ # Remove default handler
7
+ logger.remove()
8
+
9
+ # Add custom handler with clean format including module and line number
10
+ logger.add(
11
+ sys.stderr,
12
+ format="<green>{time:HH:mm:ss}</green> | <cyan>{module:>16}:{line}</cyan> | <level>{level: >8}</level> | <level>{message}</level>",
13
+ colorize=True,
14
+ level="INFO" # "DEBUG" to enable logger.debug("message") and up prints
15
+ # "ERROR" to enable only logger.error("message") prints
16
+ # etc
17
+ )
18
+
19
+ # Disable before release or as needed
20
+ logger.disable("kokoro")
21
+
22
+ from .model import KModel
23
+ from .pipeline import KPipeline
kokoro_patched/kokoro/__main__.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kokoro TTS CLI
2
+ Example usage:
3
+ python3 -m kokoro --text "The sky above the port was the color of television, tuned to a dead channel." -o file.wav --debug
4
+
5
+ echo "Bom dia mundo, como vรฃo vocรชs" > text.txt
6
+ python3 -m kokoro -i text.txt -l p --voice pm_alex > audio.wav
7
+
8
+ Common issues:
9
+ pip not installed: `uv pip install pip`
10
+ (Temporary workaround while https://github.com/explosion/spaCy/issues/13747 is not fixed)
11
+
12
+ espeak not installed: `apt-get install espeak-ng`
13
+ """
14
+
15
+ import argparse
16
+ import wave
17
+ from pathlib import Path
18
+ from typing import Generator, TYPE_CHECKING
19
+
20
+ import numpy as np
21
+ from loguru import logger
22
+
23
+ languages = [
24
+ "a", # American English
25
+ "b", # British English
26
+ "d", # German
27
+ "h", # Hindi
28
+ "e", # Spanish
29
+ "f", # French
30
+ "i", # Italian
31
+ "p", # Brazilian Portuguese
32
+ "j", # Japanese
33
+ "z", # Mandarin Chinese
34
+ ]
35
+
36
+ if TYPE_CHECKING:
37
+ from kokoro import KPipeline
38
+
39
+
40
+ def generate_audio(
41
+ text: str, kokoro_language: str, voice: str, speed=1
42
+ ) -> Generator["KPipeline.Result", None, None]:
43
+ from kokoro import KPipeline
44
+
45
+ if not voice.startswith(kokoro_language):
46
+ logger.warning(f"Voice {voice} is not made for language {kokoro_language}")
47
+ pipeline = KPipeline(lang_code=kokoro_language)
48
+ yield from pipeline(text, voice=voice, speed=speed, split_pattern=r"\n+")
49
+
50
+
51
+ def generate_and_save_audio(
52
+ output_file: Path, text: str, kokoro_language: str, voice: str, speed=1
53
+ ) -> None:
54
+ with wave.open(str(output_file.resolve()), "wb") as wav_file:
55
+ wav_file.setnchannels(1) # Mono audio
56
+ wav_file.setsampwidth(2) # 2 bytes per sample (16-bit audio)
57
+ wav_file.setframerate(24000) # Sample rate
58
+
59
+ for result in generate_audio(
60
+ text, kokoro_language=kokoro_language, voice=voice, speed=speed
61
+ ):
62
+ logger.debug(result.phonemes)
63
+ if result.audio is None:
64
+ continue
65
+ audio_bytes = (result.audio.numpy() * 32767).astype(np.int16).tobytes()
66
+ wav_file.writeframes(audio_bytes)
67
+
68
+
69
+ def main() -> None:
70
+ parser = argparse.ArgumentParser()
71
+ parser.add_argument(
72
+ "-m",
73
+ "--voice",
74
+ default="af_heart",
75
+ help="Voice to use",
76
+ )
77
+ parser.add_argument(
78
+ "-l",
79
+ "--language",
80
+ help="Language to use (defaults to the one corresponding to the voice)",
81
+ choices=languages,
82
+ )
83
+ parser.add_argument(
84
+ "-o",
85
+ "--output-file",
86
+ "--output_file",
87
+ type=Path,
88
+ help="Path to output WAV file",
89
+ required=True,
90
+ )
91
+ parser.add_argument(
92
+ "-i",
93
+ "--input-file",
94
+ "--input_file",
95
+ type=Path,
96
+ help="Path to input text file (default: stdin)",
97
+ )
98
+ parser.add_argument(
99
+ "-t",
100
+ "--text",
101
+ help="Text to use instead of reading from stdin",
102
+ )
103
+ parser.add_argument(
104
+ "-s",
105
+ "--speed",
106
+ type=float,
107
+ default=1.0,
108
+ help="Speech speed",
109
+ )
110
+ parser.add_argument(
111
+ "--debug",
112
+ action="store_true",
113
+ help="Print DEBUG messages to console",
114
+ )
115
+ args = parser.parse_args()
116
+ if args.debug:
117
+ logger.level("DEBUG")
118
+ logger.debug(args)
119
+
120
+ lang = args.language or args.voice[0]
121
+
122
+ if args.text is not None and args.input_file is not None:
123
+ raise Exception("You cannot specify both 'text' and 'input_file'")
124
+ elif args.text:
125
+ text = args.text
126
+ elif args.input_file:
127
+ file: Path = args.input_file
128
+ text = file.read_text()
129
+ else:
130
+ import sys
131
+ print("Press Ctrl+D to stop reading input and start generating", flush=True)
132
+ text = '\n'.join(sys.stdin)
133
+
134
+ logger.debug(f"Input text: {text!r}")
135
+
136
+ out_file: Path = args.output_file
137
+ if not out_file.suffix == ".wav":
138
+ logger.warning("The output file name should end with .wav")
139
+ generate_and_save_audio(
140
+ output_file=out_file,
141
+ text=text,
142
+ kokoro_language=lang,
143
+ voice=args.voice,
144
+ speed=args.speed,
145
+ )
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
kokoro_patched/kokoro/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (732 Bytes). View file
 
kokoro_patched/kokoro/__pycache__/custom_stft.cpython-312.pyc ADDED
Binary file (7.2 kB). View file
 
kokoro_patched/kokoro/__pycache__/istftnet.cpython-312.pyc ADDED
Binary file (28 kB). View file
 
kokoro_patched/kokoro/__pycache__/model.cpython-312.pyc ADDED
Binary file (11.3 kB). View file
 
kokoro_patched/kokoro/__pycache__/modules.cpython-312.pyc ADDED
Binary file (15.1 kB). View file
 
kokoro_patched/kokoro/__pycache__/pipeline.cpython-312.pyc ADDED
Binary file (23.1 kB). View file
 
kokoro_patched/kokoro/custom_stft.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from attr import attr
2
+ import numpy as np
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+
7
+ class CustomSTFT(nn.Module):
8
+ """
9
+ STFT/iSTFT without unfold/complex ops, using conv1d and conv_transpose1d.
10
+
11
+ - forward STFT => Real-part conv1d + Imag-part conv1d
12
+ - inverse STFT => Real-part conv_transpose1d + Imag-part conv_transpose1d + sum
13
+ - avoids F.unfold, so easier to export to ONNX
14
+ - uses replicate or constant padding for 'center=True' to approximate 'reflect'
15
+ (reflect is not supported for dynamic shapes in ONNX)
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ filter_length=800,
21
+ hop_length=200,
22
+ win_length=800,
23
+ window="hann",
24
+ center=True,
25
+ pad_mode="replicate", # or 'constant'
26
+ ):
27
+ super().__init__()
28
+ self.filter_length = filter_length
29
+ self.hop_length = hop_length
30
+ self.win_length = win_length
31
+ self.n_fft = filter_length
32
+ self.center = center
33
+ self.pad_mode = pad_mode
34
+
35
+ # Number of frequency bins for real-valued STFT with onesided=True
36
+ self.freq_bins = self.n_fft // 2 + 1
37
+
38
+ # Build window
39
+ assert window == 'hann', window
40
+ window_tensor = torch.hann_window(win_length, periodic=True, dtype=torch.float32)
41
+ if self.win_length < self.n_fft:
42
+ # Zero-pad up to n_fft
43
+ extra = self.n_fft - self.win_length
44
+ window_tensor = F.pad(window_tensor, (0, extra))
45
+ elif self.win_length > self.n_fft:
46
+ window_tensor = window_tensor[: self.n_fft]
47
+ self.register_buffer("window", window_tensor)
48
+
49
+ # Precompute forward DFT (real, imag)
50
+ # PyTorch stft uses e^{-j 2 pi k n / N} => real=cos(...), imag=-sin(...)
51
+ n = np.arange(self.n_fft)
52
+ k = np.arange(self.freq_bins)
53
+ angle = 2 * np.pi * np.outer(k, n) / self.n_fft # shape (freq_bins, n_fft)
54
+ dft_real = np.cos(angle)
55
+ dft_imag = -np.sin(angle) # note negative sign
56
+
57
+ # Combine window and dft => shape (freq_bins, filter_length)
58
+ # We'll make 2 conv weight tensors of shape (freq_bins, 1, filter_length).
59
+ forward_window = window_tensor.numpy() # shape (n_fft,)
60
+ forward_real = dft_real * forward_window # (freq_bins, n_fft)
61
+ forward_imag = dft_imag * forward_window
62
+
63
+ # Convert to PyTorch
64
+ forward_real_torch = torch.from_numpy(forward_real).float()
65
+ forward_imag_torch = torch.from_numpy(forward_imag).float()
66
+
67
+ # Register as Conv1d weight => (out_channels, in_channels, kernel_size)
68
+ # out_channels = freq_bins, in_channels=1, kernel_size=n_fft
69
+ self.register_buffer(
70
+ "weight_forward_real", forward_real_torch.unsqueeze(1)
71
+ )
72
+ self.register_buffer(
73
+ "weight_forward_imag", forward_imag_torch.unsqueeze(1)
74
+ )
75
+
76
+ # Precompute inverse DFT
77
+ # Real iFFT formula => scale = 1/n_fft, doubling for bins 1..freq_bins-2 if n_fft even, etc.
78
+ # For simplicity, we won't do the "DC/nyquist not doubled" approach here.
79
+ # If you want perfect real iSTFT, you can add that logic.
80
+ # This version just yields good approximate reconstruction with Hann + typical overlap.
81
+ inv_scale = 1.0 / self.n_fft
82
+ n = np.arange(self.n_fft)
83
+ angle_t = 2 * np.pi * np.outer(n, k) / self.n_fft # shape (n_fft, freq_bins)
84
+ idft_cos = np.cos(angle_t).T # => (freq_bins, n_fft)
85
+ idft_sin = np.sin(angle_t).T # => (freq_bins, n_fft)
86
+
87
+ # Multiply by window again for typical overlap-add
88
+ # We also incorporate the scale factor 1/n_fft
89
+ inv_window = window_tensor.numpy() * inv_scale
90
+ backward_real = idft_cos * inv_window # (freq_bins, n_fft)
91
+ backward_imag = idft_sin * inv_window
92
+
93
+ # We'll implement iSTFT as real+imag conv_transpose with stride=hop.
94
+ self.register_buffer(
95
+ "weight_backward_real", torch.from_numpy(backward_real).float().unsqueeze(1)
96
+ )
97
+ self.register_buffer(
98
+ "weight_backward_imag", torch.from_numpy(backward_imag).float().unsqueeze(1)
99
+ )
100
+
101
+
102
+
103
+ def transform(self, waveform: torch.Tensor):
104
+ """
105
+ Forward STFT => returns magnitude, phase
106
+ Output shape => (batch, freq_bins, frames)
107
+ """
108
+ # waveform shape => (B, T). conv1d expects (B, 1, T).
109
+ # Optional center pad
110
+ if self.center:
111
+ pad_len = self.n_fft // 2
112
+ waveform = F.pad(waveform, (pad_len, pad_len), mode=self.pad_mode)
113
+
114
+ x = waveform.unsqueeze(1) # => (B, 1, T)
115
+ # Convolution to get real part => shape (B, freq_bins, frames)
116
+ real_out = F.conv1d(
117
+ x,
118
+ self.weight_forward_real,
119
+ bias=None,
120
+ stride=self.hop_length,
121
+ padding=0,
122
+ )
123
+ # Imag part
124
+ imag_out = F.conv1d(
125
+ x,
126
+ self.weight_forward_imag,
127
+ bias=None,
128
+ stride=self.hop_length,
129
+ padding=0,
130
+ )
131
+
132
+ # magnitude, phase
133
+ magnitude = torch.sqrt(real_out**2 + imag_out**2 + 1e-14)
134
+ phase = torch.atan2(imag_out, real_out)
135
+ # Handle the case where imag_out is 0 and real_out is negative to correct ONNX atan2 to match PyTorch
136
+ # In this case, PyTorch returns pi, ONNX returns -pi
137
+ correction_mask = (imag_out == 0) & (real_out < 0)
138
+ phase[correction_mask] = torch.pi
139
+ return magnitude, phase
140
+
141
+
142
+ def inverse(self, magnitude: torch.Tensor, phase: torch.Tensor, length=None):
143
+ """
144
+ Inverse STFT => returns waveform shape (B, T).
145
+ """
146
+ # magnitude, phase => (B, freq_bins, frames)
147
+ # Re-create real/imag => shape (B, freq_bins, frames)
148
+ real_part = magnitude * torch.cos(phase)
149
+ imag_part = magnitude * torch.sin(phase)
150
+
151
+ # conv_transpose wants shape (B, freq_bins, frames). We'll treat "frames" as time dimension
152
+ # so we do (B, freq_bins, frames) => (B, freq_bins, frames)
153
+ # But PyTorch conv_transpose1d expects (B, in_channels, input_length)
154
+ real_part = real_part # (B, freq_bins, frames)
155
+ imag_part = imag_part
156
+
157
+ # real iSTFT => convolve with "backward_real", "backward_imag", and sum
158
+ # We'll do 2 conv_transpose calls, each giving (B, 1, time),
159
+ # then add them => (B, 1, time).
160
+ real_rec = F.conv_transpose1d(
161
+ real_part,
162
+ self.weight_backward_real, # shape (freq_bins, 1, filter_length)
163
+ bias=None,
164
+ stride=self.hop_length,
165
+ padding=0,
166
+ )
167
+ imag_rec = F.conv_transpose1d(
168
+ imag_part,
169
+ self.weight_backward_imag,
170
+ bias=None,
171
+ stride=self.hop_length,
172
+ padding=0,
173
+ )
174
+ # sum => (B, 1, time)
175
+ waveform = real_rec - imag_rec # typical real iFFT has minus for imaginary part
176
+
177
+ # If we used "center=True" in forward, we should remove pad
178
+ if self.center:
179
+ pad_len = self.n_fft // 2
180
+ # Because of transposed convolution, total length might have extra samples
181
+ # We remove `pad_len` from start & end if possible
182
+ waveform = waveform[..., pad_len:-pad_len]
183
+
184
+ # If a specific length is desired, clamp
185
+ if length is not None:
186
+ waveform = waveform[..., :length]
187
+
188
+ # shape => (B, T)
189
+ return waveform
190
+
191
+ def forward(self, x: torch.Tensor):
192
+ """
193
+ Full STFT -> iSTFT pass: returns time-domain reconstruction.
194
+ Same interface as your original code.
195
+ """
196
+ mag, phase = self.transform(x)
197
+ return self.inverse(mag, phase, length=x.shape[-1])
kokoro_patched/kokoro/istftnet.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADAPTED from https://github.com/yl4579/StyleTTS2/blob/main/Modules/istftnet.py
2
+ from kokoro.custom_stft import CustomSTFT
3
+ from torch.nn.utils.parametrizations import weight_norm
4
+ import math
5
+ import torch
6
+ import torch.nn as nn
7
+ import torch.nn.functional as F
8
+
9
+
10
+ # https://github.com/yl4579/StyleTTS2/blob/main/Modules/utils.py
11
+ def init_weights(m, mean=0.0, std=0.01):
12
+ classname = m.__class__.__name__
13
+ if classname.find("Conv") != -1:
14
+ m.weight.data.normal_(mean, std)
15
+
16
+ def get_padding(kernel_size, dilation=1):
17
+ return int((kernel_size*dilation - dilation)/2)
18
+
19
+
20
+ class AdaIN1d(nn.Module):
21
+ def __init__(self, style_dim, num_features):
22
+ super().__init__()
23
+ # affine should be False, however there's a bug in the old torch.onnx.export (not newer dynamo) that causes the channel dimension to be lost if affine=False. When affine is true, there's additional learnably parameters. This shouldn't really matter setting it to True, since we're in inference mode
24
+ self.norm = nn.InstanceNorm1d(num_features, affine=True)
25
+ self.fc = nn.Linear(style_dim, num_features*2)
26
+
27
+ def forward(self, x, s):
28
+ h = self.fc(s)
29
+ h = h.view(h.size(0), h.size(1), 1)
30
+ gamma, beta = torch.chunk(h, chunks=2, dim=1)
31
+ return (1 + gamma) * self.norm(x) + beta
32
+
33
+
34
+ class AdaINResBlock1(nn.Module):
35
+ def __init__(self, channels, kernel_size=3, dilation=(1, 3, 5), style_dim=64):
36
+ super(AdaINResBlock1, self).__init__()
37
+ self.convs1 = nn.ModuleList([
38
+ weight_norm(nn.Conv1d(channels, channels, kernel_size, 1, dilation=dilation[0],
39
+ padding=get_padding(kernel_size, dilation[0]))),
40
+ weight_norm(nn.Conv1d(channels, channels, kernel_size, 1, dilation=dilation[1],
41
+ padding=get_padding(kernel_size, dilation[1]))),
42
+ weight_norm(nn.Conv1d(channels, channels, kernel_size, 1, dilation=dilation[2],
43
+ padding=get_padding(kernel_size, dilation[2])))
44
+ ])
45
+ self.convs1.apply(init_weights)
46
+ self.convs2 = nn.ModuleList([
47
+ weight_norm(nn.Conv1d(channels, channels, kernel_size, 1, dilation=1,
48
+ padding=get_padding(kernel_size, 1))),
49
+ weight_norm(nn.Conv1d(channels, channels, kernel_size, 1, dilation=1,
50
+ padding=get_padding(kernel_size, 1))),
51
+ weight_norm(nn.Conv1d(channels, channels, kernel_size, 1, dilation=1,
52
+ padding=get_padding(kernel_size, 1)))
53
+ ])
54
+ self.convs2.apply(init_weights)
55
+ self.adain1 = nn.ModuleList([
56
+ AdaIN1d(style_dim, channels),
57
+ AdaIN1d(style_dim, channels),
58
+ AdaIN1d(style_dim, channels),
59
+ ])
60
+ self.adain2 = nn.ModuleList([
61
+ AdaIN1d(style_dim, channels),
62
+ AdaIN1d(style_dim, channels),
63
+ AdaIN1d(style_dim, channels),
64
+ ])
65
+ self.alpha1 = nn.ParameterList([nn.Parameter(torch.ones(1, channels, 1)) for i in range(len(self.convs1))])
66
+ self.alpha2 = nn.ParameterList([nn.Parameter(torch.ones(1, channels, 1)) for i in range(len(self.convs2))])
67
+
68
+ def forward(self, x, s):
69
+ for c1, c2, n1, n2, a1, a2 in zip(self.convs1, self.convs2, self.adain1, self.adain2, self.alpha1, self.alpha2):
70
+ xt = n1(x, s)
71
+ xt = xt + (1 / a1) * (torch.sin(a1 * xt) ** 2) # Snake1D
72
+ xt = c1(xt)
73
+ xt = n2(xt, s)
74
+ xt = xt + (1 / a2) * (torch.sin(a2 * xt) ** 2) # Snake1D
75
+ xt = c2(xt)
76
+ x = xt + x
77
+ return x
78
+
79
+
80
+ class TorchSTFT(nn.Module):
81
+ def __init__(self, filter_length=800, hop_length=200, win_length=800, window='hann'):
82
+ super().__init__()
83
+ self.filter_length = filter_length
84
+ self.hop_length = hop_length
85
+ self.win_length = win_length
86
+ assert window == 'hann', window
87
+ self.window = torch.hann_window(win_length, periodic=True, dtype=torch.float32)
88
+
89
+ def transform(self, input_data):
90
+ forward_transform = torch.stft(
91
+ input_data,
92
+ self.filter_length, self.hop_length, self.win_length, window=self.window.to(input_data.device),
93
+ return_complex=True)
94
+ return torch.abs(forward_transform), torch.angle(forward_transform)
95
+
96
+ def inverse(self, magnitude, phase):
97
+ inverse_transform = torch.istft(
98
+ magnitude * torch.exp(phase * 1j),
99
+ self.filter_length, self.hop_length, self.win_length, window=self.window.to(magnitude.device))
100
+ return inverse_transform.unsqueeze(-2) # unsqueeze to stay consistent with conv_transpose1d implementation
101
+
102
+ def forward(self, input_data):
103
+ self.magnitude, self.phase = self.transform(input_data)
104
+ reconstruction = self.inverse(self.magnitude, self.phase)
105
+ return reconstruction
106
+
107
+
108
+ class SineGen(nn.Module):
109
+ """ Definition of sine generator
110
+ SineGen(samp_rate, harmonic_num = 0,
111
+ sine_amp = 0.1, noise_std = 0.003,
112
+ voiced_threshold = 0,
113
+ flag_for_pulse=False)
114
+ samp_rate: sampling rate in Hz
115
+ harmonic_num: number of harmonic overtones (default 0)
116
+ sine_amp: amplitude of sine-wavefrom (default 0.1)
117
+ noise_std: std of Gaussian noise (default 0.003)
118
+ voiced_thoreshold: F0 threshold for U/V classification (default 0)
119
+ flag_for_pulse: this SinGen is used inside PulseGen (default False)
120
+ Note: when flag_for_pulse is True, the first time step of a voiced
121
+ segment is always sin(torch.pi) or cos(0)
122
+ """
123
+ def __init__(self, samp_rate, upsample_scale, harmonic_num=0,
124
+ sine_amp=0.1, noise_std=0.003,
125
+ voiced_threshold=0,
126
+ flag_for_pulse=False):
127
+ super(SineGen, self).__init__()
128
+ self.sine_amp = sine_amp
129
+ self.noise_std = noise_std
130
+ self.harmonic_num = harmonic_num
131
+ self.dim = self.harmonic_num + 1
132
+ self.sampling_rate = samp_rate
133
+ self.voiced_threshold = voiced_threshold
134
+ self.flag_for_pulse = flag_for_pulse
135
+ self.upsample_scale = upsample_scale
136
+
137
+ def _f02uv(self, f0):
138
+ # generate uv signal
139
+ uv = (f0 > self.voiced_threshold).type(torch.float32)
140
+ return uv
141
+
142
+ def _f02sine(self, f0_values):
143
+ """ f0_values: (batchsize, length, dim)
144
+ where dim indicates fundamental tone and overtones
145
+ """
146
+ # convert to F0 in rad. The interger part n can be ignored
147
+ # because 2 * torch.pi * n doesn't affect phase
148
+ rad_values = (f0_values / self.sampling_rate) % 1
149
+ # initial phase noise (no noise for fundamental component)
150
+ rand_ini = torch.rand(f0_values.shape[0], f0_values.shape[2], device=f0_values.device)
151
+ rand_ini[:, 0] = 0
152
+ rad_values[:, 0, :] = rad_values[:, 0, :] + rand_ini
153
+ # instantanouse phase sine[t] = sin(2*pi \sum_i=1 ^{t} rad)
154
+ if not self.flag_for_pulse:
155
+ rad_values = F.interpolate(rad_values.transpose(1, 2), scale_factor=1/self.upsample_scale, mode="linear").transpose(1, 2)
156
+ phase = torch.cumsum(rad_values, dim=1) * 2 * torch.pi
157
+ phase = F.interpolate(phase.transpose(1, 2) * self.upsample_scale, scale_factor=self.upsample_scale, mode="linear").transpose(1, 2)
158
+ sines = torch.sin(phase)
159
+ else:
160
+ # If necessary, make sure that the first time step of every
161
+ # voiced segments is sin(pi) or cos(0)
162
+ # This is used for pulse-train generation
163
+ # identify the last time step in unvoiced segments
164
+ uv = self._f02uv(f0_values)
165
+ uv_1 = torch.roll(uv, shifts=-1, dims=1)
166
+ uv_1[:, -1, :] = 1
167
+ u_loc = (uv < 1) * (uv_1 > 0)
168
+ # get the instantanouse phase
169
+ tmp_cumsum = torch.cumsum(rad_values, dim=1)
170
+ # different batch needs to be processed differently
171
+ for idx in range(f0_values.shape[0]):
172
+ temp_sum = tmp_cumsum[idx, u_loc[idx, :, 0], :]
173
+ temp_sum[1:, :] = temp_sum[1:, :] - temp_sum[0:-1, :]
174
+ # stores the accumulation of i.phase within
175
+ # each voiced segments
176
+ tmp_cumsum[idx, :, :] = 0
177
+ tmp_cumsum[idx, u_loc[idx, :, 0], :] = temp_sum
178
+ # rad_values - tmp_cumsum: remove the accumulation of i.phase
179
+ # within the previous voiced segment.
180
+ i_phase = torch.cumsum(rad_values - tmp_cumsum, dim=1)
181
+ # get the sines
182
+ sines = torch.cos(i_phase * 2 * torch.pi)
183
+ return sines
184
+
185
+ def forward(self, f0):
186
+ """ sine_tensor, uv = forward(f0)
187
+ input F0: tensor(batchsize=1, length, dim=1)
188
+ f0 for unvoiced steps should be 0
189
+ output sine_tensor: tensor(batchsize=1, length, dim)
190
+ output uv: tensor(batchsize=1, length, 1)
191
+ """
192
+ f0_buf = torch.zeros(f0.shape[0], f0.shape[1], self.dim, device=f0.device)
193
+ # fundamental component
194
+ fn = torch.multiply(f0, torch.FloatTensor([[range(1, self.harmonic_num + 2)]]).to(f0.device))
195
+ # generate sine waveforms
196
+ sine_waves = self._f02sine(fn) * self.sine_amp
197
+ # generate uv signal
198
+ # uv = torch.ones(f0.shape)
199
+ # uv = uv * (f0 > self.voiced_threshold)
200
+ uv = self._f02uv(f0)
201
+ # noise: for unvoiced should be similar to sine_amp
202
+ # std = self.sine_amp/3 -> max value ~ self.sine_amp
203
+ # for voiced regions is self.noise_std
204
+ noise_amp = uv * self.noise_std + (1 - uv) * self.sine_amp / 3
205
+ noise = noise_amp * torch.randn_like(sine_waves)
206
+ # first: set the unvoiced part to 0 by uv
207
+ # then: additive noise
208
+ sine_waves = sine_waves * uv + noise
209
+ return sine_waves, uv, noise
210
+
211
+
212
+ class SourceModuleHnNSF(nn.Module):
213
+ """ SourceModule for hn-nsf
214
+ SourceModule(sampling_rate, harmonic_num=0, sine_amp=0.1,
215
+ add_noise_std=0.003, voiced_threshod=0)
216
+ sampling_rate: sampling_rate in Hz
217
+ harmonic_num: number of harmonic above F0 (default: 0)
218
+ sine_amp: amplitude of sine source signal (default: 0.1)
219
+ add_noise_std: std of additive Gaussian noise (default: 0.003)
220
+ note that amplitude of noise in unvoiced is decided
221
+ by sine_amp
222
+ voiced_threshold: threhold to set U/V given F0 (default: 0)
223
+ Sine_source, noise_source = SourceModuleHnNSF(F0_sampled)
224
+ F0_sampled (batchsize, length, 1)
225
+ Sine_source (batchsize, length, 1)
226
+ noise_source (batchsize, length 1)
227
+ uv (batchsize, length, 1)
228
+ """
229
+ def __init__(self, sampling_rate, upsample_scale, harmonic_num=0, sine_amp=0.1,
230
+ add_noise_std=0.003, voiced_threshod=0):
231
+ super(SourceModuleHnNSF, self).__init__()
232
+ self.sine_amp = sine_amp
233
+ self.noise_std = add_noise_std
234
+ # to produce sine waveforms
235
+ self.l_sin_gen = SineGen(sampling_rate, upsample_scale, harmonic_num,
236
+ sine_amp, add_noise_std, voiced_threshod)
237
+ # to merge source harmonics into a single excitation
238
+ self.l_linear = nn.Linear(harmonic_num + 1, 1)
239
+ self.l_tanh = nn.Tanh()
240
+
241
+ def forward(self, x):
242
+ """
243
+ Sine_source, noise_source = SourceModuleHnNSF(F0_sampled)
244
+ F0_sampled (batchsize, length, 1)
245
+ Sine_source (batchsize, length, 1)
246
+ noise_source (batchsize, length 1)
247
+ """
248
+ # source for harmonic branch
249
+ with torch.no_grad():
250
+ sine_wavs, uv, _ = self.l_sin_gen(x)
251
+ sine_merge = self.l_tanh(self.l_linear(sine_wavs))
252
+ # source for noise branch, in the same shape as uv
253
+ noise = torch.randn_like(uv) * self.sine_amp / 3
254
+ return sine_merge, noise, uv
255
+
256
+
257
+ class Generator(nn.Module):
258
+ def __init__(self, style_dim, resblock_kernel_sizes, upsample_rates, upsample_initial_channel, resblock_dilation_sizes, upsample_kernel_sizes, gen_istft_n_fft, gen_istft_hop_size, disable_complex=False):
259
+ super(Generator, self).__init__()
260
+ self.num_kernels = len(resblock_kernel_sizes)
261
+ self.num_upsamples = len(upsample_rates)
262
+ self.m_source = SourceModuleHnNSF(
263
+ sampling_rate=24000,
264
+ upsample_scale=math.prod(upsample_rates) * gen_istft_hop_size,
265
+ harmonic_num=8, voiced_threshod=10)
266
+ self.f0_upsamp = nn.Upsample(scale_factor=math.prod(upsample_rates) * gen_istft_hop_size)
267
+ self.noise_convs = nn.ModuleList()
268
+ self.noise_res = nn.ModuleList()
269
+ self.ups = nn.ModuleList()
270
+ for i, (u, k) in enumerate(zip(upsample_rates, upsample_kernel_sizes)):
271
+ self.ups.append(weight_norm(
272
+ nn.ConvTranspose1d(upsample_initial_channel//(2**i), upsample_initial_channel//(2**(i+1)),
273
+ k, u, padding=(k-u)//2)))
274
+ self.resblocks = nn.ModuleList()
275
+ for i in range(len(self.ups)):
276
+ ch = upsample_initial_channel//(2**(i+1))
277
+ for j, (k, d) in enumerate(zip(resblock_kernel_sizes,resblock_dilation_sizes)):
278
+ self.resblocks.append(AdaINResBlock1(ch, k, d, style_dim))
279
+ c_cur = upsample_initial_channel // (2 ** (i + 1))
280
+ if i + 1 < len(upsample_rates):
281
+ stride_f0 = math.prod(upsample_rates[i + 1:])
282
+ self.noise_convs.append(nn.Conv1d(
283
+ gen_istft_n_fft + 2, c_cur, kernel_size=stride_f0 * 2, stride=stride_f0, padding=(stride_f0+1) // 2))
284
+ self.noise_res.append(AdaINResBlock1(c_cur, 7, [1,3,5], style_dim))
285
+ else:
286
+ self.noise_convs.append(nn.Conv1d(gen_istft_n_fft + 2, c_cur, kernel_size=1))
287
+ self.noise_res.append(AdaINResBlock1(c_cur, 11, [1,3,5], style_dim))
288
+ self.post_n_fft = gen_istft_n_fft
289
+ self.conv_post = weight_norm(nn.Conv1d(ch, self.post_n_fft + 2, 7, 1, padding=3))
290
+ self.ups.apply(init_weights)
291
+ self.conv_post.apply(init_weights)
292
+ self.reflection_pad = nn.ReflectionPad1d((1, 0))
293
+ self.stft = (
294
+ CustomSTFT(filter_length=gen_istft_n_fft, hop_length=gen_istft_hop_size, win_length=gen_istft_n_fft)
295
+ if disable_complex
296
+ else TorchSTFT(filter_length=gen_istft_n_fft, hop_length=gen_istft_hop_size, win_length=gen_istft_n_fft)
297
+ )
298
+
299
+ def forward(self, x, s, f0):
300
+ with torch.no_grad():
301
+ f0 = self.f0_upsamp(f0[:, None]).transpose(1, 2) # bs,n,t
302
+ har_source, noi_source, uv = self.m_source(f0)
303
+ har_source = har_source.transpose(1, 2).squeeze(1)
304
+ har_spec, har_phase = self.stft.transform(har_source)
305
+ har = torch.cat([har_spec, har_phase], dim=1)
306
+ for i in range(self.num_upsamples):
307
+ x = F.leaky_relu(x, negative_slope=0.1)
308
+ x_source = self.noise_convs[i](har)
309
+ x_source = self.noise_res[i](x_source, s)
310
+ x = self.ups[i](x)
311
+ if i == self.num_upsamples - 1:
312
+ x = self.reflection_pad(x)
313
+ x = x + x_source
314
+ xs = None
315
+ for j in range(self.num_kernels):
316
+ if xs is None:
317
+ xs = self.resblocks[i*self.num_kernels+j](x, s)
318
+ else:
319
+ xs += self.resblocks[i*self.num_kernels+j](x, s)
320
+ x = xs / self.num_kernels
321
+ x = F.leaky_relu(x)
322
+ x = self.conv_post(x)
323
+ spec = torch.exp(x[:,:self.post_n_fft // 2 + 1, :])
324
+ phase = torch.sin(x[:, self.post_n_fft // 2 + 1:, :])
325
+ return self.stft.inverse(spec, phase)
326
+
327
+
328
+ class UpSample1d(nn.Module):
329
+ def __init__(self, layer_type):
330
+ super().__init__()
331
+ self.layer_type = layer_type
332
+
333
+ def forward(self, x):
334
+ if self.layer_type == 'none':
335
+ return x
336
+ else:
337
+ return F.interpolate(x, scale_factor=2, mode='nearest')
338
+
339
+
340
+ class AdainResBlk1d(nn.Module):
341
+ def __init__(self, dim_in, dim_out, style_dim=64, actv=nn.LeakyReLU(0.2), upsample='none', dropout_p=0.0):
342
+ super().__init__()
343
+ self.actv = actv
344
+ self.upsample_type = upsample
345
+ self.upsample = UpSample1d(upsample)
346
+ self.learned_sc = dim_in != dim_out
347
+ self._build_weights(dim_in, dim_out, style_dim)
348
+ self.dropout = nn.Dropout(dropout_p)
349
+ if upsample == 'none':
350
+ self.pool = nn.Identity()
351
+ else:
352
+ self.pool = weight_norm(nn.ConvTranspose1d(dim_in, dim_in, kernel_size=3, stride=2, groups=dim_in, padding=1, output_padding=1))
353
+
354
+ def _build_weights(self, dim_in, dim_out, style_dim):
355
+ self.conv1 = weight_norm(nn.Conv1d(dim_in, dim_out, 3, 1, 1))
356
+ self.conv2 = weight_norm(nn.Conv1d(dim_out, dim_out, 3, 1, 1))
357
+ self.norm1 = AdaIN1d(style_dim, dim_in)
358
+ self.norm2 = AdaIN1d(style_dim, dim_out)
359
+ if self.learned_sc:
360
+ self.conv1x1 = weight_norm(nn.Conv1d(dim_in, dim_out, 1, 1, 0, bias=False))
361
+
362
+ def _shortcut(self, x):
363
+ x = self.upsample(x)
364
+ if self.learned_sc:
365
+ x = self.conv1x1(x)
366
+ return x
367
+
368
+ def _residual(self, x, s):
369
+ x = self.norm1(x, s)
370
+ x = self.actv(x)
371
+ x = self.pool(x)
372
+ x = self.conv1(self.dropout(x))
373
+ x = self.norm2(x, s)
374
+ x = self.actv(x)
375
+ x = self.conv2(self.dropout(x))
376
+ return x
377
+
378
+ def forward(self, x, s):
379
+ out = self._residual(x, s)
380
+ out = (out + self._shortcut(x)) * torch.rsqrt(torch.tensor(2))
381
+ return out
382
+
383
+
384
+ class Decoder(nn.Module):
385
+ def __init__(self, dim_in, style_dim, dim_out,
386
+ resblock_kernel_sizes,
387
+ upsample_rates,
388
+ upsample_initial_channel,
389
+ resblock_dilation_sizes,
390
+ upsample_kernel_sizes,
391
+ gen_istft_n_fft, gen_istft_hop_size,
392
+ disable_complex=False,
393
+ # These two widths were hardcoded at 1024/512, so no config could shrink the
394
+ # decoder -- 53.28M of Kokoro's 82M. The defaults reproduce the stock model
395
+ # exactly, so existing checkpoints load unchanged.
396
+ hidden_channels=1024, out_channels=512):
397
+ super().__init__()
398
+ self.encode = AdainResBlk1d(dim_in + 2, hidden_channels, style_dim)
399
+ self.decode = nn.ModuleList()
400
+ self.decode.append(AdainResBlk1d(hidden_channels + 2 + 64, hidden_channels, style_dim))
401
+ self.decode.append(AdainResBlk1d(hidden_channels + 2 + 64, hidden_channels, style_dim))
402
+ self.decode.append(AdainResBlk1d(hidden_channels + 2 + 64, hidden_channels, style_dim))
403
+ self.decode.append(AdainResBlk1d(hidden_channels + 2 + 64, out_channels, style_dim, upsample=True))
404
+ self.F0_conv = weight_norm(nn.Conv1d(1, 1, kernel_size=3, stride=2, groups=1, padding=1))
405
+ self.N_conv = weight_norm(nn.Conv1d(1, 1, kernel_size=3, stride=2, groups=1, padding=1))
406
+ # NOTE: this consumes `asr` (dim_in channels), NOT the decode-block width. In the
407
+ # stock model dim_in == out_channels == 512, so the two are indistinguishable
408
+ # until they are shrunk independently.
409
+ self.asr_res = nn.Sequential(weight_norm(nn.Conv1d(dim_in, 64, kernel_size=1)))
410
+ self.generator = Generator(style_dim, resblock_kernel_sizes, upsample_rates,
411
+ upsample_initial_channel, resblock_dilation_sizes,
412
+ upsample_kernel_sizes, gen_istft_n_fft, gen_istft_hop_size, disable_complex=disable_complex)
413
+
414
+ def forward(self, asr, F0_curve, N, s):
415
+ F0 = self.F0_conv(F0_curve.unsqueeze(1))
416
+ N = self.N_conv(N.unsqueeze(1))
417
+ x = torch.cat([asr, F0, N], axis=1)
418
+ x = self.encode(x, s)
419
+ asr_res = self.asr_res(asr)
420
+ res = True
421
+ for block in self.decode:
422
+ if res:
423
+ x = torch.cat([x, asr_res, F0, N], axis=1)
424
+ x = block(x, s)
425
+ if block.upsample_type != "none":
426
+ res = False
427
+ x = self.generator(x, s, F0_curve)
428
+ return x
kokoro_patched/kokoro/model.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .istftnet import Decoder
2
+ from .modules import CustomAlbert, ProsodyPredictor, TextEncoder
3
+ from dataclasses import dataclass
4
+ from huggingface_hub import hf_hub_download
5
+ from loguru import logger
6
+ from transformers import AlbertConfig
7
+ from typing import Dict, Optional, Union
8
+ import json
9
+ import torch
10
+
11
+ class KModel(torch.nn.Module):
12
+ '''
13
+ KModel is a torch.nn.Module with 2 main responsibilities:
14
+ 1. Init weights, downloading config.json + model.pth from HF if needed
15
+ 2. forward(phonemes: str, ref_s: FloatTensor) -> (audio: FloatTensor)
16
+
17
+ You likely only need one KModel instance, and it can be reused across
18
+ multiple KPipelines to avoid redundant memory allocation.
19
+
20
+ Unlike KPipeline, KModel is language-blind.
21
+
22
+ KModel stores self.vocab and thus knows how to map phonemes -> input_ids,
23
+ so there is no need to repeatedly download config.json outside of KModel.
24
+ '''
25
+
26
+ MODEL_NAMES = {
27
+ 'hexgrad/Kokoro-82M': 'kokoro-v1_0.pth',
28
+ 'hexgrad/Kokoro-82M-v1.1-zh': 'kokoro-v1_1-zh.pth',
29
+ }
30
+
31
+ def __init__(
32
+ self,
33
+ repo_id: Optional[str] = None,
34
+ config: Union[Dict, str, None] = None,
35
+ model: Optional[str] = None,
36
+ disable_complex: bool = False
37
+ ):
38
+ super().__init__()
39
+ if repo_id is None:
40
+ repo_id = 'hexgrad/Kokoro-82M'
41
+ print(f"WARNING: Defaulting repo_id to {repo_id}. Pass repo_id='{repo_id}' to suppress this warning.")
42
+ self.repo_id = repo_id
43
+ if not isinstance(config, dict):
44
+ if not config:
45
+ logger.debug("No config provided, downloading from HF")
46
+ config = hf_hub_download(repo_id=repo_id, filename='config.json')
47
+ with open(config, 'r', encoding='utf-8') as r:
48
+ config = json.load(r)
49
+ logger.debug(f"Loaded config: {config}")
50
+ self.vocab = config['vocab']
51
+ self.bert = CustomAlbert(AlbertConfig(vocab_size=config['n_token'], **config['plbert']))
52
+ self.bert_encoder = torch.nn.Linear(self.bert.config.hidden_size, config['hidden_dim'])
53
+ self.context_length = self.bert.config.max_position_embeddings
54
+ self.predictor = ProsodyPredictor(
55
+ style_dim=config['style_dim'], d_hid=config['hidden_dim'],
56
+ nlayers=config['n_layer'], max_dur=config['max_dur'], dropout=config['dropout']
57
+ )
58
+ self.text_encoder = TextEncoder(
59
+ channels=config['hidden_dim'], kernel_size=config['text_encoder_kernel_size'],
60
+ depth=config['n_layer'], n_symbols=config['n_token']
61
+ )
62
+ self.decoder = Decoder(
63
+ dim_in=config['hidden_dim'], style_dim=config['style_dim'],
64
+ dim_out=config['n_mels'], disable_complex=disable_complex, **config['istftnet']
65
+ )
66
+ if not model:
67
+ model = hf_hub_download(repo_id=repo_id, filename=KModel.MODEL_NAMES[repo_id])
68
+ for key, state_dict in torch.load(model, map_location='cpu', weights_only=True).items():
69
+ assert hasattr(self, key), key
70
+ try:
71
+ getattr(self, key).load_state_dict(state_dict)
72
+ except:
73
+ logger.debug(f"Did not load {key} from state_dict")
74
+ state_dict = {k[7:]: v for k, v in state_dict.items()}
75
+ getattr(self, key).load_state_dict(state_dict, strict=False)
76
+
77
+ @property
78
+ def device(self):
79
+ return self.bert.device
80
+
81
+ @dataclass
82
+ class Output:
83
+ audio: torch.FloatTensor
84
+ pred_dur: Optional[torch.LongTensor] = None
85
+
86
+ @torch.no_grad()
87
+ def forward_with_tokens(
88
+ self,
89
+ input_ids: torch.LongTensor,
90
+ ref_s: torch.FloatTensor,
91
+ speed: float = 1
92
+ ) -> tuple[torch.FloatTensor, torch.LongTensor]:
93
+ input_lengths = torch.full(
94
+ (input_ids.shape[0],),
95
+ input_ids.shape[-1],
96
+ device=input_ids.device,
97
+ dtype=torch.long
98
+ )
99
+
100
+ text_mask = torch.arange(input_lengths.max()).unsqueeze(0).expand(input_lengths.shape[0], -1).type_as(input_lengths)
101
+ text_mask = torch.gt(text_mask+1, input_lengths.unsqueeze(1)).to(self.device)
102
+ bert_dur = self.bert(input_ids, attention_mask=(~text_mask).int())
103
+ d_en = self.bert_encoder(bert_dur).transpose(-1, -2)
104
+ s = ref_s[:, 128:]
105
+ d = self.predictor.text_encoder(d_en, s, input_lengths, text_mask)
106
+ x, _ = self.predictor.lstm(d)
107
+ duration = self.predictor.duration_proj(x)
108
+ duration = torch.sigmoid(duration).sum(axis=-1) / speed
109
+ pred_dur = torch.round(duration).clamp(min=1).long().squeeze()
110
+ indices = torch.repeat_interleave(torch.arange(input_ids.shape[1], device=self.device), pred_dur)
111
+ pred_aln_trg = torch.zeros((input_ids.shape[1], indices.shape[0]), device=self.device)
112
+ pred_aln_trg[indices, torch.arange(indices.shape[0])] = 1
113
+ pred_aln_trg = pred_aln_trg.unsqueeze(0).to(self.device)
114
+ en = d.transpose(-1, -2) @ pred_aln_trg
115
+ F0_pred, N_pred = self.predictor.F0Ntrain(en, s)
116
+ t_en = self.text_encoder(input_ids, input_lengths, text_mask)
117
+ asr = t_en @ pred_aln_trg
118
+ audio = self.decoder(asr, F0_pred, N_pred, ref_s[:, :128]).squeeze()
119
+ return audio, pred_dur
120
+
121
+ def forward(
122
+ self,
123
+ phonemes: str,
124
+ ref_s: torch.FloatTensor,
125
+ speed: float = 1,
126
+ return_output: bool = False
127
+ ) -> Union['KModel.Output', torch.FloatTensor]:
128
+ input_ids = list(filter(lambda i: i is not None, map(lambda p: self.vocab.get(p), phonemes)))
129
+ logger.debug(f"phonemes: {phonemes} -> input_ids: {input_ids}")
130
+ assert len(input_ids)+2 <= self.context_length, (len(input_ids)+2, self.context_length)
131
+ input_ids = torch.LongTensor([[0, *input_ids, 0]]).to(self.device)
132
+ ref_s = ref_s.to(self.device)
133
+ audio, pred_dur = self.forward_with_tokens(input_ids, ref_s, speed)
134
+ audio = audio.squeeze().cpu()
135
+ pred_dur = pred_dur.cpu() if pred_dur is not None else None
136
+ logger.debug(f"pred_dur: {pred_dur}")
137
+ return self.Output(audio=audio, pred_dur=pred_dur) if return_output else audio
138
+
139
+ class KModelForONNX(torch.nn.Module):
140
+ def __init__(self, kmodel: KModel):
141
+ super().__init__()
142
+ self.kmodel = kmodel
143
+
144
+ def forward(
145
+ self,
146
+ input_ids: torch.LongTensor,
147
+ ref_s: torch.FloatTensor,
148
+ speed: float = 1
149
+ ) -> tuple[torch.FloatTensor, torch.LongTensor]:
150
+ waveform, duration = self.kmodel.forward_with_tokens(input_ids, ref_s, speed)
151
+ return waveform, duration
kokoro_patched/kokoro/modules.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # https://github.com/yl4579/StyleTTS2/blob/main/models.py
2
+ from .istftnet import AdainResBlk1d
3
+ from torch.nn.utils.parametrizations import weight_norm
4
+ from transformers import AlbertModel
5
+ import numpy as np
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.nn.functional as F
9
+
10
+
11
+ class LinearNorm(nn.Module):
12
+ def __init__(self, in_dim, out_dim, bias=True, w_init_gain='linear'):
13
+ super(LinearNorm, self).__init__()
14
+ self.linear_layer = nn.Linear(in_dim, out_dim, bias=bias)
15
+ nn.init.xavier_uniform_(self.linear_layer.weight, gain=nn.init.calculate_gain(w_init_gain))
16
+
17
+ def forward(self, x):
18
+ return self.linear_layer(x)
19
+
20
+
21
+ class LayerNorm(nn.Module):
22
+ def __init__(self, channels, eps=1e-5):
23
+ super().__init__()
24
+ self.channels = channels
25
+ self.eps = eps
26
+ self.gamma = nn.Parameter(torch.ones(channels))
27
+ self.beta = nn.Parameter(torch.zeros(channels))
28
+
29
+ def forward(self, x):
30
+ x = x.transpose(1, -1)
31
+ x = F.layer_norm(x, (self.channels,), self.gamma, self.beta, self.eps)
32
+ return x.transpose(1, -1)
33
+
34
+
35
+ class TextEncoder(nn.Module):
36
+ def __init__(self, channels, kernel_size, depth, n_symbols, actv=nn.LeakyReLU(0.2)):
37
+ super().__init__()
38
+ self.embedding = nn.Embedding(n_symbols, channels)
39
+ padding = (kernel_size - 1) // 2
40
+ self.cnn = nn.ModuleList()
41
+ for _ in range(depth):
42
+ self.cnn.append(nn.Sequential(
43
+ weight_norm(nn.Conv1d(channels, channels, kernel_size=kernel_size, padding=padding)),
44
+ LayerNorm(channels),
45
+ actv,
46
+ nn.Dropout(0.2),
47
+ ))
48
+ self.lstm = nn.LSTM(channels, channels//2, 1, batch_first=True, bidirectional=True)
49
+
50
+ def forward(self, x, input_lengths, m):
51
+ x = self.embedding(x) # [B, T, emb]
52
+ x = x.transpose(1, 2) # [B, emb, T]
53
+ m = m.unsqueeze(1)
54
+ x.masked_fill_(m, 0.0)
55
+ for c in self.cnn:
56
+ x = c(x)
57
+ x.masked_fill_(m, 0.0)
58
+ x = x.transpose(1, 2) # [B, T, chn]
59
+ lengths = input_lengths if input_lengths.device == torch.device('cpu') else input_lengths.to('cpu')
60
+ x = nn.utils.rnn.pack_padded_sequence(x, lengths, batch_first=True, enforce_sorted=False)
61
+ self.lstm.flatten_parameters()
62
+ x, _ = self.lstm(x)
63
+ x, _ = nn.utils.rnn.pad_packed_sequence(x, batch_first=True)
64
+ x = x.transpose(-1, -2)
65
+ x_pad = torch.zeros([x.shape[0], x.shape[1], m.shape[-1]], device=x.device)
66
+ x_pad[:, :, :x.shape[-1]] = x
67
+ x = x_pad
68
+ x.masked_fill_(m, 0.0)
69
+ return x
70
+
71
+
72
+ class AdaLayerNorm(nn.Module):
73
+ def __init__(self, style_dim, channels, eps=1e-5):
74
+ super().__init__()
75
+ self.channels = channels
76
+ self.eps = eps
77
+ self.fc = nn.Linear(style_dim, channels*2)
78
+
79
+ def forward(self, x, s):
80
+ x = x.transpose(-1, -2)
81
+ x = x.transpose(1, -1)
82
+ h = self.fc(s)
83
+ h = h.view(h.size(0), h.size(1), 1)
84
+ gamma, beta = torch.chunk(h, chunks=2, dim=1)
85
+ gamma, beta = gamma.transpose(1, -1), beta.transpose(1, -1)
86
+ x = F.layer_norm(x, (self.channels,), eps=self.eps)
87
+ x = (1 + gamma) * x + beta
88
+ return x.transpose(1, -1).transpose(-1, -2)
89
+
90
+
91
+ class ProsodyPredictor(nn.Module):
92
+ def __init__(self, style_dim, d_hid, nlayers, max_dur=50, dropout=0.1):
93
+ super().__init__()
94
+ self.text_encoder = DurationEncoder(sty_dim=style_dim, d_model=d_hid,nlayers=nlayers, dropout=dropout)
95
+ self.lstm = nn.LSTM(d_hid + style_dim, d_hid // 2, 1, batch_first=True, bidirectional=True)
96
+ self.duration_proj = LinearNorm(d_hid, max_dur)
97
+ self.shared = nn.LSTM(d_hid + style_dim, d_hid // 2, 1, batch_first=True, bidirectional=True)
98
+ self.F0 = nn.ModuleList()
99
+ self.F0.append(AdainResBlk1d(d_hid, d_hid, style_dim, dropout_p=dropout))
100
+ self.F0.append(AdainResBlk1d(d_hid, d_hid // 2, style_dim, upsample=True, dropout_p=dropout))
101
+ self.F0.append(AdainResBlk1d(d_hid // 2, d_hid // 2, style_dim, dropout_p=dropout))
102
+ self.N = nn.ModuleList()
103
+ self.N.append(AdainResBlk1d(d_hid, d_hid, style_dim, dropout_p=dropout))
104
+ self.N.append(AdainResBlk1d(d_hid, d_hid // 2, style_dim, upsample=True, dropout_p=dropout))
105
+ self.N.append(AdainResBlk1d(d_hid // 2, d_hid // 2, style_dim, dropout_p=dropout))
106
+ self.F0_proj = nn.Conv1d(d_hid // 2, 1, 1, 1, 0)
107
+ self.N_proj = nn.Conv1d(d_hid // 2, 1, 1, 1, 0)
108
+
109
+ def forward(self, texts, style, text_lengths, alignment, m):
110
+ d = self.text_encoder(texts, style, text_lengths, m)
111
+ m = m.unsqueeze(1)
112
+ lengths = text_lengths if text_lengths.device == torch.device('cpu') else text_lengths.to('cpu')
113
+ x = nn.utils.rnn.pack_padded_sequence(d, lengths, batch_first=True, enforce_sorted=False)
114
+ self.lstm.flatten_parameters()
115
+ x, _ = self.lstm(x)
116
+ x, _ = nn.utils.rnn.pad_packed_sequence(x, batch_first=True)
117
+ x_pad = torch.zeros([x.shape[0], m.shape[-1], x.shape[-1]], device=x.device)
118
+ x_pad[:, :x.shape[1], :] = x
119
+ x = x_pad
120
+ duration = self.duration_proj(nn.functional.dropout(x, 0.5, training=False))
121
+ en = (d.transpose(-1, -2) @ alignment)
122
+ return duration.squeeze(-1), en
123
+
124
+ def F0Ntrain(self, x, s):
125
+ x, _ = self.shared(x.transpose(-1, -2))
126
+ F0 = x.transpose(-1, -2)
127
+ for block in self.F0:
128
+ F0 = block(F0, s)
129
+ F0 = self.F0_proj(F0)
130
+ N = x.transpose(-1, -2)
131
+ for block in self.N:
132
+ N = block(N, s)
133
+ N = self.N_proj(N)
134
+ return F0.squeeze(1), N.squeeze(1)
135
+
136
+
137
+ class DurationEncoder(nn.Module):
138
+ def __init__(self, sty_dim, d_model, nlayers, dropout=0.1):
139
+ super().__init__()
140
+ self.lstms = nn.ModuleList()
141
+ for _ in range(nlayers):
142
+ self.lstms.append(nn.LSTM(d_model + sty_dim, d_model // 2, num_layers=1, batch_first=True, bidirectional=True))
143
+ self.lstms.append(AdaLayerNorm(sty_dim, d_model))
144
+ self.dropout = dropout
145
+ self.d_model = d_model
146
+ self.sty_dim = sty_dim
147
+
148
+ def forward(self, x, style, text_lengths, m):
149
+ masks = m
150
+ x = x.permute(2, 0, 1)
151
+ s = style.expand(x.shape[0], x.shape[1], -1)
152
+ x = torch.cat([x, s], axis=-1)
153
+ x.masked_fill_(masks.unsqueeze(-1).transpose(0, 1), 0.0)
154
+ x = x.transpose(0, 1)
155
+ x = x.transpose(-1, -2)
156
+ for block in self.lstms:
157
+ if isinstance(block, AdaLayerNorm):
158
+ x = block(x.transpose(-1, -2), style).transpose(-1, -2)
159
+ x = torch.cat([x, s.permute(1, 2, 0)], axis=1)
160
+ x.masked_fill_(masks.unsqueeze(-1).transpose(-1, -2), 0.0)
161
+ else:
162
+ lengths = text_lengths if text_lengths.device == torch.device('cpu') else text_lengths.to('cpu')
163
+ x = x.transpose(-1, -2)
164
+ x = nn.utils.rnn.pack_padded_sequence(
165
+ x, lengths, batch_first=True, enforce_sorted=False)
166
+ block.flatten_parameters()
167
+ x, _ = block(x)
168
+ x, _ = nn.utils.rnn.pad_packed_sequence(
169
+ x, batch_first=True)
170
+ x = F.dropout(x, p=self.dropout, training=False)
171
+ x = x.transpose(-1, -2)
172
+ x_pad = torch.zeros([x.shape[0], x.shape[1], m.shape[-1]], device=x.device)
173
+ x_pad[:, :, :x.shape[-1]] = x
174
+ x = x_pad
175
+
176
+ return x.transpose(-1, -2)
177
+
178
+
179
+ # https://github.com/yl4579/StyleTTS2/blob/main/Utils/PLBERT/util.py
180
+ class CustomAlbert(AlbertModel):
181
+ def forward(self, *args, **kwargs):
182
+ outputs = super().forward(*args, **kwargs)
183
+ return outputs.last_hidden_state
kokoro_patched/kokoro/pipeline.py ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .model import KModel
2
+ from dataclasses import dataclass
3
+ from huggingface_hub import hf_hub_download
4
+ from loguru import logger
5
+ from misaki import en, espeak
6
+ from typing import Callable, Generator, List, Optional, Tuple, Union
7
+ import re
8
+ import torch
9
+ import os
10
+
11
+ ALIASES = {
12
+ 'en-us': 'a',
13
+ 'en-gb': 'b',
14
+ 'de': 'd',
15
+ 'es': 'e',
16
+ 'fr-fr': 'f',
17
+ 'hi': 'h',
18
+ 'it': 'i',
19
+ 'pt-br': 'p',
20
+ 'ja': 'j',
21
+ 'zh': 'z',
22
+ }
23
+
24
+ LANG_CODES = dict(
25
+ # pip install misaki[en]
26
+ a='American English',
27
+ b='British English',
28
+
29
+ # espeak-ng
30
+ d='de',
31
+ e='es',
32
+ f='fr-fr',
33
+ h='hi',
34
+ i='it',
35
+ p='pt-br',
36
+
37
+ # pip install misaki[ja]
38
+ j='Japanese',
39
+
40
+ # pip install misaki[zh]
41
+ z='Mandarin Chinese',
42
+ )
43
+
44
+ class KPipeline:
45
+ '''
46
+ KPipeline is a language-aware support class with 2 main responsibilities:
47
+ 1. Perform language-specific G2P, mapping (and chunking) text -> phonemes
48
+ 2. Manage and store voices, lazily downloaded from HF if needed
49
+
50
+ You are expected to have one KPipeline per language. If you have multiple
51
+ KPipelines, you should reuse one KModel instance across all of them.
52
+
53
+ KPipeline is designed to work with a KModel, but this is not required.
54
+ There are 2 ways to pass an existing model into a pipeline:
55
+ 1. On init: us_pipeline = KPipeline(lang_code='a', model=model)
56
+ 2. On call: us_pipeline(text, voice, model=model)
57
+
58
+ By default, KPipeline will automatically initialize its own KModel. To
59
+ suppress this, construct a "quiet" KPipeline with model=False.
60
+
61
+ A "quiet" KPipeline yields (graphemes, phonemes, None) without generating
62
+ any audio. You can use this to phonemize and chunk your text in advance.
63
+
64
+ A "loud" KPipeline _with_ a model yields (graphemes, phonemes, audio).
65
+ '''
66
+ def __init__(
67
+ self,
68
+ lang_code: str,
69
+ repo_id: Optional[str] = None,
70
+ model: Union[KModel, bool] = True,
71
+ trf: bool = False,
72
+ en_callable: Optional[Callable[[str], str]] = None,
73
+ device: Optional[str] = None
74
+ ):
75
+ """Initialize a KPipeline.
76
+
77
+ Args:
78
+ lang_code: Language code for G2P processing
79
+ model: KModel instance, True to create new model, False for no model
80
+ trf: Whether to use transformer-based G2P
81
+ device: Override default device selection ('cuda' or 'cpu', or None for auto)
82
+ If None, will auto-select cuda if available
83
+ If 'cuda' and not available, will explicitly raise an error
84
+ """
85
+ if repo_id is None:
86
+ repo_id = 'hexgrad/Kokoro-82M'
87
+ print(f"WARNING: Defaulting repo_id to {repo_id}. Pass repo_id='{repo_id}' to suppress this warning.")
88
+ self.repo_id = repo_id
89
+ lang_code = lang_code.lower()
90
+ lang_code = ALIASES.get(lang_code, lang_code)
91
+ assert lang_code in LANG_CODES, (lang_code, LANG_CODES)
92
+ self.lang_code = lang_code
93
+ self.model = None
94
+ if isinstance(model, KModel):
95
+ self.model = model
96
+ elif model:
97
+ if device == 'cuda' and not torch.cuda.is_available():
98
+ raise RuntimeError("CUDA requested but not available")
99
+ if device == 'mps' and not torch.backends.mps.is_available():
100
+ raise RuntimeError("MPS requested but not available")
101
+ if device == 'mps' and os.environ.get('PYTORCH_ENABLE_MPS_FALLBACK') != '1':
102
+ raise RuntimeError("MPS requested but fallback not enabled")
103
+ if device is None:
104
+ if torch.cuda.is_available():
105
+ device = 'cuda'
106
+ elif os.environ.get('PYTORCH_ENABLE_MPS_FALLBACK') == '1' and torch.backends.mps.is_available():
107
+ device = 'mps'
108
+ else:
109
+ device = 'cpu'
110
+ try:
111
+ self.model = KModel(repo_id=repo_id).to(device).eval()
112
+ except RuntimeError as e:
113
+ if device == 'cuda':
114
+ raise RuntimeError(f"""Failed to initialize model on CUDA: {e}.
115
+ Try setting device='cpu' or check CUDA installation.""")
116
+ raise
117
+ self.voices = {}
118
+ if lang_code in 'ab':
119
+ try:
120
+ fallback = espeak.EspeakFallback(british=lang_code=='b')
121
+ except Exception as e:
122
+ logger.warning("EspeakFallback not Enabled: OOD words will be skipped")
123
+ logger.warning({str(e)})
124
+ fallback = None
125
+ self.g2p = en.G2P(trf=trf, british=lang_code=='b', fallback=fallback, unk='')
126
+ elif lang_code == 'j':
127
+ try:
128
+ from misaki import ja
129
+ self.g2p = ja.JAG2P()
130
+ except ImportError:
131
+ logger.error("You need to `pip install misaki[ja]` to use lang_code='j'")
132
+ raise
133
+ elif lang_code == 'z':
134
+ try:
135
+ from misaki import zh
136
+ self.g2p = zh.ZHG2P(
137
+ version=None if repo_id.endswith('/Kokoro-82M') else '1.1',
138
+ en_callable=en_callable
139
+ )
140
+ except ImportError:
141
+ logger.error("You need to `pip install misaki[zh]` to use lang_code='z'")
142
+ raise
143
+ elif lang_code == 'd':
144
+ try:
145
+ from misaki import de
146
+ self.g2p = de.DEG2P()
147
+ except ImportError:
148
+ logger.error("You need to `pip install misaki[de]` to use lang_code='d'")
149
+ raise
150
+ else:
151
+ language = LANG_CODES[lang_code]
152
+ logger.warning(f"Using EspeakG2P(language='{language}'). Chunking logic not yet implemented, so long texts may be truncated unless you split them with '\\n'.")
153
+ self.g2p = espeak.EspeakG2P(language=language)
154
+
155
+ def load_single_voice(self, voice: str):
156
+ if voice in self.voices:
157
+ return self.voices[voice]
158
+ if voice.endswith('.pt'):
159
+ f = voice
160
+ else:
161
+ f = hf_hub_download(repo_id=self.repo_id, filename=f'voices/{voice}.pt')
162
+ if not voice.startswith(self.lang_code):
163
+ v = LANG_CODES.get(voice, voice)
164
+ p = LANG_CODES.get(self.lang_code, self.lang_code)
165
+ logger.warning(f'Language mismatch, loading {v} voice into {p} pipeline.')
166
+ pack = torch.load(f, weights_only=True)
167
+ self.voices[voice] = pack
168
+ return pack
169
+
170
+ """
171
+ load_voice is a helper function that lazily downloads and loads a voice:
172
+ Single voice can be requested (e.g. 'af_bella') or multiple voices (e.g. 'af_bella,af_jessica').
173
+ If multiple voices are requested, they are averaged.
174
+ Delimiter is optional and defaults to ','.
175
+ """
176
+ def load_voice(self, voice: Union[str, torch.FloatTensor], delimiter: str = ",") -> torch.FloatTensor:
177
+ if isinstance(voice, torch.FloatTensor):
178
+ return voice
179
+ if voice in self.voices:
180
+ return self.voices[voice]
181
+ logger.debug(f"Loading voice: {voice}")
182
+ packs = [self.load_single_voice(v) for v in voice.split(delimiter)]
183
+ if len(packs) == 1:
184
+ return packs[0]
185
+ self.voices[voice] = torch.mean(torch.stack(packs), dim=0)
186
+ return self.voices[voice]
187
+
188
+ @staticmethod
189
+ def tokens_to_ps(tokens: List[en.MToken]) -> str:
190
+ return ''.join(t.phonemes + (' ' if t.whitespace else '') for t in tokens).strip()
191
+
192
+ @staticmethod
193
+ def waterfall_last(
194
+ tokens: List[en.MToken],
195
+ next_count: int,
196
+ waterfall: List[str] = ['!.?โ€ฆ', ':;', ',โ€”'],
197
+ bumps: List[str] = [')', 'โ€']
198
+ ) -> int:
199
+ for w in waterfall:
200
+ z = next((i for i, t in reversed(list(enumerate(tokens))) if t.phonemes in set(w)), None)
201
+ if z is None:
202
+ continue
203
+ z += 1
204
+ if z < len(tokens) and tokens[z].phonemes in bumps:
205
+ z += 1
206
+ if next_count - len(KPipeline.tokens_to_ps(tokens[:z])) <= 510:
207
+ return z
208
+ return len(tokens)
209
+
210
+ @staticmethod
211
+ def tokens_to_text(tokens: List[en.MToken]) -> str:
212
+ return ''.join(t.text + t.whitespace for t in tokens).strip()
213
+
214
+ def en_tokenize(
215
+ self,
216
+ tokens: List[en.MToken]
217
+ ) -> Generator[Tuple[str, str, List[en.MToken]], None, None]:
218
+ tks = []
219
+ pcount = 0
220
+ for t in tokens:
221
+ # American English: ษพ => T
222
+ t.phonemes = '' if t.phonemes is None else t.phonemes#.replace('ษพ', 'T')
223
+ next_ps = t.phonemes + (' ' if t.whitespace else '')
224
+ next_pcount = pcount + len(next_ps.rstrip())
225
+ if next_pcount > 510:
226
+ z = KPipeline.waterfall_last(tks, next_pcount)
227
+ text = KPipeline.tokens_to_text(tks[:z])
228
+ logger.debug(f"Chunking text at {z}: '{text[:30]}{'...' if len(text) > 30 else ''}'")
229
+ ps = KPipeline.tokens_to_ps(tks[:z])
230
+ yield text, ps, tks[:z]
231
+ tks = tks[z:]
232
+ pcount = len(KPipeline.tokens_to_ps(tks))
233
+ if not tks:
234
+ next_ps = next_ps.lstrip()
235
+ tks.append(t)
236
+ pcount += len(next_ps)
237
+ if tks:
238
+ text = KPipeline.tokens_to_text(tks)
239
+ ps = KPipeline.tokens_to_ps(tks)
240
+ yield ''.join(text).strip(), ''.join(ps).strip(), tks
241
+
242
+ @staticmethod
243
+ def infer(
244
+ model: KModel,
245
+ ps: str,
246
+ pack: torch.FloatTensor,
247
+ speed: Union[float, Callable[[int], float]] = 1
248
+ ) -> KModel.Output:
249
+ if callable(speed):
250
+ speed = speed(len(ps))
251
+ return model(ps, pack[len(ps)-1], speed, return_output=True)
252
+
253
+ def generate_from_tokens(
254
+ self,
255
+ tokens: Union[str, List[en.MToken]],
256
+ voice: str,
257
+ speed: float = 1,
258
+ model: Optional[KModel] = None
259
+ ) -> Generator['KPipeline.Result', None, None]:
260
+ """Generate audio from either raw phonemes or pre-processed tokens.
261
+
262
+ Args:
263
+ tokens: Either a phoneme string or list of pre-processed MTokens
264
+ voice: The voice to use for synthesis
265
+ speed: Speech speed modifier (default: 1)
266
+ model: Optional KModel instance (uses pipeline's model if not provided)
267
+
268
+ Yields:
269
+ KPipeline.Result containing the input tokens and generated audio
270
+
271
+ Raises:
272
+ ValueError: If no voice is provided or token sequence exceeds model limits
273
+ """
274
+ model = model or self.model
275
+ if model and voice is None:
276
+ raise ValueError('Specify a voice: pipeline.generate_from_tokens(..., voice="af_heart")')
277
+
278
+ pack = self.load_voice(voice).to(model.device) if model else None
279
+
280
+ # Handle raw phoneme string
281
+ if isinstance(tokens, str):
282
+ logger.debug("Processing phonemes from raw string")
283
+ if len(tokens) > 510:
284
+ raise ValueError(f'Phoneme string too long: {len(tokens)} > 510')
285
+ output = KPipeline.infer(model, tokens, pack, speed) if model else None
286
+ yield self.Result(graphemes='', phonemes=tokens, output=output)
287
+ return
288
+
289
+ logger.debug("Processing MTokens")
290
+ # Handle pre-processed tokens
291
+ for gs, ps, tks in self.en_tokenize(tokens):
292
+ if not ps:
293
+ continue
294
+ elif len(ps) > 510:
295
+ logger.warning(f"Unexpected len(ps) == {len(ps)} > 510 and ps == '{ps}'")
296
+ logger.warning("Truncating to 510 characters")
297
+ ps = ps[:510]
298
+ output = KPipeline.infer(model, ps, pack, speed) if model else None
299
+ if output is not None and output.pred_dur is not None:
300
+ KPipeline.join_timestamps(tks, output.pred_dur)
301
+ yield self.Result(graphemes=gs, phonemes=ps, tokens=tks, output=output)
302
+
303
+ @staticmethod
304
+ def join_timestamps(tokens: List[en.MToken], pred_dur: torch.LongTensor):
305
+ # Multiply by 600 to go from pred_dur frames to sample_rate 24000
306
+ # Equivalent to dividing pred_dur frames by 40 to get timestamp in seconds
307
+ # We will count nice round half-frames, so the divisor is 80
308
+ MAGIC_DIVISOR = 80
309
+ if not tokens or len(pred_dur) < 3:
310
+ # We expect at least 3: <bos>, token, <eos>
311
+ return
312
+ # We track 2 counts, measured in half-frames: (left, right)
313
+ # This way we can cut space characters in half
314
+ # TODO: Is -3 an appropriate offset?
315
+ left = right = 2 * max(0, pred_dur[0].item() - 3)
316
+ # Updates:
317
+ # left = right + (2 * token_dur) + space_dur
318
+ # right = left + space_dur
319
+ i = 1
320
+ for t in tokens:
321
+ if i >= len(pred_dur)-1:
322
+ break
323
+ if not t.phonemes:
324
+ if t.whitespace:
325
+ i += 1
326
+ left = right + pred_dur[i].item()
327
+ right = left + pred_dur[i].item()
328
+ i += 1
329
+ continue
330
+ j = i + len(t.phonemes)
331
+ if j >= len(pred_dur):
332
+ break
333
+ t.start_ts = left / MAGIC_DIVISOR
334
+ token_dur = pred_dur[i: j].sum().item()
335
+ space_dur = pred_dur[j].item() if t.whitespace else 0
336
+ left = right + (2 * token_dur) + space_dur
337
+ t.end_ts = left / MAGIC_DIVISOR
338
+ right = left + space_dur
339
+ i = j + (1 if t.whitespace else 0)
340
+
341
+ @dataclass
342
+ class Result:
343
+ graphemes: str
344
+ phonemes: str
345
+ tokens: Optional[List[en.MToken]] = None
346
+ output: Optional[KModel.Output] = None
347
+ text_index: Optional[int] = None
348
+
349
+ @property
350
+ def audio(self) -> Optional[torch.FloatTensor]:
351
+ return None if self.output is None else self.output.audio
352
+
353
+ @property
354
+ def pred_dur(self) -> Optional[torch.LongTensor]:
355
+ return None if self.output is None else self.output.pred_dur
356
+
357
+ ### MARK: BEGIN BACKWARD COMPAT ###
358
+ def __iter__(self):
359
+ yield self.graphemes
360
+ yield self.phonemes
361
+ yield self.audio
362
+
363
+ def __getitem__(self, index):
364
+ return [self.graphemes, self.phonemes, self.audio][index]
365
+
366
+ def __len__(self):
367
+ return 3
368
+ #### MARK: END BACKWARD COMPAT ####
369
+
370
+ def __call__(
371
+ self,
372
+ text: Union[str, List[str]],
373
+ voice: Optional[str] = None,
374
+ speed: Union[float, Callable[[int], float]] = 1,
375
+ split_pattern: Optional[str] = r'\n+',
376
+ model: Optional[KModel] = None
377
+ ) -> Generator['KPipeline.Result', None, None]:
378
+ model = model or self.model
379
+ if model and voice is None:
380
+ raise ValueError('Specify a voice: en_us_pipeline(text="Hello world!", voice="af_heart")')
381
+ pack = self.load_voice(voice).to(model.device) if model else None
382
+
383
+ # Convert input to list of segments
384
+ if isinstance(text, str):
385
+ text = re.split(split_pattern, text.strip()) if split_pattern else [text]
386
+
387
+ # Process each segment
388
+ for graphemes_index, graphemes in enumerate(text):
389
+ if not graphemes.strip(): # Skip empty segments
390
+ continue
391
+
392
+ # English processing (unchanged)
393
+ if self.lang_code in 'ab':
394
+ logger.debug(f"Processing English text: {graphemes[:50]}{'...' if len(graphemes) > 50 else ''}")
395
+ _, tokens = self.g2p(graphemes)
396
+ for gs, ps, tks in self.en_tokenize(tokens):
397
+ if not ps:
398
+ continue
399
+ elif len(ps) > 510:
400
+ logger.warning(f"Unexpected len(ps) == {len(ps)} > 510 and ps == '{ps}'")
401
+ ps = ps[:510]
402
+ output = KPipeline.infer(model, ps, pack, speed) if model else None
403
+ if output is not None and output.pred_dur is not None:
404
+ KPipeline.join_timestamps(tks, output.pred_dur)
405
+ yield self.Result(graphemes=gs, phonemes=ps, tokens=tks, output=output, text_index=graphemes_index)
406
+
407
+ # Non-English processing with chunking
408
+ else:
409
+ # Split long text into smaller chunks (roughly 400 characters each)
410
+ # Using sentence boundaries when possible
411
+ chunk_size = 400
412
+ chunks = []
413
+
414
+ # Try to split on sentence boundaries first
415
+ sentences = re.split(r'([.!?]+)', graphemes)
416
+ current_chunk = ""
417
+
418
+ for i in range(0, len(sentences), 2):
419
+ sentence = sentences[i]
420
+ # Add the punctuation back if it exists
421
+ if i + 1 < len(sentences):
422
+ sentence += sentences[i + 1]
423
+
424
+ if len(current_chunk) + len(sentence) <= chunk_size:
425
+ current_chunk += sentence
426
+ else:
427
+ if current_chunk:
428
+ chunks.append(current_chunk.strip())
429
+ current_chunk = sentence
430
+
431
+ if current_chunk:
432
+ chunks.append(current_chunk.strip())
433
+
434
+ # If no chunks were created (no sentence boundaries), fall back to character-based chunking
435
+ if not chunks:
436
+ chunks = [graphemes[i:i+chunk_size] for i in range(0, len(graphemes), chunk_size)]
437
+
438
+ # Process each chunk
439
+ for chunk in chunks:
440
+ if not chunk.strip():
441
+ continue
442
+
443
+ ps, _ = self.g2p(chunk)
444
+ if not ps:
445
+ continue
446
+ elif len(ps) > 510:
447
+ logger.warning(f'Truncating len(ps) == {len(ps)} > 510')
448
+ ps = ps[:510]
449
+
450
+ output = KPipeline.infer(model, ps, pack, speed) if model else None
451
+ yield self.Result(graphemes=chunk, phonemes=ps, output=output, text_index=graphemes_index)
load_model.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load oddadmix/Nabra-Saudi-7M, a 7.48M Saudi-dialect Arabic voice.
2
+
3
+ Use this rather than a plain `from kokoro import KModel`: this config sets the decoder's
4
+ `hidden_channels` / `out_channels`, which upstream Kokoro hardcodes, so the stock package raises
5
+ a TypeError on it. The patched package is vendored here with its defaults unchanged.
6
+
7
+ from load_model import load
8
+ model, pipeline, voice = load()
9
+ audio = next(pipeline("ูŠุง ุฃู‡ู„ุงู‹ ูˆุณู‡ู„ุงู‹ุŒ ูƒูŠู ุญุงู„ูƒู…ุŸ", voice=voice))[2] # 24 kHz
10
+
11
+ Saudi dialect text is normally written WITHOUT full tashkeel, and that is what this model
12
+ expects. Adding MSA diacritics makes it worse, not better: an MSA diacritizer imposes case
13
+ endings a dialect speaker does not pronounce.
14
+ """
15
+ import os, sys, torch
16
+
17
+ HERE = os.path.dirname(os.path.abspath(__file__))
18
+ sys.path.insert(0, os.path.join(HERE, "kokoro_patched"))
19
+ sys.path.insert(0, HERE)
20
+
21
+ from kokoro import KModel, KPipeline # noqa: E402
22
+ from arabic_g2p import ArabicG2P, EXTRA_SYMBOLS, clean_phonemes # noqa: E402
23
+ from kokoro import pipeline as kpipeline_mod # noqa: E402
24
+
25
+ kpipeline_mod.LANG_CODES.setdefault("ar", "ar")
26
+ REPO = "oddadmix/Nabra-Saudi-7M"
27
+
28
+
29
+ def load(device=None, weights="nabra_saudi_7m.pth", config="config.json", voice="af_msa.pt"):
30
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
31
+ m = KModel(repo_id=REPO, config=os.path.join(HERE, config),
32
+ model=os.path.join(HERE, weights), disable_complex=True).eval().to(device)
33
+ m.vocab.update(EXTRA_SYMBOLS)
34
+ p = KPipeline(lang_code="ar", repo_id=REPO, model=m)
35
+ g = ArabicG2P(diacritize=False) # dialect: use the text as written
36
+ p.g2p = lambda t: (clean_phonemes(g.process(t.replace("\u0640", ""))[1]), t)
37
+ return m, p, torch.load(os.path.join(HERE, voice), map_location="cpu", weights_only=True)
nabra_saudi_7m.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:98531f832d224bcecd89e1d582db7c22e034b706bd936e33b2f1c56a99c57a9f
3
+ size 30097344