Lot 9 : dictée vocale tenue par la façade (LOKI_DICTATE=rust)

La façade sert /api/transcribe et /api/dictate/* quand LOKI_DICTATE=rust
(expérimental ; le Go reste le défaut) et supervise elle-même le serveur
de transcription sherpa-onnx : lancé à la demande sur la boucle locale,
GPU masqués et sans le jeton interne, prêt dès que sa socket répond, mort
au démarrage expliquée, éteint après dix minutes d'inactivité ou quand
les réglages changent. Modèles Parakeet téléchargés et décompressés au fil
de l'eau (tar.bz2, entrées hors du dossier refusées, liens ignorés),
protocole WebSocket identique (en-tête de 8 octets, trames de 10 Kio).

Côté Go, facade_dictate.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais, et asrShutdownVite (libération de la
VRAM, optimiseur) lui demande l'arrêt par l'API interne.

Parité : parity_dictate_test.go tire du Go la lecture de treize WAV, les
chemins d'archive, réglages, catalogue, arguments et treize appels de
route, rejoués par tests/dictate.rs ; supervision testée contre un faux
sherpa-onnx qui parle le protocole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 16:17:29 +00:00
1 parent 17ec2291d4
commit 46bfab3343
21 files changed
+2699 -19

No files matched your search

+37
View File
@@ -56,6 +56,15 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "cc"
version = "1.8.0"
@@ -192,6 +201,16 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -639,6 +658,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "libbz2-rs-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.190"
@@ -668,6 +693,7 @@ name = "loki-rs"
version = "0.1.0"
dependencies = [
"bytes",
"bzip2",
"flate2",
"futures-util",
"getrandom 0.4.3",
@@ -683,6 +709,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"tar",
"tempfile",
"tokio",
"tokio-tungstenite",
@@ -1198,6 +1225,16 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
]
[[package]]
name = "tempfile"
version = "3.27.0"
+3 -1
View File
@@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
bytes = "1.12.1"
bzip2 = "0.6.1"
flate2 = "1.1.10"
futures-util = "0.3.34"
getrandom = "0.4.3"
@@ -20,7 +21,9 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.154", features = ["raw_value"] }
sha1 = "0.11.0"
sha2 = "0.11.1"
tar = { version = "0.4.46", default-features = false }
tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "signal", "process", "sync", "fs"] }
tokio-tungstenite = "0.30.0"
[features]
# Crypto TLS (ring) pour les API externes en HTTPS. Activée par défaut ; le
@@ -35,4 +38,3 @@ libc = "0.2"
tokio = { version = "1.53.2", features = ["test-util"] }
futures-util = "0.3.34"
tempfile = "3.27.0"
tokio-tungstenite = "0.30.0"
+2
View File
@@ -30,6 +30,8 @@ pub struct Ctx {
/// catalogue Hugging Face) tenues par la façade (`LOKI_MODELS=rust`) ;
/// `None` : au Go.
pub models: Option<std::sync::Arc<crate::models::Models>>,
/// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) ; `None` : au Go.
pub dictate: Option<std::sync::Arc<crate::dictate::Dictation>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -15,6 +15,7 @@ pub mod auth;
pub mod chat;
pub mod chatapi;
pub mod child;
pub mod dictate;
pub mod downloads;
pub mod engine;
pub mod engstatus;
+19 -2
View File
@@ -121,6 +121,9 @@ fn serve() -> ExitCode {
// LOKI_MODELS=rust : les routes des modèles aussi (le Go remet alors
// celles du tunnel et ne vide plus les .part).
let models_rust = models_held_by_facade();
// LOKI_DICTATE=rust : la dictée vocale aussi (le Go lui demande alors
// d'arrêter son serveur quand il libère la VRAM).
let dictate_rust = held_by_facade("LOKI_DICTATE");
let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())];
if chat_rust {
env.push(("LOKI_CHAT".into(), "rust".into()));
@@ -128,7 +131,10 @@ fn serve() -> ExitCode {
if models_rust {
env.push(("LOKI_MODELS".into(), "rust".into()));
}
if chat_rust || models_rust {
if dictate_rust {
env.push(("LOKI_DICTATE".into(), "rust".into()));
}
if chat_rust || models_rust || dictate_rust {
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
}
let sup = child::Supervisor::spawn(go_bin, go_port, env);
@@ -167,6 +173,11 @@ fn serve() -> ExitCode {
None
};
let dictate = dictate_rust.then(|| {
eprintln!("[loki-rs] dictée tenue par la façade (LOKI_DICTATE=rust)");
std::sync::Arc::new(loki_rs::dictate::Dictation::new(&engine_paths.home))
});
let addr = SocketAddr::from(([0, 0, 0, 0], web_port));
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
@@ -184,6 +195,7 @@ fn serve() -> ExitCode {
engine: engine_paths,
chat,
models,
dictate,
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
@@ -205,7 +217,12 @@ fn chat_held_by_facade() -> bool {
/// `LOKI_MODELS=rust` : routes des modèles tenues par la façade.
fn models_held_by_facade() -> bool {
std::env::var("LOKI_MODELS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
held_by_facade("LOKI_MODELS")
}
/// `<VAR>=rust` : ce lot est tenu par la façade (le Go par défaut).
fn held_by_facade(var: &str) -> bool {
std::env::var(var).is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
}
/// Les `.part` d'un téléchargement tué ne reprendront jamais : vidés au
+21
View File
@@ -396,6 +396,27 @@ pub fn base_name(p: &str) -> String {
}
}
/// `exec.LookPath` : le chemin du programme dans le PATH (exécutable).
pub fn look_path(name: &str) -> Option<String> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|p| {
std::fs::metadata(p).is_ok_and(|m| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
m.is_file() && m.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
m.is_file()
}
})
})
.map(|p| p.to_string_lossy().into_owned())
}
/// Message d'une `*os.PathError` du Go : « op chemin: raison ». La raison
/// suit la forme des `syscall.Errno` du Go sous Unix (minuscule en tête, sans
/// le « (os error N) » de Rust).
+21 -4
View File
@@ -66,6 +66,9 @@ pub struct Config {
/// Routes des modèles tenues par la façade (`LOKI_MODELS=rust`) : servies
/// ici ; `None` : relayées au Go.
pub models: Option<Arc<crate::models::Models>>,
/// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) : servie ici ;
/// `None` : relayée au Go.
pub dictate: Option<Arc<crate::dictate::Dictation>>,
}
struct Proxy {
@@ -94,6 +97,7 @@ pub async fn serve(
engine: cfg.engine,
chat: cfg.chat,
models: cfg.models,
dictate: cfg.dictate,
internal_token: cfg.internal_token.clone(),
},
});
@@ -133,10 +137,11 @@ pub async fn serve(
impl Proxy {
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
// Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la
// façade le trafic du chat et des modèles qui lui arrive sans passer
// par elle.
let from_go = (self.ctx.chat.is_some() || self.ctx.models.is_some())
&& self.ctx.from_go(req.headers());
// façade le trafic du chat, des modèles et de la dictée qui lui arrive
// sans passer par elle.
let from_go =
(self.ctx.chat.is_some() || self.ctx.models.is_some() || self.ctx.dictate.is_some())
&& self.ctx.from_go(req.headers());
// Jamais de confiance accordée sur la foi d'un en-tête du client :
// ni « je viens du tunnel », ni « je suis la façade ».
req.headers_mut().remove(VIA_TUNNEL);
@@ -150,6 +155,13 @@ impl Proxy {
{
return chatapi::serve_internal(chat, req).await;
}
if from_go
&& let Some(d) = &self.ctx.dictate
&& path == crate::dictate::INTERNAL_STOP
{
d.shutdown_fast().await;
return plain(StatusCode::NO_CONTENT, "");
}
// L'API interne du Go n'est jamais joignable de l'extérieur.
if path.starts_with("/internal/") {
return plain(StatusCode::NOT_FOUND, "404 page not found");
@@ -169,6 +181,11 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if let Some(d) = &self.ctx.dictate
&& crate::dictate::handles(path)
{
return crate::dictate::serve(&self.ctx, d, req, from_go).await;
}
if let Some(m) = &self.ctx.models
&& crate::models::handles(path)
{
+43 -10
View File
@@ -427,7 +427,7 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
/// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade,
/// état du faux Go, garde d'arrêt).
pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let (f, st, tx, _) = start_opts(home, false, None, None).await;
let (f, st, tx, _) = start_with(home, Opts::default()).await;
(f, st, tx)
}
@@ -437,10 +437,36 @@ pub async fn start_models(
home: std::path::PathBuf,
hf: &str,
) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let (f, st, tx, _) = start_opts(home, false, None, Some(hf.to_string())).await;
let opts = Opts {
models_hf: Some(hf.to_string()),
..Opts::default()
};
let (f, st, tx, _) = start_with(home, opts).await;
(f, st, tx)
}
/// Comme [`start`], avec la dictée tenue par la façade (`LOKI_DICTATE=rust`).
pub async fn start_dictate(
home: std::path::PathBuf,
d: Arc<loki_rs::dictate::Dictation>,
) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let opts = Opts {
dictate: Some(d),
..Opts::default()
};
let (f, st, tx, _) = start_with(home, opts).await;
(f, st, tx)
}
/// Ce que la façade tient en plus du proxy.
#[derive(Default)]
pub struct Opts {
pub chat: bool,
pub engine_port: Option<u16>,
pub models_hf: Option<String>,
pub dictate: Option<Arc<loki_rs::dictate::Dictation>>,
}
/// Comme [`start`], avec la conversation tenue par la façade
/// (`LOKI_CHAT=rust`) ; `engine_port` : port du faux moteur (config PORT).
pub async fn start_chat(
@@ -452,15 +478,21 @@ pub async fn start_chat(
oneshot::Sender<()>,
Arc<loki_rs::chat::generate::ChatService>,
) {
let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), None).await;
let (f, st, tx, chat) = start_with(
home,
Opts {
chat: true,
engine_port: Some(engine_port),
..Opts::default()
},
)
.await;
(f, st, tx, chat.unwrap())
}
async fn start_opts(
pub async fn start_with(
home: std::path::PathBuf,
with_chat: bool,
engine_port: Option<u16>,
models_hf: Option<String>,
opts: Opts,
) -> (
SocketAddr,
Arc<FakeGo>,
@@ -468,7 +500,7 @@ async fn start_opts(
Option<Arc<loki_rs::chat::generate::ChatService>>,
) {
let st = Arc::new(FakeGo::default());
if let Some(p) = engine_port {
if let Some(p) = opts.engine_port {
st.set("config", "PORT", &p.to_string());
}
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -489,7 +521,7 @@ async fn start_opts(
});
}
});
let chat = if with_chat {
let chat = if opts.chat {
use loki_rs::chat::{
conv::Conversation, generate::ChatService, host::ChatShared, sessions,
};
@@ -517,12 +549,13 @@ async fn start_opts(
service: "loki-engine".into(),
},
chat: chat.clone(),
models: models_hf.map(|hf| {
models: opts.models_hf.map(|hf| {
Arc::new(loki_rs::models::Models {
downloads: Arc::new(loki_rs::downloads::Downloads::new()),
hf: loki_rs::hf::HfClient::new(&hf),
})
}),
dictate: opts.dictate,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+705
View File
@@ -0,0 +1,705 @@
//! Lot 9 : dictée vocale tenue par la façade (`LOKI_DICTATE=rust`).
//!
//! - le journal tiré du Go (`internal/loki/parity_dictate_test.go` →
//! `tests/parity_dictate/dictate.json`) : lecture des WAV, chemins
//! d'archive, réglages, catalogue, arguments du serveur, routes ;
//! - le protocole WebSocket de sherpa-onnx contre un faux serveur ;
//! - l'extraction d'une archive et le téléchargement d'un modèle ;
//! - la supervision d'un faux sherpa-onnx (script Python) par la façade.
mod common;
use std::convert::Infallible;
use std::io::Write;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bytes::Bytes;
use common::fakego;
use futures_util::{SinkExt, StreamExt};
use http_body_util::Full;
use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode, service::service_fn};
use hyper_util::rt::TokioIo;
use loki_rs::dictate::{self, DictateCfg, Dictation};
use serde_json::Value;
use tokio_tungstenite::tungstenite::Message;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_dictate/dictate.json")).expect("journal")
}
fn b64(s: &str) -> Vec<u8> {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::new();
let mut acc = 0u32;
let mut n = 0;
for c in s.bytes().filter(|c| *c != b'=') {
acc = acc << 6 | T.iter().position(|t| *t == c).unwrap() as u32;
n += 6;
if n >= 8 {
n -= 8;
out.push((acc >> n) as u8);
acc &= (1 << n) - 1;
}
}
out
}
/// v2 installé (quatre fichiers non vides), v3 à moitié — comme le journal.
fn install_models(home: &Path) {
for f in dictate::ASR_FICHIERS {
let p = dictate::model_dir_for(home, "parakeet-tdt-0.6b-v2")
.unwrap()
.join(f);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, b"x").unwrap();
}
let v3 = dictate::model_dir_for(home, "parakeet-tdt-0.6b-v3").unwrap();
std::fs::create_dir_all(&v3).unwrap();
std::fs::write(v3.join("encoder.int8.onnx"), b"x").unwrap();
std::fs::write(v3.join("tokens.txt"), b"").unwrap();
}
#[test]
fn parite_des_fonctions_pures() {
let fx = fixture();
for w in fx["wav"].as_array().unwrap() {
let input = b64(w["in"].as_str().unwrap_or(""));
let name = w["name"].as_str().unwrap();
match dictate::wav_to_f32(&input) {
Err(e) => assert_eq!(e, w["err"].as_str().unwrap_or("<ok attendu>"), "{name}"),
Ok((rate, ech)) => {
assert!(w.get("err").is_none(), "{name} : erreur attendue");
assert_eq!(rate as u64, w["rate"].as_u64().unwrap(), "{name}");
let want: Vec<f32> = w
.get("ech")
.and_then(Value::as_array)
.map(|a| a.iter().map(|x| x.as_f64().unwrap() as f32).collect())
.unwrap_or_default();
assert_eq!(ech, want, "{name}");
}
}
}
for c in fx["safe_rel"].as_array().unwrap() {
let input = c["in"].as_str().unwrap();
match dictate::safe_rel(input) {
Ok(o) => assert_eq!(o, c["out"].as_str().unwrap_or(""), "{input:?}"),
Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or("<ok attendu>"), "{input:?}"),
}
}
for (s, want) in fx["marker"].as_object().unwrap() {
assert_eq!(dictate::marqueur(s), want.as_bool().unwrap(), "{s:?}");
}
for c in fx["defaults"].as_array().unwrap() {
let d = DictateCfg {
model: c["model"].as_str().unwrap().into(),
reactivity: c["reactivity"].as_str().unwrap().into(),
}
.with_defauts();
assert_eq!(d.model, c["out_model"].as_str().unwrap());
assert_eq!(d.reactivity, c["out_reactivity"].as_str().unwrap());
}
for (s, want) in fx["last_line"].as_object().unwrap() {
assert_eq!(dictate::last_line(s), want.as_str().unwrap(), "{s:?}");
}
let home = PathBuf::from("/maison");
let cfg = DictateCfg {
model: "parakeet-tdt-0.6b-v3".into(),
..Default::default()
};
let args: Vec<String> = dictate::server_args(&home, &cfg, 4567)
.unwrap()
.iter()
.map(|a| a.replace("/maison", "{{HOME}}"))
.collect();
let want: Vec<String> = serde_json::from_value(fx["args"].clone()).unwrap();
assert_eq!(args, want);
let bad = DictateCfg {
model: "x".into(),
..Default::default()
};
assert_eq!(
dictate::server_args(&home, &bad, 1).unwrap_err(),
fx["args_err"].as_str().unwrap()
);
for (id, url) in fx["urls"].as_object().unwrap() {
assert_eq!(
dictate::model_url_for(dictate::ASR_MODEL_BASE, id),
url.as_str().unwrap(),
"{id}"
);
}
let tmp = tempfile::tempdir().unwrap();
install_models(tmp.path());
for (id, want) in fx["present"].as_object().unwrap() {
assert_eq!(
dictate::model_present(tmp.path(), id),
want.as_bool().unwrap(),
"{id}"
);
}
}
#[tokio::test]
async fn parite_des_routes() {
let fx = fixture();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
install_models(&home);
let d = Arc::new(Dictation::with(
&home,
"http://127.0.0.1:1/",
Some("/nexiste/pas".into()),
Duration::from_secs(60),
Duration::from_secs(5),
));
let (facade, _go, _stop) = fakego::start_dictate(home.clone(), d).await;
let h = home.to_string_lossy().into_owned();
for (i, c) in fx["calls"].as_array().unwrap().iter().enumerate() {
let (m, p) = (c["method"].as_str().unwrap(), c["path"].as_str().unwrap());
let body = c.get("body").and_then(Value::as_str).unwrap_or("");
let (code, b) = fakego::send(facade, m, p, &[], body).await;
let got: Value =
serde_json::from_str(&String::from_utf8_lossy(&b).replace(&h, "{{HOME}}")).unwrap();
assert_eq!(
code.as_u16() as u64,
c["status"].as_u64().unwrap(),
"appel {i} : {m} {p} {body}"
);
assert_eq!(got, c["out"], "appel {i} : {m} {p} {body}");
}
}
// ---------------------------------------------------------------------------
// Protocole
// ---------------------------------------------------------------------------
fn wav16(n: usize) -> Vec<u8> {
let data: Vec<u8> = (0..n)
.flat_map(|i| ((i as i16).wrapping_mul(7)).to_le_bytes())
.collect();
let mut b = b"RIFF\0\0\0\0WAVEfmt ".to_vec();
b.extend_from_slice(&16u32.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&16000u32.to_le_bytes());
b.extend_from_slice(&32000u32.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&16u16.to_le_bytes());
b.extend_from_slice(b"data");
b.extend_from_slice(&(data.len() as u32).to_le_bytes());
b.extend_from_slice(&data);
let len = (b.len() - 8) as u32;
b[4..8].copy_from_slice(&len.to_le_bytes());
b
}
/// Faux sherpa-onnx : relit l'en-tête, attend le compte annoncé, répond.
async fn fake_ws(reply: &'static str) -> (String, Arc<Mutex<Vec<usize>>>) {
let frames = Arc::new(Mutex::new(Vec::new()));
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let f = frames.clone();
tokio::spawn(async move {
while let Ok((s, _)) = l.accept().await {
let f = f.clone();
tokio::spawn(async move {
let Ok(mut ws) = tokio_tungstenite::accept_async(s).await else {
return;
};
let mut buf = Vec::new();
while let Some(Ok(m)) = ws.next().await {
if let Message::Binary(b) = m {
f.lock().unwrap().push(b.len());
buf.extend_from_slice(&b);
if buf.len() >= 8 {
let n = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize;
if buf.len() - 8 >= n {
let _ = ws.send(Message::Text(reply.into())).await;
return;
}
}
}
}
});
}
});
(format!("ws://{addr}"), frames)
}
#[tokio::test]
async fn protocole_websocket() {
let (adr, frames) = fake_ws(r#"{"text":" bonjour ceci est un test "}"#).await;
// 6000 échantillons : 8 + 24000 octets → trois trames de 10 Kio au plus.
let txt = dictate::infer_at(&adr, &wav16(6000)).await.unwrap();
assert_eq!(txt, "bonjour ceci est un test");
assert_eq!(*frames.lock().unwrap(), [10240, 10240, 3528]);
let (adr, _) = fake_ws(r#"{"text":" [musique] "}"#).await;
assert_eq!(dictate::infer_at(&adr, &wav16(10)).await.unwrap(), "");
let (adr, _) = fake_ws("pas du json").await;
let e = dictate::infer_at(&adr, &wav16(10)).await.unwrap_err();
assert!(
e.starts_with("réponse illisible du serveur de dictée : "),
"{e}"
);
// Audio vide : rien à transcrire, aucune connexion.
assert_eq!(
dictate::infer_at("ws://127.0.0.1:1", &wav16(0))
.await
.unwrap(),
""
);
let e = dictate::infer_at("ws://127.0.0.1:1", &wav16(10))
.await
.unwrap_err();
assert!(e.starts_with("connexion au serveur de dictée : "), "{e}");
let e = dictate::infer_at("ws://127.0.0.1:1", b"pas un wav")
.await
.unwrap_err();
assert_eq!(e, "audio illisible : ce n'est pas un WAV");
}
// ---------------------------------------------------------------------------
// Archives et téléchargement
// ---------------------------------------------------------------------------
/// Une archive `.tar.bz2` de modèle : dossier racine, quatre fichiers, un
/// sous-dossier et un lien (ignoré).
fn archive(root: &str, evil: bool) -> Vec<u8> {
let enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::fast());
let mut tb = tar::Builder::new(enc);
let mut dir = tar::Header::new_gnu();
dir.set_entry_type(tar::EntryType::Directory);
dir.set_mode(0o755);
dir.set_size(0);
tb.append_data(&mut dir, format!("{root}/"), std::io::empty())
.unwrap();
for (i, f) in dictate::ASR_FICHIERS
.iter()
.chain(["sous/notes.txt"].iter())
.enumerate()
{
let body = format!("contenu {i}");
let mut h = tar::Header::new_gnu();
h.set_mode(0o644);
h.set_size(body.len() as u64);
tb.append_data(&mut h, format!("{root}/{f}"), body.as_bytes())
.unwrap();
}
let mut l = tar::Header::new_gnu();
l.set_entry_type(tar::EntryType::Symlink);
l.set_size(0);
tb.append_link(&mut l, format!("{root}/lien"), "/etc/passwd")
.unwrap();
if evil {
let mut h = tar::Header::new_old();
let name = format!("{root}/../evade.txt");
h.as_old_mut().name[..name.len()].copy_from_slice(name.as_bytes());
h.set_mode(0o644);
h.set_size(1);
h.set_cksum();
tb.append(&h, &b"x"[..]).unwrap();
}
let enc = tb.into_inner().unwrap();
enc.finish().unwrap()
}
#[test]
fn extraction_d_une_archive() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("asr/modele");
std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
dictate::extract(&archive("racine", false)[..], &dest).unwrap();
assert_eq!(
std::fs::read_to_string(dest.join("tokens.txt")).unwrap(),
"contenu 3"
);
assert_eq!(
std::fs::read_to_string(dest.join("sous/notes.txt")).unwrap(),
"contenu 4"
);
assert!(!dest.join("lien").exists(), "lien suivi");
assert!(!tmp.path().join("asr/modele.part").exists());
// Entrée qui remonte : refus, rien de partiel ni d'évadé, l'ancien gardé.
let e = dictate::extract(&archive("racine", true)[..], &dest).unwrap_err();
assert_eq!(
e,
r#"entrée d'archive qui remonte hors du dossier : "racine/../evade.txt""#
);
assert!(!tmp.path().join("asr/modele.part").exists());
assert!(!tmp.path().join("asr/evade.txt").exists());
assert!(dest.join("tokens.txt").exists());
// Archive corrompue.
assert!(dictate::extract(&b"pas du bzip2"[..], &dest).is_err());
assert!(!tmp.path().join("asr/modele.part").exists());
}
async fn file_server(files: Vec<(String, Vec<u8>)>) -> SocketAddr {
let files = Arc::new(files);
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((s, _)) = l.accept().await {
let files = files.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<Incoming>| {
let files = files.clone();
async move {
let p = req.uri().path().to_string();
let r = match files.iter().find(|(n, _)| *n == p) {
Some((_, b)) => Response::new(Full::new(Bytes::from(b.clone()))),
None => {
let mut r = Response::new(Full::new(Bytes::from_static(b"absent")));
*r.status_mut() = StatusCode::NOT_FOUND;
r
}
};
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(s), svc)
.await;
});
}
});
addr
}
async fn wait_dl(d: &Dictation) -> dictate::DlState {
for _ in 0..500 {
let st = d.dl_state();
if !st.running {
return st;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("téléchargement jamais terminé");
}
#[tokio::test]
async fn telechargement_d_un_modele() {
let dossier = "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8";
let addr = file_server(vec![(
format!("/m/{dossier}.tar.bz2"),
archive(dossier, false),
)])
.await;
let tmp = tempfile::tempdir().unwrap();
let d = Arc::new(Dictation::with(
tmp.path(),
&format!("http://{addr}/m/"),
None,
Duration::from_secs(60),
Duration::from_secs(5),
));
d.start_download("parakeet-tdt-0.6b-v2");
// Un seul à la fois : le second est ignoré.
d.start_download("parakeet-tdt-0.6b-v3");
let st = wait_dl(&d).await;
assert_eq!(
(st.id.as_str(), st.pct, st.err.as_str()),
("parakeet-tdt-0.6b-v2", 100, "")
);
assert!(dictate::model_present(tmp.path(), "parakeet-tdt-0.6b-v2"));
d.start_download("parakeet-tdt-0.6b-v3");
let st = wait_dl(&d).await;
assert_eq!(st.err, "téléchargement du modèle de dictée : HTTP 404");
assert!(!dictate::model_present(tmp.path(), "parakeet-tdt-0.6b-v3"));
d.start_download("inconnu");
assert_eq!(d.dl_state().err, "modèle de dictée inconnu : inconnu");
}
// ---------------------------------------------------------------------------
// Supervision d'un faux sherpa-onnx
// ---------------------------------------------------------------------------
/// Faux `sherpa-onnx-offline-websocket-server` : poignée de main WebSocket,
/// lit l'utterance, répond ce qu'il a vu.
const FAKE_SHERPA: &str = r#"#!/usr/bin/env python3
import base64, hashlib, json, os, socket, struct, sys
port = int([a for a in sys.argv if a.startswith('--port=')][0][7:])
print("faux sherpa prêt", flush=True)
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', port)); s.listen(8)
def lire(c, n):
b = b''
while len(b) < n:
x = c.recv(n - len(b))
if not x: raise EOFError
b += x
return b
while True:
c, _ = s.accept()
try:
req = b''
while b'\r\n\r\n' not in req:
x = c.recv(4096)
if not x: raise EOFError
req += x
key = [l.split(b':', 1)[1].strip() for l in req.split(b'\r\n') if l.lower().startswith(b'sec-websocket-key')][0]
acc = base64.b64encode(hashlib.sha1(key + b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest())
c.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + acc + b'\r\n\r\n')
buf = b''; trames = 0
while True:
h = lire(c, 2); op = h[0] & 15; n = h[1] & 127
if n == 126: n = struct.unpack('>H', lire(c, 2))[0]
elif n == 127: n = struct.unpack('>Q', lire(c, 8))[0]
m = lire(c, 4); d = bytes(b ^ m[i % 4] for i, b in enumerate(lire(c, n)))
if op == 8: break
trames += 1; buf += d
if len(buf) >= 8 and len(buf) - 8 >= struct.unpack('<I', buf[4:8])[0]:
taux = struct.unpack('<I', buf[0:4])[0]; ech = struct.unpack('<I', buf[4:8])[0] // 4
cuda = os.environ.get('CUDA_VISIBLE_DEVICES', 'absent')
jeton = 'LOKI_INTERNAL_TOKEN' in os.environ
t = json.dumps({"text": " vu %d échantillons à %d Hz en %d trames, cuda=[%s], jeton=%s " % (ech, taux, trames, cuda, jeton)}).encode()
c.sendall(bytes([0x81]) + (bytes([len(t)]) if len(t) < 126 else bytes([126]) + struct.pack('>H', len(t))) + t)
break
except Exception:
pass
finally:
c.close()
"#;
fn script(dir: &Path, name: &str, body: &str) -> String {
let p = dir.join(name);
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(body.as_bytes()).unwrap();
drop(f);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
p.to_string_lossy().into_owned()
}
fn python3() -> bool {
std::process::Command::new("python3")
.arg("-c")
.arg("pass")
.status()
.is_ok_and(|s| s.success())
}
fn install_v3(home: &Path) {
for f in dictate::ASR_FICHIERS {
let p = dictate::model_dir_for(home, "parakeet-tdt-0.6b-v3")
.unwrap()
.join(f);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, b"x").unwrap();
}
}
async fn transcribe(facade: SocketAddr, wav: &[u8]) -> (StatusCode, Value) {
use http_body_util::BodyExt;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
let r = client
.request(
Request::post(format!("http://{facade}/api/transcribe"))
.body(Full::new(Bytes::copy_from_slice(wav)))
.unwrap(),
)
.await
.unwrap();
let code = r.status();
let b = r.into_body().collect().await.unwrap().to_bytes();
(code, serde_json::from_slice(&b).unwrap())
}
#[tokio::test]
async fn supervision_du_serveur_de_dictee() {
if !python3() {
eprintln!("python3 absent : test sauté");
return;
}
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
install_v3(&home);
let bin = script(&home, "faux-sherpa", FAKE_SHERPA);
let d = Arc::new(Dictation::with(
&home,
"http://127.0.0.1:1/",
Some(bin),
Duration::from_secs(60),
Duration::from_secs(20),
));
let (facade, go, _stop) = fakego::start_dictate(home.clone(), d.clone()).await;
// 3000 échantillons : 8 + 12000 octets → deux trames. GPU masqués, jeton
// interne absent de l'environnement du serveur.
let (code, v) = transcribe(facade, &wav16(3000)).await;
assert_eq!(code, StatusCode::OK, "{v}");
assert_eq!(
v["text"],
"vu 3000 échantillons à 16000 Hz en 2 trames, cuda=[], jeton=False"
);
let (_, _, b) = fakego::get(facade, "/api/dictate/state", &[]).await;
let st: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(
(st["actif"].as_bool(), st["present"].as_bool()),
(Some(true), Some(true))
);
assert!(st["depuis_s"].is_u64());
// Même réglage : même serveur (pas de nouveau démarrage).
let port = d
.ensure(DictateCfg::default().with_defauts())
.await
.unwrap();
assert_eq!(
d.ensure(DictateCfg::default().with_defauts())
.await
.unwrap(),
port
);
// Le Go demande l'arrêt (libération de la VRAM).
let (code, _) = fakego::send(
facade,
"POST",
dictate::INTERNAL_STOP,
&[("x-loki-internal", fakego::JETON)],
"{}",
)
.await;
assert_eq!(code, StatusCode::NO_CONTENT);
let (_, _, b) = fakego::get(facade, "/api/dictate/state", &[]).await;
assert_eq!(serde_json::from_slice::<Value>(&b).unwrap()["actif"], false);
// Sans le jeton : la route interne n'est pas joignable.
let (code, _) = fakego::send(facade, "POST", dictate::INTERNAL_STOP, &[], "{}").await;
assert_eq!(code, StatusCode::NOT_FOUND);
// Changer de réglage éteint le serveur.
let (code, _) = transcribe(facade, &wav16(1000)).await;
assert_eq!(code, StatusCode::OK);
let (code, _) = fakego::send(
facade,
"POST",
"/api/dictate/config",
&[],
r#"{"model":"parakeet-tdt-0.6b-v3","reactivity":"court"}"#,
)
.await;
assert_eq!(code, StatusCode::OK);
assert_eq!(
go.get("state", "dictate_cfg").as_deref(),
Some(r#"{"model":"parakeet-tdt-0.6b-v3","reactivity":"court"}"#)
);
let (_, _, b) = fakego::get(facade, "/api/dictate/state", &[]).await;
assert_eq!(serde_json::from_slice::<Value>(&b).unwrap()["actif"], false);
}
#[tokio::test]
async fn demarrages_rates() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
install_v3(&home);
let cfg = DictateCfg::default().with_defauts();
// Mort au démarrage : la raison et la dernière ligne.
let mort = script(
&home,
"mort",
"#!/bin/sh\necho chargement\necho boom >&2\nexit 3\n",
);
let d = Arc::new(Dictation::with(
&home,
"x",
Some(mort),
Duration::from_secs(60),
Duration::from_secs(10),
));
let e = d.ensure(cfg.clone()).await.unwrap_err();
assert_eq!(
e,
"le serveur de dictée s'est arrêté au démarrage : exit status 3 — dernière sortie : boom"
);
// Muet : le délai, à la Go.
let muet = script(&home, "muet", "#!/bin/sh\nsleep 30\n");
let d = Arc::new(Dictation::with(
&home,
"x",
Some(muet),
Duration::from_secs(60),
Duration::from_secs(1),
));
let e = d.ensure(cfg.clone()).await.unwrap_err();
assert_eq!(e, "le serveur de dictée n'a pas répondu en 1s");
// Un arrêt « vite » pendant un démarrage tue le processus qui démarre.
let d2 = d.clone();
let lent = Arc::new(Dictation::with(
&home,
"x",
Some(script(&home, "lent", "#!/bin/sh\nsleep 30\n")),
Duration::from_secs(60),
Duration::from_secs(60),
));
let l2 = lent.clone();
let c2 = cfg.clone();
let h = tokio::spawn(async move { l2.ensure(c2).await });
tokio::time::sleep(Duration::from_millis(300)).await;
let t0 = std::time::Instant::now();
lent.shutdown_fast().await;
let e = tokio::time::timeout(Duration::from_secs(5), h)
.await
.unwrap()
.unwrap()
.unwrap_err();
assert!(
e.starts_with("le serveur de dictée s'est arrêté au démarrage : signal: killed"),
"{e}"
);
assert!(t0.elapsed() < Duration::from_secs(4));
drop(d2);
// Binaire introuvable, modèle absent.
let d = Arc::new(Dictation::with(
&home,
"x",
Some(String::new()),
Duration::from_secs(60),
Duration::from_secs(1),
));
assert_eq!(
d.ensure(cfg).await.unwrap_err(),
"serveur de dictée introuvable (image à reconstruire)"
);
let v2 = DictateCfg {
model: "parakeet-tdt-0.6b-v2".into(),
..Default::default()
};
assert_eq!(
d.ensure(v2).await.unwrap_err(),
"modèle de dictée absent : parakeet-tdt-0.6b-v2 (à télécharger dans Paramètres → Dictée)"
);
}
#[tokio::test]
async fn sans_la_variable_la_dictee_reste_au_go() {
let tmp = tempfile::tempdir().unwrap();
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
for p in [
"/api/transcribe",
"/api/dictate/config",
"/api/dictate/state",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert_eq!(&b[..], format!("go:{p}").as_bytes());
}
let (code, _) = fakego::send(
facade,
"POST",
dictate::INTERNAL_STOP,
&[("x-loki-internal", fakego::JETON)],
"{}",
)
.await;
assert_eq!(code, StatusCode::NOT_FOUND);
}
+1
View File
@@ -95,6 +95,7 @@ async fn banc(cle: Option<&str>) -> Banc {
},
chat: None,
models: None,
dictate: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
@@ -0,0 +1,337 @@
{
"wav": [
{
"name": "mono 16 bits",
"in": "UklGRi4AAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQoAAAAAAABAAMD/fwCA",
"rate": 16000,
"ech": [
0,
0.5,
-0.5,
0.9999695,
-1
]
},
{
"name": "bloc LIST intercalé",
"in": "UklGRjgAAABXQVZFTElTVAcAAABJTkZPeHl6AGZtdCAQAAAAAQABAIA+AAAAfQAAAgAQAGRhdGEEAAAAZACc/w==",
"rate": 16000,
"ech": [
0.0030517578,
-0.0030517578
]
},
{
"name": "stéréo moyenné",
"in": "UklGRiwAAABXQVZFZm10IBAAAAABAAIAgLsAAADuAgAEABAAZGF0YQgAAAAAAP9/AID/fw==",
"rate": 48000,
"ech": [
0.49998474,
-0.000015258789
]
},
{
"name": "flottant 32 bits",
"in": "UklGRjAAAABXQVZFZm10IBAAAAADAAEAgD4AAAD6AAAEACAAZGF0YQwAAAAAAIA+AACAvwAAAD8=",
"rate": 16000,
"ech": [
0.25,
-1,
0.5
]
},
{
"name": "data tronqué",
"in": "UklGRioAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQYAAAABAAIA",
"rate": 16000,
"ech": [
0.000030517578,
0.000061035156
]
},
{
"name": "data impair",
"in": "UklGRigAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQMAAAABAgMA",
"rate": 8000,
"ech": [
0.015655518
]
},
{
"name": "data vide",
"in": "UklGRiQAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQAAAAA=",
"rate": 16000
},
{
"name": "24 bits",
"in": "UklGRioAAABXQVZFZm10IBAAAAABAAEAgD4AAIC7AAADABgAZGF0YQYAAAAAAAAAAAA=",
"err": "format audio non géré (format 1, 24 bits) — attendu PCM 16 bits"
},
{
"name": "data avant fmt",
"in": "UklGRiYAAABXQVZFZGF0YQIAAAABAGZtdCAQAAAAAQABAIA+AAAAfQAAAgAQAA==",
"err": "audio illisible : bloc data avant fmt"
},
{
"name": "fmt tronqué",
"in": "UklGRhQAAABXQVZFZm10IAgAAAAAAAAAAAAAAA==",
"err": "audio illisible : bloc fmt tronqué"
},
{
"name": "sans data",
"in": "UklGRhwAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAA",
"err": "audio illisible : aucun bloc data"
},
{
"name": "pas un WAV",
"in": "Y2VjaSBuJ2VzdCBwYXMgdW4gZmljaGllciBhdWRpbyBkdSB0b3V0",
"err": "audio illisible : ce n'est pas un WAV"
},
{
"name": "vide",
"in": "",
"err": "audio illisible : ce n'est pas un WAV"
}
],
"safe_rel": [
{
"in": "",
"err": "entrée d'archive au chemin absolu : \"\""
},
{
"in": "racine"
},
{
"in": "racine/"
},
{
"in": "racine/encoder.onnx",
"out": "encoder.onnx"
},
{
"in": "racine/sous/tokens.txt",
"out": "sous/tokens.txt"
},
{
"in": "/abs/x",
"err": "entrée d'archive au chemin absolu : \"/abs/x\""
},
{
"in": "racine/../x",
"err": "entrée d'archive qui remonte hors du dossier : \"racine/../x\""
},
{
"in": "racine\\a\\b.txt",
"out": "a/b.txt"
},
{
"in": " racine/a ",
"out": "a"
},
{
"in": "..",
"err": "entrée d'archive qui remonte hors du dossier : \"..\""
},
{
"in": "a/b/../c",
"err": "entrée d'archive qui remonte hors du dossier : \"a/b/../c\""
}
],
"marker": {
"": true,
" ": true,
" (rires) ": true,
"(silence)": true,
"(x": false,
"[a] b": false,
"[musique]": true,
"bonjour": false
},
"defaults": [
{
"model": "",
"out_model": "parakeet-tdt-0.6b-v3",
"out_reactivity": "moyen",
"reactivity": ""
},
{
"model": "ggml-base.bin",
"out_model": "parakeet-tdt-0.6b-v3",
"out_reactivity": "moyen",
"reactivity": ""
},
{
"model": "parakeet-tdt-0.6b-v2",
"out_model": "parakeet-tdt-0.6b-v2",
"out_reactivity": "court",
"reactivity": "court"
}
],
"last_line": {
"": "",
" \n x \n": "x",
"a\nb\n\n ": "b",
"un seul": "un seul"
},
"args": [
"--encoder={{HOME}}/asr/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/encoder.int8.onnx",
"--decoder={{HOME}}/asr/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/decoder.int8.onnx",
"--joiner={{HOME}}/asr/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/joiner.int8.onnx",
"--tokens={{HOME}}/asr/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/tokens.txt",
"--model-type=nemo_transducer",
"--port=4567",
"--host=127.0.0.1"
],
"args_err": "modèle de dictée inconnu : x",
"urls": {
"inconnu": "",
"parakeet-tdt-0.6b-v2": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2",
"parakeet-tdt-0.6b-v3": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2"
},
"calls": [
{
"method": "GET",
"path": "/api/dictate/config",
"status": 200,
"out": {
"model": "parakeet-tdt-0.6b-v3",
"reactivity": "moyen"
}
},
{
"method": "POST",
"path": "/api/dictate/config",
"body": "pas du json",
"status": 400,
"out": {
"error": "requête illisible"
}
},
{
"method": "POST",
"path": "/api/dictate/config",
"body": "{\"model\":\"ggml-base.bin\"}",
"status": 400,
"out": {
"error": "modèle de dictée inconnu : ggml-base.bin"
}
},
{
"method": "POST",
"path": "/api/dictate/config",
"body": "{\"model\":\"parakeet-tdt-0.6b-v2\"}",
"status": 200,
"out": {
"ok": true
}
},
{
"method": "GET",
"path": "/api/dictate/config",
"status": 200,
"out": {
"model": "parakeet-tdt-0.6b-v2",
"reactivity": "moyen"
}
},
{
"method": "POST",
"path": "/api/dictate/config",
"body": "{\"model\":\"parakeet-tdt-0.6b-v3\",\"reactivity\":\"long\",\"autre\":1}",
"status": 200,
"out": {
"ok": true
}
},
{
"method": "GET",
"path": "/api/dictate/config",
"status": 200,
"out": {
"model": "parakeet-tdt-0.6b-v3",
"reactivity": "long"
}
},
{
"method": "GET",
"path": "/api/dictate/models",
"status": 200,
"out": [
{
"id": "parakeet-tdt-0.6b-v2",
"langues": "anglais uniquement",
"nom": "Parakeet v2 — anglais seul",
"octets": 482468385,
"present": true
},
{
"id": "parakeet-tdt-0.6b-v3",
"langues": "25 langues européennes, français inclus — détection automatique",
"nom": "Parakeet v3 — recommandé",
"octets": 487170055,
"present": false
}
]
},
{
"method": "GET",
"path": "/api/dictate/models/download",
"status": 200,
"out": {
"error": "",
"id": "",
"pct": 0,
"running": false
}
},
{
"method": "POST",
"path": "/api/dictate/models/download",
"body": "{\"id\":\" inconnu \"}",
"status": 400,
"out": {
"error": "modèle de dictée inconnu : inconnu"
}
},
{
"method": "POST",
"path": "/api/dictate/models/download",
"body": "x",
"status": 400,
"out": {
"error": "requête illisible"
}
},
{
"method": "GET",
"path": "/api/dictate/state",
"status": 200,
"out": {
"actif": false,
"device": "cpu",
"dl": {
"error": "",
"id": "",
"pct": 0,
"running": false
},
"modele": "parakeet-tdt-0.6b-v3",
"present": false
}
},
{
"method": "POST",
"path": "/api/transcribe",
"body": "trop court",
"status": 400,
"out": {
"error": "audio vide ou trop court"
}
}
],
"present": {
"inconnu": false,
"parakeet-tdt-0.6b-v2": true,
"parakeet-tdt-0.6b-v3": false
}
}
+1
View File
@@ -101,6 +101,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
},
chat: None,
models: None,
dictate: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;