Browse Source

Bundle pre-generated alert sounds

main
Matteo Benedetto 4 weeks ago
parent
commit
fe80be6c05
  1. 15
      README.md
  2. BIN
      assets/block.wav
  3. BIN
      assets/confirm.wav
  4. BIN
      assets/refine.wav
  5. 87
      index.ts
  6. 4
      package-lock.json
  7. 8
      package.json
  8. 80
      scripts/generate_sounds.py

15
README.md

@ -66,17 +66,13 @@ Then load it from local `node_modules` or publish it to your npm registry later.
## Sound alerts
The extension generates three small WAV files programmatically on first use:
The package ships with three pre-generated WAV files and the extension only plays them at runtime:
- `confirm.wav` — soft ascending alert for human confirmation
- `block.wav` — lower, harsher alert for denied actions
- `refine.wav` — distinct mid-tone alert for “blocked, but reformulate safely” cases
By default it writes them under:
```text
~/.pi/agent/policy-gate-sounds/
```
By default it plays the bundled files from the package `assets/` directory, so it does **not** write audio files into your agent home at startup.
It automatically uses the first available local player among:
@ -147,7 +143,7 @@ Example:
"soundBlockEnabled": true,
"soundRefineEnabled": true,
"soundPlayer": "auto",
"soundDirectory": "~/.pi/agent/policy-gate-sounds",
"soundDirectory": "/absolute/path/to/custom/policy-gate-sounds",
"sensitivePathGlobs": ["~/.ssh/**", "**/.env"],
"overrides": [
{
@ -168,7 +164,7 @@ Example:
- `soundPlayer: "auto"` picks the first available local player.
- Set `soundEnabled: false` to disable all alert sounds.
- `soundConfirmEnabled`, `soundBlockEnabled`, and `soundRefineEnabled` let you mute just one class of event.
- `soundDirectory` can be absolute, `~`-based, or relative to the project cwd.
- `soundDirectory` is optional: by default the extension uses the package's bundled `assets/` directory. If you set it, it should point to an existing directory containing `confirm.wav`, `block.wav`, and `refine.wav`.
- `workspaceRoots` can be absolute paths, `~` paths, or paths relative to the current project cwd.
@ -212,10 +208,11 @@ Inside pi:
## Development
Install deps and verify the package tarball:
Install deps, regenerate the packaged WAV assets if needed, and verify the tarball:
```bash
npm install
npm run generate:sounds
npm run pack:check
```

BIN
assets/block.wav

Binary file not shown.

BIN
assets/confirm.wav

Binary file not shown.

BIN
assets/refine.wav

Binary file not shown.

87
index.ts

@ -1,9 +1,10 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { getAgentDir, isToolCallEventType } from "@earendil-works/pi-coding-agent";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import os from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { minimatch } from "minimatch";
type PolicyAction = "allow" | "confirm" | "deny" | "refine";
@ -67,9 +68,10 @@ interface Decision {
const HOME = os.homedir();
const AGENT_DIR = getAgentDir();
const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url));
const GLOBAL_CONFIG = resolve(AGENT_DIR, "policy-gate.json");
const PROJECT_CONFIG_NAME = ".pi/policy-gate.json";
const DEFAULT_SOUND_DIR = resolve(AGENT_DIR, "policy-gate-sounds");
const DEFAULT_SOUND_DIR = resolve(PACKAGE_DIR, "assets");
const AUDIO_PLAYER_CANDIDATES = ["paplay", "pw-play", "aplay", "ffplay", "play"];
const DEFAULT_SENSITIVE_PATH_GLOBS = [
@ -412,84 +414,6 @@ function soundPath(kind: AlertSoundKind, config: ResolvedConfig): string {
return join(config.soundDirectory, `${kind}.wav`);
}
function createAlertWav(kind: AlertSoundKind): Buffer {
const sampleRate = 44100;
const amplitude = kind === "block" ? 0.42 : kind === "refine" ? 0.3 : 0.28;
const segments =
kind === "confirm"
? [
{ durationMs: 80, freqs: [660, 990] },
{ durationMs: 35, freqs: [] },
{ durationMs: 95, freqs: [880, 1320] },
{ durationMs: 35, freqs: [] },
{ durationMs: 130, freqs: [1174, 1568] },
]
: kind === "refine"
? [
{ durationMs: 95, freqs: [587, 784] },
{ durationMs: 32, freqs: [] },
{ durationMs: 95, freqs: [659, 880] },
{ durationMs: 32, freqs: [] },
{ durationMs: 95, freqs: [587, 784] },
]
: [
{ durationMs: 120, freqs: [392, 587] },
{ durationMs: 36, freqs: [] },
{ durationMs: 130, freqs: [294, 440] },
{ durationMs: 36, freqs: [] },
{ durationMs: 190, freqs: [196, 294] },
];
const sampleCount = segments.reduce((sum, segment) => sum + Math.round((segment.durationMs / 1000) * sampleRate), 0);
const pcm = Buffer.alloc(sampleCount * 2);
let sampleOffset = 0;
for (const segment of segments) {
const segmentSamples = Math.round((segment.durationMs / 1000) * sampleRate);
for (let i = 0; i < segmentSamples; i += 1) {
const globalIndex = sampleOffset + i;
let sample = 0;
if (segment.freqs.length > 0) {
const t = i / sampleRate;
const attack = Math.min(1, i / Math.max(1, segmentSamples * 0.1));
const release = Math.min(1, (segmentSamples - i) / Math.max(1, segmentSamples * 0.12));
const envelope = Math.min(attack, release);
for (const freq of segment.freqs) {
sample += Math.sin(2 * Math.PI * freq * t);
}
sample = (sample / segment.freqs.length) * amplitude * envelope;
}
pcm.writeInt16LE(Math.max(-1, Math.min(1, sample)) * 32767, globalIndex * 2);
}
sampleOffset += segmentSamples;
}
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
function ensureSoundAssets(config: ResolvedConfig) {
if (!config.soundEnabled) return;
mkdirSync(config.soundDirectory, { recursive: true });
for (const kind of ["confirm", "block", "refine"] as const) {
const target = soundPath(kind, config);
writeFileSync(target, createAlertWav(kind));
}
}
function playAlert(kind: AlertSoundKind, config: ResolvedConfig) {
if (!config.soundEnabled) return;
if (kind === "confirm" && !config.soundConfirmEnabled) return;
@ -498,8 +422,8 @@ function playAlert(kind: AlertSoundKind, config: ResolvedConfig) {
const player = resolveAudioPlayer(config.soundPlayer);
if (!player) return;
try {
ensureSoundAssets(config);
const file = soundPath(kind, config);
if (!existsSync(file)) return;
const args =
player === "ffplay"
? ["-nodisp", "-autoexit", "-loglevel", "quiet", file]
@ -944,7 +868,6 @@ export default function policyGate(pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
const extraConfig = pi.getFlag("policy-gate-config") as string | undefined;
activeConfig = loadConfig(ctx.cwd, extraConfig ? resolveConfigPath(extraConfig, ctx.cwd) : undefined);
ensureSoundAssets(activeConfig);
if (ctx.hasUI) {
ctx.ui.setStatus("policy-gate", ctx.ui.theme.fg("accent", "policy-gate active"));
}

4
package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "@enne2/pi-policy-gate",
"version": "0.4.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@enne2/pi-policy-gate",
"version": "0.4.0",
"version": "0.5.0",
"license": "MIT",
"dependencies": {
"minimatch": "^10.1.1"

8
package.json

@ -1,6 +1,6 @@
{
"name": "@enne2/pi-policy-gate",
"version": "0.4.0",
"version": "0.5.0",
"description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.",
"type": "module",
"keywords": [
@ -13,7 +13,8 @@
"files": [
"index.ts",
"README.md",
"policy-gate.example.json"
"policy-gate.example.json",
"assets/*.wav"
],
"pi": {
"extensions": [
@ -22,7 +23,8 @@
},
"scripts": {
"check": "npm pack --dry-run >/dev/null",
"pack:check": "npm pack --dry-run"
"pack:check": "npm pack --dry-run",
"generate:sounds": "python3 scripts/generate_sounds.py"
},
"dependencies": {
"minimatch": "^10.1.1"

80
scripts/generate_sounds.py

@ -0,0 +1,80 @@
#!/usr/bin/env python3
from __future__ import annotations
import math
import wave
from pathlib import Path
SAMPLE_RATE = 44100
PROFILES = {
"confirm": {
"amplitude": 0.28,
"segments": [
(80, [660, 990]),
(35, []),
(95, [880, 1320]),
(35, []),
(130, [1174, 1568]),
],
},
"block": {
"amplitude": 0.42,
"segments": [
(120, [392, 587]),
(36, []),
(130, [294, 440]),
(36, []),
(190, [196, 294]),
],
},
"refine": {
"amplitude": 0.30,
"segments": [
(95, [587, 784]),
(32, []),
(95, [659, 880]),
(32, []),
(95, [587, 784]),
],
},
}
def synth_segment(duration_ms: int, freqs: list[int], amplitude: float) -> list[int]:
samples = round((duration_ms / 1000) * SAMPLE_RATE)
data: list[int] = []
for i in range(samples):
sample = 0.0
if freqs:
t = i / SAMPLE_RATE
attack = min(1.0, i / max(1, int(samples * 0.1)))
release = min(1.0, (samples - i) / max(1, int(samples * 0.12)))
envelope = min(attack, release)
sample = sum(math.sin(2 * math.pi * freq * t) for freq in freqs) / len(freqs)
sample *= amplitude * envelope
data.append(int(max(-1.0, min(1.0, sample)) * 32767))
return data
def write_wave(path: Path, profile: dict[str, object]) -> None:
amplitude = float(profile["amplitude"])
segments = profile["segments"]
pcm: list[int] = []
for duration_ms, freqs in segments: # type: ignore[misc]
pcm.extend(synth_segment(duration_ms, list(freqs), amplitude))
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(SAMPLE_RATE)
wav.writeframes(b"".join(sample.to_bytes(2, "little", signed=True) for sample in pcm))
if __name__ == "__main__":
root = Path(__file__).resolve().parent.parent
assets = root / "assets"
for name, profile in PROFILES.items():
write_wave(assets / f"{name}.wav", profile)
print(f"wrote {assets / f'{name}.wav'}")
Loading…
Cancel
Save