@mostajs/prononciation 0.0.0-stage → 0.2.0
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 +43 -0
- package/LICENSE +15 -0
- package/README.md +72 -2
- package/llms.txt +79 -0
- package/package.json +77 -4
- package/service/README.md +75 -0
- package/service/audio.py +38 -0
- package/service/erreurs.py +19 -0
- package/service/installer.sh +29 -0
- package/service/moteur.py +74 -0
- package/service/requirements.txt +10 -0
- package/service/serveur.py +130 -0
- package/service/sons.py +64 -0
- package/service/traits.py +62 -0
- package/src/adapters/fixe.js +12 -0
- package/src/adapters/http.js +37 -0
- package/src/index.js +13 -0
- package/src/lib/alignement.js +36 -0
- package/src/lib/erreurs.js +38 -0
- package/src/lib/lexique.js +45 -0
- package/src/lib/notation.js +71 -0
- package/src/lib/note.js +98 -0
- package/src/lib/resistances.js +25 -0
- package/src/lib/texte.js +11 -0
- package/src/poids/note-apprise-1.js +5 -0
- package/src/prononciation.js +100 -0
|
@@ -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()
|
package/service/sons.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @mostajs/prononciation — reconnaisseur FIXE : rend une réponse donnée, ou calculée des sons attendus. Pour les essais.
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/** @param {object | ((demande: { audio, attendus: string[] }) => object)} reponse */
|
|
5
|
+
export function reconnaisseurFixe(reponse) {
|
|
6
|
+
return Object.freeze({
|
|
7
|
+
nom: 'fixe',
|
|
8
|
+
async reconnaitre({ audio, attendus }) {
|
|
9
|
+
return typeof reponse === 'function' ? reponse({ audio, attendus: [...attendus] }) : { ...reponse };
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @mostajs/prononciation — reconnaisseur HTTP : client du service de reconnaissance (wav2vec2, joint en local).
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
// Contrat (docs/03-PLAN-DEV-PRONONCIATION.md §3.2) : POST <url>, corps = octets de l'audio, en-tête x-attendus = JSON
|
|
4
|
+
// des sons attendus (encodeURIComponent) ; réponse 200 { entendus, traits?, duree? }. L'adaptateur ne fait que traduire (R-L).
|
|
5
|
+
import { ErreurPrononciation, refus } from '../lib/erreurs.js';
|
|
6
|
+
|
|
7
|
+
export function reconnaisseurHttp({ url, fetch: f = globalThis.fetch, delaiMs = 15_000, type = 'application/octet-stream' } = {}) {
|
|
8
|
+
if (typeof url !== 'string' || !/^https?:\/\//.test(url)) refus('CONFIGURATION', 'reconnaisseurHttp : url http(s) attendue');
|
|
9
|
+
if (typeof f !== 'function') refus('CONFIGURATION', 'reconnaisseurHttp : fetch indisponible');
|
|
10
|
+
const echec = (m) => new ErreurPrononciation('RECONNAISSEUR', `reconnaissance : ${m}`);
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
nom: 'http',
|
|
13
|
+
async reconnaitre({ audio, attendus }) {
|
|
14
|
+
const ctl = new AbortController();
|
|
15
|
+
let minuterie;
|
|
16
|
+
const delai = new Promise((_, rej) => { minuterie = setTimeout(() => { ctl.abort(); rej(echec(`délai de ${delaiMs} ms dépassé`)); }, delaiMs); });
|
|
17
|
+
try {
|
|
18
|
+
const rep = await Promise.race([f(url, {
|
|
19
|
+
method: 'POST', body: audio, signal: ctl.signal,
|
|
20
|
+
headers: { 'content-type': audio?.type || type, 'x-attendus': encodeURIComponent(JSON.stringify(attendus)) },
|
|
21
|
+
}), delai]);
|
|
22
|
+
if (!rep?.ok) throw echec(`statut ${rep?.status}`);
|
|
23
|
+
let corps;
|
|
24
|
+
try { corps = await rep.json(); } catch { throw echec('réponse qui n’est pas du JSON'); }
|
|
25
|
+
if (!Array.isArray(corps?.entendus) || !corps.entendus.every((s) => typeof s === 'string')) throw echec('entendus absent ou mal formé');
|
|
26
|
+
const r = { entendus: corps.entendus };
|
|
27
|
+
if (corps.traits !== undefined) r.traits = corps.traits;
|
|
28
|
+
if (corps.duree !== undefined) r.duree = corps.duree;
|
|
29
|
+
return r;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
throw e instanceof ErreurPrononciation ? e : echec(e?.message ?? String(e));
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(minuterie);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @mostajs/prononciation — point d'entrée. Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
2
|
+
export { createPrononciation } from './prononciation.js';
|
|
3
|
+
export { ErreurPrononciation, CODES, LIBELLES } from './lib/erreurs.js';
|
|
4
|
+
export { arpaVersIpa, normaliserJetons, egal, ARPA, PHONEMES, REGLES } from './lib/notation.js';
|
|
5
|
+
export { motsDe } from './lib/texte.js';
|
|
6
|
+
export { creerLexique, lexiqueCmudict, combinerLexiques } from './lib/lexique.js';
|
|
7
|
+
export { aligner } from './lib/alignement.js';
|
|
8
|
+
export { appliquerNote, noteSimple, validerNote } from './lib/note.js';
|
|
9
|
+
export { resistances } from './lib/resistances.js';
|
|
10
|
+
export { reconnaisseurFixe } from './adapters/fixe.js';
|
|
11
|
+
export { reconnaisseurHttp } from './adapters/http.js';
|
|
12
|
+
/** Les poids appris sur speechocean762 (CC BY 4.0) : phrase 0,72, son 0,56 d'accord avec les juges sur la partie test. */
|
|
13
|
+
export { default as NOTE_APPRISE_1 } from './poids/note-apprise-1.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @mostajs/prononciation — alignement des sons attendus et entendus (Levenshtein).
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
// Même calcul et même ordre de retour arrière que aligner() de mesure_moteur_speechocean762.py.
|
|
4
|
+
import { egal } from './notation.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string[]} attendus sons attendus (suffixe 0 possible)
|
|
8
|
+
* @param {string[]} entendus sons entendus, normalisés
|
|
9
|
+
* @returns {{ etats: ('juste'|'faux'|'omis')[], entendus: (string|null)[], insertions: { apres: number, entendu: string }[] }}
|
|
10
|
+
* `entendus[i]` : le son entendu en face du i-ème attendu ; `apres` : l'indice de l'attendu qui précède l'insertion (-1 en tête)
|
|
11
|
+
*/
|
|
12
|
+
export function aligner(attendus, entendus, { regle = 'stricte-2' } = {}) {
|
|
13
|
+
const n = attendus.length, m = entendus.length;
|
|
14
|
+
const d = Array.from({ length: n + 1 }, (_, i) => { const r = new Int32Array(m + 1); r[0] = i; return r; });
|
|
15
|
+
for (let j = 0; j <= m; j++) d[0][j] = j;
|
|
16
|
+
const cout = (i, j) => (egal(attendus[i - 1], entendus[j - 1], regle) ? 0 : 1);
|
|
17
|
+
for (let i = 1; i <= n; i++) {
|
|
18
|
+
for (let j = 1; j <= m; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cout(i, j));
|
|
19
|
+
}
|
|
20
|
+
const etats = new Array(n).fill(null), vis = new Array(n).fill(null), insertions = [];
|
|
21
|
+
let i = n, j = m;
|
|
22
|
+
while (i > 0 || j > 0) {
|
|
23
|
+
if (i > 0 && j > 0 && d[i][j] === d[i - 1][j - 1] + cout(i, j)) {
|
|
24
|
+
etats[i - 1] = cout(i, j) === 0 ? 'juste' : 'faux';
|
|
25
|
+
vis[i - 1] = entendus[j - 1];
|
|
26
|
+
i--; j--;
|
|
27
|
+
} else if (i > 0 && d[i][j] === d[i - 1][j] + 1) {
|
|
28
|
+
etats[i - 1] = 'omis';
|
|
29
|
+
i--;
|
|
30
|
+
} else {
|
|
31
|
+
insertions.unshift({ apres: i - 1, entendu: entendus[j - 1] });
|
|
32
|
+
j--;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { etats, entendus: vis, insertions };
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @mostajs/prononciation — vocabulaire d'erreur unique (R-I). Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
/** Chaque code et son statut HTTP : l'application le mappe une fois, à un seul endroit. */
|
|
4
|
+
export const CODES = Object.freeze({
|
|
5
|
+
CONFIGURATION: 400,
|
|
6
|
+
TEXTE_VIDE: 400,
|
|
7
|
+
TEXTE_TROP_LONG: 413,
|
|
8
|
+
MOT_INCONNU: 422,
|
|
9
|
+
AUDIO_ABSENT: 400,
|
|
10
|
+
AUDIO_TROP_LONG: 413,
|
|
11
|
+
RECONNAISSEUR: 502,
|
|
12
|
+
TRAITS: 502,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Le libellé français de référence de chaque code ; l'application le traduit. */
|
|
16
|
+
export const LIBELLES = Object.freeze({
|
|
17
|
+
CONFIGURATION: 'Configuration du moteur de prononciation invalide.',
|
|
18
|
+
TEXTE_VIDE: 'Le texte ne contient aucun mot.',
|
|
19
|
+
TEXTE_TROP_LONG: 'Le texte contient trop de mots.',
|
|
20
|
+
MOT_INCONNU: 'Un ou plusieurs mots sont absents du lexique.',
|
|
21
|
+
AUDIO_ABSENT: 'Aucun enregistrement reçu.',
|
|
22
|
+
AUDIO_TROP_LONG: "L'enregistrement est trop long.",
|
|
23
|
+
RECONNAISSEUR: "Le service de reconnaissance n'a pas répondu correctement.",
|
|
24
|
+
TRAITS: 'Le service de reconnaissance ne rend pas les traits que demande la note apprise.',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export class ErreurPrononciation extends Error {
|
|
28
|
+
/** @param {keyof CODES} code ; @param {string} message ; @param {object} [details] copiés sur l'erreur (ex. { mots }) */
|
|
29
|
+
constructor(code, message = LIBELLES[code], details = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ErreurPrononciation';
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.statut = CODES[code] ?? 500;
|
|
34
|
+
Object.assign(this, details);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const refus = (code, message, details) => { throw new ErreurPrononciation(code, message, details); };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @mostajs/prononciation — lexiques (port `lexique` : { phonemes(mot) → string[] | null }).
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
import { arpaVersIpa } from './notation.js';
|
|
4
|
+
import { refus } from './erreurs.js';
|
|
5
|
+
|
|
6
|
+
const ARPABET = /^[A-Z]{1,2}[0-2]?$/;
|
|
7
|
+
/** Une suite de sons (chaîne séparée par des espaces, ou tableau), en IPA ou en ARPAbet. */
|
|
8
|
+
function lireSons(valeur, mot) {
|
|
9
|
+
const sons = Array.isArray(valeur) ? valeur.map(String) : String(valeur ?? '').trim().split(/\s+/).filter(Boolean);
|
|
10
|
+
if (!sons.length) refus('CONFIGURATION', `lexique : le mot ${mot} n'a aucun son`);
|
|
11
|
+
return sons.every((s) => ARPABET.test(s)) ? sons.map(arpaVersIpa) : sons;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Un lexique depuis une table { MOT: 'k ʌ p' | 'K AH1 P' | ['k','ʌ','p'] }. */
|
|
15
|
+
export function creerLexique(table = {}) {
|
|
16
|
+
if (table === null || typeof table !== 'object') refus('CONFIGURATION', 'creerLexique : une table est attendue');
|
|
17
|
+
const m = new Map();
|
|
18
|
+
for (const [mot, sons] of Object.entries(table)) m.set(mot.toUpperCase(), Object.freeze(lireSons(sons, mot)));
|
|
19
|
+
return Object.freeze({ phonemes: (mot) => (m.has(String(mot).toUpperCase()) ? [...m.get(String(mot).toUpperCase())] : null), taille: m.size });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Un lexique depuis le texte d'un fichier CMUdict (BSD-2), fourni par l'application. Première variante gardée. */
|
|
23
|
+
export function lexiqueCmudict(texte) {
|
|
24
|
+
if (typeof texte !== 'string') refus('CONFIGURATION', 'lexiqueCmudict : le texte du fichier est attendu');
|
|
25
|
+
const table = {};
|
|
26
|
+
for (const ligne of texte.split(/\r?\n/)) {
|
|
27
|
+
if (!ligne.trim() || ligne.startsWith(';;;')) continue;
|
|
28
|
+
const [mot, ...sons] = ligne.trim().split(/\s+/);
|
|
29
|
+
if (!sons.length || /\(\d+\)$/.test(mot)) continue; // variante : TOMATO(2)
|
|
30
|
+
const cle = mot.toUpperCase();
|
|
31
|
+
if (!(cle in table)) table[cle] = sons.map((s) => s.toUpperCase()).filter((s) => !s.startsWith('#'));
|
|
32
|
+
}
|
|
33
|
+
return creerLexique(table);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Plusieurs lexiques consultés dans l'ordre : le premier qui connaît le mot l'emporte (les mots de l'enseignant d'abord). */
|
|
37
|
+
export function combinerLexiques(...lexiques) {
|
|
38
|
+
if (!lexiques.length || lexiques.some((l) => typeof l?.phonemes !== 'function')) refus('CONFIGURATION', 'combinerLexiques : des lexiques sont attendus');
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
phonemes(mot) {
|
|
41
|
+
for (const l of lexiques) { const s = l.phonemes(mot); if (s) return s; }
|
|
42
|
+
return null;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @mostajs/prononciation — notation des sons : ARPAbet → IPA, classes, égalité selon la règle, jetons d'espeak.
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
// Porté de mesure_moteur_speechocean762.py (dossier Entreprise/saas-making/produits/prononciation, règle stricte-2),
|
|
4
|
+
// mesuré sur les 2 500 phrases de test de speechocean762.
|
|
5
|
+
|
|
6
|
+
/** ARPAbet (CMUdict, speechocean762) → IPA à la manière d'espeak. */
|
|
7
|
+
export const ARPA = Object.freeze({
|
|
8
|
+
AA: 'ɑ', AE: 'æ', AH: 'ʌ', AO: 'ɔ', AW: 'aʊ', AY: 'aɪ', B: 'b', CH: 'tʃ', D: 'd', DH: 'ð', EH: 'ɛ', ER: 'ɜ',
|
|
9
|
+
EY: 'eɪ', F: 'f', G: 'ɡ', HH: 'h', IH: 'ɪ', IY: 'i', JH: 'dʒ', K: 'k', L: 'l', M: 'm', N: 'n', NG: 'ŋ',
|
|
10
|
+
OW: 'oʊ', OY: 'ɔɪ', P: 'p', R: 'ɹ', S: 's', SH: 'ʃ', T: 't', TH: 'θ', UH: 'ʊ', UW: 'u', V: 'v', W: 'w',
|
|
11
|
+
Y: 'j', Z: 'z', ZH: 'ʒ',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** Les 39 classes de sons de l'anglais que compare le module. */
|
|
15
|
+
export const PHONEMES = Object.freeze([...new Set(Object.values(ARPA))]);
|
|
16
|
+
|
|
17
|
+
/** Variantes de notation ramenées à leur classe (le modèle acoustique est multilingue, 392 symboles). */
|
|
18
|
+
export const CLASSES = Object.freeze({
|
|
19
|
+
'ə': 'ʌ', 'ɐ': 'ʌ', 'ɚ': 'ɜ', 'ɝ': 'ɜ', 'əʊ': 'oʊ', 'e': 'ɛ', 'ɒ': 'ɑ', 'g': 'ɡ', 'r': 'ɹ', 'ɾ': 't',
|
|
20
|
+
'ɫ': 'l', 'ᵻ': 'ɪ', 'ɪ̈': 'ɪ', 'o': 'oʊ', 'tS': 'tʃ', 'dZ': 'dʒ', 'th': 't', 'kh': 'k', 'ph': 'p',
|
|
21
|
+
'e̞': 'ɛ', 'o̞': 'oʊ', 'a': 'æ', 'ä': 'ɑ', 'l̩': 'l', 'n̩': 'n',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Symboles composés d'espeak découpés en leurs sons. */
|
|
25
|
+
export const COMPOSES = Object.freeze({
|
|
26
|
+
'ɑɹ': ['ɑ', 'ɹ'], 'ɔɹ': ['ɔ', 'ɹ'], 'oɹ': ['ɔ', 'ɹ'], 'ɪɹ': ['ɪ', 'ɹ'], 'ɛɹ': ['ɛ', 'ɹ'], 'ʊɹ': ['ʊ', 'ɹ'],
|
|
27
|
+
'əl': ['ʌ', 'l'], 'aɪə': ['aɪ', 'ʌ'], 'aɪɚ': ['aɪ', 'ɜ'], 'ju': ['j', 'u'], 'ja': ['j', 'ɑ'], 'iə': ['i', 'ʌ'],
|
|
28
|
+
'eə': ['ɛ', 'ʌ'], 'ʊə': ['ʊ', 'ʌ'],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const REGLES = Object.freeze(['stricte-2', 'tolerante-2']);
|
|
32
|
+
const VOYELLES = new Set('aeiouæɑɒɔəɛɜɪʊʌy');
|
|
33
|
+
|
|
34
|
+
/** Un son ARPAbet (AA1, AH0…) → classe IPA ; AH0 et IY0 gardent le suffixe 0 (non accentué). */
|
|
35
|
+
export function arpaVersIpa(ph) {
|
|
36
|
+
const base = String(ph).toUpperCase().replace(/\d/g, '');
|
|
37
|
+
const brut = ARPA[base] ?? base.toLowerCase();
|
|
38
|
+
const ipa = CLASSES[brut] ?? brut;
|
|
39
|
+
return (base === 'AH' || base === 'IY') && /0$/.test(ph) ? `${ipa}0` : ipa;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** La classe d'un son attendu, sans la marque 0. */
|
|
43
|
+
export const base = (p) => (p.endsWith('0') ? p.slice(0, -1) : p);
|
|
44
|
+
|
|
45
|
+
function nettoyer(p) {
|
|
46
|
+
let q = p.replace(/[ːˈˌ:."^[ʰʲ]/g, '').replace(/[1-5]+$/, '');
|
|
47
|
+
if (q.length > 1 && q.endsWith('ɜ') && VOYELLES.has(q[0])) q = q.slice(0, -1); // ton 3 noté ɜ
|
|
48
|
+
return q;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Jetons bruts d'espeak (sortie du modèle) → sons comparables. */
|
|
52
|
+
export function normaliserJetons(jetons) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const j of jetons) {
|
|
55
|
+
const p = nettoyer(String(j).trim());
|
|
56
|
+
if (!p) continue;
|
|
57
|
+
for (const q of COMPOSES[p] ?? [p]) out.push(CLASSES[q] ?? q);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Le son attendu `a` (suffixe 0 possible) est-il le son entendu `b`, selon la règle ? */
|
|
63
|
+
export function egal(a, b, regle = 'stricte-2') {
|
|
64
|
+
const x = base(a);
|
|
65
|
+
if (x === b) return true;
|
|
66
|
+
if (regle === 'tolerante-2') {
|
|
67
|
+
if ((x === 'ɑ' && b === 'ɔ') || (x === 'ɔ' && b === 'ɑ')) return true;
|
|
68
|
+
if ((a === 'ʌ0' || a === 'i0') && b === 'ɪ') return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
package/src/lib/note.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @mostajs/prononciation — les notes : simple (part des sons retrouvés) et apprise (traits GOP → réseau → ridge).
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
// La passe avant reproduit apprendre_note.py (dossier Entreprise/saas-making/produits/prononciation, doc 13) ;
|
|
4
|
+
// l'essai T-PRO-11 compare ses sorties à celles de Python sur des phrases de contrôle.
|
|
5
|
+
import { refus } from './erreurs.js';
|
|
6
|
+
|
|
7
|
+
export const ETATS = Object.freeze(['juste', 'faux', 'omis']);
|
|
8
|
+
export const NB_TRAITS = 8;
|
|
9
|
+
const NB_AGREGATS = 9;
|
|
10
|
+
|
|
11
|
+
/** Note simple : un son retrouvé vaut 2, sinon 0 ; la phrase vaut 10 × part retrouvée. */
|
|
12
|
+
export function noteSimple(etats) {
|
|
13
|
+
const notes = etats.map((e) => (e === 'juste' ? 2 : 0));
|
|
14
|
+
return { notes, note: etats.length ? (10 * notes.filter((x) => x === 2).length) / etats.length : 0 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const nombres = (x, n) => Array.isArray(x) && x.length === n && x.every(Number.isFinite);
|
|
18
|
+
const matrice = (w, lignes, colonnes) => Array.isArray(w) && w.length === lignes && w.every((r) => nombres(r, colonnes));
|
|
19
|
+
|
|
20
|
+
/** Vérifie la forme d'un objet de poids ; rend sa dimension d'entrée par son. */
|
|
21
|
+
export function validerNote(note) {
|
|
22
|
+
const mal = (m) => refus('CONFIGURATION', `note apprise mal formée : ${m}`);
|
|
23
|
+
if (note === null || typeof note !== 'object') mal('un objet est attendu');
|
|
24
|
+
if (typeof note.nom !== 'string' || !note.nom) mal('nom');
|
|
25
|
+
if (!Array.isArray(note.phonemes) || !note.phonemes.length) mal('phonemes');
|
|
26
|
+
const d = NB_TRAITS + ETATS.length + 1 + note.phonemes.length;
|
|
27
|
+
const ph = note.phoneme ?? {};
|
|
28
|
+
if (!nombres(ph.moy, d) || !nombres(ph.ect, d)) mal(`phoneme.moy et phoneme.ect : ${d} valeurs attendues`);
|
|
29
|
+
if (ph.modele === 'reseau') {
|
|
30
|
+
const c = ph.couches;
|
|
31
|
+
if (!Array.isArray(c) || !c.length) mal('phoneme.couches');
|
|
32
|
+
let entree = d;
|
|
33
|
+
c.forEach(([w, b], k) => {
|
|
34
|
+
if (!Array.isArray(b) || !b.length || !matrice(w, b.length, entree) || !nombres(b, b.length)) mal(`couche ${k}`);
|
|
35
|
+
entree = b.length;
|
|
36
|
+
});
|
|
37
|
+
if (entree !== 1) mal('la dernière couche doit rendre une note');
|
|
38
|
+
} else if (ph.modele === 'ridge') {
|
|
39
|
+
if (!nombres(ph.ridge, d + 1)) mal('phoneme.ridge');
|
|
40
|
+
} else mal('phoneme.modele : reseau ou ridge');
|
|
41
|
+
const phr = note.phrase ?? {};
|
|
42
|
+
if (!nombres(phr.moy, NB_AGREGATS) || !nombres(phr.ect, NB_AGREGATS) || !nombres(phr.ridge, NB_AGREGATS + 1)) mal('phrase');
|
|
43
|
+
const s = note.seuils ?? {};
|
|
44
|
+
if (!Number.isFinite(s.phrase) || !Number.isFinite(s.phoneme)) mal('seuils');
|
|
45
|
+
return d;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lineaire = (w, b, x) => b.map((bi, i) => { let s = bi; const r = w[i]; for (let k = 0; k < x.length; k++) s += r[k] * x[k]; return s; });
|
|
49
|
+
const ridge = (w, x) => { let s = w[w.length - 1]; for (let k = 0; k < x.length; k++) s += w[k] * x[k]; return s; };
|
|
50
|
+
|
|
51
|
+
function vecteur(note, index, p, etat, traits) {
|
|
52
|
+
const t = traits.slice();
|
|
53
|
+
t[5] = Math.log1p(t[5]);
|
|
54
|
+
t[6] = Math.log1p(t[6]);
|
|
55
|
+
const un = new Array(note.phonemes.length).fill(0);
|
|
56
|
+
const b = p.endsWith('0') ? p.slice(0, -1) : p;
|
|
57
|
+
if (index.has(b)) un[index.get(b)] = 1;
|
|
58
|
+
const x = [...t, ...ETATS.map((s) => (etat === s ? 1 : 0)), p.endsWith('0') ? 1 : 0, ...un];
|
|
59
|
+
return x.map((v, k) => (v - note.phoneme.moy[k]) / note.phoneme.ect[k]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function centile(tries, q) {
|
|
63
|
+
const pos = (tries.length - 1) * q, bas = Math.floor(pos), haut = Math.ceil(pos);
|
|
64
|
+
return tries[bas] + (tries[haut] - tries[bas]) * (pos - bas);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function agregats(notes, etats, nEntendus, duree) {
|
|
68
|
+
const n = notes.map((v) => Math.min(2, Math.max(0, v)));
|
|
69
|
+
const moy = n.reduce((a, b) => a + b, 0) / n.length;
|
|
70
|
+
const ecart = Math.sqrt(n.reduce((a, b) => a + (b - moy) ** 2, 0) / n.length);
|
|
71
|
+
const part = (f) => n.filter(f).length / n.length;
|
|
72
|
+
const total = Math.max(1, etats.length);
|
|
73
|
+
return [moy, Math.min(...n), ecart, centile([...n].sort((a, b) => a - b), 0.1), part((v) => v < 1), part((v) => v < 1.5),
|
|
74
|
+
etats.filter((e) => e === 'juste').length / etats.length, nEntendus / total, duree / total];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const indexDe = new WeakMap();
|
|
78
|
+
/**
|
|
79
|
+
* Applique la note apprise. Notes brutes, non bornées (la parité avec Python se juge sur elles) ; le service les borne.
|
|
80
|
+
* @param {object} note poids (NOTE_APPRISE_1)
|
|
81
|
+
* @param {{ phonemes: string[], etats: string[], traits: number[][], nEntendus: number, duree: number }} e
|
|
82
|
+
* @returns {{ notes: number[], note: number }}
|
|
83
|
+
*/
|
|
84
|
+
export function appliquerNote(note, { phonemes, etats, traits, nEntendus, duree }) {
|
|
85
|
+
if (!indexDe.has(note)) { validerNote(note); indexDe.set(note, new Map(note.phonemes.map((p, i) => [p, i]))); }
|
|
86
|
+
const index = indexDe.get(note);
|
|
87
|
+
const notes = phonemes.map((p, i) => {
|
|
88
|
+
let x = vecteur(note, index, p, etats[i], traits[i]);
|
|
89
|
+
if (note.phoneme.modele === 'ridge') return ridge(note.phoneme.ridge, x);
|
|
90
|
+
note.phoneme.couches.forEach(([w, b], k) => {
|
|
91
|
+
x = lineaire(w, b, x);
|
|
92
|
+
if (k < note.phoneme.couches.length - 1) x = x.map((v) => (v > 0 ? v : 0));
|
|
93
|
+
});
|
|
94
|
+
return x[0];
|
|
95
|
+
});
|
|
96
|
+
const a = agregats(notes, etats, nEntendus, duree).map((v, k) => (v - note.phrase.moy[k]) / note.phrase.ect[k]);
|
|
97
|
+
return { notes, note: ridge(note.phrase.ridge, a) };
|
|
98
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @mostajs/prononciation — les sons qui résistent, sur un ensemble de résultats (la vue de classe).
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {object[]} resultats des résultats de evaluer()
|
|
6
|
+
* @param {{ minEssais?: number }} [o] un son vu moins de minEssais fois est écarté
|
|
7
|
+
* @returns {{ phoneme: string, essais: number, echecs: number, taux: number }[]} du plus résistant au moins résistant
|
|
8
|
+
*/
|
|
9
|
+
export function resistances(resultats, { minEssais = 1 } = {}) {
|
|
10
|
+
const t = new Map();
|
|
11
|
+
for (const r of resultats ?? []) {
|
|
12
|
+
for (const m of r?.mots ?? []) {
|
|
13
|
+
for (const ph of m.phonemes ?? []) {
|
|
14
|
+
const c = t.get(ph.attendu) ?? { phoneme: ph.attendu, essais: 0, echecs: 0 };
|
|
15
|
+
c.essais += 1;
|
|
16
|
+
if (ph.aReprendre) c.echecs += 1;
|
|
17
|
+
t.set(ph.attendu, c);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return [...t.values()]
|
|
22
|
+
.filter((c) => c.essais >= minEssais)
|
|
23
|
+
.map((c) => ({ ...c, taux: c.echecs / c.essais }))
|
|
24
|
+
.sort((a, b) => b.taux - a.taux || b.essais - a.essais || a.phoneme.localeCompare(b.phoneme));
|
|
25
|
+
}
|
package/src/lib/texte.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @mostajs/prononciation — découpe d'un texte en mots. Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
/** Texte → mots en majuscules : ponctuation ôtée, apostrophes internes gardées (I'D, DON'T), traits d'union coupés. */
|
|
4
|
+
export function motsDe(texte) {
|
|
5
|
+
return String(texte ?? '')
|
|
6
|
+
.replace(/[’‘ʼ`]/g, "'")
|
|
7
|
+
.split(/[^\p{L}\p{N}']+/u)
|
|
8
|
+
.map((m) => m.replace(/^'+|'+$/g, ''))
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
.map((m) => m.toUpperCase());
|
|
11
|
+
}
|