File size: 63,293 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
"""openTARS 3.0: voz.

- ouvir: o microfone (arecord / parecord / pw-record, o que houver) vira
  trechos de fala (Segmentador: energia com piso de ruído que se adapta);
- palavra de ativação: cada trecho curto passa por um Whisper pequeno
  (tiny); se começa com "TARS" ("ei TARS", "ok TARS"...), o trecho é
  transcrito de novo com o modelo melhor e o que vem depois do nome é o
  pedido. "TARS" sozinho: o próximo trecho é o pedido;
- transcrever: faster-whisper (roda na CPU, int8);
- falar: Piper (voz neural local) + o tocador que houver (pw-play,
  paplay, aplay).
- 5.2: no Windows e no macOS o microfone é o sounddevice (este arquivo com
  --gravar, cru como o arecord) e o tocador é o do sistema (winsound com
  --tocar no Windows, afplay no macOS).

Tudo 100% local. As dependências (faster-whisper, piper-tts) e os modelos
(~470 MB de bibliotecas, ~220 MB dos modelos do Whisper e ~60 MB por voz) só são baixados quando a pessoa
liga a voz (instalar()).

A lógica pura (Segmentador, achar_ativacao, limpar_para_fala, escolhas de
voz e de programa) não depende de nada instalado e é testada sozinha."""

import array
import importlib.util
import json
import math
import os
import queue
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import unicodedata
from pathlib import Path

import tars_plataforma as plataforma

TAXA = 16000               # Hz, mono, 16 bits: o que o Whisper quer
BYTES_AMOSTRA = 2
QUADRO_MS = 30

# ------------------------------------------------------------
# Onde ficam os modelos
# ------------------------------------------------------------


def pasta_dados():
    return plataforma.pastas()["dados"] / "voz"


def pasta_pacotes():
    """Onde o openTARS instala as bibliotecas da voz: na pasta da PESSOA (o
    Python do openTARS fica em /usr/share, que é do root), uma por versão
    do Python (atualizar o sistema não quebra)."""
    return pasta_dados() / f"python{sys.version_info.major}.{sys.version_info.minor}"


def _ativar_pacotes():
    pasta = str(pasta_pacotes())
    if Path(pasta).is_dir() and pasta not in sys.path:
        sys.path.append(pasta)
    return pasta


_ativar_pacotes()

# ------------------------------------------------------------
# Whisper (ouvir): escolhido sozinho pelo hardware (4.0)
# ------------------------------------------------------------
# Na placa (NVIDIA com memória sobrando): large-v3-turbo, preciso e rápido. Só no
# processador: small (multilíngue; em inglês, small.en: os ".en" acertam mais).
# TARS_WHISPER / TARS_WHISPER_ATIVACAO trocam os modelos;
# TARS_WHISPER_DISPOSITIVO=cpu|cuda|auto força o lugar.
MODELO_WHISPER = os.environ.get("TARS_WHISPER") or None             # None = escolher sozinho
MODELO_WHISPER_ATIVACAO = os.environ.get("TARS_WHISPER_ATIVACAO") or None
WHISPER_PLACA = ("large-v3-turbo", "base")    # (pedido, "TARS") na placa (5.0: multilíngue, serve pro português)
WHISPER_PLACA_EN = ("large-v3-turbo", "base.en")
WHISPER_CPU = ("small", "base")               # (pedido, "TARS") no processador, multilíngue (português e inglês)
WHISPER_CPU_EN = ("small.en", "base.en")      # só em inglês os ".en" acertam mais
VRAM_LIVRE_MIN_MB = 4500                      # placa de 6 GB vazia: cabe o Whisper (~1 GB) + o Gargantua (~3 GB)
VRAM_LIVRE_MIN_GRANDE_MB = 2000               # placa de 8 GB ou mais
_config_whisper = {}
_idioma_whisper = {"l": "en"}   # idioma da voz em uso (muda o modelo escolhido)


def _memoria_da_placa():
    """(livre_mb, total_mb) da NVIDIA, ou None."""
    if not shutil.which("nvidia-smi"):
        return None
    try:
        saida = subprocess.run(["nvidia-smi", "--query-gpu=memory.free,memory.total", "--format=csv,noheader,nounits"],
                               capture_output=True, text=True, timeout=5).stdout.strip().splitlines()
        livre, total = (int(x) for x in saida[0].split(","))
        return livre, total
    except Exception:
        return None


def placa_serve_pro_whisper(memoria=None):
    memoria = memoria if memoria is not None else _memoria_da_placa()
    if not memoria:
        return False
    livre, total = memoria
    return livre >= VRAM_LIVRE_MIN_MB or (total >= 8000 and livre >= VRAM_LIVRE_MIN_GRANDE_MB)


def config_whisper(forcar=False, memoria=None):
    """{'pedido': (modelo, dispositivo, tipo), 'ativacao': (...)}."""
    if _config_whisper and not forcar and _config_whisper.get("idioma") == _idioma_whisper["l"]:
        return dict(_config_whisper)
    lugar = (os.environ.get("TARS_WHISPER_DISPOSITIVO") or "auto").lower()
    placa = lugar == "cuda" or (lugar == "auto" and not _config_whisper.get("placa_falhou")
                                and placa_serve_pro_whisper(memoria))
    ingles = _idioma_whisper["l"] == "en"
    pedido, ativacao = (WHISPER_PLACA_EN if ingles else WHISPER_PLACA) if placa else (WHISPER_CPU_EN if ingles else WHISPER_CPU)
    dispositivo, tipo = ("cuda", "int8_float16") if placa else ("cpu", "int8")
    _config_whisper.update(idioma=_idioma_whisper["l"], pedido=(MODELO_WHISPER or pedido, dispositivo, tipo),
                           ativacao=(MODELO_WHISPER_ATIVACAO or ativacao, dispositivo, tipo))
    return dict(_config_whisper)


def _preparar_cuda():
    """O faster-whisper na placa precisa das bibliotecas cuBLAS e cuDNN. Elas
    vêm do pip (nvidia-cublas-cu12, nvidia-cudnn-cu12) na pasta da voz:
    carrega antes, pra o ctranslate2 achar sem mexer no LD_LIBRARY_PATH."""
    import ctypes
    import glob
    base = pasta_pacotes() / "nvidia"
    for padrao in ("cublas/lib/libcublasLt.so*", "cublas/lib/libcublas.so*", "cudnn/lib/libcudnn*.so*"):
        for arquivo in sorted(glob.glob(str(base / padrao))):
            try:
                ctypes.CDLL(arquivo, mode=ctypes.RTLD_GLOBAL)
            except OSError:
                pass


# ------------------------------------------------------------
# Falar: Kokoro (4.0)
# ------------------------------------------------------------
# Kokoro-82M (Apache 2.0) pelo onnxruntime, no processador: soa natural,
# bem diferente do Piper (que ficou só de reserva). Um modelo só pra todas
# as vozes; a fonética vem do espeak-ng que o kokoro-onnx já traz.
KOKORO_ARQUIVOS = {
    "kokoro-v1.0.onnx": "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx",
    "voices-v1.0.bin": "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin",
}
KOKORO_LINGUA = {"en": "en-us", "pt_BR": "pt-br"}

# nome -> (voz do Kokoro, descrição curta). A primeira é a padrão.
# 5.0 Tesseract: o português voltou (Kokoro pt-br: menos natural que o inglês, mas funciona).
VOZES_DISPONIVEIS = {
    "pt_BR": {
        "dora": ("pf_dora", "feminina (padrão)"),
        "alex": ("pm_alex", "masculina"),
        "santa": ("pm_santa", "masculina, grave"),
    },
    "en": {
        "heart": ("af_heart", "female, warm (default)"),
        "bella": ("af_bella", "female, bright"),
        "michael": ("am_michael", "male"),
        "emma": ("bf_emma", "female, British"),
    },
}

# Reserva: Piper (rhasspy/piper-voices), se o Kokoro não estiver instalado
# mas a voz antiga já estiver baixada.
VOZES = {
    "en": ("en", "en_US", "lessac", "medium"),
}
URL_VOZ = ("https://huggingface.co/rhasspy/piper-voices/resolve/main/"
           "{familia}/{codigo}/{nome}/{qualidade}/{codigo}-{nome}-{qualidade}{extensao}?download=true")

# Preferências que o núcleo passa (config.json): voz escolhida por idioma,
# velocidade (1.0 = normal; 1.2 = 20% mais rápida) e pausa entre frases.
PREFERENCIAS = {"vozes": {}, "velocidade": 1.0, "pausa": 0.15}
VELOCIDADE_MIN, VELOCIDADE_MAX = 0.7, 1.6


def configurar(vozes=None, velocidade=None, pausa=None, idioma=None):
    if idioma is not None:
        _idioma_whisper["l"] = idioma if idioma in IDIOMA_WHISPER else "en"
    if vozes is not None:
        PREFERENCIAS["vozes"] = {k: v for k, v in vozes.items() if v in VOZES_DISPONIVEIS.get(k, {})}
    if velocidade is not None:
        PREFERENCIAS["velocidade"] = min(VELOCIDADE_MAX, max(VELOCIDADE_MIN, float(velocidade)))
    if pausa is not None:
        PREFERENCIAS["pausa"] = min(1.5, max(0.0, float(pausa)))


def _idioma_voz(idioma):
    return idioma if idioma in VOZES_DISPONIVEIS else "en"


def nome_da_voz(idioma, nome=None):
    """Nome curto da voz pedida, da escolhida ou da padrão ('dora')."""
    disponiveis = VOZES_DISPONIVEIS[_idioma_voz(idioma)]
    nome = nome or PREFERENCIAS["vozes"].get(_idioma_voz(idioma))
    return nome if nome in disponiveis else next(iter(disponiveis))


def voz_kokoro(idioma, nome=None):
    """('af_heart', 'en-us')"""
    codigo = VOZES_DISPONIVEIS[_idioma_voz(idioma)][nome_da_voz(idioma, nome)][0]
    lingua = "en-gb" if codigo.startswith("b") else KOKORO_LINGUA[_idioma_voz(idioma)]
    return codigo, lingua


def pasta_kokoro():
    return pasta_dados() / "kokoro"


def arquivos_kokoro():
    return pasta_kokoro() / "kokoro-v1.0.onnx", pasta_kokoro() / "voices-v1.0.bin"


def kokoro_baixado():
    return all(a.exists() and a.stat().st_size > 1000 for a in arquivos_kokoro())


def arquivo_voz(idioma, nome=None):
    """O arquivo que precisa existir pra falar: o modelo do Kokoro (o mesmo
    pra todas as vozes)."""
    return arquivos_kokoro()[0]


def arquivo_voz_piper(idioma):
    familia, codigo, nome, qualidade = VOZES.get(idioma) or VOZES["en"]
    return pasta_dados() / f"{codigo}-{nome}-{qualidade}.onnx"


def urls_voz_piper(idioma):
    familia, codigo, nome, qualidade = VOZES.get(idioma) or VOZES["en"]
    campos = dict(familia=familia, codigo=codigo, nome=nome, qualidade=qualidade)
    return URL_VOZ.format(extensao=".onnx", **campos), URL_VOZ.format(extensao=".onnx.json", **campos)


IDIOMA_WHISPER = {"en": "en", "pt_BR": "pt"}
IDIOMAS_COM_VOZ = ("en", "pt_BR")


def tem_voz(idioma):
    """Ouvir e falar em inglês e em português (5.0)."""
    return str(idioma or "") in IDIOMAS_COM_VOZ

# Só o nome, sem frase de exemplo: uma frase longa (a do 3.0 Miller, em
# português) fazia o Whisper "ouvir" coisas que ninguém falou.
DICA = "TARS, openTARS."

# O que o TARS responde quando ouve só o nome
CONFIRMACOES = {"en": "Yes?", "pt_BR": "Sim?"}


# ------------------------------------------------------------
# Programas de áudio
# ------------------------------------------------------------
GRAVADORES = (
    ("arecord", ["arecord", "-q", "-f", "S16_LE", "-r", str(TAXA), "-c", "1", "-t", "raw"]),
    ("parecord", ["parecord", "--raw", f"--rate={TAXA}", "--channels=1", "--format=s16le"]),
    ("pw-record", ["pw-record", "--rate", str(TAXA), "--channels", "1", "--format", "s16", "-"]),
)
TOCADORES = (
    ("pw-play", ["pw-play"]),
    ("paplay", ["paplay"]),
    ("aplay", ["aplay", "-q"]),
    ("afplay", ["afplay"]),          # macOS
)
_AJUDANTE = [sys.executable, str(Path(__file__).resolve())]     # este arquivo como programa (--gravar, --tocar)


def _primeiro(programas, existe=shutil.which):
    for programa, comando in programas:
        if existe(programa):
            return list(comando)
    return None


def _gravadores(existe=shutil.which):
    """Os gravadores que existem aqui, na ordem de preferência."""
    if plataforma.sistema() != "linux":
        return [_AJUDANTE + ["--gravar"]] if _tem_modulo("sounddevice") else []
    return [list(c) for p, c in GRAVADORES if existe(p)]


def comando_gravar(existe=shutil.which):
    return next(iter(_gravadores(existe)), None)


def comando_tocar(existe=shutil.which):
    if plataforma.sistema() == "windows":
        return _AJUDANTE + ["--tocar"]
    return _primeiro(TOCADORES, existe)


def _tem_modulo(nome):
    try:
        return importlib.util.find_spec(nome) is not None
    except (ImportError, ValueError):
        return False


def o_que_falta(idioma="en", existe=shutil.which, tem_modulo=_tem_modulo):
    """Lista do que falta pra voz funcionar (vazia = pronta). Cada item é
    uma chave curta: 'faster-whisper', 'kokoro-onnx', 'voz', 'gravador', 'tocador'."""
    falta = []
    if not tem_modulo("faster_whisper"):
        falta.append("faster-whisper")
    if not tem_modulo("kokoro_onnx"):
        falta.append("kokoro-onnx")
    if not kokoro_baixado():
        falta.append("voz")
    if plataforma.sistema() != "linux":
        if not tem_modulo("sounddevice"):
            falta.append("sounddevice")       # o microfone; o tocador é do sistema (winsound, afplay)
        return falta
    if not comando_gravar(existe):
        falta.append("gravador")
    if not comando_tocar(existe):
        falta.append("tocador")
    return falta


# ------------------------------------------------------------
# Instalação (só quando a pessoa liga a voz)
# ------------------------------------------------------------
PACOTES = ("faster-whisper", "kokoro-onnx")
# Só com placa NVIDIA: as bibliotecas que o Whisper usa na placa (~1 GB).
PACOTES_PLACA = ("nvidia-cublas-cu12", "nvidia-cudnn-cu12==9.*")


def _pip(pacotes, avisar):
    avisar("pip install " + " ".join(pacotes))
    pasta = pasta_pacotes()
    pasta.mkdir(parents=True, exist_ok=True)
    comando = [sys.executable, "-m", "pip", "install", "--quiet", "--upgrade", "--target", str(pasta), *pacotes]
    r = subprocess.run(comando, capture_output=True, text=True)
    _ativar_pacotes()
    importlib.invalidate_caches()
    return r.returncode == 0, (r.stderr or r.stdout or "pip failed").strip()[-600:]


def _tem_bibliotecas_placa():
    return (pasta_pacotes() / "nvidia" / "cudnn").is_dir() and (pasta_pacotes() / "nvidia" / "cublas").is_dir()


def instalar(idioma, avisar=print, progresso=None):
    """Instala as bibliotecas no Python do openTARS, baixa o Kokoro e os
    modelos do Whisper. Devolve (ok, mensagem). `progresso(fracao | None, texto)` alimenta a barra da janela (26/10):
    fracao de 0 a 1 do conjunto (o Kokoro, de ~340 MB, é a parte medida em bytes), ou None quando a etapa não tem tamanho
    conhecido (o pip e o carregamento do Whisper) e a barra fica em movimento."""
    progresso = progresso or (lambda fracao, texto: None)
    falta = o_que_falta(idioma)
    pacotes = PACOTES if plataforma.sistema() == "linux" else PACOTES + ("sounddevice",)
    lugar = (os.environ.get("TARS_WHISPER_DISPOSITIVO") or "auto").lower()
    # peso de cada etapa na barra: só entram as que vão rodar (o que já está instalado não conta)
    pesos = {"libs": 15 if set(falta) & set(pacotes) else 0,
             "placa": 20 if lugar != "cpu" and placa_serve_pro_whisper() and not _tem_bibliotecas_placa() else 0,
             "kokoro": 55 if not kokoro_baixado() else 0, "whisper": 10}
    total, feito = sum(pesos.values()), 0

    def fatia(etapa, parte=None):
        return (feito + pesos[etapa] * (parte or 0)) / total

    if pesos["libs"]:
        progresso(None, "pip install " + " ".join(pacotes))
        ok, erro = _pip(pacotes, avisar)
        if not ok:
            return False, erro
        feito += pesos["libs"]
    if pesos["placa"]:
        progresso(None, "pip install " + " ".join(PACOTES_PLACA))
        ok, _erro = _pip(PACOTES_PLACA, avisar)  # sem elas, o Whisper fica no processador
        if not ok:
            _config_whisper["placa_falhou"] = True
        feito += pesos["placa"]
    if pesos["kokoro"]:
        ok, erro = baixar_voz(idioma, avisar, progresso=lambda parte, texto: progresso(fatia("kokoro", parte), texto))
        if not ok:
            return False, erro
        feito += pesos["kokoro"]
    config = config_whisper(forcar=True)
    avisar(f"Whisper {config['ativacao'][0]} + {config['pedido'][0]} ({config['pedido'][1]})")
    progresso(None, f"Whisper {config['pedido'][0]}")
    try:
        Transcritor(papel="ativacao").carregar()
        Transcritor(papel="pedido").carregar()
    except Exception as e:
        return False, f"Whisper: {e}"
    falta = o_que_falta(idioma)
    if falta:
        return False, "missing: " + ", ".join(falta)
    return True, ""


def _baixar(url, caminho, avisar, ao_bytes=None):
    import requests  # só aqui: o resto do módulo não precisa

    avisar(f"{caminho.name}...")
    caminho.parent.mkdir(parents=True, exist_ok=True)
    temporario = caminho.with_suffix(caminho.suffix + ".parte")
    try:
        with requests.get(url, stream=True, timeout=60) as r:
            r.raise_for_status()
            total, feito = int(r.headers.get("Content-Length") or 0), 0
            with open(temporario, "wb") as f:
                for bloco in r.iter_content(1 << 16):
                    f.write(bloco)
                    feito += len(bloco)
                    if ao_bytes:
                        ao_bytes(feito, total)
        os.replace(temporario, caminho)
        return True, ""
    except Exception as e:
        try:
            temporario.unlink()
        except OSError:
            pass
        return False, f"{caminho.name}: {e}"


def baixar_voz(idioma, avisar=print, nome=None, progresso=None):
    """Baixa o Kokoro (~340 MB, uma vez: serve pra todas as vozes). `progresso(fracao, texto)`: fração dos bytes dos
    arquivos que faltam (o `voices` é pequeno; o modelo é quase tudo)."""
    faltam = [(n, u) for n, u in KOKORO_ARQUIVOS.items()
              if not ((pasta_kokoro() / n).exists() and (pasta_kokoro() / n).stat().st_size > 1000)]
    for i, (nome_arquivo, url) in enumerate(faltam):
        def ao_bytes(feito, total, i=i, nome_arquivo=nome_arquivo):
            if progresso:
                parte = ((i + (feito / total if total else 0)) / len(faltam))
                progresso(parte, f"{nome_arquivo}: {feito / 1e6:.0f} / {total / 1e6:.0f} MB" if total else nome_arquivo)
        ok, erro = _baixar(url, pasta_kokoro() / nome_arquivo, avisar, ao_bytes)
        if not ok:
            return False, erro
    return True, ""


# ------------------------------------------------------------
# Segmentador: microfone -> trechos de fala
# ------------------------------------------------------------
LIMIAR_MINIMO = 180        # RMS (0–32768): abaixo disso nunca é fala
FATOR_RUIDO = 3.0          # fala = mais que 3x o ruído de fundo
SILENCIO_FIM_MS = 600      # tanto silêncio encerra o trecho
FALA_MINIMA_MS = 250       # menos que isso é estalo, tosse, clique
FALA_MAXIMA_S = 12
PRE_ROLL_MS = 300          # o começo da palavra fica abaixo do limiar
AQUECIMENTO_MS = 250       # o começo só aprende o ruído de fundo (ventoinha, rua)


def rms(quadro):
    amostras = array.array("h")
    amostras.frombytes(quadro[: len(quadro) - len(quadro) % 2])
    if sys.byteorder == "big":
        amostras.byteswap()
    if not amostras:
        return 0.0
    return math.sqrt(sum(a * a for a in amostras) / len(amostras))


class Segmentador:
    """Recebe áudio cru (16 kHz, 16 bits, mono) em pedaços de qualquer
    tamanho e devolve os trechos de fala completos (bytes)."""

    def __init__(self, taxa=TAXA, quadro_ms=QUADRO_MS):
        self.bytes_quadro = int(taxa * quadro_ms / 1000) * BYTES_AMOSTRA
        self.quadro_ms = quadro_ms
        self.piso = None
        self._resto = b""
        self._pre = []
        self._fala = []
        self._silencio_ms = 0
        self._fala_ms = 0
        self._aquecido_ms = 0
        self.id_trecho = 0        # sobe a cada fala que começa

    @property
    def limiar(self):
        return max(LIMIAR_MINIMO, (self.piso or 0) * FATOR_RUIDO)

    def alimentar(self, dados):
        return [trecho for _id, trecho in self.alimentar_com_id(dados)]

    def alimentar_com_id(self, dados):
        """Como alimentar(), mas cada trecho vem com o id da fala: [(id, bytes)]."""
        prontos = []
        dados = self._resto + dados
        n = len(dados) - len(dados) % self.bytes_quadro
        self._resto = dados[n:]
        for i in range(0, n, self.bytes_quadro):
            ident = self.id_trecho
            trecho = self._quadro(dados[i:i + self.bytes_quadro])
            if trecho:
                prontos.append((ident, trecho))
        return prontos

    @property
    def falando_ms(self):
        """Quanto da fala EM ANDAMENTO já chegou (0 se ninguém está falando)."""
        return len(self._fala) * self.quadro_ms if self._fala else 0

    def audio_parcial(self, max_s):
        """O começo da fala em andamento (até max_s segundos)."""
        return b"".join(self._fala[:int(max_s * 1000 / self.quadro_ms)])

    def encerrar(self):
        """Fecha a fala em andamento agora (o botão "segurar para falar" foi solto). bytes ou None."""
        if not self._fala:
            return None
        fala, fala_ms = b"".join(self._fala), self._fala_ms
        self._fala, self._fala_ms, self._silencio_ms = [], 0, 0
        return fala if fala_ms >= FALA_MINIMA_MS else None

    def _quadro(self, quadro):
        energia = rms(quadro)
        if self._aquecido_ms < AQUECIMENTO_MS:
            # o quadro mais quieto do começo é o ruído de fundo; os quadros
            # ficam no pre-roll (quem já começou a falar não perde o início)
            self._aquecido_ms += self.quadro_ms
            self.piso = energia if self.piso is None else min(self.piso, energia)
            self._pre.append(quadro)
            if len(self._pre) * self.quadro_ms > PRE_ROLL_MS:
                self._pre.pop(0)
            return None
        falando = energia > self.limiar
        if not self._fala:
            if falando:
                self.id_trecho += 1
                self._fala = list(self._pre) + [quadro]
                self._fala_ms = self.quadro_ms
                self._silencio_ms = 0
                self._pre = []
                return None
            # silêncio: aprende o ruído de fundo e guarda o pre-roll
            self.piso = energia if self.piso is None else 0.95 * self.piso + 0.05 * energia
            self._pre.append(quadro)
            if len(self._pre) * self.quadro_ms > PRE_ROLL_MS:
                self._pre.pop(0)
            return None
        self._fala.append(quadro)
        if falando:
            self._fala_ms += self.quadro_ms
            self._silencio_ms = 0
        else:
            self._silencio_ms += self.quadro_ms
        total_ms = len(self._fala) * self.quadro_ms
        if self._silencio_ms >= SILENCIO_FIM_MS or total_ms >= FALA_MAXIMA_S * 1000:
            fala, fala_ms = b"".join(self._fala), self._fala_ms
            self._fala, self._fala_ms, self._silencio_ms = [], 0, 0
            if fala_ms >= FALA_MINIMA_MS:
                return fala
        return None


# ------------------------------------------------------------
# Palavra de ativação
# ------------------------------------------------------------
# O Whisper escreve "TARS" de vários jeitos: Tars, Tarz, Thars, Taars...
# Como o Whisper costuma escrever "TARS" falado: tars, tarz, thars, taars,
# tares, dars, ters...
_RE_ATIVACAO = re.compile(
    r"\b(?:open[\s-]?)?(?:th?|d)(?:a{1,2}|e)r(?:[sz]{1,2}[oe]?|es)h?\b[\s,.!?:;…-]*", re.IGNORECASE)
PALAVRAS_ANTES_MAX = 2     # "ei TARS", "ok TARS", "hey there TARS": o nome vem logo no começo


def _sem_acento(texto):
    """Tira acentos SEM mudar o tamanho do texto (um caractere por
    caractere): as posições achadas valem pro texto original."""
    return "".join(unicodedata.normalize("NFD", ch)[0] for ch in texto)


def achar_ativacao(texto):
    """(chamou, resto): 'Ei TARS, abre o Firefox.' -> (True, 'abre o Firefox.').
    Só vale com o nome no começo (até 2 palavras antes), pra não ligar
    quando alguém só fala SOBRE o TARS."""
    original = (texto or "").strip()
    achado = _RE_ATIVACAO.search(_sem_acento(original))
    if not achado:
        return False, ""
    antes = re.findall(r"\w+", original[:achado.start()])
    if len(antes) > PALAVRAS_ANTES_MAX:
        return False, ""
    resto = original[achado.end():].strip(" \t,.;:!?-…")
    if resto and not re.search(r"\w", resto):
        resto = ""
    return True, resto


def parece_chamado(texto):
    """O modelo rápido ouviu algo PARECIDO com "TARS" no começo ("Taz",
    "Tarde", "Dárcio")? Aí o modelo bom confere de novo, em vez de perder
    o chamado."""
    import difflib
    palavras = re.findall(r"[a-z]+", _sem_acento((texto or "").lower()))[:PALAVRAS_ANTES_MAX + 1]
    return any(difflib.SequenceMatcher(None, p, "tars").ratio() >= 0.55 for p in palavras if 3 <= len(p) <= 7)


# Frases que o Whisper "ouve" no silêncio ou no barulho (vieram das legendas
# com que ele foi treinado). Se o trecho inteiro é isso, ninguém falou nada.
_ALUCINACOES = re.compile(
    r"^\W*(thanks? (you )?for watching|subtitles? by.*|please subscribe|(music|applause|laughter|silence)|"
    r"obrigad[oa] por assistir|legendas? (pela|por|de).*|inscreva-se.*|(música|palmas|risos|silêncio)|"
    r"\.+|♪+)\W*$", re.IGNORECASE)


def e_alucinacao(texto):
    return bool(_ALUCINACOES.match((texto or "").strip())) or "amara.org" in (texto or "").lower()


# ------------------------------------------------------------
# Texto -> fala
# ------------------------------------------------------------
LIMITE_FALA = 700  # caracteres: resposta longa é lida até aí (o resto está na tela)


def limpar_para_fala(texto, limite=LIMITE_FALA):
    """Tira o que não se fala: blocos de código, markdown, links, emoji."""
    t = texto or ""
    t = re.sub(r"```.*?(```|$)", " ", t, flags=re.S)
    t = re.sub(r"`([^`]*)`", r"\1", t)
    t = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", t)
    t = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", t)
    t = re.sub(r"https?://\S+", " ", t)
    t = re.sub(r"^\s{0,3}#{1,6}\s*", "", t, flags=re.M)
    t = re.sub(r"^\s*[-*•]\s+", "", t, flags=re.M)
    t = re.sub(r"^\s*\|.*\|\s*$", " ", t, flags=re.M)      # tabelas
    t = re.sub(r"[*_~]{1,3}([^*_~\n]+)[*_~]{1,3}", r"\1", t)
    t = "".join(ch for ch in t if unicodedata.category(ch) not in ("So", "Cs", "Co"))
    t = re.sub(r"\s*\n\s*", ". ", t.strip())
    t = re.sub(r"\.(\s*\.)+", ".", t)
    t = re.sub(r"([!?:;])\.", r"\1", t)
    t = re.sub(r"\s{2,}", " ", t).strip(" .")
    if len(t) > limite:
        corte = t[:limite]
        fim = max(corte.rfind(". "), corte.rfind("! "), corte.rfind("? "))
        t = corte[:fim + 1] if fim > limite // 3 else corte.rsplit(" ", 1)[0] + "..."
    return t


# O texto já limpo vira algo que soa natural falado. "12 x 8 = 96", "1,234",
# "2026-10-01", "1920x1080" ou "GB" saem estranhos lidos ao pé da letra: aqui viram
# "12 times 8 equals 96", "1234", "October 1, 2026", "1920 by 1080", "gigabytes"
# (em português: "12 vezes 8 igual a 96", "1 de outubro de 2026", "1920 por 1080").
_FALA = {
    "en": {
        "unidades": {"GB": "gigabytes", "MB": "megabytes", "KB": "kilobytes", "TB": "terabytes", "GHz": "gigahertz",
                     "MHz": "megahertz", "km": "kilometers", "ms": "milliseconds", "°C": "degrees", "%": "percent"},
        "operadores": {"x": "times", "*": "times", "×": "times", "/": "divided by", "÷": "divided by", "+": "plus",
                       "-": "minus", "=": "equals"},
        "meses": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
                  "November", "December"],
        "data": lambda dia, mes, ano: f"{mes} {dia}, {ano}",
        "por": "by", "ate": "to", "igual": "equals", "marca": "open Tars", "nome": "Tars",
    },
    "pt_BR": {
        "unidades": {"GB": "gigabytes", "MB": "megabytes", "KB": "kilobytes", "TB": "terabytes", "GHz": "gigahertz",
                     "MHz": "megahertz", "km": "quilômetros", "ms": "milissegundos", "°C": "graus", "%": "por cento"},
        "operadores": {"x": "vezes", "*": "vezes", "×": "vezes", "/": "dividido por", "÷": "dividido por",
                       "+": "mais", "-": "menos", "=": "igual a"},
        "meses": ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro",
                  "outubro", "novembro", "dezembro"],
        "data": lambda dia, mes, ano: f"{'primeiro' if dia == 1 else dia} de {mes} de {ano}",
        "por": "por", "ate": "a", "igual": "igual a", "marca": "open Tars", "nome": "Tars",
    },
}
# nomes antigos (testes e quem importa)
_UNIDADES = _FALA["en"]["unidades"]
_OPERADORES = _FALA["en"]["operadores"]
_MESES = _FALA["en"]["meses"]


def preparar_para_fala(texto, idioma="en"):
    f = _FALA.get(idioma) or _FALA["en"]
    pt = idioma == "pt_BR"
    t = texto or ""
    # Caminhos: só o último nome ("/home/sam/Desktop/notes.txt" -> "notes.txt")
    t = re.sub(r"(?:~|/[\w.-]+)(?:/[\w .-]+?)*/([\w.-]+(?: [\w.-]+)?)(?=[\s,.;:!?)]|$)", lambda m: m.group(1), t)
    t = re.sub(r"\bopenTARS\b", f["marca"], t, flags=re.I)
    t = re.sub(r"\bTARS\b", f["nome"], t)
    # milhares: "1,234" (inglês) e "1.234" (português) viram "1234"
    t = re.sub(r"\b\d{1,3}(?:\.\d{3})+\b" if pt else r"\b\d{1,3}(?:,\d{3})+\b",
               lambda m: re.sub(r"[.,]", "", m.group(0)), t)

    def data(m):
        ano, mes, dia = int(m.group(1)), int(m.group(2)), int(m.group(3))
        return f["data"](dia, f["meses"][mes - 1], ano) if 1 <= mes <= 12 and 1 <= dia <= 31 else m.group(0)

    t = re.sub(r"\b(\d{4})-(\d{2})-(\d{2})\b", data, t)
    t = re.sub(r"\b(\d{3,})x(\d{3,})\b", rf"\1 {f['por']} \2", t)
    t = re.sub(r"\b(\d+)-(\d+)\b", rf"\1 {f['ate']} \2", t)
    t = re.sub(r"(?<=\d)\s*([x*×/÷+=-])\s*(?=\d)", lambda m: f" {f['operadores'][m.group(1)]} ", t)
    t = re.sub(r"(?<=\d)\s*=\s*", f" {f['igual']} ", t)
    for unidade, nome in f["unidades"].items():
        t = re.sub(rf"(?<=\d)\s?{re.escape(unidade)}(?![\w])", f" {nome}", t)
    return re.sub(r"\s{2,}", " ", t).strip()


def dividir_frases(texto, minimo=40, maximo=260):
    """Frases pra falar uma de cada vez (a primeira começa a tocar enquanto
    as outras ainda são geradas). Frases curtas se juntam; longas quebram
    na vírgula."""
    partes = [p.strip() for p in re.split(r"(?<=[.!?…])\s+", texto or "") if p.strip()]
    frases = []
    for parte in partes:
        while len(parte) > maximo:
            corte = parte.rfind(", ", 0, maximo)
            corte = corte + 1 if corte > minimo else parte.rfind(" ", 0, maximo)
            if corte <= 0:
                break
            frases.append(parte[:corte].strip())
            parte = parte[corte:].strip()
        if frases and (len(frases[-1]) < minimo or len(parte) < minimo // 2) and len(frases[-1]) + len(parte) < maximo:
            frases[-1] = f"{frases[-1]} {parte}"
        else:
            frases.append(parte)
    return frases


# ------------------------------------------------------------
# Whisper
# ------------------------------------------------------------
class Transcritor:
    """papel 'pedido' ou 'ativacao' escolhe o modelo e o lugar pelo hardware
    (config_whisper). Com um nome explícito (Transcritor("small")), usa
    esse modelo no lugar escolhido."""
    _cache = {}
    _trava = threading.Lock()

    def __init__(self, tamanho=None, papel="pedido"):
        self._tamanho_fixo = tamanho
        self.papel = papel

    @property
    def tamanho(self):
        return self._tamanho_fixo or config_whisper()[self.papel][0]

    def _onde(self):
        _nome, dispositivo, tipo = config_whisper()[self.papel]
        return dispositivo, tipo

    def carregar(self):
        with self._trava:
            nome = self.tamanho
            if nome in self._cache:  # nome explícito já carregado (ou um falso, nos testes)
                return self._cache[nome]
            dispositivo, tipo = self._onde()
            chave = f"{nome}@{dispositivo}"
            modelo = self._cache.get(chave)
            if modelo is None:
                from faster_whisper import WhisperModel
                if dispositivo == "cuda":
                    try:
                        _preparar_cuda()
                        modelo = WhisperModel(nome, device="cuda", compute_type=tipo)
                    except Exception:
                        # sem cuDNN/cuBLAS, driver velho, placa cheia: volta pro processador
                        _config_whisper["placa_falhou"] = True
                        config_whisper(forcar=True)
                        nome = self._tamanho_fixo or config_whisper()[self.papel][0]
                        chave = f"{nome}@cpu"
                        modelo = self._cache.get(chave)
                if modelo is None:
                    modelo = WhisperModel(nome, device="cpu", compute_type="int8")
                self._cache[chave] = modelo
            return modelo

    def transcrever(self, audio, idioma=None, dica=None, rapido=False, vocabulario=None):
        """Texto do áudio (bytes 16 kHz 16 bits mono). rapido=True: só
        pra achar o "TARS" (poucos tokens). vocabulario: palavras que
        provavelmente aparecem (nomes dos apps): o Whisper erra menos nelas."""
        import numpy as np

        dados = np.frombuffer(audio, dtype=np.int16).astype(np.float32) / 32768.0
        lingua = IDIOMA_WHISPER.get(idioma or _idioma_whisper["l"], "en")
        if rapido:
            opcoes = dict(language=lingua, beam_size=1, vad_filter=False,
                          initial_prompt=dica or DICA, condition_on_previous_text=False, temperature=0.0,
                          without_timestamps=True, max_new_tokens=12, hotwords="TARS")
        else:
            # O pedido: busca mais cuidadosa (beam 5) e o detector de voz (Silero) tira o silêncio
            # e o barulho, que é onde o Whisper inventa frases. Sem frase de exemplo no prompt.
            palavras = ["TARS", "openTARS"] + [v for v in (vocabulario or []) if v][:40]
            opcoes = dict(language=lingua, beam_size=5, vad_filter=True,
                          condition_on_previous_text=False, temperature=0.0, without_timestamps=True,
                          hotwords=", ".join(dict.fromkeys(palavras)))
            if dica:
                opcoes["initial_prompt"] = dica
        modelo = self.carregar()
        while True:
            try:
                segmentos, _info = modelo.transcribe(dados, **opcoes)
                texto = " ".join(s.text.strip() for s in segmentos).strip()
                return "" if e_alucinacao(texto) else texto
            except TypeError as e:  # faster-whisper antigo: tira a opção que ele não conhece
                opcao = next((k for k in ("max_new_tokens", "without_timestamps", "hotwords") if k in opcoes
                              and k in str(e)), None)
                if not opcao:
                    raise
                opcoes.pop(opcao)

    def aquecer(self):
        """Carrega e roda uma vez (a primeira transcrição é a mais lenta)."""
        self.transcrever(b"\x00\x00" * (TAXA // 2), _idioma_whisper["l"], rapido=True)


# ------------------------------------------------------------
# Falar (Kokoro; Piper de reserva)
# ------------------------------------------------------------
class Falador:
    def __init__(self, tocar=None):
        self._kokoro = None              # processo que guarda o Kokoro carregado
        self._trava_kokoro = threading.Lock()
        self._processos = []
        self._trava = threading.Lock()
        self.falando = threading.Event()   # ligado enquanto QUALQUER fala estiver em andamento
        self._falas = 0
        self._tocar = tocar

    def _comecou(self):
        with self._trava:
            self._falas += 1
            self.falando.set()

    def _acabou(self):
        with self._trava:
            self._falas = max(0, self._falas - 1)
            if not self._falas:
                self.falando.clear()

    def falar(self, texto, idioma, esperar=False, ao_terminar=None):
        texto = limpar_para_fala(texto)
        if not texto:
            return False
        thread = threading.Thread(target=self._falar, args=(texto, idioma, ao_terminar), daemon=True)
        thread.start()
        if esperar:
            thread.join()
        return True

    # --- "Sim?" quando ouve só o nome ---
    @staticmethod
    def _arquivo_confirmacao(idioma):
        return pasta_dados() / "cache" / f"sim-{idioma}-{nome_da_voz(idioma)}.wav"

    def preparar_confirmacao(self, idioma):
        """Gera (uma vez por voz) o "Sim?" na voz do idioma, pra tocar na hora."""
        if kokoro_baixado() and _tem_modulo("kokoro_onnx"):
            with self._trava_kokoro:  # já deixa o Kokoro carregado pra primeira resposta
                self._iniciar_kokoro()
        destino = self._arquivo_confirmacao(idioma)
        if destino.exists() or not (kokoro_baixado() or arquivo_voz_piper(idioma).exists()):
            return destino.exists()
        destino.parent.mkdir(parents=True, exist_ok=True)
        return self._sintetizar(CONFIRMACOES.get(idioma, "Yes?"), idioma, destino) and destino.exists()

    def confirmar(self, idioma):
        """Toca o "Sim?" (ou um sininho, se a voz ainda não estiver pronta)
        sem travar: é o sinal de que ouviu o nome."""
        tocador = self._tocar or comando_tocar()
        if not tocador:
            return False
        arquivo = self._arquivo_confirmacao(idioma)
        if not arquivo.exists():
            arquivo = pasta_dados() / "cache" / "sininho.wav"
            if not arquivo.exists():
                arquivo.parent.mkdir(parents=True, exist_ok=True)
                arquivo.write_bytes(sininho_wav())
        self._comecou()  # já conta como falando: o microfone não ouve o próprio "Sim?"

        def tocar():
            try:
                subprocess.run(tocador + [str(arquivo)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                               timeout=10)
            except Exception:
                pass
            finally:
                self._acabou()
        threading.Thread(target=tocar, daemon=True).start()
        return True

    @staticmethod
    def _ambiente():
        ambiente = dict(os.environ)
        ambiente["PYTHONPATH"] = os.pathsep.join(
            x for x in (str(pasta_pacotes()), os.environ.get("PYTHONPATH", "")) if x)
        return ambiente

    @staticmethod
    def _opcoes_piper():
        """Velocidade e pausa entre frases (4.1). Um Piper antigo que não
        conheça as opções faz o Falador tentar de novo sem elas."""
        if Falador._piper_sem_opcoes:
            return []
        velocidade = PREFERENCIAS["velocidade"] or 1.0
        return ["--length-scale", f"{1.0 / velocidade:.3f}", "--sentence-silence", f"{PREFERENCIAS['pausa']:.2f}"]

    _piper_sem_opcoes = False

    # --- Kokoro: um processo que carrega o modelo uma vez e gera frase por frase ---
    def _iniciar_kokoro(self):
        if self._kokoro is not None and self._kokoro.poll() is None:
            return self._kokoro
        modelo, vozes = arquivos_kokoro()
        ambiente = self._ambiente()
        # a pasta da voz vem PRIMEIRO: o numpy/onnxruntime dela, não os do sistema
        self._kokoro = subprocess.Popen(
            [sys.executable, str(Path(__file__).resolve()), "--kokoro", str(modelo), str(vozes)],
            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=ambiente,
            text=True, bufsize=1)
        if not self._ler_kokoro(timeout=90).get("pronto"):
            self._fechar_kokoro()
            return None
        return self._kokoro

    def _ler_kokoro(self, timeout):
        import select
        processo = self._kokoro
        if processo is None or processo.stdout is None:
            return {}
        if os.name == "nt":
            # o select do Windows só aceita socket: lê numa thread (no tempo esgotado quem chama mata o processo,
            # e a leitura parada termina junto)
            caixa = queue.Queue()
            threading.Thread(target=lambda: caixa.put(processo.stdout.readline()), daemon=True).start()
            try:
                linha = caixa.get(timeout=timeout)
            except queue.Empty:
                return {}
        else:
            prontos, _, _ = select.select([processo.stdout], [], [], timeout)
            if not prontos:
                return {}
            linha = processo.stdout.readline()
        try:
            return json.loads(linha) if linha else {}
        except ValueError:
            return {}

    def _fechar_kokoro(self):
        processo, self._kokoro = self._kokoro, None
        if processo is not None and processo.poll() is None:
            try:
                processo.kill()
                processo.wait(timeout=5)
            except Exception:
                pass

    def _gerar_kokoro(self, texto, idioma, destino):
        voz, lingua = voz_kokoro(idioma)
        with self._trava_kokoro:
            for _tentativa in range(2):  # se o processo morreu, sobe de novo uma vez
                processo = self._iniciar_kokoro()
                if processo is None:
                    return False
                pedido = {"texto": texto, "voz": voz, "lingua": lingua, "destino": str(destino),
                          "velocidade": PREFERENCIAS["velocidade"], "pausa": PREFERENCIAS["pausa"]}
                try:
                    processo.stdin.write(json.dumps(pedido, ensure_ascii=False) + "\n")
                    processo.stdin.flush()
                except (BrokenPipeError, OSError):
                    self._fechar_kokoro()
                    continue
                resposta = self._ler_kokoro(timeout=120)
                if resposta.get("ok"):
                    return Path(destino).exists() and Path(destino).stat().st_size > 44
                self._fechar_kokoro()
            return False

    def _gerar(self, texto, idioma, destino, cancelar=None):
        """Texto -> wav: Kokoro; se não der, o Piper (se a voz antiga existir)."""
        if cancelar is not None and cancelar.is_set():
            return False
        if kokoro_baixado() and _tem_modulo("kokoro_onnx") and self._gerar_kokoro(texto, idioma, destino):
            return True
        if arquivo_voz_piper(idioma).exists() and _tem_modulo("piper"):
            return self._gerar_piper(texto, idioma, destino, cancelar)
        return False

    def _gerar_piper(self, texto, idioma, destino, cancelar=None):
        """Piper (reserva): texto -> wav. True se deu certo."""
        for opcoes in ([self._opcoes_piper()] + ([[]] if self._opcoes_piper() else [])):
            if cancelar is not None and cancelar.is_set():
                return False
            processo = None
            try:
                processo = subprocess.Popen(
                    [sys.executable, "-m", "piper", "-m", str(arquivo_voz_piper(idioma)), "-f", str(destino), *opcoes],
                    stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=self._ambiente())
                with self._trava:
                    self._processos.append(processo)
                processo.communicate(texto.encode("utf-8"), timeout=120)
                if processo.returncode == 0 and Path(destino).exists() and Path(destino).stat().st_size > 44:
                    return True
            except Exception:
                pass
            finally:
                if processo is not None and processo.poll() is None:
                    try:
                        processo.kill()
                        processo.wait(timeout=5)
                    except Exception:
                        pass
            if opcoes:
                Falador._piper_sem_opcoes = True  # tenta de novo sem as opções
        return False

    def _sintetizar(self, texto, idioma, destino):
        return self._gerar(texto, idioma, destino)

    def _falar(self, texto, idioma, ao_terminar=None):
        try:
            self._falar_de_fato(texto, idioma)
        finally:
            if ao_terminar is not None:
                try:
                    ao_terminar()
                except Exception:
                    pass

    def _tocar_arquivo(self, tocador, wav, cancelar):
        if cancelar.is_set():
            return
        tocar = subprocess.Popen(tocador + [wav], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        with self._trava:
            self._processos.append(tocar)
        try:
            tocar.wait(timeout=300)
        except Exception:
            pass
        finally:
            if tocar.poll() is None:
                try:
                    tocar.kill()
                    tocar.wait(timeout=5)
                except Exception:
                    pass

    def _falar_de_fato(self, texto, idioma):
        """4.1: frase a frase. A primeira frase começa a tocar enquanto as
        próximas ainda estão sendo geradas: resposta longa não fica muda
        esperando o Piper gerar tudo."""
        self.parar()
        tocador = self._tocar or comando_tocar()
        if not (kokoro_baixado() or arquivo_voz_piper(idioma).exists()) or not tocador:
            return
        cancelar = threading.Event()
        with self._trava:
            self._cancelar_atual = cancelar
        frases = dividir_frases(preparar_para_fala(texto, idioma)) or [texto]
        fila = queue.Queue()
        fim = object()
        self._comecou()

        def produzir():
            try:
                for frase in frases:
                    if cancelar.is_set():
                        break
                    fd, wav = tempfile.mkstemp(suffix=".wav", prefix="opentars-fala-")
                    os.close(fd)
                    if not self._gerar(frase, idioma, wav, cancelar):
                        _apagar(wav)
                        break
                    fila.put(wav)
            finally:
                fila.put(fim)

        threading.Thread(target=produzir, daemon=True).start()
        try:
            while True:
                try:
                    item = fila.get(timeout=150)
                except queue.Empty:
                    break
                if item is fim:
                    break
                try:
                    self._tocar_arquivo(tocador, item, cancelar)
                finally:
                    _apagar(item)
        finally:
            cancelar.set()
            while True:  # o que sobrou na fila (cancelado no meio)
                try:
                    item = fila.get_nowait()
                except queue.Empty:
                    break
                if item is not fim:
                    _apagar(item)
            with self._trava:
                self._processos = [p for p in self._processos if p.poll() is None]
            self._acabou()

    def parar(self):
        with self._trava:
            cancelar = getattr(self, "_cancelar_atual", None)
            if cancelar is not None:
                cancelar.set()
            for p in self._processos:
                if p.poll() is None:
                    try:
                        p.terminate()
                    except Exception:
                        pass
            self._processos = []


def _apagar(caminho):
    try:
        os.unlink(caminho)
    except OSError:
        pass


# ------------------------------------------------------------
# Ouvir
# ------------------------------------------------------------
ESPERA_PEDIDO_S = 8      # depois de "TARS" sozinho, quanto tempo espera o pedido
ESPERA_CONVERSA_S = 7    # depois de responder falando: dá pra continuar sem dizer "TARS"
CEDO_MS = 650            # com tanto de fala, já confere se começou com "TARS" (sem esperar a pausa)
SILENCIO_DIGITAL_S = 4.0  # gravador que só entrega zeros por tanto tempo está no dispositivo errado
CEDO_MAX_S = 1.6         # o nome vem no começo: basta olhar esse pedaço
SEGUNDA_CHANCE_MAX_S = 8 # trecho até esse tamanho, com algo parecido com "TARS", o modelo bom confere


class Ouvinte:
    """Escuta o microfone numa thread.

    modo "ativacao": fica ouvindo; cada trecho que começa com "TARS" vira
    pedido (ao_pedido(texto)). modo "uma_vez": o primeiro trecho de fala é
    o pedido e para (o botão "Falar agora"). modo "continua": conversa
    contínua, TODO trecho de fala é pedido, sem precisar dizer "TARS" (se
    a pessoa disser, o nome é ignorado).

    Pra responder rápido (3.0 Miller):
    - os dois Whisper são carregados e "aquecidos" assim que começa a ouvir
      (antes, o primeiro "TARS" esperava o modelo carregar);
    - com ~0,65 s de fala, já confere (em paralelo, sem parar de gravar) se
      começou com "TARS": a tela mostra na hora que ouviu, e no fim da fala
      só falta transcrever o pedido;
    - "TARS" sozinho: responde "Sim?" na voz do idioma (ou um sininho) e o
      próximo trecho é o pedido;
    - depois de responder falando, dá pra continuar sem dizer "TARS"
      (abrir_conversa).

    ao_estado(estado): "carregando", "ouvindo", "chamado" (ouviu o nome),
    "conversa" (pode continuar sem o nome), "transcrevendo", "parado",
    ou "erro:<motivo>"."""

    def __init__(self, ao_pedido, idioma, modo="ativacao", ao_estado=None, falador=None,
                 gravar=None, transcritor=None, transcritor_ativacao=None, aquecer=True, vocabulario=None):
        self.ao_pedido = ao_pedido
        self.idioma = idioma if callable(idioma) else (lambda: idioma)
        self.modo = modo
        self.ao_estado = ao_estado or (lambda estado: None)
        self.falador = falador
        self._gravar = gravar
        self._transcritor = transcritor or Transcritor(papel="pedido")
        self._ativacao = transcritor_ativacao or Transcritor(papel="ativacao")
        # 4.0: palavras que provavelmente aparecem no pedido (nomes dos apps instalados)
        self._vocabulario = vocabulario or (lambda: [])
        self._aquecer = aquecer
        self._parar = threading.Event()
        self._thread = None
        self._processo = None
        self.chamado_ate = 0.0
        # conferência "cedo" do nome: id do trecho -> (chamou, resto); e a thread em andamento
        self._cedo = {}
        self._cedo_id = None
        self._cedo_thread = None
        self._fechar = threading.Event()   # 5.0: "segurar para falar": soltou, fecha o trecho

    def terminar_fala(self):
        """Soltou o botão: o que já foi falado vira o pedido, sem esperar o silêncio."""
        self._fechar.set()

    @property
    def ativo(self):
        return self._thread is not None and self._thread.is_alive()

    def iniciar(self):
        if self.ativo:
            if not self._parar.is_set():
                return
            self._thread.join(timeout=3)      # desligou e ligou de novo: espera a rodada antiga terminar de verdade
            if self.ativo:
                return
        self._parar.clear()
        self._thread = threading.Thread(target=self._rodar, daemon=True)
        self._thread.start()

    def parar(self):
        self._parar.set()
        if self._processo is not None:
            try:
                self._processo.terminate()
            except Exception:
                pass

    def abrir_conversa(self, segundos=ESPERA_CONVERSA_S):
        """Por alguns segundos, a próxima fala é pedido mesmo sem "TARS"."""
        if self.ativo and self.modo == "ativacao":
            self.chamado_ate = time.monotonic() + segundos
            self.ao_estado("conversa")

    def _preparar(self):
        """Carrega os modelos (e o "Sim?" falado) antes da primeira fala."""
        if not self._aquecer:
            return
        self.ao_estado("carregando")
        for transcritor in ((self._ativacao, self._transcritor) if self.modo == "ativacao" else (self._transcritor,)):
            try:
                transcritor.aquecer()
            except Exception:
                pass
        if self.falador is not None and hasattr(self.falador, "preparar_confirmacao"):
            # gera o "Sim?" e já deixa o Kokoro carregado: a primeira resposta sai sem esperar
            threading.Thread(target=self.falador.preparar_confirmacao, args=(self.idioma(),), daemon=True).start()

    def _fonte(self):
        """Iterador de pedaços de áudio cru. Se o gravador morre na hora (sem microfone, sem permissão,
        dispositivo errado) ou só entrega silêncio digital, tenta o próximo (arecord, parecord, pw-record);
        se nenhum serve, levanta um erro com a mensagem do próprio gravador (antes ficava mudo e "parado")."""
        if self._gravar is not None:
            yield from self._gravar()
            return
        candidatos = _gravadores()
        if not candidatos:
            raise RuntimeError("no recorder (install alsa-utils or pulseaudio-utils)" if plataforma.sistema() == "linux"
                               else "no recorder (sounddevice missing: turn the voice off and on again)")
        erros = []
        for comando in candidatos:
            self._processo = subprocess.Popen(comando, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            inicio = time.monotonic()
            recebeu = 0
            som = False
            motivo = ""
            try:
                while not self._parar.is_set():
                    bloco = self._processo.stdout.read(1600)  # 50 ms: reage mais rápido ao fim da fala
                    if not bloco:
                        motivo = "o gravador parou"
                        break
                    recebeu += len(bloco)
                    if not som and rms(bloco) > 0:
                        som = True
                    if not som and time.monotonic() - inicio > SILENCIO_DIGITAL_S:
                        motivo = "só silêncio (microfone mudo ou dispositivo errado)"
                        break
                    yield bloco
            finally:
                try:
                    self._processo.terminate()
                    self._processo.wait(timeout=2)       # sem isto o gravador morto ficava zumbi
                except Exception:
                    pass
            if self._parar.is_set():
                return
            try:
                detalhe = (self._processo.stderr.read() or b"").decode("utf-8", "replace").strip()[:200]
            except Exception:
                detalhe = ""
            erros.append(f"{comando[0]}: {motivo}{' - ' + detalhe if detalhe else ''}")
            if som and time.monotonic() - inicio > 3:
                return          # funcionou um tempo e caiu depois: não é problema de dispositivo; fecha normalmente
        raise RuntimeError("microfone: " + " | ".join(erros))

    def _rodar(self):
        segmentador = Segmentador()
        try:
            self._preparar()
            self.ao_estado("ouvindo")
            for bloco in self._fonte():
                if self._parar.is_set():
                    break
                if self.falador is not None and self.falador.falando.is_set():
                    continue  # não ouve a própria voz
                prontos = segmentador.alimentar_com_id(bloco)
                if self._fechar.is_set():
                    self._fechar.clear()
                    resto = segmentador.encerrar()
                    if resto:
                        prontos.append((segmentador.id_trecho, resto))
                    elif not prontos and self.modo == "uma_vez":
                        self._parar.set()   # soltou sem ter falado nada
                self._conferir_cedo(segmentador)
                for ident, trecho in prontos:
                    if self._tratar(trecho, ident) and self.modo == "uma_vez":
                        self._parar.set()
                        break
                if self._parar.is_set():
                    break
        except Exception as e:
            self.ao_estado(f"erro:{e}")
            return
        self.ao_estado("parado")

    # --- nome conferido enquanto a pessoa ainda fala ---
    def _esperando_pedido(self):
        return self.modo in ("uma_vez", "continua") or time.monotonic() < self.chamado_ate

    def _conferir_cedo(self, segmentador):
        if (self.modo != "ativacao" or self._esperando_pedido() or segmentador.falando_ms < CEDO_MS
                or segmentador.id_trecho == self._cedo_id
                or (self._cedo_thread is not None and self._cedo_thread.is_alive())):
            return
        ident = self._cedo_id = segmentador.id_trecho
        audio = segmentador.audio_parcial(CEDO_MAX_S)
        idioma = self.idioma()

        def conferir():
            try:
                rapido = self._ativacao.transcrever(audio, idioma, rapido=True)
                chamou, resto = achar_ativacao(rapido)
            except Exception:
                rapido, chamou, resto = "", False, ""
            self._cedo[ident] = (chamou, resto, rapido)
            if chamou:
                self.ao_estado("chamado")  # a pessoa vê na hora que foi ouvida

        self._cedo_thread = threading.Thread(target=conferir, daemon=True)
        self._cedo_thread.start()

    def _transcrever_pedido(self, trecho, idioma):
        try:
            vocabulario = list(self._vocabulario() or [])
        except Exception:
            vocabulario = []
        try:
            return self._transcritor.transcrever(trecho, idioma, vocabulario=vocabulario)
        except TypeError:  # transcritor sem vocabulário (testes, versões antigas)
            return self._transcritor.transcrever(trecho, idioma)

    def _nome_no_trecho(self, trecho, ident, idioma):
        """(chamou, resto) do começo do trecho: da conferência cedo, se houve.
        4.0: se o modelo rápido ouviu algo PARECIDO com "TARS" ("Taz", "Tarde"),
        o modelo bom confere o trecho inteiro antes de desistir."""
        if ident is not None and ident == self._cedo_id and self._cedo_thread is not None:
            self._cedo_thread.join(timeout=10)
        if ident in self._cedo:
            chamou, resto, rapido = self._cedo.pop(ident)
        else:
            inicio = trecho[:int(CEDO_MAX_S * TAXA) * BYTES_AMOSTRA]
            rapido = self._ativacao.transcrever(inicio, idioma, rapido=True)
            chamou, resto = achar_ativacao(rapido)
        if not chamou and parece_chamado(rapido) and len(trecho) / BYTES_AMOSTRA / TAXA <= SEGUNDA_CHANCE_MAX_S:
            chamou, resto = achar_ativacao(self._transcrever_pedido(trecho, idioma))
        return chamou, resto

    def _confirmar_chamado(self, idioma):
        if self.falador is not None and hasattr(self.falador, "confirmar"):
            try:
                self.falador.confirmar(idioma)
            except Exception:
                pass

    def _tratar(self, trecho, ident=None):
        """True se o trecho virou pedido."""
        idioma = self.idioma()
        if self._esperando_pedido():
            self.ao_estado("transcrevendo")
            texto = self._transcrever_pedido(trecho, idioma)
            chamou, resto = achar_ativacao(texto)
            texto = resto if chamou and resto else texto
            self.chamado_ate = 0.0
            if texto and re.search(r"\w", texto) and not (chamou and not resto):
                self.ao_pedido(texto)
                self.ao_estado("ouvindo")
                return True
            if chamou:  # disse só "TARS" de novo: continua esperando
                self.chamado_ate = time.monotonic() + ESPERA_PEDIDO_S
                self._confirmar_chamado(idioma)
                self.ao_estado("chamado")
                return False
            self.ao_estado("ouvindo")
            return False
        chamou, resto = self._nome_no_trecho(trecho, ident, idioma)
        if not chamou:
            return False
        duracao = len(trecho) / BYTES_AMOSTRA / TAXA
        if resto or duracao > CEDO_MAX_S:
            # o pedido veio junto ("TARS, abre o Firefox"): o bom transcreve tudo
            self.ao_estado("transcrevendo")
            completo = self._transcrever_pedido(trecho, idioma)
            chamou2, resto2 = achar_ativacao(completo)
            pedido = resto2 or (resto if chamou2 else completo)
            if pedido and re.search(r"\w", pedido):
                self.ao_pedido(pedido)
                self.ao_estado("ouvindo")
                return True
        # só o nome: "Sim?" e o próximo trecho é o pedido
        self.chamado_ate = time.monotonic() + ESPERA_PEDIDO_S
        self._confirmar_chamado(idioma)
        self.ao_estado("chamado")
        return False


def sininho_wav(taxa=22050):
    """Dois toques curtos (mi-si), baixinho: "ouvi você"."""
    import io
    import wave

    amostras = array.array("h")
    for freq, dur in ((659.25, 0.09), (987.77, 0.13)):
        n = int(taxa * dur)
        for i in range(n):
            envelope = min(1.0, i / (taxa * 0.01), (n - i) / (taxa * 0.04))
            amostras.append(int(7000 * envelope * math.sin(2 * math.pi * freq * i / taxa)))
    if sys.byteorder == "big":
        amostras.byteswap()
    saida = io.BytesIO()
    with wave.open(saida, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(taxa)
        w.writeframes(amostras.tobytes())
    return saida.getvalue()


# ------------------------------------------------------------
# Processo do Kokoro: "python tars_voz.py --kokoro MODELO VOZES"
# ------------------------------------------------------------
def _trabalhador_kokoro(modelo, vozes):
    """Carrega o Kokoro uma vez e gera um wav por linha recebida:
    {"texto", "voz", "lingua", "velocidade", "pausa", "destino"} -> {"ok": true}."""
    import wave

    import numpy as np
    from kokoro_onnx import Kokoro

    kokoro = Kokoro(modelo, vozes)
    print(json.dumps({"pronto": True}), flush=True)
    for linha in sys.stdin:
        try:
            p = json.loads(linha)
            velocidade = min(2.0, max(0.5, float(p.get("velocidade") or 1.0)))
            amostras, taxa = kokoro.create(p["texto"], voice=p["voz"], speed=velocidade, lang=p["lingua"])
            pausa = np.zeros(int(taxa * float(p.get("pausa") or 0)), dtype=np.float32)
            dados = np.concatenate([np.asarray(amostras, dtype=np.float32).reshape(-1), pausa])
            pcm = (np.clip(dados, -1.0, 1.0) * 32767).astype("<i2").tobytes()
            with wave.open(p["destino"], "wb") as w:
                w.setnchannels(1)
                w.setsampwidth(2)
                w.setframerate(int(taxa))
                w.writeframes(pcm)
            print(json.dumps({"ok": True}), flush=True)
        except Exception as e:
            print(json.dumps({"ok": False, "erro": str(e)[:300]}), flush=True)


def _gravar_microfone():
    """Windows e macOS: o microfone pelo sounddevice (PortAudio) na saída, cru como o arecord (16 kHz, mono, s16le)."""
    import sounddevice
    saida = sys.stdout.buffer
    with sounddevice.RawInputStream(samplerate=TAXA, channels=1, dtype="int16", blocksize=800) as fluxo:
        while True:
            dados, _transbordou = fluxo.read(800)
            saida.write(bytes(dados))
            saida.flush()


def _tocar_wav(caminho):
    import winsound
    winsound.PlaySound(caminho, winsound.SND_FILENAME)


if __name__ == "__main__" and len(sys.argv) >= 4 and sys.argv[1] == "--kokoro":
    _trabalhador_kokoro(sys.argv[2], sys.argv[3])
elif __name__ == "__main__" and sys.argv[1:2] == ["--gravar"]:
    try:
        _gravar_microfone()
    except (BrokenPipeError, KeyboardInterrupt):
        pass
elif __name__ == "__main__" and len(sys.argv) >= 3 and sys.argv[1] == "--tocar":
    _tocar_wav(sys.argv[2])