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

Commit 449e028

Browse files
committed
Actually use a waveform instead of the frequency data
1 parent 8ddd14e commit 449e028

File tree

7 files changed

+159
-80
lines changed

7 files changed

+159
-80
lines changed

res/css/_components.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@
246246
@import "./views/toasts/_AnalyticsToast.scss";
247247
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
248248
@import "./views/verification/_VerificationShowSas.scss";
249-
@import "./views/voice_messages/_FrequencyBars.scss";
249+
@import "./views/voice_messages/_Waveform.scss";
250250
@import "./views/voip/_CallContainer.scss";
251251
@import "./views/voip/_CallView.scss";
252252
@import "./views/voip/_DialPad.scss";

res/css/views/voice_messages/_FrequencyBars.scss renamed to res/css/views/voice_messages/_Waveform.scss

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

17-
.mx_FrequencyBars {
17+
.mx_Waveform {
1818
position: relative;
1919
height: 30px; // tallest bar can only be 30px
2020

2121
display: flex;
2222
align-items: center; // so the bars grow from the middle
2323

24-
.mx_FrequencyBars_bar {
24+
.mx_Waveform_bar {
2525
width: 2px;
2626
margin-left: 1px;
2727
margin-right: 1px;

src/components/views/rooms/VoiceRecordComposerTile.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {VoiceRecorder} from "../../../voice/VoiceRecorder";
2121
import {Room} from "matrix-js-sdk/src/models/room";
2222
import {MatrixClientPeg} from "../../../MatrixClientPeg";
2323
import classNames from "classnames";
24-
import FrequencyBars from "../voice_messages/FrequencyBars";
24+
import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform";
2525

2626
interface IProps {
2727
room: Room;
@@ -68,16 +68,16 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
6868
'mx_VoiceRecordComposerTile_stop': !!this.state.recorder,
6969
});
7070

71-
let bars = null;
71+
let waveform = null;
7272
let tooltip = _t("Record a voice message");
7373
if (!!this.state.recorder) {
7474
// TODO: @@ TravisR: Change to match behaviour
7575
tooltip = _t("Stop & send recording");
76-
bars = <FrequencyBars recorder={this.state.recorder} />;
76+
waveform = <LiveRecordingWaveform recorder={this.state.recorder} />;
7777
}
7878

7979
return (<>
80-
{bars}
80+
{waveform}
8181
<AccessibleTooltipButton
8282
className={classes}
8383
onClick={this.onStartStopVoiceMessage}

src/components/views/voice_messages/FrequencyBars.tsx

Lines changed: 0 additions & 58 deletions
This file was deleted.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
Copyright 2021 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import {IRecordingUpdate, VoiceRecorder} from "../../../voice/VoiceRecorder";
19+
import {replaceableComponent} from "../../../utils/replaceableComponent";
20+
import {arrayFastResample, arraySeed} from "../../../utils/arrays";
21+
import {clamp, percentageOf} from "../../../utils/numbers";
22+
import Waveform from "./Waveform";
23+
24+
interface IProps {
25+
recorder: VoiceRecorder;
26+
}
27+
28+
interface IState {
29+
heights: number[];
30+
}
31+
32+
const DOWNSAMPLE_TARGET = 35; // number of bars we want
33+
34+
/**
35+
* A waveform which shows the waveform of a live recording
36+
*/
37+
@replaceableComponent("views.voice_messages.LiveRecordingWaveform")
38+
export default class LiveRecordingWaveform extends React.PureComponent<IProps, IState> {
39+
public constructor(props) {
40+
super(props);
41+
42+
this.state = {heights: arraySeed(0, DOWNSAMPLE_TARGET)};
43+
this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
44+
}
45+
46+
private onRecordingUpdate = (update: IRecordingUpdate) => {
47+
// The waveform and the downsample target are pretty close, so we should be fine to
48+
// do this, despite the docs on arrayFastResample.
49+
const bars = arrayFastResample(Array.from(update.waveform), DOWNSAMPLE_TARGET);
50+
this.setState({
51+
// The incoming data is between zero and one, but typically even screaming into a
52+
// microphone won't send you over 0.6, so we "cap" the graph at about 0.4 for a
53+
// point where the average user can still see feedback and be perceived as peaking
54+
// when talking "loudly".
55+
//
56+
// We multiply by 100 because the Waveform component wants values in 0-100 (percentages)
57+
heights: bars.map(b => percentageOf(b, 0, 0.40) * 100),
58+
});
59+
};
60+
61+
public render() {
62+
return <Waveform heights={this.state.heights} />;
63+
}
64+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
Copyright 2021 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import {IRecordingUpdate, VoiceRecorder} from "../../../voice/VoiceRecorder";
19+
import {replaceableComponent} from "../../../utils/replaceableComponent";
20+
import {arrayFastResample, arraySeed} from "../../../utils/arrays";
21+
import {percentageOf} from "../../../utils/numbers";
22+
23+
interface IProps {
24+
heights: number[]; // percentages as integers (0-100)
25+
}
26+
27+
interface IState {
28+
}
29+
30+
/**
31+
* A simple waveform component. This renders bars (centered vertically) for each
32+
* height provided in the component properties. Updating the properties will update
33+
* the rendered waveform.
34+
*/
35+
@replaceableComponent("views.voice_messages.Waveform")
36+
export default class Waveform extends React.PureComponent<IProps, IState> {
37+
public constructor(props) {
38+
super(props);
39+
}
40+
41+
public render() {
42+
return <div className='mx_Waveform'>
43+
{this.props.heights.map((h, i) => {
44+
return <span key={i} style={{height: h + '%'}} className='mx_Waveform_bar' />;
45+
})}
46+
</div>;
47+
}
48+
}

src/voice/VoiceRecorder.ts

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,8 @@ const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose
2525
const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus.
2626
const FREQ_SAMPLE_RATE = 10; // Target rate of frequency data (samples / sec). We don't need this super often.
2727

28-
export interface IFrequencyPackage {
29-
dbBars: Float32Array;
30-
dbMin: number;
31-
dbMax: number;
28+
export interface IRecordingUpdate {
29+
waveform: number[]; // floating points between 0 (low) and 1 (high).
3230

3331
// TODO: @@ TravisR: Generalize this for a timing package?
3432
}
@@ -38,11 +36,11 @@ export class VoiceRecorder {
3836
private recorderContext: AudioContext;
3937
private recorderSource: MediaStreamAudioSourceNode;
4038
private recorderStream: MediaStream;
41-
private recorderFreqNode: AnalyserNode;
39+
private recorderFFT: AnalyserNode;
4240
private buffer = new Uint8Array(0);
4341
private mxc: string;
4442
private recording = false;
45-
private observable: SimpleObservable<IFrequencyPackage>;
43+
private observable: SimpleObservable<IRecordingUpdate>;
4644
private freqTimerId: number;
4745

4846
public constructor(private client: MatrixClient) {
@@ -64,8 +62,16 @@ export class VoiceRecorder {
6462
sampleRate: SAMPLE_RATE, // once again, the browser will resample for us
6563
});
6664
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
67-
this.recorderFreqNode = this.recorderContext.createAnalyser();
68-
this.recorderSource.connect(this.recorderFreqNode);
65+
this.recorderFFT = this.recorderContext.createAnalyser();
66+
67+
// Bring the FFT time domain down a bit. The default is 2048, and this must be a power
68+
// of two. We use 64 points because we happen to know down the line we need less than
69+
// that, but 32 would be too few. Large numbers are not helpful here and do not add
70+
// precision: they introduce higher precision outputs of the FFT (frequency data), but
71+
// it makes the time domain less than helpful.
72+
this.recorderFFT.fftSize = 64;
73+
74+
this.recorderSource.connect(this.recorderFFT);
6975
this.recorder = new Recorder({
7076
encoderPath, // magic from webpack
7177
encoderSampleRate: SAMPLE_RATE,
@@ -91,7 +97,7 @@ export class VoiceRecorder {
9197
};
9298
}
9399

94-
public get frequencyData(): SimpleObservable<IFrequencyPackage> {
100+
public get liveData(): SimpleObservable<IRecordingUpdate> {
95101
if (!this.recording) throw new Error("No observable when not recording");
96102
return this.observable;
97103
}
@@ -121,16 +127,35 @@ export class VoiceRecorder {
121127
if (this.observable) {
122128
this.observable.close();
123129
}
124-
this.observable = new SimpleObservable<IFrequencyPackage>();
130+
this.observable = new SimpleObservable<IRecordingUpdate>();
125131
await this.makeRecorder();
126132
this.freqTimerId = setInterval(() => {
127133
if (!this.recording) return;
128-
const data = new Float32Array(this.recorderFreqNode.frequencyBinCount);
129-
this.recorderFreqNode.getFloatFrequencyData(data);
134+
135+
// The time domain is the input to the FFT, which means we use an array of the same
136+
// size. The time domain is also known as the audio waveform. We're ignoring the
137+
// output of the FFT here (frequency data) because we're not interested in it.
138+
//
139+
// We use bytes out of the analyser because floats have weird precision problems
140+
// and are slightly more difficult to work with. The bytes are easy to work with,
141+
// which is why we pick them (they're also more precise, but we care less about that).
142+
const data = new Uint8Array(this.recorderFFT.fftSize);
143+
this.recorderFFT.getByteTimeDomainData(data);
144+
145+
// Because we're dealing with a uint array we need to do math a bit differently.
146+
// If we just `Array.from()` the uint array, we end up with 1s and 0s, which aren't
147+
// what we're after. Instead, we have to use a bit of manual looping to correctly end
148+
// up with the right values
149+
const translatedData: number[] = [];
150+
for (let i = 0; i < data.length; i++) {
151+
// All we're doing here is inverting the amplitude and putting the metric somewhere
152+
// between zero and one. Without the inversion, lower values are "louder", which is
153+
// not super helpful.
154+
translatedData.push(1 - (data[i] / 128.0));
155+
}
156+
130157
this.observable.update({
131-
dbBars: data,
132-
dbMin: this.recorderFreqNode.minDecibels,
133-
dbMax: this.recorderFreqNode.maxDecibels,
158+
waveform: translatedData,
134159
});
135160
}, 1000 / FREQ_SAMPLE_RATE) as any as number; // XXX: Linter doesn't understand timer environment
136161
await this.recorder.start();

0 commit comments

Comments
 (0)