primocode 10.14.1 → 10.15.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/LICENSES/robo.txt +19 -0
- package/README.md +6 -2
- package/bin/primocode.js +54 -3
- package/lib/act.js +1 -0
- package/lib/conta.js +1 -0
- package/lib/estudio/exportar.js +1 -0
- package/lib/mcp.js +1 -0
- package/lib/preparar.js +2 -0
- package/lib/tools.js +1 -0
- package/lib/voz-ponte.js +15 -0
- package/lib/voz.js +99 -5
- package/package.json +3 -2
- package/voz/primo_voz/__main__.py +429 -57
- package/voz/primo_voz/app.py +350 -0
- package/voz/primo_voz/appmac.py +124 -0
- package/voz/primo_voz/assets/mascote.svg +1 -0
- package/voz/primo_voz/conversa.py +236 -0
- package/voz/primo_voz/escuta.py +56 -4
- package/voz/primo_voz/fala.py +87 -0
- package/voz/primo_voz/hud.html +584 -0
- package/voz/primo_voz/hud.py +275 -0
- package/voz/primo_voz/identidade.py +88 -0
- package/voz/primo_voz/luz.py +137 -0
- package/voz/primo_voz/painel.py +459 -0
- package/voz/primo_voz/painel_tui.py +26 -0
- package/voz/primo_voz/sons.py +96 -0
- package/voz/primo_voz/tela.py +319 -0
- package/voz/primo_voz/transcrever.py +13 -2
- package/voz/primo_voz/vendor/RobotExpressive.glb +0 -0
- package/voz/primo_voz/vendor/primo-icone.png +0 -0
- package/voz/primo_voz/vendor/spline-runtime.min.js +8219 -0
- package/voz/primo_voz/vendor/three-robo.min.js +3824 -0
- package/voz/primo_voz/vendor/three.LICENSE.txt +21 -0
- package/voz/primo_voz/vivo.py +34 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""conversa.py — a lógica da conversa, sem microfone, sem tela e sem som.
|
|
2
|
+
|
|
3
|
+
Tudo aqui é puro (entra texto/evento, sai decisão), de propósito: é o que o
|
|
4
|
+
teste mede. O que toca hardware (escuta.py, hud.py, __main__.py) só chama isto.
|
|
5
|
+
|
|
6
|
+
Maquina ouvindo → pensando → falando → (interrompido) → ouvindo
|
|
7
|
+
Fatiador texto em streaming → frases prontas para falar
|
|
8
|
+
resumo resposta longa → (fala curta, o que vai para o painel)
|
|
9
|
+
ativacao "Primo, abre o projeto" → (True, "abre o projeto")
|
|
10
|
+
comando "esconde o painel" → "esconder"
|
|
11
|
+
confirmacao ferramenta → "rodando os testes"
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import unicodedata
|
|
17
|
+
from typing import List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sem_acento(s: str) -> str:
|
|
21
|
+
return "".join(ch for ch in unicodedata.normalize("NFD", s) if unicodedata.category(ch) != "Mn")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalizar(s: str) -> str:
|
|
25
|
+
"""minúsculo, sem acento, sem pontuação — para comparar o que o Whisper deu."""
|
|
26
|
+
s = _sem_acento(str(s or "").lower())
|
|
27
|
+
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]+", " ", s)).strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── a máquina de estados ─────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
ESTADOS = ("dormindo", "ouvindo", "pensando", "falando", "executando", "interrompido")
|
|
33
|
+
|
|
34
|
+
# evento → {estado de origem → estado novo}. O que não está aqui é ignorado
|
|
35
|
+
# (um evento fora de hora não pode quebrar a conversa).
|
|
36
|
+
TRANSICOES = {
|
|
37
|
+
"ativar": {"dormindo": "ouvindo", "ouvindo": "ouvindo"},
|
|
38
|
+
"dormir": {"ouvindo": "dormindo", "interrompido": "dormindo"},
|
|
39
|
+
"ouviu": {"ouvindo": "pensando", "interrompido": "pensando", "dormindo": "pensando"},
|
|
40
|
+
"ferramenta": {"pensando": "executando", "executando": "executando", "falando": "falando"},
|
|
41
|
+
"resposta": {"pensando": "falando", "executando": "falando", "ouvindo": "falando"},
|
|
42
|
+
"fim_fala": {"falando": "ouvindo", "interrompido": "ouvindo"},
|
|
43
|
+
"sem_fala": {"pensando": "ouvindo", "executando": "ouvindo"},
|
|
44
|
+
"cortou": {"falando": "interrompido"},
|
|
45
|
+
"escutar": {"interrompido": "ouvindo"},
|
|
46
|
+
"parar": {"pensando": "ouvindo", "executando": "ouvindo", "falando": "ouvindo", "interrompido": "ouvindo"},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Maquina:
|
|
51
|
+
"""O estado da conversa. `mandar(evento)` devolve o estado novo."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, inicial: str = "ouvindo") -> None:
|
|
54
|
+
self.estado = inicial
|
|
55
|
+
self.historico: List[str] = [inicial]
|
|
56
|
+
|
|
57
|
+
def mandar(self, evento: str) -> str:
|
|
58
|
+
novo = TRANSICOES.get(evento, {}).get(self.estado)
|
|
59
|
+
if novo and novo != self.estado:
|
|
60
|
+
self.estado = novo
|
|
61
|
+
self.historico.append(novo)
|
|
62
|
+
return self.estado
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def pode_cortar(self) -> bool:
|
|
66
|
+
"""Falar por cima só corta se ele está falando."""
|
|
67
|
+
return self.estado == "falando"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── frase a frase ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
_FIM = re.compile(r"(?<=[.!?…])\s+|(?<=\n)")
|
|
73
|
+
_ABREV = re.compile(r"\b(sr|sra|dr|dra|etc|ex|obs|vs|p\.ex)\.$", re.I)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Fatiador:
|
|
77
|
+
"""Recebe texto aos pedaços (streaming) e devolve frases completas.
|
|
78
|
+
|
|
79
|
+
Frase curta demais não sai sozinha (a voz picotada soa pior que a espera):
|
|
80
|
+
gruda na seguinte. A primeira frase sai mais cedo (mínimo menor), porque é
|
|
81
|
+
ela que decide quanto tempo a pessoa espera até ouvir algo."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, minimo: int = 24, minimo_primeira: int = 12) -> None:
|
|
84
|
+
self.buf = ""
|
|
85
|
+
self.minimo = minimo
|
|
86
|
+
self.minimo_primeira = minimo_primeira
|
|
87
|
+
self.saiu = 0
|
|
88
|
+
|
|
89
|
+
def _alvo(self) -> int:
|
|
90
|
+
return self.minimo_primeira if self.saiu == 0 else self.minimo
|
|
91
|
+
|
|
92
|
+
def alimentar(self, pedaco: str) -> List[str]:
|
|
93
|
+
self.buf += pedaco or ""
|
|
94
|
+
return self._tirar(final=False)
|
|
95
|
+
|
|
96
|
+
def fechar(self) -> List[str]:
|
|
97
|
+
return self._tirar(final=True)
|
|
98
|
+
|
|
99
|
+
def _tirar(self, final: bool) -> List[str]:
|
|
100
|
+
saida: List[str] = []
|
|
101
|
+
partes = _FIM.split(self.buf)
|
|
102
|
+
resto = partes.pop() if partes else ""
|
|
103
|
+
acumulado = ""
|
|
104
|
+
for p in partes:
|
|
105
|
+
acumulado = (acumulado + " " + p).strip() if acumulado else p.strip()
|
|
106
|
+
if _ABREV.search(acumulado):
|
|
107
|
+
continue
|
|
108
|
+
if len(acumulado) >= self._alvo():
|
|
109
|
+
saida.append(acumulado)
|
|
110
|
+
self.saiu += 1
|
|
111
|
+
acumulado = ""
|
|
112
|
+
self.buf = (acumulado + " " + resto).strip() if acumulado else resto
|
|
113
|
+
if final and self.buf.strip():
|
|
114
|
+
saida.append(self.buf.strip())
|
|
115
|
+
self.saiu += 1
|
|
116
|
+
self.buf = ""
|
|
117
|
+
return saida
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def dividir_frases(texto: str) -> List[str]:
|
|
121
|
+
f = Fatiador()
|
|
122
|
+
return f.alimentar(texto) + f.fechar()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── o que vai para o ouvido e o que vai para o painel ────────────────────
|
|
126
|
+
|
|
127
|
+
def limpar_para_voz(texto: str) -> str:
|
|
128
|
+
t = str(texto or "")
|
|
129
|
+
t = re.sub(r"```[\s\S]*?```", " ", t)
|
|
130
|
+
t = re.sub(r"\s+(em|no|na|de|do|da|para)\s+`[^`]*[/\\.][^`]*`", "", t)
|
|
131
|
+
t = re.sub(r"`([^`]*)`", lambda m: "" if re.search(r"[/\\.]", m.group(1)) else m.group(1), t)
|
|
132
|
+
t = re.sub(r"https?://\S+", "", t)
|
|
133
|
+
t = re.sub(r"(^|\s)[~.]?/[\w.\-/]+", " ", t)
|
|
134
|
+
t = re.sub(r"^\s*(#+|[-*•]|\d+[.)])\s+", "", t, flags=re.M)
|
|
135
|
+
t = re.sub(r"[*_>|]", "", t)
|
|
136
|
+
return re.sub(r"\s+", " ", t).strip()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resumo(texto: str, frases: int = 2, teto: int = 260) -> Tuple[str, str]:
|
|
140
|
+
"""(fala, painel). A voz resume; o texto inteiro (código, listas) vai ao painel."""
|
|
141
|
+
painel = str(texto or "").strip()
|
|
142
|
+
limpo = limpar_para_voz(painel)
|
|
143
|
+
partes = [p.strip() for p in re.findall(r"[^.!?…]+[.!?…]*", limpo) if p.strip()]
|
|
144
|
+
fala = ""
|
|
145
|
+
for p in partes[:frases]:
|
|
146
|
+
if len(fala) + len(p) > teto:
|
|
147
|
+
break
|
|
148
|
+
fala = (fala + " " + p).strip()
|
|
149
|
+
if not fala:
|
|
150
|
+
fala = limpo[:teto].strip()
|
|
151
|
+
tinha_mais = bool(re.search(r"```", painel)) or len(partes) > frases
|
|
152
|
+
if tinha_mais and fala and not re.search(r"painel|tela", fala, re.I):
|
|
153
|
+
fala += " O resto está no painel."
|
|
154
|
+
return fala, painel
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── a palavra de ativação ────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
_NOMES = ("primo", "primu", "prima", "primocode", "primo code")
|
|
160
|
+
_ANTES = ("ei", "ey", "hey", "oi", "ola", "fala", "e ai", "opa", "salve")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def ativacao(texto: str, nome: str = "Primo") -> Tuple[bool, str]:
|
|
164
|
+
"""Começa com "Primo" (ou "ei Primo")? Devolve (sim?, o resto do pedido).
|
|
165
|
+
|
|
166
|
+
Só vale no INÍCIO da fala: "o primo da Ana ligou" não acorda ninguém."""
|
|
167
|
+
cru = str(texto or "").strip()
|
|
168
|
+
n = normalizar(cru)
|
|
169
|
+
meus = _NOMES if normalizar(nome) in ("primo", "") else (normalizar(nome),)
|
|
170
|
+
for ant in ("",) + _ANTES:
|
|
171
|
+
for nm in meus:
|
|
172
|
+
pre = (ant + " " + nm).strip()
|
|
173
|
+
if n == pre or n.startswith(pre + " "):
|
|
174
|
+
resto = re.sub(r"^\W*(?:" + r"\W+".join(re.escape(w) for w in pre.split()) + r")\W*", "",
|
|
175
|
+
_sem_acento(cru), flags=re.I)
|
|
176
|
+
# o corte vale no texto sem acento; o tamanho é o mesmo
|
|
177
|
+
resto = cru[len(cru) - len(resto):] if len(resto) <= len(cru) else resto
|
|
178
|
+
return True, resto.strip(" ,.!?;:-")
|
|
179
|
+
return False, cru
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── comandos de voz que o painel e o modo resolvem sozinhos ──────────────
|
|
183
|
+
|
|
184
|
+
_COMANDOS = [
|
|
185
|
+
("esconder", r"(esconde|esconder|some com|fecha|oculta) (o )?(painel|hud)"),
|
|
186
|
+
("mostrar", r"(mostra|mostrar|abre|exibe|volta com) (o )?(painel|hud)"),
|
|
187
|
+
("parar", r"(para|pare|calma|chega|cala a boca|silencio|cancela)"),
|
|
188
|
+
("olhar", r"(olha aqui|olha isso|olha a tela|veja a tela|ve a tela|olha pra tela|ve isso)"),
|
|
189
|
+
("resumo_dia", r"(resumo do dia|como foi o dia|resumo de hoje|briefing|o que mudou hoje)"),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def comando(texto: str) -> Optional[str]:
|
|
194
|
+
n = normalizar(texto)
|
|
195
|
+
if not n or len(n.split()) > 6: # comando é curto; frase longa é pedido
|
|
196
|
+
return None
|
|
197
|
+
for nome, rx in _COMANDOS:
|
|
198
|
+
if re.fullmatch(rx + r"( por favor| ai| agora)?", n):
|
|
199
|
+
return nome
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── confirmações curtas de ação ──────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def confirmacao(ferramenta: str, args: Optional[dict] = None) -> str:
|
|
206
|
+
"""O que o Primo diz ao começar uma ferramenta. Vazio = não diz nada."""
|
|
207
|
+
f = str(ferramenta or "")
|
|
208
|
+
cmd = str((args or {}).get("command") or "")
|
|
209
|
+
if f == "run_shell":
|
|
210
|
+
if re.search(r"\b(test|jest|pytest|vitest)\b", cmd):
|
|
211
|
+
return "Rodando os testes."
|
|
212
|
+
if re.search(r"\bgit\s+(push|commit)\b", cmd):
|
|
213
|
+
return "Mandando pro git."
|
|
214
|
+
if re.search(r"\b(build|tsc)\b", cmd):
|
|
215
|
+
return "Fazendo o build."
|
|
216
|
+
return "Rodando."
|
|
217
|
+
if f in ("desktop_open_app",):
|
|
218
|
+
return "Abrindo."
|
|
219
|
+
if f in ("write_file", "edit_file", "apply_patch"):
|
|
220
|
+
return "Editando."
|
|
221
|
+
if f in ("web_search", "pesquisar_referencia"):
|
|
222
|
+
return "Pesquisando."
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def rotulo_ferramenta(ferramenta: str, args: Optional[dict] = None) -> str:
|
|
227
|
+
"""O texto do painel: ferramenta + alvo curto."""
|
|
228
|
+
a = args or {}
|
|
229
|
+
alvo = a.get("path") or a.get("command") or a.get("query") or a.get("url") or ""
|
|
230
|
+
alvo = re.sub(r"\s+", " ", str(alvo)).strip()
|
|
231
|
+
return (str(ferramenta) + (" · " + alvo[:48] if alvo else ""))[:70]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def frase_final(texto: str) -> str:
|
|
235
|
+
"""Termina a fala de ação com um 'feito' quando a resposta não tem fala."""
|
|
236
|
+
return texto if texto.strip() else "Feito."
|
package/voz/primo_voz/escuta.py
CHANGED
|
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|
|
30
30
|
|
|
31
31
|
import threading
|
|
32
32
|
import time
|
|
33
|
+
import os
|
|
33
34
|
import wave
|
|
34
35
|
from pathlib import Path
|
|
35
36
|
from typing import List, Optional
|
|
@@ -46,9 +47,10 @@ TETO_FALA = 700.0 # nunca exige mais que isso (não ficar surdo)
|
|
|
46
47
|
FATOR_CONTINUAR = 0.5 # a histerese: depois de começar, o limiar cai
|
|
47
48
|
PRE_ROLL = 4 # blocos guardados ANTES do início da fala
|
|
48
49
|
|
|
49
|
-
RABO_SILENCIO =
|
|
50
|
-
RABO_SILENCIO_LONGA =
|
|
50
|
+
RABO_SILENCIO = 1.0 # silêncio que encerra a frase
|
|
51
|
+
RABO_SILENCIO_LONGA = 1.5 # …e quando a fala já vem longa
|
|
51
52
|
FALA_LONGA = 6.0
|
|
53
|
+
RABO_VAD = 0.5 # silêncio que encerra a frase quando o VAD confirma
|
|
52
54
|
ESPERA_COMECAR = 8.0 # tempo para a pessoa começar a falar
|
|
53
55
|
MAX_FRASE = 45.0
|
|
54
56
|
MAX_FRASE_ESTENDIDA = 100.0
|
|
@@ -93,6 +95,9 @@ class Gravador:
|
|
|
93
95
|
self._ambiente = PISO_FALA
|
|
94
96
|
self._amostras_ambiente: List[float] = []
|
|
95
97
|
self.encerrou_por = ""
|
|
98
|
+
# VAD opcional (Silero, via pysilero-vad): bloco → True se for voz.
|
|
99
|
+
# Com ele, a frase fecha em 0,9 s de silêncio em vez de 2 s.
|
|
100
|
+
self.vad = None
|
|
96
101
|
|
|
97
102
|
# O ambiente é medido ENQUANTO ele espera a pessoa começar. Medir antes de
|
|
98
103
|
# abrir o microfone daria o silêncio do processo, não o do cômodo.
|
|
@@ -114,6 +119,12 @@ class Gravador:
|
|
|
114
119
|
self._relogio += BLOCO_MS / 1000.0
|
|
115
120
|
agora = self._relogio
|
|
116
121
|
nivel = rms(bloco)
|
|
122
|
+
voz = None
|
|
123
|
+
if self.vad is not None:
|
|
124
|
+
try:
|
|
125
|
+
voz = bool(self.vad(bloco))
|
|
126
|
+
except Exception:
|
|
127
|
+
self.vad = None
|
|
117
128
|
|
|
118
129
|
if not self._falando:
|
|
119
130
|
# Guarda os últimos blocos: sem eles, a frase começa cortada na
|
|
@@ -128,7 +139,7 @@ class Gravador:
|
|
|
128
139
|
if len(self._amostras_ambiente) >= 5:
|
|
129
140
|
self._ambiente = float(np.median(self._amostras_ambiente))
|
|
130
141
|
|
|
131
|
-
if nivel > self._limiar():
|
|
142
|
+
if nivel > self._limiar() and voz is not False:
|
|
132
143
|
self._falando = True
|
|
133
144
|
self._blocos = list(self._pre)
|
|
134
145
|
self._ultimo_som = agora
|
|
@@ -138,11 +149,13 @@ class Gravador:
|
|
|
138
149
|
return False
|
|
139
150
|
|
|
140
151
|
self._blocos.append(bloco)
|
|
141
|
-
if nivel > self._limiar():
|
|
152
|
+
if voz is True or (voz is None and nivel > self._limiar()):
|
|
142
153
|
self._ultimo_som = agora
|
|
143
154
|
|
|
144
155
|
duracao = len(self._blocos) * BLOCO_MS / 1000.0
|
|
145
156
|
rabo = RABO_SILENCIO_LONGA if duracao > FALA_LONGA else RABO_SILENCIO
|
|
157
|
+
if self.vad is not None:
|
|
158
|
+
rabo = RABO_VAD
|
|
146
159
|
|
|
147
160
|
if agora - self._ultimo_som > rabo:
|
|
148
161
|
self.encerrou_por = "silencio"
|
|
@@ -216,7 +229,28 @@ class Microfone:
|
|
|
216
229
|
self._ao_receber = ao_receber
|
|
217
230
|
self._fluxo = None
|
|
218
231
|
|
|
232
|
+
def _abrir_de_arquivo(self, caminho: str) -> None:
|
|
233
|
+
"""Modo de teste (PRIMOCODE_TESTE_WAV): em vez do microfone, toca um WAV 16 kHz mono na mesma entrada,
|
|
234
|
+
depois de 12 s de silêncio (a saudação termina e o motor assenta) e com silêncio no fim."""
|
|
235
|
+
import threading
|
|
236
|
+
import time as _t
|
|
237
|
+
import numpy as _np
|
|
238
|
+
|
|
239
|
+
def alimentar():
|
|
240
|
+
with wave.open(caminho, "rb") as w:
|
|
241
|
+
dados = _np.frombuffer(w.readframes(w.getnframes()), dtype=_np.int16)
|
|
242
|
+
_t.sleep(float(os.environ.get("PRIMOCODE_TESTE_ATRASO", "12")))
|
|
243
|
+
silencio = _np.zeros(BLOCO, dtype=_np.int16)
|
|
244
|
+
for i in range(0, len(dados) + BLOCO * 20, BLOCO):
|
|
245
|
+
bloco = dados[i:i + BLOCO]
|
|
246
|
+
self._ao_receber(bloco if len(bloco) == BLOCO else (_np.concatenate([bloco, silencio])[:BLOCO]))
|
|
247
|
+
_t.sleep(BLOCO_MS / 1000)
|
|
248
|
+
threading.Thread(target=alimentar, daemon=True).start()
|
|
249
|
+
|
|
219
250
|
def abrir(self) -> None:
|
|
251
|
+
if os.environ.get("PRIMOCODE_TESTE_WAV"):
|
|
252
|
+
return self._abrir_de_arquivo(os.environ["PRIMOCODE_TESTE_WAV"])
|
|
253
|
+
|
|
220
254
|
def retorno(dados, quadros, tempo, estado): # noqa: ARG001
|
|
221
255
|
self._ao_receber(dados[:, 0].copy())
|
|
222
256
|
|
|
@@ -234,3 +268,21 @@ class Microfone:
|
|
|
234
268
|
except Exception:
|
|
235
269
|
pass
|
|
236
270
|
self._fluxo = None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def criar_vad():
|
|
274
|
+
"""O detector de voz Silero (MIT, pysilero-vad, só onnx/ggml) ou None se não instalou."""
|
|
275
|
+
try:
|
|
276
|
+
from pysilero_vad import SileroVoiceActivityDetector
|
|
277
|
+
det = SileroVoiceActivityDetector()
|
|
278
|
+
except Exception:
|
|
279
|
+
return None
|
|
280
|
+
passo = det.chunk_samples()
|
|
281
|
+
|
|
282
|
+
def vad(bloco: np.ndarray) -> bool:
|
|
283
|
+
pcm = bloco.astype(np.int16)
|
|
284
|
+
pico = 0.0
|
|
285
|
+
for i in range(0, len(pcm) - passo + 1, passo):
|
|
286
|
+
pico = max(pico, det(pcm[i:i + passo].tobytes()))
|
|
287
|
+
return pico > 0.5
|
|
288
|
+
return vad
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""fala.py — a voz do Primo: Edge TTS (neural pt-BR) dentro do processo, sem subprocesso por frase.
|
|
2
|
+
|
|
3
|
+
Ordem: Edge TTS (precisa de internet) → ElevenLabs só se a pessoa pediu (`"tts": "elevenlabs"` em
|
|
4
|
+
~/.primocode/voz.json) → voz do sistema (`say`) offline. As vozes: francisca (padrão), antonio, thalita.
|
|
5
|
+
O event loop e o import do edge_tts ficam vivos na thread `Edge`; cada frase é uma tarefa nele.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import threading
|
|
11
|
+
import unicodedata
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
VOZES = {"francisca": "pt-BR-FranciscaNeural", "antonio": "pt-BR-AntonioNeural", "thalita": "pt-BR-ThalitaMultilingualNeural"}
|
|
16
|
+
ORDEM = ["francisca", "antonio", "thalita"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _s(t: str) -> str:
|
|
20
|
+
return "".join(c for c in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(c) != "Mn")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def voz_pedida(texto: str, atual: str) -> Optional[str]:
|
|
24
|
+
""""muda a voz" / "troca a voz para antonio" → o id da nova voz (a próxima da lista, se não disse qual); None se não era isso."""
|
|
25
|
+
import re
|
|
26
|
+
m = re.search(r"\b(?:muda|mudar|troca|trocar|mude|troque)\s+(?:a\s+)?voz(?:\s+(?:para|pra|pro|do|da|de)\s+(?:a\s+|o\s+)?(\w+))?", _s(texto))
|
|
27
|
+
if not m:
|
|
28
|
+
return None
|
|
29
|
+
pedida = m.group(1)
|
|
30
|
+
if pedida and pedida in VOZES:
|
|
31
|
+
return pedida
|
|
32
|
+
i = ORDEM.index(atual) if atual in ORDEM else -1
|
|
33
|
+
return ORDEM[(i + 1) % len(ORDEM)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Edge:
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.ok = False
|
|
39
|
+
self._loop: "asyncio.AbstractEventLoop | None" = None
|
|
40
|
+
self._pronto = threading.Event()
|
|
41
|
+
threading.Thread(target=self._rodar, daemon=True).start()
|
|
42
|
+
|
|
43
|
+
def _rodar(self) -> None:
|
|
44
|
+
try:
|
|
45
|
+
import edge_tts # noqa: F401
|
|
46
|
+
self.ok = True
|
|
47
|
+
except ImportError:
|
|
48
|
+
self._pronto.set()
|
|
49
|
+
return
|
|
50
|
+
self._loop = asyncio.new_event_loop()
|
|
51
|
+
self._pronto.set()
|
|
52
|
+
self._loop.run_forever()
|
|
53
|
+
|
|
54
|
+
async def _tarefa(self, texto: str, voz: str, saida: Path) -> bool:
|
|
55
|
+
import edge_tts
|
|
56
|
+
n = 0
|
|
57
|
+
with open(saida, "wb") as f:
|
|
58
|
+
async for c in edge_tts.Communicate(texto, voz, rate="+8%").stream():
|
|
59
|
+
if c["type"] == "audio":
|
|
60
|
+
f.write(c["data"])
|
|
61
|
+
n += len(c["data"])
|
|
62
|
+
return n > 0
|
|
63
|
+
|
|
64
|
+
def sintetizar(self, texto: str, voz_id: str, saida: Path, limite: float = 15.0) -> bool:
|
|
65
|
+
self._pronto.wait(5)
|
|
66
|
+
if not self.ok or not self._loop:
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
fut = asyncio.run_coroutine_threadsafe(self._tarefa(texto, VOZES.get(voz_id, VOZES["francisca"]), saida), self._loop)
|
|
70
|
+
return bool(fut.result(limite))
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def aquecer(self) -> None:
|
|
75
|
+
"""Uma frase curta descartada: abre DNS, TLS e o módulo antes da primeira fala de verdade."""
|
|
76
|
+
import tempfile
|
|
77
|
+
p = Path(tempfile.gettempdir()) / "primo-voz-aquece.mp3"
|
|
78
|
+
self.sintetizar("oi", "francisca", p)
|
|
79
|
+
try:
|
|
80
|
+
p.unlink()
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def hora_falada(t) -> str:
|
|
86
|
+
""""São 19 e 05." — o mesmo texto no app.py (resposta) e no motor (pré-voz do próximo minuto)."""
|
|
87
|
+
return f"São {t.tm_hour} e {t.tm_min:02d}." if t.tm_min else f"São {t.tm_hour} horas em ponto."
|