Skip to content

Commit

Permalink
Add mic check (#51)
Browse files Browse the repository at this point in the history
* add recorder mic check and tests
* move record templates from src to package root
* put mic check in typescript file and add tsc build step to generate js file
* add mocks for WebAudioAPI and test coverage for recorder checkMic
* ignore test coverage for fixtures
  • Loading branch information
becky-gilbert authored Sep 20, 2024
1 parent 8eb4c39 commit 4c436ee
Show file tree
Hide file tree
Showing 14 changed files with 752 additions and 28 deletions.
31 changes: 22 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 82 additions & 0 deletions packages/record/fixtures/MockWebAudioAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

/** Mock the message port, which is needed by the Audio Worklet Processor. */
export const msgPort = {
addEventListener: jest.fn(),
start: jest.fn(),
close: jest.fn(),
postMessage: jest.fn(),
// eslint-disable-next-line jsdoc/require-jsdoc
onmessage: jest.fn(),
} as unknown as MessagePort;

/** Mock for Media Stream Audio Source Node. */
export class MediaStreamAudioSourceNodeMock {
/**
* Mock the MediaStreamAudioSourceNode.
*
* @param _destination - Destination
* @returns This
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public connect(_destination: any): any {
// Return this to support chaining
return this;
}
}

/** Mock for Audio Worklet Node */
export class AudioWorkletNodeMock {
/**
* Constructor.
*
* @param _context - Base audio context
* @param _name - Name
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public constructor(_context: any, _name: string) {
this.port = msgPort;
}
public port: MessagePort;
/**
* Connect.
*
* @param _destination - Destination
* @returns This
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public connect(_destination: any): any {
return this;
}
}

// Define a partial mock for the AudioContext
export const audioContextMock = {
audioWorklet: {
addModule: jest.fn(async () => await Promise.resolve()),
},
createBuffer: jest.fn(
() =>
({
getChannelData: jest.fn(() => new Float32Array(256)),
}) as unknown as AudioBuffer,
),
createBufferSource: jest.fn(
() =>
({
connect: jest.fn(),
}) as unknown as AudioBufferSourceNode,
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
createMediaStreamSource: jest.fn(
(_stream: MediaStream) => new MediaStreamAudioSourceNodeMock(),
),
sampleRate: 44100,
destination: new AudioWorkletNodeMock(null, ""), // Mock destination
close: jest.fn(),
decodeAudioData: jest.fn(),
resume: jest.fn(),
suspend: jest.fn(),
state: "suspended",
onstatechange: null as any,
} as unknown as AudioContext; // Cast as AudioContext for compatibility
1 change: 1 addition & 0 deletions packages/record/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const config = require("../../jest.cjs").makePackageConfig();
module.exports = {
...config,
coveragePathIgnorePatterns: ["fixtures"],
transformIgnorePatterns: ["node_modules/(?!auto-bind)"],
testEnvironmentOptions: {
...config.testEnvironmentOptions,
Expand Down
6 changes: 4 additions & 2 deletions packages/record/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
],
"scripts": {
"build": "rollup --config",
"buildMicCheck": "npx tsc src/mic_check.ts --target esnext --lib ESNext --types node,audioworklet --skipLibCheck true",
"dev": "rollup --config rollup.config.dev.mjs --watch",
"test": "jest --coverage"
},
Expand All @@ -31,10 +32,11 @@
},
"devDependencies": {
"@jspsych/config": "^2.0.0",
"@types/audioworklet": "^0.0.58",
"@types/audioworklet": "^0.0.60",
"@types/mustache": "^4.2.5",
"rollup-plugin-dotenv": "^0.5.1",
"rollup-plugin-string-import": "^1.2.4"
"rollup-plugin-string-import": "^1.2.4",
"typescript": "^5.6.2"
},
"peerDependencies": {
"jspsych": "^8.0.2"
Expand Down
38 changes: 38 additions & 0 deletions packages/record/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,41 @@ export class NoStopPromiseError extends Error {
this.name = "NoStopPromiseError";
}
}

/**
* Error thrown when attempting an action that relies on an input stream, such
* as the mic volume check, but no such stream is found.
*/
export class NoStreamError extends Error {
/**
* When attempting an action that requires an input stream, such as the mic
* check, but no stream is found.
*/
public constructor() {
const message =
"No input stream found. Maybe the recorder was not initialized with intializeRecorder.";
super(message);
this.name = "NoStreamError";
}
}

/**
* Error thrown if there's a problem setting up the microphone input level
* check.
*/
export class MicCheckError extends Error {
/**
* Occurs if there's a problem setting up the mic check, including setting up
* the audio context and stream source, loading the audio worklet processor
* script, setting up the port message event handler, and resolving the
* promise chain via message events passed to onMicActivityLevel.
*
* @param err - Error passed into this error that is thrown in the catch
* block, if any.
*/
public constructor(err: Error) {
const message = `There was a problem setting up and running the microphone check. ${err.message}`;
super(message);
this.name = "MicCheckError";
}
}
1 change: 1 addition & 0 deletions packages/record/src/mic_check.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "*";
71 changes: 71 additions & 0 deletions packages/record/src/mic_check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const SMOOTHING_FACTOR = 0.99;
const SCALING_FACTOR = 5;
/**
* Audio Worklet Processor class for processing audio input streams. This is
* used by the Recorder to run a volume check on the microphone input stream.
* Source:
* https://www.webrtc-developers.com/how-to-know-if-my-microphone-works/#detect-noise-or-silence
*/
export default class MicCheckProcessor extends AudioWorkletProcessor {
_volume;
_micChecked;
/** Constructor for the mic check processor. */
constructor() {
super();
this._volume = 0;
this._micChecked = false;
/**
* Callback to handle a message event on the processor's port. This
* determines how the processor responds when the recorder posts a message
* to the processor with e.g. this.processorNode.port.postMessage({
* micChecked: true }).
*
* @param event - Message event generated from the 'postMessage' call, which
* includes, among other things, the data property.
* @param event.data - Data sent by the message emitter.
*/
this.port.onmessage = (event) => {
if (
event.data &&
event.data.micChecked &&
event.data.micChecked == true
) {
this._micChecked = true;
}
};
}
/**
* Process method that implements the audio processing algorithm for the Audio
* Processor Worklet. "Although the method is not a part of the
* AudioWorkletProcessor interface, any implementation of
* AudioWorkletProcessor must provide a process() method." Source:
* https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletProcessor/process
* The process method can take the following arguments: inputs, outputs,
* parameters. Here we are only using inputs.
*
* @param inputs - An array of inputs from the audio stream (microphone)
* connnected to the node. Each item in the inputs array is an array of
* channels. Each channel is a Float32Array containing 128 samples. For
* example, inputs[n][m][i] will access n-th input, m-th channel of that
* input, and i-th sample of that channel.
* @returns Boolean indicating whether or not the Audio Worklet Node should
* remain active, even if the User Agent thinks it is safe to shut down. In
* this case, when the recorder decides that the mic check criteria has been
* met, it will return false (processor should be shut down), otherwise it
* will return true (processor should remain active).
*/
process(inputs) {
if (this._micChecked) {
return false;
} else {
const input = inputs[0];
const samples = input[0];
const sumSquare = samples.reduce((p, c) => p + c * c, 0);
const rms = Math.sqrt(sumSquare / (samples.length || 1)) * SCALING_FACTOR;
this._volume = Math.max(rms, this._volume * SMOOTHING_FACTOR);
this.port.postMessage({ volume: this._volume });
return true;
}
}
}
registerProcessor("mic-check-processor", MicCheckProcessor);
74 changes: 74 additions & 0 deletions packages/record/src/mic_check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const SMOOTHING_FACTOR = 0.99;
const SCALING_FACTOR = 5;

/**
* Audio Worklet Processor class for processing audio input streams. This is
* used by the Recorder to run a volume check on the microphone input stream.
* Source:
* https://www.webrtc-developers.com/how-to-know-if-my-microphone-works/#detect-noise-or-silence
*/
export default class MicCheckProcessor extends AudioWorkletProcessor {
private _volume: number;
private _micChecked: boolean;
/** Constructor for the mic check processor. */
public constructor() {
super();
this._volume = 0;
this._micChecked = false;
/**
* Callback to handle a message event on the processor's port. This
* determines how the processor responds when the recorder posts a message
* to the processor with e.g. this.processorNode.port.postMessage({
* micChecked: true }).
*
* @param event - Message event generated from the 'postMessage' call, which
* includes, among other things, the data property.
* @param event.data - Data sent by the message emitter.
*/
this.port.onmessage = (event: MessageEvent) => {
if (
event.data &&
event.data.micChecked &&
event.data.micChecked == true
) {
this._micChecked = true;
}
};
}

/**
* Process method that implements the audio processing algorithm for the Audio
* Processor Worklet. "Although the method is not a part of the
* AudioWorkletProcessor interface, any implementation of
* AudioWorkletProcessor must provide a process() method." Source:
* https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletProcessor/process
* The process method can take the following arguments: inputs, outputs,
* parameters. Here we are only using inputs.
*
* @param inputs - An array of inputs from the audio stream (microphone)
* connnected to the node. Each item in the inputs array is an array of
* channels. Each channel is a Float32Array containing 128 samples. For
* example, inputs[n][m][i] will access n-th input, m-th channel of that
* input, and i-th sample of that channel.
* @returns Boolean indicating whether or not the Audio Worklet Node should
* remain active, even if the User Agent thinks it is safe to shut down. In
* this case, when the recorder decides that the mic check criteria has been
* met, it will return false (processor should be shut down), otherwise it
* will return true (processor should remain active).
*/
public process(inputs: Float32Array[][]) {
if (this._micChecked) {
return false;
} else {
const input = inputs[0];
const samples = input[0];
const sumSquare = samples.reduce((p, c) => p + c * c, 0);
const rms = Math.sqrt(sumSquare / (samples.length || 1)) * SCALING_FACTOR;
this._volume = Math.max(rms, this._volume * SMOOTHING_FACTOR);
this.port.postMessage({ volume: this._volume });
return true;
}
}
}

registerProcessor("mic-check-processor", MicCheckProcessor);
Loading

0 comments on commit 4c436ee

Please sign in to comment.