pums-tools / vieneu_utils /core_utils.py
ndmhung6's picture
feat: restructure for cloud deployment and auto-load TTS
850ba44
Raw
History Blame Contribute Delete
10.6 kB
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 ──────────────────────────────────────────────────────────────
@dataclass
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')