├── resource ├── mute.ico └── default_config.yaml ├── requirements.txt ├── README.assets ├── image-20230506110911354.png └── image-20230506111158848.png ├── .gitignore ├── utils ├── ProcessUtil.py ├── PIDLockUtil.py ├── GetProcessUtil.py ├── ThreadUtil.py ├── LoggerUtil.py ├── ShutdownUtil.py ├── ConfigUtil.py ├── StrayUtil.py └── AudioUtil.py ├── LICENSE ├── main.py └── README.md /resource/mute.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingkai5wu/AutoMuteBG/HEAD/resource/mute.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | injector 2 | psutil 3 | pycaw 4 | pyinstaller 5 | pystray 6 | pywin32 7 | pyyaml 8 | setuptools 9 | -------------------------------------------------------------------------------- /README.assets/image-20230506110911354.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingkai5wu/AutoMuteBG/HEAD/README.assets/image-20230506110911354.png -------------------------------------------------------------------------------- /README.assets/image-20230506111158848.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingkai5wu/AutoMuteBG/HEAD/README.assets/image-20230506111158848.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /build/ 3 | /dist/ 4 | /utils/config.yaml 5 | /config.yaml 6 | /backup_config.yaml 7 | *.exe.spec 8 | /logs/ 9 | /process_name.txt 10 | -------------------------------------------------------------------------------- /resource/default_config.yaml: -------------------------------------------------------------------------------- 1 | setting: 2 | bg_scan_interval: 1 3 | max_log_files: 0 4 | setup_msg: true 5 | 6 | default: 7 | loop_interval: 0.2 8 | fg_volume: 1 9 | bg_volume: 0 10 | ignore: [ ] 11 | easing: 12 | duration: 0.5 13 | steps: 50 14 | 15 | processes: 16 | StarRail.exe: null -------------------------------------------------------------------------------- /utils/ProcessUtil.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import win32gui 4 | import win32process 5 | from psutil import Process 6 | 7 | 8 | class ProcessUtil: 9 | def __init__(self, process: Process): 10 | self.process = process 11 | self.hwnd_list = self.get_process_hwnd_list() 12 | 13 | def is_running(self): 14 | return self.process.is_running() 15 | 16 | def is_window_in_foreground(self): 17 | return win32gui.GetForegroundWindow() in self.hwnd_list 18 | 19 | def get_process_hwnd_list(self): 20 | def callback(hwnd, res: List): 21 | _, window_pid = win32process.GetWindowThreadProcessId(hwnd) 22 | if window_pid == self.process.pid: 23 | res.append(hwnd) 24 | 25 | hwnd_list = [] 26 | win32gui.EnumWindows(callback, hwnd_list) 27 | 28 | return hwnd_list 29 | -------------------------------------------------------------------------------- /utils/PIDLockUtil.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import psutil 5 | from injector import singleton, inject 6 | 7 | from utils.LoggerUtil import LoggerUtil 8 | 9 | 10 | @singleton 11 | class PIDLockUtil: 12 | @inject 13 | def __init__(self, logger_util: LoggerUtil): 14 | self.logger = logger_util.logger 15 | 16 | self.lockfile = os.path.join(tempfile.gettempdir(), "run.lock") 17 | 18 | def is_locked(self): 19 | if os.path.isfile(self.lockfile): 20 | with open(self.lockfile, 'r') as f: 21 | pid = f.read().strip() 22 | if pid: 23 | return psutil.pid_exists(int(pid)) 24 | return False 25 | 26 | def create_lock(self): 27 | with open(self.lockfile, 'w') as f: 28 | f.write(str(os.getpid())) 29 | self.logger.info("Lock file created.") 30 | 31 | def remove_lock(self): 32 | if os.path.isfile(self.lockfile): 33 | os.remove(self.lockfile) 34 | self.logger.info("Lock file removed.") 35 | -------------------------------------------------------------------------------- /utils/GetProcessUtil.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | import win32gui 3 | import win32process 4 | from pycaw.utils import AudioUtilities 5 | 6 | 7 | def get_all_audio_sessions(): 8 | sessions = AudioUtilities.GetAllSessions() 9 | res = [session for session in sessions if session.Process is not None] 10 | return res 11 | 12 | 13 | def get_all_window_processes(): 14 | window_processes = [] 15 | ignore_list = ['TextInputHost.exe'] # 添加过滤列表 16 | 17 | def enum_window_callback(hwnd, _): 18 | if win32gui.IsWindowVisible(hwnd) and win32gui.GetWindowText(hwnd): 19 | _, pid = win32process.GetWindowThreadProcessId(hwnd) 20 | try: 21 | p = psutil.Process(pid) 22 | process_name = p.name() 23 | if process_name not in ignore_list: 24 | window_title = win32gui.GetWindowText(hwnd) 25 | window_processes.append((process_name, window_title)) 26 | except psutil.NoSuchProcess: 27 | pass 28 | 29 | win32gui.EnumWindows(enum_window_callback, None) 30 | return window_processes 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Wuu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /utils/ThreadUtil.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | from comtypes import CoInitialize 5 | from injector import Injector, singleton, inject 6 | 7 | from utils.AudioUtil import AudioUtil 8 | from utils.ConfigUtil import ConfigUtil 9 | from utils.GetProcessUtil import get_all_audio_sessions 10 | from utils.LoggerUtil import LoggerUtil 11 | 12 | 13 | @singleton 14 | class ThreadUtil: 15 | @inject 16 | def __init__(self, injector: Injector, config_util: ConfigUtil, 17 | event: threading.Event, logger_util: LoggerUtil): 18 | self.injector = injector 19 | self.config_util = config_util 20 | self.event = event 21 | self.logger = logger_util.logger 22 | 23 | def start_audio_control_threads(self): 24 | alive_process = [process.name for process in threading.enumerate() if process.is_alive()] 25 | # self.logger.info(alive_process) 26 | sessions = get_all_audio_sessions() 27 | for session in sessions: 28 | process_name = session.Process.name() 29 | if process_name in self.config_util.config["processes"] and str(session.ProcessId) not in alive_process: 30 | self.logger.info(f"Found target process: {process_name} (PID: {session.ProcessId})") 31 | audio_util = AudioUtil(session, self.config_util, self.event, self.logger) 32 | thread = threading.Thread(target=audio_util.loop, name=session.ProcessId) 33 | thread.start() 34 | 35 | def background_scanner(self): 36 | CoInitialize() 37 | 38 | config = self.config_util.config 39 | bg_scan_interval = config["setting"]["bg_scan_interval"] 40 | self.logger.info(f"Starting with scan interval: {bg_scan_interval}s") 41 | while True: 42 | self.start_audio_control_threads() 43 | time.sleep(bg_scan_interval) 44 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import threading 3 | 4 | import pywintypes 5 | import win32api 6 | import win32con 7 | from injector import Injector, singleton 8 | 9 | from utils.LoggerUtil import LoggerUtil 10 | from utils.PIDLockUtil import PIDLockUtil 11 | from utils.ShutdownUtil import ShutdownUtil 12 | from utils.StrayUtil import StrayUtil 13 | from utils.ThreadUtil import ThreadUtil 14 | 15 | 16 | def configure(binder): 17 | binder.bind(threading.Event, scope=singleton) 18 | 19 | 20 | def check_lock(): 21 | cur_lock_util = injector.get(PIDLockUtil) 22 | if cur_lock_util.is_locked(): 23 | logger.info("Application is already running.") 24 | response = win32api.MessageBox( 25 | 0, 26 | "仍然启动(若存在多个进程可能导致问题)", 27 | "后台静音正在运行", 28 | win32con.MB_ICONWARNING | win32con.MB_YESNO 29 | ) 30 | if response == win32con.IDYES: 31 | logger.info("User chose to force start. Exiting other instance.") 32 | cur_lock_util.remove_lock() 33 | cur_lock_util.create_lock() 34 | else: 35 | logger.info("User chose not to force start. Exiting.") 36 | sys.exit(1) 37 | else: 38 | cur_lock_util.create_lock() 39 | return cur_lock_util 40 | 41 | 42 | def main(): 43 | stray_util = injector.get(StrayUtil) 44 | stray_util.run_detached() 45 | 46 | thread_util = injector.get(ThreadUtil) 47 | threading.Thread( 48 | target=thread_util.background_scanner, 49 | name="BGScannerThread", 50 | daemon=True 51 | ).start() 52 | 53 | shutdown_util = injector.get(ShutdownUtil) 54 | shutdown_util.loop() 55 | 56 | 57 | if __name__ == '__main__': 58 | injector = Injector(configure) 59 | 60 | logger = injector.get(LoggerUtil).logger 61 | lock_util = check_lock() 62 | 63 | logger.info("Starting main function.") 64 | main() 65 | 66 | lock_util.remove_lock() 67 | 68 | # 打包用 69 | print(pywintypes) 70 | # pyinstaller -Fw --add-data "resource/;resource/" -i "resource/mute.ico" -n background_muter.exe main.py 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AutoMuteBG 2 | 3 | 让设定的进程在后台时自动静音,切换到前台恢复。 4 | 5 | 本程序的原理为调整系统的音量合成器,仅Windows可用。 6 | 7 | 开源地址:[GitHub](https://github.com/lingkai5wu/AutoMuteBG) | [Gitee](https://gitee.com/lingkai5wu/AutoMuteBG),主要使用前者。 8 | 9 | ## 使用方法 10 | 11 | ### 首次使用 12 | 13 | 以本程序默认的配置,对`崩坏:星穹铁道`后台静音为例: 14 | 15 | 1. 下载最近版本的`background_muter.exe`。 16 | - **国内访问:[Gitee Releases](https://gitee.com/lingkai5wu/AutoMuteBG/releases/latest)** 17 | - [GitHub Releases](https://github.com/lingkai5wu/AutoMuteBG/releases/latest) 18 | 2. 运行`background_muter.exe`,程序会在后台运行,自动根据前后台情况调整目标进程的音量。 19 | 3. 可以从任务托盘的右键菜单手动退出,也可随计算机关闭。 20 | 21 | ### 自定义配置 22 | 23 | 通过修改配置文件可以添加多个需要被本程序自动调整的进程,并自定义每个进程的处理参数。 24 | 25 | 首次运行程序会在同目录下创建配置文件`config.yaml`,该文件复制自[default_config.yaml](resource/default_config.yaml)。 26 | 27 | #### 配置说明 28 | 29 | 默认配置中key不可更改,只能更改对应的值。若key存在错误,本程序会在初始化并备份原有`config.yaml`。 30 | 31 | - `setting`(程序设置):本程序运行的一些设置项。 32 | - `bg_scan_interval`(后台扫描间隔): 33 | 设置程序在后台检测目标进程是否启动的时间间隔,单位为秒。 34 | - `max_log_files`(最多日志文件数): 35 | 程序每次启动会以当前时间为文件名,在同目录`logs`文件夹下生成日志文件,便于排查问题。 36 | 设置最多日志文件数,取值范围为正整数,若超过这个数量则删除最早的日志文件。 37 | 设置为`0`则不会生成日志文件。 38 | - `setup_msg`(启动通知):启动完成是否显示通知,取值范围为布尔值。 39 | 40 | - `default`(默认配置):所有进程的配置将使用以下缺省值。 41 | - `loop_interval`(循环检测间隔):指定程序循环检测前台窗口的时间间隔,单位为秒。 42 | 该值不应大于5秒,否则可能导致在进程后台运行时关机无法恢复音量。 43 | - `fg_volume`(前台音量): 44 | 指定目标进程前台音量的期望大小,单位为百分比。取值范围为 `[0, 1]` 或 `auto`。 45 | - `bg_volume`(后台音量):单位为百分比,取值范围为 `[0, 1]`。 46 | - `easing`(淡入淡出效果配置):配置淡入淡出效果的参数。 47 | 取值范围**仅单个进程配置中**可为下方一个或多个映射(map),或`null`。 48 | - `duration`(淡入淡出持续时间):单位为秒。 49 | - `steps`(淡入淡出处理步数) 50 | - `processes`(单个进程配置):指定需要被本程序自动调整的进程和配置。 51 | - `StarRail.exe`:`崩坏:星穹铁道`进程的配置,`null` 表示全部沿用默认配置,若有内容则覆盖默认配置。 52 | 53 | #### 增加自定义配置 54 | 55 | 以增加网易云音乐的配置为例,修改后的`processes`部分如下,需注意`yaml`的严格缩进: 56 | 57 | ```yaml 58 | processes: 59 | StarRail.exe: null 60 | 61 | # 需要本程序自动调整的进程名 62 | cloudmusic.exe: 63 | # 前台音量继承音量合成器的音量 64 | fg_volume: auto 65 | # 后台音量设置为 10% 66 | bg_volume: 0.1 67 | # 不使用淡入淡出 68 | easing: null 69 | ``` 70 | 71 | ### 已知问题 72 | 73 | - **在本程序运行时强制关闭(例如任务管理器),本程序无法自动恢复目标进程的音量**,遇到该问题可以再次启动本程序,或在音量合成器中重置。 74 | - ~~有时会忘记自己还在打游戏~~。 75 | 76 | ### 运行截图 77 | 78 | ![image-20230506110911354](README.assets/image-20230506110911354.png) 79 | 80 | ![image-20230506111158848](README.assets/image-20230506111158848.png) -------------------------------------------------------------------------------- /utils/LoggerUtil.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | from datetime import datetime 5 | 6 | from injector import singleton, inject 7 | 8 | from utils.ConfigUtil import ConfigUtil 9 | 10 | LOG_PATH = "../logs/" 11 | 12 | 13 | def get_log_dir(): 14 | if getattr(sys, 'frozen', False): 15 | # Running as compiled executable 16 | exe_dir = os.path.dirname(sys.executable) 17 | return os.path.join(exe_dir, 'logs') 18 | else: 19 | # Running as script 20 | script_dir = os.path.dirname(os.path.abspath(__file__)) 21 | main_dir = os.path.dirname(script_dir) 22 | return os.path.join(main_dir, 'logs') 23 | 24 | 25 | @singleton 26 | class LoggerUtil: 27 | logger = None 28 | 29 | @inject 30 | def __init__(self, config_util: ConfigUtil): 31 | self.max_log_files = config_util.config["setting"]["max_log_files"] 32 | 33 | self.log_dir = get_log_dir() 34 | self.logger = self._setup_logging() 35 | 36 | self._delete_old_log_files() 37 | 38 | def _setup_logging(self): 39 | logger = logging.getLogger() 40 | logger.setLevel(logging.INFO) 41 | formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(threadName)s - %(message)s") 42 | 43 | # 设置控制台日志输出 44 | console_handler = logging.StreamHandler() 45 | console_handler.setFormatter(formatter) 46 | logger.addHandler(console_handler) 47 | 48 | if self.max_log_files > 0: 49 | os.makedirs(self.log_dir, exist_ok=True) 50 | log_file = os.path.join(self.log_dir, datetime.now().strftime("%Y%m%d%H%M%S") + ".log") 51 | file_handler = logging.FileHandler(log_file) 52 | file_handler.setFormatter(formatter) 53 | logger.addHandler(file_handler) 54 | 55 | return logger 56 | 57 | def _delete_old_log_files(self): 58 | if not os.path.exists(self.log_dir): 59 | return 60 | 61 | log_files = [f for f in os.listdir(self.log_dir) if f.endswith(".log")] 62 | log_files.sort() 63 | 64 | if self.max_log_files == 0: 65 | os.remove(self.log_dir) 66 | 67 | if len(log_files) > self.max_log_files: 68 | files_to_delete = log_files[:len(log_files) - self.max_log_files] 69 | for file_name in files_to_delete: 70 | file_path = os.path.join(self.log_dir, file_name) 71 | os.remove(file_path) 72 | -------------------------------------------------------------------------------- /utils/ShutdownUtil.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | import win32api 5 | import win32con 6 | import win32gui 7 | from injector import singleton, inject 8 | 9 | from utils.LoggerUtil import LoggerUtil 10 | from utils.PIDLockUtil import PIDLockUtil 11 | from utils.StrayUtil import StrayUtil 12 | 13 | 14 | @singleton 15 | class ShutdownUtil: 16 | @inject 17 | def __init__(self, stray_util: StrayUtil, lock_util: PIDLockUtil, 18 | event: threading.Event, logger_util: LoggerUtil): 19 | self.stray_util = stray_util 20 | self.lock_util = lock_util 21 | self.event = event 22 | self.logger = logger_util.logger 23 | 24 | self.hwnd = None 25 | 26 | def on_win32_wm_event(self, h_wnd, u_msg, w_param, l_param): 27 | self.logger.info(self, h_wnd, u_msg, w_param, l_param) 28 | if u_msg == win32con.WM_QUERYENDSESSION: 29 | self.logger.warn(f"Received WM_QUERYENDSESSION. {h_wnd, u_msg, w_param, l_param}") 30 | elif u_msg == win32con.WM_ENDSESSION: 31 | self.logger.warn("Received WM_ENDSESSION. System is shutting down.") 32 | self.stray_util.exit_app() 33 | self.lock_util.remove_lock() 34 | while threading.active_count() > 2: 35 | alive_process = [process.name for process in threading.enumerate() if process.is_alive()] 36 | self.logger.info(alive_process) 37 | time.sleep(0.1) 38 | self.logger.info("Program exit.") 39 | return True 40 | 41 | def create_hidden_window(self): 42 | h_inst = win32api.GetModuleHandle(None) 43 | wnd_class = win32gui.WNDCLASS() 44 | wnd_class.hInstance = h_inst 45 | wnd_class.lpszClassName = "AutoMuteBGClass" 46 | wnd_class.lpfnWndProc = { 47 | win32con.WM_QUERYENDSESSION: self.on_win32_wm_event, 48 | win32con.WM_ENDSESSION: self.on_win32_wm_event 49 | } 50 | 51 | self.hwnd = win32gui.CreateWindowEx( 52 | win32con.WS_EX_LEFT, 53 | win32gui.RegisterClass(wnd_class), 54 | "AutoMuteBG", 55 | 0, 56 | 0, 57 | 0, 58 | win32con.CW_USEDEFAULT, 59 | win32con.CW_USEDEFAULT, 60 | 0, 61 | 0, 62 | h_inst, 63 | None 64 | ) 65 | 66 | def destroy_hidden_window(self): 67 | if self.hwnd is not None: 68 | win32gui.DestroyWindow(self.hwnd) 69 | win32gui.UnregisterClass("AutoMuteBGClass", win32api.GetModuleHandle(None)) 70 | 71 | def loop(self): 72 | self.logger.info("Start ShutdownUtil") 73 | try: 74 | self.create_hidden_window() 75 | while not self.event.is_set(): 76 | win32gui.PumpWaitingMessages() 77 | time.sleep(0.5) 78 | self.destroy_hidden_window() 79 | except Exception as e: 80 | self.logger.error(e, exc_info=True, stack_info=True) 81 | -------------------------------------------------------------------------------- /utils/ConfigUtil.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | import pkg_resources 5 | import yaml 6 | from injector import singleton, inject 7 | 8 | 9 | @singleton 10 | class ConfigUtil: 11 | config = None 12 | 13 | @inject 14 | def __init__(self): 15 | self.config_file = "config.yaml" 16 | self.default_config_path = pkg_resources.resource_filename(__name__, "../resource/default_config.yaml") 17 | self.config = None 18 | self.default_config = None 19 | 20 | self._read() 21 | 22 | def _read(self): 23 | if not os.path.exists(self.config_file): 24 | shutil.copy2(self.default_config_path, self.config_file) 25 | with open(self.config_file, "r", encoding="utf-8") as f: 26 | self.config = yaml.safe_load(f) 27 | self._verify() 28 | 29 | def _verify(self): 30 | with open(self.default_config_path, "r", encoding="utf-8") as f: 31 | self.default_config = yaml.safe_load(f) 32 | try: 33 | self._verify_consistency(self.config["setting"], self.default_config["setting"]) 34 | self._verify_consistency(self.config["default"], self.default_config["default"]) 35 | except KeyError: 36 | self._reset() 37 | 38 | def _verify_consistency(self, target_config, reference_config): 39 | for key in reference_config: 40 | if key not in target_config: 41 | self._reset() 42 | if isinstance(reference_config[key], dict) and isinstance(target_config[key], dict): 43 | self._verify_consistency(target_config[key], reference_config[key]) 44 | 45 | def _reset(self): 46 | if os.path.exists(self.config_file): 47 | shutil.move(self.config_file, "backup_" + self.config_file) 48 | shutil.copy2(self.default_config_path, self.config_file) 49 | raise CustomException(f"Configuration file verification failed. {self.config_file} has been reset. " 50 | f"Please modify and run the program again.") 51 | 52 | def get_by_process(self, process_name): 53 | def merge_configs(parent_config, child_config): 54 | if not set(child_config.keys()).issubset(set(parent_config.keys())): 55 | raise CustomException(f"Process configuration error for {process_name}.", child_config) 56 | merged = parent_config.copy() 57 | for key, value in child_config.items(): 58 | if key in merged and isinstance(value, dict) and isinstance(merged[key], dict): 59 | merged[key] = merge_configs(merged[key], value) 60 | else: 61 | merged[key] = value 62 | return merged 63 | 64 | process_config = self.config["processes"][process_name] 65 | if process_config is None: 66 | return self.config["default"] 67 | default_config = self.config["default"].copy() 68 | return merge_configs(default_config, process_config) 69 | 70 | 71 | class CustomException(Exception): 72 | pass 73 | -------------------------------------------------------------------------------- /utils/StrayUtil.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | import webbrowser 4 | 5 | import pkg_resources 6 | import pystray 7 | import win32api 8 | from PIL import Image 9 | from injector import singleton, inject 10 | 11 | from utils.ConfigUtil import ConfigUtil 12 | from utils.GetProcessUtil import get_all_audio_sessions, get_all_window_processes 13 | from utils.LoggerUtil import LoggerUtil 14 | 15 | 16 | def _open_site(): 17 | webbrowser.open('https://gitee.com/lingkai5wu/AutoMuteBG') 18 | 19 | 20 | @singleton 21 | class StrayUtil: 22 | @inject 23 | def __init__(self, config_util: ConfigUtil, logger_util: LoggerUtil, event: threading.Event): 24 | self.setup_msg = config_util.config["setting"]["setup_msg"] 25 | self.logger = logger_util.logger 26 | self.event = event 27 | 28 | name = "后台静音" 29 | menu = pystray.Menu( 30 | pystray.MenuItem("关于", self.show_version_info), 31 | pystray.MenuItem("开源地址", _open_site), 32 | pystray.MenuItem("进程列表", self._save_process_list_to_txt), 33 | pystray.MenuItem("退出", self.exit_app) 34 | ) 35 | icon = Image.open(pkg_resources.resource_filename(__name__, "../resource/mute.ico")) 36 | 37 | self.icon = pystray.Icon(name, icon, name, menu) 38 | 39 | @staticmethod 40 | def show_version_info(): 41 | version_info = ( 42 | "后台应用自动静音器\n" 43 | "让设定的进程在后台时自动静音,切换到前台恢复。\n" 44 | "版本: 0.2.2 Dev\n" 45 | "开源地址: github.com/lingkai5wu/AutoMuteBG" 46 | ) 47 | win32api.MessageBox(0, version_info, "关于Auto Mute Background", 0x40) 48 | 49 | def run_detached(self): 50 | def on_icon_ready(icon): 51 | icon.visible = True 52 | if self.setup_msg: 53 | icon.notify("程序启动成功,在系统托盘右键菜单中退出") 54 | threading.current_thread().setName("StrayRunCallbackThread") 55 | self.logger.info("Stray is running.") 56 | 57 | self.logger.info("Starting stray.") 58 | threading.Thread(target=self.icon.run, args=(on_icon_ready,), name="StrayThread").start() 59 | 60 | def _save_process_list_to_txt(self): 61 | filename = "process_name.txt" 62 | window_processes = get_all_window_processes() 63 | print(window_processes) 64 | audio_sessions = get_all_audio_sessions() 65 | with open(filename, 'w', encoding="utf-8") as file: 66 | if window_processes: 67 | file.write("当前在窗口管理器中注册的进程:\n窗口标题 - 进程名\n") 68 | else: 69 | file.write("当前在窗口管理器中没有进程,请先启动任意进程并打开窗口。") 70 | for process_name, window_title in window_processes: 71 | file.write(f"{window_title} - {process_name}\n") 72 | file.write("\n") 73 | if audio_sessions: 74 | file.write("当前在音量合成器中注册的进程:\n进程名\n") 75 | else: 76 | file.write("当前在音量合成器中没有进程,请先启动任意进程并播放声音。") 77 | for session in audio_sessions: 78 | process_name = session.Process.name() 79 | file.write(f"{process_name}\n") 80 | self.logger.info(f"Process list saved to {filename}.") 81 | os.startfile(filename) 82 | 83 | def exit_app(self): 84 | self.logger.info("Exiting by StrayUtil.") 85 | self.event.set() 86 | self.icon.stop() 87 | -------------------------------------------------------------------------------- /utils/AudioUtil.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | from pycaw.utils import AudioSession 5 | 6 | from utils.ConfigUtil import ConfigUtil 7 | from utils.LoggerUtil import LoggerUtil 8 | from utils.ProcessUtil import ProcessUtil 9 | 10 | 11 | # 两个缓动公式 12 | # Source: https://blog.csdn.net/songche123/article/details/102520760 13 | def _ease_in_cubic(t, b, c, d): 14 | t /= d 15 | return c * t * t * t + b 16 | 17 | 18 | def _ease_out_cubic(t, b, c, d): 19 | t = t / d - 1 20 | return c * (t * t * t + 1) + b 21 | 22 | 23 | class AudioUtil: 24 | def __init__(self, session: AudioSession, config_util: ConfigUtil, 25 | event: threading.Event, logger: LoggerUtil.logger): 26 | self.session = session 27 | self.config = config_util.get_by_process(session.Process.name()) 28 | self.event = event 29 | self.logger = logger 30 | 31 | self.process_util = ProcessUtil(session.Process) 32 | self.last_target_volume = None 33 | self.last_volume = None 34 | self.easing_thread = None 35 | self.stop_easing_thread = False 36 | 37 | # 运行函数 38 | self._check_fg_volume() 39 | 40 | def _check_fg_volume(self): 41 | if self.config["fg_volume"] == "auto": 42 | self.config["fg_volume"] = self.session.SimpleAudioVolume.GetMasterVolume() 43 | # 意外退出的情况 44 | if self.config["fg_volume"] == self.config["bg_volume"]: 45 | self.config["fg_volume"] = 1 46 | self.logger.info(f"Change fg_volume to {self.config['fg_volume']}.") 47 | 48 | def set_volume(self, volume: float): 49 | def no_easing(cur_volume=volume): 50 | self.session.SimpleAudioVolume.SetMasterVolume(cur_volume, None) 51 | self.last_volume = cur_volume 52 | 53 | def easing(stop_easing_thread): 54 | f = _ease_in_cubic if self.last_volume < volume else _ease_out_cubic 55 | c = volume - self.last_volume 56 | this_last_volume = self.last_volume 57 | for i in range(self.config["easing"]["steps"]): 58 | if stop_easing_thread(): 59 | break 60 | cur_volume = f(i + 1, this_last_volume, c, self.config["easing"]["steps"]) 61 | # print("sep:{:}, {:.2f} -> {:.2f}".format(i, self.last_volume, cur_volume)) 62 | no_easing(cur_volume) 63 | time.sleep(self.config["easing"]["duration"] / self.config["easing"]["steps"]) 64 | 65 | def stop_easing(): 66 | if self.easing_thread is not None and self.easing_thread.is_alive(): 67 | self.stop_easing_thread = True 68 | self.easing_thread.join() 69 | self.stop_easing_thread = False 70 | 71 | if self.last_target_volume != volume: 72 | self.last_target_volume = volume 73 | if self.config["easing"] is None or self.last_volume is None: 74 | no_easing() 75 | else: 76 | stop_easing() 77 | self.easing_thread = threading.Thread( 78 | target=easing, 79 | args=(lambda: self.stop_easing_thread,), 80 | name="EasingThread", 81 | daemon=True 82 | ) 83 | self.easing_thread.start() 84 | 85 | def loop(self): 86 | self.logger.info("Starting loop.") 87 | while not self.event.isSet() and self.process_util.is_running(): 88 | if self.process_util.is_window_in_foreground(): 89 | self.set_volume(self.config["fg_volume"]) 90 | else: 91 | self.set_volume(self.config["bg_volume"]) 92 | time.sleep(self.config["loop_interval"]) 93 | else: 94 | self.stop_easing_thread = True 95 | self.session.SimpleAudioVolume.SetMasterVolume(self.config["fg_volume"], None) 96 | self.logger.info("Exiting loop.") 97 | --------------------------------------------------------------------------------