Sitelet https://github.com/qwertymuzaffar/ngx-transformers
Skip to content

Repository files navigation

ngx-transformers

npm version npm downloads CI license: MIT

Run Hugging Face Transformers.js models in Angular - on-device ML with a signals API. Text classification, zero-shot classification, sentence embeddings, semantic search, translation, Whisper speech-to-text and small LLMs with streaming, executed entirely in the browser (in a Web Worker if you like): no server, no API key, works offline once the model is cached.

Documentation · Demo app · Storybook - the demos load real models in your browser.

Why

Transformers.js has a React tutorial and hooks ecosystem - Angular has nothing. This library closes that gap with idiomatic Angular: lazily-loaded pipelines wrapped in signals, DI-friendly configuration, automatic cleanup with the owning component, and a drop-in progress component for the model download.

Install

npm i ngx-transformers @huggingface/transformers

@huggingface/transformers (v4) is a peer dependency. Angular >= 22.

Quick start

import { Component, signal } from '@angular/core';
import { createTextClassifier, ModelProgressComponent } from 'ngx-transformers';

@Component({
  imports: [ModelProgressComponent],
  template: `
    <textarea #box></textarea>
    <button (click)="analyze(box.value)" [disabled]="classifier.busy()">Analyze</button>
    <ngx-model-progress [status]="classifier.status()" [progress]="classifier.progress()" />
    @if (label(); as l) { <strong>{{ l }}</strong> }
  `,
})
export class SentimentComponent {
  readonly classifier = createTextClassifier(); // no download yet - lazy
  readonly label = signal<string | null>(null);

  async analyze(text: string) {
    const [top] = await this.classifier.classify(text); // downloads model on first call
    this.label.set(`${top.label} ${(top.score * 100).toFixed(1)}%`);
  }
}

The model downloads on the first classify() call (with progress reported through the progress signal), is cached by the browser, and is disposed automatically when the component is destroyed.

Semantic search

import { createTextEmbedder } from 'ngx-transformers';

readonly embedder = createTextEmbedder(); // all-MiniLM-L6-v2, ~23 MB q8

const ranked = await this.embedder.rank('how do I make my app faster?', docs);
// [{ text: 'Use trackBy and virtual scrolling...', score: 0.28, index: 2 }, ...]

const score = await this.embedder.similarity('car', 'automobile'); // ~0.8
const vectors = await this.embedder.embed(['one', 'two']); // number[][]

Speech to text (v0.2)

Whisper, fully in the browser - the audio never leaves the device:

import { createMicRecorder, createSpeechRecognizer } from 'ngx-transformers';

readonly whisper = createSpeechRecognizer(); // whisper-tiny.en, ~41 MB q4
readonly mic = createMicRecorder();

// a URL, File/Blob, ArrayBuffer, or 16 kHz Float32Array:
const { text, chunks } = await this.whisper.transcribe(fileOrUrl, { returnTimestamps: true });

// dictation:
async toggle() {
  if (this.mic.recording()) {
    const audio = await this.mic.stop();            // encoded Blob
    const { text } = await this.whisper.transcribe(audio); // decoded + resampled for you
  } else {
    await this.mic.start();                          // asks for mic permission
  }
}

MicRecorder exposes recording, seconds, and error signals for the UI. decodeAudio(blob) is exported separately if you want the 16 kHz mono Float32Array yourself.

Note: the recognizer defaults to dtype: 'q4' - q8 Whisper decoders currently fail on the v4 WASM runtime (transformers.js#1707). Multilingual checkpoints (e.g. onnx-community/whisper-tiny) accept language and task: 'translate' options.

Zero-shot classification

Score labels you name against a text, no fine-tuning - an NLI model judges whether "This example is {label}." follows from the text:

import { createZeroShotClassifier } from 'ngx-transformers';

readonly classifier = createZeroShotClassifier(); // mobilebert-uncased-mnli, ~26 MB q8

const scored = await this.classifier.classify(ticketText, ['billing', 'bug report', 'feature request']);
// [{ label: 'bug report', score: 0.91 }, { label: 'billing', score: 0.06 }, ...] - scores sum to 1

// several labels can apply at once - score each on its own:
const tags = await this.classifier.classify(text, ['food', 'repair', 'politics'], {
  multiLabel: true,
  hypothesisTemplate: 'This text is about {}.',
});

Xenova/distilbert-base-uncased-mnli (~80 MB) is the more accurate drop-in: createZeroShotClassifier({ model: '...' }).

Translation

Marian opus-mt checkpoints, one per language pair (~105 MB q8 each), resolved from the pair and loaded on demand:

import { createTranslator } from 'ngx-transformers';

readonly translator = createTranslator({ from: 'en', to: 'ru' }); // Xenova/opus-mt-en-ru

const russian = await this.translator.translate('The model runs entirely in the browser.');
const german = await this.translator.translate('Good morning.', { to: 'de' }); // opus-mt-en-de, its own download

The translator's status / progress signals follow the model used by the latest call, so one <ngx-model-progress> covers every pair; handleFor({ to: 'de' }) returns the underlying PipelineHandle when you want one per pair. Override the checkpoint for a pair globally with provideTransformers({ translationModels: { 'en-ru': 'my-org/en-ru-tiny' } }).

Multilingual checkpoints (NLLB, M2M100) serve every pair from one model and take the codes per call:

readonly translator = createTranslator({ model: 'Xenova/nllb-200-distilled-600M' });
await this.translator.translate(text, { from: 'eng_Latn', to: 'tgk_Cyrl' }); // src_lang / tgt_lang forwarded

Text generation

A small language model, streamed token by token:

import { createTextGenerator } from 'ngx-transformers';

readonly generator = createTextGenerator(); // SmolLM2-135M-Instruct, ~100 MB q4

const reply = await this.generator.generate(
  [{ role: 'user', content: 'Explain signals in Angular in two sentences.' }],
  { maxNewTokens: 120, onToken: (piece) => console.log(piece) },
);
// generator.output() holds the text generated so far while the model runs

Reactive inference

inferenceResource() runs a handle whenever an input signal changes, as an Angular resource with debounce and latest-wins:

readonly text = signal('');
readonly sentiment = inferenceResource({
  input: () => this.text().trim() || undefined, // undefined: idle
  run: (text) => this.classifier.classify(text),
  debounceMs: 300,
});
// template: sentiment.value(), sentiment.isLoading(), sentiment.error()

Web Worker

One provider moves every pipeline off the main thread; handles and signals work unchanged:

// transformers.worker.ts
/// <reference lib="webworker" />
import { runTransformersWorker } from 'ngx-transformers/worker';
runTransformersWorker();

// app.config.ts
provideTransformersWorker(
  () => new Worker(new URL('./transformers.worker', import.meta.url), { type: 'module' }),
);

ngx-transformers/worker has no Angular dependency, so the worker chunk stays tiny; @huggingface/transformers loads inside the worker only.

Any pipeline

createPipeline() exposes the full Transformers.js task surface with the same signal lifecycle:

import { createPipeline } from 'ngx-transformers';

readonly summarizer = createPipeline<string, { summary_text: string }[]>({
  task: 'summarization',
  model: 'Xenova/distilbart-cnn-6-6',
});

const [out] = await this.summarizer.run(longText);

Global configuration

import { provideTransformers } from 'ngx-transformers';

bootstrapApplication(App, {
  providers: [provideTransformers({ device: 'webgpu', dtype: 'q8' })],
});

Per-pipeline device/dtype/options win over the global config. translationModels maps a "from-to" pair to a checkpoint for createTranslator().

Every handle gets its pipeline from the PIPELINE_FACTORY injection token. Provide your own to add options or logging, route work to a Web Worker, or stub models in tests. createDefaultPipelineFactory() is the default; it lazy-imports @huggingface/transformers on the first pipeline, so wrapping it keeps the initial bundle small:

import { PIPELINE_FACTORY, createDefaultPipelineFactory, type PipelineFactory } from 'ngx-transformers';

const base = createDefaultPipelineFactory();
const logging: PipelineFactory = (task, model, options) => {
  console.log('loading', task, model);
  return base(task, model, options);
};

bootstrapApplication(App, {
  providers: [{ provide: PIPELINE_FACTORY, useValue: logging }],
});

API

Handles

Export What it is
createPipeline(request) Generic PipelineHandle for any Transformers.js task
createTextClassifier(options?) TextClassifier - sentiment/classification, classify(text, topK?)
createTextEmbedder(options?) TextEmbedder - embed(), similarity(), rank()
createSpeechRecognizer(options?) SpeechRecognizer - transcribe(audio, options?) with timestamps
createZeroShotClassifier(options?) ZeroShotClassifier - classify(text, labels, { multiLabel?, hypothesisTemplate? })
createTranslator(options?) Translator - translate(text, { from?, to? }), one model per pair, handleFor(pair)
createTextGenerator(options?) TextGenerator - generate(prompt, { maxNewTokens?, onToken?, ... }), streamed into output
inferenceResource({ input, run, debounceMs? }) An Angular resource that re-runs a handle when an input signal changes
provideTransformersWorker(createWorker) Run every pipeline in a Web Worker (ngx-transformers/worker runs the worker side)
createMicRecorder(deps?) MicRecorder - mic capture with recording/seconds/error signals
cosineSimilarity(a, b) / decodeAudio(blob) Standalone helpers
PIPELINE_FACTORY / createDefaultPipelineFactory() Swap or wrap how pipelines are created

All create* functions must run in an injection context (field initializer, constructor, or runInInjectionContext); handles are destroyed with the surrounding component (the model is released and later calls reject). Call dispose() yourself to free a model early and load it again later.

PipelineHandle signals

Signal Type Meaning
status 'idle' | 'loading' | 'ready' | 'busy' | 'error' Lifecycle; error only from a failed load, retryable
progress ModelProgress | null Download progress: the file reported last plus overall over every file of the model
error unknown The load error, if any
runError unknown The error of the most recent run, if it failed
ready / busy boolean (computed) Convenience for buttons and spinners

<ngx-model-progress>

Status line + download bar for any handle. Inputs: status (required), progress, labels (override per-status text). Themeable via --nt-accent, --nt-ink, --nt-muted, --nt-track.

Default models

Wrapper Model Size (q8) License
createTextClassifier Xenova/distilbert-base-uncased-finetuned-sst-2-english ~65 MB Apache-2.0
createTextEmbedder Xenova/all-MiniLM-L6-v2 ~23 MB Apache-2.0
createSpeechRecognizer onnx-community/whisper-tiny.en ~41 MB (q4) Apache-2.0
createZeroShotClassifier Xenova/mobilebert-uncased-mnli ~26 MB unlisted on the Hub (base MobileBERT: Apache-2.0)
createTranslator Xenova/opus-mt-{from}-{to} ~105 MB per pair varies per pair (Apache-2.0 or CC-BY-4.0)
createTextGenerator HuggingFaceTB/SmolLM2-135M-Instruct ~100 MB (q4) Apache-2.0

Swap any compatible checkpoint via { model: '...' }. Check the license of the model you ship.

SSR

Model loading is browser-only (WASM/WebGPU). Creating handles is safe on the server - nothing downloads until load()/run() - but call those only in browser code paths.

Device selection

WebGPU is several times faster than WASM where it works, but navigator.gpu existing is not enough: requestAdapter() can still hand back nothing on a machine without a usable GPU. hasWebGpu() runs that probe once (cached) and detectDevice() turns the answer into a device name; both resolve to the WASM answer on the server.

import { createTextClassifier, detectDevice } from 'ngx-transformers';

const device = await detectDevice(); // 'webgpu' | 'wasm'
const classifier = createTextClassifier({ device });

Or let every handle decide for itself:

bootstrapApplication(App, {
  providers: [provideTransformers({ autoDevice: true, dtype: 'q8' })],
});

With autoDevice, a handle that sets no device (or 'auto') probes WebGPU on its first load and uses it when available, else WASM. An explicit device on the handle or in the global config still wins. resetDeviceDetection() clears the cached answer.

License

MIT (c) Muzaffar Qosimov

About

On-device ML for Angular - Transformers.js v4 with a signals API: text and zero-shot classification, embeddings and semantic search, translation, Whisper speech-to-text in the browser, on WebGPU when available. No server, no API key.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages