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