pingthings 0.1.0 → 0.2.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/README.md CHANGED
@@ -252,11 +252,87 @@ To support informational mode, add an `events` field mapping event types to soun
252
252
  }
253
253
  ```
254
254
 
255
+ ## Themes
256
+
257
+ Apply a pre-built theme that maps events to packs with one command:
258
+
259
+ ```bash
260
+ pingthings theme # list available themes
261
+ pingthings theme sci-fi # apply a theme
262
+ pingthings theme reset # back to defaults
263
+ ```
264
+
265
+ | Theme | Description |
266
+ |-------|-------------|
267
+ | `retro` | Classic retro gaming — Freedoom weapons + 7kaa soldiers |
268
+ | `sci-fi` | Sci-fi command center — Warzone 2100 commander voice |
269
+ | `arena` | Arena announcer with FPS weapons for errors |
270
+ | `fantasy` | Medieval fantasy — Wesnoth + 0 A.D. civilizations |
271
+ | `ancient` | Ancient world — 7kaa soldiers + 0 A.D. voices |
272
+ | `chaos` | Different pack for every event — maximum variety |
273
+
274
+ ## Tools
275
+
276
+ ### Create a pack
277
+ ```bash
278
+ pingthings create ./my-sounds my-pack
279
+ ```
280
+ Scaffolds a new pack from a folder of audio files with an auto-generated manifest.
281
+
282
+ ### Install from GitHub
283
+ ```bash
284
+ pingthings install user/repo
285
+ pingthings install https://github.com/user/pack-name
286
+ ```
287
+
288
+ ### Auto-setup Claude Code hooks
289
+ ```bash
290
+ pingthings init # interactive wizard
291
+ pingthings init --basic # random sounds, no prompts
292
+ pingthings init --informational # event-based sounds, no prompts
293
+ ```
294
+
295
+ ## Use with other tools
296
+
297
+ pingthings works anywhere you can run a shell command.
298
+
299
+ ### Git hooks
300
+ ```bash
301
+ # .git/hooks/post-commit
302
+ #!/bin/sh
303
+ pingthings play --event done
304
+ ```
305
+
306
+ ### CI notifications (GitHub Actions)
307
+ ```yaml
308
+ - name: Notify on failure
309
+ if: failure()
310
+ run: npx pingthings play --event error
311
+ ```
312
+
313
+ ### Shell aliases
314
+ ```bash
315
+ # Add to ~/.zshrc or ~/.bashrc
316
+ alias done='pingthings play --event complete'
317
+ alias oops='pingthings play --event error'
318
+ ```
319
+
320
+ ### Pomodoro timer
321
+ ```bash
322
+ sleep 1500 && pingthings play --event complete # 25 minute focus
323
+ ```
324
+
255
325
  ## Requirements
256
326
 
257
327
  - Node.js >= 18
258
- - macOS (`afplay`) or Linux (`paplay` / `aplay`)
328
+ - macOS (`afplay`), Linux (`paplay` / `aplay`), or Windows (PowerShell)
259
329
 
260
330
  ## License
261
331
 
262
- GPL v2 — includes audio from [Seven Kingdoms: Ancient Adversaries](https://github.com/the3dfxdude/7kaa) (GPL v2).
332
+ GPL v2 — includes audio from open source games:
333
+ - [Seven Kingdoms: Ancient Adversaries](https://github.com/the3dfxdude/7kaa) (GPL v2)
334
+ - [Battle for Wesnoth](https://github.com/wesnoth/wesnoth) (GPL v2+)
335
+ - [OpenArena](http://openarena.ws) (GPL v2)
336
+ - [Freedoom](https://github.com/freedoom/freedoom) (BSD-3-Clause)
337
+ - [Warzone 2100](https://github.com/Warzone2100/warzone2100) (GPL v2)
338
+ - [0 A.D.](https://github.com/0ad/0ad) (CC-BY-SA 3.0)
package/bin/pingthings.js CHANGED
@@ -14,9 +14,14 @@ const commands = {
14
14
  play: () => import('../src/cli/play.js'),
15
15
  list: () => import('../src/cli/list.js'),
16
16
  use: () => import('../src/cli/use.js'),
17
+ select: () => import('../src/cli/select.js'),
17
18
  preview: () => import('../src/cli/preview.js'),
18
19
  config: () => import('../src/cli/config.js'),
19
20
  install: () => import('../src/cli/install.js'),
21
+ init: () => import('../src/cli/init.js'),
22
+ create: () => import('../src/cli/create.js'),
23
+ theme: () => import('../src/cli/theme.js'),
24
+ 'test-events': () => import('../src/cli/test-events.js'),
20
25
  };
21
26
 
22
27
  function showHelp() {
@@ -28,10 +33,15 @@ Usage: pingthings <command> [options]
28
33
  Commands:
29
34
  play [sound] Play a sound from the active pack (random by default)
30
35
  list Show available sound packs
36
+ select Interactive pack selector
31
37
  use <pack> Set the active sound pack
32
38
  preview <pack> Preview a random sound from a pack
39
+ test-events [pack] Play all event sounds to hear what each one sounds like
40
+ theme [name] Apply a sound theme (maps events across packs)
33
41
  config [key] [val] Show or update configuration
34
- install <pack> Install a sound pack (coming soon)
42
+ init Set up Claude Code hooks automatically
43
+ create <dir> Create a new pack from a folder of audio files
44
+ install <source> Install a pack from GitHub or URL
35
45
 
36
46
  Options:
37
47
  --help, -h Show this help message
@@ -47,9 +57,11 @@ Examples:
47
57
  pingthings play 00083-READY Play a specific sound
48
58
  pingthings play --event done Play a "task done" sound
49
59
  pingthings play -e error Play an "error" sound
50
- pingthings list List all available packs
51
- pingthings use 7kaa-soldiers Switch to a pack
52
- pingthings config mode informational Enable event-based sounds
60
+ pingthings select Choose a pack interactively
61
+ pingthings test-events Hear all event sounds
62
+ pingthings theme retro Apply the retro theme
63
+ pingthings init Set up Claude Code hooks
64
+ pingthings config volume 50 Set volume to 50%
53
65
  `);
54
66
  }
55
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pingthings",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Notification sounds for Claude Code and other CLI tools",
5
5
  "type": "module",
6
6
  "license": "GPL-2.0",
package/src/cli/config.js CHANGED
@@ -1,8 +1,39 @@
1
- import { readConfig, writeConfig, VALID_MODES } from '../config.js';
1
+ import { readConfig, writeConfig, VALID_MODES, VALID_EVENTS } from '../config.js';
2
2
 
3
- const VALID_KEYS = ['activePack', 'mode', 'specificSound'];
3
+ const VALID_KEYS = ['activePack', 'mode', 'specificSound', 'volume', 'eventPacks'];
4
+
5
+ function showHelp() {
6
+ console.log(`
7
+ Usage: pingthings config [key] [value]
8
+
9
+ Show or update configuration.
10
+
11
+ With no arguments, shows the full config.
12
+ With one argument, shows that key's value.
13
+ With two arguments, sets the key to the value.
14
+
15
+ Keys:
16
+ activePack Which sound pack to use
17
+ mode random, specific, or informational
18
+ specificSound Sound name for specific mode
19
+ volume Playback volume (0-100)
20
+ eventPacks Per-event pack overrides (use "pingthings config eventPacks.<event> <pack>")
21
+
22
+ Examples:
23
+ pingthings config Show full config
24
+ pingthings config volume Show current volume
25
+ pingthings config volume 50 Set volume to 50%
26
+ pingthings config mode informational Enable event-based sounds
27
+ pingthings config eventPacks.error openarena-announcer
28
+ `);
29
+ }
4
30
 
5
31
  export default function config(args) {
32
+ if (args.includes('--help') || args.includes('-h')) {
33
+ showHelp();
34
+ return;
35
+ }
36
+
6
37
  const [key, ...rest] = args;
7
38
  const value = rest.join(' ') || undefined;
8
39
 
@@ -13,6 +44,31 @@ export default function config(args) {
13
44
  return;
14
45
  }
15
46
 
47
+ // Handle eventPacks.event syntax
48
+ if (key.startsWith('eventPacks.')) {
49
+ const event = key.split('.')[1];
50
+ if (!VALID_EVENTS.includes(event)) {
51
+ console.error(`Unknown event: ${event}`);
52
+ console.error(`Valid events: ${VALID_EVENTS.join(', ')}`);
53
+ process.exit(1);
54
+ }
55
+ if (value === undefined) {
56
+ const cfg = readConfig();
57
+ console.log(cfg.eventPacks?.[event] ?? '(not set — uses active pack)');
58
+ return;
59
+ }
60
+ const cfg = readConfig();
61
+ if (!cfg.eventPacks) cfg.eventPacks = {};
62
+ if (value === 'null' || value === 'reset') {
63
+ delete cfg.eventPacks[event];
64
+ } else {
65
+ cfg.eventPacks[event] = value;
66
+ }
67
+ writeConfig(cfg);
68
+ console.log(`${event} pack set to: ${value === 'null' || value === 'reset' ? '(active pack)' : value}`);
69
+ return;
70
+ }
71
+
16
72
  // Validate key
17
73
  if (!VALID_KEYS.includes(key)) {
18
74
  console.error(`Unknown config key: ${key}`);
@@ -23,7 +79,12 @@ export default function config(args) {
23
79
  // One arg: show value
24
80
  if (value === undefined) {
25
81
  const cfg = readConfig();
26
- console.log(cfg[key] ?? '(not set)');
82
+ const val = cfg[key];
83
+ if (typeof val === 'object' && val !== null) {
84
+ console.log(JSON.stringify(val, null, 2));
85
+ } else {
86
+ console.log(val ?? '(not set)');
87
+ }
27
88
  return;
28
89
  }
29
90
 
@@ -34,6 +95,20 @@ export default function config(args) {
34
95
  process.exit(1);
35
96
  }
36
97
 
98
+ // Validate volume
99
+ if (key === 'volume') {
100
+ const vol = parseInt(value, 10);
101
+ if (isNaN(vol) || vol < 0 || vol > 100) {
102
+ console.error('Volume must be a number between 0 and 100.');
103
+ process.exit(1);
104
+ }
105
+ const cfg = readConfig();
106
+ cfg.volume = vol;
107
+ writeConfig(cfg);
108
+ console.log(`volume set to: ${vol}`);
109
+ return;
110
+ }
111
+
37
112
  // Two args: set value
38
113
  const cfg = readConfig();
39
114
  cfg[key] = value === 'null' ? null : value;
@@ -0,0 +1,109 @@
1
+ import { existsSync, readdirSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs';
2
+ import { join, basename, extname } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { getConfigDir } from '../config.js';
5
+
6
+ const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.ogg', '.flac']);
7
+
8
+ function showHelp() {
9
+ console.log(`
10
+ Usage: pingthings create <source-dir> [pack-name]
11
+
12
+ Create a new sound pack from a folder of audio files.
13
+
14
+ Arguments:
15
+ source-dir Directory containing audio files (.wav, .mp3, .ogg, .flac)
16
+ pack-name Name for the pack (defaults to directory name)
17
+
18
+ The pack will be installed to ~/.config/pingthings/packs/<pack-name>/
19
+ with an auto-generated manifest.json.
20
+
21
+ Examples:
22
+ pingthings create ./my-sounds
23
+ pingthings create ./my-sounds custom-pack
24
+ `);
25
+ }
26
+
27
+ export default async function create(args) {
28
+ if (args.includes('--help') || args.includes('-h')) {
29
+ showHelp();
30
+ return;
31
+ }
32
+
33
+ const sourceDir = args[0];
34
+ if (!sourceDir) {
35
+ showHelp();
36
+ process.exit(1);
37
+ }
38
+
39
+ if (!existsSync(sourceDir)) {
40
+ console.error(`Directory not found: ${sourceDir}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ // Find audio files
45
+ const audioFiles = readdirSync(sourceDir)
46
+ .filter(f => AUDIO_EXTENSIONS.has(extname(f).toLowerCase()))
47
+ .sort();
48
+
49
+ if (audioFiles.length === 0) {
50
+ console.error(`No audio files found in ${sourceDir}`);
51
+ console.error('Supported formats: .wav, .mp3, .ogg, .flac');
52
+ process.exit(1);
53
+ }
54
+
55
+ const packName = args[1] || basename(sourceDir);
56
+ const packsDir = join(getConfigDir(), 'packs');
57
+ const packDir = join(packsDir, packName);
58
+ const soundsDir = join(packDir, 'sounds');
59
+
60
+ if (existsSync(packDir)) {
61
+ console.error(`Pack already exists: ${packDir}`);
62
+ console.error('Delete it first or choose a different name.');
63
+ process.exit(1);
64
+ }
65
+
66
+ // Create pack directory structure
67
+ mkdirSync(soundsDir, { recursive: true });
68
+
69
+ // Copy audio files
70
+ console.log(`\nCreating pack "${packName}" with ${audioFiles.length} sounds...\n`);
71
+
72
+ const soundPaths = [];
73
+ for (const file of audioFiles) {
74
+ const dest = join(soundsDir, file);
75
+ copyFileSync(join(sourceDir, file), dest);
76
+ soundPaths.push(`sounds/${file}`);
77
+ console.log(` + ${file}`);
78
+ }
79
+
80
+ // Generate manifest
81
+ const manifest = {
82
+ name: packName,
83
+ description: '',
84
+ version: '1.0.0',
85
+ license: '',
86
+ credits: '',
87
+ sounds: soundPaths,
88
+ events: {
89
+ done: [],
90
+ permission: [],
91
+ complete: [],
92
+ error: [],
93
+ blocked: [],
94
+ },
95
+ };
96
+
97
+ writeFileSync(
98
+ join(packDir, 'manifest.json'),
99
+ JSON.stringify(manifest, null, 2) + '\n',
100
+ 'utf8'
101
+ );
102
+
103
+ console.log(`\nPack created at: ${packDir}`);
104
+ console.log(`Manifest: ${join(packDir, 'manifest.json')}`);
105
+ console.log('\nNext steps:');
106
+ console.log(' 1. Edit manifest.json to add description, license, and credits');
107
+ console.log(' 2. Map sounds to events in the "events" field');
108
+ console.log(` 3. Run: pingthings use ${packName}`);
109
+ }
@@ -0,0 +1,123 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings init
9
+
10
+ Interactive setup wizard that configures Claude Code hooks
11
+ in ~/.claude/settings.json automatically.
12
+
13
+ Options:
14
+ --basic Set up basic random sounds (no prompts)
15
+ --informational Set up event-based sounds (no prompts)
16
+ --help, -h Show this help message
17
+ `);
18
+ }
19
+
20
+ const BASIC_HOOKS = {
21
+ Notification: [{
22
+ matcher: '',
23
+ hooks: [{ type: 'command', command: 'pingthings play' }],
24
+ }],
25
+ };
26
+
27
+ const INFORMATIONAL_HOOKS = {
28
+ Notification: [{
29
+ matcher: '',
30
+ hooks: [{ type: 'command', command: 'pingthings play --event permission' }],
31
+ }],
32
+ Stop: [{
33
+ matcher: '',
34
+ hooks: [{ type: 'command', command: 'pingthings play --event complete' }],
35
+ }],
36
+ PostToolUseFailure: [{
37
+ matcher: '',
38
+ hooks: [{ type: 'command', command: 'pingthings play --event error' }],
39
+ }],
40
+ StopFailure: [{
41
+ matcher: '',
42
+ hooks: [{ type: 'command', command: 'pingthings play --event blocked' }],
43
+ }],
44
+ };
45
+
46
+ function getSettingsPath() {
47
+ return join(homedir(), '.claude', 'settings.json');
48
+ }
49
+
50
+ function readSettings() {
51
+ const path = getSettingsPath();
52
+ if (!existsSync(path)) return {};
53
+ try {
54
+ return JSON.parse(readFileSync(path, 'utf8'));
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ function writeSettings(settings) {
61
+ const path = getSettingsPath();
62
+ writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8');
63
+ }
64
+
65
+ function applyHooks(mode) {
66
+ const settings = readSettings();
67
+ const hooks = mode === 'informational' ? INFORMATIONAL_HOOKS : BASIC_HOOKS;
68
+ settings.hooks = { ...settings.hooks, ...hooks };
69
+ writeSettings(settings);
70
+
71
+ console.log(`\nClaude Code hooks configured (${mode} mode).`);
72
+ console.log(`Settings written to: ${getSettingsPath()}`);
73
+
74
+ if (mode === 'informational') {
75
+ console.log('\nHook mapping:');
76
+ console.log(' Notification → permission sound');
77
+ console.log(' Stop → complete sound');
78
+ console.log(' PostToolUseFailure → error sound');
79
+ console.log(' StopFailure → blocked sound');
80
+ } else {
81
+ console.log('\nA random sound will play on every Claude Code notification.');
82
+ }
83
+
84
+ console.log('\nRestart Claude Code for hooks to take effect.');
85
+ }
86
+
87
+ export default async function init(args) {
88
+ if (args.includes('--help') || args.includes('-h')) {
89
+ showHelp();
90
+ return;
91
+ }
92
+
93
+ if (args.includes('--basic')) {
94
+ applyHooks('basic');
95
+ return;
96
+ }
97
+
98
+ if (args.includes('--informational')) {
99
+ applyHooks('informational');
100
+ return;
101
+ }
102
+
103
+ console.log('\npingthings — Claude Code hook setup\n');
104
+ console.log('How would you like sounds to work?\n');
105
+ console.log(' 1. Basic — random sound on every notification');
106
+ console.log(' 2. Informational — different sounds for different events');
107
+ console.log(' (done, permission, error, blocked)\n');
108
+
109
+ const rl = createInterface({
110
+ input: process.stdin,
111
+ output: process.stdout,
112
+ });
113
+
114
+ rl.question('Choose (1 or 2): ', (answer) => {
115
+ const choice = answer.trim();
116
+ if (choice === '2') {
117
+ applyHooks('informational');
118
+ } else {
119
+ applyHooks('basic');
120
+ }
121
+ rl.close();
122
+ });
123
+ }
@@ -1,8 +1,117 @@
1
- export default function install() {
2
- console.log('Remote pack installation is coming in a future version.');
3
- console.log('');
4
- console.log('For now, you can manually install packs by placing them in:');
5
- console.log(' ~/.config/pingthings/packs/<pack-name>/');
6
- console.log('');
7
- console.log('Each pack needs a manifest.json and a sounds/ directory.');
1
+ import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { getConfigDir } from '../config.js';
5
+
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings install <source>
9
+
10
+ Install a sound pack from a GitHub repository or local path.
11
+
12
+ Arguments:
13
+ source GitHub URL, GitHub shorthand (user/repo), or local path
14
+
15
+ The pack will be installed to ~/.config/pingthings/packs/
16
+
17
+ Examples:
18
+ pingthings install user/repo
19
+ pingthings install https://github.com/user/pingthings-pack-name
20
+ pingthings install ./path/to/local/pack
21
+ `);
22
+ }
23
+
24
+ function installFromGit(source) {
25
+ const packsDir = join(getConfigDir(), 'packs');
26
+ mkdirSync(packsDir, { recursive: true });
27
+
28
+ // Convert shorthand to full URL
29
+ let url = source;
30
+ if (!source.startsWith('http') && !source.startsWith('git@') && source.includes('/')) {
31
+ url = `https://github.com/${source}.git`;
32
+ }
33
+
34
+ const repoName = basename(url, '.git');
35
+ const destDir = join(packsDir, repoName);
36
+
37
+ if (existsSync(destDir)) {
38
+ console.error(`Pack already exists: ${repoName}`);
39
+ console.error(`Delete ${destDir} first to reinstall.`);
40
+ process.exit(1);
41
+ }
42
+
43
+ console.log(`Cloning ${url}...`);
44
+
45
+ try {
46
+ execFileSync('git', ['clone', '--depth', '1', url, destDir], {
47
+ stdio: 'inherit',
48
+ });
49
+ } catch {
50
+ console.error('Failed to clone repository. Check the URL and try again.');
51
+ process.exit(1);
52
+ }
53
+
54
+ // Check for manifest
55
+ if (!existsSync(join(destDir, 'manifest.json'))) {
56
+ console.log('\nNote: No manifest.json found. The pack may need one to work properly.');
57
+ console.log('Run "pingthings create" to generate a manifest from audio files.');
58
+ }
59
+
60
+ console.log(`\nInstalled: ${repoName}`);
61
+ console.log(`Location: ${destDir}`);
62
+ console.log(`\nTo use: pingthings use ${repoName}`);
63
+ }
64
+
65
+ function installFromLocal(source) {
66
+ if (!existsSync(source)) {
67
+ console.error(`Path not found: ${source}`);
68
+ process.exit(1);
69
+ }
70
+
71
+ if (!existsSync(join(source, 'manifest.json'))) {
72
+ console.error('No manifest.json found in the source directory.');
73
+ console.error('Run "pingthings create <dir>" to create a pack from audio files.');
74
+ process.exit(1);
75
+ }
76
+
77
+ const packName = basename(source);
78
+ const packsDir = join(getConfigDir(), 'packs');
79
+ const destDir = join(packsDir, packName);
80
+
81
+ if (existsSync(destDir)) {
82
+ console.error(`Pack already exists: ${packName}`);
83
+ console.error(`Delete ${destDir} first to reinstall.`);
84
+ process.exit(1);
85
+ }
86
+
87
+ mkdirSync(packsDir, { recursive: true });
88
+
89
+ try {
90
+ execFileSync('cp', ['-r', source, destDir], { stdio: 'inherit' });
91
+ } catch {
92
+ console.error('Failed to copy pack.');
93
+ process.exit(1);
94
+ }
95
+
96
+ console.log(`Installed: ${packName}`);
97
+ console.log(`Location: ${destDir}`);
98
+ console.log(`\nTo use: pingthings use ${packName}`);
99
+ }
100
+
101
+ export default function install(args) {
102
+ const source = args[0];
103
+
104
+ if (!source || source === '--help' || source === '-h') {
105
+ showHelp();
106
+ if (!source) process.exit(1);
107
+ return;
108
+ }
109
+
110
+ // Determine if it's a git URL, shorthand, or local path
111
+ if (source.startsWith('http') || source.startsWith('git@') ||
112
+ (source.includes('/') && !source.startsWith('.') && !source.startsWith('/'))) {
113
+ installFromGit(source);
114
+ } else {
115
+ installFromLocal(source);
116
+ }
8
117
  }
package/src/cli/list.js CHANGED
@@ -1,7 +1,21 @@
1
1
  import { readConfig } from '../config.js';
2
2
  import { listPacks } from '../packs.js';
3
3
 
4
- export default function list() {
4
+ function showHelp() {
5
+ console.log(`
6
+ Usage: pingthings list
7
+
8
+ Show all available sound packs with their sound count and source.
9
+ The active pack is marked with *.
10
+ `);
11
+ }
12
+
13
+ export default function list(args) {
14
+ if (args?.includes('--help') || args?.includes('-h')) {
15
+ showHelp();
16
+ return;
17
+ }
18
+
5
19
  const config = readConfig();
6
20
  const packs = listPacks();
7
21
 
package/src/cli/play.js CHANGED
@@ -78,10 +78,15 @@ export default function play(args) {
78
78
 
79
79
  // If --event flag is provided, use event mapping
80
80
  if (parsed.event) {
81
- const eventSounds = getEventSounds(packName, parsed.event);
81
+ // Pack mixing: check if there's a per-event pack override
82
+ const eventPackName = config.eventPacks?.[parsed.event] || packName;
83
+ const eventPack = resolvePack(eventPackName);
84
+ const resolvedPack = eventPack ? eventPackName : packName;
85
+
86
+ const eventSounds = getEventSounds(resolvedPack, parsed.event);
82
87
  if (eventSounds.length === 0) {
83
88
  // Fall back to random if pack has no mapping for this event
84
- const allSounds = getPackSounds(packName);
89
+ const allSounds = getPackSounds(resolvedPack);
85
90
  soundFile = pickRandom(allSounds);
86
91
  } else {
87
92
  soundFile = pickRandom(eventSounds);
@@ -123,5 +128,5 @@ export default function play(args) {
123
128
  soundFile = pickRandom(sounds);
124
129
  }
125
130
 
126
- playSound(soundFile);
131
+ playSound(soundFile, config.volume);
127
132
  }
@@ -1,14 +1,26 @@
1
+ import { readConfig } from '../config.js';
1
2
  import { getPackSounds, pickRandom, resolvePack } from '../packs.js';
2
3
  import { playSound } from '../player.js';
3
4
  import { basename } from 'node:path';
4
5
 
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings preview <pack>
9
+
10
+ Play a random sound from a pack to hear what it sounds like.
11
+
12
+ Arguments:
13
+ pack Name of the pack to preview
14
+ `);
15
+ }
16
+
5
17
  export default function preview(args) {
6
18
  const packName = args[0];
7
19
 
8
- if (!packName) {
9
- console.error('Usage: pingthings preview <pack>');
10
- console.error('Run "pingthings list" to see available packs.');
11
- process.exit(1);
20
+ if (!packName || packName === '--help' || packName === '-h') {
21
+ showHelp();
22
+ if (!packName) process.exit(1);
23
+ return;
12
24
  }
13
25
 
14
26
  const pack = resolvePack(packName);
@@ -24,7 +36,8 @@ export default function preview(args) {
24
36
  process.exit(1);
25
37
  }
26
38
 
39
+ const config = readConfig();
27
40
  const sound = pickRandom(sounds);
28
41
  console.log(`Playing: ${basename(sound)} from "${packName}"`);
29
- playSound(sound);
42
+ playSound(sound, config.volume);
30
43
  }
@@ -0,0 +1,80 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { readConfig, writeConfig } from '../config.js';
3
+ import { listPacks, getPackSounds } from '../packs.js';
4
+ import { playSound } from '../player.js';
5
+
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings select
9
+
10
+ Interactive menu for choosing your active sound pack.
11
+ Displays a numbered list — type a number to preview and select.
12
+ `);
13
+ }
14
+
15
+ export default async function select(args) {
16
+ if (args.includes('--help') || args.includes('-h')) {
17
+ showHelp();
18
+ return;
19
+ }
20
+
21
+ const config = readConfig();
22
+ const packs = listPacks();
23
+
24
+ if (packs.length === 0) {
25
+ console.error('No sound packs found.');
26
+ process.exit(1);
27
+ }
28
+
29
+ console.log('\nChoose a sound pack:\n');
30
+
31
+ for (let i = 0; i < packs.length; i++) {
32
+ const active = packs[i].name === config.activePack ? ' *' : ' ';
33
+ console.log(`${active} ${i + 1}. ${packs[i].name} (${packs[i].soundCount} sounds)`);
34
+ if (packs[i].description) {
35
+ console.log(` ${packs[i].description}`);
36
+ }
37
+ }
38
+
39
+ console.log('\n * = current pack');
40
+ console.log(' Enter a number to preview & select, or q to quit.\n');
41
+
42
+ const rl = createInterface({
43
+ input: process.stdin,
44
+ output: process.stdout,
45
+ });
46
+
47
+ const ask = () => {
48
+ rl.question('> ', (answer) => {
49
+ const trimmed = answer.trim().toLowerCase();
50
+
51
+ if (trimmed === 'q' || trimmed === 'quit' || trimmed === '') {
52
+ rl.close();
53
+ return;
54
+ }
55
+
56
+ const num = parseInt(trimmed, 10);
57
+ if (isNaN(num) || num < 1 || num > packs.length) {
58
+ console.log(`Enter 1-${packs.length} or q to quit.`);
59
+ ask();
60
+ return;
61
+ }
62
+
63
+ const chosen = packs[num - 1];
64
+ config.activePack = chosen.name;
65
+ writeConfig(config);
66
+
67
+ // Preview a sound from the chosen pack
68
+ const sounds = getPackSounds(chosen.name);
69
+ if (sounds.length > 0) {
70
+ const sample = sounds[Math.floor(Math.random() * sounds.length)];
71
+ playSound(sample);
72
+ }
73
+
74
+ console.log(`\nActive pack set to: ${chosen.name}`);
75
+ rl.close();
76
+ });
77
+ };
78
+
79
+ ask();
80
+ }
@@ -0,0 +1,58 @@
1
+ import { readConfig, VALID_EVENTS } from '../config.js';
2
+ import { getEventSounds, getPackSounds, pickRandom, resolvePack } from '../packs.js';
3
+ import { playSound } from '../player.js';
4
+ import { basename } from 'node:path';
5
+
6
+ function showHelp() {
7
+ console.log(`
8
+ Usage: pingthings test-events [pack]
9
+
10
+ Play one sound for each event type from a pack so you can hear
11
+ what each event sounds like. Defaults to the active pack.
12
+
13
+ Arguments:
14
+ pack Pack to test (optional, defaults to active pack)
15
+
16
+ Events played: done, permission, complete, error, blocked
17
+ `);
18
+ }
19
+
20
+ function sleep(ms) {
21
+ return new Promise(resolve => setTimeout(resolve, ms));
22
+ }
23
+
24
+ export default async function testEvents(args) {
25
+ if (args.includes('--help') || args.includes('-h')) {
26
+ showHelp();
27
+ return;
28
+ }
29
+
30
+ const config = readConfig();
31
+ const packName = args[0] || config.activePack;
32
+ const pack = resolvePack(packName);
33
+
34
+ if (!pack) {
35
+ console.error(`Pack not found: ${packName}`);
36
+ console.error('Run "pingthings list" to see available packs.');
37
+ process.exit(1);
38
+ }
39
+
40
+ console.log(`\nTesting events for: ${packName}\n`);
41
+
42
+ for (const event of VALID_EVENTS) {
43
+ const sounds = getEventSounds(packName, event);
44
+ if (sounds.length === 0) {
45
+ console.log(` ${event.padEnd(12)} (no sounds mapped)`);
46
+ continue;
47
+ }
48
+
49
+ const sound = pickRandom(sounds);
50
+ console.log(` ${event.padEnd(12)} ${basename(sound)}`);
51
+ playSound(sound);
52
+
53
+ // Wait between sounds so they don't overlap
54
+ await sleep(1500);
55
+ }
56
+
57
+ console.log('\nDone.\n');
58
+ }
@@ -0,0 +1,132 @@
1
+ import { readConfig, writeConfig } from '../config.js';
2
+ import { resolvePack } from '../packs.js';
3
+
4
+ const THEMES = {
5
+ 'retro': {
6
+ description: 'Classic retro gaming — Freedoom weapons and 7kaa soldiers',
7
+ activePack: '7kaa-soldiers',
8
+ eventPacks: {
9
+ done: '7kaa-soldiers',
10
+ permission: '7kaa-soldiers',
11
+ complete: 'freedoom-arsenal',
12
+ error: 'freedoom-arsenal',
13
+ blocked: 'freedoom-arsenal',
14
+ },
15
+ },
16
+ 'sci-fi': {
17
+ description: 'Sci-fi command center — Warzone 2100 commander voice',
18
+ activePack: 'warzone2100-command',
19
+ eventPacks: {
20
+ done: 'warzone2100-command',
21
+ permission: 'warzone2100-command',
22
+ complete: 'warzone2100-command',
23
+ error: 'warzone2100-command',
24
+ blocked: 'warzone2100-command',
25
+ },
26
+ },
27
+ 'arena': {
28
+ description: 'Arena announcer with FPS weapons for errors',
29
+ activePack: 'openarena-announcer',
30
+ eventPacks: {
31
+ done: 'openarena-announcer',
32
+ permission: 'openarena-announcer',
33
+ complete: 'openarena-announcer',
34
+ error: 'freedoom-arsenal',
35
+ blocked: 'freedoom-arsenal',
36
+ },
37
+ },
38
+ 'fantasy': {
39
+ description: 'Medieval fantasy — Wesnoth combat and 0 A.D. civilizations',
40
+ activePack: 'wesnoth-combat',
41
+ eventPacks: {
42
+ done: 'wesnoth-combat',
43
+ permission: '0ad-civilizations',
44
+ complete: 'wesnoth-combat',
45
+ error: 'wesnoth-combat',
46
+ blocked: '0ad-civilizations',
47
+ },
48
+ },
49
+ 'ancient': {
50
+ description: 'Ancient world — 7kaa soldiers and 0 A.D. civilizations',
51
+ activePack: '7kaa-soldiers',
52
+ eventPacks: {
53
+ done: '7kaa-soldiers',
54
+ permission: '0ad-civilizations',
55
+ complete: '0ad-civilizations',
56
+ error: '7kaa-soldiers',
57
+ blocked: '0ad-civilizations',
58
+ },
59
+ },
60
+ 'chaos': {
61
+ description: 'Random pack for every event — maximum variety',
62
+ activePack: '7kaa-soldiers',
63
+ eventPacks: {
64
+ done: '7kaa-soldiers',
65
+ permission: 'openarena-announcer',
66
+ complete: 'warzone2100-command',
67
+ error: 'freedoom-arsenal',
68
+ blocked: 'wesnoth-combat',
69
+ },
70
+ },
71
+ };
72
+
73
+ function showHelp() {
74
+ console.log(`
75
+ Usage: pingthings theme [name]
76
+
77
+ Apply a sound theme that maps events to packs.
78
+
79
+ With no arguments, lists available themes.
80
+
81
+ Available themes:`);
82
+
83
+ for (const [name, theme] of Object.entries(THEMES)) {
84
+ console.log(` ${name.padEnd(12)} ${theme.description}`);
85
+ }
86
+
87
+ console.log(`
88
+ Examples:
89
+ pingthings theme List themes
90
+ pingthings theme sci-fi Apply the sci-fi theme
91
+ pingthings theme reset Reset to defaults
92
+ `);
93
+ }
94
+
95
+ export default function theme(args) {
96
+ const name = args[0];
97
+
98
+ if (!name || name === '--help' || name === '-h') {
99
+ showHelp();
100
+ return;
101
+ }
102
+
103
+ if (name === 'reset') {
104
+ const config = readConfig();
105
+ config.eventPacks = {};
106
+ config.mode = 'random';
107
+ writeConfig(config);
108
+ console.log('Theme reset. Using default random mode.');
109
+ return;
110
+ }
111
+
112
+ const selected = THEMES[name];
113
+ if (!selected) {
114
+ console.error(`Unknown theme: ${name}`);
115
+ console.error(`Available themes: ${Object.keys(THEMES).join(', ')}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ const config = readConfig();
120
+ config.activePack = selected.activePack;
121
+ config.mode = 'informational';
122
+ config.eventPacks = { ...selected.eventPacks };
123
+ writeConfig(config);
124
+
125
+ console.log(`\nTheme applied: ${name}`);
126
+ console.log(`${selected.description}\n`);
127
+ console.log('Event mapping:');
128
+ for (const [event, pack] of Object.entries(selected.eventPacks)) {
129
+ console.log(` ${event.padEnd(12)} → ${pack}`);
130
+ }
131
+ console.log(`\nMode set to: informational`);
132
+ }
package/src/cli/use.js CHANGED
@@ -1,13 +1,27 @@
1
1
  import { readConfig, writeConfig } from '../config.js';
2
2
  import { resolvePack } from '../packs.js';
3
3
 
4
+ function showHelp() {
5
+ console.log(`
6
+ Usage: pingthings use <pack>
7
+
8
+ Set the active sound pack.
9
+
10
+ Arguments:
11
+ pack Name of the pack to activate
12
+
13
+ Run "pingthings list" to see available packs.
14
+ Run "pingthings select" for an interactive picker.
15
+ `);
16
+ }
17
+
4
18
  export default function use(args) {
5
19
  const packName = args[0];
6
20
 
7
- if (!packName) {
8
- console.error('Usage: pingthings use <pack>');
9
- console.error('Run "pingthings list" to see available packs.');
10
- process.exit(1);
21
+ if (!packName || packName === '--help' || packName === '-h') {
22
+ showHelp();
23
+ if (!packName) process.exit(1);
24
+ return;
11
25
  }
12
26
 
13
27
  const pack = resolvePack(packName);
package/src/config.js CHANGED
@@ -6,6 +6,8 @@ const DEFAULTS = {
6
6
  activePack: '7kaa-soldiers',
7
7
  mode: 'random',
8
8
  specificSound: null,
9
+ volume: 100,
10
+ eventPacks: {},
9
11
  };
10
12
 
11
13
  export function getConfigDir() {
package/src/packs.js CHANGED
@@ -48,6 +48,7 @@ function scanDirectory(dir, isBuiltIn) {
48
48
 
49
49
  return {
50
50
  name: manifest?.name || d.name,
51
+ version: manifest?.version || '0.0.0',
51
52
  description: manifest?.description || '',
52
53
  license: manifest?.license || 'Unknown',
53
54
  credits: manifest?.credits || '',
package/src/player.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { platform } from 'node:os';
4
4
 
@@ -7,33 +7,82 @@ function getPlayerCommand() {
7
7
  case 'darwin':
8
8
  return 'afplay';
9
9
  case 'linux': {
10
- // Prefer PulseAudio/PipeWire, fall back to ALSA
11
10
  try {
12
- const result = spawn('which', ['paplay'], { stdio: 'pipe' });
11
+ execFileSync('which', ['paplay'], { stdio: 'pipe' });
13
12
  return 'paplay';
14
13
  } catch {
15
14
  return 'aplay';
16
15
  }
17
16
  }
17
+ case 'win32':
18
+ return 'powershell';
18
19
  default:
19
20
  return null;
20
21
  }
21
22
  }
22
23
 
23
- export function playSound(filePath) {
24
+ function buildArgs(cmd, filePath, volume) {
25
+ const vol = Math.max(0, Math.min(100, volume ?? 100));
26
+
27
+ switch (cmd) {
28
+ case 'afplay': {
29
+ // afplay volume: 0.0 to 1.0
30
+ const afplayVol = (vol / 100).toFixed(2);
31
+ return ['-v', afplayVol, filePath];
32
+ }
33
+ case 'paplay': {
34
+ // paplay volume: 0 to 65536 (100% = 65536)
35
+ const paplayVol = Math.round((vol / 100) * 65536).toString();
36
+ return ['--volume', paplayVol, filePath];
37
+ }
38
+ case 'aplay':
39
+ // aplay doesn't support volume natively
40
+ return [filePath];
41
+ case 'powershell': {
42
+ // PowerShell SoundPlayer doesn't support volume, but it plays the file
43
+ const script = `(New-Object System.Media.SoundPlayer '${filePath.replace(/'/g, "''")}').PlaySync()`;
44
+ return ['-NoProfile', '-Command', script];
45
+ }
46
+ default:
47
+ return [filePath];
48
+ }
49
+ }
50
+
51
+ export function playSound(filePath, volume) {
24
52
  if (!existsSync(filePath)) {
25
53
  throw new Error(`Sound file not found: ${filePath}`);
26
54
  }
27
55
 
28
56
  const cmd = getPlayerCommand();
29
57
  if (!cmd) {
30
- throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux.`);
58
+ throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux, Windows.`);
31
59
  }
32
60
 
33
- const child = spawn(cmd, [filePath], {
61
+ const args = buildArgs(cmd, filePath, volume);
62
+
63
+ const child = spawn(cmd, args, {
34
64
  detached: true,
35
65
  stdio: 'ignore',
36
66
  });
37
67
 
38
68
  child.unref();
39
69
  }
70
+
71
+ export function playSoundSync(filePath, volume) {
72
+ if (!existsSync(filePath)) {
73
+ throw new Error(`Sound file not found: ${filePath}`);
74
+ }
75
+
76
+ const cmd = getPlayerCommand();
77
+ if (!cmd) {
78
+ throw new Error(`Unsupported platform: ${platform()}. Supported: macOS, Linux, Windows.`);
79
+ }
80
+
81
+ const args = buildArgs(cmd, filePath, volume);
82
+
83
+ try {
84
+ execFileSync(cmd, args, { stdio: 'ignore', timeout: 10000 });
85
+ } catch {
86
+ // Timeout or error — don't crash
87
+ }
88
+ }