From 15a00103f2f75c2e97d82e20fcc83349ec9d3f4d Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Wed, 9 Sep 2026 01:54:00 -0400 Subject: [PATCH 01/11] fix(windows): support packaged runtime paths --- run_background.vbs | 13 +++++++++---- src/assets.py | 11 ++++++++++- src/autostart.py | 28 ++++++++++++++++------------ tests/test_assets.py | 22 ++++++++++++++++++++++ tests/test_autostart.py | 35 +++++++++++++++++++++++++++-------- 5 files changed, 84 insertions(+), 25 deletions(-) diff --git a/run_background.vbs b/run_background.vbs index 619a325..78f6a8b 100644 --- a/run_background.vbs +++ b/run_background.vbs @@ -3,17 +3,22 @@ Set fso = CreateObject("Scripting.FileSystemObject") Dim pythonPath Dim scriptPath +Dim packagedPath ' Get absolute path to the current directory strPath = fso.GetParentFolderName(WScript.ScriptFullName) scriptPath = Chr(34) & strPath & "\run.py" & Chr(34) +packagedPath = strPath & "\dist\Murmur\murmur.exe" -' Check for venv, otherwise use system pythonw -If fso.FileExists(strPath & "\venv\Scripts\pythonw.exe") Then +' Prefer the packaged release, then fall back to source mode +If fso.FileExists(packagedPath) Then + WinScriptHost.Run Chr(34) & packagedPath & Chr(34), 0 +ElseIf fso.FileExists(strPath & "\venv\Scripts\pythonw.exe") Then pythonPath = Chr(34) & strPath & "\venv\Scripts\pythonw.exe" & Chr(34) + WinScriptHost.Run pythonPath & " " & scriptPath, 0 Else pythonPath = "pythonw.exe" + WinScriptHost.Run pythonPath & " " & scriptPath, 0 End If -WinScriptHost.Run pythonPath & " " & scriptPath, 0 -Set WinScriptHost = Nothing \ No newline at end of file +Set WinScriptHost = Nothing diff --git a/src/assets.py b/src/assets.py index 8e5157c..1a7c2f5 100644 --- a/src/assets.py +++ b/src/assets.py @@ -1,5 +1,6 @@ """Shared asset path helpers.""" +import sys from pathlib import Path from PIL import Image @@ -10,10 +11,18 @@ APP_ICON_FILENAME = "murmur.ico" +def _get_resource_root() -> Path: + """Return the source or frozen-bundle directory containing app assets.""" + bundle_root = getattr(sys, "_MEIPASS", None) + if bundle_root is not None: + return Path(bundle_root) + return PROJECT_ROOT + + def get_logo_path() -> Path | None: """Return the preferred app logo path when it exists.""" for filename in ("murmur tray logo.png", "murmur.png"): - path = PROJECT_ROOT / filename + path = _get_resource_root() / filename if path.exists(): return path return None diff --git a/src/autostart.py b/src/autostart.py index 30903ac..6a6d0aa 100644 --- a/src/autostart.py +++ b/src/autostart.py @@ -12,27 +12,31 @@ winreg = None +def _get_launch_command() -> str: + """Return the current Murmur launch command for Windows startup.""" + executable = Path(sys.executable) + if getattr(sys, "frozen", False): + return f'"{executable}"' + + if executable.name.lower() == "python.exe": + executable = executable.with_name("pythonw.exe") + + script_path = Path(__file__).resolve().parent.parent / "run.py" + return f'"{executable}" "{script_path}"' + + def set_autostart(enabled: bool): """ Enable or disable auto-start with Windows. - Uses sys.executable to ensure it uses the same environment that launched the app. + Uses the packaged executable when frozen, otherwise the current Python + environment's windowless interpreter and ``run.py``. """ if winreg is None: return app_name = "Murmur" - # sys.executable points to the current python.exe or pythonw.exe - current_python = sys.executable - - # Ensure we use the windowless version for background startup - if current_python.lower().endswith("python.exe"): - python_exe = current_python.lower().replace("python.exe", "pythonw.exe") - else: - python_exe = current_python - - script_path = Path(__file__).resolve().parent.parent / "run.py" - cmd = f'"{python_exe}" "{script_path}"' + cmd = _get_launch_command() key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" diff --git a/tests/test_assets.py b/tests/test_assets.py index 5124486..8695b81 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -1,6 +1,28 @@ from src import assets +def test_logo_path_uses_project_root_in_source_mode(tmp_path, monkeypatch): + logo_path = tmp_path / "murmur tray logo.png" + logo_path.write_bytes(b"logo") + monkeypatch.setattr(assets, "PROJECT_ROOT", tmp_path) + monkeypatch.delattr(assets.sys, "_MEIPASS", raising=False) + + assert assets.get_logo_path() == logo_path + + +def test_logo_path_uses_bundle_root_when_frozen(tmp_path, monkeypatch): + source_root = tmp_path / "source" + bundle_root = tmp_path / "bundle" + source_root.mkdir() + bundle_root.mkdir() + logo_path = bundle_root / "murmur tray logo.png" + logo_path.write_bytes(b"logo") + monkeypatch.setattr(assets, "PROJECT_ROOT", source_root) + monkeypatch.setattr(assets.sys, "_MEIPASS", str(bundle_root), raising=False) + + assert assets.get_logo_path() == logo_path + + def test_app_icon_uses_canonical_app_data_dir(tmp_path, monkeypatch): logo_path = tmp_path / "logo.png" icon_dir = tmp_path / "app-data" diff --git a/tests/test_autostart.py b/tests/test_autostart.py index 3179c97..d23d70d 100644 --- a/tests/test_autostart.py +++ b/tests/test_autostart.py @@ -1,5 +1,4 @@ import sys -from pathlib import Path from types import SimpleNamespace import pytest @@ -7,6 +6,31 @@ from src import autostart as autostart_module +def test_launch_command_uses_pythonw_for_source_mode(tmp_path, monkeypatch): + source_root = tmp_path / "murmur project" + module_path = source_root / "src" / "autostart.py" + monkeypatch.setattr(autostart_module.sys, "executable", r"C:\Python312\python.exe") + monkeypatch.setattr(autostart_module.sys, "frozen", False, raising=False) + monkeypatch.setattr(autostart_module, "__file__", str(module_path)) + + assert autostart_module._get_launch_command() == ( + f'"C:\\Python312\\pythonw.exe" "{source_root / "run.py"}"' + ) + + +def test_launch_command_uses_packaged_executable(monkeypatch): + monkeypatch.setattr( + autostart_module.sys, + "executable", + r"C:\Program Files\Murmur\murmur.exe", + ) + monkeypatch.setattr(autostart_module.sys, "frozen", True, raising=False) + + assert autostart_module._get_launch_command() == ( + '"C:\\Program Files\\Murmur\\murmur.exe"' + ) + + def test_set_autostart_noops_when_winreg_is_unavailable(monkeypatch): monkeypatch.setattr(autostart_module, "winreg", None) @@ -48,12 +72,7 @@ def close_key(reg_key): ) monkeypatch.setattr(autostart_module, "winreg", fake_winreg) - monkeypatch.setattr(autostart_module.sys, "executable", r"C:\Python312\python.exe") - monkeypatch.setattr( - autostart_module, - "Path", - lambda _: Path(r"C:\test\murmur\src\autostart.py"), - ) + monkeypatch.setattr(autostart_module, "_get_launch_command", lambda: "command") autostart_module.set_autostart(True) @@ -68,7 +87,7 @@ def close_key(reg_key): "Murmur", 0, fake_winreg.REG_SZ, - '"c:\\python312\\pythonw.exe" "C:\\test\\murmur\\run.py"', + "command", ) assert captured["close_key"] is not None assert "delete_value" not in captured From 80679f586d2d9c3dc72b0e77121f29802ca0dce4 Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Wed, 9 Sep 2026 03:12:42 -0400 Subject: [PATCH 02/11] build(windows): add PyInstaller release path --- build_windows.ps1 | 94 ++++++++++++++++++++++++ packaging/hooks/hook-torch.py | 38 ++++++++++ packaging/hooks/hook-webrtcvad.py | 5 ++ packaging/murmur.spec | 107 ++++++++++++++++++++++++++++ pyproject.toml | 3 + run.py | 51 ++++++++++++- src/assets.py | 2 +- tests/test_prepare_windows_build.py | 51 +++++++++++++ tests/test_run.py | 24 +++++++ tools/prepare_windows_build.py | 101 ++++++++++++++++++++++++++ 10 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 build_windows.ps1 create mode 100644 packaging/hooks/hook-torch.py create mode 100644 packaging/hooks/hook-webrtcvad.py create mode 100644 packaging/murmur.spec create mode 100644 tests/test_prepare_windows_build.py create mode 100644 tests/test_run.py create mode 100644 tools/prepare_windows_build.py diff --git a/build_windows.ps1 b/build_windows.ps1 new file mode 100644 index 0000000..e9cbd2d --- /dev/null +++ b/build_windows.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param( + [switch]$SkipInstall +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if ($env:OS -ne "Windows_NT") { + throw "The Murmur release build must run on Windows." +} + +$repoRoot = $PSScriptRoot +$pythonPath = Join-Path $repoRoot "venv\Scripts\python.exe" +$generatedPath = Join-Path $repoRoot "build\windows" +$executablePath = Join-Path $repoRoot "dist\Murmur\murmur.exe" + +if (-not (Test-Path -LiteralPath $pythonPath -PathType Leaf)) { + throw "Create the repository venv before building: py -3.12 -m venv venv" +} + +Push-Location $repoRoot +try { + if (-not $SkipInstall) { + & $pythonPath -m pip install -e ".[packaging]" + if ($LASTEXITCODE -ne 0) { + throw "Failed to install Murmur packaging dependencies." + } + } + + & $pythonPath tools\prepare_windows_build.py ` + --repo-root $repoRoot ` + --output-dir $generatedPath + if ($LASTEXITCODE -ne 0) { + throw "Failed to prepare Windows build resources." + } + + & $pythonPath -m PyInstaller --noconfirm --clean packaging\murmur.spec + if ($LASTEXITCODE -ne 0) { + throw "PyInstaller failed to build Murmur." + } + + if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) { + throw "Expected executable was not created: $executablePath" + } + + $versionInfo = (Get-Item -LiteralPath $executablePath).VersionInfo + $expectedMetadata = @{ + FileDescription = "Murmur - Local Speech-to-Text Hotkey App" + OriginalFilename = "murmur.exe" + ProductName = "Murmur" + } + foreach ($field in $expectedMetadata.Keys) { + if ($versionInfo.$field -ne $expectedMetadata[$field]) { + throw "Executable metadata check failed for $field." + } + } + + $selfCheckLog = Join-Path $generatedPath "self-check-error.txt" + Remove-Item -LiteralPath $selfCheckLog -ErrorAction SilentlyContinue + $previousSelfCheckLog = $env:MURMUR_PACKAGING_SELF_CHECK_LOG + $env:MURMUR_PACKAGING_SELF_CHECK_LOG = $selfCheckLog + try { + $selfCheck = Start-Process ` + -FilePath $executablePath ` + -ArgumentList "--packaging-self-check" ` + -WindowStyle Hidden ` + -PassThru + } + finally { + $env:MURMUR_PACKAGING_SELF_CHECK_LOG = $previousSelfCheckLog + } + + if (-not $selfCheck.WaitForExit(120000)) { + Stop-Process -Id $selfCheck.Id -ErrorAction SilentlyContinue + throw "Packaged dependency self-check timed out after 120 seconds." + } + if ($selfCheck.ExitCode -ne 0) { + $failureDetail = if (Test-Path -LiteralPath $selfCheckLog -PathType Leaf) { + Get-Content -LiteralPath $selfCheckLog -Raw + } + else { + "No Python traceback was captured." + } + throw "Packaged dependency self-check failed with exit code $($selfCheck.ExitCode).`n$failureDetail" + } + Remove-Item -LiteralPath $selfCheckLog -ErrorAction SilentlyContinue + + Write-Host "Built and validated $executablePath" + Write-Host "Product version: $($versionInfo.ProductVersion)" +} +finally { + Pop-Location +} diff --git a/packaging/hooks/hook-torch.py b/packaging/hooks/hook-torch.py new file mode 100644 index 0000000..9f0b6ad --- /dev/null +++ b/packaging/hooks/hook-torch.py @@ -0,0 +1,38 @@ +"""Bundle Torch for Murmur without collecting unrelated training toolchains.""" + +from PyInstaller.utils.hooks import ( + PY_DYLIB_PATTERNS, + collect_data_files, + collect_dynamic_libs, +) + +module_collection_mode = "pyz+py" +warn_on_missing_hiddenimports = False + +datas = collect_data_files( + "torch", + excludes=[ + "**/*.h", + "**/*.hpp", + "**/*.cuh", + "**/*.lib", + "**/*.cpp", + "**/*.pyi", + "**/*.cmake", + ], +) +binaries = collect_dynamic_libs( + "torch", + search_patterns=[*PY_DYLIB_PATTERNS, "*.so.*"], +) +hiddenimports = [ + "torch._C", + "torch.autograd", + "torch.backends.cuda", + "torch.backends.cudnn", + "torch.cuda", + "torch.distributions", + "torch.distributions.categorical", + "torch.nn", + "torch.nn.functional", +] diff --git a/packaging/hooks/hook-webrtcvad.py b/packaging/hooks/hook-webrtcvad.py new file mode 100644 index 0000000..b72d300 --- /dev/null +++ b/packaging/hooks/hook-webrtcvad.py @@ -0,0 +1,5 @@ +"""Collect metadata for the maintained webrtcvad-wheels distribution.""" + +from PyInstaller.utils.hooks import copy_metadata + +datas = copy_metadata("webrtcvad-wheels") diff --git a/packaging/murmur.spec b/packaging/murmur.spec new file mode 100644 index 0000000..acec952 --- /dev/null +++ b/packaging/murmur.spec @@ -0,0 +1,107 @@ +from pathlib import Path + +from PyInstaller.building import build_main +from PyInstaller.utils.hooks import collect_data_files + + +_find_binary_dependencies = build_main.find_binary_dependencies + + +def find_binary_dependencies(binaries, import_packages, symlink_suppression_patterns): + """Initialize Torch's DLL path once instead of importing every subpackage.""" + if any( + package == "torch" or package.startswith("torch.") + for package in import_packages + ): + import_packages = [ + "torch", + *( + package + for package in import_packages + if package != "torch" and not package.startswith("torch.") + ), + ] + return _find_binary_dependencies( + binaries, import_packages, symlink_suppression_patterns + ) + + +build_main.find_binary_dependencies = find_binary_dependencies + +repo_root = Path(SPECPATH).parent +generated_root = repo_root / "build" / "windows" +icon_path = generated_root / "murmur.ico" +version_path = generated_root / "version_info.txt" + +datas = collect_data_files("customtkinter") +datas += collect_data_files("whisper") +datas += [ + (str(repo_root / "murmur tray logo.png"), "."), + (str(repo_root / "murmur logo.png"), "."), +] + +hidden_imports = [ + "pystray._win32", + "winrt.windows.foundation", + "winrt.windows.foundation.collections", + "winrt.windows.media.control", +] + +excluded_modules = [ + "_pytest", + "functorch", + "pytest", + "torch._dynamo", + "torch._inductor", + "torch._lazy", + "torch._numpy", + "torch.onnx", + "torch.utils.hipify", + "torch.utils.tensorboard", +] + +analysis = Analysis( + [str(repo_root / "run.py")], + pathex=[str(repo_root)], + binaries=[], + datas=datas, + hiddenimports=hidden_imports, + hookspath=[str(repo_root / "packaging" / "hooks")], + hooksconfig={}, + runtime_hooks=[], + excludes=excluded_modules, + noarchive=False, + optimize=0, +) + +pyz = PYZ(analysis.pure) + +exe = EXE( + pyz, + analysis.scripts, + [], + exclude_binaries=True, + name="murmur", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=str(icon_path), + version=str(version_path), +) + +coll = COLLECT( + exe, + analysis.binaries, + analysis.datas, + strip=False, + upx=False, + upx_exclude=[], + name="Murmur", +) diff --git a/pyproject.toml b/pyproject.toml index 63b94da..77a3e32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ dev = [ "pytest>=8.0.0", "ruff==0.16.2", ] +packaging = [ + "pyinstaller==6.22.2", +] [project.scripts] murmur = "src.main:main" diff --git a/run.py b/run.py index f96e9ec..d074e8a 100644 --- a/run.py +++ b/run.py @@ -4,7 +4,9 @@ Run this script to start Murmur. """ +import os import sys +import traceback from pathlib import Path # Add the project root to the path @@ -12,5 +14,52 @@ from src.main import main + +def _run_packaging_self_check() -> None: + """Exercise packaged dependencies without starting the desktop runtime.""" + import numpy as np + import torch + import whisper + import winrt.windows.media.control # noqa: F401 + + from src.assets import get_logo_path + + audio = np.zeros(16000, dtype=np.float32) + mel = whisper.log_mel_spectrogram(audio) + if not isinstance(mel, torch.Tensor) or mel.shape[-1] == 0: + raise RuntimeError("Whisper audio processing is unavailable") + + dimensions = whisper.model.ModelDimensions( + n_mels=80, + n_audio_ctx=10, + n_audio_state=8, + n_audio_head=2, + n_audio_layer=1, + n_vocab=100, + n_text_ctx=10, + n_text_state=8, + n_text_head=2, + n_text_layer=1, + ) + model = whisper.model.Whisper(dimensions) + if next(model.parameters(), None) is None: + raise RuntimeError("Whisper model initialization is unavailable") + if get_logo_path() is None: + raise RuntimeError("Packaged Murmur logo is unavailable") + + +def _packaging_self_check_exit_code() -> int: + try: + _run_packaging_self_check() + except Exception: + if error_path := os.environ.get("MURMUR_PACKAGING_SELF_CHECK_LOG"): + Path(error_path).write_text(traceback.format_exc(), encoding="utf-8") + return 1 + return 0 + + if __name__ == "__main__": - main() + if "--packaging-self-check" in sys.argv: + raise SystemExit(_packaging_self_check_exit_code()) + else: + main() diff --git a/src/assets.py b/src/assets.py index 1a7c2f5..d2ac7ee 100644 --- a/src/assets.py +++ b/src/assets.py @@ -21,7 +21,7 @@ def _get_resource_root() -> Path: def get_logo_path() -> Path | None: """Return the preferred app logo path when it exists.""" - for filename in ("murmur tray logo.png", "murmur.png"): + for filename in ("murmur tray logo.png", "murmur logo.png", "murmur.png"): path = _get_resource_root() / filename if path.exists(): return path diff --git a/tests/test_prepare_windows_build.py b/tests/test_prepare_windows_build.py new file mode 100644 index 0000000..ed53b45 --- /dev/null +++ b/tests/test_prepare_windows_build.py @@ -0,0 +1,51 @@ +import importlib.util +from pathlib import Path + +import pytest + +MODULE_PATH = ( + Path(__file__).resolve().parent.parent / "tools" / "prepare_windows_build.py" +) +SPEC = importlib.util.spec_from_file_location("prepare_windows_build", MODULE_PATH) +prepare_windows_build = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(prepare_windows_build) + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("0.1.0", (0, 1, 0, 0)), + ("1.2.3.4", (1, 2, 3, 4)), + ], +) +def test_windows_version_accepts_supported_versions(version, expected): + assert prepare_windows_build._windows_version(version) == expected + + +@pytest.mark.parametrize("version", ["1.2", "1.2.3.dev1", "1.2.3.4.5", "1.2.70000"]) +def test_windows_version_rejects_unsupported_versions(version): + with pytest.raises(ValueError, match=r"version|components"): + prepare_windows_build._windows_version(version) + + +def test_prepare_build_generates_icon_and_version_info(tmp_path): + repo_root = tmp_path / "repo" + output_dir = repo_root / "build" / "windows" + repo_root.mkdir() + (repo_root / "pyproject.toml").write_text( + '[project]\nversion = "1.2.3"\n', encoding="utf-8" + ) + + from PIL import Image + + Image.new("RGBA", (256, 256), color=(73, 109, 137, 255)).save( + repo_root / "murmur tray logo.png" + ) + + prepare_windows_build.prepare_build(repo_root, output_dir) + + assert (output_dir / "murmur.ico").is_file() + version_info = (output_dir / "version_info.txt").read_text(encoding="utf-8") + assert "filevers=(1, 2, 3, 0)" in version_info + assert "StringStruct(u'ProductName', u'Murmur')" in version_info diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..c44f4d9 --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,24 @@ +from pathlib import Path + +import run + + +def test_packaging_self_check_returns_success(monkeypatch): + monkeypatch.setattr(run, "_run_packaging_self_check", lambda: None) + + assert run._packaging_self_check_exit_code() == 0 + + +def test_packaging_self_check_records_failure(monkeypatch, tmp_path: Path): + error_log = tmp_path / "self-check-error.txt" + + def fail() -> None: + raise RuntimeError("dependency unavailable") + + monkeypatch.setattr(run, "_run_packaging_self_check", fail) + monkeypatch.setenv("MURMUR_PACKAGING_SELF_CHECK_LOG", str(error_log)) + + assert run._packaging_self_check_exit_code() == 1 + assert "RuntimeError: dependency unavailable" in error_log.read_text( + encoding="utf-8" + ) diff --git a/tools/prepare_windows_build.py b/tools/prepare_windows_build.py new file mode 100644 index 0000000..dd64bea --- /dev/null +++ b/tools/prepare_windows_build.py @@ -0,0 +1,101 @@ +"""Generate deterministic icon and version inputs for the Windows build.""" + +import argparse +import re +import tomllib +from pathlib import Path + +from PIL import Image + +ICON_SIZES = (16, 24, 32, 48, 64, 128, 256) + + +def _read_project_version(pyproject_path: Path) -> str: + with pyproject_path.open("rb") as handle: + project = tomllib.load(handle)["project"] + return str(project["version"]) + + +def _windows_version(version: str) -> tuple[int, int, int, int]: + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?", version) + if match is None: + raise ValueError( + f"Project version {version!r} must contain three or four integers" + ) + parts = [int(part) if part is not None else 0 for part in match.groups()] + if any(part > 65535 for part in parts): + raise ValueError("Windows version components must not exceed 65535") + return tuple(parts) + + +def _write_icon(source: Path, destination: Path) -> None: + with Image.open(source) as image: + image.save( + destination, + format="ICO", + sizes=[(size, size) for size in ICON_SIZES], + ) + + +def _write_version_info(destination: Path, version: str) -> None: + version_tuple = _windows_version(version) + destination.write_text( + f"""VSVersionInfo( + ffi=FixedFileInfo( + filevers={version_tuple}, + prodvers={version_tuple}, + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo([ + StringTable( + u'040904B0', + [ + StringStruct(u'CompanyName', u'laceyp99'), + StringStruct(u'FileDescription', u'Murmur - Local Speech-to-Text Hotkey App'), + StringStruct(u'FileVersion', u'{version}'), + StringStruct(u'InternalName', u'murmur'), + StringStruct(u'LegalCopyright', u'Copyright (c) laceyp99'), + StringStruct(u'OriginalFilename', u'murmur.exe'), + StringStruct(u'ProductName', u'Murmur'), + StringStruct(u'ProductVersion', u'{version}') + ] + ) + ]), + VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) + ] +) +""", + encoding="utf-8", + ) + + +def prepare_build(repo_root: Path, output_dir: Path) -> None: + """Create the generated resources consumed by the PyInstaller spec.""" + logo_path = repo_root / "murmur tray logo.png" + pyproject_path = repo_root / "pyproject.toml" + for required_path in (logo_path, pyproject_path): + if not required_path.is_file(): + raise FileNotFoundError(f"Required build input is missing: {required_path}") + + output_dir.mkdir(parents=True, exist_ok=True) + version = _read_project_version(pyproject_path) + _write_icon(logo_path, output_dir / "murmur.ico") + _write_version_info(output_dir / "version_info.txt", version) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + prepare_build(args.repo_root.resolve(), args.output_dir.resolve()) + + +if __name__ == "__main__": + main() From 526a86dea844e38dd1f0ac6da173b8110fb33253 Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Wed, 9 Sep 2026 03:14:21 -0400 Subject: [PATCH 03/11] docs(windows): document packaged release workflow --- .github/CODEOWNERS | 1 + README.md | 10 ++++++++++ docs/getting-started.md | 32 ++++++++++++++++++++++++++++++-- docs/settings-and-privacy.md | 5 +++-- 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..7cca126 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @laceyp99 diff --git a/README.md b/README.md index 6741f99..878212c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,16 @@ venv\Scripts\python.exe run.py See [Getting Started](docs/getting-started.md) for CUDA installation, FFmpeg, Ollama setup, background launch, first-run behavior, and the recording flow. +To build the standalone Windows application folder with PyInstaller: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 +``` + +The validated output is written to `dist\Murmur\`. Distribute the complete +folder. See [Build a packaged release](docs/getting-started.md#build-a-packaged-release) +for requirements and repeat-build instructions. + ## Development Run the project checks from the repository virtual environment: diff --git a/docs/getting-started.md b/docs/getting-started.md index d56b2c1..47b0198 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -91,8 +91,36 @@ venv\Scripts\python.exe -m src ``` `run_background.vbs` is the convenience launcher for the background mode. It -uses `venv\Scripts\pythonw.exe` when the repository virtual environment exists -and otherwise falls back to `pythonw.exe` from `PATH`. +prefers `dist\Murmur\murmur.exe` when a packaged build exists, then uses +`venv\Scripts\pythonw.exe`, and finally falls back to `pythonw.exe` from +`PATH`. + +## Build a packaged release + +The packaged release keeps the Python source workflow above unchanged while +providing a standalone Windows application folder. Build it from the repository +root with PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 +``` + +The script installs the pinned PyInstaller dependency into `venv`, generates +Windows icon and version resources, builds Murmur, and runs a packaged dependency +self-check. The first build can take several minutes because Whisper, Torch, and +their native libraries are analyzed. + +The release is written to `dist\Murmur\`. Keep that directory together when +copying or distributing the application, then launch `murmur.exe`. End users do +not need a separate Python installation, but FFmpeg must still be available on +`PATH`. The first launch can download the configured Whisper model if it is not +already in the user's cache. + +For repeat local builds after dependencies are installed, skip the install step: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 -SkipInstall +``` ## First launch diff --git a/docs/settings-and-privacy.md b/docs/settings-and-privacy.md index 60ef05a..5fad3bf 100644 --- a/docs/settings-and-privacy.md +++ b/docs/settings-and-privacy.md @@ -89,8 +89,9 @@ The current default values are: The settings save operation writes a temporary file and replaces the config atomically. Windows autostart is stored in the current user's -`Software\Microsoft\Windows\CurrentVersion\Run` key and launches the same -environment's `pythonw.exe` with `run.py`. +`Software\Microsoft\Windows\CurrentVersion\Run` key. A packaged app registers +its own `murmur.exe`; a source launch registers the same environment's +`pythonw.exe` with `run.py`. ## Training-data logging From a7760836fc80a6e372a7dbc1ac13c3869517f04e Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Wed, 9 Sep 2026 17:57:30 -0400 Subject: [PATCH 04/11] fix(packaging): preserve runtime validation and vocabulary --- docs/settings-and-privacy.md | 10 ++++++---- docs/transcription-and-cleanup.md | 4 +++- run.py | 13 +++++++++++-- src/user_vocab.py | 14 ++++++++++++-- tests/test_run.py | 16 ++++++++++++++++ tests/test_user_vocab.py | 12 ++++++++++++ 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/docs/settings-and-privacy.md b/docs/settings-and-privacy.md index 5fad3bf..12dd1a5 100644 --- a/docs/settings-and-privacy.md +++ b/docs/settings-and-privacy.md @@ -132,10 +132,12 @@ Disabling logging stops future writes; it does not delete existing records. ## Vocabulary and network boundary -An optional gitignored `user_vocab.json` in the repository root supplies -preferred spellings to the Ollama prompt. It is loaded lazily only when the -Ollama post-processor is built; it does not change Whisper's recognition and is -ignored when Ollama cleanup is disabled. +An optional `user_vocab.json` supplies preferred spellings to the Ollama prompt. +Source launches use the gitignored file in the repository root. Packaged +launches use `%APPDATA%\murmur\user_vocab.json` so the file remains writable and +survives replacement of the application folder. It is loaded lazily only when +the Ollama post-processor is built; it does not change Whisper's recognition and +is ignored when Ollama cleanup is disabled. Whisper audio inference and the default Ollama endpoint are local. If `ollama_endpoint` points to another machine, the final transcript sent for diff --git a/docs/transcription-and-cleanup.md b/docs/transcription-and-cleanup.md index d2f6d08..7b83d83 100644 --- a/docs/transcription-and-cleanup.md +++ b/docs/transcription-and-cleanup.md @@ -113,7 +113,9 @@ turn dictated text into an answer, a list, or a rewritten document. `user_vocab.json` is loaded lazily when the LLM post-processor is built. Entries are included in the prompt as preferred vocabulary and corrections. This keeps personal names and project-specific terms out of the codebase while still -allowing the local cleanup model to prefer them. +allowing the local cleanup model to prefer them. Source launches read the file +from the repository root; packaged launches read it from +`%APPDATA%\murmur\user_vocab.json`. ## Implementation Map diff --git a/run.py b/run.py index d074e8a..c8f5550 100644 --- a/run.py +++ b/run.py @@ -12,7 +12,12 @@ # Add the project root to the path sys.path.insert(0, str(Path(__file__).resolve().parent)) -from src.main import main + +def _run_app() -> None: + """Import and start the desktop runtime after handling build-only commands.""" + from src.main import main + + main() def _run_packaging_self_check() -> None: @@ -23,6 +28,10 @@ def _run_packaging_self_check() -> None: import winrt.windows.media.control # noqa: F401 from src.assets import get_logo_path + from src.main import main as app_main + + if not callable(app_main): + raise RuntimeError("Murmur desktop runtime is unavailable") audio = np.zeros(16000, dtype=np.float32) mel = whisper.log_mel_spectrogram(audio) @@ -62,4 +71,4 @@ def _packaging_self_check_exit_code() -> int: if "--packaging-self-check" in sys.argv: raise SystemExit(_packaging_self_check_exit_code()) else: - main() + _run_app() diff --git a/src/user_vocab.py b/src/user_vocab.py index d693313..7d43b3f 100644 --- a/src/user_vocab.py +++ b/src/user_vocab.py @@ -3,15 +3,25 @@ from __future__ import annotations import json +import sys from collections.abc import Mapping from pathlib import Path +from .config import get_app_data_dir + DEFAULT_USER_VOCAB_PATH = Path(__file__).resolve().parents[1] / "user_vocab.json" +def _get_default_user_vocab_path() -> Path: + """Return a writable vocabulary path for the current launch mode.""" + if getattr(sys, "frozen", False): + return get_app_data_dir() / "user_vocab.json" + return DEFAULT_USER_VOCAB_PATH + + def load_user_vocab(path: Path | None = None) -> dict[str, str]: """Return user vocabulary overrides from disk, or an empty mapping.""" - vocab_path = Path(path) if path is not None else DEFAULT_USER_VOCAB_PATH + vocab_path = Path(path) if path is not None else _get_default_user_vocab_path() if not vocab_path.exists(): return {} @@ -36,7 +46,7 @@ def load_user_vocab(path: Path | None = None) -> dict[str, str]: def save_user_vocab(vocab: Mapping[str, str], path: Path | None = None) -> None: """Persist user vocabulary overrides to disk.""" - vocab_path = Path(path) if path is not None else DEFAULT_USER_VOCAB_PATH + vocab_path = Path(path) if path is not None else _get_default_user_vocab_path() vocab_path.parent.mkdir(parents=True, exist_ok=True) normalized_vocab = {str(source): str(target) for source, target in vocab.items()} diff --git a/tests/test_run.py b/tests/test_run.py index c44f4d9..1162632 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,8 +1,24 @@ +import subprocess +import sys from pathlib import Path import run +def test_import_does_not_load_desktop_runtime(): + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import run; raise SystemExit('src.main' in sys.modules)", + ], + cwd=Path(__file__).resolve().parent.parent, + check=False, + ) + + assert result.returncode == 0 + + def test_packaging_self_check_returns_success(monkeypatch): monkeypatch.setattr(run, "_run_packaging_self_check", lambda: None) diff --git a/tests/test_user_vocab.py b/tests/test_user_vocab.py index 2f310bd..f6c28cc 100644 --- a/tests/test_user_vocab.py +++ b/tests/test_user_vocab.py @@ -1,5 +1,6 @@ import json +from src import user_vocab from src.user_vocab import load_user_vocab, save_user_vocab @@ -27,3 +28,14 @@ def test_load_user_vocab_returns_empty_mapping_for_invalid_json(tmp_path): vocab_path.write_text("not json", encoding="utf-8") assert load_user_vocab(vocab_path) == {} + + +def test_default_vocab_path_uses_app_data_when_frozen(tmp_path, monkeypatch): + app_data_dir = tmp_path / "app-data" + monkeypatch.setattr(user_vocab.sys, "frozen", True, raising=False) + monkeypatch.setattr(user_vocab, "get_app_data_dir", lambda: app_data_dir) + + save_user_vocab({"murmer": "Murmur"}) + + assert load_user_vocab() == {"murmer": "Murmur"} + assert (app_data_dir / "user_vocab.json").is_file() From 8abb7f841e4ff3fbf81b6ec168b57f6faed42f8d Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Wed, 9 Sep 2026 17:57:48 -0400 Subject: [PATCH 05/11] fix(windows): prefer source background launch --- docs/getting-started.md | 6 +++--- run_background.vbs | 8 ++++---- tests/test_autostart.py | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 47b0198..281fb1f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -91,9 +91,9 @@ venv\Scripts\python.exe -m src ``` `run_background.vbs` is the convenience launcher for the background mode. It -prefers `dist\Murmur\murmur.exe` when a packaged build exists, then uses -`venv\Scripts\pythonw.exe`, and finally falls back to `pythonw.exe` from -`PATH`. +prefers `venv\Scripts\pythonw.exe` for source development, then uses +`dist\Murmur\murmur.exe` when a packaged build exists, and finally falls back +to `pythonw.exe` from `PATH`. ## Build a packaged release diff --git a/run_background.vbs b/run_background.vbs index 78f6a8b..772be1f 100644 --- a/run_background.vbs +++ b/run_background.vbs @@ -10,12 +10,12 @@ strPath = fso.GetParentFolderName(WScript.ScriptFullName) scriptPath = Chr(34) & strPath & "\run.py" & Chr(34) packagedPath = strPath & "\dist\Murmur\murmur.exe" -' Prefer the packaged release, then fall back to source mode -If fso.FileExists(packagedPath) Then - WinScriptHost.Run Chr(34) & packagedPath & Chr(34), 0 -ElseIf fso.FileExists(strPath & "\venv\Scripts\pythonw.exe") Then +' Prefer source mode for development, then fall back to a packaged release +If fso.FileExists(strPath & "\venv\Scripts\pythonw.exe") Then pythonPath = Chr(34) & strPath & "\venv\Scripts\pythonw.exe" & Chr(34) WinScriptHost.Run pythonPath & " " & scriptPath, 0 +ElseIf fso.FileExists(packagedPath) Then + WinScriptHost.Run Chr(34) & packagedPath & Chr(34), 0 Else pythonPath = "pythonw.exe" WinScriptHost.Run pythonPath & " " & scriptPath, 0 diff --git a/tests/test_autostart.py b/tests/test_autostart.py index d23d70d..75ed7f6 100644 --- a/tests/test_autostart.py +++ b/tests/test_autostart.py @@ -9,12 +9,13 @@ def test_launch_command_uses_pythonw_for_source_mode(tmp_path, monkeypatch): source_root = tmp_path / "murmur project" module_path = source_root / "src" / "autostart.py" - monkeypatch.setattr(autostart_module.sys, "executable", r"C:\Python312\python.exe") + python_path = tmp_path / "venv" / "Scripts" / "python.exe" + monkeypatch.setattr(autostart_module.sys, "executable", str(python_path)) monkeypatch.setattr(autostart_module.sys, "frozen", False, raising=False) monkeypatch.setattr(autostart_module, "__file__", str(module_path)) assert autostart_module._get_launch_command() == ( - f'"C:\\Python312\\pythonw.exe" "{source_root / "run.py"}"' + f'"{python_path.with_name("pythonw.exe")}" "{source_root / "run.py"}"' ) From c1636b6e8cb29e5088968ae6e769cce39b9333d5 Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Wed, 9 Sep 2026 17:58:06 -0400 Subject: [PATCH 06/11] build(windows): guard PyInstaller compatibility workaround --- packaging/murmur.spec | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packaging/murmur.spec b/packaging/murmur.spec index acec952..b22c4bb 100644 --- a/packaging/murmur.spec +++ b/packaging/murmur.spec @@ -1,14 +1,27 @@ from pathlib import Path +import PyInstaller from PyInstaller.building import build_main from PyInstaller.utils.hooks import collect_data_files +EXPECTED_PYINSTALLER_VERSION = "6.22.2" +if PyInstaller.__version__ != EXPECTED_PYINSTALLER_VERSION: + raise RuntimeError( + "packaging/murmur.spec patches a private PyInstaller API and requires " + f"PyInstaller {EXPECTED_PYINSTALLER_VERSION}; found {PyInstaller.__version__}" + ) + _find_binary_dependencies = build_main.find_binary_dependencies def find_binary_dependencies(binaries, import_packages, symlink_suppression_patterns): - """Initialize Torch's DLL path once instead of importing every subpackage.""" + """Initialize Torch's DLL path once instead of importing every subpackage. + + PyInstaller 6.22.2 otherwise imports collected Torch subpackages one by one + during its Windows DLL scan. Torch must be first and imported only at its + package root to avoid repeatedly crashing PyInstaller's isolated helper. + """ if any( package == "torch" or package.startswith("torch.") for package in import_packages From 1af6d128443c605b0c174f4a63bfd16ea7379368 Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Sun, 13 Sep 2026 18:11:38 -0400 Subject: [PATCH 07/11] docs: drop inaccurate FFmpeg requirement Whisper only shells out to FFmpeg for audio file paths; Murmur always passes recorded audio arrays to the model and writes training audio with the stdlib wave module, so FFmpeg is not a runtime dependency. Remove it from the requirements table, install steps, and packaged-release notes. --- README.md | 8 ++++---- docs/getting-started.md | 9 ++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 878212c..ff62cc6 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ Then open [http://127.0.0.1:8000/](http://127.0.0.1:8000/). ## Quick Start -Murmur supports Windows 10/11 and Python 3.12. A microphone and FFmpeg on -`PATH` are required; an NVIDIA GPU and Ollama are optional. +Murmur supports Windows 10/11 and Python 3.12. A microphone is required; an +NVIDIA GPU and Ollama are optional. ```powershell py -3.12 -m venv venv @@ -51,8 +51,8 @@ venv\Scripts\python.exe -m pip install -e ".[dev]" venv\Scripts\python.exe run.py ``` -See [Getting Started](docs/getting-started.md) for CUDA installation, FFmpeg, -Ollama setup, background launch, first-run behavior, and the recording flow. +See [Getting Started](docs/getting-started.md) for CUDA installation, Ollama +setup, background launch, first-run behavior, and the recording flow. To build the standalone Windows application folder with PyInstaller: diff --git a/docs/getting-started.md b/docs/getting-started.md index 281fb1f..fdaecbf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,7 +13,6 @@ the full recording remains available as a fallback. | Windows 10 or 11 | Global hotkeys, tray integration, and Windows media/notification features | Yes | | Python 3.12 | Supported runtime | Yes | | Microphone | Audio capture through `sounddevice` | Yes | -| FFmpeg on `PATH` | Audio support used by Whisper | Yes | | NVIDIA GPU and CUDA-enabled PyTorch | Faster Whisper inference | Optional; CPU fallback is supported | | Ollama server and configured model | Final punctuation/correction pass | Optional; local cleanup remains available | @@ -54,9 +53,6 @@ expected: venv\Scripts\python.exe -c "import torch; print('torch', torch.__version__); print('cuda build', torch.version.cuda); print('cuda available', torch.cuda.is_available()); print('gpu', torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'n/a')" ``` -Install FFmpeg separately and add the directory containing `ffmpeg.exe` to -`PATH`. Open a new terminal after changing `PATH`. - ## Optional Ollama setup Ollama is enabled by default, but it is not required for the core @@ -112,9 +108,8 @@ their native libraries are analyzed. The release is written to `dist\Murmur\`. Keep that directory together when copying or distributing the application, then launch `murmur.exe`. End users do -not need a separate Python installation, but FFmpeg must still be available on -`PATH`. The first launch can download the configured Whisper model if it is not -already in the user's cache. +not need a separate Python or FFmpeg installation. The first launch can download +the configured Whisper model if it is not already in the user's cache. For repeat local builds after dependencies are installed, skip the install step: From 00b2e3c4ed1bd808d17a03042974af7fa569628c Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Sun, 13 Sep 2026 18:11:38 -0400 Subject: [PATCH 08/11] chore: ignore mkdocs site build output --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6e8c151..907eb0d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,8 @@ user_vocab.json .ruff_cache/ .pytest_cache/ +# mkdocs build output +site/ + # Whisper models are cached in user directory, not in project From 3e1c0554205bb5f26a20cbc5e2c5ec610485e2cb Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Sun, 13 Sep 2026 21:32:03 -0400 Subject: [PATCH 09/11] fix(packaging): support model downloads without a console --- docs/getting-started.md | 3 ++ docs/troubleshooting.md | 39 +++++++++++++++++++++ run.py | 14 ++++++++ tests/test_run.py | 76 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) diff --git a/docs/getting-started.md b/docs/getting-started.md index fdaecbf..69084e8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -117,6 +117,9 @@ For repeat local builds after dependencies are installed, skip the install step: powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 -SkipInstall ``` +For maintainers investigating a packaged app that fails to load its model, +see [Diagnose packaged model loading](troubleshooting.md#diagnose-packaged-model-loading-maintainers). + ## First launch ```mermaid diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8594576..8083dab 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -44,6 +44,45 @@ are taking too long to close. - Use a smaller Whisper model such as `tiny` or `base`. - Check startup output for `Device: cuda`. +## Diagnose packaged model loading (maintainers) + +Use this check when a packaged release exits before the tray appears and +you suspect model downloading or loading. Run it from the repository root +after [building the executable](getting-started.md#build-a-packaged-release). + +The regular build self-check stays offline and does not test model downloads. +This check downloads and loads Whisper's `tiny` model on CPU in a fresh +temporary cache, so an already-cached model cannot hide a download failure. +It requires internet access and leaves your usual cache and settings alone. + +```powershell +$modelCheckDir = Join-Path $env:TEMP ("murmur-model-check-" + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $modelCheckDir | Out-Null +$previousModelCache = $env:MURMUR_PACKAGING_SELF_CHECK_MODEL_CACHE +$previousCheckLog = $env:MURMUR_PACKAGING_SELF_CHECK_LOG +try { + $env:MURMUR_PACKAGING_SELF_CHECK_MODEL_CACHE = Join-Path $modelCheckDir "models" + $env:MURMUR_PACKAGING_SELF_CHECK_LOG = Join-Path $modelCheckDir "error.txt" + $check = Start-Process -FilePath .\dist\Murmur\murmur.exe ` + -ArgumentList "--packaging-self-check" -WindowStyle Hidden -Wait -PassThru + if ($check.ExitCode -ne 0) { + throw "Model check failed. Inspect $modelCheckDir\error.txt" + } + Write-Host "Model download and loading passed. Temporary files: $modelCheckDir" +} +finally { + $env:MURMUR_PACKAGING_SELF_CHECK_MODEL_CACHE = $previousModelCache + $env:MURMUR_PACKAGING_SELF_CHECK_LOG = $previousCheckLog +} +``` + +A successful result confirms that the packaged app can download and load +a model. It does not test the microphone, tray, clipboard, autostart, or +CUDA. If it fails, use `error.txt` in the printed temporary directory to +investigate; if that file is absent, the process may have failed before +Python could capture the error. The temporary directory can be deleted +afterward. + ## Failure and fallback behavior For implementation details and the full fallback ladder, see diff --git a/run.py b/run.py index c8f5550..77f33ec 100644 --- a/run.py +++ b/run.py @@ -13,6 +13,15 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) +def _ensure_output_streams() -> None: + """Discard windowless progress output without writing user data to a log.""" + # Like normal stdio, these streams stay open for the process lifetime. + if sys.stdout is None: + sys.stdout = Path(os.devnull).open("w", encoding="utf-8") # noqa: SIM115 + if sys.stderr is None: + sys.stderr = Path(os.devnull).open("w", encoding="utf-8") # noqa: SIM115 + + def _run_app() -> None: """Import and start the desktop runtime after handling build-only commands.""" from src.main import main @@ -56,6 +65,10 @@ def _run_packaging_self_check() -> None: if get_logo_path() is None: raise RuntimeError("Packaged Murmur logo is unavailable") + # Opt in to a real first-download check without touching the user's cache. + if model_cache := os.environ.get("MURMUR_PACKAGING_SELF_CHECK_MODEL_CACHE"): + whisper.load_model("tiny", device="cpu", download_root=model_cache) + def _packaging_self_check_exit_code() -> int: try: @@ -68,6 +81,7 @@ def _packaging_self_check_exit_code() -> int: if __name__ == "__main__": + _ensure_output_streams() if "--packaging-self-check" in sys.argv: raise SystemExit(_packaging_self_check_exit_code()) else: diff --git a/tests/test_run.py b/tests/test_run.py index 1162632..00a819d 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,10 +1,86 @@ +import hashlib +import io +import os +import runpy import subprocess import sys from pathlib import Path +from types import SimpleNamespace + +import pytest import run +@pytest.mark.parametrize("missing_stream", [None, "stdout", "stderr"]) +def test_existing_output_streams_are_preserved(monkeypatch, missing_stream): + with ( + io.StringIO() as stdout, + io.StringIO() as stderr, + monkeypatch.context() as streams, + ): + streams.setattr(sys, "stdout", stdout) + streams.setattr(sys, "stderr", stderr) + if missing_stream is not None: + streams.setattr(sys, missing_stream, None) + try: + run._ensure_output_streams() + print("console output") + print("console error", file=sys.stderr) + if missing_stream != "stdout": + assert sys.stdout is stdout + assert stdout.getvalue() == "console output\n" + if missing_stream != "stderr": + assert sys.stderr is stderr + assert stderr.getvalue() == "console error\n" + finally: + if missing_stream is not None: + replacement = getattr(sys, missing_stream) + if replacement is not None: + replacement.close() + + +def test_windowless_launch_downloads_model_without_saving_output(tmp_path, monkeypatch): + import whisper + + payload = b"model download fixture" + checksum = hashlib.sha256(payload).hexdigest() + url = f"https://example.invalid/{checksum}/model.pt" + + class DownloadResponse(io.BytesIO): + def info(self): + return {"Content-Length": str(len(payload))} + + def urlopen(request_url): + assert request_url == url + return DownloadResponse(payload) + + def app_main(): + whisper._download(url, str(tmp_path), in_memory=False) + print("discarded output") + print("discarded error output", file=sys.stderr) + sys.stdout.flush() + sys.stderr.flush() + assert sys.stdout.name == os.devnull + assert sys.stderr.name == os.devnull + + monkeypatch.setattr(whisper.urllib.request, "urlopen", urlopen) + with monkeypatch.context() as windowless: + windowless.setattr(sys, "stdout", None) + windowless.setattr(sys, "stderr", None) + windowless.setattr(sys, "argv", ["run.py"]) + windowless.setitem(sys.modules, "src.main", SimpleNamespace(main=app_main)) + try: + runpy.run_path(run.__file__, run_name="__main__") + finally: + for stream in (sys.stdout, sys.stderr): + if stream is not None: + stream.close() + + assert (tmp_path / "model.pt").read_bytes() == payload + assert list(tmp_path.iterdir()) == [tmp_path / "model.pt"] + + def test_import_does_not_load_desktop_runtime(): result = subprocess.run( [ From e1c6eff762a1b24756f8d2944b592bf64f3e945e Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Sun, 13 Sep 2026 23:25:54 -0400 Subject: [PATCH 10/11] fix(windows): restore packaged notifications --- README.md | 4 ++-- build_windows.ps1 | 12 +++++----- docs/failure-and-fallbacks.md | 8 +++---- docs/getting-started.md | 26 ++++++++++----------- docs/index.md | 6 ++--- docs/live-pipeline.md | 2 +- docs/pipeline.md | 10 ++++---- docs/settings-and-privacy.md | 10 ++++---- docs/transcription-and-cleanup.md | 2 +- docs/troubleshooting.md | 12 +++++----- docs/vad-segmentation.md | 2 +- mkdocs.yml | 4 ++-- packaging/hooks/hook-torch.py | 2 +- packaging/murmur.spec | 2 +- run_background.vbs | 2 +- src/__init__.py | 2 +- src/__main__.py | 2 +- src/audio.py | 2 +- src/autostart.py | 8 +++---- src/clipboard.py | 2 +- src/config.py | 6 ++--- src/hotkey.py | 2 +- src/logger.py | 2 +- src/main.py | 4 ++-- src/notifications.py | 12 ++++++++-- src/settings_gui.py | 4 ++-- src/settings_schema.py | 2 +- src/transcription.py | 2 +- src/tray.py | 4 ++-- src/vad.py | 2 +- src/vad_segmenter.py | 2 +- src/windows_identity.py | 2 +- tests/test_autostart.py | 6 ++--- tests/test_llm_postprocess.py | 4 ++-- tests/test_notifications.py | 36 +++++++++++++++++++++++++++++ tests/test_prepare_windows_build.py | 2 +- tests/test_user_vocab.py | 6 ++--- tools/prepare_windows_build.py | 4 ++-- 38 files changed, 133 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index ff62cc6..915569d 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Then open [http://127.0.0.1:8000/](http://127.0.0.1:8000/). ## Quick Start -Murmur supports Windows 10/11 and Python 3.12. A microphone is required; an +murmur supports Windows 10/11 and Python 3.12. A microphone is required; an NVIDIA GPU and Ollama are optional. ```powershell @@ -60,7 +60,7 @@ To build the standalone Windows application folder with PyInstaller: powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 ``` -The validated output is written to `dist\Murmur\`. Distribute the complete +The validated output is written to `dist\murmur\`. Distribute the complete folder. See [Build a packaged release](docs/getting-started.md#build-a-packaged-release) for requirements and repeat-build instructions. diff --git a/build_windows.ps1 b/build_windows.ps1 index e9cbd2d..d2b3600 100644 --- a/build_windows.ps1 +++ b/build_windows.ps1 @@ -7,13 +7,13 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" if ($env:OS -ne "Windows_NT") { - throw "The Murmur release build must run on Windows." + throw "The murmur release build must run on Windows." } $repoRoot = $PSScriptRoot $pythonPath = Join-Path $repoRoot "venv\Scripts\python.exe" $generatedPath = Join-Path $repoRoot "build\windows" -$executablePath = Join-Path $repoRoot "dist\Murmur\murmur.exe" +$executablePath = Join-Path $repoRoot "dist\murmur\murmur.exe" if (-not (Test-Path -LiteralPath $pythonPath -PathType Leaf)) { throw "Create the repository venv before building: py -3.12 -m venv venv" @@ -24,7 +24,7 @@ try { if (-not $SkipInstall) { & $pythonPath -m pip install -e ".[packaging]" if ($LASTEXITCODE -ne 0) { - throw "Failed to install Murmur packaging dependencies." + throw "Failed to install murmur packaging dependencies." } } @@ -37,7 +37,7 @@ try { & $pythonPath -m PyInstaller --noconfirm --clean packaging\murmur.spec if ($LASTEXITCODE -ne 0) { - throw "PyInstaller failed to build Murmur." + throw "PyInstaller failed to build murmur." } if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) { @@ -46,9 +46,9 @@ try { $versionInfo = (Get-Item -LiteralPath $executablePath).VersionInfo $expectedMetadata = @{ - FileDescription = "Murmur - Local Speech-to-Text Hotkey App" + FileDescription = "murmur - Local Speech-to-Text Hotkey App" OriginalFilename = "murmur.exe" - ProductName = "Murmur" + ProductName = "murmur" } foreach ($field in $expectedMetadata.Keys) { if ($versionInfo.$field -ne $expectedMetadata[$field]) { diff --git a/docs/failure-and-fallbacks.md b/docs/failure-and-fallbacks.md index 4512f1c..a603bbc 100644 --- a/docs/failure-and-fallbacks.md +++ b/docs/failure-and-fallbacks.md @@ -1,6 +1,6 @@ # Fallbacks And Failure Modes -Murmur treats the live pipeline as an optimization, not the only source of +murmur treats the live pipeline as an optimization, not the only source of truth. The full recording remains available until finalization, so most live failures degrade to a slower full-recording path instead of losing the user's dictation. A recording with no captured audio or a final transcription exception @@ -49,7 +49,7 @@ flowchart LR Disabled --> FullFallback ``` -A degraded live path does not mean the recording failed. It means Murmur should +A degraded live path does not mean the recording failed. It means murmur should ignore partial live output and rebuild the final transcript from the full recording. Live VAD initialization failure is handled as a disabled optimization and leads to the same fallback when no live text is available. @@ -72,7 +72,7 @@ flowchart TB ``` Both `transcribe_segments()` and `transcribe()` perform the local document -cleanup and optional Ollama pass before returning. This gives Murmur three +cleanup and optional Ollama pass before returning. This gives murmur three chances to produce useful text: 1. Use the live transcript accumulated during recording. @@ -82,7 +82,7 @@ chances to produce useful text: ## Clipboard And Logging Outcomes Finalization can still succeed even if clipboard copy fails. In that case, -Murmur reports the copy failure. The logger runs after the clipboard attempt, so +murmur reports the copy failure. The logger runs after the clipboard attempt, so if training data logging is enabled and the log write succeeds, the transcript and source audio are still saved locally in the opt-in training-data area. diff --git a/docs/getting-started.md b/docs/getting-started.md index 69084e8..342bcfc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting Started -Murmur is a Windows-first desktop application. It records microphone audio, +murmur is a Windows-first desktop application. It records microphone audio, keeps the complete recording in memory, performs local Whisper transcription, and copies the final text to the system clipboard. The live VAD and transcription workers reduce the amount of work left after the stop hotkey, but @@ -16,7 +16,7 @@ the full recording remains available as a fallback. | NVIDIA GPU and CUDA-enabled PyTorch | Faster Whisper inference | Optional; CPU fallback is supported | | Ollama server and configured model | Final punctuation/correction pass | Optional; local cleanup remains available | -Murmur's Whisper inference is local after the model is available. Installing +murmur's Whisper inference is local after the model is available. Installing Python packages and downloading a Whisper model may require internet access. Ollama defaults to `http://localhost:11434`; configuring a remote Ollama endpoint sends the final transcript to that endpoint. @@ -32,14 +32,14 @@ venv\Scripts\python.exe -m pip install --upgrade pip For an NVIDIA GPU, install the CUDA-enabled PyTorch wheel recommended by the [official PyTorch selector](https://pytorch.org/get-started/locally/) before -installing Murmur. The repository only requires `torch`; the exact CUDA wheel +installing murmur. The repository only requires `torch`; the exact CUDA wheel depends on the driver and Python environment. ```powershell venv\Scripts\python.exe -m pip install torch --index-url https://download.pytorch.org/whl/cu121 ``` -Then install Murmur and its development tools: +Then install murmur and its development tools: ```powershell venv\Scripts\python.exe -m pip install -e ".[dev]" @@ -64,7 +64,7 @@ ollama pull granite4.1:3b ``` Start Ollama using its desktop service or with `ollama serve`. If the service, -model, or request is unavailable, Murmur keeps the locally cleaned transcript +model, or request is unavailable, murmur keeps the locally cleaned transcript and continues finalization. Use **Settings > LLM Cleanup > Test Ollama Connection** to check an endpoint and model. @@ -88,7 +88,7 @@ venv\Scripts\python.exe -m src `run_background.vbs` is the convenience launcher for the background mode. It prefers `venv\Scripts\pythonw.exe` for source development, then uses -`dist\Murmur\murmur.exe` when a packaged build exists, and finally falls back +`dist\murmur\murmur.exe` when a packaged build exists, and finally falls back to `pythonw.exe` from `PATH`. ## Build a packaged release @@ -102,11 +102,11 @@ powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 ``` The script installs the pinned PyInstaller dependency into `venv`, generates -Windows icon and version resources, builds Murmur, and runs a packaged dependency +Windows icon and version resources, builds murmur, and runs a packaged dependency self-check. The first build can take several minutes because Whisper, Torch, and their native libraries are analyzed. -The release is written to `dist\Murmur\`. Keep that directory together when +The release is written to `dist\murmur\`. Keep that directory together when copying or distributing the application, then launch `murmur.exe`. End users do not need a separate Python or FFmpeg installation. The first launch can download the configured Whisper model if it is not already in the user's cache. @@ -124,7 +124,7 @@ see [Diagnose packaged model loading](troubleshooting.md#diagnose-packaged-model ```mermaid flowchart TB - Launch["Launch Murmur"] --> AppIdentity["Set Windows app identity"] + Launch["Launch murmur"] --> AppIdentity["Set Windows app identity"] AppIdentity --> LoadConfig["Load config from %APPDATA%\\murmur\\config.json"] LoadConfig --> ConfigState{"Config present and valid?"} ConfigState -->|no file| CreateDefaults["Create default config"] @@ -145,14 +145,14 @@ flowchart TB The first launch downloads the selected Whisper model if it is not already in Whisper's user cache. Ollama warmup checks for an installed model but does not download one. If the configured hotkey is invalid or cannot be registered, -Murmur attempts to reset it to the default `ctrl+shift+space`; an unrecoverable +murmur attempts to reset it to the default `ctrl+shift+space`; an unrecoverable registration failure stops startup. ## Record a transcript -1. Wait for the Murmur icon in the Windows system tray. +1. Wait for the murmur icon in the Windows system tray. 2. Press `Ctrl+Shift+Space` (or the configured hotkey) to start recording. -3. Speak normally. Murmur captures 100 ms recorder blocks and processes sealed +3. Speak normally. murmur captures 100 ms recorder blocks and processes sealed speech segments in background workers. 4. Press the hotkey again to stop, or let the configured maximum duration stop capture. @@ -162,7 +162,7 @@ registration failure stops startup. Silence closes VAD segments; it does not stop the overall recording. The stop path uses the accumulated live text when healthy. If live processing is empty -or degraded, Murmur recomputes from the complete recorded audio. +or degraded, murmur recomputes from the complete recorded audio. ## Development checks diff --git a/docs/index.md b/docs/index.md index 670a9c8..8d3230b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ -# Murmur Pipeline Docs +# murmur Pipeline Docs -This directory documents Murmur's setup, runtime behavior, audio processing +This directory documents murmur's setup, runtime behavior, audio processing pipeline, and privacy boundaries in more detail than the root README. The diagrams are written as Mermaid blocks inside Markdown so they are easy to edit, review, and keep in sync with code changes. @@ -19,7 +19,7 @@ review, and keep in sync with code changes. WebRTC VAD frames and speech segments. 6. [Transcription And Cleanup](transcription-and-cleanup.md) covers Whisper, transcript accumulation, local cleanup, and optional Ollama cleanup. -7. [Fallbacks And Failure Modes](failure-and-fallbacks.md) shows how Murmur +7. [Fallbacks And Failure Modes](failure-and-fallbacks.md) shows how murmur recovers when live processing degrades. ## Diagram Editing diff --git a/docs/live-pipeline.md b/docs/live-pipeline.md index 49cf89c..4c2ec99 100644 --- a/docs/live-pipeline.md +++ b/docs/live-pipeline.md @@ -104,7 +104,7 @@ for the same local model and keeps output ordering predictable. If live VAD cannot be initialized—for example, because the configured sample rate is not supported by WebRTC—the recorder still starts without a live VAD callback. The stop path then uses the offline fallback. A callback or worker -failure marks the live pipeline degraded; Murmur keeps capturing the full audio +failure marks the live pipeline degraded; murmur keeps capturing the full audio but ignores partial live output during finalization. Stopping is deliberately ordered: the recorder is stopped first, then live VAD diff --git a/docs/pipeline.md b/docs/pipeline.md index 584c394..58f6e3b 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -1,6 +1,6 @@ # Pipeline Overview -Murmur has one user-visible workflow: press the hotkey, speak, press the hotkey +murmur has one user-visible workflow: press the hotkey, speak, press the hotkey again, and paste the final transcript. Internally, that workflow is split into a live path and a fallback path. @@ -8,7 +8,7 @@ The live path starts VAD segmentation and Whisper transcription while recording is still active. This lowers stop-time latency because many sealed speech segments have already been transcribed before the user releases the hotkey. -The fallback path keeps the system reliable. Murmur still records the full audio +The fallback path keeps the system reliable. murmur still records the full audio clip, so if live VAD or live transcription degrades—or produces no usable text— finalization can recompute the transcript from the full recording. @@ -82,16 +82,16 @@ segment order by `segment_id`. ### Finalization -When recording stops, Murmur stops capture, flushes pending VAD state, drains +When recording stops, murmur stops capture, flushes pending VAD state, drains queued live transcription work, and first checks the per-recording degraded flag. If the live path is healthy and its accumulator contains text, `finalize_segment_texts()` joins the ordered chunks and performs the final cleanup pass. -If the live path degraded or produced no text, Murmur falls back to the full +If the live path degraded or produced no text, murmur falls back to the full recorded clip. The fallback path runs offline VAD segmentation and then Whisper transcription over the resulting speech segments. If offline VAD is unavailable -or finds no speech, Murmur transcribes the full clip directly. Both the segmented +or finds no speech, murmur transcribes the full clip directly. Both the segmented fallback and full-clip path perform final cleanup through `transcribe_segments()`; a completed recording receives one final cleanup path, not one cleanup call per live chunk. diff --git a/docs/settings-and-privacy.md b/docs/settings-and-privacy.md index 12dd1a5..25e9011 100644 --- a/docs/settings-and-privacy.md +++ b/docs/settings-and-privacy.md @@ -1,6 +1,6 @@ # Settings And Privacy -Murmur exposes user-facing settings through the **Settings** item in the +murmur exposes user-facing settings through the **Settings** item in the system-tray menu. The window is owned by a persistent CustomTkinter UI thread; the tray callback places a request on that thread instead of creating a second independent UI loop. @@ -54,10 +54,10 @@ and can be changed only by editing `config.json`; restart after doing so. ## Persistent configuration -The configuration file is `%APPDATA%\murmur\config.json`. Murmur creates the +The configuration file is `%APPDATA%\murmur\config.json`. murmur creates the directory and file on first launch. Missing keys receive values from `DEFAULT_CONFIG`. If the file is malformed, invalid UTF-8, or not a JSON object, -Murmur moves it to a timestamped `config.corrupt-*.json` backup, writes defaults, +murmur moves it to a timestamped `config.corrupt-*.json` backup, writes defaults, and shows a startup notice. The current default values are: @@ -96,7 +96,7 @@ its own `murmur.exe`; a source launch registers the same environment's ## Training-data logging Logging is disabled by default and enabling it requires confirmation in the -privacy tab. Murmur logs only when final text is non-empty and logging is +privacy tab. murmur logs only when final text is non-empty and logging is enabled. The logger runs after the clipboard attempt and writes: ```mermaid @@ -142,7 +142,7 @@ is ignored when Ollama cleanup is disabled. Whisper audio inference and the default Ollama endpoint are local. If `ollama_endpoint` points to another machine, the final transcript sent for cleanup leaves the local computer. The Ollama cleanup result is accepted only -when it remains transcript-like; otherwise Murmur keeps the locally cleaned +when it remains transcript-like; otherwise murmur keeps the locally cleaned text. ## Source map diff --git a/docs/transcription-and-cleanup.md b/docs/transcription-and-cleanup.md index 7b83d83..be2c540 100644 --- a/docs/transcription-and-cleanup.md +++ b/docs/transcription-and-cleanup.md @@ -1,6 +1,6 @@ # Transcription And Cleanup -Murmur uses Whisper for speech-to-text and an optional Ollama model—local by +murmur uses Whisper for speech-to-text and an optional Ollama model—local by default—for a single final cleanup pass. Segment transcription and document cleanup are kept separate so the live path can transcribe chunks early without asking the LLM to rewrite partial text. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8083dab..eebbb7c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -6,7 +6,7 @@ - Another application may already use the configured hotkey. - Try a different hotkey combination from **Settings**. -- If registration continues to fail, try running Murmur as Administrator. +- If registration continues to fail, try running murmur as Administrator. ### No speech detected @@ -18,14 +18,14 @@ ### Clipboard copy failed - Retry the recording if you still need the transcript on your clipboard. -- When training-data logging is disabled, Murmur does not store the transcript +- When training-data logging is disabled, murmur does not store the transcript for recovery. - When logging is enabled and saving succeeds, the transcript remains in the local training-data area even if clipboard copy fails. ### There is a short pause after stopping -Murmur transcribes completed speech segments during recording, but it still +murmur transcribes completed speech segments during recording, but it still performs a final flush, transcript cleanup, and optional Ollama request after the stop hotkey. The remaining delay is usually the last queued segment and those finalization steps. Lower `vad_silence_duration_ms` carefully if segments @@ -36,7 +36,7 @@ are taking too long to close. - Confirm Ollama is running at the configured `ollama_endpoint`. - Confirm the configured model is installed locally. - Increase `ollama_timeout_seconds` if model startup is slow. -- Murmur falls back to the locally cleaned transcript when Ollama is unavailable. +- murmur falls back to the locally cleaned transcript when Ollama is unavailable. ### Slow transcription @@ -63,7 +63,7 @@ $previousCheckLog = $env:MURMUR_PACKAGING_SELF_CHECK_LOG try { $env:MURMUR_PACKAGING_SELF_CHECK_MODEL_CACHE = Join-Path $modelCheckDir "models" $env:MURMUR_PACKAGING_SELF_CHECK_LOG = Join-Path $modelCheckDir "error.txt" - $check = Start-Process -FilePath .\dist\Murmur\murmur.exe ` + $check = Start-Process -FilePath .\dist\murmur\murmur.exe ` -ArgumentList "--packaging-self-check" -WindowStyle Hidden -Wait -PassThru if ($check.ExitCode -ne 0) { throw "Model check failed. Inspect $modelCheckDir\error.txt" @@ -86,6 +86,6 @@ afterward. ## Failure and fallback behavior For implementation details and the full fallback ladder, see -[Fallbacks and Failure Modes](failure-and-fallbacks.md). Murmur keeps the full +[Fallbacks and Failure Modes](failure-and-fallbacks.md). murmur keeps the full recording in memory, so live VAD or live transcription failures generally degrade to offline processing instead of losing the recording. diff --git a/docs/vad-segmentation.md b/docs/vad-segmentation.md index 9654113..f9ae734 100644 --- a/docs/vad-segmentation.md +++ b/docs/vad-segmentation.md @@ -1,6 +1,6 @@ # VAD Segmentation -Murmur uses WebRTC VAD to convert continuous audio into speech segments. The +murmur uses WebRTC VAD to convert continuous audio into speech segments. The same timing settings drive both live segmentation and offline segmentation, but the two paths differ in how they receive audio: diff --git a/mkdocs.yml b/mkdocs.yml index b1a6630..e53aeea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ -site_name: Murmur Docs -site_description: Visual documentation for Murmur's local audio processing pipeline. +site_name: murmur Docs +site_description: Visual documentation for murmur's local audio processing pipeline. repo_url: https://github.com/laceyp99/murmur repo_name: laceyp99/murmur diff --git a/packaging/hooks/hook-torch.py b/packaging/hooks/hook-torch.py index 9f0b6ad..ca8e9d3 100644 --- a/packaging/hooks/hook-torch.py +++ b/packaging/hooks/hook-torch.py @@ -1,4 +1,4 @@ -"""Bundle Torch for Murmur without collecting unrelated training toolchains.""" +"""Bundle Torch for murmur without collecting unrelated training toolchains.""" from PyInstaller.utils.hooks import ( PY_DYLIB_PATTERNS, diff --git a/packaging/murmur.spec b/packaging/murmur.spec index b22c4bb..3e820a2 100644 --- a/packaging/murmur.spec +++ b/packaging/murmur.spec @@ -116,5 +116,5 @@ coll = COLLECT( strip=False, upx=False, upx_exclude=[], - name="Murmur", + name="murmur", ) diff --git a/run_background.vbs b/run_background.vbs index 772be1f..b762168 100644 --- a/run_background.vbs +++ b/run_background.vbs @@ -8,7 +8,7 @@ Dim packagedPath ' Get absolute path to the current directory strPath = fso.GetParentFolderName(WScript.ScriptFullName) scriptPath = Chr(34) & strPath & "\run.py" & Chr(34) -packagedPath = strPath & "\dist\Murmur\murmur.exe" +packagedPath = strPath & "\dist\murmur\murmur.exe" ' Prefer source mode for development, then fall back to a packaged release If fso.FileExists(strPath & "\venv\Scripts\pythonw.exe") Then diff --git a/src/__init__.py b/src/__init__.py index fe85ce0..da4eb69 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,4 +1,4 @@ -# Murmur - Local Speech-to-Text Hotkey App +# murmur - Local Speech-to-Text Hotkey App """ A lightweight Windows application for local speech-to-text transcription using OpenAI Whisper with GPU acceleration. diff --git a/src/__main__.py b/src/__main__.py index e8b028b..cfb5a61 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,5 +1,5 @@ """ -Entry point for running Murmur as a module. +Entry point for running murmur as a module. Usage: python -m src """ diff --git a/src/audio.py b/src/audio.py index 707767a..e1d4251 100644 --- a/src/audio.py +++ b/src/audio.py @@ -1,5 +1,5 @@ """ -Audio recording functionality for Murmur. +Audio recording functionality for murmur. """ import threading diff --git a/src/autostart.py b/src/autostart.py index 6a6d0aa..b412a43 100644 --- a/src/autostart.py +++ b/src/autostart.py @@ -1,5 +1,5 @@ """ -Auto-start management for Murmur on Windows. +Auto-start management for murmur on Windows. """ import contextlib @@ -13,7 +13,7 @@ def _get_launch_command() -> str: - """Return the current Murmur launch command for Windows startup.""" + """Return the current murmur launch command for Windows startup.""" executable = Path(sys.executable) if getattr(sys, "frozen", False): return f'"{executable}"' @@ -34,7 +34,7 @@ def set_autostart(enabled: bool): if winreg is None: return - app_name = "Murmur" + app_name = "murmur" cmd = _get_launch_command() @@ -60,7 +60,7 @@ def is_autostart_enabled() -> bool: return False key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" - app_name = "Murmur" + app_name = "murmur" try: key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_READ) diff --git a/src/clipboard.py b/src/clipboard.py index 04bafef..673cfc0 100644 --- a/src/clipboard.py +++ b/src/clipboard.py @@ -1,5 +1,5 @@ """ -Clipboard module for Murmur. +Clipboard module for murmur. Handles copying transcribed text to the system clipboard. """ diff --git a/src/config.py b/src/config.py index 17f0f9f..932cb15 100644 --- a/src/config.py +++ b/src/config.py @@ -1,5 +1,5 @@ """ -Configuration management for Murmur. +Configuration management for murmur. """ import contextlib @@ -46,11 +46,11 @@ class ConfigError(RuntimeError): - """Raised when Murmur cannot read or write its config safely.""" + """Raised when murmur cannot read or write its config safely.""" def get_app_data_dir() -> Path: - """Get the canonical Murmur AppData directory.""" + """Get the canonical murmur AppData directory.""" return Path(os.environ.get("APPDATA", ".")) / APP_DIR_NAME diff --git a/src/hotkey.py b/src/hotkey.py index dbf0d18..c63c54a 100644 --- a/src/hotkey.py +++ b/src/hotkey.py @@ -1,5 +1,5 @@ """ -Hotkey handling module for Murmur. +Hotkey handling module for murmur. Manages global hotkey registration and callbacks. """ diff --git a/src/logger.py b/src/logger.py index d50e614..ef42d5b 100644 --- a/src/logger.py +++ b/src/logger.py @@ -1,5 +1,5 @@ """ -Data logging module for Murmur. +Data logging module for murmur. Logs audio recordings and transcriptions for fine-tuning datasets. """ diff --git a/src/main.py b/src/main.py index e76464a..0dbecbc 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,5 @@ """ -Main application module for Murmur. +Main application module for murmur. Orchestrates audio recording, transcription, and clipboard operations. """ @@ -46,7 +46,7 @@ class MurmurApp: def __init__(self, preload_model: bool = True): """ - Initialize the Murmur application. + Initialize the murmur application. Args: preload_model: Whether to preload the Whisper model on startup. diff --git a/src/notifications.py b/src/notifications.py index 527673f..db15d7b 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -1,5 +1,5 @@ """ -Notification module for Murmur. +Notification module for murmur. Provides user feedback through Windows toast notifications. """ @@ -13,6 +13,7 @@ except ImportError: TOAST_AVAILABLE = False +from .assets import get_app_icon_path from .config import get_config @@ -80,7 +81,14 @@ def _show_toast(self, title: str, message: str, duration: int) -> None: return try: - toaster.show_toast(title, message, duration=duration, threaded=False) + icon_path = get_app_icon_path() + toaster.show_toast( + title, + message, + icon_path=str(icon_path) if icon_path is not None else None, + duration=duration, + threaded=False, + ) except Exception: self._print_fallback(title, "toast delivery failed") diff --git a/src/settings_gui.py b/src/settings_gui.py index 270c060..62f1620 100644 --- a/src/settings_gui.py +++ b/src/settings_gui.py @@ -1,4 +1,4 @@ -"""Settings GUI for Murmur using customtkinter.""" +"""Settings GUI for murmur using customtkinter.""" import contextlib import ctypes @@ -457,7 +457,7 @@ def _ensure_settings_ui_thread(): class SettingsWindow: - """A tabbed customtkinter window for editing Murmur configuration.""" + """A tabbed customtkinter window for editing murmur configuration.""" def __init__(self, master=None, on_close=None): self.config = get_config() diff --git a/src/settings_schema.py b/src/settings_schema.py index 3973a9f..2e464a2 100644 --- a/src/settings_schema.py +++ b/src/settings_schema.py @@ -1,4 +1,4 @@ -"""Metadata and value helpers for the Murmur settings panel.""" +"""Metadata and value helpers for the murmur settings panel.""" from __future__ import annotations diff --git a/src/transcription.py b/src/transcription.py index f0f3da5..887f9ed 100644 --- a/src/transcription.py +++ b/src/transcription.py @@ -1,5 +1,5 @@ """ -Transcription module for Murmur. +Transcription module for murmur. Handles Whisper model loading and speech-to-text transcription. """ diff --git a/src/tray.py b/src/tray.py index db64a06..da48aae 100644 --- a/src/tray.py +++ b/src/tray.py @@ -1,5 +1,5 @@ """ -System tray management for Murmur. +System tray management for murmur. """ import threading @@ -12,7 +12,7 @@ class TrayManager: """ - Manages the system tray icon and menu for Murmur. + Manages the system tray icon and menu for murmur. """ def __init__(self, on_exit_callback=None): diff --git a/src/vad.py b/src/vad.py index 731b31a..20bf501 100644 --- a/src/vad.py +++ b/src/vad.py @@ -1,4 +1,4 @@ -"""Public VAD surface for Murmur.""" +"""Public VAD surface for murmur.""" from .vad_audio import float32_to_pcm16, generate_frames, resample_audio from .vad_config import DEFAULT_VAD_SAMPLE_RATE, SUPPORTED_VAD_SAMPLE_RATES, VADSettings diff --git a/src/vad_segmenter.py b/src/vad_segmenter.py index 195fc79..909fb21 100644 --- a/src/vad_segmenter.py +++ b/src/vad_segmenter.py @@ -17,7 +17,7 @@ def _create_vad(aggressiveness: int) -> object: """Construct a WebRTC VAD instance or fail with a clear message.""" if webrtcvad is None: - raise RuntimeError("webrtcvad-wheels is required to use Murmur VAD") + raise RuntimeError("webrtcvad-wheels is required to use murmur VAD") return webrtcvad.Vad(aggressiveness) diff --git a/src/windows_identity.py b/src/windows_identity.py index 7520300..f59196e 100644 --- a/src/windows_identity.py +++ b/src/windows_identity.py @@ -7,7 +7,7 @@ def configure_windows_app_identity() -> None: - """Set Murmur's Windows identity once, before it creates any UI.""" + """Set murmur's Windows identity once, before it creates any UI.""" global _WINDOWS_APP_ID_SET if _WINDOWS_APP_ID_SET: diff --git a/tests/test_autostart.py b/tests/test_autostart.py index 75ed7f6..760c74f 100644 --- a/tests/test_autostart.py +++ b/tests/test_autostart.py @@ -23,12 +23,12 @@ def test_launch_command_uses_packaged_executable(monkeypatch): monkeypatch.setattr( autostart_module.sys, "executable", - r"C:\Program Files\Murmur\murmur.exe", + r"C:\Program Files\murmur\murmur.exe", ) monkeypatch.setattr(autostart_module.sys, "frozen", True, raising=False) assert autostart_module._get_launch_command() == ( - '"C:\\Program Files\\Murmur\\murmur.exe"' + '"C:\\Program Files\\murmur\\murmur.exe"' ) @@ -85,7 +85,7 @@ def close_key(reg_key): ) assert captured["set_value_ex"] == ( registry_key, - "Murmur", + "murmur", 0, fake_winreg.REG_SZ, "command", diff --git a/tests/test_llm_postprocess.py b/tests/test_llm_postprocess.py index a699fa1..b76e751 100644 --- a/tests/test_llm_postprocess.py +++ b/tests/test_llm_postprocess.py @@ -111,7 +111,7 @@ def test_llm_post_processor_builds_prompt_with_vocab_and_returns_cleaned_text(): model_name=MODEL_NAME, client=fake_client, ), - user_vocab={"q win": "Qwen", "murmer": "Murmur"}, + user_vocab={"q win": "Qwen", "murmer": "murmur"}, ) result = processor.process( @@ -149,7 +149,7 @@ def test_llm_post_processor_builds_prompt_with_vocab_and_returns_cleaned_text(): assert messages[3]["role"] == "user" assert "Preferred vocabulary and corrections:" in messages[3]["content"] assert "- q win -> Qwen" in messages[3]["content"] - assert "- murmer -> Murmur" in messages[3]["content"] + assert "- murmer -> murmur" in messages[3]["content"] def test_llm_post_processor_returns_original_text_on_failure(): diff --git a/tests/test_notifications.py b/tests/test_notifications.py index be1af60..97e5ad4 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -1,3 +1,4 @@ +from pathlib import Path from types import SimpleNamespace import src.notifications as notifications_module @@ -112,6 +113,41 @@ def show_toast(self, title, message, duration, threaded): assert "leaked in toast failure" not in stdout +def test_toast_uses_murmur_app_icon(monkeypatch): + calls = [] + + class RecordingToaster: + def show_toast(self, title, message, icon_path, duration, threaded): + calls.append((title, message, icon_path, duration, threaded)) + + monkeypatch.setattr(notifications_module, "TOAST_AVAILABLE", True) + monkeypatch.setattr( + notifications_module, + "ToastNotifier", + lambda: RecordingToaster(), + raising=False, + ) + monkeypatch.setattr( + notifications_module, + "get_app_icon_path", + lambda: Path(r"C:\Users\Pat\AppData\murmur.ico"), + ) + enable_notifications(monkeypatch) + manager = notifications_module.NotificationManager() + + manager.notify("murmur", "Ready", threaded=False) + + assert calls == [ + ( + "murmur", + "Ready", + r"C:\Users\Pat\AppData\murmur.ico", + 3, + False, + ) + ] + + def test_toast_initialization_failure_uses_safe_stdout_fallback( monkeypatch, capsys, diff --git a/tests/test_prepare_windows_build.py b/tests/test_prepare_windows_build.py index ed53b45..b2faff5 100644 --- a/tests/test_prepare_windows_build.py +++ b/tests/test_prepare_windows_build.py @@ -48,4 +48,4 @@ def test_prepare_build_generates_icon_and_version_info(tmp_path): assert (output_dir / "murmur.ico").is_file() version_info = (output_dir / "version_info.txt").read_text(encoding="utf-8") assert "filevers=(1, 2, 3, 0)" in version_info - assert "StringStruct(u'ProductName', u'Murmur')" in version_info + assert "StringStruct(u'ProductName', u'murmur')" in version_info diff --git a/tests/test_user_vocab.py b/tests/test_user_vocab.py index f6c28cc..9c8b3f5 100644 --- a/tests/test_user_vocab.py +++ b/tests/test_user_vocab.py @@ -14,7 +14,7 @@ def test_save_user_vocab_round_trips_json_mapping(tmp_path): vocab_path = tmp_path / "user_vocab.json" vocab = { "brew ridge": "Blue Ridge Data", - "murmer": "Murmur", + "murmer": "murmur", } save_user_vocab(vocab, vocab_path) @@ -35,7 +35,7 @@ def test_default_vocab_path_uses_app_data_when_frozen(tmp_path, monkeypatch): monkeypatch.setattr(user_vocab.sys, "frozen", True, raising=False) monkeypatch.setattr(user_vocab, "get_app_data_dir", lambda: app_data_dir) - save_user_vocab({"murmer": "Murmur"}) + save_user_vocab({"murmer": "murmur"}) - assert load_user_vocab() == {"murmer": "Murmur"} + assert load_user_vocab() == {"murmer": "murmur"} assert (app_data_dir / "user_vocab.json").is_file() diff --git a/tools/prepare_windows_build.py b/tools/prepare_windows_build.py index dd64bea..a321ec4 100644 --- a/tools/prepare_windows_build.py +++ b/tools/prepare_windows_build.py @@ -57,12 +57,12 @@ def _write_version_info(destination: Path, version: str) -> None: u'040904B0', [ StringStruct(u'CompanyName', u'laceyp99'), - StringStruct(u'FileDescription', u'Murmur - Local Speech-to-Text Hotkey App'), + StringStruct(u'FileDescription', u'murmur - Local Speech-to-Text Hotkey App'), StringStruct(u'FileVersion', u'{version}'), StringStruct(u'InternalName', u'murmur'), StringStruct(u'LegalCopyright', u'Copyright (c) laceyp99'), StringStruct(u'OriginalFilename', u'murmur.exe'), - StringStruct(u'ProductName', u'Murmur'), + StringStruct(u'ProductName', u'murmur'), StringStruct(u'ProductVersion', u'{version}') ] ) From 389ef177ac67592971c4cb377f1bb75585f444d0 Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Mon, 14 Sep 2026 01:00:02 -0400 Subject: [PATCH 11/11] docs(windows): document zip distribution --- README.md | 7 +++++-- docs/getting-started.md | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 915569d..bf14a99 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,11 @@ powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 ``` The validated output is written to `dist\murmur\`. Distribute the complete -folder. See [Build a packaged release](docs/getting-started.md#build-a-packaged-release) -for requirements and repeat-build instructions. +folder. For manual per-user use, extract it to +`%LOCALAPPDATA%\Programs\murmur` and optionally create a Desktop shortcut to +`murmur.exe`. Keep the application files together. See [Build a packaged +release](docs/getting-started.md#build-a-packaged-release) for requirements and +repeat-build instructions. ## Development diff --git a/docs/getting-started.md b/docs/getting-started.md index 342bcfc..c345c2b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -107,9 +107,22 @@ self-check. The first build can take several minutes because Whisper, Torch, and their native libraries are analyzed. The release is written to `dist\murmur\`. Keep that directory together when -copying or distributing the application, then launch `murmur.exe`. End users do -not need a separate Python or FFmpeg installation. The first launch can download -the configured Whisper model if it is not already in the user's cache. +copying or distributing the application. For a manual per-user installation, +copy or extract the complete folder to: + +```text +%LOCALAPPDATA%\Programs\murmur +``` + +Launch `murmur.exe` from that folder. Do not move the executable out of the +folder or distribute it by itself. An optional Desktop shortcut can point to +`murmur.exe` while leaving the application files together. The **Start with +Windows** setting in murmur controls automatic launch and does not require a +Desktop or Start Menu shortcut. + +End users do not need a separate Python or FFmpeg installation. The first +launch can download the configured Whisper model if it is not already in the +user's cache. For repeat local builds after dependencies are installed, skip the install step: