Spaces:
Runtime error
Runtime error
| import re | |
| import os | |
| from dataclasses import dataclass | |
| from typing import List, Tuple, Optional | |
| import numpy as np | |
| # ─── Regex ─────────────────────────────────────────────────────────────────── | |
| RE_NEWLINE = re.compile(r'[\r\n]+') # dùng chung cho cả v1 và v2 | |
| RE_SENTENCE_FINDALL = re.compile(r'[^.!?]+[.!?]*|[.!?]+') | |
| # v1 only | |
| RE_SENTENCE_END = re.compile(r'(?<=[\.\!\?\…])\s+') | |
| RE_MINOR_PUNCT = re.compile(r'(?<=[\,\;\:\-\–\—])\s+') | |
| # v2 noise cleanup | |
| _NOISE_RULES: List[Tuple[re.Pattern, str]] = [ | |
| (re.compile(r'([.!?])[.,;:]+'), r'\1'), | |
| (re.compile(r'[.,;:]+([.!?])'), r'\1'), | |
| (re.compile(r'\s+[,;]\s+'), ' '), | |
| (re.compile(r' {2,}'), ' '), | |
| ] | |
| _MULTI_PUNCT = re.compile(r'([.!?])\s*[.!?]+') | |
| # ─── Data class ────────────────────────────────────────────────────────────── | |
| class PhoneChunk: | |
| text: str | |
| is_sentence_end: bool # True = kết thúc câu thật | False = cắt nhân tạo | |
| # ─── Audio utils ───────────────────────────────────────────────────────────── | |
| def join_audio_chunks( | |
| chunks: List[np.ndarray], | |
| sr: int, | |
| silence_p: float = 0.0, | |
| crossfade_p: float = 0.0, | |
| ) -> np.ndarray: | |
| if not chunks: | |
| return np.array([], dtype=np.float32) | |
| if len(chunks) == 1: | |
| return chunks[0] | |
| silence_samples = int(sr * silence_p) | |
| crossfade_samples = int(sr * crossfade_p) | |
| final_wav = chunks[0] | |
| for i in range(1, len(chunks)): | |
| next_chunk = chunks[i] | |
| if silence_samples > 0: | |
| silence = np.zeros(silence_samples, dtype=np.float32) | |
| final_wav = np.concatenate([final_wav, silence, next_chunk]) | |
| elif crossfade_samples > 0: | |
| overlap = min(len(final_wav), len(next_chunk), crossfade_samples) | |
| if overlap > 0: | |
| fade_out = np.linspace(1.0, 0.0, overlap, dtype=np.float32) | |
| fade_in = np.linspace(0.0, 1.0, overlap, dtype=np.float32) | |
| blended = final_wav[-overlap:] * fade_out + next_chunk[:overlap] * fade_in | |
| final_wav = np.concatenate([final_wav[:-overlap], blended, next_chunk[overlap:]]) | |
| else: | |
| final_wav = np.concatenate([final_wav, next_chunk]) | |
| else: | |
| final_wav = np.concatenate([final_wav, next_chunk]) | |
| return final_wav | |
| # ─── v1: split raw text ────────────────────────────────────────────────────── | |
| def split_text_into_chunks(text: str, max_chars: int = 256) -> List[str]: | |
| """Split raw text (chưa phonemize) thành chunks <= max_chars.""" | |
| if not text: | |
| return [] | |
| paragraphs = RE_NEWLINE.split(text.strip()) | |
| final_chunks: List[str] = [] | |
| for para in paragraphs: | |
| para = para.strip() | |
| if not para: | |
| continue | |
| sentences = RE_SENTENCE_END.split(para) | |
| buffer = "" | |
| for sentence in sentences: | |
| sentence = sentence.strip() | |
| if not sentence: | |
| continue | |
| if len(sentence) > max_chars: | |
| if buffer: | |
| final_chunks.append(buffer) | |
| buffer = "" | |
| sub_parts = RE_MINOR_PUNCT.split(sentence) | |
| for part in sub_parts: | |
| part = part.strip() | |
| if not part: | |
| continue | |
| if len(buffer) + 1 + len(part) <= max_chars: | |
| buffer = (buffer + ' ' + part) if buffer else part | |
| else: | |
| if buffer: | |
| final_chunks.append(buffer) | |
| buffer = part | |
| if len(buffer) > max_chars: | |
| words, current = buffer.split(), "" | |
| for word in words: | |
| if current and len(current) + 1 + len(word) > max_chars: | |
| final_chunks.append(current) | |
| current = word | |
| else: | |
| current = (current + ' ' + word) if current else word | |
| buffer = current | |
| else: | |
| if buffer and len(buffer) + 1 + len(sentence) > max_chars: | |
| final_chunks.append(buffer) | |
| buffer = sentence | |
| else: | |
| buffer = (buffer + ' ' + sentence) if buffer else sentence | |
| if buffer: | |
| final_chunks.append(buffer) | |
| return [c.strip() for c in final_chunks if c.strip()] | |
| # ─── v2 helpers ────────────────────────────────────────────────────────────── | |
| def _pick_strongest(m: re.Match) -> str: | |
| s = m.group(0) | |
| return '!' if '!' in s else '?' if '?' in s else '.' | |
| def _clean_phoneme_noise(text: str) -> str: | |
| for pattern, repl in _NOISE_RULES: | |
| text = pattern.sub(repl, text) | |
| return _MULTI_PUNCT.sub(_pick_strongest, text).strip() | |
| def _find_best_split(text: str, max_size: int) -> Tuple[int, bool]: | |
| mid = max_size // 2 | |
| best_comma_pos, best_comma_dist = -1, max_size | |
| best_space_pos, best_space_dist = -1, max_size | |
| for i in range(min(max_size, len(text))): | |
| ch = text[i] | |
| if ch == ',': | |
| d = abs(i - mid) | |
| if d < best_comma_dist: | |
| best_comma_dist, best_comma_pos = d, i | |
| elif ch == ' ': | |
| d = abs(i - mid) | |
| if d < best_space_dist: | |
| best_space_dist, best_space_pos = d, i | |
| if best_comma_pos != -1: | |
| return best_comma_pos, True | |
| if best_space_pos != -1: | |
| return best_space_pos, False | |
| return -1, False | |
| def _smart_split_body(text: str, max_chunk_size: int) -> List[str]: | |
| result: List[str] = [] | |
| stack = [text.strip()] | |
| while stack: | |
| seg = stack.pop() | |
| if not seg: | |
| continue | |
| if len(seg) <= max_chunk_size: | |
| result.append(seg) | |
| continue | |
| pos, _ = _find_best_split(seg, max_chunk_size) | |
| if pos != -1: | |
| left = seg[:pos].rstrip() | |
| right = seg[pos + 1:].lstrip() | |
| else: | |
| cut = max_chunk_size | |
| while cut > 0 and seg[cut - 1] != ' ': | |
| cut -= 1 | |
| if cut == 0: | |
| cut = max_chunk_size | |
| left = seg[:cut].rstrip() | |
| right = seg[cut:].lstrip() | |
| if right: | |
| stack.append(right) | |
| if left: | |
| stack.append(left) | |
| return result | |
| def _split_sentence(sent: str, max_chunk_size: int) -> List[PhoneChunk]: | |
| sent = sent.strip() | |
| if not sent: | |
| return [] | |
| if sent[-1] in '.!?': | |
| body, punct = sent[:-1].rstrip(), sent[-1] | |
| else: | |
| body, punct = sent, '.' | |
| if not body: | |
| return [] | |
| if len(sent) <= max_chunk_size: | |
| return [PhoneChunk(text=body + punct, is_sentence_end=True)] | |
| sub_chunks = _smart_split_body(body, max_chunk_size) | |
| if not sub_chunks: | |
| return [PhoneChunk(text=punct, is_sentence_end=True)] | |
| last_idx = len(sub_chunks) - 1 | |
| return [ | |
| PhoneChunk( | |
| text=chunk + (punct if i == last_idx else '.'), | |
| is_sentence_end=(i == last_idx), | |
| ) | |
| for i, chunk in enumerate(sub_chunks) | |
| if chunk | |
| ] | |
| # ─── v2: split phoneme string ──────────────────────────────────────────────── | |
| def split_into_chunks_v2( | |
| full_phones: str, | |
| max_chunk_size: int = 256, | |
| min_chunk_size: int = 10, | |
| ) -> List[PhoneChunk]: | |
| """ | |
| Phân đoạn chuỗi phoneme thành các PhoneChunk. | |
| is_sentence_end=True → kết thúc câu thật → cần silence | |
| is_sentence_end=False → cắt nhân tạo → không cần silence | |
| """ | |
| if not full_phones: | |
| return [] | |
| full_phones = _clean_phoneme_noise(full_phones) | |
| raw_parts: List[PhoneChunk] = [] | |
| for para in RE_NEWLINE.split(full_phones): | |
| para = para.strip() | |
| if not para: | |
| continue | |
| for sent in RE_SENTENCE_FINDALL.findall(para): | |
| sent = sent.strip() | |
| if sent: | |
| raw_parts.extend(_split_sentence(sent, max_chunk_size)) | |
| if not raw_parts: | |
| return [] | |
| merged: List[PhoneChunk] = [] | |
| i, n = 0, len(raw_parts) | |
| while i < n: | |
| cur = raw_parts[i] | |
| while len(cur.text) < min_chunk_size and i + 1 < n: | |
| nxt = raw_parts[i + 1] | |
| candidate = cur.text.rstrip('.!?').rstrip() + ' ' + nxt.text | |
| if len(candidate) <= max_chunk_size: | |
| cur = PhoneChunk(text=candidate, is_sentence_end=nxt.is_sentence_end) | |
| i += 1 | |
| else: | |
| break | |
| merged.append(cur) | |
| i += 1 | |
| if len(merged) >= 2 and len(merged[-1].text) < min_chunk_size: | |
| last = merged.pop() | |
| candidate = merged[-1].text.rstrip('.!?').rstrip() + ' ' + last.text | |
| if len(candidate) <= max_chunk_size: | |
| merged[-1] = PhoneChunk(text=candidate, is_sentence_end=last.is_sentence_end) | |
| else: | |
| merged.append(last) | |
| return merged | |
| def get_silence_duration_v2(chunk: PhoneChunk) -> float: | |
| """ | |
| Silence sau chunk (giây). | |
| is_sentence_end=False → 0.0s | |
| kết thúc '!'/'?' → 0.4s | |
| kết thúc '.' → 0.3s | |
| """ | |
| if not chunk.is_sentence_end: | |
| return 0.0 | |
| return 0.4 if chunk.text.strip()[-1] in '!?' else 0.3 | |
| # ─── Misc ──────────────────────────────────────────────────────────────────── | |
| def env_bool(name: str, default: bool = False) -> bool: | |
| v = os.getenv(name) | |
| if v is None: | |
| return default | |
| return v.strip().lower() in ('1', 'true', 'yes', 'y', 'on') |