@mostajs/prononciation 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
3
3
  **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
4
  Format : Keep a Changelog ; versions : SemVer.
5
5
 
6
+ ## [0.2.1] — 2026-10-26
7
+
8
+ ### Corrigé
9
+ - `service/installer.sh` accepte **`PRONONCIATION_VENV`**. L'environnement Python peut vivre hors de `node_modules/`, que `npm ci` efface à chaque déploiement.
10
+ - `lexiqueCmudict` ignore le **commentaire de fin de ligne**. Le CMUdict de référence (cmusphinx/cmudict, `cmudict.dict`) en porte sur 22 lignes (« aalborg AO1 L B AO0 R G # place, danish »). Les mots du commentaire devenaient des sons. Essai T-PRO-24.
11
+
12
+ ## [0.2.0] — 2026-10-25
13
+
14
+ ### Ajouté
15
+ - **Le service de reconnaissance** (`service/`, Python ≥ 3.10), livré avec le module :
16
+ - `POST /reconnaitre` sert le contrat de `reconnaisseurHttp` ; `GET /sante` le décrit ;
17
+ - wav2vec2 phonémique à révision figée, et dépendances figées (`requirements.txt`) ;
18
+ - alignement forcé CTC et 8 traits par son attendu, repris d'`extraire_gop.py`.
19
+ - **Refus nommés** : ATTENDUS, AUDIO_ABSENT, LONGUEUR, TROP_GROS, TROP_LONG, FORMAT, CHEMIN, METHODE, INTERNE.
20
+ - **Fonctionnement** : écoute locale par défaut, une inférence à la fois. L'audio n'est jamais écrit, et le journal ne contient que des comptes et des durées.
21
+ - **`service/installer.sh`** : rejouable, il vérifie que le modèle se charge sans réseau.
22
+ - **Essais** :
23
+ - T-SRV-1 à 9 (unittest). Parité avec l'apprentissage sur cinq phrases : écart de traits **nul**.
24
+ - T-PRO-23 : le service réel derrière `reconnaisseurHttp` rend la note de Python.
25
+
26
+ ### Corrigé
27
+ - Quand l'audio a moins de trames que de sons attendus, le partage égal débordait de l'audio, et un trait se calculait sur un segment vide. Les segments sont désormais ramenés sur la dernière trame. Les cas du corpus ne changent pas : la parité le vérifie.
28
+
29
+ ### Points ouverts
30
+ - Le WebM/Opus enregistré par les navigateurs n'est pas lu. Il faut l'enregistreur WAV de `@mostajs/media` (B-03).
31
+ - Le débit reste à mesurer sur le serveur de production (amia).
32
+
6
33
  ## [0.1.0] — 2026-10-25
7
34
 
8
35
  ### Ajouté
package/README.md CHANGED
@@ -11,8 +11,11 @@ Ce module dit si une voix a prononcé un texte connu, et où elle s'en écarte,
11
11
 
12
12
  ```bash
13
13
  npm install @mostajs/prononciation
14
+ bash node_modules/@mostajs/prononciation/service/installer.sh # le service de reconnaissance (0.2.0), voir service/README.md
14
15
  ```
15
16
 
17
+ Le service de reconnaissance (Python, wav2vec2) est livré avec le module, dans `service/`. Il écoute en local, et `reconnaisseurHttp` en est le client.
18
+
16
19
  ## Exemple minimal
17
20
 
18
21
  ```js
@@ -23,7 +26,7 @@ import { readFileSync } from 'node:fs';
23
26
  const p = createPrononciation({
24
27
  lexique: combinerLexiques(creerLexique({ BOUMERDES: 'b u m ɛ ɹ d ɛ s' }),
25
28
  lexiqueCmudict(readFileSync('cmudict.dict', 'utf8'))),
26
- reconnaisseur: reconnaisseurHttp({ url: 'http://127.0.0.1:8765/reconnaitre' }),
29
+ reconnaisseur: reconnaisseurHttp({ url: 'http://127.0.0.1:8762/reconnaitre', type: 'audio/wav' }),
27
30
  note: NOTE_APPRISE_1,
28
31
  });
29
32
 
@@ -57,7 +60,7 @@ Les poids sont appris sur des voix d'apprenants chinois. Il faut les remesurer s
57
60
  ## Vérifier
58
61
 
59
62
  ```bash
60
- npm test # 22 essais (mjs-unit), parité JS ↔ Python comprise
63
+ npm test # 23 essais (mjs-unit) + 9 essais du service (Python), parités comprises
61
64
  node examples/verifier.mjs # trois vraies phrases, par le vrai adaptateur HTTP
62
65
  ```
63
66
 
package/llms.txt CHANGED
@@ -42,6 +42,17 @@ Port reconnaisseur { reconnaitre({ audio, attendus }) → Promise<{ entendus: st
42
42
  Contrat HTTP : POST <url>, corps = octets de l'audio ; en-tête x-attendus = encodeURIComponent(JSON des sons attendus) ;
43
43
  réponse 200 JSON { entendus, traits?, duree? }.
44
44
 
45
+ SERVICE DE RECONNAISSANCE (0.2.0, service/, Python ≥ 3.10)
46
+ Installer : bash service/installer.sh (venv, dépendances FIGÉES, wav2vec2 à la révision ae45363b ; PRONONCIATION_HF_HOME)
47
+ Lancer : HF_HOME=… service/.venv/bin/python service/serveur.py --local-seulement → écoute http://127.0.0.1:8762
48
+ options --hote 127.0.0.1, --port 8762 (0 = libre), --fils 2, --octets 2000000, --secondes 15
49
+ POST /reconnaitre → { entendus, traits, duree, regle: 'stricte-2', modele } ; GET /sante → { ok, modele, revision, regle, classes }
50
+ Refus { erreur, message } : ATTENDUS 400, AUDIO_ABSENT 400 (vide ou < 0,25 s), LONGUEUR 411, TROP_GROS 413,
51
+ TROP_LONG 413, FORMAT 415 (WAV/FLAC/OGG ; pas WebM), CHEMIN 404, METHODE 405, INTERNE 500 → côté JS : RECONNAISSEUR 502
52
+ Une inférence à la fois ; audio jamais écrit ; journal = comptes et durées seulement.
53
+ Parité : traits identiques à l'apprentissage (T-SRV-9, écart 0) ; note de Python de bout en bout (T-PRO-23).
54
+ Piège : la révision du modèle et les versions sont figées — en changer, c'est changer la note.
55
+
45
56
  NOTES ET SEUILS
46
57
  - Simple (aucune note configurée) : son = 2 si retrouvé, sinon 0 ; phrase = 10 × part retrouvée. Seuils : phrase 6, mot 1, son 1.
47
58
  - Apprise (note: NOTE_APPRISE_1) :
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mostajs/prononciation",
3
- "version": "0.1.0",
4
- "description": "Compare une voix à un texte connu : sons attendus par un lexique injecté, sons entendus par un reconnaisseur injecté, alignement, note par son, mot et phrase (note apprise ou simple), verdict, et les sons qui résistent.",
3
+ "version": "0.2.1",
4
+ "description": "Compare une voix à un texte connu : sons attendus par un lexique injecté, sons entendus par un reconnaisseur injecté, alignement, note par son, mot et phrase (note apprise ou simple), verdict, et les sons qui résistent. Avec son service de reconnaissance (Python, wav2vec2), local.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
7
  "type": "module",
@@ -12,6 +12,9 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
+ "service",
16
+ "!service/.venv",
17
+ "!service/**/__pycache__",
15
18
  "llms.txt",
16
19
  "README.md",
17
20
  "CHANGELOG.md",
@@ -33,7 +36,9 @@
33
36
  "phoneme",
34
37
  "speech",
35
38
  "english",
36
- "cmudict"
39
+ "cmudict",
40
+ "wav2vec2",
41
+ "speech-recognition"
37
42
  ],
38
43
  "dependencies": {},
39
44
  "devDependencies": {
@@ -59,7 +64,8 @@
59
64
  "interop": [
60
65
  "IPA (Alphabet phonétique international)",
61
66
  "ARPAbet / CMUdict",
62
- "speechocean762 (échelles de notes 0-2 par son, 0-10 par phrase)"
67
+ "speechocean762 (échelles de notes 0-2 par son, 0-10 par phrase)",
68
+ "HTTP : POST /reconnaitre (x-attendus), GET /sante — service/README.md"
63
69
  ],
64
70
  "ne_fait_pas": {
65
71
  "faire entendre une phrase": "@mostajs/voix",
@@ -0,0 +1,81 @@
1
+ # Service de reconnaissance de `@mostajs/prononciation`
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later · Python ≥ 3.10
4
+
5
+ Il reçoit l'audio d'un élève et les sons attendus. Il rend les sons entendus, 8 traits par son attendu et la durée, exactement comme pour l'apprentissage de `note-apprise-1`. `reconnaisseurHttp` en est le client.
6
+
7
+ ## Installer
8
+
9
+ ```bash
10
+ bash node_modules/@mostajs/prononciation/service/installer.sh
11
+ # ou, pour ranger le modèle (≈ 1,2 Go) ailleurs que dans ~/.cache/huggingface :
12
+ PRONONCIATION_HF_HOME=/srv/modeles bash node_modules/@mostajs/prononciation/service/installer.sh
13
+ ```
14
+
15
+ Installé depuis npm, le service vit dans `node_modules/`, que `npm ci` efface à chaque déploiement : posez `PRONONCIATION_VENV` (et `PRONONCIATION_HF_HOME`) **hors du projet**, pour n'installer qu'une fois :
16
+
17
+ ```bash
18
+ PRONONCIATION_VENV=/srv/prononciation-venv PRONONCIATION_HF_HOME=/srv/modeles bash node_modules/@mostajs/prononciation/service/installer.sh
19
+ ```
20
+
21
+ L'installeur fait trois choses :
22
+ - il crée `service/.venv` et y installe les dépendances **figées** (`requirements.txt` : torch 2.4.1 CPU, transformers 4.44.2…) ;
23
+ - il télécharge `facebook/wav2vec2-lv-60-espeak-cv-ft` à sa **révision figée** (`ae45363b`) ;
24
+ - il vérifie que le modèle se charge sans réseau.
25
+
26
+ Une autre version des dépendances ou du modèle donne d'autres traits, et la note apprise ne vaut plus ses 0,72.
27
+
28
+ ## Lancer
29
+
30
+ ```bash
31
+ HF_HOME=/srv/modeles service/.venv/bin/python service/serveur.py --local-seulement
32
+ # écoute http://127.0.0.1:8762
33
+ ```
34
+
35
+ | option | défaut | rôle |
36
+ |---|---|---|
37
+ | `--hote` | `127.0.0.1` (`PRONONCIATION_HOTE`) | écoute locale : seule l'application le joint |
38
+ | `--port` | `8762` (`PRONONCIATION_PORT`) | `0` : un port libre, annoncé sur la sortie |
39
+ | `--fils` | `2` (`PRONONCIATION_FILS`) | fils de calcul du modèle |
40
+ | `--octets` | `2000000` | taille maximale du corps |
41
+ | `--secondes` | `15` | durée maximale de l'audio |
42
+ | `--local-seulement` | non | ne rien télécharger ; sortie 3 si le modèle manque |
43
+
44
+ Côté application :
45
+
46
+ ```js
47
+ reconnaisseurHttp({ url: 'http://127.0.0.1:8762/reconnaitre', type: 'audio/wav' })
48
+ ```
49
+
50
+ ## Le contrat
51
+
52
+ - `POST /reconnaitre` : le corps est l'audio (WAV, FLAC ou OGG, de 0,25 à 15 s) ; l'en-tête `x-attendus` porte les sons attendus, en JSON encodé. Réponse 200 : `{ entendus, traits, duree, regle, modele }`.
53
+ - `GET /sante` : `{ ok, modele, revision, regle, classes }`.
54
+ - Refus `{ erreur, message }` :
55
+
56
+ | code | statut | quand |
57
+ |---|---|---|
58
+ | `ATTENDUS` | 400 | `x-attendus` absent, illisible, vide, plus de 400 sons, ou un son de plus de 4 signes |
59
+ | `AUDIO_ABSENT` | 400 | corps vide, ou moins de 0,25 s |
60
+ | `LONGUEUR` | 411 | pas de `content-length` |
61
+ | `TROP_GROS` | 413 | corps au-delà de `--octets` (refusé sans être lu) |
62
+ | `TROP_LONG` | 413 | audio au-delà de `--secondes` |
63
+ | `FORMAT` | 415 | audio illisible (le WebM/Opus des navigateurs ne l'est pas : enregistrer en WAV) |
64
+ | `CHEMIN`, `METHODE` | 404, 405 | autre adresse, autre méthode |
65
+ | `INTERNE` | 500 | toute autre faute |
66
+
67
+ ## Ce qu'il ne garde pas
68
+
69
+ L'audio est lu en mémoire et n'est jamais écrit. Le journal, sur la sortie d'erreur, ne porte que des comptes et des durées : jamais l'audio, jamais les sons attendus (essai T-SRV-8).
70
+
71
+ ## Débit
72
+
73
+ Une inférence à la fois ; les requêtes suivantes attendent. Mesure du 25/10/2026, poste de développement, 2 fils : **1,5 à 2 s de calcul pour 3 s d'audio**. Le débit sur le serveur de production reste à mesurer.
74
+
75
+ ## Essais
76
+
77
+ ```bash
78
+ PRONONCIATION_PYTHON=service/.venv/bin/python npm test # 23 essais JS + 9 essais du service
79
+ ```
80
+
81
+ T-SRV-9 compare les traits du vrai modèle à ceux de l'apprentissage, sur cinq phrases de speechocean762 : l'écart est **nul**. T-PRO-23 lance le service et note trois phrases de bout en bout, par `reconnaisseurHttp` : on retrouve la note de Python.
@@ -0,0 +1,38 @@
1
+ """
2
+ @mostajs/prononciation — service de reconnaissance : octets → onde mono à 16 kHz, en mémoire.
3
+
4
+ @author Dr Hamid MADANI <drmdh@msn.com>
5
+ @license AGPL-3.0-or-later
6
+
7
+ libsndfile lit WAV, FLAC, OGG. Rien n'est écrit sur le disque : la voix d'un élève ne quitte pas la mémoire du
8
+ processus, et le journal n'en garde que la durée.
9
+ """
10
+ import io
11
+
12
+ import soundfile as sf
13
+ import torch
14
+ import torchaudio.functional as AF
15
+
16
+ from erreurs import ErreurService
17
+
18
+ TAUX = 16000
19
+ SECONDES_MIN = 0.25 # en deçà, le modèle n'a pas assez de trames pour un mot
20
+
21
+
22
+ def lire_audio(octets, secondes_max=15.0):
23
+ try:
24
+ with sf.SoundFile(io.BytesIO(octets)) as f:
25
+ if f.frames / f.samplerate > secondes_max:
26
+ raise ErreurService('TROP_LONG', f'audio de {f.frames / f.samplerate:.1f} s ; au plus {secondes_max:g} s')
27
+ if f.frames / f.samplerate < SECONDES_MIN:
28
+ raise ErreurService('AUDIO_ABSENT', f'audio de {f.frames / f.samplerate:.2f} s ; au moins {SECONDES_MIN:g} s')
29
+ onde, sr = f.read(dtype='float32'), f.samplerate
30
+ except ErreurService:
31
+ raise
32
+ except Exception:
33
+ raise ErreurService('FORMAT', 'audio illisible (WAV, FLAC ou OGG attendu)') from None
34
+ if onde.ndim > 1:
35
+ onde = onde.mean(axis=1)
36
+ if sr != TAUX:
37
+ onde = AF.resample(torch.from_numpy(onde), sr, TAUX).numpy()
38
+ return onde
@@ -0,0 +1,19 @@
1
+ """
2
+ @mostajs/prononciation — service de reconnaissance : les refus nommés (plan §8.3).
3
+
4
+ @author Dr Hamid MADANI <drmdh@msn.com>
5
+ @license AGPL-3.0-or-later
6
+ """
7
+
8
+ STATUTS = {
9
+ 'ATTENDUS': 400, 'AUDIO_ABSENT': 400, 'LONGUEUR': 411, 'TROP_GROS': 413, 'TROP_LONG': 413,
10
+ 'FORMAT': 415, 'CHEMIN': 404, 'METHODE': 405, 'INTERNE': 500,
11
+ }
12
+
13
+
14
+ class ErreurService(Exception):
15
+ """Un refus du service : un code nommé, son statut HTTP, un message en français."""
16
+
17
+ def __init__(self, code, message):
18
+ super().__init__(message)
19
+ self.code, self.statut, self.message = code, STATUTS[code], message
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # @mostajs/prononciation — installe le service de reconnaissance (rejouable, plan §8.2).
4
+ # Auteur : Dr Hamid MADANI <drmdh@msn.com>
5
+ #
6
+ # bash service/installer.sh
7
+ # PRONONCIATION_HF_HOME=/srv/modeles bash service/installer.sh # où ranger le modèle (≈ 1,2 Go)
8
+ # PRONONCIATION_VENV=/srv/prononciation-venv bash service/installer.sh # l'environnement Python HORS de node_modules
9
+ #
10
+ # 0.2.1 — installé depuis npm, le service vit dans node_modules/, que `npm ci` efface à chaque déploiement : poser
11
+ # PRONONCIATION_VENV (et PRONONCIATION_HF_HOME) hors du projet, pour n'installer qu'une fois.
12
+ #
13
+ # Crée service/.venv (Python ≥ 3.10), y installe les dépendances figées, télécharge le modèle à sa révision figée,
14
+ # puis vérifie qu'il se charge sans réseau. Rien d'autre n'est écrit.
15
+ set -euo pipefail
16
+ ICI="$(cd "$(dirname "$0")" && pwd)"
17
+ PY="${PYTHON:-python3}"
18
+ "$PY" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' || { echo "✗ Python ≥ 3.10 requis ($PY)"; exit 1; }
19
+ VENV="${PRONONCIATION_VENV:-$ICI/.venv}"
20
+ [ -x "$VENV/bin/python" ] || "$PY" -m venv "$VENV"
21
+ "$VENV/bin/pip" install -q --upgrade pip
22
+ "$VENV/bin/pip" install -q -r "$ICI/requirements.txt"
23
+ [ -n "${PRONONCIATION_HF_HOME:-}" ] && export HF_HOME="$PRONONCIATION_HF_HOME"
24
+ cd "$ICI"
25
+ "$VENV/bin/python" - <<'PY'
26
+ from huggingface_hub import hf_hub_download
27
+ import moteur
28
+ for f in moteur.FICHIERS:
29
+ hf_hub_download(moteur.MODELE, f, revision=moteur.REVISION)
30
+ assert moteur.modele_installe(), 'modèle absent après téléchargement'
31
+ m = moteur.Moteur(local_seulement=True)
32
+ print(f'✓ modèle {moteur.MODELE}@{moteur.REVISION[:8]} chargé : {m.classes} classes de sons, règle {m.regle}')
33
+ PY
34
+ echo "✓ installé. Lancer : ${PRONONCIATION_HF_HOME:+HF_HOME=$PRONONCIATION_HF_HOME }$VENV/bin/python $ICI/serveur.py --local-seulement"
@@ -0,0 +1,74 @@
1
+ """
2
+ @mostajs/prononciation — service de reconnaissance : le modèle acoustique, chargé une fois.
3
+
4
+ @author Dr Hamid MADANI <drmdh@msn.com>
5
+ @license AGPL-3.0-or-later
6
+
7
+ wav2vec2 phonémique (Apache-2.0), à la révision qui a servi à apprendre note-apprise-1. Une inférence à la fois :
8
+ deux en parallèle sur les mêmes cœurs se ralentissent l'une l'autre (dossier produit, 11 §3).
9
+ """
10
+ import json
11
+ import threading
12
+ import time
13
+
14
+ import torch
15
+ from huggingface_hub import hf_hub_download
16
+ from transformers import Wav2Vec2FeatureExtractor, Wav2Vec2ForCTC
17
+ from transformers.utils import logging as journal_transformers
18
+
19
+ # transformers 4.44 annonce à tort des poids « réinitialisés » (weight_g/weight_v → parametrizations) : ils sont bien
20
+ # chargés — T-SRV-9 retrouve les traits d'apprentissage à l'identique. L'avertissement égarerait l'exploitant.
21
+ journal_transformers.set_verbosity_error()
22
+
23
+ import sons
24
+ import traits
25
+
26
+ MODELE = 'facebook/wav2vec2-lv-60-espeak-cv-ft'
27
+ REVISION = 'ae45363bf3413b374fecd9dc8bc1df0e24c3b7f4'
28
+ FICHIERS = ('config.json', 'preprocessor_config.json', 'vocab.json', 'pytorch_model.bin')
29
+
30
+
31
+ def modele_installe():
32
+ """Vrai si la révision figée du modèle est dans le cache local (HF_HOME), sans réseau."""
33
+ try:
34
+ for f in FICHIERS:
35
+ hf_hub_download(MODELE, f, revision=REVISION, local_files_only=True)
36
+ return True
37
+ except Exception:
38
+ return False
39
+
40
+
41
+ class Moteur:
42
+ modele, revision, regle = MODELE, REVISION, sons.REGLE
43
+
44
+ def __init__(self, fils=2, local_seulement=False):
45
+ torch.set_num_threads(fils)
46
+ o = {'revision': REVISION, 'local_files_only': local_seulement}
47
+ vocab = json.load(open(hf_hub_download(MODELE, 'vocab.json', **o), encoding='utf-8'))
48
+ self.noms, self.membre, self.matrice, self.blanc = sons.classes_du_vocabulaire(vocab)
49
+ self.classes = len(self.noms)
50
+ self.id2tok = {i: t for t, i in vocab.items()}
51
+ self.proc = Wav2Vec2FeatureExtractor.from_pretrained(MODELE, **o)
52
+ self.model = Wav2Vec2ForCTC.from_pretrained(MODELE, **o).eval()
53
+ self.verrou = threading.Lock()
54
+
55
+ def reconnaitre(self, onde, attendus):
56
+ """onde : float32 mono 16 kHz ; attendus : sons → { entendus, traits, duree, calcul }."""
57
+ with self.verrou:
58
+ t0 = time.perf_counter()
59
+ with torch.inference_mode():
60
+ logits = self.model(self.proc(onde, sampling_rate=16000, return_tensors='pt').input_values).logits[0]
61
+ lp = torch.log_softmax(logits, dim=-1)
62
+ lp_cls = torch.log(lp.exp() @ self.matrice + 1e-12)
63
+ lp_blanc = lp[:, self.blanc]
64
+ bruts, prec = [], None
65
+ for i in torch.argmax(logits, dim=-1).tolist(): # décodage glouton
66
+ if i != prec and i != self.blanc:
67
+ tk = self.id2tok.get(i, '')
68
+ if tk.strip() and tk not in sons.SPECIAUX:
69
+ bruts.append(tk)
70
+ prec = i
71
+ r = {'entendus': sons.entendre(bruts), 'traits': traits.caracteristiques(lp_cls, lp_blanc, attendus, self.membre),
72
+ 'duree': len(onde) / 16000}
73
+ r['calcul'] = time.perf_counter() - t0
74
+ return r
@@ -0,0 +1,10 @@
1
+ # @mostajs/prononciation — service de reconnaissance : dépendances FIGÉES (plan §8.2).
2
+ # Ce sont celles qui ont produit les traits d'apprentissage de note-apprise-1 : une autre version est une autre mesure.
3
+ # Auteur : Dr Hamid MADANI <drmdh@msn.com>
4
+ --extra-index-url https://download.pytorch.org/whl/cpu
5
+ torch==2.4.1+cpu
6
+ torchaudio==2.4.1+cpu
7
+ transformers==4.44.2
8
+ huggingface_hub==0.24.6
9
+ numpy==1.26.4
10
+ soundfile==0.12.1
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ @mostajs/prononciation — service de reconnaissance : le serveur HTTP (plan §8.3).
4
+
5
+ @author Dr Hamid MADANI <drmdh@msn.com>
6
+ @license AGPL-3.0-or-later
7
+
8
+ POST /reconnaitre corps = l'audio ; x-attendus = JSON des sons attendus (encodeURIComponent)
9
+ → 200 { entendus, traits, duree, regle, modele }
10
+ GET /sante → 200 { ok, modele, revision, regle, classes }
11
+ Tout refus : { erreur, message } avec son statut. Écoute locale par défaut : seule l'application le joint.
12
+ Le journal ne porte que des comptes et des durées — jamais l'audio, jamais les sons attendus.
13
+
14
+ Lancer : python serveur.py [--hote 127.0.0.1] [--port 8762] [--fils 2] [--octets 2000000] [--secondes 15]
15
+ """
16
+ import argparse
17
+ import json
18
+ import os
19
+ import signal
20
+ import sys
21
+ import time
22
+ import urllib.parse
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+
25
+ from audio import lire_audio
26
+ from erreurs import ErreurService
27
+
28
+ SONS_MAX, SON_LONGUEUR_MAX = 400, 4
29
+
30
+
31
+ def lire_attendus(entete):
32
+ if not entete:
33
+ raise ErreurService('ATTENDUS', 'en-tête x-attendus absent')
34
+ try:
35
+ a = json.loads(urllib.parse.unquote(entete))
36
+ except Exception:
37
+ raise ErreurService('ATTENDUS', 'x-attendus : JSON illisible') from None
38
+ if not isinstance(a, list) or not 1 <= len(a) <= SONS_MAX:
39
+ raise ErreurService('ATTENDUS', f'x-attendus : une liste de 1 à {SONS_MAX} sons')
40
+ if not all(isinstance(s, str) and 1 <= len(s) <= SON_LONGUEUR_MAX and not any(c.isspace() for c in s) for s in a):
41
+ raise ErreurService('ATTENDUS', f'x-attendus : chaque son est une chaîne de 1 à {SON_LONGUEUR_MAX} signes')
42
+ return a
43
+
44
+
45
+ def creer_serveur(moteur, hote='127.0.0.1', port=8762, octets_max=2_000_000, secondes_max=15.0, journal=None):
46
+ ecrire = journal or (lambda m: print(time.strftime('%Y-%m-%dT%H:%M:%S'), m, file=sys.stderr, flush=True))
47
+
48
+ class Gestion(BaseHTTPRequestHandler):
49
+ server_version = 'prononciation-reconnaissance'
50
+ sys_version = ''
51
+
52
+ def log_message(self, *args): # pas de journal d'accès : il porterait les en-têtes
53
+ pass
54
+
55
+ def repondre(self, statut, corps):
56
+ b = json.dumps(corps, ensure_ascii=False).encode('utf-8')
57
+ self.send_response(statut)
58
+ self.send_header('content-type', 'application/json; charset=utf-8')
59
+ self.send_header('content-length', str(len(b)))
60
+ self.send_header('connection', 'close')
61
+ self.end_headers()
62
+ self.wfile.write(b)
63
+ self.close_connection = True
64
+
65
+ def refuser(self, e):
66
+ ecrire(f'refus {e.code} ({e.statut})')
67
+ self.repondre(e.statut, {'erreur': e.code, 'message': e.message})
68
+
69
+ def do_GET(self):
70
+ if self.path == '/sante':
71
+ return self.repondre(200, {'ok': True, 'modele': moteur.modele, 'revision': moteur.revision,
72
+ 'regle': moteur.regle, 'classes': moteur.classes})
73
+ self.refuser(ErreurService('METHODE', 'POST attendu') if self.path == '/reconnaitre'
74
+ else ErreurService('CHEMIN', f'adresse inconnue ; /reconnaitre ou /sante'))
75
+
76
+ def autre(self):
77
+ self.refuser(ErreurService('METHODE', 'POST /reconnaitre ou GET /sante'))
78
+
79
+ do_PUT = do_DELETE = do_PATCH = autre
80
+
81
+ def do_POST(self):
82
+ try:
83
+ if self.path != '/reconnaitre':
84
+ raise ErreurService('CHEMIN', 'adresse inconnue ; /reconnaitre ou /sante')
85
+ attendus = lire_attendus(self.headers.get('x-attendus'))
86
+ longueur = self.headers.get('content-length')
87
+ if longueur is None or not longueur.isdigit():
88
+ raise ErreurService('LONGUEUR', 'content-length attendu')
89
+ n = int(longueur)
90
+ if n == 0:
91
+ raise ErreurService('AUDIO_ABSENT', 'corps vide : l’audio est attendu')
92
+ if n > octets_max: # refusé SANS lire : la borne n'est jamais dépassée en mémoire
93
+ raise ErreurService('TROP_GROS', f'{n} octets ; au plus {octets_max}')
94
+ onde = lire_audio(self.rfile.read(n), secondes_max)
95
+ r = moteur.reconnaitre(onde, attendus)
96
+ ecrire(f'reconnu : {len(attendus)} sons attendus, {len(r["entendus"])} entendus, '
97
+ f'{r["duree"]:.2f} s d’audio, {r.get("calcul", 0.0):.2f} s de calcul')
98
+ self.repondre(200, {'entendus': r['entendus'], 'traits': r['traits'], 'duree': r['duree'],
99
+ 'regle': moteur.regle, 'modele': moteur.modele})
100
+ except ErreurService as e:
101
+ self.refuser(e)
102
+ except Exception:
103
+ self.refuser(ErreurService('INTERNE', 'faute interne du service'))
104
+
105
+ srv = ThreadingHTTPServer((hote, port), Gestion)
106
+ srv.daemon_threads = True
107
+ return srv
108
+
109
+
110
+ def main():
111
+ ap = argparse.ArgumentParser(description='Service de reconnaissance de @mostajs/prononciation')
112
+ ap.add_argument('--hote', default=os.environ.get('PRONONCIATION_HOTE', '127.0.0.1'))
113
+ ap.add_argument('--port', type=int, default=int(os.environ.get('PRONONCIATION_PORT', '8762')))
114
+ ap.add_argument('--fils', type=int, default=int(os.environ.get('PRONONCIATION_FILS', '2')))
115
+ ap.add_argument('--octets', type=int, default=2_000_000)
116
+ ap.add_argument('--secondes', type=float, default=15.0)
117
+ ap.add_argument('--local-seulement', action='store_true', help='ne rien télécharger : le modèle doit être installé')
118
+ a = ap.parse_args()
119
+ import moteur as m
120
+ if a.local_seulement and not m.modele_installe():
121
+ print(f'modèle {m.MODELE}@{m.REVISION[:8]} non installé : service/installer.sh', file=sys.stderr)
122
+ sys.exit(3)
123
+ srv = creer_serveur(m.Moteur(fils=a.fils, local_seulement=a.local_seulement), a.hote, a.port, a.octets, a.secondes)
124
+ signal.signal(signal.SIGTERM, lambda *_: (srv.server_close(), sys.exit(0)))
125
+ print(f'écoute http://{srv.server_address[0]}:{srv.server_address[1]}', flush=True)
126
+ srv.serve_forever()
127
+
128
+
129
+ if __name__ == '__main__':
130
+ main()
@@ -0,0 +1,64 @@
1
+ """
2
+ @mostajs/prononciation — service de reconnaissance : jetons du modèle → classes de sons (règle stricte-2).
3
+
4
+ @author Dr Hamid MADANI <drmdh@msn.com>
5
+ @license AGPL-3.0-or-later
6
+
7
+ Repris de mesure_moteur_speechocean762.py (dossier produit), la règle qui a servi à apprendre note-apprise-1.
8
+ Le cœur JS (src/lib/notation.js, normaliserJetons) applique la même : l'essai T-SRV-1 les compare jeton par jeton
9
+ sur tout le vocabulaire du modèle. Corrections de notation seulement : longueur, accent, aspiration, tons chinois,
10
+ symboles composés (ɑːɹ = ɑ + ɹ).
11
+ """
12
+ import re
13
+
14
+ import torch
15
+
16
+ REGLE = 'stricte-2'
17
+
18
+ CLASSES = {'ə': 'ʌ', 'ɐ': 'ʌ', 'ɚ': 'ɜ', 'ɝ': 'ɜ', 'əʊ': 'oʊ', 'e': 'ɛ', 'ɒ': 'ɑ', 'g': 'ɡ',
19
+ 'r': 'ɹ', 'ɾ': 't', 'ɫ': 'l', 'ᵻ': 'ɪ', 'ɪ̈': 'ɪ', 'o': 'oʊ', 'tS': 'tʃ', 'dZ': 'dʒ',
20
+ 'th': 't', 'kh': 'k', 'ph': 'p', 'e̞': 'ɛ', 'o̞': 'oʊ', 'a': 'æ', 'ä': 'ɑ', 'l̩': 'l', 'n̩': 'n'}
21
+ COMPOSES = {'ɑɹ': ['ɑ', 'ɹ'], 'ɔɹ': ['ɔ', 'ɹ'], 'oɹ': ['ɔ', 'ɹ'], 'ɪɹ': ['ɪ', 'ɹ'], 'ɛɹ': ['ɛ', 'ɹ'],
22
+ 'ʊɹ': ['ʊ', 'ɹ'], 'əl': ['ʌ', 'l'], 'aɪə': ['aɪ', 'ʌ'], 'aɪɚ': ['aɪ', 'ɜ'], 'ju': ['j', 'u'],
23
+ 'ja': ['j', 'ɑ'], 'iə': ['i', 'ʌ'], 'eə': ['ɛ', 'ʌ'], 'ʊə': ['ʊ', 'ʌ']}
24
+ VOYELLES = set('aeiouæɑɒɔəɛɜɪʊʌy')
25
+ SPECIAUX = {'<s>', '</s>', '<unk>', '|', ' ', '<pad>'}
26
+
27
+
28
+ def nettoyer(p):
29
+ p = re.sub(r'[ːˈˌ:."^\[ʰʲ]', '', p)
30
+ p = re.sub(r'[1-5]+$', '', p) # tons chinois notés par chiffre
31
+ if len(p) > 1 and p.endswith('ɜ') and p[0] in VOYELLES:
32
+ p = p[:-1] # ton 3 noté ɜ (iɜ, uɜ, əɜ…)
33
+ return p
34
+
35
+
36
+ def entendre(jetons):
37
+ """Jetons bruts du modèle → classes de sons comparables (liste)."""
38
+ out = []
39
+ for j in jetons:
40
+ p = nettoyer(j)
41
+ if not p:
42
+ continue
43
+ for q in COMPOSES.get(p, [p]):
44
+ out.append(CLASSES.get(q, q))
45
+ return out
46
+
47
+
48
+ def classes_du_vocabulaire(vocab):
49
+ """vocab {jeton: indice} → (noms des classes, {classe: colonne}, matrice [jetons × classes], indice du blanc).
50
+ Un symbole composé (ɑːɹ) compte pour CHACUN de ses sons."""
51
+ noms, membre = [], {}
52
+ for tok in vocab:
53
+ if tok in SPECIAUX:
54
+ continue
55
+ for c in entendre([tok]):
56
+ if c not in membre:
57
+ membre[c] = len(noms)
58
+ noms.append(c)
59
+ matrice = torch.zeros(len(vocab), len(noms))
60
+ for tok, i in vocab.items():
61
+ if tok not in SPECIAUX:
62
+ for c in entendre([tok]):
63
+ matrice[i, membre[c]] = 1.0
64
+ return noms, membre, matrice, vocab['<pad>']
@@ -0,0 +1,62 @@
1
+ """
2
+ @mostajs/prononciation — service de reconnaissance : les 8 traits par son attendu (GOP).
3
+
4
+ @author Dr Hamid MADANI <drmdh@msn.com>
5
+ @license AGPL-3.0-or-later
6
+
7
+ Repris tel quel d'extraire_gop.py (dossier produit), qui a produit les traits d'apprentissage de note-apprise-1 :
8
+ l'alignement forcé CTC place chaque son attendu sur ses trames, puis, par son :
9
+ gop_moy, gop_max log-probabilité moyenne et maximale de la classe attendue sur ses trames ;
10
+ fenetre_max maximum sur une fenêtre élargie (fin du son précédent → début du suivant : le CTC est « pointu ») ;
11
+ lpr_moy, lpr_max écart au meilleur concurrent ;
12
+ duree, fenetre en trames de 20 ms ;
13
+ blanc_moy probabilité moyenne du « rien ».
14
+ Un son que le modèle ne connaît pas reçoit le plancher.
15
+ """
16
+ import numpy as np
17
+ import torch
18
+ import torchaudio.functional as AF
19
+
20
+ PLANCHER = float(np.log(1e-8))
21
+
22
+
23
+ def caracteristiques(lp_cls, lp_blanc, attendus, membre):
24
+ """lp_cls [T, C] log-proba par classe ; lp_blanc [T] ; attendus : sons (suffixe 0 possible) → [[8 traits]]."""
25
+ T = lp_cls.shape[0]
26
+ bases = [a[:-1] if a.endswith('0') else a for a in attendus]
27
+ cibles = [membre.get(b, -1) for b in bases]
28
+ # émission réduite : colonne 0 = blanc, puis une colonne par position attendue
29
+ em = torch.full((T, len(cibles) + 1), PLANCHER)
30
+ em[:, 0] = lp_blanc
31
+ for j, c in enumerate(cibles):
32
+ if c >= 0:
33
+ em[:, j + 1] = lp_cls[:, c]
34
+ em = em - torch.logsumexp(em, dim=1, keepdim=True)
35
+ try:
36
+ ali, _ = AF.forced_align(em[None], torch.arange(1, len(cibles) + 1, dtype=torch.int32)[None], blank=0)
37
+ spans = AF.merge_tokens(ali[0], torch.zeros(T))
38
+ bornes = [(s.start, s.end) for s in spans]
39
+ if len(bornes) != len(cibles):
40
+ raise RuntimeError('alignement incomplet')
41
+ except Exception:
42
+ pas = T / max(1, len(cibles)) # trop court pour l'alignement : partage égal
43
+ bornes = [(int(j * pas), max(int(j * pas) + 1, int((j + 1) * pas))) for j in range(len(cibles))]
44
+ # Plus de sons que de trames : le partage déborde de l'audio. On ramène chaque segment sur la dernière trame
45
+ # (0.2.0 ; extraire_gop.py n'a jamais rencontré le cas sur le corpus). Un segment dans l'audio n'est pas touché.
46
+ bornes = [(d, f) if d < T else (T - 1, T) for d, f in bornes]
47
+ meilleur = lp_cls.max(dim=1).values
48
+ out = []
49
+ for j, (c, (d, f)) in enumerate(zip(cibles, bornes)):
50
+ wd = bornes[j - 1][1] if j else 0
51
+ wf = bornes[j + 1][0] if j + 1 < len(bornes) else T
52
+ wf = max(wf, f)
53
+ wd = min(wd, d)
54
+ if c >= 0:
55
+ seg, win = lp_cls[d:f, c], lp_cls[wd:wf, c]
56
+ gop_moy, gop_max, win_max = float(seg.mean()), float(seg.max()), float(win.max())
57
+ lpr = seg - meilleur[d:f]
58
+ lpr_moy, lpr_max = float(lpr.mean()), float(lpr.max())
59
+ else:
60
+ gop_moy = gop_max = win_max = lpr_moy = lpr_max = PLANCHER
61
+ out.append([gop_moy, gop_max, win_max, lpr_moy, lpr_max, f - d, wf - wd, float(lp_blanc[d:f].mean())])
62
+ return out
@@ -23,12 +23,14 @@ export function creerLexique(table = {}) {
23
23
  export function lexiqueCmudict(texte) {
24
24
  if (typeof texte !== 'string') refus('CONFIGURATION', 'lexiqueCmudict : le texte du fichier est attendu');
25
25
  const table = {};
26
- for (const ligne of texte.split(/\r?\n/)) {
27
- if (!ligne.trim() || ligne.startsWith(';;;')) continue;
26
+ for (const brute of texte.split(/\r?\n/)) {
27
+ if (brute.startsWith(';;;')) continue;
28
+ const ligne = brute.split('#')[0]; // 0.2.1 : « … R G # place, danish » → commentaire
29
+ if (!ligne.trim()) continue;
28
30
  const [mot, ...sons] = ligne.trim().split(/\s+/);
29
31
  if (!sons.length || /\(\d+\)$/.test(mot)) continue; // variante : TOMATO(2)
30
32
  const cle = mot.toUpperCase();
31
- if (!(cle in table)) table[cle] = sons.map((s) => s.toUpperCase()).filter((s) => !s.startsWith('#'));
33
+ if (!(cle in table)) table[cle] = sons.map((s) => s.toUpperCase());
32
34
  }
33
35
  return creerLexique(table);
34
36
  }