agentix-cli 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -118
- package/apps/mac-helper/Sources/AgentXHelper/AXTree.swift +159 -0
- package/apps/mac-helper/Sources/AgentXHelper/Brand.swift +63 -0
- package/apps/mac-helper/Sources/AgentXHelper/Focus.swift +97 -0
- package/apps/mac-helper/Sources/AgentXHelper/HUD.swift +179 -0
- package/apps/mac-helper/Sources/AgentXHelper/OCR.swift +117 -0
- package/apps/mac-helper/Sources/AgentXHelper/Pointer.swift +309 -0
- package/apps/mac-helper/Sources/AgentXHelper/Screens.swift +67 -0
- package/apps/mac-helper/Sources/AgentXHelper/Typer.swift +185 -0
- package/apps/mac-helper/Sources/AgentXHelper/Vision.swift +201 -0
- package/apps/mac-helper/Sources/AgentXHelper/main.swift +265 -0
- package/apps/mac-helper/build.sh +39 -0
- package/apps/mac-voice/Resources/Info.plist +18 -0
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +108 -0
- package/apps/mac-voice/Sources/AgentXVoice/Brand.swift +123 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +68 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hold.swift +74 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +76 -0
- package/apps/mac-voice/Sources/AgentXVoice/Markdown.swift +140 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +275 -0
- package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +147 -0
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +139 -0
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +162 -0
- package/apps/mac-voice/Sources/AgentXVoice/SmartPaste.swift +72 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +192 -0
- package/apps/mac-voice/Sources/AgentXVoice/StepLabel.swift +159 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +380 -0
- package/apps/mac-voice/build.sh +32 -0
- package/dist/{App-BPNHIGUX.js → App-CKPH3HHF.js} +2 -2
- package/dist/{ChatApp-6XSSZB4U.js → ChatApp-MX7IG4K2.js} +2 -2
- package/dist/agent-2U574KBZ.js +2 -0
- package/dist/article-fields-L4D2BOGY.js +2 -0
- package/dist/article-quality-44ONVGLN.js +3 -0
- package/dist/article-quality-44ONVGLN.js.map +1 -0
- package/dist/{audit-JU3HWISB.js → audit-4245CX6P.js} +2 -2
- package/dist/backfill-MTD2B62I.js +6 -0
- package/dist/backfill-MTD2B62I.js.map +1 -0
- package/dist/{board-dashboard-M3372FLO.js → board-dashboard-QR7CVTKA.js} +1411 -453
- package/dist/board-dashboard-QR7CVTKA.js.map +1 -0
- package/dist/{builtin-FRKAQMFS.js → builtin-B67DMJ3M.js} +2 -2
- package/dist/calibration-ILS5OI44.js +2 -0
- package/dist/{chunk-V3U4OF7L.js → chunk-3LYM22IV.js} +46 -48
- package/dist/chunk-3LYM22IV.js.map +1 -0
- package/dist/chunk-3SCPRYJB.js +5 -0
- package/dist/chunk-3SCPRYJB.js.map +1 -0
- package/dist/chunk-4BR7D2OU.js +36 -0
- package/dist/chunk-4BR7D2OU.js.map +1 -0
- package/dist/{chunk-P4VWIVKN.js → chunk-52ZPELD2.js} +403 -29
- package/dist/chunk-52ZPELD2.js.map +1 -0
- package/dist/chunk-5IA2CYHN.js +5 -0
- package/dist/{chunk-O6YPEOQF.js.map → chunk-5IA2CYHN.js.map} +1 -1
- package/dist/chunk-64CAIB3C.js +9 -0
- package/dist/chunk-64CAIB3C.js.map +1 -0
- package/dist/{chunk-5U35WAZF.js → chunk-77OV7346.js} +6 -6
- package/dist/chunk-77OV7346.js.map +1 -0
- package/dist/chunk-AJ5RMU7X.js +2 -0
- package/dist/chunk-AJ5RMU7X.js.map +1 -0
- package/dist/chunk-AJONZWLT.js +2 -0
- package/dist/chunk-AJONZWLT.js.map +1 -0
- package/dist/chunk-BH3GTCZ6.js +2 -0
- package/dist/chunk-BH3GTCZ6.js.map +1 -0
- package/dist/chunk-E3UJHQGN.js +1189 -0
- package/dist/chunk-E3UJHQGN.js.map +1 -0
- package/dist/chunk-EVWEG3VD.js +3 -0
- package/dist/chunk-EVWEG3VD.js.map +1 -0
- package/dist/chunk-F4HZTZHW.js +2 -0
- package/dist/chunk-F4HZTZHW.js.map +1 -0
- package/dist/chunk-F6CNF7V3.js +25 -0
- package/dist/chunk-F6CNF7V3.js.map +1 -0
- package/dist/chunk-FIOB3MEX.js +2 -0
- package/dist/{chunk-H5D7ATXS.js → chunk-FKBGJYWQ.js} +5 -5
- package/dist/chunk-FKBGJYWQ.js.map +1 -0
- package/dist/{chunk-BVGJSPW4.js → chunk-G2HMRDUQ.js} +7 -7
- package/dist/chunk-G2HMRDUQ.js.map +1 -0
- package/dist/chunk-H2QQU2XV.js +109 -0
- package/dist/chunk-H2QQU2XV.js.map +1 -0
- package/dist/{chunk-LDHPMJ7M.js → chunk-IWMW5432.js} +3 -3
- package/dist/chunk-IWMW5432.js.map +1 -0
- package/dist/chunk-K33GIBSE.js +2 -0
- package/dist/chunk-K33GIBSE.js.map +1 -0
- package/dist/chunk-L2WSPY6S.js +2 -0
- package/dist/chunk-L2WSPY6S.js.map +1 -0
- package/dist/chunk-LCIREYTD.js +54 -0
- package/dist/chunk-LCIREYTD.js.map +1 -0
- package/dist/chunk-MHIMGM4G.js +126 -0
- package/dist/chunk-MHIMGM4G.js.map +1 -0
- package/dist/chunk-P5ZBBEPL.js +13 -0
- package/dist/chunk-P5ZBBEPL.js.map +1 -0
- package/dist/{chunk-JTJIPNXY.js → chunk-PNWNVOEZ.js} +1 -1
- package/dist/chunk-PNWNVOEZ.js.map +1 -0
- package/dist/chunk-QHTQPBHU.js +45 -0
- package/dist/chunk-QHTQPBHU.js.map +1 -0
- package/dist/chunk-S2MNL5V6.js +2 -0
- package/dist/chunk-S2MNL5V6.js.map +1 -0
- package/dist/chunk-SCTIUFL4.js +5 -0
- package/dist/{chunk-K2CAPYES.js.map → chunk-SCTIUFL4.js.map} +1 -1
- package/dist/chunk-ULLAE6LE.js +157 -0
- package/dist/chunk-ULLAE6LE.js.map +1 -0
- package/dist/{chunk-DJEZ2HVU.js → chunk-VDMYBPYB.js} +1 -1
- package/dist/chunk-VDMYBPYB.js.map +1 -0
- package/dist/chunk-VIH7ARPF.js +547 -0
- package/dist/chunk-VIH7ARPF.js.map +1 -0
- package/dist/chunk-W5C2GDNC.js +32 -0
- package/dist/{chunk-JP5IEXR7.js.map → chunk-W5C2GDNC.js.map} +1 -1
- package/dist/chunk-WPMX3VGI.js +3 -0
- package/dist/chunk-WPMX3VGI.js.map +1 -0
- package/dist/chunk-YLBERT7N.js +24 -0
- package/dist/chunk-YLBERT7N.js.map +1 -0
- package/dist/chunk-YLGIZRHV.js +114 -0
- package/dist/chunk-YLGIZRHV.js.map +1 -0
- package/dist/chunk-ZMYGXHPT.js +121 -0
- package/dist/chunk-ZMYGXHPT.js.map +1 -0
- package/dist/chunk-ZTEU5JVQ.js +2 -0
- package/dist/chunk-ZTEU5JVQ.js.map +1 -0
- package/dist/{chunk-2WVX3LEC.js → chunk-ZVP6GB4G.js} +1 -1
- package/dist/chunk-ZVP6GB4G.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client-ZLSXGPTV.js +2 -0
- package/dist/{compaction-NWEMOSHA.js → compaction-FD4R23HZ.js} +4 -4
- package/dist/config-HMW3EBZL.js +2 -0
- package/dist/config-mutator-4SZ5YEVD.js +2 -0
- package/dist/{conflict-detector-D7VIYPDI.js → conflict-detector-ZPOT3M2C.js} +2 -2
- package/dist/conflict-detector-ZPOT3M2C.js.map +1 -0
- package/dist/context-planner-AQHJMVY2.js +17 -0
- package/dist/context-planner-AQHJMVY2.js.map +1 -0
- package/dist/debug-3MEUO2P2.js +2 -0
- package/dist/{discover-XJXQXXIO.js → discover-D4VVRNAI.js} +2 -2
- package/dist/discover-D4VVRNAI.js.map +1 -0
- package/dist/doctor-LRYV3QLK.js +2 -0
- package/dist/editor-chat-OB7IQOB5.js +137 -0
- package/dist/editor-chat-OB7IQOB5.js.map +1 -0
- package/dist/entry-triage-LK34TSQD.js +2 -0
- package/dist/entry-triage-LK34TSQD.js.map +1 -0
- package/dist/facts-YVEJOM7O.js +22 -0
- package/dist/facts-YVEJOM7O.js.map +1 -0
- package/dist/heal-FVLBHYP7.js +2 -0
- package/dist/index.d.ts +1040 -64
- package/dist/index.js +12 -19
- package/dist/index.js.map +1 -1
- package/dist/{ingest-whatsapp-LZE62INA.js → ingest-whatsapp-NS4OWDOU.js} +2 -2
- package/dist/loader-4ZGTK7LD.js +2 -0
- package/dist/loader-BOKE5M5S.js +2 -0
- package/dist/memory-extract-F5ZHWY5E.js +2 -0
- package/dist/{migrate-v2-QO7SLKIM.js → migrate-v2-V4WN2VVM.js} +2 -2
- package/dist/ntfy-X7EPB3TG.js +4 -0
- package/dist/ntfy-X7EPB3TG.js.map +1 -0
- package/dist/processes-WU23U2OJ.js +2 -0
- package/dist/program-GSZ5AYAM.js +2 -0
- package/dist/{projects-api-KRME2YKZ.js → projects-api-QE6L65SK.js} +2 -2
- package/dist/{projects-mutate-3NHVG437.js → projects-mutate-MY7JTLSZ.js} +2 -2
- package/dist/providers-KO2DODIO.js +2 -0
- package/dist/query-SQDHE4HV.js +2 -0
- package/dist/questions-4B4KSPXG.js +3 -0
- package/dist/questions-4B4KSPXG.js.map +1 -0
- package/dist/recipes-7S4MC5OJ.js +2 -0
- package/dist/registry-LE6FWYZC.js +2 -0
- package/dist/review-candidates-RQU5KNZM.js +4 -0
- package/dist/review-candidates-RQU5KNZM.js.map +1 -0
- package/dist/review-store-B4YE3EFK.js +4 -0
- package/dist/review-store-B4YE3EFK.js.map +1 -0
- package/dist/{rotation-memo-B3EXXSEH.js → rotation-memo-7T75BLEX.js} +2 -2
- package/dist/routing-ILERJXB6.js +2 -0
- package/dist/runner-YRBVNYTY.js +2 -0
- package/dist/scheduler-BFM3EINS.js +2 -0
- package/dist/seat-7WM7II2B.js +2 -0
- package/dist/select-X4KVMOP5.js +2 -0
- package/dist/select-X4KVMOP5.js.map +1 -0
- package/dist/store-MCOA6N4F.js +2 -0
- package/dist/{subagent-WV7QKQ3L.js → subagent-2SJHSSJS.js} +2 -2
- package/dist/{surface-usage-PRL6Z4U3.js → surface-usage-NS74AXSE.js} +2 -2
- package/dist/timers-PCB4ZIBD.js +2 -0
- package/dist/triage-backtest-JNO7RKX7.js +2 -0
- package/dist/triage-backtest-JNO7RKX7.js.map +1 -0
- package/dist/types-H5ENIUIT.js +2 -0
- package/dist/types-OIFU3KSQ.js +2 -0
- package/dist/types-OIFU3KSQ.js.map +1 -0
- package/dist/web/workflow-editor.global.js +1542 -0
- package/dist/web/workflow-editor.global.js.map +1 -0
- package/dist/{whatsapp-state-XMTCSRXU.js → whatsapp-state-6D6FHHD2.js} +2 -2
- package/dist/wiki-2AUFSIKO.js +2 -0
- package/dist/wiki-2AUFSIKO.js.map +1 -0
- package/dist/workflows/templates/branching.yaml +1 -1
- package/dist/{workspace-setup-TBQAHYHJ.js → workspace-setup-B3F3TUDE.js} +2 -2
- package/dist/workspace-setup-B3F3TUDE.js.map +1 -0
- package/package.json +14 -3
- package/scripts/postinstall.mjs +80 -0
- package/dist/agent-5QMITZT6.js +0 -2
- package/dist/architect-SEPWMWS4.js +0 -2
- package/dist/board-dashboard-M3372FLO.js.map +0 -1
- package/dist/chunk-2WVX3LEC.js.map +0 -1
- package/dist/chunk-3WQFCIB6.js +0 -24
- package/dist/chunk-3WQFCIB6.js.map +0 -1
- package/dist/chunk-4YCH6IZV.js +0 -2
- package/dist/chunk-53NRX2NA.js +0 -2
- package/dist/chunk-53NRX2NA.js.map +0 -1
- package/dist/chunk-5U35WAZF.js.map +0 -1
- package/dist/chunk-BFXONUUN.js +0 -108
- package/dist/chunk-BFXONUUN.js.map +0 -1
- package/dist/chunk-BFZUG66X.js +0 -45
- package/dist/chunk-BFZUG66X.js.map +0 -1
- package/dist/chunk-BTHHSY7D.js +0 -13
- package/dist/chunk-BTHHSY7D.js.map +0 -1
- package/dist/chunk-BVGJSPW4.js.map +0 -1
- package/dist/chunk-D46VZ7K7.js +0 -116
- package/dist/chunk-D46VZ7K7.js.map +0 -1
- package/dist/chunk-DJEZ2HVU.js.map +0 -1
- package/dist/chunk-E5CXCI33.js +0 -155
- package/dist/chunk-E5CXCI33.js.map +0 -1
- package/dist/chunk-FDWOECJY.js +0 -54
- package/dist/chunk-FDWOECJY.js.map +0 -1
- package/dist/chunk-H5D7ATXS.js.map +0 -1
- package/dist/chunk-HR4JSRZS.js +0 -3
- package/dist/chunk-HR4JSRZS.js.map +0 -1
- package/dist/chunk-ISEBLHWX.js +0 -118
- package/dist/chunk-ISEBLHWX.js.map +0 -1
- package/dist/chunk-JP5IEXR7.js +0 -32
- package/dist/chunk-JTJIPNXY.js.map +0 -1
- package/dist/chunk-K2CAPYES.js +0 -5
- package/dist/chunk-LDHPMJ7M.js.map +0 -1
- package/dist/chunk-METYYZKQ.js +0 -1115
- package/dist/chunk-METYYZKQ.js.map +0 -1
- package/dist/chunk-MTAPOCGA.js +0 -47
- package/dist/chunk-MTAPOCGA.js.map +0 -1
- package/dist/chunk-O6YPEOQF.js +0 -5
- package/dist/chunk-P4VWIVKN.js.map +0 -1
- package/dist/chunk-SR3X25TN.js +0 -3
- package/dist/chunk-SR3X25TN.js.map +0 -1
- package/dist/chunk-UHE5FCPB.js +0 -431
- package/dist/chunk-UHE5FCPB.js.map +0 -1
- package/dist/chunk-V3U4OF7L.js.map +0 -1
- package/dist/chunk-VJUTQAPJ.js +0 -2
- package/dist/chunk-VJUTQAPJ.js.map +0 -1
- package/dist/chunk-VT5XYFDD.js +0 -126
- package/dist/chunk-VT5XYFDD.js.map +0 -1
- package/dist/client-HGKAC6VP.js +0 -2
- package/dist/config-A4RWEJHV.js +0 -2
- package/dist/config-mutator-MNSFUN7D.js +0 -2
- package/dist/conflict-detector-D7VIYPDI.js.map +0 -1
- package/dist/context-planner-45GEWKGL.js +0 -17
- package/dist/context-planner-45GEWKGL.js.map +0 -1
- package/dist/debug-N3B5NVJU.js +0 -2
- package/dist/discover-XJXQXXIO.js.map +0 -1
- package/dist/doctor-3Q36ZFE6.js +0 -2
- package/dist/heal-HZ3EQD52.js +0 -2
- package/dist/loader-5CA3O5GM.js +0 -2
- package/dist/loader-6IHUCPVO.js +0 -2
- package/dist/memory-extract-AENFVVO5.js +0 -2
- package/dist/processes-UIC4XXA6.js +0 -2
- package/dist/program-SAUNRCCD.js +0 -2
- package/dist/providers-L27BJ5A6.js +0 -2
- package/dist/query-HZYYRCLU.js +0 -2
- package/dist/recipes-EPLE2SNT.js +0 -2
- package/dist/registry-HK3WQ7BS.js +0 -2
- package/dist/runner-BFYDWPUO.js +0 -2
- package/dist/scheduler-6T2SU5ST.js +0 -2
- package/dist/store-I3HEWVLQ.js +0 -2
- package/dist/timers-5LPZN55J.js +0 -2
- package/dist/types-VECTMYTO.js +0 -2
- package/dist/types-Y6ZCZ52Z.js +0 -2
- package/dist/wiki-M6YNRFC4.js +0 -2
- /package/dist/{App-BPNHIGUX.js.map → App-CKPH3HHF.js.map} +0 -0
- /package/dist/{ChatApp-6XSSZB4U.js.map → ChatApp-MX7IG4K2.js.map} +0 -0
- /package/dist/{agent-5QMITZT6.js.map → agent-2U574KBZ.js.map} +0 -0
- /package/dist/{architect-SEPWMWS4.js.map → article-fields-L4D2BOGY.js.map} +0 -0
- /package/dist/{audit-JU3HWISB.js.map → audit-4245CX6P.js.map} +0 -0
- /package/dist/{builtin-FRKAQMFS.js.map → builtin-B67DMJ3M.js.map} +0 -0
- /package/dist/{chunk-4YCH6IZV.js.map → calibration-ILS5OI44.js.map} +0 -0
- /package/dist/{client-HGKAC6VP.js.map → chunk-FIOB3MEX.js.map} +0 -0
- /package/dist/{config-A4RWEJHV.js.map → client-ZLSXGPTV.js.map} +0 -0
- /package/dist/{compaction-NWEMOSHA.js.map → compaction-FD4R23HZ.js.map} +0 -0
- /package/dist/{config-mutator-MNSFUN7D.js.map → config-HMW3EBZL.js.map} +0 -0
- /package/dist/{debug-N3B5NVJU.js.map → config-mutator-4SZ5YEVD.js.map} +0 -0
- /package/dist/{doctor-3Q36ZFE6.js.map → debug-3MEUO2P2.js.map} +0 -0
- /package/dist/{heal-HZ3EQD52.js.map → doctor-LRYV3QLK.js.map} +0 -0
- /package/dist/{loader-5CA3O5GM.js.map → heal-FVLBHYP7.js.map} +0 -0
- /package/dist/{ingest-whatsapp-LZE62INA.js.map → ingest-whatsapp-NS4OWDOU.js.map} +0 -0
- /package/dist/{loader-6IHUCPVO.js.map → loader-4ZGTK7LD.js.map} +0 -0
- /package/dist/{memory-extract-AENFVVO5.js.map → loader-BOKE5M5S.js.map} +0 -0
- /package/dist/{processes-UIC4XXA6.js.map → memory-extract-F5ZHWY5E.js.map} +0 -0
- /package/dist/{migrate-v2-QO7SLKIM.js.map → migrate-v2-V4WN2VVM.js.map} +0 -0
- /package/dist/{program-SAUNRCCD.js.map → processes-WU23U2OJ.js.map} +0 -0
- /package/dist/{providers-L27BJ5A6.js.map → program-GSZ5AYAM.js.map} +0 -0
- /package/dist/{projects-api-KRME2YKZ.js.map → projects-api-QE6L65SK.js.map} +0 -0
- /package/dist/{projects-mutate-3NHVG437.js.map → projects-mutate-MY7JTLSZ.js.map} +0 -0
- /package/dist/{query-HZYYRCLU.js.map → providers-KO2DODIO.js.map} +0 -0
- /package/dist/{recipes-EPLE2SNT.js.map → query-SQDHE4HV.js.map} +0 -0
- /package/dist/{registry-HK3WQ7BS.js.map → recipes-7S4MC5OJ.js.map} +0 -0
- /package/dist/{runner-BFYDWPUO.js.map → registry-LE6FWYZC.js.map} +0 -0
- /package/dist/{rotation-memo-B3EXXSEH.js.map → rotation-memo-7T75BLEX.js.map} +0 -0
- /package/dist/{scheduler-6T2SU5ST.js.map → routing-ILERJXB6.js.map} +0 -0
- /package/dist/{store-I3HEWVLQ.js.map → runner-YRBVNYTY.js.map} +0 -0
- /package/dist/{surface-usage-PRL6Z4U3.js.map → scheduler-BFM3EINS.js.map} +0 -0
- /package/dist/{timers-5LPZN55J.js.map → seat-7WM7II2B.js.map} +0 -0
- /package/dist/{types-VECTMYTO.js.map → store-MCOA6N4F.js.map} +0 -0
- /package/dist/{subagent-WV7QKQ3L.js.map → subagent-2SJHSSJS.js.map} +0 -0
- /package/dist/{types-Y6ZCZ52Z.js.map → surface-usage-NS74AXSE.js.map} +0 -0
- /package/dist/{wiki-M6YNRFC4.js.map → timers-PCB4ZIBD.js.map} +0 -0
- /package/dist/{workspace-setup-TBQAHYHJ.js.map → types-H5ENIUIT.js.map} +0 -0
- /package/dist/{whatsapp-state-XMTCSRXU.js.map → whatsapp-state-6D6FHHD2.js.map} +0 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// The floating widget itself.
|
|
4
|
+
///
|
|
5
|
+
/// NSPanel with .nonactivatingPanel so pressing the hotkey never pulls
|
|
6
|
+
/// focus out of whatever you were doing — the entire point of a voice
|
|
7
|
+
/// assistant is that you don't stop working to use it. It floats above
|
|
8
|
+
/// normal windows, joins every Space, and is deliberately small.
|
|
9
|
+
final class Panel: NSPanel {
|
|
10
|
+
private let label = NSTextField(labelWithString: "")
|
|
11
|
+
private let orb = NSView()
|
|
12
|
+
|
|
13
|
+
/// Scrolls text too long for the pill instead of truncating it.
|
|
14
|
+
///
|
|
15
|
+
/// Step text is routinely wider than 180 points, and an ellipsis hides
|
|
16
|
+
/// exactly the part that makes it reassuring.
|
|
17
|
+
///
|
|
18
|
+
/// The first version rotated the STRING by one character every 0.18s.
|
|
19
|
+
/// That flickers, and unavoidably: every tick re-lays out the whole
|
|
20
|
+
/// text, and the motion is quantised to a character width, so it jumps
|
|
21
|
+
/// rather than slides. Instead the label now moves inside a clipping
|
|
22
|
+
/// view, animated by Core Animation — continuous, sub-pixel, composited
|
|
23
|
+
/// off the main thread, and it never touches the text once set.
|
|
24
|
+
private var clip: NSView?
|
|
25
|
+
private var marqueeText = ""
|
|
26
|
+
|
|
27
|
+
private var pressedAt: NSPoint?
|
|
28
|
+
|
|
29
|
+
/// A click acts on mouse UP, and only when the pointer has not moved.
|
|
30
|
+
///
|
|
31
|
+
/// The panel is draggable by its background, so acting on mouse DOWN
|
|
32
|
+
/// would fire every time someone repositioned it — you could not move
|
|
33
|
+
/// the widget without talking to it.
|
|
34
|
+
override func mouseDown(with event: NSEvent) {
|
|
35
|
+
pressedAt = NSEvent.mouseLocation
|
|
36
|
+
super.mouseDown(with: event)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Right-click opens the only menu the widget has.
|
|
40
|
+
///
|
|
41
|
+
/// A hold has to be reversible in one gesture from wherever you are,
|
|
42
|
+
/// and it has to SHOW — a switch that silences your notifications
|
|
43
|
+
/// without saying so is how people miss things for a day and blame the
|
|
44
|
+
/// software.
|
|
45
|
+
override func rightMouseDown(with event: NSEvent) {
|
|
46
|
+
let menu = NSMenu()
|
|
47
|
+
let holding = Hold.isOn
|
|
48
|
+
let item = NSMenuItem(
|
|
49
|
+
title: holding ? "Delivering notifications" : "Hold notifications",
|
|
50
|
+
action: #selector(toggleHold), keyEquivalent: "")
|
|
51
|
+
item.target = self
|
|
52
|
+
item.state = holding ? .on : .off
|
|
53
|
+
menu.addItem(item)
|
|
54
|
+
menu.addItem(.separator())
|
|
55
|
+
let quit = NSMenuItem(title: "Quit AgentX Voice",
|
|
56
|
+
action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
|
57
|
+
menu.addItem(quit)
|
|
58
|
+
NSMenu.popUpContextMenu(menu, with: event, for: contentView ?? NSView())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@objc private func toggleHold() {
|
|
62
|
+
let nowOn = Hold.toggle()
|
|
63
|
+
onHoldChanged?(nowOn)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Told when the hold flips, so the app can redraw and flush.
|
|
67
|
+
var onHoldChanged: ((Bool) -> Void)?
|
|
68
|
+
|
|
69
|
+
override func mouseUp(with event: NSEvent) {
|
|
70
|
+
defer { pressedAt = nil }
|
|
71
|
+
if let start = pressedAt {
|
|
72
|
+
let now = NSEvent.mouseLocation
|
|
73
|
+
let moved = hypot(now.x - start.x, now.y - start.y)
|
|
74
|
+
if moved < 4 { onClick?() }
|
|
75
|
+
}
|
|
76
|
+
super.mouseUp(with: event)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
enum State {
|
|
80
|
+
case idle, listening, thinking, speaking
|
|
81
|
+
/// Live activity from the daemon, with seconds elapsed — the
|
|
82
|
+
/// difference between "it's working" and "it's hung".
|
|
83
|
+
case working(String, Int)
|
|
84
|
+
/// What is being said right now, scrolled in full.
|
|
85
|
+
///
|
|
86
|
+
/// "Speaking" told you the state and not the content, which is the
|
|
87
|
+
/// wrong half: you already know it is speaking, you can hear it.
|
|
88
|
+
/// What you cannot do is re-read a sentence that has gone past, or
|
|
89
|
+
/// follow it at all in a noisy room.
|
|
90
|
+
case saying(String)
|
|
91
|
+
case error(String)
|
|
92
|
+
|
|
93
|
+
var text: String {
|
|
94
|
+
switch self {
|
|
95
|
+
case .idle: return Hold.isOn ? "notifications held" : "hold ⌥space"
|
|
96
|
+
case .listening: return "Listening"
|
|
97
|
+
case .thinking: return "Thinking"
|
|
98
|
+
case .speaking: return "Speaking"
|
|
99
|
+
case .saying(let text): return text
|
|
100
|
+
// "·" is the system's own separator glyph.
|
|
101
|
+
case .working(let what, let secs): return "\(what) · \(secs)s"
|
|
102
|
+
case .error(let m): return m
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
var color: NSColor {
|
|
106
|
+
switch self {
|
|
107
|
+
// Teal carries every ACTIVE state, so the widget reads as one
|
|
108
|
+
// thing doing work rather than a traffic light. System reds
|
|
109
|
+
// and greens were macOS's voice, not the product's, and they
|
|
110
|
+
// shift with the user's accent-colour setting.
|
|
111
|
+
case .idle: return Hold.isOn ? Brand.warn : .tertiaryLabelColor
|
|
112
|
+
case .listening: return Brand.accent
|
|
113
|
+
case .thinking, .working: return Brand.primaryBright
|
|
114
|
+
case .speaking, .saying: return Brand.accentDeep
|
|
115
|
+
case .error: return Brand.alert
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Idle is an invitation and belongs in meta type; everything else
|
|
120
|
+
/// is a running commentary and belongs in body type.
|
|
121
|
+
var isMeta: Bool { if case .idle = self { return true }; return false }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Called when the pill is clicked. Set by the app.
|
|
125
|
+
///
|
|
126
|
+
/// The hotkey is faster once you know it, and invisible until then.
|
|
127
|
+
/// A widget whose only affordance is a chord you have to be told about
|
|
128
|
+
/// is a widget most people never use — "HOLD ⌥SPACE" is printed on it
|
|
129
|
+
/// precisely because there was nothing to click.
|
|
130
|
+
var onClick: (() -> Void)?
|
|
131
|
+
|
|
132
|
+
init() {
|
|
133
|
+
super.init(contentRect: NSRect(x: 0, y: 0, width: 230, height: 54),
|
|
134
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
135
|
+
backing: .buffered, defer: false)
|
|
136
|
+
isFloatingPanel = true
|
|
137
|
+
level = .floating
|
|
138
|
+
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
139
|
+
isOpaque = false
|
|
140
|
+
backgroundColor = .clear
|
|
141
|
+
hasShadow = true
|
|
142
|
+
isMovableByWindowBackground = true
|
|
143
|
+
hidesOnDeactivate = false
|
|
144
|
+
|
|
145
|
+
let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size: NSSize(width: 230, height: 54)))
|
|
146
|
+
blur.material = .hudWindow
|
|
147
|
+
blur.blendingMode = .behindWindow
|
|
148
|
+
blur.state = .active
|
|
149
|
+
blur.wantsLayer = true
|
|
150
|
+
blur.layer?.cornerRadius = Brand.Radius.lg
|
|
151
|
+
blur.layer?.masksToBounds = true
|
|
152
|
+
blur.autoresizingMask = [.width, .height]
|
|
153
|
+
contentView = blur
|
|
154
|
+
|
|
155
|
+
// An opaque tint over the blur, and it is not a style preference.
|
|
156
|
+
//
|
|
157
|
+
// `blendingMode = .behindWindow` composites whatever is behind the
|
|
158
|
+
// pill, so the editor's own panel dividers were coming through it
|
|
159
|
+
// as a faint rectangle around the widget — it read as a border the
|
|
160
|
+
// widget was drawing, when it was the window behind. Translucency
|
|
161
|
+
// is pleasant over wallpaper and actively confusing over ruled UI.
|
|
162
|
+
//
|
|
163
|
+
// This keeps enough blur to feel native while making the pill read
|
|
164
|
+
// as one solid object wherever it is parked.
|
|
165
|
+
let tint = NSView(frame: blur.bounds)
|
|
166
|
+
tint.wantsLayer = true
|
|
167
|
+
tint.layer?.backgroundColor = NSColor.windowBackgroundColor
|
|
168
|
+
.withAlphaComponent(0.72).cgColor
|
|
169
|
+
tint.layer?.cornerRadius = Brand.Radius.lg
|
|
170
|
+
tint.autoresizingMask = [.width, .height]
|
|
171
|
+
blur.addSubview(tint)
|
|
172
|
+
|
|
173
|
+
orb.wantsLayer = true
|
|
174
|
+
orb.layer?.cornerRadius = 4
|
|
175
|
+
orb.frame = NSRect(x: 18, y: 22, width: 8, height: 8)
|
|
176
|
+
blur.addSubview(orb)
|
|
177
|
+
|
|
178
|
+
// A clipping window the label slides behind.
|
|
179
|
+
let clipView = NSView(frame: NSRect(x: 38, y: 17, width: 180, height: 20))
|
|
180
|
+
clipView.wantsLayer = true
|
|
181
|
+
clipView.layer?.masksToBounds = true
|
|
182
|
+
label.frame = NSRect(x: 0, y: 0, width: 180, height: 20)
|
|
183
|
+
label.font = Brand.body(size: 12)
|
|
184
|
+
label.lineBreakMode = .byClipping
|
|
185
|
+
label.wantsLayer = true
|
|
186
|
+
clipView.addSubview(label)
|
|
187
|
+
blur.addSubview(clipView)
|
|
188
|
+
clip = clipView
|
|
189
|
+
|
|
190
|
+
render(.idle)
|
|
191
|
+
positionBottomRight()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Bottom-right, clear of the Dock — out of the way but visible.
|
|
195
|
+
private func positionBottomRight() {
|
|
196
|
+
guard let screen = NSScreen.main else { return }
|
|
197
|
+
let v = screen.visibleFrame
|
|
198
|
+
setFrameOrigin(NSPoint(x: v.maxX - frame.width - 24, y: v.minY + 24))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// AppKit is not thread-safe. Marking this explicitly means a stray
|
|
202
|
+
/// call from a delegate queue is a compile error rather than heap
|
|
203
|
+
/// corruption that traps somewhere unrelated an hour later.
|
|
204
|
+
@MainActor
|
|
205
|
+
func render(_ state: State) {
|
|
206
|
+
orb.layer?.backgroundColor = state.color.cgColor
|
|
207
|
+
// A soft halo on the dot while active: the same --nq-ring-accent
|
|
208
|
+
// idea, and the only ornament on the pill.
|
|
209
|
+
orb.layer?.shadowColor = state.color.cgColor
|
|
210
|
+
orb.layer?.shadowOpacity = state.isMeta ? 0 : 0.55
|
|
211
|
+
orb.layer?.shadowRadius = 5
|
|
212
|
+
orb.layer?.shadowOffset = .zero
|
|
213
|
+
|
|
214
|
+
if state.isMeta {
|
|
215
|
+
stopMarquee()
|
|
216
|
+
label.attributedStringValue = Brand.metaString(state.text, color: state.color)
|
|
217
|
+
label.frame.origin.x = 0
|
|
218
|
+
} else {
|
|
219
|
+
label.textColor = state.color
|
|
220
|
+
setText(state.text)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// Fits, or scrolls. Identical text is left alone so a per-second tick
|
|
225
|
+
/// cannot restart the animation and make it stutter in place.
|
|
226
|
+
@MainActor
|
|
227
|
+
private func setText(_ text: String) {
|
|
228
|
+
guard let clipView = clip else { label.stringValue = text; return }
|
|
229
|
+
let font = label.font ?? NSFont.systemFont(ofSize: 12)
|
|
230
|
+
let width = (text as NSString).size(withAttributes: [.font: font]).width
|
|
231
|
+
|
|
232
|
+
if width <= clipView.bounds.width {
|
|
233
|
+
stopMarquee()
|
|
234
|
+
label.stringValue = text
|
|
235
|
+
label.frame.size.width = clipView.bounds.width
|
|
236
|
+
label.frame.origin.x = 0
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if text == marqueeText { return }
|
|
240
|
+
marqueeText = text
|
|
241
|
+
startMarquee(text: text, textWidth: width, in: clipView)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@MainActor
|
|
245
|
+
private func startMarquee(text: String, textWidth: CGFloat, in clipView: NSView) {
|
|
246
|
+
label.layer?.removeAnimation(forKey: "marquee")
|
|
247
|
+
|
|
248
|
+
// Gap so the loop point reads as a pause rather than words colliding.
|
|
249
|
+
let gap: CGFloat = 40
|
|
250
|
+
let travel = textWidth + gap
|
|
251
|
+
label.stringValue = text
|
|
252
|
+
label.frame = NSRect(x: 0, y: 0, width: textWidth + gap, height: clipView.bounds.height)
|
|
253
|
+
|
|
254
|
+
let slide = CABasicAnimation(keyPath: "position.x")
|
|
255
|
+
slide.fromValue = label.layer?.position.x ?? 0
|
|
256
|
+
slide.byValue = -travel
|
|
257
|
+
// Speed proportional to length, so a long step is not punishingly
|
|
258
|
+
// slow and a short one is not a blur. ~40 points a second reads at
|
|
259
|
+
// a glance without demanding attention.
|
|
260
|
+
slide.duration = CFTimeInterval(travel / 40)
|
|
261
|
+
slide.repeatCount = .infinity
|
|
262
|
+
slide.isRemovedOnCompletion = false
|
|
263
|
+
label.layer?.add(slide, forKey: "marquee")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
@MainActor
|
|
267
|
+
private func stopMarquee() {
|
|
268
|
+
label.layer?.removeAnimation(forKey: "marquee")
|
|
269
|
+
marqueeText = ""
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Borderless panels refuse key status unless told otherwise; without
|
|
273
|
+
// this the widget cannot show a caret or take any future text input.
|
|
274
|
+
override var canBecomeKey: Bool { true }
|
|
275
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Live activity from the daemon while a turn runs.
|
|
4
|
+
///
|
|
5
|
+
/// A voice assistant that goes silent for ten seconds reads as broken, and
|
|
6
|
+
/// the honest fix is not a spinner — it is saying what is actually
|
|
7
|
+
/// happening. The daemon already publishes `task:step` on its /events SSE
|
|
8
|
+
/// with a step name, action and input summary, so the widget can narrate
|
|
9
|
+
/// real work instead of inventing reassurance.
|
|
10
|
+
///
|
|
11
|
+
/// Deliberately best-effort: if the stream drops or the shape changes, the
|
|
12
|
+
/// turn still completes and the answer is still spoken. Progress is
|
|
13
|
+
/// decoration over a working path, never a dependency of it.
|
|
14
|
+
final class Progress: NSObject, URLSessionDataDelegate {
|
|
15
|
+
private var task: URLSessionDataTask?
|
|
16
|
+
private var session: URLSession?
|
|
17
|
+
private var buffer = Data()
|
|
18
|
+
private let agentID: String
|
|
19
|
+
private let onStep: (String) -> Void
|
|
20
|
+
|
|
21
|
+
/// Every touch of `buffer`, `task` and `session` happens here.
|
|
22
|
+
///
|
|
23
|
+
/// URLSession delivers on its own queue while stop() is called from the
|
|
24
|
+
/// main thread at the end of a turn, and both mutated `buffer`. Two
|
|
25
|
+
/// threads mutating a Data value is not a race you get to lose
|
|
26
|
+
/// gracefully — it corrupts the heap, and the process traps later in
|
|
27
|
+
/// unrelated code (a CATransaction flush, in the crash that prompted
|
|
28
|
+
/// this). Serialising is the fix; a lock around only removeAll() would
|
|
29
|
+
/// not be, because the append path mutates it too.
|
|
30
|
+
private let queue = DispatchQueue(label: "tn.acme.agentx.voice.progress")
|
|
31
|
+
private var stopped = false
|
|
32
|
+
|
|
33
|
+
init(agentID: String, onStep: @escaping (String) -> Void) {
|
|
34
|
+
self.agentID = agentID
|
|
35
|
+
self.onStep = onStep
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func start() {
|
|
39
|
+
queue.async { [weak self] in self?.startLocked() }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private func startLocked() {
|
|
43
|
+
guard !stopped else { return }
|
|
44
|
+
guard let url = URL(string: "\(Config.daemonURL)/events?type=task") else { return }
|
|
45
|
+
var req = URLRequest(url: url)
|
|
46
|
+
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
|
47
|
+
req.timeoutInterval = 3600
|
|
48
|
+
let cfg = URLSessionConfiguration.default
|
|
49
|
+
cfg.timeoutIntervalForRequest = 3600
|
|
50
|
+
// Deliver on our own serial queue rather than an arbitrary one, so
|
|
51
|
+
// didReceive and stop() cannot interleave.
|
|
52
|
+
let opQueue = OperationQueue()
|
|
53
|
+
opQueue.maxConcurrentOperationCount = 1
|
|
54
|
+
opQueue.underlyingQueue = queue
|
|
55
|
+
let s = URLSession(configuration: cfg, delegate: self, delegateQueue: opQueue)
|
|
56
|
+
session = s
|
|
57
|
+
task = s.dataTask(with: req)
|
|
58
|
+
task?.resume()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func stop() {
|
|
62
|
+
queue.async { [weak self] in
|
|
63
|
+
guard let self, !self.stopped else { return }
|
|
64
|
+
self.stopped = true
|
|
65
|
+
self.task?.cancel(); self.task = nil
|
|
66
|
+
// invalidateAndCancel breaks the session's strong reference to
|
|
67
|
+
// this delegate; without it the object leaks for the life of
|
|
68
|
+
// the app and keeps receiving events after the turn ended.
|
|
69
|
+
self.session?.invalidateAndCancel(); self.session = nil
|
|
70
|
+
self.buffer.removeAll()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
deinit { task?.cancel(); session?.invalidateAndCancel() }
|
|
75
|
+
|
|
76
|
+
// Already on `queue` — see the delegateQueue wiring in startLocked().
|
|
77
|
+
func urlSession(_ s: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
78
|
+
guard !stopped else { return }
|
|
79
|
+
buffer.append(data)
|
|
80
|
+
// SSE frames are separated by a blank line. Anything not yet
|
|
81
|
+
// terminated stays buffered for the next packet.
|
|
82
|
+
while let range = buffer.range(of: Data("\n\n".utf8)) {
|
|
83
|
+
let frame = buffer.subdata(in: buffer.startIndex..<range.lowerBound)
|
|
84
|
+
buffer.removeSubrange(buffer.startIndex..<range.upperBound)
|
|
85
|
+
handle(String(data: frame, encoding: .utf8) ?? "")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func handle(_ frame: String) {
|
|
90
|
+
var event = "message", payload = ""
|
|
91
|
+
for line in frame.split(separator: "\n", omittingEmptySubsequences: false) {
|
|
92
|
+
if line.hasPrefix("event:") { event = line.dropFirst(6).trimmingCharacters(in: .whitespaces) }
|
|
93
|
+
if line.hasPrefix("data:") { payload += line.dropFirst(5).trimmingCharacters(in: .whitespaces) }
|
|
94
|
+
}
|
|
95
|
+
guard event.contains("task"), !payload.isEmpty,
|
|
96
|
+
let data = payload.data(using: .utf8),
|
|
97
|
+
let outer = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
98
|
+
else { return }
|
|
99
|
+
|
|
100
|
+
// broadcastSSE wraps every frame as {time, message}, where
|
|
101
|
+
// `message` is the payload re-encoded as a STRING. So the step is
|
|
102
|
+
// one JSON level deeper than the frame appears to be — unwrap it,
|
|
103
|
+
// while still accepting a flat frame in case that ever changes.
|
|
104
|
+
var obj = outer
|
|
105
|
+
if let inner = outer["message"] as? String,
|
|
106
|
+
let innerData = inner.data(using: .utf8),
|
|
107
|
+
let parsed = try? JSONSerialization.jsonObject(with: innerData) as? [String: Any] {
|
|
108
|
+
obj = parsed
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Only this widget's agent. Other agents are busy on their own work
|
|
112
|
+
// and narrating theirs would be both confusing and a privacy leak.
|
|
113
|
+
guard (obj["agentId"] as? String) == agentID else { return }
|
|
114
|
+
guard let label = summarise(obj) else { return }
|
|
115
|
+
DispatchQueue.main.async { self.onStep(label) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Turn a step event into something worth showing in 180 points of
|
|
119
|
+
/// label. Prefers the human-facing action over the internal step name.
|
|
120
|
+
private func summarise(_ obj: [String: Any]) -> String? {
|
|
121
|
+
// Only the start of a step is worth announcing. A tool_result
|
|
122
|
+
// arrives a beat later and would just repeat what was said.
|
|
123
|
+
if (obj["name"] as? String) == "tool_result" { return nil }
|
|
124
|
+
|
|
125
|
+
let tool = (obj["action"] as? String) ?? (obj["name"] as? String) ?? ""
|
|
126
|
+
guard !tool.isEmpty, tool != "tool_use" else { return nil }
|
|
127
|
+
|
|
128
|
+
// inputSummary is the tool's raw arguments as JSON. The useful
|
|
129
|
+
// part is one field — a command, a path, a query — and showing the
|
|
130
|
+
// braces instead would waste the whole label.
|
|
131
|
+
var detail = ""
|
|
132
|
+
if let input = obj["inputSummary"] as? String,
|
|
133
|
+
let d = input.data(using: .utf8),
|
|
134
|
+
let args = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
|
|
135
|
+
for key in ["command", "file_path", "path", "query", "pattern", "url", "description"] {
|
|
136
|
+
if let v = args[key] as? String, !v.isEmpty { detail = v; break }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Raw tool names never reach the screen. The marquee used to show
|
|
140
|
+
// things like "mcp__agentx__agentx_task", which is unreadable to
|
|
141
|
+
// anyone outside this codebase and appears during exactly the long
|
|
142
|
+
// waits the widget exists to explain.
|
|
143
|
+
guard let text = StepLabel.describe(tool: tool, detail: detail) else { return nil }
|
|
144
|
+
// Long text is fine — the panel marquees it rather than truncating.
|
|
145
|
+
return text.count > 90 ? String(text.prefix(88)) + "…" : text
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
|
|
3
|
+
/// Microphone capture that yields exactly what a speech API wants: 16 kHz
|
|
4
|
+
/// mono 16-bit PCM in a RIFF container.
|
|
5
|
+
///
|
|
6
|
+
/// The hardware input is whatever the device feels like (commonly 48 kHz
|
|
7
|
+
/// float32, and on some Macs the format is only valid AFTER the engine
|
|
8
|
+
/// starts), so the tap installs with the input node's own format and an
|
|
9
|
+
/// AVAudioConverter does the rest. Converting in the tap rather than at
|
|
10
|
+
/// the end keeps memory flat for long utterances.
|
|
11
|
+
final class Recorder {
|
|
12
|
+
private let engine = AVAudioEngine()
|
|
13
|
+
private var converter: AVAudioConverter?
|
|
14
|
+
private var pcm = Data()
|
|
15
|
+
private let lock = NSLock()
|
|
16
|
+
private(set) var isRecording = false
|
|
17
|
+
|
|
18
|
+
/// Loudness of the most recent buffer, 0…1, smoothed.
|
|
19
|
+
///
|
|
20
|
+
/// Exists so the widget can tell talking from silence without a second
|
|
21
|
+
/// audio tap. Hands-free listening needs to know when you have
|
|
22
|
+
/// FINISHED a sentence, and the only honest signal for that is the
|
|
23
|
+
/// microphone going quiet — a fixed timeout either cuts people off
|
|
24
|
+
/// mid-thought or leaves the mic open staring at them.
|
|
25
|
+
///
|
|
26
|
+
/// Read from the main thread while the audio thread writes it. A
|
|
27
|
+
/// Float write is atomic on every platform this runs on, and a reader
|
|
28
|
+
/// that occasionally sees the previous buffer's value is choosing
|
|
29
|
+
/// between two adjacent 100ms windows — which changes nothing.
|
|
30
|
+
private(set) var level: Float = 0
|
|
31
|
+
|
|
32
|
+
/// 16 kHz mono int16 — the format both ElevenLabs and whisper prefer,
|
|
33
|
+
/// and small enough that a 30-second utterance is under 1 MB.
|
|
34
|
+
private let target = AVAudioFormat(
|
|
35
|
+
commonFormat: .pcmFormatInt16, sampleRate: 16_000, channels: 1, interleaved: true)!
|
|
36
|
+
|
|
37
|
+
enum RecorderError: LocalizedError {
|
|
38
|
+
case micDenied
|
|
39
|
+
case tooShort
|
|
40
|
+
var errorDescription: String? {
|
|
41
|
+
switch self {
|
|
42
|
+
case .micDenied: return "Microphone access denied. Grant it in System Settings › Privacy & Security › Microphone."
|
|
43
|
+
case .tooShort: return "Didn't catch that — hold the hotkey while you speak."
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func requestPermission(_ done: @escaping (Bool) -> Void) {
|
|
49
|
+
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
50
|
+
case .authorized: done(true)
|
|
51
|
+
case .notDetermined: AVCaptureDevice.requestAccess(for: .audio) { ok in
|
|
52
|
+
DispatchQueue.main.async { done(ok) } }
|
|
53
|
+
default: done(false)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func start() throws {
|
|
58
|
+
level = 0
|
|
59
|
+
guard !isRecording else { return }
|
|
60
|
+
lock.lock(); pcm.removeAll(keepingCapacity: true); lock.unlock()
|
|
61
|
+
|
|
62
|
+
let input = engine.inputNode
|
|
63
|
+
let inputFormat = input.inputFormat(forBus: 0)
|
|
64
|
+
guard inputFormat.sampleRate > 0 else { throw RecorderError.micDenied }
|
|
65
|
+
converter = AVAudioConverter(from: inputFormat, to: target)
|
|
66
|
+
|
|
67
|
+
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buf, _ in
|
|
68
|
+
self?.append(buf)
|
|
69
|
+
}
|
|
70
|
+
engine.prepare()
|
|
71
|
+
try engine.start()
|
|
72
|
+
isRecording = true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Stops and returns a WAV, or nil when nothing usable was captured.
|
|
76
|
+
func stop() -> Data? {
|
|
77
|
+
guard isRecording else { return nil }
|
|
78
|
+
engine.inputNode.removeTap(onBus: 0)
|
|
79
|
+
engine.stop()
|
|
80
|
+
isRecording = false
|
|
81
|
+
|
|
82
|
+
lock.lock(); let body = pcm; lock.unlock()
|
|
83
|
+
// 16 kHz * 2 bytes = 32 kB/s; under a quarter second is a misfire,
|
|
84
|
+
// not speech, and sending it just wastes an API call.
|
|
85
|
+
guard body.count > 8_000 else { return nil }
|
|
86
|
+
return wav(body)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func append(_ buffer: AVAudioPCMBuffer) {
|
|
90
|
+
guard let converter else { return }
|
|
91
|
+
let ratio = target.sampleRate / buffer.format.sampleRate
|
|
92
|
+
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1024
|
|
93
|
+
guard let out = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: capacity) else { return }
|
|
94
|
+
|
|
95
|
+
var supplied = false
|
|
96
|
+
var error: NSError?
|
|
97
|
+
converter.convert(to: out, error: &error) { _, status in
|
|
98
|
+
if supplied { status.pointee = .noDataNow; return nil }
|
|
99
|
+
supplied = true
|
|
100
|
+
status.pointee = .haveData
|
|
101
|
+
return buffer
|
|
102
|
+
}
|
|
103
|
+
guard error == nil, out.frameLength > 0,
|
|
104
|
+
let channel = out.int16ChannelData?[0] else { return }
|
|
105
|
+
|
|
106
|
+
// RMS over the buffer, in the same pass that copies it.
|
|
107
|
+
var sum: Float = 0
|
|
108
|
+
for i in 0..<Int(out.frameLength) {
|
|
109
|
+
let sample = Float(channel[i]) / 32768.0
|
|
110
|
+
sum += sample * sample
|
|
111
|
+
}
|
|
112
|
+
let rms = (sum / Float(max(Int(out.frameLength), 1))).squareRoot()
|
|
113
|
+
// Attack fast, release slow: a level that drops instantly makes a
|
|
114
|
+
// pause between words look like the end of a sentence.
|
|
115
|
+
level = rms > level ? rms : level * 0.82 + rms * 0.18
|
|
116
|
+
|
|
117
|
+
let bytes = Int(out.frameLength) * MemoryLayout<Int16>.size
|
|
118
|
+
lock.lock()
|
|
119
|
+
pcm.append(UnsafeBufferPointer(start: channel, count: Int(out.frameLength)).withMemoryRebound(to: UInt8.self) {
|
|
120
|
+
Data(bytes: $0.baseAddress!, count: bytes)
|
|
121
|
+
})
|
|
122
|
+
lock.unlock()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Minimal RIFF/WAVE header for 16-bit mono PCM.
|
|
126
|
+
private func wav(_ body: Data) -> Data {
|
|
127
|
+
let rate = UInt32(target.sampleRate), channels: UInt16 = 1, bits: UInt16 = 16
|
|
128
|
+
let byteRate = rate * UInt32(channels) * UInt32(bits / 8)
|
|
129
|
+
var d = Data()
|
|
130
|
+
func str(_ s: String) { d.append(s.data(using: .ascii)!) }
|
|
131
|
+
func u32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
|
|
132
|
+
func u16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
|
|
133
|
+
str("RIFF"); u32(UInt32(36 + body.count)); str("WAVE")
|
|
134
|
+
str("fmt "); u32(16); u16(1); u16(channels); u32(rate); u32(byteRate)
|
|
135
|
+
u16(channels * bits / 8); u16(bits)
|
|
136
|
+
str("data"); u32(UInt32(body.count)); d.append(body)
|
|
137
|
+
return d
|
|
138
|
+
}
|
|
139
|
+
}
|