claudible 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl

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.
claudible/audio.py CHANGED
@@ -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
 
claudible/cli.py CHANGED
@@ -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
 
claudible/materials.py CHANGED
@@ -27,12 +27,380 @@ class Material:
27
27
  attack_ms: float # attack ramp time in milliseconds
28
28
  freq_spread: float # semitones of random base freq variation per grain
29
29
  volume: float # per-material volume scaling (0-1)
30
+ octave_range: Tuple[int, int] # (min, max) octave shifts for grain cache
30
31
  description: str
31
32
 
32
33
 
33
- MATERIALS = {
34
- # --- Original materials (upgraded with physics parameters) ---
34
+ DEFAULT_SOUND_SET = "ambient"
35
35
 
36
+ # --- Ambient sound set (default) ---
37
+ # Throbbing, textured, overtone-rich with percussive attack.
38
+ # Close partial pairs create beating/throbbing. High attack noise for punch.
39
+ # Kept low — nothing shrill. Dense, gritty texture.
40
+
41
+ AMBIENT_MATERIALS = {
42
+ "drift": Material(
43
+ name="drift",
44
+ base_freq=95,
45
+ partials=[
46
+ (1.0, 1.0), (1.005, 0.95), # beating pair ~0.48Hz throb
47
+ (2.0, 0.5), (2.008, 0.45), # octave beating pair
48
+ (3.01, 0.2), (4.02, 0.1),
49
+ ],
50
+ decay_rates=[3, 3.2, 5, 5.3, 8, 11],
51
+ grain_duration=0.16,
52
+ attack_noise=0.35,
53
+ noise_freq=600,
54
+ detune_amount=12,
55
+ reverb_amount=0.6,
56
+ reverb_damping=0.55,
57
+ room_size=1.8,
58
+ pitch_drop=0.997,
59
+ attack_ms=1.5,
60
+ freq_spread=3.0,
61
+ volume=0.7,
62
+ octave_range=(-3, 0),
63
+ description="Low undulating throb, punchy attack into beating pairs",
64
+ ),
65
+
66
+ "tide": Material(
67
+ name="tide",
68
+ base_freq=120,
69
+ partials=[
70
+ (1.0, 1.0), (1.003, 0.92), # slow beat
71
+ (1.498, 0.55), (1.502, 0.52), # fifth beating pair
72
+ (2.0, 0.35), (3.0, 0.12),
73
+ ],
74
+ decay_rates=[4, 4.1, 6, 6.2, 8, 12],
75
+ grain_duration=0.18,
76
+ attack_noise=0.3,
77
+ noise_freq=500,
78
+ detune_amount=14,
79
+ reverb_amount=0.7,
80
+ reverb_damping=0.5,
81
+ room_size=2.0,
82
+ pitch_drop=0.996,
83
+ attack_ms=1.0,
84
+ freq_spread=4.0,
85
+ volume=0.65,
86
+ octave_range=(-3, 0),
87
+ description="Oceanic wash with percussive bite, wide phase interference",
88
+ ),
89
+
90
+ "breath": Material(
91
+ name="breath",
92
+ base_freq=160,
93
+ partials=[
94
+ (1.0, 1.0), (1.006, 0.85), # beating pair
95
+ (2.01, 0.4), (3.02, 0.15),
96
+ ],
97
+ decay_rates=[5, 5.2, 8, 12],
98
+ grain_duration=0.14,
99
+ attack_noise=0.4,
100
+ noise_freq=900,
101
+ detune_amount=18,
102
+ reverb_amount=0.55,
103
+ reverb_damping=0.6,
104
+ room_size=1.6,
105
+ pitch_drop=0.998,
106
+ attack_ms=1.2,
107
+ freq_spread=5.0,
108
+ volume=0.6,
109
+ octave_range=(-3, 0),
110
+ description="Gritty exhale, noisy attack decaying into warm throb",
111
+ ),
112
+
113
+ "haze": Material(
114
+ name="haze",
115
+ base_freq=110,
116
+ partials=[
117
+ (1.0, 1.0), (1.004, 0.9), # tight beating
118
+ (2.003, 0.6), (2.007, 0.55), # octave beating
119
+ (3.005, 0.3), (4.01, 0.15),
120
+ (5.02, 0.06),
121
+ ],
122
+ decay_rates=[3.5, 3.7, 5, 5.2, 7, 10, 14],
123
+ grain_duration=0.15,
124
+ attack_noise=0.32,
125
+ noise_freq=550,
126
+ detune_amount=10,
127
+ reverb_amount=0.65,
128
+ reverb_damping=0.6,
129
+ room_size=1.9,
130
+ pitch_drop=0.997,
131
+ attack_ms=1.0,
132
+ freq_spread=3.5,
133
+ volume=0.65,
134
+ octave_range=(-3, 0),
135
+ description="Dense foggy cluster, percussive onset into thick overtone haze",
136
+ ),
137
+
138
+ "pulse": Material(
139
+ name="pulse",
140
+ base_freq=80,
141
+ partials=[
142
+ (1.0, 1.0), (1.008, 0.9), # ~0.64Hz beating = pulsing
143
+ (2.0, 0.55), (2.012, 0.5), # faster beat in octave
144
+ (3.0, 0.25),
145
+ ],
146
+ decay_rates=[2.5, 2.6, 4, 4.2, 7],
147
+ grain_duration=0.2,
148
+ attack_noise=0.38,
149
+ noise_freq=400,
150
+ detune_amount=8,
151
+ reverb_amount=0.5,
152
+ reverb_damping=0.65,
153
+ room_size=1.5,
154
+ pitch_drop=0.995,
155
+ attack_ms=0.8,
156
+ freq_spread=2.5,
157
+ volume=0.7,
158
+ octave_range=(-3, 0),
159
+ description="Thumpy rhythmic throb, hard attack into hypnotic beating",
160
+ ),
161
+
162
+ "glow": Material(
163
+ name="glow",
164
+ base_freq=145,
165
+ partials=[
166
+ (1.0, 1.0), (1.003, 0.88), # beating pair
167
+ (2.0, 0.6), (3.0, 0.35),
168
+ (4.0, 0.2), (5.0, 0.1),
169
+ ],
170
+ decay_rates=[4, 4.2, 5, 6.5, 8, 11],
171
+ grain_duration=0.13,
172
+ attack_noise=0.28,
173
+ noise_freq=700,
174
+ detune_amount=15,
175
+ reverb_amount=0.55,
176
+ reverb_damping=0.45,
177
+ room_size=1.7,
178
+ pitch_drop=0.998,
179
+ attack_ms=1.5,
180
+ freq_spread=4.0,
181
+ volume=0.65,
182
+ octave_range=(-3, 0),
183
+ description="Warm radiant harmonics, punchy onset with rich overtone bloom",
184
+ ),
185
+
186
+ "cloud": Material(
187
+ name="cloud",
188
+ base_freq=190,
189
+ partials=[
190
+ (1.0, 1.0), (1.002, 0.85),
191
+ (1.5, 0.4), (2.0, 0.25),
192
+ (2.5, 0.1),
193
+ ],
194
+ decay_rates=[5, 5.2, 7, 9, 13],
195
+ grain_duration=0.14,
196
+ attack_noise=0.3,
197
+ noise_freq=800,
198
+ detune_amount=16,
199
+ reverb_amount=0.75,
200
+ reverb_damping=0.4,
201
+ room_size=2.2,
202
+ pitch_drop=0.998,
203
+ attack_ms=1.2,
204
+ freq_spread=5.0,
205
+ volume=0.55,
206
+ octave_range=(-3, 0),
207
+ description="Percussive puff into massive diffuse reverb, textured",
208
+ ),
209
+
210
+ "murmur": Material(
211
+ name="murmur",
212
+ base_freq=70,
213
+ partials=[
214
+ (1.0, 1.0), (1.006, 0.88), # low beating
215
+ (2.0, 0.5), (3.0, 0.25),
216
+ (4.01, 0.1),
217
+ ],
218
+ decay_rates=[3, 3.2, 5, 8, 12],
219
+ grain_duration=0.17,
220
+ attack_noise=0.35,
221
+ noise_freq=350,
222
+ detune_amount=12,
223
+ reverb_amount=0.5,
224
+ reverb_damping=0.7,
225
+ room_size=1.4,
226
+ pitch_drop=0.994,
227
+ attack_ms=0.8,
228
+ freq_spread=3.0,
229
+ volume=0.7,
230
+ octave_range=(-3, 0),
231
+ description="Deep rumble thump, heavy attack into warm beating murmur",
232
+ ),
233
+
234
+ "shimmer": Material(
235
+ name="shimmer",
236
+ base_freq=260,
237
+ partials=[
238
+ (1.0, 1.0), (1.002, 0.9), # slow beating
239
+ (2.001, 0.55), (2.003, 0.52), # octave beating
240
+ (3.0, 0.25), (4.0, 0.1),
241
+ ],
242
+ decay_rates=[5, 5.1, 7, 7.2, 10, 14],
243
+ grain_duration=0.11,
244
+ attack_noise=0.25,
245
+ noise_freq=1200,
246
+ detune_amount=10,
247
+ reverb_amount=0.65,
248
+ reverb_damping=0.3,
249
+ room_size=2.0,
250
+ pitch_drop=0.999,
251
+ attack_ms=1.0,
252
+ freq_spread=4.0,
253
+ volume=0.55,
254
+ octave_range=(-3, 0),
255
+ description="Bright-ish tap into shimmering beating overtones",
256
+ ),
257
+
258
+ "deep": Material(
259
+ name="deep",
260
+ base_freq=50,
261
+ partials=[
262
+ (1.0, 1.0), (1.01, 0.85), # ~0.5Hz beating
263
+ (2.0, 0.55), (2.015, 0.5), # wider octave beat
264
+ (3.0, 0.2),
265
+ ],
266
+ decay_rates=[2, 2.2, 3.5, 3.8, 6],
267
+ grain_duration=0.22,
268
+ attack_noise=0.4,
269
+ noise_freq=250,
270
+ detune_amount=8,
271
+ reverb_amount=0.45,
272
+ reverb_damping=0.8,
273
+ room_size=1.3,
274
+ pitch_drop=0.992,
275
+ attack_ms=0.6,
276
+ freq_spread=2.5,
277
+ volume=0.75,
278
+ octave_range=(-3, 0),
279
+ description="Sub-bass thump, hard hit into very deep slow throb",
280
+ ),
281
+
282
+ # --- High register whispers (very quiet, above 3kHz) ---
283
+
284
+ "wisp": Material(
285
+ name="wisp",
286
+ base_freq=3200,
287
+ partials=[
288
+ (1.0, 1.0), (1.002, 0.9), # tight beating
289
+ (2.0, 0.2), (3.01, 0.05),
290
+ ],
291
+ decay_rates=[12, 12.5, 20, 30],
292
+ grain_duration=0.06,
293
+ attack_noise=0.15,
294
+ noise_freq=5000,
295
+ detune_amount=4,
296
+ reverb_amount=0.7,
297
+ reverb_damping=0.2,
298
+ room_size=2.0,
299
+ pitch_drop=1.0,
300
+ attack_ms=2.0,
301
+ freq_spread=2.0,
302
+ volume=0.18,
303
+ octave_range=(-1, 1),
304
+ description="Faint crystalline wisp, barely there",
305
+ ),
306
+
307
+ "glint": Material(
308
+ name="glint",
309
+ base_freq=3800,
310
+ partials=[
311
+ (1.0, 1.0), (1.5, 0.4), (2.01, 0.15),
312
+ ],
313
+ decay_rates=[18, 25, 35],
314
+ grain_duration=0.04,
315
+ attack_noise=0.25,
316
+ noise_freq=6000,
317
+ detune_amount=6,
318
+ reverb_amount=0.65,
319
+ reverb_damping=0.15,
320
+ room_size=1.8,
321
+ pitch_drop=0.998,
322
+ attack_ms=0.8,
323
+ freq_spread=3.0,
324
+ volume=0.15,
325
+ octave_range=(-1, 0),
326
+ description="Tiny metallic glint, quiet and sharp",
327
+ ),
328
+
329
+ "arc": Material(
330
+ name="arc",
331
+ base_freq=4200,
332
+ partials=[
333
+ (1.0, 1.0), (1.003, 0.85), # slow beating at high freq
334
+ (1.5, 0.3), (2.0, 0.1),
335
+ ],
336
+ decay_rates=[10, 10.5, 16, 24],
337
+ grain_duration=0.07,
338
+ attack_noise=0.1,
339
+ noise_freq=7000,
340
+ detune_amount=3,
341
+ reverb_amount=0.75,
342
+ reverb_damping=0.1,
343
+ room_size=2.2,
344
+ pitch_drop=1.0,
345
+ attack_ms=3.0,
346
+ freq_spread=1.5,
347
+ volume=0.14,
348
+ octave_range=(-1, 0),
349
+ description="High glass arc, delicate beating shimmer",
350
+ ),
351
+
352
+ "spark": Material(
353
+ name="spark",
354
+ base_freq=5000,
355
+ partials=[
356
+ (1.0, 1.0), (1.41, 0.35), (2.0, 0.08),
357
+ ],
358
+ decay_rates=[22, 30, 45],
359
+ grain_duration=0.03,
360
+ attack_noise=0.3,
361
+ noise_freq=8000,
362
+ detune_amount=8,
363
+ reverb_amount=0.6,
364
+ reverb_damping=0.15,
365
+ room_size=1.6,
366
+ pitch_drop=0.996,
367
+ attack_ms=0.5,
368
+ freq_spread=4.0,
369
+ volume=0.12,
370
+ octave_range=(-1, 0),
371
+ description="Tiny electric spark, fast and faint",
372
+ ),
373
+
374
+ "dust": Material(
375
+ name="dust",
376
+ base_freq=3500,
377
+ partials=[
378
+ (1.0, 1.0), (1.001, 0.95), # very tight beating
379
+ (2.003, 0.3), (2.006, 0.28), # octave beating pair
380
+ (3.0, 0.08),
381
+ ],
382
+ decay_rates=[8, 8.2, 14, 14.5, 22],
383
+ grain_duration=0.08,
384
+ attack_noise=0.12,
385
+ noise_freq=5500,
386
+ detune_amount=5,
387
+ reverb_amount=0.8,
388
+ reverb_damping=0.1,
389
+ room_size=2.4,
390
+ pitch_drop=1.0,
391
+ attack_ms=4.0,
392
+ freq_spread=2.0,
393
+ volume=0.16,
394
+ octave_range=(-1, 0),
395
+ description="Soft high dust motes, floating reverb texture",
396
+ ),
397
+ }
398
+
399
+
400
+ # --- Material sound set (percussive, physical) ---
401
+ # The original set: crisp, percussive sounds modelled on physical materials.
402
+
403
+ MATERIAL_MATERIALS = {
36
404
  "ice": Material(
37
405
  name="ice",
38
406
  base_freq=2800,
@@ -49,6 +417,7 @@ MATERIALS = {
49
417
  attack_ms=0.5,
50
418
  freq_spread=4.0,
51
419
  volume=0.85,
420
+ octave_range=(-3, 3),
52
421
  description="Brittle, very high, fast decay with pitch drop",
53
422
  ),
54
423
 
@@ -68,6 +437,7 @@ MATERIALS = {
68
437
  attack_ms=0.8,
69
438
  freq_spread=3.0,
70
439
  volume=0.9,
440
+ octave_range=(-3, 3),
71
441
  description="Classic wine glass ping",
72
442
  ),
73
443
 
@@ -87,6 +457,7 @@ MATERIALS = {
87
457
  attack_ms=1.0,
88
458
  freq_spread=2.5,
89
459
  volume=0.85,
460
+ octave_range=(-3, 3),
90
461
  description="Pure lead crystal with beating from close partial pairs",
91
462
  ),
92
463
 
@@ -106,6 +477,7 @@ MATERIALS = {
106
477
  attack_ms=1.2,
107
478
  freq_spread=3.5,
108
479
  volume=1.0,
480
+ octave_range=(-3, 3),
109
481
  description="Duller muted earthenware tap",
110
482
  ),
111
483
 
@@ -128,6 +500,7 @@ MATERIALS = {
128
500
  attack_ms=0.3,
129
501
  freq_spread=2.5,
130
502
  volume=0.8,
503
+ octave_range=(-3, 3),
131
504
  description="Small metallic bell, classic ratios, long ring",
132
505
  ),
133
506
 
@@ -147,6 +520,7 @@ MATERIALS = {
147
520
  attack_ms=0.3,
148
521
  freq_spread=4.0,
149
522
  volume=0.8,
523
+ octave_range=(-3, 3),
150
524
  description="Water droplet, pitch bend down, liquid",
151
525
  ),
152
526
 
@@ -166,6 +540,7 @@ MATERIALS = {
166
540
  attack_ms=0.2,
167
541
  freq_spread=6.0,
168
542
  volume=0.95,
543
+ octave_range=(-3, 3),
169
544
  description="Sharp mechanical click, keyboard-like",
170
545
  ),
171
546
 
@@ -190,6 +565,7 @@ MATERIALS = {
190
565
  attack_ms=0.6,
191
566
  freq_spread=4.0,
192
567
  volume=1.0,
568
+ octave_range=(-3, 3),
193
569
  description="Hollow wooden tap, warm marimba-like resonance",
194
570
  ),
195
571
 
@@ -212,6 +588,7 @@ MATERIALS = {
212
588
  attack_ms=0.4,
213
589
  freq_spread=5.0,
214
590
  volume=1.0,
591
+ octave_range=(-3, 3),
215
592
  description="Dense slate tap, heavy and earthy",
216
593
  ),
217
594
 
@@ -233,6 +610,7 @@ MATERIALS = {
233
610
  attack_ms=1.0,
234
611
  freq_spread=3.5,
235
612
  volume=0.85,
613
+ octave_range=(-3, 3),
236
614
  description="Hollow tube resonance, odd harmonics, breathy and airy",
237
615
  ),
238
616
 
@@ -254,6 +632,7 @@ MATERIALS = {
254
632
  attack_ms=0.3,
255
633
  freq_spread=8.0,
256
634
  volume=0.9,
635
+ octave_range=(-3, 3),
257
636
  description="Warm crackling ember, fire-like with wide pitch scatter",
258
637
  ),
259
638
 
@@ -273,6 +652,7 @@ MATERIALS = {
273
652
  attack_ms=2.5,
274
653
  freq_spread=5.0,
275
654
  volume=0.6,
655
+ octave_range=(-3, 3),
276
656
  description="Soft breathy whisper, delicate airy texture",
277
657
  ),
278
658
 
@@ -295,6 +675,7 @@ MATERIALS = {
295
675
  attack_ms=1.5,
296
676
  freq_spread=2.0,
297
677
  volume=0.75,
678
+ octave_range=(-3, 3),
298
679
  description="Swirly ocean interference, dense phase beating",
299
680
  ),
300
681
 
@@ -314,23 +695,47 @@ MATERIALS = {
314
695
  attack_ms=3.0,
315
696
  freq_spread=4.0,
316
697
  volume=0.7,
698
+ octave_range=(-3, 3),
317
699
  description="Ultra-soft muffled earth, mossy dampness",
318
700
  ),
319
701
  }
320
702
 
321
703
 
322
- def get_material(name: str) -> Material:
323
- """Get a material by name."""
324
- if name not in MATERIALS:
325
- raise ValueError(f"Unknown material: {name}. Available: {list(MATERIALS.keys())}")
326
- return MATERIALS[name]
704
+ SOUND_SETS = {
705
+ "ambient": AMBIENT_MATERIALS,
706
+ "material": MATERIAL_MATERIALS,
707
+ }
708
+
709
+ # Default MATERIALS points to the default set for backwards compat
710
+ MATERIALS = SOUND_SETS[DEFAULT_SOUND_SET]
711
+
712
+
713
+ def list_sound_sets() -> List[str]:
714
+ """List all available sound set names."""
715
+ return list(SOUND_SETS.keys())
716
+
717
+
718
+ def get_sound_set(name: str) -> dict:
719
+ """Get a sound set by name."""
720
+ if name not in SOUND_SETS:
721
+ raise ValueError(f"Unknown sound set: {name}. Available: {list(SOUND_SETS.keys())}")
722
+ return SOUND_SETS[name]
723
+
724
+
725
+ def get_material(name: str, sound_set: str = DEFAULT_SOUND_SET) -> Material:
726
+ """Get a material by name from a given sound set."""
727
+ materials = get_sound_set(sound_set)
728
+ if name not in materials:
729
+ raise ValueError(f"Unknown character: {name}. Available: {list(materials.keys())}")
730
+ return materials[name]
327
731
 
328
732
 
329
- def get_random_material() -> Material:
330
- """Get a random material."""
331
- return random.choice(list(MATERIALS.values()))
733
+ def get_random_material(sound_set: str = DEFAULT_SOUND_SET) -> Material:
734
+ """Get a random material from a given sound set."""
735
+ materials = get_sound_set(sound_set)
736
+ return random.choice(list(materials.values()))
332
737
 
333
738
 
334
- def list_materials() -> List[str]:
335
- """List all available material names."""
336
- return list(MATERIALS.keys())
739
+ def list_materials(sound_set: str = DEFAULT_SOUND_SET) -> List[str]:
740
+ """List all available material names in a sound set."""
741
+ return list(get_sound_set(sound_set).keys())
claudible/monitor.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """I/O activity tracking and event detection."""
2
2
 
3
3
  import time
4
- from typing import Callable, Optional
4
+ from typing import Callable, Optional, Union
5
5
  import threading
6
6
 
7
7
 
@@ -17,7 +17,7 @@ class ActivityMonitor:
17
17
 
18
18
  def __init__(
19
19
  self,
20
- on_grain: Callable[[], None],
20
+ on_grain: Callable[[str], None],
21
21
  on_chime: Callable[[], None],
22
22
  on_attention: Callable[[], None],
23
23
  attention_seconds: float = 30.0,
@@ -68,7 +68,7 @@ class ActivityMonitor:
68
68
  if not self._reverse_playing:
69
69
  self._reverse_playing = True
70
70
  self.on_chime()
71
- self.on_grain()
71
+ self.on_grain("")
72
72
  time.sleep(reverse_interval)
73
73
  else:
74
74
  if self._reverse_playing:
@@ -109,11 +109,11 @@ class ActivityMonitor:
109
109
  self._consecutive_newlines = 0
110
110
  else:
111
111
  self._consecutive_newlines = 0
112
- self._maybe_trigger_grain()
112
+ self._maybe_trigger_grain(char)
113
113
 
114
- def _maybe_trigger_grain(self):
114
+ def _maybe_trigger_grain(self, char: str = ""):
115
115
  """Trigger a grain if enough time has passed (throttling)."""
116
116
  now = time.time()
117
117
  if now - self._last_grain_time >= self._min_grain_interval:
118
118
  self._last_grain_time = now
119
- self.on_grain()
119
+ self.on_grain(char)
@@ -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
@@ -0,0 +1,11 @@
1
+ claudible/__init__.py,sha256=xSLL4yfc1p79BWVb65PJBTFMxyJ2PWuu7pCLYcCuOVM,96
2
+ claudible/__main__.py,sha256=5mFnPjtOjk5Hw-mvUhRYtwXVnXFR7ZbXKC27VIXu_dI,106
3
+ claudible/audio.py,sha256=8lHgp74Igh6Hs4HAbTZIRos-LVA0HCxJ71cTlyAlmFk,15945
4
+ claudible/cli.py,sha256=gLGaNDFVf4Bfr9UAH2yZUPeTUVxZ-5OR9DNjEwTRNsI,6549
5
+ claudible/materials.py,sha256=M49vT0oL63Iv1e8qxa2bsz3G6sQgPXwsJ3kAHMggX7g,21039
6
+ claudible/monitor.py,sha256=KAnb0rmnuTDanD7pgls50Oxd3-TlhXv2tUDjX8RkHIg,4106
7
+ claudible-0.1.4.dist-info/METADATA,sha256=qHEjYY2MlrMjnC7aa4oTij0k0N4PpMvNw0Zmvku6MUc,6726
8
+ claudible-0.1.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ claudible-0.1.4.dist-info/entry_points.txt,sha256=4tBIsIGcdtJ-1N9YZ3MrADyNvPBDcUxVgAnt9IuC7TA,49
10
+ claudible-0.1.4.dist-info/top_level.txt,sha256=4ZB16Aa5ZDS12bmxzrQmAJejjsTgQ9Yozwc0Z3qpJp4,10
11
+ claudible-0.1.4.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- claudible/__init__.py,sha256=xSLL4yfc1p79BWVb65PJBTFMxyJ2PWuu7pCLYcCuOVM,96
2
- claudible/__main__.py,sha256=5mFnPjtOjk5Hw-mvUhRYtwXVnXFR7ZbXKC27VIXu_dI,106
3
- claudible/audio.py,sha256=6Do7_g5SUfJkMGSH_c_XGG4YiC2uakhaDTy8xYAp7rE,12043
4
- claudible/cli.py,sha256=j14ZnHT7L_efA67U-3hLSqUsgf2rvlMYgs1b6w3X2pU,4803
5
- claudible/materials.py,sha256=6OsobcWOFvLBCuvzkeWi9C7fYHFnW9RED6nEElK-Ki4,9517
6
- claudible/monitor.py,sha256=iPCaqdO-CDp1CoIie2VtddUUjG1A_xucbFJ1DPk3MKk,4070
7
- claudible-0.1.2.dist-info/METADATA,sha256=b38veaujlcjfAzO8G7EaKK5eXWVyb7Qqf5Cojg1nKMw,5095
8
- claudible-0.1.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
- claudible-0.1.2.dist-info/entry_points.txt,sha256=4tBIsIGcdtJ-1N9YZ3MrADyNvPBDcUxVgAnt9IuC7TA,49
10
- claudible-0.1.2.dist-info/top_level.txt,sha256=4ZB16Aa5ZDS12bmxzrQmAJejjsTgQ9Yozwc0Z3qpJp4,10
11
- claudible-0.1.2.dist-info/RECORD,,