claudible 0.1.0__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.0/PKG-INFO +143 -0
- claudible-0.1.0/README.md +118 -0
- claudible-0.1.0/pyproject.toml +40 -0
- claudible-0.1.0/setup.cfg +4 -0
- claudible-0.1.0/src/claudible/__init__.py +3 -0
- claudible-0.1.0/src/claudible/__main__.py +6 -0
- claudible-0.1.0/src/claudible/audio.py +309 -0
- claudible-0.1.0/src/claudible/cli.py +176 -0
- claudible-0.1.0/src/claudible/materials.py +336 -0
- claudible-0.1.0/src/claudible/monitor.py +119 -0
- claudible-0.1.0/src/claudible.egg-info/PKG-INFO +143 -0
- claudible-0.1.0/src/claudible.egg-info/SOURCES.txt +14 -0
- claudible-0.1.0/src/claudible.egg-info/dependency_links.txt +1 -0
- claudible-0.1.0/src/claudible.egg-info/entry_points.txt +2 -0
- claudible-0.1.0/src/claudible.egg-info/requires.txt +3 -0
- claudible-0.1.0/src/claudible.egg-info/top_level.txt +1 -0
claudible-0.1.0/PKG-INFO
ADDED
|
@@ -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,118 @@
|
|
|
1
|
+
# claudible
|
|
2
|
+
|
|
3
|
+
*An opus for your terminals.*
|
|
4
|
+
|
|
5
|
+
Possibly the most annoying Claude utility ever made but here it is anyway. Ambient audio soundscape feedback for terminal output.
|
|
6
|
+
|
|
7
|
+
## The Idea
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install claudible
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Run claude with audio feedback (default)
|
|
21
|
+
claudible
|
|
22
|
+
|
|
23
|
+
# Run a different command
|
|
24
|
+
claudible "python my_script.py"
|
|
25
|
+
|
|
26
|
+
# Pipe mode
|
|
27
|
+
some-command 2>&1 | claudible --pipe
|
|
28
|
+
|
|
29
|
+
# Choose a sound character
|
|
30
|
+
claudible --character crystal
|
|
31
|
+
|
|
32
|
+
# Adjust volume
|
|
33
|
+
claudible --volume 0.3
|
|
34
|
+
|
|
35
|
+
# List available characters
|
|
36
|
+
claudible --list-characters
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Reverse Mode
|
|
40
|
+
|
|
41
|
+
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*.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Ambient grains play when Claude is idle/waiting for you
|
|
45
|
+
claudible --reverse
|
|
46
|
+
|
|
47
|
+
# Combine with a character
|
|
48
|
+
claudible --reverse -c shell
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Works With Any CLI
|
|
52
|
+
|
|
53
|
+
Built for Claude Code, but claudible works with anything that produces terminal output.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Aider
|
|
57
|
+
claudible "aider"
|
|
58
|
+
|
|
59
|
+
# Watch a dev server
|
|
60
|
+
npm run dev 2>&1 | claudible --pipe
|
|
61
|
+
|
|
62
|
+
# Monitor logs
|
|
63
|
+
tail -f /var/log/app.log | claudible --pipe -c droplet
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Sound Characters
|
|
67
|
+
|
|
68
|
+
| Character | Description |
|
|
69
|
+
|-----------|-------------|
|
|
70
|
+
| `ice` | Brittle, very high, fast decay with pitch drop |
|
|
71
|
+
| `glass` | Classic wine glass ping |
|
|
72
|
+
| `crystal` | Pure lead crystal with beating from close partial pairs |
|
|
73
|
+
| `ceramic` | Duller muted earthenware tap |
|
|
74
|
+
| `bell` | Small metallic bell, classic ratios, long ring |
|
|
75
|
+
| `droplet` | Water droplet, pitch bend down, liquid |
|
|
76
|
+
| `click` | Sharp mechanical click, keyboard-like |
|
|
77
|
+
| `wood` | Hollow wooden tap, warm marimba-like resonance |
|
|
78
|
+
| `stone` | Dense slate tap, heavy and earthy |
|
|
79
|
+
| `bamboo` | Hollow tube resonance, odd harmonics, breathy and airy |
|
|
80
|
+
| `ember` | Warm crackling ember, fire-like with wide pitch scatter |
|
|
81
|
+
| `silk` | Soft breathy whisper, delicate airy texture |
|
|
82
|
+
| `shell` | Swirly ocean interference, dense phase beating |
|
|
83
|
+
| `moss` | Ultra-soft muffled earth, mossy dampness |
|
|
84
|
+
|
|
85
|
+
## Options
|
|
86
|
+
|
|
87
|
+
| Flag | Description |
|
|
88
|
+
|------|-------------|
|
|
89
|
+
| `--pipe` | Read from stdin instead of wrapping |
|
|
90
|
+
| `--character`, `-c` | Sound character |
|
|
91
|
+
| `--volume`, `-v` | Volume 0.0-1.0 (default: 0.5) |
|
|
92
|
+
| `--attention`, `-a` | Silence alert seconds (default: 30) |
|
|
93
|
+
| `--reverse`, `-r` | Reverse mode: sound during silence, quiet during output |
|
|
94
|
+
| `--list-characters` | Show presets |
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
Test locally without installing:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cd claudible
|
|
102
|
+
|
|
103
|
+
# Run wrapping claude (Ctrl+C to stop)
|
|
104
|
+
PYTHONPATH=src python3 -m claudible
|
|
105
|
+
|
|
106
|
+
# Wrap a different command
|
|
107
|
+
PYTHONPATH=src python3 -m claudible "ls -la"
|
|
108
|
+
|
|
109
|
+
# Pipe mode
|
|
110
|
+
echo "test" | PYTHONPATH=src python3 -m claudible --pipe -c glass
|
|
111
|
+
|
|
112
|
+
# List characters
|
|
113
|
+
PYTHONPATH=src python3 -m claudible --list-characters
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "claudible"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Ambient audio soundscape feedback for terminal output - an opus for your terminals"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
25
|
+
"Topic :: Utilities",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"numpy>=1.20.0",
|
|
29
|
+
"scipy>=1.7.0",
|
|
30
|
+
"sounddevice>=0.4.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
claudible = "claudible.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/qwertykeith/claudible"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
@@ -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))
|