当前位置: 首页 > news >正文

:实现微信、QQ提示音接管与 OpenCode 联动桌面宠物开发实录

桌面宠物开发实录:实现微信、QQ提示音接管与 OpenCode 联动

文章目录

  • 桌面宠物开发实录:实现微信、QQ提示音接管与 OpenCode 联动
    • 摘要
    • 一、场景切入:我想要的不是"挂件",而是嵌入生活的角色
    • 二、总体架构:一个进程,四条线程
    • 三、原理拆解与关键实现
      • 3.1 精灵图动画:一图多动作,配置驱动
      • 3.2 声音引擎:场景 → 文件映射,多通道播放
      • 3.3 核心难点:WASAPI 只读监听,实现"提示音接管"
      • 3.4 定时提醒与日程:调度线程 + SQLite
      • 3.5 OpenCode 联动:内嵌 FastAPI,信号桥接线程
        • 配置提示
        • 关键实现
    • 四、项目结构速览
    • 五、总结

摘要

本文分享一个 PyQt5 桌面宠物的完整实现:不碰任何第三方文件,用 pycaw / WASAPI 对音频会话做只读轮询,配合状态机降误报,在微信响起的瞬间播放自定义语音(叠加 / 软替换双模式);同时内嵌 FastAPI 服务接入 opencode-im-notifier 插件,任务完成时弹窗并播"任务完成"语音。文章围绕精灵动画、声音引擎、音频监听、定时调度、OpenCode 联动五个子技术点展开,以低资源占用、高体验为设计取舍,只保留关键实现与可复用代码。

一、场景切入:我想要的不是"挂件",而是嵌入生活的角色

做桌宠的初衷很简单:把自己喜欢的动漫角色放进电脑里。但是又不希望只是一张会动的图片——真正的期待是,角色**"活"在工作环境里**:来消息时开口提醒,到点催喝水,任务跑完击掌提醒等。

市面上的桌宠,大多是个"挂件":

  1. 用法有限:主要是一个会动的背景板,接不了微信/QQ 提示音,也感知不到你的开发任务,热闹一阵就沦为桌面角落的装饰。
  2. 资源占用:为变现塞满广告,白白吃掉大量内存和 CPU——为了一点可爱付出卡顿的代价,本末倒置。

于是我把目标定为三个:
① 一个可自定义、会卖萌的精灵图桌宠,把喜欢的角色请进电脑;
② 微信/QQ 来消息时播自定义语音(“接管"提示音)——角色真的"会说话”;
③ opencode 任务完成时主动通知——角色见证你的工作,而不是在旁边发呆。

一句话概括这条主线:低资源占用 + 高用户体验,让桌宠从"挂件"变成"嵌入生活的工具"。下面按"原理拆解 → 关键实现"记录这条路。
首先,效果展示:
气泡效果如图所示:

功能详情如图所示:

二、总体架构:一个进程,四条线程

项目是单进程多线程:PyQt5 事件循环为主干,三个守护线程各自干活,通过Qt 信号(Signal)回主线程更新 UI,天然线程安全。

为守住"低资源占用"这条底线,三个守护线程全部做成事件驱动:音频轮询 0.25s 一次、调度每秒一次、OpenCode 服务惰性导入——平时几乎不占 CPU,只在关键时刻向主线程发信号,让资源让位给体验。

线程职责与主线程通信
主线程精灵动画、气泡、提醒 UI、托盘
SchedulerManager每秒扫 SQLite,查到期提醒remind_signal
AudioWatcher0.25s 轮询 WASAPI 会话notify_signal
OpenCodeHook内嵌 FastAPI 服务收通知hook_signal
┌──────────────────── 主线程(UI) ────────────────────┐ │ FloatingBall 精灵动画 / 气泡 / 提醒卡片 / 托盘 │ │ │ │ │ │ │ SoundEngine(声音) SchedulerManager AudioWatcher │ │ │ (定时调度) (音频监听:pycaw) │ │ │ │ │ │ │ OpenCodeHook ◄── 内嵌FastAPI ◄── opencode-im-notifier│ └────────────────────────────────────────────────────┘

代码量分布也说明主线:floating_ball.py是躯干,其余模块各司其职。下面按五个子技术点拆解。

三、原理拆解与关键实现

3.1 精灵图动画:一图多动作,配置驱动

精灵图是精灵表(Sprite Sheet)img/spt.webp共 9 行 × 8 列,每格 192×208 像素,一行一个动作。帧配置在img/spt.txt,格式是"每行动作保留的帧数 # 动作描述",例如:

6 # 眨眼(待机) 8 # 向右跑 4 # 打招呼1 5 # 跳跃

这样加新动作只需改文本配置,不用动代码。加载时按行裁剪,并用keep_cfg截断帧数:

defload_sprite_sheet(self):img=Image.open(self.sprite_path).convert("RGBA")forrowinrange(self.rows):action_frames=[]forcolinrange(self.cols):frame=img.crop((col*self.frame_width,row*self.frame_height,(col+1)*self.frame_width,(row+1)*self.frame_height))# 剔除几乎全透明的帧(动作末尾的空帧)alpha=frame.getdata(3)ifsum(1forainalphaifa>10)<len(alpha)*0.005:continue# RGBA 原始字节 → QImage → QPixmapdata=frame.tobytes("raw","RGBA")qimage=QImage(data,self.frame_width,self.frame_height,QImage.Format_RGBA8888)action_frames.append(QPixmap.fromImage(qimage))ifrowinkeep_cfg:# 按 spt.txt 截断保留帧数action_frames=action_frames[:keep_cfg[row]]self.all_actions.append(action_frames)

播放是QTimer驱动的两态逻辑:switch_action切动作并复位帧号;next_frame在"乒乓(PingPong)"模式下到边界反向,动作更自然(loop模式则直接取模循环):

defnext_frame(self):frames=self.all_actions[self.current_action_index]ifself.play_mode=='loop':# 循环模式self.current_frame_index=(self.current_frame_index+1)%len(frames)returnnext_idx=self.current_frame_index+self.direction# 乒乓模式ifnext_idx<0ornext_idx>=len(frames):# 到边界折返self.direction*=-1next_idx=self.current_frame_index+self.direction self.current_frame_index=next_idx

3.2 声音引擎:场景 → 文件映射,多通道播放

语音文件放在voice_tmp/,命名约定XX-场景序号.wav。启动时扫描目录,按文件名前缀建立场景(Scene)映射

SCENE_ALIASES={"消息":"msg","提醒":"remind","闹钟":"alarm","彩蛋":"easter","任务完成":"done",}

新增语音零代码改动:丢个XX-任务完成.wav进去就自动归入done场景。播放时从场景池里随机选、尽量不与上次重复,并维护多个QMediaPlayer通道实现叠加播放——提醒音不会打断消息音:

defplay_random(self,key):files=self._scene_files.get(key,[])ifnotfiles:returnNone# 场景无语音 → 静默降级pool=[fforfinfilesiff!=self._last_played.get(key)]orfiles chosen=random.choice(pool)self._last_played[key]=chosen self.play(chosen)

3.3 核心难点:WASAPI 只读监听,实现"提示音接管"

这是全文最有含金量的一段。原理:pycaw 封装了 WASAPI,AudioUtilities.GetAllSessions()能拿到每个进程的音频会话,会话状态(State)在响一声时是Inactive → Active → Inactive

难点在于误报:微信播放语音消息、语音通话也会使会话变Active。解决思路是状态机 + 时长阈值 + 冷却:

  • 会话变Active记下时间戳;
  • 若持续超过阈值(默认 2s)→ 判定为"长播放"(语音/通话),本次忽略;
  • 若在阈值内回到Inactive→ 是一次短提示音,触发接管;
  • 同一会话触发后进入冷却(默认 3s),避免连续提示音重复连响。
def_update(self,name,state,session,now):tr=self._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 → 一次播放结束since=tr["active_since"]ifsinceisnotNoneandnottr["decided"]\andnow-since<=self.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.MasterVolume=0.0self._silenced[name]=(session,now+self._mute_seconds)def_restore(self,name):# 播完恢复音量volume=self._silenced.pop(name)[0].SimpleAudioVolume volume.MasterVolume=1.0

选 pycaw 而不用 ctypes 强转 COM 指针,是因为 pycaw 内部管理 COM 生命周期,能避免常见的_ctypes.pyd崩溃(这是踩过坑换来的经验)。

3.4 定时提醒与日程:调度线程 + SQLite

提醒和日程存 SQLite(data.db),SchedulerManager线程每秒查一次到期项,命中就发remind_signal

classSchedulerManager(QThread):remind_signal=pyqtSignal(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?key=xxx"},"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_signal=pyqtSignal(dict)# 线程 → 主线程的桥defrun(self):app=build_app(self._handle_payload)# POST /hook 端点server=uvicorn.Server(uvicorn.Config(app,host=self.host,port=self.port,log_level="warning"))server.run()def_handle_payload(self,payload):parsed=parse_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"],subtitle=parsed["body"],anchor=self)idx=self._find_action("打招呼1")orself._find_action("打招呼2")ifidxisnotNone:self.play_action(idx,notify=False)

细节: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 # 场景语音(消息/提醒/彩蛋/任务完成)

项目源码地址

五、总结

回头看,这个桌宠在我这里已经不再是一个"挂件"——会提醒、会恭喜、会在角落陪着你干活,真正嵌进了日常的工作流。如果你也在做类似的桌宠 / 通知工具,希望我的实现思路,能给你一定的灵感。

后续可做的方向:打包成单 exe(PyInstaller)、声音文件按主题分类、精灵图增加更多动作帧。增加精灵图和声音替换的用户体验等等,这只是我的一个想法的初步样子,后续还有待探索。

http://www.jsqmd.com/news/1402043/

相关文章:

  • 2026年工业级硅胶制品供应体系评估:能力模型与适配路径分析 - 卓企推荐
  • 数据结构-栈和队列(一):C语言手写顺序栈|两种 top 约定 + 接口封装详解
  • Excel VLOOKUP函数实战:跨表数据查找与填充全解析
  • 绵阳交联聚乙烯隔声保温垫优质工厂直供:一站式楼板隔音降噪解决方案 - 装修教育财税推荐2026
  • AI大模型核心原理:从Transformer架构到涌现能力的技术解析
  • OpenClaw:开源AI智能体框架部署与实战指南
  • 构建AI驱动的自动化运维系统:从根因定位到智能决策
  • 2026 年任城知名的离型纸厂家生产商推荐几家,这种藏在胶黏制品背后的“隐形选手”,竟有你不知道的省成本妙招?-平宇新材料 - 企业推荐管【认证】
  • Linux磁盘分区工具parted详解:GPT分区、大容量磁盘管理与自动化运维实战
  • 通俗讲解 BMS 五大核心功能,新手入门不再迷茫
  • [光学原理与应用-505]:RViz2 激光雷达 TF 坐标轴解析(T‑MINI‑PLUS)
  • Python内存Hook技术实现小程序云函数网络流量抓包与逆向分析
  • 2026 和田玉收藏与定制选型指南:新疆 7 家实力品牌深度盘点 - 互联网科技品牌测评
  • 【CTF-SHOW】无字母数字命令执行
  • 彻底解决Maven环境配置与IDEA集成问题:从原理到实战
  • 千问8元通用优惠券领取,新用户福利099638,可点奶茶、外卖、打车、充话费
  • AgentScope Java 2.0 基础:用 Java 构建多智能体应用
  • 零基础2小时搭建个人网站:云服务器+宝塔面板+WordPress全攻略
  • 示波器的使用方法(一),模拟示波器的使用方法介绍
  • Ubuntu中文输入法安装与配置全攻略:Fcitx5与IBus框架详解
  • OpenClaw-RL:用对话训练智能体的强化学习新框架
  • DBF Converter(DBF文件转换器)
  • 内存序 — DD leijiang 分享
  • 基于Obsidian与GitHub Actions构建自动化内容工作流
  • 物联网设备交互的一些实践经验
  • VS Code配置Java开发环境:从JDK安装到项目调试完整指南
  • MBTI测试时总想选“更好的自己”?避免理想化作答的实用方法
  • 2026 年新消息:闸北评价高的闲置制冷设备回收服务团队哪个好,家里不用的旧冰柜藏着钱?它居然比你想的更有价值-博霄制冷设备回收 - 行业鉴选官
  • Windows系统部署Dify AI开发平台实战指南
  • C++哈希表深度解析:从核心原理到LeetCode实战与工程优化