Align
Word-timestamp refinement for any transcript, on device.
Align model page- Platforms
- iOS, macOS, tvOS, visionOS, Linux, Windows, Node
- Languages
- 9
- Weights
- v1.1.0
Install
.package(url: "https://github.com/Desert-Ant-Labs/desert-ant-core.git", from: "3.5.0")
Then add the Align product to your target.
npm i @desert-ant-labs/align
Changes in this release
refineisasyncand takeslanguageCode; it works on any transcript's words, not onlySpeechAnalyzeroutput.- The handle is
Align.SpeechTimestampRefinerremains as a deprecated alias for the name only:Alignhas nolocale:initializer, and bothrefineandisSupportedchanged shape, so a 3.x call site does not compile through it. - Streaming callers move to
StreamingRefiner, which wraps anAlignand keeps theSpeechAnalyzerintegration.SpeechTimestampRefiner(locale:)no longer compiles. - The Apple runtime's log-mel frontend is corrected: it scaled power by 4 before the log. On-device results change at this release.
- A LiteRT export adds Linux, Windows and Node.
- The npm package is Node-only, and its default entry refuses in the browser with an actionable error. The refiner is a cascade of two graphs, and the WebAssembly host compiles one model per module, so there is no browser build.
- Three further changes break source on 3.x callers, separately from the two above:
isSupportedis nowasync throws -> Booland takes a language code rather than being a property;reset()is no longer on the offline handle; andAlignResourceErroris removed.
Usage
Align corrects the word timestamps of any transcript against its audio; on Apple it also
attaches to SpeechAnalyzer (iOS 26 and later) through StreamingRefiner.
import Align
let align = Align()
guard try await align.isSupported(languageCode: "en") else { return words }
let fixed = try await align.refine(words, audio: samples, sampleRate: 16_000, languageCode: "en")
SpeechAnalyzer (Apple)
Attach the refiner to the standard Speech pipeline. It records the audio going in and corrects the timestamps coming out:
import Align
let refiner = StreamingRefiner(locale: locale)
try await analyzer.start(inputSequence: inputs.recordingAudio(for: refiner))
for try await result in transcriber.results.refiningTimestamps(with: refiner) {
result.text // corrected word-level audioTimeRange attributes
result.words // [WordTiming]: text, start, end, refined
}
Volatile results pass through unchanged; finalized results are refined. In a
callback-based audio pipeline, analyzerInput does both halves at once:
let input = try await refiner.analyzerInput(buffer) // buffers the audio, returns Apple's input
For file input, hand it the AVAudioFile the analyzer is reading. A separate file handle
is used, so the file stays positioned for the analyzer:
let refiner = try await StreamingRefiner(locale: locale, audioFile: file)
The /native subpath runs inference in plain Node, prebuilt for linux-x64, linux-arm64 and
darwin-arm64.
import { Align } from "@desert-ant-labs/align/native";
const align = await Align.load();
const fixed = await align.refine(samples, 16000, words, { language: "en", deviceId: userId });
align.dispose();
Unsupported locales
Not every locale is covered by the model. Check before you build the pipeline; when it is
false, refine is a passthrough rather than an error:
guard try await align.isSupported(languageCode: "sv") else { /* use the original timestamps as-is */ }
A StreamingRefiner checks the language it was created with the same way, with
try await refiner.isSupported(). On JavaScript, Align.isSupported(language) is a
synchronous check with the same meaning.
refine also keeps the original timestamp for any single word whose correction runs into the
search edge, whose corrected range would end before it starts, or, when streaming, whose forward
context is not buffered yet. Those fallbacks are checks on structure, not on accuracy: a
correction that looks plausible but is wrong still lands. A stage that fails outright throws
rather than falling back. See Limitations.
Input that cannot be a time is refused before any work, whatever the language. Every start
and end must be finite and from -1 to 10,000,000 seconds, and the sample rate finite and
positive, and the audio must not be empty. Audio at another rate that resamples to more than
about 37 hours at 16 kHz is refused too. Swift throws AlignError.invalidInput, naming the
word; JavaScript rejects with a RangeError. A word more than about 1.2 seconds past the end
of the audio has nothing to refine against, so it keeps its input times with refined
false.
Loading the model
The weights are fetched from the Hub on first use and cached. To fetch them earlier, for example during onboarding, or to ship them yourself, see model downloads and caching.
let align = Align()
if !align.isDownloaded() {
try await align.download { fraction in print("\(Int(fraction * 100))%") }
}
let offline = Align(directory: myModelDirectory) // adopted as-is, nothing downloaded
Files
| File | Format | Size | Contents |
|---|---|---|---|
align-coarse. | Compiled Core ML (FP16) | ~0.3 MB | Coarse stage |
align-fine. | Compiled Core ML (FP16) | ~0.3 MB | Fine stage |
align-coarse. | LiteRT (FP32) | ~0.5 MB | Coarse stage |
align-fine. | LiteRT (FP32) | ~0.5 MB | Fine stage |
mel_ | Float32 filter bank | ~40 KB | Log-mel filter bank the runtime frontend needs |
calibrator. | Gradient-boosted trees | ~70 KB | Correction calibrator |
refiner_ | JSON | tiny | Runtime config |
The compiled .mlmodelc stages, mel_filters.bin, calibrator.bin, and refiner_config.json
are what the Swift SDK downloads on Apple; the .tflite pair replaces the two .mlmodelc
directories on Linux, Windows and Node.
Inputs and outputs
- Input: mono audio plus any transcript's words with their proposed start/end times.
- Output: the same words with corrected start/end times, or the original time when a correction is not structurally safe.
Accuracy
On the clean condition, macro-averaged over the nine languages, Align cuts the proposer's raw
timing error by roughly two-thirds. Per language it ranges from a third to over three quarters,
and the noisy condition is lower. Both runtimes are scored on gold-en-us, the 258-boundary set
corrected by hand against the waveform. There the on-device Core ML runtime's corpus mean boundary
error is 44.8 ms (darwin-arm64, 2026-09-21), within 0.2 ms of the same checkpoint through the
training-time frontend (45.0) and less than half the proposer's 100.8 ms, and the LiteRT export's
corpus mean boundary error on that set matches the training-time reference to five significant
figures. That is one corpus average in one language, not a per-boundary guarantee. The
per-condition and per-language figures on this page are the training-side measurement; the Apple
runtime's frontend changed at this release, so on-device Core ML numbers differ from v1.0.0's and
are not carried over from it. The same per-condition figures are on the
model card, which also names the three
weakest languages.
Languages
English, Spanish, French, Italian, Portuguese, German, Japanese, Korean, and Chinese. A locale outside this set is passed through unchanged.
Limitations
- The macro-averaged figures are measured against machine forced-alignment estimates, not human annotations, so they show a large, consistent reduction of the proposer's timing error rather than sample-accurate ground truth.
- A learned correction is not guaranteed to improve every boundary; the structural fallback keeps the original timestamp when a correction looks unsafe but cannot catch every plausible-looking error.
- Japanese, Korean, and Chinese were the weakest languages before v1.0.0. They now improve their proposals by 33%, 55%, and 51%.
- Number timings are the weakest remaining case. On a small sample refinement moved digit boundaries further from the reference than leaving them alone, so treat spoken numbers as unimproved until a larger sample settles it.
- The LiteRT export is a third numeric path alongside Core ML and the training-time reference. The parity fixture is Core ML's own recorded output on synthetic audio: Core ML on the CPU reproduces it where it was measured (0.0 ms, against a 25 ms tolerance) while LiteRT drifts 10.4 ms from it (linux-arm64, 2026-09-17). Treat the two runtimes as able to disagree by around 10 ms on the same audio, not as agreeing to sub-millisecond.
- No browser build: the cascade is two graphs, and the WebAssembly host compiles one model per module.
- No Android SDK.