@sanas-ai/browser-sdk
v1.0.0
Published
Browser SDK for integrating Sanas audio processing into web applications
Readme
Sanas Browser SDK — Developer Guide
The Sanas Browser SDK lets a web application send a live microphone stream through Sanas's audio processing (accent translation, speech enhancement, language translation) and receive the processed audio back as a MediaStream. It runs entirely in the browser and is transport-agnostic from your point of view: give it an input stream, receive an output stream.
This guide covers everything a developer needs to ship an integration. If you get stuck, jump to Troubleshooting.
Contents
- Overview
- Installation
- Authentication
- Quick start
- Remote Inferencing mode (accent translation, speech enhancement)
- Language Translation mode
- The processor lifecycle
- API reference
- Error handling
- Advanced options
- Troubleshooting
Overview
The SDK exposes a single top-level object — SanasSDK — that manages a session. From that session you create one or more AudioProcessor instances, each of which:
- Takes a
MediaStreamin (typically fromnavigator.mediaDevices.getUserMedia). - Emits a processed
MediaStreamout. You can attach the output to an<audio>element, feed it into WebRTC, or hand it to any other Web Audio API consumer. - Runs one of two modes:
- Remote Inferencing (RI) — a single Sanas model applied to the stream. Used for accent translation, speech enhancement, and other single-model features. You identify the model by name.
- Language Translation (LT) — one-direction live translation. Each processor handles a single direction (say English → Spanish). A real two-person conversation uses two processors — one per speaker's direction — linked by a shared
conversationIdso the server can share context between them. Callbacks stream per-utterance transcripts and language-detection hints back.
Availability of RI and LT is controlled per group in the Sanas Developer Console. The SDK checks entitlement before creating a processor and throws a distinct error if a feature isn't enabled for your group.
Installation
npm install @sanas-ai/browser-sdkThe package is self-contained — no peer dependencies. Works with any modern bundler (Vite, webpack, Next.js, Rollup, Parcel).
Browser support: Any browser that supports MediaStream, RTCPeerConnection, and WebSocket — Chrome 90+, Firefox 90+, Safari 15+, Edge 90+.
Authentication
The SDK never sees your Sanas API key. Instead, it accepts a tokenProvider callback that asks your backend for a short-lived access token, and your backend uses its stored API key to mint one.
Never embed a Sanas API key in browser code. Anyone can view your bundle, extract the key, and use it against your usage budget.
Backend responsibilities
Your backend needs a single endpoint (name it whatever you want) that:
- Authenticates the browser request using your app's own session mechanism (cookies, JWT, etc. — that's your call).
- Calls Sanas's OAuth token endpoint with your stored API key.
- Returns the token payload to the browser as JSON.
Here's a minimal Node.js example (no dependencies beyond Node 18+):
import http from "node:http";
const SANAS_API_KEY = process.env.SANAS_API_KEY;
const SANAS_IDENTITY_URL = "https://identity.sanas.ai";
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || "http://localhost:5173";
http.createServer(async (req, res) => {
// CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(204, corsHeaders());
return res.end();
}
if (req.method === "POST" && req.url === "/token") {
// Authenticate the calling browser session here (skipped for brevity).
const upstream = await fetch(`${SANAS_IDENTITY_URL}/oauth2/api-key/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: SANAS_API_KEY }),
});
const body = await upstream.text();
res.writeHead(upstream.status, {
"Content-Type": "application/json",
...corsHeaders(),
});
return res.end(body);
}
res.writeHead(404, corsHeaders());
res.end();
}).listen(process.env.PORT || 4000);
function corsHeaders() {
return {
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}Run it with SANAS_API_KEY=... node server.js.
Browser side
import { SanasSDK, type TokenResponse } from "@sanas-ai/browser-sdk";
const sdk = new SanasSDK();
await sdk.initialize({
tokenProvider: async (): Promise<TokenResponse> => {
const res = await fetch("https://your-backend.example.com/token", {
method: "POST",
credentials: "include", // if you use cookie sessions
});
if (!res.ok) throw new Error(`Token request failed: ${res.status}`);
return res.json();
},
});tokenProvider is called once at initialize() and again any time the SDK needs a fresh token. Make it idempotent and quick — the SDK will time it out after 30 seconds.
Origin allowlisting
Every group in the Sanas Developer Console has an allowed origins list. All API calls the SDK makes from the browser include the page's origin; requests from a non-allowlisted origin are rejected. Add every origin your app runs on (production, staging, localhost during development) under Project settings → Allowed origins.
Quick start
import { SanasSDK, ProcessorState } from "@sanas-ai/browser-sdk";
const sdk = new SanasSDK();
// 1. Initialize the SDK with your token provider.
await sdk.initialize({
tokenProvider: () =>
fetch("/token", { method: "POST" }).then((r) => r.json()),
});
// 2. Capture the microphone.
const input = await navigator.mediaDevices.getUserMedia({ audio: true });
// 3. Create a processor. RI mode with an accent-translation model:
const processor = await sdk.createAudioProcessor(
{ input, modelName: "AT5.2" },
{
// Drive your UI off state, not off the reason string.
// `Initializing` — session is being set up; output is silent.
// `Ready` — audio is flowing through the processor.
// `Failed` — fatal error; the processor can't recover. Destroy it,
// optionally show `reason` to the user, and create a new
// one if you want to keep going.
// `Disconnected` — you called destroyAudioProcessor(); terminal.
onStateChange: (state, reason) => {
if (state === ProcessorState.Ready) enableMicButton();
if (state === ProcessorState.Failed) {
disableMicButton();
showBanner(reason);
}
},
}
);
// 4. Attach the processed output to an <audio> element.
const audio = document.querySelector("audio")!;
audio.srcObject = processor.outputStream;
audio.play();
// 5. When you're done, tear down.
await sdk.destroyAudioProcessor(processor);
await sdk.shutdown();Remote Inferencing mode
For accent translation, speech enhancement, and similar single-model features. The model you use is provisioned in your Developer Console; the SDK verifies your group is entitled to the model before starting the call.
const processor = await sdk.createAudioProcessor(
{
input: micStream,
modelName: "AT5.2", // as shown in the Developer Console
},
{
onStateChange: (state, reason) => {
if (state === ProcessorState.Ready) console.log("live");
if (state === ProcessorState.Failed) console.error("failed:", reason);
},
}
);
audioElement.srcObject = processor.outputStream;Common errors:
CreateProcessorErrorCode.FeatureNotEntitled— Your group doesn't have Remote Inferencing enabled at all. Contact your Sanas administrator.CreateProcessorErrorCode.ModelNotEntitled— Your group has RI but not this specific model. Verify the exact name against the console.
Language Translation mode
Each LT processor handles one direction of translation — one input language, one output language. A processor delivers translated audio out and streams two kinds of side-channel data via callbacks:
onTranscript— a per-utterance transcript with partial and complete words for the detected input language.onLanguageRoute— a per-utterance language-detection result telling you which input language the server detected on that utterance (and which output language it will render).
Two-person conversations use two processors
For a real conversation between two people who speak different languages, run two processors on the same page, one per direction, linked by a shared conversationId. For example, an English-speaking agent and a Spanish-speaking customer on the same call:
- Processor A: agent's mic (
languageIn: "en-US") → Spanish audio played to the customer (languageOut: "es-ES"). - Processor B: customer's mic (
languageIn: "es-ES") → English audio played to the agent (languageOut: "en-US").
Give both processors the same conversationId (any UUID you generate per call) and the server shares translation context between them — proper nouns, jargon, and prior context stay consistent across both directions.
const conversationId = crypto.randomUUID();
// Agent's mic → Spanish audio for the customer.
const outbound = await sdk.createAudioProcessor(
{
input: agentMicStream,
languageIn: "en-US",
languageOut: "es-ES",
conversationId,
},
{
onStateChange: (state, reason) => console.log("outbound", state, reason),
onTranscript: (json) => console.log("outbound transcript", JSON.parse(json)),
onLanguageRoute: (json) => console.log("outbound route", JSON.parse(json)),
}
);
customerSpeakerElement.srcObject = outbound.outputStream;
// Customer's mic → English audio for the agent.
const inbound = await sdk.createAudioProcessor(
{
input: customerMicStream,
languageIn: "es-ES",
languageOut: "en-US",
conversationId, // same UUID — shared context.
},
{
onStateChange: (state, reason) => console.log("inbound", state, reason),
onTranscript: (json) => console.log("inbound transcript", JSON.parse(json)),
onLanguageRoute: (json) => console.log("inbound route", JSON.parse(json)),
}
);
agentSpeakerElement.srcObject = inbound.outputStream;conversationId is optional. Omit it if you're only running a single direction (e.g. a monologue translation) and don't need context sharing.
Single-direction usage
If you only need to translate one direction (for example, translating a broadcast into another language), a single processor is enough:
const processor = await sdk.createAudioProcessor(
{
input: micStream,
languageIn: "en-US",
languageOut: "es-MX",
},
{
onStateChange: (state, reason) => console.log(state, reason),
onTranscript: (json) => console.log("transcript", JSON.parse(json)),
onLanguageRoute: (json) => console.log("route", JSON.parse(json)),
}
);onTranscript payload
Delivered as a JSON string; parse before use. Fires multiple times per utterance as the server refines its hypothesis. Each event carries a mix of complete (newly-finalized words) and partial (still-in-flight words).
{
"type": "transcription",
"transcription": {
"lang": "en-US",
"utterance_idx": 0,
"complete": [],
"partial": [
{ "word": "Hi", "start": 0.0, "end": 0.0, "probability": 0.900 },
{ "word": ",", "start": 0.0, "end": 0.0, "probability": 0.881 },
{ "word": " testing","start": 0.0, "end": 0.0, "probability": 0.941 },
{ "word": " of", "start": 0.0, "end": 0.0, "probability": 0.936 }
]
}
}How the stream evolves for one utterance. partial is the current best guess and is replaced wholesale each event as the server refines it. complete is a delta of newly-finalized words in this event only — it is not the running total of everything finalized so far. To get the full utterance you must accumulate complete per utterance_idx on your side. end / start timestamps are reserved for future use and can be treated as 0.
For example, an utterance like "Hi, testing of language translation." arrives across several events. The complete deltas are [], [], ["Hi", ",", " testing", " of"], [" language"], [" translation"], ["."] — concatenating those deltas rebuilds the full sentence.
Minimal accumulator:
const utterances = new Map<number, string>();
onTranscript: (json) => {
const { transcription } = JSON.parse(json);
const prev = utterances.get(transcription.utterance_idx) ?? "";
const finalized = transcription.complete.map((w) => w.word).join("");
const pending = transcription.partial.map((w) => w.word).join("");
utterances.set(transcription.utterance_idx, prev + finalized);
render(prev + finalized + pending); // finalized + current guess
};Trailing punctuation typically arrives in its own event a second after the last word — plan your UI to accept it gracefully rather than assuming the utterance is done as soon as partial empties.
utterance_idx starts at 0 and increments per utterance. It correlates with onLanguageRoute events so you can label transcripts with the language the server detected.
onLanguageRoute payload
Emitted once at the start of each utterance, before the transcript events for that utterance begin arriving. Tells you the input language the server detected on that utterance and the output language it's rendering into.
{
"type": "language_route_chosen",
"language_route_chosen": {
"utterance_idx": 1,
"lang_in": "en-US",
"lang_out": "es-ES",
"is_final": true
}
}utterance_idx— correlates withonTranscriptevents for the same utterance.lang_in/lang_out— the concrete input and output languages for this utterance.is_final—truewhen the server has locked in the routing for this utterance. In wildcard-mode edge cases the server may emit tentative routes withis_final: falsebefore it commits; a subsequent event with the sameutterance_idxandis_final: truesupersedes the earlier one.
Sent for every utterance in both normal and wildcard mode. In wildcard mode, set languageIn or languageOut to "*" when creating the processor and the server detects the actual language for you; the value you get here is the concrete language the server chose. In normal mode (fixed languageIn / languageOut), the values echo back what the server confirmed for that utterance.
Changing languages mid-call
Rather than tearing down and recreating the processor (which drops the call), swap languages in place:
await processor.updateLanguageConfig({
languageIn: "en-US",
languageOut: "fr-FR",
});Usage attribution correctly rotates across language changes — the SDK bills each language segment separately without losing history when you switch back.
Common error:
CreateProcessorErrorCode.FeatureNotEntitled— Language Translation isn't enabled for your group. Contact your Sanas administrator.
The processor lifecycle
Every processor moves through a small state machine, observable via onStateChange:
Initializing ──► Ready ──► Disconnected (consumer destroyed)
│ │
│ └──► Failed (mid-call error)
│
└──► Failed (setup error)- Initializing — the SDK is negotiating the media session.
outputStreamexists and is silent. - Ready — the media session is up.
outputStreamis now delivering processed audio. - Failed — a fatal error occurred. Read
reasonfor a short human-readable explanation. The processor cannot be recovered; destroy it and create a new one. - Disconnected — the consumer called
destroyAudioProcessor. Terminal, expected.
The outputStream reference is stable for the life of the processor — you can attach it to an <audio> element or WebRTC track before the processor reaches Ready, and it will start emitting audio automatically once the media path opens.
API reference
SanasSDK
class SanasSDK {
initialize(params: InitParams): Promise<void>;
isInitialized(): boolean;
createAudioProcessor(
params: AudioProcessorParams,
callbacks?: ProcessorCallbacks
): Promise<IAudioProcessor>;
destroyAudioProcessor(processor: IAudioProcessor): Promise<void>;
getActiveProcessorCount(): number;
shutdown(): Promise<void>;
}initialize(params)
Establish a session with your token provider and validate your group's entitlements. Must be called before createAudioProcessor. Calling initialize twice throws InitErrorCode.AlreadyInitialized.
createAudioProcessor(params, callbacks?)
Create a processor for a single audio stream. You can hold multiple active processors on one SDK instance (each processes an independent stream).
Wire callbacks at construction — they are guaranteed to fire even for the initial Initializing → Failed transition on setup errors.
destroyAudioProcessor(processor)
Gracefully tear down a single processor. The output stream ends. Emits a final usage snapshot before returning so that usage accounting is complete.
shutdown()
Destroy all live processors, stop background reporting, clear all session state. Call this on page unload or when your app no longer needs Sanas. After shutdown, you can call initialize again to start a new session.
InitParams
interface InitParams {
tokenProvider: () => Promise<TokenResponse>;
logLevel?: "debug" | "info" | "warn" | "error" | "silent";
jitterBufferTargetMs?: number;
}TokenResponse
interface TokenResponse {
access_token: string;
user_id: string;
group_id: string;
expires_in?: number;
authenticated_at?: string;
token_type?: string;
}This is exactly the shape returned by Sanas's OAuth login endpoint — your backend can forward the response body verbatim.
AudioProcessorParams
A discriminated union:
type AudioProcessorParams =
| AudioProcessorModelParams
| AudioProcessorLanguageParams;
interface AudioProcessorModelParams {
input: MediaStream;
modelName: string;
sampleRate?: number;
jitterBufferTargetMs?: number;
}
interface AudioProcessorLanguageParams {
input: MediaStream;
languageIn: string;
languageOut: string;
conversationId?: string;
sampleRate?: number;
jitterBufferTargetMs?: number;
}ProcessorCallbacks
interface ProcessorCallbacks {
onStateChange?: (state: ProcessorState, reason: string) => void;
onTranscript?: (json: string) => void;
onLanguageRoute?: (json: string) => void;
}The two JSON callbacks fire only for LT processors. The payload is a raw JSON string — parse with JSON.parse on your side.
IAudioProcessor
interface IAudioProcessor {
readonly outputStream: MediaStream;
getState(): ProcessorState;
updateInputStream(input: MediaStream): Promise<void>;
updateLanguageConfig(config: {
languageIn: string;
languageOut: string;
conversationId?: string;
}): Promise<void>;
}updateInputStream swaps the mic mid-call (device change, headset unplug) without dropping the session.
updateLanguageConfig throws if called on a model-mode processor.
ProcessorState
enum ProcessorState {
Initializing = "initializing",
Ready = "ready",
Failed = "failed",
Disconnected = "disconnected",
Unknown = "unknown",
}Error handling
All SDK errors are instances of SdkError with a machine-readable code and a human-readable message.
import { SdkError, InitErrorCode } from "@sanas-ai/browser-sdk";
try {
await sdk.initialize({ tokenProvider });
} catch (err) {
if (err instanceof SdkError && err.code === InitErrorCode.OriginNotAllowed) {
showBanner("This origin isn't allowed. Contact your admin.");
} else {
throw err;
}
}InitErrorCode
| Code | Meaning | Recommended action |
|---|---|---|
| AlreadyInitialized | initialize was called on an already-initialized SDK. | Call shutdown() first, or reuse the existing session. |
| InitializationInProgress | A concurrent initialize is still running. | Await the first call before starting another. |
| InvalidParameters | tokenProvider was missing or not a function. | Check the call site. |
| AuthenticationFailed | Your backend returned an error, or the token payload was malformed. | Log the message; verify your backend is returning {access_token, user_id, group_id, ...}. |
| OriginNotAllowed | This page's origin isn't in the group's allowed list. | Add the origin in Project settings → Allowed origins. |
| NetworkError | Network / CORS failure reaching either your backend or the Sanas console. | Message includes which stage failed; check CORS on your backend or inspect DevTools network tab for the console request. |
| UnknownError | Anything else. | Read the message; open a support ticket if it recurs. |
CreateProcessorErrorCode
| Code | Meaning | Recommended action |
|---|---|---|
| SdkNotInitialized | You didn't call initialize, or the session was lost. | Initialize (or reinitialize) before creating processors. |
| InvalidParameters | Required field missing or unsafe characters in a param. | Check modelName / languageIn / languageOut values. |
| UnsupportedSampleRate | The requested sample rate isn't supported. | Use 8000, 16000, or 48000. |
| ModelNotEntitled | The model name isn't in this group's entitlements. | Verify the exact name in the console. Case-sensitive. |
| FeatureNotEntitled | The feature (RI or LT) isn't enabled at group level. | Contact your Sanas administrator. |
| OriginNotAllowed | Same as init — origin got revoked between init and this call. | Update Project settings → Allowed origins. |
| NetworkError | Console API unreachable for the entitlement check. | Retry; check DevTools. |
| UnknownError | Anything else. | Read the message. |
Runtime state changes
Once a processor is created, transient failures surface through onStateChange(Failed, reason), not by throwing. Wire the callback at construction so you never miss an early failure:
sdk.createAudioProcessor(params, {
onStateChange: (state, reason) => {
if (state === ProcessorState.Failed) {
// reason is a short user-facing string
// e.g. "Server rejected the call (auth failure)"
// e.g. "Media connection lost"
// e.g. "Authentication lost: origin not allowed for this group"
// e.g. "Entitlement revoked: language_translation is no longer available for this group"
showErrorBanner(reason);
}
},
});Advanced options
Sample rate
Default is 16000 Hz. Supported: 8000 / 16000 / 48000. Set per processor:
sdk.createAudioProcessor({ input, modelName: "...", sampleRate: 48000 }, ...);Rates outside the supported set throw UnsupportedSampleRate.
Jitter buffer
For bumpy networks, increase the receive-side buffer to smooth playback at the cost of extra latency. Default is chosen by the SDK; override globally in InitParams or per processor:
await sdk.initialize({ tokenProvider, jitterBufferTargetMs: 200 });
// or
sdk.createAudioProcessor(
{ input, modelName: "...", jitterBufferTargetMs: 100 },
...
);Swapping input mid-call
Users unplug headsets or switch devices. Update in place instead of tearing down:
const newInput = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: newDeviceId },
});
await processor.updateInputStream(newInput);The active call stays up; the outgoing track is swapped.
Multiple concurrent processors
You can run more than one processor per SDK instance — e.g. a mic in RI mode and a system-audio stream in LT mode. Each is fully independent.
Logging
Set logLevel: "debug" in InitParams to see the SDK's internal logs in DevTools. Default is warn. Use silent in production if you want no console output.
Troubleshooting
"Origin not allowed" during initialize
Your page's origin (protocol + host + port) isn't in the group's allowed list. Add it under Project settings → Allowed origins. Note that http://localhost:5173 and http://localhost:3000 are different origins — add every dev port you use.
Processor reaches Failed immediately with "Server rejected the call (auth failure)"
The access token expired between tokenProvider returning it and the media session opening, or your backend returned a stale token.
Processor reaches Failed mid-call with "Media connection lost"
Network dropped. The SDK does not auto-reconnect a lost media session. Destroy the processor and create a new one.
Metrics appear delayed
The SDK batches usage reports on a 1-minute cadence. If you tear down a processor, the final snapshot is captured before the underlying session closes.
