Repository navigation
Expand file tree
/
Copy pathspeech_engine.py
More file actions
152 lines (121 loc) · 3.98 KB
/
Copy pathspeech_engine.py
File metadata and controls
152 lines (121 loc) · 3.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""
Speech Engine — Uses edge-tts (Microsoft Edge's neural TTS) to render
speech with voice and prosody control.
edge-tts provides free access to Microsoft's neural TTS voices with support for:
- 50+ neural voices
- Rate control (speaking speed)
- Pitch control (in Hz offset)
- Volume control
"""
import asyncio
import re
import tempfile
import edge_tts
def _sanitize_pitch(pitch_str: str) -> str:
"""
Convert any pitch format to Hz format required by edge-tts.
edge-tts requires pitch in '+NHz' or '-NHz' format.
"""
if not pitch_str or pitch_str in ("+0%", "0%", "0", "+0", "+0Hz"):
return "+0Hz"
# Already in Hz format
match_hz = re.match(r"([+-]?\d+)Hz$", pitch_str)
if match_hz:
val = int(match_hz.group(1))
sign = "+" if val >= 0 else ""
return f"{sign}{val}Hz"
# Percentage format → convert to Hz (roughly 1% ≈ 1Hz)
match_pct = re.match(r"([+-]?)(\d+)%?$", pitch_str)
if match_pct:
sign = match_pct.group(1) or "+"
value = int(match_pct.group(2))
return f"{sign}{value}Hz"
return "+0Hz"
def _sanitize_rate(rate_str: str) -> str:
"""Ensure rate is in valid format for edge-tts (e.g., '+20%')."""
if not rate_str or rate_str in ("0", "+0", "normal"):
return "+0%"
match = re.match(r"([+-]?)(\d+)%?$", rate_str)
if match:
sign = match.group(1) or "+"
value = int(match.group(2))
# Clamp to reasonable range
value = min(100, max(0, value))
return f"{sign}{value}%"
return "+0%"
def _sanitize_volume(volume_str: str) -> str:
"""Ensure volume is in valid format for edge-tts (e.g., '+20%')."""
if not volume_str or volume_str in ("0", "+0", "default", "medium"):
return "+0%"
# Already percentage
match = re.match(r"([+-]?)(\d+)%?$", volume_str)
if match:
sign = match.group(1) or "+"
value = int(match.group(2))
value = min(50, max(0, value))
return f"{sign}{value}%"
return "+0%"
async def _synthesize_async(
text: str,
voice: str = "en-US-AriaNeural",
rate: str = "+0%",
pitch: str = "+0Hz",
volume: str = "+0%",
output_path: str | None = None,
) -> tuple[bytes, str]:
"""Internal async synthesis function."""
if output_path is None:
output_path = tempfile.mktemp(suffix=".mp3")
# Sanitize all parameters
pitch = _sanitize_pitch(pitch)
rate = _sanitize_rate(rate)
volume = _sanitize_volume(volume)
communicate = edge_tts.Communicate(
text=text,
voice=voice,
rate=rate,
pitch=pitch,
volume=volume,
)
await communicate.save(output_path)
with open(output_path, "rb") as f:
audio_bytes = f.read()
return audio_bytes, output_path
def synthesize(
text: str,
voice: str = "en-US-AriaNeural",
rate: str = "+0%",
pitch: str = "+0Hz",
volume: str = "+0%",
output_path: str | None = None,
) -> tuple[bytes, str]:
"""
Synthesize speech from text with voice parameters.
Args:
text: Content text to speak
voice: Microsoft neural voice name
rate: Speaking rate (e.g., "+20%", "-10%")
pitch: Pitch adjustment in Hz (e.g., "+10Hz", "-5Hz")
volume: Volume adjustment (e.g., "+20%", "-10%")
output_path: Save path (None = temp file)
Returns:
(audio_bytes, output_path)
"""
return asyncio.run(_synthesize_async(
text=text,
voice=voice,
rate=rate,
pitch=pitch,
volume=volume,
output_path=output_path,
))
async def _list_voices_async() -> list[dict]:
"""List all available voices."""
voices = await edge_tts.list_voices()
return voices
def list_voices(language: str = "en") -> list[dict]:
"""List available voices, optionally filtered by language."""
voices = asyncio.run(_list_voices_async())
if language:
voices = [v for v in voices if v["Locale"].startswith(language)]
return voices