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.
@@ -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."
@@ -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 = 2.0 # silêncio que encerra a frase
50
- RABO_SILENCIO_LONGA = 2.8 # …e quando a fala já vem 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."