Speech to text
Upload an audio file and get a transcript back — Persian by default, 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: larsa-stt |
| language Optional | string | A Whisper language name (persian, english, spanish, …). The default is Persian — set this explicitly for anything else, or the audio gets decoded as if it were Persian.Default: persian |
| response_format Optional | "json" | "text" | json returns {text, duration, processing_time}. text returns the transcript as a plain body. Neither carries word or segment timestamps — there is no verbose_json here.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. Default: false |
curl -s https://api.console.larsa.larsima.com/v1/audio/transcriptions \
-H "Authorization: Bearer $LARSA_API_KEY" \
-F file=@voice.m4a \
-F model=larsa-stt \
-F language=persian \
-F response_format=json
# {"text":"مهلت تجدیدنظرخواهی از رأی دادگاه بیست روز است.",
# "duration":4.58,"processing_time":1.12}
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.console.larsa.larsima.com/v1",
api_key=os.environ["LARSA_API_KEY"])
with open("voice.m4a", "rb") as f:
resp = client.audio.transcriptions.create(
model="larsa-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.console.larsa.larsima.com/v1",
apiKey: process.env.LARSA_API_KEY,
});
const resp = await client.audio.transcriptions.create({
file: fs.createReadStream("voice.m4a"),
model: "larsa-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.console.larsa.larsima.com/v1",
apiKey: process.env.LARSA_API_KEY,
});
const resp = await client.audio.transcriptions.create({
file: fs.createReadStream("voice.m4a"),
model: "larsa-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", "larsa-stt")
w.WriteField("language", "persian")
w.WriteField("response_format", "json")
w.Close()
req, _ := http.NewRequest("POST",
"https://api.console.larsa.larsima.com/v1/audio/transcriptions", &buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("LARSA_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("LARSA_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", "larsa-stt")
.text("language", "persian")
.text("response_format", "json");
let resp = reqwest::Client::new()
.post("https://api.console.larsa.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 = "----larsa" + System.currentTimeMillis();
Map<String, String> fields = new LinkedHashMap<>();
fields.put("model", "larsa-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.console.larsa.larsima.com/v1/audio/transcriptions"))
.header("Authorization", "Bearer " + System.getenv("LARSA_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("LARSA_API_KEY"));
using var form = new MultipartFormDataContent
{
{ new StringContent("larsa-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.console.larsa.larsima.com/v1/audio/transcriptions", form);
Console.WriteLine(await response.Content.ReadAsStringAsync());
<?php
$payload = [
"model" => "larsa-stt",
"language" => "persian",
"response_format" => "json",
"file" => new CURLFile("voice.m4a"),
];
$ch = curl_init("https://api.console.larsa.larsima.com/v1/audio/transcriptions");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("LARSA_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.console.larsa.larsima.com/v1/audio/transcriptions")
File.open("voice.m4a") do |file|
request = Net::HTTP::Post::Multipart.new(uri,
"model" => "larsa-stt",
"language" => "persian",
"response_format" => "json",
"file" => UploadIO.new(file, "audio/mp4", "voice.m4a"))
request["Authorization"] = "Bearer #{ENV.fetch('LARSA_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.console.larsa.larsima.com/v1/audio/transcriptions \
-H "Authorization: Bearer $LARSA_API_KEY" \
-F file=@voice.m4a \
-F model=larsa-stt \
-F language=persian \
-F fusion=true
# {"text":"...", "duration":4.58, "processing_time":2.3,
# "candidates":["...", "...", "..."]}fusion=true, the response adds candidates: the three raw transcripts that went into reconciling text. Worth logging if you ever need to see what the recognisers actually disagreed about.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.
| Model tested | Read speech (Common Voice) | Spontaneous speech (voice-note-like) |
|---|---|---|
Whisper large-v3 — larsa-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.