Translate one segment of speech
The app cuts speech into short segments at each pause and translates each one with one call. You can do the same: send the segment (as recognised, unpunctuated is fine), the two languages and the last few translated segments as context. You get back one line of JSON with the translation, the language it was actually spoken in, an optional pronunciation guide and an optional back-translation. Listening and the spoken voice happen in the browser app; the API is the text step in the middle.
Basics
Base URL https://api.skillsafe.ai/v1/app-api. Every call except
POST /guest sends Authorization: Bearer <token>; bodies are JSON.
The token is bound to this app, so there is no app header: the slug appears only in the
POST /guest body.
Responses use one envelope: {"ok":true,"data":{...}} on success and
{"ok":false,"error":{"code","message","details"}} on failure.
/estimate, /run and /run-stream take the input object
itself as the body - never wrap it as {"input":{...}}.
Credits: 1 credit = $0.0001. /estimate is free and creates no job; a run holds up
to hold_credits and charges charged_credits when it finishes. One
segment is one run.
| Status | error.code | Meaning |
|---|---|---|
400 | validation_error | A field of the wrong type, or an object where a scalar belongs; details names it. |
401 | unauthorized | Invalid or expired token. Get a new one (step 1). |
402 | payment_required | The balance is below min_credits. Top up at skillsafe.ai. |
403 | forbidden | A guest token cannot run metered turns unless the app sponsors guests. Sign in for a personal token. |
404 | not_found | Unknown app slug, or a job id that is not yours. |
409 | conflict | The Idempotency-Key was used before with a different body. |
429 | rate_limited | Back off and retry with the same Idempotency-Key. |
5xx | - | Transient. Retry with backoff and the same key. |
Browsers enforce CORS for this API: run the samples from a server, script or terminal.
The task field first
There is one task, translate. Always send "task": "translate".
| task | What it does | Reply |
|---|---|---|
translate | Translate one segment of speech from source_lang to target_lang, using the earlier segments in context for names, pronouns and register. If the segment was actually spoken in the target language it is translated the other way. | One line of JSON: translation, to, detected, romanization, back, note. |
Input fields
Every field is a scalar (strings, and 0/1 numbers for the two switches). Send one segment per call, at most 600 characters; the app splits longer text at sentence ends and sends the pieces in order.
| Field | Type | Meaning |
|---|---|---|
task | string | translate. |
text | string | The segment as heard or typed. Fillers, missing punctuation and recognition slips are expected. |
source_lang / source_name | string | The speaker's language: a code (en, es, zh, zh-Hant, ja ...) and its English name. |
target_lang / target_name | string | The listener's language, code and English name. |
mode | string | oneway (one speaker) or chat (two people taking turns). |
speaker | string | A or B in chat mode, empty otherwise. In chat mode person B speaks target_lang, so swap source and target for B's turns. |
context | string | Up to four earlier translated segments, oldest first, one per line: A (en): original => translation. Each side is clipped to 280 characters at a word boundary. Empty for the first segment. |
glossary | string | Optional lines source = target; a line with both sides equal is a name to keep. Up to 40 lines, 1,200 characters. |
tone | string | auto | formal | casual: how the listener is addressed (usted/tu, vous/tu, Sie/du, polite or plain Japanese). |
want_romanization | number | 1 asks for a pronunciation guide in Latin letters when the translation is in a non-Latin script (pinyin, Hepburn, Revised Romanization ...). |
want_back | number | 1 asks for a literal back-translation into the speaker's language. |
numbers | string | The numbers found in text, separated by | . The reply must keep each one in digits. The app finds them with a digit pattern; send an empty string if you do not. |
retry_note | string | Optional: a format correction after a reply that could not be read; send it with a new attempt number in the key. |
Reply contract
One line of JSON, nothing else:
{"translation":"...","to":"es","detected":"en","romanization":"","back":"","note":""}.
Take the text from the first { to the last } and parse it. If that
fails, the reply was cut short: read the translation string up to where it stops
and mark it partial.
| Field | Meaning |
|---|---|
translation | The translation, in the script of to. Never empty. |
to | The language translated into: target_lang, or source_lang when the segment was spoken in the target language. |
detected | The language the segment was actually spoken in. |
romanization | The pronunciation guide when asked for and the script is not Latin; otherwise empty. |
back | The back-translation when asked for; otherwise empty. |
note | Up to 25 words in English: a corrected mishearing, a real ambiguity, a formality choice. Usually empty. |
Check the reply as the app does: every number from numbers appears in
translation with the same digits, every glossary target appears, and
detected matches the language you expected. The app shows a warning on the
segment when one of these fails.
Step 1 - A tiny client, and a token
A short helper adds the auth header, sends JSON and unwraps the envelope; every later step
reuses it. For the token, open the token page, sign in with
SkillSafe and copy the token. A personal token bills runs to your account. A script with no
browser can mint a guest token with POST /guest and the body
{"slug":"voice-translator"}, sent with no auth header; guests can
estimate but not run.
export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN" # "Copy shell export" on /tokens.html
# No browser? A guest token (this call has no Authorization header):
SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" -H "Content-Type: application/json" \
-d '{"slug":"voice-translator"}' | jq -r '.data.token')
import json, time, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from tokens.html
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}", **(headers or {})}
res = requests.request(method, API + path, json=body, headers=h, timeout=180)
payload = res.json()
if not res.ok or payload.get("ok") is False:
err = payload.get("error", {})
raise RuntimeError(f"{res.status_code} {err.get('code')}: {err.get('message')}")
return payload["data"]
// Node 18+ (global fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from tokens.html
async function api(method, path, body, headers = {}) {
const res = await fetch(API + path, {
method,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}`, ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
throw new Error(`${res.status} ${payload.error?.code}: ${payload.error?.message}`);
}
return payload.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http"; "time")
const API = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from tokens.html
func call(method, path string, body any, headers map[string]string, out any) error {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
for k, v := range headers { req.Header.Set(k, v) }
res, err := (&http.Client{Timeout: 180 * time.Second}).Do(req)
if err != nil { return err }
defer res.Body.Close()
var env struct {
Ok bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct{ Code, Message string } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if !env.Ok { return fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message) }
return json.Unmarshal(env.Data, out)
}
// Java 11+, Jackson for JSON
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import java.net.URI;
import java.net.http.*;
import java.util.Map;
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from tokens.html
static final ObjectMapper JSON = new ObjectMapper();
static final HttpClient HTTP = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json").header("Authorization", "Bearer " + TOKEN)
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
headers.forEach(b::header);
var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
JsonNode env = JSON.readTree(res.body());
if (!env.path("ok").asBoolean()) throw new RuntimeException(res.statusCode() + " " + env.path("error"));
return env.get("data");
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
headers.each { |k, v| req[k] = v }
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 180) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{res.code} #{payload.dig('error', 'code')}: #{payload.dig('error', 'message')}" unless payload["ok"]
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from tokens.html
function api(string $method, string $path, ?array $body = null, array $headers = []): array {
$h = ["Content-Type: application/json", "Authorization: Bearer " . TOKEN];
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(API . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h,
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 180]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$payload = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (empty($payload["ok"])) throw new RuntimeException("$code " . json_encode($payload["error"] ?? null));
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class Translator
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from tokens.html
static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(180) };
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null,
Dictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(method, Api + path);
req.Headers.Add("Authorization", "Bearer " + Token);
foreach (var (k, v) in headers ?? new()) req.Headers.Add(k, v);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!env.GetProperty("ok").GetBoolean()) throw new Exception($"{(int)res.StatusCode} {env.GetProperty("error")}");
return env.GetProperty("data").Clone();
}
}
Step 2 - Who am I, and what is my balance?
subject_type is user for a personal token and guest otherwise; credits is the balance.
curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
if err := call("GET", "/me", nil, nil, &me); err != nil { panic(err) }
fmt.Println(me.SubjectType, me.Credits)
JsonNode me = api("GET", "/me", null, Map.of());
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts me.values_at("subject_type", "credits").inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? 0, PHP_EOL;
var me = await Translator.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
Step 3 - Price a segment for free
/estimate takes the same body as a run and creates no job. It returns
hold_credits (reserved while the run is in flight) and min_credits.
A segment is charged charged_credits, usually far less than the hold.
jq -n '{task: "translate", text: "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
source_lang: "en", source_name: "English", target_lang: "es", target_name: "Spanish",
mode: "oneway", speaker: "", context: "", glossary: "", tone: "auto",
want_romanization: 0, want_back: 1, numbers: "7:30 | 4"}' > segment.json
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-d @segment.json | jq '.data | {hold_credits, min_credits, model_alias, warnings}'
segment = {"task": "translate", "text": "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
"source_lang": "en", "source_name": "English", "target_lang": "es", "target_name": "Spanish",
"mode": "oneway", "speaker": "", "context": "", "glossary": "", "tone": "auto",
"want_romanization": 0, "want_back": 1, "numbers": "7:30 | 4"}
est = api("POST", "/estimate", segment)
print(est["hold_credits"], est["min_credits"])
const segment = { task: "translate", text: "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
source_lang: "en", source_name: "English", target_lang: "es", target_name: "Spanish",
mode: "oneway", speaker: "", context: "", glossary: "", tone: "auto",
want_romanization: 0, want_back: 1, numbers: "7:30 | 4" };
const est = await api("POST", "/estimate", segment);
console.log(est.hold_credits, est.min_credits);
segment := map[string]any{"task": "translate", "text": "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
"source_lang": "en", "source_name": "English", "target_lang": "es", "target_name": "Spanish",
"mode": "oneway", "speaker": "", "context": "", "glossary": "", "tone": "auto",
"want_romanization": 0, "want_back": 1, "numbers": "7:30 | 4"}
var est struct {
Hold int `json:"hold_credits"`
Min int `json:"min_credits"`
}
if err := call("POST", "/estimate", segment, nil, &est); err != nil { panic(err) }
fmt.Println(est.Hold, est.Min)
ObjectNode segment = JSON.createObjectNode()
.put("task", "translate").put("text", "um so the train leaves at 7:30 from platform 4 where do I buy a ticket")
.put("source_lang", "en").put("source_name", "English").put("target_lang", "es").put("target_name", "Spanish")
.put("mode", "oneway").put("speaker", "").put("context", "").put("glossary", "").put("tone", "auto")
.put("want_romanization", 0).put("want_back", 1).put("numbers", "7:30 | 4");
JsonNode est = api("POST", "/estimate", segment, Map.of());
System.out.println(est.get("hold_credits") + " " + est.get("min_credits"));
segment = { task: "translate", text: "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
source_lang: "en", source_name: "English", target_lang: "es", target_name: "Spanish",
mode: "oneway", speaker: "", context: "", glossary: "", tone: "auto",
want_romanization: 0, want_back: 1, numbers: "7:30 | 4" }
est = api("POST", "/estimate", segment)
puts est.values_at("hold_credits", "min_credits").inspect
$segment = ["task" => "translate", "text" => "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
"source_lang" => "en", "source_name" => "English", "target_lang" => "es", "target_name" => "Spanish",
"mode" => "oneway", "speaker" => "", "context" => "", "glossary" => "", "tone" => "auto",
"want_romanization" => 0, "want_back" => 1, "numbers" => "7:30 | 4"];
$est = api("POST", "/estimate", $segment);
echo $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var segment = new Dictionary<string, object> {
["task"] = "translate", ["text"] = "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
["source_lang"] = "en", ["source_name"] = "English", ["target_lang"] = "es", ["target_name"] = "Spanish",
["mode"] = "oneway", ["speaker"] = "", ["context"] = "", ["glossary"] = "", ["tone"] = "auto",
["want_romanization"] = 0, ["want_back"] = 1, ["numbers"] = "7:30 | 4" };
var est = await Translator.ApiAsync(HttpMethod.Post, "/estimate", segment);
Console.WriteLine($"{est.GetProperty("hold_credits")} {est.GetProperty("min_credits")}");
Step 4 - Translate the segment
Send the body with an Idempotency-Key: a retry with the same key and body never
bills twice. Poll the job until it is succeeded or failed, then read
the reply from output.output. If truncated is true the balance
cut the reply short - keep what arrived and mark it partial.
BODY=$(jq -c . segment.json)
KEY="voice-translator:translate:$(printf %s "$BODY" | shasum | cut -c1-16):1"
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = succeeded ] || [ "$STATUS" = failed ] && break
sleep 1
done
echo "$JOB" | jq '.data | {status, charged_credits, truncated}'
echo "$JOB" | jq -r '.data.output.output' | jq '{translation, to, detected, back, note}'
import hashlib
def reply(text):
a, b = text.find("{"), text.rfind("}")
return json.loads(text[a:b + 1])
body = json.dumps(segment)
key = "voice-translator:translate:" + hashlib.sha1(body.encode()).hexdigest()[:16] + ":1"
job = api("POST", "/run", segment, {"Idempotency-Key": key})
while job.get("status") not in ("succeeded", "failed"):
time.sleep(1)
job = api("GET", f"/jobs/{job['job_id']}")
out = reply((job.get("output") or {}).get("output", ""))
print(out["translation"], "|", out["back"], "|", job.get("charged_credits"), "credits")
# every number the speaker said must survive, in digits
for n in segment["numbers"].split(" | "):
digits = "".join(c for c in n if c.isdigit())
if digits and digits not in "".join(c for c in out["translation"] if c.isdigit()):
print("check this number:", n)
import { createHash } from "node:crypto";
const reply = (t) => JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
const body = JSON.stringify(segment);
const key = `voice-translator:translate:${createHash("sha1").update(body).digest("hex").slice(0, 16)}:1`;
let job = await api("POST", "/run", segment, { "Idempotency-Key": key });
while (!["succeeded", "failed"].includes(job.status)) {
await new Promise((r) => setTimeout(r, 1000));
job = await api("GET", `/jobs/${job.job_id}`);
}
const out = reply(job.output?.output || "");
console.log(out.translation, "|", out.back, "|", job.charged_credits, "credits");
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Charged int `json:"charged_credits"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
var job Job
key := "voice-translator:translate:example-1" // any stable hash of the exact body
if err := call("POST", "/run", segment, map[string]string{"Idempotency-Key": key}, &job); err != nil { panic(err) }
for job.Status != "succeeded" && job.Status != "failed" {
time.Sleep(time.Second)
if err := call("GET", "/jobs/"+job.JobID, nil, nil, &job); err != nil { panic(err) }
}
t := job.Output.Output // import "strings"
var out struct{ Translation, To, Detected, Back, Note string }
json.Unmarshal([]byte(t[strings.Index(t, "{"):strings.LastIndex(t, "}")+1]), &out)
fmt.Println(out.Translation, "|", out.Back, "|", job.Charged, "credits")
String key = "voice-translator:translate:example-1"; // any stable hash of the exact body
JsonNode job = api("POST", "/run", segment, Map.of("Idempotency-Key", key));
while (!job.path("status").asText().matches("succeeded|failed")) {
Thread.sleep(1000);
job = api("GET", "/jobs/" + job.get("job_id").asText(), null, Map.of());
}
String t = job.path("output").path("output").asText();
JsonNode out = JSON.readTree(t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1));
System.out.println(out.get("translation").asText() + " | " + job.get("charged_credits") + " credits");
require "digest"
key = "voice-translator:translate:#{Digest::SHA1.hexdigest(segment.to_json)[0, 16]}:1"
job = api("POST", "/run", segment, { "Idempotency-Key" => key })
until %w[succeeded failed].include?(job["status"])
sleep 1
job = api("GET", "/jobs/#{job['job_id']}")
end
t = job.dig("output", "output").to_s
out = JSON.parse(t[t.index("{")..t.rindex("}")])
puts "#{out['translation']} | #{out['back']} | #{job['charged_credits']} credits"
$key = "voice-translator:translate:" . substr(sha1(json_encode($segment)), 0, 16) . ":1";
$job = api("POST", "/run", $segment, ["Idempotency-Key" => $key]);
while (!in_array($job["status"] ?? "", ["succeeded", "failed"])) {
sleep(1);
$job = api("GET", "/jobs/" . $job["job_id"]);
}
$t = $job["output"]["output"] ?? "";
$out = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
echo $out["translation"], " | ", $out["back"], " | ", $job["charged_credits"], " credits", PHP_EOL;
var key = "voice-translator:translate:example-1"; // any stable hash of the exact body
var job = await Translator.ApiAsync(HttpMethod.Post, "/run", segment, new() { ["Idempotency-Key"] = key });
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"))
{
await Task.Delay(1000);
job = await Translator.ApiAsync(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString());
}
var t = job.GetProperty("output").GetProperty("output").GetString()!;
var o = JsonDocument.Parse(t[t.IndexOf('{')..(t.LastIndexOf('}') + 1)]).RootElement;
Console.WriteLine($"{o.GetProperty("translation")} | {job.GetProperty("charged_credits")} credits");
Step 5 - Stream the next segment, with context
The next segment carries the previous one in context, so "it" and "there" land
on the right words. Over Server-Sent Events you get a job event,
tick heartbeats, then one done event carrying the whole job. Read
the reply from done. An idempotent replay answers with plain JSON instead of a
stream, so check the content type.
BODY=$(jq -c '. + {text: "and is it far from here can I walk", numbers: "",
context: "A (en): um so the train leaves at 7:30 from platform 4 where do I buy a ticket => Bueno, el tren sale a las 7:30 del andén 4. ¿Dónde compro un billete?"}' segment.json)
KEY="voice-translator:translate:$(printf %s "$BODY" | shasum | cut -c1-16):1"
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d "$BODY" > stream.txt
sed -n '/^event: done/{n;s/^data: *//p;}' stream.txt | jq -r '(.job // .) | .output.output' | jq .translation
def run_stream(body, key):
h = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key}
res = requests.post(API + "/run-stream", json=body, headers=h, stream=True, timeout=300)
if "text/event-stream" not in res.headers.get("content-type", ""):
return res.json()["data"]
event = None
for line in res.iter_lines(decode_unicode=True):
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:") and event in ("done", "error"):
data = json.loads(line[5:])
if event == "error":
raise RuntimeError(data)
return data.get("job", data)
raise RuntimeError("stream ended without a done event")
nxt = dict(segment, text="and is it far from here can I walk", numbers="",
context="A (en): " + segment["text"] + " => " + out["translation"])
done = run_stream(nxt, "voice-translator:translate:seg2:1")
print(reply(done["output"]["output"])["translation"])
async function runStream(body, key) {
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}`, "Idempotency-Key": key },
body: JSON.stringify(body),
});
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) return (await res.json()).data;
let event = "";
for (const line of (await res.text()).split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:") && (event === "done" || event === "error")) {
const data = JSON.parse(line.slice(5));
if (event === "error") throw new Error(JSON.stringify(data));
return data.job || data;
}
}
throw new Error("stream ended without a done event");
}
const next = { ...segment, text: "and is it far from here can I walk", numbers: "",
context: `A (en): ${segment.text} => ${out.translation}` };
const done = await runStream(next, "voice-translator:translate:seg2:1");
console.log(reply(done.output.output).translation);
// Same request as step 4 against /run-stream; read lines with bufio.Scanner and keep the
// data: line that follows "event: done".
segment["text"] = "and is it far from here can I walk"
segment["numbers"] = ""
segment["context"] = "A (en): ... => " + out.Translation
bodyBytes, _ := json.Marshal(segment)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "voice-translator:translate:seg2:1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<22)
event := ""
for sc.Scan() {
l := sc.Text()
if strings.HasPrefix(l, "event:") { event = strings.TrimSpace(l[6:]) }
if strings.HasPrefix(l, "data:") && event == "done" { fmt.Println(l[5:]); break }
}
segment.put("text", "and is it far from here can I walk").put("numbers", "")
.put("context", "A (en): ... => " + out.get("translation").asText());
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Content-Type", "application/json").header("Authorization", "Bearer " + TOKEN)
.header("Idempotency-Key", "voice-translator:translate:seg2:1")
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(segment)))
.build();
String event = "";
for (String line : HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body().toList()) {
if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:") && event.equals("done")) { System.out.println(line.substring(5)); break; }
}
nxt = segment.merge(text: "and is it far from here can I walk", numbers: "",
context: "A (en): #{segment[:text]} => #{out['translation']}")
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "voice-translator:translate:seg2:1")
req.body = nxt.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) { |h| h.request(req) }
event = nil
res.body.each_line do |line|
event = line[6..].strip if line.start_with?("event:")
puts line[5..] and break if line.start_with?("data:") && event == "done"
end
$next = ["text" => "and is it far from here can I walk", "numbers" => "",
"context" => "A (en): " . $segment["text"] . " => " . $out["translation"]] + $segment;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300,
CURLOPT_HTTPHEADER => ["Content-Type: application/json", "Authorization: Bearer " . TOKEN,
"Idempotency-Key: voice-translator:translate:seg2:1"],
CURLOPT_POSTFIELDS => json_encode($next)]);
$event = "";
foreach (explode("\n", curl_exec($ch)) as $line) {
if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
elseif (str_starts_with($line, "data:") && $event === "done") { echo substr($line, 5), PHP_EOL; break; }
}
segment["text"] = "and is it far from here can I walk";
segment["numbers"] = "";
segment["context"] = "A (en): ... => " + o.GetProperty("translation").GetString();
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream")
{ Content = JsonContent.Create(segment) };
req.Headers.Add("Authorization", "Bearer " + Translator.Token);
req.Headers.Add("Idempotency-Key", "voice-translator:translate:seg2:1");
using var http = new HttpClient();
var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? line, ev = "";
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:") && ev == "done") { Console.WriteLine(line[5..]); break; }
}
In conversation mode, person B's turns go the other way: send B's speech with
speaker: "B" and the source and target swapped. The browser app also reads every
translation aloud on the listener's device; from the API you get the text, so feed
translation to any text-to-speech engine for the voice.
Step 6 - Start from a recording: Whisper on SkillSafe
$model: "whisper"
The app's Accurate listening mode does this for every spoken segment: one clip (the app sends
16 kHz mono WAV, cut at a pause) is uploaded, transcribed by Whisper large-v3 turbo, and the
transcript becomes the text of a normal translate run (steps 3 to 5).
A transcription run takes exactly one audio file in $files (WAV, MP3, OGG, WebM,
FLAC, M4A or AAC, up to 10 MB) and no other fields. The reply's output.output is the
plain transcript, without timestamps. It is billed per second of audio: about $0.0001 for 10 s,
and /estimate on the same body shows the fixed amount reserved per clip.
# 1. upload the clip (keep the file id)
FILE=$(curl -s "$API/files" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-F "file=@clip.wav;type=audio/wav" | jq -r '.data.file.file_id')
# 2. transcribe it - Idempotency-Key makes a retry replay instead of billing twice
curl -sN "$API/run-stream" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" -H "Idempotency-Key: voice-translator:stt:clip1:1" \
-d "{\"\$model\":\"whisper\",\"\$files\":[\"$FILE\"]}"
# event: done data: {"status":"succeeded","charged_credits":2,"output":{"output":"where is the train station"}}
# 3. delete the clip once transcribed
curl -s -X DELETE "$API/files/$FILE" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
Then send the transcript as text with task: "translate". Whisper detects
the spoken language itself; the translate run checks it against source_lang.