Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 75c2c1a

Browse files
authored
Honor advanced audio processing settings when recording voice messages (#9610)
* VoiceRecordings: honor advanced audio processing settings Audio processing settings introduced in #8759 is now taken into account when recording a voice message. Signed-off-by: László Várady <[email protected]> * VoiceRecordings: add higher-quality audio recording When recording non-voice audio (e.g. music, FX), a different Opus encoder application should be specified. It is also recommended to increase the bitrate to 64-96 kb/s for musical use. Note: the HQ mode is currently activated when noise suppression is turned off. This is a very arbitrary condition. Signed-off-by: László Várady <[email protected]> * RecorderWorklet: fix type mismatch src/audio/VoiceRecording.ts:129:67 - Argument of type 'null' is not assignable to parameter of type 'string | URL'. Signed-off-by: László Várady <[email protected]> * VoiceRecording: test audio settings Signed-off-by: László Várady <[email protected]> * Fix typos Signed-off-by: László Várady <[email protected]> * VoiceRecording: refactor using destructuring assignment Signed-off-by: László Várady <[email protected]> * VoiceRecording: add comments about constants and non-trivial conditions Signed-off-by: László Várady <[email protected]> Signed-off-by: László Várady <[email protected]>
1 parent 1f8fbc8 commit 75c2c1a

File tree

3 files changed

+103
-7
lines changed

3 files changed

+103
-7
lines changed

src/audio/RecorderWorklet.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,4 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
8585

8686
registerProcessor(WORKLET_NAME, MxVoiceWorklet);
8787

88-
export default null; // to appease module loaders (we never use the export)
88+
export default ""; // to appease module loaders (we never use the export)

src/audio/VoiceRecording.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import * as Recorder from 'opus-recorder';
17+
// @ts-ignore
18+
import Recorder from 'opus-recorder/dist/recorder.min.js';
1819
import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
1920
import { SimpleObservable } from "matrix-widget-api";
2021
import EventEmitter from "events";
@@ -32,12 +33,26 @@ import mxRecorderWorkletPath from "./RecorderWorklet";
3233

3334
const CHANNELS = 1; // stereo isn't important
3435
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
35-
const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus.
3636
const TARGET_MAX_LENGTH = 900; // 15 minutes in seconds. Somewhat arbitrary, though longer == larger files.
3737
const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary.
3838

3939
export const RECORDING_PLAYBACK_SAMPLES = 44;
4040

41+
interface RecorderOptions {
42+
bitrate: number;
43+
encoderApplication: number;
44+
}
45+
46+
export const voiceRecorderOptions: RecorderOptions = {
47+
bitrate: 24000, // recommended Opus bitrate for high-quality VoIP
48+
encoderApplication: 2048, // voice
49+
};
50+
51+
export const highQualityRecorderOptions: RecorderOptions = {
52+
bitrate: 96000, // recommended Opus bitrate for high-quality music/audio streaming
53+
encoderApplication: 2049, // full band audio
54+
};
55+
4156
export interface IRecordingUpdate {
4257
waveform: number[]; // floating points between 0 (low) and 1 (high).
4358
timeSeconds: number; // float
@@ -88,13 +103,22 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
88103
this.targetMaxLength = null;
89104
}
90105

106+
private shouldRecordInHighQuality(): boolean {
107+
// Non-voice use case is suspected when noise suppression is disabled by the user.
108+
// When recording complex audio, higher quality is required to avoid audio artifacts.
109+
// This is a really arbitrary decision, but it can be refined/replaced at any time.
110+
return !MediaDeviceHandler.getAudioNoiseSuppression();
111+
}
112+
91113
private async makeRecorder() {
92114
try {
93115
this.recorderStream = await navigator.mediaDevices.getUserMedia({
94116
audio: {
95117
channelCount: CHANNELS,
96-
noiseSuppression: true, // browsers ignore constraints they can't honour
97118
deviceId: MediaDeviceHandler.getAudioInput(),
119+
autoGainControl: { ideal: MediaDeviceHandler.getAudioAutoGainControl() },
120+
echoCancellation: { ideal: MediaDeviceHandler.getAudioEchoCancellation() },
121+
noiseSuppression: { ideal: MediaDeviceHandler.getAudioNoiseSuppression() },
98122
},
99123
});
100124
this.recorderContext = createAudioContext({
@@ -135,15 +159,19 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
135159
this.recorderProcessor.addEventListener("audioprocess", this.onAudioProcess);
136160
}
137161

162+
const recorderOptions = this.shouldRecordInHighQuality() ?
163+
highQualityRecorderOptions : voiceRecorderOptions;
164+
const { encoderApplication, bitrate } = recorderOptions;
165+
138166
this.recorder = new Recorder({
139167
encoderPath, // magic from webpack
140168
encoderSampleRate: SAMPLE_RATE,
141-
encoderApplication: 2048, // voice (default is "audio")
169+
encoderApplication: encoderApplication,
142170
streamPages: true, // this speeds up the encoding process by using CPU over time
143171
encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder
144172
numberOfChannels: CHANNELS,
145173
sourceNode: this.recorderSource,
146-
encoderBitRate: BITRATE,
174+
encoderBitRate: bitrate,
147175

148176
// We use low values for the following to ease CPU usage - the resulting waveform
149177
// is indistinguishable for a voice message. Note that the underlying library will

test/audio/VoiceRecording-test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,24 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { VoiceRecording } from "../../src/audio/VoiceRecording";
17+
import { mocked } from 'jest-mock';
18+
// @ts-ignore
19+
import Recorder from 'opus-recorder/dist/recorder.min.js';
20+
21+
import { VoiceRecording, voiceRecorderOptions, highQualityRecorderOptions } from "../../src/audio/VoiceRecording";
22+
import { createAudioContext } from '../..//src/audio/compat';
23+
import MediaDeviceHandler from "../../src/MediaDeviceHandler";
24+
25+
jest.mock('opus-recorder/dist/recorder.min.js');
26+
const RecorderMock = mocked(Recorder);
27+
28+
jest.mock('../../src/audio/compat', () => ({
29+
createAudioContext: jest.fn(),
30+
}));
31+
const createAudioContextMock = mocked(createAudioContext);
32+
33+
jest.mock("../../src/MediaDeviceHandler");
34+
const MediaDeviceHandlerMock = mocked(MediaDeviceHandler);
1835

1936
/**
2037
* The tests here are heavily using access to private props.
@@ -43,6 +60,7 @@ describe("VoiceRecording", () => {
4360
// @ts-ignore
4461
recording.observable = {
4562
update: jest.fn(),
63+
close: jest.fn(),
4664
};
4765
jest.spyOn(recording, "stop").mockImplementation();
4866
recorderSecondsSpy = jest.spyOn(recording, "recorderSeconds", "get");
@@ -52,6 +70,56 @@ describe("VoiceRecording", () => {
5270
jest.resetAllMocks();
5371
});
5472

73+
describe("when starting a recording", () => {
74+
beforeEach(() => {
75+
const mockAudioContext = {
76+
createMediaStreamSource: jest.fn().mockReturnValue({
77+
connect: jest.fn(),
78+
disconnect: jest.fn(),
79+
}),
80+
createScriptProcessor: jest.fn().mockReturnValue({
81+
connect: jest.fn(),
82+
disconnect: jest.fn(),
83+
addEventListener: jest.fn(),
84+
removeEventListener: jest.fn(),
85+
}),
86+
destination: {},
87+
close: jest.fn(),
88+
};
89+
createAudioContextMock.mockReturnValue(mockAudioContext as unknown as AudioContext);
90+
});
91+
92+
afterEach(async () => {
93+
await recording.stop();
94+
});
95+
96+
it("should record high-quality audio if voice processing is disabled", async () => {
97+
MediaDeviceHandlerMock.getAudioNoiseSuppression.mockReturnValue(false);
98+
await recording.start();
99+
100+
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
101+
audio: expect.objectContaining({ noiseSuppression: { ideal: false } }),
102+
}));
103+
expect(RecorderMock).toHaveBeenCalledWith(expect.objectContaining({
104+
encoderBitRate: highQualityRecorderOptions.bitrate,
105+
encoderApplication: highQualityRecorderOptions.encoderApplication,
106+
}));
107+
});
108+
109+
it("should record normal-quality voice if voice processing is enabled", async () => {
110+
MediaDeviceHandlerMock.getAudioNoiseSuppression.mockReturnValue(true);
111+
await recording.start();
112+
113+
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
114+
audio: expect.objectContaining({ noiseSuppression: { ideal: true } }),
115+
}));
116+
expect(RecorderMock).toHaveBeenCalledWith(expect.objectContaining({
117+
encoderBitRate: voiceRecorderOptions.bitrate,
118+
encoderApplication: voiceRecorderOptions.encoderApplication,
119+
}));
120+
});
121+
});
122+
55123
describe("when recording", () => {
56124
beforeEach(() => {
57125
// @ts-ignore

0 commit comments

Comments
 (0)