From a33bf29e9b1af19eb06a2aa752f329295c91f622 Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:43:00 +0800 Subject: [PATCH 1/5] build: add reproducible macOS environment --- .gitignore | 5 +- .python-version | 1 + packaging/macos/CapsWriterClient.spec | 64 ++ pyproject.toml | 62 ++ requirements-client.txt | 11 +- requirements-server.txt | 8 +- scripts/download_macos_models.py | 147 +++ uv.lock | 1255 +++++++++++++++++++++++++ 8 files changed, 1548 insertions(+), 5 deletions(-) create mode 100644 .python-version create mode 100644 packaging/macos/CapsWriterClient.spec create mode 100644 pyproject.toml create mode 100644 scripts/download_macos_models.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 2cb36af9..fd84d2fa 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ MANIFEST *.manifest *.spec *.zip +!packaging/macos/CapsWriterClient.spec # 安装日志 pip-log.txt @@ -176,6 +177,8 @@ test_*.py test_*.ipynb test_*.md test_*.txt +!tests/ +!tests/**/*.py stocks.txt example_*.py example_.ipynb @@ -187,4 +190,4 @@ release *.dll *.exe -models/**/*.cfg \ No newline at end of file +models/**/*.cfg diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..28d9a01b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.13 diff --git a/packaging/macos/CapsWriterClient.spec b/packaging/macos/CapsWriterClient.spec new file mode 100644 index 00000000..aa2aa773 --- /dev/null +++ b/packaging/macos/CapsWriterClient.spec @@ -0,0 +1,64 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path + +from PyInstaller.utils.hooks import collect_submodules + + +project_root = Path(SPECPATH).parents[1] +hidden_imports = collect_submodules('core.client') + collect_submodules('core.tools') + +a = Analysis( + [str(project_root / 'start_client.py')], + pathex=[str(project_root)], + binaries=[], + datas=[], + hiddenimports=hidden_imports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[str(project_root / 'build_hook.py')], + excludes=['core.server'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='CapsWriter', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch='arm64', + codesign_identity=None, + entitlements_file=None, +) + +collection = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='CapsWriter', +) + +app = BUNDLE( + collection, + name='CapsWriter.app', + icon=str(project_root / 'assets' / 'icon.ico'), + bundle_identifier='io.github.alex-ghost599.capswriter-offline.client', + info_plist={ + 'CFBundleDisplayName': 'CapsWriter', + 'NSHighResolutionCapable': True, + 'NSMicrophoneUsageDescription': 'CapsWriter 需要使用麦克风进行离线语音转文字。', + }, +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..26a48c4b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[project] +name = "capswriter-offline" +version = "2.6.0" +description = "Offline speech-to-text client and server" +readme = "readme.md" +requires-python = ">=3.12,<3.13" +license = { file = "LICENSE" } +dependencies = [ + "colorama", + "gguf", + "httpx", + "keyboard; sys_platform == 'win32'", + "markdown", + "numba", + "numpy", + "ollama", + "onnxruntime; sys_platform != 'win32'", + "onnxruntime-directml; sys_platform == 'win32'", + "openai", + "pillow", + "pyclip", + "pynput", + "pyobjc-framework-avfoundation; sys_platform == 'darwin'", + "pyobjc-framework-cocoa; sys_platform == 'darwin'", + "pyobjc-framework-quartz; sys_platform == 'darwin'", + "pypinyin", + "pystray", + "rapidfuzz", + "rich", + "sentencepiece", + "sherpa-onnx", + "scipy", + "sounddevice", + "soundfile", + "soxr>=1.1.0", + "srt", + "tkhtmlview", + "typer", + "watchdog", + "websockets", +] + +[dependency-groups] +dev = [ + "psutil", + "pyinstaller", + "pytest", + "pytest-asyncio", + "ruff", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +pythonpath = ["."] + +[tool.ruff] +target-version = "py312" +line-length = 120 diff --git a/requirements-client.txt b/requirements-client.txt index 1ffbdcac..1610e8c3 100644 --- a/requirements-client.txt +++ b/requirements-client.txt @@ -5,12 +5,17 @@ colorama markdown # system, input, hardware -keyboard +keyboard; sys_platform == "win32" pynput pyclip sounddevice watchdog +# macOS native input and clipboard backends +pyobjc-framework-AVFoundation; sys_platform == "darwin" +pyobjc-framework-Cocoa; sys_platform == "darwin" +pyobjc-framework-Quartz; sys_platform == "darwin" + # network and api websockets openai @@ -20,6 +25,8 @@ httpx # data process numpy numba +scipy +soxr pypinyin srt rapidfuzz @@ -30,4 +37,4 @@ Pillow tkhtmlview # build -pyinstaller \ No newline at end of file +pyinstaller diff --git a/requirements-server.txt b/requirements-server.txt index 47cc78b9..ad30fbdf 100644 --- a/requirements-server.txt +++ b/requirements-server.txt @@ -2,7 +2,11 @@ sherpa-onnx numpy gguf -onnxruntime-directml +onnxruntime-directml; sys_platform == "win32" +onnxruntime; sys_platform != "win32" +sentencepiece +soundfile +srt # basic rich @@ -15,4 +19,4 @@ pystray Pillow markdown -tkhtmlview \ No newline at end of file +tkhtmlview diff --git a/scripts/download_macos_models.py b/scripts/download_macos_models.py new file mode 100644 index 00000000..4c3a117e --- /dev/null +++ b/scripts/download_macos_models.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Download and verify the default macOS ASR and punctuation models.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import stat +import tempfile +import urllib.request +import zipfile +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class ModelAsset: + name: str + url: str + sha256: str + install_path: Path + required_file: Path + + +ASSETS = ( + ModelAsset( + name='Paraformer.zip', + url='https://github.com/HaujetZhao/CapsWriter-Offline/releases/download/models/Paraformer.zip', + sha256='a12a3f9791483329441c94ad759cbcf258d7246784a6d368cd3c591add4d888b', + install_path=Path('models/Paraformer/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx'), + required_file=Path('model.onnx'), + ), + ModelAsset( + name='Punct-CT-Transformer.zip', + url='https://github.com/HaujetZhao/CapsWriter-Offline/releases/download/models/Punct-CT-Transformer.zip', + sha256='de106e6cf13764bd3124f31864bc30158f04961788765b63262bdd5ba21fa421', + install_path=Path( + 'models/Punct-CT-Transformer/' + 'sherpa-onnx-punct-ct-transformer-zh-en-vocab272727-2024-04-12' + ), + required_file=Path('model.onnx'), + ), +) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def download_file(url: str, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_suffix(destination.suffix + '.part') + request = urllib.request.Request(url, headers={'User-Agent': 'CapsWriter-Offline-macOS-setup'}) + try: + with urllib.request.urlopen(request, timeout=60) as response, temporary.open('wb') as output: + shutil.copyfileobj(response, output, length=1024 * 1024) + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + + +def safe_extract(archive: Path, destination: Path) -> None: + destination = destination.resolve() + with zipfile.ZipFile(archive) as bundle: + for member in bundle.infolist(): + member_path = (destination / member.filename).resolve() + if destination not in member_path.parents and member_path != destination: + raise ValueError(f'压缩包包含越界路径:{member.filename}') + mode = member.external_attr >> 16 + if stat.S_ISLNK(mode): + raise ValueError(f'压缩包包含不允许的符号链接:{member.filename}') + bundle.extractall(destination) + + +def install_asset(asset: ModelAsset, project_root: Path, cache_dir: Path, force: bool = False) -> Path: + install_path = project_root / asset.install_path + required_file = install_path / asset.required_file + manifest_path = install_path / '.capswriter-install.json' + if required_file.is_file() and manifest_path.is_file() and not force: + try: + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + except (json.JSONDecodeError, OSError): + manifest = {} + if ( + manifest.get('sha256') == asset.sha256 + and manifest.get('required_file_sha256') == sha256_file(required_file) + ): + print(f'[已安装] {asset.name}: {install_path}') + return install_path + + archive = cache_dir / asset.name + if not archive.is_file() or sha256_file(archive) != asset.sha256: + archive.unlink(missing_ok=True) + print(f'[下载] {asset.url}') + download_file(asset.url, archive) + + actual_hash = sha256_file(archive) + if actual_hash != asset.sha256: + raise RuntimeError(f'{asset.name} SHA-256 不匹配:期望 {asset.sha256},实际 {actual_hash}') + print(f'[校验通过] {asset.name}: {actual_hash}') + + install_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='capswriter-model-', dir=install_path.parent) as temporary: + extraction_root = Path(temporary) + safe_extract(archive, extraction_root) + extracted_path = extraction_root / install_path.name + if not (extracted_path / asset.required_file).is_file(): + raise RuntimeError(f'{asset.name} 中缺少 {asset.required_file}') + if install_path.exists(): + shutil.rmtree(install_path) + shutil.move(str(extracted_path), install_path) + + manifest = { + **asdict(asset), + 'install_path': str(asset.install_path), + 'required_file': str(asset.required_file), + 'required_file_sha256': sha256_file(required_file), + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + print(f'[安装完成] {asset.name}: {install_path}') + return install_path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cache-dir', type=Path, help='模型 ZIP 缓存目录') + parser.add_argument('--force', action='store_true', help='重新校验并安装模型') + return parser.parse_args() + + +def main() -> int: + args = parse_args() + project_root = Path(__file__).resolve().parents[1] + cache_dir = (args.cache_dir or project_root / '.cache' / 'capswriter-models').expanduser().resolve() + for asset in ASSETS: + install_asset(asset, project_root, cache_dir, force=args.force) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..7972d748 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1255 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "capswriter-offline" +version = "2.6.0" +source = { virtual = "." } +dependencies = [ + { name = "colorama" }, + { name = "gguf" }, + { name = "httpx" }, + { name = "keyboard", marker = "sys_platform == 'win32'" }, + { name = "markdown" }, + { name = "numba" }, + { name = "numpy" }, + { name = "ollama" }, + { name = "onnxruntime", marker = "sys_platform != 'win32'" }, + { name = "onnxruntime-directml", marker = "sys_platform == 'win32'" }, + { name = "openai" }, + { name = "pillow" }, + { name = "pyclip" }, + { name = "pynput" }, + { name = "pyobjc-framework-avfoundation", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pypinyin" }, + { name = "pystray" }, + { name = "rapidfuzz" }, + { name = "rich" }, + { name = "scipy" }, + { name = "sentencepiece" }, + { name = "sherpa-onnx" }, + { name = "sounddevice" }, + { name = "soundfile" }, + { name = "soxr" }, + { name = "srt" }, + { name = "tkhtmlview" }, + { name = "typer" }, + { name = "watchdog" }, + { name = "websockets" }, +] + +[package.dev-dependencies] +dev = [ + { name = "psutil" }, + { name = "pyinstaller" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "colorama" }, + { name = "gguf" }, + { name = "httpx" }, + { name = "keyboard", marker = "sys_platform == 'win32'" }, + { name = "markdown" }, + { name = "numba" }, + { name = "numpy" }, + { name = "ollama" }, + { name = "onnxruntime", marker = "sys_platform != 'win32'" }, + { name = "onnxruntime-directml", marker = "sys_platform == 'win32'" }, + { name = "openai" }, + { name = "pillow" }, + { name = "pyclip" }, + { name = "pynput" }, + { name = "pyobjc-framework-avfoundation", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pypinyin" }, + { name = "pystray" }, + { name = "rapidfuzz" }, + { name = "rich" }, + { name = "scipy" }, + { name = "sentencepiece" }, + { name = "sherpa-onnx" }, + { name = "sounddevice" }, + { name = "soundfile" }, + { name = "soxr", specifier = ">=1.1.0" }, + { name = "srt" }, + { name = "tkhtmlview" }, + { name = "typer" }, + { name = "watchdog" }, + { name = "websockets" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "psutil" }, + { name = "pyinstaller" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "evdev" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "gguf" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ae/17f1308ae45cd7b08ebb521747d5b23f4efc4d172038a4e228dd5106c3ff/gguf-0.19.0.tar.gz", hash = "sha256:dbadcd6cc7ccd44256f2229fe7c2dff5e8aa5cf0612ab987fd2b1a57e428923f", size = 111220, upload-time = "2026-05-06T13:04:03.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/bb/d71d6da82763528c2c2ed6b59a9d6142c6595545a4c448e2085d155e88c2/gguf-0.19.0-py3-none-any.whl", hash = "sha256:70bcd10edfe697fb2dad6e40af2234b9d8ece9a41a99761405121ebda1c3c1cd", size = 118475, upload-time = "2026-05-06T13:04:02.588Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + +[[package]] +name = "keyboard" +version = "0.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/75/c969f2258e908c39aadfc57d1cb78247dc49e6d36371bb3a48c194640c01/keyboard-0.13.5.zip", hash = "sha256:63ed83305955939ca5c9a73755e5cc43e8242263f5ad5fd3bb7e0b032f3d308b", size = 71798, upload-time = "2020-03-23T21:47:06.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/88/287159903c5b3fc6d47b651c7ab65a54dcf9c9916de546188a7f62870d6d/keyboard-0.13.5-py3-none-any.whl", hash = "sha256:8e9c2422f1217e0bd84489b9ecd361027cc78415828f4fe4f88dd4acd587947b", size = 58098, upload-time = "2020-03-23T21:47:05.023Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/a0/acc8ffcd5bdc63df0097e22c719bfcd61b604358343089313a8aebbb24ab/llvmlite-0.48.0.tar.gz", hash = "sha256:543b19f9ef8f3c7c60d1468191e4ee1b1537bf9f8a3d56f64c0ddd98de92edd2", size = 184016, upload-time = "2026-07-02T20:20:05.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/a2/28696a9e61e245d1a79816d29d106692a90a2b6e7d78c98b326db70827af/llvmlite-0.48.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:d66c3beb4209087ddd4cf4ed2a0856b6887e6a913bdcf1aacfec9851cf2cba4e", size = 40480651, upload-time = "2026-07-01T18:41:35.694Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/72409351db66d0a317ec5087e076f31fb7b773a640db8a90ce6b5cac9edd/llvmlite-0.48.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:416fa4c2c66c2c6dc6d0a402648c19206e548efa0aa1eff01ad5cdad0af8217d", size = 59890118, upload-time = "2026-07-01T18:41:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/3a/27/5ae2f3722606360480707adb47f001ad89df8251d06b14ee80336e660b66/llvmlite-0.48.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5e5a5131045b72345c71062ea1a91910dde913792b6c9b28ebb2c1c0a712e98", size = 58343459, upload-time = "2026-07-01T18:41:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/16/78/d824ffff7521cd140dc2006e44ce2bc82e64b48d1b32e90e956308c85a74/llvmlite-0.48.0-cp312-cp312-win_amd64.whl", hash = "sha256:d45c7541a80934ec6d8ab0defe67439494ecd2193cbf852a44ba827808976ac1", size = 41865022, upload-time = "2026-07-01T18:41:48.663Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "numba" +version = "0.66.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/a0/570e3dc53e5602b49108f62a13e529f1eec8bfc7ef37d49c825924dcf546/numba-0.66.0.tar.gz", hash = "sha256:b900e63a0e26c05ea9a6d5a3a5a0a177cb64c5011887bf43edb8c3ed2c38d363", size = 2806181, upload-time = "2026-07-01T23:12:46.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a3/70deb7f88461c1cd5d16aa990c2380604102661a427667b8950dcdccc27f/numba-0.66.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:53ca5900b7cab15109796030113a6b28576bae5ad7bb507ad6dd1360ddd81ba4", size = 2727264, upload-time = "2026-07-01T23:12:18.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/25c319845e9a4e08f16611ddbda56a192eb7b6ed13e1a2bff2da272ffb97/numba-0.66.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0999e3ee1b18c48e1fb51d11af35ef59852c7f4f50569c9550c25faef0616ad1", size = 3866252, upload-time = "2026-07-01T23:12:20.429Z" }, + { url = "https://files.pythonhosted.org/packages/71/ef/a82d6fd6bf1b0fe461651e924d3647eeec9ac17f8eee4896264bf7480930/numba-0.66.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efe0d2d5099790df945e0cb6e1b3104bd965d7bbfac50d62f1d5d1d6ade0825d", size = 3566974, upload-time = "2026-07-01T23:12:22.116Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/9e6171e378822ab191c7abcfd3d8cfc8644516f6c7834c22e210e4acc070/numba-0.66.0-cp312-cp312-win_amd64.whl", hash = "sha256:b075a4e7ebc43dc6294f223e2821659656209fd5e0ce53245877c23d66d6e1a9", size = 2797403, upload-time = "2026-07-01T23:12:23.724Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, +] + +[[package]] +name = "ollama" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/72/5f12423b6b39ca8430fbe56f77fcf4ef60f63067c7c4a2e30e200ed9ec16/ollama-0.6.2.tar.gz", hash = "sha256:936d55daa684f474364c098611c933626f8d6c7d67065c5b7ae0c477b508b07f", size = 53145, upload-time = "2026-04-29T21:21:15.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/d6722beeb2d10f7a3b9ff49375708904fde18f82b5609a0bc4aeb5996a4d/ollama-0.6.2-py3-none-any.whl", hash = "sha256:3ad7daab28e5a973445c36a73882a3ef698c2ebb00e21e308652741577509f7d", size = 15115, upload-time = "2026-04-29T21:21:13.794Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/b7/dd3a524ed93a820dff1af902d0412957ab12499953333e9daa01af5bc480/onnxruntime-1.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a14c2ce45312def86b77aea651f46565e45960cf5f0721bfdff449165086ab76", size = 18433506, upload-time = "2026-06-15T22:43:47.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/c3b6b17745a1997d784dadc9bd88d713d2e6721139a5a0e885b28cfb79b1/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fddce0539a4898c7bef35b052ffd37935b2190e35488eab99ce91887743ea1", size = 16438140, upload-time = "2026-06-15T22:42:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/26/81/24dd9b31b0fb912ee19ca53ac1c9764bfd79d58a2ccef564eb693be831a5/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c65a7438632d55dfbc8a02ee60bd6cf7dd9d1ba05a43d4b851452f32338e194", size = 18658316, upload-time = "2026-06-15T22:43:04.012Z" }, +] + +[[package]] +name = "onnxruntime-directml" +version = "1.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ea/33814eb0ec96775eda4c1d30b0d86e91d7d2cd0d84c66d3915aef0e06fa3/onnxruntime_directml-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:f2ecb68b7b7b259d2ef3112ae760149f9b5a1e7c0fbb73d539da6250a648a614", size = 25111930, upload-time = "2026-03-17T21:47:18.419Z" }, +] + +[[package]] +name = "openai" +version = "2.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/ac/f725c4efbda8657d02be684607e5a2e5ce362e4790fdbcbdfb7c15018647/openai-2.46.0.tar.gz", hash = "sha256:0421e0735ac41451cad894af4cddf0435bfbf8cbc538ac0e15b3c062f2ddc06a", size = 1114628, upload-time = "2026-07-17T02:48:06.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/7b/206238ebcb50b235942b1c66dba4974776f2057402a8d91c399be587d66a/openai-2.46.0-py3-none-any.whl", hash = "sha256:672381db55efb3a1e2610f29304c130cccdd0b319bace4d492b2443cb64c1e7c", size = 1637556, upload-time = "2026-07-17T02:48:03.695Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pasteboard" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/43/adc39246de74e9c482c11fbe6a50e8bc7f9dc367fdc261f6f767de5b1419/pasteboard-0.3.3.tar.gz", hash = "sha256:d80275e76ff1eaaa5ca3d0e0fd8aecd5ea1298523dfd51f3774b5aaebdde02cf", size = 13540, upload-time = "2021-01-29T08:10:43.131Z" } + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyclip" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pasteboard", marker = "sys_platform == 'darwin'" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/8a/98ed02d81dea4b2211a4746724d7a271264b701c991fed503c246de52864/pyclip-0.7.0.tar.gz", hash = "sha256:57602047a4ceab709bdcd42f3dde6449a3349b95c16154cfdce27376a2072491", size = 13639, upload-time = "2022-10-31T21:13:34.952Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/4f/4b02e2c232ddf694bca0baea3049b886eeb80ee3d89c415067f5191596c1/pyclip-0.7.0-py3-none-any.whl", hash = "sha256:a7b7ea2cfad2dd61e86a529af588d006070d3eb0bd32981afaee5215f4604fec", size = 18864, upload-time = "2022-10-31T21:13:33.459Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" }, + { url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" }, +] + +[[package]] +name = "pynput" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/c6/e2d415610cfbc78308bee44218a46124aaa3301b1df08814df819b2254a1/pynput-1.8.2.tar.gz", hash = "sha256:f493c87157cd3861b4468f7f896857051762f44ed26f1b641e7cc5840a457087", size = 82818, upload-time = "2026-05-12T19:11:39.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/98/bbeb760852adb27f166ce1617f0e51aabb15f21b1e60ea703f2aed3c78ac/pynput-1.8.2-py2.py3-none-any.whl", hash = "sha256:8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1", size = 92028, upload-time = "2026-05-12T19:11:37.89Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/89/39a7462006afbc06c69029fe4181b7359a9da25ae7864ef75f9d3ffb9272/pyobjc_framework_applicationservices-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b", size = 32764, upload-time = "2026-06-19T16:05:39.26Z" }, +] + +[[package]] +name = "pyobjc-framework-avfoundation" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coreaudio" }, + { name = "pyobjc-framework-coremedia" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/26/7616f0bc8e4eaaba948cf5d220c8f55e0f54f617a2812392a82f19c30f39/pyobjc_framework_avfoundation-12.2.1.tar.gz", hash = "sha256:2735e4f1c345d2b533541577e292f3ad2f75d19200eff99f1a2db16d78b4f1a3", size = 410329, upload-time = "2026-06-19T16:19:52.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/bb/e427b2fd705e9dabbfa0ab89b863305788906e34064db2bb930f1c3ba216/pyobjc_framework_avfoundation-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f372800274df35f8d964cad0ad6da7636f1b6720e802e28e5a6bc1f16f16f135", size = 85578, upload-time = "2026-06-19T16:06:22.292Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, +] + +[[package]] +name = "pyobjc-framework-coreaudio" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/df/f1d402bb7b437374f942bb19410a955a74291867c77a920d9c13887d9e48/pyobjc_framework_coreaudio-12.2.1.tar.gz", hash = "sha256:7dfbf1851523aed453af43a628e057d8950d6e020574aa497a2e4f559b6383c8", size = 78690, upload-time = "2026-06-19T16:20:10.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/c1/1fc7a344ac15646fdf78c91af81028113c87375036e505082cf1f90bd657/pyobjc_framework_coreaudio-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:41a0d8c3b3957f35f17370071aeef94e176d07c9e2130d0aeeb4b260117acd52", size = 35415, upload-time = "2026-06-19T16:08:11.329Z" }, +] + +[[package]] +name = "pyobjc-framework-coremedia" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/79/f501d730a9c320e0b2b3916e95f57e66dd6736d210a1aa5b63eb6c43e605/pyobjc_framework_coremedia-12.2.1.tar.gz", hash = "sha256:71b45f7cd52bd997d836c15a0e1016db90815a219dc87fd20435a6f08b87df7b", size = 98252, upload-time = "2026-06-19T16:20:15.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/17/6bf365530573a6b7719b5a47efe6c38ac8c42f6b556babe419f5de48a84c/pyobjc_framework_coremedia-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fd0e25bc704dd91882bc29d682880c42f712b39bdfe3d3168780d24a9d9eaf26", size = 29419, upload-time = "2026-06-19T16:08:53.52Z" }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/11/c1298c2ec3b0cd19a457a1fd0da47898f894a13df5516f80dc04d1a7a4d9/pyobjc_framework_coretext-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6", size = 30123, upload-time = "2026-06-19T16:09:51.183Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, +] + +[[package]] +name = "pypinyin" +version = "0.55.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836, upload-time = "2025-07-20T12:01:50.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203, upload-time = "2025-07-20T12:01:48.535Z" }, +] + +[[package]] +name = "pystray" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/64/927a4b9024196a4799eba0180e0ca31568426f258a4a5c90f87a97f51d28/pystray-0.19.5-py2.py3-none-any.whl", hash = "sha256:a0c2229d02cf87207297c22d86ffc57c86c227517b038c0d3c59df79295ac617", size = 49068, upload-time = "2023-09-17T13:44:26.872Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, +] + +[[package]] +name = "sentencepiece" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/33/ea3cb3839607eb175da835244a798f797f478c5ddf0e8ecdf57ea85a4c70/sentencepiece-0.2.2.tar.gz", hash = "sha256:3d2b5e824b5622038dc7b490897efe05ebbbb9e7350fc142f3ecc8789ef9bdf6", size = 8218435, upload-time = "2026-07-12T08:39:34.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/13/7a562289c8d5b49ebdf3f9c1e8ab67cf14a8743b1d90c8f406bfdec36b72/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1edb10e520e4bddf74d85b0f5ae74cc2d60c2b448885080bfb618bc2b3a49f6b", size = 2188384, upload-time = "2026-07-12T08:38:28.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/d1/912f14fd5eae168aba726ffb6a9a2dc1c71fe7676c53da6f5c442b886d4a/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7c06c751c19d923435a54bff4f7e66e728fad160e8da28254f133abc9725820", size = 1441553, upload-time = "2026-07-12T08:38:30.552Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/caa9cab5f261a019e2808bc5046152775dc57352ba9cbae7525e9e7a1ed4/sentencepiece-0.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38111ed1f79268f399c505028023d5eaaf0ab4e5eafceb709468b0d3323e7838", size = 1347176, upload-time = "2026-07-12T08:38:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/cd798935668cff71d309d8ff10385844ecf216b1fe454f1993ed8bf2cb91/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbce24284f51f71d10a42b7b9c964dcb9048b28f1c8e5db40bcbcb6f428cba6a", size = 1325200, upload-time = "2026-07-12T08:38:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/37e3da037318a70066ded0d51bc2a7f35491ae6338dd993d5eb1503fc3b5/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a168b040bc61681293f79a949b5d911c8e25086f4260285b8d97ab5f1195da", size = 1397736, upload-time = "2026-07-12T08:38:35.771Z" }, + { url = "https://files.pythonhosted.org/packages/8d/11/753fca2e6b109be3ab7867abf357dfe48677fe726ae5a5363d0b54ca9450/sentencepiece-0.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7c6e7bf684dc12145bfa685d3060beaea55139134ba848289bee514ed42e7383", size = 1248030, upload-time = "2026-07-12T08:38:37.604Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0a/70efbe861ca182d7d4b6e1a20f58e043400848fa9f2915229f082e221648/sentencepiece-0.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:76ff5814db72e7462dece042d7593cdf102b8ec82c2b1cc201a2add34ee3050d", size = 1187325, upload-time = "2026-07-12T08:38:39.348Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/f8/735244770b4bc63f85fabdad0e46d6ec1f4cc24e64f6e082c2e0fea92b8c/sherpa_onnx-1.13.4.tar.gz", hash = "sha256:29547692418513ad88034c2b5f98985e33042b2351e4ab375469f19a8de18c5f", size = 982750, upload-time = "2026-07-07T13:04:55.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/57/179e3a6c1fec33aa6535051feddd5da36e5622d35630b12a67a2805b76b3/sherpa_onnx-1.13.4-cp312-cp312-linux_armv7l.whl", hash = "sha256:bcf64f2d853a1afe236e9e220df62f2f53ef6ad792ca7e406d6173ec003319b8", size = 11933213, upload-time = "2026-07-07T13:50:35.989Z" }, + { url = "https://files.pythonhosted.org/packages/e8/34/b6d3483b08ec8a4a141e978c4b92530fd0a61dd571a575c1fe24bee300d7/sherpa_onnx-1.13.4-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:2257545ea170f58b7977309793979d6d078761b7fdb0528561285f8ead4169db", size = 4422157, upload-time = "2026-07-07T12:29:40.234Z" }, + { url = "https://files.pythonhosted.org/packages/82/37/07f03e97f157b206f6e62d722ec7c5ff41c7e9dc6aa2dc7de69b57e39b5a/sherpa_onnx-1.13.4-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:02b57dc2c829976eb842e6aee6a0e4ac3b9991aeb5afa89fd44eb71d848a4ecd", size = 2345135, upload-time = "2026-07-07T12:10:19.957Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/ee999f0c3b7789077d0939716a38234573d139f851a31409aa028fe2c610/sherpa_onnx-1.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:84e58b5a074b97c5307c9b6221d1d20fbf412a1a5dff4960ca9c32bb5184219f", size = 2105219, upload-time = "2026-07-07T11:48:22.518Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/9b67ed3e7adc79daf0ba49c4936a691521488125b04fe469b64a8b5398ff/sherpa_onnx-1.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f709e6dd02ebf7d37dcb02d5eadc5fb66c9922dd5809df770c1ef5d625ae7a44", size = 4135963, upload-time = "2026-07-07T11:58:30.98Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b1/8dfe5d1d72c92ea1c95db999a95b61bfbb9769f1c569f06e572eda095c52/sherpa_onnx-1.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f0158f3513d3adab1ebba0c26f0c815e53ba13b96846d92ef095ae25d648860", size = 4358555, upload-time = "2026-07-07T12:58:59.654Z" }, + { url = "https://files.pythonhosted.org/packages/d4/04/cfd543933ae24430d124e533847c73a84a5f0efd60f07bbc6403032f9624/sherpa_onnx-1.13.4-cp312-cp312-win32.whl", hash = "sha256:d49928a3455bae1dd4e93f6b013cfbd2c3ccb5cde74aabae3710b656e7d79b6b", size = 1930647, upload-time = "2026-07-07T12:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bb/1e723ab703a1e354f390de19981ec0c347576f87be01915d826dc6fc9f41/sherpa_onnx-1.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:b8436ffe2763b3fd522fbac8fe53f47d611721c84819c241acfb65d122403d7d", size = 2244142, upload-time = "2026-07-07T13:01:42.293Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sounddevice" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, +] + +[[package]] +name = "soundfile" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/d1/5e338af9ca6ed0786cd5bb03f6d60de1c325728c1189014f3b59aae7403c/soundfile-0.14.0-py2.py3-none-any.whl", hash = "sha256:8ba81ae3a89fd5ab3bef8a8eb481fbbe794e806309675a89b4df48b8d31908a8", size = 26799, upload-time = "2026-06-06T08:58:33.269Z" }, + { url = "https://files.pythonhosted.org/packages/7e/72/c6b21e58d3113596e7e8de0a08d6f1d95173492cfbca0a4db14148cbba2a/soundfile-0.14.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:19be05428da76ed61a4cad29b8e4bcf43a3e5c100089d2ec81dc961eed1b0dd4", size = 1144568, upload-time = "2026-06-06T08:58:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c", size = 1103726, upload-time = "2026-06-06T08:58:37.948Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f8/fc39fad6f879633461d27394cd1ddaf1f769ffa0597dca35872f51b16461/soundfile-0.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e85724a90bc99a6e8062c0b4ddf725f53b2a3b70afd4da875e9d2cfc4e92f377", size = 1238050, upload-time = "2026-06-06T08:58:39.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a2/70fd4432b924684c372df8b0a45708c36c057ef3596c9eb53e0a806b980b/soundfile-0.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1e38bac1853412871318e82a1ba69a8be677619b56025bbfcccdb41b6cafe82d", size = 1315963, upload-time = "2026-06-06T08:58:41.716Z" }, + { url = "https://files.pythonhosted.org/packages/d9/34/c9e80783d83eab739a9531fdee03675d53e0bf1b2ccb4bb3af5844675046/soundfile-0.14.0-py2.py3-none-win32.whl", hash = "sha256:0a6ae43c50c71b4e020cc55382925cb89451c1ed1a0c3d0f5d802da269226849", size = 902199, upload-time = "2026-06-06T08:58:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/ed/97/b39c18ac1df45e755ca22b8b00e872929da5d107998a207a5e4ac831bfda/soundfile-0.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:299491d3499460fb1b74bb4bd78b57ffc2d243a5fafa7b6ec1b264875c78453e", size = 1021480, upload-time = "2026-06-06T08:58:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/f4/83/55c65e61cf457805ce2ec157c1c6ae17715d0851aa2374422de0538838ca/soundfile-0.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:e090704718e124e7c844695236f1fce8d18a5e761eaf7c82dfcd124620805f98", size = 888858, upload-time = "2026-06-06T08:58:46.593Z" }, +] + +[[package]] +name = "soxr" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/11/27cebce4a108f77afea7c80545115536b45e3f11ebfb914f638fdd9ba847/soxr-1.1.0.tar.gz", hash = "sha256:9f228ae21c78fa9359ca98d8a5e8e91f30639e438e574133dace62c5b5309e44", size = 173067, upload-time = "2026-05-03T00:15:18.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/f3da7973b5f1b05d2d7e94d5376b881dcbc05297900cae6c3d33d95b209b/soxr-1.1.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:e0e09fa633ce2e67df08b298afced4d184f6e753fc330f241022250f1d0d61da", size = 204124, upload-time = "2026-05-03T00:14:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/dc/200013a74641f8774664bbcd2346c695c05c2e300ea792adcb40a293eed0/soxr-1.1.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:d6a7ad82b8d5f3fcc04b1d2ca055562b96af571e1d4fa7c6c61d0fb509ac43b4", size = 165457, upload-time = "2026-05-03T00:14:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/88/2b/2e5eba817a762a2ec589ff165b8bc5955b25a0ad140045f7cd8e45410543/soxr-1.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf98c0d7b7d5ef5bf072fee8d3020e8b664f2d195933ea7bc5089267c2e22a06", size = 206529, upload-time = "2026-05-03T00:14:57.646Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/0e55195893228609c9a08c3b13b7a83a46c3a992cd00d3304f0f320cfb07/soxr-1.1.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b033078e86f3c4a658e5697fac8995764fad9e799563616b630136b613167f1", size = 240413, upload-time = "2026-05-03T00:14:59.363Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4d/621e4150e4815246ad552d215a8a294a90143fedd19ee442cf82d3b3abc8/soxr-1.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:6ae2a174bffea94e8ead857dad85999d3f49f091774dbad5b046c0417d7092f4", size = 174357, upload-time = "2026-05-03T00:15:00.724Z" }, +] + +[[package]] +name = "srt" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296, upload-time = "2023-03-28T02:35:44.007Z" } + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tkhtmlview" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/17/7e8acc5bf815f7be9639a5ffc5eaafbb7f9a3c2e6cfcc8114c7397aba9d8/tkhtmlview-0.3.2.tar.gz", hash = "sha256:e9f34def321585407a19b40a69c63cffa4e4acadff1ccf8b81a4e27afc9bace0", size = 8765, upload-time = "2026-02-12T13:26:36.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a2/4b53d0d9a5eca110c774f4f3921c166f8fd4ca952f5f6b2a1d680a1a9b2d/tkhtmlview-0.3.2-py3-none-any.whl", hash = "sha256:21f4492e98e3800a7ef5db223d201430aff3d855cf1dab3e40f17c1acc2e6739", size = 9958, upload-time = "2026-02-12T13:26:35.851Z" }, +] + +[[package]] +name = "tqdm" +version = "4.69.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] From 287200d090a1be9f9e6d48153dbd76867ee3c9be Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:43:22 +0800 Subject: [PATCH 2/5] feat: add verified macOS runtime --- .github/workflows/windows.yml | 25 +++ config_client.py | 82 ++++++---- config_server.py | 20 ++- core/client/__init__.py | 10 +- core/client/app.py | 50 ++++-- core/client/audio/file_manager.py | 36 +++-- core/client/audio/recorder.py | 100 ++++++++---- core/client/audio/stream.py | 43 +++-- core/client/clipboard/clipboard.py | 112 +++++++++++-- core/client/llm/llm_get_selection.py | 5 +- core/client/llm/llm_output_typing.py | 28 +++- core/client/macos_permissions.py | 131 +++++++++++++++ core/client/manager/file_runner.py | 11 +- core/client/manager/mic_runner.py | 32 +++- core/client/output/result_processor.py | 18 ++- core/client/output/text_output.py | 43 ++--- core/client/platform_input.py | 38 +++++ core/client/shortcut/key_mapper.py | 36 +++-- core/client/shortcut/shortcut_manager.py | 76 +++++++-- core/client/state.py | 5 +- core/client/transcribe/media_tool.py | 13 +- core/client/ui/tips.py | 40 ++++- core/server/connection/server_manager.py | 10 +- core/server/connection/ws_recv.py | 94 +++++++++-- .../engines/ct_transformer/punc_engine.py | 5 +- .../engines/paraformer_onnx/asr_engine.py | 14 +- core/server/engines/sherpa_runtime.py | 49 ++++++ core/server/worker/model_loader.py | 14 +- core/tools/external_tools.py | 28 ++++ scripts/lint_macos_port.sh | 34 ++++ tests/test_audio_file_manager.py | 79 +++++++++ tests/test_audio_resampling.py | 45 ++++++ tests/test_cross_platform_regression.py | 151 ++++++++++++++++++ tests/test_external_tools.py | 26 +++ tests/test_file_runner.py | 62 +++++++ tests/test_key_mapper.py | 11 ++ tests/test_macos_clipboard.py | 64 ++++++++ tests/test_macos_permissions.py | 21 +++ tests/test_mic_runner.py | 37 +++++ tests/test_model_download.py | 58 +++++++ tests/test_server_input_limits.py | 58 +++++++ tests/test_sherpa_runtime.py | 12 ++ 42 files changed, 1589 insertions(+), 237 deletions(-) create mode 100644 .github/workflows/windows.yml create mode 100644 core/client/macos_permissions.py create mode 100644 core/client/platform_input.py create mode 100644 core/server/engines/sherpa_runtime.py create mode 100644 core/tools/external_tools.py create mode 100755 scripts/lint_macos_port.sh create mode 100644 tests/test_audio_file_manager.py create mode 100644 tests/test_audio_resampling.py create mode 100644 tests/test_cross_platform_regression.py create mode 100644 tests/test_external_tools.py create mode 100644 tests/test_file_runner.py create mode 100644 tests/test_key_mapper.py create mode 100644 tests/test_macos_clipboard.py create mode 100644 tests/test_macos_permissions.py create mode 100644 tests/test_mic_runner.py create mode 100644 tests/test_model_download.py create mode 100644 tests/test_server_input_limits.py create mode 100644 tests/test_sherpa_runtime.py diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..00dc8597 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,25 @@ +name: Windows regression + +on: + push: + branches: [develop, 'feat--**'] + pull_request: + branches: [develop] + +permissions: + contents: read + +jobs: + test: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + version: '0.11.29' + enable-cache: true + - run: uv python install 3.12 + - run: uv sync --all-groups --frozen + - run: uv run python -c "from core.client.app import CapsWriterClient; from core.client.shortcut.key_mapper import KeyMapper" + - run: uv run pytest -q diff --git a/config_client.py b/config_client.py index 2a71df18..c64a7b74 100644 --- a/config_client.py +++ b/config_client.py @@ -1,44 +1,67 @@ import os -from collections.abc import Iterable +import sys from pathlib import Path # 版本信息 __version__ = '2.6' -# 项目根目录 -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +def _default_base_dir() -> Path: + if sys.platform == 'darwin' and getattr(sys, 'frozen', False): + return Path.home() / 'Library' / 'Application Support' / 'CapsWriter-Offline' + return Path(__file__).resolve().parent + + +def _macos_shortcut_key() -> str: + return os.environ.get('CAPSWRITER_HOTKEY', 'shift_r').strip() or 'shift_r' + + +# 源码运行时使用仓库目录;macOS App 使用可写的 Application Support 目录。 +BASE_DIR = str(Path(os.environ.get('CAPSWRITER_HOME', _default_base_dir())).expanduser().resolve()) +Path(BASE_DIR).mkdir(parents=True, exist_ok=True) # 客户端配置 class ClientConfig: - addr = '127.0.0.1' # Server 地址 - port = '6016' # Server 端口 + addr = os.environ.get('CAPSWRITER_SERVER_ADDR', '127.0.0.1') + port = os.environ.get('CAPSWRITER_SERVER_PORT', '6016') # 快捷键配置列表 - shortcuts = [ - { - 'key': 'caps_lock', # 监听大写锁定键 - 'type': 'keyboard', # 是键盘快捷键 - 'suppress': True, # 阻塞按键(短按会补发) - 'hold_mode': True, # 长按模式 - 'enabled': True # 启用此快捷键 - }, - { - 'key': 'x2', - 'type': 'mouse', - 'suppress': True, - 'hold_mode': True, - 'enabled': True - }, - ] + shortcuts = ( + [ + { + 'key': _macos_shortcut_key(), + 'type': 'keyboard', + 'suppress': False, + 'hold_mode': True, + 'enabled': True, + }, + ] + if sys.platform == 'darwin' + else [ + { + 'key': 'caps_lock', # 监听大写锁定键 + 'type': 'keyboard', # 是键盘快捷键 + 'suppress': True, # 阻塞按键(短按会补发) + 'hold_mode': True, # 长按模式 + 'enabled': True # 启用此快捷键 + }, + { + 'key': 'x2', + 'type': 'mouse', + 'suppress': True, + 'hold_mode': True, + 'enabled': True + }, + ] + ) threshold = 0.3 # 快捷键触发阈值(秒) - paste = False # 是否以写入剪切板然后模拟 Ctrl-V 粘贴的方式输出结果 + paste = sys.platform == 'darwin' # macOS 使用剪贴板与 Command-V 输出 restore_clip = True # 模拟粘贴后是否恢复剪贴板 - paste_apps = ['WeiXin.exe', 'Telegram.exe'] # 匹配时强制粘贴 + paste_apps = [] if sys.platform == 'darwin' else ['WeiXin.exe', 'Telegram.exe'] - enter_apps = [('happ.exe', 0.5), ('hexin.exe', 0.5)] # (应用名, 延迟秒数) 输出完成后自动回车,如同花顺,输入股票名后,需要回车才能切换 + enter_apps = [] if sys.platform == 'darwin' else [('happ.exe', 0.5), ('hexin.exe', 0.5)] save_audio = True # 是否保存录音文件 audio_name_len = 20 # 将录音识别结果的前多少个字存储到录音文件名中,建议不要超过200 @@ -48,7 +71,7 @@ class ClientConfig: trash_punc = ',。,.' # 识别结果要消除的末尾标点 trash_punc_thresh = 8 # 识别结果的单词数量低于阈值时,强制去除末尾标点 - trash_punc_apps = ['WeiXin.exe', ] # 对于指定的应用,强制去除末尾标点 + trash_punc_apps = [] if sys.platform == 'darwin' else ['WeiXin.exe', ] traditional_convert = False # 是否将识别结果转换为繁体中文 traditional_locale = 'zh-hant' # 繁体地区:'zh-hant'(标准繁体), 'zh-tw'(台湾繁体), 'zh-hk'(香港繁体) @@ -58,16 +81,20 @@ class ClientConfig: hot_similar = 0.6 # RAG 相似热词阈值(低阈值,用于 LLM 上下文) hot_rule = True # 是否启用自定义规则替换(基于正则表达式) - llm_enabled = True # 是否启用 LLM 润色功能,需要配置 LLM/ 目录下的角色文件 + llm_enabled = sys.platform != 'darwin' # macOS 首版先验证离线 ASR 主链路 llm_stop_key = 'esc' # 中断 LLM 输出的快捷键 - enable_tray = True # 客户端默认启用托盘图标功能 + enable_tray = sys.platform == 'win32' # 当前托盘生命周期仅适配 Windows # 日志配置 log_level = 'DEBUG' # 日志级别:'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' mic_seg_duration = 60 # 麦克风听写时分段长度:60秒 mic_seg_overlap = 4 # 麦克风听写时分段重叠:4秒 + audio_device = os.environ.get('CAPSWRITER_AUDIO_DEVICE') or None + if isinstance(audio_device, str) and audio_device.isdigit(): + audio_device = int(audio_device) + audio_sample_rate = int(os.environ.get('CAPSWRITER_AUDIO_SAMPLE_RATE', '48000')) file_seg_duration = 60 # 转录文件时分段长度 file_seg_overlap = 4 # 转录文件时分段重叠 @@ -130,4 +157,3 @@ class ClientConfig: {'key': 'f12', 'type': 'keyboard', 'suppress': True, 'hold_mode': True, 'enabled': True}, {'key': 'x2', 'type': 'mouse', 'suppress': True, 'hold_mode': True, 'enabled': True}, """ - diff --git a/config_server.py b/config_server.py index d1f06f10..278c11d9 100644 --- a/config_server.py +++ b/config_server.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path # 版本信息 @@ -10,16 +11,26 @@ # 服务端配置 class ServerConfig: - addr = '0.0.0.0' - port = '6016' + addr = os.environ.get('CAPSWRITER_SERVER_BIND', '127.0.0.1' if sys.platform == 'darwin' else '0.0.0.0') + port = os.environ.get('CAPSWRITER_SERVER_PORT', '6016') + max_message_bytes = 8 * 1024 * 1024 + max_connections = 4 + max_audio_chunk_bytes = 4 * 1024 * 1024 + max_audio_seconds = 4 * 60 * 60 + max_segment_duration = 300 + max_segment_overlap = 30 + max_context_chars = 4096 # 语音模型选择:'qwen_asr', 'fun_asr_nano', 'sensevoice', 'paraformer' - model_type = 'qwen_asr' + model_type = os.environ.get( + 'CAPSWRITER_MODEL_TYPE', + 'paraformer' if sys.platform == 'darwin' else 'qwen_asr', + ) format_num = True # 输出时是否将中文数字转为阿拉伯数字 format_spell = True # 输出时是否调整中英之间的空格 - enable_tray = True # 是否启用托盘图标功能 + enable_tray = sys.platform == 'win32' # 当前托盘实现依赖 Windows 控制台生命周期 hotwords_path = Path() / 'hot-server.txt' # 全局热词配置文件路径 # 日志配置 @@ -173,4 +184,3 @@ class ForceAlignerGGUFArgs: # 对齐细节 n_ctx = 3072 # 上下文窗口大小 dml_pad_to = 30 # 开启 DirectML 加速时,短音频统一填充到指定长度,有加速效果 - diff --git a/core/client/__init__.py b/core/client/__init__.py index 7a9fb2e1..57417b98 100644 --- a/core/client/__init__.py +++ b/core/client/__init__.py @@ -23,10 +23,14 @@ setup_logger('client', level=Config.log_level) logger = get_logger('client') -# 门面类 -from core.client.app import CapsWriterClient - __all__ = [ 'CapsWriterClient', ] + +def __getattr__(name): + if name == 'CapsWriterClient': + from core.client.app import CapsWriterClient + + return CapsWriterClient + raise AttributeError(name) diff --git a/core/client/app.py b/core/client/app.py index 7b04a4d7..d42e4461 100644 --- a/core/client/app.py +++ b/core/client/app.py @@ -13,11 +13,10 @@ from .state import ClientState from . import logger -from config_client import ClientConfig as Config, __version__ +from config_client import BASE_DIR, ClientConfig as Config from core.tools.signal_handler import register_signal from .state import console from .connection import WebSocketManager -from typing import TYPE_CHECKING, Optional from .manager import ( TrayManager, MicRunner, FileRunner @@ -32,8 +31,24 @@ from .llm.llm_handler import LLMHandler from .output.text_output import TextOutput from .diary.diary_writer import DiaryWriter +from .macos_permissions import MacOSPermissionError, show_macos_error, show_permission_error from core.tools.empty_working_set import empty_current_working_set -from platform import system + + +def _resolve_input_files(arguments: list[str], launch_dir: Path) -> list[Path]: + """Resolve command-line files before the client changes its working directory.""" + files = [] + for argument in arguments: + try: + path = Path(argument).expanduser() + if not path.is_absolute(): + path = launch_dir / path + path = path.resolve() + if path.is_file(): + files.append(path) + except (OSError, RuntimeError) as exc: + logger.warning(f'忽略无法解析的输入路径 {argument!r}: {exc}') + return files @@ -45,7 +60,8 @@ class CapsWriterClient: """ def __init__(self): # 确保正确的工作目录 - self.base_dir = Path(__file__).parents[2] + self.launch_dir = Path.cwd() + self.base_dir = Path(BASE_DIR) os.chdir(self.base_dir) # 初始化事件循环 @@ -124,7 +140,7 @@ def start(self): # 注册退出函数 register_signal(self.stop) - files = [Path(f) for f in sys.argv[1:] if os.path.exists(f)] + files = _resolve_input_files(sys.argv[1:], self.launch_dir) if files: # 文件转录模式 @@ -135,7 +151,23 @@ def start(self): try: self.loop.run_until_complete(runner.run()) - except RuntimeError: - ... - - + except MacOSPermissionError as exc: + logger.error(str(exc)) + console.print(f'[bold red]{exc}[/bold red]') + if sys.platform == 'darwin' and getattr(sys, 'frozen', False): + try: + show_permission_error(str(exc)) + except Exception: + logger.exception('macOS 权限错误弹窗显示失败') + raise SystemExit(2) from None + except RuntimeError as exc: + if str(exc) == 'Event loop stopped before Future completed.': + return + logger.exception('客户端运行异常') + console.print(f'[bold red]{exc}[/bold red]') + if sys.platform == 'darwin' and getattr(sys, 'frozen', False): + try: + show_macos_error(str(exc)) + except Exception: + logger.exception('macOS 运行错误弹窗显示失败') + raise SystemExit(1) from None diff --git a/core/client/audio/file_manager.py b/core/client/audio/file_manager.py index 9da51028..af08c031 100644 --- a/core/client/audio/file_manager.py +++ b/core/client/audio/file_manager.py @@ -9,18 +9,18 @@ from __future__ import annotations import re -import shutil import tempfile import time import wave from os import makedirs from pathlib import Path -from subprocess import DEVNULL, PIPE, Popen +from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired from typing import Optional, Tuple, Union import numpy as np from config_client import ClientConfig as Config +from core.tools.external_tools import find_executable from . import logger @@ -39,14 +39,14 @@ class AudioFileManager: - 重命名:根据识别文本重命名文件 """ - SAMPLE_RATE = 48000 - - def __init__(self): + def __init__(self, sample_rate: int = 48000): """初始化音频文件管理器""" + self.sample_rate = sample_rate self.file_path: Optional[Path] = None self.file_handle: Optional[AudioWriter] = None self.channels: int = 1 - self._has_ffmpeg = shutil.which('ffmpeg') is not None + self._ffmpeg = find_executable('ffmpeg') + self._has_ffmpeg = self._ffmpeg is not None if self._has_ffmpeg: logger.debug("检测到 FFmpeg,将使用 MP3 格式保存录音") @@ -82,10 +82,11 @@ def create(self, channels: int, time_start: float) -> Tuple[Path, AudioWriter]: if self._has_ffmpeg: # 使用 FFmpeg 输出 MP3 file_path = file_path.with_suffix('.mp3') + assert self._ffmpeg is not None ffmpeg_command = [ - 'ffmpeg', '-y', + self._ffmpeg, '-y', '-f', 'f32le', - '-ar', str(self.SAMPLE_RATE), + '-ar', str(self.sample_rate), '-ac', str(channels), '-i', '-', '-b:a', '192k', @@ -99,7 +100,7 @@ def create(self, channels: int, time_start: float) -> Tuple[Path, AudioWriter]: file_handle = wave.open(str(file_path), 'w') file_handle.setnchannels(channels) file_handle.setsampwidth(2) # 16-bit - file_handle.setframerate(self.SAMPLE_RATE) + file_handle.setframerate(self.sample_rate) logger.debug(f"创建 WAV 文件: {file_path}") self.file_path = file_path @@ -139,13 +140,26 @@ def finish(self) -> Optional[Path]: try: if isinstance(self.file_handle, Popen): - self.file_handle.stdin.close() - logger.debug("FFmpeg 进程已关闭") + process = self.file_handle + if process.stdin is not None: + process.stdin.close() + try: + return_code = process.wait(timeout=10) + except TimeoutExpired: + process.kill() + process.wait() + raise RuntimeError("FFmpeg 完成录音编码超时") from None + if return_code != 0: + raise RuntimeError(f"FFmpeg 录音编码失败,退出码 {return_code}") + logger.debug("FFmpeg 进程已完成") elif isinstance(self.file_handle, wave.Wave_write): self.file_handle.close() logger.debug("WAV 文件已关闭") except Exception as e: logger.error(f"关闭音频文件时发生错误: {e}") + if self.file_path is not None: + self.file_path.unlink(missing_ok=True) + return None finally: self.file_handle = None diff --git a/core/client/audio/recorder.py b/core/client/audio/recorder.py index 673bc0d5..8d6caa00 100644 --- a/core/client/audio/recorder.py +++ b/core/client/audio/recorder.py @@ -8,13 +8,12 @@ from __future__ import annotations -import asyncio import base64 import uuid from typing import TYPE_CHECKING, Optional import numpy as np -import websockets +import soxr from config_client import ClientConfig as Config from core.client.state import console @@ -30,6 +29,43 @@ # 日志记录器 +def _mix_to_mono(data: np.ndarray) -> np.ndarray: + if data.ndim == 1: + return np.ascontiguousarray(data, dtype=np.float32) + return np.ascontiguousarray(np.mean(data, axis=1, dtype=np.float32)) + + +def prepare_audio_for_server(data: np.ndarray, source_sample_rate: int) -> np.ndarray: + """Mix a complete input buffer to mono and resample it to 16 kHz.""" + mono = _mix_to_mono(data) + if source_sample_rate == 16000: + return mono + + return np.ascontiguousarray(soxr.resample(mono, source_sample_rate, 16000, quality='HQ'), dtype=np.float32) + + +class StreamingAudioConverter: + """Stateful mono converter for the live CoreAudio stream.""" + + def __init__(self, source_sample_rate: int): + self.source_sample_rate = source_sample_rate + self._resampler = None + if source_sample_rate != 16000: + self._resampler = soxr.ResampleStream( + source_sample_rate, + 16000, + num_channels=1, + dtype='float32', + quality='HQ', + ) + + def process(self, data: np.ndarray, *, last: bool = False) -> np.ndarray: + mono = _mix_to_mono(data) + if self._resampler is None: + return mono + return np.ascontiguousarray(self._resampler.resample_chunk(mono, last=last), dtype=np.float32) + + class AudioRecorder: """ 音频录制器 @@ -94,11 +130,29 @@ async def record_and_send(self) -> None: self._start_time = 0.0 self._duration = 0.0 self._cache = [] + converter = StreamingAudioConverter(self.state.audio_sample_rate) + + async def send_audio_chunk(data: np.ndarray, *, last: bool = False) -> None: + converted = converter.process(data, last=last) + if not converted.size: + return + message = AudioMessage( + task_id=self.task_id, + source='mic', + data=base64.b64encode(converted.tobytes()).decode('utf-8'), + is_final=False, + time_start=self._start_time, + seg_duration=Config.mic_seg_duration, + seg_overlap=Config.mic_seg_overlap, + context=Config.context, + language=Config.language, + ) + await self._send_message(message) # 音频文件管理 file_path = None if Config.save_audio: - self._file_manager = AudioFileManager() + self._file_manager = AudioFileManager(sample_rate=self.state.audio_sample_rate) # 从队列读取数据 while task := await self.state.queue_in.get(): @@ -125,31 +179,18 @@ async def record_and_send(self) -> None: # 获取音频数据 if self._cache: - data = np.concatenate(self._cache) + data = np.concatenate((*self._cache, task['data'])) self._cache.clear() else: data = task['data'] # 保存音频至本地文件 - self._duration += len(data) / 48000 + self._duration += len(data) / self.state.audio_sample_rate if Config.save_audio and self._file_manager: self._file_manager.write(data) # 发送音频数据用于识别 - message = AudioMessage( - task_id=self.task_id, - source='mic', - data=base64.b64encode( - np.mean(data[::3], axis=1).tobytes() - ).decode('utf-8'), - is_final=False, - time_start=self._start_time, - seg_duration=Config.mic_seg_duration, - seg_overlap=Config.mic_seg_overlap, - context=Config.context, - language=Config.language, - ) - asyncio.create_task(self._send_message(message)) + await send_audio_chunk(data) elif task['type'] == 'finish': # 如果有缓存的数据未发送,先发送缓存 @@ -157,24 +198,13 @@ async def record_and_send(self) -> None: data = np.concatenate(self._cache) self._cache.clear() - self._duration += len(data) / 48000 + self._duration += len(data) / self.state.audio_sample_rate if Config.save_audio and self._file_manager: self._file_manager.write(data) - message = AudioMessage( - task_id=self.task_id, - source='mic', - data=base64.b64encode( - np.mean(data[::3], axis=1).tobytes() - ).decode('utf-8'), - is_final=False, - time_start=self._start_time, - seg_duration=Config.mic_seg_duration, - seg_overlap=Config.mic_seg_overlap, - context=Config.context, - language=Config.language, - ) - asyncio.create_task(self._send_message(message)) + await send_audio_chunk(data) + + await send_audio_chunk(np.empty(0, dtype=np.float32), last=True) # 完成写入本地文件 if Config.save_audio and self._file_manager: @@ -197,7 +227,7 @@ async def record_and_send(self) -> None: context=Config.context, language=Config.language, ) - asyncio.create_task(self._send_message(message)) + await self._send_message(message) break except Exception as e: diff --git a/core/client/audio/stream.py b/core/client/audio/stream.py index 0e3c9fe0..500cb817 100644 --- a/core/client/audio/stream.py +++ b/core/client/audio/stream.py @@ -8,7 +8,6 @@ from __future__ import annotations -import sys import time import threading from typing import TYPE_CHECKING, Optional @@ -17,6 +16,7 @@ import sounddevice as sd from core.client.state import console +from config_client import ClientConfig as Config from . import logger if TYPE_CHECKING: @@ -41,7 +41,6 @@ class AudioStreamManager: block_duration: 每个数据块的时长(秒,默认 0.05s) """ - SAMPLE_RATE = 48000 BLOCK_DURATION = 0.05 # 50ms def __init__(self, app: CapsWriterClient): @@ -53,6 +52,7 @@ def __init__(self, app: CapsWriterClient): """ self.app = app self._channels = 1 + self.sample_rate = Config.audio_sample_rate self._running = False # 标志是否应该运行 @property @@ -112,27 +112,41 @@ def start(self) -> Optional[sd.InputStream]: # 检测音频设备 try: - device = sd.query_devices(kind='input') + device = sd.query_devices(device=Config.audio_device, kind='input') self._channels = min(2, device['max_input_channels']) device_name = device.get('name', '未知设备') + try: + sd.check_input_settings( + device=Config.audio_device, + channels=self._channels, + dtype='float32', + samplerate=self.sample_rate, + ) + except sd.PortAudioError: + self.sample_rate = int(device['default_samplerate']) + sd.check_input_settings( + device=Config.audio_device, + channels=self._channels, + dtype='float32', + samplerate=self.sample_rate, + ) + logger.warning(f"首选采样率不可用,改用设备默认值 {self.sample_rate} Hz") console.print( - f'使用默认音频设备:[italic]{device_name},声道数:{self._channels}', + f'使用音频设备:[italic]{device_name},声道数:{self._channels},采样率:{self.sample_rate} Hz', end='\n\n' ) - logger.info(f"找到音频设备: {device_name}, 声道数: {self._channels}") + logger.info(f"找到音频设备: {device_name}, 声道数: {self._channels}, 采样率: {self.sample_rate}") except UnicodeDecodeError: logger.warning("无法获取音频设备名称(编码问题)") - except sd.PortAudioError: - logger.error("未找到麦克风设备") - input('按回车键退出') - sys.exit(1) + except sd.PortAudioError as exc: + raise RuntimeError(f"麦克风设备不可用: {exc}") from exc # 创建音频流 try: stream = sd.InputStream( - samplerate=self.SAMPLE_RATE, - blocksize=int(self.BLOCK_DURATION * self.SAMPLE_RATE), - device=None, + samplerate=self.sample_rate, + blocksize=int(self.BLOCK_DURATION * self.sample_rate), + device=Config.audio_device, dtype="float32", channels=self._channels, callback=self._audio_callback, @@ -141,10 +155,11 @@ def start(self) -> Optional[sd.InputStream]: stream.start() self.state.stream = stream + self.state.audio_sample_rate = self.sample_rate self._running = True logger.debug( - f"音频流已启动: 采样率={self.SAMPLE_RATE}, " - f"块大小={int(self.BLOCK_DURATION * self.SAMPLE_RATE)}" + f"音频流已启动: 采样率={self.sample_rate}, " + f"块大小={int(self.BLOCK_DURATION * self.sample_rate)}" ) return stream diff --git a/core/client/clipboard/clipboard.py b/core/client/clipboard/clipboard.py index 306ebdc4..d9638a8f 100644 --- a/core/client/clipboard/clipboard.py +++ b/core/client/clipboard/clipboard.py @@ -10,9 +10,13 @@ """ import asyncio import platform +from dataclasses import dataclass from contextlib import contextmanager +from typing import Any + import pyclip -from pynput import keyboard + +from core.client.platform_input import send_paste_shortcut from . import logger @@ -20,6 +24,58 @@ CLIPBOARD_ENCODINGS = ['utf-8', 'gbk', 'utf-16', 'latin1'] +@dataclass(frozen=True) +class MacPasteboardSnapshot: + items: tuple[tuple[tuple[str, bytes], ...], ...] + + def restore(self, pasteboard: Any, expected_change_count: int) -> bool: + if pasteboard.changeCount() != expected_change_count: + logger.info("剪贴板已被用户或其他应用更新,跳过自动恢复") + return False + + from AppKit import NSPasteboardItem + from Foundation import NSData + + restored_items = [] + for item_payload in self.items: + item = NSPasteboardItem.alloc().init() + for pasteboard_type, payload in item_payload: + data = NSData.dataWithBytes_length_(payload, len(payload)) + item.setData_forType_(data, pasteboard_type) + restored_items.append(item) + + pasteboard.clearContents() + if restored_items: + pasteboard.writeObjects_(restored_items) + return True + + +def snapshot_macos_pasteboard(pasteboard: Any = None) -> MacPasteboardSnapshot: + """Capture every pasteboard item and UTI without reducing it to plain text.""" + from AppKit import NSPasteboard + + pasteboard = pasteboard or NSPasteboard.generalPasteboard() + snapshot_items = [] + for item in pasteboard.pasteboardItems() or []: + payloads = [] + for pasteboard_type in item.types() or []: + data = item.dataForType_(pasteboard_type) + if data is not None: + payloads.append((str(pasteboard_type), bytes(data))) + snapshot_items.append(tuple(payloads)) + return MacPasteboardSnapshot(tuple(snapshot_items)) + + +def write_macos_pasteboard_text(text: str, pasteboard: Any = None) -> int: + from AppKit import NSPasteboard, NSPasteboardTypeString + + pasteboard = pasteboard or NSPasteboard.generalPasteboard() + pasteboard.clearContents() + if not pasteboard.setString_forType_(text, NSPasteboardTypeString): + raise RuntimeError("macOS 剪贴板写入失败") + return int(pasteboard.changeCount()) + + def safe_paste() -> str: """ 安全地从剪贴板读取并解码文本 @@ -81,19 +137,41 @@ def copy_to_clipboard(content: str): @contextmanager -def save_and_restore_clipboard(): +def save_and_restore_clipboard(pasteboard=None): """ 剪贴板保存/恢复上下文管理器 用法: - with save_and_restore_clipboard(): + with save_and_restore_clipboard() as mark_clipboard_updated: # 在这里操作剪贴板 pyclip.copy("临时内容") + mark_clipboard_updated() # 退出后剪贴板恢复原内容 + + macOS 调用方必须在自己的最后一次写入后调用返回函数。若随后有其他应用更新 + 剪贴板,退出上下文时将跳过恢复,避免覆盖用户的新内容。 """ + if platform.system() == 'Darwin': + from AppKit import NSPasteboard + + if pasteboard is None: + pasteboard = NSPasteboard.generalPasteboard() + snapshot = snapshot_macos_pasteboard(pasteboard) + expected_change_count = int(pasteboard.changeCount()) + + def mark_clipboard_updated(): + nonlocal expected_change_count + expected_change_count = int(pasteboard.changeCount()) + + try: + yield mark_clipboard_updated + finally: + snapshot.restore(pasteboard, expected_change_count) + return + original = safe_paste() try: - yield + yield lambda: None finally: if original: pyclip.copy(original) @@ -108,28 +186,32 @@ async def paste_text(text: str, restore_clipboard: bool = True): text: 要粘贴的文本 restore_clipboard: 粘贴后是否恢复原剪贴板内容 """ + if platform.system() == 'Darwin': + from AppKit import NSPasteboard + + pasteboard = NSPasteboard.generalPasteboard() + snapshot = snapshot_macos_pasteboard(pasteboard) if restore_clipboard else None + expected_change_count = write_macos_pasteboard_text(text, pasteboard) + send_paste_shortcut() + logger.debug("已发送粘贴命令 (Command+V)") + if snapshot is not None: + await asyncio.sleep(0.25) + snapshot.restore(pasteboard, expected_change_count) + return + # 保存剪切板 original = '' if restore_clipboard: try: original = safe_paste() - except: + except Exception: pass # 复制要粘贴的文本 pyclip.copy(text) logger.debug(f"已复制文本到剪贴板,长度: {len(text)}") - # 粘贴结果(使用 pynput 模拟 Ctrl+V) - controller = keyboard.Controller() - if platform.system() == 'Darwin': - # macOS: Command+V - with controller.pressed(keyboard.Key.cmd): - controller.tap('v') - else: - # Windows/Linux: Ctrl+V - with controller.pressed(keyboard.Key.ctrl): - controller.tap('v') + send_paste_shortcut() logger.debug("已发送粘贴命令 (Ctrl+V)") diff --git a/core/client/llm/llm_get_selection.py b/core/client/llm/llm_get_selection.py index fa9ecfea..c43d3706 100644 --- a/core/client/llm/llm_get_selection.py +++ b/core/client/llm/llm_get_selection.py @@ -10,7 +10,7 @@ """ import time import pyclip -import keyboard +from core.client.platform_input import send_copy_shortcut from . import logger from .llm_clipboard import safe_paste @@ -42,7 +42,7 @@ def get_selected_text(role_config, state) -> str: original_clipboard = safe_paste() # 模拟 Ctrl+C 复制选中的文字 - keyboard.press_and_release('ctrl+c') + send_copy_shortcut() # 等待复制操作完成 time.sleep(0.1) @@ -91,4 +91,3 @@ def record_selection_usage(role_config, selection_text: str): global _last_selection_by_role role_name = role_config.name _last_selection_by_role[role_name] = selection_text - diff --git a/core/client/llm/llm_output_typing.py b/core/client/llm/llm_output_typing.py index 5ec6df21..546f9539 100644 --- a/core/client/llm/llm_output_typing.py +++ b/core/client/llm/llm_output_typing.py @@ -5,8 +5,7 @@ - paste=True: 等流式输出完成后一次性粘贴 - paste=False: 实时流式 write,每个字都打出来 """ -import asyncio -import keyboard +import sys from config_client import ClientConfig as Config from core.tools.asyncio_to_thread import to_thread @@ -15,9 +14,19 @@ from . import logger +def _write_text(text: str) -> None: + if sys.platform == 'darwin': + raise RuntimeError("macOS 流式逐字输出不可用,请使用粘贴模式") + import keyboard + + keyboard.write(text) + + async def handle_typing_mode(handler, text: str, paste: bool = None, matched_hotwords=None, role_config=None, content=None) -> tuple: """打字输出模式""" from .llm_error_handler import handle_llm_error + if sys.platform == 'darwin': + paste = True # 如果没传,则现场检测一次(兼容性) if not role_config or content is None: role_config, content = handler.detect_role(text) @@ -63,7 +72,8 @@ async def _process_streaming(handler, role_config, content, matched_hotwords) -> def stream_write_chunk(chunk: str): nonlocal pending_buffer - if not chunk: return + if not chunk: + return chunks.append(chunk) full_current = pending_buffer + chunk @@ -85,7 +95,7 @@ def stream_write_chunk(chunk: str): if content_to_write: logger.debug(f"output_text: keyboard.write '{content_to_write}'") - keyboard.write(content_to_write) + _write_text(content_to_write) pending_buffer = trailing else: pending_buffer = trailing @@ -104,21 +114,25 @@ def stream_write_chunk(chunk: str): if not chunks: final_text = TextOutput.strip_punc(content) logger.debug(f"output_text: keyboard.write '{final_text}' (降级)") - keyboard.write(final_text) + _write_text(final_text) return (final_text, 0, 0.0) # 如果 LLM 只输出标点,会被拦截,就要做补偿输出 full_output = ''.join(chunks).strip() if len(full_output) == 1 and full_output in Config.trash_punc: - keyboard.write(full_output) + _write_text(full_output) return (TextOutput.strip_punc(polished_text), token_count, gen_time) async def output_text(text: str, paste: bool = None): """输出文本(根据 paste 或 Config.paste 选择方式)""" + if paste is None: + paste = Config.paste + if sys.platform == 'darwin': + paste = True if paste: await paste_text(text, restore_clipboard=Config.restore_clip) else: logger.debug(f"output_text: keyboard.write '{text}'") - keyboard.write(text) + _write_text(text) diff --git a/core/client/macos_permissions.py b/core/client/macos_permissions.py new file mode 100644 index 00000000..30b39ae8 --- /dev/null +++ b/core/client/macos_permissions.py @@ -0,0 +1,131 @@ +"""macOS privacy permission checks used by the interactive client.""" + +from __future__ import annotations + +import sys +import threading +import time +from dataclasses import dataclass + + +class MacOSPermissionError(RuntimeError): + pass + + +@dataclass(frozen=True) +class MacOSPermissionStatus: + microphone: bool + accessibility: bool + input_monitoring: bool + post_events: bool + + @property + def ready(self) -> bool: + return all((self.microphone, self.accessibility, self.input_monitoring, self.post_events)) + + +def _require_macos() -> None: + if sys.platform != 'darwin': + raise RuntimeError('macOS permission APIs are only available on Darwin') + + +def microphone_authorized() -> bool: + _require_macos() + from AVFoundation import ( + AVAuthorizationStatusAuthorized, + AVCaptureDevice, + AVMediaTypeAudio, + ) + + return AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio) == AVAuthorizationStatusAuthorized + + +def request_microphone_access(timeout: float = 60.0) -> bool: + _require_macos() + from AVFoundation import ( + AVAuthorizationStatusAuthorized, + AVAuthorizationStatusNotDetermined, + AVCaptureDevice, + AVMediaTypeAudio, + ) + from Foundation import NSDate, NSRunLoop + + status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio) + if status == AVAuthorizationStatusAuthorized: + return True + if status != AVAuthorizationStatusNotDetermined: + return False + + result = {'granted': False} + completed = threading.Event() + + def completion_handler(granted): + result['granted'] = bool(granted) + completed.set() + + AVCaptureDevice.requestAccessForMediaType_completionHandler_(AVMediaTypeAudio, completion_handler) + deadline = time.monotonic() + timeout + run_loop = NSRunLoop.currentRunLoop() + while not completed.is_set() and time.monotonic() < deadline: + run_loop.runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.1)) + return result['granted'] + + +def accessibility_authorized(prompt: bool = False) -> bool: + _require_macos() + from ApplicationServices import AXIsProcessTrusted, AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt + + if prompt: + return bool(AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})) + return bool(AXIsProcessTrusted()) + + +def input_monitoring_authorized(request: bool = False) -> bool: + _require_macos() + from Quartz import CGPreflightListenEventAccess, CGRequestListenEventAccess + + return bool(CGRequestListenEventAccess() if request else CGPreflightListenEventAccess()) + + +def post_events_authorized(request: bool = False) -> bool: + _require_macos() + from Quartz import CGPreflightPostEventAccess, CGRequestPostEventAccess + + return bool(CGRequestPostEventAccess() if request else CGPreflightPostEventAccess()) + + +def permission_status() -> MacOSPermissionStatus: + _require_macos() + return MacOSPermissionStatus( + microphone=microphone_authorized(), + accessibility=accessibility_authorized(), + input_monitoring=input_monitoring_authorized(), + post_events=post_events_authorized(), + ) + + +def request_required_permissions() -> MacOSPermissionStatus: + _require_macos() + request_microphone_access() + accessibility_authorized(prompt=True) + input_monitoring_authorized(request=True) + post_events_authorized(request=True) + return permission_status() + + +def show_macos_error(message: str, title: str = 'CapsWriter 无法启动') -> None: + """Display a visible error when the windowed app cannot use the terminal.""" + _require_macos() + from AppKit import NSAlert, NSAlertStyleCritical, NSApplication + + NSApplication.sharedApplication() + alert = NSAlert.alloc().init() + alert.setAlertStyle_(NSAlertStyleCritical) + alert.setMessageText_(title) + alert.setInformativeText_(message) + alert.addButtonWithTitle_('退出') + alert.runModal() + + +def show_permission_error(message: str) -> None: + show_macos_error(message, title='CapsWriter 需要 macOS 权限') diff --git a/core/client/manager/file_runner.py b/core/client/manager/file_runner.py index 1ab5b76c..511b27fb 100644 --- a/core/client/manager/file_runner.py +++ b/core/client/manager/file_runner.py @@ -1,8 +1,14 @@ # coding: utf-8 from __future__ import annotations + +import sys from pathlib import Path + from . import logger -from config_client import ClientConfig as Config, __version__ + + +def _has_interactive_stdin() -> bool: + return bool(sys.stdin and sys.stdin.isatty()) class FileRunner: @@ -56,7 +62,8 @@ async def run(self): logger.info("所有文件已处理完成") - input('\n按回车退出\n') + if _has_interactive_stdin(): + input('\n按回车退出\n') except Exception as e: logger.error(f"文件模式运行异常: {e}", exc_info=True) diff --git a/core/client/manager/mic_runner.py b/core/client/manager/mic_runner.py index 1c212087..5d52fd98 100644 --- a/core/client/manager/mic_runner.py +++ b/core/client/manager/mic_runner.py @@ -1,5 +1,5 @@ # coding: utf-8 -import asyncio +import sys from . import logger from ..ui import TipsDisplay from config_client import ClientConfig as Config, __version__ @@ -27,6 +27,28 @@ def tray_manager(self): def start_resources(self): """初始化麦克风模式特有资源 (音频硬件、快捷键、UI 托盘)""" + if sys.platform == 'darwin': + from core.client.macos_permissions import MacOSPermissionError, request_required_permissions + + permissions = request_required_permissions() + if not permissions.ready: + missing = [ + name + for name, granted in ( + ('麦克风', permissions.microphone), + ('辅助功能', permissions.accessibility), + ('输入监控', permissions.input_monitoring), + ('按键控制', permissions.post_events), + ) + if not granted + ] + permission_target = 'CapsWriter.app' if getattr(sys, 'frozen', False) else '当前 Python 客户端' + raise MacOSPermissionError( + 'macOS 权限尚未完成:' + + '、'.join(missing) + + f'。请在“系统设置 > 隐私与安全性”中授权 {permission_target} 后重启客户端。' + ) + # 1. 托盘 self.tray_manager.start() @@ -34,7 +56,9 @@ def start_resources(self): TipsDisplay.show_mic_tips() # 3. 开启运行组件 (音频流、快捷键监听) - self.app.stream.start() + stream = self.app.stream.start() + if stream is None: + raise RuntimeError('音频输入流启动失败,请检查麦克风设备与采样率设置') self.app.shortcut.start() # 4. 开启 UDP 控制 (如果启用) @@ -43,7 +67,8 @@ def start_resources(self): # 5. 开启后台服务 (热词、LLM) self.app.hotword.start() - self.app.llm.start() + if Config.llm_enabled: + self.app.llm.start() async def run(self): """麦克风模式主入口""" @@ -61,4 +86,3 @@ async def run(self): self.processor = ResultProcessor(self.app) await self.processor.start() - diff --git a/core/client/output/result_processor.py b/core/client/output/result_processor.py index 3991be9e..f634bd07 100644 --- a/core/client/output/result_processor.py +++ b/core/client/output/result_processor.py @@ -16,8 +16,8 @@ from core.protocol import RecognitionMessage from core.client.output.text_output import TextOutput +from core.client.platform_input import tap_key from core.tools.window_detector import get_active_window_info -import keyboard from . import logger from core.client.udp.udp_broadcaster import broadcast_output_udp @@ -26,6 +26,7 @@ from core.client.llm.llm_write_md import write_llm_md if TYPE_CHECKING: + from core.client.connection import WebSocketManager from core.client.state import ClientState from core.client.app import CapsWriterClient from core.client.hotword.manager import HotwordManager @@ -45,7 +46,7 @@ def _estimate_tokens(text: str) -> int: async def _auto_enter(delay: float) -> None: """延迟发送回车键""" await asyncio.sleep(delay) - keyboard.press_and_release('enter') + tap_key('enter') logger.debug(f"自动回车已发送 (延迟 {delay}s)") @@ -143,6 +144,10 @@ def _log_modifier_key_state(self) -> None: 用于调试按键卡住问题。 """ + import sys + + if sys.platform != 'win32': + return try: import keyboard @@ -168,13 +173,14 @@ async def start(self) -> None: while not self._exit_event.is_set(): try: message = await self.ws.receive() - if message is None: break + if message is None: + break await self._handle_message(message) except Exception as e: logger.debug(f"连接异常中断: {e}") break - console.print(f'[bold red]已断开服务端连接[/bold red]\n') + console.print('[bold red]已断开服务端连接[/bold red]\n') self._cleanup() @@ -257,7 +263,9 @@ async def _handle_message(self, message: Optional[RecognitionMessage]) -> None: # 窗口兼容性检测 paste = Config.paste - process_name = get_active_window_info().get('process_name', '').lower() + process_name = '' + if Config.paste_apps or Config.enter_apps: + process_name = get_active_window_info().get('process_name', '').lower() logger.debug(f"当前活动窗口: {process_name}") if any(app.lower() == process_name for app in Config.paste_apps): paste = True diff --git a/core/client/output/text_output.py b/core/client/output/text_output.py index cd651be7..e1ce58d4 100644 --- a/core/client/output/text_output.py +++ b/core/client/output/text_output.py @@ -7,16 +7,12 @@ from __future__ import annotations -import asyncio -import platform +import sys from typing import Optional import re -import keyboard -import pyclip -from pynput import keyboard as pynput_keyboard - from config_client import ClientConfig as Config +from core.client.clipboard import paste_text from core.tools.window_detector import get_active_window_info from . import logger @@ -90,6 +86,8 @@ async def output(self, text: str, paste: Optional[bool] = None) -> None: # 确定输出方式 if paste is None: paste = Config.paste + if sys.platform == 'darwin': + paste = True if paste: await self._paste_text(text) @@ -104,34 +102,7 @@ async def _paste_text(self, text: str) -> None: text: 要粘贴的文本 """ logger.debug(f"使用粘贴方式输出文本,长度: {len(text)}") - - # 保存剪贴板 - try: - temp = pyclip.paste().decode('utf-8') - except Exception: - temp = '' - - # 复制结果 - pyclip.copy(text) - - # 粘贴结果(使用 pynput 模拟 Ctrl+V) - controller = pynput_keyboard.Controller() - if platform.system() == 'Darwin': - # macOS: Command+V - with controller.pressed(pynput_keyboard.Key.cmd): - controller.tap('v') - else: - # Windows/Linux: Ctrl+V - with controller.pressed(pynput_keyboard.Key.ctrl): - controller.tap('v') - - logger.debug("已发送粘贴命令 (Ctrl+V)") - - # 还原剪贴板 - if Config.restore_clip: - await asyncio.sleep(0.1) - pyclip.copy(temp) - logger.debug("剪贴板已恢复") + await paste_text(text, restore_clipboard=Config.restore_clip) def _type_text(self, text: str) -> None: """ @@ -144,4 +115,8 @@ def _type_text(self, text: str) -> None: text: 要输出的文本 """ logger.debug(f"使用打字方式输出文本,长度: {len(text)}") + if sys.platform == 'darwin': + raise RuntimeError("macOS 不支持逐字模拟,请使用粘贴输出") + import keyboard + keyboard.write(text) diff --git a/core/client/platform_input.py b/core/client/platform_input.py new file mode 100644 index 00000000..38098fa8 --- /dev/null +++ b/core/client/platform_input.py @@ -0,0 +1,38 @@ +"""Platform-neutral synthetic keyboard input.""" + +from __future__ import annotations + +import sys + +from pynput import keyboard + + +def _controller() -> keyboard.Controller: + return keyboard.Controller() + + +def send_primary_shortcut(character: str) -> None: + """Send Command+character on macOS and Ctrl+character elsewhere.""" + modifier = keyboard.Key.cmd if sys.platform == 'darwin' else keyboard.Key.ctrl + controller = _controller() + with controller.pressed(modifier): + controller.tap(character) + + +def send_copy_shortcut() -> None: + send_primary_shortcut('c') + + +def send_paste_shortcut() -> None: + send_primary_shortcut('v') + + +def tap_key(key_name: str) -> None: + special_keys = { + 'enter': keyboard.Key.enter, + 'esc': keyboard.Key.esc, + 'tab': keyboard.Key.tab, + 'space': keyboard.Key.space, + } + key = special_keys.get(key_name, key_name) + _controller().tap(key) diff --git a/core/client/shortcut/key_mapper.py b/core/client/shortcut/key_mapper.py index d4cc0584..19a332ec 100644 --- a/core/client/shortcut/key_mapper.py +++ b/core/client/shortcut/key_mapper.py @@ -5,13 +5,18 @@ 处理按键名称和虚拟键码之间的转换,以及相关常量定义 """ +import sys + from pynput import keyboard -from pynput._util.win32 import KeyTranslator from . import logger -# 创建键盘翻译器实例(用于 VK 到字符的转换) -_key_translator = KeyTranslator() +if sys.platform == 'win32': + from pynput._util.win32 import KeyTranslator + + _key_translator = KeyTranslator() +else: + _key_translator = None # 特殊键 VK 映射(从 pynput 复制) _SPECIAL_KEYS = { @@ -106,16 +111,29 @@ def vk_to_name(vk: int) -> str: return NUMPAD_KEYS[vk] # 使用 pynput 的 KeyTranslator 获取字符(字母、数字、符号键) - try: - params = _key_translator(vk, is_press=True) - if 'char' in params and params['char'] is not None: - return params['char'] - except Exception: - pass + if _key_translator is not None: + try: + params = _key_translator(vk, is_press=True) + if 'char' in params and params['char'] is not None: + return params['char'] + except Exception: + pass # 未知键码,返回 vk_ 格式 return f'vk_{vk}' + @staticmethod + def key_to_name(key) -> str: + """Convert a pynput key event to the configured shortcut name.""" + if isinstance(key, keyboard.Key): + return key.name + if isinstance(key, keyboard.KeyCode): + if key.char: + return key.char.lower() + if key.vk is not None: + return f'vk_{key.vk}' + return '' + @staticmethod def name_to_key(key_name: str): """ diff --git a/core/client/shortcut/shortcut_manager.py b/core/client/shortcut/shortcut_manager.py index 75710adc..db2ab4a6 100644 --- a/core/client/shortcut/shortcut_manager.py +++ b/core/client/shortcut/shortcut_manager.py @@ -9,6 +9,7 @@ 4. hold_mode 和 click_mode 支持 """ from __future__ import annotations +import sys import time from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Dict, List, Optional @@ -16,8 +17,18 @@ from pynput import keyboard, mouse from . import logger -from core.client.shortcut.key_mapper import * -from core.client.shortcut.key_mapper import KeyMapper +from core.client.shortcut.key_mapper import ( + KEYBOARD_MESSAGES, + KEY_DOWN_MESSAGES, + KEY_UP_MESSAGES, + MOUSE_MESSAGES, + WM_KEYUP, + WM_SYSKEYUP, + WM_XBUTTONDOWN, + WM_XBUTTONUP, + XBUTTON1, + KeyMapper, +) from core.client.shortcut.emulator import ShortcutEmulator from core.client.shortcut.event_handler import ShortcutEventHandler from core.client.shortcut.task import ShortcutTask @@ -34,7 +45,7 @@ class ShortcutManager: 快捷键管理器 统一管理多个快捷键,使用 pynput 监听键盘和鼠标事件。 - 所有事件处理都在 win32_event_filter 中完成,确保高性能和低延迟。 + Windows 保留底层事件过滤器,其他平台使用 pynput 的标准回调。 """ def __init__(self, app: CapsWriterClient, shortcuts: List[Shortcut]): @@ -161,6 +172,42 @@ def win32_event_filter(msg, data): return win32_event_filter + def _handle_key_event(self, key, is_press: bool) -> None: + """Handle a platform-neutral pynput keyboard event.""" + key_name = KeyMapper.key_to_name(key) + if not key_name: + return + + if self._emulator.is_emulating(key_name): + if not is_press: + self._emulator.clear_emulating_flag(key_name) + return + if self.is_restoring(key_name): + if not is_press: + self.clear_restoring_flag(key_name) + return + + task = self.tasks.get(key_name) + if task is None: + return + if is_press: + self._event_handler.handle_keydown(key_name, task) + else: + self._event_handler.handle_keyup(key_name, task) + + def _handle_mouse_event(self, _x, _y, button, pressed: bool) -> None: + """Handle side-button events on non-Windows platforms.""" + button_name = getattr(button, 'name', '') + if button_name not in ('x1', 'x2'): + return + task = self.tasks.get(button_name) + if task is None: + return + if pressed: + self._event_handler.handle_keydown(button_name, task) + else: + self._handle_mouse_keyup(button_name, task) + def _handle_mouse_keyup(self, button_name: str, task) -> None: """处理鼠标按键释放事件""" # 单击模式 @@ -258,9 +305,15 @@ def start(self) -> None: if self.keyboard_listener and self.keyboard_listener.is_alive(): logger.debug("键盘监听器已在运行,跳过启动") else: - self.keyboard_listener = keyboard.Listener( - win32_event_filter=self.create_keyboard_filter() - ) + if sys.platform == 'win32': + self.keyboard_listener = keyboard.Listener( + win32_event_filter=self.create_keyboard_filter() + ) + else: + self.keyboard_listener = keyboard.Listener( + on_press=lambda key: self._handle_key_event(key, True), + on_release=lambda key: self._handle_key_event(key, False), + ) self.keyboard_listener.start() logger.info("键盘监听器已启动") @@ -268,15 +321,20 @@ def start(self) -> None: if self.mouse_listener and self.mouse_listener.is_alive(): logger.debug("鼠标监听器已在运行,跳过启动") else: - self.mouse_listener = mouse.Listener( - win32_event_filter=self.create_mouse_filter() - ) + if sys.platform == 'win32': + self.mouse_listener = mouse.Listener( + win32_event_filter=self.create_mouse_filter() + ) + else: + self.mouse_listener = mouse.Listener(on_click=self._handle_mouse_event) self.mouse_listener.start() logger.info("鼠标监听器已启动") # 打印所有启用的快捷键 for shortcut in self.shortcuts: if shortcut.enabled: + if sys.platform != 'win32' and shortcut.suppress: + logger.warning(f"[{shortcut.key}] 当前平台暂不支持按键抑制,已按非抑制模式监听") mode = "长按" if shortcut.hold_mode else "单击" toggle = "可恢复" if shortcut.is_toggle_key() else "普通键" logger.info(f" [{shortcut.key}] {mode}模式, 阻塞:{shortcut.suppress}, {toggle}") diff --git a/core/client/state.py b/core/client/state.py index dc309835..6dfd1fb8 100644 --- a/core/client/state.py +++ b/core/client/state.py @@ -12,7 +12,7 @@ import time from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Optional, Dict, Any +from typing import TYPE_CHECKING, Optional, Dict if TYPE_CHECKING: import sounddevice as sd @@ -61,6 +61,7 @@ class ClientState: recording: bool = False recording_start_time: float = 0.0 + audio_sample_rate: int = 48000 audio_files: Dict[str, Path] = field(default_factory=dict) # 最近一次识别结果(用于手动添加纠错记录) @@ -177,5 +178,3 @@ def set_output_text(self, text: str) -> None: """ self.last_output_text = text - - diff --git a/core/client/transcribe/media_tool.py b/core/client/transcribe/media_tool.py index 4516aeeb..7677af22 100644 --- a/core/client/transcribe/media_tool.py +++ b/core/client/transcribe/media_tool.py @@ -1,11 +1,10 @@ # coding: utf-8 import asyncio -import shutil -import subprocess from pathlib import Path -from typing import List, Optional +from typing import List from core.client.state import console +from core.tools.external_tools import find_executable from . import logger class MediaTool: @@ -14,8 +13,8 @@ class MediaTool: @staticmethod def check_environment() -> bool: """检查 FFmpeg 和 ffprobe 环境""" - ffmpeg_path = shutil.which('ffmpeg') - ffprobe_path = shutil.which('ffprobe') + ffmpeg_path = find_executable('ffmpeg') + ffprobe_path = find_executable('ffprobe') if ffmpeg_path is None: console.print('\n[bold red]错误:未检测到 FFmpeg 环境[/bold red]') @@ -39,7 +38,7 @@ def check_environment() -> bool: async def get_audio_duration(file: Path) -> float: """获取音视频文件时长""" cmd = [ - "ffprobe", "-v", "error", "-show_entries", "format=duration", + find_executable('ffprobe') or "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file) ] try: @@ -59,6 +58,6 @@ async def get_audio_duration(file: Path) -> float: def build_ffmpeg_cmd(file: Path) -> List[str]: """构建提取音频的 FFmpeg 命令""" return [ - "ffmpeg", "-i", str(file), + find_executable('ffmpeg') or "ffmpeg", "-i", str(file), "-f", "f32le", "-ac", "1", "-ar", "16000", "-" ] diff --git a/core/client/ui/tips.py b/core/client/ui/tips.py index 168d4739..d18bf1b8 100644 --- a/core/client/ui/tips.py +++ b/core/client/ui/tips.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +import sys from rich.markdown import Markdown @@ -27,7 +28,19 @@ def _format_shortcut_name(key: str) -> str: Returns: str: 格式化后的名称(如 'CapsLock', 'F12') """ - # 将下划线替换为空格,然后标题化 + directional_modifiers = { + 'shift_l': 'Left Shift', + 'shift_r': 'Right Shift', + 'ctrl_l': 'Left Control', + 'ctrl_r': 'Right Control', + 'alt_l': 'Left Option', + 'alt_r': 'Right Option', + 'cmd_l': 'Left Command', + 'cmd_r': 'Right Command', + } + if key in directional_modifiers: + return directional_modifiers[key] + return key.replace('_', ' ').title() @@ -62,18 +75,29 @@ def show_mic_tips() -> None: console.rule('[bold #d55252]CapsWriter Offline Client[/]') console.print(f'\n版本:[bold green]{__version__}[/]') + if sys.platform == 'darwin': + platform_intro = '**CapsWriter-Offline** 是一个完全离线的 macOS 语音输入工具。' + launch_step = '运行 `uv run python start_client.py` 或 **CapsWriter.app**,它负责听音和打字上屏。' + permission_note = '首次运行需在系统设置中允许麦克风、辅助功能和输入监控权限。' + privilege_note = f'macOS 当前使用 {shortcuts_display} 长按录音,并通过剪贴板与 Command-V 输入结果。' + else: + platform_intro = '**CapsWriter-Offline** 是一个专为 Windows 打造的完全离线语音输入工具。' + launch_step = '运行 **Client** 端,它作为「耳朵」负责听音和打字上屏。' + permission_note = '如需在管理员权限运行的程序中输入,请以管理员权限运行客户端。' + privilege_note = '将音视频文件拖动到 **Client** 端 exe 文件后松开,可转录生成字幕。' + markdown = f''' -项目地址:https://github.com/HaujetZhao/CapsWriter-Offline +项目地址:https://github.com/Alex-ghost599/CapsWriter-Offline -**CapsWriter-Offline** 是一个专为 Windows 打造的**完全离线**语音输入工具。 +{platform_intro} 使用步骤: 1. 运行 **Server** 端,它作为「大脑」负责 AI 推理,约占用 1.5G 内存。 -2. 运行 **Client** 端,它作为「耳朵」负责听音和打字上屏。 +2. {launch_step} 3. 按住快捷键(`{shortcuts_display}`)说话,松开即输入。 -4. 将音视频文件拖动到 **Client** 端 exe 文件后松开,可转录生成字幕。 +4. {privilege_note} 特性: @@ -88,8 +112,8 @@ def show_mic_tips() -> None: 注意事项: -1. 当前快捷键:`{shortcuts_display}`,可在 `config.py` 中修改。 -2. 如需在管理员权限运行的程序(如任务管理器、游戏)中输入,请**以管理员权限运行客户端**。 +1. 当前快捷键:`{shortcuts_display}`,可在 `config_client.py` 中修改。 +2. {permission_note} 3. 识别结果默认去除末尾逗句号。 4. 录音保存功能:若检测到 `FFmpeg`,会以 `mp3` 压缩保存;否则保存为 `wav` 。 ''' @@ -108,7 +132,7 @@ def show_file_tips() -> None: """显示文件转录模式的启动提示""" console.print(f'\n版本:[bold green]{__version__}[/]') - markdown = '\n项目地址:https://github.com/HaujetZhao/CapsWriter-Offline' + markdown = '\n项目地址:https://github.com/Alex-ghost599/CapsWriter-Offline' console.print(Markdown(markdown), highlight=True) console.print(f'当前基文件夹:[cyan underline]{os.getcwd()}[/]') console.print(f'服务端地址: [cyan underline]{Config.addr}:{Config.port}[/]') diff --git a/core/server/connection/server_manager.py b/core/server/connection/server_manager.py index d23f571d..be2a13fa 100644 --- a/core/server/connection/server_manager.py +++ b/core/server/connection/server_manager.py @@ -6,7 +6,6 @@ 心跳监控、数据发送任务的编排。 """ -import asyncio import functools import websockets from config_server import ServerConfig as Config @@ -41,7 +40,8 @@ async def start(self): """ 启动 WebSocket 网络服务 """ - if self._is_running: return + if self._is_running: + return # 0. 启动前自检环境 if not self._check_port(): @@ -61,13 +61,17 @@ async def start(self): # 3. 启动服务 logger.info(f"正在拉起 WebSocket 服务 (监听: {Config.addr}:{Config.port})") + if Config.addr not in ('127.0.0.1', '::1', 'localhost'): + logger.warning('服务端正在监听非回环地址;协议未提供身份验证,请仅在受信网络中使用') async with websockets.serve( handler, Config.addr, Config.port, subprotocols=["binary"], - max_size=None + max_size=Config.max_message_bytes, + max_queue=16, + compression=None, ) as server: self._server = server # 保存 server 引用,用于外部关闭 diff --git a/core/server/connection/ws_recv.py b/core/server/connection/ws_recv.py index a4510e17..1d7e04d1 100644 --- a/core/server/connection/ws_recv.py +++ b/core/server/connection/ws_recv.py @@ -6,6 +6,7 @@ """ import json +import math import time from base64 import b64decode @@ -31,9 +32,10 @@ class AudioCache: 用于缓存接收到的音频数据,直到达到分段阈值后提交处理。 """ def __init__(self): - self.chunks: bytes = b'' # 音频数据缓冲 + self.chunks = bytearray() # 音频数据缓冲 self.offset: float = 0.0 # 当前偏移时间(秒) self.byte_count: int = 0 # 累计接收字节数 + self.task_signature = None @property def duration(self) -> float: @@ -47,9 +49,65 @@ def total_duration(self) -> float: def reset(self) -> None: """重置缓冲区""" - self.chunks = b'' + self.chunks.clear() self.offset = 0.0 self.byte_count = 0 + self.task_signature = None + + def validate_sequence(self, msg: AudioMessage) -> None: + signature = (msg.task_id, msg.source, msg.seg_duration, msg.seg_overlap) + if self.task_signature is None: + self.task_signature = signature + elif signature != self.task_signature: + raise ValueError('同一连接不能混合不同音频任务或分段配置') + + def append(self, data: bytes) -> None: + next_total = self.byte_count + len(data) + max_total = AudioFormat.seconds_to_bytes(Config.max_audio_seconds) + if next_total > max_total: + raise ValueError(f'单个任务音频不能超过 {Config.max_audio_seconds} 秒') + + max_buffer = AudioFormat.seconds_to_bytes( + Config.max_segment_duration + Config.max_segment_overlap * 2 + ) + if len(self.chunks) + len(data) > max_buffer: + raise ValueError('音频缓冲超过服务端上限') + self.chunks.extend(data) + self.byte_count = next_total + + +def validate_audio_message(msg: AudioMessage) -> None: + """Reject malformed protocol values before allocating audio buffers.""" + if msg.source not in ('mic', 'file'): + raise ValueError('不支持的音频来源') + if not isinstance(msg.task_id, str) or not msg.task_id or len(msg.task_id) > 128: + raise ValueError('任务标识无效') + if not isinstance(msg.data, str): + raise ValueError('音频数据必须为 Base64 字符串') + if not isinstance(msg.is_final, bool): + raise ValueError('is_final 必须为布尔值') + if isinstance(msg.time_start, bool) or not isinstance(msg.time_start, (int, float)) or not math.isfinite(msg.time_start): + raise ValueError('开始时间无效') + if ( + isinstance(msg.seg_duration, bool) + or not isinstance(msg.seg_duration, (int, float)) + or not math.isfinite(msg.seg_duration) + ): + raise ValueError('分段时长无效') + if not 0 < msg.seg_duration <= Config.max_segment_duration: + raise ValueError(f'分段时长必须在 0 到 {Config.max_segment_duration} 秒之间') + if ( + isinstance(msg.seg_overlap, bool) + or not isinstance(msg.seg_overlap, (int, float)) + or not math.isfinite(msg.seg_overlap) + ): + raise ValueError('分段重叠时长无效') + if not 0 <= msg.seg_overlap <= min(Config.max_segment_overlap, msg.seg_duration): + raise ValueError('分段重叠时长超过上限') + if not isinstance(msg.context, str) or len(msg.context) > Config.max_context_chars: + raise ValueError('上下文长度超过上限') + if not isinstance(msg.language, str) or len(msg.language) > 32: + raise ValueError('语言参数无效') async def message_handler(websocket, msg: AudioMessage, cache: AudioCache, app) -> None: @@ -61,7 +119,9 @@ async def message_handler(websocket, msg: AudioMessage, cache: AudioCache, app) queue_in = app.state.queue_in global status_mic - is_start = not bool(cache.chunks) + validate_audio_message(msg) + cache.validate_sequence(msg) + is_start = cache.byte_count == 0 socket_id = str(websocket.id) # 麦克风首次消息 → GPU 加速 @@ -80,9 +140,12 @@ async def message_handler(websocket, msg: AudioMessage, cache: AudioCache, app) try: # base64 解码音频数据(float32, 16kHz, mono) - data = b64decode(msg.data) - cache.chunks += data - cache.byte_count += len(data) + data = b64decode(msg.data, validate=True) + if len(data) > Config.max_audio_chunk_bytes: + raise ValueError('单个音频数据块超过服务端上限') + if len(data) % AudioFormat.BYTES_PER_SAMPLE: + raise ValueError('音频数据不是完整的 float32 采样') + cache.append(data) if not msg.is_final: # 打印状态消息 @@ -97,8 +160,8 @@ async def message_handler(websocket, msg: AudioMessage, cache: AudioCache, app) stride_bytes = AudioFormat.seconds_to_bytes(msg.seg_duration) while cache.duration >= seg_threshold: - segment_data = cache.chunks[:segment_bytes] - cache.chunks = cache.chunks[stride_bytes:] + segment_data = bytes(cache.chunks[:segment_bytes]) + del cache.chunks[:stride_bytes] task = Task( type=msg.source, @@ -131,7 +194,7 @@ async def message_handler(websocket, msg: AudioMessage, cache: AudioCache, app) # 提交最终片段 task = Task( type=msg.source, - data=cache.chunks, + data=bytes(cache.chunks), offset=cache.offset, task_id=msg.task_id, socket_id=socket_id, @@ -166,6 +229,10 @@ async def ws_recv(websocket, app) -> None: sockets = state.sockets sockets_id = state.sockets_id socket_id = str(websocket.id) + if len(sockets) >= Config.max_connections: + logger.warning(f'拒绝超过连接上限的客户端: {websocket.remote_address}') + await websocket.close(code=1013, reason='server connection limit reached') + return sockets[socket_id] = websocket sockets_id.append(socket_id) remote = websocket.remote_address @@ -184,9 +251,14 @@ async def ws_recv(websocket, app) -> None: msg = AudioMessage.from_dict(data) # 处理音频数据 await message_handler(websocket, msg, cache, app) - except Exception as e: + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as e: logger.error(f"消息解析失败: {str(e)}") - continue + await websocket.close(code=1008, reason='invalid audio message') + break + except Exception as e: + logger.error(f"消息处理失败: {str(e)}", exc_info=True) + await websocket.close(code=1011, reason='audio message processing failed') + break logger.info(f"客户端正常关闭连接: {socket_id}") diff --git a/core/server/engines/ct_transformer/punc_engine.py b/core/server/engines/ct_transformer/punc_engine.py index 2b1fb8f3..37c7fca9 100644 --- a/core/server/engines/ct_transformer/punc_engine.py +++ b/core/server/engines/ct_transformer/punc_engine.py @@ -1,5 +1,4 @@ # coding: utf-8 -from typing import Any from ..base import BasePuncEngine @@ -16,7 +15,9 @@ def __init__(self, model_path: str): def _initialize(self): """延迟初始化内核""" - import sherpa_onnx + from ..sherpa_runtime import load_sherpa_onnx + + sherpa_onnx = load_sherpa_onnx() punc_cfg = sherpa_onnx.OfflinePunctuationConfig( model=sherpa_onnx.OfflinePunctuationModelConfig( ct_transformer=self.model_path diff --git a/core/server/engines/paraformer_onnx/asr_engine.py b/core/server/engines/paraformer_onnx/asr_engine.py index 4620e17a..2cce7259 100644 --- a/core/server/engines/paraformer_onnx/asr_engine.py +++ b/core/server/engines/paraformer_onnx/asr_engine.py @@ -1,10 +1,14 @@ # coding: utf-8 -import sherpa_onnx -import numpy as np -from typing import Optional, List, Any, Tuple from dataclasses import dataclass -from ..base import BaseASREngine, RecognitionStream, EngineCapabilities, RecognitionResult +from typing import List, Optional, Tuple + +import numpy as np + from core import get_logger +from ..base import BaseASREngine, EngineCapabilities, RecognitionStream +from ..sherpa_runtime import load_sherpa_onnx + +sherpa_onnx = load_sherpa_onnx() logger = get_logger('server') @@ -162,7 +166,7 @@ def decode_stream( ): """解码识别流并同步结果""" if context: - logger.debug(f"ParaformerEngine 不支持解码 context,已忽略") + logger.debug("ParaformerEngine 不支持解码 context,已忽略") if language and language != 'auto': logger.debug(f"ParaformerEngine 是中文专用模型,语言设置 '{language}' 已忽略") diff --git a/core/server/engines/sherpa_runtime.py b/core/server/engines/sherpa_runtime.py new file mode 100644 index 00000000..3c8c0883 --- /dev/null +++ b/core/server/engines/sherpa_runtime.py @@ -0,0 +1,49 @@ +"""Runtime compatibility helpers for sherpa-onnx.""" + +from __future__ import annotations + +import os +import sys +from importlib import import_module +from importlib.metadata import version +from importlib.util import find_spec +from pathlib import Path + + +def ensure_sherpa_onnx_runtime() -> Path | None: + """Expose ONNX Runtime's dylib at the rpath used by sherpa-onnx on macOS.""" + if sys.platform != "darwin": + return None + + sherpa_spec = find_spec("sherpa_onnx") + ort_spec = find_spec("onnxruntime") + if sherpa_spec is None or sherpa_spec.origin is None: + raise RuntimeError("sherpa-onnx is not installed; run `uv sync --all-groups`") + if ort_spec is None or ort_spec.origin is None: + raise RuntimeError("onnxruntime is not installed; run `uv sync --all-groups`") + + sherpa_lib = Path(sherpa_spec.origin).parent / "lib" + ort_capi = Path(ort_spec.origin).parent / "capi" + ort_version = version('onnxruntime') + source = ort_capi / f'libonnxruntime.{ort_version}.dylib' + if not source.is_file(): + raise RuntimeError(f"ONNX Runtime {ort_version} dylib was not found at {source}") + target = sherpa_lib / source.name + if target.is_symlink() or target.exists(): + if target.resolve() != source.resolve(): + raise RuntimeError(f"Unexpected ONNX Runtime library already exists at {target}") + return target + + try: + target.symlink_to(Path(os.path.relpath(source, sherpa_lib))) + except OSError as exc: + raise RuntimeError( + "Unable to prepare sherpa-onnx's macOS runtime. " + "Ensure the uv environment is writable and run the command again." + ) from exc + return target + + +def load_sherpa_onnx(): + ensure_sherpa_onnx_runtime() + return import_module('sherpa_onnx') diff --git a/core/server/worker/model_loader.py b/core/server/worker/model_loader.py index 0528e885..28468632 100644 --- a/core/server/worker/model_loader.py +++ b/core/server/worker/model_loader.py @@ -8,8 +8,7 @@ import time from core.server.state import console from config_server import ( - ServerConfig as Config, - ModelPaths + ServerConfig as Config, ) from ..engines.factory import EngineFactory from ..engines.base import EngineCapabilities @@ -39,7 +38,9 @@ def load(self): """ # 1. 延迟导入通用库 with console.status("载入模块中...", spinner="bouncingBall", spinner_style="yellow"): - import sherpa_onnx + from ..engines.sherpa_runtime import ensure_sherpa_onnx_runtime + + ensure_sherpa_onnx_runtime() t1 = time.time() model_type = Config.model_type.lower() @@ -61,8 +62,11 @@ def load(self): # 5. 加载热词 (如果引擎支持 HOTWORDS 能力) if EngineCapabilities.HOTWORDS in caps and Config.hotwords_path.exists(): - hotwords = [l.strip() for l in Config.hotwords_path.read_text('utf-8').splitlines() - if l.strip() and not l.strip().startswith('#')] + hotwords = [ + line.strip() + for line in Config.hotwords_path.read_text('utf-8').splitlines() + if line.strip() and not line.strip().startswith('#') + ] self.recognizer.update_hotwords(hotwords) logger.info(f"全系统初始化完成,耗时: {time.time() - t1:.2f}s") diff --git a/core/tools/external_tools.py b/core/tools/external_tools.py new file mode 100644 index 00000000..e94c45f3 --- /dev/null +++ b/core/tools/external_tools.py @@ -0,0 +1,28 @@ +"""Resolve external command-line tools across source and bundled app launches.""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + + +def find_executable(name: str) -> str | None: + """Find a tool, including Homebrew paths omitted from Finder-launched apps.""" + override = os.environ.get(f'CAPSWRITER_{name.upper()}') + if override: + override_path = Path(override).expanduser() + if override_path.is_file() and os.access(override_path, os.X_OK): + return str(override_path.resolve()) + + resolved = shutil.which(name) + if resolved: + return resolved + + if sys.platform == 'darwin': + for directory in (Path('/opt/homebrew/bin'), Path('/usr/local/bin')): + candidate = directory / name + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + return None diff --git a/scripts/lint_macos_port.sh b/scripts/lint_macos_port.sh new file mode 100755 index 00000000..df2962d2 --- /dev/null +++ b/scripts/lint_macos_port.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +uv run ruff check \ + config_client.py \ + config_server.py \ + core/client/__init__.py \ + core/client/app.py \ + core/client/audio/file_manager.py \ + core/client/audio/recorder.py \ + core/client/audio/stream.py \ + core/client/clipboard/clipboard.py \ + core/client/llm/llm_get_selection.py \ + core/client/llm/llm_output_typing.py \ + core/client/macos_permissions.py \ + core/client/manager/file_runner.py \ + core/client/manager/mic_runner.py \ + core/client/output/result_processor.py \ + core/client/output/text_output.py \ + core/client/platform_input.py \ + core/client/shortcut/key_mapper.py \ + core/client/shortcut/shortcut_manager.py \ + core/client/state.py \ + core/client/transcribe/media_tool.py \ + core/client/ui/tips.py \ + core/server/connection/server_manager.py \ + core/server/connection/ws_recv.py \ + core/server/engines/ct_transformer/punc_engine.py \ + core/server/engines/paraformer_onnx/asr_engine.py \ + core/server/engines/sherpa_runtime.py \ + core/server/worker/model_loader.py \ + core/tools/external_tools.py \ + scripts/download_macos_models.py \ + tests diff --git a/tests/test_audio_file_manager.py b/tests/test_audio_file_manager.py new file mode 100644 index 00000000..b6a64a18 --- /dev/null +++ b/tests/test_audio_file_manager.py @@ -0,0 +1,79 @@ +from pathlib import Path + +from core.client.audio import file_manager + + +class FakeStdin: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + +class FakePopen: + def __init__(self, return_code=0): + self.stdin = FakeStdin() + self.wait_calls = [] + self.return_code = return_code + + def wait(self, timeout=None): + self.wait_calls.append(timeout) + return self.return_code + + +class TimeoutPopen(FakePopen): + def __init__(self): + super().__init__() + self.kill_calls = 0 + + def wait(self, timeout=None): + self.wait_calls.append(timeout) + if timeout is not None: + raise file_manager.TimeoutExpired('ffmpeg', timeout) + return -9 + + def kill(self): + self.kill_calls += 1 + + +def test_finish_waits_for_ffmpeg_before_returning(tmp_path: Path, monkeypatch): + monkeypatch.setattr(file_manager, 'Popen', FakePopen) + process = FakePopen() + manager = file_manager.AudioFileManager() + manager.file_handle = process + manager.file_path = tmp_path / 'recording.mp3' + + assert manager.finish() == manager.file_path + assert process.stdin.closed + assert process.wait_calls == [10] + + +def test_finish_returns_none_and_removes_failed_ffmpeg_output(tmp_path: Path, monkeypatch): + monkeypatch.setattr(file_manager, 'Popen', FakePopen) + process = FakePopen(return_code=1) + output = tmp_path / 'recording.mp3' + output.write_bytes(b'incomplete') + manager = file_manager.AudioFileManager() + manager.file_handle = process + manager.file_path = output + + assert manager.finish() is None + assert not output.exists() + assert manager.file_handle is None + + +def test_finish_kills_timed_out_ffmpeg_and_removes_output(tmp_path: Path, monkeypatch): + monkeypatch.setattr(file_manager, 'Popen', TimeoutPopen) + process = TimeoutPopen() + output = tmp_path / 'recording.mp3' + output.write_bytes(b'incomplete') + manager = file_manager.AudioFileManager() + manager.file_handle = process + manager.file_path = output + + assert manager.finish() is None + assert process.kill_calls == 1 + assert process.wait_calls == [10, None] + assert not output.exists() + assert manager.file_handle is None diff --git a/tests/test_audio_resampling.py b/tests/test_audio_resampling.py new file mode 100644 index 00000000..336487e4 --- /dev/null +++ b/tests/test_audio_resampling.py @@ -0,0 +1,45 @@ +import numpy as np + +from core.client.audio.recorder import StreamingAudioConverter, prepare_audio_for_server + + +def test_resamples_stereo_48k_to_mono_16k(): + source_rate = 48000 + duration = 0.25 + time_axis = np.arange(int(source_rate * duration), dtype=np.float32) / source_rate + tone = np.sin(2 * np.pi * 440 * time_axis).astype(np.float32) + stereo = np.column_stack((tone, tone)) + + result = prepare_audio_for_server(stereo, source_rate) + + assert result.dtype == np.float32 + assert result.shape == (4000,) + assert np.max(np.abs(result)) > 0.9 + + +def test_keeps_native_16k_audio_contiguous(): + source = np.arange(160, dtype=np.float32) + + result = prepare_audio_for_server(source, 16000) + + assert np.array_equal(result, source) + assert result.flags.c_contiguous + + +def test_streaming_resampler_matches_continuous_reference(): + for source_rate in (44100, 48000): + time_axis = np.arange(source_rate, dtype=np.float32) / source_rate + source = np.sin(2 * np.pi * 997 * time_axis).astype(np.float32) + converter = StreamingAudioConverter(source_rate) + block_size = int(source_rate * 0.05) + chunks = [ + converter.process(source[offset:offset + block_size]) + for offset in range(0, len(source), block_size) + ] + chunks.append(converter.process(np.empty(0, dtype=np.float32), last=True)) + + streamed = np.concatenate(chunks) + reference = prepare_audio_for_server(source, source_rate) + + assert streamed.shape == reference.shape == (16000,) + assert np.max(np.abs(streamed - reference)) < 1e-6 diff --git a/tests/test_cross_platform_regression.py b/tests/test_cross_platform_regression.py new file mode 100644 index 00000000..336c12e0 --- /dev/null +++ b/tests/test_cross_platform_regression.py @@ -0,0 +1,151 @@ +import runpy +import sys +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import config_client +from core.client import platform_input +from core.client.clipboard import clipboard +from core.client.llm import llm_get_selection +from core.client.ui.tips import _format_shortcut_name + + +class FakeController: + def __init__(self): + self.modifiers = [] + self.tapped = [] + + @contextmanager + def pressed(self, modifier): + self.modifiers.append(modifier) + yield + + def tap(self, key): + self.tapped.append(key) + + +def test_windows_primary_shortcut_uses_control(monkeypatch): + controller = FakeController() + monkeypatch.setattr(platform_input.sys, 'platform', 'win32') + monkeypatch.setattr(platform_input, '_controller', lambda: controller) + + platform_input.send_primary_shortcut('v') + + assert controller.modifiers == [platform_input.keyboard.Key.ctrl] + assert controller.tapped == ['v'] + + +@pytest.mark.asyncio +async def test_windows_paste_restores_clipboard(monkeypatch): + copied = [] + paste_shortcut = Mock() + + async def no_sleep(_seconds): + return None + + monkeypatch.setattr(clipboard.platform, 'system', lambda: 'Windows') + monkeypatch.setattr(clipboard, 'safe_paste', lambda: 'original') + monkeypatch.setattr(clipboard.pyclip, 'copy', copied.append) + monkeypatch.setattr(clipboard, 'send_paste_shortcut', paste_shortcut) + monkeypatch.setattr(clipboard.asyncio, 'sleep', no_sleep) + + await clipboard.paste_text('recognized', restore_clipboard=True) + + assert copied == ['recognized', 'original'] + paste_shortcut.assert_called_once_with() + + +def test_llm_selection_uses_platform_copy_and_restores_clipboard(monkeypatch): + clipboard_values = iter(('original', 'selected text')) + copied = [] + copy_shortcut = Mock() + role = SimpleNamespace( + enable_read_selection=True, + name='assistant', + selection_max_length=1000, + enable_history=False, + ) + state = SimpleNamespace(last_output_text='previous output') + monkeypatch.setattr(llm_get_selection, 'safe_paste', lambda: next(clipboard_values)) + monkeypatch.setattr(llm_get_selection.pyclip, 'copy', copied.append) + monkeypatch.setattr(llm_get_selection, 'send_copy_shortcut', copy_shortcut) + monkeypatch.setattr(llm_get_selection.time, 'sleep', lambda _seconds: None) + + assert llm_get_selection.get_selected_text(role, state) == 'selected text' + assert copied == ['original'] + copy_shortcut.assert_called_once_with() + + +def test_macos_shortcut_environment_override(monkeypatch): + monkeypatch.setenv('CAPSWRITER_HOTKEY', 'f10') + assert config_client._macos_shortcut_key() == 'f10' + + monkeypatch.setenv('CAPSWRITER_HOTKEY', '') + assert config_client._macos_shortcut_key() == 'shift_r' + + +def test_hotkey_environment_override_is_macos_only(monkeypatch): + monkeypatch.setenv('CAPSWRITER_HOTKEY', 'f10') + monkeypatch.setattr(sys, 'platform', 'win32') + + namespace = runpy.run_path(config_client.__file__) + + assert [item['key'] for item in namespace['ClientConfig'].shortcuts] == ['caps_lock', 'x2'] + + +@pytest.mark.skipif(sys.platform != 'darwin', reason='macOS shortcut defaults') +def test_macos_defaults_to_right_shift(): + from config_client import ClientConfig as Config + + assert Config.shortcuts == [ + { + 'key': 'shift_r', + 'type': 'keyboard', + 'suppress': False, + 'hold_mode': True, + 'enabled': True, + }, + ] + + +def test_directional_modifier_display_name(): + assert _format_shortcut_name('shift_r') == 'Right Shift' + + +@pytest.mark.skipif(sys.platform != 'win32', reason='native Win32 filter regression') +def test_windows_defaults_and_native_filters(): + from config_client import ClientConfig as Config + from core.client.shortcut.key_mapper import WM_KEYDOWN, WM_XBUTTONDOWN, XBUTTON2 + from core.client.shortcut.shortcut_manager import ShortcutManager + from pynput import keyboard + + assert [(item['key'], item['type']) for item in Config.shortcuts] == [ + ('caps_lock', 'keyboard'), + ('x2', 'mouse'), + ] + assert Config.llm_enabled and Config.enable_tray and not Config.paste + + manager = ShortcutManager.__new__(ShortcutManager) + manager._check_emulating = Mock(return_value=False) + manager._check_restoring = Mock(return_value=False) + manager._event_handler = SimpleNamespace(handle_keydown=Mock(), handle_keyup=Mock()) + manager._handle_mouse_keyup = Mock() + manager.keyboard_listener = SimpleNamespace(suppress_event=Mock()) + manager.mouse_listener = SimpleNamespace(suppress_event=Mock()) + + caps_task = SimpleNamespace(shortcut=SimpleNamespace(suppress=True)) + manager.tasks = {'caps_lock': caps_task} + keyboard_filter = manager.create_keyboard_filter() + keyboard_filter(WM_KEYDOWN, SimpleNamespace(vkCode=keyboard.Key.caps_lock.value.vk)) + manager._event_handler.handle_keydown.assert_called_once_with('caps_lock', caps_task) + manager.keyboard_listener.suppress_event.assert_called_once_with() + + x2_task = SimpleNamespace(shortcut=SimpleNamespace(suppress=True)) + manager.tasks = {'x2': x2_task} + mouse_filter = manager.create_mouse_filter() + mouse_filter(WM_XBUTTONDOWN, SimpleNamespace(mouseData=XBUTTON2 << 16)) + manager._event_handler.handle_keydown.assert_called_with('x2', x2_task) + manager.mouse_listener.suppress_event.assert_called_once_with() diff --git a/tests/test_external_tools.py b/tests/test_external_tools.py new file mode 100644 index 00000000..29d793e0 --- /dev/null +++ b/tests/test_external_tools.py @@ -0,0 +1,26 @@ +import os +from pathlib import Path + +from core.tools.external_tools import find_executable + + +def test_find_executable_uses_explicit_override(tmp_path: Path, monkeypatch): + executable = tmp_path / 'ffmpeg-custom' + executable.write_text('#!/bin/sh\n', encoding='utf-8') + executable.chmod(0o755) + monkeypatch.setenv('CAPSWRITER_FFMPEG', str(executable)) + + assert find_executable('ffmpeg') == str(executable.resolve()) + + +def test_find_executable_rejects_non_executable_override(tmp_path: Path, monkeypatch): + candidate = tmp_path / 'ffmpeg-custom' + candidate.write_text('not executable', encoding='utf-8') + candidate.chmod(0o644) + monkeypatch.setenv('CAPSWRITER_FFMPEG', str(candidate)) + monkeypatch.setenv('PATH', '') + + resolved = find_executable('ffmpeg') + + if resolved is not None: + assert os.access(resolved, os.X_OK) diff --git a/tests/test_file_runner.py b/tests/test_file_runner.py new file mode 100644 index 00000000..bc69986d --- /dev/null +++ b/tests/test_file_runner.py @@ -0,0 +1,62 @@ +import io +from pathlib import Path + +import pytest + +from core.client import app as client_app +from core.client.manager import file_runner + + +_resolve_input_files = client_app._resolve_input_files + + +def test_non_interactive_stdin_does_not_require_pause(monkeypatch): + monkeypatch.setattr(file_runner.sys, 'stdin', io.StringIO()) + + assert not file_runner._has_interactive_stdin() + + +def test_relative_input_file_is_resolved_from_launch_directory(tmp_path): + audio = tmp_path / 'input.wav' + audio.write_bytes(b'wave') + + assert _resolve_input_files(['input.wav'], tmp_path) == [audio.resolve()] + assert _resolve_input_files(['missing.wav'], tmp_path) == [] + + +@pytest.mark.parametrize('error', [OSError('bad path'), RuntimeError('symlink loop')]) +def test_unresolvable_input_path_is_ignored_and_valid_file_continues( + tmp_path, monkeypatch, error +): + original_resolve = Path.resolve + warnings = [] + audio = tmp_path / 'input.wav' + audio.write_bytes(b'wave') + + def resolve(path, *args, **kwargs): + if path.name == 'loop': + raise error + return original_resolve(path, *args, **kwargs) + + monkeypatch.setattr(Path, 'resolve', resolve) + monkeypatch.setattr(client_app.logger, 'warning', warnings.append) + + assert _resolve_input_files(['loop', 'input.wav'], tmp_path) == [audio.resolve()] + assert len(warnings) == 1 + assert "'loop'" in warnings[0] + + +def test_expanduser_failure_is_ignored(tmp_path, monkeypatch): + original_expanduser = Path.expanduser + warnings = [] + + def expanduser(path): + if path.name == '~missing-user': + raise RuntimeError('unknown user') + return original_expanduser(path) + + monkeypatch.setattr(Path, 'expanduser', expanduser) + monkeypatch.setattr(client_app.logger, 'warning', warnings.append) + + assert _resolve_input_files(['~missing-user'], tmp_path) == [] + assert len(warnings) == 1 diff --git a/tests/test_key_mapper.py b/tests/test_key_mapper.py new file mode 100644 index 00000000..ec8e77ac --- /dev/null +++ b/tests/test_key_mapper.py @@ -0,0 +1,11 @@ +from pynput import keyboard + +from core.client.shortcut.key_mapper import KeyMapper + + +def test_function_key_name_is_platform_neutral(): + assert KeyMapper.key_to_name(keyboard.Key.f8) == "f8" + + +def test_character_key_name_is_normalized(): + assert KeyMapper.key_to_name(keyboard.KeyCode.from_char("A")) == "a" diff --git a/tests/test_macos_clipboard.py b/tests/test_macos_clipboard.py new file mode 100644 index 00000000..239c010f --- /dev/null +++ b/tests/test_macos_clipboard.py @@ -0,0 +1,64 @@ +import sys + +import pytest + +from core.client.clipboard.clipboard import ( + save_and_restore_clipboard, + snapshot_macos_pasteboard, + write_macos_pasteboard_text, +) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS pasteboard test") +def test_macos_pasteboard_snapshot_restores_multiple_types(): + from AppKit import NSPasteboard, NSPasteboardItem + from Foundation import NSData + + pasteboard = NSPasteboard.pasteboardWithUniqueName() + try: + original = NSPasteboardItem.alloc().init() + text_payload = "原始内容".encode("utf-8") + binary_payload = bytes(range(16)) + original.setData_forType_( + NSData.dataWithBytes_length_(text_payload, len(text_payload)), + "public.utf8-plain-text", + ) + original.setData_forType_( + NSData.dataWithBytes_length_(binary_payload, len(binary_payload)), + "com.capswriter.test-binary", + ) + pasteboard.clearContents() + pasteboard.writeObjects_([original]) + + snapshot = snapshot_macos_pasteboard(pasteboard) + expected_change_count = write_macos_pasteboard_text("临时识别结果", pasteboard) + + assert snapshot.restore(pasteboard, expected_change_count) + restored = pasteboard.pasteboardItems()[0] + assert bytes(restored.dataForType_("public.utf8-plain-text")) == text_payload + assert bytes(restored.dataForType_("com.capswriter.test-binary")) == binary_payload + finally: + pasteboard.releaseGlobally() + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS pasteboard test") +def test_macos_clipboard_context_does_not_overwrite_later_update(): + from AppKit import NSPasteboard, NSPasteboardTypeString + + pasteboard = NSPasteboard.pasteboardWithUniqueName() + try: + write_macos_pasteboard_text("original", pasteboard) + with save_and_restore_clipboard(pasteboard) as mark_clipboard_updated: + write_macos_pasteboard_text("temporary", pasteboard) + mark_clipboard_updated() + + assert pasteboard.stringForType_(NSPasteboardTypeString) == "original" + + with save_and_restore_clipboard(pasteboard) as mark_clipboard_updated: + write_macos_pasteboard_text("temporary", pasteboard) + mark_clipboard_updated() + write_macos_pasteboard_text("new user value", pasteboard) + + assert pasteboard.stringForType_(NSPasteboardTypeString) == "new user value" + finally: + pasteboard.releaseGlobally() diff --git a/tests/test_macos_permissions.py b/tests/test_macos_permissions.py new file mode 100644 index 00000000..e28b634a --- /dev/null +++ b/tests/test_macos_permissions.py @@ -0,0 +1,21 @@ +import sys + +import pytest + +from core.client.macos_permissions import MacOSPermissionStatus + + +def test_permission_status_requires_every_capability(): + assert MacOSPermissionStatus(True, True, True, True).ready + assert not MacOSPermissionStatus(True, True, False, True).ready + + +@pytest.mark.skipif(sys.platform != 'darwin', reason='macOS permission APIs') +def test_permission_checks_return_booleans(): + from core.client.macos_permissions import permission_status + + status = permission_status() + assert isinstance(status.microphone, bool) + assert isinstance(status.accessibility, bool) + assert isinstance(status.input_monitoring, bool) + assert isinstance(status.post_events, bool) diff --git a/tests/test_mic_runner.py b/tests/test_mic_runner.py new file mode 100644 index 00000000..1c483793 --- /dev/null +++ b/tests/test_mic_runner.py @@ -0,0 +1,37 @@ +from types import SimpleNamespace + +from core.client.manager import mic_runner + + +class StartedResource: + def __init__(self, result=None): + self.result = result + self.started = False + + def start(self): + self.started = True + return self.result + + +def test_disabled_llm_monitor_is_not_started(monkeypatch): + stream = StartedResource(result=object()) + llm = StartedResource() + app = SimpleNamespace( + state=SimpleNamespace(), + ws=SimpleNamespace(), + tray=StartedResource(), + stream=stream, + shortcut=StartedResource(), + udp=StartedResource(), + hotword=StartedResource(), + llm=llm, + ) + monkeypatch.setattr(mic_runner.sys, 'platform', 'linux') + monkeypatch.setattr(mic_runner.Config, 'llm_enabled', False) + monkeypatch.setattr(mic_runner.Config, 'udp_control', False) + monkeypatch.setattr(mic_runner.TipsDisplay, 'show_mic_tips', lambda: None) + + mic_runner.MicRunner(app).start_resources() + + assert stream.started + assert not llm.started diff --git a/tests/test_model_download.py b/tests/test_model_download.py new file mode 100644 index 00000000..c4d6cc61 --- /dev/null +++ b/tests/test_model_download.py @@ -0,0 +1,58 @@ +import stat +import zipfile +from pathlib import Path + +import pytest + +from scripts.download_macos_models import ModelAsset, install_asset, safe_extract, sha256_file + + +def test_sha256_file(tmp_path: Path): + payload = tmp_path / 'payload.bin' + payload.write_bytes(b'CapsWriter') + + assert sha256_file(payload) == '10dcf187888763f7ecf61f11c750b7e5f0d4f693c0125109714915eed6bd4949' + + +def test_safe_extract_rejects_parent_traversal(tmp_path: Path): + archive = tmp_path / 'unsafe.zip' + with zipfile.ZipFile(archive, 'w') as bundle: + bundle.writestr('../escape.txt', 'blocked') + + with pytest.raises(ValueError, match='越界路径'): + safe_extract(archive, tmp_path / 'output') + + +def test_safe_extract_rejects_symbolic_link(tmp_path: Path): + archive = tmp_path / 'unsafe-link.zip' + link = zipfile.ZipInfo('model/link') + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + with zipfile.ZipFile(archive, 'w') as bundle: + bundle.writestr(link, '../outside') + + with pytest.raises(ValueError, match='符号链接'): + safe_extract(archive, tmp_path / 'output') + + +def test_install_repairs_corrupted_required_file(tmp_path: Path): + project_root = tmp_path / 'project' + cache_dir = tmp_path / 'cache' + archive = cache_dir / 'TestModel.zip' + archive.parent.mkdir() + with zipfile.ZipFile(archive, 'w') as bundle: + bundle.writestr('test-model/model.onnx', b'valid-model') + + asset = ModelAsset( + name=archive.name, + url='https://invalid.example/TestModel.zip', + sha256=sha256_file(archive), + install_path=Path('models/test-model'), + required_file=Path('model.onnx'), + ) + install_path = install_asset(asset, project_root, cache_dir) + (install_path / 'model.onnx').write_bytes(b'corrupted') + + install_asset(asset, project_root, cache_dir) + + assert (install_path / 'model.onnx').read_bytes() == b'valid-model' diff --git a/tests/test_server_input_limits.py b/tests/test_server_input_limits.py new file mode 100644 index 00000000..73ba1552 --- /dev/null +++ b/tests/test_server_input_limits.py @@ -0,0 +1,58 @@ +from base64 import b64encode +import sys + +import pytest + +from config_server import ServerConfig as Config +from core.protocol import AudioMessage +from core.server.connection.ws_recv import AudioCache, validate_audio_message + + +def make_message(**overrides): + values = { + 'task_id': 'task-1', + 'source': 'mic', + 'data': b64encode(b'\0\0\0\0').decode('ascii'), + 'is_final': False, + 'time_start': 1.0, + 'seg_duration': 60, + 'seg_overlap': 4, + } + values.update(overrides) + return AudioMessage(**values) + + +@pytest.mark.skipif(sys.platform != 'darwin', reason='macOS server default') +def test_macos_server_defaults_to_loopback(): + assert Config.addr == '127.0.0.1' + + +@pytest.mark.parametrize( + 'overrides', + [ + {'source': 'network'}, + {'seg_duration': 0}, + {'seg_duration': Config.max_segment_duration + 1}, + {'seg_overlap': Config.max_segment_overlap + 1}, + {'context': 'x' * (Config.max_context_chars + 1)}, + ], +) +def test_rejects_invalid_audio_message_values(overrides): + with pytest.raises(ValueError): + validate_audio_message(make_message(**overrides)) + + +def test_audio_cache_enforces_total_duration(monkeypatch): + monkeypatch.setattr(Config, 'max_audio_seconds', 1) + cache = AudioCache() + + with pytest.raises(ValueError): + cache.append(b'\0' * (64000 + 4)) + + +def test_audio_cache_rejects_mixed_task_sequence(): + cache = AudioCache() + cache.validate_sequence(make_message(task_id='task-1')) + + with pytest.raises(ValueError, match='混合'): + cache.validate_sequence(make_message(task_id='task-2')) diff --git a/tests/test_sherpa_runtime.py b/tests/test_sherpa_runtime.py new file mode 100644 index 00000000..4f870e38 --- /dev/null +++ b/tests/test_sherpa_runtime.py @@ -0,0 +1,12 @@ +from pathlib import Path + +from core.server.engines.sherpa_runtime import ensure_sherpa_onnx_runtime + + +def test_macos_sherpa_runtime_is_resolvable(): + target = ensure_sherpa_onnx_runtime() + + if target is not None: + assert isinstance(target, Path) + assert target.exists() + assert target.resolve().name.startswith("libonnxruntime.") From d8144b3f7ddc864fa646a73b037e841b7baa9bd9 Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:43:32 +0800 Subject: [PATCH 3/5] docs: document anonymous macOS validation --- docs/macos-port-feasibility.md | 246 +++++++++++++++++++++++++++++++++ docs/macos-setup.md | 111 +++++++++++++++ docs/macos-validation.md | 125 +++++++++++++++++ readme.md | 17 ++- 4 files changed, 495 insertions(+), 4 deletions(-) create mode 100644 docs/macos-port-feasibility.md create mode 100644 docs/macos-setup.md create mode 100644 docs/macos-validation.md diff --git a/docs/macos-port-feasibility.md b/docs/macos-port-feasibility.md new file mode 100644 index 00000000..85f29c14 --- /dev/null +++ b/docs/macos-port-feasibility.md @@ -0,0 +1,246 @@ +# CapsWriter-Offline macOS 适配可行性审计 + +审计日期:2026-07-19 + +审计基线:`7d7fac3541a998be10ebf15102f7884a7dd36edb`(上游 `master`) + +目标环境:Apple Silicon、macOS arm64 + +## 结论 + +macOS 适配**可行**,但不是修改依赖列表后即可运行的小改动。服务端的识别算法、WebSocket 通信和大部分音频处理基本跨平台;主要工作集中在客户端全局快捷键、按键拦截、文本注入、权限、菜单栏生命周期和应用打包。 + +建议先交付“独立 WAV/WebSocket 测试工具 + 命令行服务端 + 粘贴式客户端 + 单个 F 功能键”的最小版本,再实现 Caps Lock 拦截、Qwen/FunASR Metal 加速和签名后的菜单栏应用。现阶段不建议直接追求与 Windows 版完全等价。 + +建议优先路线如下: + +1. 用独立测试工具向服务端发送固定 WAV,先以 Paraformer CPU 跑通识别,不经过现有客户端入口。 +2. 拆开客户端的快捷键/UI 提前导入,再以单个 F 功能键和粘贴输出跑通客户端。 +3. 增加 macOS 快捷键后端、权限预检和 Caps Lock 抑制/恢复。 +4. 接入 llama.cpp `b7798` arm64 动态库,实测 Qwen/FunASR 的 Metal 性能。 +5. 最后处理菜单栏、`.app`、签名、公证和升级分发。 + +## 审计范围与证据 + +本次只进行静态审计和无模型的运行前验证,没有修改现有业务代码,也没有下载语音模型。 + +已完成的验证: + +- `python -m compileall`:项目 Python 源码编译通过,共生成 337 个临时字节码文件,缓存写入临时目录。 +- 客户端依赖 `pip --dry-run`:在 macOS arm64 / Python 3.13 上可解析。 +- 服务端依赖 `pip --dry-run`:因 `onnxruntime-directml` 无 macOS 发行包而失败,符合预期。 +- `sherpa-onnx`:本次可解析到 `sherpa-onnx==1.13.4` 的 macOS 11+ arm64 / Python 3.13 wheel。 +- llama.cpp `b7798`:官方发布中存在 `llama-b7798-bin-macos-arm64.tar.gz`;SHA-256 为 `b47219a34e0c966813e9a3083432cb37fc219c2b06c13dd893e2b13ae8eeb122`,确认包含 arm64 的 `libggml.dylib`、`libggml-base.dylib`、`libllama.dylib`、Metal/CPU/BLAS 等配套库。 +- 使用 `DYLD_LIBRARY_PATH` 指向完整发布目录后,三个项目当前显式加载的动态库均可通过 `ctypes.CDLL` 加载;关键 llama API 符号存在。 +- 当前仓库不包含完整模型和这些动态库,因此尚未验证真实识别延迟、内存峰值和准确率。 + +关键验证可按以下方式重放;依赖解析结果会随 PyPI 更新而变化,不能替代后续 lockfile: + +```bash +tmp="$(mktemp -d)" +PYTHONPYCACHEPREFIX="$tmp/pycache" python3 -m compileall -q . +python3 -m venv "$tmp/venv" +"$tmp/venv/bin/python" -m pip install --dry-run -r requirements-client.txt +"$tmp/venv/bin/python" -m pip install --dry-run -r requirements-server.txt + +gh release download b7798 --repo ggml-org/llama.cpp \ + --pattern llama-b7798-bin-macos-arm64.tar.gz --dir "$tmp" +echo "b47219a34e0c966813e9a3083432cb37fc219c2b06c13dd893e2b13ae8eeb122 $tmp/llama-b7798-bin-macos-arm64.tar.gz" \ + | shasum -a 256 -c - +tar -xzf "$tmp/llama-b7798-bin-macos-arm64.tar.gz" -C "$tmp" +file "$tmp"/build/bin/*.dylib +otool -L "$tmp"/build/bin/libllama.dylib +``` + +## 兼容性矩阵 + +| 模块 | 当前 macOS 状态 | 判断 | +| --- | --- | --- | +| WebSocket C/S 通信 | 无明显平台绑定 | 可直接复用 | +| 麦克风采集 | `sounddevice` 跨平台 | 基本可复用,需权限和采样率兜底 | +| 音频文件转录 | 现有入口提前导入 Win32 快捷键和 Tk | 需独立测试工具或先拆分客户端导入;之后还需 `ffmpeg` | +| Paraformer | `sherpa-onnx` 有 arm64 wheel | 最适合首个服务端 MVP | +| SenseVoice ONNX | 已有 CPU provider 路径,但服务端依赖闭包不完整 | 补齐依赖后进入运行验证,尚未确认可运行 | +| Qwen/FunASR GGUF | 已有 Darwin 动态库文件名分支 | 可适配,必须带完整 `b7798` 库集并实测模型 | +| 全局快捷键 | Win32 事件过滤器和虚拟键码 | 必须重写平台后端 | +| 文本输出 | 默认依赖 `keyboard.write` | macOS MVP 应强制粘贴模式 | +| 当前窗口检测 | macOS 分支能力很有限 | 需要按进程/Bundle ID 重做 | +| 托盘/菜单栏 | 代码显式禁用非 Windows | 需要重做主线程生命周期 | +| Toast/Tk UI | 当前解释器缺少 `_tkinter` | MVP 应关闭,后续改原生 UI 或固定运行时 | +| PyInstaller 打包 | spec 和资源路径偏 Windows | 需要单独 macOS spec、plist、签名和公证流程 | + +## 关键阻塞点 + +### 1. 快捷键层硬编码 Win32 + +- `core/client/shortcut/key_mapper.py:9` 无条件导入 `pynput._util.win32.KeyTranslator`,macOS 启动时即会碰到内部 Win32 后端绑定。 +- `core/client/shortcut/shortcut_manager.py:96-162` 的键盘和鼠标事件处理读取 `vkCode`、`mouseData` 和 Windows 消息常量。 +- `core/client/shortcut/shortcut_manager.py:261-273` 只向 pynput listener 传入 `win32_event_filter`。 +- 默认配置 `config_client.py:18-33` 同时启用了 Caps Lock 和鼠标侧键,并要求 `suppress=True`。 + +不能只把 `win32_event_filter` 政名。应先定义平台无关的按下、释放、抑制和恢复语义,然后保留 Windows 后端,新增 Darwin 后端。macOS 可先使用 pynput 的普通 listener 或 `darwin_intercept`,需要可靠拦截时再使用 Quartz `CGEventTap`。 + +首个 MVP 不应以 Caps Lock 为前置条件。现有任务表按单个键名索引,没有修饰键组合状态机,因此应先使用单个 F 功能键(例如 F8)验证录音和识别主链路。若采用 `Option+Space`,还必须实现组合键解析、抑制及释放顺序,不能把它视为零成本配置变更。 + +### 2. `keyboard` 库不能承担 macOS 核心输入 + +- `core/client/output/text_output.py:15,147` 无条件导入并调用 `keyboard.write`。 +- `core/client/output/result_processor.py:48,150` 调用 `keyboard.press_and_release`,并读取私有字段 `_pressed_events`。 +- `core/client/llm/llm_get_selection.py:45` 硬编码 `ctrl+c`。 +- `core/client/llm/llm_output_typing.py:87-124` 的流式输出也依赖 `keyboard.write`。 + +`keyboard` 官方仓库将 Linux/Windows 作为主要支持平台,macOS 支持仍标为实验性,且按键抑制只支持 Windows。它不适合作为 macOS 的可靠输入后端。 + +项目已有可利用的基础:`core/client/output/text_output.py:119-126` 和 `core/client/clipboard/clipboard.py:125` 已选择 `Command+V`。macOS MVP 应把所有文本输出统一到“写剪贴板 + Command+V”,自动回车和复制选区则改由 pynput/Quartz 的平台后端发送。 + +当前粘贴实现只通过 `pyclip.paste().decode('utf-8')` 保存纯文本,并在固定 100 ms 后恢复。原剪贴板若包含图片、文件或富文本会丢失,较慢应用也可能尚未消费粘贴内容。macOS 版必须二选一:使用 `NSPasteboard` 完整保存/恢复所有 item 与 UTI,并依据 `changeCount` 避免覆盖用户的新剪贴板;或者默认不恢复并明确暴露此限制。不能直接沿用现状后宣称剪贴板安全。 + +### 3. macOS 权限不是可选项 + +至少需要处理: + +- 麦克风权限:录音依赖;`.app` 的 `Info.plist` 需要 `NSMicrophoneUsageDescription`。 +- 辅助功能权限:向其他应用发送粘贴、回车或其他合成事件时需要。 +- 输入监控权限:全局监听或 Event Tap 的具体实现可能需要,尤其是按键抑制。 + +开发脚本、终端中的 Python 和签名后的 `.app` 会被 macOS 视为不同授权主体。产品化前必须固定 Bundle ID 和签名身份,并提供启动时预检与明确错误提示,否则开发环境可用不代表打包后可用。 + +### 4. 服务端依赖写死 DirectML + +`requirements-server.txt:5` 无条件依赖 `onnxruntime-directml`,该包面向 Windows,macOS arm64 无匹配发行包。应使用平台标记拆分: + +```text +onnxruntime-directml; platform_system == "Windows" +onnxruntime; platform_system == "Darwin" +``` + +此外,服务端依赖文件没有列出运行时代码直接导入的 `sentencepiece`、`soundfile` 和 `srt`。分别可见 `core/server/engines/sensevoice_onnx/inference/engine.py:6`、`core/server/engines/sensevoice_onnx/inference/audio.py:6` 和 `core/server/engines/qwen_asr_gguf/inference/exporters.py:5`。若客户端和服务端使用独立环境,不能依赖客户端 requirements 偶然补齐这些包;阶段 0 必须先审计并锁定每个引擎的完整依赖闭包。 + +当前 Qwen、FunASR 和 SenseVoice 的 ONNX wrapper 均已有 `CPUExecutionProvider` 回退路径,因此 CPU MVP 不需要先实现 CoreML,但这只证明代码路径存在,不证明模型已经成功运行。CoreML EP 可以作为后续优化;是否能使用预编译 Python 包、模型算子是否完整支持、是否真正提速,都必须单独验证,不能作为首版承诺。 + +### 5. 现有文件转录不是无头服务端入口 + +`start_client.py:3` 会先导入完整 `CapsWriterClient`。`core/client/app.py:21-33` 在判断文件模式前就导入 manager、快捷键和文本输出,构造函数又在 `core/client/app.py:77` 创建 `ShortcutManager`;这会触发 `core/client/shortcut/key_mapper.py:9` 的 Win32 导入。`core/client/manager/__init__.py:4` 同时提前导入 `MicRunner`,后者在 `core/client/manager/mic_runner.py:4` 导入 UI/Tk。 + +因此,安装 `ffmpeg` 后直接运行现有文件转录仍不能作为 macOS 阶段 0 验收。阶段 0 应增加一个只依赖服务端协议/引擎的固定 WAV 测试工具;现有 FileRunner 则在阶段 1 完成 lazy import 或职责拆分后再验证。 + +### 6. GGUF 动态库“能加载”不等于“模型已跑通” + +项目 loader 已为 Darwin 选择 `.dylib` 文件名,例如 `core/server/engines/qwen_asr_gguf/inference/llama.py:223-225`。审计确认精确版本 `b7798` 的官方 macOS arm64 包包含 loader 需要的库和关键符号。 + +但发布包内部还有 `libggml-metal.dylib`、`libggml-cpu.dylib`、`libggml-blas.dylib` 等依赖。不能只复制三个显式加载的文件;应保留完整库集、符号链接和 `@rpath` 关系,或集中到一个受控的 native runtime 目录。模型真正加载前仍需验证: + +- ctypes wrapper 与 `b7798` ABI 是否完全一致; +- Metal backend 是否实际启用,而非静默回退 CPU; +- 目标支持配置下模型、KV cache、ONNX encoder 和 Python 进程的总峰值; +- 首字延迟、长语音实时率、持续运行稳定性。 + +### 7. 菜单栏和 UI 生命周期需重做 + +`core/ui/tray.py:53-56` 显式禁用非 Windows,`core/ui/tray.py:325` 又在线程中调用 `pystray.Icon.run()`。pystray 的 macOS 文档要求 `run()` 位于主线程,因为 Cocoa 需要主运行循环。Windows 控制台窗口的显示/隐藏逻辑也不能复用。 + +审计所用 Python 运行时未提供 `_tkinter`,而项目 UI/Toast 使用 Tk。最小版本应默认关闭 tray、toast 和 Tk 对话框;产品化阶段再选择固定带 Tk 的 Python 运行时,或将菜单栏和设置界面迁移到原生 Cocoa/Swift 辅助程序。 + +### 8. 其他需要平台化的细节 + +- `core/tools/window_detector.py` 的 Darwin 分支主要依赖 AppleScript,且对 Safari/Terminal 做特殊处理;现有配置仍是 `WeiXin.exe` 等 Windows 进程名。应统一成平台无关应用标识,并在 macOS 使用 Bundle ID/进程名。 +- 麦克风代码固定以 48 kHz 采样并通过切片降到 16 kHz。多数设备可用,但应查询默认设备能力并提供采样率/声道回退。 +- 文件转录依赖外部 `ffmpeg`;审计时尚未安装。 +- 现有 PyInstaller spec 使用 `.ico`、Windows DLL 筛选、反斜杠路径和控制台 `.exe` 假设,不能直接作为可发布的 macOS 构建配置。 + +## 推荐实施计划 + +### 阶段 0:独立服务端基线 + +目标:证明 Apple Silicon 上的识别主链路可用。 + +- 新增独立固定 WAV/WebSocket smoke 工具,不导入 `CapsWriterClient`、快捷键或 UI。 +- 将 ONNX Runtime 依赖改为平台条件,并补齐每个引擎的显式依赖闭包。 +- 建立 macOS 安装说明和可重复的虚拟环境。 +- 优先跑通 Paraformer CPU;SenseVoice CPU 作为补齐依赖后的第二道运行门槛,不预设成功。 +- 用固定 WAV 样本记录启动时间、实时率、峰值内存和识别结果。 +- 安装 `ffmpeg`,但现有客户端 FileRunner 延后到阶段 1 拆分导入后验证。 + +验收门槛:10 次预热后连续处理 100 个固定请求且成功率 100%,再持续运行 30 分钟无未处理异常;记录预热后和结束时 RSS,结束值不得超过预热后中位数的 120%。同一固定样本重复三次应得到一致的规范化文本。这里的 120% 是首轮泄漏筛查线,不是最终性能指标。 + +### 阶段 1:粘贴式客户端 MVP + +目标:在任意常见文本框完成“按键录音、松开识别、粘贴结果”。 + +- 抽象 shortcut 和 synthetic input 平台后端。 +- 使用单个 F 功能键,暂不实现组合键,也不抑制 Caps Lock。 +- 拆分客户端 eager import,使 FileRunner 不再导入快捷键、MicRunner 或 Tk UI。 +- 强制 paste 模式,移除 macOS 路径上的 `keyboard.write` 和私有状态读取。 +- 默认关闭 tray、toast、LLM 流式逐字输出和按应用自动行为。 +- 增加麦克风、辅助功能、输入监控的权限预检。 +- 用 `NSPasteboard` 完整保存/恢复剪贴板多类型数据;若首版不做,必须默认关闭恢复并把限制列为已知风险。 + +验收门槛:记录测试应用和版本,在 TextEdit、Safari 文本框、微信各执行 30 次输入,每类至少 29 次文本正确且无焦点丢失。若选择完整恢复方案,对纯文本、富文本、图片和文件剪贴板逐类验证恢复后 UTI/数据等价;若选择首版不恢复,配置必须默认关闭恢复、不得执行有损的纯文本备份路径,并把“粘贴会替换剪贴板”列为已知限制。权限缺失时必须在 5 秒内给出可操作错误,而不是静默失效。 + +### 阶段 2:Caps Lock 与应用集成 + +目标:恢复 CapsWriter 的核心交互。 + +- 使用 `darwin_intercept` 或 Quartz Event Tap 实现 Caps Lock 的按下、释放、抑制和短按恢复。 +- 增加安全退出和异常后的锁定状态修复。 +- 将按应用规则迁移到 Bundle ID/进程名。 +- 增加睡眠唤醒、快速切换用户、权限被撤销等测试。 + +验收门槛:完成至少 200 次短按/长按交替、3 次睡眠唤醒和 3 次进程强制退出恢复测试;不得遗留错误的大写锁定状态,权限缺失或运行中的权限撤销不得静默失效。 + +### 阶段 3:Qwen/FunASR 与 Metal + +目标:在 Apple Silicon 上取得可接受的准确率和延迟。 + +- 固定 llama.cpp `b7798` arm64 运行时和校验值。 +- 集中管理完整 dylib 集,启动时输出实际 backend。 +- 下载项目对应模型,分别基准 CPU/Metal。 +- 为动态库、模型版本、内存不足和 ABI 错误提供可诊断日志。 + +验收门槛:日志明确证明 Metal backend 已启用;对至少 20 条短句和 5 条五分钟长音频记录实时率、首字延迟、峰值内存和失败率,再决定默认引擎。此阶段先产出基准,不预设“可接受”的阈值。 + +### 阶段 4:发布级 `.app` + +目标:形成普通用户可安装的版本。 + +- 新建独立 macOS PyInstaller spec 或原生启动器,不污染 Windows spec。 +- 使用 `.icns`,补齐 `Info.plist` 权限说明和稳定 Bundle ID。 +- 处理 dylib rpath、签名、Hardened Runtime、公证和首次启动授权流程。 +- 将菜单栏生命周期放在主线程,定义更新和卸载策略。 + +验收门槛:先明确最低支持版本;在最低版本和当前版本各一台未配置开发环境的机器上,安装、首次授权、30 次输入、退出、重启和升级均通过,签名与公证验证无错误。 + +## Spike 预算 + +以下仅用于安排可行性 spike,不是交付日期或发布承诺。每阶段通过验收后必须根据实际权限、模型和应用矩阵重新估算;在 Caps Lock、签名应用 TCC 权限和真实模型基准通过前,不应承诺发布日期。假设单名熟悉 Python/macOS 的工程师,不包含模型训练: + +| 目标 | 估计 | +| --- | --- | +| 命令行服务端 + Paraformer/SenseVoice CPU 基线 | 2-4 个专注工作日 | +| 普通热键 + 粘贴输出 + 权限提示的可用客户端 | 约 1 周 | +| 可靠 Caps Lock 抑制/恢复和跨应用测试 | 再增加约 1 周 | +| Qwen/FunASR Metal、菜单栏、签名公证、发布体验 | 总计约 3-6 周 | + +最大不确定性不是识别算法,而是全局输入权限、Caps Lock 状态机、不同应用的粘贴行为以及最终签名应用的授权稳定性。 + +## 建议的代码边界 + +为避免破坏现有 Windows 用户,适配应遵循以下边界: + +- 保留 Windows 后端和默认行为,平台差异通过明确 backend/factory 选择。 +- 不在业务层散布更多 `platform.system()`;集中到 shortcut、input、window、UI、runtime packaging 边界。 +- 首版 macOS 配置采用保守默认值:paste、普通热键、无 tray、CPU 引擎。 +- 每个阶段先加入可自动运行的纯逻辑测试,再做需要授权的真实系统集成测试。 +- 不向上游创建 PR;所有分支、提交和后续 fork-local PR 仅存在于本 fork。 + +## 参考资料 + +- [pynput FAQ:macOS `darwin_intercept` 与事件抑制](https://pynput.readthedocs.io/en/latest/faq.html) +- [pynput 平台限制与 macOS 辅助功能授权](https://pynput.readthedocs.io/en/latest/limitations.html) +- [keyboard 官方仓库的平台支持说明](https://github.com/boppreh/keyboard) +- [Apple:macOS 媒体采集授权](https://developer.apple.com/documentation/bundleresources/requesting-authorization-for-media-capture-on-macos?language=objc) +- [Apple:全局事件监控与辅助功能](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/MonitoringEvents/MonitoringEvents.html) +- [ONNX Runtime CoreML Execution Provider](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html) +- [pystray:macOS 主线程要求](https://pystray.readthedocs.io/en/latest/usage.html) +- [llama.cpp `b7798` 发布](https://github.com/ggml-org/llama.cpp/releases/tag/b7798) +- [PyInstaller macOS bundle 选项](https://pyinstaller.org/en/stable/usage.html) diff --git a/docs/macos-setup.md b/docs/macos-setup.md new file mode 100644 index 00000000..acfb566f --- /dev/null +++ b/docs/macos-setup.md @@ -0,0 +1,111 @@ +# macOS 安装与运行 + +当前适配目标为 Apple Silicon macOS。默认使用 Paraformer 语音模型和 +Punct-CT-Transformer 标点模型,服务端与客户端均在本机离线运行。 + +## 1. 安装环境 + +```bash +brew install uv ffmpeg +cd ~/CapsWriter-Offline +uv python install 3.12 +uv sync --all-groups +``` + +仓库固定 Python 3.12,所有解释器、虚拟环境与 Python 依赖均由 `uv` 管理。 +不要在项目内使用裸 `pip` 或 `python -m venv`。 + +## 2. 下载模型 + +```bash +uv run python scripts/download_macos_models.py +``` + +脚本从上游 GitHub Releases 下载两个官方 ZIP,逐个校验 SHA-256,拒绝越界路径和 +符号链接,再安装到 Git 忽略的 `models/` 子目录。安装记录同时保存必需模型 +文件哈希;重复执行会重新校验,如文件被损坏则从已校验 ZIP 自动修复。 + +## 3. 启动服务端 + +```bash +CAPSWRITER_MODEL_TYPE=paraformer uv run python start_server.py +``` + +服务默认仅监听 `127.0.0.1:6016`。保持此终端运行。WebSocket 对单帧、 +连接数、分段参数、缓冲和单任务时长均有上限。如确需让局域网客户端连接, +可显式设置 `CAPSWRITER_SERVER_BIND=0.0.0.0`;当前协议没有身份验证,只能在 +受信网络中这样做。远程客户端另用 `CAPSWRITER_SERVER_ADDR=<服务端 IP>` +指定连接目标;端口可在两端统一设置 `CAPSWRITER_SERVER_PORT`。 + +## 4. 构建客户端应用 + +```bash +uv run pyinstaller --noconfirm --clean packaging/macos/CapsWriterClient.spec +open dist/CapsWriter.app +``` + +客户端应用使用独立 Bundle ID +`io.github.alex-ghost599.capswriter-offline.client`。日志、热词、录音和日记位于 +`~/Library/Application Support/CapsWriter-Offline`,不会写入 `.app` 内部。 + +首次启动后,在“系统设置 > 隐私与安全性”中为 `CapsWriter` 开启: + +1. 麦克风 +2. 辅助功能 +3. 输入监控 + +修改权限后退出并重新打开 `CapsWriter.app`。这些权限分别用于采集语音、发送 +Command-V 和监听全局右 Shift。按住右 Shift 说话,松开后识别文本会写入当前前台输入框; +写入过程会保留并恢复剪贴板的原始多类型内容。 + +手动构建使用 ad-hoc 签名。每次重建二进制后,macOS 可能不继承上一版的 +TCC 授权,需删除旧权限项、重新添加最终 `CapsWriter.app` 并再启动。要做稳定 +分发,仍需 Apple Developer 签名身份与公证。 + +## 5. 源码调试 + +源码模式可直接运行: + +```bash +uv run python start_client.py +``` + +macOS 会把 TCC 权限关联到启动进程链。日常使用建议授权独立的 `CapsWriter.app`, +不要长期给通用 Python 解释器开放辅助功能和输入监控。 + +可用以下环境变量选择输入设备或首选采样率: + +```bash +CAPSWRITER_AUDIO_DEVICE=7 CAPSWRITER_AUDIO_SAMPLE_RATE=48000 \ + uv run python start_client.py +``` + +macOS 默认仅配置右 Shift(`shift_r`)作为录音键。可编辑 `config_client.py` 后重启,或在源码/终端 +启动 app 时用 `CAPSWRITER_HOTKEY=f10` 临时选择其他单键。当前仅支持普通单键,不支持 +`Option+Space` 这类修饰键组合。 + +Finder 启动的应用会自动检查 Homebrew 的 `/opt/homebrew/bin` 和 `/usr/local/bin`。 +如使用自定义 FFmpeg,可设置 `CAPSWRITER_FFMPEG` 与 `CAPSWRITER_FFPROBE` 为完整路径。 + +设备不支持首选采样率时,客户端会自动退回设备默认采样率,并高质量重采样到服务端 +要求的 16 kHz 单声道浮点音频。 + +macOS 适配默认关闭可选 LLM 角色润色,专注于完全离线 ASR 主链路。如要开启, +需先在 `config_client.py` 设置 `llm_enabled = True`,并为应用数据目录准备角色配置。 + +## 6. 验证 + +```bash +uv lock --check +uv run pytest -q +bash scripts/lint_macos_port.sh +``` + +文件识别可使用模型自带的真实语音: + +```bash +uv run python start_client.py \ + models/Paraformer/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx/example/asr_example.wav +``` + +模型目录、录音、日志、构建目录与识别副产物均由 `.gitignore` 排除,不提交到 fork。 diff --git a/docs/macos-validation.md b/docs/macos-validation.md new file mode 100644 index 00000000..8c6fcf48 --- /dev/null +++ b/docs/macos-validation.md @@ -0,0 +1,125 @@ +# macOS 运行验证记录 + +验证日期:2026-07-19 + +## 环境 + +- Apple Silicon Mac,arm64 +- macOS 15.x +- Python 3.12.13,由 `uv 0.11.29` 管理 +- FFmpeg 8.1.2(Homebrew) +- sherpa-onnx 1.13.4,onnxruntime 1.27.0,soxr 1.1.0 +- Paraformer + Punct-CT-Transformer 官方模型 + +## 模型与文件链路 + +官方 Release ZIP 校验结果: + +```text +Paraformer.zip +a12a3f9791483329441c94ad759cbcf258d7246784a6d368cd3c591add4d888b + +Punct-CT-Transformer.zip +de106e6cf13764bd3124f31864bc30158f04961788765b63262bdd5ba21fa421 +``` + +模型自带 `asr_example.wav` 为 16 kHz、13.052 秒真实语音。直接加载 Paraformer +约 0.89 秒,解码约 0.23 秒,直接推理进程最大 RSS 约 595 MB。完整服务端 + +WebSocket 文件转录输出: + +```text +正是因为存在绝对正义,所以我们接受现实的相对正义,但是不要因为现实的相对正义, +我们就认为这个世界没有正义。因为如果当你认为这个世界没有正义。 +``` + +这一路径使用真实模型与真实 WAV,不是 mock 或协议 smoke。 +安全加固后服务端实际只监听 `127.0.0.1:6016`;同一真实 WAV 在帧、分段、 +缓冲和连接上限启用后仍于 0.28 秒转写成功。999 秒的非法分段请求被实际 +WebSocket 以关闭码 `1008` 拒绝。 + +## 实时输入链路 + +第一轮已授权的构建产物 `dist/CapsWriter.app`: + +- 114 MB +- Mach-O arm64 +- Bundle ID:`io.github.alex-ghost599.capswriter-offline.client` +- PyInstaller ad-hoc 深度签名校验通过 +- 运行数据:`~/Library/Application Support/CapsWriter-Offline` + +右 Shift 默认值固化后的最终精确产物为 77 MB、Mach-O arm64,Bundle ID 保持不变, +ad-hoc CDHash 为 `ebea3514cb47c29818a3c44b213a48d8bbcf1a79`。 +`codesign --verify --deep --strict` 通过,bundle 内同时包含 soxr 与 PortAudio。启动时明确 +移除了 `CAPSWRITER_HOTKEY` 环境变量,控制台仍显示默认快捷键 `Right Shift`。 + +在 CapsWriter 的麦克风、辅助功能和事件权限获批后,最终构建成功建立: + +1. USB 物理麦克风的 48 kHz、单声道 CoreAudio 输入流 +2. 全局右 Shift 按下/释放监听 +3. `ws://127.0.0.1:6016` 连接 +4. 48 kHz 到 16 kHz 的单声道重采样 +5. Paraformer 真实推理和标点恢复 +6. Command-V 前台应用注入 +7. 多类型剪贴板恢复 + +验收使用右 Shift 和 USB 物理麦克风完成真人说话测试。最终精确 bundle 的 TextEdit +录音为 2.20 秒,转录时延 0.07 秒;Chrome 录音为 1.95 秒,转录时延 0.06 秒。两段 +非个人化测试短句均正确写入目标输入框,录音均保存为 48 kHz、单声道 MP3。客户端日志、 +服务端 Paraformer 输出、前台应用 AX 值和落盘音频四类证据一致。公开记录不包含设备 +品牌、用户路径、录音内容或其他个人标识。 + +最终文本已实际写入: + +- TextEdit 正文 +- Google Chrome 新标签页地址栏(未提交导航,验收后已清空) + +验收前将剪贴板设为固定探针值;两次识别输出后 `pbpaste` 均返回原探针,证明自动 +恢复没有覆盖为识别文本。 + +44.1 kHz 与 48 kHz 的连续信号均按 50 ms 块送入 `soxr.ResampleStream`,刷新后 +与整段 HQ soxr 参考同为 16,000 个采样,当前最大逐样本误差为 0。 +这项修正已随最终 bundle 通过右 Shift、物理 CoreAudio、Paraformer 和前台上屏全链路 +复验。 + +最终打包态文件模式已从仓库目录接收相对 WAV 路径,正确解析到同一 13.052 秒 +真实文件并完成转写,处理耗时 0.28 秒,在无交互 stdin 时以退出码 0 结束。 +最终包还实际接收了不存在的 `~用户` 路径:先记录 warning 并跳过,再继续处理有效 +相对 WAV。符号链接循环及 `OSError` 也有回归测试,不会使客户端启动崩溃。 +Finder 路径解析在独立 44.1 kHz 双声道 +编码中找到 `/opt/homebrew/bin/ffmpeg`,等待编码器成功退出后生成 25,748 字节 MP3。 +最终 bundle 的实时录音 MP3 已通过上述物理麦克风验收。 + +## 可复现环境 + +以下项目级检查已通过: + +```text +uv lock --check +bash scripts/lint_macos_port.sh +uv run pytest -q +git diff --check +``` + +结果:39 passed, 1 skipped,跳过项为只能在 Windows 执行的原生 Win32 filter +回归。所有本分支新增/修改 Python 文件 Ruff 通过。另在 `/tmp` 创建独立 +`UV_PROJECT_ENVIRONMENT`,执行 `uv sync --all-groups --frozen`;项目运行时加载器修复上游 +sherpa-onnx wheel 的 macOS rpath 后成功导入 sherpa-onnx 1.13.4 与 soxr 1.1.0, +真实 Paraformer 推理输出包含“正义”。仓库全量 +Ruff 仍有 930 个上游既有问题,主要来自未改的实验脚本、UI 和 notebooks, +本适配不批量改写这些文件。 + +fork 分支已配置 `windows-latest` 回归,覆盖 uv 冻结安装、客户端导入、 +CapsLock/X2 Win32 filter、Ctrl-V 剪贴板恢复与 LLM Ctrl-C 选区路径。最终结果以 +匿名化历史重建后的 fork-local PR checks 为准,不在公开文档中保留已清理提交的标识。 + +独立 reviewer 对目标、平台边界、安全限制、失败模式和证据质量完成多轮复核, +最终结论为 PASS,未发现未关闭 High/Medium。FFmpeg 超时分支现已覆盖 +`kill -> wait -> 删除残件 -> None`;剩余 Low 是剪贴板 changeCount 检查后仍存在 +极小的系统级 TOCTOU 窗口。 + +## 未关闭门槛 + +- 当前 bundle 使用 ad-hoc 签名,仅适合本机手动构建与授权;正式分发仍需要稳定的 + Developer ID 签名与 Apple 公证。 +- macOS 原生事件 API 无法可靠提供 Caps Lock 的物理按下/释放时长,因此本 fork 默认 + 使用右 Shift。若必须使用 Caps Lock 长按,需要额外键盘重映射/驱动层支持。 diff --git a/readme.md b/readme.md index f971dc3b..86b27743 100644 --- a/readme.md +++ b/readme.md @@ -2,9 +2,10 @@ ![demo](assets/demo.png) -> **按住 CapsLock 说话,松开就上屏。就这么简单。** +> **Windows 按住 CapsLock,macOS 按住右 Shift;说话后松开就上屏。** -**CapsWriter-Offline** 是一个专为 Windows 打造的**完全离线**语音输入工具。 +**CapsWriter-Offline** 是一个**完全离线**语音输入工具,支持 Windows;本 fork 另提供 +Apple Silicon macOS 手动构建与运行适配。 ## ✨ 核心特性 @@ -41,6 +42,7 @@ | Qwen3-ASR-1.7B | 4.0s | 1.0s | 详细功能说明请参考 [`docs/`](docs/) 目录: +- [macOS 安装与运行](docs/macos-setup.md) — uv 环境、模型校验、应用构建和 TCC 权限 - [环境依赖安装说明](docs/环境依赖安装说明.md) — VC++ 运行库、FFmpeg 安装 - [热词功能如何使用](docs/热词功能如何使用.md) — 热词替换、规则替换、自定义短语 - [角色功能如何使用](docs/角色功能如何使用.md) — LLM 角色配置、输出模式、创建新角色 @@ -54,10 +56,12 @@ ## 💻 平台支持 -目前**仅能保证在 Windows 10/11 (64位) 下完美运行**。 +Windows 10/11(64 位)仍是上游主要支持平台。 - **Linux**:暂无环境进行测试和打包,无法保证兼容性。 -- **MacOS**:由于底层的 `keyboard` 库已放弃支持 MacOS,且系统限制极多,暂时无法支持。 +- **macOS**:本 fork 已在 Apple Silicon 上使用 USB 物理麦克风和真人说话验证 + Paraformer 服务端、右 Shift 全局热键、TextEdit/Chrome 上屏及剪贴板恢复。当前需要按 + [macOS 安装与运行](docs/macos-setup.md) 从源码构建,尚未使用 Developer ID 签名或公证发布。 [LazyTyper](https://lazytyper.com/) 和 [闪电说](https://shandianshuo.cn/) 也是很优秀的作品,都有离线引擎,都支持 Windows Linux 与 MacOS,并都有漂亮的图形化页面,推荐使用。 @@ -71,6 +75,9 @@ CapsWriter 的特别之处在于追求: ## 🎬 快速开始 +Apple Silicon macOS 请直接按 [macOS 安装与运行](docs/macos-setup.md) 操作。以下步骤为 +Windows 发行包流程。 + 1. **准备环境**:确保安装了 [VC++ 运行库](https://learn.microsoft.com/zh-cn/cpp/windows/latest-supported-vc-redist)。若要使用文件转录功能,还需安装 [ffmpeg](https://ffmpeg.org/download.html) 并确保其在系统 PATH 中。 2. **下载解压**:下载 [Latest Release](https://github.com/HaujetZhao/CapsWriter-Offline/releases/latest) 里的软件本体,再到 [Models Release](https://github.com/HaujetZhao/CapsWriter-Offline/releases/tag/models) 下载模型压缩包,将模型解压,放入 `models` 文件夹中对应模型的文件夹里。 3. **启动服务**:双击 `start_server.exe`,**它会自动最小化到托盘菜单**。 @@ -81,6 +88,8 @@ CapsWriter 的特别之处在于追求: ## ⚙️ 个性化配置 所有的设置都在根目录的 `config_server.py` 和 `config_client.py` 里,可直接编辑。 +macOS 默认仅配置右 Shift(`shift_r`)作为录音键。源码或终端启动时可通过 +`CAPSWRITER_HOTKEY=f10` 临时改为其他单键;当前不支持 `Option+Space` 这类组合键。 ## 🛠️ 常见问题 From 5a9c80b8ab621e2bf43f1f41e4f027adabdb8f8a Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:57:49 +0800 Subject: [PATCH 4/5] docs: record sanitized CI result --- docs/macos-validation.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/macos-validation.md b/docs/macos-validation.md index 8c6fcf48..199417a2 100644 --- a/docs/macos-validation.md +++ b/docs/macos-validation.md @@ -100,7 +100,7 @@ uv run pytest -q git diff --check ``` -结果:39 passed, 1 skipped,跳过项为只能在 Windows 执行的原生 Win32 filter +结果:40 passed, 1 skipped,跳过项为只能在 Windows 执行的原生 Win32 filter 回归。所有本分支新增/修改 Python 文件 Ruff 通过。另在 `/tmp` 创建独立 `UV_PROJECT_ENVIRONMENT`,执行 `uv sync --all-groups --frozen`;项目运行时加载器修复上游 sherpa-onnx wheel 的 macOS rpath 后成功导入 sherpa-onnx 1.13.4 与 soxr 1.1.0, @@ -109,13 +109,9 @@ Ruff 仍有 930 个上游既有问题,主要来自未改的实验脚本、UI 本适配不批量改写这些文件。 fork 分支已配置 `windows-latest` 回归,覆盖 uv 冻结安装、客户端导入、 -CapsLock/X2 Win32 filter、Ctrl-V 剪贴板恢复与 LLM Ctrl-C 选区路径。最终结果以 -匿名化历史重建后的 fork-local PR checks 为准,不在公开文档中保留已清理提交的标识。 - -独立 reviewer 对目标、平台边界、安全限制、失败模式和证据质量完成多轮复核, -最终结论为 PASS,未发现未关闭 High/Medium。FFmpeg 超时分支现已覆盖 -`kill -> wait -> 删除残件 -> None`;剩余 Low 是剪贴板 changeCount 检查后仍存在 -极小的系统级 TOCTOU 窗口。 +CapsLock/X2 Win32 filter、Ctrl-V 剪贴板恢复与 LLM Ctrl-C 选区路径。匿名化历史 +重建后的回归结果为 36 passed, 5 skipped;公开文档不保留已清理提交或 Actions +运行的标识。FFmpeg 超时分支覆盖 `kill -> wait -> 删除残件 -> None`。 ## 未关闭门槛 From 6857e9b9ac075439447d0ba4e434763511937743 Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:10:45 +0800 Subject: [PATCH 5/5] docs: clarify macOS fork attribution --- core/client/ui/tips.py | 4 ++-- readme.md | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/core/client/ui/tips.py b/core/client/ui/tips.py index d18bf1b8..3ebe60f0 100644 --- a/core/client/ui/tips.py +++ b/core/client/ui/tips.py @@ -88,7 +88,7 @@ def show_mic_tips() -> None: markdown = f''' -项目地址:https://github.com/Alex-ghost599/CapsWriter-Offline +项目地址:https://github.com/Alex-ghost599/CapsWriter-Offline-macOS {platform_intro} @@ -132,7 +132,7 @@ def show_file_tips() -> None: """显示文件转录模式的启动提示""" console.print(f'\n版本:[bold green]{__version__}[/]') - markdown = '\n项目地址:https://github.com/Alex-ghost599/CapsWriter-Offline' + markdown = '\n项目地址:https://github.com/Alex-ghost599/CapsWriter-Offline-macOS' console.print(Markdown(markdown), highlight=True) console.print(f'当前基文件夹:[cyan underline]{os.getcwd()}[/]') console.print(f'服务端地址: [cyan underline]{Config.addr}:{Config.port}[/]') diff --git a/readme.md b/readme.md index 86b27743..32862a6a 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,11 @@ -# CapsWriter-Offline +# CapsWriter-Offline-macOS ![demo](assets/demo.png) +> [!NOTE] +> 本仓库基于 [@HaujetZhao/CapsWriter-Offline](https://github.com/HaujetZhao/CapsWriter-Offline) +> 进行 macOS 可用适配;上游仍以 Windows 为主要支持平台。 + > **Windows 按住 CapsLock,macOS 按住右 Shift;说话后松开就上屏。** **CapsWriter-Offline** 是一个**完全离线**语音输入工具,支持 Windows;本 fork 另提供 @@ -110,7 +114,7 @@ A: `Win+R` 输入 `shell:startup` 打开启动文件夹,将服务端、客户 更多问题请参阅 [docs/常见问题.md](docs/常见问题.md)。 -## 🚀 我的其他优质项目推荐 +## 🚀 上游作者的其他项目 | 项目名称 | 说明 | 体验地址 | | :--- | :--- | :--- | @@ -130,12 +134,17 @@ A: `Win+R` 输入 `shell:startup` 打开启动文件夹,将服务端、客户 - [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) - [FunASR](https://github.com/alibaba-damo-academy/FunASR) -感谢 Google Antigravity、Anthropic Claude、GLM、DeepSeek,如果不是这些编程助手,许多功能(例如基于音素的热词检索算法)我是无力实现的。 +上游作者感谢 Google Antigravity、Anthropic Claude、GLM、DeepSeek 对项目开发的帮助。 -特别感谢那些慷慨解囊的捐助者,你们的捐助让我用在了购买这些优质的 AI 编程助手服务,并最终将这些成果反馈到了软件的更新里。 +同时感谢支持上游项目并帮助其持续开发的捐助者。 -如果觉得好用,欢迎点个 Star 或者打赏支持: +如需支持原作者,可使用上游保留的赞助入口: ![sponsor](assets/sponsor.jpg) + +## 许可证 + +本仓库沿用上游的 [MIT License](LICENSE)。允许使用、复制、修改和再分发;分发时须保留 +`LICENSE` 中的原版权声明与许可声明。