Skip to content
8 changes: 8 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,14 @@ export class Conversation {
this.updateCanSendFeedback();
};

public setOutputDevice = async (deviceId: string): Promise<boolean> => {
return this.output?.setOutputDevice(deviceId);
};

public setInputDevice = async (deviceId: string): Promise<boolean> => {
return this.input?.setInputDevice(deviceId);
};

public sendContextualUpdate = (text: string) => {
this.connection.sendMessage({
type: "contextual_update",
Expand Down
39 changes: 38 additions & 1 deletion packages/client/src/utils/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class Input {
public readonly context: AudioContext,
public readonly analyser: AnalyserNode,
public readonly worklet: AudioWorkletNode,
public readonly inputStream: MediaStream
public inputStream: MediaStream
) {}

public async close() {
Expand All @@ -90,4 +90,41 @@ export class Input {
public setMuted(isMuted: boolean) {
this.worklet.port.postMessage({ type: "setMuted", isMuted });
}

public async setInputDevice(deviceId: string): Promise<boolean> {
try {
this.inputStream.getTracks().forEach(track => track.stop());

await this.context.suspend();

const newStream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: deviceId },
echoCancellation: { ideal: true },
noiseSuppression: { ideal: true },
},
});

this.analyser.disconnect();
const newSource = this.context.createMediaStreamSource(newStream);
newSource.connect(this.analyser);
this.analyser.connect(this.worklet);

this.inputStream = newStream;

await this.context.resume();

return true;
} catch (error) {
try {
await this.context.resume();
} catch (resumeError) {
console.error("Error resuming audio context:", resumeError);
return false;
}

console.error("Error switching input device:", error);
return false;
}
}
}
39 changes: 37 additions & 2 deletions packages/client/src/utils/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { audioConcatProcessor } from "./audioConcatProcessor";
import { FormatConfig } from "./connection";

export class Output {
private audioElement: HTMLAudioElement | null = null;

public static async create({
sampleRate,
format,
Expand All @@ -18,9 +20,17 @@ export class Output {
worklet.port.postMessage({ type: "setFormat", format });
worklet.connect(gain);

const mediaStreamDestination = context.createMediaStreamDestination();

await context.resume();

return new Output(context, analyser, gain, worklet);
return new Output(
context,
analyser,
gain,
worklet,
mediaStreamDestination
);
} catch (error) {
context?.close();
throw error;
Expand All @@ -31,10 +41,35 @@ export class Output {
public readonly context: AudioContext,
public readonly analyser: AnalyserNode,
public readonly gain: GainNode,
public readonly worklet: AudioWorkletNode
public readonly worklet: AudioWorkletNode,
public readonly mediaStreamDestination: MediaStreamAudioDestinationNode
) {}

public async close() {
this.audioElement?.pause();
this.audioElement = null;
await this.context.close();
}

public async setOutputDevice(deviceId: string): Promise<boolean> {
// Check if the device selection API is supported
if (!("setSinkId" in HTMLAudioElement.prototype)) {
return false;
}

if (!this.audioElement) {
// Reroute audio through the media stream
this.analyser.disconnect(this.context.destination);
this.analyser.connect(this.mediaStreamDestination);

// Create and start the audio element
this.audioElement = new Audio();
this.audioElement.srcObject = this.mediaStreamDestination.stream;
this.audioElement.play();
}

// Set the output device
await (this.audioElement as any).setSinkId(deviceId);
return true;
}
}
6 changes: 6 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ export function useConversation<T extends HookOptions & ControlledState>(
sendContextualUpdate: (text: string) => {
conversationRef.current?.sendContextualUpdate(text);
},
setOutputDevice: async (deviceId: string): Promise<boolean> => {
return conversationRef.current?.setOutputDevice(deviceId) ?? false;
},
setInputDevice: async (deviceId: string): Promise<boolean> => {
return conversationRef.current?.setInputDevice(deviceId) ?? false;
},
status,
canSendFeedback,
micMuted,
Expand Down
Loading