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/.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 diff --git a/README.md b/README.md index 6741f99..bf14a99 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,21 @@ 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: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 +``` + +The validated output is written to `dist\murmur\`. Distribute the complete +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/build_windows.ps1 b/build_windows.ps1 new file mode 100644 index 0000000..d2b3600 --- /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/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 d56b2c1..c345c2b 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 @@ -13,11 +13,10 @@ 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 | -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. @@ -33,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]" @@ -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 @@ -68,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. @@ -91,14 +87,57 @@ 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 `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 + +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. 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: + +```powershell +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 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"] @@ -119,14 +158,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. @@ -136,7 +175,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 60ef05a..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: @@ -89,13 +89,14 @@ 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 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 @@ -131,15 +132,17 @@ 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 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 d2f6d08..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. @@ -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/docs/troubleshooting.md b/docs/troubleshooting.md index 8594576..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 @@ -44,9 +44,48 @@ 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 -[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 new file mode 100644 index 0000000..ca8e9d3 --- /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..3e820a2 --- /dev/null +++ b/packaging/murmur.spec @@ -0,0 +1,120 @@ +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. + + 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 + ): + 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..77f33ec 100644 --- a/run.py +++ b/run.py @@ -4,13 +4,85 @@ Run this script to start Murmur. """ +import os import sys +import traceback from pathlib import Path # Add the project root to the path sys.path.insert(0, str(Path(__file__).resolve().parent)) -from src.main import main -if __name__ == "__main__": +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 + 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 + 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) + 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") + + # 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: + _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__": + _ensure_output_streams() + if "--packaging-self-check" in sys.argv: + raise SystemExit(_packaging_self_check_exit_code()) + else: + _run_app() diff --git a/run_background.vbs b/run_background.vbs index 619a325..b762168 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 +' 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 End If -WinScriptHost.Run pythonPath & " " & scriptPath, 0 -Set WinScriptHost = Nothing \ No newline at end of file +Set WinScriptHost = Nothing 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/assets.py b/src/assets.py index 8e5157c..d2ac7ee 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 + for filename in ("murmur tray logo.png", "murmur logo.png", "murmur.png"): + path = _get_resource_root() / filename if path.exists(): return path return None 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 30903ac..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 @@ -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 + app_name = "murmur" - # 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" @@ -56,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/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/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_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..760c74f 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,32 @@ 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" + 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'"{python_path.with_name("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 +73,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) @@ -65,10 +85,10 @@ def close_key(reg_key): ) assert captured["set_value_ex"] == ( registry_key, - "Murmur", + "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 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 new file mode 100644 index 0000000..b2faff5 --- /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..00a819d --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,116 @@ +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( + [ + 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) + + 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/tests/test_user_vocab.py b/tests/test_user_vocab.py index 2f310bd..9c8b3f5 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 @@ -13,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) @@ -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() diff --git a/tools/prepare_windows_build.py b/tools/prepare_windows_build.py new file mode 100644 index 0000000..a321ec4 --- /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()