claudible 0.1.2__tar.gz → 0.1.3__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claudible
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Ambient audio soundscape feedback for terminal output - an opus for your terminals
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/anthropics/claudible
@@ -81,6 +81,9 @@ claudible --volume 0.3
81
81
 
82
82
  # List available characters
83
83
  claudible --list-characters
84
+
85
+ # Demo all sound characters
86
+ claudible --demo
84
87
  ```
85
88
 
86
89
  ### 🔇 Reverse Mode
@@ -139,6 +142,7 @@ tail -f /var/log/app.log | claudible --pipe -c droplet
139
142
  | `--attention`, `-a` | ⏰ Silence alert seconds (default: 30) |
140
143
  | `--reverse`, `-r` | 🔄 Reverse mode: sound during silence, quiet during output |
141
144
  | `--list-characters` | 📋 Show presets |
145
+ | `--demo` | 🔊 Play a short demo of each sound character |
142
146
 
143
147
  ## 🛠️ Development
144
148
 
@@ -158,6 +162,9 @@ echo "test" | PYTHONPATH=src python3 -m claudible --pipe -c glass
158
162
 
159
163
  # List characters
160
164
  PYTHONPATH=src python3 -m claudible --list-characters
165
+
166
+ # Demo all characters
167
+ PYTHONPATH=src python3 -m claudible --demo
161
168
  ```
162
169
 
163
170
  ## 📜 License
@@ -56,6 +56,9 @@ claudible --volume 0.3
56
56
 
57
57
  # List available characters
58
58
  claudible --list-characters
59
+
60
+ # Demo all sound characters
61
+ claudible --demo
59
62
  ```
60
63
 
61
64
  ### 🔇 Reverse Mode
@@ -114,6 +117,7 @@ tail -f /var/log/app.log | claudible --pipe -c droplet
114
117
  | `--attention`, `-a` | ⏰ Silence alert seconds (default: 30) |
115
118
  | `--reverse`, `-r` | 🔄 Reverse mode: sound during silence, quiet during output |
116
119
  | `--list-characters` | 📋 Show presets |
120
+ | `--demo` | 🔊 Play a short demo of each sound character |
117
121
 
118
122
  ## 🛠️ Development
119
123
 
@@ -133,6 +137,9 @@ echo "test" | PYTHONPATH=src python3 -m claudible --pipe -c glass
133
137
 
134
138
  # List characters
135
139
  PYTHONPATH=src python3 -m claudible --list-characters
140
+
141
+ # Demo all characters
142
+ PYTHONPATH=src python3 -m claudible --demo
136
143
  ```
137
144
 
138
145
  ## 📜 License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "claudible"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Ambient audio soundscape feedback for terminal output - an opus for your terminals"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -33,8 +33,8 @@ class SoundEngine:
33
33
  self._write_pos = 0
34
34
  self._read_pos = 0
35
35
 
36
- # Pre-generate grain variations for efficiency
37
- self._grain_cache = [self._generate_grain() for _ in range(8)]
36
+ # Pre-generate grains spread across registers for tonal variety
37
+ self._grain_cache = self._build_grain_cache()
38
38
  self._cache_index = 0
39
39
 
40
40
  def start(self):
@@ -141,77 +141,147 @@ class SoundEngine:
141
141
 
142
142
  # --- Sound generation ---
143
143
 
144
- def _generate_grain(self) -> np.ndarray:
144
+ def _build_grain_cache(self) -> list:
145
+ """Build grain cache with voices at full-octave intervals, biased low + high."""
146
+ # Each register is a full octave apart.
147
+ # (octave_shift, noise_mult, decay_mult, duration_mult)
148
+ # Multiple character variations per register.
149
+ voices = [
150
+ # --- oct -3: sub rumble (3 voices) ---
151
+ (-3, 3.5, 2.5, 1.8), # sub thump
152
+ (-3, 2.0, 3.0, 2.0), # sub knock
153
+ (-3, 2.8, 2.0, 1.6), # sub punch
154
+
155
+ # --- oct -2: deep (3 voices) ---
156
+ (-2, 2.8, 1.8, 1.5), # deep percussive
157
+ (-2, 1.2, 2.0, 1.4), # deep tonal
158
+ (-2, 2.0, 1.5, 1.3), # deep knock
159
+
160
+ # --- oct -1: low (3 voices) ---
161
+ (-1, 1.8, 1.4, 1.2), # low thump
162
+ (-1, 0.8, 1.2, 1.1), # low warm
163
+ (-1, 1.4, 1.6, 1.2), # low snap
164
+
165
+ # --- oct 0: mid (2 voices) ---
166
+ ( 0, 1.0, 1.0, 1.0), # mid click
167
+ ( 0, 0.6, 0.8, 0.9), # mid soft
168
+
169
+ # --- oct +1: high (3 voices) ---
170
+ ( 1, 0.5, 0.6, 0.7), # high ping
171
+ ( 1, 0.8, 0.5, 0.7), # high tick
172
+ ( 1, 0.3, 0.7, 0.8), # high ring
173
+
174
+ # --- oct +2: bright (3 voices) ---
175
+ ( 2, 0.2, 0.4, 0.55), # bright ting
176
+ ( 2, 0.5, 0.3, 0.6), # bright click
177
+ ( 2, 0.15, 0.5, 0.5), # bright shimmer
178
+
179
+ # --- oct +3: air (3 voices) ---
180
+ ( 3, 0.1, 0.25, 0.4), # air sparkle
181
+ ( 3, 0.3, 0.2, 0.45), # air tick
182
+ ( 3, 0.05, 0.3, 0.35), # air wisp
183
+ ]
184
+ cache = []
185
+ for octave, noise_m, decay_m, dur_m in voices:
186
+ cache.append(self._generate_grain(
187
+ octave_shift=octave,
188
+ noise_mult=noise_m,
189
+ decay_mult=decay_m,
190
+ duration_mult=dur_m,
191
+ ))
192
+ return cache
193
+
194
+ def _generate_grain(self, octave_shift: float = 0.0, noise_mult: float = 1.0,
195
+ decay_mult: float = 1.0, duration_mult: float = 1.0) -> np.ndarray:
145
196
  """Generate a single grain sound from the material's physical model."""
146
197
  m = self.material
147
- # Vary duration per grain
148
- duration = m.grain_duration * np.random.uniform(0.85, 1.15)
198
+ duration = m.grain_duration * duration_mult * 0.5 * np.random.uniform(0.85, 1.15)
149
199
  n = int(duration * self.SAMPLE_RATE)
150
200
  t = np.linspace(0, duration, n, dtype=np.float32)
151
201
 
152
202
  grain = np.zeros(n, dtype=np.float32)
153
203
 
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))
204
+ # Base frequency shifted by register
205
+ base_freq = m.base_freq * (2 ** octave_shift)
206
+ base_freq *= (2 ** (np.random.uniform(-m.freq_spread, m.freq_spread) / 12))
156
207
 
157
- # Generate each partial with its own decay, detuning, and random phase
158
208
  for i, (ratio, amp) in enumerate(m.partials):
159
209
  detune_cents = np.random.uniform(-m.detune_amount, m.detune_amount)
160
210
  freq = base_freq * ratio * (2 ** (detune_cents / 1200))
161
- # Extra micro-variation per partial
162
211
  freq *= np.random.uniform(0.997, 1.003)
163
212
  phase = np.random.uniform(0, 2 * np.pi)
164
213
 
165
214
  decay_rate = m.decay_rates[i] if i < len(m.decay_rates) else m.decay_rates[-1]
215
+ decay_rate *= decay_mult
166
216
  envelope = np.exp(-decay_rate * t)
167
217
 
168
218
  grain += amp * envelope * np.sin(2 * np.pi * freq * t + phase)
169
219
 
170
- # Noise transient (the "tick" of physical impact)
220
+ # Noise transient louder for low percussive grains
171
221
  if m.attack_noise > 0:
172
222
  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)
223
+ noise_cutoff = max(m.noise_freq * (0.5 if octave_shift < -0.5 else 1.0), 200)
224
+ noise = self._highpass(noise, noise_cutoff)
225
+ noise_env = np.exp(-150 * t / max(decay_mult, 0.5))
176
226
  noise_env *= np.clip(
177
227
  1.0 + 0.3 * np.random.randn(n).astype(np.float32) * np.exp(-200 * t),
178
228
  0, 1,
179
229
  )
180
- grain += m.attack_noise * 0.3 * noise * noise_env
230
+ grain += m.attack_noise * noise_mult * 0.3 * noise * noise_env
181
231
 
182
232
  # Attack shaping
183
233
  attack_samples = max(int(m.attack_ms / 1000 * self.SAMPLE_RATE), 2)
184
234
  if attack_samples < n:
185
235
  grain[:attack_samples] *= np.linspace(0, 1, attack_samples, dtype=np.float32)
186
236
 
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)
237
+ # Pitch drop more dramatic for low grains
238
+ pitch_drop = m.pitch_drop if octave_shift >= 0 else m.pitch_drop ** (1.0 + abs(octave_shift) * 0.3)
239
+ if pitch_drop != 1.0:
240
+ pitch_env = np.linspace(1.0, pitch_drop, n)
190
241
  phase_mod = np.cumsum(pitch_env) / np.sum(pitch_env) * n
191
242
  indices = np.clip(phase_mod.astype(int), 0, n - 1)
192
243
  grain = grain[indices]
193
244
 
194
- # Multi-tap reverb with HF damping
195
- grain = self._apply_reverb(grain, m.reverb_amount, m.room_size, m.reverb_damping)
245
+ # Reverb less for percussive lows (tighter), more for highs
246
+ reverb_amt = m.reverb_amount * (0.6 if octave_shift < -0.5 else 1.0 + max(octave_shift, 0) * 0.2)
247
+ grain = self._apply_reverb(grain, reverb_amt, m.room_size, m.reverb_damping)
196
248
 
197
- # Truncate reverb tail back to grain length
198
- grain = grain[:n]
249
+ # Small pre-delay + extra tail reverb
250
+ delay_ms = 15
251
+ delay_samples = int(delay_ms * self.SAMPLE_RATE / 1000)
252
+ tail_len = n + delay_samples + int(0.08 * self.SAMPLE_RATE)
253
+ out = np.zeros(tail_len, dtype=np.float32)
254
+ out[delay_samples:delay_samples + len(grain)] = grain
255
+ # Light tail reverb on top
256
+ out = self._apply_reverb(out, 0.15, room_size=1.2, damping=0.4)
199
257
 
200
- # Normalise with per-material volume
201
- peak = np.max(np.abs(grain))
258
+ peak = np.max(np.abs(out))
202
259
  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))
260
+ out = out / peak * 0.4 * m.volume
261
+
262
+ return out.astype(np.float32)
263
+
264
+ @staticmethod
265
+ def _token_hash(token: str) -> int:
266
+ """Deterministic hash from token content, stable across sessions."""
267
+ h = 5381
268
+ for c in token:
269
+ h = ((h * 33) ^ ord(c)) & 0xFFFFFFFF
270
+ return h
271
+
272
+ def play_grain(self, token: str = ""):
273
+ """Play a grain/sparkle sound, deterministic for a given token."""
274
+ if token:
275
+ h = self._token_hash(token)
276
+ rng = np.random.RandomState(h)
277
+ else:
278
+ rng = np.random
279
+
280
+ idx = rng.randint(len(self._grain_cache))
211
281
  grain = self._grain_cache[idx].copy()
212
282
 
213
- # Per-play pitch variation via resampling 4 semitones)
214
- pitch_shift = 2 ** (np.random.uniform(-4, 4) / 12)
283
+ # Narrow pitch micro-variation (±1.5 semitones) — stays in its register
284
+ pitch_shift = 2 ** (rng.uniform(-1.5, 1.5) / 12)
215
285
  if abs(pitch_shift - 1.0) > 0.001:
216
286
  new_len = int(len(grain) / pitch_shift)
217
287
  if new_len > 0:
@@ -221,12 +291,7 @@ class SoundEngine:
221
291
  grain,
222
292
  ).astype(np.float32)
223
293
 
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()
294
+ grain *= rng.uniform(0.7, 1.0)
230
295
 
231
296
  self._add_to_buffer(grain)
232
297
 
@@ -14,6 +14,24 @@ from .materials import get_material, get_random_material, list_materials, MATERI
14
14
  from .monitor import ActivityMonitor
15
15
 
16
16
 
17
+ def run_demo(volume: float):
18
+ """Play a short demo of every sound character."""
19
+ print("Claudible character demo\n", file=sys.stderr)
20
+ for name, mat in MATERIALS.items():
21
+ print(f" {name:10} - {mat.description}", file=sys.stderr)
22
+ engine = SoundEngine(material=mat, volume=volume)
23
+ engine.start()
24
+ # Play a burst of grains then a chime
25
+ for i in range(8):
26
+ engine.play_grain(chr(ord('a') + i))
27
+ time.sleep(0.06)
28
+ time.sleep(0.15)
29
+ engine.play_chime()
30
+ time.sleep(0.6)
31
+ engine.stop()
32
+ print("\nDone.", file=sys.stderr)
33
+
34
+
17
35
  def run_pipe_mode(engine: SoundEngine, monitor: ActivityMonitor):
18
36
  """Run in pipe mode, reading from stdin."""
19
37
  engine.start()
@@ -138,6 +156,11 @@ def main():
138
156
  action='store_true',
139
157
  help='List available sound characters',
140
158
  )
159
+ parser.add_argument(
160
+ '--demo',
161
+ action='store_true',
162
+ help='Play a short demo of each sound character',
163
+ )
141
164
 
142
165
  args = parser.parse_args()
143
166
 
@@ -147,6 +170,11 @@ def main():
147
170
  print(f" {name:10} - {mat.description}")
148
171
  return
149
172
 
173
+ if args.demo:
174
+ volume = max(0.0, min(1.0, args.volume))
175
+ run_demo(volume)
176
+ return
177
+
150
178
  if args.character:
151
179
  material = get_material(args.character)
152
180
  else:
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claudible
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Ambient audio soundscape feedback for terminal output - an opus for your terminals
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/anthropics/claudible
@@ -81,6 +81,9 @@ claudible --volume 0.3
81
81
 
82
82
  # List available characters
83
83
  claudible --list-characters
84
+
85
+ # Demo all sound characters
86
+ claudible --demo
84
87
  ```
85
88
 
86
89
  ### 🔇 Reverse Mode
@@ -139,6 +142,7 @@ tail -f /var/log/app.log | claudible --pipe -c droplet
139
142
  | `--attention`, `-a` | ⏰ Silence alert seconds (default: 30) |
140
143
  | `--reverse`, `-r` | 🔄 Reverse mode: sound during silence, quiet during output |
141
144
  | `--list-characters` | 📋 Show presets |
145
+ | `--demo` | 🔊 Play a short demo of each sound character |
142
146
 
143
147
  ## 🛠️ Development
144
148
 
@@ -158,6 +162,9 @@ echo "test" | PYTHONPATH=src python3 -m claudible --pipe -c glass
158
162
 
159
163
  # List characters
160
164
  PYTHONPATH=src python3 -m claudible --list-characters
165
+
166
+ # Demo all characters
167
+ PYTHONPATH=src python3 -m claudible --demo
161
168
  ```
162
169
 
163
170
  ## 📜 License
File without changes