claudetan 1.0.0__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.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.3
2
+ Name: claudetan
3
+ Version: 1.0.0
4
+ Summary: Voice-driven Claude Code assistant with an anime avatar overlay
5
+ Keywords: claude,claude-code,voice-assistant,avatar,overlay,whisper,pyside6
6
+ Author: Stoupy51
7
+ Author-email: Stoupy51 <stoupy51@gmail.com>
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Typing :: Typed
13
+ Requires-Dist: faster-whisper>=1.2
14
+ Requires-Dist: numpy
15
+ Requires-Dist: pyside6>=6.9
16
+ Requires-Dist: pyte>=0.8.2
17
+ Requires-Dist: pywinpty>=2.0
18
+ Requires-Dist: sounddevice
19
+ Requires-Dist: soundfile
20
+ Requires-Dist: stouputils>=26.10.1
21
+ Requires-Python: >=3.14
22
+ Project-URL: Issues, https://github.com/Stoupy51/ClaudeTan/issues
23
+ Project-URL: Repository, https://github.com/Stoupy51/ClaudeTan
File without changes
@@ -0,0 +1,4 @@
1
+ from claudetan.app import main
2
+
3
+ main()
4
+
@@ -0,0 +1,310 @@
1
+ """ ClaudeTan: Claude-tan reacting to what she hears and to the hidden Claude Code session, through its hook events. """
2
+ # Imports
3
+ import logging
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import threading
8
+ from collections import deque
9
+ from pathlib import Path
10
+ from urllib.parse import urlparse
11
+
12
+ import stouputils as stp
13
+ from PySide6.QtCore import QObject, QPoint, QTimer, Signal
14
+ from PySide6.QtNetwork import QLocalServer, QLocalSocket
15
+ from PySide6.QtWidgets import QApplication
16
+
17
+ from claudetan.avatar import Avatar, Pill, chime, shorten
18
+ from claudetan.config import APP_DATA, LOG_FILE, Config
19
+ from claudetan.console import enable_colors
20
+ from claudetan.session import HookServer, JsonDict, Session
21
+ from claudetan.tray import Tray
22
+ from claudetan.voice import FOLLOW_UP_S, Listener, microphone
23
+
24
+ # Constants
25
+ LOG: logging.Logger = logging.getLogger("claudetan")
26
+ """ The app's own log, kept at INFO while the libraries stay at WARNING. """
27
+ WAKE_NOTES: tuple[float, ...] = (880.0, 1318.5)
28
+ DONE_NOTES: tuple[float, ...] = (1046.5, 1318.5, 1568.0)
29
+ LONG_TASK_MS: int = 20_000
30
+ """ How long a turn runs before she shrinks to the pill. """
31
+ HEARD_MS: int = 4000
32
+ """ How long the sentence she understood stays up when the session does not start on it right away. """
33
+ STARTUP_S: float = 60.0
34
+ """ How long the session and Whisper may take to start before the app reports them as failed. """
35
+ INSTANCE_NAME: str = "ClaudeTan"
36
+ """ Local server of the running app: a new one connects to it to have it quit. """
37
+ READY: str = "ready\n"
38
+ """ Last line the background app writes to `main`, which returns on it: the venv's launcher keeps the pipe open after it. """
39
+
40
+
41
+ # Classes
42
+ class Assistant(QObject):
43
+ """ What Claude-tan shows at each step of a request, and when the next command may be typed into the session. """
44
+ send = Signal(str)
45
+ """ A command to type, emitted only between turns: keys typed while a dialog is open would answer it. """
46
+
47
+ def __init__(self, avatar: Avatar, pill: Pill) -> None:
48
+ super().__init__()
49
+ self.avatar = avatar
50
+ self.pill = pill
51
+ self.commands: deque[str] = deque()
52
+ self.busy = False
53
+ self.agents: set[str] = set()
54
+ self.asking = False
55
+ self.worked = False
56
+ """ Whether the turn used a tool, which makes her present the result rather than smile. """
57
+ self.activity = ""
58
+ self.message_id = ""
59
+ self.message = ""
60
+
61
+ self.long_task = QTimer(self)
62
+ self.long_task.setSingleShot(True)
63
+ self.long_task.setInterval(LONG_TASK_MS)
64
+ self.long_task.timeout.connect(self.shrink)
65
+ self.dismiss = QTimer(self)
66
+ self.dismiss.setSingleShot(True)
67
+ self.dismiss.timeout.connect(self.leave)
68
+
69
+ def on_wake(self) -> None:
70
+ chime(*WAKE_NOTES)
71
+ self.appear("hello", bubble="Oui ?")
72
+ self.dismiss.start(round(FOLLOW_UP_S * 1000))
73
+
74
+ def on_command(self, text: str) -> None:
75
+ LOG.info("Heard: %s", text)
76
+ self.appear("idle", bubble=f"« {text} »" + (" Je m'en occupe juste après." if self.busy else ""))
77
+ self.dismiss.start(HEARD_MS)
78
+ self.commands.append(text)
79
+ self.flush()
80
+
81
+ def on_hook(self, event: JsonDict) -> None:
82
+ match event["hook_event_name"]:
83
+ case "UserPromptSubmit":
84
+ self.on_prompt(event["prompt"])
85
+ case "PreToolUse":
86
+ self.on_tool(describe_tool(event["tool_name"], event["tool_input"]))
87
+ case "PostToolUse" if self.asking:
88
+ self.settle_permission()
89
+ self.appear("thanks", bubble="Merci !")
90
+ case "PermissionRequest":
91
+ self.on_permission(describe_tool(event["tool_name"], event["tool_input"]))
92
+ case "Notification" if event["notification_type"] == "permission_prompt":
93
+ self.on_permission(event["message"])
94
+ case "MessageDisplay":
95
+ self.on_text(event["message_id"], event["delta"], streaming=not event["final"])
96
+ case "SubagentStart":
97
+ self.agents.add(event["agent_id"])
98
+ self.shrink()
99
+ case "SubagentStop":
100
+ self.agents.discard(event["agent_id"])
101
+ case "Stop":
102
+ self.on_stop(event["last_assistant_message"])
103
+ case "StopFailure":
104
+ self.on_failure(event["error"])
105
+ case _:
106
+ pass
107
+
108
+ def on_prompt(self, prompt: str) -> None:
109
+ """ A turn starts, typed by the app, sent from the Remote Control, or started by Claude Code itself. """
110
+ LOG.info("Prompt: %s", prompt)
111
+ self.busy = True
112
+ self.worked = False
113
+ self.activity = ""
114
+ self.dismiss.stop()
115
+ self.long_task.start()
116
+ if not self.pill.isVisible():
117
+ self.appear("point", bubble=prompt)
118
+
119
+ def on_tool(self, activity: str) -> None:
120
+ LOG.info("Action: %s", activity)
121
+ self.worked = True
122
+ self.settle_permission()
123
+ if activity != self.activity and not self.pill.isVisible():
124
+ self.activity = activity
125
+ self.appear("point", bubble=activity)
126
+
127
+ def on_permission(self, request: str) -> None:
128
+ if self.asking:
129
+ return
130
+ LOG.info("Permission asked: %s", request)
131
+ self.asking = True
132
+ self.long_task.stop()
133
+ self.appear("please", bubble=f"J'ai besoin de ton accord : {request}. Clique sur moi pour répondre.")
134
+
135
+ def settle_permission(self) -> None:
136
+ """ Closes a pending permission prompt, allowed or not, and restarts the countdown to the pill. """
137
+ if self.asking:
138
+ self.asking = False
139
+ self.long_task.start()
140
+
141
+ def on_text(self, message_id: str, delta: str, *, streaming: bool) -> None:
142
+ self.message = self.message + delta if message_id == self.message_id else delta
143
+ self.message_id = message_id
144
+ if not self.pill.isVisible():
145
+ self.appear("idle", bubble=self.message, talking=streaming)
146
+
147
+ def on_stop(self, reply: str) -> None:
148
+ LOG.info("Reply: %s", reply)
149
+ self.end_turn()
150
+ if not self.agents:
151
+ chime(*DONE_NOTES)
152
+ self.appear("present" if self.worked else "happy", bubble=reply)
153
+ self.dismiss.start(reading_ms(reply))
154
+ self.flush()
155
+
156
+ def on_failure(self, error: str) -> None:
157
+ LOG.warning("Request failed: %s", error)
158
+ self.end_turn()
159
+ self.appear("surprised", bubble=f"Oups, la requête a échoué ({error}).")
160
+ self.dismiss.start(reading_ms(error))
161
+ self.flush()
162
+
163
+ def end_turn(self) -> None:
164
+ self.busy = False
165
+ self.asking = False
166
+ self.long_task.stop()
167
+
168
+ def flush(self) -> None:
169
+ if self.commands and not self.busy:
170
+ self.send.emit(self.commands.popleft())
171
+
172
+ def appear(self, expression: str, bubble: str, *, talking: bool = False) -> None:
173
+ self.pill.hide()
174
+ self.avatar.set_expression(expression, talking=talking, bubble=bubble)
175
+ self.avatar.pop_in()
176
+
177
+ def shrink(self) -> None:
178
+ self.avatar.pop_out()
179
+ self.pill.show_at(self.avatar.home() + QPoint(self.avatar.width(), self.avatar.height()))
180
+
181
+ def leave(self) -> None:
182
+ """ Out of the way once nothing needs her: the pill while work goes on, hidden otherwise. """
183
+ if self.busy or self.agents:
184
+ self.shrink()
185
+ return
186
+ self.pill.hide()
187
+ self.avatar.pop_out()
188
+
189
+
190
+ # Functions
191
+ def describe_tool(name: str, tool_input: JsonDict) -> str:
192
+ """ What a tool call does, in a few words for the bubble.
193
+
194
+ >>> describe_tool("Edit", {"file_path": "D:/repo/claudetan/app.py", "old_string": "a", "new_string": "b"})
195
+ 'modifie app.py'
196
+ >>> describe_tool("Bash", {"command": "git status", "description": "Show working tree status"})
197
+ 'Show working tree status'
198
+ >>> describe_tool("mcp__claudetan__screenshot", {"screen": 2})
199
+ 'screenshot'
200
+ """
201
+ match name:
202
+ case "Read":
203
+ return f"lit {Path(tool_input['file_path']).name}"
204
+ case "Write":
205
+ return f"écrit {Path(tool_input['file_path']).name}"
206
+ case "Edit" | "MultiEdit":
207
+ return f"modifie {Path(tool_input['file_path']).name}"
208
+ case "Bash" | "PowerShell":
209
+ return tool_input.get("description") or f"lance {tool_input['command'][:60]}"
210
+ case "Grep" | "Glob":
211
+ return f"cherche {tool_input['pattern']}"
212
+ case "WebSearch":
213
+ return f"cherche « {tool_input['query']} » sur le web"
214
+ case "WebFetch":
215
+ return f"lit {urlparse(str(tool_input['url'])).netloc}"
216
+ case "Agent" | "Task":
217
+ return f"confie une tâche : {tool_input['description']}"
218
+ case "AskUserQuestion":
219
+ return tool_input["questions"][0]["question"]
220
+ case _:
221
+ return name.rsplit("__", 1)[-1]
222
+
223
+
224
+ def reading_ms(text: str) -> int:
225
+ """ How long a text stays up: 4 s, plus 50 ms per character the bubble shows, 20 s at most. """
226
+ return min(4000 + 50 * len(shorten(text)), 20_000)
227
+
228
+
229
+ def main() -> None:
230
+ """ Starts the app in the background and returns once it reports it is ready, so no console window stays open. """
231
+ enable_colors()
232
+ stp.progress("Starting ClaudeTan, which takes about 15 s...")
233
+ # A gui-script would not do: uv makes the venv's pythonw.exe a copy of the console python.exe, which opens a window
234
+ app = subprocess.Popen(
235
+ [sys.executable, "-m", "claudetan.app"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
236
+ text=True, creationflags=subprocess.CREATE_NO_WINDOW,
237
+ )
238
+ assert app.stdout
239
+ for line in app.stdout:
240
+ if line == READY:
241
+ stp.info('Say "Hey Claude, ..." to ask her something, and quit her from her tray icon.')
242
+ return
243
+ print(line, end="", flush=True)
244
+ stp.error(f"ClaudeTan stopped while starting, see {stp.CYAN}{LOG_FILE}")
245
+
246
+
247
+ def report_ready(session: Session, listener: Listener) -> None:
248
+ """ Tells `main`, through stdout, how the session and Whisper started. """
249
+ if session.started.wait(STARTUP_S):
250
+ stp.info(f"Claude Code session {'resumed' if session.id else 'started'}: {stp.CYAN}{session.url}")
251
+ stp.info(f"Plan usage: {session.plan_usage()}")
252
+ else:
253
+ stp.warning("Claude Code did not start Remote Control: run uv run claudetan-setup, then start the app again", file=sys.stdout)
254
+ if listener.loaded.wait(STARTUP_S):
255
+ stp.info("Whisper runs on the GPU")
256
+ else:
257
+ stp.warning("Whisper did not load", file=sys.stdout)
258
+ stp.info(f"Log: {stp.CYAN}{LOG_FILE}")
259
+ print(READY, end="", flush=True)
260
+
261
+ def replace_running_app() -> None:
262
+ """ Has the app already running quit, and waits until it has, so that starting the app again applies new code and settings. """
263
+ running = QLocalSocket()
264
+ running.connectToServer(INSTANCE_NAME)
265
+ if running.waitForConnected(1000):
266
+ running.waitForDisconnected(30_000)
267
+
268
+
269
+ def run() -> None:
270
+ APP_DATA.mkdir(parents=True, exist_ok=True)
271
+ logging.basicConfig(
272
+ filename=LOG_FILE, encoding="utf-8", level=logging.WARNING, format="%(asctime)s %(levelname)s %(message)s",
273
+ )
274
+ LOG.setLevel(logging.INFO)
275
+ # Without a console the default hooks print tracebacks nowhere
276
+ sys.excepthook = lambda _kind, exception, _trace: logging.critical("Uncaught error", exc_info=exception)
277
+ threading.excepthook = lambda hook: logging.critical("Uncaught error in %s", hook.thread, exc_info=hook.exc_value)
278
+ # Qt's event loop never hands control back to Python's KeyboardInterrupt, so let Ctrl+C end the process directly
279
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
280
+
281
+ app = QApplication(sys.argv)
282
+ app.setQuitOnLastWindowClosed(False)
283
+ replace_running_app()
284
+ instance = QLocalServer()
285
+ instance.listen(INSTANCE_NAME)
286
+ instance.newConnection.connect(app.quit)
287
+ config = Config.load()
288
+ avatar, pill = Avatar(), Pill()
289
+ assistant = Assistant(avatar, pill)
290
+ tray = Tray()
291
+ tray.show()
292
+ hooks = HookServer()
293
+ session = Session(hooks.url, config.prompt)
294
+ for clicked in (avatar.clicked, pill.clicked, tray.open_session):
295
+ clicked.connect(session.open)
296
+ hooks.posted.connect(session.on_hook)
297
+ hooks.posted.connect(assistant.on_hook)
298
+ assistant.send.connect(session.send)
299
+ app.aboutToQuit.connect(session.close)
300
+ listener = Listener(config.language, config.wake)
301
+ listener.woke.connect(assistant.on_wake)
302
+ listener.heard.connect(assistant.on_command)
303
+ threading.Thread(target=listener.run, args=(microphone(),), daemon=True).start()
304
+ report_ready(session, listener)
305
+ sys.exit(app.exec())
306
+
307
+
308
+ if __name__ == "__main__":
309
+ run()
310
+
@@ -0,0 +1,299 @@
1
+ """ Claude-tan on screen: the sprite overlay with her speech bubble, the pill she shrinks to during long tasks, and her chimes.
2
+
3
+ A click on her or on the pill opens the Remote Control session, a long press on her lets you drag her somewhere else.
4
+ """
5
+ # Imports
6
+ import random
7
+ import re
8
+ from pathlib import Path
9
+ from typing import override
10
+
11
+ import numpy as np
12
+ import sounddevice as sd
13
+ from PySide6.QtCore import (
14
+ QEasingCurve,
15
+ QParallelAnimationGroup,
16
+ QPoint,
17
+ QPropertyAnimation,
18
+ QRect,
19
+ QRectF,
20
+ QSettings,
21
+ Qt,
22
+ QTimer,
23
+ Signal,
24
+ )
25
+ from PySide6.QtGui import QColor, QFont, QGuiApplication, QHideEvent, QMouseEvent, QPainter, QPainterPath, QPaintEvent, QPen, QPixmap
26
+ from PySide6.QtWidgets import QWidget
27
+
28
+ from claudetan.config import ROOT
29
+
30
+ # Constants
31
+ ASSETS: Path = ROOT / "assets" / "claude"
32
+ SPRITE_HEIGHT: int = 400
33
+ """ Display height of the sprites, which all share one canvas with her eyes on the same pixels. """
34
+ BUBBLE_WIDTH: int = 250
35
+ BUBBLE_CHARS: int = 220
36
+ """ Longest text the bubble shows, the full reply staying readable in the session. """
37
+ SPRITE_TOP: int = 24
38
+ FACE_Y: float = 0.49
39
+ """ Vertical position of the face center in the sprites, as a fraction of the sprite height. """
40
+ FACE: QRect = QRect(300, 160, 240, 240)
41
+ """ Square around her face on the sprite canvas, cropped for the pill and the tray icon. """
42
+ PILL_SIZE: int = 64
43
+ ACCENT: QColor = QColor(217, 119, 87)
44
+ LONG_PRESS_MS: int = 200
45
+ CLICK_SLOP: int = 6
46
+ """ Pixels the cursor may travel during a press that still counts as a click. """
47
+ TALK_FRAMES: dict[str, tuple[str, ...]] = {
48
+ "idle": ("idle", "idle_talk_open", "idle_talk_o", "idle_talk_wide"),
49
+ "hello": ("hello", "hello_talk_open", "hello_talk_o", "hello_talk_wide"),
50
+ }
51
+ """ Mouth frames of the poses that can talk. """
52
+ CHIME_RATE: int = 44100
53
+
54
+
55
+ # Classes
56
+ class Avatar(QWidget):
57
+ clicked = Signal()
58
+
59
+ def __init__(self) -> None:
60
+ super().__init__(None, Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool)
61
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
62
+ self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
63
+ ratio = QGuiApplication.primaryScreen().devicePixelRatio()
64
+ self.sprites: dict[str, QPixmap] = {}
65
+ for path in ASSETS.glob("*.png"):
66
+ pixmap = QPixmap(str(path)).scaledToHeight(round(SPRITE_HEIGHT * ratio), Qt.TransformationMode.SmoothTransformation)
67
+ pixmap.setDevicePixelRatio(ratio)
68
+ self.sprites[path.stem] = pixmap
69
+ self.sprite_width = round(self.sprites["idle"].width() / ratio)
70
+ self.sprite_left = BUBBLE_WIDTH - round(0.3 * self.sprite_width)
71
+ self.resize(self.sprite_left + self.sprite_width, SPRITE_TOP + SPRITE_HEIGHT)
72
+
73
+ self.expression = "idle"
74
+ self.frame = "idle"
75
+ self.bubble = ""
76
+ self.press_offset: QPoint | None = None
77
+ self.press_origin = QPoint()
78
+ self.dragging = False
79
+ self.settings = QSettings("ClaudeTan", "overlay")
80
+
81
+ self.motion = QPropertyAnimation(self, b"pos")
82
+ self.fade = QPropertyAnimation(self, b"windowOpacity")
83
+ self.slide = QParallelAnimationGroup(self)
84
+ for animation in (self.motion, self.fade):
85
+ animation.setDuration(260)
86
+ animation.setEasingCurve(QEasingCurve.Type.OutCubic)
87
+ self.slide.addAnimation(animation)
88
+ self.slide.finished.connect(self.hide_if_faded)
89
+
90
+ self.long_press = QTimer(self)
91
+ self.long_press.setSingleShot(True)
92
+ self.long_press.setInterval(LONG_PRESS_MS)
93
+ self.long_press.timeout.connect(self.start_drag)
94
+ self.blink = QTimer(self)
95
+ self.blink.setSingleShot(True)
96
+ self.blink.timeout.connect(self.close_eyes)
97
+ self.mouth = QTimer(self)
98
+ self.mouth.setInterval(110)
99
+ self.mouth.timeout.connect(self.flap)
100
+ self.schedule_blink()
101
+
102
+ def set_expression(self, expression: str, *, talking: bool = False, bubble: str = "") -> None:
103
+ self.expression = expression
104
+ self.frame = expression
105
+ self.bubble = shorten(bubble)
106
+ if talking and expression in TALK_FRAMES:
107
+ self.mouth.start()
108
+ else:
109
+ self.mouth.stop()
110
+ self.update()
111
+
112
+ def pop_in(self) -> None:
113
+ if not self.isVisible():
114
+ self.setWindowOpacity(0)
115
+ self.move(self.home() + QPoint(0, 40))
116
+ self.show()
117
+ self.slide_to(self.home(), 1.0)
118
+
119
+ def pop_out(self) -> None:
120
+ if self.isVisible():
121
+ self.slide_to(self.home() + QPoint(0, 40), 0.0)
122
+
123
+ def home(self) -> QPoint:
124
+ """ Where she stands: where she was last dropped while that screen is plugged in, else the main screen's bottom-right corner.
125
+
126
+ The fallback is never saved, so she goes back to the other screen as soon as it returns.
127
+ """
128
+ saved = self.settings.value("pos")
129
+ if isinstance(saved, QPoint) and QGuiApplication.screenAt(saved + self.rect().center()):
130
+ return saved
131
+ area = QGuiApplication.primaryScreen().availableGeometry()
132
+ return QPoint(area.right() - self.width(), area.bottom() - self.height())
133
+
134
+ def slide_to(self, position: QPoint, opacity: float) -> None:
135
+ self.slide.stop()
136
+ self.motion.setEndValue(position)
137
+ self.fade.setEndValue(opacity)
138
+ self.slide.start()
139
+
140
+ def hide_if_faded(self) -> None:
141
+ if self.fade.endValue() == 0.0:
142
+ self.hide()
143
+
144
+ def schedule_blink(self) -> None:
145
+ self.blink.start(random.randint(2500, 6000))
146
+
147
+ def close_eyes(self) -> None:
148
+ if self.frame == "idle" and not self.mouth.isActive():
149
+ self.frame = "idle_blink"
150
+ self.update()
151
+ QTimer.singleShot(130, self.open_eyes)
152
+ self.schedule_blink()
153
+
154
+ def open_eyes(self) -> None:
155
+ if self.frame == "idle_blink":
156
+ self.frame = "idle"
157
+ self.update()
158
+
159
+ def flap(self) -> None:
160
+ self.frame = random.choice([f for f in TALK_FRAMES[self.expression] if f != self.frame])
161
+ self.update()
162
+
163
+ @override
164
+ def paintEvent(self, event: QPaintEvent) -> None:
165
+ painter = QPainter(self)
166
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
167
+ painter.drawPixmap(QPoint(self.sprite_left, SPRITE_TOP), self.sprites[self.frame])
168
+ if self.bubble:
169
+ self.paint_bubble(painter)
170
+
171
+ def paint_bubble(self, painter: QPainter) -> None:
172
+ painter.setFont(QFont("Segoe UI", 11))
173
+ text = painter.boundingRect(QRectF(0, 0, BUBBLE_WIDTH - 48, 400), Qt.TextFlag.TextWordWrap, self.bubble)
174
+ # Growing upward from just above her hands keeps the finger of `point` and the wave of `hello` in sight
175
+ bottom = SPRITE_TOP + FACE_Y * SPRITE_HEIGHT - 26
176
+ box = QRectF(8, max(4.0, bottom - text.height() - 24), BUBBLE_WIDTH - 20, text.height() + 24)
177
+ path = QPainterPath()
178
+ path.addRoundedRect(box, 14, 14)
179
+ tail = QPainterPath()
180
+ tail.moveTo(box.right() - 4, box.bottom() - 34)
181
+ tail.lineTo(box.right() + 18, box.bottom() - 22)
182
+ tail.lineTo(box.right() - 4, box.bottom() - 18)
183
+ painter.setPen(ACCENT)
184
+ painter.setBrush(QColor(255, 250, 244, 240))
185
+ painter.drawPath(path.united(tail))
186
+ painter.setPen(QColor(40, 30, 25))
187
+ painter.drawText(box.adjusted(14, 12, -14, -12), Qt.TextFlag.TextWordWrap, self.bubble)
188
+
189
+ @override
190
+ def mousePressEvent(self, event: QMouseEvent) -> None:
191
+ self.press_origin = event.globalPosition().toPoint()
192
+ self.press_offset = self.press_origin - self.pos()
193
+ self.long_press.start()
194
+
195
+ @override
196
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
197
+ cursor = event.globalPosition().toPoint()
198
+ if self.dragging and self.press_offset is not None:
199
+ self.move(cursor - self.press_offset)
200
+ elif self.long_press.isActive() and (cursor - self.press_origin).manhattanLength() > CLICK_SLOP:
201
+ self.long_press.stop()
202
+
203
+ @override
204
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
205
+ if self.long_press.isActive():
206
+ self.long_press.stop()
207
+ self.clicked.emit()
208
+ elif self.dragging:
209
+ self.dragging = False
210
+ self.unsetCursor()
211
+ self.settings.setValue("pos", self.pos())
212
+ self.press_offset = None
213
+
214
+ def start_drag(self) -> None:
215
+ self.dragging = True
216
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
217
+
218
+
219
+ class Pill(QWidget):
220
+ """ Her face in a disc ringed by a spinning arc, shown in her place while a long task runs. """
221
+ clicked = Signal()
222
+
223
+ def __init__(self) -> None:
224
+ super().__init__(None, Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool)
225
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
226
+ self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
227
+ self.resize(PILL_SIZE, PILL_SIZE)
228
+ self.face = face(PILL_SIZE)
229
+ self.angle = 0
230
+ self.spin = QTimer(self)
231
+ self.spin.setInterval(30)
232
+ self.spin.timeout.connect(self.turn)
233
+
234
+ def show_at(self, corner: QPoint) -> None:
235
+ """ Shows the pill with its bottom-right corner on `corner`. """
236
+ self.move(corner - QPoint(PILL_SIZE, PILL_SIZE))
237
+ self.show()
238
+ self.spin.start()
239
+
240
+ def turn(self) -> None:
241
+ self.angle = (self.angle - 8) % 360
242
+ self.update()
243
+
244
+ @override
245
+ def hideEvent(self, event: QHideEvent) -> None:
246
+ self.spin.stop()
247
+
248
+ @override
249
+ def paintEvent(self, event: QPaintEvent) -> None:
250
+ painter = QPainter(self)
251
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
252
+ ring = QRectF(4, 4, PILL_SIZE - 8, PILL_SIZE - 8)
253
+ disc = QPainterPath()
254
+ disc.addEllipse(ring)
255
+ painter.setClipPath(disc)
256
+ painter.drawPixmap(ring.toRect(), self.face)
257
+ painter.setClipping(False)
258
+ painter.setPen(QPen(QColor(255, 250, 244), 4))
259
+ painter.drawEllipse(ring)
260
+ painter.setPen(QPen(ACCENT, 4, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
261
+ painter.drawArc(ring, self.angle * 16, 110 * 16)
262
+
263
+ @override
264
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
265
+ self.clicked.emit()
266
+
267
+
268
+ # Functions
269
+ def face(size: int) -> QPixmap:
270
+ """ Her face cropped from `idle`, `size` device-independent pixels wide. """
271
+ ratio = QGuiApplication.primaryScreen().devicePixelRatio()
272
+ pixels = round(size * ratio)
273
+ cropped = QPixmap(str(ASSETS / "idle.png")).copy(FACE)
274
+ pixmap = cropped.scaled(pixels, pixels, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
275
+ pixmap.setDevicePixelRatio(ratio)
276
+ return pixmap
277
+
278
+
279
+ def shorten(text: str) -> str:
280
+ """ The text as the bubble shows it: Markdown marks dropped, whitespace collapsed, cut at a word past `BUBBLE_CHARS`.
281
+
282
+ >>> shorten("**Done**: edited `app.py`.\\n\\n- one\\n- two")
283
+ 'Done: edited app.py. one two'
284
+ >>> shorten("word " * 100)[-12:]
285
+ 'word word...'
286
+ """
287
+ plain = " ".join(re.sub(r"[*`#]|^\s*[-+] ", "", text, flags=re.MULTILINE).split())
288
+ return plain if len(plain) <= BUBBLE_CHARS else plain[:BUBBLE_CHARS].rsplit(" ", 1)[0] + "..."
289
+
290
+
291
+ def chime(*notes: float) -> None:
292
+ """ A short bell arpeggio of the given frequencies in Hz, played without blocking. """
293
+ t = np.arange(int(0.4 * CHIME_RATE)) / CHIME_RATE
294
+ step = int(0.08 * CHIME_RATE)
295
+ wave = np.zeros(step * (len(notes) - 1) + len(t), np.float32)
296
+ for i, note in enumerate(notes):
297
+ wave[i * step:i * step + len(t)] += np.sin(2 * np.pi * note * t) * np.exp(-8 * t)
298
+ sd.play(0.15 * wave, CHIME_RATE)
299
+
@@ -0,0 +1,41 @@
1
+ """ User settings: the defaults shipped next to this module, overridden by the keys set in the user's copy. """
2
+ # Imports
3
+ import os
4
+ import re
5
+ import shutil
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Self
10
+
11
+ # Constants
12
+ ROOT: Path = Path(__file__).resolve().parent.parent
13
+ """ The ClaudeTan repository, which the app runs from. """
14
+ APP_DATA: Path = Path(os.environ["APPDATA"]) / "ClaudeTan"
15
+ DEFAULTS: Path = Path(__file__).with_name("config.toml")
16
+ USER_CONFIG: Path = APP_DATA / "config.toml"
17
+ LOG_FILE: Path = APP_DATA / "claudetan.log"
18
+
19
+
20
+ # Classes
21
+ @dataclass(frozen=True)
22
+ class Config:
23
+ language: str | None
24
+ """ Whisper language code, None to detect it on each sentence. """
25
+ wake: re.Pattern[str]
26
+ prompt: str
27
+ """ The user's additions to the system prompt of the hidden session. """
28
+
29
+ @classmethod
30
+ def load(cls) -> Self:
31
+ """ The settings, after copying the defaults to `USER_CONFIG` on the first run so the user has a file to edit. """
32
+ if not USER_CONFIG.exists():
33
+ APP_DATA.mkdir(parents=True, exist_ok=True)
34
+ shutil.copyfile(DEFAULTS, USER_CONFIG)
35
+ values = tomllib.loads(DEFAULTS.read_text(encoding="utf-8")) | tomllib.loads(USER_CONFIG.read_text(encoding="utf-8"))
36
+ return cls(
37
+ language=None if values["language"] == "auto" else values["language"],
38
+ wake=re.compile(values["wake"], re.IGNORECASE),
39
+ prompt=values["prompt"],
40
+ )
41
+
@@ -0,0 +1,14 @@
1
+ # ClaudeTan settings. Quit the app from its tray icon and start it again to apply a change.
2
+
3
+ # Language of the speech recognition: "fr", "en", or "auto" to detect it on each sentence
4
+ language = "auto"
5
+
6
+ # Wake phrase, matched ignoring case at the start of each sentence as Whisper spells it ("Hé Claude" often comes out as "Et Claude").
7
+ # Its first group is the command said in the same breath.
8
+ wake = '^\W*(?:hey|hé|he|eh|et|dis|ok|okay)\W+(?:claude|clode|clod|clodo|clown)\b[\s,.!?]*(.*)'
9
+
10
+ # Your additions to the system prompt of the hidden Claude session, after what the app writes there itself:
11
+ # where ClaudeTan is installed and how it started the session. Write them between the two ''' lines, for example:
12
+ # Mes projets sont dans D:\advanced_desktop, mes captures d'écran dans D:\Captures.
13
+ prompt = '''
14
+ '''
@@ -0,0 +1,18 @@
1
+ """ The terminal the command-line entry points print to. """
2
+ # Imports
3
+ import ctypes
4
+
5
+ # Constants
6
+ STD_OUTPUT_HANDLE: int = -11
7
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING: int = 0x4
8
+
9
+
10
+ # Functions
11
+ def enable_colors() -> None:
12
+ """ Turns on color codes in the console, which Windows' classic console prints as text otherwise. """
13
+ kernel32 = ctypes.windll.kernel32
14
+ console = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
15
+ mode = ctypes.c_uint32()
16
+ if kernel32.GetConsoleMode(console, ctypes.byref(mode)):
17
+ kernel32.SetConsoleMode(console, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
18
+
@@ -0,0 +1,169 @@
1
+ """ The hidden Claude Code session: `claude --remote-control` in a pseudo terminal, and the hook events it posts to the app. """
2
+ # Imports
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ import uuid
10
+ import webbrowser
11
+ from functools import partial
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from pathlib import Path
14
+ from typing import Any, override
15
+
16
+ import pyte
17
+ from PySide6.QtCore import QObject, QTimer, Signal
18
+ from winpty import PtyProcess
19
+
20
+ from claudetan.config import APP_DATA, ROOT, USER_CONFIG
21
+
22
+ # Constants
23
+ type JsonDict = dict[str, Any]
24
+ SYSTEM_PROMPT: str = (
25
+ f"You are Claude-tan, the voice assistant of ClaudeTan, an app installed in {ROOT} that keeps its settings in {USER_CONFIG}.\n"
26
+ "ClaudeTan started this Claude Code session hidden in a pseudo-terminal, with Remote Control and the auto permission mode, "
27
+ "resumes it each time the app starts, and follows it through hooks to animate your avatar on the screen.\n"
28
+ "Prompts come from the user's voice, transcribed by Whisper, which can mishear words, "
29
+ "or from the Remote Control on claude.ai or in the Claude app on their iPhone.\n"
30
+ "Your replies appear in a speech bubble next to your avatar, which only shows the start of long ones.\n"
31
+ )
32
+ """ What the app tells the session about itself, before the user's additions from the config. """
33
+ HOOK_EVENTS: tuple[str, ...] = (
34
+ "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Notification", "MessageDisplay",
35
+ "SubagentStart", "SubagentStop", "Stop", "StopFailure",
36
+ )
37
+ SESSION_ID: Path = APP_DATA / "session_id"
38
+ """ The conversation the app resumes, saved once a hook reports it, so a session that never got a prompt is not resumed. """
39
+ SESSION_URL: re.Pattern[str] = re.compile(r"https://claude\.ai/code/session_\w+")
40
+ PLAN_USAGE: re.Pattern[str] = re.compile(
41
+ r"Current session\W+(?P<window>\d+)% used\s+Resets (?P<window_reset>[^)]*\)).*?"
42
+ r"Current week \(all models\)\W+(?P<week>\d+)% used\s+Resets (?P<week_reset>[^)]*\))",
43
+ re.DOTALL,
44
+ )
45
+ """ The two limits of the `/usage` panel, each followed by when it resets, such as "Resets Oct 12, 12am (Europe/Paris)". """
46
+ NESTED_SESSION_VARS: tuple[str, ...] = (
47
+ "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION",
48
+ "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_PID",
49
+ )
50
+ """ Set when the app runs inside another Claude Code session; the hosted `claude` refuses to start nested while they are present. """
51
+ PASTE_DELAY_MS: int = 400
52
+ """ Pause between the text and Enter, so the TUI takes the text as one paste before submitting it. """
53
+
54
+
55
+ # Classes
56
+ class HookServer(QObject):
57
+ """ Local endpoint for the session's `type: "http"` hooks, relaying each payload to the Qt thread. """
58
+ posted = Signal(dict)
59
+
60
+ def __init__(self) -> None:
61
+ super().__init__()
62
+ relay = self.posted
63
+
64
+ class Handler(BaseHTTPRequestHandler):
65
+ def do_POST(self) -> None:
66
+ relay.emit(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
67
+ self.send_response(200)
68
+ self.end_headers()
69
+
70
+ @override
71
+ def log_message(self, format: str, *args: Any) -> None:
72
+ pass
73
+
74
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
75
+ self.url = f"http://127.0.0.1:{server.server_port}"
76
+ threading.Thread(target=server.serve_forever, daemon=True).start()
77
+
78
+
79
+ class Session:
80
+ """ Claude Code started in the repo, resuming the saved conversation, with its hooks posting to `hooks_url`. """
81
+
82
+ def __init__(self, hooks_url: str, additions: str) -> None:
83
+ settings = APP_DATA / "session_settings.json"
84
+ hooks = {name: [{"hooks": [{"type": "http", "url": hooks_url, "timeout": 5}]}] for name in HOOK_EVENTS}
85
+ settings.write_text(json.dumps({"hooks": hooks}), encoding="utf-8")
86
+ prompt = APP_DATA / "session_prompt.md"
87
+ prompt.write_text(system_prompt(additions), encoding="utf-8")
88
+
89
+ self.id = SESSION_ID.read_text() if SESSION_ID.exists() else ""
90
+ resume = ["--resume", self.id] if self.id else ["--session-id", str(uuid.uuid4())]
91
+ claude = [
92
+ "claude", "--remote-control", "ClaudeTan", "--permission-mode", "auto",
93
+ "--settings", str(settings), "--append-system-prompt-file", str(prompt), *resume,
94
+ ]
95
+ # chcp 65001: without it the pseudo console feeds Claude Code accented letters in cp850
96
+ argv = ["cmd.exe", "/c", "chcp", "65001", ">nul", "&&", *claude]
97
+ env = {name: value for name, value in os.environ.items() if name not in NESTED_SESSION_VARS}
98
+ self.pty = PtyProcess.spawn(argv, cwd=str(ROOT), env=env, dimensions=(50, 200))
99
+ self.screen = pyte.Screen(200, 50)
100
+ """ What the terminal shows, rebuilt from its output: Claude Code only redraws the characters that change. """
101
+ self.screen_lock = threading.Lock()
102
+ self.url = "https://claude.ai/code"
103
+ self.started = threading.Event()
104
+ """ Set once Remote Control prints the session's URL, when the session is ready for prompts. """
105
+ threading.Thread(target=self.read_output, daemon=True).start()
106
+
107
+ def read_output(self) -> None:
108
+ """ Renders the terminal's output, read continuously so the session never blocks on a full pipe. """
109
+ stream = pyte.Stream(self.screen)
110
+ while self.pty.isalive():
111
+ try:
112
+ output = self.pty.read(4096)
113
+ except EOFError:
114
+ return
115
+ with self.screen_lock:
116
+ stream.feed(output)
117
+ if not self.started.is_set() and (match := SESSION_URL.search(self.text())):
118
+ self.url = match.group()
119
+ self.started.set()
120
+
121
+ def plan_usage(self) -> str:
122
+ """ The plan's limits as `/usage` shows them, read before any prompt since keys typed while its panel is open land in it. """
123
+ self.pty.write("/usage")
124
+ time.sleep(PASTE_DELAY_MS / 1000)
125
+ self.pty.write("\r")
126
+ deadline = time.monotonic() + 15
127
+ while not (match := PLAN_USAGE.search(self.text())) and time.monotonic() < deadline:
128
+ time.sleep(0.2)
129
+ self.pty.write("\x1b")
130
+ if not match:
131
+ return "unknown, /usage did not show it"
132
+ return (
133
+ f"{match['window']}% of the 5-hour window, resets {match['window_reset']}; "
134
+ f"{match['week']}% of the week, resets {match['week_reset']}"
135
+ )
136
+
137
+ def text(self) -> str:
138
+ with self.screen_lock:
139
+ return "\n".join(self.screen.display)
140
+
141
+ def on_hook(self, event: JsonDict) -> None:
142
+ if event["session_id"] != self.id:
143
+ self.id = event["session_id"]
144
+ SESSION_ID.write_text(self.id)
145
+
146
+ def send(self, prompt: str) -> None:
147
+ self.pty.write(prompt)
148
+ QTimer.singleShot(PASTE_DELAY_MS, partial(self.pty.write, "\r"))
149
+
150
+ def open(self) -> None:
151
+ webbrowser.open(self.url)
152
+
153
+ def close(self) -> None:
154
+ """ Kills Claude Code outright: on a clean exit, Remote Control archives the session, which drops it from claude.ai's list. """
155
+ kill = ["taskkill", "/F", "/T", "/PID", str(self.pty.pid)]
156
+ subprocess.run(kill, capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
157
+
158
+
159
+ # Functions
160
+ def system_prompt(additions: str) -> str:
161
+ """ Text for `--append-system-prompt-file`: `SYSTEM_PROMPT`, then the user's additions when there are any.
162
+
163
+ >>> system_prompt("\\n") == SYSTEM_PROMPT
164
+ True
165
+ >>> system_prompt("Mes jeux sont dans E:/Games.\\n").removeprefix(SYSTEM_PROMPT)
166
+ '\\nMes jeux sont dans E:/Games.'
167
+ """
168
+ return f"{SYSTEM_PROMPT}\n{additions.strip()}" if additions.strip() else SYSTEM_PROMPT
169
+
@@ -0,0 +1,51 @@
1
+ """ One-time setup, run with `uv run claudetan-setup`: prepares Whisper, then signs in to Claude Code and has it trust the repo. """
2
+ # Imports
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import stouputils as stp
10
+
11
+ from claudetan.config import ROOT
12
+ from claudetan.console import enable_colors
13
+ from claudetan.voice import RATE, load_whisper
14
+
15
+
16
+ # Functions
17
+ def main() -> None:
18
+ enable_colors()
19
+ stp.progress("Downloading Whisper (1.6 GB the first time) and running it once on the GPU...")
20
+ segments, _ = load_whisper().transcribe(np.zeros(RATE, np.float32), language="en")
21
+ list(segments)
22
+ stp.info("Whisper runs on the GPU")
23
+
24
+ if not shutil.which("claude"):
25
+ stp.warning(f"Install Claude Code, then run this setup again: {stp.CYAN}https://code.claude.com/docs/en/setup")
26
+ return
27
+ if not signed_in():
28
+ stp.progress("Claude Code opens its sign-in page: sign in with your Claude Pro or Max account.")
29
+ subprocess.run(["claude", "auth", "login"])
30
+ # The decision stays with the user: Claude Code shows its own dialog, and records the answer in a file it keeps rewriting
31
+ if not trusted():
32
+ stp.progress('Claude Code opens and asks whether you trust this folder: pick "Yes, I trust this folder", then type /exit')
33
+ subprocess.run(["claude"], cwd=ROOT)
34
+
35
+ if signed_in() and trusted():
36
+ stp.info(f"Claude Code is ready. Start the app with: {stp.CYAN}uv run claudetan")
37
+ else:
38
+ stp.warning("Claude Code is not signed in or does not trust this folder yet: run this setup again.")
39
+
40
+
41
+ def signed_in() -> bool:
42
+ """ Whether Claude Code is signed in with a Claude subscription, which the app needs instead of an API key. """
43
+ status = json.loads(subprocess.run(["claude", "auth", "status"], capture_output=True, text=True).stdout)
44
+ return status.get("authMethod") == "claude.ai"
45
+
46
+
47
+ def trusted() -> bool:
48
+ """ Whether the user has told Claude Code it may work in this repository. """
49
+ projects = json.loads((Path.home() / ".claude.json").read_text(encoding="utf-8")).get("projects", {})
50
+ return bool(projects.get(ROOT.as_posix(), {}).get("hasTrustDialogAccepted"))
51
+
@@ -0,0 +1,65 @@
1
+ """ The tray icon: its menu, and starting the app with Windows. """
2
+ # Imports
3
+ import os
4
+ import sys
5
+ import winreg
6
+ from pathlib import Path
7
+
8
+ from PySide6.QtCore import Signal
9
+ from PySide6.QtGui import QCursor, QIcon
10
+ from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
11
+
12
+ from claudetan.avatar import face
13
+ from claudetan.config import APP_DATA, LOG_FILE, USER_CONFIG
14
+
15
+ # Constants
16
+ RUN_KEY: str = r"Software\Microsoft\Windows\CurrentVersion\Run"
17
+ """ Registry key, under HKEY_CURRENT_USER, of the programs Windows starts at logon. """
18
+ LAUNCHER: Path = Path(sys.prefix) / "Scripts" / "claudetan.exe"
19
+ """ Entry point that `uv sync` installs in the project environment, which starts the app in the background. """
20
+
21
+
22
+ # Classes
23
+ class Tray(QSystemTrayIcon):
24
+ open_session = Signal()
25
+
26
+ def __init__(self) -> None:
27
+ super().__init__(QIcon(face(64)))
28
+ self.setToolTip("Claude-tan")
29
+ self.menu = QMenu()
30
+ self.menu.addAction("Ouvrir la session", self.open_session.emit)
31
+ self.menu.addAction("Ouvrir la config", lambda: os.startfile(USER_CONFIG))
32
+ self.menu.addAction("Voir le journal", lambda: os.startfile(LOG_FILE))
33
+ self.menu.addAction("Ouvrir le dossier", lambda: os.startfile(APP_DATA))
34
+ startup = self.menu.addAction("Démarrer avec Windows")
35
+ startup.setCheckable(True)
36
+ startup.setChecked(starts_with_windows())
37
+ startup.toggled.connect(start_with_windows)
38
+ self.menu.addSeparator()
39
+ self.menu.addAction("Quitter", QApplication.quit)
40
+ self.setContextMenu(self.menu)
41
+ self.activated.connect(self.on_activated)
42
+
43
+ def on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
44
+ """ Opens the menu on a left click too, Windows only showing it on a right click. """
45
+ if reason == QSystemTrayIcon.ActivationReason.Trigger:
46
+ self.menu.popup(QCursor.pos())
47
+
48
+
49
+ # Functions
50
+ def starts_with_windows() -> bool:
51
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
52
+ try:
53
+ winreg.QueryValueEx(key, "ClaudeTan")
54
+ except FileNotFoundError:
55
+ return False
56
+ return True
57
+
58
+
59
+ def start_with_windows(enabled: bool) -> None:
60
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, access=winreg.KEY_SET_VALUE) as key:
61
+ if enabled:
62
+ winreg.SetValueEx(key, "ClaudeTan", 0, winreg.REG_SZ, f'"{LAUNCHER}"')
63
+ else:
64
+ winreg.DeleteValue(key, "ClaudeTan")
65
+
@@ -0,0 +1,108 @@
1
+ """ The microphone, cut into sentences by Silero VAD and transcribed by Whisper, reduced to the commands spoken to her. """
2
+ # Imports
3
+ import queue
4
+ import re
5
+ import threading
6
+ import time
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+ import sounddevice as sd
12
+ from faster_whisper import WhisperModel
13
+ from faster_whisper.vad import get_vad_model
14
+ from PySide6.QtCore import QObject, Signal
15
+
16
+ # Constants
17
+ type Audio = np.ndarray[tuple[int], np.dtype[np.float32]]
18
+ RATE: int = 16000
19
+ FRAME: int = 512
20
+ """ Samples per VAD step, the window Silero expects at 16 kHz (32 ms). """
21
+ EARLY_CHECK_FRAMES: int = 40
22
+ """ Length (1.3 s) at which a sentence still being spoken is transcribed once, so she reacts to the wake phrase before it ends. """
23
+ FOLLOW_UP_S: float = 8.0
24
+ """ How long a lone wake phrase keeps her listening for the command. """
25
+
26
+
27
+ # Classes
28
+ @dataclass(frozen=True)
29
+ class Speech:
30
+ audio: Audio
31
+ ended: bool
32
+ """ False for the opening of a sentence still being spoken. """
33
+
34
+
35
+ class Listener(QObject):
36
+ """ Runs the microphone pipeline on its own thread and reports to the Qt thread through its signals. """
37
+ woke = Signal()
38
+ heard = Signal(str)
39
+ """ A command, said after the wake phrase or alone within `FOLLOW_UP_S` of it. """
40
+
41
+ def __init__(self, language: str | None, wake: re.Pattern[str]) -> None:
42
+ super().__init__()
43
+ self.language = language
44
+ self.wake = wake
45
+ self.loaded = threading.Event()
46
+
47
+ def run(self, frames: Iterator[Audio]) -> None:
48
+ whisper = load_whisper()
49
+ self.loaded.set()
50
+ awake_until = 0.0
51
+ for speech in utterances(frames):
52
+ segments, _ = whisper.transcribe(speech.audio, language=self.language, beam_size=1, condition_on_previous_text=False)
53
+ text = " ".join(segment.text.strip() for segment in segments)
54
+ match = self.wake.match(text)
55
+ if match:
56
+ if time.monotonic() > awake_until:
57
+ self.woke.emit()
58
+ awake_until = time.monotonic() + FOLLOW_UP_S
59
+
60
+ command = match.group(1) if match else text
61
+ if speech.ended and command and time.monotonic() < awake_until:
62
+ self.heard.emit(command)
63
+ awake_until = 0.0
64
+
65
+
66
+ # Functions
67
+ def load_whisper() -> WhisperModel:
68
+ """ Whisper on the GPU, downloaded from Hugging Face on the first call (1.6 GB). """
69
+ return WhisperModel("large-v3-turbo", device="cuda", compute_type="int8_float16")
70
+
71
+
72
+ def microphone() -> Iterator[Audio]:
73
+ frames: queue.Queue[Audio] = queue.Queue()
74
+ with sd.InputStream(
75
+ samplerate=RATE, channels=1, dtype="float32", blocksize=FRAME,
76
+ callback=lambda data, _count, _time, _status: frames.put(data[:, 0].copy()),
77
+ ):
78
+ while True:
79
+ yield frames.get()
80
+
81
+
82
+ def utterances(frames: Iterator[Audio]) -> Iterator[Speech]:
83
+ """ Sentences cut out of the frames: one starts after 250 ms above the threshold and ends after 800 ms of silence or 15 s.
84
+
85
+ Each sentence reaching `EARLY_CHECK_FRAMES` is also yielded once before it ends.
86
+ """
87
+ vad = get_vad_model()
88
+ window: list[Audio] = []
89
+ speech: list[Audio] = []
90
+ voiced = silent = 0
91
+ for frame in frames:
92
+ window = [*window[-15:], frame]
93
+ probability = float(vad(np.concatenate(window))[-1])
94
+ voiced = voiced + 1 if probability > 0.5 else 0
95
+ silent = silent + 1 if probability < 0.35 else 0
96
+ if speech:
97
+ speech.append(frame)
98
+ elif voiced >= 8:
99
+ speech = list(window)
100
+ else:
101
+ continue
102
+
103
+ ended = silent >= 25 or len(speech) >= 15 * RATE // FRAME
104
+ if ended or len(speech) == EARLY_CHECK_FRAMES:
105
+ yield Speech(audio=np.concatenate(speech), ended=ended)
106
+ if ended:
107
+ speech = []
108
+
@@ -0,0 +1,114 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.0,<0.13.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [tool.uv.build-backend]
6
+ module-root = ""
7
+
8
+ [tool.pyright]
9
+ typeCheckingMode = "strict"
10
+ exclude = ["tools/sprites"]
11
+
12
+ [tool.complexipy]
13
+ paths = ["."]
14
+ max-complexity-allowed = 15
15
+ sort = "desc"
16
+
17
+ [tool.ruff]
18
+ exclude = [
19
+ ".git",
20
+ ".ruff_cache",
21
+ ".venv",
22
+ ".vscode",
23
+ "build",
24
+ "dist",
25
+ "*.pyi",
26
+ ]
27
+ line-length = 135
28
+ src = [
29
+ ".",
30
+ "tools/sprites",
31
+ ]
32
+
33
+ [tool.ruff.lint]
34
+ select = [
35
+ "E",
36
+ "W",
37
+ "F",
38
+ "I",
39
+ "N",
40
+ "UP",
41
+ "B",
42
+ "C4",
43
+ "T20",
44
+ "RUF",
45
+ "RET",
46
+ "SIM",
47
+ "PIE",
48
+ "PERF401",
49
+ "PGH003",
50
+ "FBT003",
51
+ "TRY203",
52
+ "S110",
53
+ "PLR5501",
54
+ ]
55
+ ignore = [
56
+ "T201",
57
+ "W191",
58
+ "N803",
59
+ "N806",
60
+ ]
61
+
62
+ [tool.ruff.lint.isort]
63
+ combine-as-imports = true
64
+
65
+ [project]
66
+ name = "claudetan"
67
+ version = "1.0.0"
68
+ description = "Voice-driven Claude Code assistant with an anime avatar overlay"
69
+ keywords = [
70
+ "claude",
71
+ "claude-code",
72
+ "voice-assistant",
73
+ "avatar",
74
+ "overlay",
75
+ "whisper",
76
+ "pyside6",
77
+ ]
78
+ requires-python = ">=3.14"
79
+ classifiers = [
80
+ "Development Status :: 2 - Pre-Alpha",
81
+ "Programming Language :: Python :: 3",
82
+ "Programming Language :: Python :: 3.14",
83
+ "Operating System :: Microsoft :: Windows",
84
+ "Typing :: Typed",
85
+ ]
86
+ dependencies = [
87
+ "faster-whisper>=1.2",
88
+ "numpy",
89
+ "pyside6>=6.9",
90
+ "pyte>=0.8.2",
91
+ "pywinpty>=2.0",
92
+ "sounddevice",
93
+ "soundfile",
94
+ "stouputils>=26.10.1",
95
+ ]
96
+
97
+ [[project.authors]]
98
+ name = "Stoupy51"
99
+ email = "stoupy51@gmail.com"
100
+
101
+ [project.scripts]
102
+ claudetan = "claudetan.app:main"
103
+ claudetan-setup = "claudetan.setup:main"
104
+
105
+ [project.urls]
106
+ Issues = "https://github.com/Stoupy51/ClaudeTan/issues"
107
+ Repository = "https://github.com/Stoupy51/ClaudeTan"
108
+
109
+ [dependency-groups]
110
+ dev = [
111
+ "pyright",
112
+ "pytest",
113
+ "ruff",
114
+ ]
@@ -0,0 +1,106 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.0,<0.13.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [tool.uv.build-backend]
6
+ module-root = ""
7
+
8
+ [project]
9
+ name = "claudetan"
10
+ version = "1.0.0"
11
+ description = "Voice-driven Claude Code assistant with an anime avatar overlay"
12
+ keywords = ["claude", "claude-code", "voice-assistant", "avatar", "overlay", "whisper", "pyside6"]
13
+ requires-python = ">=3.14"
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Operating System :: Microsoft :: Windows",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "faster-whisper>=1.2",
23
+ "numpy",
24
+ "pyside6>=6.9",
25
+ "pyte>=0.8.2",
26
+ "pywinpty>=2.0",
27
+ "sounddevice",
28
+ "soundfile",
29
+ "stouputils>=26.10.1",
30
+ ]
31
+ authors = [
32
+ { name = "Stoupy51", email = "stoupy51@gmail.com" },
33
+ ]
34
+
35
+ [project.scripts]
36
+ claudetan = "claudetan.app:main"
37
+ claudetan-setup = "claudetan.setup:main"
38
+
39
+ [project.urls]
40
+ Issues = "https://github.com/Stoupy51/ClaudeTan/issues"
41
+ Repository = "https://github.com/Stoupy51/ClaudeTan"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pyright",
46
+ "pytest",
47
+ "ruff",
48
+ ]
49
+
50
+ [tool.pyright]
51
+ typeCheckingMode = "strict"
52
+ # The sprite pipeline is its own uv project, checked with its own environment
53
+ exclude = ["tools/sprites"]
54
+
55
+ [tool.complexipy]
56
+ paths = ["."]
57
+ max-complexity-allowed = 15
58
+ sort = "desc"
59
+
60
+ [tool.ruff]
61
+ exclude = [
62
+ ".git",
63
+ ".ruff_cache",
64
+ ".venv",
65
+ ".vscode",
66
+ "build",
67
+ "dist",
68
+ "*.pyi",
69
+ ]
70
+ line-length = 135
71
+ # The sprite modules import each other by plain name, as scripts run from their folder
72
+ src = [".", "tools/sprites"]
73
+
74
+ [tool.ruff.lint.isort]
75
+ # Keeps the "name as name" re-exports of a package grouped in a single statement
76
+ combine-as-imports = true
77
+
78
+ [tool.ruff.lint]
79
+ select = [
80
+ "E",
81
+ "W",
82
+ "F",
83
+ "I",
84
+ "N",
85
+ "UP",
86
+ "B",
87
+ "C4",
88
+ "T20",
89
+ "RUF",
90
+ "RET",
91
+ "SIM",
92
+ "PIE",
93
+ "PERF401",
94
+ "PGH003",
95
+ "FBT003",
96
+ "TRY203",
97
+ "S110",
98
+ "PLR5501",
99
+ ]
100
+ ignore = [
101
+ "T201",
102
+ "W191",
103
+ "N803",
104
+ "N806",
105
+ ]
106
+