Skip to main content

handset-bench

Benchmark text to speech on what survives a G.711 telephone line.

8 kHz · mu-law · 300-3400 Hz passband · packet loss

ci pypi python licence


Most text to speech is built for 24 kHz headphones and evaluated there. Then it gets squeezed down a phone line. This measures what that squeeze costs your system.

Bring your own TTS. Bring your own recogniser. Get a word error rate per condition that you can compare against anyone else's, because the text set, the codec chain and the scoring rules are all frozen.

pip install handset-bench

Score your own system

The only thing you implement is synthesize. No GPU, no cloud account, no corpus download.

from handset_bench.conditions import resolve
from handset_bench.runner import run_quality
from handset_bench.textset import loader


class MyTTS:
    def version_string(self):
        return "mytts-0.1"  # pinned identity, appears on the scorecard

    def synthesize(self, text, *, voice=None): ...  # return a SynthResult


utterances = loader.sample(loader.load(), 60)  # spread across all six categories

for name in ("wideband", "clean", "loss_1pct", "loss_3pct"):
    record = run_quality(
        MyTTS(),
        system="mytts",
        asr=MyASR(),  # anything with .transcribe(pcm, sample_rate)
        utterances=utterances,
        condition=resolve(name),
    )
    print(name, record.aggregate["wer"])

wideband is the pre-codec control, so the difference between it and clean is what the phone line costs you.

A runnable version of the above, with a stand-in TTS and recogniser, is in examples/score_your_own.py. CI runs it on every commit, so it cannot rot.

Just the codec

If all you want is to hear or score audio through a real telephone line, that is one call and it is differentiable end to end apart from the quantiser.

from handset_bench.codec import phone_line

narrow = phone_line(wav, src_sr=24000, loss_p=0.03, seed=7)  # float32 at 8 kHz
flowchart LR
    A["native PCM"] --> B["highpass<br/>300 Hz"]
    B --> C["lowpass 3400 Hz<br/>also anti-alias"]
    C --> D["resample<br/>8 kHz"]
    D --> E["mu-law encode<br/>8-bit codes"]
    E --> F["drop 20 ms frames<br/>loss lands on codes"]
    F --> G["mu-law decode<br/>float32 @ 8 kHz"]

Two details that are easy to get wrong and are wrong in a lot of code:

Band-limiting is an 8th-order Butterworth cascade with per-section Q. A single biquad leaves a 6 kHz tone only 10.9 dB down, which no carrier would ship. Cascading four identical Q=0.707 sections instead pulls the -3 dB corner from 3400 Hz to about 2260 Hz and quietly eats a third of the passband.

Loss is applied after encoding. A network drops packets, and a packet carries codes. Dropping samples first models something that does not happen on a real line.

The scoring rules

Three of these exist because getting them wrong produces a plausible number rather than an error.

Word error rate is aggregated corpus-level, total errors over total reference words. A mean of per-utterance rates over-weights short utterances.

A system that produced no audio scores as a total failure, never as a skip. Skipping flatters whichever system fails most often.

Digit runs are atomised on both sides, before and after normalisation. Whisper's English normaliser collapses a spelled-out digit run into one token, deletes leading zeros (0198 becomes 198, 007 becomes 7), and deletes bracketed spans, so (613) 555-0198 becomes 555 198. Any one of the three makes a perfect transcription of a phone number score as a near-total failure. If you score ASR on anything transactional with the stock normaliser, check this before you trust your digit numbers.

from handset_bench.metrics.wer import normalize

normalize("(613) 555-0198")  # '6 1 3 5 5 5 0 1 9 8'
normalize("six one three five five five zero one nine eight")  # identical

Reproducibility

Two consecutive runs of the same system produced 1,200 identical transcripts: a band of 0.0 percentage points.

Getting there needed one fix that is worth knowing about if you build anything like this. Most TTS models sample noise per call, so the same text gives different audio every run and a benchmark silently compares transcripts of different recordings. Piper does it through its VITS duration predictor; ZipVoice through unseeded flow matching. The tell is a non-monotonic loss series, where 3% packet loss scores better than 1%, which cannot happen because loss is additive.

Seed your system, and put the mode in its version string. Result records store their transcripts, so a change to scoring is re-scored offline instead of re-running everything:

handset-bench rescore --results results --write

The text set

dialtone_v1, 300 utterances, sha256-pinned at load. The loader refuses to run on a mismatch, because a silently edited text set makes every previously published number wrong while the scorecard still looks fine.

Category Count Why
conversational 70 ordinary agent turns
digits 60 codes, phone numbers, PINs. The hardest case on a narrow band
proper_nouns 50 names a recogniser has no prior for
addresses 40 street numbers and postcodes
datetime_money 40 times and amounts
general 40 prose control

loader.sample(n) spreads across all six. Do not slice the list: it is stored grouped by category, so [:12] gives you twelve digit utterances and a benchmark that cannot see anything else.

Conditions

Name What it is
wideband No phone line. The pre-codec control
clean Full G.711 chain, no packet loss
loss_1pct 20 ms frames dropped independently at 1%
loss_3pct Same at 3%

Loss is seeded from the utterance id, so the same frames drop every run while still varying across the corpus.

Where it came from

Built for DialTone, a project that set out to justify a telephony-native vocoder. The benchmark's answer was that the phone line costs no intelligibility on either system tested, so the vocoder was not built. That repo has the full findings, including the per-category tables and a five minute explainer.

Licence

MIT. See LICENSE.

Metadata

Release files for handset-bench 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for handset-bench 0.1.0
File Size Uploaded Provenance
handset_bench-0.1.0.tar.gz 55.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for handset-bench 0.1.0
File Interpreter ABI Platform
handset_bench-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 110.4 kB

Release files / handset_bench-0.1.0.tar.gz

Download URL handset_bench-0.1.0.tar.gz
Size 55.1 kB
Tags Source
SHA-256 checksum
How to use checksums
8cfc74868e4393f6f381301aa322b93e3a785af8d5acdd49059ff2709c128d95
BLAKE2b-256 checksum
How to use checksums
5b89691117b5dee2e73925a08c15310f402b6ededcc264c42f317c075acd082b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log

Release files / handset_bench-0.1.0-py3-none-any.whl

Download URL handset_bench-0.1.0-py3-none-any.whl
Size 55.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0ac72980e9cf731043d09f3dc8c168775a2d90c71fd7036565fbd918926f3e1c
BLAKE2b-256 checksum
How to use checksums
011deee56b73c84f238bac41482267bde7427424f7423ebc9f0ad015db44339e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log

Security for handset-bench 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for handset_bench-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page