@gekoai/sdk
v0.7.0
Published
Official zero-dependency TypeScript SDK + CLI for the Geko speech API (text-to-speech, with speech-to-text and more coming).
Maintainers
Readme
@gekoai/sdk
Official, zero-dependency TypeScript SDK for the Geko speech API.
The client is namespaced by service: text-to-speech via geko.tts (create(), stream(), voices()) and speech-to-text via geko.stt (transcribe(), stream(), models()), alongside platform-level models() and health(). One API key covers both services, and both draw down the same credit balance. It mirrors the ergonomics of the OpenAI and Anthropic SDKs and is built on the global fetch — no runtime dependencies (Node 20+, Deno, Bun, and modern browsers). Ships dual ESM + CommonJS with bundled types.
Install
npm install @gekoai/sdkQuickstart
import { Geko } from "@gekoai/sdk";
import { writeFile } from "node:fs/promises";
const geko = new Geko({ apiKey: process.env.GEKO_API_KEY });
const audio = await geko.tts.create({
text: "Сәлеметсіз бе!",
voice: "Aigerim",
nfe: 32,
});
await writeFile("hello.wav", Buffer.from(audio));apiKey is read from the GEKO_API_KEY environment variable when you don't pass it explicitly. Keys look like sk-tokay-… — create one in the Geko console.
CLI
The package ships a CLI — no code needed to try it:
export GEKO_API_KEY=sk-tokay-...
# synthesize to a file
npx @gekoai/sdk say "Сәлеметсіз бе!" --voice Aigerim -o hello.wav
# transcribe audio — transcript on stdout, cost on stderr, so redirection stays clean
npx @gekoai/sdk transcribe call.wav
npx @gekoai/sdk transcribe call.wav > transcript.txt
npx @gekoai/sdk transcribe --url https://example.com/call.mp3 --json
# word timestamps: start, end, word — tab-separated, one per line
npx @gekoai/sdk transcribe call.wav --words
# subtitles
npx @gekoai/sdk transcribe call.wav --srt > call.srt
npx @gekoai/sdk transcribe call.wav --vtt > call.vtt
# browse the catalog (no key required)
npx @gekoai/sdk voices
npx @gekoai/sdk models --jsonsay options: --voice, --model, --nfe, --speed, --no-normalize, --out/-o.
transcribe options: --url, --words, --srt, --vtt, --json, --stt-base-url. Run npx @gekoai/sdk --help for the full list.
Speech-to-text
seta-kk-ru-v2 transcribes Kazakh and Russian, including mid-sentence code-switching. There is deliberately no language parameter: both alphabets share one 70-character output vocabulary, so the model never has to be told which language to expect. It scores 8.71 % WER on the KSC2 official test set, against Whisper large-v3's 44.95 % on the same audio.
import { readFile } from "node:fs/promises";
const { text, audio_seconds, credits_charged, x_realtime } = await geko.stt.transcribe({
audio: await readFile("call.wav"),
filename: "call.wav",
});
// text: "сәлеметсіз бе бүгін ауа райы өте жақсы"Or let the server fetch it, so you never upload the bytes:
const { text } = await geko.stt.transcribe({ url: "https://example.com/call.mp3" });Transcripts are lowercase and unpunctuated — the vocabulary contains neither, so nothing is being stripped. Any container ffmpeg reads works (wav, mp3, m4a, flac, ogg) at any sample rate; the server resamples to 16 kHz. Audio over 2 hours is rejected with 413.
Live streaming
When audio is arriving as it is spoken — a voice agent that has to answer, live captions — stt.stream() opens a WebSocket and gives you results before the speaker finishes. It is both a sink for audio and an async iterable of events, so push from one place and read in another:
const stream = await geko.stt.stream();
// Read results in one place…
(async () => {
for await (const event of stream) {
if (event.type === "partial") process.stdout.write(`\r${event.text}`);
if (event.type === "final") console.log(`\n${event.text}`);
if (event.type === "done") console.log(`billed ${event.credits_charged} credits`);
}
})();
// …push audio from another.
for await (const chunk of microphone) stream.send(chunk);
stream.stop();Audio must be PCM16-LE, mono, 16 kHz. Nothing is resampled server-side, because inferring the rate of a raw byte stream is how audio gets silently transcribed at the wrong pitch.
partial events are a running guess and will be replaced; final events are committed and never revised. Render partials, append finals — the concatenation of finals is exactly the text you get back in done. A new partial arrives every 0.5s of speech and a final lands roughly 0.05s after the pause that triggers it. A client that sends large audio frames cannot receive partials faster than it sends, so 250 ms frames or smaller are recommended.
If you already have a file, use transcribe() instead: it is more accurate on long audio and a third of the price. Streaming costs 6 credits/audio-second ($0.864/hour) against batch's 2.5 ($0.36/hour) — not a surcharge for the feature, but the genuine cost of keeping a partial transcript current, which re-decodes the uncommitted tail on every tick. estimateStreamCredits(seconds) prices it before you spend it.
Node 20 has no global
WebSocket— it stabilised in Node 22. Either upgrade, or pass one in:geko.stt.stream({ webSocket: (await import("ws")).WebSocket }). The SDK itself stays dependency-free.
The service scales to zero, so the first connection after an idle period waits ~15–25s while a container boots and loads the model.
openTimeoutdefaults to 60s for that reason; connections after it open in well under a second.
Word-level timestamps
Pass timestamps: true to get every word located in time, plus the transcript grouped into runs of speech. Alignment falls out of the same decode pass, so it costs nothing extra — you are billed by audio duration either way.
const out = await geko.stt.transcribe({
audio: await readFile("call.wav"),
filename: "call.wav",
timestamps: true,
});
for (const w of out.words ?? []) {
console.log(`[${w.start.toFixed(2)}–${w.end.toFixed(2)}] ${w.word}`);
}
// [0.20–0.72] сәлеметсіз
// [0.84–1.12] беwords is { word, start, end }[] and segments is { id, text, start, end }[], both in seconds from the start of the audio. Both are absent unless you ask for them, so adding the flag never changes an existing caller's response shape.
For subtitles, use segments — one cue per word is unwatchable. toSubtitles() is pure formatting and needs no client:
import { toSubtitles } from "@gekoai/sdk";
import { writeFile } from "node:fs/promises";
await writeFile("call.srt", toSubtitles(out)); // SubRip
await writeFile("call.vtt", toSubtitles(out, "vtt")); // WebVTT1
00:00:00,200 --> 00:00:01,120
сәлеметсіз беBilling is per second of audio: 2.5 credits/audio-second = $0.36/audio-hour. Estimate before you spend, with no request:
geko.stt.estimateCredits(3600); // 9000 credits === $0.36Drop-in for OpenAI Whisper
The API also exposes POST /v1/audio/transcriptions, so an existing OpenAI client only changes its baseURL and key:
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.GEKO_API_KEY,
baseURL: "https://geko--seta-serve-transcriber-api.modal.run/v1",
});
const { text } = await openai.audio.transcriptions.create({
file: createReadStream("call.wav"),
model: "seta-kk-ru-v2",
});language, prompt and temperature are accepted and ignored. srt/vtt are refused with a 400 rather than silently downgraded, because a CTC model produces no word timings.
Voices, models & health
The catalog endpoints are open — call them to discover what's available (never hard-code voice lists, they change).
const { voices } = await geko.tts.voices("tokay-kk-v1");
const { data: models } = await geko.models();
const { status, models: loaded } = await geko.health();Voices today: Aigerim, Arman, Ainur, Aruzhan, Sanzhar, Yerlan (Aigerim is the default).
Streaming text-to-speech
(For streaming speech-to-text, see Live streaming above.)
For long text, stream sentence-by-sentence to start playback sooner. tts.stream() is an async iterable of WAV ArrayBuffers (one per chunk), each independently playable:
for await (const wav of geko.tts.stream({ text: longText, voice: "Aigerim" })) {
// enqueue `wav` for playback as it arrives (each is a standalone WAV)
console.log(`chunk: ${wav.byteLength} bytes`);
}Requires a server that supports POST /v1/tts/stream. Streams aren't retried (a partial stream can't be replayed); use signal to cancel and a per-call timeout to bound the whole stream.
In the browser
Call the API from your server, not the browser. Your API key is a secret — anything shipped to the client is exposed, and the API does not currently send CORS headers, so direct cross-origin browser calls are blocked. Put a thin proxy on your backend (see the docs for a Next.js route example) and have the browser call that.
Once you have audio bytes (from your proxy or a Node process), playing them is straightforward — tts.create() returns an ArrayBuffer:
const url = URL.createObjectURL(new Blob([audioArrayBuffer], { type: "audio/wav" }));
new Audio(url).play();Timeouts, cancellation & retries
The GPU backend scales to zero, so a cold start can take tens of seconds — the default timeout is a generous 120s. Transient failures (network errors, timeouts, and 5xx/408/409) are retried automatically with exponential backoff (default 2 retries). A 429 (out of credits) is never retried.
const geko = new Geko({
apiKey: process.env.GEKO_API_KEY,
timeout: 60_000, // per-request, ms. 0 disables.
maxRetries: 2, // set 0 to disable retries entirely.
});
// Per-call overrides + cooperative cancellation:
const controller = new AbortController();
const audio = await geko.tts.create({
text: "…",
signal: controller.signal,
timeout: 30_000,
});Note: a
tts.create()request that times out is retried by default. In the rare case a retry re-triggers a synth that had already succeeded server-side, you could be billed twice — setmaxRetries: 0if you need strict once-only semantics.
Error handling
GekoError— the API returned a non-2xx status. Carriesstatusand the server'sdetail.GekoConnectionError(extendsGekoError,status === 0) — the request never reached the server (network failure or timeout; checktimedOut).
import { Geko, GekoError, GekoConnectionError } from "@gekoai/sdk";
try {
await geko.tts.create({ text: "…" });
} catch (err) {
if (err instanceof GekoConnectionError) {
console.error(err.timedOut ? "timed out" : "network error", err.cause);
} else if (err instanceof GekoError) {
console.error(`Geko ${err.status}: ${err.detail}`);
} else {
throw err;
}
}API
new Geko(options?)
| Option | Type | Default |
| ------------ | ------------------------ | ----------------------------------------- |
| apiKey | string | process.env.GEKO_API_KEY |
| baseUrl | string | https://geko--tokay-serve-web.modal.run |
| sttBaseUrl | string | https://geko--seta-serve-transcriber-api.modal.run |
| fetch | typeof fetch | global fetch |
| timeout | number (ms) | 120000 (0 disables) |
| maxRetries | number | 2 |
| headers | Record<string, string> | {} (extra headers on every request) |
geko.tts.create(params): Promise<ArrayBuffer>
Returns raw WAV bytes (24 kHz, PCM16).
| Param | Type | Default | Notes |
| ----------- | ------------- | -------------- | ----------------------------------------- |
| text | string | — (required) | Text to synthesize (1–5000 chars) |
| model | string | tokay-kk-v1 | Model id |
| voice | string | model default | Voice name from tts.voices() |
| speed | number | 1.0 | Playback speed (0.5–2) |
| nfe | number | 32 | Diffusion steps: 16 = fast, 32 = quality |
| normalize | boolean | true | Expand numbers/currency/dates to speech |
| signal | AbortSignal | — | Cancel the request |
| timeout | number (ms) | client default | Per-call timeout override |
geko.tts.voices(model?, options?): Promise<{ model, voices: Voice[] }>
geko.stt.transcribe(params): Promise<Transcription>
Pass exactly one of audio or url.
| Param | Type | Default | Notes |
| ---------- | -------------------------------------- | -------------- | -------------------------------------------- |
| audio | ArrayBuffer \| ArrayBufferView \| Blob | — | Audio bytes to upload |
| url | string | — | Public URL for the server to fetch instead |
| filename | string | audio.wav | Extension only — tells the server the container |
| timestamps | boolean | false | Also return words and segments |
| signal | AbortSignal | — | Cancel the request |
| timeout | number (ms) | client default | Per-call timeout override |
Returns { text, model, audio_seconds, processing_seconds, rtf, x_realtime, chunks, sample_rate_in, credits_charged }, plus words and segments when timestamps: true.
toSubtitles(transcription, format?): string
Formats a timestamps: true transcription as "srt" (default) or "vtt". Pure function — no client, no request. Also available as geko.stt.toSubtitles(). Throws TypeError if the transcription has no segments.
geko.stt.stream(params?): Promise<SttStream>
Opens a live transcription socket. Resolves once the server has accepted the key and loaded the model, so a resolved SttStream is ready for audio.
| Param | Type | Default | Notes |
| ------------- | --------------------- | ------------------ | -------------------------------------------------- |
| openTimeout | number (ms) | 60000 | Raise it if you hit cold starts on an idle endpoint |
| webSocket | WebSocketLike | platform global | Required on Node 20, which has none |
| url | string | production endpoint | Override the endpoint |
| signal | AbortSignal | — | Closes the socket when aborted |
The returned SttStream is an AsyncIterable<SttStreamEvent> (ready → partial/final → done) with:
send(pcm)— push PCM16-LE mono 16 kHz audio.stop()— end the audio; the server flushes, emits any lastfinal, thendone.close()— abandon immediately. Audio already accepted is still billed.result()— convenience:stop()then drain, returning thedoneevent. Don't combine withfor awaiton the same stream; there is one queue and two consumers would each get half the events.
Throws GekoError(401) if the key is refused at the handshake, and GekoConnectionError if no WebSocket implementation exists or the endpoint doesn't accept the connection in time.
geko.stt.models(options?): Promise<{ data: SttModel[] }>
geko.stt.estimateCredits(audioSeconds): number
Pure arithmetic — ceil(audioSeconds × 2.5), matching the server's rounding. No request.
geko.stt.estimateStreamCredits(audioSeconds): number
The same for streamed audio — ceil(audioSeconds × 6). Verified against the server's actual charge in the SDK's end-to-end test.
geko.models(options?): Promise<{ data: Model[] }>
geko.health(options?): Promise<{ status, models: string[] }>
options accepts { signal?, timeout? }.
Response types describe the server contract; like most thin SDKs, JSON bodies are not re-validated at runtime.
License
MIT © Geko AI
