barebones-tts 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ uv.lock
12
+
13
+ .DS_Store
14
+ test_word_files/
15
+
16
+ # node leftovers from local browser testing of web/site
17
+ node_modules/
18
+
19
+ # wrangler local state (from npx wrangler deploy --dry-run etc.)
20
+ .wrangler/
21
+
22
+ web/wheel_src/
23
+ web/build_wheel.py
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Dylan Oonk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.5
2
+ Name: barebones-tts
3
+ Version: 0.1.0
4
+ Summary: I wanted a TTS from first principles
5
+ Project-URL: Homepage, https://tts.dylanoonk.net
6
+ Project-URL: Repository, https://github.com/dylanoonk/barebones-tts
7
+ Author: Dylan Oonk
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: arpabet,audio,formant-synthesis,speech-synthesis,text-to-speech,tts
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
17
+ Requires-Python: >=3.13
18
+ Requires-Dist: colorama>=0.4.6
19
+ Requires-Dist: numpy>=2.5.3
20
+ Requires-Dist: scipy>=1.18.1
21
+ Requires-Dist: sounddevice>=0.5.6
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Barebones Text To Speech
25
+
26
+ I wrote a text-to-speech system from first principles because I wanted to learn how old-timey text-to-speech systems worked. I'll make a video or something on how it works because there weren't any good in-depth videos about non-nueral-network text-to-speech systems.
27
+
28
+ ## How to install
29
+
30
+ Use `pip` to install
31
+
32
+ ```bash
33
+ pip install barebones-tts
34
+ ```
35
+
36
+ ## Start speaking
37
+
38
+ Literally just
39
+
40
+ ```bash
41
+ python3 barebones-tts.py
42
+ ```
43
+
44
+ ...and start typing. This is still in early development so this will change soon to be better.
45
+
46
+ If you want to save the audio to a wav file then use the `--wav` or `-w` flags.
47
+
48
+ ```bash
49
+ python3 barebones-tts.py --wav
50
+ ```
51
+
52
+ Also, to use, just import like normal and start speaking:
53
+
54
+ ```python
55
+ from barebones-tts import barebones_tts
56
+
57
+ barebones = barebones_tts()
58
+ barebones.speak("Hello world.")
59
+ ```
@@ -0,0 +1,36 @@
1
+ # Barebones Text To Speech
2
+
3
+ I wrote a text-to-speech system from first principles because I wanted to learn how old-timey text-to-speech systems worked. I'll make a video or something on how it works because there weren't any good in-depth videos about non-nueral-network text-to-speech systems.
4
+
5
+ ## How to install
6
+
7
+ Use `pip` to install
8
+
9
+ ```bash
10
+ pip install barebones-tts
11
+ ```
12
+
13
+ ## Start speaking
14
+
15
+ Literally just
16
+
17
+ ```bash
18
+ python3 barebones-tts.py
19
+ ```
20
+
21
+ ...and start typing. This is still in early development so this will change soon to be better.
22
+
23
+ If you want to save the audio to a wav file then use the `--wav` or `-w` flags.
24
+
25
+ ```bash
26
+ python3 barebones-tts.py --wav
27
+ ```
28
+
29
+ Also, to use, just import like normal and start speaking:
30
+
31
+ ```python
32
+ from barebones-tts import barebones_tts
33
+
34
+ barebones = barebones_tts()
35
+ barebones.speak("Hello world.")
36
+ ```
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "barebones-tts"
7
+ version = "0.1.0"
8
+ description = "I wanted a TTS from first principles"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.13"
12
+ authors = [{ name = "Dylan Oonk" }]
13
+ keywords = [
14
+ "tts",
15
+ "text-to-speech",
16
+ "speech-synthesis",
17
+ "formant-synthesis",
18
+ "arpabet",
19
+ "audio",
20
+ ]
21
+ classifiers = [
22
+ "Intended Audience :: Developers",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Operating System :: OS Independent",
25
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.13",
28
+ ]
29
+ dependencies = [
30
+ "colorama>=0.4.6",
31
+ "numpy>=2.5.3",
32
+ "scipy>=1.18.1",
33
+ "sounddevice>=0.5.6",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://tts.dylanoonk.net"
38
+ Repository = "https://github.com/dylanoonk/barebones-tts"
39
+
40
+ [project.scripts]
41
+ barebones-tts = "barebones_tts.__main__:main"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/barebones_tts"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = [
48
+ "src/barebones_tts",
49
+ "README.md",
50
+ "LICENSE",
51
+ ]
@@ -0,0 +1,4 @@
1
+ from .core import barebones_tts
2
+
3
+ __all__ = ["barebones_tts"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,47 @@
1
+ import argparse
2
+
3
+ from colorama import init, Fore
4
+
5
+ from .core import barebones_tts
6
+
7
+
8
+ def main() -> None:
9
+ parser = argparse.ArgumentParser(
10
+ prog="barebones-tts",
11
+ description="Text-to-speech from first principles.",
12
+ )
13
+ parser.add_argument(
14
+ "text",
15
+ nargs="*",
16
+ help="text to speak; if omitted, enters interactive mode",
17
+ )
18
+ parser.add_argument(
19
+ "-w", "--wav",
20
+ action="store_true",
21
+ help="also save the audio to a .wav file",
22
+ )
23
+ args = parser.parse_args()
24
+
25
+ init()
26
+ tts = barebones_tts()
27
+
28
+ if args.text:
29
+ input_text = " ".join(args.text)
30
+ tts.speak(input_text)
31
+ if args.wav:
32
+ tts.save(input_text)
33
+ return
34
+
35
+ try:
36
+ while True:
37
+ input_text = input(f"{Fore.GREEN}> ").strip()
38
+ tts.speak(input_text)
39
+
40
+ if args.wav:
41
+ tts.save(input_text)
42
+ except KeyboardInterrupt:
43
+ print(f"{Fore.RESET}\nGoodbye.")
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -0,0 +1,76 @@
1
+ from .text_normalization import normalize_text
2
+ from .tokenization import TokenList, Token
3
+ from colorama import Fore
4
+ from pickle import load
5
+ from pathlib import Path
6
+
7
+ DEFAULT_PRONUNCIATION_FILE = Path(__file__).parent / "data" / "pronunciation.pkl"
8
+
9
+ class arpabet:
10
+ def __init__(self, pronunciation_file: str = str(DEFAULT_PRONUNCIATION_FILE)) -> None:
11
+ with open(pronunciation_file, mode='rb') as f:
12
+ self.PRONUNCIATIONS: dict[str, str] = load(f)
13
+
14
+ def get_sound_from_db(self, TEXT: str) -> str | None:
15
+ if TEXT in self.PRONUNCIATIONS:
16
+ return self.PRONUNCIATIONS[TEXT]
17
+
18
+ return None
19
+
20
+
21
+ def fallback_pronunciation(self, TEXT: str) -> str:
22
+ CHARACTERS: list[str] = list(TEXT.strip())
23
+ output: list[str] = []
24
+
25
+ for CHARACTER in CHARACTERS:
26
+ sound = self.get_sound_from_db(CHARACTER.lower())
27
+ if sound:
28
+ output.append(sound)
29
+ else:
30
+ print(f"{Fore.RESET}{Fore.YELLOW}Unknown Character \"{CHARACTER.encode()}\"")
31
+
32
+ if output:
33
+ return " ".join(output).strip()
34
+
35
+ return ""
36
+
37
+
38
+
39
+ def arpabetize(self, tokens: TokenList, ) -> TokenList:
40
+ pronunciations: TokenList = TokenList()
41
+ for token in tokens:
42
+ if token.get_speakable_flag():
43
+ pronunciation_token: Token = Token()
44
+
45
+ TOKEN_TEXT = token.get_text().strip().lower()
46
+
47
+ sound = self.get_sound_from_db(TOKEN_TEXT)
48
+
49
+ if sound == None:
50
+ sound = self.fallback_pronunciation(TOKEN_TEXT)
51
+
52
+ sound = sound.split()
53
+ pronunciation_token.set_text(TOKEN_TEXT)
54
+ pronunciation_token.set_phoneme(sound)
55
+ pronunciation_token.set_speakable_flag(True)
56
+
57
+ pronunciations.append(pronunciation_token)
58
+ else:
59
+ pronunciations.append(token)
60
+
61
+
62
+ return pronunciations
63
+
64
+
65
+ def main():
66
+ arpa = arpabet()
67
+ input_text = input('> ').strip()
68
+ normalized = normalize_text(input_text)
69
+ arpabetized = arpa.arpabetize(normalized)
70
+
71
+
72
+ print(arpabetized)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
@@ -0,0 +1,95 @@
1
+ from .text_normalization import normalize_text
2
+ from .arpabet import arpabet
3
+ from .formant_synth import FormantSynthesizer
4
+ from colorama import Fore
5
+
6
+ import numpy as np
7
+ import re
8
+
9
+ class barebones_tts:
10
+
11
+ def __init__(self) -> None:
12
+ self.synth = FormantSynthesizer()
13
+ self.arpabet = arpabet()
14
+
15
+
16
+ def sanitize_for_filename(self, input_string: str) -> str:
17
+ s = input_string.strip()
18
+ s = re.sub(r'[\s-]+', '_', s)
19
+ s = re.sub(r'[<>:"/\\|?*]', '', s)
20
+ s = s.strip('._')
21
+
22
+ if not s:
23
+ return "unnamed_file"
24
+
25
+ return s
26
+
27
+ def render(self, input_text: str) -> np.ndarray:
28
+
29
+ normalized = normalize_text(input_text)
30
+ print(f"{Fore.RESET}normalized: {Fore.CYAN}'{normalized}'")
31
+ arpabetized = self.arpabet.arpabetize(normalized)
32
+ print(f"{Fore.RESET}arpabetized: {Fore.YELLOW}'{arpabetized}'")
33
+
34
+ audios = []
35
+
36
+ for index, token in enumerate(arpabetized):
37
+ if token.get_speakable_flag():
38
+ TOKEN_PHONEME = token.get_phoneme()
39
+
40
+ audio = self.synth.synthesize(TOKEN_PHONEME)
41
+ silence = self.synth.generate_silence(100)
42
+ audio = np.concatenate([audio, silence])
43
+ elif token.get_modifies_previous_token_flag() and index > 0:
44
+
45
+ audios[index - 1] = self.synth.pitch_shift(audios[index - 1], token.get_pitch_modifier())
46
+ audio = self.synth.generate_silence(token.get_silence_time())
47
+ else:
48
+ audio = self.synth.generate_silence(token.get_silence_time())
49
+ audios.append(audio)
50
+
51
+ print(f"{Fore.RESET}{Fore.BLUE}Playing...")
52
+ complete_audio = np.concatenate(audios)
53
+ return complete_audio
54
+
55
+
56
+ def speak(self, input_text: str) -> None:
57
+ """
58
+ Wrapper around the `render()` function that speaks outloud the `input_text`.
59
+
60
+ :param input_text: Text to be spoken
61
+ :type input_text: str
62
+ :param synth: Syntheizer object to use for synthesizing the phonemes into sound
63
+ :type synth: FormantSynthesizer
64
+ """
65
+
66
+ audio = self.render(input_text)
67
+ self.synth.play(audio)
68
+
69
+
70
+ def save(self, input_text: str, filename: str = "") -> str:
71
+ """
72
+ Wrapper around the `render()` function that saves the `input_text` to a `wav` file.
73
+
74
+ :param input_text: Text to be spoken
75
+ :type input_text: str
76
+ :param synth: Syntheizer object to use for synthesizing the phonemes into sound.
77
+ :type synth: FormantSynthesizer
78
+ :param filename: Filename of the new `wav` file. Will default to a sanitized version of the `input_text`.
79
+ :type filename: str
80
+
81
+ :return: Filename of newly created `wav` file.
82
+ :rtype: str
83
+ """
84
+ audio = self.render(input_text)
85
+
86
+ if filename == "":
87
+ filename = f'{self.sanitize_for_filename(input_text)}.wav'
88
+
89
+ self.synth.save_wav(audio, filename)
90
+ return filename
91
+
92
+
93
+
94
+
95
+
@@ -0,0 +1,298 @@
1
+ import numpy as np
2
+ from scipy.io import wavfile
3
+ from scipy import signal
4
+
5
+
6
+ # Types: 'vowel', 'nasal', 'liquid', 'fricative', 'stop', 'affricate'
7
+ PHONEME_DATA = {
8
+ # VOWELS
9
+ 'AA': [730, 1090, 2440, 1.0, 0.7, 0.3, 0.15, 'vowel'],
10
+ 'AE': [660, 1720, 2410, 1.0, 0.8, 0.3, 0.15, 'vowel'],
11
+ 'AH': [640, 1190, 2390, 1.0, 0.7, 0.3, 0.12, 'vowel'],
12
+ 'AO': [570, 840, 2410, 1.0, 0.6, 0.3, 0.15, 'vowel'],
13
+ 'AW': [640, 1190, 2390, 1.0, 0.7, 0.3, 0.20, 'vowel'],
14
+ 'AY': [730, 1090, 2440, 1.0, 0.7, 0.3, 0.20, 'vowel'],
15
+ 'EH': [530, 1840, 2480, 1.0, 0.8, 0.3, 0.12, 'vowel'],
16
+ 'ER': [490, 1350, 1690, 1.0, 0.7, 0.4, 0.15, 'vowel'],
17
+ 'EY': [400, 2000, 2550, 1.0, 0.8, 0.3, 0.18, 'vowel'],
18
+ 'IH': [390, 1990, 2550, 1.0, 0.8, 0.3, 0.10, 'vowel'],
19
+ 'IY': [270, 2290, 3010, 1.0, 0.9, 0.4, 0.15, 'vowel'],
20
+ 'OW': [500, 700, 2600, 1.0, 0.6, 0.3, 0.18, 'vowel'],
21
+ 'OY': [570, 840, 2410, 1.0, 0.6, 0.3, 0.20, 'vowel'],
22
+ 'UH': [440, 1020, 2240, 1.0, 0.6, 0.3, 0.12, 'vowel'],
23
+ 'UW': [300, 870, 2240, 1.0, 0.6, 0.3, 0.15, 'vowel'],
24
+
25
+ # NASALS
26
+ 'M': [280, 1200, 2500, 0.9, 0.4, 0.2, 0.08, 'nasal'],
27
+ 'N': [280, 1700, 2600, 0.9, 0.4, 0.2, 0.07, 'nasal'],
28
+ 'NG': [280, 2200, 2600, 0.9, 0.4, 0.2, 0.09, 'nasal'],
29
+
30
+ # LIQUIDS
31
+ 'L': [300, 1300, 3000, 0.8, 0.5, 0.3, 0.07, 'liquid'],
32
+ 'R': [420, 1300, 1600, 0.8, 0.5, 0.3, 0.08, 'liquid'],
33
+
34
+ # SEMIVOWELS
35
+ 'W': [300, 610, 2200, 0.8, 0.5, 0.3, 0.08, 'liquid'],
36
+ 'Y': [280, 2250, 3000, 0.8, 0.6, 0.3, 0.06, 'liquid'],
37
+
38
+ # VOICED FRICATIVES
39
+ 'V': [200, 1000, 2500, 0.3, 0.3, 0.3, 0.09, 'fricative', {'voiced': True, 'freq': 2000}],
40
+ 'DH': [200, 1400, 2500, 0.3, 0.3, 0.3, 0.07, 'fricative', {'voiced': True, 'freq': 3500}],
41
+ 'Z': [200, 1500, 2500, 0.3, 0.4, 0.4, 0.10, 'fricative', {'voiced': True, 'freq': 5000}],
42
+ 'ZH': [200, 1500, 2000, 0.3, 0.4, 0.4, 0.11, 'fricative', {'voiced': True, 'freq': 3000}],
43
+
44
+ # UNVOICED FRICATIVES
45
+ 'F': [200, 1000, 2500, 0.4, 0.4, 0.4, 0.10, 'fricative', {'voiced': False, 'freq': 2500}],
46
+ 'TH': [200, 1400, 2500, 0.4, 0.4, 0.4, 0.09, 'fricative', {'voiced': False, 'freq': 4000}],
47
+ 'S': [200, 1500, 2500, 0.4, 0.5, 0.5, 0.12, 'fricative', {'voiced': False, 'freq': 6000}],
48
+ 'SH': [200, 1500, 2000, 0.4, 0.5, 0.5, 0.12, 'fricative', {'voiced': False, 'freq': 3500}],
49
+ 'HH': [200, 1500, 2500, 0.3, 0.3, 0.3, 0.08, 'fricative', {'voiced': False, 'freq': 2000}],
50
+
51
+ # VOICED STOPS
52
+ 'B': [200, 1000, 2500, 0.6, 0.4, 0.3, 0.08, 'stop', {'voiced': True, 'closure': 0.04, 'freq': 500}],
53
+ 'D': [200, 1700, 2500, 0.6, 0.4, 0.3, 0.07, 'stop', {'voiced': True, 'closure': 0.04, 'freq': 2000}],
54
+ 'G': [200, 2500, 3000, 0.6, 0.4, 0.3, 0.08, 'stop', {'voiced': True, 'closure': 0.05, 'freq': 2500}],
55
+
56
+ # UNVOICED STOPS
57
+ 'P': [200, 1000, 2500, 0.7, 0.5, 0.3, 0.09, 'stop', {'voiced': False, 'closure': 0.05, 'freq': 500}],
58
+ 'T': [200, 1700, 2500, 0.7, 0.5, 0.3, 0.08, 'stop', {'voiced': False, 'closure': 0.05, 'freq': 3000}],
59
+ 'K': [200, 2500, 3000, 0.7, 0.5, 0.3, 0.09, 'stop', {'voiced': False, 'closure': 0.06, 'freq': 3500}],
60
+
61
+ # AFFRICATES
62
+ 'CH': [200, 1500, 2000, 0.6, 0.5, 0.4, 0.13, 'affricate', {'voiced': False, 'closure': 0.04, 'freq': 3500}],
63
+ 'JH': [200, 1500, 2000, 0.6, 0.5, 0.4, 0.13, 'affricate', {'voiced': True, 'closure': 0.04, 'freq': 3000}],
64
+ }
65
+
66
+ class FormantSynthesizer:
67
+ def __init__(self, sample_rate=22050):
68
+ self.sample_rate = sample_rate
69
+ self.pitch = 120 # Hz
70
+
71
+ def generate_glottal_pulse(self, duration):
72
+ num_samples = int(self.sample_rate * duration)
73
+ t = np.arange(num_samples) / self.sample_rate
74
+
75
+ pulse_period = 1.0 / self.pitch
76
+ excitation = np.zeros(num_samples)
77
+
78
+ pulse_samples = int(self.sample_rate * pulse_period)
79
+ pulse = np.zeros(pulse_samples)
80
+
81
+ rise_len = int(pulse_samples * 0.4)
82
+ pulse[:rise_len] = np.linspace(0, 1, rise_len) ** 2
83
+
84
+ fall_len = int(pulse_samples * 0.16)
85
+ pulse[rise_len:rise_len+fall_len] = np.linspace(1, 0, fall_len)
86
+
87
+ num_pulses = int(duration / pulse_period) + 1
88
+ for i in range(num_pulses):
89
+ start = i * pulse_samples
90
+ end = min(start + pulse_samples, num_samples)
91
+ if start < num_samples:
92
+ excitation[start:end] = pulse[:end-start]
93
+
94
+ return excitation
95
+
96
+ def generate_noise(self, duration):
97
+ num_samples = int(self.sample_rate * duration)
98
+ return np.random.randn(num_samples)
99
+
100
+ def highpass_filter(self, audio, cutoff=500):
101
+ nyquist = self.sample_rate / 2
102
+ normalized_cutoff = cutoff / nyquist
103
+ b, a = signal.butter(4, normalized_cutoff, btype='high')
104
+ return signal.filtfilt(b, a, audio)
105
+
106
+ def bandpass_filter(self, audio, center_freq, bandwidth=1000):
107
+ nyquist = self.sample_rate / 2
108
+ low = max((center_freq - bandwidth/2) / nyquist, 0.01)
109
+ high = min((center_freq + bandwidth/2) / nyquist, 0.99)
110
+ b, a = signal.butter(3, [low, high], btype='band')
111
+ return signal.filtfilt(b, a, audio)
112
+
113
+ def formant_filter(self, audio, frequency, bandwidth) -> float:
114
+ r = np.exp(-np.pi * bandwidth / self.sample_rate)
115
+ theta = 2 * np.pi * frequency / self.sample_rate
116
+
117
+ a = [1.0, -2*r*np.cos(theta), r**2]
118
+ b = [1 - r**2]
119
+
120
+ FILTERED: float = signal.lfilter(b, a, audio)
121
+
122
+ return FILTERED
123
+
124
+ def synthesize_vowel(self, f1, f2, f3, amp1, amp2, amp3, duration):
125
+ excitation = self.generate_glottal_pulse(duration)
126
+
127
+ audio = np.zeros_like(excitation)
128
+ audio += amp1 * self.formant_filter(excitation, f1, 60)
129
+ audio += amp2 * self.formant_filter(excitation, f2, 90)
130
+ audio += amp3 * self.formant_filter(excitation, f3, 120)
131
+
132
+ return audio
133
+
134
+ def synthesize_nasal(self, f1, f2, f3, amp1, amp2, amp3, duration):
135
+ excitation = self.generate_glottal_pulse(duration)
136
+
137
+ audio = np.zeros_like(excitation)
138
+ audio += amp1 * self.formant_filter(excitation, f1, 100)
139
+ audio += amp2 * self.formant_filter(excitation, f2, 150)
140
+ audio += amp3 * self.formant_filter(excitation, f3, 200)
141
+
142
+ audio += 0.3 * self.formant_filter(excitation, 250, 100)
143
+
144
+ return audio
145
+
146
+ def synthesize_fricative(self, f1, f2, f3, amp1, amp2, amp3, duration, voiced=False, freq=3000):
147
+ if voiced:
148
+ excitation = 0.3 * self.generate_glottal_pulse(duration) + \
149
+ 0.7 * self.generate_noise(duration)
150
+ else:
151
+ excitation = self.generate_noise(duration)
152
+
153
+ audio = self.bandpass_filter(excitation, freq, 2000)
154
+
155
+ audio += amp1 * 0.3 * self.formant_filter(excitation, f1, 200)
156
+ audio += amp2 * 0.3 * self.formant_filter(excitation, f2, 200)
157
+
158
+ return audio
159
+
160
+ def synthesize_stop(self, f1, f2, f3, amp1, amp2, amp3, duration, voiced=False, closure=0.05, freq=2000):
161
+ closure_samples = int(self.sample_rate * closure)
162
+ burst_duration = duration - closure
163
+ burst_samples = int(self.sample_rate * burst_duration)
164
+
165
+ silence = np.zeros(closure_samples)
166
+
167
+ short_burst_duration = min(burst_duration * 0.3, 0.03) # Max 30ms
168
+
169
+ if voiced:
170
+ burst = 0.5 * self.generate_glottal_pulse(short_burst_duration) + \
171
+ 0.5 * self.generate_noise(short_burst_duration)
172
+ else:
173
+ burst = self.generate_noise(short_burst_duration)
174
+
175
+ burst = np.pad(burst, (0, burst_samples - len(burst)))
176
+
177
+ if len(burst) > 0:
178
+ burst = self.bandpass_filter(burst, freq, 2000)
179
+ envelope = np.exp(-np.linspace(0, 5, len(burst)))
180
+ burst = burst * envelope
181
+
182
+ return np.concatenate([silence, burst])
183
+
184
+ def synthesize_affricate(self, f1, f2, f3, amp1, amp2, amp3, duration, voiced=False, closure=0.04, freq=3000):
185
+ closure_samples = int(self.sample_rate * closure)
186
+ fric_duration = duration - closure
187
+
188
+ silence = np.zeros(closure_samples)
189
+
190
+ fricative = self.synthesize_fricative(f1, f2, f3, amp1, amp2, amp3,
191
+ fric_duration, voiced, freq)
192
+
193
+ return np.concatenate([silence, fricative])
194
+
195
+ def synthesize_phoneme(self, params):
196
+ f1, f2, f3, amp1, amp2, amp3, duration, ptype = params[:8]
197
+ extra = params[8] if len(params) > 8 else {}
198
+
199
+ if ptype == 'vowel':
200
+ audio = self.synthesize_vowel(f1, f2, f3, amp1, amp2, amp3, duration)
201
+ elif ptype == 'nasal':
202
+ audio = self.synthesize_nasal(f1, f2, f3, amp1, amp2, amp3, duration)
203
+ elif ptype == 'liquid':
204
+ audio = self.synthesize_vowel(f1, f2, f3, amp1, amp2, amp3, duration)
205
+ elif ptype == 'fricative':
206
+ audio = self.synthesize_fricative(f1, f2, f3, amp1, amp2, amp3, duration, extra.get('voiced', False), extra.get('freq', 3000))
207
+ elif ptype == 'stop':
208
+ audio = self.synthesize_stop(f1, f2, f3, amp1, amp2, amp3, duration, extra.get('voiced', False), extra.get('closure', 0.05), extra.get('freq', 2000))
209
+ elif ptype == 'affricate':
210
+ audio = self.synthesize_affricate(f1, f2, f3, amp1, amp2, amp3, duration, extra.get('voiced', False), extra.get('closure', 0.04), extra.get('freq', 3000))
211
+ else:
212
+ audio = np.zeros(int(self.sample_rate * duration))
213
+
214
+ fade_samples = min(int(0.005 * self.sample_rate), len(audio) // 4)
215
+ if fade_samples > 0 and len(audio) > 0:
216
+ envelope = np.ones(len(audio))
217
+ envelope[:fade_samples] = np.linspace(0, 1, fade_samples)
218
+ envelope[-fade_samples:] = np.linspace(1, 0, fade_samples)
219
+ audio = audio * envelope
220
+
221
+ return audio
222
+
223
+ def synthesize(self, phonemes):
224
+ audio_segments = []
225
+
226
+ for i, phoneme in enumerate(phonemes):
227
+ # i'd like to eventually add lexical stress but i've no idea where to start for that
228
+ phoneme = phoneme.rstrip('012')
229
+
230
+ if phoneme not in PHONEME_DATA:
231
+ print(f"Warning: Unknown phoneme '{phoneme}', adding silence")
232
+ audio_segments.append(np.zeros(int(self.sample_rate * 0.05)))
233
+ continue
234
+
235
+ params = PHONEME_DATA[phoneme]
236
+ audio = self.synthesize_phoneme(params)
237
+ audio_segments.append(audio)
238
+
239
+ if not audio_segments:
240
+ return np.zeros(self.sample_rate)
241
+
242
+ audio = np.concatenate(audio_segments)
243
+
244
+ if np.max(np.abs(audio)) > 0:
245
+ audio = audio / np.max(np.abs(audio)) * 0.8
246
+
247
+ return audio
248
+
249
+ def pitch_shift(self, audio, percent):
250
+ """
251
+ Pitch shift audio by a given percentage.
252
+ Positive percent = pitch up, Negative percent = pitch down.
253
+
254
+ :param audio: input audio array
255
+ :param percent: percentage to shift
256
+ :return: pitch-shifted audio
257
+ """
258
+ if percent == 0.0:
259
+ return audio
260
+
261
+ ratio = 1.0 + (percent / 100.0)
262
+
263
+ new_length = int(len(audio) / ratio)
264
+ return signal.resample(audio, new_length)
265
+
266
+ def generate_silence(self, milliseconds):
267
+ duration_seconds = milliseconds / 1000.0
268
+ num_samples = int(self.sample_rate * duration_seconds)
269
+ return np.zeros(num_samples)
270
+
271
+ def save_wav(self, audio, filename):
272
+ audio_int = np.int16(audio * 32767)
273
+ wavfile.write(filename, self.sample_rate, audio_int)
274
+ print(f"Saved to {filename}")
275
+
276
+ def play(self, audio, blocking=True):
277
+ from sounddevice import play, wait
278
+
279
+ play(audio, self.sample_rate)
280
+ if blocking:
281
+ wait()
282
+
283
+
284
+ if __name__ == "__main__":
285
+ synth = FormantSynthesizer()
286
+
287
+ test_words = {
288
+ 'this is dylans text to speech synthesizer': 'DH IH1 S IH1 Z D IH1 L AH0 N Z T EH1 K S T T UW1 S P IY1 CH S IH1 N TH AH0 S AY2 Z ER0'.split(),
289
+ 'sahara please set me free': 'S AH0 HH EH1 R AH0 P L IY1 Z S EH1 T M IY1 F R IY1'.split(),
290
+ 'its me five nights at freddys ar ar ar ar': 'IH1 T S M IY1 F AY1 V N AY1 T S AE1 T F R EH1 D IY0 Z AA1 R AA1 R AA1 R AA1 R'.split(),
291
+ }
292
+
293
+ for word, phonemes in test_words.items():
294
+ print(f"Synthesizing: {word}")
295
+ audio = synth.synthesize(phonemes)
296
+ synth.save_wav(audio, f'test_word_files/{word}.wav')
297
+
298
+ print("\nDone!")
@@ -0,0 +1,284 @@
1
+ import re
2
+ from .tokenization import TokenList, Token
3
+ # I moved these out here to keep from having to recompile all of the regexs everytime the replace_punctuation_and_expand_abreviations() runs
4
+
5
+ COMMON_REPLACEMENT_RULES = [
6
+ # Titles
7
+ (re.compile(r'\bmr\.'), 'mister'),
8
+ (re.compile(r'\bmrs\.'), 'missus'),
9
+ (re.compile(r'\bms\.'), 'miss'),
10
+ (re.compile(r'\bdr\.'), 'doctor'),
11
+ (re.compile(r'\bprof\.'), 'professor'),
12
+ (re.compile(r'\brev\.'), 'reverend'),
13
+ (re.compile(r'\bgen\.'), 'general'),
14
+ (re.compile(r'\bsen\.'), 'senator'),
15
+ (re.compile(r'\brep\.'), 'representative'),
16
+ (re.compile(r'\bgov\.'), 'governor'),
17
+ (re.compile(r'\bcol\.'), 'colonel'),
18
+ (re.compile(r'\bcapt\.'), 'captain'),
19
+
20
+ # Common abbreviations
21
+ (re.compile(r'\be\.g\.'), 'for example'),
22
+ (re.compile(r'\bi\.e\.'), 'that is'),
23
+ (re.compile(r'\betc\.'), 'et cetera'),
24
+ (re.compile(r'\bvs\.'), 'versus'),
25
+ (re.compile(r'\bvs\b'), 'versus'),
26
+ (re.compile(r'\bet al\.'), 'and others'),
27
+ (re.compile(r'\bapprox\.'), 'approximately'),
28
+ (re.compile(r'\bdept\.'), 'department'),
29
+ (re.compile(r'\bfig\.'), 'figure'),
30
+ (re.compile(r'\bno\.'), 'number'),
31
+ (re.compile(r'\bpg\.'), 'page'),
32
+ (re.compile(r'\bvol\.'), 'volume'),
33
+ (re.compile(r'\bch\.'), 'chapter'),
34
+ (re.compile(r'\bsec\.'), 'section'),
35
+
36
+ # Time
37
+ (re.compile(r'\b(a\.m\.)'), ' a m '),
38
+ (re.compile(r'\b(p\.m\.)'), ' p m '),
39
+
40
+ # Units (avoiding numbers)
41
+ (re.compile(r'(?<=\d)\s*ft\.'), ' feet '),
42
+ (re.compile(r'(?<=\d)\s*in\.'), ' inches '),
43
+ (re.compile(r'(?<=\d)\s*lb\.'), ' pounds '),
44
+ (re.compile(r'(?<=\d)\s*lbs\.'), ' pounds '),
45
+ (re.compile(r'(?<=\d)\s*oz\.'), ' ounces '),
46
+
47
+ # Organizations
48
+ (re.compile(r'\bcorp\.'), 'corporation'),
49
+ (re.compile(r'\bco\.'), 'company'),
50
+ (re.compile(r'\binc\.'), 'incorporated'),
51
+ (re.compile(r'\bltd\.'), 'limited'),
52
+ (re.compile(r'\bl\.l\.c\.'), ' l l c '),
53
+
54
+ # Directions
55
+ (re.compile(r'\bn\.'), 'north'),
56
+ (re.compile(r'\bs\.'), 'south'),
57
+ (re.compile(r'\be\.'), 'east'),
58
+ (re.compile(r'\bw\.'), 'west'),
59
+ (re.compile(r'\bne\b'), 'north east'),
60
+ (re.compile(r'\bnw\b'), 'north west'),
61
+ (re.compile(r'\bse\b'), 'south east'),
62
+ (re.compile(r'\bsw\b'), 'south west'),
63
+
64
+ ]
65
+
66
+ PUNCTUATION_REPLACEMENT_RULES = [
67
+ # These three have to run first to avoid some edge cases where they replace some of the tokens
68
+ (re.compile(r'\[',), ' open bracket '),
69
+ (re.compile(r'\]'), ' close bracket '),
70
+ (re.compile(r'(?<=\d),(?=\d)'), ''), # Remove commas in numbers
71
+
72
+ # Dollar sign
73
+ (re.compile(r'\$(\d+(?:\.\d+)?)'), r'\1$'), # Swaps around the dollar sign to the end
74
+ (re.compile(r'(?<!\d)\$(?!\d)'), ' dollar sign '),
75
+
76
+ # Dots
77
+ (re.compile(r'\.\.\.'), ' dot dot dot '),
78
+ (re.compile(r'(?<=\d)\.(?=\d)'), ' point '),
79
+ (re.compile(r'(?<=\w)\.(?=\w)'), ' dot '),
80
+
81
+ (re.compile(r'(\d+)\$'), r'\1 dollars '),
82
+
83
+ # Sentence endings
84
+ (re.compile(r'\.'), ' [PERIOD_SILENCE] '),
85
+ (re.compile(r'\?'), ' [QUESTION] '),
86
+ (re.compile(r'!'), ' [EXCLAMATION] '),
87
+
88
+ # Pauses
89
+ (re.compile(r','), ' [COMMA_SILENCE] '),
90
+ (re.compile(r':'), ' colon '),
91
+ (re.compile(r';'), ' semi colon '),
92
+
93
+ # Dashes and hyphens
94
+ (re.compile(r'—'), ' dash '), # em dash
95
+ (re.compile(r'–'), ' dash '), # en dash
96
+ (re.compile(r'--'), ' dash '),
97
+ (re.compile(r'(?<=\w)-(?=\w)'), ' '), # hyphen between words (remove)
98
+ (re.compile(r'(?<=\s)-(?=\s)'), ' dash '), # spaced dash
99
+
100
+ # Parentheses and brackets
101
+ (re.compile(r'\('), ' open parenthesis '),
102
+ (re.compile(r'\)'), ' close parenthesis '),
103
+
104
+
105
+ (re.compile(r'"'), ' [QUOTE] '),
106
+
107
+ # Slashes
108
+ (re.compile(r'/'), ' slash '),
109
+ (re.compile(r'\\'), ' back slash '),
110
+
111
+ # Ampersand
112
+ (re.compile(r'&'), ' and '),
113
+
114
+ # At symbol
115
+ (re.compile(r'@'), ' at '),
116
+
117
+ # Hashtag
118
+ (re.compile(r'#'), ' hashtag '),
119
+
120
+ # Percent sign
121
+ (re.compile(r'%'), ' percent '),
122
+
123
+ # Asterisk
124
+ (re.compile(r'\*'), ' asterisk '),
125
+ (re.compile(r'\^'), ' caret '),
126
+ ]
127
+
128
+ MULTIPLE_SPACES_PATTERN = re.compile(r'\s+')
129
+
130
+ def numbers_to_words(number: int) -> list[str]:
131
+ """
132
+ Okay this is sorta shitass but it takes in an integer and returns the word version of that integer.
133
+
134
+ :param int number: The number you want to turn into words
135
+ :return: The number specified in word form
136
+ :rtype: list[str]
137
+
138
+ For example:
139
+
140
+ ```
141
+ numbers_to_words(1234567890)
142
+ ```
143
+
144
+ returns
145
+
146
+ ```
147
+ one billion two hundred thirty four million five hundred sixty seven thousand eight hundred ninety
148
+ ```
149
+
150
+
151
+ """
152
+ if number == 0:
153
+ return ["zero"]
154
+
155
+ def three_digits(NUMBER):
156
+ ONES = ["", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
157
+ TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"]
158
+
159
+ if NUMBER < 20:
160
+ return ONES[NUMBER]
161
+ elif NUMBER < 100:
162
+ return TENS[NUMBER // 10] + ( " " + ONES[NUMBER % 10] if NUMBER % 10 != 0 else "")
163
+ else:
164
+ return ONES[NUMBER // 100] + " hundred" + (" " + three_digits(NUMBER % 100) if NUMBER % 100 != 0 else "")
165
+
166
+ SCALES = ["", "thousand", "million", "billion", "trillion"]
167
+ words = []
168
+
169
+ for SCALE in SCALES:
170
+ if number % 1000 != 0:
171
+ words.append(three_digits(number % 1000) + (" " + SCALE if SCALE else ""))
172
+ number //= 1000
173
+ if number == 0:
174
+ break
175
+
176
+ return (" ".join(reversed(words)).strip()).split()
177
+
178
+
179
+ def replace_punctuation_and_expand_abreviations(text: str) -> str:
180
+ """
181
+ Replaces punctuation and expands abreviations into full words in the given text. This is a preprocessing step for the text normalization step. Needs to be run **BEFORE** tokenization.
182
+
183
+ :param text: Text you want to expand
184
+ :type text: str
185
+ :return: Expanded text
186
+ :rtype: str
187
+ """
188
+ text = str.lower(text)
189
+
190
+ # I separated out COMMON_REPLACEMENT_RULES and PUNCTUATION_REPLACEMENT_RULES to avoid edge cases like "Hello, Dr. Oz. How are you?"
191
+ # being replaced with "hello [COMMA_SILENCE] dr [PERIOD_SILENCE] oz [PERIOD_SILENCE] how are you?"
192
+ # instead of the correct "hello [COMMA_SILENCE] doctor oz [PERIOD_SILENCE] how are you?"
193
+
194
+ for pattern, replacement in COMMON_REPLACEMENT_RULES:
195
+ text = pattern.sub(replacement, text)
196
+
197
+ for pattern, replacement in PUNCTUATION_REPLACEMENT_RULES:
198
+ text = pattern.sub(replacement, text)
199
+
200
+ #text = re.sub(MULTIPLE_SPACES_PATTERN, ' ', text).strip()
201
+ text = MULTIPLE_SPACES_PATTERN.sub(' ', text).strip()
202
+
203
+ return text
204
+
205
+ def set_modifiers_from_table(tokens: TokenList) -> TokenList:
206
+ MODIFIER_TABLE = { # MODIFIER_NAME: [SILENCE_TIME (ms), PITCH_MODIFIER (%), MODIFIES_PREVIOUS_TOKEN]
207
+ '[PERIOD_SILENCE]': [300.0, 0.0, False],
208
+ '[COMMA_SILENCE]': [190.0, 0.0, False],
209
+ '[QUESTION]': [280.0, 20.0, True],
210
+ '[EXCLAMATION]': [280.0, 5.0, True],
211
+ '[QUOTE]': [0.0, 0.0, False], # All quotes will be removed and replaced with proper words during normalization
212
+ }
213
+
214
+ for token in tokens:
215
+ text = token.get_text()
216
+
217
+ if text in MODIFIER_TABLE:
218
+ token.set_modifier_flag(True)
219
+ token.set_silence_time(MODIFIER_TABLE[text][0])
220
+ token.set_pitch_modifier(MODIFIER_TABLE[text][1])
221
+ token.set_modifies_previous_token_flag(MODIFIER_TABLE[text][2])
222
+ else:
223
+ token.set_speakable_flag(True)
224
+
225
+ return tokens
226
+
227
+
228
+
229
+ def normalize_text(text: str) -> TokenList:
230
+ """
231
+ Takes text and transforms it into a more speakable version of it (in token form)
232
+
233
+ ## Example
234
+
235
+ ```python
236
+ normalize_text('she said, "Hello World" and that I owe her $123.')
237
+ ```
238
+
239
+ Returns a tokenized list that looks like this:
240
+
241
+ ```python
242
+ [{'text': 'she'}, {'text': 'said'}, {'text': '[COMMA_SILENCE]', 'modifies_previous_token': False, 'silence_time': 190.0, 'pitch_modifier': 0.0}, {'text': 'quote'}, {'text': 'hello'}, {'text': 'world'}, {'text': 'unquote'}, {'text': 'and'}, {'text': 'that'}, {'text': 'i'}, {'text': 'owe'}, {'text': 'her'}, {'text': 'one'}, {'text': 'hundred'}, {'text': 'twenty'}, {'text': 'three'}, {'text': 'dollars'}, {'text': '[PERIOD_SILENCE]', 'modifies_previous_token': False, 'silence_time': 300.0, 'pitch_modifier': 0.0}]
243
+ ```
244
+
245
+ :param text: Text to normalize
246
+ :type text: str
247
+ :return: Normalized text
248
+ :rtype: TokenList
249
+ """
250
+
251
+ text = replace_punctuation_and_expand_abreviations(text)
252
+ tokens = TokenList(text)
253
+
254
+ expanded_tokens = []
255
+ quote_opened = False
256
+
257
+ for token in tokens:
258
+ if token.get_text().isdigit():
259
+ token_converted_to_words = numbers_to_words(int(token.get_text()))
260
+ for word in token_converted_to_words:
261
+ expanded_tokens.append(Token(TEXT=word))
262
+ elif token.get_text() == '[QUOTE]':
263
+ if not quote_opened:
264
+ expanded_tokens.append(Token(TEXT='quote'))
265
+ else:
266
+ expanded_tokens.append(Token(TEXT='unquote'))
267
+
268
+ quote_opened = not quote_opened
269
+ else:
270
+ expanded_tokens.append(token)
271
+
272
+ tokens.set_list(expanded_tokens)
273
+
274
+ tokens = set_modifiers_from_table(tokens)
275
+
276
+ return tokens
277
+
278
+ def main():
279
+ normalized_text = normalize_text('she said, "Hello World" and that I owe her $123.')
280
+ print(normalized_text)
281
+
282
+
283
+ if __name__ == '__main__':
284
+ main()
@@ -0,0 +1,130 @@
1
+ class Token:
2
+ def __init__(self, TEXT: str = "", IS_MODIFIER: bool = False, MODIFIES_PREVIOUS_TOKEN: bool = False, IS_SPEAKABLE: bool = False, SILENCE_TIME: float = 0.0, PITCH_MODIFIER: float = 0.0):
3
+ self._text: str = TEXT.strip()
4
+ self._phoneme : list[str] = []
5
+ self._is_modifier: bool = IS_MODIFIER
6
+ self._is_speakable: bool = IS_SPEAKABLE
7
+
8
+ if self._is_modifier:
9
+ self._modifies_previous_token: bool = MODIFIES_PREVIOUS_TOKEN
10
+ self._silence_time: float = SILENCE_TIME
11
+ self._pitch_modifier: float = PITCH_MODIFIER
12
+ pass
13
+
14
+ def set_text(self, TEXT: str):
15
+ self._text = TEXT
16
+
17
+ def get_text(self) -> str:
18
+ return self._text
19
+
20
+ def set_phoneme(self, PHONEME: list[str]):
21
+ self._phoneme = PHONEME
22
+
23
+ def get_phoneme(self) -> list[str]:
24
+ return self._phoneme
25
+
26
+ def set_modifier_flag(self, IS_MODIFIER: bool):
27
+ self._is_modifier = IS_MODIFIER
28
+
29
+ def get_modifier_flag(self) -> bool:
30
+ return self._is_modifier
31
+
32
+ def set_modifies_previous_token_flag(self, MODIFIES_PREVIOUS_TOKEN: bool):
33
+ self._modifies_previous_token = MODIFIES_PREVIOUS_TOKEN
34
+
35
+ def get_modifies_previous_token_flag(self) -> bool:
36
+ return self._modifies_previous_token
37
+
38
+ def set_silence_time(self, SILENCE_TIME: float):
39
+ self._silence_time = SILENCE_TIME
40
+
41
+ def get_silence_time(self) -> float:
42
+ return self._silence_time
43
+
44
+ def set_pitch_modifier(self, PITCH_MODIFIER: float):
45
+ self._pitch_modifier = PITCH_MODIFIER
46
+
47
+ def get_pitch_modifier(self) -> float:
48
+ return self._pitch_modifier
49
+
50
+ def set_speakable_flag(self, IS_SPEAKABLE: bool):
51
+ self._is_speakable = IS_SPEAKABLE
52
+
53
+ def get_speakable_flag(self) -> bool:
54
+ return self._is_speakable
55
+
56
+
57
+ class TokenList:
58
+ def __init__(self, input_data=None):
59
+
60
+ if input_data is None:
61
+ self._tokens: list[Token] = []
62
+ elif isinstance(input_data, str): # check if it's a string
63
+ self._tokens: list[Token] = self.list_to_tokens(input_data.split())
64
+ elif isinstance(input_data, list): # check if it's a list of tokens
65
+ if all(isinstance(item, Token) for item in input_data):
66
+ self._tokens: list[Token] = input_data
67
+ else: #it's probably a list of strings if it's not a list of tokens
68
+ self._tokens: list[Token] = self.list_to_tokens(input_data)
69
+ else:
70
+ raise TypeError("TokenList requires a string, list of strings, or list of Tokens")
71
+
72
+ def __iter__(self):
73
+ return iter(self._tokens)
74
+
75
+ def list_to_tokens(self, LIST_OF_RAW_TEXT: list[str]) -> list[Token]:
76
+ output_list: list[Token] = []
77
+
78
+ for text in LIST_OF_RAW_TEXT:
79
+ text_token = Token(TEXT=text)
80
+ output_list.append(text_token)
81
+
82
+ return output_list
83
+
84
+ def to_dict_list(self):
85
+ output_list = []
86
+
87
+ for token in self._tokens:
88
+ if token._is_modifier:
89
+ output_list.append({'text': token._text, 'modifies_previous_token': token._modifies_previous_token, 'is_speakable': token._is_speakable, 'silence_time': token._silence_time, 'pitch_modifier': token._pitch_modifier})
90
+ else:
91
+ output_list.append({'text': token._text, 'phoneme': token._phoneme})
92
+ return output_list
93
+
94
+ def to_json(self):
95
+ return self.to_dict_list()
96
+
97
+ def __str__(self):
98
+ return str(self.to_dict_list())
99
+
100
+ def get(self, index: int) -> Token:
101
+ TOKEN_LIST_SIZE: int = len(self._tokens)
102
+ if index > TOKEN_LIST_SIZE or index < 0:
103
+ raise IndexError(f"Attempted to access out of bounds token {index}")
104
+
105
+ return self._tokens[index]
106
+
107
+ def set(self, index: int, token: Token):
108
+ TOKEN_LIST_SIZE: int = len(self._tokens)
109
+ if index > TOKEN_LIST_SIZE + 1 or index < 0: # add one to token list size because in that case, it will just be appended at the end of the list
110
+ raise IndexError(f"Attempted to access out of bounds token {index}")
111
+
112
+ if index == TOKEN_LIST_SIZE + 1:
113
+ self.append(token)
114
+ else:
115
+ self._tokens[index] = token
116
+
117
+ def set_list(self, token_list: list[Token]):
118
+ self._tokens = token_list
119
+
120
+ def append(self, token: Token):
121
+ self._tokens.append(token)
122
+
123
+ def main():
124
+ token = Token("Hello")
125
+ tokens = TokenList(["hello", "world"])
126
+
127
+ print(token)
128
+ print(tokens)
129
+ if __name__ == "__main__":
130
+ main()