From a99cf58c79ae77bcadb84539252964b1072956ae Mon Sep 17 00:00:00 2001 From: Artem Akymenko Date: Sun, 19 Jul 2026 10:02:14 +0000 Subject: [PATCH] refactor: consolidate voice formula building into voice_formulas.py - voice_formulas.py: add pairs_to_formula() as canonical implementation - webui/routes/utils/voice.py: formula_from_profile() and pairs_to_formula() now delegate to voice_formulas.pairs_to_formula() - pyqt/gui.py: get_voice_formula() now uses voice_formulas.pairs_to_formula() instead of inline string formatting - Eliminates 3 duplicate implementations of voice*weight formula building - +9 tests - 1178 tests pass --- abogen/pyqt/gui.py | 7 ++--- abogen/voice_formulas.py | 29 +++++++++++++++++++- abogen/webui/routes/utils/voice.py | 28 ++++--------------- tests/test_voice_formulas_pairs.py | 44 ++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 28 deletions(-) create mode 100644 tests/test_voice_formulas_pairs.py diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py index 9454a61..a1f2844 100644 --- a/abogen/pyqt/gui.py +++ b/abogen/pyqt/gui.py @@ -2235,11 +2235,10 @@ class abogen(QWidget): self.current_queue_index = 0 # Reset for next time def get_voice_formula(self) -> str: + from abogen.voice_formulas import pairs_to_formula + if self.mixed_voice_state: - formula_components = [ - f"{name}*{weight}" for name, weight in self.mixed_voice_state - ] - return " + ".join(filter(None, formula_components)) + return pairs_to_formula(self.mixed_voice_state) or "" else: return self.selected_voice diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index b4731d7..99c88b8 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -1,5 +1,5 @@ import re -from typing import List, Tuple +from typing import Iterable, List, Optional, Tuple from abogen.tts_plugin.utils import get_voices @@ -72,6 +72,33 @@ def parse_voice_formula(pipeline, formula): return weighted_sum +def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]: + """Build a voice formula string from (voice_name, weight) pairs. + + Normalizes weights to sum to 1.0 and formats as "voice1*0.5+voice2*0.5". + + Args: + pairs: Iterable of (voice_name, weight) tuples. Zero-weight entries + are filtered out. + + Returns: + Formula string, or None if no valid entries. + """ + voices = [(voice, float(weight)) for voice, weight in pairs if weight is not None and float(weight) > 0] + if not voices: + return None + total = sum(weight for _, weight in voices) + if total <= 0: + return None + + def _format_value(value: float) -> str: + normalized = value / total if total else 0.0 + return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0" + + parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices] + return "+".join(parts) + + def calculate_sum_from_formula(formula): weights = re.findall(r"\* *([\d.]+)", formula) total_sum = sum(float(weight) for weight in weights) diff --git a/abogen/webui/routes/utils/voice.py b/abogen/webui/routes/utils/voice.py index 8ac1509..f56916e 100644 --- a/abogen/webui/routes/utils/voice.py +++ b/abogen/webui/routes/utils/voice.py @@ -548,19 +548,12 @@ def prepare_speaker_metadata( def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]: + from abogen.voice_formulas import pairs_to_formula + voices = entry.get("voices") or [] if not voices: return None - total = sum(weight for _, weight in voices) - if total <= 0: - return None - - def _format_weight(value: float) -> str: - normalized = value / total if total else 0.0 - return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0" - - parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0] - return "+".join(parts) if parts else None + return pairs_to_formula(voices) def template_options() -> Dict[str, Any]: @@ -710,19 +703,8 @@ def sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]: def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]: - voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0] - if not voices: - return None - total = sum(weight for _, weight in voices) - if total <= 0: - return None - - def _format_value(value: float) -> str: - normalized = value / total if total else 0.0 - return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0" - - parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices] - return "+".join(parts) + from abogen.voice_formulas import pairs_to_formula as _pairs_to_formula + return _pairs_to_formula(pairs) def profiles_payload() -> Dict[str, Any]: diff --git a/tests/test_voice_formulas_pairs.py b/tests/test_voice_formulas_pairs.py new file mode 100644 index 0000000..80753d4 --- /dev/null +++ b/tests/test_voice_formulas_pairs.py @@ -0,0 +1,44 @@ +"""Tests for voice_formulas.py — pairs_to_formula.""" + +from abogen.voice_formulas import pairs_to_formula + + +class TestPairsToFormula: + def test_basic_pair(self): + result = pairs_to_formula([("A", 1.0), ("B", 1.0)]) + assert result == "A*0.5+B*0.5" + + def test_unequal_weights(self): + result = pairs_to_formula([("A", 3.0), ("B", 1.0)]) + assert result == "A*0.75+B*0.25" + + def test_single_voice(self): + result = pairs_to_formula([("A", 1.0)]) + assert result == "A*1" + + def test_filters_zero_weight(self): + result = pairs_to_formula([("A", 1.0), ("B", 0.0)]) + assert result == "A*1" + + def test_all_zero_returns_none(self): + result = pairs_to_formula([("A", 0.0), ("B", 0.0)]) + assert result is None + + def test_empty_returns_none(self): + result = pairs_to_formula([]) + assert result is None + + def test_none_values_filtered(self): + result = pairs_to_formula([("A", 1.0), ("B", None)]) + assert result is not None + + def test_weight_normalization(self): + result = pairs_to_formula([("A", 2.0), ("B", 2.0)]) + assert result == "A*0.5+B*0.5" + + def test_three_voices(self): + result = pairs_to_formula([("A", 1.0), ("B", 1.0), ("C", 1.0)]) + assert "A*" in result + assert "B*" in result + assert "C*" in result + assert "+" in result