claudible 0.1.0__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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Claudible - ambient audio soundscape feedback for terminal output."""
2
+
3
+ __version__ = "0.1.0"
claudible/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m claudible."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
claudible/audio.py ADDED
@@ -0,0 +1,309 @@
1
+ """Sound engine for procedural audio generation."""
2
+
3
+ import numpy as np
4
+ import sounddevice as sd
5
+ from typing import Optional
6
+ import threading
7
+
8
+ try:
9
+ from scipy.signal import butter, lfilter
10
+ _HAS_SCIPY = True
11
+ except ImportError:
12
+ _HAS_SCIPY = False
13
+
14
+ from .materials import Material, get_random_material
15
+
16
+
17
+ class SoundEngine:
18
+ """Generates and plays procedural sounds based on physical material models."""
19
+
20
+ SAMPLE_RATE = 44100
21
+ BUFFER_SIZE = 44100 * 2 # 2 seconds of audio buffer
22
+
23
+ def __init__(self, material: Optional[Material] = None, volume: float = 0.5):
24
+ self.material = material or get_random_material()
25
+ self.volume = volume
26
+ self._stream: Optional[sd.OutputStream] = None
27
+ self._running = False
28
+ self._hum_phase = 0.0
29
+ self._lock = threading.Lock()
30
+
31
+ # Ring buffer for mixing audio
32
+ self._buffer = np.zeros(self.BUFFER_SIZE, dtype=np.float32)
33
+ self._write_pos = 0
34
+ self._read_pos = 0
35
+
36
+ # Pre-generate grain variations for efficiency
37
+ self._grain_cache = [self._generate_grain() for _ in range(8)]
38
+ self._cache_index = 0
39
+
40
+ def start(self):
41
+ """Start the audio output stream."""
42
+ self._running = True
43
+ self._stream = sd.OutputStream(
44
+ samplerate=self.SAMPLE_RATE,
45
+ channels=1,
46
+ callback=self._audio_callback,
47
+ blocksize=1024,
48
+ )
49
+ self._stream.start()
50
+
51
+ def stop(self):
52
+ """Stop the audio output stream."""
53
+ self._running = False
54
+ if self._stream:
55
+ self._stream.stop()
56
+ self._stream.close()
57
+ self._stream = None
58
+
59
+ # --- DSP utilities ---
60
+
61
+ def _highpass(self, data: np.ndarray, cutoff: float) -> np.ndarray:
62
+ """High-pass filter. Uses scipy if available, else differentiation."""
63
+ if _HAS_SCIPY:
64
+ nyq = self.SAMPLE_RATE / 2
65
+ norm = min(cutoff / nyq, 0.99)
66
+ if norm <= 0:
67
+ return data
68
+ b, a = butter(2, norm, btype='high')
69
+ return lfilter(b, a, data).astype(np.float32)
70
+ # Fallback: repeated differentiation
71
+ out = np.diff(data, prepend=data[0])
72
+ return np.diff(out, prepend=out[0]).astype(np.float32)
73
+
74
+ def _lowpass(self, data: np.ndarray, cutoff: float) -> np.ndarray:
75
+ """Low-pass filter. Uses scipy if available, else moving average."""
76
+ if _HAS_SCIPY:
77
+ nyq = self.SAMPLE_RATE / 2
78
+ norm = min(cutoff / nyq, 0.99)
79
+ if norm <= 0:
80
+ return np.zeros_like(data)
81
+ b, a = butter(2, norm, btype='low')
82
+ return lfilter(b, a, data).astype(np.float32)
83
+ # Fallback: simple moving average
84
+ n = max(int(self.SAMPLE_RATE / cutoff / 2), 1)
85
+ kernel = np.ones(n, dtype=np.float32) / n
86
+ return np.convolve(data, kernel, mode='same').astype(np.float32)
87
+
88
+ def _apply_reverb(self, signal: np.ndarray, amount: float,
89
+ room_size: float = 1.0, damping: float = 0.3) -> np.ndarray:
90
+ """Multi-tap reverb with high-frequency damping on later reflections."""
91
+ if amount <= 0:
92
+ return signal
93
+ base_delays_ms = [23, 37, 53, 79, 113, 149]
94
+ out = signal.copy()
95
+ for i, d in enumerate(base_delays_ms):
96
+ delay_samples = int(d * room_size * self.SAMPLE_RATE / 1000)
97
+ if delay_samples >= len(signal) or delay_samples <= 0:
98
+ continue
99
+ tap = np.zeros_like(signal)
100
+ tap[delay_samples:] = signal[:-delay_samples]
101
+ # Damp high frequencies in later reflections
102
+ if i > 1 and damping > 0:
103
+ cutoff = max(4000 - i * 500 * damping, 500)
104
+ tap = self._lowpass(tap, cutoff)
105
+ out += tap * amount * (0.55 ** i)
106
+ return out / max(1.0 + amount * 1.5, 1.0)
107
+
108
+ # --- Audio callback ---
109
+
110
+ def _audio_callback(self, outdata, frames, time, status):
111
+ """Audio stream callback - mixes queued sounds with background hum."""
112
+ output = np.zeros(frames, dtype=np.float32)
113
+
114
+ # Background hum: multiple detuned oscillators with slow wobble
115
+ t = np.arange(frames) / self.SAMPLE_RATE
116
+ phase_inc = 2 * np.pi * 55.0 / self.SAMPLE_RATE * frames
117
+ wobble = 0.002 * np.sin(2 * np.pi * 0.08 * t + self._hum_phase * 0.01)
118
+
119
+ for freq, amp in [(55.0, 0.007), (55.15, 0.006), (54.85, 0.006), (110.05, 0.001)]:
120
+ output += amp * np.sin(
121
+ 2 * np.pi * freq * (1 + wobble) * t + self._hum_phase * (freq / 55.0)
122
+ ).astype(np.float32)
123
+
124
+ self._hum_phase = (self._hum_phase + phase_inc) % (2 * np.pi)
125
+
126
+ # Read from ring buffer
127
+ with self._lock:
128
+ for i in range(frames):
129
+ output[i] += self._buffer[self._read_pos]
130
+ self._buffer[self._read_pos] = 0.0
131
+ self._read_pos = (self._read_pos + 1) % self.BUFFER_SIZE
132
+
133
+ outdata[:] = (output * self.volume).reshape(-1, 1)
134
+
135
+ def _add_to_buffer(self, samples: np.ndarray):
136
+ """Add samples to the ring buffer (mixes with existing)."""
137
+ with self._lock:
138
+ for i, sample in enumerate(samples):
139
+ pos = (self._read_pos + i) % self.BUFFER_SIZE
140
+ self._buffer[pos] += sample
141
+
142
+ # --- Sound generation ---
143
+
144
+ def _generate_grain(self) -> np.ndarray:
145
+ """Generate a single grain sound from the material's physical model."""
146
+ m = self.material
147
+ # Vary duration per grain
148
+ duration = m.grain_duration * np.random.uniform(0.85, 1.15)
149
+ n = int(duration * self.SAMPLE_RATE)
150
+ t = np.linspace(0, duration, n, dtype=np.float32)
151
+
152
+ grain = np.zeros(n, dtype=np.float32)
153
+
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))
156
+
157
+ # Generate each partial with its own decay, detuning, and random phase
158
+ for i, (ratio, amp) in enumerate(m.partials):
159
+ detune_cents = np.random.uniform(-m.detune_amount, m.detune_amount)
160
+ freq = base_freq * ratio * (2 ** (detune_cents / 1200))
161
+ # Extra micro-variation per partial
162
+ freq *= np.random.uniform(0.997, 1.003)
163
+ phase = np.random.uniform(0, 2 * np.pi)
164
+
165
+ decay_rate = m.decay_rates[i] if i < len(m.decay_rates) else m.decay_rates[-1]
166
+ envelope = np.exp(-decay_rate * t)
167
+
168
+ grain += amp * envelope * np.sin(2 * np.pi * freq * t + phase)
169
+
170
+ # Noise transient (the "tick" of physical impact)
171
+ if m.attack_noise > 0:
172
+ 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)
176
+ noise_env *= np.clip(
177
+ 1.0 + 0.3 * np.random.randn(n).astype(np.float32) * np.exp(-200 * t),
178
+ 0, 1,
179
+ )
180
+ grain += m.attack_noise * 0.3 * noise * noise_env
181
+
182
+ # Attack shaping
183
+ attack_samples = max(int(m.attack_ms / 1000 * self.SAMPLE_RATE), 2)
184
+ if attack_samples < n:
185
+ grain[:attack_samples] *= np.linspace(0, 1, attack_samples, dtype=np.float32)
186
+
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)
190
+ phase_mod = np.cumsum(pitch_env) / np.sum(pitch_env) * n
191
+ indices = np.clip(phase_mod.astype(int), 0, n - 1)
192
+ grain = grain[indices]
193
+
194
+ # Multi-tap reverb with HF damping
195
+ grain = self._apply_reverb(grain, m.reverb_amount, m.room_size, m.reverb_damping)
196
+
197
+ # Truncate reverb tail back to grain length
198
+ grain = grain[:n]
199
+
200
+ # Normalise with per-material volume
201
+ peak = np.max(np.abs(grain))
202
+ 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))
211
+ grain = self._grain_cache[idx].copy()
212
+
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()
230
+
231
+ self._add_to_buffer(grain)
232
+
233
+ def play_chime(self):
234
+ """Play a soft completion chime using the material's tonal character."""
235
+ m = self.material
236
+ duration = 0.35
237
+ n = int(duration * self.SAMPLE_RATE)
238
+ t = np.linspace(0, duration, n, dtype=np.float32)
239
+
240
+ chime = np.zeros(n, dtype=np.float32)
241
+
242
+ # Use the material's own partials at a lower register for the chime
243
+ base = m.base_freq * 0.5
244
+ for i, (ratio, amp) in enumerate(m.partials[:4]):
245
+ freq = base * ratio * np.random.uniform(0.998, 1.002)
246
+ phase = np.random.uniform(0, 2 * np.pi)
247
+ decay_rate = m.decay_rates[i] if i < len(m.decay_rates) else m.decay_rates[-1]
248
+ # Slower decay for sustained chime
249
+ envelope = np.exp(-decay_rate * 0.25 * t) * np.sin(np.pi * t / duration)
250
+ chime += amp * 0.3 * np.sin(2 * np.pi * freq * t + phase) * envelope
251
+
252
+ # Softer noise transient
253
+ if m.attack_noise > 0:
254
+ noise = np.random.randn(n).astype(np.float32)
255
+ noise = self._highpass(noise, m.noise_freq)
256
+ chime += m.attack_noise * 0.15 * noise * np.exp(-50 * t)
257
+
258
+ # More reverb for chimes
259
+ chime = self._apply_reverb(
260
+ chime, m.reverb_amount * 1.3,
261
+ room_size=m.room_size * 1.2,
262
+ damping=m.reverb_damping,
263
+ )
264
+ chime = chime[:n]
265
+
266
+ peak = np.max(np.abs(chime))
267
+ if peak > 0:
268
+ chime = chime / peak * 0.35 * m.volume
269
+
270
+ self._add_to_buffer(chime.astype(np.float32))
271
+
272
+ def play_attention(self):
273
+ """Play a gentle attention signal using the material's tonal character."""
274
+ m = self.material
275
+ duration = 0.8
276
+ n = int(duration * self.SAMPLE_RATE)
277
+ t = np.linspace(0, duration, n, dtype=np.float32)
278
+
279
+ signal = np.zeros(n, dtype=np.float32)
280
+
281
+ # Two-note rising pattern using material's frequency
282
+ note_samples = n // 2
283
+ for i, pitch_mult in enumerate([1.0, 1.25]): # Root and major third
284
+ start = i * note_samples
285
+ end = start + note_samples
286
+ nt = t[start:end] - t[start]
287
+ freq = m.base_freq * 0.4 * pitch_mult
288
+
289
+ envelope = np.sin(np.pi * nt / (duration / 2)) ** 2
290
+ # Use a couple of the material's partials
291
+ for j, (ratio, amp) in enumerate(m.partials[:2]):
292
+ pfreq = freq * ratio * np.random.uniform(0.998, 1.002)
293
+ signal[start:end] += 0.2 * amp * np.sin(
294
+ 2 * np.pi * pfreq * nt + np.random.uniform(0, 2 * np.pi)
295
+ ) * envelope
296
+
297
+ # Gentle reverb
298
+ signal = self._apply_reverb(
299
+ signal, m.reverb_amount * 1.5,
300
+ room_size=m.room_size * 1.3,
301
+ damping=m.reverb_damping,
302
+ )
303
+ signal = signal[:n]
304
+
305
+ peak = np.max(np.abs(signal))
306
+ if peak > 0:
307
+ signal = signal / peak * 0.3 * m.volume
308
+
309
+ self._add_to_buffer(signal.astype(np.float32))
claudible/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ """Command-line interface for claudible."""
2
+
3
+ import argparse
4
+ import os
5
+ import pty
6
+ import select
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from typing import Optional
11
+
12
+ from .audio import SoundEngine
13
+ from .materials import get_material, get_random_material, list_materials, MATERIALS
14
+ from .monitor import ActivityMonitor
15
+
16
+
17
+ def run_pipe_mode(engine: SoundEngine, monitor: ActivityMonitor):
18
+ """Run in pipe mode, reading from stdin."""
19
+ engine.start()
20
+ monitor.start()
21
+
22
+ try:
23
+ while True:
24
+ data = sys.stdin.buffer.read(1024)
25
+ if not data:
26
+ break
27
+ monitor.process_chunk(data)
28
+ sys.stdout.buffer.write(data)
29
+ sys.stdout.buffer.flush()
30
+ # Let audio buffer drain
31
+ time.sleep(0.5)
32
+ except KeyboardInterrupt:
33
+ pass
34
+ finally:
35
+ monitor.stop()
36
+ engine.stop()
37
+
38
+
39
+ def run_wrap_mode(command: str, engine: SoundEngine, monitor: ActivityMonitor):
40
+ """Run in wrap mode, spawning a PTY for the command."""
41
+ import shlex
42
+ import tty
43
+ import termios
44
+
45
+ engine.start()
46
+ monitor.start()
47
+
48
+ if ' ' in command:
49
+ args = shlex.split(command)
50
+ else:
51
+ args = [command]
52
+
53
+ master_fd, slave_fd = pty.openpty()
54
+
55
+ try:
56
+ process = subprocess.Popen(
57
+ args,
58
+ stdin=slave_fd,
59
+ stdout=slave_fd,
60
+ stderr=slave_fd,
61
+ preexec_fn=os.setsid,
62
+ )
63
+ os.close(slave_fd)
64
+
65
+ old_settings = termios.tcgetattr(sys.stdin)
66
+ try:
67
+ tty.setraw(sys.stdin.fileno())
68
+
69
+ while process.poll() is None:
70
+ rlist, _, _ = select.select([sys.stdin, master_fd], [], [], 0.1)
71
+
72
+ for fd in rlist:
73
+ if fd == sys.stdin:
74
+ data = os.read(sys.stdin.fileno(), 1024)
75
+ if data:
76
+ os.write(master_fd, data)
77
+ elif fd == master_fd:
78
+ try:
79
+ data = os.read(master_fd, 1024)
80
+ if data:
81
+ monitor.process_chunk(data)
82
+ sys.stdout.buffer.write(data)
83
+ sys.stdout.buffer.flush()
84
+ except OSError:
85
+ break
86
+ finally:
87
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
88
+
89
+ except KeyboardInterrupt:
90
+ pass
91
+ finally:
92
+ os.close(master_fd)
93
+ monitor.stop()
94
+ engine.stop()
95
+
96
+
97
+ def main():
98
+ """Main entry point."""
99
+ parser = argparse.ArgumentParser(
100
+ prog='claudible',
101
+ description='Ambient audio soundscape feedback for terminal output',
102
+ )
103
+ parser.add_argument(
104
+ 'command',
105
+ nargs='?',
106
+ default='claude',
107
+ help='Command to wrap (default: claude)',
108
+ )
109
+ parser.add_argument(
110
+ '--pipe',
111
+ action='store_true',
112
+ help='Pipe mode: read from stdin instead of wrapping a command',
113
+ )
114
+ parser.add_argument(
115
+ '--character', '-c',
116
+ choices=list_materials(),
117
+ help='Sound character (default: random)',
118
+ )
119
+ parser.add_argument(
120
+ '--volume', '-v',
121
+ type=float,
122
+ default=0.5,
123
+ help='Volume 0.0-1.0 (default: 0.5)',
124
+ )
125
+ parser.add_argument(
126
+ '--attention', '-a',
127
+ type=float,
128
+ default=30.0,
129
+ help='Seconds of silence before attention signal (default: 30)',
130
+ )
131
+ parser.add_argument(
132
+ '--reverse', '-r',
133
+ action='store_true',
134
+ help='Reverse mode: sound plays during silence, not during output',
135
+ )
136
+ parser.add_argument(
137
+ '--list-characters',
138
+ action='store_true',
139
+ help='List available sound characters',
140
+ )
141
+
142
+ args = parser.parse_args()
143
+
144
+ if args.list_characters:
145
+ print("Available sound characters:\n")
146
+ for name, mat in MATERIALS.items():
147
+ print(f" {name:10} - {mat.description}")
148
+ return
149
+
150
+ if args.character:
151
+ material = get_material(args.character)
152
+ else:
153
+ material = get_random_material()
154
+
155
+ volume = max(0.0, min(1.0, args.volume))
156
+
157
+ engine = SoundEngine(material=material, volume=volume)
158
+ monitor = ActivityMonitor(
159
+ on_grain=engine.play_grain,
160
+ on_chime=engine.play_chime,
161
+ on_attention=engine.play_attention,
162
+ attention_seconds=args.attention,
163
+ reverse=args.reverse,
164
+ )
165
+
166
+ mode_label = " (reverse)" if args.reverse else ""
167
+ print(f"[claudible] {material.name}{mode_label} - {material.description}", file=sys.stderr)
168
+
169
+ if args.pipe:
170
+ run_pipe_mode(engine, monitor)
171
+ else:
172
+ run_wrap_mode(args.command, engine, monitor)
173
+
174
+
175
+ if __name__ == '__main__':
176
+ main()
claudible/materials.py ADDED
@@ -0,0 +1,336 @@
1
+ """Material character presets for sound generation."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Tuple
5
+ import random
6
+
7
+
8
+ @dataclass
9
+ class Material:
10
+ """Defines the sonic character of a material.
11
+
12
+ Based on physical vibration properties of real materials.
13
+ """
14
+
15
+ name: str
16
+ base_freq: float # centre frequency in Hz
17
+ partials: List[Tuple[float, float]] # (ratio, amplitude) pairs
18
+ decay_rates: List[float] # per-partial exponential decay (higher = faster)
19
+ grain_duration: float # base grain duration in seconds
20
+ attack_noise: float # 0-1, amount of noise transient on attack
21
+ noise_freq: float # high-pass cutoff for noise transient in Hz
22
+ detune_amount: float # cents of random micro-detuning per partial
23
+ reverb_amount: float # 0-1 reverb wet amount
24
+ reverb_damping: float # 0-1 high-frequency damping in reverb tails
25
+ room_size: float # reverb room size multiplier
26
+ pitch_drop: float # pitch envelope multiplier (<1 = drops, >1 = rises, 1 = flat)
27
+ attack_ms: float # attack ramp time in milliseconds
28
+ freq_spread: float # semitones of random base freq variation per grain
29
+ volume: float # per-material volume scaling (0-1)
30
+ description: str
31
+
32
+
33
+ MATERIALS = {
34
+ # --- Original materials (upgraded with physics parameters) ---
35
+
36
+ "ice": Material(
37
+ name="ice",
38
+ base_freq=2800,
39
+ partials=[(1.0, 1.0), (2.71, 0.7), (5.13, 0.5), (8.41, 0.3), (12.7, 0.15)],
40
+ decay_rates=[25, 40, 60, 80, 100],
41
+ grain_duration=0.035,
42
+ attack_noise=0.4,
43
+ noise_freq=5000,
44
+ detune_amount=8,
45
+ reverb_amount=0.2,
46
+ reverb_damping=0.3,
47
+ room_size=1.0,
48
+ pitch_drop=0.995,
49
+ attack_ms=0.5,
50
+ freq_spread=4.0,
51
+ volume=0.85,
52
+ description="Brittle, very high, fast decay with pitch drop",
53
+ ),
54
+
55
+ "glass": Material(
56
+ name="glass",
57
+ base_freq=1200,
58
+ partials=[(1.0, 1.0), (2.32, 0.6), (4.17, 0.35), (6.85, 0.15)],
59
+ decay_rates=[18, 28, 45, 65],
60
+ grain_duration=0.05,
61
+ attack_noise=0.35,
62
+ noise_freq=4000,
63
+ detune_amount=5,
64
+ reverb_amount=0.28,
65
+ reverb_damping=0.3,
66
+ room_size=1.0,
67
+ pitch_drop=1.0,
68
+ attack_ms=0.8,
69
+ freq_spread=3.0,
70
+ volume=0.9,
71
+ description="Classic wine glass ping",
72
+ ),
73
+
74
+ "crystal": Material(
75
+ name="crystal",
76
+ base_freq=1800,
77
+ partials=[(1.0, 1.0), (1.003, 0.9), (2.41, 0.5), (2.43, 0.45), (4.2, 0.2)],
78
+ decay_rates=[12, 13, 22, 24, 40],
79
+ grain_duration=0.07,
80
+ attack_noise=0.25,
81
+ noise_freq=3500,
82
+ detune_amount=3,
83
+ reverb_amount=0.35,
84
+ reverb_damping=0.25,
85
+ room_size=1.2,
86
+ pitch_drop=1.0,
87
+ attack_ms=1.0,
88
+ freq_spread=2.5,
89
+ volume=0.85,
90
+ description="Pure lead crystal with beating from close partial pairs",
91
+ ),
92
+
93
+ "ceramic": Material(
94
+ name="ceramic",
95
+ base_freq=600,
96
+ partials=[(1.0, 1.0), (2.15, 0.5), (3.87, 0.35), (5.21, 0.2), (7.43, 0.1)],
97
+ decay_rates=[30, 45, 60, 75, 90],
98
+ grain_duration=0.04,
99
+ attack_noise=0.5,
100
+ noise_freq=2500,
101
+ detune_amount=12,
102
+ reverb_amount=0.22,
103
+ reverb_damping=0.4,
104
+ room_size=0.9,
105
+ pitch_drop=0.998,
106
+ attack_ms=1.2,
107
+ freq_spread=3.5,
108
+ volume=1.0,
109
+ description="Duller muted earthenware tap",
110
+ ),
111
+
112
+ "bell": Material(
113
+ name="bell",
114
+ base_freq=900,
115
+ partials=[
116
+ (1.0, 1.0), (1.183, 0.8), (1.506, 0.6),
117
+ (2.0, 0.7), (2.514, 0.4), (3.011, 0.25),
118
+ ],
119
+ decay_rates=[10, 12, 15, 14, 20, 28],
120
+ grain_duration=0.08,
121
+ attack_noise=0.2,
122
+ noise_freq=3000,
123
+ detune_amount=6,
124
+ reverb_amount=0.4,
125
+ reverb_damping=0.2,
126
+ room_size=1.3,
127
+ pitch_drop=1.0,
128
+ attack_ms=0.3,
129
+ freq_spread=2.5,
130
+ volume=0.8,
131
+ description="Small metallic bell, classic ratios, long ring",
132
+ ),
133
+
134
+ "droplet": Material(
135
+ name="droplet",
136
+ base_freq=1400,
137
+ partials=[(1.0, 1.0), (2.8, 0.3), (5.2, 0.1)],
138
+ decay_rates=[35, 50, 70],
139
+ grain_duration=0.03,
140
+ attack_noise=0.15,
141
+ noise_freq=6000,
142
+ detune_amount=4,
143
+ reverb_amount=0.45,
144
+ reverb_damping=0.2,
145
+ room_size=1.0,
146
+ pitch_drop=0.92,
147
+ attack_ms=0.3,
148
+ freq_spread=4.0,
149
+ volume=0.8,
150
+ description="Water droplet, pitch bend down, liquid",
151
+ ),
152
+
153
+ "click": Material(
154
+ name="click",
155
+ base_freq=3500,
156
+ partials=[(1.0, 1.0), (2.5, 0.3)],
157
+ decay_rates=[40, 60],
158
+ grain_duration=0.02,
159
+ attack_noise=0.7,
160
+ noise_freq=7000,
161
+ detune_amount=15,
162
+ reverb_amount=0.1,
163
+ reverb_damping=0.5,
164
+ room_size=0.5,
165
+ pitch_drop=1.0,
166
+ attack_ms=0.2,
167
+ freq_spread=6.0,
168
+ volume=0.95,
169
+ description="Sharp mechanical click, keyboard-like",
170
+ ),
171
+
172
+ # --- New materials with distinct textures ---
173
+
174
+ "wood": Material(
175
+ name="wood",
176
+ base_freq=420,
177
+ partials=[
178
+ (1.0, 1.0), (2.0, 0.55), (3.0, 0.28),
179
+ (4.01, 0.12), (5.02, 0.05),
180
+ ],
181
+ decay_rates=[22, 32, 45, 55, 70],
182
+ grain_duration=0.04,
183
+ attack_noise=0.45,
184
+ noise_freq=2000,
185
+ detune_amount=8,
186
+ reverb_amount=0.18,
187
+ reverb_damping=0.55,
188
+ room_size=0.8,
189
+ pitch_drop=0.997,
190
+ attack_ms=0.6,
191
+ freq_spread=4.0,
192
+ volume=1.0,
193
+ description="Hollow wooden tap, warm marimba-like resonance",
194
+ ),
195
+
196
+ "stone": Material(
197
+ name="stone",
198
+ base_freq=280,
199
+ partials=[
200
+ (1.0, 1.0), (2.37, 0.5), (4.73, 0.25),
201
+ (7.11, 0.12), (9.89, 0.06),
202
+ ],
203
+ decay_rates=[35, 48, 60, 72, 85],
204
+ grain_duration=0.03,
205
+ attack_noise=0.6,
206
+ noise_freq=1800,
207
+ detune_amount=15,
208
+ reverb_amount=0.15,
209
+ reverb_damping=0.6,
210
+ room_size=0.7,
211
+ pitch_drop=0.994,
212
+ attack_ms=0.4,
213
+ freq_spread=5.0,
214
+ volume=1.0,
215
+ description="Dense slate tap, heavy and earthy",
216
+ ),
217
+
218
+ "bamboo": Material(
219
+ name="bamboo",
220
+ base_freq=680,
221
+ partials=[
222
+ (1.0, 1.0), (3.01, 0.5), (5.03, 0.25), (7.02, 0.1),
223
+ ],
224
+ decay_rates=[15, 24, 34, 45],
225
+ grain_duration=0.05,
226
+ attack_noise=0.3,
227
+ noise_freq=5000,
228
+ detune_amount=6,
229
+ reverb_amount=0.35,
230
+ reverb_damping=0.25,
231
+ room_size=1.1,
232
+ pitch_drop=1.0,
233
+ attack_ms=1.0,
234
+ freq_spread=3.5,
235
+ volume=0.85,
236
+ description="Hollow tube resonance, odd harmonics, breathy and airy",
237
+ ),
238
+
239
+ "ember": Material(
240
+ name="ember",
241
+ base_freq=350,
242
+ partials=[
243
+ (1.0, 1.0), (1.73, 0.6), (2.91, 0.35), (4.37, 0.15),
244
+ ],
245
+ decay_rates=[42, 55, 68, 80],
246
+ grain_duration=0.025,
247
+ attack_noise=0.7,
248
+ noise_freq=1500,
249
+ detune_amount=22,
250
+ reverb_amount=0.2,
251
+ reverb_damping=0.7,
252
+ room_size=0.6,
253
+ pitch_drop=0.98,
254
+ attack_ms=0.3,
255
+ freq_spread=8.0,
256
+ volume=0.9,
257
+ description="Warm crackling ember, fire-like with wide pitch scatter",
258
+ ),
259
+
260
+ "silk": Material(
261
+ name="silk",
262
+ base_freq=1800,
263
+ partials=[(1.0, 1.0), (2.01, 0.4), (3.5, 0.1)],
264
+ decay_rates=[22, 35, 50],
265
+ grain_duration=0.045,
266
+ attack_noise=0.55,
267
+ noise_freq=6500,
268
+ detune_amount=10,
269
+ reverb_amount=0.5,
270
+ reverb_damping=0.15,
271
+ room_size=1.4,
272
+ pitch_drop=1.0,
273
+ attack_ms=2.5,
274
+ freq_spread=5.0,
275
+ volume=0.6,
276
+ description="Soft breathy whisper, delicate airy texture",
277
+ ),
278
+
279
+ "shell": Material(
280
+ name="shell",
281
+ base_freq=750,
282
+ partials=[
283
+ (1.0, 1.0), (1.003, 0.9), (2.01, 0.6),
284
+ (2.016, 0.55), (3.02, 0.3), (3.028, 0.28),
285
+ ],
286
+ decay_rates=[8, 9, 14, 15, 22, 23],
287
+ grain_duration=0.07,
288
+ attack_noise=0.2,
289
+ noise_freq=3000,
290
+ detune_amount=4,
291
+ reverb_amount=0.6,
292
+ reverb_damping=0.2,
293
+ room_size=1.5,
294
+ pitch_drop=1.0,
295
+ attack_ms=1.5,
296
+ freq_spread=2.0,
297
+ volume=0.75,
298
+ description="Swirly ocean interference, dense phase beating",
299
+ ),
300
+
301
+ "moss": Material(
302
+ name="moss",
303
+ base_freq=220,
304
+ partials=[(1.0, 1.0), (2.1, 0.25), (3.3, 0.08)],
305
+ decay_rates=[28, 42, 58],
306
+ grain_duration=0.035,
307
+ attack_noise=0.15,
308
+ noise_freq=1200,
309
+ detune_amount=12,
310
+ reverb_amount=0.3,
311
+ reverb_damping=0.8,
312
+ room_size=0.5,
313
+ pitch_drop=0.996,
314
+ attack_ms=3.0,
315
+ freq_spread=4.0,
316
+ volume=0.7,
317
+ description="Ultra-soft muffled earth, mossy dampness",
318
+ ),
319
+ }
320
+
321
+
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]
327
+
328
+
329
+ def get_random_material() -> Material:
330
+ """Get a random material."""
331
+ return random.choice(list(MATERIALS.values()))
332
+
333
+
334
+ def list_materials() -> List[str]:
335
+ """List all available material names."""
336
+ return list(MATERIALS.keys())
claudible/monitor.py ADDED
@@ -0,0 +1,119 @@
1
+ """I/O activity tracking and event detection."""
2
+
3
+ import time
4
+ from typing import Callable, Optional
5
+ import threading
6
+
7
+
8
+ class ActivityMonitor:
9
+ """Monitors text output and triggers audio events."""
10
+
11
+ MAX_GRAINS_PER_SEC = 30
12
+ NEWLINE_THRESHOLD = 3 # Consecutive newlines to trigger completion chime
13
+
14
+ # Reverse mode: ambient grain rate when idle
15
+ REVERSE_GRAINS_PER_SEC = 12
16
+ REVERSE_IDLE_DELAY = 3.0 # Seconds of silence before ambient grains start
17
+
18
+ def __init__(
19
+ self,
20
+ on_grain: Callable[[], None],
21
+ on_chime: Callable[[], None],
22
+ on_attention: Callable[[], None],
23
+ attention_seconds: float = 30.0,
24
+ reverse: bool = False,
25
+ ):
26
+ self.on_grain = on_grain
27
+ self.on_chime = on_chime
28
+ self.on_attention = on_attention
29
+ self.attention_seconds = attention_seconds
30
+ self.reverse = reverse
31
+
32
+ self._last_grain_time = 0.0
33
+ self._min_grain_interval = 1.0 / self.MAX_GRAINS_PER_SEC
34
+ self._consecutive_newlines = 0
35
+ self._last_activity_time = time.time()
36
+ self._attention_triggered = False
37
+ self._running = False
38
+ self._bg_thread: Optional[threading.Thread] = None
39
+ self._lock = threading.Lock()
40
+
41
+ # Reverse mode state
42
+ self._reverse_playing = False
43
+ self._was_active = False
44
+
45
+ def start(self):
46
+ """Start monitoring."""
47
+ self._running = True
48
+ self._last_activity_time = time.time()
49
+ self._bg_thread = threading.Thread(target=self._background_loop, daemon=True)
50
+ self._bg_thread.start()
51
+
52
+ def stop(self):
53
+ """Stop monitoring."""
54
+ self._running = False
55
+ if self._bg_thread:
56
+ self._bg_thread.join(timeout=1.0)
57
+
58
+ def _background_loop(self):
59
+ """Background thread for attention signals and reverse-mode ambient grains."""
60
+ reverse_interval = 1.0 / self.REVERSE_GRAINS_PER_SEC
61
+
62
+ while self._running:
63
+ if self.reverse:
64
+ with self._lock:
65
+ elapsed = time.time() - self._last_activity_time
66
+
67
+ if elapsed >= self.REVERSE_IDLE_DELAY:
68
+ if not self._reverse_playing:
69
+ self._reverse_playing = True
70
+ self.on_chime()
71
+ self.on_grain()
72
+ time.sleep(reverse_interval)
73
+ else:
74
+ if self._reverse_playing:
75
+ self._reverse_playing = False
76
+ time.sleep(0.1)
77
+ else:
78
+ time.sleep(1.0)
79
+ with self._lock:
80
+ elapsed = time.time() - self._last_activity_time
81
+ if elapsed >= self.attention_seconds and not self._attention_triggered:
82
+ self._attention_triggered = True
83
+ self.on_attention()
84
+
85
+ def process_chunk(self, data: bytes):
86
+ """Process a chunk of output data."""
87
+ if not data:
88
+ return
89
+
90
+ with self._lock:
91
+ self._last_activity_time = time.time()
92
+ self._attention_triggered = False
93
+
94
+ if self.reverse:
95
+ # In reverse mode, activity = silence. Just update the timestamp.
96
+ return
97
+
98
+ # Normal mode: trigger sounds on output
99
+ try:
100
+ text = data.decode('utf-8', errors='replace')
101
+ except Exception:
102
+ text = str(data)
103
+
104
+ for char in text:
105
+ if char == '\n':
106
+ self._consecutive_newlines += 1
107
+ if self._consecutive_newlines >= self.NEWLINE_THRESHOLD:
108
+ self.on_chime()
109
+ self._consecutive_newlines = 0
110
+ else:
111
+ self._consecutive_newlines = 0
112
+ self._maybe_trigger_grain()
113
+
114
+ def _maybe_trigger_grain(self):
115
+ """Trigger a grain if enough time has passed (throttling)."""
116
+ now = time.time()
117
+ if now - self._last_grain_time >= self._min_grain_interval:
118
+ self._last_grain_time = now
119
+ self.on_grain()
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: claudible
3
+ Version: 0.1.0
4
+ Summary: Ambient audio soundscape feedback for terminal output - an opus for your terminals
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/qwertykeith/claudible
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Multimedia :: Sound/Audio
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: numpy>=1.20.0
23
+ Requires-Dist: scipy>=1.7.0
24
+ Requires-Dist: sounddevice>=0.4.0
25
+
26
+ # claudible
27
+
28
+ *An opus for your terminals.*
29
+
30
+ Possibly the most annoying Claude utility ever made but here it is anyway. Ambient audio soundscape feedback for terminal output.
31
+
32
+ ## The Idea
33
+
34
+ Imagine you are working in a factory. Each Claude Code session is a machine that must be attended to. When it's working, you hear it - crystalline sparkles as text flows, soft chimes when tasks complete. When it goes quiet, you know something needs your attention.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install claudible
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ # Run claude with audio feedback (default)
46
+ claudible
47
+
48
+ # Run a different command
49
+ claudible "python my_script.py"
50
+
51
+ # Pipe mode
52
+ some-command 2>&1 | claudible --pipe
53
+
54
+ # Choose a sound character
55
+ claudible --character crystal
56
+
57
+ # Adjust volume
58
+ claudible --volume 0.3
59
+
60
+ # List available characters
61
+ claudible --list-characters
62
+ ```
63
+
64
+ ### Reverse Mode
65
+
66
+ In reverse mode, claudible is silent while output is flowing and plays ambient sound during silence. Useful when you want to know a task is *waiting* rather than *working*.
67
+
68
+ ```bash
69
+ # Ambient grains play when Claude is idle/waiting for you
70
+ claudible --reverse
71
+
72
+ # Combine with a character
73
+ claudible --reverse -c shell
74
+ ```
75
+
76
+ ## Works With Any CLI
77
+
78
+ Built for Claude Code, but claudible works with anything that produces terminal output.
79
+
80
+ ```bash
81
+ # Aider
82
+ claudible "aider"
83
+
84
+ # Watch a dev server
85
+ npm run dev 2>&1 | claudible --pipe
86
+
87
+ # Monitor logs
88
+ tail -f /var/log/app.log | claudible --pipe -c droplet
89
+ ```
90
+
91
+ ## Sound Characters
92
+
93
+ | Character | Description |
94
+ |-----------|-------------|
95
+ | `ice` | Brittle, very high, fast decay with pitch drop |
96
+ | `glass` | Classic wine glass ping |
97
+ | `crystal` | Pure lead crystal with beating from close partial pairs |
98
+ | `ceramic` | Duller muted earthenware tap |
99
+ | `bell` | Small metallic bell, classic ratios, long ring |
100
+ | `droplet` | Water droplet, pitch bend down, liquid |
101
+ | `click` | Sharp mechanical click, keyboard-like |
102
+ | `wood` | Hollow wooden tap, warm marimba-like resonance |
103
+ | `stone` | Dense slate tap, heavy and earthy |
104
+ | `bamboo` | Hollow tube resonance, odd harmonics, breathy and airy |
105
+ | `ember` | Warm crackling ember, fire-like with wide pitch scatter |
106
+ | `silk` | Soft breathy whisper, delicate airy texture |
107
+ | `shell` | Swirly ocean interference, dense phase beating |
108
+ | `moss` | Ultra-soft muffled earth, mossy dampness |
109
+
110
+ ## Options
111
+
112
+ | Flag | Description |
113
+ |------|-------------|
114
+ | `--pipe` | Read from stdin instead of wrapping |
115
+ | `--character`, `-c` | Sound character |
116
+ | `--volume`, `-v` | Volume 0.0-1.0 (default: 0.5) |
117
+ | `--attention`, `-a` | Silence alert seconds (default: 30) |
118
+ | `--reverse`, `-r` | Reverse mode: sound during silence, quiet during output |
119
+ | `--list-characters` | Show presets |
120
+
121
+ ## Development
122
+
123
+ Test locally without installing:
124
+
125
+ ```bash
126
+ cd claudible
127
+
128
+ # Run wrapping claude (Ctrl+C to stop)
129
+ PYTHONPATH=src python3 -m claudible
130
+
131
+ # Wrap a different command
132
+ PYTHONPATH=src python3 -m claudible "ls -la"
133
+
134
+ # Pipe mode
135
+ echo "test" | PYTHONPATH=src python3 -m claudible --pipe -c glass
136
+
137
+ # List characters
138
+ PYTHONPATH=src python3 -m claudible --list-characters
139
+ ```
140
+
141
+ ## License
142
+
143
+ MIT
@@ -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=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.0.dist-info/METADATA,sha256=HXc1eXgzZECcs0cx7ZCWj4-m7Lz5aOUP2t002GZd4oI,4100
8
+ claudible-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ claudible-0.1.0.dist-info/entry_points.txt,sha256=4tBIsIGcdtJ-1N9YZ3MrADyNvPBDcUxVgAnt9IuC7TA,49
10
+ claudible-0.1.0.dist-info/top_level.txt,sha256=4ZB16Aa5ZDS12bmxzrQmAJejjsTgQ9Yozwc0Z3qpJp4,10
11
+ claudible-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ claudible = claudible.cli:main
@@ -0,0 +1 @@
1
+ claudible