Skip to content

Commit 0592c9f

Browse files
committed
feat: better ui
1 parent c49228d commit 0592c9f

File tree

12 files changed

+1558
-150
lines changed

12 files changed

+1558
-150
lines changed

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ lint:
88

99
test:
1010
@echo "Running tests... 🧪"
11-
uv run pytest tests/ -v
11+
uv run pytest tests/ -q
1212
@echo "Tests completed. ✅"
1313

1414
# test-fast:
1515
# @echo "Running fast tests... ⚡"
16-
# uv run pytest tests/ -v -m "not slow"
16+
# uv run pytest tests/ -q -m "not slow"
1717
# @echo "Fast tests completed. ✅"
1818

1919
coverage:

README.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ Play audio files through your microphone in multiplayer games like CS, Battlefie
1111
- 🔊 **Per-sound volume** - Set individual volume levels (0-200%) for each sound
1212
- 📋 **Queue & playlists** - Build queues and save them as reusable playlists
1313
- 🎨 **Beautiful CLI** - Rich-click powered interface with colors and tables
14+
- 📊 **Progress bar** - Visual playback progress with time display
1415
- 🎛️ **Multiple formats** - Supports WAV, MP3, OGG, FLAC, M4A
1516
- 🔍 **Auto-detection** - Finds VB-Cable and virtual audio devices automatically
17+
- 🔎 **Fuzzy search** - Find sounds quickly with typo-tolerant search
1618
- 📁 **Organized library** - Subdirectory support for sound organization
1719
- 🔉 **Global volume** - Adjustable playback volume with persistent settings
1820
- 🎲 **Auto-play mode** - Play all sounds randomly or sequentially
@@ -103,6 +105,7 @@ muc devices # List all audio devices
103105
muc sounds # List available sounds in your library
104106
muc sounds --tag meme # Filter by tag
105107
muc sounds --favorites # Show only favorites
108+
muc search [query] # Fuzzy search for sounds by name or tag
106109
muc play [name] # Play a specific sound (prompts if no name)
107110
muc stop # Stop currently playing sound
108111
muc auto # Play all sounds randomly (use --sequential for alphabetical order)
@@ -206,22 +209,20 @@ muc hotkeys-reset
206209

207210
### Interactive Menu Mode
208211

209-
For a full-featured text menu:
212+
For a full-featured visual menu:
210213
```bash
211214
muc interactive
212215
```
213216

214-
Menu options:
215-
1. List all sounds
216-
2. Play sound by name
217-
3. View hotkey bindings
218-
4. Start hotkey listener
219-
5. Stop current sound
220-
6. List audio devices
221-
7. Change output device
222-
8. Adjust volume
223-
9. Auto-play all sounds
224-
0. Exit
217+
Features a visual status header showing device, volume, and sound count, plus:
218+
- 🎵 List & play sounds
219+
- 🔍 Search sounds (fuzzy matching)
220+
- ⌨️ View & manage hotkeys
221+
- 🎧 Start hotkey listener
222+
- ⏹️ Stop current sound
223+
- 🔊 Adjust volume
224+
- ⚙️ Change output device
225+
- 🎲 Auto-play all sounds
225226

226227
## 🎵 Audio File Management
227228

src/audio_manager.py

Lines changed: 151 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
import sounddevice as sd
99
import soundfile as sf
1010
from rich.console import Console
11+
from rich.progress import (
12+
BarColumn,
13+
Progress,
14+
TaskProgressColumn,
15+
TextColumn,
16+
)
1117
from rich.table import Table
1218

1319
from .exceptions import (
@@ -163,13 +169,70 @@ def _adjust_channels(data: np.ndarray, max_channels: int) -> np.ndarray:
163169

164170
return data
165171

166-
def play_audio(self, audio_file: Path, *, blocking: bool = False, sound_volume: float = 1.0) -> bool:
172+
def _load_and_prepare_audio(
173+
self,
174+
audio_file: Path,
175+
sound_volume: float,
176+
) -> tuple[np.ndarray, int] | None:
177+
"""Load and prepare audio data for playback.
178+
179+
Args:
180+
audio_file: Path to the audio file
181+
sound_volume: Per-sound volume multiplier
182+
183+
Returns:
184+
Tuple of (data, samplerate) or None if loading failed
185+
186+
"""
187+
try:
188+
data, samplerate = sf.read(str(audio_file)) # pyright: ignore[reportGeneralTypeIssues]
189+
190+
except sf.LibsndfileError as e:
191+
logger.exception("Failed to read audio file")
192+
error = AudioFileCorruptedError(
193+
f"Cannot read audio file: {audio_file.name}",
194+
details={"path": str(audio_file), "error": str(e)},
195+
)
196+
self.console.print(f"[red]✗[/red] {error.message}")
197+
self.console.print(f"[dim]💡 {error.suggestion}[/dim]")
198+
return None
199+
200+
# Ensure data is in the correct format
201+
if len(data.shape) == 1:
202+
data = data.reshape(-1, 1)
203+
204+
# Get device info to match channels
205+
device_info = sd.query_devices(self.output_device_id)
206+
max_channels = device_info["max_output_channels"] # pyright: ignore[reportCallIssue, reportArgumentType]
207+
208+
logger.debug(
209+
f"Audio info: duration={len(data) / samplerate:.2f}s, rate={samplerate}, channels={data.shape[1]}",
210+
)
211+
212+
# Adjust channels if needed
213+
data = self._adjust_channels(data, max_channels)
214+
215+
# Apply combined volume scaling: global x sound-specific
216+
final_volume = self.volume * sound_volume
217+
data *= final_volume
218+
219+
return data, samplerate
220+
221+
def play_audio(
222+
self,
223+
audio_file: Path,
224+
*,
225+
blocking: bool = False,
226+
sound_volume: float = 1.0,
227+
show_progress: bool = True,
228+
) -> bool:
167229
"""Play an audio file through the selected output device.
168230
169231
Args:
170232
audio_file: Path to the audio file
171233
blocking: If True, wait for playback to finish
172234
sound_volume: Per-sound volume multiplier (0.0 to 2.0), combined with global volume
235+
show_progress: If True and blocking, show a progress bar
173236
174237
Returns:
175238
True if playback started successfully, False otherwise.
@@ -196,52 +259,32 @@ def play_audio(self, audio_file: Path, *, blocking: bool = False, sound_volume:
196259
self.console.print(f"[dim]💡 {e.suggestion}[/dim]")
197260
return False
198261

199-
try:
200-
# Stop any currently playing audio
201-
self.stop_audio()
202-
203-
logger.debug(f"Loading audio file: {audio_file}")
204-
205-
# Load and play the audio file
206-
data, samplerate = sf.read(str(audio_file)) # pyright: ignore[reportGeneralTypeIssues]
207-
208-
# Ensure data is in the correct format
209-
if len(data.shape) == 1:
210-
# Mono audio
211-
data = data.reshape(-1, 1)
212-
213-
# Get device info to match channels
214-
device_info = sd.query_devices(self.output_device_id)
215-
max_channels = device_info["max_output_channels"] # pyright: ignore[reportCallIssue, reportArgumentType]
216-
217-
logger.debug(
218-
f"Audio info: duration={len(data) / samplerate:.2f}s, rate={samplerate}, channels={data.shape[1]}",
219-
)
262+
# Stop any currently playing audio
263+
self.stop_audio()
220264

221-
# Adjust channels if needed
222-
data = self._adjust_channels(data, max_channels)
265+
logger.debug(f"Loading audio file: {audio_file}")
223266

224-
# Apply combined volume scaling: global x sound-specific
225-
final_volume = self.volume * sound_volume
226-
data *= final_volume
267+
# Load and prepare audio
268+
result = self._load_and_prepare_audio(audio_file, sound_volume)
269+
if result is None:
270+
return False
271+
data, samplerate = result
227272

273+
try:
228274
logger.debug(f"Starting playback to device {self.output_device_id}")
229275
sd.play(data, samplerate, device=self.output_device_id)
230276

231277
if blocking:
278+
# Calculate duration for progress bar
279+
duration_seconds = len(data) / samplerate
280+
281+
if show_progress and self.console.is_terminal:
282+
return self._show_progress(audio_file.name, duration_seconds)
283+
232284
# Use polling loop instead of sd.wait() to allow KeyboardInterrupt
233285
while sd.get_stream() and sd.get_stream().active:
234286
time.sleep(0.1)
235287

236-
except sf.LibsndfileError as e:
237-
logger.exception("Failed to read audio file")
238-
error = AudioFileCorruptedError(
239-
f"Cannot read audio file: {audio_file.name}",
240-
details={"path": str(audio_file), "error": str(e)},
241-
)
242-
self.console.print(f"[red]✗[/red] {error.message}")
243-
self.console.print(f"[dim]💡 {error.suggestion}[/dim]")
244-
return False
245288
except sd.PortAudioError as e:
246289
# Device disconnection or error during playback
247290
if "device" in str(e).lower() or "stream" in str(e).lower():
@@ -264,6 +307,78 @@ def play_audio(self, audio_file: Path, *, blocking: bool = False, sound_volume:
264307
)
265308
return True
266309

310+
def _format_time(self, seconds: float) -> str:
311+
"""Format seconds as M:SS.
312+
313+
Args:
314+
seconds: Number of seconds
315+
316+
Returns:
317+
Formatted time string
318+
319+
"""
320+
mins = int(seconds // 60)
321+
secs = int(seconds % 60)
322+
return f"{mins}:{secs:02d}"
323+
324+
def _show_progress(self, filename: str, duration: float) -> bool:
325+
"""Display progress bar during playback.
326+
327+
Args:
328+
filename: Name of the audio file
329+
duration: Total duration in seconds
330+
331+
Returns:
332+
True if completed, False if interrupted
333+
334+
"""
335+
with Progress(
336+
TextColumn("[bold cyan]▶[/bold cyan]"),
337+
TextColumn("[white]{task.fields[filename]}[/white]"),
338+
BarColumn(bar_width=30, style="cyan", complete_style="green"),
339+
TaskProgressColumn(),
340+
TextColumn("•"),
341+
TextColumn("[cyan]{task.fields[elapsed]}[/cyan]"),
342+
TextColumn("/"),
343+
TextColumn("[dim]{task.fields[total_time]}[/dim]"),
344+
TextColumn("•"),
345+
TextColumn("[dim]Ctrl+C to stop[/dim]"),
346+
console=self.console,
347+
transient=True,
348+
) as progress:
349+
task = progress.add_task(
350+
"Playing",
351+
total=duration,
352+
filename=filename,
353+
elapsed="0:00",
354+
total_time=self._format_time(duration),
355+
)
356+
357+
start_time = time.time()
358+
359+
try:
360+
while sd.get_stream() and sd.get_stream().active:
361+
elapsed = time.time() - start_time
362+
progress.update(
363+
task,
364+
completed=min(elapsed, duration),
365+
elapsed=self._format_time(elapsed),
366+
)
367+
time.sleep(0.1) # 10 FPS update rate
368+
369+
except KeyboardInterrupt:
370+
self.stop_audio()
371+
self.console.print("\n[yellow]⏹[/yellow] Playback stopped")
372+
return False
373+
else:
374+
# Ensure 100% on completion
375+
progress.update(
376+
task,
377+
completed=duration,
378+
elapsed=self._format_time(duration),
379+
)
380+
return True
381+
267382
def stop_audio(self) -> None:
268383
"""Stop any currently playing audio."""
269384
try:

0 commit comments

Comments
 (0)