Skip to content

Commit

Permalink
browser-audio-input: Add mute + unmute to PCM recorder (#113)
Browse files Browse the repository at this point in the history
  • Loading branch information
mnemitz authored Jan 30, 2025
1 parent 7c007b9 commit 64b5d51
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 32 deletions.
75 changes: 45 additions & 30 deletions examples/nextjs-flow/components/Controls.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';
import { useFlow } from '@speechmatics/flow-client-react';
import { useCallback, useMemo, type FormEventHandler } from 'react';
import { useCallback, type FormEventHandler } from 'react';
import { useFlowWithBrowserAudio } from '../hooks/useFlowWithBrowserAudio';
import { MicrophoneSelect, Select } from './MicrophoneSelect';
import Card from './Card';
import { usePCMAudioRecorder } from '@speechmatics/browser-audio-input-react';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
Expand All @@ -19,13 +20,16 @@ export function Controls({
personas,
}: { personas: Record<string, { name: string }> }) {
const { socketState, sessionId } = useFlow();

const { startSession, stopSession } = useFlowWithBrowserAudio();

const handleSubmit = useCallback<FormEventHandler>(
async (e) => {
e.preventDefault();

if (socketState === 'open' && sessionId) {
return stopSession();
}

const formData = new FormData(e.target as HTMLFormElement);

const personaId = formData.get('personaId')?.toString();
Expand All @@ -36,35 +40,9 @@ export function Controls({

startSession({ personaId, deviceId });
},
[startSession],
[startSession, stopSession, socketState, sessionId],
);

const startButton = useMemo(() => {
if (socketState === 'open' && sessionId) {
return (
<Button className="btn-accent" type="button" onClick={stopSession}>
End conversation
</Button>
);
}
if (
socketState === 'connecting' ||
socketState === 'closing' ||
(socketState === 'open' && !sessionId)
) {
return (
<Button type="button" className="btn-primary" disabled aria-busy>
<span className="loading loading-spinner" />
</Button>
);
}
return (
<Button type="submit" className="btn-primary">
Start conversation
</Button>
);
}, [socketState, stopSession, sessionId]);

return (
<Card>
<form onSubmit={handleSubmit}>
Expand All @@ -76,8 +54,45 @@ export function Controls({
))}
</Select>
</div>
<div className="card-actions mt-4">{startButton}</div>
<div className="card-actions mt-4">
<ActionButton />
<MuteMicrophoneButton />
</div>
</form>
</Card>
);
}

function ActionButton() {
const { socketState, sessionId } = useFlow();

if (
socketState === 'connecting' ||
socketState === 'closing' ||
(socketState === 'open' && !sessionId)
) {
return (
<Button type="button" className="btn-primary" disabled aria-busy>
<span className="loading loading-spinner" />
</Button>
);
}

const running = socketState === 'open' && sessionId;
return (
<Button type="submit" className={running ? 'btn-accent' : 'btn-primary'}>
{running ? 'Stop' : 'Start'}
</Button>
);
}

function MuteMicrophoneButton() {
const { isRecording, mute, unmute, isMuted } = usePCMAudioRecorder();
if (!isRecording) return null;

return (
<Button type="button" onClick={isMuted ? unmute : mute}>
{isMuted ? 'Unmute microphone' : 'Mute microphone'}
</Button>
);
}
2 changes: 1 addition & 1 deletion packages/browser-audio-input-react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@speechmatics/browser-audio-input-react",
"version": "1.0.3",
"version": "1.1.0",
"description": "React hooks for managing audio inputs and permissions across browsers",
"type": "module",
"exports": ["./dist/index.js"],
Expand Down
32 changes: 32 additions & 0 deletions packages/browser-audio-input-react/src/use-pcm-audio-recorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import {
export interface IPCMAudioRecorderContext {
startRecording: PCMRecorder['startRecording'];
stopRecording: PCMRecorder['stopRecording'];
mute: PCMRecorder['mute'];
unmute: PCMRecorder['unmute'];
addEventListener: PCMRecorder['addEventListener'];
removeEventListener: PCMRecorder['removeEventListener'];
analyser: PCMRecorder['analyser'];
isRecording: PCMRecorder['isRecording'];
isMuted: PCMRecorder['isMuted'];
}

const context = createContext<IPCMAudioRecorderContext | null>(null);
Expand Down Expand Up @@ -95,10 +98,36 @@ export function PCMAudioRecorderProvider({
() => recorder.isRecording,
);

const mute = useCallback<PCMRecorder['mute']>(
() => recorder.mute(),
[recorder],
);

const unmute = useCallback<PCMRecorder['unmute']>(
() => recorder.unmute(),
[recorder],
);

const isMuted = useSyncExternalStore(
(onChange) => {
recorder.addEventListener('mute', onChange);
recorder.addEventListener('unmute', onChange);
return () => {
recorder.removeEventListener('mute', onChange);
recorder.removeEventListener('unmute', onChange);
};
},
() => recorder.isMuted,
() => recorder.isMuted,
);

const value = useMemo(
() => ({
startRecording,
stopRecording,
mute,
unmute,
isMuted,
addEventListener,
removeEventListener,
analyser,
Expand All @@ -107,6 +136,9 @@ export function PCMAudioRecorderProvider({
[
startRecording,
stopRecording,
mute,
unmute,
isMuted,
addEventListener,
removeEventListener,
analyser,
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-audio-input/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@speechmatics/browser-audio-input",
"version": "1.0.3",
"version": "1.1.0",
"description": "Manage audio input devices and persmissions across browsers",
"exports": {
".": "./dist/index.js",
Expand Down
44 changes: 44 additions & 0 deletions packages/browser-audio-input/src/pcm-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,33 @@ import { TypedEventTarget } from 'typescript-event-target';
const RECORDING_STARTED = 'recordingStarted';
const RECORDING_STOPPED = 'recordingStopped';
const AUDIO = 'audio';
const MUTE = 'mute';
const UNMUTE = 'unmute';

export class InputAudioEvent extends Event {
constructor(public readonly data: Float32Array) {
super(AUDIO);
}
}

export class MuteEvent extends Event {
constructor() {
super(MUTE);
}
}

export class UnmuteEvent extends Event {
constructor() {
super(UNMUTE);
}
}

interface PCMRecorderEventMap {
[RECORDING_STARTED]: Event;
[RECORDING_STOPPED]: Event;
[AUDIO]: InputAudioEvent;
[MUTE]: MuteEvent;
[UNMUTE]: UnmuteEvent;
}

export type StartRecordingOptions = {
Expand Down Expand Up @@ -85,6 +101,34 @@ export class PCMRecorder extends TypedEventTarget<PCMRecorderEventMap> {
this.dispatchTypedEvent(RECORDING_STARTED, new Event(RECORDING_STARTED));
}

mute() {
if (!this.mediaStream) return;

for (const track of this.mediaStream.getTracks()) {
console.log(track);
track.enabled = false;
}

this.dispatchTypedEvent(MUTE, new MuteEvent());
}

unmute() {
if (!this.mediaStream) return;

for (const track of this.mediaStream.getTracks()) {
track.enabled = true;
}

this.dispatchTypedEvent(UNMUTE, new UnmuteEvent());
}

get isMuted() {
return (
this.mediaStream?.getAudioTracks().some((track) => !track.enabled) ??
false
);
}

stopRecording() {
if (this.mediaStream) {
for (const track of this.mediaStream.getTracks()) {
Expand Down

0 comments on commit 64b5d51

Please sign in to comment.