Skip to content

Commit 8167e54

Browse files
committed
feat(asr): audio transcription — Whisper via ONNX, pure Rust (closes Phase 7)
Ports docling's AsrPipeline (native-Whisper path, its ASR defaults: whisper tiny, temperature-0 greedy, timestamps) as a new fleischwolf-asr crate, Rust all the way down: - symphonia demuxes/decodes the audio in-process — wav, mp3, flac, ogg, aac, m4a, plus the audio track of mp4/mov; no ffmpeg. AVI is the one container symphonia cannot demux (clear error). - A ported log-mel front-end (80 Slaney mel filters, centered STFT, Whisper's exact log/clamp/rescale), unit-tested against librosa reference values. - Whisper tiny encoder/decoder ONNX (onnx-community export) on ort — the same runtime as layout/TableFormer/OCR. Greedy decoding implements OpenAI's rules: special-token + non-speech-symbol suppression (the default suppress_tokens="-1" set, derived by decode-scanning the vocabulary), suppress_blank, forced initial timestamp with max_initial_timestamp, timestamp pairing/monotonicity, the timestamp-probability-mass rule, and the no-speech gate (p_nospeech > 0.6 && avg_logprob < -1). 30-second windowing advances seek to the last emitted timestamp. - GPT-2 byte-level BPE decoding from vocab.json alone (no merges needed for id→text). FLEISCHWOLF_ASR_LANG selects the language via added_tokens.json (default en). - Each segment renders as a `[time: start-end] text` paragraph (docling's conversation form, Python float formatting). Wiring: InputFormat::Audio now converts (the rejects-unimplemented test moves to XmlDoclang, the one remaining backend-less format); download_dependencies.sh fetches Whisper tiny from Hugging Face (--no-asr to skip; DOCLING_ASR_{ENCODER,DECODER,VOCAB} override). Verified end-to-end on tests/data/audio: all 12 audio fixtures + the mp4/mov video fixtures transcribe the LibriVox sample correctly ("Shakespeare on scenery by Oscar Wilde…", [time: 0.0-10.0]); silence yields whisper-tiny's documented model-faithful behavior. 8 new unit tests (mel filterbank vs librosa, silence floor, timestamp rules, byte decoder, resampler, wav decode, time formatting). Docs: README (status, models table, try-it), MIGRATION (status: migration complete; crate map; ML table; Phase 7 done), COMPARING (HTML 31/32 + refreshed gap table from the earlier commits).
1 parent c782e69 commit 8167e54

13 files changed

Lines changed: 1244 additions & 38 deletions

File tree

COMPARING.md

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ case — see the divergence table below.
181181
| **XLSX** | **9 / 9**| 9 / 9 |
182182
| **PPTX** | **7 / 7**| 7 / 7 |
183183
| **DOCX** | **25 / 26** | 25 / 26 |
184-
| **HTML** | **28 / 33** | 28 / 33 |
184+
| **HTML** | **31 / 32** | 31 / 32 |
185185
| **PDF** | **6 / 14**| 7 / 14 |
186186

187187
> † The pure-parse backends above are scored against **live** docling. **PDF** is a
@@ -194,6 +194,16 @@ case — see the divergence table below.
194194
> `1⁄4` fraction, where the Rust output's single space is the more faithful
195195
> rendering.
196196
197+
> **HTML** is scored against the committed groundtruth
198+
> (`tests/data/html/groundtruth`, regenerated from live docling — which now
199+
> emits *compact* markdown tables) with the **deliberate** padded-vs-compact
200+
> table divergence (§4 of `MIGRATION.md`) normalized; the cell *content* is
201+
> byte-identical, including docling's exact multi-space seams in deeply nested
202+
> table cells. The one remaining miss is `wiki_duck`: its menus are collapsed
203+
> by external, host-relative stylesheets, so suppressing them requires those
204+
> stylesheets to be fetchable at render time (`--use-web-browser` with network
205+
> access) — inherently out of reach for a fully-offline conversion.
206+
197207
**PDF** (`*.pdf`) ports docling's *standard* (discriminative) PDF pipeline. A
198208
**pure-Rust text parser** (`textparse.rs`, on `lopdf`) reconstructs each glyph's
199209
box from the font's own advance widths and the PDF text/graphics matrices — the
@@ -261,10 +271,13 @@ the drawing parts via the shared `ooxml` zip/rels helper). The one miss
261271
all-empty table.
262272

263273
CSV, Markdown, AsciiDoc, and the DeepSeek-OCR Markdown variant are fully
264-
one-to-one. HTML's 5 remaining misses are a tail of docling-internal behaviours —
265-
some requiring **headless-browser rendering**, others (a large Wikipedia page,
266-
key-value form extraction) needing substantial structural work — see below. 28/33
267-
is roughly the ceiling for a pure-parse port.
274+
one-to-one. HTML is at **31/32**: key-value form regions, docling-faithful
275+
inline-image handling, inline visibility suppression, and deep nested-table
276+
cell flattening (docling's exact spacing — BeautifulSoup whitespace semantics,
277+
not rendered geometry) are all reproduced statically, and CSS-cascade
278+
visibility suppression is available behind the optional `web-browser` feature.
279+
The single remaining miss (`wiki_duck`) needs the page's external stylesheets
280+
fetchable at render time — see below.
268281

269282
**AsciiDoc** (`*.asciidoc`/`*.adoc`) ports docling's line-oriented
270283
`AsciiDocBackend`: titles/sections, nested bullet/numbered lists (all rendered
@@ -293,22 +306,17 @@ tail of docling-specific quirks (below), each typically 1–2 lines.
293306
294307
### HTML
295308

296-
Against live docling: **10 / 32** exact, **12 / 32** whitespace-normalized. (The
297-
older committed groundtruth would report a lower 6/32 — it predates docling's
298-
padded-table serializer; see §A.) The remaining real differences trace to a
299-
small number of *systematic* behaviours below, not to parsing errors — closing
300-
each tends to fix several fixtures at once.
301-
302-
### Known divergences (tracked conformance gaps)
303-
304-
HTML — remaining gaps (4 of 32), all blocked or impractical:
309+
**31 / 32** content-exact against the committed groundtruth (regenerated from
310+
live docling); the table-padding divergence is deliberate (§4 of
311+
`MIGRATION.md`). The four gaps once thought browser-bound have all been closed,
312+
three of them **without any browser**:
305313

306-
| # | Difference | Example | Why it's blocked |
314+
| # | Former gap | Example | Resolution |
307315
|---|---|---|---|
308-
| 1 | Browser-rendering visibility / nav suppression | `wiki_duck` | docling renders the page in a headless browser to drop nav/menu/sidebar cruft — not replicable from parsing alone |
309-
| 2 | Key-value-pair / form extraction | `kvp_data_example` | docling's `form_region` subsystem builds key/marker/value relations using rendered bounding boxes and synthesizes `<!-- missing-text -->` placeholders — browser-bbox-dependent |
310-
| 3 | Browser-hidden image (mobile nav) dropped | `hyperlink_02` | docling drops it via rendered visibility |
311-
| 4 | Deeply-nested-table padding when flattened into a cell | `table_06` | docling pads the rich-cell text using rendered table bounding boxes; shallow nesting matches, ≥3-level deep padding does not |
316+
| 1 | Key-value-pair / form extraction | `kvp_data_example` | detected statically from docling's `keyN` / `keyN_valueM` / `keyN_marker` `id`-convention → `field_region`/`field_item` nodes + `<!-- missing-text -->` markers; no bboxes involved |
317+
| 2 | Browser-hidden image (mobile nav) dropped | `hyperlink_02` | docling never emits inline image markers; restricting the wrapper→picture path to `<a>` and dropping inline images reproduces it statically |
318+
| 3 | Deeply-nested-table cell padding | `table_06` | not rendered geometry after all — BeautifulSoup collapses whitespace-only text nodes to `\n`/`' '`, docling's `get_text` adds a trailing space per cell, and the `\n` runs flatten to spaces at render; ported byte-exact |
319+
| 4 | Stylesheet-collapsed nav/menus | `wiki_duck` | CSS-cascade visibility genuinely needs a rendered page: available behind the `web-browser` feature / `--use-web-browser` (Rust-driven Chromium). The saved fixture's stylesheets are external + host-relative, so offline conversion cannot resolve them — the one remaining miss |
312320

313321
Markdown — **10/10**. Both former gaps fixed: `signature`/`stamp` → image blocks,
314322
and the `2\.` escape via contiguity-aware text merging (pulldown splits both

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Fleischwolf — a Rust port of docling.
22
#
3-
# A Cargo workspace of four crates:
3+
# A Cargo workspace of five crates:
44
# fleischwolf-core — the DoclingDocument data model + Markdown/JSON serializers
55
# fleischwolf — the DocumentConverter and the per-format backends
66
# fleischwolf-pdf — the PDF/image ML pipeline (pdfium text + ONNX layout/OCR)
7+
# fleischwolf-asr — the audio/ASR pipeline (symphonia decode + ONNX Whisper)
78
# fleischwolf-cli — the `fleischwolf` command-line binary
89
# See MIGRATION.md for the Python → Rust mapping and what is / isn't ported.
910

@@ -12,6 +13,7 @@ resolver = "2"
1213
members = [
1314
"crates/fleischwolf-core",
1415
"crates/fleischwolf",
16+
"crates/fleischwolf-asr",
1517
"crates/fleischwolf-cli",
1618
"crates/fleischwolf-pdf",
1719
# Node.js / Bun N-API bindings (published to npm, not crates.io).

MIGRATION.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ Rust. This document is the **current status**: what is migrated, how it compares
55
to upstream docling, and what is intentionally not done yet. (The original
66
phased plan is kept at the end as history.)
77

8-
> **Status: the format migration is essentially complete.** Every document
9-
> format in docling's pipeline except **audio/ASR** is supported, plus Markdown
10-
> (legacy + a Rust-only *strict* mode), docling-native **JSON** output, **image
11-
> extraction**, and **MHTML** (a fleischwolf-only extension docling doesn't
12-
> have). The declarative formats are pure-Rust and checked byte-for-byte
8+
> **Status: the format migration is complete.** Every document format in
9+
> docling's pipeline is supported — including **audio/ASR** (Whisper via ONNX,
10+
> in `fleischwolf-asr`) — plus Markdown (legacy + a Rust-only *strict* mode),
11+
> docling-native **JSON** output, **image extraction**, and **MHTML** (a
12+
> fleischwolf-only extension docling doesn't have). The declarative formats are pure-Rust and checked byte-for-byte
1313
> against *live* docling; the PDF/image/METS ML path lives in `fleischwolf-pdf`
1414
> (a pure-Rust PDF text parser + pdfium rasterization + ONNX
1515
> layout/TableFormer/OCR + a port of docling-parse's line sanitizer) and is also
@@ -30,13 +30,15 @@ Four layers, mirroring docling's:
3030
| **Converter** | `docling/document_converter.py` | `fleischwolf``converter.rs` (format dispatch + XML content sniffing) |
3131
| **Backends** | `docling/backend/*` | `fleischwolf``backend/*` (one per format) |
3232
| **PDF/ML pipeline** | `docling/pipeline/*`, `docling/models/*` | `fleischwolf-pdf` — pdfium + ONNX layout/OCR + assembly |
33+
| **Audio/ASR pipeline** | `docling/pipeline/asr_pipeline.py` | `fleischwolf-asr` — symphonia decode + log-mel + ONNX Whisper |
3334
| **CLI** | `docling/cli` | `fleischwolf-cli` |
3435

3536
```text
3637
crates/
3738
├── fleischwolf-core/ # DoclingDocument, Node model, markdown.rs, json.rs, base64.rs, labels.rs
3839
├── fleischwolf/ # DocumentConverter, source/format detection, backend/*.rs, ooxml.rs
3940
├── fleischwolf-pdf/ # pdfium_backend, layout (RT-DETR/ONNX), ocr (PP-OCRv3/ONNX), assemble, mets
41+
├── fleischwolf-asr/ # audio decode (symphonia), mel.rs, whisper.rs (ONNX), tokenizer.rs
4042
├── fleischwolf-cli/ # `--strict`, `--to md|json`, `--images placeholder|embedded|referenced`
4143
└── fleischwolf-node/ # Node.js/Bun N-API bindings (napi-rs), published to npm as `fleischwolf`
4244
```
@@ -99,6 +101,7 @@ close — see `PDF_CONFORMANCE.md`. A deterministic snapshot baseline
99101
| PDF | **pure-Rust text parser** (`textparse.rs`, font-advance glyph boxes) + pdfium page render → RT-DETR layout (ONNX) → **TableFormer** table structure (ONNX) → PP-OCRv3 OCR for scanned pages → **docling-parse line sanitizer** (`dp_lines.rs`) + reading-order assembly |
100102
| Images (tiff/webp/png/jpeg) | the same pipeline, image as a single page |
101103
| METS / Google Books | `.tar.gz` of per-page hOCR + TIFF → cells from hOCR → the same layout+assembly path (no OCR needed) |
104+
| Audio (wav/mp3/flac/ogg/aac/m4a + mp4/mov audio tracks) | `fleischwolf-asr`: **symphonia** decode (no ffmpeg) → 16 kHz mono → ported log-mel front-end → **Whisper tiny** encoder/decoder (ONNX, greedy with OpenAI's timestamp rules — docling's ASR defaults) → `[time: start-end] text` paragraphs. AVI is the one container symphonia can't demux. |
102105

103106
---
104107

@@ -203,11 +206,10 @@ deliberate scope boundary or a cosmetic, single-fixture polish gap.
203206

204207
**Out of scope by design:**
205208

206-
- **Audio / ASR.** docling's Whisper-based speech path. A separate ML boundary
207-
like PDF; deferred by design.
208209
- **VLM pipelines** (SmolDocling / remote VLM) and **enrichment models** (picture
209210
classification, formula understanding, code understanding). Model-bound; out of
210-
scope for the discriminative port.
211+
scope for the discriminative port. (**Audio/ASR is now done** — see §2; the
212+
only container gap is AVI, which symphonia cannot demux.)
211213
- **XML DocLang / DocTags** input backend — no `.dclg` sources in the corpus to
212214
verify against, and not in the requested scope.
213215
- **Older patent schemas.** USPTO covers the modern `v4x` XML only; the
@@ -293,9 +295,9 @@ The port followed roughly: **Phase 0** skeleton & API → **Phase 2** text/marku
293295
(Markdown, CSV, HTML, AsciiDoc, DeepSeek) → **Phase 3** Office & e-book (DOCX,
294296
PPTX, XLSX, EPUB, ODF) → **Phase 4** long tail (XML families, LaTeX, Email,
295297
WebVTT, JSON) → **Phase 5–6** the PDF/image ML pipeline (pdfium + ONNX layout/OCR
296-
+ geometric tables) → output formats (strict Markdown, JSON, image extraction).
297-
Audio/ASR (the old "Phase 7" tail) and PyO3 interop bindings remain the main
298-
unbuilt pieces.
298+
+ geometric tables) → output formats (strict Markdown, JSON, image extraction)
299+
**Phase 7** audio/ASR (symphonia + ONNX Whisper). PyO3 interop bindings remain
300+
the one unbuilt piece.
299301

300302
## Why "Fleischwolf"? 🦀
301303

README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ the full architecture, the Python → Rust mapping, and the phased plan.
2828

2929
The public API works end to end across **Markdown, CSV, HTML, AsciiDoc, DOCX,
3030
PPTX, XLSX, EPUB, ODF, WebVTT, Email, MHTML, JATS, USPTO, XBRL, LaTeX, JSON,
31-
PDF, images and METS** — plus Markdown / docling-JSON output and image
31+
PDF, images, METS and audio** — plus Markdown / docling-JSON output and image
3232
extraction. MHTML is a fleischwolf-only extension (docling has no MHTML
3333
backend): saved-webpage `.mhtml`/`.mht` archives are parsed as a MIME message
3434
with [`mail-parser`](https://crates.io/crates/mail-parser) (which conforms to
@@ -39,8 +39,16 @@ lives in `fleischwolf-pdf`: a pure-Rust PDF text parser, pdfium for page
3939
rasterization, and an ONNX layout/TableFormer/OCR stack. TableFormer is ported
4040
to ONNX and run on every detected table region to recover its structure;
4141
geometric reconstruction from cell positions remains only as the fallback when
42-
the TableFormer graphs aren't present (see `PDF_CONFORMANCE.md`). Audio/ASR is
43-
the main format still on the roadmap (see `MIGRATION.md`).
42+
the TableFormer graphs aren't present (see `PDF_CONFORMANCE.md`).
43+
44+
**Audio/ASR** (docling's Whisper pipeline) lives in `fleischwolf-asr`, and it is
45+
Rust all the way down: [`symphonia`](https://crates.io/crates/symphonia)
46+
demuxes/decodes the container in-process (wav, mp3, flac, ogg, aac, m4a — plus
47+
the audio track of mp4/mov; no ffmpeg), a ported log-mel front-end feeds a
48+
**Whisper tiny** encoder/decoder exported to ONNX (run on `ort`, greedy with
49+
OpenAI's timestamp rules — docling's ASR defaults), and each segment becomes a
50+
`[time: start-end] text` paragraph. `FLEISCHWOLF_ASR_LANG` picks the language
51+
(default `en`). AVI is the one container symphonia cannot demux.
4452

4553
Output is checked against upstream Python docling — declarative formats
4654
byte-for-byte against live docling, the ML pipeline against a deterministic
@@ -271,10 +279,13 @@ curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/do
271279
| RT-DETR layout | `models/layout_heron.onnx` |
272280
| PP-OCRv3 rec + dictionary | `models/ocr_rec.onnx`, `models/ppocr_keys_v1.txt` |
273281
| TableFormer (optional) | `models/tableformer/{encoder,decoder,bbox}.onnx` (+ `.data` sidecars where the export needs them) |
282+
| Whisper tiny (audio/ASR; skip with `--no-asr`) | `models/asr/{encoder_model,decoder_model}.onnx`, `models/asr/vocab.json` (+ `added_tokens.json` for language selection) |
274283

275284
Idempotent — safe to re-run; it skips files already on disk. Pass `--force` to
276285
re-fetch everything, or set `$FLEISCHWOLF_MODELS_URL` to fetch from a
277-
different host (your own export, an internal mirror, …). pdfium is Linux x64
286+
different host (your own export, an internal mirror, …); the Whisper assets
287+
come from Hugging Face (`$FLEISCHWOLF_ASR_MODELS_URL` overrides, or point
288+
`DOCLING_ASR_{ENCODER,DECODER,VOCAB}` at explicit files). pdfium is Linux x64
278289
only for now — other platforms, or building the models from source, need
279290
[`scripts/pdf_setup.sh`](#testing) instead.
280291

@@ -368,6 +379,10 @@ cargo run -p fleischwolf-cli -- --to json crates/fleischwolf/sample.html > out.j
368379
scripts/download_dependencies.sh
369380
cargo run -p fleischwolf-cli -- document.pdf
370381

382+
# transcribe audio (wav/mp3/flac/ogg/aac/m4a, or an mp4/mov audio track) — the
383+
# Whisper models come from the same download script
384+
cargo run -p fleischwolf-cli -- recording.mp3
385+
371386
# extract pictures (PDF/image inputs): embed as data URIs, or write ./artifacts/*.png
372387
cargo run -p fleischwolf-cli -- --images embedded document.pdf
373388
cargo run -p fleischwolf-cli -- --images referenced document.pdf > out.md

crates/fleischwolf-asr/Cargo.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[package]
2+
name = "fleischwolf-asr"
3+
description = "Audio/ASR pipeline for Fleischwolf (Whisper via ONNX; a Rust port of docling's ASR pipeline)."
4+
version.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
homepage.workspace = true
9+
readme.workspace = true
10+
keywords.workspace = true
11+
categories.workspace = true
12+
# ort needs a newer compiler than the std-only crates' MSRV (same as fleischwolf-pdf).
13+
rust-version = "1.85"
14+
15+
[dependencies]
16+
fleischwolf-core = { path = "../fleischwolf-core", version = "0.11.0" }
17+
# Same ort as fleischwolf-pdf so the workspace keeps a single ONNX runtime.
18+
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "download-binaries", "tls-rustls"] }
19+
serde_json = "1"
20+
# Pure-Rust audio demuxing/decoding for every container docling's ASR accepts:
21+
# wav (pcm), mp3, flac, ogg/vorbis, aac (adts), m4a/mp4 (isomp4+aac).
22+
symphonia = { version = "0.5", default-features = false, features = ["wav", "pcm", "mp3", "flac", "ogg", "vorbis", "aac", "isomp4"] }

0 commit comments

Comments
 (0)