barebones-tts 0.1.0__py3-none-any.whl
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.
- barebones_tts/__init__.py +4 -0
- barebones_tts/__main__.py +47 -0
- barebones_tts/arpabet.py +76 -0
- barebones_tts/core.py +95 -0
- barebones_tts/data/pronunciation.pkl +0 -0
- barebones_tts/formant_synth.py +298 -0
- barebones_tts/text_normalization.py +284 -0
- barebones_tts/tokenization.py +130 -0
- barebones_tts-0.1.0.dist-info/METADATA +59 -0
- barebones_tts-0.1.0.dist-info/RECORD +13 -0
- barebones_tts-0.1.0.dist-info/WHEEL +4 -0
- barebones_tts-0.1.0.dist-info/entry_points.txt +2 -0
- barebones_tts-0.1.0.dist-info/licenses/LICENSE +21 -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()
|
barebones_tts/arpabet.py
ADDED
|
@@ -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()
|
barebones_tts/core.py
ADDED
|
@@ -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
|
+
|
|
Binary file
|
|
@@ -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()
|
|
@@ -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,13 @@
|
|
|
1
|
+
barebones_tts/__init__.py,sha256=7VRnQ0g8erNbJ1LGrCcPLZGGk3p4q6AL6_DE0TRAyzE,83
|
|
2
|
+
barebones_tts/__main__.py,sha256=eJMDMFdKPZmRiXXRxsJyiKiU7dajxUpGR7wvT-nB7s0,1030
|
|
3
|
+
barebones_tts/arpabet.py,sha256=VZ1BB1rioancu8E0i6beQ99_c4PcRhM5pe4rWCxcT1M,2258
|
|
4
|
+
barebones_tts/core.py,sha256=BWfYDIeL4Y2wAs4sKy3qp1W7GZt8EXgmhTSZMpFHBWQ,3038
|
|
5
|
+
barebones_tts/formant_synth.py,sha256=_aYjRoVzM1SZPmzWUGYXv21MSMg_dCynNhAfhzaL7Kg,12776
|
|
6
|
+
barebones_tts/text_normalization.py,sha256=nkHn8UQZkLcQuEkapGr7VrypTlGCIlhdCzOqVnShILI,9689
|
|
7
|
+
barebones_tts/tokenization.py,sha256=i2RN0uk2ayF3ljdnr6vyB99jUF5tlTV0H-jz7FaUjsk,4573
|
|
8
|
+
barebones_tts/data/pronunciation.pkl,sha256=-yS2M4z3Ywb3GKytZDIWR--qUaytnY7q_3syJNR119Y,3835147
|
|
9
|
+
barebones_tts-0.1.0.dist-info/METADATA,sha256=k5hxaQvt5Z0OxxktgtMzXqpY8Pio-rNYex8eAp5pEuc,1692
|
|
10
|
+
barebones_tts-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
barebones_tts-0.1.0.dist-info/entry_points.txt,sha256=yxDmzG1tTgTX1xtffGIjKFviGJQqpbaV3CyBMd0psH4,62
|
|
12
|
+
barebones_tts-0.1.0.dist-info/licenses/LICENSE,sha256=ZNgmQHhfQREuX292wYq3U9Bt6wRlk3mJFOEvr1MoMVQ,1067
|
|
13
|
+
barebones_tts-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|