primocode 9.3.1 → 9.5.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.
@@ -1,36 +1,37 @@
1
1
  """orbe.py — a única coisa que aparece na tela no modo voz.
2
2
 
3
- "ia ser a flutuante ali na parte inferior da tela, bem centralizada,
4
- toda cheia de partículas, toda bonita."
3
+ "Quero que ele fique na parte inferior da tela, bem centralizado, um pouco
4
+ maior [...] mas ele tá rodando em uma caixa, deve ser totalmente
5
+ transparente." (23/09/2026)
5
6
 
6
- ── POR QUE A PRIMEIRA VERSÃO FICOU FEIA, E A CULPA É DE QUATRO COISAS ──────
7
+ ── O QUE ERA A "CAIXA" ─────────────────────────────────────────────────────
8
+ Medido com uma captura da própria janela, no Tk 9 do macOS:
7
9
 
8
- 1. `-transparentcolor` SÓ EXISTE NO WINDOWS. No macOS ele levanta TclError,
9
- que o código engolia — e a janela ficava com o fundo opaco pintado nela.
10
- Era o "quadrado feio": não era desenho, era a janela inteira aparecendo.
11
- No macOS a transparência de verdade se pede de outro jeito:
12
- `-transparent` mais o fundo mágico `systemTransparent`.
10
+ 1. `overrideredirect(True)` NÃO tira a moldura: a janela nascia com barra de
11
+ título, os três botões e o nome "Primo Code Voice".
12
+ 2. O fundo `systemTransparent` saía PRETO.
13
+ 3. O Canvas não apaga o quadro anterior sobre fundo transparente: cada
14
+ partícula deixava um rastro, e em segundos a janela virava um borrão.
13
15
 
14
- 2. O VÉU cobria a tela inteira com 72% de preto. A ideia era destacar a orb;
15
- o efeito é escurecer o trabalho da pessoa enquanto ela fala. Saiu.
16
+ Nenhum dos três se conserta de dentro do Tk. Então, no macOS, a orb é
17
+ desenhada pelo próprio sistema (PyObjC): uma NSWindow sem borda, sem fundo e
18
+ sem sombra, e uma NSView que limpa e pinta cada quadro com transparência de
19
+ verdade — o brilho é um gradiente com alfa, não discos misturados contra uma
20
+ cor de fundo adivinhada. Só a orb aparece; o resto da janela não existe.
16
21
 
17
- 3. Ela nascia no CENTRO da tela — bem no meio do que a pessoa está fazendo.
18
- Agora mora embaixo, centralizada na horizontal, como uma barra flutuante.
22
+ O Tk continua sendo quem roda o laço de eventos (é ele que o resto do modo
23
+ voz já usa), e segue como desenho de reserva fora do macOS ou sem PyObjC.
19
24
 
20
- 4. Não havia partícula nenhuma. Havia três círculos concêntricos.
25
+ ── ONDE ELA MORA ───────────────────────────────────────────────────────────
26
+ Embaixo, centralizada na ÁREA ÚTIL da tela — acima do Dock, não atrás dele.
27
+ A posição é aplicada depois de a janela existir: dada cedo demais, a janela
28
+ sem moldura ignora.
21
29
 
22
30
  ── O QUE ELA DESENHA ───────────────────────────────────────────────────────
23
- Um núcleo com halo suave e um enxame de partículas em órbita. A cor diz o
31
+ Um núcleo com brilho suave e um enxame de partículas em órbita. A cor diz o
24
32
  estado; o MOVIMENTO diz a intensidade — as partículas se afastam e aceleram
25
33
  com a voz, e respiram devagar quando ele está pensando. Nenhuma palavra: quem
26
34
  está falando não lê.
27
-
28
- ── O QUE NÃO DÁ PARA FAZER EM TKINTER, E COMO SE CONTORNA ─────────────────
29
- Não há desfoque nem gradiente: o Canvas do Tk desenha forma chapada. O halo é
30
- feito de círculos concêntricos com a cor interpolada contra o fundo — o
31
- mesmo truque de sempre, e o motivo de o fundo precisar ser conhecido. Onde a
32
- janela é transparente de verdade, o "fundo" para a mistura é o preto, que é o
33
- que mais se aproxima de qualquer tela.
34
35
  """
35
36
  from __future__ import annotations
36
37
 
@@ -39,23 +40,27 @@ import queue
39
40
  import random
40
41
  import sys
41
42
  import tkinter as tk
42
- from typing import Callable, Optional
43
+ from typing import Callable, List, Optional, Tuple
43
44
 
44
45
  # Os quatro estados, e a cor de cada um. A paleta é a do PrimoCode (ciano de
45
46
  # marca, âmbar de aviso) para o modo voz não parecer outro produto.
46
47
  CORES = {
47
- "esperando": ("#0891b2", "#0e3a4a"), # ciano recuado: pronto, sem pressa
48
+ "esperando": ("#22b8d8", "#0e3a4a"), # ciano calmo: pronto, sem pressa
48
49
  "ouvindo": ("#22d3ee", "#0e7490"), # ciano vivo: a vez é sua
49
50
  "pensando": ("#fbbf24", "#78350f"), # âmbar: ele está trabalhando
50
51
  "falando": ("#67e8f9", "#0891b2"), # ciano claro: a voz é dele
51
52
  }
52
53
 
53
- LARGURA = 340 # a janela, deitada: é uma barra flutuante, não um disco
54
- ALTURA = 170
55
- RAIO_BASE = 26 # o núcleo
56
- MARGEM_INFERIOR = 72 # distância do pé da tela
57
- PARTICULAS = 34
58
- FUNDO_FALSO = "#05070a" # só onde o sistema não dá transparência
54
+ LARGURA = 360 # só a área da orb e do brilho — o resto é transparente
55
+ ALTURA = 220
56
+ RAIO_BASE = 34 # o núcleo
57
+ MARGEM_INFERIOR = 24 # acima da área útil (o Dock já fica de fora)
58
+ PARTICULAS = 44
59
+ FUNDO_FALSO = "#05070a" # só no desenho de reserva, onde não há alfa
60
+
61
+
62
+ def _rgb(cor: str) -> Tuple[float, float, float]:
63
+ return int(cor[1:3], 16) / 255, int(cor[3:5], 16) / 255, int(cor[5:7], 16) / 255
59
64
 
60
65
 
61
66
  def _mistura(a: str, b: str, quanto: float) -> str:
@@ -80,11 +85,111 @@ class Particula:
80
85
 
81
86
  def __init__(self, rnd: random.Random) -> None:
82
87
  self.ang = rnd.uniform(0, math.tau)
83
- self.raio = rnd.uniform(1.0, 2.2) # múltiplo do raio do núcleo
88
+ self.raio = rnd.uniform(1.25, 2.3) # múltiplo do raio do núcleo
84
89
  self.giro = rnd.uniform(-0.9, 0.9) or 0.4
85
- self.tam = rnd.uniform(1.2, 3.0)
90
+ self.tam = rnd.uniform(1.3, 3.0)
86
91
  self.fase = rnd.uniform(0, math.tau)
87
- self.brilho = rnd.uniform(0.35, 1.0)
92
+ self.brilho = rnd.uniform(0.45, 1.0)
93
+
94
+
95
+ class Quadro:
96
+ """Um quadro da animação, sem saber quem vai pintá-lo."""
97
+
98
+ def __init__(self, vivo: str, fundo: str, raio: float, pulso: float,
99
+ particulas: List[Tuple[float, float, float, float]]):
100
+ self.vivo, self.fundo = vivo, fundo
101
+ self.raio, self.pulso = raio, pulso
102
+ self.particulas = particulas # (x, y, tamanho, intensidade 0..1)
103
+
104
+
105
+ # ── O desenho nativo do macOS ────────────────────────────────────────────
106
+
107
+ def _criar_nativa(orbe: "Orbe"):
108
+ """A janela sem borda e sem fundo, desenhada pelo macOS. None se não der."""
109
+ if sys.platform != "darwin":
110
+ return None
111
+ try:
112
+ import objc
113
+ from AppKit import (NSBackingStoreBuffered, NSBezierPath, NSColor,
114
+ NSCompositingOperationClear, NSGradient, NSMakeRect,
115
+ NSRectFillUsingOperation, NSScreen, NSView, NSWindow)
116
+ except Exception:
117
+ return None
118
+
119
+ def cor(hexa: str, alfa: float):
120
+ r, g, b = _rgb(hexa)
121
+ return NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, max(0.0, min(1.0, alfa)))
122
+
123
+ # A classe Objective-C só pode ser registrada uma vez por processo.
124
+ try:
125
+ Vista = objc.lookUpClass("PrimoOrbeVista")
126
+ except objc.nosuchclass_error:
127
+ class PrimoOrbeVista(NSView):
128
+ def isOpaque(self):
129
+ return False
130
+
131
+ def acceptsFirstMouse_(self, evento):
132
+ return True
133
+
134
+ def drawRect_(self, rect):
135
+ NSColor.clearColor().set()
136
+ NSRectFillUsingOperation(self.bounds(), NSCompositingOperationClear)
137
+ q = getattr(self, "quadro", None)
138
+ if q is None:
139
+ return
140
+ cx, cy = LARGURA / 2, ALTURA / 2
141
+ centro = (cx, cy)
142
+
143
+ # BRILHO: um gradiente radial com alfa, que some no nada. É o
144
+ # que o Tk não sabia fazer — e o que tira o "disco escuro".
145
+ alcance = q.raio * (2.6 + q.pulso * 0.9)
146
+ brilho = NSGradient.alloc().initWithColors_(
147
+ [cor(q.vivo, 0.34 + q.pulso * 0.20), cor(q.vivo, 0.10), cor(q.vivo, 0.0)])
148
+ brilho.drawFromCenter_radius_toCenter_radius_options_(centro, q.raio * 0.6, centro, alcance, 0)
149
+
150
+ # PARTÍCULAS
151
+ for (px, py, t, forca) in q.particulas:
152
+ cor(q.vivo, 0.25 + forca * 0.75).set()
153
+ NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(px - t, py - t, 2 * t, 2 * t)).fill()
154
+
155
+ # NÚCLEO: gradiente do claro ao vivo, com um anel fino por fora.
156
+ r = q.raio
157
+ nucleo = NSGradient.alloc().initWithColors_(
158
+ [cor(_mistura(q.vivo, "#ffffff", 0.55), 1.0), cor(q.vivo, 1.0),
159
+ cor(_mistura(q.vivo, q.fundo, 0.45), 1.0)])
160
+ nucleo.drawFromCenter_radius_toCenter_radius_options_(
161
+ (cx - r * 0.25, cy + r * 0.25), 0, centro, r, 0)
162
+ cor(q.vivo, 0.55).set()
163
+ anel = NSBezierPath.bezierPathWithOvalInRect_(
164
+ NSMakeRect(cx - r * 1.18, cy - r * 1.18, r * 2.36, r * 2.36))
165
+ anel.setLineWidth_(1.5)
166
+ anel.stroke()
167
+
168
+ Vista = PrimoOrbeVista
169
+
170
+ try:
171
+ janela = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
172
+ NSMakeRect(0, 0, LARGURA, ALTURA), 0, NSBackingStoreBuffered, False) # 0 = sem borda
173
+ janela.setOpaque_(False)
174
+ janela.setBackgroundColor_(NSColor.clearColor())
175
+ janela.setHasShadow_(False)
176
+ janela.setLevel_(25) # NSStatusWindowLevel: por cima de tudo
177
+ janela.setMovableByWindowBackground_(True) # arrasta pela orb
178
+ janela.setCollectionBehavior_(1 | 16) # todos os espaços; fora do Exposé
179
+ janela.setReleasedWhenClosed_(False)
180
+ vista = Vista.alloc().initWithFrame_(NSMakeRect(0, 0, LARGURA, ALTURA))
181
+ janela.setContentView_(vista)
182
+
183
+ # Embaixo, centralizada na área útil (acima do Dock). O Cocoa conta
184
+ # de baixo para cima, então a margem é somada ao pé da área útil.
185
+ util = NSScreen.mainScreen().visibleFrame()
186
+ x = util.origin.x + (util.size.width - LARGURA) / 2
187
+ y = util.origin.y + MARGEM_INFERIOR
188
+ janela.setFrameOrigin_((x, y))
189
+ janela.orderFrontRegardless()
190
+ return janela, vista
191
+ except Exception:
192
+ return None
88
193
 
89
194
 
90
195
  class Orbe:
@@ -103,97 +208,65 @@ class Orbe:
103
208
  self._rnd = random.Random(7) # semente fixa: o enxame é sempre o mesmo
104
209
  self._particulas = [Particula(self._rnd) for _ in range(PARTICULAS)]
105
210
 
106
- self._montar()
211
+ nativa = _criar_nativa(self)
212
+ if nativa:
213
+ # O macOS desenha; a janela do Tk fica escondida e só roda o laço.
214
+ self.janela, self.vista = nativa
215
+ self.nativa = True
216
+ self.raiz.withdraw()
217
+ else:
218
+ self.janela = self.vista = None
219
+ self.nativa = False
220
+ self._montar_tk()
221
+
107
222
  self.raiz.protocol("WM_DELETE_WINDOW", self.fechar)
108
223
  self.raiz.bind("<Escape>", lambda e: self.fechar())
109
224
  self._pintar()
110
225
 
111
- # ── a janela ─────────────────────────────────────────────────────────
112
- def _montar(self) -> None:
113
- """Sem moldura, embaixo, centralizada — e transparente onde dá.
114
-
115
- A transparência se pede de um jeito em cada sistema, e pedir errado
116
- não avisa: devolve TclError, que engolido deixa a janela OPACA. Era
117
- exatamente o "quadrado feio". Por isso aqui cada caminho é explícito,
118
- e o fundo falso só entra quando nenhum funcionou.
119
- """
226
+ # ── a reserva em Tk (fora do macOS, ou sem PyObjC) ───────────────────
227
+ def _montar_tk(self) -> None:
120
228
  self.raiz.overrideredirect(True)
121
- larguraTela = self.raiz.winfo_screenwidth()
122
- alturaTela = self.raiz.winfo_screenheight()
123
- x = (larguraTela - LARGURA) // 2
124
- y = alturaTela - ALTURA - MARGEM_INFERIOR
125
- self.raiz.geometry(f"{LARGURA}x{ALTURA}+{x}+{max(0, y)}")
126
-
127
229
  self.fundo = FUNDO_FALSO
128
- transparente = False
129
-
130
- if sys.platform == "darwin":
131
- # macOS: `-transparent` mais o fundo mágico. Os dois juntos, ou
132
- # nenhum dos dois funciona.
133
- try:
134
- self.raiz.attributes("-transparent", True)
135
- self.fundo = "systemTransparent"
136
- transparente = True
137
- except tk.TclError:
138
- pass
139
- elif sys.platform == "win32":
230
+ if sys.platform == "win32":
231
+ # No Windows o Tk dá transparência por cor-chave, e ela funciona.
140
232
  try:
141
233
  self.raiz.attributes("-transparentcolor", FUNDO_FALSO)
142
- transparente = True
143
234
  except tk.TclError:
144
235
  pass
145
-
146
236
  try:
147
237
  self.raiz.attributes("-topmost", True)
148
238
  except tk.TclError:
149
239
  pass
150
-
151
240
  self.raiz.configure(bg=self.fundo)
152
241
  self.tela = tk.Canvas(self.raiz, width=LARGURA, height=ALTURA,
153
242
  bg=self.fundo, highlightthickness=0, bd=0)
154
243
  self.tela.pack()
155
-
156
- # Para a MISTURA de cor do halo, o fundo tem de ser um valor de cor —
157
- # e "systemTransparent" não é. Contra o preto é a aproximação que
158
- # some melhor em qualquer tela.
159
- self.fundoDaMistura = FUNDO_FALSO if transparente else self.fundo
160
-
161
- # Sem moldura não há barra de título para arrastar: o arrasto é na
162
- # janela toda. Quem quiser a orb noutro canto, leva.
244
+ self.raiz.update_idletasks()
245
+ self._posicionar_tk()
246
+ self.raiz.after(120, self._posicionar_tk)
163
247
  self.raiz.bind("<Button-1>", self._pegar)
164
248
  self.raiz.bind("<B1-Motion>", self._arrastar)
165
249
  self._pego = (0, 0)
166
250
 
251
+ def _posicionar_tk(self) -> None:
252
+ x = (self.raiz.winfo_screenwidth() - LARGURA) // 2
253
+ y = self.raiz.winfo_screenheight() - ALTURA - MARGEM_INFERIOR - 48
254
+ try:
255
+ self.raiz.geometry(f"{LARGURA}x{ALTURA}+{x}+{max(0, y)}")
256
+ except tk.TclError:
257
+ pass
258
+
167
259
  def _pegar(self, e) -> None:
168
260
  self._pego = (e.x_root - self.raiz.winfo_x(), e.y_root - self.raiz.winfo_y())
169
261
 
170
262
  def _arrastar(self, e) -> None:
171
263
  self.raiz.geometry(f"+{e.x_root - self._pego[0]}+{e.y_root - self._pego[1]}")
172
264
 
173
- # ── o desenho ────────────────────────────────────────────────────────
174
- def _pintar(self) -> None:
175
- if not self._vivo:
176
- return
177
- while True:
178
- try:
179
- estado, nivel = self._fila.get_nowait()
180
- if estado == "__esconder__":
181
- self._aplicarVisibilidade(False)
182
- continue
183
- if estado == "__mostrar__":
184
- self._aplicarVisibilidade(True)
185
- continue
186
- if estado:
187
- self.estado = estado
188
- if nivel is not None:
189
- self.nivel = max(0.0, min(1.0, nivel))
190
- except queue.Empty:
191
- break
192
-
265
+ # ── a animação ───────────────────────────────────────────────────────
266
+ def _quadro(self) -> Quadro:
193
267
  self._fase += 0.045
194
268
  vivo, fundo = CORES.get(self.estado, CORES["esperando"])
195
269
  cx, cy = LARGURA / 2, ALTURA / 2
196
- self.tela.delete("all")
197
270
 
198
271
  # A respiração de cada estado. Quem espera respira devagar; quem
199
272
  # pensa, num ritmo próprio; quem ouve pulsa com a VOZ, não com o
@@ -206,49 +279,77 @@ class Orbe:
206
279
  pulso = 0.55 + 0.38 * math.sin(self._fase * 3.4)
207
280
  else:
208
281
  pulso = 0.16 + 0.10 * math.sin(self._fase * 0.9)
282
+ raio = RAIO_BASE * (1 + pulso * 0.30)
209
283
 
210
- raio = RAIO_BASE * (1 + pulso * 0.42)
211
-
212
- # HALO: círculos concêntricos, do mais largo e apagado ao núcleo. É o
213
- # desfoque que o Canvas não tem — e por isso a cor de cada anel é
214
- # interpolada contra o fundo, em vez de ter opacidade.
215
- for i in range(11, 0, -1):
216
- f = i / 11
217
- r = raio * (1 + f * 2.6)
218
- cor = _mistura(self.fundoDaMistura, fundo, (1 - f) ** 2 * 0.85)
219
- self.tela.create_oval(cx - r, cy - r, cx + r, cy + r, fill=cor, outline="")
220
-
221
- # PARTÍCULAS: entre o halo e o núcleo, para o núcleo ficar por cima.
284
+ pontos = []
222
285
  for p in self._particulas:
223
286
  p.ang += p.giro * 0.012 * (0.6 + pulso)
224
287
  # O raio respira junto com a voz: quanto mais alto se fala, mais
225
288
  # longe o enxame se abre.
226
289
  osc = math.sin(self._fase * 1.7 + p.fase)
227
- r = raio * (p.raio + 0.30 * osc + pulso * 0.55)
228
- px = cx + math.cos(p.ang) * r * 1.55 # elipse: a janela é deitada
229
- py = cy + math.sin(p.ang) * r * 0.80
230
- t = p.tam * (0.7 + pulso * 0.8)
231
- cor = _mistura(self.fundoDaMistura, vivo, p.brilho * (0.35 + pulso * 0.65))
232
- self.tela.create_oval(px - t, py - t, px + t, py + t, fill=cor, outline="")
233
-
234
- # NÚCLEO: dois discos, o de dentro mais claro. Dá volume sem gradiente.
235
- self.tela.create_oval(cx - raio, cy - raio, cx + raio, cy + raio,
236
- fill=_mistura(fundo, vivo, 0.72), outline="")
237
- rn = raio * 0.55
238
- self.tela.create_oval(cx - rn, cy - rn, cx + rn, cy + rn,
239
- fill=_mistura(vivo, "#ffffff", 0.35), outline="")
290
+ r = raio * (p.raio + 0.25 * osc + pulso * 0.40)
291
+ # Elipse leve, mais larga que alta — e dentro da janela: o que
292
+ # passa da borda é cortado seco, e partícula cortada é defeito.
293
+ px = cx + math.cos(p.ang) * r * 1.2
294
+ py = cy + math.sin(p.ang) * r * 0.74
295
+ t = p.tam * (0.75 + pulso * 0.6)
296
+ pontos.append((px, py, t, p.brilho * (0.5 + pulso * 0.5)))
297
+ return Quadro(vivo, fundo, raio, pulso, pontos)
240
298
 
299
+ def _pintar(self) -> None:
300
+ if not self._vivo:
301
+ return
302
+ while True:
303
+ try:
304
+ estado, nivel = self._fila.get_nowait()
305
+ if estado == "__esconder__":
306
+ self._aplicarVisibilidade(False)
307
+ continue
308
+ if estado == "__mostrar__":
309
+ self._aplicarVisibilidade(True)
310
+ continue
311
+ if estado:
312
+ self.estado = estado
313
+ if nivel is not None:
314
+ self.nivel = max(0.0, min(1.0, nivel))
315
+ except queue.Empty:
316
+ break
317
+
318
+ q = self._quadro()
319
+ if self.nativa:
320
+ self.vista.quadro = q
321
+ self.vista.setNeedsDisplay_(True)
322
+ else:
323
+ self._pintar_tk(q)
241
324
  self.raiz.after(33, self._pintar)
242
325
 
326
+ def _pintar_tk(self, q: Quadro) -> None:
327
+ """A reserva: forma chapada, sem alfa. Misturas contra o fundo."""
328
+ cx, cy = LARGURA / 2, ALTURA / 2
329
+ self.tela.delete("all")
330
+ for i in range(6, 0, -1):
331
+ f = i / 6
332
+ r = q.raio * (1 + f * 1.3)
333
+ self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
334
+ outline=_mistura(self.fundo, q.vivo, (1 - f) ** 1.6 * 0.7), width=2)
335
+ for (px, py, t, forca) in q.particulas:
336
+ self.tela.create_oval(px - t, py - t, px + t, py + t,
337
+ fill=_mistura(self.fundo, q.vivo, 0.3 + forca * 0.7), outline="")
338
+ r = q.raio
339
+ self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
340
+ fill=_mistura(q.fundo, q.vivo, 0.8), outline="")
341
+ rn = r * 0.55
342
+ self.tela.create_oval(cx - rn, cy - rn, cx + rn, cy + rn,
343
+ fill=_mistura(q.vivo, "#ffffff", 0.4), outline="")
344
+
243
345
  # ── visibilidade ─────────────────────────────────────────────────────
244
346
  def esconder(self) -> None:
245
347
  """Some da tela sem morrer.
246
348
 
247
349
  No modo de espera ele fica horas sem nada acontecer, e uma orb parada
248
350
  na tela todo esse tempo é estorvo. Destruir e recriar a janela a cada
249
- despertar seria o caminho óbvio, e é o errado: recriar uma Tk leva
250
- centenas de milissegundos, pisca, e em alguns sistemas rouba o foco da
251
- janela onde a pessoa estava digitando. `withdraw` guarda tudo de pé.
351
+ despertar pisca e, em alguns sistemas, rouba o foco de onde a pessoa
352
+ estava digitando. Esconder guarda tudo de pé.
252
353
  """
253
354
  self._fila.put(("__esconder__", None))
254
355
 
@@ -256,9 +357,14 @@ class Orbe:
256
357
  self._fila.put(("__mostrar__", None))
257
358
 
258
359
  def _aplicarVisibilidade(self, visivel: bool) -> None:
259
- # Só na thread da interface: tkinter não perdoa chamada de fora.
360
+ # Só na thread da interface: nem o Tk nem o AppKit perdoam chamada de fora.
260
361
  try:
261
- if visivel:
362
+ if self.nativa:
363
+ if visivel:
364
+ self.janela.orderFrontRegardless()
365
+ else:
366
+ self.janela.orderOut_(None)
367
+ elif visivel:
262
368
  self.raiz.deiconify()
263
369
  self.raiz.lift()
264
370
  if sys.platform != "linux":
@@ -282,6 +388,12 @@ class Orbe:
282
388
  self._ao_fechar()
283
389
  except Exception:
284
390
  pass
391
+ try:
392
+ if self.janela is not None:
393
+ self.janela.orderOut_(None)
394
+ self.janela.close()
395
+ except Exception:
396
+ pass
285
397
  try:
286
398
  self.raiz.destroy()
287
399
  except Exception:
@@ -0,0 +1,106 @@
1
+ """transcrever.py — a fala vira texto AQUI, na máquina.
2
+
3
+ "Pode ser local, daí pra transcrever ele instala Whisper." (23/09/2026)
4
+
5
+ ── POR QUE LOCAL ───────────────────────────────────────────────────────────
6
+ A transcrição ia para o servidor do PrimoCode (`/api/transcrever`). Com o
7
+ servidor fora do ar, o modo voz ouvia, pensava — e não entendia nada. Local,
8
+ ela não depende de rede, não custa chave e o áudio não sai do computador.
9
+
10
+ ── POR QUE faster-whisper, E POR QUE O `small` ─────────────────────────────
11
+ É o Whisper da OpenAI reimplementado em CTranslate2: roda no processador de
12
+ qualquer máquina (Mac Intel ou Apple Silicon, Linux, Windows), instala por pip
13
+ sem compilar nada e lê o wav direto, sem ffmpeg. O modelo `small` (~480 MB,
14
+ uma vez) é o menor que entende português falado sem tropeçar em nome próprio;
15
+ o `base` erra demais, o `medium` dobra o tempo de resposta. Quem quiser outro
16
+ troca por PRIMOCODE_WHISPER_MODELO.
17
+
18
+ O modelo carrega UMA vez por sessão, em segundo plano, logo que o modo voz
19
+ abre — senão a primeira frase esperaria o carregamento inteiro.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sys
26
+ import threading
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ MODELO = os.environ.get("PRIMOCODE_WHISPER_MODELO", "small").strip() or "small"
31
+ PASTA = Path.home() / ".primocode" / "modelos" / "whisper"
32
+
33
+ _modelo = None
34
+ _trava = threading.Lock()
35
+ _erro: Optional[str] = None
36
+
37
+
38
+ def disponivel() -> bool:
39
+ try:
40
+ import faster_whisper # noqa: F401
41
+ return True
42
+ except Exception:
43
+ return False
44
+
45
+
46
+ def carregar():
47
+ """O modelo, carregado uma vez (e baixado, se for a primeira)."""
48
+ global _modelo, _erro
49
+ with _trava:
50
+ if _modelo is not None:
51
+ return _modelo
52
+ from faster_whisper import WhisperModel
53
+ PASTA.mkdir(parents=True, exist_ok=True)
54
+ try:
55
+ # int8 no processador: é o que cabe em qualquer máquina e ainda
56
+ # responde uma frase de dez segundos em um ou dois.
57
+ _modelo = WhisperModel(MODELO, device="cpu", compute_type="int8",
58
+ download_root=str(PASTA))
59
+ _erro = None
60
+ except Exception as e:
61
+ _erro = str(e)
62
+ raise
63
+ return _modelo
64
+
65
+
66
+ def aquecer() -> None:
67
+ """Carrega em segundo plano. Erro aqui não derruba nada: a transcrição
68
+ tenta de novo na hora, e cai para o servidor se não der."""
69
+ def _ir():
70
+ try:
71
+ carregar()
72
+ except Exception:
73
+ pass
74
+ threading.Thread(target=_ir, daemon=True).start()
75
+
76
+
77
+ def transcrever(wav: Path, idioma: str = "pt") -> str:
78
+ modelo = carregar()
79
+ # vad_filter corta o silêncio das pontas: sem ele, o Whisper às vezes
80
+ # "ouve" uma frase inteira inventada no silêncio ("Obrigado por assistir").
81
+ segmentos, _ = modelo.transcribe(str(wav), language=idioma, beam_size=5,
82
+ vad_filter=True, condition_on_previous_text=False)
83
+ return " ".join(s.text.strip() for s in segmentos).strip()
84
+
85
+
86
+ def main() -> int:
87
+ """`python -m primo_voz.transcrever baixar` — usado pelo /voice para deixar
88
+ o modelo pronto antes da primeira frase. Responde uma linha de JSON."""
89
+ acao = sys.argv[1] if len(sys.argv) > 1 else "baixar"
90
+ if acao != "baixar":
91
+ print(json.dumps({"ok": False, "error": f"ação desconhecida: {acao}"}))
92
+ return 2
93
+ if not disponivel():
94
+ print(json.dumps({"ok": False, "error": "o faster-whisper não está instalado"}))
95
+ return 1
96
+ try:
97
+ carregar()
98
+ except Exception as e:
99
+ print(json.dumps({"ok": False, "error": f"não consegui baixar o modelo {MODELO}: {e}"}))
100
+ return 1
101
+ print(json.dumps({"ok": True, "modelo": MODELO, "pasta": str(PASTA)}))
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())