- {playPause}
- {clock}
- {waveform}
+ // only other UI is the recording-in-progress UI
+ return
+
+
;
}
- public render() {
+ public render(): ReactNode {
let recordingInfo;
let deleteButton;
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
diff --git a/src/components/views/voice_messages/Clock.tsx b/src/components/views/voice_messages/Clock.tsx
index 6c256957e9..8b71f6b7fe 100644
--- a/src/components/views/voice_messages/Clock.tsx
+++ b/src/components/views/voice_messages/Clock.tsx
@@ -29,11 +29,17 @@ interface IState {
* displayed, making it possible to see "82:29".
*/
@replaceableComponent("views.voice_messages.Clock")
-export default class Clock extends React.PureComponent
{
+export default class Clock extends React.Component {
public constructor(props) {
super(props);
}
+ shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean {
+ const currentFloor = Math.floor(this.props.seconds);
+ const nextFloor = Math.floor(nextProps.seconds);
+ return currentFloor !== nextFloor;
+ }
+
public render() {
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
const seconds = Math.round(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
diff --git a/src/components/views/voice_messages/IRecordingWaveformStateProps.ts b/src/components/views/voice_messages/IRecordingWaveformStateProps.ts
deleted file mode 100644
index fcdbf3e3b1..0000000000
--- a/src/components/views/voice_messages/IRecordingWaveformStateProps.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
-Copyright 2021 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-import {VoiceRecording} from "../../../voice/VoiceRecording";
-
-export interface IRecordingWaveformProps {
- recorder: VoiceRecording;
-}
-
-export interface IRecordingWaveformState {
- heights: number[];
-}
-
-export const DOWNSAMPLE_TARGET = 35; // number of bars we want
diff --git a/src/components/views/voice_messages/LiveRecordingClock.tsx b/src/components/views/voice_messages/LiveRecordingClock.tsx
index 5e9006c6ab..b82539eb16 100644
--- a/src/components/views/voice_messages/LiveRecordingClock.tsx
+++ b/src/components/views/voice_messages/LiveRecordingClock.tsx
@@ -31,7 +31,7 @@ interface IState {
* A clock for a live recording.
*/
@replaceableComponent("views.voice_messages.LiveRecordingClock")
-export default class LiveRecordingClock extends React.Component {
+export default class LiveRecordingClock extends React.PureComponent {
public constructor(props) {
super(props);
@@ -39,12 +39,6 @@ export default class LiveRecordingClock extends React.Component
this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
}
- shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean {
- const currentFloor = Math.floor(this.state.seconds);
- const nextFloor = Math.floor(nextState.seconds);
- return currentFloor !== nextFloor;
- }
-
private onRecordingUpdate = (update: IRecordingUpdate) => {
this.setState({seconds: update.timeSeconds});
};
diff --git a/src/components/views/voice_messages/LiveRecordingWaveform.tsx b/src/components/views/voice_messages/LiveRecordingWaveform.tsx
index e9b3fea629..e7c34c9177 100644
--- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx
+++ b/src/components/views/voice_messages/LiveRecordingWaveform.tsx
@@ -20,24 +20,32 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
import {arrayFastResample, arraySeed} from "../../../utils/arrays";
import {percentageOf} from "../../../utils/numbers";
import Waveform from "./Waveform";
-import {DOWNSAMPLE_TARGET, IRecordingWaveformProps, IRecordingWaveformState} from "./IRecordingWaveformStateProps";
+import {PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback";
+
+interface IProps {
+ recorder: VoiceRecording;
+}
+
+interface IState {
+ heights: number[];
+}
/**
* A waveform which shows the waveform of a live recording
*/
@replaceableComponent("views.voice_messages.LiveRecordingWaveform")
-export default class LiveRecordingWaveform extends React.PureComponent {
+export default class LiveRecordingWaveform extends React.PureComponent {
public constructor(props) {
super(props);
- this.state = {heights: arraySeed(0, DOWNSAMPLE_TARGET)};
+ this.state = {heights: arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES)};
this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
}
private onRecordingUpdate = (update: IRecordingUpdate) => {
// The waveform and the downsample target are pretty close, so we should be fine to
// do this, despite the docs on arrayFastResample.
- const bars = arrayFastResample(Array.from(update.waveform), DOWNSAMPLE_TARGET);
+ const bars = arrayFastResample(Array.from(update.waveform), PLAYBACK_WAVEFORM_SAMPLES);
this.setState({
// The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the
diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx
index 1339caf77f..b4f69b02bc 100644
--- a/src/components/views/voice_messages/PlayPauseButton.tsx
+++ b/src/components/views/voice_messages/PlayPauseButton.tsx
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React from "react";
+import React, {ReactNode} from "react";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {VoiceRecording} from "../../../voice/VoiceRecording";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
@@ -24,12 +24,14 @@ import classNames from "classnames";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
interface IProps {
- recorder: VoiceRecording;
+ // Playback instance to manipulate. Cannot change during the component lifecycle.
+ playback: Playback;
+
+ // The playback phase to render. Able to change during the component lifecycle.
+ playbackPhase: PlaybackState;
}
interface IState {
- playback: Playback;
- playbackPhase: PlaybackState;
}
/**
@@ -40,40 +42,16 @@ interface IState {
export default class PlayPauseButton extends React.PureComponent {
public constructor(props) {
super(props);
- this.state = {
- playback: null, // not ready yet
- playbackPhase: PlaybackState.Decoding,
- };
+ this.state = {};
}
- public async componentDidMount() {
- const playback = await this.props.recorder.getPlayback();
- playback.on(UPDATE_EVENT, this.onPlaybackState);
- this.setState({
- playback: playback,
-
- // We know the playback is no longer decoding when we get here. It'll emit an update
- // before we've bound a listener, so we just update the state here.
- playbackPhase: PlaybackState.Stopped,
- });
- }
-
- public componentWillUnmount() {
- if (this.state.playback) this.state.playback.off(UPDATE_EVENT, this.onPlaybackState);
- }
-
- private onPlaybackState = (newState: PlaybackState) => {
- this.setState({playbackPhase: newState});
- };
-
private onClick = async () => {
- if (!this.state.playback) return; // ignore for now
- await this.state.playback.toggle();
+ await this.props.playback.toggle();
};
- public render() {
- const isPlaying = this.state.playback?.isPlaying;
- const isDisabled = this.state.playbackPhase === PlaybackState.Decoding;
+ public render(): ReactNode {
+ const isPlaying = this.props.playback.isPlaying;
+ const isDisabled = this.props.playbackPhase === PlaybackState.Decoding;
const classes = classNames('mx_PlayPauseButton', {
'mx_PlayPauseButton_play': !isPlaying,
'mx_PlayPauseButton_pause': isPlaying,
diff --git a/src/components/views/voice_messages/PlaybackClock.tsx b/src/components/views/voice_messages/PlaybackClock.tsx
new file mode 100644
index 0000000000..2e8ec9a3e7
--- /dev/null
+++ b/src/components/views/voice_messages/PlaybackClock.tsx
@@ -0,0 +1,71 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import {replaceableComponent} from "../../../utils/replaceableComponent";
+import Clock from "./Clock";
+import {Playback, PlaybackState} from "../../../voice/Playback";
+import {UPDATE_EVENT} from "../../../stores/AsyncStore";
+
+interface IProps {
+ playback: Playback;
+}
+
+interface IState {
+ seconds: number;
+ durationSeconds: number;
+ playbackPhase: PlaybackState;
+}
+
+/**
+ * A clock for a playback of a recording.
+ */
+@replaceableComponent("views.voice_messages.PlaybackClock")
+export default class PlaybackClock extends React.PureComponent {
+ public constructor(props) {
+ super(props);
+
+ this.state = {
+ seconds: this.props.playback.clockInfo.timeSeconds,
+ // we track the duration on state because we won't really know what the clip duration
+ // is until the first time update, and as a PureComponent we are trying to dedupe state
+ // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
+ // member property to track "did we get a duration".
+ durationSeconds: this.props.playback.clockInfo.durationSeconds,
+ playbackPhase: PlaybackState.Stopped, // assume not started, so full clock
+ };
+ this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
+ this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
+ }
+
+ private onPlaybackUpdate = (ev: PlaybackState) => {
+ // Convert Decoding -> Stopped because we don't care about the distinction here
+ if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped;
+ this.setState({playbackPhase: ev});
+ };
+
+ private onTimeUpdate = (time: number[]) => {
+ this.setState({seconds: time[0], durationSeconds: time[1]});
+ };
+
+ public render() {
+ let seconds = this.state.seconds;
+ if (this.state.playbackPhase === PlaybackState.Stopped) {
+ seconds = this.state.durationSeconds;
+ }
+ return ;
+ }
+}
diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx
index 02647aa3ee..89de908575 100644
--- a/src/components/views/voice_messages/PlaybackWaveform.tsx
+++ b/src/components/views/voice_messages/PlaybackWaveform.tsx
@@ -15,28 +15,41 @@ limitations under the License.
*/
import React from "react";
-import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording";
import {replaceableComponent} from "../../../utils/replaceableComponent";
-import {arrayFastResample, arraySeed, arrayTrimFill} from "../../../utils/arrays";
-import {percentageOf} from "../../../utils/numbers";
+import {arraySeed, arrayTrimFill} from "../../../utils/arrays";
import Waveform from "./Waveform";
-import {DOWNSAMPLE_TARGET, IRecordingWaveformProps, IRecordingWaveformState} from "./IRecordingWaveformStateProps";
+import {Playback, PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback";
+
+interface IProps {
+ playback: Playback;
+}
+
+interface IState {
+ heights: number[];
+}
/**
* A waveform which shows the waveform of a previously recorded recording
*/
-@replaceableComponent("views.voice_messages.LiveRecordingWaveform")
-export default class PlaybackWaveform extends React.PureComponent {
+@replaceableComponent("views.voice_messages.PlaybackWaveform")
+export default class PlaybackWaveform extends React.PureComponent {
public constructor(props) {
super(props);
- // Like the live recording waveform
- const bars = arrayFastResample(this.props.recorder.finalWaveform, DOWNSAMPLE_TARGET);
- const seed = arraySeed(0, DOWNSAMPLE_TARGET);
- const heights = arrayTrimFill(bars, DOWNSAMPLE_TARGET, seed).map(b => percentageOf(b, 0, 0.5));
- this.state = {heights};
+ this.state = {heights: this.toHeights(this.props.playback.waveform)};
+
+ this.props.playback.waveformData.onUpdate(this.onWaveformUpdate);
}
+ private toHeights(waveform: number[]) {
+ const seed = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
+ return arrayTrimFill(waveform, PLAYBACK_WAVEFORM_SAMPLES, seed);
+ }
+
+ private onWaveformUpdate = (waveform: number[]) => {
+ this.setState({heights: this.toHeights(waveform)});
+ };
+
public render() {
return ;
}
diff --git a/src/components/views/voice_messages/RecordingPlayback.tsx b/src/components/views/voice_messages/RecordingPlayback.tsx
new file mode 100644
index 0000000000..776997cec2
--- /dev/null
+++ b/src/components/views/voice_messages/RecordingPlayback.tsx
@@ -0,0 +1,62 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {Playback, PlaybackState} from "../../../voice/Playback";
+import React, {ReactNode} from "react";
+import {UPDATE_EVENT} from "../../../stores/AsyncStore";
+import PlaybackWaveform from "./PlaybackWaveform";
+import PlayPauseButton from "./PlayPauseButton";
+import PlaybackClock from "./PlaybackClock";
+
+interface IProps {
+ // Playback instance to render. Cannot change during component lifecycle: create
+ // an all-new component instead.
+ playback: Playback;
+}
+
+interface IState {
+ playbackPhase: PlaybackState;
+}
+
+export default class RecordingPlayback extends React.PureComponent {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ playbackPhase: PlaybackState.Decoding, // default assumption
+ };
+
+ // We don't need to de-register: the class handles this for us internally
+ this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
+
+ // Don't wait for the promise to complete - it will emit a progress update when it
+ // is done, and it's not meant to take long anyhow.
+ // noinspection JSIgnoredPromiseFromCall
+ this.props.playback.prepare();
+ }
+
+ private onPlaybackUpdate = (ev: PlaybackState) => {
+ this.setState({playbackPhase: ev});
+ };
+
+ public render(): ReactNode {
+ return
+ }
+}
diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts
index 0039113a57..99b1f62866 100644
--- a/src/voice/Playback.ts
+++ b/src/voice/Playback.ts
@@ -16,6 +16,10 @@ limitations under the License.
import EventEmitter from "events";
import {UPDATE_EVENT} from "../stores/AsyncStore";
+import {arrayFastResample, arraySeed} from "../utils/arrays";
+import {SimpleObservable} from "matrix-widget-api";
+import {IDestroyable} from "../utils/IDestroyable";
+import {PlaybackClock} from "./PlaybackClock";
export enum PlaybackState {
Decoding = "decoding",
@@ -24,15 +28,52 @@ export enum PlaybackState {
Playing = "playing", // active progress through timeline
}
-export class Playback extends EventEmitter {
- private context: AudioContext;
+export const PLAYBACK_WAVEFORM_SAMPLES = 35;
+const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
+
+export class Playback extends EventEmitter implements IDestroyable {
+ private readonly context: AudioContext;
private source: AudioBufferSourceNode;
private state = PlaybackState.Decoding;
private audioBuf: AudioBuffer;
+ private resampledWaveform: number[];
+ private waveformObservable = new SimpleObservable();
+ private readonly clock: PlaybackClock;
- constructor(private buf: ArrayBuffer) {
+ /**
+ * Creates a new playback instance from a buffer.
+ * @param {ArrayBuffer} buf The buffer containing the sound sample.
+ * @param {number[]} seedWaveform Optional seed waveform to present until the proper waveform
+ * can be calculated. Contains values between zero and one, inclusive.
+ */
+ constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
super();
this.context = new AudioContext();
+ this.resampledWaveform = arrayFastResample(seedWaveform, PLAYBACK_WAVEFORM_SAMPLES);
+ this.waveformObservable.update(this.resampledWaveform);
+ this.clock = new PlaybackClock(this.context);
+
+ // TODO: @@ TR: Calculate real waveform
+ }
+
+ public get waveform(): number[] {
+ return this.resampledWaveform;
+ }
+
+ public get waveformData(): SimpleObservable {
+ return this.waveformObservable;
+ }
+
+ public get clockInfo(): PlaybackClock {
+ return this.clock;
+ }
+
+ public get currentState(): PlaybackState {
+ return this.state;
+ }
+
+ public get isPlaying(): boolean {
+ return this.currentState === PlaybackState.Playing;
}
public emit(event: PlaybackState, ...args: any[]): boolean {
@@ -42,17 +83,18 @@ export class Playback extends EventEmitter {
return true; // we don't ever care if the event had listeners, so just return "yes"
}
+ public destroy() {
+ // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
+ this.stop();
+ this.removeAllListeners();
+ this.clock.destroy();
+ this.waveformObservable.close();
+ }
+
public async prepare() {
this.audioBuf = await this.context.decodeAudioData(this.buf);
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
- }
-
- public get currentState(): PlaybackState {
- return this.state;
- }
-
- public get isPlaying(): boolean {
- return this.currentState === PlaybackState.Playing;
+ this.clock.durationSeconds = this.audioBuf.duration;
}
private onPlaybackEnd = async () => {
@@ -78,6 +120,7 @@ export class Playback extends EventEmitter {
// We use the context suspend/resume functions because it allows us to pause a source
// node, but that still doesn't help us when the source node runs out (see above).
await this.context.resume();
+ this.clock.flagStart();
this.emit(PlaybackState.Playing);
}
@@ -88,6 +131,7 @@ export class Playback extends EventEmitter {
public async stop() {
await this.onPlaybackEnd();
+ this.clock.flagStop();
}
public async toggle() {
diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts
new file mode 100644
index 0000000000..06d6381691
--- /dev/null
+++ b/src/voice/PlaybackClock.ts
@@ -0,0 +1,78 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {SimpleObservable} from "matrix-widget-api";
+import {IDestroyable} from "../utils/IDestroyable";
+
+// Because keeping track of time is sufficiently complicated...
+export class PlaybackClock implements IDestroyable {
+ private clipStart = 0;
+ private stopped = true;
+ private lastCheck = 0;
+ private observable = new SimpleObservable();
+ private timerId: number;
+ private clipDuration = 0;
+
+ public constructor(private context: AudioContext) {
+ }
+
+ public get durationSeconds(): number {
+ return this.clipDuration;
+ }
+
+ public set durationSeconds(val: number) {
+ this.clipDuration = val;
+ this.observable.update([this.timeSeconds, this.clipDuration]);
+ }
+
+ public get timeSeconds(): number {
+ return (this.context.currentTime - this.clipStart) % this.clipDuration;
+ }
+
+ public get liveData(): SimpleObservable {
+ return this.observable;
+ }
+
+ private checkTime = () => {
+ const now = this.timeSeconds;
+ if (this.lastCheck !== now) {
+ this.observable.update([now, this.durationSeconds]);
+ this.lastCheck = now;
+ }
+ };
+
+ public flagStart() {
+ if (this.stopped) {
+ this.clipStart = this.context.currentTime;
+ this.stopped = false;
+ }
+
+ if (!this.timerId) {
+ // case to number because the types are wrong
+ // 100ms interval to make sure the time is as accurate as possible
+ this.timerId = setInterval(this.checkTime, 100);
+ }
+ }
+
+ public flagStop() {
+ this.stopped = true;
+ }
+
+ public destroy() {
+ this.observable.close();
+ if (this.timerId) clearInterval(this.timerId);
+ }
+}
diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts
index 0c76ac406f..6b0b84ad18 100644
--- a/src/voice/VoiceRecording.ts
+++ b/src/voice/VoiceRecording.ts
@@ -24,7 +24,6 @@ import EventEmitter from "events";
import {IDestroyable} from "../utils/IDestroyable";
import {Singleflight} from "../utils/Singleflight";
import {PayloadEvent, WORKLET_NAME} from "./consts";
-import {arrayFastClone} from "../utils/arrays";
import {UPDATE_EVENT} from "../stores/AsyncStore";
import {Playback} from "./Playback";
@@ -59,15 +58,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private recording = false;
private observable: SimpleObservable;
private amplitudes: number[] = []; // at each second mark, generated
+ private playback: Playback;
public constructor(private client: MatrixClient) {
super();
}
- public get finalWaveform(): number[] {
- return arrayFastClone(this.amplitudes);
- }
-
public get contentType(): string {
return "audio/ogg";
}
@@ -277,12 +273,19 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
});
}
- public getPlayback(): Promise {
- return Singleflight.for(this, "playback").do(async () => {
- const playback = new Playback(this.audioBuffer.buffer); // cast to ArrayBuffer proper
- await playback.prepare();
- return playback;
+ /**
+ * Gets a playback instance for this voice recording. Note that the playback will not
+ * have been prepared fully, meaning the `prepare()` function needs to be called on it.
+ *
+ * The same playback instance is returned each time.
+ *
+ * @returns {Playback} The playback instance.
+ */
+ public getPlayback(): Playback {
+ this.playback = Singleflight.for(this, "playback").do(() => {
+ return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper;
});
+ return this.playback;
}
public destroy() {
@@ -290,6 +293,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
this.stop();
this.removeAllListeners();
Singleflight.forgetAllFor(this);
+ // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
+ this.playback?.destroy();
+ this.observable.close();
}
public async upload(): Promise {