mustafoyev202 Claude Opus 4.6 (1M context) commited on
Commit
3ccc7b7
·
1 Parent(s): 2ed1985

Add dialogue-aware multi-voice generation

Browse files

- Parse Erkak:/Ayol: markers to alternate male/female voices
- Strip speaker prefixes so they aren't read aloud
- Synthesize each turn separately, stitch into one audio file
- Show per-turn progress and detailed log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +93 -37
  2. engines/_common.py +54 -0
app.py CHANGED
@@ -17,7 +17,10 @@ except ImportError:
17
  import gradio as gr
18
 
19
  from engines import ENGINES
20
- from engines._common import normalize_uzbek
 
 
 
21
 
22
  ROOT = Path(__file__).parent
23
  DEFAULT_TEXT = ROOT / "111.txt"
@@ -91,10 +94,30 @@ LABEL_TO_KEY = {ENGINE_INFO[k]["label"]: k for k in ENGINE_KEYS}
91
 
92
 
93
  # ---------------------------------------------------------------------------
94
- # Generation handler
95
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  def generate(text, engine_label, male_voice, female_voice,
97
  progress=gr.Progress(track_tqdm=True)):
 
 
98
  text = normalize_uzbek(text.strip())
99
  if not text:
100
  return None, "**Error:** No text provided.", ""
@@ -107,49 +130,82 @@ def generate(text, engine_label, male_voice, female_voice,
107
 
108
  info = ENGINE_INFO[key]
109
  engine = ENGINES[key]
110
- progress(0, desc=f"Generating with {engine_label}...")
111
 
112
- # Pick the voice to use. For single-voice engines, use the female voice
113
- # or the only available one. The male_voice is available for future
114
- # multi-speaker dialogue rendering.
115
- voice = female_voice or male_voice
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
- kwargs = {}
118
- if key == "gemini":
119
- kwargs["api_key"] = os.environ.get("GEMINI_API_KEY", "")
120
- kwargs["voice"] = voice
121
- elif key == "edge":
122
- kwargs["voice"] = voice
123
- elif key == "omnivoice":
124
- kwargs["instruct"] = voice
125
- elif key == "aisha":
126
- kwargs["api_key"] = os.environ.get("AISHA_API_KEY", "")
127
- elif key == "elevenlabs":
128
- kwargs["api_key"] = os.environ.get("ELEVENLABS_API_KEY", "")
129
- kwargs["voice"] = voice
130
 
131
- try:
132
- result = engine.synthesize(text=text, **kwargs)
133
- except Exception as e:
134
- return None, f"**Error:** {e}", f"[{engine_label}] EXCEPTION: {e}"
135
 
136
- if result.error:
137
- return None, f"**Error:** {result.error}", f"[{engine_label}] {result.error}"
 
 
 
 
 
 
 
 
138
 
139
  meta = (
140
  f"**Engine:** {engine_label} \n"
141
- f"**Model:** `{result.model}` \n"
142
- f"**Voice:** {result.voice} \n"
143
- f"**Male voice selected:** {male_voice or 'N/A'} \n"
144
- f"**Female voice selected:** {female_voice or 'N/A'} \n"
145
- f"**Duration:** {result.duration_sec:.1f}s \n"
146
- f"**Generation time:** {result.generation_time_sec:.1f}s \n"
147
- f"**Format:** {result.format}"
148
- + (f" @ {result.sample_rate} Hz" if result.sample_rate else "")
149
  )
150
- log = (f"[{engine_label}] Done — {result.duration_sec:.1f}s audio "
151
- f"in {result.generation_time_sec:.1f}s")
152
- return result.audio_path, meta, log
 
153
 
154
 
155
  # ---------------------------------------------------------------------------
 
17
  import gradio as gr
18
 
19
  from engines import ENGINES
20
+ from engines._common import (
21
+ normalize_uzbek, parse_dialogue, get_temp_path,
22
+ stitch_wav_files, stitch_mp3_files,
23
+ )
24
 
25
  ROOT = Path(__file__).parent
26
  DEFAULT_TEXT = ROOT / "111.txt"
 
94
 
95
 
96
  # ---------------------------------------------------------------------------
97
+ # Generation handler — supports multi-voice dialogue
98
  # ---------------------------------------------------------------------------
99
+ def _build_kwargs(key, voice, api_key_map):
100
+ """Build engine-specific kwargs for a single synthesis call."""
101
+ kwargs = {}
102
+ if key == "gemini":
103
+ kwargs["api_key"] = api_key_map.get("gemini", "")
104
+ kwargs["voice"] = voice
105
+ elif key == "edge":
106
+ kwargs["voice"] = voice
107
+ elif key == "omnivoice":
108
+ kwargs["instruct"] = voice
109
+ elif key == "aisha":
110
+ kwargs["api_key"] = api_key_map.get("aisha", "")
111
+ elif key == "elevenlabs":
112
+ kwargs["api_key"] = api_key_map.get("elevenlabs", "")
113
+ kwargs["voice"] = voice
114
+ return kwargs
115
+
116
+
117
  def generate(text, engine_label, male_voice, female_voice,
118
  progress=gr.Progress(track_tqdm=True)):
119
+ import time
120
+
121
  text = normalize_uzbek(text.strip())
122
  if not text:
123
  return None, "**Error:** No text provided.", ""
 
130
 
131
  info = ENGINE_INFO[key]
132
  engine = ENGINES[key]
 
133
 
134
+ api_keys = {
135
+ "gemini": os.environ.get("GEMINI_API_KEY", ""),
136
+ "aisha": os.environ.get("AISHA_API_KEY", ""),
137
+ "elevenlabs": os.environ.get("ELEVENLABS_API_KEY", ""),
138
+ }
139
+
140
+ # Parse dialogue turns (Erkak → male voice, Ayol → female voice).
141
+ turns = parse_dialogue(text)
142
+ has_dialogue = any(role in ("erkak", "ayol") for role, _ in turns)
143
+
144
+ # Voice mapping per role.
145
+ voice_map = {
146
+ "erkak": male_voice or female_voice,
147
+ "ayol": female_voice or male_voice,
148
+ "plain": female_voice or male_voice,
149
+ }
150
+
151
+ t0 = time.time()
152
+ clip_paths = []
153
+ log_lines = []
154
+ output_fmt = None
155
+
156
+ for i, (role, line_text) in enumerate(turns):
157
+ voice = voice_map[role]
158
+ progress((i / len(turns)), desc=f"Turn {i+1}/{len(turns)} ({role})...")
159
+
160
+ kwargs = _build_kwargs(key, voice, api_keys)
161
+ try:
162
+ result = engine.synthesize(text=line_text, **kwargs)
163
+ except Exception as e:
164
+ log_lines.append(f" Turn {i+1} ({role}) FAILED: {e}")
165
+ continue
166
+
167
+ if result.error:
168
+ log_lines.append(f" Turn {i+1} ({role}) ERROR: {result.error}")
169
+ continue
170
+
171
+ clip_paths.append(result.audio_path)
172
+ output_fmt = result.format
173
+ speaker = "Erkak" if role == "erkak" else ("Ayol" if role == "ayol" else "—")
174
+ log_lines.append(
175
+ f" [{i+1:02d}] {speaker:5s} → {voice} "
176
+ f"({result.duration_sec:.1f}s)"
177
+ )
178
 
179
+ total_time = time.time() - t0
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
+ if not clip_paths:
182
+ return None, "**Error:** All turns failed.", "\n".join(log_lines)
 
 
183
 
184
+ # Stitch clips into one file.
185
+ if len(clip_paths) == 1:
186
+ final_path = clip_paths[0]
187
+ else:
188
+ if output_fmt == "wav":
189
+ final_path = get_temp_path(".wav")
190
+ stitch_wav_files(clip_paths, final_path)
191
+ else:
192
+ final_path = get_temp_path(".mp3")
193
+ stitch_mp3_files(clip_paths, final_path)
194
 
195
  meta = (
196
  f"**Engine:** {engine_label} \n"
197
+ f"**Model:** `{engine.get_config()['model']}` \n"
198
+ f"**Male voice:** {male_voice or 'N/A'} \n"
199
+ f"**Female voice:** {female_voice or 'N/A'} \n"
200
+ f"**Turns:** {len(clip_paths)} of {len(turns)} \n"
201
+ f"**Dialogue mode:** {'Yes' if has_dialogue else 'No (plain text)'} \n"
202
+ f"**Total generation time:** {total_time:.1f}s \n"
203
+ f"**Format:** {output_fmt}"
 
204
  )
205
+ log_header = (f"[{engine_label}] {len(clip_paths)} turns, "
206
+ f"{total_time:.1f}s total")
207
+ log = log_header + "\n" + "\n".join(log_lines)
208
+ return final_path, meta, log
209
 
210
 
211
  # ---------------------------------------------------------------------------
engines/_common.py CHANGED
@@ -45,6 +45,60 @@ def get_wav_duration(path: str) -> float:
45
  return wf.getnframes() / wf.getframerate()
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def get_audio_duration(path: str, fmt: str) -> float:
49
  if fmt == "wav":
50
  return get_wav_duration(path)
 
45
  return wf.getnframes() / wf.getframerate()
46
 
47
 
48
+ def parse_dialogue(text: str):
49
+ """Parse text with Erkak:/Ayol: markers into turns.
50
+
51
+ Returns list of (role, clean_text) where role is "erkak" or "ayol".
52
+ If no markers found, returns [("plain", text)] — meaning no dialogue.
53
+ """
54
+ turns = []
55
+ role = None
56
+ for line in text.splitlines():
57
+ s = line.strip()
58
+ if not s:
59
+ continue
60
+ # Detect speaker markers (various forms).
61
+ if re.match(r"^Erkak\b", s, re.IGNORECASE):
62
+ role = "erkak"
63
+ s = re.sub(r"^Erkak[^:]*:\s*", "", s, flags=re.IGNORECASE)
64
+ elif re.match(r"^Ayol\b", s, re.IGNORECASE):
65
+ role = "ayol"
66
+ s = re.sub(r"^Ayol[^:]*:\s*", "", s, flags=re.IGNORECASE)
67
+ if not s:
68
+ continue
69
+ if role:
70
+ turns.append((role, s))
71
+ else:
72
+ turns.append(("plain", s))
73
+ return turns if turns else [("plain", text)]
74
+
75
+
76
+ def stitch_wav_files(paths: list, output_path: str, pause_sec: float = 0.35):
77
+ """Concatenate multiple WAV files into one with pauses between them."""
78
+ sr = ch = sw = None
79
+ pcm_chunks = []
80
+ for p in paths:
81
+ with wave.open(p, "rb") as w:
82
+ cur = (w.getnchannels(), w.getsampwidth(), w.getframerate())
83
+ if sr is None:
84
+ ch, sw, sr = cur
85
+ pcm_chunks.append(w.readframes(w.getnframes()))
86
+ pcm_chunks.append(b"\x00" * int(pause_sec * sr * ch * sw))
87
+ with wave.open(output_path, "wb") as out:
88
+ out.setnchannels(ch)
89
+ out.setsampwidth(sw)
90
+ out.setframerate(sr)
91
+ out.writeframes(b"".join(pcm_chunks))
92
+
93
+
94
+ def stitch_mp3_files(paths: list, output_path: str):
95
+ """Concatenate MP3 files (naive frame concat — works for most players)."""
96
+ with open(output_path, "wb") as out:
97
+ for p in paths:
98
+ with open(p, "rb") as f:
99
+ out.write(f.read())
100
+
101
+
102
  def get_audio_duration(path: str, fmt: str) -> float:
103
  if fmt == "wav":
104
  return get_wav_duration(path)