Integrar Uttera en tu código
No hay SDK. La API es HTTP y multipart/form-data corriente, así que se habla con ella desde cualquier lenguaje sin instalar nada nuestro. Abajo hay un cliente completo —subida, reintentos, lectura de lo facturado— en seis lenguajes.
Descárgalos todos: ejemplos.zip — los mismos ficheros que muestra esta página, empaquetados en el momento. No hay una segunda copia que pueda quedarse atrás.
Lo que conviene saber antes
Tiempos de espera: la regla es dos horas
El servidor mantiene la conexión abierta hasta 7200 segundos. Un cliente con el tiempo de espera por defecto —30 s en muchas librerías— corta trabajos que iban perfectamente. Es el fallo más común al integrar.
Como referencia medida: una grabación de 100 minutos se transcribe en 10 a 35 segundos según el nodo que la atienda. La espera larga no es para el caso normal, es para que el caso raro no se pierda.
Qué se reintenta y qué no
| Respuesta | ¿Reintentar? | Por qué |
|---|---|---|
429 | Sí, respetando Retry-After | Has pasado el cupo por segundo, o tienes demasiadas peticiones a la vez. |
502 503 504 | Sí, con espera creciente | Un nodo falló. Nuestro sistema ya reintenta una vez por su cuenta antes de devolvértelo. |
400 401 402 403 404 413 415 422 | No | El problema está en la petición o en la cuenta. Reintentar da lo mismo y gasta cupo. |
Los errores que genera el borde llegan todos con la misma forma:
{"error": "quota_exceeded", "message": "Monthly credit balance depleted…"}
400 por un fichero que no se puede decodificar, o que pasa del tamaño máximo— la respuesta trae detail en vez de error y message:{"detail": "Maximum file size exceeded (parameter=audio_filesize_mb, value=112.9)"}Al leer un error, mira error y cae a detail si no está.Cabeceras que trae cada respuesta
| Cabecera | Cuándo aparece | Qué dice |
|---|---|---|
X-Request-Id | siempre | Identifica la petición. Es lo primero que te vamos a pedir si algo va mal. |
X-Audio-Duration | audio de entrada o generado | Segundos que se facturan. No está en el cuerpo, solo aquí. |
X-Detected-Language | transcripción | El idioma en el que ha trabajado el motor, en código de dos letras. Si no mandaste language, es el que detectó. Si lo mandaste, te devuelve el tuyo — no es una segunda opinión sobre si acertaste. |
X-RateLimit-ServiceX-RateLimit-Limit-SecondX-RateLimit-Remaining-Second | siempre | Cupo por segundo del servicio que has usado, y lo que te queda. |
X-Credits-Limit-MonthlyX-Credits-Used-MonthlyX-Credits-Remaining-MonthlyX-Credits-Reset-Monthly | solo si tu plan tiene bolsa | Estado de los créditos del ciclo. |
X-Credits-Overage | al pasarte de la bolsa | true: sigues servido, pero en exceso facturable. |
X-Concurrency-LimitX-Concurrency-ActiveX-Concurrency-Slots | solo si tu plan tiene tope | Peticiones simultáneas permitidas, en curso, y cuántas consume esta. |
X-Cache | voz | HIT · MISS · BYPASS · ADHOC · DISABLED. Un acierto cuesta una décima parte; BYPASS confirma que no se guardó nada. |
X-Watermark | audio generado | El esquema con el que van marcadas las muestras, audioseal-1. Está siempre y ningún parámetro la quita. Un audio nuestro sin esta cabecera es un fallo nuestro — qué dice la marca y qué no. |
X-Audio-Sha256Content-Digest | audio generado | SHA-256 de los bytes que se te entregan: en hexadecimal, y el mismo resumen otra vez en el formato del RFC 9530. Compruébalo y sabes que el audio te ha llegado entero. Una respuesta de caché trae el mismo, porque los bytes son los mismos. |
Retry-After | 429 y 402 | Segundos que hay que esperar. Mándalo él, no tu propia cuenta. |
enterprise, con bolsa y concurrencia sin tope, no verás ninguna de las de créditos ni de concurrencia. No es un fallo: no hay nada que contar.Un endpoint mueve varios servicios
/v1/translate y /v1/summarize encadenan varios motores, y cada tramo consume un hueco de concurrencia. Con un plan de 3 simultáneas, dos resúmenes a la vez pueden darte 429 too_many_concurrent_requests aunque solo hayas lanzado dos peticiones. Mira X-Concurrency-Slots para saber cuántos huecos gasta cada una.
Normaliza el audio antes de enviarlo
El motor remuestrea todo a 16 kHz mono de todas formas. Si subes un WAV de 48 kHz estéreo estás pagando ancho de banda y tiempo de subida por información que se va a tirar, y te acercas antes al tope de tamaño.
ffmpeg -i original.wav -ar 16000 -ac 1 -b:a 64k listo.mp3
Dos horas de audio así pesan unos 58 MB, muy por debajo del límite. La misma grabación en WAV de 48 kHz estéreo serían 1,3 GB.
Bash
Para trabajos de una vez, cron y tuberías. Solo necesita curl.
#!/usr/bin/env bash
# Transcribe a recording, with retries and a read of what was billed.
set -euo pipefail
: "${UTTERA_API_KEY:?export your key: export UTTERA_API_KEY=sk-echo-...}"
API=https://api.uttera.ai
FILE=${1:?usage: ./transcribe.sh recording.mp3}
# The rule is two hours: the server holds the connection for up to 7200 s, so
# the client must not give up sooner or you will cut off a job that was fine.
MAX_WAIT=7200
attempt() {
curl -sS --max-time "$MAX_WAIT" \
-D /tmp/uttera.hdr -o /tmp/uttera.out -w '%{http_code}' \
-H "Authorization: Bearer $UTTERA_API_KEY" \
-F "file=@${FILE}" \
-F "model=whisper-1" \
-F "response_format=text" \
"$API/v1/audio/transcriptions"
}
for try in 1 2 3 4 5; do
code=$(attempt) || code=000
# 2xx: done.
if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
cat /tmp/uttera.out
echo
echo "--- seconds billed: $(grep -i '^x-audio-duration:' /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2)"
echo "--- request id: $(grep -i '^x-request-id:' /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2)"
exit 0
fi
# 4xx (except 429): the problem is yours. Retrying will not fix it.
if [ "$code" -ge 400 ] && [ "$code" -lt 500 ] && [ "$code" != 429 ]; then
echo "error $code, not retrying:" >&2
cat /tmp/uttera.out >&2
exit 1
fi
# 429 and 5xx: temporary. If Retry-After comes back, let it decide.
wait=$(grep -i '^retry-after:' /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2 || true)
[ -n "${wait:-}" ] || wait=$(( 2 ** try ))
echo "error $code, attempt $try, retrying in ${wait}s" >&2
sleep "$wait"
done
echo "retries exhausted" >&2
exit 1
Python
Solo necesita requests. Incluye la consulta de lo cobrado, que se pide aparte porque el cargo se calcula después de responderte.
"""Minimal Uttera client. Only needs `requests`."""
import os
import time
import requests
API = "https://api.uttera.ai"
# The rule is two hours: the server holds on for up to 7200 s. The first number
# is the connect timeout, the second the read timeout between response bytes.
TIMEOUT = (10, 7200)
# 429 asks you to wait; 502/503/504 are a node that failed and is already
# retried once on the server. Other 4xx are yours: retrying will not fix them.
RETRYABLE = {429, 502, 503, 504}
class UtteraError(RuntimeError):
def __init__(self, status, body, request_id=None):
self.status = status
self.code = (body or {}).get("error")
self.request_id = request_id
super().__init__(f"{status} {self.code}: {(body or {}).get('message')}")
class Uttera:
def __init__(self, key=None, api=API, attempts=4):
self.key = key or os.environ["UTTERA_API_KEY"]
self.api = api
self.attempts = attempts
self.session = requests.Session()
def _call(self, method, path, **kw):
headers = {"Authorization": f"Bearer {self.key}"}
headers.update(kw.pop("headers", {}))
for attempt in range(1, self.attempts + 1):
r = self.session.request(method, self.api + path,
headers=headers, timeout=TIMEOUT, **kw)
if r.ok:
return r
if r.status_code not in RETRYABLE or attempt == self.attempts:
try:
body = r.json()
except ValueError:
body = {"message": r.text[:200]}
raise UtteraError(r.status_code, body, r.headers.get("X-Request-Id"))
# If the server says how long to wait, it decides.
wait = float(r.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
def transcribe(self, audio_path, **fields):
fields.setdefault("model", "whisper-1")
with open(audio_path, "rb") as f:
r = self._call("POST", "/v1/audio/transcriptions",
files={"file": (os.path.basename(audio_path), f)},
data=fields)
return r
def speak(self, text, voice="alloy", fmt="mp3"):
return self._call("POST", "/v1/audio/speech",
json={"model": "tts-1", "voice": voice,
"input": text, "response_format": fmt})
def last_charge(self, endpoint=None):
params = {"endpoint": endpoint} if endpoint else None
return self._call("GET", "/v1/usage/last", params=params).json()
if __name__ == "__main__":
import sys
u = Uttera()
r = u.transcribe(sys.argv[1], response_format="text")
print(r.text.strip())
# The seconds you are billed for come in the header, not in the body.
print("seconds billed:", r.headers.get("X-Audio-Duration"))
print("request id: ", r.headers.get("X-Request-Id"))
# The charge is computed AFTER you are answered, so it is queried apart.
print("charged:", u.last_charge()["credits"], "credits")
JavaScript · Node
Sin dependencias: fetch, FormData y Blob son nativos en Node desde la 20. El fichero es un módulo ES, así que o lo llamas .mjs o pones "type": "module" en tu package.json.
// Minimal Uttera client. No dependencies: fetch, FormData and Blob are built
// into Node from version 20.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";
const API = "https://api.uttera.ai";
// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours:
// retrying will not fix them.
const RETRYABLE = new Set([429, 502, 503, 504]);
export class UtteraError extends Error {
constructor(status, body, requestId) {
super(`${status} ${body?.error}: ${body?.message}`);
this.name = "UtteraError";
this.status = status;
this.code = body?.error;
this.requestId = requestId;
}
}
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
export class Uttera {
constructor(key = process.env.UTTERA_API_KEY, { api = API, attempts = 4 } = {}) {
if (!key) throw new Error("UTTERA_API_KEY is missing");
this.key = key;
this.api = api;
this.attempts = attempts;
}
async #call(path, options = {}, rebuild = null) {
for (let attempt = 1; attempt <= this.attempts; attempt++) {
// The body is consumed when sent: if we have to retry, it is rebuilt.
const body = rebuild ? await rebuild() : options.body;
// The rule is two hours: the server holds on for up to 7200 s. Without
// this, fetch waits forever; with 30 s you would cut off healthy jobs.
const res = await fetch(this.api + path, {
...options,
body,
headers: { Authorization: `Bearer ${this.key}`, ...options.headers },
signal: AbortSignal.timeout(7200_000),
});
if (res.ok) return res;
if (!RETRYABLE.has(res.status) || attempt === this.attempts) {
let detail = null;
try { detail = await res.json(); } catch { detail = { message: await res.text() }; }
throw new UtteraError(res.status, detail, res.headers.get("x-request-id"));
}
// If the server says how long to wait, it decides.
await sleep(Number(res.headers.get("retry-after")) || 2 ** attempt);
}
}
async transcribe(path, fields = {}) {
const rebuild = async () => {
const fd = new FormData();
fd.append("file", await openAsBlob(path), basename(path));
fd.append("model", fields.model ?? "whisper-1");
for (const [k, v] of Object.entries(fields)) if (k !== "model") fd.append(k, v);
return fd;
};
return this.#call("/v1/audio/transcriptions", { method: "POST" }, rebuild);
}
async speak(text, voice = "alloy", fmt = "mp3") {
return this.#call("/v1/audio/speech", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "tts-1", voice, input: text, response_format: fmt }),
});
}
async lastCharge(endpoint) {
const q = endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : "";
return (await this.#call(`/v1/usage/last${q}`)).json();
}
}
// ── usage ──────────────────────────────────────────────────────────────────
const u = new Uttera();
const res = await u.transcribe(process.argv[2], { response_format: "text" });
console.log((await res.text()).trim());
// The seconds you are billed for come in the header, not in the body.
console.log("seconds billed:", res.headers.get("x-audio-duration"));
console.log("request id: ", res.headers.get("x-request-id"));
// The charge is computed AFTER you are answered, so it is queried apart.
console.log("charged:", (await u.lastCharge()).credits, "credits");
TypeScript
El mismo cliente con los tipos de las respuestas y de los errores, que es lo que de verdad aporta aquí: codigo es estable y se puede ramificar sobre él, message es para que lo lea un humano.
node uttera.mts, sin paso de compilación. A cambio hay que quedarse en el subconjunto borrable del lenguaje: nada de enum, namespace ni «parameter properties» (constructor(private x: T)), porque eso emite código y no solo tipos. Con erasableSyntaxOnly en el tsconfig.json, el compilador te avisa si te sales.{
"type": "module",
"name": "uttera-ejemplo",
"version": "1.0.0",
"private": true,
"devDependencies": {
"typescript": "^5",
"@types/node": "^22"
}
}
{
"compilerOptions": {
"target": "ES2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["ES2023", "DOM"],
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"types": ["node"]
}
}
// Minimal Uttera client in TypeScript. No dependencies.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";
const API = "https://api.uttera.ai";
const RETRYABLE = new Set([429, 502, 503, 504]);
/** Shape of EVERY API error. Always the same, wherever it is handled. */
export interface ErrorBody {
error: string;
message: string;
/** Only on errors raised by the edge (404, 413, 502…). */
status?: number;
docs?: string;
}
/** What GET /v1/usage/last returns. The breakdown depends on the endpoint. */
export interface Charge {
endpoint: string;
service: string;
credits: number;
breakdown: Record<string, number>;
ts: number;
audio_seconds?: number;
input_tokens?: number;
output_tokens?: number;
}
export type ResponseFormat = "json" | "text" | "verbose_json" | "srt" | "vtt";
export interface TranscribeOptions {
model?: string;
language?: string;
prompt?: string;
response_format?: ResponseFormat;
temperature?: number;
}
export class UtteraError extends Error {
readonly status: number;
readonly code: string;
readonly requestId: string | null;
constructor(status: number, body: Partial<ErrorBody>, requestId: string | null) {
super(`${status} ${body.error}: ${body.message}`);
this.name = "UtteraError";
this.status = status;
this.code = body.error ?? "unknown";
this.requestId = requestId;
}
}
const sleep = (s: number): Promise<void> =>
new Promise((r) => setTimeout(r, s * 1000));
export class Uttera {
// ⚠ Fields declared and assigned by hand, NOT parameter properties
// (`constructor(private key: string)`). That form emits code, not just
// types, and Node refuses to run it directly: with ordinary fields this file
// runs as it is with `node uttera.mts`, no compilation step.
private readonly key: string;
private readonly attempts: number;
private readonly api: string;
constructor(
key: string = process.env.UTTERA_API_KEY ?? "",
attempts: number = 4,
api: string = API,
) {
if (!key) throw new Error("UTTERA_API_KEY is missing");
this.key = key;
this.attempts = attempts;
this.api = api;
}
private async call(
path: string,
options: RequestInit = {},
rebuild?: () => Promise<BodyInit>,
): Promise<Response> {
for (let attempt = 1; attempt <= this.attempts; attempt++) {
// The body is consumed when sent: if we have to retry, it is rebuilt.
const body = rebuild ? await rebuild() : options.body;
// The rule is two hours: the server holds on for up to 7200 s.
const res = await fetch(this.api + path, {
...options,
body,
headers: { Authorization: `Bearer ${this.key}`, ...options.headers },
signal: AbortSignal.timeout(7_200_000),
});
if (res.ok) return res;
if (!RETRYABLE.has(res.status) || attempt === this.attempts) {
const err = (await res.json().catch(() => ({}))) as Partial<ErrorBody>;
throw new UtteraError(res.status, err, res.headers.get("x-request-id"));
}
// If the server says how long to wait, it decides.
await sleep(Number(res.headers.get("retry-after")) || 2 ** attempt);
}
throw new Error("unreachable");
}
async transcribe(path: string, options: TranscribeOptions = {}): Promise<Response> {
const rebuild = async (): Promise<FormData> => {
const fd = new FormData();
fd.append("file", await openAsBlob(path), basename(path));
fd.append("model", options.model ?? "whisper-1");
for (const [k, v] of Object.entries(options)) {
if (k !== "model" && v !== undefined) fd.append(k, String(v));
}
return fd;
};
return this.call("/v1/audio/transcriptions", { method: "POST" }, rebuild);
}
async lastCharge(endpoint?: string): Promise<Charge> {
const q = endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : "";
return (await this.call(`/v1/usage/last${q}`)).json() as Promise<Charge>;
}
}
// ── usage ──────────────────────────────────────────────────────────────────
const u = new Uttera();
try {
const res = await u.transcribe(process.argv[2]!, { response_format: "text" });
console.log((await res.text()).trim());
console.log("seconds billed:", res.headers.get("x-audio-duration"));
const charge = await u.lastCharge();
console.log("charged:", charge.credits, "credits", charge.breakdown);
} catch (e) {
if (e instanceof UtteraError) {
// `code` is stable; `message` is for a human to read, not to branch on.
console.error(`failed ${e.code} (request ${e.requestId})`);
process.exit(1);
}
throw e;
}
Go
Sin dependencias: todo es biblioteca estándar. Ojo a un detalle que muerde: el cuerpo multipart se consume al enviarlo, así que hay que rearmarlo en cada reintento, no reutilizar el mismo lector.
module uttera
go 1.21
// Minimal Uttera client. No dependencies: standard library only.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
const api = "https://api.uttera.ai"
// The rule is two hours: the server holds on for up to 7200 s. A Client with
// no Timeout waits forever; one with 30 s cuts off healthy jobs.
var client = &http.Client{Timeout: 2 * time.Hour}
// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours.
func retryable(code int) bool {
return code == 429 || code == 502 || code == 503 || code == 504
}
type APIError struct {
Status int `json:"-"`
Code string `json:"error"`
Message string `json:"message"`
RequestID string `json:"-"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("%d %s: %s (request %s)", e.Status, e.Code, e.Message, e.RequestID)
}
// multipart must be rebuilt on every attempt: the body is consumed when sent.
func audioBody(path string, fields map[string]string) (io.Reader, string, error) {
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
defer f.Close()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("file", filepath.Base(path))
if err != nil {
return nil, "", err
}
if _, err := io.Copy(part, f); err != nil {
return nil, "", err
}
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
return nil, "", err
}
}
if err := w.Close(); err != nil {
return nil, "", err
}
return &buf, w.FormDataContentType(), nil
}
func Transcribe(key, path string, fields map[string]string) (string, http.Header, error) {
for attempt := 1; attempt <= 4; attempt++ {
body, ctype, err := audioBody(path, fields)
if err != nil {
return "", nil, err
}
req, err := http.NewRequest("POST", api+"/v1/audio/transcriptions", body)
if err != nil {
return "", nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", ctype)
res, err := client.Do(req)
if err != nil {
return "", nil, err
}
data, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode < 300 {
return string(data), res.Header, nil
}
if !retryable(res.StatusCode) || attempt == 4 {
e := &APIError{Status: res.StatusCode, RequestID: res.Header.Get("X-Request-Id")}
if json.Unmarshal(data, e) != nil {
e.Message = string(data)
}
return "", res.Header, e
}
// If the server says how long to wait, it decides.
wait := math.Pow(2, float64(attempt))
if ra, err := strconv.ParseFloat(res.Header.Get("Retry-After"), 64); err == nil {
wait = ra
}
time.Sleep(time.Duration(wait * float64(time.Second)))
}
return "", nil, fmt.Errorf("retries exhausted")
}
func main() {
key := os.Getenv("UTTERA_API_KEY")
if key == "" || len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: UTTERA_API_KEY=sk-echo-... ./uttera recording.mp3")
os.Exit(2)
}
text, hdr, err := Transcribe(key, os.Args[1], map[string]string{
"model": "whisper-1",
"response_format": "text",
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(text)
// The seconds you are billed for come in the header, not in the body.
fmt.Println("seconds billed:", hdr.Get("X-Audio-Duration"))
fmt.Println("request id: ", hdr.Get("X-Request-Id"))
}
Rust
Con reqwest en modo bloqueante, que para un cliente de API se lee mucho mejor que el asíncrono. Se usa rustls en vez de native-tls para no depender del OpenSSL del sistema. Compílalo con un toolchain actual (rustup update): las dependencias de reqwest van subiendo la versión de Rust que exigen, y con un compilador viejo la compilación falla dentro de una de ellas, no en este código.
[package]
name = "uttera"
version = "0.1.0"
edition = "2021"
[dependencies]
# rustls instead of native-tls: that way you do not need the system OpenSSL.
reqwest = { version = "0.12", default-features = false, features = [
"blocking", "multipart", "json", "rustls-tls", "charset", "http2",
] }
serde_json = "1"
//! Minimal Uttera client.
use std::{env, thread, time::Duration};
const API: &str = "https://api.uttera.ai";
/// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours:
/// retrying will not fix them.
fn retryable(code: u16) -> bool {
matches!(code, 429 | 502 | 503 | 504)
}
fn transcribe(key: &str, path: &str) -> Result<(String, reqwest::header::HeaderMap), String> {
// The rule is two hours: the server holds on for up to 7200 s. A short
// timeout cuts off healthy jobs.
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(7200))
.build()
.map_err(|e| e.to_string())?;
for attempt in 1..=4u32 {
// The form is consumed when sent: it has to be rebuilt every time.
let form = reqwest::blocking::multipart::Form::new()
.file("file", path)
.map_err(|e| e.to_string())?
.text("model", "whisper-1")
.text("response_format", "text");
let res = client
.post(format!("{API}/v1/audio/transcriptions"))
.bearer_auth(key)
.multipart(form)
.send()
.map_err(|e| e.to_string())?;
let code = res.status().as_u16();
let headers = res.headers().clone();
let body = res.text().unwrap_or_default();
if (200..300).contains(&code) {
return Ok((body, headers));
}
if !retryable(code) || attempt == 4 {
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or(body);
let id = headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
return Err(format!("{code}: {detail} (request {id})"));
}
// If the server says how long to wait, it decides.
let wait = headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1u64 << attempt);
thread::sleep(Duration::from_secs(wait));
}
Err("retries exhausted".into())
}
fn main() {
let key = env::var("UTTERA_API_KEY").expect("export UTTERA_API_KEY");
let path = env::args().nth(1).expect("usage: uttera recording.mp3");
match transcribe(&key, &path) {
Ok((text, hdr)) => {
println!("{}", text.trim());
// The seconds you are billed for come in the header, not the body.
let read = |n| hdr.get(n).and_then(|v| v.to_str().ok()).unwrap_or("-");
println!("seconds billed: {}", read("x-audio-duration"));
println!("request id: {}", read("x-request-id"));
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
}