Skip to content

Commit 65c4c3f

Browse files
authored
Merge pull request #5213 from LHMQ878/fix/decimal-trailing-zero-dropped
fix(text): speak a decimal's trailing zero instead of dropping it
2 parents f82b059 + 5ca23f8 commit 65c4c3f

3 files changed

Lines changed: 77 additions & 5 deletions

File tree

changelog/5213.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fixed `expand_numbers` dropping a decimal's trailing zero, so "1.0" now reads as "one point zero" instead of the bare "one". This was most audible composed with `expand_units`, which keeps the plural for a decimal: `VoiceFormatter(expand_numbers=True)` turned "1.0km left" into "one kilometers left". Decimals without trailing zeros are unchanged.

src/pipecat/utils/text/transforms/numbers.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ def expand_numbers(
2727
expanded as quantities (e.g. ``"42"`` → ``"forty two"``). Pass ``None``
2828
to expand all numbers as words regardless of magnitude.
2929
30+
Decimals are read as the whole part followed by their fractional digits, one at a
31+
time, so every written digit is spoken (``"1.0"`` → ``"one point zero"``).
32+
3033
Args:
3134
digit_cutoff: Numbers larger than this value are read digit-by-digit.
3235
``None`` disables the cutoff so every number is expanded as a word.
@@ -53,11 +56,14 @@ def _num_to_words(match: re.Match) -> str:
5356
return result
5457

5558
if frac_str:
56-
words = num2words(float(f"{whole_str}.{frac_str}"), lang="en")
57-
else:
58-
words = num2words(whole, lang="en")
59-
60-
return words
59+
# Reading the fraction one digit at a time keeps trailing zeros audible,
60+
# which also keeps a decimal agreeing with the plural unit expand_units
61+
# picks for it ("1.0 kilometers").
62+
return f"{num2words(whole, lang='en')} point " + " ".join(
63+
num2words(int(digit), lang="en") for digit in frac_str
64+
)
65+
66+
return num2words(whole, lang="en")
6167

6268
async def _transform(text: str, aggregation_type: str | AggregationType) -> str:
6369
return _NUMBER_RE.sub(_num_to_words, text)

tests/test_voice_formatting.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,71 @@ async def test_above_cutoff_multi_digit_fraction(self):
519519
self.assertIn("point", result)
520520
self.assertIn("7 5", result)
521521

522+
async def test_trailing_fractional_zero_is_spoken(self):
523+
# Every written digit is spoken, trailing zeros included -- the above-cutoff
524+
# branch reads fractions the same way.
525+
transform = expand_numbers(digit_cutoff=2025)
526+
cases = {
527+
"1.0": "one point zero",
528+
"1.00": "one point zero zero",
529+
"2.0": "two point zero",
530+
"0.50": "zero point five zero",
531+
"1.10": "one point one zero",
532+
"10.0": "ten point zero",
533+
}
534+
for text, expected in cases.items():
535+
with self.subTest(text=text):
536+
self.assertEqual(await transform(text, "*"), expected)
537+
538+
async def test_fractions_without_trailing_zeros_unchanged(self):
539+
# The digit-by-digit reading covers every decimal, not just the ones with a
540+
# trailing zero.
541+
transform = expand_numbers(digit_cutoff=None)
542+
cases = {
543+
"3.5": "three point five",
544+
"1.05": "one point zero five",
545+
"1.25": "one point two five",
546+
"12.345": "twelve point three four five",
547+
"5.0001": "five point zero zero zero one",
548+
}
549+
for text, expected in cases.items():
550+
with self.subTest(text=text):
551+
self.assertEqual(await transform(text, "*"), expected)
552+
553+
554+
class TestUnitsAndNumbersComposition(unittest.IsolatedAsyncioTestCase):
555+
"""``expand_units`` and ``expand_numbers`` have to agree on decimal quantities.
556+
557+
``expand_units`` keeps the plural for a decimal such as "1.0" because it reads as
558+
"one point zero" in speech, and ``expand_numbers`` runs after it in the default
559+
``VoiceFormatter`` order. Both have to spell the decimal out the same way, or the
560+
composed output is ungrammatical: "one kilometers".
561+
"""
562+
563+
async def test_decimal_one_agrees_with_plural_unit(self):
564+
formatter = VoiceFormatter(expand_numbers=True)
565+
cases = {
566+
"1.0km left": "one point zero kilometers left",
567+
"1.0kg of flour": "one point zero kilograms of flour",
568+
"1.0 mi away": "one point zero miles away",
569+
"1.00km left": "one point zero zero kilometers left",
570+
}
571+
for text, expected in cases.items():
572+
with self.subTest(text=text):
573+
self.assertEqual(await formatter(text, "*"), expected)
574+
575+
async def test_singular_and_plural_quantities_still_agree(self):
576+
formatter = VoiceFormatter(expand_numbers=True)
577+
cases = {
578+
"1km left": "one kilometer left",
579+
"2.0km left": "two point zero kilometers left",
580+
"1.5km left": "one point five kilometers left",
581+
"1.0hz tone": "one point zero hertz tone",
582+
}
583+
for text, expected in cases.items():
584+
with self.subTest(text=text):
585+
self.assertEqual(await formatter(text, "*"), expected)
586+
522587

523588
if __name__ == "__main__":
524589
unittest.main()

0 commit comments

Comments
 (0)