@mcptoolshop/claude-sfx 1.2.0 → 1.3.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/CHANGELOG.md +19 -0
- package/dist/guard.d.ts +8 -0
- package/dist/guard.js +1 -1
- package/dist/hook-handler.js +14 -1
- package/dist/mix.d.ts +42 -0
- package/dist/mix.js +119 -0
- package/dist/player.js +19 -9
- package/dist/profiles.js +13 -13
- package/dist/synth.d.ts +5 -0
- package/dist/synth.js +15 -0
- package/dist/verbs.d.ts +4 -0
- package/dist/verbs.js +51 -6
- package/package.json +1 -1
- package/profiles/minimal.json +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.3.0] - 2026-03-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Density-aware ducking** (`mix.ts`) — rolling 3s window classifies event density as sparse/medium/bursty. Gain and release are scaled per density level. High-priority verbs (commit, sync) duck less; low-priority (navigate) ducks more during bursts.
|
|
14
|
+
- **Repetition guard** — same verb firing 2+ times in 3s triggers variant rotation, gain reduction (×0.95→×0.85), and release shortening. Prevents search/grep spam from becoming a chirp wall.
|
|
15
|
+
- **Remote low-pass softening** — one-pole RC filter (6dB/octave rolloff at 2500 Hz) applied to all remote-scope sounds. Mimics distance by absorbing high frequencies, complementing the existing envelope/gain changes.
|
|
16
|
+
- **Per-verb ducking priorities** — commit/sync are "high" (protected from heavy ducking), navigate is "low" (maximally ducked during bursts), others are "normal"
|
|
17
|
+
- **Gain floor** at 0.40 prevents sounds from disappearing under worst-case stacking (bursty + many repeats + low priority)
|
|
18
|
+
- `applyLowPass(buffer, cutoffHz)` in synth.ts — reusable one-pole LP filter
|
|
19
|
+
- `MixAdvice` type and `getMixAdvice()` in mix.ts — pure function returning ducking adjustments
|
|
20
|
+
- `mixGain` and `mixRelease` fields in `PlayOptions` for density/repetition adjustments
|
|
21
|
+
- 59 new tests (262 total)
|
|
22
|
+
|
|
23
|
+
## [1.2.1] - 2026-03-30
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **Windows audio playback** — use ffplay (ffmpeg) instead of PowerShell SoundPlayer, which silently failed on short audio clips. Falls back to PowerShell if ffplay is not installed.
|
|
27
|
+
- **Gain levels too low** — boosted all verb master gains from 0.16–0.20 to 0.50–0.60 so sounds are actually audible through system speakers
|
|
28
|
+
|
|
10
29
|
## [1.2.0] - 2026-03-30
|
|
11
30
|
|
|
12
31
|
### Changed
|
package/dist/guard.d.ts
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
* Each play call reads/writes a small JSON ledger.
|
|
7
7
|
*/
|
|
8
8
|
import { type SfxConfig } from "./config.js";
|
|
9
|
+
export interface LedgerEntry {
|
|
10
|
+
verb: string;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
}
|
|
13
|
+
export interface Ledger {
|
|
14
|
+
entries: LedgerEntry[];
|
|
15
|
+
}
|
|
16
|
+
export declare function readLedger(): Ledger;
|
|
9
17
|
export interface GuardResult {
|
|
10
18
|
allowed: boolean;
|
|
11
19
|
reason?: string;
|
package/dist/guard.js
CHANGED
|
@@ -18,7 +18,7 @@ const RATE_LIMIT_MAX = 8;
|
|
|
18
18
|
const RATE_LIMIT_WINDOW_MS = 10_000;
|
|
19
19
|
/** Ledger file — tiny JSON tracking recent plays. */
|
|
20
20
|
const LEDGER_FILE = join(tmpdir(), "claude-sfx-ledger.json");
|
|
21
|
-
function readLedger() {
|
|
21
|
+
export function readLedger() {
|
|
22
22
|
if (!existsSync(LEDGER_FILE)) {
|
|
23
23
|
return { entries: [] };
|
|
24
24
|
}
|
package/dist/hook-handler.js
CHANGED
|
@@ -14,7 +14,8 @@ import { applyVolume } from "./synth.js";
|
|
|
14
14
|
import { playSync } from "./player.js";
|
|
15
15
|
import { resolveProfile } from "./profiles.js";
|
|
16
16
|
import { loadConfig, resolveProfileName, volumeToGain } from "./config.js";
|
|
17
|
-
import { guardPlay } from "./guard.js";
|
|
17
|
+
import { guardPlay, readLedger } from "./guard.js";
|
|
18
|
+
import { getMixAdvice } from "./mix.js";
|
|
18
19
|
import { resolveAmbient, isAmbientRunning } from "./ambient.js";
|
|
19
20
|
import { recordSuccess, recordError, getSessionOutcome, resetStreak } from "./streak.js";
|
|
20
21
|
/** Map a PostToolUse tool_name to a verb + options. */
|
|
@@ -122,6 +123,18 @@ function playVerb(verb, options = {}) {
|
|
|
122
123
|
const { intensity } = recordSuccess(verb);
|
|
123
124
|
options = { ...options, intensity };
|
|
124
125
|
}
|
|
126
|
+
// Mix intelligence — density ducking + repetition guard
|
|
127
|
+
const ledger = readLedger();
|
|
128
|
+
const advice = getMixAdvice(verb, ledger.entries);
|
|
129
|
+
if (advice.gainMultiplier < 1) {
|
|
130
|
+
options = { ...options, mixGain: advice.gainMultiplier };
|
|
131
|
+
}
|
|
132
|
+
if (advice.releaseMultiplier < 1) {
|
|
133
|
+
options = { ...options, mixRelease: advice.releaseMultiplier };
|
|
134
|
+
}
|
|
135
|
+
if (advice.forcedVariantIndex !== undefined && options.variantIndex === undefined) {
|
|
136
|
+
options = { ...options, variantIndex: advice.forcedVariantIndex };
|
|
137
|
+
}
|
|
125
138
|
const profileName = resolveProfileName(config);
|
|
126
139
|
const profile = resolveProfile(profileName);
|
|
127
140
|
let buffer = generateVerb(profile, verb, options);
|
package/dist/mix.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mix intelligence — density-aware ducking and repetition guard.
|
|
3
|
+
* Pure functions that read the guard ledger and return adjustment advice.
|
|
4
|
+
* No side effects, no writes — the guard module owns the ledger.
|
|
5
|
+
*/
|
|
6
|
+
import type { LedgerEntry } from "./guard.js";
|
|
7
|
+
export type DensityLevel = "sparse" | "medium" | "bursty";
|
|
8
|
+
export type DuckingPriority = "high" | "normal" | "low";
|
|
9
|
+
export interface MixAdvice {
|
|
10
|
+
/** Gain multiplier (0.4–1.0), applied on top of existing gain. */
|
|
11
|
+
gainMultiplier: number;
|
|
12
|
+
/** Release multiplier (< 1.0 shortens tails under density). */
|
|
13
|
+
releaseMultiplier: number;
|
|
14
|
+
/** If set, force this variant index (for repetition rotation). */
|
|
15
|
+
forcedVariantIndex?: number;
|
|
16
|
+
/** Current density level (for diagnostics/testing). */
|
|
17
|
+
density: DensityLevel;
|
|
18
|
+
/** How many times the same verb repeated recently. */
|
|
19
|
+
recentRepeatCount: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Classify event density based on recent entries in the window.
|
|
23
|
+
* 0–2 events = sparse, 3–5 = medium, 6+ = bursty.
|
|
24
|
+
*/
|
|
25
|
+
export declare function classifyDensity(entries: LedgerEntry[], now?: number): DensityLevel;
|
|
26
|
+
/**
|
|
27
|
+
* Count how many times the same verb fired in the recent window.
|
|
28
|
+
* Returns the count of prior occurrences (0 = first time).
|
|
29
|
+
*/
|
|
30
|
+
export declare function countRecentRepeats(entries: LedgerEntry[], verb: string, now?: number, windowMs?: number): number;
|
|
31
|
+
/**
|
|
32
|
+
* Get the ducking priority for a verb.
|
|
33
|
+
* High = important culmination events (less ducking).
|
|
34
|
+
* Low = frequent scan events (more ducking during bursts).
|
|
35
|
+
* Normal = everything else.
|
|
36
|
+
*/
|
|
37
|
+
export declare function getDuckingPriority(verb: string): DuckingPriority;
|
|
38
|
+
/**
|
|
39
|
+
* Compute mix advice for a verb given the current ledger state.
|
|
40
|
+
* Returns gain/release multipliers, optional forced variant, and diagnostics.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getMixAdvice(verb: string, entries: LedgerEntry[], now?: number): MixAdvice;
|
package/dist/mix.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mix intelligence — density-aware ducking and repetition guard.
|
|
3
|
+
* Pure functions that read the guard ledger and return adjustment advice.
|
|
4
|
+
* No side effects, no writes — the guard module owns the ledger.
|
|
5
|
+
*/
|
|
6
|
+
// --- Constants ---
|
|
7
|
+
/** Window for density classification and repeat counting. */
|
|
8
|
+
const DENSITY_WINDOW_MS = 3000;
|
|
9
|
+
/** Minimum gain multiplier — prevents sounds from disappearing entirely. */
|
|
10
|
+
const GAIN_FLOOR = 0.40;
|
|
11
|
+
// --- Density classification ---
|
|
12
|
+
/**
|
|
13
|
+
* Classify event density based on recent entries in the window.
|
|
14
|
+
* 0–2 events = sparse, 3–5 = medium, 6+ = bursty.
|
|
15
|
+
*/
|
|
16
|
+
export function classifyDensity(entries, now = Date.now()) {
|
|
17
|
+
const cutoff = now - DENSITY_WINDOW_MS;
|
|
18
|
+
const recent = entries.filter((e) => e.timestamp > cutoff);
|
|
19
|
+
if (recent.length <= 2)
|
|
20
|
+
return "sparse";
|
|
21
|
+
if (recent.length <= 5)
|
|
22
|
+
return "medium";
|
|
23
|
+
return "bursty";
|
|
24
|
+
}
|
|
25
|
+
// --- Repetition counting ---
|
|
26
|
+
/**
|
|
27
|
+
* Count how many times the same verb fired in the recent window.
|
|
28
|
+
* Returns the count of prior occurrences (0 = first time).
|
|
29
|
+
*/
|
|
30
|
+
export function countRecentRepeats(entries, verb, now = Date.now(), windowMs = DENSITY_WINDOW_MS) {
|
|
31
|
+
const cutoff = now - windowMs;
|
|
32
|
+
return entries.filter((e) => e.timestamp > cutoff && e.verb === verb).length;
|
|
33
|
+
}
|
|
34
|
+
// --- Priority tiers ---
|
|
35
|
+
/**
|
|
36
|
+
* Get the ducking priority for a verb.
|
|
37
|
+
* High = important culmination events (less ducking).
|
|
38
|
+
* Low = frequent scan events (more ducking during bursts).
|
|
39
|
+
* Normal = everything else.
|
|
40
|
+
*/
|
|
41
|
+
export function getDuckingPriority(verb) {
|
|
42
|
+
switch (verb) {
|
|
43
|
+
case "commit":
|
|
44
|
+
case "sync":
|
|
45
|
+
return "high";
|
|
46
|
+
case "navigate":
|
|
47
|
+
return "low";
|
|
48
|
+
default:
|
|
49
|
+
return "normal";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// --- Density ducking tables ---
|
|
53
|
+
const DENSITY_GAIN = {
|
|
54
|
+
sparse: { high: 1.0, normal: 1.0, low: 1.0 },
|
|
55
|
+
medium: { high: 0.95, normal: 0.88, low: 0.82 },
|
|
56
|
+
bursty: { high: 0.90, normal: 0.75, low: 0.65 },
|
|
57
|
+
};
|
|
58
|
+
const DENSITY_RELEASE = {
|
|
59
|
+
sparse: 1.0,
|
|
60
|
+
medium: 0.85,
|
|
61
|
+
bursty: 0.70,
|
|
62
|
+
};
|
|
63
|
+
// --- Repetition ducking ---
|
|
64
|
+
function getRepetitionGain(repeatCount) {
|
|
65
|
+
if (repeatCount <= 1)
|
|
66
|
+
return 1.0;
|
|
67
|
+
if (repeatCount === 2)
|
|
68
|
+
return 0.95;
|
|
69
|
+
if (repeatCount === 3)
|
|
70
|
+
return 0.90;
|
|
71
|
+
return 0.85; // 4+
|
|
72
|
+
}
|
|
73
|
+
function getRepetitionRelease(repeatCount) {
|
|
74
|
+
if (repeatCount <= 1)
|
|
75
|
+
return 1.0;
|
|
76
|
+
if (repeatCount === 2)
|
|
77
|
+
return 1.0;
|
|
78
|
+
if (repeatCount === 3)
|
|
79
|
+
return 0.85;
|
|
80
|
+
return 0.80; // 4+
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Determine variant rotation for repeated verbs.
|
|
84
|
+
* After 2+ repeats, force rotation to the next variant.
|
|
85
|
+
*/
|
|
86
|
+
function getRepetitionVariant(entries, verb, now, repeatCount) {
|
|
87
|
+
if (repeatCount < 2)
|
|
88
|
+
return undefined;
|
|
89
|
+
// Rotate based on repeat count (simple modular rotation across 3 variants)
|
|
90
|
+
return repeatCount % 3;
|
|
91
|
+
}
|
|
92
|
+
// --- Main entry point ---
|
|
93
|
+
/**
|
|
94
|
+
* Compute mix advice for a verb given the current ledger state.
|
|
95
|
+
* Returns gain/release multipliers, optional forced variant, and diagnostics.
|
|
96
|
+
*/
|
|
97
|
+
export function getMixAdvice(verb, entries, now = Date.now()) {
|
|
98
|
+
const density = classifyDensity(entries, now);
|
|
99
|
+
const priority = getDuckingPriority(verb);
|
|
100
|
+
const repeatCount = countRecentRepeats(entries, verb, now);
|
|
101
|
+
// Density ducking
|
|
102
|
+
const densityGain = DENSITY_GAIN[density][priority];
|
|
103
|
+
const densityRelease = DENSITY_RELEASE[density];
|
|
104
|
+
// Repetition ducking (composes multiplicatively)
|
|
105
|
+
const repGain = getRepetitionGain(repeatCount);
|
|
106
|
+
const repRelease = getRepetitionRelease(repeatCount);
|
|
107
|
+
// Compose
|
|
108
|
+
const gainMultiplier = Math.max(densityGain * repGain, GAIN_FLOOR);
|
|
109
|
+
const releaseMultiplier = Math.max(densityRelease * repRelease, 0.50);
|
|
110
|
+
// Variant rotation
|
|
111
|
+
const forcedVariantIndex = getRepetitionVariant(entries, verb, now, repeatCount);
|
|
112
|
+
return {
|
|
113
|
+
gainMultiplier,
|
|
114
|
+
releaseMultiplier,
|
|
115
|
+
forcedVariantIndex,
|
|
116
|
+
density,
|
|
117
|
+
recentRepeatCount: repeatCount,
|
|
118
|
+
};
|
|
119
|
+
}
|
package/dist/player.js
CHANGED
|
@@ -22,15 +22,25 @@ function getTempPath() {
|
|
|
22
22
|
function getPlayCommand(filePath) {
|
|
23
23
|
const platform = process.platform;
|
|
24
24
|
if (platform === "win32") {
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
// Prefer ffplay (from ffmpeg) — fast, reliable, no UI
|
|
26
|
+
try {
|
|
27
|
+
execSync("where ffplay", { stdio: "ignore" });
|
|
28
|
+
return {
|
|
29
|
+
command: "ffplay",
|
|
30
|
+
args: ["-nodisp", "-autoexit", "-loglevel", "quiet", filePath],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Fallback: PowerShell SoundPlayer
|
|
35
|
+
return {
|
|
36
|
+
command: "powershell",
|
|
37
|
+
args: [
|
|
38
|
+
"-NoProfile",
|
|
39
|
+
"-Command",
|
|
40
|
+
`(New-Object Media.SoundPlayer '${filePath}').PlaySync()`,
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
34
44
|
}
|
|
35
45
|
if (platform === "darwin") {
|
|
36
46
|
return { command: "afplay", args: [filePath] };
|
package/dist/profiles.js
CHANGED
|
@@ -66,7 +66,7 @@ const MINIMAL_PROFILE = {
|
|
|
66
66
|
// ── intake: light, inviting, curious ───────────────────────────
|
|
67
67
|
intake: {
|
|
68
68
|
type: "motif",
|
|
69
|
-
gain: 0.
|
|
69
|
+
gain: 0.55,
|
|
70
70
|
variants: [
|
|
71
71
|
// Variant A: A4 → C#5
|
|
72
72
|
{ notes: [
|
|
@@ -88,7 +88,7 @@ const MINIMAL_PROFILE = {
|
|
|
88
88
|
// ── transform: active, clever, articulate ─────────────────────
|
|
89
89
|
transform: {
|
|
90
90
|
type: "motif",
|
|
91
|
-
gain: 0.
|
|
91
|
+
gain: 0.52,
|
|
92
92
|
variants: [
|
|
93
93
|
// Variant A: C#5 → B4 → E5 (3-note)
|
|
94
94
|
{ notes: [
|
|
@@ -112,7 +112,7 @@ const MINIMAL_PROFILE = {
|
|
|
112
112
|
// ── commit: satisfying, settled, rewarding ────────────────────
|
|
113
113
|
commit: {
|
|
114
114
|
type: "motif",
|
|
115
|
-
gain: 0.
|
|
115
|
+
gain: 0.60,
|
|
116
116
|
variants: [
|
|
117
117
|
// Variant A: E4+A4 dyad → C#5 sparkle
|
|
118
118
|
{ notes: [
|
|
@@ -137,7 +137,7 @@ const MINIMAL_PROFILE = {
|
|
|
137
137
|
// ── navigate: precise, clean, directional ─────────────────────
|
|
138
138
|
navigate: {
|
|
139
139
|
type: "motif",
|
|
140
|
-
gain: 0.
|
|
140
|
+
gain: 0.50,
|
|
141
141
|
variants: [
|
|
142
142
|
// Variant A: B4 → E5
|
|
143
143
|
{ notes: [
|
|
@@ -159,7 +159,7 @@ const MINIMAL_PROFILE = {
|
|
|
159
159
|
// ── execute: grounded, tactile, percussive ────────────────────
|
|
160
160
|
execute: {
|
|
161
161
|
type: "motif",
|
|
162
|
-
gain: 0.
|
|
162
|
+
gain: 0.58,
|
|
163
163
|
variants: [
|
|
164
164
|
// Variant A: A3 body + noise click
|
|
165
165
|
{
|
|
@@ -193,7 +193,7 @@ const MINIMAL_PROFILE = {
|
|
|
193
193
|
freqDown: [2400, 700],
|
|
194
194
|
bandwidth: 0.7,
|
|
195
195
|
envelope: { attack: 0.01, decay: 0.11, sustain: 0.10, release: 0.06 },
|
|
196
|
-
gain: 0.
|
|
196
|
+
gain: 0.52,
|
|
197
197
|
anchorVariants: [
|
|
198
198
|
// Variant A: E4 → A4
|
|
199
199
|
{ notes: [
|
|
@@ -219,7 +219,7 @@ const MINIMAL_PROFILE = {
|
|
|
219
219
|
freqDown: [2600, 500],
|
|
220
220
|
bandwidth: 0.65,
|
|
221
221
|
envelope: { attack: 0.008, decay: 0.12, sustain: 0.16, release: 0.09 },
|
|
222
|
-
gain: 0.
|
|
222
|
+
gain: 0.58,
|
|
223
223
|
anchorVariants: [
|
|
224
224
|
// Variant A: A3+E4 anchor → C#5 confirmation
|
|
225
225
|
{ notes: [
|
|
@@ -250,7 +250,7 @@ const MINIMAL_PROFILE = {
|
|
|
250
250
|
frequency: S.A4,
|
|
251
251
|
duration: 0.18,
|
|
252
252
|
envelope: { attack: 0.01, decay: 0.08, sustain: 0.25, release: 0.06 },
|
|
253
|
-
gain: 0.
|
|
253
|
+
gain: 0.60,
|
|
254
254
|
harmonicGain: 0.08,
|
|
255
255
|
},
|
|
256
256
|
tone2: {
|
|
@@ -260,7 +260,7 @@ const MINIMAL_PROFILE = {
|
|
|
260
260
|
frequency: S.Cs5,
|
|
261
261
|
duration: 0.22,
|
|
262
262
|
envelope: { attack: 0.01, decay: 0.10, sustain: 0.25, release: 0.08 },
|
|
263
|
-
gain: 0.
|
|
263
|
+
gain: 0.55,
|
|
264
264
|
harmonicGain: 0.08,
|
|
265
265
|
},
|
|
266
266
|
staggerSeconds: 0.065,
|
|
@@ -273,7 +273,7 @@ const MINIMAL_PROFILE = {
|
|
|
273
273
|
frequency: S.Cs5,
|
|
274
274
|
duration: 0.15,
|
|
275
275
|
envelope: { attack: 0.01, decay: 0.08, sustain: 0.18, release: 0.04 },
|
|
276
|
-
gain: 0.
|
|
276
|
+
gain: 0.55,
|
|
277
277
|
},
|
|
278
278
|
tone2: {
|
|
279
279
|
waveform: "sine",
|
|
@@ -282,20 +282,20 @@ const MINIMAL_PROFILE = {
|
|
|
282
282
|
frequency: S.A4,
|
|
283
283
|
duration: 0.25,
|
|
284
284
|
envelope: { attack: 0.01, decay: 0.12, sustain: 0.18, release: 0.10 },
|
|
285
|
-
gain: 0.
|
|
285
|
+
gain: 0.50,
|
|
286
286
|
},
|
|
287
287
|
staggerSeconds: 0.075,
|
|
288
288
|
},
|
|
289
289
|
ambient: {
|
|
290
290
|
droneFreq: S.A3,
|
|
291
291
|
droneWaveform: "sine",
|
|
292
|
-
droneGain: 0.
|
|
292
|
+
droneGain: 0.25,
|
|
293
293
|
chunkDuration: 2.0,
|
|
294
294
|
resolveNote1: S.A4,
|
|
295
295
|
resolveNote2: S.E5,
|
|
296
296
|
resolveWaveform: "sine",
|
|
297
297
|
resolveDuration: 0.16,
|
|
298
|
-
resolveGain: 0.
|
|
298
|
+
resolveGain: 0.55,
|
|
299
299
|
},
|
|
300
300
|
};
|
|
301
301
|
// --- Retro profile: same motifs, square waveform, snappier envelopes ---
|
package/dist/synth.d.ts
CHANGED
|
@@ -46,6 +46,11 @@ export declare function mixBuffers(buffers: Float64Array[]): Float64Array;
|
|
|
46
46
|
export declare function concatBuffers(buffers: Float64Array[], gapSeconds?: number): Float64Array;
|
|
47
47
|
/** Encode a Float64 audio buffer as a WAV file (PCM 16-bit mono). */
|
|
48
48
|
export declare function encodeWav(buffer: Float64Array): Buffer;
|
|
49
|
+
/**
|
|
50
|
+
* One-pole RC low-pass filter.
|
|
51
|
+
* Gentle 6dB/octave rolloff — mimics distance (air absorbs highs).
|
|
52
|
+
*/
|
|
53
|
+
export declare function applyLowPass(buffer: Float64Array, cutoffHz: number): Float64Array;
|
|
49
54
|
/** Apply a hard loudness cap with gentle compression to a buffer. */
|
|
50
55
|
export declare function limitLoudness(buffer: Float64Array, ceiling?: number): Float64Array;
|
|
51
56
|
/** Apply a volume gain (0.0–1.0) to a buffer. */
|
package/dist/synth.js
CHANGED
|
@@ -187,6 +187,21 @@ export function encodeWav(buffer) {
|
|
|
187
187
|
}
|
|
188
188
|
return wav;
|
|
189
189
|
}
|
|
190
|
+
// --- Low-Pass Filter ---
|
|
191
|
+
/**
|
|
192
|
+
* One-pole RC low-pass filter.
|
|
193
|
+
* Gentle 6dB/octave rolloff — mimics distance (air absorbs highs).
|
|
194
|
+
*/
|
|
195
|
+
export function applyLowPass(buffer, cutoffHz) {
|
|
196
|
+
const a = Math.exp(-2 * Math.PI * cutoffHz / SAMPLE_RATE);
|
|
197
|
+
const result = new Float64Array(buffer.length);
|
|
198
|
+
let prev = 0;
|
|
199
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
200
|
+
prev = (1 - a) * buffer[i] + a * prev;
|
|
201
|
+
result[i] = prev;
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
190
205
|
// --- Loudness Limiter ---
|
|
191
206
|
/** Apply a hard loudness cap with gentle compression to a buffer. */
|
|
192
207
|
export function limitLoudness(buffer, ceiling = 0.85) {
|
package/dist/verbs.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ export interface PlayOptions {
|
|
|
19
19
|
escalation?: number;
|
|
20
20
|
/** Override variant selection (0-indexed). For testing determinism. */
|
|
21
21
|
variantIndex?: number;
|
|
22
|
+
/** Mix-layer gain multiplier from density/repetition analysis (0.4–1.0). */
|
|
23
|
+
mixGain?: number;
|
|
24
|
+
/** Mix-layer release multiplier from density/repetition analysis. */
|
|
25
|
+
mixRelease?: number;
|
|
22
26
|
}
|
|
23
27
|
export type Verb = "intake" | "transform" | "commit" | "navigate" | "execute" | "move" | "sync";
|
|
24
28
|
export declare const VERB_LABELS: Record<Verb, string>;
|
package/dist/verbs.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Architecture: multi-note motifs in A major pentatonic with constrained variation.
|
|
6
6
|
* Each verb has 3 variants. Micro-jitter keeps sounds alive without breaking identity.
|
|
7
7
|
*/
|
|
8
|
-
import { SAMPLE_RATE, generateTone, generateWhoosh, mixBuffers, limitLoudness, } from "./synth.js";
|
|
8
|
+
import { SAMPLE_RATE, generateTone, generateWhoosh, mixBuffers, limitLoudness, applyLowPass, applyVolume, } from "./synth.js";
|
|
9
9
|
import { SCALE, scaleStepDown } from "./profiles.js";
|
|
10
10
|
export const VERB_LABELS = {
|
|
11
11
|
intake: "Intake",
|
|
@@ -335,6 +335,30 @@ function applyWhooshEscalation(params, escalation) {
|
|
|
335
335
|
}
|
|
336
336
|
return p;
|
|
337
337
|
}
|
|
338
|
+
// --- Mix modifiers (density/repetition from Pass B) ---
|
|
339
|
+
/** Scale all note release times in a motif (density/repetition ducking). */
|
|
340
|
+
function applyMotifMixRelease(variant, multiplier) {
|
|
341
|
+
if (multiplier >= 1)
|
|
342
|
+
return variant;
|
|
343
|
+
return {
|
|
344
|
+
...variant,
|
|
345
|
+
notes: variant.notes.map(n => ({
|
|
346
|
+
...n,
|
|
347
|
+
envelope: { ...n.envelope, release: n.envelope.release * multiplier },
|
|
348
|
+
})),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/** Scale whoosh envelope release (density/repetition ducking). */
|
|
352
|
+
function applyWhooshMixRelease(params, multiplier) {
|
|
353
|
+
if (multiplier >= 1)
|
|
354
|
+
return params;
|
|
355
|
+
return {
|
|
356
|
+
...params,
|
|
357
|
+
envelope: { ...params.envelope, release: params.envelope.release * multiplier },
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/** Low-pass cutoff for remote scope (Hz). Gentle rolloff mimicking distance. */
|
|
361
|
+
const REMOTE_LP_CUTOFF = 2500;
|
|
338
362
|
// --- Main generation ---
|
|
339
363
|
/** Generate a verb sound using the given profile. */
|
|
340
364
|
export function generateVerb(profile, verb, options = {}) {
|
|
@@ -342,6 +366,7 @@ export function generateVerb(profile, verb, options = {}) {
|
|
|
342
366
|
if (!cfg) {
|
|
343
367
|
throw new Error(`Profile "${profile.name}" has no config for verb "${verb}"`);
|
|
344
368
|
}
|
|
369
|
+
const isRemote = options.scope === "remote";
|
|
345
370
|
// ── Whoosh-based verbs (move, sync) ─────────────────────────
|
|
346
371
|
if (cfg.type === "whoosh") {
|
|
347
372
|
const dir = options.direction ?? "up";
|
|
@@ -362,11 +387,13 @@ export function generateVerb(profile, verb, options = {}) {
|
|
|
362
387
|
wp = applyWhooshIntensity(wp, options.intensity);
|
|
363
388
|
if (options.escalation)
|
|
364
389
|
wp = applyWhooshEscalation(wp, options.escalation);
|
|
390
|
+
if (options.mixRelease && options.mixRelease < 1)
|
|
391
|
+
wp = applyWhooshMixRelease(wp, options.mixRelease);
|
|
365
392
|
const whooshBuf = generateWhoosh(wp);
|
|
366
393
|
// Tonal anchor variant
|
|
394
|
+
let result;
|
|
367
395
|
if (cfg.anchorVariants && cfg.anchorVariants.length > 0) {
|
|
368
396
|
let anchor = pickVariant(cfg.anchorVariants, options.variantIndex);
|
|
369
|
-
// Apply motif modifiers to anchor notes too
|
|
370
397
|
let anchorMotif = { notes: anchor.notes };
|
|
371
398
|
if (options.status)
|
|
372
399
|
anchorMotif = applyMotifStatus(anchorMotif, options.status);
|
|
@@ -376,16 +403,26 @@ export function generateVerb(profile, verb, options = {}) {
|
|
|
376
403
|
anchorMotif = applyMotifIntensity(anchorMotif, options.intensity);
|
|
377
404
|
if (options.escalation)
|
|
378
405
|
anchorMotif = applyMotifEscalation(anchorMotif, options.escalation);
|
|
406
|
+
if (options.mixRelease && options.mixRelease < 1)
|
|
407
|
+
anchorMotif = applyMotifMixRelease(anchorMotif, options.mixRelease);
|
|
379
408
|
const anchorBuf = renderMotif(anchorMotif, cfg.gain);
|
|
380
|
-
// Mix: align to same start, extend to longest
|
|
381
409
|
const maxLen = Math.max(whooshBuf.length, anchorBuf.length);
|
|
382
410
|
const whooshPadded = new Float64Array(maxLen);
|
|
383
411
|
whooshPadded.set(whooshBuf, 0);
|
|
384
412
|
const anchorPadded = new Float64Array(maxLen);
|
|
385
413
|
anchorPadded.set(anchorBuf, 0);
|
|
386
|
-
|
|
414
|
+
result = mixBuffers([whooshPadded, anchorPadded]);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
result = whooshBuf;
|
|
387
418
|
}
|
|
388
|
-
|
|
419
|
+
// Remote low-pass softening
|
|
420
|
+
if (isRemote)
|
|
421
|
+
result = applyLowPass(result, REMOTE_LP_CUTOFF);
|
|
422
|
+
// Mix gain ducking
|
|
423
|
+
if (options.mixGain && options.mixGain < 1)
|
|
424
|
+
result = applyVolume(result, options.mixGain);
|
|
425
|
+
return limitLoudness(result);
|
|
389
426
|
}
|
|
390
427
|
// ── Motif-based verbs (intake, transform, commit, navigate, execute) ──
|
|
391
428
|
let variant = pickVariant(cfg.variants, options.variantIndex);
|
|
@@ -397,7 +434,15 @@ export function generateVerb(profile, verb, options = {}) {
|
|
|
397
434
|
variant = applyMotifIntensity(variant, options.intensity);
|
|
398
435
|
if (options.escalation)
|
|
399
436
|
variant = applyMotifEscalation(variant, options.escalation);
|
|
400
|
-
|
|
437
|
+
if (options.mixRelease && options.mixRelease < 1)
|
|
438
|
+
variant = applyMotifMixRelease(variant, options.mixRelease);
|
|
439
|
+
let buffer = renderMotif(variant, cfg.gain);
|
|
440
|
+
// Remote low-pass softening
|
|
441
|
+
if (isRemote)
|
|
442
|
+
buffer = applyLowPass(buffer, REMOTE_LP_CUTOFF);
|
|
443
|
+
// Mix gain ducking
|
|
444
|
+
if (options.mixGain && options.mixGain < 1)
|
|
445
|
+
buffer = applyVolume(buffer, options.mixGain);
|
|
401
446
|
return limitLoudness(buffer);
|
|
402
447
|
}
|
|
403
448
|
// --- Session sounds ---
|
package/package.json
CHANGED
package/profiles/minimal.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"verbs": {
|
|
5
5
|
"intake": {
|
|
6
6
|
"type": "motif",
|
|
7
|
-
"gain": 0.
|
|
7
|
+
"gain": 0.55,
|
|
8
8
|
"variants": [
|
|
9
9
|
{
|
|
10
10
|
"notes": [
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
},
|
|
113
113
|
"transform": {
|
|
114
114
|
"type": "motif",
|
|
115
|
-
"gain": 0.
|
|
115
|
+
"gain": 0.52,
|
|
116
116
|
"variants": [
|
|
117
117
|
{
|
|
118
118
|
"notes": [
|
|
@@ -266,7 +266,7 @@
|
|
|
266
266
|
},
|
|
267
267
|
"commit": {
|
|
268
268
|
"type": "motif",
|
|
269
|
-
"gain": 0.
|
|
269
|
+
"gain": 0.6,
|
|
270
270
|
"variants": [
|
|
271
271
|
{
|
|
272
272
|
"notes": [
|
|
@@ -428,7 +428,7 @@
|
|
|
428
428
|
},
|
|
429
429
|
"navigate": {
|
|
430
430
|
"type": "motif",
|
|
431
|
-
"gain": 0.
|
|
431
|
+
"gain": 0.5,
|
|
432
432
|
"variants": [
|
|
433
433
|
{
|
|
434
434
|
"notes": [
|
|
@@ -542,7 +542,7 @@
|
|
|
542
542
|
},
|
|
543
543
|
"execute": {
|
|
544
544
|
"type": "motif",
|
|
545
|
-
"gain": 0.
|
|
545
|
+
"gain": 0.58,
|
|
546
546
|
"variants": [
|
|
547
547
|
{
|
|
548
548
|
"notes": [
|
|
@@ -657,7 +657,7 @@
|
|
|
657
657
|
"sustain": 0.1,
|
|
658
658
|
"release": 0.06
|
|
659
659
|
},
|
|
660
|
-
"gain": 0.
|
|
660
|
+
"gain": 0.52,
|
|
661
661
|
"anchorVariants": [
|
|
662
662
|
{
|
|
663
663
|
"notes": [
|
|
@@ -766,7 +766,7 @@
|
|
|
766
766
|
"sustain": 0.16,
|
|
767
767
|
"release": 0.09
|
|
768
768
|
},
|
|
769
|
-
"gain": 0.
|
|
769
|
+
"gain": 0.58,
|
|
770
770
|
"anchorVariants": [
|
|
771
771
|
{
|
|
772
772
|
"notes": [
|
|
@@ -916,7 +916,7 @@
|
|
|
916
916
|
"sustain": 0.25,
|
|
917
917
|
"release": 0.06
|
|
918
918
|
},
|
|
919
|
-
"gain": 0.
|
|
919
|
+
"gain": 0.6,
|
|
920
920
|
"harmonicGain": 0.08
|
|
921
921
|
},
|
|
922
922
|
"tone2": {
|
|
@@ -931,7 +931,7 @@
|
|
|
931
931
|
"sustain": 0.25,
|
|
932
932
|
"release": 0.08
|
|
933
933
|
},
|
|
934
|
-
"gain": 0.
|
|
934
|
+
"gain": 0.55,
|
|
935
935
|
"harmonicGain": 0.08
|
|
936
936
|
},
|
|
937
937
|
"staggerSeconds": 0.065
|
|
@@ -949,7 +949,7 @@
|
|
|
949
949
|
"sustain": 0.18,
|
|
950
950
|
"release": 0.04
|
|
951
951
|
},
|
|
952
|
-
"gain": 0.
|
|
952
|
+
"gain": 0.55
|
|
953
953
|
},
|
|
954
954
|
"tone2": {
|
|
955
955
|
"waveform": "sine",
|
|
@@ -963,19 +963,19 @@
|
|
|
963
963
|
"sustain": 0.18,
|
|
964
964
|
"release": 0.1
|
|
965
965
|
},
|
|
966
|
-
"gain": 0.
|
|
966
|
+
"gain": 0.5
|
|
967
967
|
},
|
|
968
968
|
"staggerSeconds": 0.075
|
|
969
969
|
},
|
|
970
970
|
"ambient": {
|
|
971
971
|
"droneFreq": 220,
|
|
972
972
|
"droneWaveform": "sine",
|
|
973
|
-
"droneGain": 0.
|
|
973
|
+
"droneGain": 0.25,
|
|
974
974
|
"chunkDuration": 2,
|
|
975
975
|
"resolveNote1": 440,
|
|
976
976
|
"resolveNote2": 659.25,
|
|
977
977
|
"resolveWaveform": "sine",
|
|
978
978
|
"resolveDuration": 0.16,
|
|
979
|
-
"resolveGain": 0.
|
|
979
|
+
"resolveGain": 0.55
|
|
980
980
|
}
|
|
981
981
|
}
|