mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 20:26:35 +02:00
tests/engine.rs (redemarre) a échoué en CI sur « Text file busy » : un test écrivait son faux binaire pendant qu'un test voisin, dans un autre thread du même process, lançait un enfant — qui héritait du descripteur d'écriture le temps de son exec, et l'exec du script échouait. Les faux binaires (moteur, serveur de dictée) sont désormais écrits par un sh à part : le process des tests ne tient jamais le fichier ouvert en écriture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
708 lines
25 KiB
Rust
708 lines
25 KiB
Rust
//! 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::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()
|
|
"#;
|
|
|
|
/// Écrit par un `sh` à part : si ce process tenait lui-même le fichier ouvert
|
|
/// en écriture, un `fork` lancé au même moment par un test voisin en
|
|
/// hériterait, et l'`exec` du script échouerait sur ETXTBSY.
|
|
fn script(dir: &Path, name: &str, body: &str) -> String {
|
|
let p = dir.join(name);
|
|
let ok = std::process::Command::new("sh")
|
|
.args(["-c", r#"printf '%s' "$1" > "$2" && chmod 755 "$2""#, "sh"])
|
|
.arg(body)
|
|
.arg(&p)
|
|
.status()
|
|
.unwrap()
|
|
.success();
|
|
assert!(ok, "script non écrit");
|
|
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);
|
|
}
|