claude-code-tools 1.19.6__tar.gz → 1.19.8__tar.gz

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.
Files changed (127) hide show
  1. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/PKG-INFO +1 -1
  2. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/app.py +5 -1
  4. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/cli.py +25 -0
  5. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/config.py +25 -5
  6. claude_code_tools-1.19.8/claude_code_tools/voice_type/overlay.py +402 -0
  7. claude_code_tools-1.19.8/claude_code_tools/voice_type/setup_wizard.py +357 -0
  8. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/pyproject.toml +2 -2
  9. claude_code_tools-1.19.6/claude_code_tools/voice_type/overlay.py +0 -273
  10. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/.gitignore +0 -0
  11. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/LICENSE +0 -0
  12. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/README.md +0 -0
  13. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/action_rpc.py +0 -0
  14. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  15. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/backends.py +0 -0
  16. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/cli.py +0 -0
  17. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/config.py +0 -0
  18. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/convert.py +0 -0
  19. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  20. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/locking.py +0 -0
  21. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/paths.py +0 -0
  22. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/registry.py +0 -0
  23. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/session.py +0 -0
  24. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/store.py +0 -0
  25. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  26. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/agent_tunnel/trust.py +0 -0
  27. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/aichat.py +0 -0
  28. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/claude_continue.py +0 -0
  29. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_continue.py +0 -0
  30. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server.py +0 -0
  31. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_cli.py +0 -0
  32. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_fingerprint.py +0 -0
  33. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_generation.py +0 -0
  34. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_legacy.py +0 -0
  35. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_models.py +0 -0
  36. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_process.py +0 -0
  37. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_reservation.py +0 -0
  38. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_retry.py +0 -0
  39. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_reuse.py +0 -0
  40. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_state.py +0 -0
  41. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_supervisor.py +0 -0
  42. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/codex_server_worker.py +0 -0
  43. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/config.py +0 -0
  44. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/csv2gsheet.py +0 -0
  45. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/delete_session.py +0 -0
  46. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/dotenv_vault.py +0 -0
  47. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/env_safe.py +0 -0
  48. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/export_all.py +0 -0
  49. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/export_claude_session.py +0 -0
  50. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/export_codex_session.py +0 -0
  51. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/export_session.py +0 -0
  52. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/find_claude_session.py +0 -0
  53. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/find_codex_session.py +0 -0
  54. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/find_original_session.py +0 -0
  55. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/find_session.py +0 -0
  56. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/find_trimmed_sessions.py +0 -0
  57. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/fix_session.py +0 -0
  58. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/gdoc2docx.py +0 -0
  59. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/gdoc2md.py +0 -0
  60. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/gsheet2csv.py +0 -0
  61. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/md2gdoc.py +0 -0
  62. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/msg/__init__.py +0 -0
  63. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/msg/cli.py +0 -0
  64. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/msg/hooks.py +0 -0
  65. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/msg/models.py +0 -0
  66. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/msg/prompt_detect.py +0 -0
  67. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/msg/store.py +0 -0
  68. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/msg/watcher.py +0 -0
  69. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/node_menu_ui.py +0 -0
  70. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/port_claude_noise.py +0 -0
  71. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/port_claude_to_codex.py +0 -0
  72. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/port_codex_flatten.py +0 -0
  73. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/port_codex_to_claude.py +0 -0
  74. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/port_service.py +0 -0
  75. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/resolve_session.py +0 -0
  76. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/resolve_session_names.py +0 -0
  77. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/resolve_session_render.py +0 -0
  78. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/search_index.py +0 -0
  79. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/session_lineage.py +0 -0
  80. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/session_menu.py +0 -0
  81. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/session_menu_cli.py +0 -0
  82. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/session_utils.py +0 -0
  83. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/smart_trim.py +0 -0
  84. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/smart_trim_core.py +0 -0
  85. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/tmux_cli_controller.py +0 -0
  86. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/tmux_execution_helpers.py +0 -0
  87. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/tmux_remote_controller.py +0 -0
  88. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/trim_in_place.py +0 -0
  89. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/trim_session.py +0 -0
  90. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/trim_session_claude.py +0 -0
  91. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/trim_session_codex.py +0 -0
  92. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/__init__.py +0 -0
  93. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/engine_parakeet.py +0 -0
  94. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/engine_parakeet_mlx.py +0 -0
  95. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/engines.py +0 -0
  96. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/hotkey.py +0 -0
  97. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/inject.py +0 -0
  98. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/voice_type/logic.py +0 -0
  99. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli.py +0 -0
  100. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli_contract.py +0 -0
  101. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli_formatting.py +0 -0
  102. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  103. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli_manifest.py +0 -0
  104. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli_projection.py +0 -0
  105. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli_rendering.py +0 -0
  106. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  107. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  108. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_processes.py +0 -0
  109. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_runs.py +0 -0
  110. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_store_io.py +0 -0
  111. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/claude_code_tools/workflow_validation.py +0 -0
  112. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/agent-tunnel-spec.md +0 -0
  113. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/claude-code-tmux-tutorials.md +0 -0
  114. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/dot-zshrc.md +0 -0
  115. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/find-claude-session.md +0 -0
  116. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/intercom-spec.md +0 -0
  117. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/lmsh.md +0 -0
  118. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/local-llm-setup.md +0 -0
  119. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/msg-phase-a-plan.md +0 -0
  120. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/msg-plan-v2.md +0 -0
  121. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/tmux-cli-instructions.md +0 -0
  122. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/docs/vault-documentation.md +0 -0
  123. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/hatch_build.py +0 -0
  124. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/node_ui/action_config.js +0 -0
  125. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/node_ui/menu.js +0 -0
  126. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/node_ui/package-lock.json +0 -0
  127. {claude_code_tools-1.19.6 → claude_code_tools-1.19.8}/node_ui/package.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-tools
3
- Version: 1.19.6
3
+ Version: 1.19.8
4
4
  Summary: Collection of tools for working with Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "1.19.6"
3
+ __version__ = "1.19.8"
@@ -536,7 +536,11 @@ class VoiceTypeApp:
536
536
  return state.value, state is State.ACTIVE, level
537
537
 
538
538
  overlay.run_overlay(
539
- sample, tick, self._stop.is_set
539
+ sample,
540
+ tick,
541
+ self._stop.is_set,
542
+ flex=self.cfg.overlay_flex,
543
+ speed=self.cfg.overlay_speed,
540
544
  )
541
545
  return result["code"]
542
546
  self._status("overlay unavailable; running without it")
@@ -157,6 +157,12 @@ def _cmd_hotkey() -> int:
157
157
  return 0
158
158
 
159
159
 
160
+ def _cmd_setup(args: argparse.Namespace) -> int:
161
+ from .setup_wizard import run_setup
162
+
163
+ return run_setup(config_path=args.config, force=args.force)
164
+
165
+
160
166
  def _cmd_init(args: argparse.Namespace) -> int:
161
167
  try:
162
168
  path = write_sample_config(args.config, force=args.force)
@@ -164,6 +170,7 @@ def _cmd_init(args: argparse.Namespace) -> int:
164
170
  print(f"voice-type: {e}", file=sys.stderr)
165
171
  return 1
166
172
  print(f"wrote {path}")
173
+ print("(tip: `voice-type setup` walks you through it interactively)")
167
174
  return 0
168
175
 
169
176
 
@@ -179,6 +186,7 @@ examples:
179
186
  voice-type --engine parakeet-mlx --segmentation hold
180
187
  # best accuracy: whole-take dictation
181
188
  voice-type --mode wake # hands-free with wake word "claude"
189
+ voice-type setup # interactive walkthrough -> config
182
190
  voice-type init # write a commented sample config
183
191
  voice-type hotkey # record a chord, print the config line
184
192
 
@@ -218,12 +226,29 @@ Accessibility (to type), Input Monitoring (global hotkeys).
218
226
  "hotkey",
219
227
  help="press a key combo and print the matching config line",
220
228
  )
229
+ p_setup = sub.add_parser(
230
+ "setup",
231
+ help="interactive walkthrough that writes your config",
232
+ )
233
+ p_setup.add_argument(
234
+ "--config",
235
+ type=Path,
236
+ # SUPPRESS so a --config given BEFORE the subcommand is not
237
+ # clobbered by the subparser's default (see the init parser).
238
+ default=argparse.SUPPRESS,
239
+ help="destination path",
240
+ )
241
+ p_setup.add_argument(
242
+ "--force", action="store_true", help="overwrite existing config"
243
+ )
221
244
 
222
245
  args = parser.parse_args()
223
246
  if args.command == "init":
224
247
  return _cmd_init(args)
225
248
  if args.command == "hotkey":
226
249
  return _cmd_hotkey()
250
+ if args.command == "setup":
251
+ return _cmd_setup(args)
227
252
  return _cmd_run(args)
228
253
 
229
254
 
@@ -55,9 +55,13 @@ class Config:
55
55
  fastest for v3-int8 on Apple Silicon; 8 helps v2-fp16).
56
56
  strip_fillers: Drop standalone filler words (uh, um, ...) from
57
57
  typed text.
58
- overlay: Show the floating waveform pill while recording
59
- (macOS only; ignored elsewhere). The pill is hidden while
58
+ overlay: Show the floating ghost indicator while recording
59
+ (macOS only; ignored elsewhere). The ghost is hidden while
60
60
  paused/passive — its presence IS the recording indicator.
61
+ overlay_flex: How much the ghost's face flexes/wobbles its
62
+ shape (1.0 = default; higher = more flexible, calmer < 1.0).
63
+ overlay_speed: Overall animation speed multiplier (1.0 =
64
+ default; lower = slower/gentler motion).
61
65
  model_arch: Moonshine model architecture name (moonshine engine
62
66
  only).
63
67
  language: Language tag understood by Moonshine (e.g. "en").
@@ -98,6 +102,8 @@ class Config:
98
102
  mlx_model: str = "mlx-community/parakeet-tdt-0.6b-v3"
99
103
  strip_fillers: bool = True
100
104
  overlay: bool = True
105
+ overlay_flex: float = 1.0
106
+ overlay_speed: float = 1.0
101
107
  model_arch: str = "medium-streaming"
102
108
  language: str = "en"
103
109
  hotkey: str = "<ctrl>+;"
@@ -181,6 +187,16 @@ class Config:
181
187
  raise ValueError(
182
188
  f"{name} must be a string, got {value!r}"
183
189
  )
190
+ for name in ("overlay_flex", "overlay_speed"):
191
+ value = getattr(self, name)
192
+ if isinstance(value, bool) or not isinstance(
193
+ value, (int, float)
194
+ ):
195
+ raise ValueError(f"{name} must be a number, got {value!r}")
196
+ if not 0.0 <= value <= 5.0:
197
+ raise ValueError(
198
+ f"{name} must be between 0 and 5, got {value!r}"
199
+ )
184
200
  if self.mode == "wake" and not self.wake_word.strip():
185
201
  raise ValueError('mode "wake" requires a non-empty wake_word')
186
202
  if isinstance(self.idle_timeout, bool) or not isinstance(
@@ -304,10 +320,14 @@ parakeet_model = "v3-int8"
304
320
  # Apple Silicon (~32x realtime); v2-fp16 benefits from 8 (~16x).
305
321
  parakeet_threads = 4
306
322
 
307
- # Floating waveform pill (macOS): shown ONLY while recording — red
308
- # waves as you speak; hidden when paused/waiting. Click-through; never
309
- # steals focus.
323
+ # Floating ghost indicator (macOS): shown ONLY while recording — a
324
+ # little blue ghost whose mouth opens with your voice; hidden when
325
+ # paused/waiting. Click-through; never steals focus.
310
326
  overlay = true
327
+ # How much the ghost's face flexes its shape (higher = more flexible).
328
+ overlay_flex = 1.0
329
+ # Overall animation speed (lower = slower, gentler motion).
330
+ overlay_speed = 1.0
311
331
 
312
332
  # Remove standalone filler words (uh, um, ...) from typed text.
313
333
  strip_fillers = true
@@ -0,0 +1,402 @@
1
+ """Floating waveform pill: visible proof that voice-type is listening.
2
+
3
+ A small always-on-top, click-through, non-activating macOS panel at the
4
+ bottom-center of the screen, shown ONLY while recording. It scrolls a
5
+ live waveform of the mic level — red waves as you speak — and hides
6
+ when paused or waiting for the wake word. Because the panel is a
7
+ non-activating borderless NSPanel that ignores mouse events, it can
8
+ never steal keyboard focus from the app being dictated into.
9
+
10
+ Uses pyobjc (already a transitive dependency of pynput on macOS).
11
+ The overlay must run on the process's MAIN thread (AppKit rule); the
12
+ app hands its per-tick housekeeping in as a callback.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import signal
18
+ from typing import Callable
19
+
20
+ # (state_label, is_recording, level 0..1) sampled ~20x per second.
21
+ SampleFn = Callable[[], tuple[str, bool, float]]
22
+ TickFn = Callable[[], None]
23
+
24
+ _WIDTH = 210.0
25
+ _HEIGHT = 240.0 # generous headroom so the halo never clips when moving
26
+ _MARGIN_BOTTOM = 110.0
27
+ _TICK_SECONDS = 0.033 # ~30 fps
28
+ # Master animation speed: small = slow, gentle, gradual motion.
29
+ _PHASE_SPEED = 0.05
30
+ # Idle blink: every ~_BLINK_EVERY frames the eyes close for a few.
31
+ _BLINK_EVERY = 130
32
+ _BLINK_FRAMES = 6
33
+
34
+
35
+ def overlay_available() -> bool:
36
+ """True if the AppKit bridge is importable (macOS with pyobjc)."""
37
+ try:
38
+ import AppKit # noqa: F401
39
+
40
+ return True
41
+ except Exception:
42
+ return False
43
+
44
+
45
+ def run_overlay( # noqa: PLR0913
46
+ sample: SampleFn,
47
+ tick: TickFn,
48
+ stopped: Callable[[], bool],
49
+ flex: float = 1.0,
50
+ speed: float = 1.0,
51
+ ) -> None:
52
+ """Show the ghost and block until ``stopped()`` returns True.
53
+
54
+ Args:
55
+ sample: Returns (state_label, is_recording, level) each frame.
56
+ tick: App housekeeping to run each frame (idle timeout, fatal
57
+ checks); exceptions are swallowed so the UI never dies.
58
+ stopped: Polled each frame; True ends the loop and closes the
59
+ panel.
60
+ flex: Face shape-flex multiplier (config overlay_flex).
61
+ speed: Animation speed multiplier (config overlay_speed).
62
+
63
+ SIGINT is redirected to a flag-friendly handler while the loop
64
+ runs (AppKit's run loop would otherwise swallow Ctrl+C), and
65
+ restored afterwards.
66
+ """
67
+ import AppKit
68
+ import objc
69
+ from AppKit import (
70
+ NSAffineTransform,
71
+ NSApplication,
72
+ NSBackingStoreBuffered,
73
+ NSBezierPath,
74
+ NSColor,
75
+ NSGradient,
76
+ NSGraphicsContext,
77
+ NSMakeRect,
78
+ NSPanel,
79
+ NSScreen,
80
+ NSTimer,
81
+ )
82
+
83
+ # Plain helpers (NOT NSView methods — pyobjc would try to bridge
84
+ # underscored methods as selectors and reject the extra arguments).
85
+ def _mk_oval(cx, cy, w, h): # noqa: ANN001, ANN202
86
+ return NSBezierPath.bezierPathWithOvalInRect_(
87
+ NSMakeRect(cx - w / 2, cy - h / 2, w, h)
88
+ )
89
+
90
+ def _mk_ghost(cx, cy, gw, jelly, ph): # noqa: ANN001, ANN202
91
+ """Ghost silhouette (domed head, wavy hem) with a soft jelly flex.
92
+
93
+ Built from points so a gentle, slow, spatially-coherent
94
+ displacement can wobble the whole outline — the head is
95
+ non-rigid (flexes shape), but the amplitude is small and smooth
96
+ so it never turns into a lumpy blob. ``jelly`` is the wobble
97
+ amplitude in points; ``ph`` the slow master phase.
98
+ """
99
+ import math
100
+
101
+ dome_y = cy + gw * 0.55
102
+ hem_y = cy - gw * 0.95
103
+ pts = [(cx + gw, hem_y), (cx + gw, dome_y)]
104
+ nd = 26
105
+ for i in range(1, nd):
106
+ a = math.pi * i / nd
107
+ pts.append((cx + gw * math.cos(a), dome_y + gw * math.sin(a)))
108
+ pts.append((cx - gw, dome_y))
109
+ pts.append((cx - gw, hem_y))
110
+ segs, bumps, ha = 30, 3, gw * 0.16
111
+ for i in range(1, segs + 1):
112
+ u = i / segs
113
+ x = cx - gw + u * 2 * gw
114
+ pts.append((x, hem_y - ha * math.sin(u * math.pi * bumps)))
115
+
116
+ p = NSBezierPath.bezierPath()
117
+ for i, (x, y) in enumerate(pts):
118
+ dx = jelly * math.sin(0.028 * y + ph * 0.8)
119
+ dy = jelly * math.sin(0.028 * x - ph * 0.6)
120
+ pt = (x + dx, y + dy)
121
+ if i == 0:
122
+ p.moveToPoint_(pt)
123
+ else:
124
+ p.lineToPoint_(pt)
125
+ p.closePath()
126
+ return p
127
+
128
+ def _mk_mouth(mx, lip_y, gw, open_, ph): # noqa: ANN001, ANN202
129
+ """An organic mouth-hole that opens downward (jaw) and morphs.
130
+
131
+ Not a scaling oval: the rim is perturbed by slow low harmonics
132
+ so it wobbles/changes shape, it opens mostly DOWNWARD from a
133
+ fixed upper-lip line (like a dropping jaw), and it stays a thin
134
+ slit at rest. ``open_`` is 0..1; ``ph`` is the slow master phase.
135
+ """
136
+ import math
137
+
138
+ rx = gw * (0.36 + 0.08 * open_)
139
+ ry = gw * (0.05 + 0.34 * open_)
140
+ cy = lip_y - ry * 0.6 # grows downward from the lip line
141
+ # Constrained flex: two gentle low harmonics at small amplitude
142
+ # so the mouth has organic life without becoming a lumpy blob.
143
+ # (It is also clipped to the face by the caller, so it can never
144
+ # spill past the ghost's silhouette.)
145
+ deform = 0.04 + 0.06 * open_
146
+ n = 44
147
+ p = NSBezierPath.bezierPath()
148
+ for i in range(n + 1):
149
+ a = 2.0 * math.pi * i / n
150
+ wob = 1.0 + deform * (
151
+ math.sin(2 * a + ph * 0.5)
152
+ + 0.4 * math.sin(3 * a - ph * 0.4)
153
+ )
154
+ x = mx + rx * wob * math.cos(a)
155
+ y = cy + ry * wob * math.sin(a)
156
+ if i == 0:
157
+ p.moveToPoint_((x, y))
158
+ else:
159
+ p.lineToPoint_((x, y))
160
+ p.closePath()
161
+ return p
162
+
163
+ class WaveView(AppKit.NSView): # noqa: D401
164
+ """A little glowing-blue ghost whose mouth opens with your voice.
165
+
166
+ A friendly translucent ghost floats at the bottom of the screen:
167
+ it bobs and blinks on its own so it feels alive, and its mouth
168
+ opens wider the louder you speak (driven by the live,
169
+ gain-adjusted mic level) -- the Animoji-style talking face that
170
+ makes the tool fun to use.
171
+ """
172
+
173
+ def initWithFrame_(self, frame): # noqa: ANN001, ANN201, N802
174
+ self = objc.super(WaveView, self).initWithFrame_(frame)
175
+ if self is None:
176
+ return None
177
+ self.amp = 0.0 # smoothed level -> mouth opening
178
+ self.phase = 0.0
179
+ self.frame_no = 0
180
+ self.recording = False
181
+ return self
182
+
183
+ def push_(self, sample): # noqa: ANN001, ANN201, N802
184
+ _label, recording, level = sample
185
+ level = max(0.0, min(1.0, float(level)))
186
+ # Gentle attack, slow release so the mouth and motion change
187
+ # gradually rather than twitching frame-to-frame.
188
+ k = 0.4 if level > self.amp else 0.10
189
+ self.amp += k * (level - self.amp)
190
+ self.phase += _PHASE_SPEED * speed
191
+ self.frame_no += 1
192
+ self.recording = bool(recording)
193
+ self.setNeedsDisplay_(True)
194
+
195
+ def drawRect_(self, rect): # noqa: ANN001, ANN201, N802
196
+ import math
197
+
198
+ b = self.bounds()
199
+ cx, cy = b.size.width / 2, b.size.height / 2
200
+ gw = min(b.size.width, b.size.height) * 0.22
201
+ amp = self.amp
202
+ ph = self.phase
203
+
204
+ # Slow, gentle motion — a gradual drift, not a vibration.
205
+ # Bob and sway run at DIFFERENT slow frequencies so the
206
+ # ghost describes a lazy figure-eight rather than a rigid
207
+ # vertical bounce; both grow only mildly with volume.
208
+ bob = (3.0 + 3.0 * amp) * math.sin(ph * 0.9)
209
+ sway = (3.0 + 4.5 * amp) * math.sin(ph * 0.6 + 0.8)
210
+ dcx, dcy = cx + sway, cy + bob
211
+ # The whole face gently breathes and EXPANDS with volume
212
+ # (uniform scale — grows/shrinks, no shape distortion), plus
213
+ # a soft left/right lean. That is the head's audio reaction.
214
+ scale = (1.0 + 0.03 * math.sin(ph * 0.7)) * (1.0 + 0.11 * amp)
215
+ sx = sy = scale
216
+ lean = (2.5 + 3.0 * amp) * math.sin(ph * 0.45) # degrees
217
+
218
+ NSGraphicsContext.currentContext().saveGraphicsState()
219
+ t = NSAffineTransform.transform()
220
+ t.translateXBy_yBy_(dcx, dcy)
221
+ t.rotateByDegrees_(lean)
222
+ t.scaleXBy_yBy_(sx, sy)
223
+ t.translateXBy_yBy_(-dcx, -dcy)
224
+ t.concat()
225
+ try:
226
+ # Slow, spatially-coherent jelly flex so the head is
227
+ # visibly non-rigid; more when talking, and scaled by the
228
+ # user's overlay_flex.
229
+ jelly = (2.6 + 5.0 * amp) * flex
230
+
231
+ # soft halo for presence (flexes WITH the body)
232
+ halo = _mk_ghost(dcx, dcy, gw * 1.10, jelly, ph)
233
+ NSColor.colorWithCalibratedRed_green_blue_alpha_(
234
+ 0.30, 0.62, 1.0, 0.20
235
+ ).setFill()
236
+ halo.fill()
237
+
238
+ # body: bluish radial gradient + bright rim
239
+ body = _mk_ghost(dcx, dcy, gw, jelly, ph)
240
+ core = NSColor.colorWithCalibratedRed_green_blue_alpha_(
241
+ 0.62, 0.86, 1.0, 0.88
242
+ )
243
+ edge = NSColor.colorWithCalibratedRed_green_blue_alpha_(
244
+ 0.16, 0.44, 0.98, 0.74
245
+ )
246
+ grad = (
247
+ NSGradient.alloc().initWithStartingColor_endingColor_(
248
+ core, edge
249
+ )
250
+ )
251
+ if grad is not None:
252
+ grad.drawInBezierPath_relativeCenterPosition_(
253
+ body, (0.0, 0.35)
254
+ )
255
+ else: # pragma: no cover
256
+ edge.setFill()
257
+ body.fill()
258
+ NSColor.colorWithCalibratedRed_green_blue_alpha_(
259
+ 0.80, 0.94, 1.0, 0.9
260
+ ).setStroke()
261
+ body.setLineWidth_(2.0)
262
+ body.stroke()
263
+
264
+ # --- face features, CLIPPED to the body so nothing (the
265
+ # mouth in particular) can ever spill past the silhouette
266
+ gc = NSGraphicsContext.currentContext()
267
+ gc.saveGraphicsState()
268
+ body.addClip()
269
+ dark = NSColor.colorWithCalibratedRed_green_blue_alpha_(
270
+ 0.05, 0.10, 0.26, 0.92
271
+ )
272
+ dark.setFill()
273
+ eye_y = dcy + gw * 0.45
274
+ eye_dx = gw * 0.42
275
+ blinking = (
276
+ self.frame_no % _BLINK_EVERY
277
+ ) < _BLINK_FRAMES
278
+ eye_w = gw * 0.26
279
+ eye_h = gw * 0.06 if blinking else gw * 0.36
280
+ for s in (-1, 1):
281
+ _mk_oval(
282
+ dcx + s * eye_dx, eye_y, eye_w, eye_h
283
+ ).fill()
284
+ _mk_oval(dcx, dcy + gw * 0.05, gw * 0.10, gw * 0.12).fill()
285
+ lip_y = dcy - gw * 0.18
286
+ _mk_mouth(dcx, lip_y, gw, amp, ph).fill()
287
+ gc.restoreGraphicsState()
288
+ finally:
289
+ NSGraphicsContext.currentContext().restoreGraphicsState()
290
+
291
+ class Driver(AppKit.NSObject):
292
+ """NSTimer target: samples audio, ticks the app, ends the loop.
293
+
294
+ The pill is shown ONLY while recording — its mere presence is
295
+ the "you left the mic on" indicator; absence means not
296
+ listening. (A color change alone proved too subtle.)
297
+ """
298
+
299
+ def initWithView_panel_(self, view, panel): # noqa: ANN001, ANN201, N802, E501
300
+ self = objc.super(Driver, self).init()
301
+ if self is None:
302
+ return None
303
+ self.view = view
304
+ self.panel = panel
305
+ self.shown = False
306
+ return self
307
+
308
+ def tick_(self, _timer): # noqa: ANN001, ANN201, N802
309
+ try:
310
+ tick()
311
+ except Exception:
312
+ pass
313
+ try:
314
+ data = sample()
315
+ recording = bool(data[1])
316
+ if recording != self.shown:
317
+ if recording:
318
+ self.panel.orderFrontRegardless()
319
+ else:
320
+ self.panel.orderOut_(None)
321
+ self.shown = recording
322
+ if recording:
323
+ self.view.push_(data)
324
+ except Exception:
325
+ pass
326
+ if stopped():
327
+ NSApplication.sharedApplication().stop_(None)
328
+ # stop_() only takes effect once an event is processed;
329
+ # post a no-op event so the run loop wakes immediately.
330
+ evt = AppKit.NSEvent.otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2_( # noqa: E501
331
+ AppKit.NSEventTypeApplicationDefined,
332
+ (0, 0), 0, 0.0, 0, None, 0, 0, 0,
333
+ )
334
+ NSApplication.sharedApplication().postEvent_atStart_(
335
+ evt, True
336
+ )
337
+
338
+ app = NSApplication.sharedApplication()
339
+ # Accessory: no Dock icon, no menu bar, but windows still display.
340
+ app.setActivationPolicy_(
341
+ AppKit.NSApplicationActivationPolicyAccessory
342
+ )
343
+
344
+ screen = NSScreen.mainScreen()
345
+ sframe = screen.frame() if screen is not None else NSMakeRect(
346
+ 0, 0, 1440, 900
347
+ )
348
+ x = sframe.origin.x + (sframe.size.width - _WIDTH) / 2
349
+ y = sframe.origin.y + _MARGIN_BOTTOM
350
+ panel = NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
351
+ NSMakeRect(x, y, _WIDTH, _HEIGHT),
352
+ AppKit.NSWindowStyleMaskBorderless
353
+ | AppKit.NSWindowStyleMaskNonactivatingPanel,
354
+ NSBackingStoreBuffered,
355
+ False,
356
+ )
357
+ panel.setLevel_(AppKit.NSStatusWindowLevel)
358
+ panel.setOpaque_(False)
359
+ panel.setBackgroundColor_(NSColor.clearColor())
360
+ panel.setIgnoresMouseEvents_(True)
361
+ panel.setHasShadow_(True)
362
+ panel.setCollectionBehavior_(
363
+ AppKit.NSWindowCollectionBehaviorCanJoinAllSpaces
364
+ | AppKit.NSWindowCollectionBehaviorStationary
365
+ )
366
+ view = WaveView.alloc().initWithFrame_(
367
+ NSMakeRect(0, 0, _WIDTH, _HEIGHT)
368
+ )
369
+ panel.setContentView_(view)
370
+ # Hidden at startup; the driver shows it only while recording.
371
+
372
+ driver = Driver.alloc().initWithView_panel_(view, panel)
373
+ timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( # noqa: E501
374
+ _TICK_SECONDS, driver, b"tick:", None, True
375
+ )
376
+ AppKit.NSRunLoop.currentRunLoop().addTimer_forMode_(
377
+ timer, AppKit.NSRunLoopCommonModes
378
+ )
379
+
380
+ # Ctrl+C: AppKit's run loop doesn't deliver KeyboardInterrupt on
381
+ # its own. Record the signal in a flag the timer polls (the timer
382
+ # firing is what lets the Python-level handler run at all), end
383
+ # the loop, and re-raise KeyboardInterrupt for the caller.
384
+ sigint = {"hit": False}
385
+ caller_stopped = stopped
386
+
387
+ def stopped() -> bool: # noqa: ANN202
388
+ return sigint["hit"] or caller_stopped()
389
+
390
+ previous_handler = signal.getsignal(signal.SIGINT)
391
+ signal.signal(
392
+ signal.SIGINT,
393
+ lambda _sig, _frame: sigint.__setitem__("hit", True),
394
+ )
395
+ try:
396
+ app.run()
397
+ finally:
398
+ signal.signal(signal.SIGINT, previous_handler)
399
+ timer.invalidate()
400
+ panel.orderOut_(None)
401
+ if sigint["hit"]:
402
+ raise KeyboardInterrupt