Speech to text
Upload an audio file and get a transcript back — language auto-detected (falling back to Persian), m4a and webm handled without conversion.
POST /v1/audio/transcriptions takes one audio file and returns its text — the same shape OpenAI's Whisper endpoint uses, multipart form and all, so an existing client library already knows how to call it.
/v1/audio/transcriptions| Parameter | Type | Description |
|---|---|---|
| file Required | file | The audio. Any container ffmpeg reads: m4a, mp3, wav, webm/opus, and more. |
| model Optional | string | The deployment name. The engine underneath is Whisper large-v3 — see below for why that's the default rather than the Persian fine-tune that wins on short clips. Default: data-stt |
| language Optional | string | An ISO 639-1 code (fa, en, es, …) or a Whisper language name (persian, english, spanish, …), case-insensitively. Left unset — or set to `auto` — the audio is auto-detected from up to its first 30 seconds; detection only overrides the Persian default when it is both confident and not one of Persian's own confusables (Arabic, Urdu, Pashto, Tajik), so real Persian audio is never misdetected into one of its close neighbours. An unrecognised value is a 400, not a silent guess.Default: auto |
| response_format Optional | "json" | "text" | json returns {text, duration, language, usage, words, segments} — the gateway normalises this to the OpenAI transcription shape, so words and segments are always null here; there is no verbose_json. text returns the transcript as a plain body.Default: json |
| fusion Optional | boolean | Reconcile three Whisper variants instead of running one — see below. Roughly doubles the time, and is worth it for anything unattended. The fused result comes back as text like any other request; the individual per-model candidates the service computes along the way are not surfaced through this gateway endpoint.Default: false |
curl -s https://api.data.larsima.com/v1/audio/transcriptions \
-H "Authorization: Bearer $DATA_API_KEY" \
-F file=@voice.m4a \
-F model=data-stt \
-F language=persian \
-F response_format=json
# {"text":"مهلت تجدیدنظرخواهی از رأی دادگاه بیست روز است.",
# "duration":4.58,"language":"fa","usage":null,"words":null,"segments":null}
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.data.larsima.com/v1",
api_key=os.environ["DATA_API_KEY"])
with open("voice.m4a", "rb") as f:
resp = client.audio.transcriptions.create(
model="data-stt",
file=f,
language="persian",
response_format="json",
)
print(resp.text)
import fs from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.data.larsima.com/v1",
apiKey: process.env.DATA_API_KEY,
});
const resp = await client.audio.transcriptions.create({
file: fs.createReadStream("voice.m4a"),
model: "data-stt",
language: "persian",
response_format: "json",
});
console.log(resp.text);
import fs from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.data.larsima.com/v1",
apiKey: process.env.DATA_API_KEY,
});
const resp = await client.audio.transcriptions.create({
file: fs.createReadStream("voice.m4a"),
model: "data-stt",
language: "persian",
response_format: "json",
});
console.log(resp.text);
package main
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
func main() {
file, err := os.Open("voice.m4a")
if err != nil {
panic(err)
}
defer file.Close()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, _ := w.CreateFormFile("file", "voice.m4a")
io.Copy(part, file)
w.WriteField("model", "data-stt")
w.WriteField("language", "persian")
w.WriteField("response_format", "json")
w.Close()
req, _ := http.NewRequest("POST",
"https://api.data.larsima.com/v1/audio/transcriptions", &buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("DATA_API_KEY"))
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(string(out))
}
// Cargo.toml: reqwest = { version = "0.12", features = ["multipart"] }
// tokio = { version = "1", features = ["full"] }
use reqwest::multipart;
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = env::var("DATA_API_KEY")?;
let file = tokio::fs::read("voice.m4a").await?;
let form = multipart::Form::new()
.part("file", multipart::Part::bytes(file).file_name("voice.m4a"))
.text("model", "data-stt")
.text("language", "persian")
.text("response_format", "json");
let resp = reqwest::Client::new()
.post("https://api.data.larsima.com/v1/audio/transcriptions")
.bearer_auth(key)
.multipart(form)
.send()
.await?
.text()
.await?;
println!("{resp}");
Ok(())
}
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
public class Transcribe {
public static void main(String[] args) throws Exception {
String boundary = "----data" + System.currentTimeMillis();
Map<String, String> fields = new LinkedHashMap<>();
fields.put("model", "data-stt");
fields.put("language", "persian");
fields.put("response_format", "json");
var out = new ByteArrayOutputStream();
for (var e : fields.entrySet()) {
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\""
+ e.getKey() + "\"\r\n\r\n" + e.getValue() + "\r\n").getBytes());
}
byte[] audio = Files.readAllBytes(Path.of("voice.m4a"));
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; "
+ "filename=\"voice.m4a\"\r\nContent-Type: audio/mp4\r\n\r\n").getBytes());
out.write(audio);
out.write(("\r\n--" + boundary + "--\r\n").getBytes());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.data.larsima.com/v1/audio/transcriptions"))
.header("Authorization", "Bearer " + System.getenv("DATA_API_KEY"))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
}
}
using System.Net.Http.Headers;
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("DATA_API_KEY"));
using var form = new MultipartFormDataContent
{
{ new StringContent("data-stt"), "model" },
{ new StringContent("persian"), "language" },
{ new StringContent("json"), "response_format" },
{ new StreamContent(File.OpenRead("voice.m4a")), "file", "voice.m4a" },
};
var response = await client.PostAsync(
"https://api.data.larsima.com/v1/audio/transcriptions", form);
Console.WriteLine(await response.Content.ReadAsStringAsync());
<?php
$payload = [
"model" => "data-stt",
"language" => "persian",
"response_format" => "json",
"file" => new CURLFile("voice.m4a"),
];
$ch = curl_init("https://api.data.larsima.com/v1/audio/transcriptions");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("DATA_API_KEY")],
CURLOPT_POSTFIELDS => $payload,
]);
echo curl_exec($ch);
require "net/http"
require "net/http/post/multipart" # gem install multipart-post
require "uri"
uri = URI("https://api.data.larsima.com/v1/audio/transcriptions")
File.open("voice.m4a") do |file|
request = Net::HTTP::Post::Multipart.new(uri,
"model" => "data-stt",
"language" => "persian",
"response_format" => "json",
"file" => UploadIO.new(file, "audio/mp4", "voice.m4a"))
request["Authorization"] = "Bearer #{ENV.fetch('DATA_API_KEY')}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
end
The phone recording that usually breaks this
A voice note from a phone is m4a, and m4a keeps its index in a trailing atom at the *end* of the file — a decoder has to seek backwards to find it. Fed through a pipe, ffmpeg cannot seek, exits 0 having produced nothing, and the request "succeeds" with an empty transcript and no error anywhere. This service always writes the upload to a temp file first and decodes from that, specifically so this doesn't happen. Send the file exactly as your phone recorded it — m4a, webm/opus, whatever your caller produces — there is no need to convert it yourself.
Long audio: cut on silence, not on a clock
There's no length limit beyond the edge's 200MB upload cap. Long audio is cut at the quietest gaps — never longer than 25 seconds a segment, never mid-word — rather than at fixed 30-second boundaries. That distinction is not cosmetic: a fixed-window chunker on the same audio, same model, silently dropped a whole passage from the middle of a recording — 69.6% word error rate against 38.5% for the version that cuts on silence.
fusion: four ears instead of one
Set fusion=true and three Whisper variants transcribe the same audio independently; a fourth model reads all three candidates and reconstructs what none of them got right alone. Measured on short clips, this took the error rate from 34.4% to 15.6%. It roughly doubles the time, and it's off by default for that reason — but for anything unattended, it earns the cost. On one real voice note the raw transcript garbled "کلاهبرداری" (fraud) into "نکلاه برداری", and reading that garbled word changed a downstream model's answer from *the law protects you* to *you may be prosecuted*. Fusion is the guard against exactly that.
curl -s https://api.data.larsima.com/v1/audio/transcriptions \
-H "Authorization: Bearer $DATA_API_KEY" \
-F file=@voice.m4a \
-F model=data-stt \
-F language=persian \
-F fusion=true
# {"text":"...", "duration":4.58, "language":"fa",
# "usage":null, "words":null, "segments":null}candidates — the three raw transcripts that went into reconciling text — but this endpoint is reached through the gateway, which reshapes every response to the OpenAI transcription schema, and candidates is not part of it. Only the fused text comes back; there is currently no supported way to retrieve the individual candidates over the public API.Why Whisper large-v3 and not the Persian specialist
A Persian fine-tune of Whisper wins the short-clip leaderboard. It also has a failure mode large-v3 doesn't: on a 63-second passage it fell into a repetition loop — "دادخواهم، دادخواهم، …" — and scored 90.7% word error rate where large-v3 scored 38.5% on the same audio. Voice notes are long. The short-clip winner was the wrong default.
fusion or not.| Model tested | Read speech (Common Voice) | Spontaneous speech (voice-note-like) |
|---|---|---|
Whisper large-v3 — data-stt, default | 53.0% WER | 34.0% WER |
| Whisper large-v3-turbo | 67.1% WER | 36.9% WER |
| Whisper large-fa (Persian fine-tune) | 52.4% WER | 55.8% WER |
Measured on hezarai/common-voice-13-fa (read) and pourmand1376/asr-farsi-youtube-chunked-30-seconds (spontaneous), the same files through every model. Note that "read" and "spontaneous" rank the models differently — a single number from either test alone would be true and misleading.