primocode 7.18.0 → 8.0.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.
@@ -0,0 +1,254 @@
1
+ /* ==========================================================================
2
+ narracao.js — locução.
3
+
4
+ Três caminhos, do melhor para o pior:
5
+ 1. VOZ GRAVADA — o usuário grava com o microfone. Sempre ganha.
6
+ 2. VOZ PREMIUM — voz aprimorada do sistema, se estiver instalada.
7
+ 3. VOZ PADRÃO — síntese compacta do sistema. Serve para rascunho.
8
+
9
+ As vozes de brincadeira da Apple (Eddy, Grandma, Rocko…) ficam escondidas:
10
+ existem no sistema, mas não servem para narrar nada.
11
+ ========================================================================== */
12
+
13
+ var Narracao = (function () {
14
+ "use strict";
15
+
16
+ var sintese = window.speechSynthesis || null;
17
+ var cacheVozes = [];
18
+ var cancelado = false;
19
+
20
+ /* Vozes de novidade do macOS — som proposital de desenho animado. */
21
+ var NOVIDADE = /^(albert|bad news|bahh|bells|boing|bubbles|cellos|deranged|good news|jester|junior|kathy|organ|pipe organ|princess|ralph|superstar|trinoids|whisper|wobble|zarvox|fred|hysterical|eddy|flo|grandma|grandpa|reed|rocko|sandy|shelley|bruce|agnes|vicki|victoria|alice|alva|damayanti|fiona|moira|tessa|veena|rishi|karen|melina|xander)\b/i;
22
+
23
+ var PREMIUM = /premium|enhanced|aprimorad|neural|siri/i;
24
+
25
+ /* "Joana (português (Portugal))" → "Joana" — os parênteses aninham, então
26
+ corta a partir do primeiro em vez de tentar casar os pares. */
27
+ function limpo(nome) {
28
+ return String(nome || "").split(/\s*\(/)[0].trim() || String(nome || "");
29
+ }
30
+
31
+ function disponivel() { return !!sintese; }
32
+
33
+ /* nota de qualidade: quanto maior, melhor a voz para narrar */
34
+ function nota(v) {
35
+ var n = 0;
36
+ if (PREMIUM.test(v.name)) n += 100;
37
+ if (/pt[-_]?BR/i.test(v.lang)) n += 40;
38
+ else if (/^pt/i.test(v.lang)) n += 25;
39
+ if (v.localService) n += 5;
40
+ if (NOVIDADE.test(limpo(v.name))) n -= 200;
41
+ return n;
42
+ }
43
+
44
+ function vozes(op) {
45
+ op = op || {};
46
+ if (!sintese) return [];
47
+ var v = sintese.getVoices() || [];
48
+ if (v.length) cacheVozes = v;
49
+ var lista = cacheVozes.slice();
50
+ if (!op.todas) {
51
+ var uteis = lista.filter(function (x) { return !NOVIDADE.test(limpo(x.name)); });
52
+ if (uteis.length) lista = uteis;
53
+ }
54
+ if (op.idioma !== false) {
55
+ var pt = lista.filter(function (x) { return /^pt/i.test(x.lang); });
56
+ if (pt.length && !op.todosIdiomas) lista = pt;
57
+ }
58
+ // o sistema às vezes repete a mesma voz; fica só uma de cada
59
+ var vistas = {};
60
+ lista = lista.filter(function (v) {
61
+ var chave = limpo(v.name) + "|" + v.lang;
62
+ if (vistas[chave]) return false;
63
+ vistas[chave] = true;
64
+ return true;
65
+ });
66
+
67
+ return lista.sort(function (a, b) { return nota(b) - nota(a) || a.name.localeCompare(b.name); });
68
+ }
69
+
70
+ function melhorVoz() {
71
+ var v = vozes()[0];
72
+ return v ? v.name : null;
73
+ }
74
+
75
+ /* Quantas vozes boas existem: se der zero, vale avisar o usuário. */
76
+ function diagnostico() {
77
+ var todas = (sintese && sintese.getVoices()) || cacheVozes;
78
+ var pt = todas.filter(function (v) { return /^pt/i.test(v.lang); });
79
+ return {
80
+ total: todas.length,
81
+ portugues: pt.length,
82
+ premium: todas.filter(function (v) { return PREMIUM.test(v.name); }).length,
83
+ boasEmPortugues: pt.filter(function (v) { return !NOVIDADE.test(limpo(v.name)); }).length,
84
+ melhor: melhorVoz(),
85
+ };
86
+ }
87
+
88
+ function prontas() {
89
+ return new Promise(function (ok) {
90
+ if (!sintese) return ok([]);
91
+ if (vozes().length) return ok(vozes());
92
+ var t = setTimeout(function () { ok(vozes()); }, 1200);
93
+ sintese.addEventListener("voiceschanged", function so() {
94
+ clearTimeout(t);
95
+ sintese.removeEventListener("voiceschanged", so);
96
+ ok(vozes());
97
+ });
98
+ });
99
+ }
100
+
101
+ /* ------------------------------------------------------------ prosódia -- */
102
+
103
+ /* Fala corrida soa robótica. Quebrar em frases e respirar entre elas é o
104
+ que mais aproxima de uma locução de verdade. */
105
+ function emFrases(texto) {
106
+ return String(texto || "")
107
+ .split(/(?<=[.!?…:])\s+|\n+/)
108
+ .map(function (f) { return f.trim(); })
109
+ .filter(Boolean);
110
+ }
111
+
112
+ function pausaDe(frase) {
113
+ if (/[?!]$/.test(frase)) return 420;
114
+ if (/[.…]$/.test(frase)) return 340;
115
+ if (/:$/.test(frase)) return 260;
116
+ return 180;
117
+ }
118
+
119
+ function montar(texto, cfg) {
120
+ cfg = cfg || {};
121
+ var f = new SpeechSynthesisUtterance(String(texto || ""));
122
+ f.lang = cfg.idioma || "pt-BR";
123
+ // um pouco mais devagar que o padrão soa menos apressado
124
+ f.rate = cfg.velocidade != null ? cfg.velocidade : 0.96;
125
+ f.pitch = cfg.tom != null ? cfg.tom : 1;
126
+ f.volume = cfg.volume != null ? cfg.volume : 1;
127
+
128
+ var alvo = cfg.voz || melhorVoz();
129
+ if (alvo) {
130
+ var achou = (sintese.getVoices() || cacheVozes).filter(function (v) {
131
+ return v.name === alvo;
132
+ })[0];
133
+ if (achou) { f.voice = achou; f.lang = achou.lang; }
134
+ }
135
+ return f;
136
+ }
137
+
138
+ function falarUma(texto, cfg) {
139
+ return new Promise(function (ok) {
140
+ var f = montar(texto, cfg);
141
+ f.onend = f.onerror = function () { ok(); };
142
+ sintese.speak(f);
143
+ });
144
+ }
145
+
146
+ /* Fala o texto inteiro, frase a frase, com respiro entre elas. */
147
+ function falar(texto, cfg) {
148
+ if (!sintese || !String(texto || "").trim()) return Promise.resolve(0);
149
+ parar();
150
+ cancelado = false;
151
+
152
+ var frases = emFrases(texto);
153
+ var t0 = performance.now();
154
+
155
+ return frases.reduce(function (corrente, frase) {
156
+ return corrente.then(function () {
157
+ if (cancelado) return;
158
+ return falarUma(frase, cfg).then(function () {
159
+ if (cancelado) return;
160
+ return new Promise(function (r) { setTimeout(r, pausaDe(frase)); });
161
+ });
162
+ });
163
+ }, Promise.resolve()).then(function () {
164
+ return (performance.now() - t0) / 1000;
165
+ });
166
+ }
167
+
168
+ /* Mede quanto tempo a fala leva, sem ninguém ouvir. */
169
+ function medir(texto, cfg) {
170
+ var c = {};
171
+ for (var k in (cfg || {})) c[k] = cfg[k];
172
+ c.volume = 0;
173
+ return falar(texto, c);
174
+ }
175
+
176
+ function parar() {
177
+ cancelado = true;
178
+ if (!sintese) return;
179
+ try { sintese.cancel(); } catch (_) {}
180
+ }
181
+
182
+ function falando() { return !!sintese && sintese.speaking; }
183
+
184
+ function estimar(texto, velocidade) {
185
+ var palavras = String(texto || "").trim().split(/\s+/).filter(Boolean).length;
186
+ if (!palavras) return 0;
187
+ var frases = emFrases(texto).length;
188
+ return Math.max(1.2, palavras / (2.6 * (velocidade || 0.96)) + frases * 0.32);
189
+ }
190
+
191
+ /* -------------------------------------------------- gravar a própria voz */
192
+
193
+ var gravador = null, fluxoMic = null, pedacos = [];
194
+
195
+ function podeGravar() {
196
+ return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia &&
197
+ window.MediaRecorder);
198
+ }
199
+
200
+ /* Grava do microfone e devolve {url, nome} já salvo na biblioteca. */
201
+ function gravar() {
202
+ if (!podeGravar()) return Promise.reject(new Error("este navegador não grava áudio"));
203
+ return navigator.mediaDevices.getUserMedia({
204
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
205
+ }).then(function (fluxo) {
206
+ fluxoMic = fluxo;
207
+ pedacos = [];
208
+ var tipo = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
209
+ ? "audio/webm;codecs=opus"
210
+ : (MediaRecorder.isTypeSupported("audio/mp4") ? "audio/mp4" : "");
211
+ gravador = new MediaRecorder(fluxo, tipo ? { mimeType: tipo } : undefined);
212
+ gravador.ondataavailable = function (e) { if (e.data.size) pedacos.push(e.data); };
213
+ gravador.start();
214
+ return true;
215
+ });
216
+ }
217
+
218
+ function pararGravacao(nomeBase) {
219
+ return new Promise(function (ok, falha) {
220
+ if (!gravador || gravador.state !== "recording") return falha(new Error("nada gravando"));
221
+ gravador.onstop = function () {
222
+ if (fluxoMic) fluxoMic.getTracks().forEach(function (f) { f.stop(); });
223
+ var mime = gravador.mimeType || "audio/webm";
224
+ var blob = new Blob(pedacos, { type: mime });
225
+ var ext = /mp4/.test(mime) ? ".m4a" : ".weba";
226
+ var leitor = new FileReader();
227
+ leitor.onload = function () {
228
+ fetch("/api/midia", {
229
+ method: "POST",
230
+ headers: { "Content-Type": "application/json" },
231
+ body: JSON.stringify({
232
+ nome: (nomeBase || "narracao") + ext,
233
+ dados: leitor.result,
234
+ }),
235
+ }).then(function (r) { return r.json(); })
236
+ .then(function (m) { m.erro ? falha(new Error(m.erro)) : ok(m); })
237
+ .catch(falha);
238
+ };
239
+ leitor.onerror = function () { falha(new Error("não consegui ler a gravação")); };
240
+ leitor.readAsDataURL(blob);
241
+ };
242
+ gravador.stop();
243
+ });
244
+ }
245
+
246
+ function gravando() { return !!gravador && gravador.state === "recording"; }
247
+
248
+ return {
249
+ disponivel: disponivel, vozes: vozes, prontas: prontas, melhorVoz: melhorVoz,
250
+ diagnostico: diagnostico, limpo: limpo,
251
+ falar: falar, medir: medir, parar: parar, falando: falando, estimar: estimar,
252
+ podeGravar: podeGravar, gravar: gravar, pararGravacao: pararGravacao, gravando: gravando,
253
+ };
254
+ })();