Skip to content

Commit 942deca

Browse files
maystudiosclaude
andcommitted
feat: add volume control for bundled sounds (0-100)
- Add hooks.sound_volume config option (0-100, default 50) - Update playSound() with cross-platform volume support: - Windows: WPF MediaPlayer with Volume property - macOS: afplay -v flag (0.0-1.0) - Linux: paplay --volume flag (0-65536) - Volume only applies to bundled WAV sounds, system sounds use OS volume - Update getSoundPreference() to return SoundConfig object (style + volume) - Update notification and stop hooks to pass volume to playSound - Update tests for new SoundConfig return type and volume parameter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 146ee1b commit 942deca

6 files changed

Lines changed: 57 additions & 32 deletions

File tree

packages/cli/src/hooks/maxsim-notification-sound.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ function playSystemNotification(): void {
2727
}
2828

2929
/** Play the best available notification sound for the current platform. */
30-
function playNotification(preference: 'bundled' | 'system'): void {
30+
function playNotification(preference: 'bundled' | 'system', volume: number): void {
3131
if (preference === 'bundled') {
3232
const wav = bundledSound('notification.wav');
3333
if (wav) {
34-
playSound(wav);
34+
playSound(wav, volume);
3535
return;
3636
}
3737
// Fall back to system sound if bundled WAV not found
@@ -41,7 +41,7 @@ function playNotification(preference: 'bundled' | 'system'): void {
4141
}
4242

4343
readStdinJson<NotificationInput>((input) => {
44-
const pref = getSoundPreference(input.cwd ?? process.cwd());
45-
playNotification(pref);
44+
const { style, volume } = getSoundPreference(input.cwd ?? process.cwd());
45+
playNotification(style, volume);
4646
process.exit(0);
4747
});

packages/cli/src/hooks/maxsim-stop-sound.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ function playSystemCompletion(): void {
2828
}
2929

3030
/** Play the best available completion sound for the current platform. */
31-
function playCompletion(preference: 'bundled' | 'system'): void {
31+
function playCompletion(preference: 'bundled' | 'system', volume: number): void {
3232
if (preference === 'bundled') {
3333
const wav = bundledSound('complete.wav');
3434
if (wav) {
35-
playSound(wav);
35+
playSound(wav, volume);
3636
return;
3737
}
3838
// Fall back to system sound if bundled WAV not found
@@ -45,7 +45,7 @@ readStdinJson<StopInput>((input) => {
4545
if (input.stop_hook_active === true) {
4646
process.exit(0);
4747
}
48-
const pref = getSoundPreference(input.cwd ?? process.cwd());
49-
playCompletion(pref);
48+
const { style, volume } = getSoundPreference(input.cwd ?? process.cwd());
49+
playCompletion(style, volume);
5050
process.exit(0);
5151
});

packages/cli/src/hooks/shared.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,29 @@ export function isMaxsimProject(projectDir: string): boolean {
3232
}
3333
}
3434

35+
/** Parsed sound config from .claude/maxsim/config.json. */
36+
export interface SoundConfig {
37+
style: 'bundled' | 'system';
38+
/** Volume 0–100. Only applies to bundled WAV sounds. */
39+
volume: number;
40+
}
41+
3542
/**
36-
* Read the sound preference from .claude/maxsim/config.json.
37-
* Returns 'bundled' (use WAV files) or 'system' (use OS system sounds).
38-
* Defaults to 'system' if config is missing or unreadable.
43+
* Read sound preferences from .claude/maxsim/config.json.
44+
* Returns style ('bundled' | 'system') and volume (0–100).
45+
* Defaults to style='system', volume=50.
3946
*/
40-
export function getSoundPreference(projectDir: string): 'bundled' | 'system' {
47+
export function getSoundPreference(projectDir: string): SoundConfig {
4148
try {
4249
const configPath = path.join(projectDir, CLAUDE_DIR, 'maxsim', 'config.json');
4350
const raw = fs.readFileSync(configPath, 'utf8');
4451
const config = JSON.parse(raw);
45-
return config?.hooks?.sound_style === 'bundled' ? 'bundled' : 'system';
52+
const style = config?.hooks?.sound_style === 'bundled' ? 'bundled' as const : 'system' as const;
53+
const rawVol = Number(config?.hooks?.sound_volume);
54+
const volume = Number.isFinite(rawVol) ? Math.max(0, Math.min(100, rawVol)) : 50;
55+
return { style, volume };
4656
} catch {
47-
return 'system';
57+
return { style: 'system', volume: 50 };
4858
}
4959
}
5060

@@ -111,40 +121,53 @@ export function bundledSound(name: string): string | null {
111121
* @param soundFile Absolute path to a WAV/MP3/etc. file, or a named system
112122
* sound token recognised by the platform helper (e.g. the
113123
* Windows-only SystemAsterisk token).
124+
* @param volume Volume 0–100. Only effective for file-based sounds (WAV/AIFF/OGA),
125+
* not for named system sound tokens. Default: 50.
114126
*/
115-
export function playSound(soundFile: string): void {
127+
export function playSound(soundFile: string, volume = 50): void {
116128
try {
129+
const vol = Math.max(0, Math.min(100, volume));
130+
117131
if (isWindows()) {
118-
// PowerShell's SoundPlayer works with WAV files synchronously.
119-
// For named system sounds (no extension) fall back to rundll32.
120132
const isWav = soundFile.toLowerCase().endsWith('.wav');
121133
if (isWav) {
122-
// Use double-quoted string which handles spaces and most special chars
134+
// Use WPF MediaPlayer for volume control on WAV files.
135+
const volFraction = (vol / 100).toFixed(2);
123136
const escaped = soundFile.replace(/"/g, '\\"');
124137
spawnSync(
125138
'powershell',
126139
[
127140
'-NoProfile',
128141
'-NonInteractive',
129142
'-Command',
130-
`$p="${escaped}"; (New-Object System.Media.SoundPlayer $p).PlaySync()`,
143+
[
144+
'Add-Type -AssemblyName PresentationCore;',
145+
`$m = New-Object System.Windows.Media.MediaPlayer;`,
146+
`$m.Open([uri]"${escaped}");`,
147+
`$m.Volume = ${volFraction};`,
148+
'$m.Play();',
149+
'Start-Sleep -Milliseconds 1500;',
150+
'$m.Close()',
151+
].join(' '),
131152
],
132-
{ stdio: 'ignore' },
153+
{ stdio: 'ignore', timeout: 5000 },
133154
);
134155
} else {
135-
// Named system sound token (e.g. "SystemAsterisk") or unsupported format —
136-
// use the rundll32 winsound bridge.
156+
// Named system sound token — no volume control available.
137157
spawnSync(
138158
'rundll32',
139159
['user32.dll,MessageBeep'],
140160
{ stdio: 'ignore' },
141161
);
142162
}
143163
} else if (isMac()) {
144-
spawnSync('afplay', [soundFile], { stdio: 'ignore' });
164+
// afplay -v accepts a float: 0.0 = silent, 1.0 = full volume
165+
const afVol = (vol / 100).toFixed(2);
166+
spawnSync('afplay', ['-v', afVol, soundFile], { stdio: 'ignore' });
145167
} else {
146-
// Linux: try paplay (PulseAudio) then aplay (ALSA)
147-
const paplay = spawnSync('paplay', [soundFile], { stdio: 'ignore' });
168+
// Linux: paplay --volume accepts 0–65536 (0=silent, 65536=100%)
169+
const paVol = String(Math.round(vol * 655.36));
170+
const paplay = spawnSync('paplay', [`--volume=${paVol}`, soundFile], { stdio: 'ignore' });
148171
if (paplay.status !== 0) {
149172
spawnSync('aplay', [soundFile], { stdio: 'ignore' });
150173
}

packages/cli/tests/unit/notification-sound.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async function loadHook(opts: {
5050
isWindowsMock = vi.fn(() => opts.isWindows ?? false);
5151
isMacMock = vi.fn(() => opts.isMac ?? false);
5252
bundledSoundMock = vi.fn(() => opts.bundledSound ?? null);
53-
getSoundPreferenceMock = vi.fn(() => opts.soundPreference ?? 'system');
53+
getSoundPreferenceMock = vi.fn(() => ({ style: opts.soundPreference ?? 'system', volume: 50 }));
5454

5555
vi.doMock('../../src/hooks/shared.js', () => ({
5656
readStdinJson: vi.fn((cb: (data: Record<string, unknown>) => void) => {
@@ -84,7 +84,7 @@ describe('sound playback on Notification event', () => {
8484

8585
hookCallback!({ message: 'Question' });
8686

87-
expect(playSoundMock).toHaveBeenCalledWith('/path/to/notification.wav');
87+
expect(playSoundMock).toHaveBeenCalledWith('/path/to/notification.wav', 50);
8888
});
8989

9090
it('checks for bundled notification.wav when preference is bundled', async () => {
@@ -132,7 +132,7 @@ describe('platform-specific sound selection', () => {
132132

133133
hookCallback!({});
134134

135-
expect(playSoundMock).toHaveBeenCalledWith('/bundled/notification.wav');
135+
expect(playSoundMock).toHaveBeenCalledWith('/bundled/notification.wav', 50);
136136
// Should not have been called with the Windows system sound
137137
expect(playSoundMock).not.toHaveBeenCalledWith('SystemAsterisk');
138138
});

packages/cli/tests/unit/stop-sound.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ async function loadHook(opts: {
7171
isWindowsMock = vi.fn(() => opts.isWindows ?? false);
7272
isMacMock = vi.fn(() => opts.isMac ?? false);
7373
bundledSoundMock = vi.fn(() => opts.bundledSound ?? null);
74-
getSoundPreferenceMock = vi.fn(() => opts.soundPreference ?? 'system');
74+
getSoundPreferenceMock = vi.fn(() => ({ style: opts.soundPreference ?? 'system', volume: 50 }));
7575

7676
vi.doMock('../../src/hooks/shared.js', () => ({
7777
readStdinJson: vi.fn((cb: (data: Record<string, unknown>) => void) => {
@@ -144,7 +144,7 @@ describe('sound playback when stop_hook_active is false', () => {
144144

145145
invokeHook({ stop_hook_active: false });
146146

147-
expect(playSoundMock).toHaveBeenCalledWith('/path/to/complete.wav');
147+
expect(playSoundMock).toHaveBeenCalledWith('/path/to/complete.wav', 50);
148148
});
149149

150150
it('checks for bundled complete.wav when preference is bundled', async () => {
@@ -186,7 +186,7 @@ describe('sound playback when stop_hook_active is false', () => {
186186

187187
invokeHook({});
188188

189-
expect(playSoundMock).toHaveBeenCalledWith('/bundled/complete.wav');
189+
expect(playSoundMock).toHaveBeenCalledWith('/bundled/complete.wav', 50);
190190
expect(playSoundMock).not.toHaveBeenCalledWith('SystemNotification');
191191
});
192192
});

templates/templates/config.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@
9696
"hooks": {
9797
"enabled": true,
9898
"__doc_sound_style": "Sound style for notification and completion hooks. 'system' uses OS-native system sounds (default). 'bundled' uses MaxsimCLI's custom chime sounds (pleasant ascending tones).",
99-
"sound_style": "system"
99+
"sound_style": "system",
100+
"__doc_sound_volume": "Volume for bundled sounds (0–100). Only applies when sound_style is 'bundled'. System sounds always play at OS volume. Default: 50.",
101+
"sound_volume": 50
100102
},
101103
"workflow": {
102104
"research": true,

0 commit comments

Comments
 (0)