Files
doctate/canary/CHUNKING.md
T
Brummel 268954f722 feat: Add standalone canary service
This commit introduces a new standalone FastAPI service for the NVIDIA
Canary ASR model.

Key changes include:
- A new `canary/` directory containing the service code.
- `Dockerfile`: Defines the Docker image for the Canary service.
- `docker-compose.yml`: Configures the Docker Compose setup for running
  the service.
- `main.py`: Implements the FastAPI application, model loading, and
  inference logic.
- `requirements.txt`: Lists the Python dependencies for the service.
- `CHUNKING.md`: Documents the long-form audio handling strategy for
  Canary.
- `README.md`: Provides an overview of the service, API, and deployment
  instructions.
- `deploy.sh`: A script for deploying the service to a remote host.

This service allows for independent evaluation and deployment of the
Canary ASR model, separate from the existing `doctate-whisper` service.
It utilizes a buffered inference approach for handling long audio files,
as detailed in `CHUNKING.md`.
2026-04-30 09:24:33 +02:00

9.9 KiB
Raw Blame History

Long-form audio handling in canary-1b-v2

TL;DR

  • Canary-1b-v2 truncates audio > ~40 s on the default model.transcribe([path]) call.
  • The fix is NeMo ≥ 2.5: long-form inference with overlapping-window auto-chunking ships in transcribe() itself, triggered by batch_size=1 or single-file input.
  • The explicit FrameBatchMultiTaskAED path in NeMo 2.4.x is broken for canary-1b-v2 (NeMo issue #14544: CanaryBPETokenizer lacks the vocabulary attribute the streaming-utils class requires).
  • Pin: nemo_toolkit[asr]>=2.7.3,<3.0 in requirements.txt.
  • Currently used in this codebase: Path 2 (FrameBatchMultiTaskAED), branched on duration > 25 s. Empirically verified end-to-end on experiments/case_c414cf52/. Path 1 was not retested after the NeMo upgrade — see Decision below.
  • VRAM cost: ~9.7 GB on RTX 3060 with batch_size=8. Canary cannot share a 12 GB GPU with doctate-whisper (~3.9 GB) — sequential operation required, or reduce FrameBatchMultiTaskAED.batch_size.

Problem

The default model.transcribe([audio_path]) path silently truncates audio longer than roughly 40 s. Observed empirically in our experiments/case_c414cf52/asr_comparison.md benchmark with NeMo 2.4.1: audios at 66 s, 78 s, and 81 s lost their final 2540 s of content. The model emits no warning, container logs are clean — only the output length reveals the truncation.

The HuggingFace model card claims "automatic chunking enabled at ~40 s with 1 s overlap." That claim is true — but only in NeMo ≥ 2.5, and only on specific call shapes (single-file or batch_size=1). Our pinned nemo_toolkit==2.4.1 did not have the feature merged into a release yet.

Why it happens

Canary-1b-v2's FastConformer encoder has a fixed positional-embedding window. When the input exceeds it, the encoder produces features only for the first window. The decoder emits text up to whatever it generated from those features and then stops. No exception, no warning.

The official NeMo answer (per maintainer @nithinraok in issue #14544):

can you switch to main branch. Its not part of release version yet.

That comment dates from August 2025, when canary-1b-v2 was new and only the main branch had the long-form handling. By NeMo 2.5 it shipped to release; by 2.7.x (our current pin) it is stable.

The transcribe() method itself supports dynamic chunking with overlapping windows in NeMo ≥ 2.5. It activates automatically under either condition:

  • a single audio file passed as a string (not a list), OR
  • batch_size=1 set explicitly

Quote from NVIDIA-NeMo's official example docstring:

Canary-1b-v2 supports longform inference via the .transcribe() method. It will use dynamic chunking with overlapping windows for better performance. This behavior is enabled automatically for longform inference when transcribing a single audio file or when batch_size is set to 1.

Minimal call:

results = model.transcribe(
    [wav_path],
    source_lang="de",
    target_lang="de",
    pnc="yes",
    batch_size=1,        # triggers long-form path
)
text = results[0].text

This path is preferred: it is shorter, has no manifest plumbing, and uses NeMo's tested overlapping-window strategy.

Path 2 — explicit FrameBatchMultiTaskAED (fallback)

For batch inference over many files, manifest-driven workflows, or when fine control over chunk length is needed, NeMo exposes FrameBatchMultiTaskAED + get_buffered_pred_feat_multitaskAED.

Caveat — broken in NeMo 2.4.x for canary-1b-v2: FrameBatchMultiTaskAED.__init__ calls len(asr_model.tokenizer.vocabulary), but CanaryBPETokenizer (the tokenizer of canary-1b-v2) has no vocabulary attribute. NeMo issue #14544 tracks this. In v2.7.3 the same line was extended to fall back on tokenizer.vocab_size first, so this path is fixed from NeMo 2.5 onward. Verified by diffing streaming_utils.py:740760 between v2.4.1 and v2.7.3.

Key parameters from the official example:

Parameter Default Notes
chunk_len_in_secs 40.0 window length
total_buffer same as frame_len overlap budget
batch_size 8 chunks processed in parallel
model_stride 8 FastConformer downsampling factor

Pre-conditions on the model config (set once at startup):

import copy
from omegaconf import OmegaConf

_model_cfg = copy.deepcopy(model._cfg)
OmegaConf.set_struct(_model_cfg.preprocessor, False)
_model_cfg.preprocessor.dither = 0.0
_model_cfg.preprocessor.pad_to = 0
OmegaConf.set_struct(_model_cfg.preprocessor, True)
assert _model_cfg.preprocessor.normalize == "per_feature"

Setup pattern (condensed from the official script):

from nemo.collections.asr.parts.utils.streaming_utils import FrameBatchMultiTaskAED
from nemo.collections.asr.parts.utils.transcribe_utils import get_buffered_pred_feat_multitaskAED

frame_asr = FrameBatchMultiTaskAED(
    asr_model=model,
    frame_len=40.0,
    total_buffer=40.0,
    batch_size=8,
)
feature_stride = _model_cfg.preprocessor["window_stride"]
model_stride_in_secs = feature_stride * 8  # 0.01 * 8 = 0.08 for FastConformer

with torch.amp.autocast(model.device.type, enabled=True, dtype=torch.bfloat16):
    with torch.no_grad():
        hyps = get_buffered_pred_feat_multitaskAED(
            frame_asr,
            _model_cfg.preprocessor,
            model_stride_in_secs,
            model.device,
            manifest=manifest_path,   # JSONL with audio_filepath, source_lang, target_lang, pnc, taskname
            filepaths=None,
            timestamps=False,
        )

For German ASR, the manifest is required — filepaths=-only mode defaults to English. One JSON object per line:

{"audio_filepath": "/path/audio.wav", "duration": 80.9, "taskname": "asr",
 "source_lang": "de", "target_lang": "de", "pnc": "yes"}

Caveat — timestamps require shorter chunks

When timestamps=True, the official script forces chunk_len_in_secs=10.0:

When timestamps is True, it's recommended to use chunk_len_in_secs=10.0 for optimal results.

Larger windows produce less reliable per-segment timestamps. If we expose timestamps in /inference, chunk length must drop to 10 s for that mode.

Decision — what this codebase actually uses

Path 2 (FrameBatchMultiTaskAED) is implemented in main.py (_transcribe_buffered), branched on duration > CHUNKED_THRESHOLD_SECS (default 25 s). It was implemented before we upgraded NeMo, when Path 1 was unavailable in 2.4.1. After upgrading to 2.7.3 we kept Path 2 because it is the path we have empirical evidence for.

Path 1 was not retested after the NeMo upgrade. It would likely work in 2.7.3 — the official docs and example script promise so — and would let us delete _transcribe_buffered and the manifest plumbing. Reasons to revisit Path 1 later:

  • Code simplification (~50 lines of main.py could go away).
  • Lower VRAM (Path 2's batch_size=8 reserves buffers for 8 parallel chunks; Path 1's overlapping-window strategy may use less).
  • One fewer setup-per-call (FrameBatchMultiTaskAED is currently instantiated each request).

Reasons to keep Path 2:

  • It works, with measurements and full transcripts.
  • Explicit control over chunk_len_in_secs (currently 40 s).
  • The fallback for short audio (model.transcribe([wav], ...) for duration ≤ 25 s) is still useful for low latency on short clips.

The migration risk is non-zero (we'd need to re-run the case_c414cf52 benchmark to confirm output quality), so it's a "later" task rather than a "now" task.

What we ruled out

  • Hard fixed-cut chunking at second boundaries (the approach in EvilFreelancer/docker-canary-serve, using wave.readframes() with no overlap): cuts mid-word at chunk boundaries. NeMo's overlapping-window paths are strictly better and exist now in release versions.
  • Streaming variant (speech_to_text_aed_streaming_infer.py, ~20 KB): cache-aware streaming for online use cases. Overkill for our offline transcription workflow.
  • Monkey-patching tokenizer.vocabulary to work around the NeMo 2.4.x bug. Possible but a code smell — the upstream fix in 2.5+ is the right solution.

Empirical history (this codebase)

Date Attempt Result
2026-04-29 NeMo 2.4.1 + model.transcribe([wav], ...) Truncation > 50 s
2026-04-29 + batch_size=1 No change — feature not in 2.4.1
2026-04-30 + explicit FrameBatchMultiTaskAED (Path 2) on 2.4.1 AttributeError: CanaryBPETokenizer.vocabulary (NeMo bug)
2026-04-30 NeMo bumped to ≥ 2.7.3, Path 2 active ✓ all 6 audios fully transcribed; ~2 s inference per audio (~30× realtime); 9.7 GB VRAM

References