Skip to main content

Fully-local speech-to-text for dental clinical audio, with domain correction

Project description

dentascribe

Local speech-to-text for dental clinical audio. Speak, watch text appear, get a corrected transcript. Nothing leaves the machine and nothing costs money.

uv pip install -e ".[whisper,mic,ui]"
dentascribe ui --allow-network        # http://127.0.0.1:8000

Why it exists

General-purpose speech recognition handles dental dictation badly, and it fails in a way ordinary metrics hide. A 2026 evaluation of 11 ASR systems on orthodontic records found every system was significantly worse on clinical vocabulary than on general speech (P < 0.001) — with one exception: the configuration that added an LLM correction pass, which was also the most accurate overall. The same study found clinically significant errors in every system tested, ranging 2%–66%.

Reproduced here, then fixed:

overall WER domain WER
verbatim 9.57% 15.38%
corrected 5.32% 3.08%

Clinical vocabulary fails at roughly twice the headline rate. With correction, domain WER drops ~80% relative and falls below general WER — the same inversion the study reported.

That drives three decisions: domain WER is the metric, correction is a separate constrained stage, and both transcripts are kept with a diff so corrections stay auditable.


How it works

microphone ─┐
audio file ─┼─→ resample 16k mono ─→ VAD ─→ ASR ─→ LocalAgreement-2 ─→ live text
growing WAV─┘                                            │
                                                         └─→ on stop ─→ full-context ASR
                                                                     ─→ LLM correction
                                                                     ─→ review flags
                                                                     ─→ final transcript

Two tiers, and why

A recognizer revises its output as more context arrives. Emitting every hypothesis directly produces text that visibly rewrites itself — and can briefly display a wrong drug name or tooth number before correcting.

  • Live tier — text is held back until two successive decodes agree on it (LocalAgreement-2). First partial ~1.3 s, first confirmed ~4.2 s. Explicitly provisional.
  • Final tier — on stop, the complete audio is re-transcribed with full context, then corrected. This is the record.

The difference is real. On the same recording, live produced "mesialocleucle distal" where the final pass produced "mesial occlusal, distal".

The audio buffer scrolls past confirmed text, so decode cost stays bounded over a long appointment instead of growing with it.

The dental layer

787-term lexicon built from MeSH (public domain) and RxNorm (open API), merged with a hand-written seed list. Regenerate with python scripts/build_lexicon.py.

Correction is constrained, not free rewriting. The model gets one utterance plus a short list of candidate terms chosen by similarity — given "buckle" it sees "buccal". Three guards reject a proposal before it can reach a record:

guard rejects
edit ratio rewriting >25% of an utterance — that's paraphrasing, not term repair
numeric token count inventing or dropping a number; values may change, the count may not
tooth reference count adding a tooth that was never dictated

Every accepted change is recorded as an Edit with before/after, so the diff is reviewable.

Review flags handle what correction cannot. Some misrecognitions land on another valid clinical term: "reversible pulpitis" for "irreversible pulpitis" is correctly spelled, in the lexicon, and names the opposite treatment. Nothing about the string looks wrong, so similarity search can't find it — and an LLM inverting a diagnosis on inference is worse than the original error. These are flagged for a human, never auto-corrected.

Flags are high-severity only by default. Directional pairs like mesial/distal are genuinely confusable but appear in nearly every note; flagging them every time buries the rare flag that matters.

Tooth notation converts across Universal, FDI and Palmer explicitly, never inferred — "tooth 18" is the upper-right third molar in FDI and the lower-left second molar in Universal.

Number handling folds words to digits, but only compounds with an explicit multiplier: "one hundred thousand" = 100000, while "three two three" stays three probing depths rather than collapsing to 323.


Using it

Browser UI

dentascribe ui --allow-network            # first run downloads models
dentascribe ui -c configs/demo.yaml       # with LLM correction

Pick an input device (built-in mic, AirPods, USB interface), press start, speak. Live text on the left; the final transcript lands in an editable textbox with Download .txt, and the raw JSON sits beside it with Download .json.

Terminal

dentascribe listen --list-devices
dentascribe listen --device 2 -o visit.json
dentascribe transcribe visit.wav --format text
dentascribe transcribe live.wav --live          # a WAV still being written
dentascribe eval data/smoke/manifest.jsonl      # WER, domain WER, tooth accuracy

listen emits one JSON object per line:

{"type":"partial","provisional":true,"start":0.0,"end":2.1,"text":"tooth number three has a"}
{"type":"final","provisional":false,"id":"u00000","start":0.0,"end":4.2,"text":"..."}
{"type":"complete","text":"...","verbatim":{...},"corrected":{...},"edits":[],"flags":[]}

Embedding in another project

The core is a library; the CLI and UI are thin wrappers over it.

from dentascribe import Config, transcribe_file

result = transcribe_file("visit.wav", Config())
result.verbatim.text      # raw ASR
result.best.text          # corrected when correction is enabled
result.edits              # every change, with before/after
result.flags              # spans a human should verify

Live, with your own audio source:

from dentascribe import Config
from dentascribe.pipeline import Pipeline
from dentascribe.audio import MicrophoneSource   # or QueueAudioSource for a socket feed

with Pipeline(Config()) as pipeline, MicrophoneSource() as mic:
    for event in pipeline.stream(mic):
        ...   # PartialUtterance | FinalUtterance | TranscriptComplete

Swapping a stage

Every stage is a Protocol resolved by name through entry points. Registering your own needs no fork:

[project.entry-points."dentascribe.asr"]
my-engine = "my_package.engines:MyEngine"
stage protocol bundled backends
source AudioSource file, growing WAV, microphone, push queue
VAD VAD silero
ASR ASREngine parakeet-onnx, faster-whisper
policy StreamingPolicy LocalAgreement-N
correction Corrector ollama, passthrough
sink Sink json, jsonl, text, srt, vtt

A shared contract suite (tests/test_contracts_and_sinks.py) verifies backends are genuinely interchangeable — the real faster-whisper engine and the test fake pass identical assertions. Subclass ASREngineContract and your backend is held to the same bar.

Install only what you use: whisper, parakeet, vad, mic, llm, ui, eval, dev. The core is light and engines load lazily, so an uninstalled extra costs nothing.


Migrating to another computer

Verified, not assumed — a wheel was built, installed into a clean environment with no source tree, and used to transcribe a file:

uv build --wheel                                   # 88 KB
uv pip install "dentascribe-0.1.0-py3-none-any.whl[whisper,mic,ui]"
dentascribe fetch-models --asr large-v3             # one-time, ~2.4 GB cached
dentascribe transcribe visit.wav --format text      # runs offline from here

Two things move: the wheel (88 KB) and the model cache (~2.4 GB in ~/.cache/huggingface). Copy the cache directly to skip re-downloading, or let fetch-models pull it once.

Runs on Windows. Development is macOS, production Windows with no guaranteed GPU, so every default is portable — CTranslate2 and ONNX Runtime for recognition, PyAV for decoding (no separate ffmpeg install), llama.cpp via Ollama for correction. CI runs the full suite on Windows, macOS and Linux across Python 3.11 and 3.12, plus a Windows job that installs the real model wheels and transcribes a file end to end. See docs/windows.md.

ONNX execution providers are auto-detected: CUDA → DirectML (any Windows GPU) → CPU.


Cost and privacy

No paid services. The only network addresses anywhere in the source are 127.0.0.1 and localhost. No API keys, no accounts, no metered calls. Every model is open-weight:

model role licence
Parakeet-TDT-0.6b-v3 recognition CC-BY-4.0
Whisper (faster-whisper) recognition MIT
Silero VAD speech detection MIT
Qwen3 correction (optional) Apache-2.0

offline_only defaults to true. Models are fetched in an explicit provisioning step; at inference the model libraries run offline, telemetry is disabled, and a non-loopback correction endpoint is rejected outright. A test runs the whole pipeline with every outbound socket refused, so the guarantee is enforced rather than merely documented.

CDT and SNODENT are excluded. Both are American Dental Association copyright and require a paid licence to redistribute. A practice may use CDT freely in its own records, so supply your own list via correction.user_codes_path.


Performance

Measured on an M4 Max, CPU only:

configuration domain WER RTF
parakeet-v3 8.82% 0.076
parakeet-v3 + correction 2.94% 0.161
whisper-small 14.71% 0.319
whisper-small + correction 4.41% 0.319

Correction beats model size decisively: whisper-tiny with correction reaches 0.00% domain WER on this set, where whisper-small alone sits at 14.71%.

Reproduce with python scripts/benchmark.py data/smoke/manifest.jsonl --correct.


What these numbers do and don't mean

Every figure above comes from synthetic speech — text-to-speech with no handpiece whine, no suction, no surgical masks, no crosstalk, no disfluency. Published work finds background noise substantially increases error rates, so real operatory audio will score worse. Treat the tables as a ranking of configurations, not a forecast of clinical accuracy.

Acoustic capture already shows this: the same audio played through speakers and re-captured by a microphone turned "carpules" into "car peels" and "mesial" into "measly" — before any operatory noise is involved.

Getting real numbers needs a gold set: the scripts in scripts/make_smoke_dataset.py read aloud by several people in an actual operatory. Because the script is the reference, that yields exact ground truth at near-zero labelling cost.

This is a drafting aid that requires clinician review, not unattended documentation.


Development

uv venv --python 3.11
uv pip install -e ".[dev,whisper,parakeet,vad,mic,llm,ui,eval]"
pytest                       # 293 tests
pytest -m "not slow"         # skips model downloads
ruff check src/ tests/ && ruff format src/ tests/
mypy src/                    # strict

To reproduce a CI failure locally — a warm venv drifts from a clean resolve and hides real bugs:

git clone --branch <branch> . /tmp/citest && cd /tmp/citest
uv sync --python 3.11 --extra dev --extra whisper --extra eval --extra llm
uv run mypy src/ && uv run pytest -m "not slow" -q

Publishing a release

Set up Trusted Publishing once — owner Victor-lyhan, repository dentascribe, workflow release.yml, environment release — then no API token is ever stored.

# dry run first
gh workflow run release.yml -f target=testpypi

# real release: bump the version, tag it, publish the GitHub release
gh release create v0.1.0 --generate-notes

The workflow refuses to publish if the git tag and pyproject.toml version disagree, and runs lint, mypy and the test suite before building. A PyPI version number can be yanked but never reused, so publishing is deliberate: it never fires on a plain push.

See CLAUDE.md for architecture notes and Development.md for the decision log.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dentascribe-0.1.0.tar.gz (106.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dentascribe-0.1.0-py3-none-any.whl (96.2 kB view details)

Uploaded Python 3

File details

Details for the file dentascribe-0.1.0.tar.gz.

File metadata

  • Download URL: dentascribe-0.1.0.tar.gz
  • Upload date:
  • Size: 106.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for dentascribe-0.1.0.tar.gz
Algorithm Hash digest
SHA256 a5bc292fb32ea54b1a08cedf0faf6767f73c1f79b65d36a3a741d57a116748e3
MD5 3a28e8a2e46124eb8895a425de3667af
BLAKE2b-256 da851674850477ae47d7161484fea4abf15c769080b144172bbbc99e1e03869b

See more details on using hashes here.

Provenance

The following attestation bundles were made for dentascribe-0.1.0.tar.gz:

Publisher: release.yml on Victor-lyhan/dentascribe

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dentascribe-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: dentascribe-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 96.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for dentascribe-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 20556a8c853999b4872fe9474527a5ead67e03235f373f444b0b848e866773d9
MD5 d3454bdf5dfc4cdc435f9f01f031f66c
BLAKE2b-256 358f6bf687143c910050032061c46d4fa79cb0025c7ab824809ef559d134effb

See more details on using hashes here.

Provenance

The following attestation bundles were made for dentascribe-0.1.0-py3-none-any.whl:

Publisher: release.yml on Victor-lyhan/dentascribe

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page