primocode 9.0.0 → 9.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/studio/pessoa.py CHANGED
@@ -36,10 +36,30 @@ Uso:
36
36
 
37
37
  import argparse
38
38
  import json
39
+ import shutil
40
+ import os
39
41
  import subprocess
40
42
  import sys
41
43
 
42
44
 
45
+ def achar_ffmpeg():
46
+ """Onde está o ffmpeg — pelo PATH, e nos lugares onde ele costuma cair.
47
+
48
+ O PATH de um processo aberto pela interface gráfica não é o PATH do
49
+ terminal: no macOS um app aberto pelo ícone não enxerga /opt/homebrew/bin,
50
+ onde o Homebrew instala. Procurar nos lugares conhecidos evita um "não
51
+ achei" para quem tem o programa instalado.
52
+ """
53
+ achado = shutil.which("ffmpeg")
54
+ if achado:
55
+ return achado
56
+ for c in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg",
57
+ r"C:\Program Files\ffmpeg\bin\ffmpeg.exe"):
58
+ if os.path.exists(c):
59
+ return c
60
+ return None
61
+
62
+
43
63
  def faltando():
44
64
  """O que precisa estar instalado, e não está."""
45
65
  falta = []
@@ -76,8 +96,20 @@ def recortar(entrada, saida, suavizar=7, encolher=2, limiar=0.6):
76
96
 
77
97
  # VP9 num .webm é o formato de vídeo com transparência que o Chromium do
78
98
  # Remotion abre — e é o Chromium dele que vai desenhar isto.
99
+ # O ffmpeg pelo nome cru estoura FileNotFoundError, e o que chega ao
100
+ # usuário é o traceback do Python — que não diz o que instalar. Pior: aqui
101
+ # o vídeo já foi ABERTO e a segmentação está prestes a começar, então a
102
+ # falha aparece depois da espera, não antes dela.
103
+ binario = achar_ffmpeg()
104
+ if not binario:
105
+ captura.release()
106
+ return {"ok": False,
107
+ "error": "o recorte de pessoa precisa do ffmpeg, e não achei nenhum nesta máquina.",
108
+ "comoResolver": "macOS: brew install ffmpeg · Linux: sudo apt install ffmpeg · "
109
+ "Windows: winget install ffmpeg"}
110
+
79
111
  escritor = subprocess.Popen(
80
- ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
112
+ [binario, "-hide_banner", "-loglevel", "error", "-y",
81
113
  "-f", "rawvideo", "-pix_fmt", "bgra",
82
114
  "-s", f"{largura}x{altura}", "-r", str(fps), "-i", "-",
83
115
  "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "3M",
@@ -50,7 +50,7 @@ from pathlib import Path
50
50
  AQUI = Path(__file__).resolve().parent
51
51
  sys.path.insert(0, str(AQUI.parent))
52
52
 
53
- from primo_voz import escuta, orbe # noqa: E402
53
+ from primo_voz import desperta, escuta, orbe # noqa: E402
54
54
 
55
55
 
56
56
  def falar_para_o_node(obj: dict) -> None:
@@ -65,14 +65,75 @@ def avisar(texto: str) -> None:
65
65
  sys.stderr.flush()
66
66
 
67
67
 
68
+ # O PowerShell que toca MP3 no Windows. MediaPlayer é assíncrono: Play()
69
+ # devolve na hora, então é preciso esperar a duração — e esperar com teto, ou
70
+ # um Open que falha deixaria a thread da fala pendurada para sempre.
71
+ def motivo_da_narracao(r) -> str:
72
+ """O que o narrar.py disse que houve — em vez do código de saída.
73
+
74
+ Ele responde JSON em stdout, com "error" e "comoResolver". Quando esse
75
+ JSON não vem (o processo morreu antes de escrever), sobra o stderr, que
76
+ ainda diz mais que "returned non-zero exit status 1".
77
+ """
78
+ try:
79
+ d = json.loads((r.stdout or b"").decode("utf-8", "replace"))
80
+ if isinstance(d, dict) and d.get("error"):
81
+ comoResolver = d.get("comoResolver") or ""
82
+ return "não deu para gerar a voz: " + str(d["error"]) + (
83
+ " — " + str(comoResolver) if comoResolver else "")
84
+ except Exception:
85
+ pass
86
+ erro = (r.stderr or b"").decode("utf-8", "replace").strip()
87
+ ultima = erro.splitlines()[-1] if erro else ""
88
+ return "não deu para gerar a voz" + (f": {ultima[:300]}" if ultima else
89
+ f" (o narrar.py saiu com {r.returncode})")
90
+
91
+
92
+ PS_TOCAR_MP3 = (
93
+ "Add-Type -AssemblyName PresentationCore; "
94
+ "$p = New-Object System.Windows.Media.MediaPlayer; "
95
+ "$p.Open([uri]'{arquivo}'); "
96
+ "$n = 0; "
97
+ "while (-not $p.NaturalDuration.HasTimeSpan -and $n -lt 100) "
98
+ "{{ Start-Sleep -Milliseconds 50; $n++ }}; "
99
+ "if (-not $p.NaturalDuration.HasTimeSpan) {{ exit 1 }}; "
100
+ "$p.Play(); "
101
+ "Start-Sleep -Seconds $p.NaturalDuration.TimeSpan.TotalSeconds; "
102
+ "$p.Close()"
103
+ )
104
+
105
+
68
106
  class Voz:
69
107
  """A costura: microfone → transcrição → Node → voz → microfone."""
70
108
 
71
- def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
109
+ def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio",
110
+ esperando: bool = False):
72
111
  self.servidor = servidor
73
112
  self.token = token
74
113
  self.voz = voz_escolhida
75
114
 
115
+ # ── MODO DE ESPERA ───────────────────────────────────────────
116
+ #
117
+ # Ligado, ele fica dormindo com a orbe escondida até ouvir o nome, e
118
+ # volta a dormir depois de atender. Desligado (o padrão antigo), ele
119
+ # já entra ouvindo — que é o certo para quem digitou /voice e vai
120
+ # falar agora.
121
+ #
122
+ # A espera exige o reconhecedor local. Sem ele instalado, não cai
123
+ # para "mandar tudo para o servidor": cai para o modo de sempre, e
124
+ # DIZ por quê. Silenciosamente transformar uma coisa na outra seria
125
+ # abrir o microfone da pessoa para a internet sem ela saber.
126
+ #
127
+ self.esperando = bool(esperando)
128
+ self.desperta = None
129
+ if self.esperando:
130
+ self.desperta = desperta.Desperta(ao_despertar=lambda: None)
131
+ if not self.desperta.pronto:
132
+ avisar(f"modo de espera indisponível: {self.desperta.motivo} — "
133
+ "abrindo no modo normal, já ouvindo.")
134
+ self.esperando = False
135
+ self.desperta = None
136
+
76
137
  self.orb = orbe.Orbe(ao_fechar=self.parar)
77
138
  self.gravador = escuta.Gravador()
78
139
  self.corte = escuta.Corte()
@@ -84,6 +145,19 @@ class Voz:
84
145
  self._parar_fala = threading.Event()
85
146
  self._do_node = queue.Queue()
86
147
 
148
+ def _esvaziar_blocos(self) -> None:
149
+ """Joga fora o áudio que está na fila.
150
+
151
+ Depois de despertar, os blocos do próprio "ei primo" ainda estão
152
+ enfileirados — e sem isto eles entram na gravação do PEDIDO, que chega
153
+ ao agente com o nome colado na frente.
154
+ """
155
+ while True:
156
+ try:
157
+ self._blocos.get_nowait()
158
+ except queue.Empty:
159
+ return
160
+
87
161
  # ── microfone ────────────────────────────────────────────────────────
88
162
  def _ao_receber(self, bloco) -> None:
89
163
  if not self._vivo:
@@ -115,6 +189,31 @@ class Voz:
115
189
  return
116
190
 
117
191
  while self._vivo:
192
+ # ── O SONO ───────────────────────────────────────────────
193
+ # No modo de espera a orbe some e o microfone fica ouvindo SÓ o
194
+ # nome, aqui dentro, sem nada sair da máquina (ver desperta.py).
195
+ # Sem isso, o modo de espera seria o microfone da pessoa aberto
196
+ # para a internet o dia inteiro.
197
+ if self.esperando and self.desperta is not None:
198
+ self.orb.esconder()
199
+ self.desperta.zerar()
200
+ acordou = False
201
+ while self._vivo and not acordou:
202
+ try:
203
+ bloco = self._blocos.get(timeout=0.3)
204
+ except queue.Empty:
205
+ continue
206
+ acordou = self.desperta.alimentar(bloco)
207
+ if not self._vivo:
208
+ break
209
+ # Chamou: a orbe aparece e ele passa a ouvir o PEDIDO.
210
+ self.orb.mostrar()
211
+ self.orb.mudar(estado="ouvindo", nivel=0)
212
+ # Sem esvaziar a fila, os blocos do próprio "ei primo" entram
213
+ # na gravação do pedido — e o pedido chega com o nome colado
214
+ # na frente.
215
+ self._esvaziar_blocos()
216
+
118
217
  self.orb.mudar(estado="esperando", nivel=0)
119
218
  self.gravador.comecar()
120
219
 
@@ -227,13 +326,17 @@ class Voz:
227
326
 
228
327
  mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
229
328
  narrar = AQUI.parent.parent / "studio" / "narrar.py"
230
- try:
231
- subprocess.run(
232
- [sys.executable, str(narrar), "falar", "--texto", limpo,
233
- "--saida", str(mp3), "--voz", self.voz],
234
- capture_output=True, timeout=180, check=True)
235
- except Exception as e:
236
- avisar(f"não deu para gerar a voz: {e}")
329
+ # O narrar.py IMPRIME um JSON dizendo o que faltou e como resolver
330
+ # ("instale o edge-tts com… "). Com check=True isso virava uma
331
+ # CalledProcessError cujo texto é "Command '[...]' returned non-zero
332
+ # exit status 1" — e era ISSO que chegava ao usuário, com o conselho
333
+ # útil jogado fora dentro do capture_output. Agora a saída é lida.
334
+ r = subprocess.run(
335
+ [sys.executable, str(narrar), "falar", "--texto", limpo,
336
+ "--saida", str(mp3), "--voz", self.voz],
337
+ capture_output=True, timeout=180)
338
+ if r.returncode != 0 or not mp3.exists():
339
+ avisar(motivo_da_narracao(r))
237
340
  return
238
341
 
239
342
  self._falando = True
@@ -260,8 +363,15 @@ class Voz:
260
363
  if sys.platform == "darwin":
261
364
  tentativas.append(["afplay", str(arquivo)])
262
365
  elif sys.platform == "win32":
263
- tentativas.append(["powershell", "-c",
264
- f"(New-Object Media.SoundPlayer '{arquivo}').PlaySync()"])
366
+ # Media.SoundPlayer NÃO toca MP3 — ele só lê WAV, e o que o
367
+ # narrar.py entrega é MP3. Era a primeira tentativa da fila, e
368
+ # levantava exceção sempre; como o laço abaixo aceitava qualquer
369
+ # processo que tivesse CONSEGUIDO INICIAR, ele dava por tocado e
370
+ # voltava. Resultado: o modo voz era MUDO no Windows, sem nenhum
371
+ # aviso — a orb piscava "falando" e não saía som nenhum.
372
+ # MediaPlayer decodifica MP3; a espera é limitada para não travar
373
+ # a thread se o Open falhar.
374
+ tentativas.append(["powershell", "-NoProfile", "-Command", PS_TOCAR_MP3.format(arquivo=arquivo)])
265
375
  tentativas += [
266
376
  ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(arquivo)],
267
377
  ["mpg123", "-q", str(arquivo)],
@@ -271,7 +381,7 @@ class Voz:
271
381
  for cmd in tentativas:
272
382
  try:
273
383
  p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
274
- except FileNotFoundError:
384
+ except (FileNotFoundError, OSError):
275
385
  continue
276
386
  while p.poll() is None:
277
387
  if self._parar_fala.is_set() or not self._vivo:
@@ -279,10 +389,19 @@ class Voz:
279
389
  p.terminate()
280
390
  except Exception:
281
391
  pass
282
- return
392
+ return # calado de propósito: falaram por cima
283
393
  time.sleep(0.05)
284
- return
285
- avisar("não achei um tocador de áudio (ffplay, mpg123, mpv ou afplay).")
394
+ if p.returncode == 0:
395
+ return
396
+ # INICIAR não é TOCAR. O tocador existia e falhou (formato que ele
397
+ # não lê, dispositivo de áudio ocupado): a fila continua. Sair aqui
398
+ # era o que transformava "o primeiro tocador não serve" em silêncio
399
+ # absoluto, com quatro alternativas por tentar logo abaixo.
400
+ continue
401
+ avisar("não achei um tocador de áudio que funcione neste computador."
402
+ + (" Instale o ffmpeg: winget install ffmpeg" if sys.platform == "win32"
403
+ else " Instale o ffmpeg: brew install ffmpeg" if sys.platform == "darwin"
404
+ else " Instale o ffmpeg: sudo apt install ffmpeg"))
286
405
 
287
406
  # ── ciclo de vida ────────────────────────────────────────────────────
288
407
  def parar(self) -> None:
@@ -304,6 +423,9 @@ def main() -> int:
304
423
  servidor = os.environ.get("PRIMOCODE_SERVER", "").strip()
305
424
  token = os.environ.get("PRIMOCODE_TOKEN", "").strip()
306
425
  voz = os.environ.get("PRIMOCODE_VOZ", "antonio").strip() or "antonio"
426
+ # O modo de espera vem por variável, como o resto: quem abre este programa
427
+ # é o /voice do Node, não uma pessoa numa linha de comando.
428
+ esperando = os.environ.get("PRIMOCODE_VOZ_ESPERA", "") == "1"
307
429
 
308
430
  if not servidor:
309
431
  avisar("falta PRIMOCODE_SERVER — este programa é aberto pelo /voice do PrimoCode.")
@@ -317,7 +439,7 @@ def main() -> int:
317
439
  f"{sys.executable} -m pip install sounddevice numpy")
318
440
  return 3
319
441
 
320
- Voz(servidor, token, voz).rodar()
442
+ Voz(servidor, token, voz, esperando=esperando).rodar()
321
443
  return 0
322
444
 
323
445
 
@@ -0,0 +1,195 @@
1
+ """desperta.py — ouvir o nome, e só o nome, sem mandar áudio para lugar nenhum.
2
+
3
+ ── POR QUE OFFLINE, E POR QUE ISSO NÃO É DETALHE ───────────────────────────
4
+ No modo de espera o microfone fica ABERTO o tempo todo. Existem dois jeitos de
5
+ saber se alguém disse "ei, primo":
6
+
7
+ 1. Mandar tudo o que o microfone ouve para um servidor transcrever.
8
+ 2. Reconhecer a palavra aqui dentro, sem nada sair da máquina.
9
+
10
+ O primeiro funciona e é mais simples de escrever. Também significa que TODA
11
+ conversa perto do computador — a ligação, a reunião, a briga em casa — sobe
12
+ para um servidor, o dia inteiro, para que uma vez por hora alguém diga o nome
13
+ do programa. Isso não é uma troca aceitável, e não é decisão que um programa
14
+ toma pelo dono da máquina.
15
+
16
+ Então é o segundo. O Vosk roda local, o modelo mora no disco da pessoa, e
17
+ NADA sai daqui enquanto o nome não for dito. Depois do nome, sim: a frase vai
18
+ para a transcrição, como no modo normal — mas aí foi a pessoa que chamou.
19
+
20
+ ── A GRAMÁTICA RESTRITA ────────────────────────────────────────────────────
21
+ O reconhecedor é criado sabendo APENAS as palavras de despertar. Não é
22
+ otimização: é o que impede ele de "ouvir" o nome no meio de uma frase
23
+ qualquer. Um reconhecedor livre transcreve tudo e depois se procura a palavra
24
+ no texto — e aí "prima", "primeiro" e "exprimo" acordam o assistente. Com a
25
+ gramática restrita ele só pode devolver uma das palavras da lista ou "[unk]".
26
+
27
+ Herdado do Jarvis (src/jarvis/core/wake.py), que resolveu isto primeiro; o
28
+ modelo aqui é o de PORTUGUÊS, porque "primo" é palavra portuguesa — ao
29
+ contrário de "Jarvis", que é nome inglês e por isso lá usava o modelo de
30
+ inglês.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import threading
37
+ import urllib.request
38
+ import zipfile
39
+ from pathlib import Path
40
+ from typing import Callable, Optional
41
+
42
+ TAXA = 16000
43
+
44
+ # Onde o modelo mora. Fora do pacote npm de propósito: são 31 MB, e um pacote
45
+ # que engorda 31 MB para todo mundo por causa de um modo que nem todos usam é
46
+ # um pacote que demora a instalar para todo mundo.
47
+ PASTA_MODELO = Path.home() / ".primocode" / "modelos"
48
+ NOME_MODELO = "vosk-model-small-pt-0.3"
49
+ URL_MODELO = f"https://alphacephei.com/vosk/models/{NOME_MODELO}.zip"
50
+
51
+ # O que ACORDA. Variantes porque o reconhecedor pequeno erra a vogal átona, e
52
+ # porque as pessoas falam de jeitos diferentes ("ei primo", "oi primo", "ô
53
+ # primo"). O "primo" sozinho entra: dentro da gramática restrita ele não
54
+ # dispara em conversa comum, porque o reconhecedor não transcreve conversa
55
+ # comum — só consegue devolver estas palavras.
56
+ VOCABULARIO = ("primo", "ei", "oi", "olá", "ô")
57
+ DESPERTA = ("primo",)
58
+
59
+
60
+ def caminho_do_modelo() -> Path:
61
+ return PASTA_MODELO / NOME_MODELO
62
+
63
+
64
+ def tem_modelo() -> bool:
65
+ caminho = caminho_do_modelo()
66
+ return caminho.is_dir() and any(caminho.iterdir())
67
+
68
+
69
+ def baixar_modelo(aoFalar: Optional[Callable[[str], None]] = None) -> dict:
70
+ """Traz o modelo, uma vez. ~31 MB.
71
+
72
+ Baixa para um arquivo temporário e só então move para o lugar: interromper
73
+ no meio deixaria uma pasta pela metade que `tem_modelo()` daria por boa, e
74
+ o erro apareceria depois, como "falha ao carregar o Vosk".
75
+ """
76
+ fala = aoFalar or (lambda _t: None)
77
+ if tem_modelo():
78
+ return {"ok": True, "jaTinha": True}
79
+ try:
80
+ PASTA_MODELO.mkdir(parents=True, exist_ok=True)
81
+ temporario = PASTA_MODELO / (NOME_MODELO + ".zip.parcial")
82
+ fala("baixando o reconhecedor de voz (uma vez, ~31 MB)")
83
+ with urllib.request.urlopen(URL_MODELO, timeout=120) as resposta, open(temporario, "wb") as saida:
84
+ while True:
85
+ pedaco = resposta.read(1 << 16)
86
+ if not pedaco:
87
+ break
88
+ saida.write(pedaco)
89
+ fala("descompactando")
90
+ with zipfile.ZipFile(temporario) as z:
91
+ z.extractall(PASTA_MODELO)
92
+ temporario.unlink(missing_ok=True)
93
+ if not tem_modelo():
94
+ return {"ok": False, "error": "o modelo baixou mas não apareceu onde devia"}
95
+ return {"ok": True}
96
+ except Exception as e:
97
+ return {"ok": False, "error": str(e),
98
+ "comoResolver": f"baixe {URL_MODELO} à mão e descompacte em {PASTA_MODELO}"}
99
+
100
+
101
+ def contem_nome(texto: str) -> bool:
102
+ """O texto que o reconhecedor devolveu contém o nome?
103
+
104
+ Comparação por PALAVRA INTEIRA. Com `in` de substring, "prima" e
105
+ "primeiro" acordariam o assistente — e a gramática restrita não protege
106
+ disso sozinha, porque ela devolve as palavras da lista e o resto vira
107
+ "[unk]", que pode vir colado.
108
+ """
109
+ palavras = set(str(texto or "").lower().replace("[unk]", " ").split())
110
+ return any(n in palavras for n in DESPERTA)
111
+
112
+
113
+ class Desperta:
114
+ """Escuta o nome. Nada sai da máquina até ele ser dito."""
115
+
116
+ def __init__(self, ao_despertar: Callable[[], None]) -> None:
117
+ self.ao_despertar = ao_despertar
118
+ self._rec = None
119
+ self._modelo = None
120
+ self.pronto = False
121
+ self.ligado = True
122
+ self.motivo = ""
123
+ # O reconhecedor do Vosk NÃO é seguro entre threads: a do áudio chama
124
+ # process() dez vezes por segundo enquanto o laço principal pode
125
+ # chamar reset() ao despertar. Sem trava, o estado interno corrompe e
126
+ # ele passa a levantar "Failed to process waveform" — sintoma que não
127
+ # fala de thread nenhuma, e por isso custa caro de achar.
128
+ self._trava = threading.Lock()
129
+ self._carregar()
130
+
131
+ def _carregar(self) -> None:
132
+ if not tem_modelo():
133
+ self.motivo = "o reconhecedor de voz não está baixado"
134
+ return
135
+ try:
136
+ import vosk
137
+ vosk.SetLogLevel(-1)
138
+ self._modelo = vosk.Model(str(caminho_do_modelo()))
139
+ self._rec = self._novo()
140
+ self.pronto = True
141
+ except ImportError:
142
+ self.motivo = "falta o vosk (pip install vosk)"
143
+ except Exception as e:
144
+ self.motivo = f"não consegui carregar o reconhecedor: {e}"
145
+
146
+ def _novo(self):
147
+ import vosk
148
+ gramatica = json.dumps(sorted({*VOCABULARIO, "[unk]"}))
149
+ return vosk.KaldiRecognizer(self._modelo, TAXA, gramatica)
150
+
151
+ def alimentar(self, bloco) -> bool:
152
+ """Um bloco de áudio. Devolve True se o nome foi dito."""
153
+ if not self.pronto or self._rec is None or not self.ligado:
154
+ return False
155
+ with self._trava:
156
+ try:
157
+ if self._rec.AcceptWaveform(bloco.tobytes()):
158
+ texto = json.loads(self._rec.Result()).get("text", "")
159
+ else:
160
+ texto = json.loads(self._rec.PartialResult()).get("partial", "")
161
+ except Exception:
162
+ # Reconhecedor travado: recria e segue. Desligar o modo de
163
+ # espera por causa de um bloco ruim seria perder o recurso
164
+ # inteiro por um soluço de áudio.
165
+ try:
166
+ self._rec = self._novo()
167
+ except Exception:
168
+ self.pronto = False
169
+ self.motivo = "o reconhecedor parou e não voltou"
170
+ return False
171
+
172
+ achou = bool(texto and contem_nome(texto))
173
+ if achou:
174
+ self._rec.Reset()
175
+
176
+ if achou:
177
+ try:
178
+ self.ao_despertar()
179
+ except Exception:
180
+ pass
181
+ return achou
182
+
183
+ def zerar(self) -> None:
184
+ """Esquece o que ouviu até agora.
185
+
186
+ Chamado ao voltar do atendimento: sem isso, o resto da frase que a
187
+ pessoa falou DEPOIS do nome ainda está no reconhecedor, e ele desperta
188
+ de novo no instante seguinte.
189
+ """
190
+ with self._trava:
191
+ if self._rec is not None:
192
+ try:
193
+ self._rec.Reset()
194
+ except Exception:
195
+ pass
@@ -20,10 +20,11 @@ mas CONTINUAR gravando exige bem menos. Sem isso, a sílaba fraca no fim ("…pr
20
20
  mim") cai abaixo do limiar e a frase é cortada antes de acabar.
21
21
 
22
22
  ── O QUE SAIU DO JARVIS ────────────────────────────────────────────────────
23
- A wake word. Lá ela existia porque o assistente ficava ligado o dia inteiro
24
- esperando ser chamado. Aqui a pessoa abriu o modo voz de propósito: ele já
25
- está ouvindo, e não há nome para dizer. Some com isso o Vosk e os 40 MB de
26
- modelo que ele baixava.
23
+ A wake word saiu DAQUI, não do produto. Este módulo cuida de uma coisa só:
24
+ achar o começo e o fim de uma frase. Quem reconhece o nome é o desperta.py, e
25
+ ele só entra no modo de espera — quando a pessoa pediu para ele ficar ouvindo
26
+ em segundo plano. Quem digita /voice e vai falar agora não diz nome nenhum: a
27
+ orbe já está ouvindo.
27
28
  """
28
29
  from __future__ import annotations
29
30
 
@@ -39,6 +39,7 @@ from __future__ import annotations
39
39
 
40
40
  import math
41
41
  import queue
42
+ import sys
42
43
  import threading
43
44
  import tkinter as tk
44
45
  from typing import Callable, Optional
@@ -63,6 +64,7 @@ class Orbe:
63
64
  self.raiz = tk.Tk()
64
65
  self.raiz.title("Primo Code Voice")
65
66
  self.estado = "esperando"
67
+ self.visivel = True
66
68
  self.nivel = 0.0 # 0..1, o quanto a pessoa está falando
67
69
  self._fase = 0.0
68
70
  self._fila = queue.Queue()
@@ -130,6 +132,12 @@ class Orbe:
130
132
  while True:
131
133
  try:
132
134
  estado, nivel = self._fila.get_nowait()
135
+ if estado == '__esconder__':
136
+ self._aplicar_visibilidade(False)
137
+ continue
138
+ if estado == '__mostrar__':
139
+ self._aplicar_visibilidade(True)
140
+ continue
133
141
  except queue.Empty:
134
142
  break
135
143
  if estado:
@@ -197,6 +205,40 @@ class Orbe:
197
205
  principal, então o que chega vai para uma fila e é lido no desenho."""
198
206
  self._fila.put((estado, nivel))
199
207
 
208
+ def esconder(self) -> None:
209
+ """Some da tela sem morrer.
210
+
211
+ O modo de espera fica MINUTOS ou horas sem nada acontecer, e uma orbe
212
+ parada no meio do monitor todo esse tempo é um estorvo — ela cobre o
213
+ que a pessoa está fazendo. Então ela some e volta quando chamada.
214
+
215
+ Destruir e recriar a janela a cada despertar seria o caminho óbvio, e é
216
+ o errado: recriar uma Tk leva centenas de milissegundos, pisca, e em
217
+ alguns sistemas rouba o foco da janela onde a pessoa estava digitando.
218
+ `withdraw` tira da tela e guarda tudo de pé.
219
+ """
220
+ self._fila.put(("__esconder__", None))
221
+
222
+ def mostrar(self) -> None:
223
+ """Volta para a tela, no centro, por cima de tudo."""
224
+ self._fila.put(("__mostrar__", None))
225
+
226
+ def _aplicar_visibilidade(self, visivel: bool) -> None:
227
+ # Só na thread da interface: tkinter não perdoa chamada de fora.
228
+ try:
229
+ if visivel:
230
+ self.veu.deiconify()
231
+ self.raiz.deiconify()
232
+ self.raiz.lift()
233
+ if sys.platform != "linux":
234
+ self.raiz.attributes("-topmost", True)
235
+ else:
236
+ self.raiz.withdraw()
237
+ self.veu.withdraw()
238
+ except Exception:
239
+ pass # janela já fechando: nada a fazer
240
+ self.visivel = visivel
241
+
200
242
  def fechar(self) -> None:
201
243
  if not self._vivo:
202
244
  return
File without changes