195 lines
7.0 KiB
Python
195 lines
7.0 KiB
Python
"""
|
|
Punctuation / Truecase runtime helpers for standalone transcript cleanup nodes.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
|
|
from utils.aux_models.registry import get_punctuation_model_by_label
|
|
from utils.hf_download_logging import quiet_hf_download_logs
|
|
|
|
|
|
_MODEL_CACHE: Dict[str, object] = {}
|
|
|
|
|
|
class PunctuationModelDownloader:
|
|
"""Lazy downloader for punctuation/truecase helper models."""
|
|
|
|
def __init__(self, base_path: str = None):
|
|
if base_path is None:
|
|
try:
|
|
from utils.models.extra_paths import get_preferred_download_path
|
|
self.base_path = get_preferred_download_path(model_type="TTS", engine_name="punctuation")
|
|
except Exception:
|
|
try:
|
|
import folder_paths
|
|
models_dir = folder_paths.models_dir
|
|
except Exception:
|
|
models_dir = str(Path(__file__).resolve().parents[2] / "models")
|
|
self.base_path = os.path.join(models_dir, "TTS", "punctuation")
|
|
else:
|
|
self.base_path = base_path
|
|
|
|
def resolve_model_path(self, model_label: str) -> Tuple[Dict[str, object], str]:
|
|
model_info = get_punctuation_model_by_label(model_label)
|
|
model_dir = os.path.join(self.base_path, model_info["model_dir"])
|
|
|
|
if self._is_model_ready(model_dir):
|
|
return model_info, model_dir
|
|
|
|
self._download_model(model_info, model_dir)
|
|
return model_info, model_dir
|
|
|
|
def _download_model(self, model_info: Dict[str, object], model_dir: str) -> None:
|
|
print(f"📥 Downloading punctuation model: {model_info['name']}")
|
|
print(f"📁 Target directory: {model_dir}")
|
|
|
|
try:
|
|
from huggingface_hub import snapshot_download
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"huggingface_hub is required for punctuation model downloads. "
|
|
"Please reinstall the suite dependencies."
|
|
) from exc
|
|
|
|
with quiet_hf_download_logs():
|
|
snapshot_download(
|
|
repo_id=model_info["repo_id"],
|
|
local_dir=model_dir,
|
|
local_dir_use_symlinks=False,
|
|
)
|
|
|
|
if not self._is_model_ready(model_dir):
|
|
raise RuntimeError(f"Punctuation model download incomplete: {model_info['name']}")
|
|
|
|
print(f"✅ Punctuation model downloaded successfully")
|
|
|
|
@staticmethod
|
|
def _is_model_ready(model_dir: str) -> bool:
|
|
path = Path(model_dir)
|
|
if not path.exists():
|
|
return False
|
|
return any(path.glob("*.onnx")) and any(path.glob("*.model")) and (path / "config.yaml").exists()
|
|
|
|
|
|
def _load_model(model_label: str):
|
|
model_info, model_dir = PunctuationModelDownloader().resolve_model_path(model_label)
|
|
cache_key = os.path.abspath(model_dir)
|
|
|
|
if cache_key in _MODEL_CACHE:
|
|
return _MODEL_CACHE[cache_key], model_info, model_dir
|
|
|
|
try:
|
|
from punctuators.models.punc_cap_seg_model import PunctCapSegConfigONNX, PunctCapSegModelONNX
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"The punctuation node requires the optional 'punctuators' package. "
|
|
"Run the suite installer again or install 'punctuators' in the same Python environment."
|
|
) from exc
|
|
|
|
cfg = PunctCapSegConfigONNX(
|
|
directory=model_dir,
|
|
spe_filename=model_info.get("spe_filename", "sp.model"),
|
|
model_filename=model_info.get("model_filename", "model.onnx"),
|
|
config_filename=model_info.get("config_filename", "config.yaml"),
|
|
)
|
|
model = PunctCapSegModelONNX(cfg=cfg)
|
|
_MODEL_CACHE[cache_key] = model
|
|
return model, model_info, model_dir
|
|
|
|
|
|
def _normalize_infer_output(output, sentence_mode: bool) -> str:
|
|
if isinstance(output, list):
|
|
if sentence_mode:
|
|
flattened: List[str] = []
|
|
for item in output:
|
|
if isinstance(item, list):
|
|
flattened.extend(str(x).strip() for x in item if str(x).strip())
|
|
elif str(item).strip():
|
|
flattened.append(str(item).strip())
|
|
return "\n".join(flattened)
|
|
|
|
joined = " ".join(str(item).strip() for item in output if str(item).strip())
|
|
return " ".join(joined.split())
|
|
|
|
return str(output).strip()
|
|
|
|
|
|
def _looks_already_punctuated(text: str) -> bool:
|
|
cleaned = " ".join(text.split()).strip()
|
|
if not cleaned:
|
|
return False
|
|
|
|
words = re.findall(r"\b\w+\b", cleaned, flags=re.UNICODE)
|
|
if len(words) < 6:
|
|
return False
|
|
|
|
sentence_endings = re.findall(r"[.!?…]+(?:\s+|$)", cleaned)
|
|
commas = re.findall(r",", cleaned)
|
|
capitalized_sentence_starts = re.findall(r"(?:(?<=^)|(?<=[.!?…]\s))[A-Z]", cleaned)
|
|
|
|
# Strong signal: prose-like sentence punctuation already exists.
|
|
if len(sentence_endings) >= 2 and len(capitalized_sentence_starts) >= 1:
|
|
return True
|
|
|
|
# Medium signal: enough punctuation density for non-trivial text.
|
|
if len(words) >= 12 and (len(sentence_endings) >= 2 or (len(sentence_endings) >= 1 and len(commas) >= 2)):
|
|
return True
|
|
|
|
# Another medium signal: multiple comma-separated clauses plus a sentence stop.
|
|
if len(commas) >= 3 and len(sentence_endings) >= 1:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _split_text(text: str, processing_scope: str) -> Tuple[List[str], str]:
|
|
if processing_scope == "Per Paragraph":
|
|
chunks = [chunk.strip() for chunk in re.split(r"\n\s*\n+", text) if chunk.strip()]
|
|
return chunks or [text.strip()], "\n\n"
|
|
return [text.strip()], ""
|
|
|
|
|
|
def restore_punctuation(
|
|
text: str,
|
|
model_label: str,
|
|
processing_scope: str = "Whole Text",
|
|
output_mode: str = "Restored Paragraphs",
|
|
lowercase_input_first: bool = True,
|
|
) -> Tuple[str, str]:
|
|
if not text or not text.strip():
|
|
return "", "status=empty"
|
|
|
|
model, model_info, model_dir = _load_model(model_label)
|
|
chunks, joiner = _split_text(text, processing_scope)
|
|
sentence_mode = output_mode == "One Sentence Per Line"
|
|
processed_chunks: List[str] = []
|
|
skipped_chunks = 0
|
|
|
|
for chunk in chunks:
|
|
prepared = " ".join(chunk.split())
|
|
if _looks_already_punctuated(prepared):
|
|
processed_chunks.append(chunk.strip())
|
|
skipped_chunks += 1
|
|
continue
|
|
|
|
if lowercase_input_first:
|
|
prepared = prepared.lower()
|
|
|
|
inference = model.infer(texts=[prepared], apply_sbd=sentence_mode)
|
|
first = inference[0] if isinstance(inference, list) and inference else inference
|
|
processed_chunks.append(_normalize_infer_output(first, sentence_mode))
|
|
|
|
restored_text = joiner.join(chunk for chunk in processed_chunks if chunk).strip()
|
|
info = (
|
|
f"model={model_info['name']} | "
|
|
f"scope={processing_scope.lower().replace(' ', '_')} | "
|
|
f"output={output_mode.lower().replace(' ', '_')} | "
|
|
f"lowercase_input={str(lowercase_input_first).lower()} | "
|
|
f"skipped_punctuated_chunks={skipped_chunks}/{len(chunks)} | "
|
|
f"path={model_dir}"
|
|
)
|
|
return restored_text, info
|