
桌面宠物开发实录实现微信、QQ提示音接管与 OpenCode 联动文章目录桌面宠物开发实录实现微信、QQ提示音接管与 OpenCode 联动摘要一、场景切入我想要的不是挂件而是嵌入生活的角色二、总体架构一个进程四条线程三、原理拆解与关键实现3.1 精灵图动画一图多动作配置驱动3.2 声音引擎场景 → 文件映射多通道播放3.3 核心难点WASAPI 只读监听实现提示音接管3.4 定时提醒与日程调度线程 SQLite3.5 OpenCode 联动内嵌 FastAPI信号桥接线程配置提示关键实现四、项目结构速览五、总结摘要本文分享一个 PyQt5 桌面宠物的完整实现不碰任何第三方文件用 pycaw / WASAPI 对音频会话做只读轮询配合状态机降误报在微信响起的瞬间播放自定义语音叠加 / 软替换双模式同时内嵌 FastAPI 服务接入 opencode-im-notifier 插件任务完成时弹窗并播任务完成语音。文章围绕精灵动画、声音引擎、音频监听、定时调度、OpenCode 联动五个子技术点展开以低资源占用、高体验为设计取舍只保留关键实现与可复用代码。一、场景切入我想要的不是挂件而是嵌入生活的角色做桌宠的初衷很简单把自己喜欢的动漫角色放进电脑里。但是又不希望只是一张会动的图片——真正的期待是角色**活在工作环境里**来消息时开口提醒到点催喝水任务跑完击掌提醒等。市面上的桌宠大多是个挂件用法有限主要是一个会动的背景板接不了微信/QQ 提示音也感知不到你的开发任务热闹一阵就沦为桌面角落的装饰。资源占用为变现塞满广告白白吃掉大量内存和 CPU——为了一点可爱付出卡顿的代价本末倒置。于是我把目标定为三个① 一个可自定义、会卖萌的精灵图桌宠把喜欢的角色请进电脑② 微信/QQ 来消息时播自定义语音“接管提示音——角色真的会说话”③ opencode 任务完成时主动通知——角色见证你的工作而不是在旁边发呆。一句话概括这条主线低资源占用 高用户体验让桌宠从挂件变成嵌入生活的工具。下面按原理拆解 → 关键实现记录这条路。首先效果展示气泡效果如图所示功能详情如图所示二、总体架构一个进程四条线程项目是单进程多线程PyQt5 事件循环为主干三个守护线程各自干活通过Qt 信号Signal回主线程更新 UI天然线程安全。为守住低资源占用这条底线三个守护线程全部做成事件驱动音频轮询 0.25s 一次、调度每秒一次、OpenCode 服务惰性导入——平时几乎不占 CPU只在关键时刻向主线程发信号让资源让位给体验。线程职责与主线程通信主线程精灵动画、气泡、提醒 UI、托盘—SchedulerManager每秒扫 SQLite查到期提醒remind_signalAudioWatcher0.25s 轮询 WASAPI 会话notify_signalOpenCodeHook内嵌 FastAPI 服务收通知hook_signal┌──────────────────── 主线程UI ────────────────────┐ │ FloatingBall 精灵动画 / 气泡 / 提醒卡片 / 托盘 │ │ │ │ │ │ │ SoundEngine(声音) SchedulerManager AudioWatcher │ │ │ (定时调度) (音频监听:pycaw) │ │ │ │ │ │ │ OpenCodeHook ◄── 内嵌FastAPI ◄── opencode-im-notifier│ └────────────────────────────────────────────────────┘代码量分布也说明主线floating_ball.py是躯干其余模块各司其职。下面按五个子技术点拆解。三、原理拆解与关键实现3.1 精灵图动画一图多动作配置驱动精灵图是精灵表Sprite Sheetimg/spt.webp共 9 行 × 8 列每格 192×208 像素一行一个动作。帧配置在img/spt.txt格式是每行动作保留的帧数 # 动作描述例如6 # 眨眼待机 8 # 向右跑 4 # 打招呼1 5 # 跳跃这样加新动作只需改文本配置不用动代码。加载时按行裁剪并用keep_cfg截断帧数defload_sprite_sheet(self):imgImage.open(self.sprite_path).convert(RGBA)forrowinrange(self.rows):action_frames[]forcolinrange(self.cols):frameimg.crop((col*self.frame_width,row*self.frame_height,(col1)*self.frame_width,(row1)*self.frame_height))# 剔除几乎全透明的帧动作末尾的空帧alphaframe.getdata(3)ifsum(1forainalphaifa10)len(alpha)*0.005:continue# RGBA 原始字节 → QImage → QPixmapdataframe.tobytes(raw,RGBA)qimageQImage(data,self.frame_width,self.frame_height,QImage.Format_RGBA8888)action_frames.append(QPixmap.fromImage(qimage))ifrowinkeep_cfg:# 按 spt.txt 截断保留帧数action_framesaction_frames[:keep_cfg[row]]self.all_actions.append(action_frames)播放是QTimer驱动的两态逻辑switch_action切动作并复位帧号next_frame在乒乓PingPong模式下到边界反向动作更自然loop模式则直接取模循环defnext_frame(self):framesself.all_actions[self.current_action_index]ifself.play_modeloop:# 循环模式self.current_frame_index(self.current_frame_index1)%len(frames)returnnext_idxself.current_frame_indexself.direction# 乒乓模式ifnext_idx0ornext_idxlen(frames):# 到边界折返self.direction*-1next_idxself.current_frame_indexself.direction self.current_frame_indexnext_idx3.2 声音引擎场景 → 文件映射多通道播放语音文件放在voice_tmp/命名约定XX-场景序号.wav。启动时扫描目录按文件名前缀建立场景Scene映射SCENE_ALIASES{消息:msg,提醒:remind,闹钟:alarm,彩蛋:easter,任务完成:done,}新增语音零代码改动丢个XX-任务完成.wav进去就自动归入done场景。播放时从场景池里随机选、尽量不与上次重复并维护多个QMediaPlayer通道实现叠加播放——提醒音不会打断消息音defplay_random(self,key):filesself._scene_files.get(key,[])ifnotfiles:returnNone# 场景无语音 → 静默降级pool[fforfinfilesiff!self._last_played.get(key)]orfiles chosenrandom.choice(pool)self._last_played[key]chosen self.play(chosen)3.3 核心难点WASAPI 只读监听实现提示音接管这是全文最有含金量的一段。原理pycaw 封装了 WASAPIAudioUtilities.GetAllSessions()能拿到每个进程的音频会话会话状态State在响一声时是Inactive → Active → Inactive。难点在于误报微信播放语音消息、语音通话也会使会话变Active。解决思路是状态机 时长阈值 冷却会话变Active记下时间戳若持续超过阈值默认 2s→ 判定为长播放语音/通话本次忽略若在阈值内回到Inactive→ 是一次短提示音触发接管同一会话触发后进入冷却默认 3s避免连续提示音重复连响。def_update(self,name,state,session,now):trself._track.setdefault(name,{active_since:None,decided:False,last_trigger:0.0})ifstate_SESSION_ACTIVE:iftr[active_since]isNone:# 提示音响起的瞬间tr[active_since]now tr[decided]Falseelifnottr[decided]andnow-tr[active_since]self.sensitivity:tr[decided]True# 超过阈值 → 长播放忽略else:# Inactive → 一次播放结束sincetr[active_since]ifsinceisnotNoneandnottr[decided]\andnow-sinceself.sensitivity \andnow-tr[last_trigger]self.cool_down_s:# 冷却防连响tr[last_trigger]now self._on_notify(name,session,now)# 通知主线程播自定义语音tr[active_since]Nonetr[decided]False接管有两种模式模式行为叠加Overlay保留微信原声在其上额外播自定义语音默认软替换Silent触发瞬间把该会话音量MasterVolume临时置 0播完恢复——不碰文件也能只响自定义音def_mute(self,name,session,now):# 软替换临时静音该应用session.SimpleAudioVolume.MasterVolume0.0self._silenced[name](session,nowself._mute_seconds)def_restore(self,name):# 播完恢复音量volumeself._silenced.pop(name)[0].SimpleAudioVolume volume.MasterVolume1.0选 pycaw 而不用 ctypes 强转 COM 指针是因为 pycaw 内部管理 COM 生命周期能避免常见的_ctypes.pyd崩溃这是踩过坑换来的经验。3.4 定时提醒与日程调度线程 SQLite提醒和日程存 SQLitedata.dbSchedulerManager线程每秒查一次到期项命中就发remind_signalclassSchedulerManager(QThread):remind_signalpyqtSignal(dict)# {title:..., sound_key:..., time:...}defrun(self):whileself._running:self._check(datetime.now())time.sleep(1.0)def_check(self,now):forrinself.db.due_reminders(now):# 查已到期且未触发的项self.remind_signal.emit({kind:reminder,title:r[title],time:r[start_time],sound_key:alarm})self._advance_reminder(r,now)# 触发后推进下一次每日/每周主线程收到信号后播remind场景语音、弹提醒卡片、切打招呼动作。提醒支持一次性 / 每日 / 每周日程有独立日历视图CalendarView管理这里不展开。3.5 OpenCode 联动内嵌 FastAPI信号桥接线程配置提示opencode 用opencode-im-notifier插件把任务完成消息推送到 HTTP 端点桌宠内嵌一个 FastAPI 服务来接。在 opencode-im-notifier 中根据提示配置好文件将飞书的webhook地址替换成后端的地址默认http://127.0.0.1:8992/hook或者见下面的代码复制到~/.config/opencode/opencode-im-notifier.json中{dingtalk:{enable:false},feishu:{enable:true,webhook:http://127.0.0.1:8992/hook},wecom:{enable:false,webhook:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxx},notifyOn:[idle,permission,question,error],quietHours:{start:22:00,end:08:00},title:我的项目}关键实现OpenCodeHook(QThread)在独立线程里跑uvicorn.Server.run()自带事件循环不阻塞 Qt收到POST /hook后解析消息通过 Qt 信号交回主线程classOpenCodeHook(QThread):hook_signalpyqtSignal(dict)# 线程 → 主线程的桥defrun(self):appbuild_app(self._handle_payload)# POST /hook 端点serveruvicorn.Server(uvicorn.Config(app,hostself.host,portself.port,log_levelwarning))server.run()def_handle_payload(self,payload):parsedparse_opencode_payload(payload)# 从卡片消息里提取标题/正文self.hook_signal.emit(parsed)主线程收到信号就弹完成卡片、播done场景语音如银月-任务完成.wav、切打招呼动作def_on_opencode_hook(self,parsed):self.sound.play_random(done)# 任务完成语音self.card.show_card(parsed[title],subtitleparsed[body],anchorself)idxself._find_action(打招呼1)orself._find_action(打招呼2)ifidxisnotNone:self.play_action(idx,notifyFalse)细节fastapi/uvicorn惰性导入——没装依赖时线程直接退出并提示不影响桌宠本体端口被占用如调试用的test.py仍开着也会优雅降级。这是低侵入设计哲学的延续。四、项目结构速览VoxNotify/ ├── main.py # 入口 ├── floating_ball.py # 桌宠主体动画/交互/气泡/提醒 ├── audio_watcher.py # WASAPI 音频监听核心语音接管 ├── sound_engine.py # 声音引擎场景映射 多通道播放 ├── opencode_hook.py # OpenCode 联动内嵌 FastAPI ├── schedule_manager.py # 定时调度线程 ├── reminder_dialog.py # 定时提醒 UI ├── calendar_view.py # 日程日历 UI ├── database.py # SQLite 数据层 ├── bubble_widget.py # 气泡 / 提醒卡片无边框置顶 ├── config.py config.json ├── logger.py # 统一日志轮转文件 ├── img/ # 精灵图 spt.webp 帧配置 spt.txt └── voice_tmp/*.wav # 场景语音消息/提醒/彩蛋/任务完成项目源码地址五、总结回头看这个桌宠在我这里已经不再是一个挂件——会提醒、会恭喜、会在角落陪着你干活真正嵌进了日常的工作流。如果你也在做类似的桌宠 / 通知工具希望我的实现思路能给你一定的灵感。后续可做的方向打包成单 exePyInstaller、声音文件按主题分类、精灵图增加更多动作帧。增加精灵图和声音替换的用户体验等等这只是我的一个想法的初步样子后续还有待探索。