claudible 0.1.2__tar.gz → 0.1.4__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claudible
3
- Version: 0.1.2
4
- Summary: Ambient audio soundscape feedback for terminal output - an opus for your terminals
3
+ Version: 0.1.4
4
+ Summary: Ambient audio soundscape feedback for terminal output - an opus for your terminals, claude code by default
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/anthropics/claudible
7
7
  Classifier: Development Status :: 4 - Beta
@@ -40,7 +40,9 @@ Requires-Dist: sounddevice>=0.4.0
40
40
 
41
41
  *An opus for your terminals.*
42
42
 
43
- Possibly the most annoying Claude utility ever made but here it is anyway. Ambient audio soundscape feedback for terminal output.
43
+ Possibly the most annoying Claude utility ever made but here it is anyway.
44
+
45
+ Ambient audio soundscape feedback for terminal output. It really shines with a lot of busy busy Claude Code terminals running at the same time.
44
46
 
45
47
  ## 🎼 The Idea
46
48
 
@@ -64,7 +66,7 @@ pip install claudible
64
66
  ## 🎹 Usage
65
67
 
66
68
  ```bash
67
- # Run claude with audio feedback (default)
69
+ # Run claude with audio feedback (default: ambient sound set)
68
70
  claudible
69
71
 
70
72
  # Run a different command
@@ -74,13 +76,20 @@ claudible "python my_script.py"
74
76
  some-command 2>&1 | claudible --pipe
75
77
 
76
78
  # Choose a sound character
77
- claudible --character crystal
79
+ claudible --character drift
80
+
81
+ # Use the percussive material sound set
82
+ claudible --set material -c bell
78
83
 
79
84
  # Adjust volume
80
85
  claudible --volume 0.3
81
86
 
82
- # List available characters
87
+ # List available characters across all sets
83
88
  claudible --list-characters
89
+
90
+ # Demo all sound characters in a set
91
+ claudible --demo
92
+ claudible --demo --set material
84
93
  ```
85
94
 
86
95
  ### 🔇 Reverse Mode
@@ -110,7 +119,26 @@ npm run dev 2>&1 | claudible --pipe
110
119
  tail -f /var/log/app.log | claudible --pipe -c droplet
111
120
  ```
112
121
 
113
- ## 🎻 Sound Characters
122
+ ## 🎻 Sound Sets
123
+
124
+ Claudible ships with two sound sets. The **ambient** set (default) produces soft, throbbing textures with rich overtones. The **material** set has crisp, percussive sounds modelled on physical materials.
125
+
126
+ ### Ambient (default) `--set ambient`
127
+
128
+ | Character | | Description |
129
+ |-----------|---|-------------|
130
+ | `drift` | 🌊 | Slow undulating low throb, gentle beating pairs |
131
+ | `tide` | 🌀 | Oceanic wash, phase interference, wide and enveloping |
132
+ | `breath` | 💨 | Soft exhale texture, breathy warmth with filtered noise |
133
+ | `haze` | 🌫️ | Dense foggy overtones, warm and thick with close partial clusters |
134
+ | `pulse` | 💗 | Gentle rhythmic throbbing from detuned pairs, hypnotic |
135
+ | `glow` | 🕯️ | Warm radiant harmonics, rich natural overtone series |
136
+ | `cloud` | ☁️ | Diffuse and soft, massive reverb space, floating |
137
+ | `murmur` | 🫧 | Low gentle rumble, warm harmonic murmur with subtle throb |
138
+ | `shimmer` | ✨ | High ethereal overtones, floating and luminous |
139
+ | `deep` | 🎚️ | Sub-bass throb, felt more than heard, very deep and slow |
140
+
141
+ ### Material `--set material`
114
142
 
115
143
  | Character | | Description |
116
144
  |-----------|---|-------------|
@@ -134,11 +162,13 @@ tail -f /var/log/app.log | claudible --pipe -c droplet
134
162
  | Flag | Description |
135
163
  |------|-------------|
136
164
  | `--pipe` | 📥 Read from stdin instead of wrapping |
137
- | `--character`, `-c` | 🎵 Sound character |
165
+ | `--set`, `-s` | 🎼 Sound set: `ambient` (default), `material` |
166
+ | `--character`, `-c` | 🎵 Sound character within the set |
138
167
  | `--volume`, `-v` | 🔊 Volume 0.0–1.0 (default: 0.5) |
139
168
  | `--attention`, `-a` | ⏰ Silence alert seconds (default: 30) |
140
169
  | `--reverse`, `-r` | 🔄 Reverse mode: sound during silence, quiet during output |
141
- | `--list-characters` | 📋 Show presets |
170
+ | `--list-characters` | 📋 Show all characters across all sets |
171
+ | `--demo` | 🔊 Demo characters in the selected set |
142
172
 
143
173
  ## 🛠️ Development
144
174
 
@@ -158,6 +188,9 @@ echo "test" | PYTHONPATH=src python3 -m claudible --pipe -c glass
158
188
 
159
189
  # List characters
160
190
  PYTHONPATH=src python3 -m claudible --list-characters
191
+
192
+ # Demo all characters
193
+ PYTHONPATH=src python3 -m claudible --demo
161
194
  ```
162
195
 
163
196
  ## 📜 License
@@ -15,7 +15,9 @@
15
15
 
16
16
  *An opus for your terminals.*
17
17
 
18
- Possibly the most annoying Claude utility ever made but here it is anyway. Ambient audio soundscape feedback for terminal output.
18
+ Possibly the most annoying Claude utility ever made but here it is anyway.
19
+
20
+ Ambient audio soundscape feedback for terminal output. It really shines with a lot of busy busy Claude Code terminals running at the same time.
19
21
 
20
22
  ## 🎼 The Idea
21
23
 
@@ -39,7 +41,7 @@ pip install claudible
39
41
  ## 🎹 Usage
40
42
 
41
43
  ```bash
42
- # Run claude with audio feedback (default)
44
+ # Run claude with audio feedback (default: ambient sound set)
43
45
  claudible
44
46
 
45
47
  # Run a different command
@@ -49,13 +51,20 @@ claudible "python my_script.py"
49
51
  some-command 2>&1 | claudible --pipe
50
52
 
51
53
  # Choose a sound character
52
- claudible --character crystal
54
+ claudible --character drift
55
+
56
+ # Use the percussive material sound set
57
+ claudible --set material -c bell
53
58
 
54
59
  # Adjust volume
55
60
  claudible --volume 0.3
56
61
 
57
- # List available characters
62
+ # List available characters across all sets
58
63
  claudible --list-characters
64
+
65
+ # Demo all sound characters in a set
66
+ claudible --demo
67
+ claudible --demo --set material
59
68
  ```
60
69
 
61
70
  ### 🔇 Reverse Mode
@@ -85,7 +94,26 @@ npm run dev 2>&1 | claudible --pipe
85
94
  tail -f /var/log/app.log | claudible --pipe -c droplet
86
95
  ```
87
96
 
88
- ## 🎻 Sound Characters
97
+ ## 🎻 Sound Sets
98
+
99
+ Claudible ships with two sound sets. The **ambient** set (default) produces soft, throbbing textures with rich overtones. The **material** set has crisp, percussive sounds modelled on physical materials.
100
+
101
+ ### Ambient (default) `--set ambient`
102
+
103
+ | Character | | Description |
104
+ |-----------|---|-------------|
105
+ | `drift` | 🌊 | Slow undulating low throb, gentle beating pairs |
106
+ | `tide` | 🌀 | Oceanic wash, phase interference, wide and enveloping |
107
+ | `breath` | 💨 | Soft exhale texture, breathy warmth with filtered noise |
108
+ | `haze` | 🌫️ | Dense foggy overtones, warm and thick with close partial clusters |
109
+ | `pulse` | 💗 | Gentle rhythmic throbbing from detuned pairs, hypnotic |
110
+ | `glow` | 🕯️ | Warm radiant harmonics, rich natural overtone series |
111
+ | `cloud` | ☁️ | Diffuse and soft, massive reverb space, floating |
112
+ | `murmur` | 🫧 | Low gentle rumble, warm harmonic murmur with subtle throb |
113
+ | `shimmer` | ✨ | High ethereal overtones, floating and luminous |
114
+ | `deep` | 🎚️ | Sub-bass throb, felt more than heard, very deep and slow |
115
+
116
+ ### Material `--set material`
89
117
 
90
118
  | Character | | Description |
91
119
  |-----------|---|-------------|
@@ -109,11 +137,13 @@ tail -f /var/log/app.log | claudible --pipe -c droplet
109
137
  | Flag | Description |
110
138
  |------|-------------|
111
139
  | `--pipe` | 📥 Read from stdin instead of wrapping |
112
- | `--character`, `-c` | 🎵 Sound character |
140
+ | `--set`, `-s` | 🎼 Sound set: `ambient` (default), `material` |
141
+ | `--character`, `-c` | 🎵 Sound character within the set |
113
142
  | `--volume`, `-v` | 🔊 Volume 0.0–1.0 (default: 0.5) |
114
143
  | `--attention`, `-a` | ⏰ Silence alert seconds (default: 30) |
115
144
  | `--reverse`, `-r` | 🔄 Reverse mode: sound during silence, quiet during output |
116
- | `--list-characters` | 📋 Show presets |
145
+ | `--list-characters` | 📋 Show all characters across all sets |
146
+ | `--demo` | 🔊 Demo characters in the selected set |
117
147
 
118
148
  ## 🛠️ Development
119
149
 
@@ -133,6 +163,9 @@ echo "test" | PYTHONPATH=src python3 -m claudible --pipe -c glass
133
163
 
134
164
  # List characters
135
165
  PYTHONPATH=src python3 -m claudible --list-characters
166
+
167
+ # Demo all characters
168
+ PYTHONPATH=src python3 -m claudible --demo
136
169
  ```
137
170
 
138
171
  ## 📜 License
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "claudible"
7
- version = "0.1.2"
8
- description = "Ambient audio soundscape feedback for terminal output - an opus for your terminals"
7
+ version = "0.1.4"
8
+ description = "Ambient audio soundscape feedback for terminal output - an opus for your terminals, claude code by default"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  requires-python = ">=3.8"
@@ -26,6 +26,7 @@ class SoundEngine:
26
26
  self._stream: Optional[sd.OutputStream] = None
27
27
  self._running = False
28
28
  self._hum_phase = 0.0
29
+ self._last_pitch = 1.0 # portamento tracking
29
30
  self._lock = threading.Lock()
30
31
 
31
32
  # Ring buffer for mixing audio
@@ -33,8 +34,8 @@ class SoundEngine:
33
34
  self._write_pos = 0
34
35
  self._read_pos = 0
35
36
 
36
- # Pre-generate grain variations for efficiency
37
- self._grain_cache = [self._generate_grain() for _ in range(8)]
37
+ # Pre-generate grains spread across registers for tonal variety
38
+ self._grain_cache = self._build_grain_cache()
38
39
  self._cache_index = 0
39
40
 
40
41
  def start(self):
@@ -141,92 +142,183 @@ class SoundEngine:
141
142
 
142
143
  # --- Sound generation ---
143
144
 
144
- def _generate_grain(self) -> np.ndarray:
145
+ def _build_grain_cache(self) -> list:
146
+ """Build grain cache with voices at full-octave intervals, filtered by material range."""
147
+ # Each register is a full octave apart.
148
+ # (octave_shift, noise_mult, decay_mult, duration_mult)
149
+ # Multiple character variations per register.
150
+ all_voices = [
151
+ # --- oct -3: sub rumble (3 voices) ---
152
+ (-3, 3.5, 2.5, 1.8), # sub thump
153
+ (-3, 2.0, 3.0, 2.0), # sub knock
154
+ (-3, 2.8, 2.0, 1.6), # sub punch
155
+
156
+ # --- oct -2: deep (3 voices) ---
157
+ (-2, 2.8, 1.8, 1.5), # deep percussive
158
+ (-2, 1.2, 2.0, 1.4), # deep tonal
159
+ (-2, 2.0, 1.5, 1.3), # deep knock
160
+
161
+ # --- oct -1: low (3 voices) ---
162
+ (-1, 1.8, 1.4, 1.2), # low thump
163
+ (-1, 0.8, 1.2, 1.1), # low warm
164
+ (-1, 1.4, 1.6, 1.2), # low snap
165
+
166
+ # --- oct 0: mid (2 voices) ---
167
+ ( 0, 1.0, 1.0, 1.0), # mid click
168
+ ( 0, 0.6, 0.8, 0.9), # mid soft
169
+
170
+ # --- oct +1: high (3 voices) ---
171
+ ( 1, 0.5, 0.6, 0.7), # high ping
172
+ ( 1, 0.8, 0.5, 0.7), # high tick
173
+ ( 1, 0.3, 0.7, 0.8), # high ring
174
+
175
+ # --- oct +2: bright (3 voices) ---
176
+ ( 2, 0.2, 0.4, 0.55), # bright ting
177
+ ( 2, 0.5, 0.3, 0.6), # bright click
178
+ ( 2, 0.15, 0.5, 0.5), # bright shimmer
179
+
180
+ # --- oct +3: air (3 voices) ---
181
+ ( 3, 0.1, 0.25, 0.4), # air sparkle
182
+ ( 3, 0.3, 0.2, 0.45), # air tick
183
+ ( 3, 0.05, 0.3, 0.35), # air wisp
184
+ ]
185
+ oct_min, oct_max = self.material.octave_range
186
+ voices = [v for v in all_voices if oct_min <= v[0] <= oct_max]
187
+ cache = []
188
+ for octave, noise_m, decay_m, dur_m in voices:
189
+ cache.append(self._generate_grain(
190
+ octave_shift=octave,
191
+ noise_mult=noise_m,
192
+ decay_mult=decay_m,
193
+ duration_mult=dur_m,
194
+ ))
195
+ return cache
196
+
197
+ def _generate_grain(self, octave_shift: float = 0.0, noise_mult: float = 1.0,
198
+ decay_mult: float = 1.0, duration_mult: float = 1.0) -> np.ndarray:
145
199
  """Generate a single grain sound from the material's physical model."""
146
200
  m = self.material
147
- # Vary duration per grain
148
- duration = m.grain_duration * np.random.uniform(0.85, 1.15)
201
+ duration = m.grain_duration * duration_mult * 0.5 * np.random.uniform(0.85, 1.15)
149
202
  n = int(duration * self.SAMPLE_RATE)
150
203
  t = np.linspace(0, duration, n, dtype=np.float32)
151
204
 
152
205
  grain = np.zeros(n, dtype=np.float32)
153
206
 
154
- # Randomise base frequency within the material's spread
155
- base_freq = m.base_freq * (2 ** (np.random.uniform(-m.freq_spread, m.freq_spread) / 12))
207
+ # Base frequency shifted by register
208
+ base_freq = m.base_freq * (2 ** octave_shift)
209
+ base_freq *= (2 ** (np.random.uniform(-m.freq_spread, m.freq_spread) / 12))
156
210
 
157
- # Generate each partial with its own decay, detuning, and random phase
158
211
  for i, (ratio, amp) in enumerate(m.partials):
159
212
  detune_cents = np.random.uniform(-m.detune_amount, m.detune_amount)
160
213
  freq = base_freq * ratio * (2 ** (detune_cents / 1200))
161
- # Extra micro-variation per partial
162
214
  freq *= np.random.uniform(0.997, 1.003)
163
215
  phase = np.random.uniform(0, 2 * np.pi)
164
216
 
165
217
  decay_rate = m.decay_rates[i] if i < len(m.decay_rates) else m.decay_rates[-1]
218
+ decay_rate *= decay_mult
166
219
  envelope = np.exp(-decay_rate * t)
167
220
 
168
221
  grain += amp * envelope * np.sin(2 * np.pi * freq * t + phase)
169
222
 
170
- # Noise transient (the "tick" of physical impact)
223
+ # Noise transient louder for low percussive grains
171
224
  if m.attack_noise > 0:
172
225
  noise = np.random.randn(n).astype(np.float32)
173
- noise = self._highpass(noise, m.noise_freq)
174
- # Shape: fast attack, very fast decay with micro-variation
175
- noise_env = np.exp(-150 * t)
226
+ noise_cutoff = max(m.noise_freq * (0.5 if octave_shift < -0.5 else 1.0), 200)
227
+ noise = self._highpass(noise, noise_cutoff)
228
+ noise_env = np.exp(-150 * t / max(decay_mult, 0.5))
176
229
  noise_env *= np.clip(
177
230
  1.0 + 0.3 * np.random.randn(n).astype(np.float32) * np.exp(-200 * t),
178
231
  0, 1,
179
232
  )
180
- grain += m.attack_noise * 0.3 * noise * noise_env
233
+ grain += m.attack_noise * noise_mult * 0.3 * noise * noise_env
181
234
 
182
235
  # Attack shaping
183
236
  attack_samples = max(int(m.attack_ms / 1000 * self.SAMPLE_RATE), 2)
184
237
  if attack_samples < n:
185
238
  grain[:attack_samples] *= np.linspace(0, 1, attack_samples, dtype=np.float32)
186
239
 
187
- # Pitch drop envelope (physical: pitch drops as energy dissipates)
188
- if m.pitch_drop != 1.0:
189
- pitch_env = np.linspace(1.0, m.pitch_drop, n)
240
+ # Pitch drop more dramatic for low grains
241
+ pitch_drop = m.pitch_drop if octave_shift >= 0 else m.pitch_drop ** (1.0 + abs(octave_shift) * 0.3)
242
+ if pitch_drop != 1.0:
243
+ pitch_env = np.linspace(1.0, pitch_drop, n)
190
244
  phase_mod = np.cumsum(pitch_env) / np.sum(pitch_env) * n
191
245
  indices = np.clip(phase_mod.astype(int), 0, n - 1)
192
246
  grain = grain[indices]
193
247
 
194
- # Multi-tap reverb with HF damping
195
- grain = self._apply_reverb(grain, m.reverb_amount, m.room_size, m.reverb_damping)
248
+ # Reverb less for percussive lows (tighter), more for highs
249
+ reverb_amt = m.reverb_amount * (0.6 if octave_shift < -0.5 else 1.0 + max(octave_shift, 0) * 0.2)
250
+ grain = self._apply_reverb(grain, reverb_amt, m.room_size, m.reverb_damping)
196
251
 
197
- # Truncate reverb tail back to grain length
198
- grain = grain[:n]
252
+ # Small pre-delay + extra tail reverb
253
+ delay_ms = 15
254
+ delay_samples = int(delay_ms * self.SAMPLE_RATE / 1000)
255
+ tail_len = n + delay_samples + int(0.08 * self.SAMPLE_RATE)
256
+ out = np.zeros(tail_len, dtype=np.float32)
257
+ out[delay_samples:delay_samples + len(grain)] = grain
258
+ # Light tail reverb on top
259
+ out = self._apply_reverb(out, 0.15, room_size=1.2, damping=0.4)
199
260
 
200
- # Normalise with per-material volume
201
- peak = np.max(np.abs(grain))
261
+ peak = np.max(np.abs(out))
202
262
  if peak > 0:
203
- grain = grain / peak * 0.4 * m.volume
204
-
205
- return grain.astype(np.float32)
206
-
207
- def play_grain(self):
208
- """Play a grain/sparkle sound."""
209
- # Pick a random cached grain
210
- idx = np.random.randint(len(self._grain_cache))
263
+ out = out / peak * 0.4 * m.volume
264
+
265
+ return out.astype(np.float32)
266
+
267
+ @staticmethod
268
+ def _token_hash(token: str) -> int:
269
+ """Deterministic hash from token content, stable across sessions."""
270
+ h = 5381
271
+ for c in token:
272
+ h = ((h * 33) ^ ord(c)) & 0xFFFFFFFF
273
+ return h
274
+
275
+ # Minor pentatonic scale degrees in semitones (wraps at octave)
276
+ _SCALE_DEGREES = [0, 3, 5, 7, 10]
277
+
278
+ def _snap_to_scale(self, semitones: float) -> float:
279
+ """Quantise a semitone offset to the nearest scale degree."""
280
+ octave = int(semitones // 12)
281
+ remainder = semitones % 12
282
+ nearest = min(self._SCALE_DEGREES, key=lambda d: abs(d - remainder))
283
+ return octave * 12 + nearest
284
+
285
+ def play_grain(self, token: str = ""):
286
+ """Play a grain/sparkle sound, deterministic for a given token."""
287
+ if token:
288
+ h = self._token_hash(token)
289
+ rng = np.random.RandomState(h)
290
+ else:
291
+ rng = np.random
292
+
293
+ idx = rng.randint(len(self._grain_cache))
211
294
  grain = self._grain_cache[idx].copy()
212
295
 
213
- # Per-play pitch variation via resampling (±4 semitones)
214
- pitch_shift = 2 ** (np.random.uniform(-4, 4) / 12)
215
- if abs(pitch_shift - 1.0) > 0.001:
216
- new_len = int(len(grain) / pitch_shift)
217
- if new_len > 0:
218
- grain = np.interp(
219
- np.linspace(0, len(grain) - 1, new_len),
220
- np.arange(len(grain)),
221
- grain,
222
- ).astype(np.float32)
223
-
224
- # Random amplitude variation
225
- grain *= np.random.uniform(0.6, 1.0)
226
-
227
- # Regenerate cached grains aggressively for variety
228
- if np.random.random() < 0.5:
229
- self._grain_cache[np.random.randint(len(self._grain_cache))] = self._generate_grain()
296
+ # Pitch variation snapped to minor pentatonic scale
297
+ raw_semi = rng.uniform(-1.5, 1.5)
298
+ snapped_semi = self._snap_to_scale(raw_semi)
299
+ pitch_shift = 2 ** (snapped_semi / 12)
300
+
301
+ # Fast portamento: slide from last pitch to new target
302
+ prev_pitch = self._last_pitch
303
+ self._last_pitch = pitch_shift
304
+
305
+ n = len(grain)
306
+ if n > 0 and (abs(pitch_shift - 1.0) > 0.001 or abs(prev_pitch - 1.0) > 0.001):
307
+ # Glide over first 30% of grain, then hold target
308
+ glide_len = int(n * 0.3)
309
+ pitch_env = np.ones(n, dtype=np.float32)
310
+ if glide_len > 0:
311
+ pitch_env[:glide_len] = np.linspace(
312
+ prev_pitch, pitch_shift, glide_len, dtype=np.float32,
313
+ )
314
+ pitch_env[glide_len:] = pitch_shift
315
+
316
+ # Time-varying resample using cumulative phase
317
+ phase = np.cumsum(pitch_env)
318
+ phase = phase / phase[-1] * (n - 1)
319
+ grain = np.interp(phase, np.arange(n), grain).astype(np.float32)
320
+
321
+ grain *= rng.uniform(0.7, 1.0)
230
322
 
231
323
  self._add_to_buffer(grain)
232
324
 
@@ -10,10 +10,32 @@ import time
10
10
  from typing import Optional
11
11
 
12
12
  from .audio import SoundEngine
13
- from .materials import get_material, get_random_material, list_materials, MATERIALS
13
+ from .materials import (
14
+ get_material, get_random_material, list_materials, list_sound_sets,
15
+ get_sound_set, SOUND_SETS, DEFAULT_SOUND_SET,
16
+ )
14
17
  from .monitor import ActivityMonitor
15
18
 
16
19
 
20
+ def run_demo(volume: float, sound_set: str = DEFAULT_SOUND_SET):
21
+ """Play a short demo of every sound character in a set."""
22
+ materials = get_sound_set(sound_set)
23
+ print(f"Claudible character demo [{sound_set}]\n", file=sys.stderr)
24
+ for name, mat in materials.items():
25
+ print(f" {name:10} - {mat.description}", file=sys.stderr)
26
+ engine = SoundEngine(material=mat, volume=volume)
27
+ engine.start()
28
+ # Play a burst of grains then a chime
29
+ for i in range(8):
30
+ engine.play_grain(chr(ord('a') + i))
31
+ time.sleep(0.06)
32
+ time.sleep(0.15)
33
+ engine.play_chime()
34
+ time.sleep(0.6)
35
+ engine.stop()
36
+ print("\nDone.", file=sys.stderr)
37
+
38
+
17
39
  def run_pipe_mode(engine: SoundEngine, monitor: ActivityMonitor):
18
40
  """Run in pipe mode, reading from stdin."""
19
41
  engine.start()
@@ -111,10 +133,15 @@ def main():
111
133
  action='store_true',
112
134
  help='Pipe mode: read from stdin instead of wrapping a command',
113
135
  )
136
+ parser.add_argument(
137
+ '--set', '-s',
138
+ choices=list_sound_sets(),
139
+ default=DEFAULT_SOUND_SET,
140
+ help=f'Sound set (default: {DEFAULT_SOUND_SET})',
141
+ )
114
142
  parser.add_argument(
115
143
  '--character', '-c',
116
- choices=list_materials(),
117
- help='Sound character (default: random)',
144
+ help='Sound character (default: random). Use --list-characters to see options.',
118
145
  )
119
146
  parser.add_argument(
120
147
  '--volume', '-v',
@@ -138,19 +165,41 @@ def main():
138
165
  action='store_true',
139
166
  help='List available sound characters',
140
167
  )
168
+ parser.add_argument(
169
+ '--demo',
170
+ action='store_true',
171
+ help='Play a short demo of each sound character',
172
+ )
141
173
 
142
174
  args = parser.parse_args()
143
175
 
176
+ sound_set = args.set
177
+
144
178
  if args.list_characters:
145
- print("Available sound characters:\n")
146
- for name, mat in MATERIALS.items():
147
- print(f" {name:10} - {mat.description}")
179
+ for set_name, materials in SOUND_SETS.items():
180
+ default_tag = " (default)" if set_name == DEFAULT_SOUND_SET else ""
181
+ print(f"\n [{set_name}]{default_tag}\n")
182
+ for name, mat in materials.items():
183
+ print(f" {name:10} - {mat.description}")
184
+ print()
185
+ return
186
+
187
+ if args.demo:
188
+ volume = max(0.0, min(1.0, args.volume))
189
+ run_demo(volume, sound_set)
148
190
  return
149
191
 
192
+ # Validate character against the chosen set
150
193
  if args.character:
151
- material = get_material(args.character)
194
+ available = list_materials(sound_set)
195
+ if args.character not in available:
196
+ parser.error(
197
+ f"character '{args.character}' not in set '{sound_set}'. "
198
+ f"Available: {', '.join(available)}"
199
+ )
200
+ material = get_material(args.character, sound_set)
152
201
  else:
153
- material = get_random_material()
202
+ material = get_random_material(sound_set)
154
203
 
155
204
  volume = max(0.0, min(1.0, args.volume))
156
205