@mcptoolshop/claude-sfx 0.1.0
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.
- package/LICENSE +21 -0
- package/README.es.md +188 -0
- package/README.fr.md +188 -0
- package/README.hi.md +188 -0
- package/README.it.md +188 -0
- package/README.ja.md +188 -0
- package/README.md +188 -0
- package/README.pt-BR.md +188 -0
- package/README.zh.md +188 -0
- package/assets/logo.jpg +0 -0
- package/dist/ambient.d.ts +28 -0
- package/dist/ambient.js +179 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +594 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +104 -0
- package/dist/guard.d.ts +19 -0
- package/dist/guard.js +87 -0
- package/dist/hook-handler.d.ts +34 -0
- package/dist/hook-handler.js +200 -0
- package/dist/hooks.d.ts +35 -0
- package/dist/hooks.js +192 -0
- package/dist/player.d.ts +16 -0
- package/dist/player.js +109 -0
- package/dist/profiles.d.ts +103 -0
- package/dist/profiles.js +297 -0
- package/dist/synth.d.ts +72 -0
- package/dist/synth.js +254 -0
- package/dist/verbs.d.ts +25 -0
- package/dist/verbs.js +251 -0
- package/package.json +54 -0
- package/profiles/minimal.json +202 -0
- package/profiles/retro.json +200 -0
package/dist/synth.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure PCM synthesis engine. Zero dependencies.
|
|
3
|
+
* Generates audio from math — oscillators, envelopes, FM, and WAV encoding.
|
|
4
|
+
*/
|
|
5
|
+
const SAMPLE_RATE = 44100;
|
|
6
|
+
const BIT_DEPTH = 16;
|
|
7
|
+
const NUM_CHANNELS = 1; // mono
|
|
8
|
+
/** Returns a sample value in [-1, 1] for a given phase (0–1 cycle). */
|
|
9
|
+
function oscillator(waveform, phase) {
|
|
10
|
+
const p = phase % 1;
|
|
11
|
+
switch (waveform) {
|
|
12
|
+
case "sine":
|
|
13
|
+
return Math.sin(2 * Math.PI * p);
|
|
14
|
+
case "square":
|
|
15
|
+
return p < 0.5 ? 1 : -1;
|
|
16
|
+
case "sawtooth":
|
|
17
|
+
return 2 * p - 1;
|
|
18
|
+
case "triangle":
|
|
19
|
+
return p < 0.5 ? 4 * p - 1 : 3 - 4 * p;
|
|
20
|
+
case "noise":
|
|
21
|
+
return Math.random() * 2 - 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Returns envelope amplitude (0–1) at a given time within a note of total duration. */
|
|
25
|
+
function envelopeAt(env, time, duration) {
|
|
26
|
+
const { attack, decay, sustain, release } = env;
|
|
27
|
+
const releaseStart = duration - release;
|
|
28
|
+
if (time < 0)
|
|
29
|
+
return 0;
|
|
30
|
+
if (time < attack) {
|
|
31
|
+
// Attack: ramp from 0 to 1
|
|
32
|
+
return time / attack;
|
|
33
|
+
}
|
|
34
|
+
if (time < attack + decay) {
|
|
35
|
+
// Decay: ramp from 1 to sustain level
|
|
36
|
+
const decayProgress = (time - attack) / decay;
|
|
37
|
+
return 1 - decayProgress * (1 - sustain);
|
|
38
|
+
}
|
|
39
|
+
if (time < releaseStart) {
|
|
40
|
+
// Sustain
|
|
41
|
+
return sustain;
|
|
42
|
+
}
|
|
43
|
+
if (time < duration) {
|
|
44
|
+
// Release: ramp from sustain to 0
|
|
45
|
+
const releaseProgress = (time - releaseStart) / release;
|
|
46
|
+
return sustain * (1 - releaseProgress);
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
// --- PCM Generation ---
|
|
51
|
+
/** Generate a Float64 audio buffer from tone parameters. */
|
|
52
|
+
export function generateTone(params) {
|
|
53
|
+
const numSamples = Math.floor(params.duration * SAMPLE_RATE);
|
|
54
|
+
const buffer = new Float64Array(numSamples);
|
|
55
|
+
for (let i = 0; i < numSamples; i++) {
|
|
56
|
+
const t = i / SAMPLE_RATE; // time in seconds
|
|
57
|
+
// Frequency sweep (linear interpolation)
|
|
58
|
+
const freqEnd = params.frequencyEnd ?? params.frequency;
|
|
59
|
+
const progress = i / numSamples;
|
|
60
|
+
const freq = params.frequency + (freqEnd - params.frequency) * progress;
|
|
61
|
+
// FM synthesis: modulate the carrier phase
|
|
62
|
+
let fmOffset = 0;
|
|
63
|
+
if (params.fmRatio && params.fmDepth) {
|
|
64
|
+
const modFreq = freq * params.fmRatio;
|
|
65
|
+
fmOffset =
|
|
66
|
+
(params.fmDepth / (2 * Math.PI * freq)) *
|
|
67
|
+
Math.sin(2 * Math.PI * modFreq * t);
|
|
68
|
+
}
|
|
69
|
+
// Carrier phase
|
|
70
|
+
const phase = freq * t + fmOffset;
|
|
71
|
+
let sample = oscillator(params.waveform, phase);
|
|
72
|
+
// Octave harmonic
|
|
73
|
+
if (params.harmonicGain && params.harmonicGain > 0) {
|
|
74
|
+
sample += params.harmonicGain * oscillator(params.waveform, phase * 2);
|
|
75
|
+
// Normalize to prevent clipping
|
|
76
|
+
sample /= 1 + params.harmonicGain;
|
|
77
|
+
}
|
|
78
|
+
// Detune (second oscillator for beating effect)
|
|
79
|
+
if (params.detune && params.detune !== 0) {
|
|
80
|
+
const detunePhase = (freq + params.detune) * t + fmOffset;
|
|
81
|
+
const detuneSample = oscillator(params.waveform, detunePhase);
|
|
82
|
+
sample = (sample + detuneSample) / 2;
|
|
83
|
+
}
|
|
84
|
+
// Envelope
|
|
85
|
+
sample *= envelopeAt(params.envelope, t, params.duration);
|
|
86
|
+
// Tremolo (amplitude modulation)
|
|
87
|
+
if (params.tremoloRate && params.tremoloDepth) {
|
|
88
|
+
const tremoloMod = 1 - params.tremoloDepth * 0.5 * (1 + Math.sin(2 * Math.PI * params.tremoloRate * t));
|
|
89
|
+
sample *= tremoloMod;
|
|
90
|
+
}
|
|
91
|
+
// Master gain
|
|
92
|
+
sample *= params.gain;
|
|
93
|
+
buffer[i] = sample;
|
|
94
|
+
}
|
|
95
|
+
return buffer;
|
|
96
|
+
}
|
|
97
|
+
// --- Mixing ---
|
|
98
|
+
/** Mix multiple tone buffers into one (summed and normalized). */
|
|
99
|
+
export function mixBuffers(buffers) {
|
|
100
|
+
const maxLen = Math.max(...buffers.map((b) => b.length));
|
|
101
|
+
const mixed = new Float64Array(maxLen);
|
|
102
|
+
for (const buf of buffers) {
|
|
103
|
+
for (let i = 0; i < buf.length; i++) {
|
|
104
|
+
mixed[i] += buf[i];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Find peak for normalization
|
|
108
|
+
let peak = 0;
|
|
109
|
+
for (let i = 0; i < mixed.length; i++) {
|
|
110
|
+
const abs = Math.abs(mixed[i]);
|
|
111
|
+
if (abs > peak)
|
|
112
|
+
peak = abs;
|
|
113
|
+
}
|
|
114
|
+
// Normalize if clipping
|
|
115
|
+
if (peak > 1) {
|
|
116
|
+
for (let i = 0; i < mixed.length; i++) {
|
|
117
|
+
mixed[i] /= peak;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return mixed;
|
|
121
|
+
}
|
|
122
|
+
/** Concatenate buffers sequentially with an optional gap (in seconds). */
|
|
123
|
+
export function concatBuffers(buffers, gapSeconds = 0) {
|
|
124
|
+
const gapSamples = Math.floor(gapSeconds * SAMPLE_RATE);
|
|
125
|
+
const totalLength = buffers.reduce((sum, b) => sum + b.length + gapSamples, -gapSamples // no gap after last buffer
|
|
126
|
+
);
|
|
127
|
+
const result = new Float64Array(Math.max(0, totalLength));
|
|
128
|
+
let offset = 0;
|
|
129
|
+
for (let bi = 0; bi < buffers.length; bi++) {
|
|
130
|
+
const buf = buffers[bi];
|
|
131
|
+
result.set(buf, offset);
|
|
132
|
+
offset += buf.length;
|
|
133
|
+
if (bi < buffers.length - 1) {
|
|
134
|
+
offset += gapSamples;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
// --- WAV Encoding ---
|
|
140
|
+
/** Encode a Float64 audio buffer as a WAV file (PCM 16-bit mono). */
|
|
141
|
+
export function encodeWav(buffer) {
|
|
142
|
+
const numSamples = buffer.length;
|
|
143
|
+
const byteRate = SAMPLE_RATE * NUM_CHANNELS * (BIT_DEPTH / 8);
|
|
144
|
+
const blockAlign = NUM_CHANNELS * (BIT_DEPTH / 8);
|
|
145
|
+
const dataSize = numSamples * NUM_CHANNELS * (BIT_DEPTH / 8);
|
|
146
|
+
const fileSize = 44 + dataSize; // 44-byte header + data
|
|
147
|
+
const wav = Buffer.alloc(fileSize);
|
|
148
|
+
let offset = 0;
|
|
149
|
+
// RIFF header
|
|
150
|
+
wav.write("RIFF", offset);
|
|
151
|
+
offset += 4;
|
|
152
|
+
wav.writeUInt32LE(fileSize - 8, offset);
|
|
153
|
+
offset += 4;
|
|
154
|
+
wav.write("WAVE", offset);
|
|
155
|
+
offset += 4;
|
|
156
|
+
// fmt sub-chunk
|
|
157
|
+
wav.write("fmt ", offset);
|
|
158
|
+
offset += 4;
|
|
159
|
+
wav.writeUInt32LE(16, offset); // sub-chunk size (PCM = 16)
|
|
160
|
+
offset += 4;
|
|
161
|
+
wav.writeUInt16LE(1, offset); // audio format (PCM = 1)
|
|
162
|
+
offset += 2;
|
|
163
|
+
wav.writeUInt16LE(NUM_CHANNELS, offset);
|
|
164
|
+
offset += 2;
|
|
165
|
+
wav.writeUInt32LE(SAMPLE_RATE, offset);
|
|
166
|
+
offset += 4;
|
|
167
|
+
wav.writeUInt32LE(byteRate, offset);
|
|
168
|
+
offset += 4;
|
|
169
|
+
wav.writeUInt16LE(blockAlign, offset);
|
|
170
|
+
offset += 2;
|
|
171
|
+
wav.writeUInt16LE(BIT_DEPTH, offset);
|
|
172
|
+
offset += 2;
|
|
173
|
+
// data sub-chunk
|
|
174
|
+
wav.write("data", offset);
|
|
175
|
+
offset += 4;
|
|
176
|
+
wav.writeUInt32LE(dataSize, offset);
|
|
177
|
+
offset += 4;
|
|
178
|
+
// PCM samples (float → 16-bit signed int)
|
|
179
|
+
for (let i = 0; i < numSamples; i++) {
|
|
180
|
+
let sample = buffer[i];
|
|
181
|
+
// Hard clip
|
|
182
|
+
sample = Math.max(-1, Math.min(1, sample));
|
|
183
|
+
// Convert to 16-bit signed integer
|
|
184
|
+
const int16 = Math.round(sample * 32767);
|
|
185
|
+
wav.writeInt16LE(int16, offset);
|
|
186
|
+
offset += 2;
|
|
187
|
+
}
|
|
188
|
+
return wav;
|
|
189
|
+
}
|
|
190
|
+
// --- Loudness Limiter ---
|
|
191
|
+
/** Apply a hard loudness cap with gentle compression to a buffer. */
|
|
192
|
+
export function limitLoudness(buffer, ceiling = 0.85) {
|
|
193
|
+
const result = new Float64Array(buffer.length);
|
|
194
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
195
|
+
let s = buffer[i];
|
|
196
|
+
// Soft knee compression above 70% of ceiling
|
|
197
|
+
const knee = ceiling * 0.7;
|
|
198
|
+
const abs = Math.abs(s);
|
|
199
|
+
if (abs > knee) {
|
|
200
|
+
const over = abs - knee;
|
|
201
|
+
const range = ceiling - knee;
|
|
202
|
+
// Compress the overshoot with a sqrt curve
|
|
203
|
+
const compressed = knee + range * Math.sqrt(over / range);
|
|
204
|
+
s = s > 0 ? Math.min(compressed, ceiling) : Math.max(-compressed, -ceiling);
|
|
205
|
+
}
|
|
206
|
+
result[i] = s;
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
// --- Volume scaling ---
|
|
211
|
+
/** Apply a volume gain (0.0–1.0) to a buffer. */
|
|
212
|
+
export function applyVolume(buffer, gain) {
|
|
213
|
+
if (gain >= 1)
|
|
214
|
+
return buffer;
|
|
215
|
+
const result = new Float64Array(buffer.length);
|
|
216
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
217
|
+
result[i] = buffer[i] * gain;
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Generate a whoosh: bandpass-filtered noise with a sweeping center frequency.
|
|
223
|
+
* Uses a state-variable filter (SVF) for stable real-time coefficient changes.
|
|
224
|
+
*/
|
|
225
|
+
export function generateWhoosh(params) {
|
|
226
|
+
const numSamples = Math.floor(params.duration * SAMPLE_RATE);
|
|
227
|
+
const buffer = new Float64Array(numSamples);
|
|
228
|
+
// SVF state
|
|
229
|
+
let low = 0;
|
|
230
|
+
let band = 0;
|
|
231
|
+
for (let i = 0; i < numSamples; i++) {
|
|
232
|
+
const t = i / SAMPLE_RATE;
|
|
233
|
+
const progress = i / numSamples;
|
|
234
|
+
// Sweep the center frequency
|
|
235
|
+
const centerFreq = params.freqStart + (params.freqEnd - params.freqStart) * progress;
|
|
236
|
+
// SVF coefficients
|
|
237
|
+
const f = 2 * Math.sin((Math.PI * centerFreq) / SAMPLE_RATE);
|
|
238
|
+
const q = params.bandwidth;
|
|
239
|
+
// White noise input
|
|
240
|
+
const input = Math.random() * 2 - 1;
|
|
241
|
+
// State-variable filter iteration (bandpass output)
|
|
242
|
+
low += f * band;
|
|
243
|
+
const high = input - low - q * band;
|
|
244
|
+
band += f * high;
|
|
245
|
+
let sample = band; // bandpass output
|
|
246
|
+
// Envelope
|
|
247
|
+
sample *= envelopeAt(params.envelope, t, params.duration);
|
|
248
|
+
// Gain
|
|
249
|
+
sample *= params.gain;
|
|
250
|
+
buffer[i] = sample;
|
|
251
|
+
}
|
|
252
|
+
return buffer;
|
|
253
|
+
}
|
|
254
|
+
export { SAMPLE_RATE, BIT_DEPTH, NUM_CHANNELS };
|
package/dist/verbs.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verb generation engine — profile-driven.
|
|
3
|
+
* Reads synthesis parameters from a Profile, applies modifiers, generates audio.
|
|
4
|
+
*/
|
|
5
|
+
import type { Profile } from "./profiles.js";
|
|
6
|
+
export type Status = "ok" | "err" | "warn";
|
|
7
|
+
export type Scope = "local" | "remote";
|
|
8
|
+
export type Direction = "up" | "down";
|
|
9
|
+
export interface PlayOptions {
|
|
10
|
+
status?: Status;
|
|
11
|
+
scope?: Scope;
|
|
12
|
+
direction?: Direction;
|
|
13
|
+
}
|
|
14
|
+
export type Verb = "intake" | "transform" | "commit" | "navigate" | "execute" | "move" | "sync";
|
|
15
|
+
export declare const VERB_LABELS: Record<Verb, string>;
|
|
16
|
+
export declare const VERB_DESCRIPTIONS: Record<Verb, string>;
|
|
17
|
+
export declare const ALL_VERBS: Verb[];
|
|
18
|
+
/** Generate a verb sound using the given profile. */
|
|
19
|
+
export declare function generateVerb(profile: Profile, verb: Verb, options?: PlayOptions): Float64Array;
|
|
20
|
+
export declare function generateSessionStart(profile: Profile): Float64Array;
|
|
21
|
+
export declare function generateSessionEnd(profile: Profile): Float64Array;
|
|
22
|
+
/** Generate a single chunk of the ambient thinking drone. */
|
|
23
|
+
export declare function generateAmbientChunk(profile: Profile): Float64Array;
|
|
24
|
+
/** Generate the resolution stinger (two ascending notes). */
|
|
25
|
+
export declare function generateAmbientResolve(profile: Profile): Float64Array;
|
package/dist/verbs.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verb generation engine — profile-driven.
|
|
3
|
+
* Reads synthesis parameters from a Profile, applies modifiers, generates audio.
|
|
4
|
+
*/
|
|
5
|
+
import { SAMPLE_RATE, generateTone, generateWhoosh, mixBuffers, limitLoudness, } from "./synth.js";
|
|
6
|
+
export const VERB_LABELS = {
|
|
7
|
+
intake: "Intake",
|
|
8
|
+
transform: "Transform",
|
|
9
|
+
commit: "Commit",
|
|
10
|
+
navigate: "Navigate",
|
|
11
|
+
execute: "Execute",
|
|
12
|
+
move: "Move",
|
|
13
|
+
sync: "Sync",
|
|
14
|
+
};
|
|
15
|
+
export const VERB_DESCRIPTIONS = {
|
|
16
|
+
intake: "read / open / fetch",
|
|
17
|
+
transform: "edit / format / refactor",
|
|
18
|
+
commit: "write / save / apply",
|
|
19
|
+
navigate: "search / jump / list",
|
|
20
|
+
execute: "run / test / build",
|
|
21
|
+
move: "move / rename / relocate",
|
|
22
|
+
sync: "git push / pull / deploy",
|
|
23
|
+
};
|
|
24
|
+
export const ALL_VERBS = [
|
|
25
|
+
"intake",
|
|
26
|
+
"transform",
|
|
27
|
+
"commit",
|
|
28
|
+
"navigate",
|
|
29
|
+
"execute",
|
|
30
|
+
"move",
|
|
31
|
+
"sync",
|
|
32
|
+
];
|
|
33
|
+
// --- Status modifier functions (tone-based) ---
|
|
34
|
+
function applyToneStatus(params, status) {
|
|
35
|
+
const p = { ...params };
|
|
36
|
+
switch (status) {
|
|
37
|
+
case "ok":
|
|
38
|
+
p.harmonicGain = 0.15;
|
|
39
|
+
break;
|
|
40
|
+
case "err":
|
|
41
|
+
p.frequency *= 0.7;
|
|
42
|
+
if (p.frequencyEnd)
|
|
43
|
+
p.frequencyEnd *= 0.7;
|
|
44
|
+
p.detune = 3;
|
|
45
|
+
p.duration *= 1.3;
|
|
46
|
+
break;
|
|
47
|
+
case "warn":
|
|
48
|
+
p.tremoloRate = 8;
|
|
49
|
+
p.tremoloDepth = 0.5;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
function applyToneScope(params, scope) {
|
|
55
|
+
if (scope === "remote") {
|
|
56
|
+
const p = { ...params };
|
|
57
|
+
p.envelope = { ...p.envelope, release: p.envelope.release * 2.5 };
|
|
58
|
+
p.duration *= 1.2;
|
|
59
|
+
return p;
|
|
60
|
+
}
|
|
61
|
+
return params;
|
|
62
|
+
}
|
|
63
|
+
// --- Status modifier functions (whoosh-based) ---
|
|
64
|
+
function applyWhooshStatus(params, status) {
|
|
65
|
+
const p = { ...params };
|
|
66
|
+
switch (status) {
|
|
67
|
+
case "ok":
|
|
68
|
+
p.freqEnd = Math.min(p.freqEnd * 1.2, 6000);
|
|
69
|
+
break;
|
|
70
|
+
case "err":
|
|
71
|
+
p.freqStart *= 0.6;
|
|
72
|
+
p.freqEnd *= 0.6;
|
|
73
|
+
p.duration *= 1.3;
|
|
74
|
+
p.bandwidth = 1.2;
|
|
75
|
+
break;
|
|
76
|
+
case "warn":
|
|
77
|
+
p.duration *= 0.8;
|
|
78
|
+
p.envelope = { ...p.envelope, attack: 0.01, release: 0.03 };
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
function applyWhooshScope(params, scope) {
|
|
84
|
+
if (scope === "remote") {
|
|
85
|
+
const p = { ...params };
|
|
86
|
+
p.duration *= 1.3;
|
|
87
|
+
p.envelope = { ...p.envelope, release: p.envelope.release * 2 };
|
|
88
|
+
return p;
|
|
89
|
+
}
|
|
90
|
+
return params;
|
|
91
|
+
}
|
|
92
|
+
// --- Config → Params conversion ---
|
|
93
|
+
function toneConfigToParams(cfg) {
|
|
94
|
+
return {
|
|
95
|
+
waveform: cfg.waveform,
|
|
96
|
+
frequency: cfg.frequency,
|
|
97
|
+
frequencyEnd: cfg.frequencyEnd,
|
|
98
|
+
duration: cfg.duration,
|
|
99
|
+
envelope: { ...cfg.envelope },
|
|
100
|
+
gain: cfg.gain,
|
|
101
|
+
fmRatio: cfg.fmRatio,
|
|
102
|
+
fmDepth: cfg.fmDepth,
|
|
103
|
+
harmonicGain: cfg.harmonicGain,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function whooshConfigToParams(cfg, direction) {
|
|
107
|
+
const freqPair = direction === "down" ? cfg.freqDown : cfg.freqUp;
|
|
108
|
+
return {
|
|
109
|
+
duration: cfg.duration,
|
|
110
|
+
freqStart: freqPair[0],
|
|
111
|
+
freqEnd: freqPair[1],
|
|
112
|
+
bandwidth: cfg.bandwidth,
|
|
113
|
+
envelope: { ...cfg.envelope },
|
|
114
|
+
gain: cfg.gain,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// --- Main generation ---
|
|
118
|
+
/** Generate a verb sound using the given profile. */
|
|
119
|
+
export function generateVerb(profile, verb, options = {}) {
|
|
120
|
+
const cfg = profile.verbs[verb];
|
|
121
|
+
if (!cfg) {
|
|
122
|
+
throw new Error(`Profile "${profile.name}" has no config for verb "${verb}"`);
|
|
123
|
+
}
|
|
124
|
+
// Whoosh-based verbs
|
|
125
|
+
if (cfg.type === "whoosh") {
|
|
126
|
+
const dir = options.direction ?? "up";
|
|
127
|
+
let wp = whooshConfigToParams(cfg, dir);
|
|
128
|
+
if (options.status)
|
|
129
|
+
wp = applyWhooshStatus(wp, options.status);
|
|
130
|
+
if (options.scope)
|
|
131
|
+
wp = applyWhooshScope(wp, options.scope);
|
|
132
|
+
const whoosh = generateWhoosh(wp);
|
|
133
|
+
// Tonal anchor (sync)
|
|
134
|
+
if (cfg.tonalAnchor) {
|
|
135
|
+
const anchorFreqs = dir === "down" ? cfg.tonalAnchor.freqDown : cfg.tonalAnchor.freqUp;
|
|
136
|
+
const anchor = generateTone({
|
|
137
|
+
waveform: "sine",
|
|
138
|
+
frequency: anchorFreqs[0],
|
|
139
|
+
frequencyEnd: anchorFreqs[1],
|
|
140
|
+
duration: wp.duration,
|
|
141
|
+
envelope: { ...cfg.tonalAnchor.envelope },
|
|
142
|
+
gain: cfg.tonalAnchor.gain,
|
|
143
|
+
});
|
|
144
|
+
return limitLoudness(mixBuffers([whoosh, anchor]));
|
|
145
|
+
}
|
|
146
|
+
return limitLoudness(whoosh);
|
|
147
|
+
}
|
|
148
|
+
// Tone-based verbs
|
|
149
|
+
let params = toneConfigToParams(cfg);
|
|
150
|
+
if (options.status)
|
|
151
|
+
params = applyToneStatus(params, options.status);
|
|
152
|
+
if (options.scope)
|
|
153
|
+
params = applyToneScope(params, options.scope);
|
|
154
|
+
const primary = generateTone(params);
|
|
155
|
+
// Noise burst layer (execute)
|
|
156
|
+
if (cfg.noiseBurst) {
|
|
157
|
+
const noise = generateTone({
|
|
158
|
+
waveform: "noise",
|
|
159
|
+
frequency: 0,
|
|
160
|
+
duration: cfg.noiseBurst.duration,
|
|
161
|
+
envelope: { attack: 0.002, decay: 0.03, sustain: 0.0, release: 0.005 },
|
|
162
|
+
gain: cfg.noiseBurst.gain,
|
|
163
|
+
});
|
|
164
|
+
return limitLoudness(mixBuffers([primary, noise]));
|
|
165
|
+
}
|
|
166
|
+
return limitLoudness(primary);
|
|
167
|
+
}
|
|
168
|
+
// --- Session sounds ---
|
|
169
|
+
function generateChime(chime) {
|
|
170
|
+
const tone1 = generateTone({
|
|
171
|
+
waveform: chime.tone1.waveform,
|
|
172
|
+
frequency: chime.tone1.frequency,
|
|
173
|
+
duration: chime.tone1.duration,
|
|
174
|
+
envelope: { ...chime.tone1.envelope },
|
|
175
|
+
gain: chime.tone1.gain,
|
|
176
|
+
harmonicGain: chime.tone1.harmonicGain,
|
|
177
|
+
});
|
|
178
|
+
const tone2 = generateTone({
|
|
179
|
+
waveform: chime.tone2.waveform,
|
|
180
|
+
frequency: chime.tone2.frequency,
|
|
181
|
+
duration: chime.tone2.duration,
|
|
182
|
+
envelope: { ...chime.tone2.envelope },
|
|
183
|
+
gain: chime.tone2.gain,
|
|
184
|
+
harmonicGain: chime.tone2.harmonicGain,
|
|
185
|
+
});
|
|
186
|
+
const offset = Math.floor(chime.staggerSeconds * SAMPLE_RATE);
|
|
187
|
+
const total = new Float64Array(Math.max(tone1.length, offset + tone2.length));
|
|
188
|
+
total.set(tone1, 0);
|
|
189
|
+
for (let i = 0; i < tone2.length; i++) {
|
|
190
|
+
if (offset + i < total.length) {
|
|
191
|
+
total[offset + i] += tone2[i];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return limitLoudness(total);
|
|
195
|
+
}
|
|
196
|
+
export function generateSessionStart(profile) {
|
|
197
|
+
return generateChime(profile.sessionStart);
|
|
198
|
+
}
|
|
199
|
+
export function generateSessionEnd(profile) {
|
|
200
|
+
return generateChime(profile.sessionEnd);
|
|
201
|
+
}
|
|
202
|
+
// --- Ambient (long-running thinking) ---
|
|
203
|
+
/** Generate a single chunk of the ambient thinking drone. */
|
|
204
|
+
export function generateAmbientChunk(profile) {
|
|
205
|
+
const cfg = profile.ambient;
|
|
206
|
+
// Low drone with very gentle fade in/out to allow seamless looping
|
|
207
|
+
return generateTone({
|
|
208
|
+
waveform: cfg.droneWaveform,
|
|
209
|
+
frequency: cfg.droneFreq,
|
|
210
|
+
duration: cfg.chunkDuration,
|
|
211
|
+
envelope: {
|
|
212
|
+
attack: 0.3,
|
|
213
|
+
decay: 0.2,
|
|
214
|
+
sustain: 1.0,
|
|
215
|
+
release: 0.3,
|
|
216
|
+
},
|
|
217
|
+
gain: cfg.droneGain,
|
|
218
|
+
// Subtle tremolo gives the drone "life" instead of a dead flat tone
|
|
219
|
+
tremoloRate: 0.5,
|
|
220
|
+
tremoloDepth: 0.15,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** Generate the resolution stinger (two ascending notes). */
|
|
224
|
+
export function generateAmbientResolve(profile) {
|
|
225
|
+
const cfg = profile.ambient;
|
|
226
|
+
const note1 = generateTone({
|
|
227
|
+
waveform: cfg.resolveWaveform,
|
|
228
|
+
frequency: cfg.resolveNote1,
|
|
229
|
+
duration: cfg.resolveDuration,
|
|
230
|
+
envelope: { attack: 0.008, decay: 0.06, sustain: 0.25, release: 0.04 },
|
|
231
|
+
gain: cfg.resolveGain,
|
|
232
|
+
harmonicGain: 0.1,
|
|
233
|
+
});
|
|
234
|
+
const note2 = generateTone({
|
|
235
|
+
waveform: cfg.resolveWaveform,
|
|
236
|
+
frequency: cfg.resolveNote2,
|
|
237
|
+
duration: cfg.resolveDuration * 1.3,
|
|
238
|
+
envelope: { attack: 0.008, decay: 0.08, sustain: 0.25, release: 0.06 },
|
|
239
|
+
gain: cfg.resolveGain * 0.9,
|
|
240
|
+
harmonicGain: 0.1,
|
|
241
|
+
});
|
|
242
|
+
const offset = Math.floor(cfg.resolveDuration * 0.6 * SAMPLE_RATE);
|
|
243
|
+
const total = new Float64Array(offset + note2.length);
|
|
244
|
+
total.set(note1, 0);
|
|
245
|
+
for (let i = 0; i < note2.length; i++) {
|
|
246
|
+
if (offset + i < total.length) {
|
|
247
|
+
total[offset + i] += note2[i];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return limitLoudness(total);
|
|
251
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mcptoolshop/claude-sfx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Procedural audio feedback for Claude Code — UX for agentic coding",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-sfx": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"demo": "node dist/cli.js demo",
|
|
13
|
+
"play": "node dist/cli.js play",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"prepublishOnly": "npm run build && npm test"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"sound",
|
|
21
|
+
"audio",
|
|
22
|
+
"feedback",
|
|
23
|
+
"hooks",
|
|
24
|
+
"accessibility",
|
|
25
|
+
"developer-experience",
|
|
26
|
+
"synth",
|
|
27
|
+
"procedural-audio"
|
|
28
|
+
],
|
|
29
|
+
"author": "mcp-tool-shop",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/mcp-tool-shop-org/claude-sfx.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://mcp-tool-shop-org.github.io/claude-sfx/",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18.0.0"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist/",
|
|
44
|
+
"profiles/",
|
|
45
|
+
"assets/logo.jpg",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^25.3.1",
|
|
51
|
+
"typescript": "^5.7.0",
|
|
52
|
+
"vitest": "^4.0.18"
|
|
53
|
+
}
|
|
54
|
+
}
|