lowhum 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lowhum-1.0.0/.gitignore +20 -0
- lowhum-1.0.0/.python-version +1 -0
- lowhum-1.0.0/LICENSE +21 -0
- lowhum-1.0.0/PKG-INFO +76 -0
- lowhum-1.0.0/README.md +51 -0
- lowhum-1.0.0/pyproject.toml +44 -0
- lowhum-1.0.0/src/lowhum/__init__.py +3 -0
- lowhum-1.0.0/src/lowhum/app.py +124 -0
- lowhum-1.0.0/src/lowhum/audio.py +221 -0
- lowhum-1.0.0/src/lowhum/cli.py +77 -0
- lowhum-1.0.0/src/lowhum/generator.py +74 -0
- lowhum-1.0.0/src/lowhum/icon.png +0 -0
- lowhum-1.0.0/src/lowhum/icons.py +48 -0
- lowhum-1.0.0/src/lowhum/menubar_dark.png +0 -0
- lowhum-1.0.0/src/lowhum/menubar_light.png +0 -0
- lowhum-1.0.0/src/pyproject.toml +67 -0
lowhum-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
lowhum-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luis Markmann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
lowhum-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lowhum
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Deep Brown Noise for Focus as a MenuBar App & CLI Tool
|
|
5
|
+
Project-URL: Repository, https://github.com/lmarkmann/lowhum
|
|
6
|
+
Author: Luis Markmann
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: audio,brown-noise,focus,macos,menu-bar
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: MacOS X
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: numpy>=2.0
|
|
19
|
+
Requires-Dist: pillow>=11.0
|
|
20
|
+
Requires-Dist: rumps>=0.4.0
|
|
21
|
+
Requires-Dist: scipy>=1.14
|
|
22
|
+
Requires-Dist: sounddevice>=0.5
|
|
23
|
+
Requires-Dist: typer>=0.12
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# LowHum
|
|
27
|
+
|
|
28
|
+
**Deep brown noise for focus, right from the macOS menu bar.**
|
|
29
|
+
|
|
30
|
+
No browser tabs. No subscriptions. No account. Fully offline.
|
|
31
|
+
|
|
32
|
+
<!-- TODO: Add GIF demo here -->
|
|
33
|
+
|
|
34
|
+
Brown noise is one of the most effective focus aids for people with ADHD and anyone who needs to block out distractions. Most options require keeping a YouTube tab open, paying for a subscription app, or relying on your phone. LowHum is a single-purpose menu bar app that generates deep brown noise locally and plays it on loop — install it, click play, forget about it.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv tool install lowhum # recommended
|
|
40
|
+
# or
|
|
41
|
+
pip install lowhum
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
lowhum # launch the menu-bar app
|
|
48
|
+
lowhum start # play immediately in terminal (Ctrl+C to stop)
|
|
49
|
+
lowhum start -d 3 # play on a specific output device
|
|
50
|
+
lowhum devices # list output devices
|
|
51
|
+
lowhum generate # pre-generate the audio file
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`lhm` is a shorthand alias — all commands work with either name.
|
|
55
|
+
|
|
56
|
+
### Menu-bar app
|
|
57
|
+
|
|
58
|
+
- Play / Stop from the menu bar
|
|
59
|
+
- **Output Device** submenu — pick any connected audio device
|
|
60
|
+
- Auto-stops when headphones connect or disconnect
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
1. **Generation** — On first run, a 10-minute WAV is synthesised locally: cumulative-sum brown noise, Butterworth bandpass (1–500 Hz + 20 Hz sub-bass HP), RMS-normalised per chunk, crossfaded at boundaries. Stored at `~/.lowhum/`.
|
|
65
|
+
2. **Playback** — Memory-mapped streaming through PortAudio. No full-file RAM load. Loops seamlessly.
|
|
66
|
+
3. **Device detection** — Polls audio devices every 2 seconds. Headphone unplug, Bluetooth disconnect — playback stops instantly.
|
|
67
|
+
4. **Menu-bar icon** — Template icon; macOS handles dark/light mode automatically.
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- macOS
|
|
72
|
+
- Python >= 3.12
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
lowhum-1.0.0/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# LowHum
|
|
2
|
+
|
|
3
|
+
**Deep brown noise for focus, right from the macOS menu bar.**
|
|
4
|
+
|
|
5
|
+
No browser tabs. No subscriptions. No account. Fully offline.
|
|
6
|
+
|
|
7
|
+
<!-- TODO: Add GIF demo here -->
|
|
8
|
+
|
|
9
|
+
Brown noise is one of the most effective focus aids for people with ADHD and anyone who needs to block out distractions. Most options require keeping a YouTube tab open, paying for a subscription app, or relying on your phone. LowHum is a single-purpose menu bar app that generates deep brown noise locally and plays it on loop — install it, click play, forget about it.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv tool install lowhum # recommended
|
|
15
|
+
# or
|
|
16
|
+
pip install lowhum
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
lowhum # launch the menu-bar app
|
|
23
|
+
lowhum start # play immediately in terminal (Ctrl+C to stop)
|
|
24
|
+
lowhum start -d 3 # play on a specific output device
|
|
25
|
+
lowhum devices # list output devices
|
|
26
|
+
lowhum generate # pre-generate the audio file
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`lhm` is a shorthand alias — all commands work with either name.
|
|
30
|
+
|
|
31
|
+
### Menu-bar app
|
|
32
|
+
|
|
33
|
+
- Play / Stop from the menu bar
|
|
34
|
+
- **Output Device** submenu — pick any connected audio device
|
|
35
|
+
- Auto-stops when headphones connect or disconnect
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
1. **Generation** — On first run, a 10-minute WAV is synthesised locally: cumulative-sum brown noise, Butterworth bandpass (1–500 Hz + 20 Hz sub-bass HP), RMS-normalised per chunk, crossfaded at boundaries. Stored at `~/.lowhum/`.
|
|
40
|
+
2. **Playback** — Memory-mapped streaming through PortAudio. No full-file RAM load. Loops seamlessly.
|
|
41
|
+
3. **Device detection** — Polls audio devices every 2 seconds. Headphone unplug, Bluetooth disconnect — playback stops instantly.
|
|
42
|
+
4. **Menu-bar icon** — Template icon; macOS handles dark/light mode automatically.
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- macOS
|
|
47
|
+
- Python >= 3.12
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lowhum"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Deep Brown Noise for Focus as a MenuBar App & CLI Tool"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Luis Markmann" }]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
keywords = ["brown-noise", "focus", "audio", "macos", "menu-bar"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 5 - Production/Stable",
|
|
12
|
+
"Environment :: MacOS X",
|
|
13
|
+
"Intended Audience :: End Users/Desktop",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: MacOS",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"numpy>=2.0",
|
|
21
|
+
"pillow>=11.0",
|
|
22
|
+
"rumps>=0.4.0",
|
|
23
|
+
"scipy>=1.14",
|
|
24
|
+
"sounddevice>=0.5",
|
|
25
|
+
"typer>=0.12",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
lowhum = "lowhum.cli:app"
|
|
30
|
+
lhm = "lowhum.cli:app"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://github.com/lmarkmann/lowhum"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/lowhum"]
|
|
41
|
+
exclude = ["*.wav"]
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = ["ruff>=0.9"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""LowHum — macOS menu-bar application for brown noise playback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import rumps
|
|
8
|
+
import sounddevice as sd
|
|
9
|
+
|
|
10
|
+
from .audio import AudioPlayer, list_output_devices
|
|
11
|
+
from .generator import AUDIO_FILE
|
|
12
|
+
from .icons import ensure_template_icon
|
|
13
|
+
|
|
14
|
+
_APP_ICON = Path(__file__).parent / "icon.png"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _set_dock_icon() -> None:
|
|
18
|
+
"""Set the macOS dock icon if icon.png is bundled."""
|
|
19
|
+
if not _APP_ICON.exists():
|
|
20
|
+
return
|
|
21
|
+
try:
|
|
22
|
+
from AppKit import NSApplication, NSImage # type: ignore[import-untyped]
|
|
23
|
+
|
|
24
|
+
ns_image = NSImage.alloc().initByReferencingFile_(str(_APP_ICON))
|
|
25
|
+
NSApplication.sharedApplication().setApplicationIconImage_(ns_image)
|
|
26
|
+
except ImportError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LowHumApp(rumps.App):
|
|
31
|
+
"""Menu-bar app: play / stop / select output device."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
icon_path = str(ensure_template_icon())
|
|
35
|
+
super().__init__("LowHum", icon=icon_path, template=True, quit_button=None) # type: ignore
|
|
36
|
+
_set_dock_icon()
|
|
37
|
+
|
|
38
|
+
self._player = AudioPlayer()
|
|
39
|
+
self._selected_device: int | None = None
|
|
40
|
+
self._known_device_names: set[str] = set()
|
|
41
|
+
|
|
42
|
+
# --- static menu items ---
|
|
43
|
+
self._play_item = rumps.MenuItem("Play", callback=self._on_play)
|
|
44
|
+
self._stop_item = rumps.MenuItem("Stop", callback=self._on_stop)
|
|
45
|
+
self._device_menu = rumps.MenuItem("Output Device")
|
|
46
|
+
|
|
47
|
+
self.menu = [
|
|
48
|
+
self._play_item,
|
|
49
|
+
self._stop_item,
|
|
50
|
+
None,
|
|
51
|
+
self._device_menu,
|
|
52
|
+
None,
|
|
53
|
+
rumps.MenuItem("Quit", callback=self._on_quit),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
self._refresh_devices()
|
|
57
|
+
|
|
58
|
+
# Poll for audio-device changes every 2 s
|
|
59
|
+
self._device_timer = rumps.Timer(self._check_devices, 2)
|
|
60
|
+
self._device_timer.start()
|
|
61
|
+
|
|
62
|
+
# Device management
|
|
63
|
+
|
|
64
|
+
def _refresh_devices(self) -> None:
|
|
65
|
+
devices = list_output_devices()
|
|
66
|
+
self._known_device_names = {name for _, name in devices}
|
|
67
|
+
|
|
68
|
+
# Clear submenu
|
|
69
|
+
for key in list(self._device_menu.keys()):
|
|
70
|
+
del self._device_menu[key]
|
|
71
|
+
|
|
72
|
+
# "System Default" entry
|
|
73
|
+
sys_item = rumps.MenuItem(
|
|
74
|
+
"System Default",
|
|
75
|
+
callback=lambda _: self._select_device(None),
|
|
76
|
+
)
|
|
77
|
+
sys_item.state = self._selected_device is None
|
|
78
|
+
self._device_menu["System Default"] = sys_item
|
|
79
|
+
|
|
80
|
+
for idx, name in devices:
|
|
81
|
+
item = rumps.MenuItem(
|
|
82
|
+
name,
|
|
83
|
+
callback=lambda _, d=idx: self._select_device(d),
|
|
84
|
+
)
|
|
85
|
+
item.state = self._selected_device == idx
|
|
86
|
+
self._device_menu[name] = item
|
|
87
|
+
|
|
88
|
+
def _select_device(self, device_id: int | None) -> None:
|
|
89
|
+
was_playing = self._player.playing
|
|
90
|
+
if was_playing:
|
|
91
|
+
self._player.stop()
|
|
92
|
+
|
|
93
|
+
self._selected_device = device_id
|
|
94
|
+
self._refresh_devices()
|
|
95
|
+
|
|
96
|
+
if was_playing:
|
|
97
|
+
self._player.play(AUDIO_FILE, device=self._selected_device, loop=True)
|
|
98
|
+
|
|
99
|
+
def _check_devices(self, _: rumps.Timer) -> None:
|
|
100
|
+
"""Detect connects / disconnects and stop playback immediately."""
|
|
101
|
+
try:
|
|
102
|
+
current = {name for _, name in list_output_devices()}
|
|
103
|
+
except sd.PortAudioError:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
if current != self._known_device_names:
|
|
107
|
+
if self._player.playing:
|
|
108
|
+
self._player.stop()
|
|
109
|
+
rumps.notification("LowHum", "", "Audio stopped — output device changed.")
|
|
110
|
+
self._refresh_devices()
|
|
111
|
+
|
|
112
|
+
# Menu callbacks
|
|
113
|
+
|
|
114
|
+
def _on_play(self, _: rumps.MenuItem) -> None:
|
|
115
|
+
if self._player.playing:
|
|
116
|
+
return
|
|
117
|
+
self._player.play(AUDIO_FILE, device=self._selected_device, loop=True)
|
|
118
|
+
|
|
119
|
+
def _on_stop(self, _: rumps.MenuItem) -> None:
|
|
120
|
+
self._player.stop()
|
|
121
|
+
|
|
122
|
+
def _on_quit(self, _: rumps.MenuItem) -> None:
|
|
123
|
+
self._player.stop()
|
|
124
|
+
rumps.quit_application()
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Audio playback engine — sounddevice streaming with device selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import struct
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import sounddevice as sd
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# WAV header parsing (avoids loading the full file into memory)
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class WavInfo:
|
|
21
|
+
sample_rate: int
|
|
22
|
+
channels: int
|
|
23
|
+
bits_per_sample: int
|
|
24
|
+
data_offset: int
|
|
25
|
+
data_size: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_wav_header(file_path: Path) -> WavInfo:
|
|
29
|
+
"""Parse a RIFF/WAV header and return metadata + data offset."""
|
|
30
|
+
with open(file_path, "rb") as f:
|
|
31
|
+
if f.read(4) != b"RIFF":
|
|
32
|
+
raise ValueError("Not a RIFF file")
|
|
33
|
+
f.read(4) # file size
|
|
34
|
+
if f.read(4) != b"WAVE":
|
|
35
|
+
raise ValueError("Not a WAVE file")
|
|
36
|
+
|
|
37
|
+
sample_rate = channels = bits_per_sample = 0
|
|
38
|
+
data_offset = data_size = 0
|
|
39
|
+
|
|
40
|
+
while True:
|
|
41
|
+
chunk_id = f.read(4)
|
|
42
|
+
if len(chunk_id) < 4:
|
|
43
|
+
break
|
|
44
|
+
(chunk_size,) = struct.unpack("<I", f.read(4))
|
|
45
|
+
|
|
46
|
+
if chunk_id == b"fmt ":
|
|
47
|
+
fmt = f.read(min(chunk_size, 16))
|
|
48
|
+
_audio_fmt, channels, sample_rate = struct.unpack("<HHI", fmt[:8])
|
|
49
|
+
bits_per_sample = struct.unpack("<H", fmt[14:16])[0]
|
|
50
|
+
remaining = chunk_size - 16
|
|
51
|
+
if remaining > 0:
|
|
52
|
+
f.read(remaining)
|
|
53
|
+
elif chunk_id == b"data":
|
|
54
|
+
data_offset = f.tell()
|
|
55
|
+
data_size = chunk_size
|
|
56
|
+
break
|
|
57
|
+
else:
|
|
58
|
+
f.seek(chunk_size, 1)
|
|
59
|
+
|
|
60
|
+
return WavInfo(
|
|
61
|
+
sample_rate=sample_rate,
|
|
62
|
+
channels=channels,
|
|
63
|
+
bits_per_sample=bits_per_sample,
|
|
64
|
+
data_offset=data_offset,
|
|
65
|
+
data_size=data_size,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Device helpers
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_output_devices() -> list[tuple[int, str]]:
|
|
75
|
+
"""Return ``[(index, name), ...]`` for every output-capable device."""
|
|
76
|
+
devices = sd.query_devices()
|
|
77
|
+
return [(i, d["name"]) for i, d in enumerate(devices) if d["max_output_channels"] > 0]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_default_output_device() -> int:
|
|
81
|
+
"""Index of the current default output device."""
|
|
82
|
+
return sd.default.device[1] # type: ignore[index]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Player
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AudioPlayer:
|
|
91
|
+
"""Streams a WAV file through sounddevice with device selection."""
|
|
92
|
+
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
self._stop_event = threading.Event()
|
|
95
|
+
self._stream: sd.OutputStream | None = None
|
|
96
|
+
self._playing = False
|
|
97
|
+
self._lock = threading.Lock()
|
|
98
|
+
self._thread: threading.Thread | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def playing(self) -> bool:
|
|
102
|
+
return self._playing
|
|
103
|
+
|
|
104
|
+
# -- public API ---------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def play(
|
|
107
|
+
self,
|
|
108
|
+
file_path: Path,
|
|
109
|
+
device: int | None = None,
|
|
110
|
+
loop: bool = True,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Start streaming *file_path* (non-blocking)."""
|
|
113
|
+
self.stop()
|
|
114
|
+
self._stop_event.clear()
|
|
115
|
+
self._thread = threading.Thread(
|
|
116
|
+
target=self._run,
|
|
117
|
+
args=(file_path, device, loop),
|
|
118
|
+
daemon=True,
|
|
119
|
+
)
|
|
120
|
+
self._thread.start()
|
|
121
|
+
|
|
122
|
+
def play_blocking(
|
|
123
|
+
self,
|
|
124
|
+
file_path: Path,
|
|
125
|
+
device: int | None = None,
|
|
126
|
+
loop: bool = True,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Play audio, blocking until stopped or file ends."""
|
|
129
|
+
self._stop_event.clear()
|
|
130
|
+
self._run(file_path, device, loop)
|
|
131
|
+
|
|
132
|
+
def stop(self) -> None:
|
|
133
|
+
"""Stop playback immediately."""
|
|
134
|
+
self._stop_event.set()
|
|
135
|
+
with self._lock:
|
|
136
|
+
if self._stream is not None:
|
|
137
|
+
with contextlib.suppress(Exception):
|
|
138
|
+
self._stream.abort()
|
|
139
|
+
self._stream = None
|
|
140
|
+
self._playing = False
|
|
141
|
+
if self._thread is not None:
|
|
142
|
+
self._thread.join(timeout=2)
|
|
143
|
+
self._thread = None
|
|
144
|
+
|
|
145
|
+
# -- internals ----------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _run(
|
|
148
|
+
self,
|
|
149
|
+
file_path: Path,
|
|
150
|
+
device: int | None,
|
|
151
|
+
loop: bool,
|
|
152
|
+
) -> None:
|
|
153
|
+
info = parse_wav_header(file_path)
|
|
154
|
+
n_samples = info.data_size // (info.bits_per_sample // 8)
|
|
155
|
+
|
|
156
|
+
data = np.memmap(
|
|
157
|
+
file_path,
|
|
158
|
+
dtype=np.int16,
|
|
159
|
+
mode="r",
|
|
160
|
+
offset=info.data_offset,
|
|
161
|
+
shape=(n_samples,),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
pos = [0] # mutable for callback closure
|
|
165
|
+
stop_evt = self._stop_event
|
|
166
|
+
|
|
167
|
+
def _callback(
|
|
168
|
+
outdata: np.ndarray,
|
|
169
|
+
frames: int,
|
|
170
|
+
_time: object,
|
|
171
|
+
_status: sd.CallbackFlags,
|
|
172
|
+
) -> None:
|
|
173
|
+
current = pos[0]
|
|
174
|
+
end = current + frames
|
|
175
|
+
|
|
176
|
+
if end <= n_samples:
|
|
177
|
+
outdata[:, 0] = data[current:end]
|
|
178
|
+
pos[0] = end
|
|
179
|
+
elif loop:
|
|
180
|
+
first = n_samples - current
|
|
181
|
+
outdata[:first, 0] = data[current:]
|
|
182
|
+
remaining = frames - first
|
|
183
|
+
outdata[first:, 0] = data[:remaining]
|
|
184
|
+
pos[0] = remaining
|
|
185
|
+
else:
|
|
186
|
+
first = n_samples - current
|
|
187
|
+
outdata[:first, 0] = data[current:]
|
|
188
|
+
outdata[first:] = 0
|
|
189
|
+
raise sd.CallbackStop
|
|
190
|
+
|
|
191
|
+
if stop_evt.is_set():
|
|
192
|
+
raise sd.CallbackAbort
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
stream = sd.OutputStream(
|
|
196
|
+
samplerate=info.sample_rate,
|
|
197
|
+
channels=info.channels,
|
|
198
|
+
dtype="int16",
|
|
199
|
+
device=device,
|
|
200
|
+
blocksize=2048,
|
|
201
|
+
callback=_callback,
|
|
202
|
+
)
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._stream = stream
|
|
205
|
+
self._playing = True
|
|
206
|
+
|
|
207
|
+
stream.start()
|
|
208
|
+
while stream.active and not stop_evt.is_set():
|
|
209
|
+
sd.sleep(100)
|
|
210
|
+
except sd.PortAudioError as exc:
|
|
211
|
+
print(f"Audio error: {exc}")
|
|
212
|
+
finally:
|
|
213
|
+
with self._lock:
|
|
214
|
+
if self._stream is not None:
|
|
215
|
+
try:
|
|
216
|
+
self._stream.stop()
|
|
217
|
+
self._stream.close()
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
self._stream = None
|
|
221
|
+
self._playing = False
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""CLI entry point for LowHum — ``lhm`` and ``lowhum`` resolve here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="lowhum",
|
|
12
|
+
help="LowHum — deep brown noise for focus.",
|
|
13
|
+
invoke_without_command=True,
|
|
14
|
+
no_args_is_help=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback(invoke_without_command=True)
|
|
19
|
+
def _default(ctx: typer.Context) -> None:
|
|
20
|
+
"""Launch the menu-bar app (default when no subcommand given)."""
|
|
21
|
+
if ctx.invoked_subcommand is not None:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
from .app import LowHumApp
|
|
25
|
+
from .generator import ensure_audio
|
|
26
|
+
|
|
27
|
+
ensure_audio()
|
|
28
|
+
LowHumApp().run()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def start(
|
|
33
|
+
device: int | None = typer.Option(
|
|
34
|
+
None, "--device", "-d", help="Output device index (see `lhm devices`)."
|
|
35
|
+
),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Play brown noise immediately in the terminal (Ctrl+C to stop)."""
|
|
38
|
+
from .audio import AudioPlayer
|
|
39
|
+
from .generator import AUDIO_FILE, ensure_audio
|
|
40
|
+
|
|
41
|
+
ensure_audio()
|
|
42
|
+
|
|
43
|
+
player = AudioPlayer()
|
|
44
|
+
|
|
45
|
+
def _sigint(_sig: int, _frame: object) -> None:
|
|
46
|
+
player.stop()
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
|
|
49
|
+
signal.signal(signal.SIGINT, _sigint)
|
|
50
|
+
|
|
51
|
+
typer.echo("Playing brown noise … (Ctrl+C to stop)")
|
|
52
|
+
player.play_blocking(AUDIO_FILE, device=device, loop=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def devices() -> None:
|
|
57
|
+
"""List available audio output devices."""
|
|
58
|
+
from .audio import get_default_output_device, list_output_devices
|
|
59
|
+
|
|
60
|
+
default_idx = get_default_output_device()
|
|
61
|
+
for idx, name in list_output_devices():
|
|
62
|
+
marker = " (default)" if idx == default_idx else ""
|
|
63
|
+
typer.echo(f" [{idx}] {name}{marker}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def generate() -> None:
|
|
68
|
+
"""Pre-generate the brown noise audio file."""
|
|
69
|
+
from .generator import AUDIO_FILE, generate_brown_noise
|
|
70
|
+
|
|
71
|
+
if AUDIO_FILE.exists():
|
|
72
|
+
typer.echo(f"Audio file already exists at {AUDIO_FILE}")
|
|
73
|
+
if not typer.confirm("Regenerate?"):
|
|
74
|
+
raise typer.Abort()
|
|
75
|
+
|
|
76
|
+
generate_brown_noise()
|
|
77
|
+
typer.echo(f"Saved to {AUDIO_FILE}")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Brown noise generator — generates audio on first use, caches to disk."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.io.wavfile import write as wav_write
|
|
7
|
+
from scipy.signal import butter, sosfilt
|
|
8
|
+
|
|
9
|
+
SAMPLE_RATE = 44_100
|
|
10
|
+
DURATION = 600 # seconds (10 minutes)
|
|
11
|
+
DATA_DIR = Path.home() / ".lowhum"
|
|
12
|
+
AUDIO_FILE = DATA_DIR / "deep_brown_noise_10m.wav"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_brown_noise(output_path: Path | None = None) -> Path:
|
|
16
|
+
"""Generate 10 minutes of deep brown noise and save as WAV.
|
|
17
|
+
|
|
18
|
+
Uses cumulative-sum brown noise with Butterworth bandpass (1–500 Hz)
|
|
19
|
+
and a sub-bass high-pass at 20 Hz. Chunks are crossfaded to avoid clicks.
|
|
20
|
+
"""
|
|
21
|
+
if output_path is None:
|
|
22
|
+
output_path = AUDIO_FILE
|
|
23
|
+
|
|
24
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
chunk_size = SAMPLE_RATE * 300 # 5-minute chunks
|
|
27
|
+
n_chunks = DURATION * SAMPLE_RATE // chunk_size
|
|
28
|
+
|
|
29
|
+
# Filter design (done once)
|
|
30
|
+
hp_sos = butter(2, 1.0, btype="high", fs=SAMPLE_RATE, output="sos")
|
|
31
|
+
lp_sos = butter(2, 500, btype="low", fs=SAMPLE_RATE, output="sos")
|
|
32
|
+
sub_sos = butter(1, 20, btype="high", fs=SAMPLE_RATE, output="sos")
|
|
33
|
+
|
|
34
|
+
chunks: list[np.ndarray] = []
|
|
35
|
+
|
|
36
|
+
for _ in range(n_chunks):
|
|
37
|
+
white = np.random.randn(chunk_size)
|
|
38
|
+
brown = np.cumsum(white)
|
|
39
|
+
|
|
40
|
+
brown = sosfilt(hp_sos, brown)
|
|
41
|
+
brown = sosfilt(lp_sos, brown)
|
|
42
|
+
brown = sosfilt(sub_sos, brown)
|
|
43
|
+
|
|
44
|
+
# Per-chunk RMS normalisation keeps volume consistent
|
|
45
|
+
rms = np.sqrt(np.mean(brown**2))
|
|
46
|
+
brown = brown * (0.3 / rms)
|
|
47
|
+
brown = np.clip(brown, -1.0, 1.0)
|
|
48
|
+
|
|
49
|
+
chunks.append(brown)
|
|
50
|
+
|
|
51
|
+
# Crossfade between chunks (1 s) to eliminate boundary clicks
|
|
52
|
+
xfade = SAMPLE_RATE
|
|
53
|
+
for i in range(1, len(chunks)):
|
|
54
|
+
fade_out = np.linspace(1, 0, xfade)
|
|
55
|
+
fade_in = np.linspace(0, 1, xfade)
|
|
56
|
+
chunks[i - 1][-xfade:] *= fade_out
|
|
57
|
+
chunks[i][:xfade] *= fade_in
|
|
58
|
+
chunks[i][:xfade] += chunks[i - 1][-xfade:]
|
|
59
|
+
chunks[i - 1] = chunks[i - 1][:-xfade]
|
|
60
|
+
|
|
61
|
+
final = np.concatenate(chunks)
|
|
62
|
+
audio_data = (final * 32_767).astype(np.int16)
|
|
63
|
+
wav_write(str(output_path), SAMPLE_RATE, audio_data)
|
|
64
|
+
|
|
65
|
+
return output_path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ensure_audio() -> Path:
|
|
69
|
+
"""Return the path to the audio file, generating it on first call."""
|
|
70
|
+
if AUDIO_FILE.exists():
|
|
71
|
+
return AUDIO_FILE
|
|
72
|
+
|
|
73
|
+
print("Generating brown noise audio (first run — takes ~5 s) …")
|
|
74
|
+
return generate_brown_noise()
|
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Icon generation — creates a filled template icon for the macOS menu bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
_SOURCE_ICON_DARK = Path(__file__).parent / "menubar_dark.png"
|
|
11
|
+
_SOURCE_ICON_LIGHT = Path(__file__).parent / "menubar_light.png"
|
|
12
|
+
_DATA_DIR = Path.home() / ".lowhum"
|
|
13
|
+
_TEMPLATE_ICON = _DATA_DIR / "icon_template.png"
|
|
14
|
+
|
|
15
|
+
# Menu bar icon: 22 pt = 44 px @2x retina
|
|
16
|
+
_ICON_SIZE = 44
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ensure_template_icon() -> Path:
|
|
20
|
+
"""Return path to a filled template icon, creating it if needed.
|
|
21
|
+
|
|
22
|
+
Loads the bundled buffalo artwork, thresholds every non-transparent
|
|
23
|
+
pixel to solid black, resizes to 44x44, and saves. macOS renders
|
|
24
|
+
template icons automatically in dark/light mode.
|
|
25
|
+
"""
|
|
26
|
+
if _TEMPLATE_ICON.exists():
|
|
27
|
+
return _TEMPLATE_ICON
|
|
28
|
+
|
|
29
|
+
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
# Try to load dark mode icon first, fall back to light mode
|
|
32
|
+
if _SOURCE_ICON_DARK.exists():
|
|
33
|
+
img = Image.open(_SOURCE_ICON_DARK).convert("RGBA")
|
|
34
|
+
elif _SOURCE_ICON_LIGHT.exists():
|
|
35
|
+
img = Image.open(_SOURCE_ICON_LIGHT).convert("RGBA")
|
|
36
|
+
else:
|
|
37
|
+
raise FileNotFoundError(f"Neither {_SOURCE_ICON_DARK} nor {_SOURCE_ICON_LIGHT} found")
|
|
38
|
+
img = img.resize((_ICON_SIZE, _ICON_SIZE), Image.LANCZOS) # type: ignore
|
|
39
|
+
|
|
40
|
+
pixels = np.array(img)
|
|
41
|
+
|
|
42
|
+
# Threshold: any pixel with alpha > 30 → solid black; else transparent
|
|
43
|
+
mask = pixels[:, :, 3] > 30
|
|
44
|
+
pixels[mask] = [0, 0, 0, 255]
|
|
45
|
+
pixels[~mask] = [0, 0, 0, 0]
|
|
46
|
+
|
|
47
|
+
Image.fromarray(pixels).save(_TEMPLATE_ICON)
|
|
48
|
+
return _TEMPLATE_ICON
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lowhum"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "LowHum — deep brown noise for focus"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Your Name", email = "your.email@example.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["brown-noise", "focus", "ambient", "macos", "menu-bar", "audio"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: MacOS :: MacOS X",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Multimedia :: Sound/Audio :: Players",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"rumps>=0.4.0",
|
|
30
|
+
"sounddevice>=0.4.6",
|
|
31
|
+
"numpy>=1.24.0",
|
|
32
|
+
"scipy>=1.10.0",
|
|
33
|
+
"Pillow>=10.0.0",
|
|
34
|
+
"typer>=0.9.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=7.0.0",
|
|
40
|
+
"ruff>=0.1.0",
|
|
41
|
+
"mypy>=1.0.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
lhm = "lowhum.cli:app"
|
|
46
|
+
lowhum = "lowhum.cli:app"
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/yourusername/lowhum"
|
|
50
|
+
Repository = "https://github.com/yourusername/lowhum"
|
|
51
|
+
Issues = "https://github.com/yourusername/lowhum/issues"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["lowhum"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 100
|
|
58
|
+
target-version = "py39"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
|
|
62
|
+
|
|
63
|
+
[tool.mypy]
|
|
64
|
+
python_version = "3.9"
|
|
65
|
+
strict = true
|
|
66
|
+
warn_return_any = true
|
|
67
|
+
warn_unused_configs = true
|