Skip to content

Commit 8845f66

Browse files
feat: changover to native windows API for process calling, this is both faster and safer than per process with psutil, and works with super fast speeds. Updated binary accordingly
1 parent 556ea42 commit 8845f66

10 files changed

Lines changed: 589 additions & 284 deletions

File tree

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
\|____|
1414
```
1515

16-
A terminal-based task manager for Windows, inspired by `htop`.
16+
A text based user interface (TUI) task manager for Windows, inspired by `htop`.
1717

1818
## Features
1919

@@ -44,11 +44,13 @@ A terminal-based task manager for Windows, inspired by `htop`.
4444
| `info <pid>` | Show detailed process info |
4545
| `sort <column>` | Sort by `pid`, `cpu`, `mem`, or `name` |
4646
| `filter <text>` | Filter processes by name |
47-
| `speed <rate>` | Set refresh: `slow`, `medium`, `fast`, `superfast` |
48-
| `export [file]` | Export process list to file |
47+
| `speed <rate>` | Set refresh: `slow`, `medium`, `fast`, `superfast`, `ultrafast` |
48+
| `export [file]` | Export process list to file (default: `processes.txt`) |
4949
| `quit` | Exit the application |
5050
| `showdrives` | Toggle display of all drives |
5151

52+
Do be warned that faster refresh rates (above superfast) cause delta calculations to drift heavily, making the general values less accurate and for the bars to jump unexpectedly.
53+
5254
### Controls
5355
| Key | Action |
5456
|-----|--------|
@@ -61,7 +63,10 @@ A terminal-based task manager for Windows, inspired by `htop`.
6163

6264
## Installation (Setup)
6365

64-
Simply go to releases and download the latest installer or use the ps1 files attached, choose your options (ps1 does not support this) and you're ready to use winhtop in the terminal. Uninstalling should be easy aswell.
66+
Simply go to releases and download the latest installer or use the ps1 files attached, choose your options (ps1 does not support this) and you're ready to use winhtop in the terminal. Uninstalling should be easy aswell thru the installer, or the uninstall ps1 file.
67+
68+
Small disclaimer:
69+
Some security vendors may block the main .exe due using the same headers as malware (this is a known pyinstaller [issue](https://github.com/pyinstaller/pyinstaller/issues/6754), i'll be looking into using a different method), but rest assured it can't do anything malicious.
6570

6671
## Requirements
6772

WinHtop-Setup.exe

5.84 KB
Binary file not shown.

Winhtop-installer.iss

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
[Setup]
66
AppName=WinHtop
7-
AppVersion=0.4
7+
AppVersion=0.5
88
AppPublisher=Iza Carlos
99
DefaultDirName={localappdata}\Programs\WinHtop
1010
DisableDirPage=yes
@@ -15,7 +15,7 @@ Compression=lzma
1515
SolidCompression=yes
1616
UninstallDisplayIcon={app}\winhtop.exe
1717
PrivilegesRequired=lowest
18-
ArchitecturesInstallIn64BitMode=x64
18+
ArchitecturesInstallIn64BitMode=x64compatible
1919
CloseApplications=force
2020
UsePreviousAppDir=yes
2121
VersionInfoDescription=WinHtop Installer
@@ -57,4 +57,5 @@ Flags: runhidden; Tasks: addpath
5757
; Remove PATH entry on uninstall (only user scope)
5858
Filename: "{cmd}"; \
5959
Parameters: "/C powershell -command ""$p=[Environment]::GetEnvironmentVariable('Path','User'); $np=$p -replace ';{app}',''; [Environment]::SetEnvironmentVariable('Path',$np,'User')"""; \
60-
Flags: runhidden
60+
Flags: runhidden; \
61+
RunOnceId: "RemoveUserPathWinHtop"

dist/winhtop.exe

6.6 KB
Binary file not shown.

modules/audio_vis.py

Lines changed: 125 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Audio Visualizer module for Party Mode easter egg.
2+
Audio Visualizer module.
33
Uses WASAPI loopback to capture Windows audio output and perform FFT analysis.
44
"""
55

@@ -58,8 +58,11 @@ def __init__(self, num_cpu_cores=8):
5858
# Pre-compute CPU frequency bands (log-spaced from 200Hz to 16kHz)
5959
self._cpu_freq_bands = self._compute_cpu_bands()
6060

61+
# Global amplitude multiplier for fine-tuning
62+
self.amplitude = 2.0
63+
6164
# Smoothing factor (0-1, higher = smoother but less responsive)
62-
self._smoothing = 0.3
65+
self._smoothing = 0.4
6366

6467
def _compute_cpu_bands(self):
6568
"""Compute log-spaced frequency bands for CPU cores."""
@@ -111,19 +114,40 @@ def _audio_callback(self, indata, frames, time_info, status):
111114
fft = np.fft.rfft(windowed)
112115
magnitudes = np.abs(fft)
113116

114-
# Normalize (with some headroom to prevent clipping)
115-
max_mag = np.max(magnitudes)
116-
if max_mag > 0:
117-
magnitudes = magnitudes / max_mag
117+
# Normalize by block size to get approx 0-1 range
118+
# Increasing the divisor makes it more sensitive (e.g., /12 instead of /4)
119+
magnitudes = magnitudes / (len(audio) / 12)
118120

119-
# Extract band magnitudes
120-
ram_mag = self._get_band_magnitude(magnitudes, self.BASS_LOW, self.BASS_HIGH) * 100
121-
swap_mag = self._get_band_magnitude(magnitudes, self.LOW_MID_LOW, self.LOW_MID_HIGH) * 100
122-
disk_mag = self._get_band_magnitude(magnitudes, self.HIGH_MID_LOW, self.HIGH_MID_HIGH) * 100
121+
# Extract band magnitudes using a custom scaling
122+
# We don't normalize the whole array anymore, so volume is preserved.
123+
124+
def get_scaled_mag(low, high, boost=1.0):
125+
val = self._get_band_magnitude(magnitudes, low, high)
126+
127+
# Apply global amplitude and local boost
128+
val = val * self.amplitude * boost
129+
130+
if val <= 0.0001:
131+
return 0.0
132+
133+
# Sharper log curve to boost low-level signals more aggressively
134+
# val=0.01 -> 0.17
135+
# val=0.1 -> 0.47
136+
# val=1.0 -> 1.0
137+
scaled = np.log10(1 + 39 * val) / np.log10(40)
138+
139+
return min(100.0, scaled * 100.0)
140+
141+
# Apply mild weighting to balance spectrum (bass is naturally strong)
142+
ram_mag = get_scaled_mag(self.BASS_LOW, self.BASS_HIGH, boost=0.7) # Bass
143+
swap_mag = get_scaled_mag(self.LOW_MID_LOW, self.LOW_MID_HIGH, boost=1.0) # Low Mid
144+
disk_mag = get_scaled_mag(self.HIGH_MID_LOW, self.HIGH_MID_HIGH, boost=1.3) # High Mid
123145

124146
cpu_mags = []
125-
for low, high in self._cpu_freq_bands:
126-
mag = self._get_band_magnitude(magnitudes, low, high) * 100
147+
for i, (low, high) in enumerate(self._cpu_freq_bands):
148+
# Progressive boost for higher cpu bands
149+
freq_boost = 1.0 + (i / len(self._cpu_freq_bands)) * 2.5
150+
mag = get_scaled_mag(low, high, boost=freq_boost)
127151
cpu_mags.append(mag)
128152

129153
# Apply smoothing and update shared state
@@ -141,139 +165,113 @@ def _smooth(self, old_val, new_val):
141165
return old_val * self._smoothing + new_val * (1 - self._smoothing)
142166

143167
def _find_loopback_device(self):
144-
"""Pick the best available loopback / virtual output device.
145-
146-
Priority order:
147-
1. Voicemeeter B1 virtual output bus (captures all audio sent to B1)
148-
2. Voicemeeter B2/B3 buses
149-
3. Voicemeeter Input / AUX Input (capture what's being sent to VM)
150-
4. Stereo Mix / Loopback (Windows default capture)
151-
5. Any other WASAPI input
168+
"""
169+
Pick the best audio source:
170+
1. Virtual Output (Voicemeeter B1/B2, VB-Cable) - Direct Capture
171+
2. System Default Output - WASAPI Loopback
152172
"""
153173
try:
154174
devices = sd.query_devices()
175+
host_apis = sd.query_hostapis()
176+
177+
# Get default output info for fallback matching
178+
def_out = None
179+
try:
180+
idx = sd.default.device[1]
181+
if idx is not None:
182+
def_out = sd.query_devices(idx)
183+
except: pass
184+
155185
candidates = []
156-
186+
157187
for i, dev in enumerate(devices):
158-
if dev['max_input_channels'] <= 0:
188+
# Must be WASAPI for compatibility/loopback support
189+
if 'wasapi' not in host_apis[dev['hostapi']]['name'].lower():
159190
continue
160-
191+
161192
name = dev['name'].lower()
162-
api = sd.query_hostapis(dev['hostapi'])['name'].lower()
163-
164-
# Only prioritize WASAPI devices (best quality on Windows)
165-
if 'wasapi' not in api:
166-
# WDM/DirectSound fallback - low priority
167-
if 'wdm' in api or 'directsound' in api:
168-
if 'voicemeeter' in name or 'stereo mix' in name:
169-
candidates.append((100, i, dev))
170-
continue
171-
172-
# ---- Voicemeeter B buses (virtual outputs) ----
173-
# These capture mixed audio output, B1 is typically the main bus
174-
if 'voicemeeter' in name and 'out' in name:
175-
if 'out b1' in name or ('out b' not in name and 'out a' not in name and 'aux' not in name and 'vaio3' not in name):
176-
# B1 is highest priority (or generic "Voicemeeter Out" which is B1)
177-
candidates.append((0, i, dev))
178-
elif 'out b2' in name:
179-
candidates.append((1, i, dev))
180-
elif 'out b3' in name:
181-
candidates.append((2, i, dev))
182-
# Skip A buses (hardware out, not what we want)
183-
continue
184-
185-
# ---- Voicemeeter Inputs (capture what's sent to VM) ----
186-
if 'voicemeeter' in name and ('input' in name or 'aux' in name):
187-
if 'aux' in name:
188-
candidates.append((3, i, dev)) # AUX input
189-
else:
190-
candidates.append((4, i, dev)) # Main input
191-
continue
192-
193-
# ---- Stereo Mix / Loopback (good generic source) ----
194-
if 'loopback' in name or 'stereo mix' in name or 'what u hear' in name:
195-
candidates.append((5, i, dev))
196-
continue
197-
198-
# ---- Other WASAPI inputs (last resort) ----
199-
candidates.append((50, i, dev))
200-
201-
if not candidates:
202-
return None
203-
204-
# Pick lowest priority value (highest priority device)
205-
candidates.sort(key=lambda x: x[0])
206-
_, index, dev = candidates[0]
207-
return index
193+
194+
# Priority 0: Known Virtual Outputs (Capture devices)
195+
# These are "Input" devices in Windows (in_ch > 0) but carry output audio
196+
if dev['max_input_channels'] > 0:
197+
if 'voicemeeter' in name and 'out' in name:
198+
# Prioritize B1/Main mix
199+
if 'out b1' in name:
200+
candidates.append((0, i, False, dev['name']))
201+
elif 'out b' in name:
202+
candidates.append((1, i, False, dev['name']))
203+
else:
204+
candidates.append((2, i, False, dev['name']))
205+
206+
elif 'virtual cable' in name and 'out' in name:
207+
candidates.append((0, i, False, dev['name']))
208+
209+
# Priority 10: System Default Output (Loopback)
210+
# This ensures we get what the user is actually hearing if they aren't using Voicemeeter capture
211+
if def_out and dev['max_output_channels'] > 0:
212+
# Relaxed name matching to find the WASAPI version of the default output
213+
if def_out['name'] in dev['name'] or dev['name'] in def_out['name']:
214+
candidates.append((10, i, True, dev['name']))
215+
216+
# Sort by score
217+
if candidates:
218+
candidates.sort(key=lambda x: x[0])
219+
return candidates[0][1], candidates[0][2], candidates[0][3]
220+
221+
# Fallback: If nothing matched, try to force the default output index as a loopback source
222+
# This is a last resort for systems where name matching fails completely
223+
if def_out:
224+
is_wasapi = 'wasapi' in host_apis[def_out['hostapi']]['name'].lower()
225+
return sd.default.device[1], is_wasapi, def_out['name']
226+
227+
return None, False, None
208228

209229
except Exception:
210-
return None
230+
return None, False, None
211231

212232
def start(self):
213233
"""Start audio capture."""
214234
if self._running:
215235
return True
216236

217-
device_id = self._find_loopback_device()
218-
219-
# Common sample rates to try (in order of preference)
220-
# Start with device default, then try common rates
221-
sample_rates_to_try = [48000, 44100, 96000]
222-
223-
# Get device's default sample rate and put it first
224-
if device_id is not None:
225-
try:
226-
dev_info = sd.query_devices(device_id)
227-
default_rate = int(dev_info.get('default_samplerate', 48000))
228-
if default_rate not in sample_rates_to_try:
229-
sample_rates_to_try.insert(0, default_rate)
230-
else:
231-
# Move default to front
232-
sample_rates_to_try.remove(default_rate)
233-
sample_rates_to_try.insert(0, default_rate)
234-
except Exception:
235-
pass
237+
device_id, loopback_required, device_name = self._find_loopback_device()
236238

239+
if device_id is None:
240+
return False
241+
237242
# Build list of configurations to try
238243
configs_to_try = []
239244

240-
if device_id is not None:
241-
for sample_rate in sample_rates_to_try:
242-
# Try with WASAPI settings, 2 channels
243-
configs_to_try.append({
244-
'device': device_id,
245-
'samplerate': sample_rate,
246-
'blocksize': self.block_size,
247-
'channels': 2,
248-
'extra_settings': self._get_wasapi_settings()
249-
})
250-
# Try without WASAPI settings, 2 channels
251-
configs_to_try.append({
252-
'device': device_id,
253-
'samplerate': sample_rate,
254-
'blocksize': self.block_size,
255-
'channels': 2,
256-
'extra_settings': None
257-
})
258-
# Try with 1 channel
259-
configs_to_try.append({
260-
'device': device_id,
261-
'samplerate': sample_rate,
262-
'blocksize': self.block_size,
263-
'channels': 1,
264-
'extra_settings': None
265-
})
266-
267-
# Also try default device as last fallback
268-
for sample_rate in sample_rates_to_try:
245+
sample_rates = [48000, 44100, 96000]
246+
try:
247+
dev_info = sd.query_devices(device_id)
248+
default_rate = int(dev_info.get('default_samplerate', 48000))
249+
if default_rate not in sample_rates:
250+
sample_rates.insert(0, default_rate)
251+
except: pass
252+
253+
for rate in sample_rates:
254+
# Config 1: Stereo
269255
configs_to_try.append({
270-
'device': None,
271-
'samplerate': sample_rate,
256+
'device': device_id,
257+
'samplerate': rate,
258+
'blocksize': self.block_size,
259+
'channels': 2,
260+
'extra_settings': self._get_wasapi_settings(loopback=loopback_required)
261+
})
262+
263+
# Config 2: Mono (Fallback)
264+
configs_to_try.append({
265+
'device': device_id,
266+
'samplerate': rate,
272267
'blocksize': self.block_size,
273268
'channels': 1,
274-
'extra_settings': None
269+
'extra_settings': self._get_wasapi_settings(loopback=loopback_required)
275270
})
276271

272+
# CRITICAL: We DO NOT fall back to device=None here.
273+
# device=None opens the Default Input (Mic), which we strictly want to avoid.
274+
277275
for config in configs_to_try:
278276
try:
279277
extra = config.pop('extra_settings')
@@ -282,19 +280,20 @@ def start(self):
282280
self._stream = sd.InputStream(callback=self._audio_callback, extra_settings=extra, **config)
283281
else:
284282
self._stream = sd.InputStream(callback=self._audio_callback, **config)
283+
285284
self._stream.start()
286285
self._running = True
287-
self.sample_rate = actual_rate # Update for FFT calculations
286+
self.sample_rate = actual_rate
288287
return True
289288
except Exception:
290289
continue
291290

292291
return False
293292

294-
def _get_wasapi_settings(self):
295-
"""Get WASAPI settings if available."""
293+
def _get_wasapi_settings(self, loopback=False):
294+
"""Get WASAPI settings."""
296295
try:
297-
return sd.WasapiSettings(exclusive=False)
296+
return sd.WasapiSettings(exclusive=False, loopback=loopback)
298297
except Exception:
299298
return None
300299

@@ -334,6 +333,4 @@ def get_magnitudes(self):
334333
@property
335334
def is_running(self):
336335
"""Check if audio capture is active."""
337-
return self._running
338-
339-
# TODO: make this audio amplitude sensitive (ergo the audio in being louder should make the bars taller)
336+
return self._running

0 commit comments

Comments
 (0)