Speech AI

Hướng dẫn kết nối

Mọi API đi qua một địa chỉ. Các ví dụ dưới đây đã điền sẵn địa chỉ bạn đang mở trang này: mở bằng https thì ví dụ dùng https:// và wss://.

Địa chỉ

HTTP{{HTTP}}
WebSocket{{WS}}

Không cần khoá API. HTTP API trả header CORS nên trang web ở tên miền khác gọi thẳng được.

Danh sách API

Đường dẫnViệcTrả về
POST/api/ttsĐọc một đoạn chữFile WAV
POST/api/tts/normalizeĐổi số, ngày giờ thành chữ đọc đượcJSON
WS/ws/ttsĐọc theo từng câu, câu nào xong gửi ngayJSON + WAV từng câu
POST/api/sttChép lời một file âm thanhJSON
WS/ws/sttChép lời trực tiếp (Gipformer)JSON
WS/ws/stt/gipformerNhư trên, gọi rõ tên modelJSON
WS/ws/stt/phowhisperChép lời trực tiếp (PhoWhisper)JSON
POST/api/agent/speakTiếng + lịch hình miệng cho mặt vẽNDJSON
POST/api/agent/alignLịch hình miệng cho file WAV có sẵnJSON
GET/api/agent-video/avatarsDanh sách avatar người thậtJSON
GET/api/agent-video/avatar/{id}Thông số một avatarJSON
GET/api/agent-video/avatar/{id}/frame/{i}Khung nền thứ iJPEG
POST/api/agent-video/speakTiếng + mảnh miệng đã vẽNDJSON
WS/ws/deviceThiết bị giọng nói: Opus vào, câu trả lời Opus raJSON + Opus
POST/api/device/ota/Thiết bị hỏi địa chỉ WebSocket lúc khởi độngJSON
POST/api/device/vision/explainCamera của thiết bị gửi ảnh hỏiJSON
POST/api/visionGửi một ảnh kèm câu hỏiJSON
WS/ws/visionGửi ảnh liên tục / hỏi về ảnhJSON
GET/api/health/{service}Service còn sống khôngJSON

Đọc chữ (TTS)

POST/api/tts

Thân request là JSON. Trả về file WAV (PCM 16 bit, mono, 48 kHz).

TrườngKiểuÝ nghĩa
inputstringChữ cần đọc. Bắt buộc.
voicestringTên giọng, mặc định maichi. Danh sách giọng ở GET /api/health/tts.

Endpoint này đọc nguyên văn, không tự đổi số thành chữ. Có số, ngày giờ, chữ viết tắt thì gọi /api/tts/normalize trước, hoặc dùng /ws/tts (tự làm bước này).

curl -X POST {{HTTP}}/api/tts \
  -H "Content-Type: application/json" \
  -d '{"input": "Xin chào, tôi có thể giúp gì cho bạn?", "voice": "maichi"}' \
  -o out.wav
// JavaScript (trình duyệt)
const r = await fetch("{{HTTP}}/api/tts", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ input: "Xin chào", voice: "maichi" }),
});
new Audio(URL.createObjectURL(await r.blob())).play();

POST/api/tts/normalize

curl -X POST {{HTTP}}/api/tts/normalize \
  -H "Content-Type: application/json" \
  -d '{"input": "Hẹn 9h30 ngày 15/10, nhiệt độ 26°C"}'
# {"text": "Hẹn chín giờ ba mươi phút ngày mười lăm tháng mười, nhiệt độ hai mươi sáu độ xê"}

TTS qua WebSocket

WS/ws/tts

Gửi chữ, nhận tiếng theo từng câu: câu đầu phát được ngay trong lúc câu sau đang đọc. Server tự đổi số, ngày giờ thành chữ rồi mới tách câu. Một kết nối gửi được nhiều yêu cầu, xử lý lần lượt.

ChiềuTinÝ nghĩa
server → client{"type":"ready","voices":[...],"default_voice":"maichi"}Vừa kết nối xong
client → server{"type":"speak","text":"...","voice":"maichi","id":"abc"}Yêu cầu đọc. voice, id tuỳ chọn; id được gửi lại trong mọi tin trả lời
server → client{"type":"start","id":"abc","normalized":"...","sentences":[...]}Chữ sau khi chuẩn hoá và các câu sẽ đọc
server → client{"type":"audio","id":"abc","i":0,"text":"...","bytes":52844,"ms":310}Tin nhị phân ngay sau tin này là file WAV của câu i
server → client{"type":"done","id":"abc","total_ms":1840}Đọc xong yêu cầu
server → client{"type":"error","id":"abc","error":"..."}Lỗi; kết nối vẫn dùng tiếp được
client → server{"type":"ping","t":123}Kiểm tra kết nối; server trả {"type":"pong","t":123}, t gửi lại nguyên văn
// JavaScript (trình duyệt): phát lần lượt từng câu
const ws = new WebSocket("{{WS}}/ws/tts");
ws.binaryType = "arraybuffer";
const ctx = new AudioContext();
let at = 0;
ws.onmessage = async (e) => {
  if (typeof e.data === "string") {
    const msg = JSON.parse(e.data);
    if (msg.type === "ready") ws.send(JSON.stringify({ type: "speak", text: "Chào bạn. Hôm nay là 15/10." }));
    return;
  }
  const buf = await ctx.decodeAudioData(e.data);   // file WAV của một câu
  const src = ctx.createBufferSource();
  src.buffer = buf;
  src.connect(ctx.destination);
  at = Math.max(at, ctx.currentTime);
  src.start(at);
  at += buf.duration;
};
# Python: pip install websockets
import asyncio, json, websockets

async def main():
    async with websockets.connect("{{WS}}/ws/tts", max_size=None) as ws:
        await ws.recv()                                    # ready
        await ws.send(json.dumps({"type": "speak", "text": "Chào bạn. Hôm nay trời đẹp."}))
        n = 0
        while True:
            msg = await ws.recv()
            if isinstance(msg, bytes):                     # WAV của một câu
                open(f"cau_{n}.wav", "wb").write(msg); n += 1
            elif json.loads(msg)["type"] in ("done", "error"):
                break

asyncio.run(main())

Chép lời theo file

POST/api/stt

Gửi multipart/form-data, trường file là file âm thanh (wav, mp3, m4a, webm, ogg...). Trả về {"text": "..."}. Model PhoWhisper, tiếng Việt.

curl -X POST {{HTTP}}/api/stt -F "file=@ghi_am.wav"
# {"text": "hôm nay trời đẹp quá"}
# Python
import requests
r = requests.post("{{HTTP}}/api/stt", files={"file": open("ghi_am.wav", "rb")})
print(r.json()["text"])
// JavaScript (trình duyệt): blob lấy từ MediaRecorder hoặc <input type="file">
const fd = new FormData();
fd.append("file", blob, "ghi_am.webm");
const { text } = await (await fetch("{{HTTP}}/api/stt", { method: "POST", body: fd })).json();

Chép lời trực tiếp

WS/ws/stt, /ws/stt/gipformer, /ws/stt/phowhisper

Gửi tiếng liên tục, nhận chữ ngay khi đang nói. Ba đường dẫn cùng một giao thức; /ws/stt là Gipformer.

ModelĐặc điểm
Gipformer 1.5Chính xác hơn trên câu lệnh ngắn. Chữ IN HOA, không dấu câu.
PhoWhisperChữ thường, có dấu câu. Dễ sót vài chữ đầu câu.
ChiềuTinÝ nghĩa
server → client{"type":"ready","sample_rate":16000}Vừa kết nối xong
client → servertin nhị phânÂm thanh PCM 16 bit little-endian, mono, 16000 Hz. Gói dài ngắn tuỳ ý, nên khoảng 100 ms (3200 byte)
client → server{"type":"eof"}Hết tiếng: chốt nốt câu đang dở
client → server{"type":"ping","t":123}Kiểm tra kết nối; server trả {"type":"pong","t":123}
server → client{"type":"speech","on":true}Bắt đầu / thôi nói (on:false)
server → client{"type":"partial","utt":3,"text":"..."}Chữ đoán trong lúc nói. Thay dòng partial trước
server → client{"type":"final","utt":3,"text":"...","audio_sec":2.1,"decode_sec":0.4}Câu đã chốt. Nối vào kết quả
server → client{"type":"dropped","utt":3}Câu quá ngắn, coi là tiếng ồn
server → client{"type":"eof_ack"}Đã xử lý xong sau eof

Server tự nhận ra lúc ngắt câu (khoảng 0,6 giây im lặng), không cần gửi gì để báo hết câu.

// JavaScript (trình duyệt): micro -> PCM 16 kHz -> WebSocket
const ws = new WebSocket("{{WS}}/ws/stt");
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "partial") console.log("…", msg.text);
  if (msg.type === "final") console.log("✔", msg.text);
};
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ctx = new AudioContext({ sampleRate: 16000 });         // trình duyệt tự đổi tần số mẫu
const src = ctx.createMediaStreamSource(stream);
const proc = ctx.createScriptProcessor(1024, 1, 1);
proc.onaudioprocess = (e) => {
  if (ws.readyState !== WebSocket.OPEN) return;
  const f32 = e.inputBuffer.getChannelData(0);
  const i16 = new Int16Array(f32.length);
  for (let i = 0; i < f32.length; i++) i16[i] = Math.max(-1, Math.min(1, f32[i])) * 0x7fff;
  ws.send(i16.buffer);
};
src.connect(proc);
proc.connect(ctx.destination);
// Dừng: ws.send(JSON.stringify({ type: "eof" })), chờ "eof_ack" rồi ws.close()
Trình duyệt chỉ cho dùng micro khi trang mở bằng https (hoặc localhost). Ứng dụng chạy trên máy chủ, điện thoại hay thiết bị nhúng thì không bị giới hạn này.
# Python: gửi một file WAV như đang nói thật. pip install websockets numpy
import asyncio, json, wave
import numpy as np, websockets

async def main(path):
    with wave.open(path) as w:                             # WAV PCM 16 bit, mono
        sr = w.getframerate()
        pcm = np.frombuffer(w.readframes(w.getnframes()), "<i2").astype(np.float32)
    if sr != 16000:                                        # đổi về 16 kHz
        pcm = np.interp(np.linspace(0, len(pcm) - 1, int(len(pcm) * 16000 / sr)), np.arange(len(pcm)), pcm)
    data = pcm.astype("<i2").tobytes()

    async with websockets.connect("{{WS}}/ws/stt") as ws:
        await ws.recv()                                    # ready
        async def send():
            for i in range(0, len(data), 3200):            # 100 ms mỗi gói
                await ws.send(data[i:i + 3200])
                await asyncio.sleep(0.1)
            await ws.send(json.dumps({"type": "eof"}))
        asyncio.create_task(send())
        while (msg := json.loads(await ws.recv()))["type"] != "eof_ack":
            if msg["type"] == "final":
                print(msg["text"])

asyncio.run(main("ghi_am.wav"))

Agent: mặt vẽ

POST/api/agent/speak

Thân request {"text": "...", "voice": "maichi"}. Trả về NDJSON: mỗi dòng một JSON, gửi dần theo từng câu để phát được câu đầu ngay. Tối đa 2000 ký tự.

typeTrường
metanormalized chữ đã chuẩn hoá, sentences các câu
chunki, text; audio file WAV mã hoá base64; duration giây; cues lịch hình miệng; syllables thời điểm từng âm tiết {t, d, text}; env độ to 50 lần/giây (0–1)
errori, error
donetotal_ms

Mỗi phần tử cues là {"t": giây bắt đầu, "d": độ dài, "v": hình miệng, "k": loại âm}. v theo bộ viseme của Oculus/Meta (sil PP FF DD nn SS CH kk RR aa E I O U) cộng UH (ư) và ER (ơ, â), nên gắn thẳng được vào avatar 3D có blendshape cùng tên. k: C phụ âm đầu, G âm lướt, V nguyên âm, K phụ âm cuối.

// JavaScript: đọc NDJSON theo dòng
const r = await fetch("{{HTTP}}/api/agent/speak", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: "Chào bạn. Tôi là trợ lý ảo." }),
});
const reader = r.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += value;
  let nl;
  while ((nl = buf.indexOf("\n")) >= 0) {
    const msg = JSON.parse(buf.slice(0, nl));
    buf = buf.slice(nl + 1);
    if (msg.type === "chunk") {
      // msg.audio: WAV base64. msg.cues: đổi hình miệng tại msg.cues[k].t giây kể từ lúc câu bắt đầu phát
    }
  }
}

POST/api/agent/align?text=...

Lịch hình miệng cho tiếng có sẵn (giọng thu sẵn, TTS khác). Thân request là file WAV PCM 16 bit, text là chữ đã đọc, viết ở dạng đọc ra (không còn số). Trả về {duration, cues, syllables, env} như trên.

curl -X POST "{{HTTP}}/api/agent/align?text=xin%20ch%C3%A0o" \
  -H "Content-Type: audio/wav" --data-binary @xin_chao.wav

Agent: mặt người thật

Video gốc của avatar được tải một lần (các khung nền JPEG). Khi nói, server chỉ vẽ lại vùng miệng và gửi các mảnh nhỏ; trình phát dán mảnh lên khung nền. Chạy CPU thì vẽ được khoảng 5 khung/giây, cần GPU để đủ 25 khung/giây.

GET/api/agent-video/avatars

curl {{HTTP}}/api/agent-video/avatars
# {"default": "vn_ao_xanh", "avatars": [{"id": "vn_ao_xanh", "name": "...", "credit": "...", "source": "..."}]}

GET/api/agent-video/avatar/{id}

Trả về fps (25), frames số khung nền, width, height, rev, và boxes: với mỗi khung nền, vị trí [x, y, w, h] của vùng miệng.

GET/api/agent-video/avatar/{id}/frame/{i}?v={rev}

Khung nền thứ i (JPEG), cache được lâu dài; đổi rev khi avatar được dựng lại.

POST/api/agent-video/speak

Thân request {"text": "...", "voice": "maichi", "avatar": "vn_ao_xanh", "pos": 0}. pos là số khung trình phát đang hiện lúc gửi (bộ đếm tăng 25 lần/giây, không quay vòng). Trả về NDJSON:

typeTrường
metanormalized, sentences
audioi, text, audio (WAV base64), duration; start vị trí khung mà tiếng phải bắt đầu vang lên; head, pad số khung chuyển tiếp trước và sau; frames tổng số khung của câu
framesi; at chỉ số mảnh đầu tiên trong lô; jpegs các mảnh miệng (JPEG base64)
renderedi, fps tốc độ vẽ thực tế
error / donenhư agent mặt vẽ

Ghép hình: câu bắt đầu ở khung first = start - head. Tại vị trí pos (trong khoảng first đến first + frames), mảnh pos - first được dán lên khung nền n = mirror(pos) tại toạ độ boxes[n], trong đó mirror chạy khung nền tới rồi lui: 0, 1, …, N-1, N-1, …, 0, 0, 1, …. Cách làm đầy đủ xem mã trang /ui/agent-video/.

Giữ kết nối và đo độ trễ

Cả ba WebSocket (/ws/tts, /ws/stt...) nhận {"type":"ping","t":...} và trả {"type":"pong","t":...} với đúng giá trị t đã gửi; dùng để đo độ trễ hoặc phát hiện mất kết nối từ trình duyệt. Ping frame chuẩn của WebSocket cũng được trả lời, và server tự gửi ping mỗi 20 giây nên kết nối im lặng không bị đóng.

const ws = new WebSocket("{{WS}}/ws/stt");
ws.onopen = () => setInterval(() => ws.send(JSON.stringify({ type: "ping", t: Date.now() })), 10000);
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "pong") console.log("trễ", Date.now() - msg.t, "ms");
};

Thiết bị giọng nói

WS/ws/device

Cho phần cứng (loa, mạch ESP32...): thiết bị gửi tiếng nói nén Opus, server chép lời, hỏi mô hình ngôn ngữ và gửi câu trả lời về bằng Opus, câu nào xong đọc ngay câu đó. Thiết bị là client, mở kết nối tới {{WS}}/ws/device. Prompt và các câu trả lời cố định chỉnh ở trang Thiết bị giọng nói.

HeaderGiá trị
Protocol-Version1, 2 hoặc 3: cách đóng gói khung nhị phân (bảng dưới)
Device-IdĐịa chỉ MAC. Dùng để nhớ hội thoại, gắn prompt riêng và ảnh camera cho từng thiết bị
Client-IdUUID của thiết bị
AuthorizationBearer <token>; chỉ kiểm tra khi server đặt DEVICE_TOKEN, sai thì đóng với mã 4401
ChiềuTinÝ nghĩa
client → server{"type":"hello","version":1,"features":{"mcp":true},"transport":"websocket","audio_params":{"format":"opus","sample_rate":16000,"channels":1,"frame_duration":60}}Mở đầu phiên. features.mcp: thiết bị có công cụ MCP (xem dưới)
server → client{"type":"hello","transport":"websocket","session_id":"...","audio_params":{"format":"opus","sample_rate":24000,"channels":1,"frame_duration":60}}Trả trong vòng 10 giây. audio_params là định dạng tiếng server gửi về
client → server{"type":"listen","state":"start","mode":"auto"}Bắt đầu thu, sau đó gửi các khung Opus. auto: server tự nhận ra hết câu; manual: thiết bị gửi stop; realtime: nghe liên tục kể cả lúc đang đọc, nói chen vào là ngắt câu đang đọc (thiết bị cần khử tiếng vọng)
client → servertin nhị phânMột khung Opus (16 kHz, mono, 60 ms)
client → server{"type":"listen","state":"stop"}Hết câu (mode manual)
client → server{"type":"listen","state":"detect","text":"..."}Thiết bị vừa nghe thấy từ đánh thức
client → server{"type":"abort","reason":"..."}Ngắt câu trả lời đang đọc
client → server{"type":"iot","update":true,"descriptors":[...]} / {"states":[...]}Kiểu khai báo thiết bị cũ (trước MCP): các thứ điều khiển được và trạng thái của chúng
hai chiều{"type":"mcp","payload":{...JSON-RPC 2.0...}}Công cụ của thiết bị, xem dưới
server → client{"type":"stt","text":"..."}Câu đã nghe được
server → client{"type":"llm","text":"🙂","emotion":"happy"}Cảm xúc của câu trả lời, để thiết bị vẽ mặt: neutral happy laughing funny sad angry crying loving embarrassed surprised shocked thinking winking cool relaxed delicious kissy confident sleepy silly confused
server → client{"type":"tts","state":"start"}Bắt đầu đọc; thiết bị thôi thu âm (trừ mode realtime)
server → client{"type":"tts","state":"sentence_start","text":"..."}Câu sắp phát, theo sau là các khung Opus của câu đó (24 kHz, mono, 60 ms)
server → client{"type":"tts","state":"stop"}Đọc xong (gửi khi tiếng đã phát hết); thiết bị quay lại nghe hoặc nghỉ
server → client{"type":"iot","commands":[{"name":"Lamp","method":"TurnOn","parameters":{}}]}Lệnh cho thiết bị khai báo kiểu cũ
Protocol-VersionKhung nhị phân (big-endian)
1Chỉ có gói Opus
2Header 16 byte: version u16, type u16 (0 = âm thanh), reserved u32, timestamp u32 (ms), payload_size u32; rồi gói Opus
3Header 4 byte: type u8 (0 = âm thanh), reserved u8, payload_size u16; rồi gói Opus

Thử khi chưa có phần cứng: tools/fake_device.py trong thư mục device-gateway gửi một file WAV như đang nói vào micro, giả một công cụ đèn qua MCP và lưu câu trả lời ra file WAV.

pip install websockets numpy opuslib        # opuslib cần libopus (apt install libopus0 / brew install opus)
python tools/fake_device.py {{WS}}/ws/device cau_hoi.wav --out tra_loi.wav --version 1 --mode auto

Công cụ của thiết bị (MCP)

Thiết bị gửi features.mcp: true trong hello thì ngay sau đó server hỏi danh sách công cụ. Người dùng ra lệnh ("bật đèn phòng khách") thì mô hình tự chọn công cụ, server gọi xuống thiết bị, đọc kết quả rồi trả lời.

Chiềupayload
server → client{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"vision":{"url":"{{HTTP}}/api/device/vision/explain","token":"..."}}}}
Địa chỉ camera gửi ảnh lên hỏi (xem Ảnh từ camera)
server → client{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"cursor":""}}
client → server{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"self.light.set_state","description":"...","inputSchema":{...}}],"nextCursor":""}}
server → client{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"self.light.set_state","arguments":{"on":true}}}
client → server{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"true"}],"isError":false}}

POST/api/device/ota/

Thiết bị gọi lúc khởi động để lấy địa chỉ WebSocket, token và giờ server. Đặt địa chỉ này làm OTA URL của firmware là thiết bị tự tìm ra /ws/device. Server không cập nhật firmware: luôn trả lại đúng phiên bản thiết bị đang chạy.

curl -X POST {{HTTP}}/api/device/ota/ -H "Content-Type: application/json" -d '{"application":{"version":"1.0.0"}}'
# {"server_time":{"timestamp":1760000000000,"timezone_offset":420},
#  "firmware":{"version":"1.0.0","url":""},
#  "websocket":{"url":"{{WS}}/ws/device","token":"..."}}

Ảnh từ camera

Ba đường vào, cùng một mô hình đọc ảnh. Ảnh gửi kèm Device-Id được giữ làm "mắt" của thiết bị đó trong 20 giây: hỏi bằng giọng nói lúc ấy ("cái này là gì?") thì mô hình nhìn được ảnh.

POST/api/device/vision/explain · /api/vision

Multipart: question (chữ) và file (ảnh JPEG/PNG, tối đa 8 MB). Header tuỳ chọn: Device-Id, Authorization: Bearer <token> (khi server đặt DEVICE_TOKEN). Camera của thiết bị dùng đường đầu (server báo qua MCP initialize); đường sau cho ứng dụng khác.

curl -F question="Trong ảnh có gì?" -F file=@anh.jpg {{HTTP}}/api/vision
# {"success": true, "action": "RESPONSE", "response": "Một chiếc cốc màu trắng trên bàn gỗ."}

WS/ws/vision

ChiềuTinÝ nghĩa
server → client{"type":"ready","model":"...","max_bytes":8388608}Ngay khi nối
client → server{"type":"image","id":1,"question":"Có ai không?","device_id":"aa:bb:..."} rồi một tin nhị phân là ảnhHỏi về ảnh. Có thể gửi ảnh dạng base64 trong trường image thay cho tin nhị phân
server → client{"type":"answer","id":1,"text":"...","ms":1800}Câu trả lời
client → server{"type":"image","device_id":"..."} + ảnh, hoặc chỉ tin nhị phânKhông có question: chỉ cập nhật "mắt" của thiết bị, server trả {"type":"stored"}
client → server{"type":"ping","t":...}Server trả {"type":"pong","t":...}
// Trình duyệt / Node: hỏi về một ảnh
const ws = new WebSocket("{{WS}}/ws/vision");
ws.onmessage = (e) => console.log(JSON.parse(e.data));
ws.onopen = async () => {
  ws.send(JSON.stringify({ type: "image", id: 1, question: "Trong ảnh có gì?" }));
  ws.send(await (await fetch("anh.jpg")).arrayBuffer());
};

Mô hình và prompt

Câu hỏi đi qua theo thứ tự: trường hợp đặc biệt (khớp câu thì trả lời đúng câu đã viết) → mô hình ngôn ngữ với prompt đang dùng, lịch sử vài lượt gần nhất, giờ hiện tại, trạng thái và công cụ của thiết bị → nếu mô hình lỗi hoặc chưa cấu hình thì vài luật cứng (chào, giờ, ngày...). Sửa ở trang Thiết bị giọng nói: thêm prompt, chọn prompt riêng cho từng thiết bị, thêm câu trả lời cố định, thử hỏi bằng chữ, cho thiết bị đọc một câu.

Mặc định mô hình chạy ngay trên server (Ollama, qwen3-vl:4b-instruct, đọc được cả ảnh). Đổi sang mô hình khác kiểu OpenAI API bằng biến LLM_BASE_URL, LLM_MODEL, LLM_API_KEY.

Kiểm tra sống

GET/api/health/{service}

service: tts, stt, stt-gipformer, stt-phowhisper, agent, agent-video, lab, device. Service chạy thì trả 200 kèm thông tin model; không chạy thì 502.

curl {{HTTP}}/api/health/tts
# {"status": "ok", "voices": ["maichi", ...]}

Giới hạn và lưu ý