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 +3 -0
- claudible/__main__.py +6 -0
- claudible/audio.py +309 -0
- claudible/cli.py +176 -0
- claudible/materials.py +336 -0
- claudible/monitor.py +119 -0
- claudible-0.1.0.dist-info/METADATA +143 -0
- claudible-0.1.0.dist-info/RECORD +11 -0
- claudible-0.1.0.dist-info/WHEEL +5 -0
- claudible-0.1.0.dist-info/entry_points.txt +2 -0
- claudible-0.1.0.dist-info/top_level.txt +1 -0
claudible/__init__.py
ADDED
claudible/__main__.py
ADDED
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 @@
|
|
|
1
|
+
claudible
|