File size: 12,794 Bytes
7b3b340
 
 
 
 
 
 
 
 
0596274
44c7b6f
ff3ad52
 
eae282d
ff3ad52
eae282d
7b3b340
ff3ad52
 
 
7b3b340
ff3ad52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284179e
7b3b340
 
ff3ad52
7b3b340
 
ff3ad52
 
27bebc1
 
ff3ad52
7b3b340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ff3ad52
7b3b340
 
 
 
 
 
 
 
 
 
37ad470
ba3a67a
 
 
7b3b340
 
 
 
 
ba3a67a
 
7b3b340
 
 
b184cb6
7b3b340
ff3ad52
7b3b340
 
 
 
ff3ad52
b184cb6
7b3b340
 
ff3ad52
 
b184cb6
ff3ad52
b184cb6
ff3ad52
7b3b340
ff3ad52
 
 
7b3b340
b184cb6
 
5021a0c
7b3b340
 
ff3ad52
7b3b340
555abcf
 
 
 
298c01a
555abcf
 
 
7b3b340
ff3ad52
7b3b340
ff3ad52
 
 
7b3b340
 
 
 
 
 
 
ff3ad52
 
 
 
 
7b3b340
 
 
 
 
ff3ad52
 
7b3b340
ff3ad52
 
 
7b3b340
ff3ad52
 
 
555abcf
552e1db
7b3b340
 
552e1db
 
5021a0c
552e1db
4bccf88
7b3b340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
import spaces
import gradio as gr
import edge_tts
import asyncio
import tempfile
import os
import re
from pathlib import Path
from pydub import AudioSegment

def get_silence(duration_ms=1000):
    # Create silent audio segment with specified parameters
    silent_audio = AudioSegment.silent(
        duration=duration_ms,
        frame_rate=24000  # 24kHz sampling rate
    )

    # Set audio parameters
    silent_audio = silent_audio.set_channels(1)  # Mono
    silent_audio = silent_audio.set_sample_width(4)  # 32-bit (4 bytes per sample)

    with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp_file:
        # Export with specific bitrate and codec parameters
        silent_audio.export(
            tmp_file.name,
            format="mp3",
            bitrate="48k",
            parameters=[
                "-ac", "1",  # Mono
                "-ar", "24000",  # Sample rate
                "-sample_fmt", "s32",  # 32-bit samples
                "-codec:a", "libmp3lame"  # MP3 codec
            ]
        )
        return tmp_file.name

# Get all available voices
async def get_voices():
    voices = await edge_tts.list_voices()
    return {f"{v['ShortName']} - {v['Locale']} ({v['Gender']})": v['ShortName'] for v in voices}

async def generate_audio_with_voice_prefix(text_segment, default_voice, rate, pitch):
    """Generates audio for a text segment, handling voice prefixes."""
    current_voice_full = default_voice
    current_voice_short = current_voice_full.split(" - ")[0] if current_voice_full else ""
    current_rate = rate
    current_pitch = pitch
    processed_text = text_segment.strip()
    voice1_full = "en-AU-WilliamNeural - en-AU (Male)"
    voice1_short = voice1_full.split(" - ")[0]
    voice1F_full ="en-GB-SoniaNeural - en-GB (Female)"
    voice1F_short = voice1F_full.split(" - ")[0]
    voice2_full = "en-GB-RyanNeural - en-GB (Male)"
    voice2_short = voice2_full.split(" - ")[0]
    voice2F_full = "en-US-JennyNeural - en-US (Female)"
    voice2F_short = voice2F_full.split(" - ")[0]
    voice3_full ="en-US-BrianMultilingualNeural - en-US (Male)"  #good for reading
    voice3_short = voice3_full.split(" - ")[0]
    voice3F_full = "en-HK-YanNeural - en-HK (Female)"
    voice3F_short = voice3F_full.split(" - ")[0]
    voice4_full = "en-GB-ThomasNeural - en-GB (Male)"
    voice4_short = voice4_full.split(" - ")[0]
    voice4F_full ="en-US-EmmaNeural - en-US (Female)"
    voice4F_short = voice4F_full.split(" - ")[0]
    voice5_full = "en-GB-RyanNeural - en-GB (Male)" #Old Man
    voice5_short = voice5_full.split(" - ")[0]
    voice6_full = "en-GB-MaisieNeural - en-GB (Female)"  #Child
    voice6_short = voice6_full.split(" - ")[0]
    voice7_full = "vi-VN-HoaiMyNeural - vi-VN (Female)"  #Vietnamese
    voice7_short = voice7_full.split(" - ")[0]
    voice8_full = "vi-VN-NamMinhNeural - vi-VN (Male)"  #Vietnamese
    voice8_short = voice8_full.split(" - ")[0]
    voice9F_full = "de-DE-SeraphinaMultilingualNeural - de-DE (Female)"  #Vietnamese
    voice9F_short = voice7_full.split(" - ")[0]
    voice9_full = "ko-KR-HyunsuMultilingualNeural - ko-KR (Male)"  #Vietnamese
    voice9_short = voice8_full.split(" - ")[0]
    detect=0
    if processed_text.startswith("1F"):
        current_voice_short = voice1F_short
        current_pitch = 25
        detect=1
        #processed_text = processed_text[2:].strip()
    elif processed_text.startswith("2F"):
        current_voice_short = voice2F_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("3F"):
        current_voice_short = voice3F_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("4F"):
        current_voice_short = voice4F_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("1M"):
        current_voice_short = voice1_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("2M"):
        current_voice_short = voice2_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("3M"):
        current_voice_short = voice3_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("4M"):
        current_voice_short = voice4_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("1O"):  # Old man voice
        current_voice_short = voice5_short
        current_pitch = -20
        current_rate = -10
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("1C"):  #Child voice
        current_voice_short = voice6_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("1V"):  #Female VN
        current_voice_short = voice7_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("2V"):
        current_voice_short = voice8_short
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("3V"):  #Female VN
        current_voice_short = voice9F_short
        current_pitch = 25
        #processed_text = processed_text[2:].strip()
        detect=1
    elif processed_text.startswith("4V"):
        current_voice_short = voice9_short
        current_pitch = -20
        #processed_text = processed_text[2:].strip()
        detect=1
    #Looking for number following prefix, which are pitch values.
    #match = re.search(r'[A-Za-z]\d+', part)  # Look for a letter followed by one or more digits
    match = re.search(r'[A-Za-z]+\-?\d+', processed_text)  # Look for a letter(s) followed by an optional '-' and digits
    if match:
        # Extract the prefix (e.g., '2F') and number (e.g., '-20')
        prefix = ''.join([ch for ch in match.group() if ch.isalpha()])  # Extract letters (prefix)
        number = int(''.join([ch for ch in match.group() if ch.isdigit() or ch == '-']))  # Extract digits (number)
        current_pitch += number
        # Step 2: Remove the found number from the string
        new_text = re.sub(r'[A-Za-z]+\-?\d+', '', processed_text, count=1).strip()  # Remove prefix and number (e.g., '2F-20')
        #processed_text = new_text[2:]  #cut out the prefix like 1F, 3M etc
        processed_text = new_text[len(prefix):]  # Dynamically remove the prefix part
    else:
        if detect:
            processed_text = processed_text[2:]
    if processed_text:
        rate_str = f"{current_rate:+d}%"
        pitch_str = f"{current_pitch:+d}Hz"
        communicate = edge_tts.Communicate(processed_text, current_voice_short, rate=rate_str, pitch=pitch_str)
        with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp_file:
            audio_path = tmp_file.name
            await communicate.save(audio_path)
        return audio_path
    return None

async def process_transcript_line(line, default_voice, rate, pitch):
    """Processes a single transcript line with HH:MM:SS.milliseconds timestamp and quoted text segments."""
    match = re.match(r'(\d{2}):(\d{2}):(\d{2})\.(\d{3})\s+(.*)', line)
    if match:
        hours, minutes, seconds, milliseconds, text_parts = match.groups()
        start_time_ms = (
            int(hours) * 3600000 +
            int(minutes) * 60000 +
            int(seconds) * 1000 +
            int(milliseconds)
        )
        audio_segments = []
        split_parts = re.split(r'(")', text_parts)  # Split by quote marks, keeping the quotes

        process_next = False
        for part in split_parts:
            if part == '"':
                process_next = not process_next
                continue
            if process_next and part.strip():
                audio_path = await generate_audio_with_voice_prefix(part, default_voice, rate, pitch)
                if audio_path:
                    audio_segments.append(audio_path)
            elif not process_next and part.strip():
                audio_path = await generate_audio_with_voice_prefix(part, default_voice, rate, pitch) # Process unquoted text with default voice
                if audio_path:
                    audio_segments.append(audio_path)

        return start_time_ms, audio_segments
    return None, None

async def transcript_to_speech(transcript_text, voice, rate, pitch):
    if not transcript_text.strip():
        return None, gr.Warning("Please enter transcript text.")
    if not voice:
        return None, gr.Warning("Please select a voice.")

    lines = transcript_text.strip().split('\n')
    timed_audio_segments = []
    max_end_time_ms = 0

    for line in lines:
        start_time, audio_paths = await process_transcript_line(line, voice, rate, pitch)
        if start_time is not None and audio_paths:
            combined_line_audio = AudioSegment.empty()
            for path in audio_paths:
                try:
                    audio = AudioSegment.from_mp3(path)
                    combined_line_audio += audio
                    os.remove(path)
                except FileNotFoundError:
                    print(f"Warning: Audio file not found: {path}")

            if combined_line_audio:
                timed_audio_segments.append({'start': start_time, 'audio': combined_line_audio})
                max_end_time_ms = max(max_end_time_ms, start_time + len(combined_line_audio))
        elif audio_paths:
            for path in audio_paths:
                try:
                    os.remove(path)
                except FileNotFoundError:
                    pass # Clean up even if no timestamp

    if not timed_audio_segments:
        return None, "No processable audio segments found."

    final_audio = AudioSegment.silent(duration=max_end_time_ms, frame_rate=24000)
    for segment in timed_audio_segments:
        final_audio = final_audio.overlay(segment['audio'], position=segment['start'])

    combined_audio_path = tempfile.mktemp(suffix=".mp3")
    final_audio.export(combined_audio_path, format="mp3")
    return combined_audio_path, None

@spaces.GPU
def tts_interface(transcript, voice, rate, pitch):
    audio, warning = asyncio.run(transcript_to_speech(transcript, voice, rate, pitch))
    return audio, warning

async def create_demo():
    voices = await get_voices()
    default_voice = "en-US-AndrewMultilingualNeural - en-US (Male)"
    description = """
    Process timestamped text (HH:MM:SS.milliseconds) with voice changes within quotes.
    Format: `HH:MM:SS.milliseconds "VoicePrefix Text" more text "AnotherVoicePrefix More Text"`
    Example:
    ```
    00:00:00.000 "This is the default voice." more default. "1F Now a female voice." and back to default.
    00:00:05.000 "1C Yes," said the child, "it is fun!"
    ```
    ***************************************************************************************************
    1M = en-AU-WilliamNeural - en-AU (Male)
    1F = en-GB-SoniaNeural - en-GB (Female)
    2M = en-GB-RyanNeural - en-GB (Male)
    2F = en-US-JennyNeural - en-US (Female)
    3M = en-US-BrianMultilingualNeural - en-US (Male)
    3F = en-HK-YanNeural - en-HK (Female)
    4M = en-GB-ThomasNeural - en-GB (Male)
    4F = en-US-EmmaNeural - en-US (Female)
    1O = en-GB-RyanNeural - en-GB (Male) # Old Man
    1C = en-GB-MaisieNeural - en-GB (Female) # Child
    1V = vi-VN-HoaiMyNeural - vi-VN (Female) # Vietnamese (Female)
    2V = vi-VN-NamMinhNeural - vi-VN (Male) # Vietnamese (Male)
    3V = vi-VN-HoaiMyNeural - vi-VN (Female) # Vietnamese (Female)
    4V = vi-VN-NamMinhNeural - vi-VN (Male) # Vietnamese (Male)
    ****************************************************************************************************
    """
    demo = gr.Interface(
        fn=tts_interface,
        inputs=[
            gr.Textbox(label="Timestamped Text with Voice Changes", lines=10, placeholder='00:00:00.000 "Text" more text "1F Different Voice"'),
            gr.Dropdown(choices=[""] + list(voices.keys()), label="Select Default Voice", value=default_voice),
            gr.Slider(minimum=-50, maximum=50, value=0, label="Speech Rate Adjustment (%)", step=1),
            gr.Slider(minimum=-50, maximum=50, value=0, label="Pitch Adjustment (Hz)", step=1)
        ],
        outputs=[
            gr.Audio(label="Generated Audio", type="filepath"),
            gr.Markdown(label="Warning", visible=False)
        ],
        title="TTS with HH:MM:SS.milliseconds and In-Quote Voice Switching",
        description=description,
        analytics_enabled=False,
        allow_flagging=False
    )
    return demo

if __name__ == "__main__":
    demo = asyncio.run(create_demo())
    demo.launch()