MOSS-Transcribe-Diarize โ€” ATC fine-tune

Fine-tune of OpenMOSS-Team/MOSS-Transcribe-Diarize (0.9B, joint transcription + speaker diarization) specialized for US air-traffic-control radio audio (VHF AM feeds, ~30 concurrent voices per 30-minute clip).

Training data

Publicly available ATC audio and recordings: stitched multi-speaker training samples with controlled voice recurrence, plus ~230 h of self-labeled, human-reviewed recordings across 35+ US airports (ground / tower / approach / departure), with diarization labels anchored to ADS-B-derived callsign identities.

Training compute

Iteratively fine-tuned on a single GPU: early iterations on 1ร— H100 80 GB, later ones on 1ร— H200 141 GB โ€” the larger memory raised the usable sample-length cap, letting full-length (~30-minute, up to ~40k-token) clips join the training mix instead of being filtered out. Total fine-tuning compute across all iterations: roughly $250 of on-demand GPU time.

Evaluation (vs the base model)

benchmark base fine-tuned
32-min real tower clip โ€” speaker purity 0.35 (8.5/44 voices found) 0.930 (41/41 identities)
10-clip unseen-airport eval โ€” pair-F1 / greedy-acc 0.457 / 0.593 (5-clip subset) 0.644 / 0.719
US tower audio โ€” WER vs adjudicated gold 0.162 0.115
Held-out stitched gold test โ€” WER 0.551 0.427

1. Input audio: the denoise chain (REQUIRED)

The model was trained exclusively on audio processed by this exact ffmpeg chain, and it under-performs on raw un-denoised audio โ€” run every input through it first:

ffmpeg -i input.mp3 \
  -af "highpass=f=200,lowpass=f=3600,afftdn=nr=12:nf=-25,dynaudnorm=f=150:g=15" \
  -ar 16000 -ac 1 -sample_fmt s16 input.wav

(High-pass 200 Hz and low-pass 3600 Hz bracket the AM voice band; afftdn removes broadband hiss; dynaudnorm levels the wildly varying per-transmission loudness of scanner feeds.)

2. Loading (note the processor step)

Load the weights from this repository, but the processor from the base model at the pinned revision: AutoProcessor pointed at a fine-tune checkpoint silently falls back to a bare text tokenizer (the saved config carries no processor auto_map), which breaks audio featurization without an error.

import soundfile as sf
import torch
from transformers import AutoModelForCausalLM, AutoProcessor

REPO = "danieledll/moss-transcribe-diarize-atc"
BASE = "OpenMOSS-Team/MOSS-Transcribe-Diarize"
BASE_REV = "e8681d68e7042738ffca8ac8212bc8fcb1131ab8"

PROMPT = (
    "Transcribe the audio. For each segment, start with the timestamp and speaker ID "
    "([S01], [S02], [S03], ...), then the spoken text, and end with the segment timestamp."
)  # the exact prompt the checkpoint was trained with โ€” do not change it

model = (
    AutoModelForCausalLM.from_pretrained(REPO, trust_remote_code=True, dtype="auto")
    .to(torch.bfloat16).to("cuda").eval()
)
processor = AutoProcessor.from_pretrained(BASE, revision=BASE_REV, trust_remote_code=True)

3. Transcription

audio, sr = sf.read("input.wav", dtype="float32", always_2d=True)
assert sr == 16000
audio = audio.mean(axis=1)  # mono

messages = [{"role": "user", "content": [
    {"type": "audio", "audio": ""}, {"type": "text", "text": PROMPT},
]}]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
with torch.amp.autocast("cuda", dtype=torch.bfloat16):
    inputs = processor(text=text, audio=[audio], max_length=131072,
                       audio_kwargs={"device": "cuda"}, return_tensors="pt").to("cuda")

with torch.inference_mode(), torch.amp.autocast("cuda", dtype=torch.bfloat16):
    out = model.generate(
        input_ids=inputs["input_ids"],
        attention_mask=torch.ones_like(inputs["input_ids"]),
        input_features=inputs["input_features"],
        audio_feature_lengths=inputs["audio_feature_lengths"],
        audio_chunk_mapping=inputs["audio_chunk_mapping"],
        max_new_tokens=32768,
    )
transcript = processor.tokenizer.decode(
    out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
).strip()
# -> "[0.16][S01] Two two right, taxi via Bravo.[2.83][3.28][S02] ..."

4. The looping issue โ€” and the recovery that handles it

Symptom. On near-silent stretches (quiet feeds, long gaps), greedy decoding occasionally enters a degenerate loop mid-transcript: a frozen [timestamp] repeated forever, a cycling token n-gram, or hallucinated micro-segments. Left alone, the decode burns its entire token budget on garbage and the rest of the clip is lost.

Handling (mirrors Whisper's temperature-fallback design). Detect the stall in-stream with a StoppingCriteria, truncate the output back to the last complete segment whose start timestamp still advances, then continue the same pass with sampling at the next temperature of a ladder โ€” the continuation sees every previously emitted [Sxx] label, so speaker assignments stay consistent. Detection (pure Python):

import re, zlib

TS = re.compile(r"\[(\d+(?:\.\d+)?)\]")
SEGMENT = re.compile(r"\[(\d+(?:\.\d+)?)\]\[S\d+\].*?\[(\d+(?:\.\d+)?)\]", re.DOTALL)

def has_token_loop(generated, ngram=6, window=240, min_repeats=6):
    """Tail of the generated ids cycles: last `ngram` tokens recur >= `min_repeats` in the window."""
    if len(generated) < ngram * min_repeats:
        return False
    tail = list(generated[-window:]); probe = tail[-ngram:]
    return sum(tail[i:i+ngram] == probe for i in range(len(tail)-ngram, -1, -1)) >= min_repeats

def text_stalled(tail):
    """Frozen timestamp, or the text collapses under compression (ratio > 4)."""
    stamps = TS.findall(tail)
    if len(stamps) >= 6 and len(set(stamps[-6:])) == 1:
        return True
    raw = tail.encode()
    return len(raw) >= 600 and len(raw) / len(zlib.compress(raw)) > 4.0

def keep_token_count(pieces):
    """Tokens to keep after a stall: through the last segment whose start still advances."""
    text = "".join(pieces); keep_end = 0; prev = -1.0
    for m in SEGMENT.finditer(text):
        if float(m.group(1)) < prev:
            break
        prev = float(m.group(1)); keep_end = m.end()
    total = 0
    for i, p in enumerate(pieces):
        total += len(p)
        if total >= keep_end:
            return i + 1
    return len(pieces) if keep_end else 0

Recovery loop: run greedy with a stopping criterion that fires on has_token_loop(generated) or (every 25 steps) text_stalled(decoded_tail); on a stall, keep keep_token_count(...) tokens, append them to the prompt, and re-enter generation with do_sample=True at the next temperature of (0.2, 0.5, 0.7, 0.3, 0.6, 0.8), decrementing a shared max_new_tokens budget (stop when < 512 remains). In production this recovers a stalled 30-minute decode from ~45 emitted segments to full coverage in a single retry.

Residual behavior to detect downstream: on audio far outside the training distribution, diarization can go modal โ€” every transmission minted as a new speaker, or everything merged into one. Both are trivially detectable (distinct-labels โ‰ˆ segment-count, or 1 label where context implies several) and a re-decode often lands on the correct mode.

5. Strongly recommended: a context-integration second step

VHF ATC audio is genuinely low quality โ€” clipped AM radio, overlapping transmissions, weak receivers โ€” so even a well-adapted acoustic model mishears callsigns and occasionally attributes a transmission to the wrong speaker. The single biggest quality jump comes from a second, non-acoustic step that reconciles the transcript against external context for the specific airport and frequency being processed:

  • ADS-B traffic data (e.g. adsb.lol) for the clip's time window and location: the set of aircraft actually present is a closed candidate list โ€” matching spoken callsigns against it names each speaker, corrects ASR-garbled callsigns, and merges/splits mismatched speaker labels using who could physically be on that frequency.
  • The frequency's role (delivery / ground / tower / approach / departure): constrains the phraseology and which number groups are headings, altitudes, or frequencies rather than callsigns.
  • Airport specifics (runway/taxiway identifiers, fixes) and airline telephony names (official designator-to-callword tables): repair garbled words toward what can actually be said on that frequency.

In practice this turns raw diarized output into a reliably attributed transcript โ€” it is the step that fixes mismatched speakers, not more acoustic accuracy.

Downloads last month
21
Safetensors
Model size
0.9B params
Tensor type
BF16
ยท
Inference Providers NEW
This model isn't deployed by any Inference Provider. ๐Ÿ™‹ Ask for provider support

Model tree for danieledll/moss-transcribe-diarize-atc

Finetuned
(15)
this model