primocode 9.4.0 → 9.6.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,114 @@ 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
+ # O clique ATRAVESSA a orb: ela mora no pé da tela, em cima do Dock e
178
+ # do que o cursor do Primo precisa clicar. Uma orb que segura o clique
179
+ # faria o agente clicar nela em vez de no app.
180
+ janela.setIgnoresMouseEvents_(True)
181
+ janela.setCollectionBehavior_(1 | 16) # todos os espaços; fora do Exposé
182
+ janela.setReleasedWhenClosed_(False)
183
+ vista = Vista.alloc().initWithFrame_(NSMakeRect(0, 0, LARGURA, ALTURA))
184
+ janela.setContentView_(vista)
185
+
186
+ # Embaixo, centralizada na área útil (acima do Dock). O Cocoa conta
187
+ # de baixo para cima, então a margem é somada ao pé da área útil.
188
+ util = NSScreen.mainScreen().visibleFrame()
189
+ x = util.origin.x + (util.size.width - LARGURA) / 2
190
+ y = util.origin.y + MARGEM_INFERIOR
191
+ janela.setFrameOrigin_((x, y))
192
+ janela.orderFrontRegardless()
193
+ return janela, vista
194
+ except Exception:
195
+ return None
88
196
 
89
197
 
90
198
  class Orbe:
@@ -103,97 +211,65 @@ class Orbe:
103
211
  self._rnd = random.Random(7) # semente fixa: o enxame é sempre o mesmo
104
212
  self._particulas = [Particula(self._rnd) for _ in range(PARTICULAS)]
105
213
 
106
- self._montar()
214
+ nativa = _criar_nativa(self)
215
+ if nativa:
216
+ # O macOS desenha; a janela do Tk fica escondida e só roda o laço.
217
+ self.janela, self.vista = nativa
218
+ self.nativa = True
219
+ self.raiz.withdraw()
220
+ else:
221
+ self.janela = self.vista = None
222
+ self.nativa = False
223
+ self._montar_tk()
224
+
107
225
  self.raiz.protocol("WM_DELETE_WINDOW", self.fechar)
108
226
  self.raiz.bind("<Escape>", lambda e: self.fechar())
109
227
  self._pintar()
110
228
 
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
- """
229
+ # ── a reserva em Tk (fora do macOS, ou sem PyObjC) ───────────────────
230
+ def _montar_tk(self) -> None:
120
231
  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
232
  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":
233
+ if sys.platform == "win32":
234
+ # No Windows o Tk dá transparência por cor-chave, e ela funciona.
140
235
  try:
141
236
  self.raiz.attributes("-transparentcolor", FUNDO_FALSO)
142
- transparente = True
143
237
  except tk.TclError:
144
238
  pass
145
-
146
239
  try:
147
240
  self.raiz.attributes("-topmost", True)
148
241
  except tk.TclError:
149
242
  pass
150
-
151
243
  self.raiz.configure(bg=self.fundo)
152
244
  self.tela = tk.Canvas(self.raiz, width=LARGURA, height=ALTURA,
153
245
  bg=self.fundo, highlightthickness=0, bd=0)
154
246
  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.
247
+ self.raiz.update_idletasks()
248
+ self._posicionar_tk()
249
+ self.raiz.after(120, self._posicionar_tk)
163
250
  self.raiz.bind("<Button-1>", self._pegar)
164
251
  self.raiz.bind("<B1-Motion>", self._arrastar)
165
252
  self._pego = (0, 0)
166
253
 
254
+ def _posicionar_tk(self) -> None:
255
+ x = (self.raiz.winfo_screenwidth() - LARGURA) // 2
256
+ y = self.raiz.winfo_screenheight() - ALTURA - MARGEM_INFERIOR - 48
257
+ try:
258
+ self.raiz.geometry(f"{LARGURA}x{ALTURA}+{x}+{max(0, y)}")
259
+ except tk.TclError:
260
+ pass
261
+
167
262
  def _pegar(self, e) -> None:
168
263
  self._pego = (e.x_root - self.raiz.winfo_x(), e.y_root - self.raiz.winfo_y())
169
264
 
170
265
  def _arrastar(self, e) -> None:
171
266
  self.raiz.geometry(f"+{e.x_root - self._pego[0]}+{e.y_root - self._pego[1]}")
172
267
 
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
-
268
+ # ── a animação ───────────────────────────────────────────────────────
269
+ def _quadro(self) -> Quadro:
193
270
  self._fase += 0.045
194
271
  vivo, fundo = CORES.get(self.estado, CORES["esperando"])
195
272
  cx, cy = LARGURA / 2, ALTURA / 2
196
- self.tela.delete("all")
197
273
 
198
274
  # A respiração de cada estado. Quem espera respira devagar; quem
199
275
  # pensa, num ritmo próprio; quem ouve pulsa com a VOZ, não com o
@@ -206,49 +282,77 @@ class Orbe:
206
282
  pulso = 0.55 + 0.38 * math.sin(self._fase * 3.4)
207
283
  else:
208
284
  pulso = 0.16 + 0.10 * math.sin(self._fase * 0.9)
285
+ raio = RAIO_BASE * (1 + pulso * 0.30)
209
286
 
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.
287
+ pontos = []
222
288
  for p in self._particulas:
223
289
  p.ang += p.giro * 0.012 * (0.6 + pulso)
224
290
  # O raio respira junto com a voz: quanto mais alto se fala, mais
225
291
  # longe o enxame se abre.
226
292
  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="")
293
+ r = raio * (p.raio + 0.25 * osc + pulso * 0.40)
294
+ # Elipse leve, mais larga que alta — e dentro da janela: o que
295
+ # passa da borda é cortado seco, e partícula cortada é defeito.
296
+ px = cx + math.cos(p.ang) * r * 1.2
297
+ py = cy + math.sin(p.ang) * r * 0.74
298
+ t = p.tam * (0.75 + pulso * 0.6)
299
+ pontos.append((px, py, t, p.brilho * (0.5 + pulso * 0.5)))
300
+ return Quadro(vivo, fundo, raio, pulso, pontos)
240
301
 
302
+ def _pintar(self) -> None:
303
+ if not self._vivo:
304
+ return
305
+ while True:
306
+ try:
307
+ estado, nivel = self._fila.get_nowait()
308
+ if estado == "__esconder__":
309
+ self._aplicarVisibilidade(False)
310
+ continue
311
+ if estado == "__mostrar__":
312
+ self._aplicarVisibilidade(True)
313
+ continue
314
+ if estado:
315
+ self.estado = estado
316
+ if nivel is not None:
317
+ self.nivel = max(0.0, min(1.0, nivel))
318
+ except queue.Empty:
319
+ break
320
+
321
+ q = self._quadro()
322
+ if self.nativa:
323
+ self.vista.quadro = q
324
+ self.vista.setNeedsDisplay_(True)
325
+ else:
326
+ self._pintar_tk(q)
241
327
  self.raiz.after(33, self._pintar)
242
328
 
329
+ def _pintar_tk(self, q: Quadro) -> None:
330
+ """A reserva: forma chapada, sem alfa. Misturas contra o fundo."""
331
+ cx, cy = LARGURA / 2, ALTURA / 2
332
+ self.tela.delete("all")
333
+ for i in range(6, 0, -1):
334
+ f = i / 6
335
+ r = q.raio * (1 + f * 1.3)
336
+ self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
337
+ outline=_mistura(self.fundo, q.vivo, (1 - f) ** 1.6 * 0.7), width=2)
338
+ for (px, py, t, forca) in q.particulas:
339
+ self.tela.create_oval(px - t, py - t, px + t, py + t,
340
+ fill=_mistura(self.fundo, q.vivo, 0.3 + forca * 0.7), outline="")
341
+ r = q.raio
342
+ self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
343
+ fill=_mistura(q.fundo, q.vivo, 0.8), outline="")
344
+ rn = r * 0.55
345
+ self.tela.create_oval(cx - rn, cy - rn, cx + rn, cy + rn,
346
+ fill=_mistura(q.vivo, "#ffffff", 0.4), outline="")
347
+
243
348
  # ── visibilidade ─────────────────────────────────────────────────────
244
349
  def esconder(self) -> None:
245
350
  """Some da tela sem morrer.
246
351
 
247
352
  No modo de espera ele fica horas sem nada acontecer, e uma orb parada
248
353
  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é.
354
+ despertar pisca e, em alguns sistemas, rouba o foco de onde a pessoa
355
+ estava digitando. Esconder guarda tudo de pé.
252
356
  """
253
357
  self._fila.put(("__esconder__", None))
254
358
 
@@ -256,9 +360,14 @@ class Orbe:
256
360
  self._fila.put(("__mostrar__", None))
257
361
 
258
362
  def _aplicarVisibilidade(self, visivel: bool) -> None:
259
- # Só na thread da interface: tkinter não perdoa chamada de fora.
363
+ # Só na thread da interface: nem o Tk nem o AppKit perdoam chamada de fora.
260
364
  try:
261
- if visivel:
365
+ if self.nativa:
366
+ if visivel:
367
+ self.janela.orderFrontRegardless()
368
+ else:
369
+ self.janela.orderOut_(None)
370
+ elif visivel:
262
371
  self.raiz.deiconify()
263
372
  self.raiz.lift()
264
373
  if sys.platform != "linux":
@@ -282,6 +391,12 @@ class Orbe:
282
391
  self._ao_fechar()
283
392
  except Exception:
284
393
  pass
394
+ try:
395
+ if self.janela is not None:
396
+ self.janela.orderOut_(None)
397
+ self.janela.close()
398
+ except Exception:
399
+ pass
285
400
  try:
286
401
  self.raiz.destroy()
287
402
  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())