Skip to content

Commit 30cb7ce

Browse files
authored
Zg/add volume control (#302)
1 parent bf7bef5 commit 30cb7ce

File tree

5 files changed

+93
-2
lines changed

5 files changed

+93
-2
lines changed

examples/next-app/components/ExampleComponent.tsx

+29
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export const ExampleComponent = () => {
4040
lastUserMessage,
4141
lastVoiceMessage,
4242
isPaused,
43+
volume,
44+
setVolume,
4345
} = useVoice();
4446

4547
const [textValue, setTextValue] = useState('');
@@ -56,6 +58,14 @@ export const ExampleComponent = () => {
5658
}, [isPaused, resumeAssistant, pauseAssistant]);
5759
const pausedText = isPaused ? 'Resume' : 'Pause';
5860

61+
const handleVolumeChange = useCallback(
62+
(event: React.ChangeEvent<HTMLInputElement>) => {
63+
const newVolume = parseFloat(event.target.value);
64+
setVolume(newVolume);
65+
},
66+
[setVolume],
67+
);
68+
5969
const callDuration = (
6070
<div>
6171
<div className={'text-sm font-medium uppercase'}>Call duration</div>
@@ -134,6 +144,25 @@ export const ExampleComponent = () => {
134144
{isAudioMuted ? 'Unmute Audio' : 'Mute Audio'}
135145
</button>
136146

147+
<div className="flex flex-col gap-1 pt-2">
148+
<label
149+
htmlFor="volumeSlider"
150+
className="text-sm font-medium uppercase"
151+
>
152+
Volume ({Math.round(volume * 100)}%)
153+
</label>
154+
<input
155+
id="volumeSlider"
156+
type="range"
157+
min="0"
158+
max="1"
159+
step="0.01"
160+
value={volume}
161+
onChange={handleVolumeChange}
162+
disabled={isAudioMuted}
163+
/>
164+
</div>
165+
137166
<div className="flex gap-10">
138167
<Waveform fft={audioFft} />
139168
<Waveform fft={micFft} />

packages/react/README.md

+8
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ Mute the assistant audio
196196

197197
Unmute the assistant audio
198198

199+
#### `setVolume`: (level: number) => void
200+
201+
Sets the playback volume for audio generated by the assistant. Input values are clamped between `0.0` (silent) and `1.0` (full volume).
202+
199203
#### `sendSessionSettings`: (message: [SessionSettings](https://github.com/HumeAI/hume-typescript-sdk/blob/ac89e41e45a925f9861eb6d5a1335ab51d5a1c94/src/api/resources/empathicVoice/types/SessionSettings.ts)) => void
200204

201205
Send new session settings to the assistant. This overrides any session settings that were passed as props to the VoiceProvider.
@@ -230,6 +234,10 @@ Boolean that describes whether the microphone is muted.
230234

231235
Boolean that describes whether the assistant audio is muted.
232236

237+
#### `volume`: number
238+
239+
The current playback volume level for the assistant's voice, ranging from `0.0` (silent) to `1.0` (full volume). Defaults to `1.0`.
240+
233241
#### `isPlaying`: boolean
234242

235243
Describes whether the assistant audio is currently playing.

packages/react/src/lib/VoiceProvider.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export type VoiceContextType = {
8787
chatMetadata: ChatMetadataMessage | null;
8888
playerQueueLength: number;
8989
isPaused: boolean;
90+
volume: number;
91+
setVolume: (level: number) => void;
9092
};
9193

9294
const VoiceContext = createContext<VoiceContextType | null>(null);
@@ -521,6 +523,8 @@ export const VoiceProvider: FC<VoiceProviderProps> = ({
521523
chatMetadata: messageStore.chatMetadata,
522524
playerQueueLength: player.queueLength,
523525
isPaused,
526+
volume: player.volume,
527+
setVolume: player.setVolume,
524528
}) satisfies VoiceContextType,
525529
[
526530
connect,
@@ -556,6 +560,8 @@ export const VoiceProvider: FC<VoiceProviderProps> = ({
556560
callDurationTimestamp,
557561
toolStatus.store,
558562
isPaused,
563+
player.volume,
564+
player.setVolume,
559565
],
560566
);
561567

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { generateEmptyFft } from './generateEmptyFft';
5+
import { useSoundPlayer } from './useSoundPlayer';
6+
7+
describe('useSoundPlayer', () => {
8+
it('should initialize with correct default state', () => {
9+
const mockOnError = vi.fn();
10+
const mockOnPlayAudio = vi.fn();
11+
const mockOnStopAudio = vi.fn();
12+
13+
const { result } = renderHook(() =>
14+
useSoundPlayer({
15+
onError: mockOnError,
16+
onPlayAudio: mockOnPlayAudio,
17+
onStopAudio: mockOnStopAudio,
18+
}),
19+
);
20+
21+
expect(result.current.volume).toBe(1.0); // full volume
22+
expect(result.current.isAudioMuted).toBe(false); // not muted
23+
expect(result.current.isPlaying).toBe(false); // not playing
24+
expect(result.current.queueLength).toBe(0); // empty queue
25+
expect(result.current.fft).toEqual(generateEmptyFft()); // empty fft
26+
});
27+
});

packages/react/src/lib/useSoundPlayer.ts

+23-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const useSoundPlayer = (props: {
1212
}) => {
1313
const [isPlaying, setIsPlaying] = useState(false);
1414
const [isAudioMuted, setIsAudioMuted] = useState(false);
15+
const [volume, setVolumeState] = useState<number>(1.0);
1516
const [fft, setFft] = useState<number[]>(generateEmptyFft());
1617

1718
const audioContext = useRef<AudioContext | null>(null);
@@ -171,6 +172,7 @@ export const useSoundPlayer = (props: {
171172
isProcessing.current = false;
172173
setIsPlaying(false);
173174
setIsAudioMuted(false);
175+
setVolumeState(1.0);
174176

175177
if (frequencyDataIntervalId.current) {
176178
window.clearInterval(frequencyDataIntervalId.current);
@@ -218,6 +220,20 @@ export const useSoundPlayer = (props: {
218220
setFft(generateEmptyFft());
219221
}, []);
220222

223+
const setVolume = useCallback(
224+
(newLevel: number) => {
225+
const clampedLevel = Math.max(0, Math.min(newLevel, 1.0));
226+
setVolumeState(clampedLevel);
227+
if (gainNode.current && audioContext.current && !isAudioMuted) {
228+
gainNode.current.gain.setValueAtTime(
229+
clampedLevel,
230+
audioContext.current.currentTime,
231+
);
232+
}
233+
},
234+
[isAudioMuted],
235+
);
236+
221237
const muteAudio = useCallback(() => {
222238
if (gainNode.current && audioContext.current) {
223239
gainNode.current.gain.setValueAtTime(0, audioContext.current.currentTime);
@@ -227,10 +243,13 @@ export const useSoundPlayer = (props: {
227243

228244
const unmuteAudio = useCallback(() => {
229245
if (gainNode.current && audioContext.current) {
230-
gainNode.current.gain.setValueAtTime(1, audioContext.current.currentTime);
246+
gainNode.current.gain.setValueAtTime(
247+
volume,
248+
audioContext.current.currentTime,
249+
);
231250
setIsAudioMuted(false);
232251
}
233-
}, []);
252+
}, [volume]);
234253

235254
return {
236255
addToQueue,
@@ -243,5 +262,7 @@ export const useSoundPlayer = (props: {
243262
stopAll,
244263
clearQueue,
245264
queueLength,
265+
volume,
266+
setVolume,
246267
};
247268
};

0 commit comments

Comments
 (0)