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.
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+
9
+ # Audio (generated at runtime)
10
+ *.wav
11
+
12
+ # IDE / OS
13
+ .DS_Store
14
+ .venv/
15
+ .ruff_cache/
16
+
17
+ # uv
18
+ uv.lock
19
+ CLAUDE.md
20
+ MARKETING.md
@@ -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,3 @@
1
+ """LowHum — deep brown noise for focus."""
2
+
3
+ __version__ = "1.0.0"
@@ -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
@@ -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