From 32e3ce3dea7c86b1098ac07bb5c9e430f847f34d Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 20 Apr 2021 21:30:21 -0600
Subject: [PATCH 01/14] Handle basic state machine of recordings

---
 .../views/rooms/_VoiceRecordComposerTile.scss |   7 +-
 .../views/rooms/VoiceRecordComposerTile.tsx   | 160 +++++++++++-------
 .../IRecordingWaveformStateProps.ts           |  26 +++
 .../voice_messages/LiveRecordingWaveform.tsx  |  13 +-
 .../views/voice_messages/PlaybackWaveform.tsx |  43 +++++
 src/i18n/strings/en_EN.json                   |   2 +-
 src/utils/arrays.ts                           |  20 +++
 src/voice/VoiceRecording.ts                   |  11 ++
 test/utils/arrays-test.ts                     |  33 ++++
 9 files changed, 241 insertions(+), 74 deletions(-)
 create mode 100644 src/components/views/voice_messages/IRecordingWaveformStateProps.ts
 create mode 100644 src/components/views/voice_messages/PlaybackWaveform.tsx

diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss
index 8100a03890..dc4835c4dd 100644
--- a/res/css/views/rooms/_VoiceRecordComposerTile.scss
+++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss
@@ -36,11 +36,12 @@ limitations under the License.
 }
 
 .mx_VoiceRecordComposerTile_waveformContainer {
-    padding: 5px;
+    padding: 8px; // makes us 4px taller than the send/stop button
     padding-right: 4px; // there's 1px from the waveform itself, so account for that
     padding-left: 15px; // +10px for the live circle, +5px for regular padding
     background-color: $voice-record-waveform-bg-color;
     border-radius: 12px;
+    margin: 6px; // force the composer area to put a gutter around us
     margin-right: 12px; // isolate from stop button
 
     // Cheat at alignment a bit
@@ -52,7 +53,7 @@ limitations under the License.
     color: $voice-record-waveform-fg-color;
     font-size: $font-14px;
 
-    &::before {
+    &.mx_VoiceRecordComposerTile_recording::before {
         animation: recording-pulse 2s infinite;
 
         content: '';
@@ -61,7 +62,7 @@ limitations under the License.
         height: 10px;
         position: absolute;
         left: 8px;
-        top: 16px; // vertically center
+        top: 18px; // vertically center
         border-radius: 10px;
     }
 
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index 9b7f0da472..3fcafafd05 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -17,7 +17,7 @@ limitations under the License.
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import {_t} from "../../../languageHandler";
 import React from "react";
-import {VoiceRecording} from "../../../voice/VoiceRecording";
+import {RecordingState, VoiceRecording} from "../../../voice/VoiceRecording";
 import {Room} from "matrix-js-sdk/src/models/room";
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
 import classNames from "classnames";
@@ -25,6 +25,8 @@ import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform";
 import {replaceableComponent} from "../../../utils/replaceableComponent";
 import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
 import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
+import {UPDATE_EVENT} from "../../../stores/AsyncStore";
+import PlaybackWaveform from "../voice_messages/PlaybackWaveform";
 
 interface IProps {
     room: Room;
@@ -32,6 +34,7 @@ interface IProps {
 
 interface IState {
     recorder?: VoiceRecording;
+    recordingPhase?: RecordingState;
 }
 
 /**
@@ -43,87 +46,126 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
         super(props);
 
         this.state = {
-            recorder: null, // not recording by default
+            recorder: null, // no recording started by default
         };
     }
 
-    private onStartStopVoiceMessage = async () => {
-        // TODO: @@ TravisR: We do not want to auto-send on stop.
+    public async componentWillUnmount() {
+        await VoiceRecordingStore.instance.disposeRecording();
+    }
+
+    // called by composer
+    public async send() {
+        if (!this.state.recorder) {
+            throw new Error("No recording started - cannot send anything");
+        }
+
+        await this.state.recorder.stop();
+        const mxc = await this.state.recorder.upload();
+        MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
+            "body": "Voice message",
+            "msgtype": "org.matrix.msc2516.voice",
+            //"msgtype": MsgType.Audio,
+            "url": mxc,
+            "info": {
+                duration: Math.round(this.state.recorder.durationSeconds * 1000),
+                mimetype: this.state.recorder.contentType,
+                size: this.state.recorder.contentLength,
+            },
+
+            // MSC1767 experiment
+            "org.matrix.msc1767.text": "Voice message",
+            "org.matrix.msc1767.file": {
+                url: mxc,
+                name: "Voice message.ogg",
+                mimetype: this.state.recorder.contentType,
+                size: this.state.recorder.contentLength,
+            },
+            "org.matrix.msc1767.audio": {
+                duration: Math.round(this.state.recorder.durationSeconds * 1000),
+                // TODO: @@ TravisR: Waveform? (MSC1767 decision)
+            },
+            "org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment
+                duration: Math.round(this.state.recorder.durationSeconds * 1000),
+
+                // Events can't have floats, so we try to maintain resolution by using 1024
+                // as a maximum value. The waveform contains values between zero and 1, so this
+                // should come out largely sane.
+                //
+                // We're expecting about one data point per second of audio.
+                waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)),
+            },
+        });
+        await VoiceRecordingStore.instance.disposeRecording();
+        this.setState({recorder: null});
+    }
+
+    private onRecordStartEndClick = async () => {
         if (this.state.recorder) {
             await this.state.recorder.stop();
-            const mxc = await this.state.recorder.upload();
-            MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
-                "body": "Voice message",
-                "msgtype": "org.matrix.msc2516.voice",
-                //"msgtype": MsgType.Audio,
-                "url": mxc,
-                "info": {
-                    duration: Math.round(this.state.recorder.durationSeconds * 1000),
-                    mimetype: this.state.recorder.contentType,
-                    size: this.state.recorder.contentLength,
-                },
-
-                // MSC1767 experiment
-                "org.matrix.msc1767.text": "Voice message",
-                "org.matrix.msc1767.file": {
-                    url: mxc,
-                    name: "Voice message.ogg",
-                    mimetype: this.state.recorder.contentType,
-                    size: this.state.recorder.contentLength,
-                },
-                "org.matrix.msc1767.audio": {
-                    duration: Math.round(this.state.recorder.durationSeconds * 1000),
-                    // TODO: @@ TravisR: Waveform? (MSC1767 decision)
-                },
-                "org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment
-                    duration: Math.round(this.state.recorder.durationSeconds * 1000),
-
-                    // Events can't have floats, so we try to maintain resolution by using 1024
-                    // as a maximum value. The waveform contains values between zero and 1, so this
-                    // should come out largely sane.
-                    //
-                    // We're expecting about one data point per second of audio.
-                    waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)),
-                },
-            });
-            await VoiceRecordingStore.instance.disposeRecording();
-            this.setState({recorder: null});
             return;
         }
         const recorder = VoiceRecordingStore.instance.startRecording();
         await recorder.start();
-        this.setState({recorder});
+
+        // We don't need to remove the listener: the recorder will clean that up for us.
+        recorder.on(UPDATE_EVENT, (ev: RecordingState) => {
+            if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here
+            this.setState({recordingPhase: ev});
+        });
+
+        this.setState({recorder, recordingPhase: RecordingState.Started});
     };
 
     private renderWaveformArea() {
         if (!this.state.recorder) return null;
 
-        return <div className='mx_VoiceRecordComposerTile_waveformContainer'>
-            <LiveRecordingClock recorder={this.state.recorder} />
-            <LiveRecordingWaveform recorder={this.state.recorder} />
+        const classes = classNames({
+            'mx_VoiceRecordComposerTile_waveformContainer': true,
+            'mx_VoiceRecordComposerTile_recording': this.state.recordingPhase === RecordingState.Started,
+        });
+
+        const clock = <LiveRecordingClock recorder={this.state.recorder} />;
+        let waveform = <LiveRecordingWaveform recorder={this.state.recorder} />;
+        if (this.state.recordingPhase !== RecordingState.Started) {
+            waveform = <PlaybackWaveform recorder={this.state.recorder} />;
+        }
+
+        return <div className={classes}>
+            {clock}
+            {waveform}
         </div>;
     }
 
     public render() {
-        const classes = classNames({
-            'mx_MessageComposer_button': !this.state.recorder,
-            'mx_MessageComposer_voiceMessage': !this.state.recorder,
-            'mx_VoiceRecordComposerTile_stop': !!this.state.recorder,
-        });
+        let recordingInfo;
+        if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
+            const classes = classNames({
+                'mx_MessageComposer_button': !this.state.recorder,
+                'mx_MessageComposer_voiceMessage': !this.state.recorder,
+                'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
+            });
 
-        let tooltip = _t("Record a voice message");
-        if (!!this.state.recorder) {
-            // TODO: @@ TravisR: Change to match behaviour
-            tooltip = _t("Stop & send recording");
+            let tooltip = _t("Record a voice message");
+            if (!!this.state.recorder) {
+                tooltip = _t("Stop the recording");
+            }
+
+            let stopOrRecordBtn = <AccessibleTooltipButton
+                className={classes}
+                onClick={this.onRecordStartEndClick}
+                title={tooltip}
+            />;
+            if (this.state.recorder && !this.state.recorder?.isRecording) {
+                stopOrRecordBtn = null;
+            }
+
+            recordingInfo = stopOrRecordBtn;
         }
 
         return (<>
             {this.renderWaveformArea()}
-            <AccessibleTooltipButton
-                className={classes}
-                onClick={this.onStartStopVoiceMessage}
-                title={tooltip}
-            />
+            {recordingInfo}
         </>);
     }
 }
diff --git a/src/components/views/voice_messages/IRecordingWaveformStateProps.ts b/src/components/views/voice_messages/IRecordingWaveformStateProps.ts
new file mode 100644
index 0000000000..fcdbf3e3b1
--- /dev/null
+++ b/src/components/views/voice_messages/IRecordingWaveformStateProps.ts
@@ -0,0 +1,26 @@
+/*
+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/LiveRecordingWaveform.tsx b/src/components/views/voice_messages/LiveRecordingWaveform.tsx
index c1f5e97fff..e9b3fea629 100644
--- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx
+++ b/src/components/views/voice_messages/LiveRecordingWaveform.tsx
@@ -20,22 +20,13 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
 import {arrayFastResample, arraySeed} from "../../../utils/arrays";
 import {percentageOf} from "../../../utils/numbers";
 import Waveform from "./Waveform";
-
-interface IProps {
-    recorder: VoiceRecording;
-}
-
-interface IState {
-    heights: number[];
-}
-
-const DOWNSAMPLE_TARGET = 35; // number of bars we want
+import {DOWNSAMPLE_TARGET, IRecordingWaveformProps, IRecordingWaveformState} from "./IRecordingWaveformStateProps";
 
 /**
  * A waveform which shows the waveform of a live recording
  */
 @replaceableComponent("views.voice_messages.LiveRecordingWaveform")
-export default class LiveRecordingWaveform extends React.PureComponent<IProps, IState> {
+export default class LiveRecordingWaveform extends React.PureComponent<IRecordingWaveformProps, IRecordingWaveformState> {
     public constructor(props) {
         super(props);
 
diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx
new file mode 100644
index 0000000000..02647aa3ee
--- /dev/null
+++ b/src/components/views/voice_messages/PlaybackWaveform.tsx
@@ -0,0 +1,43 @@
+/*
+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 {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording";
+import {replaceableComponent} from "../../../utils/replaceableComponent";
+import {arrayFastResample, arraySeed, arrayTrimFill} from "../../../utils/arrays";
+import {percentageOf} from "../../../utils/numbers";
+import Waveform from "./Waveform";
+import {DOWNSAMPLE_TARGET, IRecordingWaveformProps, IRecordingWaveformState} from "./IRecordingWaveformStateProps";
+
+/**
+ * A waveform which shows the waveform of a previously recorded recording
+ */
+@replaceableComponent("views.voice_messages.LiveRecordingWaveform")
+export default class PlaybackWaveform extends React.PureComponent<IRecordingWaveformProps, IRecordingWaveformState> {
+    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};
+    }
+
+    public render() {
+        return <Waveform relHeights={this.state.heights} />;
+    }
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index c6f7a8d25e..f592cc19cf 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1645,7 +1645,7 @@
     "Jump to first unread message.": "Jump to first unread message.",
     "Mark all as read": "Mark all as read",
     "Record a voice message": "Record a voice message",
-    "Stop & send recording": "Stop & send recording",
+    "Stop the recording": "Stop the recording",
     "Error updating main address": "Error updating main address",
     "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
     "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.",
diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts
index cea377bfe9..f7e693452b 100644
--- a/src/utils/arrays.ts
+++ b/src/utils/arrays.ts
@@ -73,6 +73,26 @@ export function arraySeed<T>(val: T, length: number): T[] {
     return a;
 }
 
+/**
+ * Trims or fills the array to ensure it meets the desired length. The seed array
+ * given is pulled from to fill any missing slots - it is recommended that this be
+ * at least `len` long. The resulting array will be exactly `len` long, either
+ * trimmed from the source or filled with the some/all of the seed array.
+ * @param {T[]} a The array to trim/fill.
+ * @param {number} len The length to trim or fill to, as needed.
+ * @param {T[]} seed Values to pull from if the array needs filling.
+ * @returns {T[]} The resulting array of `len` length.
+ */
+export function arrayTrimFill<T>(a: T[], len: number, seed: T[]): T[] {
+    // Dev note: we do length checks because the spread operator can result in some
+    // performance penalties in more critical code paths. As a utility, it should be
+    // as fast as possible to not cause a problem for the call stack, no matter how
+    // critical that stack is.
+    if (a.length === len) return a;
+    if (a.length > len) return a.slice(0, len);
+    return a.concat(seed.slice(0, len - a.length));
+}
+
 /**
  * Clones an array as fast as possible, retaining references of the array's values.
  * @param a The array to clone. Must be defined.
diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts
index b0cc3cd407..68a944da51 100644
--- a/src/voice/VoiceRecording.ts
+++ b/src/voice/VoiceRecording.ts
@@ -25,6 +25,7 @@ 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";
 
 const CHANNELS = 1; // stereo isn't important
 const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
@@ -79,6 +80,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
         return this.recorderContext.currentTime;
     }
 
+    public get isRecording(): boolean {
+        return this.recording;
+    }
+
+    public emit(event: string, ...args: any[]): boolean {
+        super.emit(event, ...args);
+        super.emit(UPDATE_EVENT, event, ...args);
+        return true; // we don't ever care if the event had listeners, so just return "yes"
+    }
+
     private async makeRecorder() {
         this.recorderStream = await navigator.mediaDevices.getUserMedia({
             audio: {
diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts
index ececd274b2..c5be59ab43 100644
--- a/test/utils/arrays-test.ts
+++ b/test/utils/arrays-test.ts
@@ -22,6 +22,7 @@ import {
     arrayHasOrderChange,
     arrayMerge,
     arraySeed,
+    arrayTrimFill,
     arrayUnion,
     ArrayUtil,
     GroupedArray,
@@ -64,6 +65,38 @@ describe('arrays', () => {
         });
     });
 
+    describe('arrayTrimFill', () => {
+        it('should shrink arrays', () => {
+            const input = [1, 2, 3];
+            const output = [1, 2];
+            const seed = [4, 5, 6];
+            const result = arrayTrimFill(input, output.length, seed);
+            expect(result).toBeDefined();
+            expect(result).toHaveLength(output.length);
+            expect(result).toEqual(output);
+        });
+
+        it('should expand arrays', () => {
+            const input = [1, 2, 3];
+            const output = [1, 2, 3, 4, 5];
+            const seed = [4, 5, 6];
+            const result = arrayTrimFill(input, output.length, seed);
+            expect(result).toBeDefined();
+            expect(result).toHaveLength(output.length);
+            expect(result).toEqual(output);
+        });
+
+        it('should keep arrays the same', () => {
+            const input = [1, 2, 3];
+            const output = [1, 2, 3];
+            const seed = [4, 5, 6];
+            const result = arrayTrimFill(input, output.length, seed);
+            expect(result).toBeDefined();
+            expect(result).toHaveLength(output.length);
+            expect(result).toEqual(output);
+        });
+    });
+
     describe('arraySeed', () => {
         it('should create an array of given length', () => {
             const val = 1;

From e079f64a16f9bc14c9b458aca971daac2374630d Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 20 Apr 2021 21:43:55 -0600
Subject: [PATCH 02/14] Adjust pixel dimensions

---
 .../views/rooms/_VoiceRecordComposerTile.scss | 44 +++++++++++--------
 1 file changed, 26 insertions(+), 18 deletions(-)

diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss
index dc4835c4dd..1975cf943f 100644
--- a/res/css/views/rooms/_VoiceRecordComposerTile.scss
+++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss
@@ -36,9 +36,8 @@ limitations under the License.
 }
 
 .mx_VoiceRecordComposerTile_waveformContainer {
-    padding: 8px; // makes us 4px taller than the send/stop button
-    padding-right: 4px; // there's 1px from the waveform itself, so account for that
-    padding-left: 15px; // +10px for the live circle, +5px for regular padding
+    padding: 6px; // makes us 4px taller than the send/stop button
+    padding-right: 5px; // there's 1px from the waveform itself, so account for that
     background-color: $voice-record-waveform-bg-color;
     border-radius: 12px;
     margin: 6px; // force the composer area to put a gutter around us
@@ -53,27 +52,36 @@ limitations under the License.
     color: $voice-record-waveform-fg-color;
     font-size: $font-14px;
 
-    &.mx_VoiceRecordComposerTile_recording::before {
-        animation: recording-pulse 2s infinite;
+    &.mx_VoiceRecordComposerTile_recording {
+        padding-left: 16px; // +10px for the live circle, +6px for regular padding
 
-        content: '';
-        background-color: $voice-record-live-circle-color;
-        width: 10px;
-        height: 10px;
-        position: absolute;
-        left: 8px;
-        top: 18px; // vertically center
-        border-radius: 10px;
+        &::before {
+            animation: recording-pulse 2s infinite;
+
+            content: '';
+            background-color: $voice-record-live-circle-color;
+            width: 10px;
+            height: 10px;
+            position: absolute;
+            left: 8px;
+            top: 16px; // vertically center
+            border-radius: 10px;
+        }
     }
 
-    .mx_Waveform_bar {
-        background-color: $voice-record-waveform-fg-color;
+    .mx_Waveform {
+        // We want the bars to be 2px shorter than the play/pause button in the waveform control
+        height: 28px; // default is 30px, so we're subtracting the 2px border off the bars
+
+        .mx_Waveform_bar {
+            background-color: $voice-record-waveform-fg-color;
+        }
     }
 
     .mx_Clock {
-        padding-right: 8px; // isolate from waveform
-        padding-left: 10px; // isolate from live circle
-        width: 42px; // we're not using a monospace font, so fake it
+        padding-right: 4px; // isolate from waveform
+        padding-left: 8px; // isolate from live circle
+        width: 40px; // we're not using a monospace font, so fake it
     }
 }
 

From 30e120284d0d9a8661ac164713b7c8cd91ce6495 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Mon, 26 Apr 2021 20:48:24 -0600
Subject: [PATCH 03/14] Add simple play/pause controls

---
 res/css/_components.scss                      |  1 +
 .../voice_messages/_PlayPauseButton.scss      | 51 ++++++++++
 res/img/element-icons/pause-symbol.svg        |  4 +
 res/img/element-icons/play-symbol.svg         |  3 +
 .../views/rooms/VoiceRecordComposerTile.tsx   |  7 ++
 .../views/voice_messages/PlayPauseButton.tsx  | 89 +++++++++++++++++
 src/i18n/strings/en_EN.json                   |  2 +
 src/voice/Playback.ts                         | 97 +++++++++++++++++++
 src/voice/VoiceRecording.ts                   |  9 ++
 9 files changed, 263 insertions(+)
 create mode 100644 res/css/views/voice_messages/_PlayPauseButton.scss
 create mode 100644 res/img/element-icons/pause-symbol.svg
 create mode 100644 res/img/element-icons/play-symbol.svg
 create mode 100644 src/components/views/voice_messages/PlayPauseButton.tsx
 create mode 100644 src/voice/Playback.ts

diff --git a/res/css/_components.scss b/res/css/_components.scss
index 253f97bf42..0179d146d1 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -248,6 +248,7 @@
 @import "./views/toasts/_AnalyticsToast.scss";
 @import "./views/toasts/_NonUrgentEchoFailureToast.scss";
 @import "./views/verification/_VerificationShowSas.scss";
+@import "./views/voice_messages/_PlayPauseButton.scss";
 @import "./views/voice_messages/_Waveform.scss";
 @import "./views/voip/_CallContainer.scss";
 @import "./views/voip/_CallView.scss";
diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss
new file mode 100644
index 0000000000..0fd31be28a
--- /dev/null
+++ b/res/css/views/voice_messages/_PlayPauseButton.scss
@@ -0,0 +1,51 @@
+/*
+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.
+*/
+
+.mx_PlayPauseButton {
+    position: relative;
+    width: 32px;
+    height: 32px;
+    border-radius: 32px;
+    background-color: $primary-bg-color;
+
+    &::before {
+        content: '';
+        position: absolute; // sizing varies by icon
+        background-color: $muted-fg-color;
+        mask-repeat: no-repeat;
+        mask-size: contain;
+    }
+
+    &.mx_PlayPauseButton_disabled::before {
+        opacity: 0.5;
+    }
+
+    &.mx_PlayPauseButton_play::before {
+        width: 13px;
+        height: 16px;
+        top: 8px; // center
+        left: 12px; // center
+        mask-image: url('$(res)/img/element-icons/play-symbol.svg');
+    }
+
+    &.mx_PlayPauseButton_pause::before {
+        width: 10px;
+        height: 12px;
+        top: 10px; // center
+        left: 11px; // center
+        mask-image: url('$(res)/img/element-icons/pause-symbol.svg');
+    }
+}
diff --git a/res/img/element-icons/pause-symbol.svg b/res/img/element-icons/pause-symbol.svg
new file mode 100644
index 0000000000..293c0a10d8
--- /dev/null
+++ b/res/img/element-icons/pause-symbol.svg
@@ -0,0 +1,4 @@
+<svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 1C0 0.447715 0.447715 0 1 0H2C2.55228 0 3 0.447715 3 1V11C3 11.5523 2.55228 12 2 12H1C0.447715 12 0 11.5523 0 11V1Z" fill="#737D8C"/>
+<path d="M7 1C7 0.447715 7.44772 0 8 0H9C9.55228 0 10 0.447715 10 1V11C10 11.5523 9.55228 12 9 12H8C7.44772 12 7 11.5523 7 11V1Z" fill="#737D8C"/>
+</svg>
diff --git a/res/img/element-icons/play-symbol.svg b/res/img/element-icons/play-symbol.svg
new file mode 100644
index 0000000000..339e20b729
--- /dev/null
+++ b/res/img/element-icons/play-symbol.svg
@@ -0,0 +1,3 @@
+<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 14.2104V1.78956C0 1.00724 0.857827 0.527894 1.5241 0.937906L11.6161 7.14834C12.2506 7.53883 12.2506 8.46117 11.6161 8.85166L1.5241 15.0621C0.857828 15.4721 0 14.9928 0 14.2104Z" fill="#737D8C"/>
+</svg>
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index 3fcafafd05..53dc7cc9c5 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -27,6 +27,7 @@ import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
 import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
 import {UPDATE_EVENT} from "../../../stores/AsyncStore";
 import PlaybackWaveform from "../voice_messages/PlaybackWaveform";
+import PlayPauseButton from "../voice_messages/PlayPauseButton";
 
 interface IProps {
     room: Room;
@@ -131,7 +132,13 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
             waveform = <PlaybackWaveform recorder={this.state.recorder} />;
         }
 
+        let playPause = null;
+        if (this.state.recordingPhase === RecordingState.Ended) {
+            playPause = <PlayPauseButton recorder={this.state.recorder} />;
+        }
+
         return <div className={classes}>
+            {playPause}
             {clock}
             {waveform}
         </div>;
diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx
new file mode 100644
index 0000000000..1339caf77f
--- /dev/null
+++ b/src/components/views/voice_messages/PlayPauseButton.tsx
@@ -0,0 +1,89 @@
+/*
+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 {VoiceRecording} from "../../../voice/VoiceRecording";
+import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
+import {_t} from "../../../languageHandler";
+import {Playback, PlaybackState} from "../../../voice/Playback";
+import classNames from "classnames";
+import {UPDATE_EVENT} from "../../../stores/AsyncStore";
+
+interface IProps {
+    recorder: VoiceRecording;
+}
+
+interface IState {
+    playback: Playback;
+    playbackPhase: PlaybackState;
+}
+
+/**
+ * Displays a play/pause button (activating the play/pause function of the recorder)
+ * to be displayed in reference to a recording.
+ */
+@replaceableComponent("views.voice_messages.PlayPauseButton")
+export default class PlayPauseButton extends React.PureComponent<IProps, IState> {
+    public constructor(props) {
+        super(props);
+        this.state = {
+            playback: null, // not ready yet
+            playbackPhase: PlaybackState.Decoding,
+        };
+    }
+
+    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();
+    };
+
+    public render() {
+        const isPlaying = this.state.playback?.isPlaying;
+        const isDisabled = this.state.playbackPhase === PlaybackState.Decoding;
+        const classes = classNames('mx_PlayPauseButton', {
+            'mx_PlayPauseButton_play': !isPlaying,
+            'mx_PlayPauseButton_pause': isPlaying,
+            'mx_PlayPauseButton_disabled': isDisabled,
+        });
+        return <AccessibleTooltipButton
+            className={classes}
+            title={isPlaying ? _t("Pause") : _t("Play")}
+            onClick={this.onClick}
+            disabled={isDisabled}
+        />;
+    }
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index f592cc19cf..943b2291b3 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -899,6 +899,8 @@
     "Incoming call": "Incoming call",
     "Decline": "Decline",
     "Accept": "Accept",
+    "Pause": "Pause",
+    "Play": "Play",
     "The other party cancelled the verification.": "The other party cancelled the verification.",
     "Verified!": "Verified!",
     "You've successfully verified this user.": "You've successfully verified this user.",
diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts
new file mode 100644
index 0000000000..0039113a57
--- /dev/null
+++ b/src/voice/Playback.ts
@@ -0,0 +1,97 @@
+/*
+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 EventEmitter from "events";
+import {UPDATE_EVENT} from "../stores/AsyncStore";
+
+export enum PlaybackState {
+    Decoding = "decoding",
+    Stopped = "stopped", // no progress on timeline
+    Paused = "paused", // some progress on timeline
+    Playing = "playing", // active progress through timeline
+}
+
+export class Playback extends EventEmitter {
+    private context: AudioContext;
+    private source: AudioBufferSourceNode;
+    private state = PlaybackState.Decoding;
+    private audioBuf: AudioBuffer;
+
+    constructor(private buf: ArrayBuffer) {
+        super();
+        this.context = new AudioContext();
+    }
+
+    public emit(event: PlaybackState, ...args: any[]): boolean {
+        this.state = event;
+        super.emit(event, ...args);
+        super.emit(UPDATE_EVENT, event, ...args);
+        return true; // we don't ever care if the event had listeners, so just return "yes"
+    }
+
+    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;
+    }
+
+    private onPlaybackEnd = async () => {
+        await this.context.suspend();
+        this.emit(PlaybackState.Stopped);
+    };
+
+    public async play() {
+        // We can't restart a buffer source, so we need to create a new one if we hit the end
+        if (this.state === PlaybackState.Stopped) {
+            if (this.source) {
+                this.source.disconnect();
+                this.source.removeEventListener("ended", this.onPlaybackEnd);
+            }
+
+            this.source = this.context.createBufferSource();
+            this.source.connect(this.context.destination);
+            this.source.buffer = this.audioBuf;
+            this.source.start(); // start immediately
+            this.source.addEventListener("ended", this.onPlaybackEnd);
+        }
+
+        // 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.emit(PlaybackState.Playing);
+    }
+
+    public async pause() {
+        await this.context.suspend();
+        this.emit(PlaybackState.Paused);
+    }
+
+    public async stop() {
+        await this.onPlaybackEnd();
+    }
+
+    public async toggle() {
+        if (this.isPlaying) await this.pause();
+        else await this.play();
+    }
+}
diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts
index 68a944da51..692e317333 100644
--- a/src/voice/VoiceRecording.ts
+++ b/src/voice/VoiceRecording.ts
@@ -26,6 +26,7 @@ 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";
 
 const CHANNELS = 1; // stereo isn't important
 const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
@@ -270,6 +271,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
         });
     }
 
+    public getPlayback(): Promise<Playback> {
+        return Singleflight.for(this, "playback").do(async () => {
+            const playback = new Playback(this.buffer.buffer); // cast to ArrayBuffer proper
+            await playback.prepare();
+            return playback;
+        });
+    }
+
     public destroy() {
         // noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
         this.stop();

From c1bb0bb0b8866a9b4fb8cdcfff5569ac9a0ad306 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Mon, 26 Apr 2021 21:08:51 -0600
Subject: [PATCH 04/14] Add a delete button

---
 .../views/rooms/_VoiceRecordComposerTile.scss | 11 ++++++++
 .../views/rooms/VoiceRecordComposerTile.tsx   | 25 +++++++++++++++++--
 src/i18n/strings/en_EN.json                   |  1 +
 3 files changed, 35 insertions(+), 2 deletions(-)

diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss
index 1975cf943f..cb77878666 100644
--- a/res/css/views/rooms/_VoiceRecordComposerTile.scss
+++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss
@@ -35,6 +35,17 @@ limitations under the License.
     }
 }
 
+.mx_VoiceRecordComposerTile_delete {
+    width: 14px; // w&h are size of icon
+    height: 18px;
+    vertical-align: middle;
+    margin-right: 7px; // distance from left edge of waveform container (container has some margin too)
+    background-color: $muted-fg-color;
+    mask-repeat: no-repeat;
+    mask-size: contain;
+    mask-image: url('$(res)/img/element-icons/trashcan.svg');
+}
+
 .mx_VoiceRecordComposerTile_waveformContainer {
     padding: 6px; // makes us 4px taller than the send/stop button
     padding-right: 5px; // there's 1px from the waveform itself, so account for that
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index 53dc7cc9c5..b02c6a4bae 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -97,10 +97,20 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
                 waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)),
             },
         });
-        await VoiceRecordingStore.instance.disposeRecording();
-        this.setState({recorder: null});
+        await this.disposeRecording();
     }
 
+    private async disposeRecording() {
+        await VoiceRecordingStore.instance.disposeRecording();
+
+        // Reset back to no recording, which means no phase (ie: restart component entirely)
+        this.setState({recorder: null, recordingPhase: null});
+    }
+
+    private onCancel = async () => {
+        await this.disposeRecording();
+    };
+
     private onRecordStartEndClick = async () => {
         if (this.state.recorder) {
             await this.state.recorder.stop();
@@ -134,6 +144,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
 
         let playPause = null;
         if (this.state.recordingPhase === RecordingState.Ended) {
+            // TODO: @@ TR: Should we disable this during upload? What does a failed upload look like?
             playPause = <PlayPauseButton recorder={this.state.recorder} />;
         }
 
@@ -146,6 +157,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
 
     public render() {
         let recordingInfo;
+        let deleteButton;
         if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
             const classes = classNames({
                 'mx_MessageComposer_button': !this.state.recorder,
@@ -170,7 +182,16 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
             recordingInfo = stopOrRecordBtn;
         }
 
+        if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
+            deleteButton = <AccessibleTooltipButton
+                className='mx_VoiceRecordComposerTile_delete'
+                title={_t("Delete recording")}
+                onClick={this.onCancel}
+            />;
+        }
+
         return (<>
+            {deleteButton}
             {this.renderWaveformArea()}
             {recordingInfo}
         </>);
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 943b2291b3..264c35144e 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1648,6 +1648,7 @@
     "Mark all as read": "Mark all as read",
     "Record a voice message": "Record a voice message",
     "Stop the recording": "Stop the recording",
+    "Delete recording": "Delete recording",
     "Error updating main address": "Error updating main address",
     "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
     "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.",

From 5e646f861cf592e72ae7eb18e64bd65f15de3245 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 27 Apr 2021 18:59:10 -0600
Subject: [PATCH 05/14] Wire up the send button for voice messages

This fixes a bug where we couldn't upload voice messages because the audio buffer was being read, therefore changing the position of the cursor. When this happened, the upload function would claim that the buffer was empty and could not be read.
---
 src/components/views/rooms/MessageComposer.tsx | 12 +++++++++++-
 src/voice/VoiceRecording.ts                    | 14 ++++++++++----
 2 files changed, 21 insertions(+), 5 deletions(-)

diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx
index d126a7b161..3671069903 100644
--- a/src/components/views/rooms/MessageComposer.tsx
+++ b/src/components/views/rooms/MessageComposer.tsx
@@ -198,6 +198,7 @@ interface IState {
 export default class MessageComposer extends React.Component<IProps, IState> {
     private dispatcherRef: string;
     private messageComposerInput: SendMessageComposer;
+    private voiceRecordingButton: VoiceRecordComposerTile;
 
     constructor(props) {
         super(props);
@@ -322,7 +323,15 @@ export default class MessageComposer extends React.Component<IProps, IState> {
         });
     }
 
-    sendMessage = () => {
+    sendMessage = async () => {
+        if (this.state.haveRecording && this.voiceRecordingButton) {
+            // There shouldn't be any text message to send when a voice recording is active, so
+            // just send out the voice recording.
+            await this.voiceRecordingButton.send();
+            return;
+        }
+
+        // XXX: Private function access
         this.messageComposerInput._sendMessage();
     }
 
@@ -387,6 +396,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
             if (SettingsStore.getValue("feature_voice_messages")) {
                 controls.push(<VoiceRecordComposerTile
                     key="controls_voice_record"
+                    ref={c => this.voiceRecordingButton = c}
                     room={this.props.room} />);
             }
 
diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts
index 692e317333..0c76ac406f 100644
--- a/src/voice/VoiceRecording.ts
+++ b/src/voice/VoiceRecording.ts
@@ -54,7 +54,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
     private recorderStream: MediaStream;
     private recorderFFT: AnalyserNode;
     private recorderWorklet: AudioWorkletNode;
-    private buffer = new Uint8Array(0);
+    private buffer = new Uint8Array(0); // use this.audioBuffer to access
     private mxc: string;
     private recording = false;
     private observable: SimpleObservable<IRecordingUpdate>;
@@ -166,6 +166,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
         };
     }
 
+    private get audioBuffer(): Uint8Array {
+        // We need a clone of the buffer to avoid accidentally changing the position
+        // on the real thing.
+        return this.buffer.slice(0);
+    }
+
     public get liveData(): SimpleObservable<IRecordingUpdate> {
         if (!this.recording) throw new Error("No observable when not recording");
         return this.observable;
@@ -267,13 +273,13 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
             await this.recorder.close();
             this.emit(RecordingState.Ended);
 
-            return this.buffer;
+            return this.audioBuffer;
         });
     }
 
     public getPlayback(): Promise<Playback> {
         return Singleflight.for(this, "playback").do(async () => {
-            const playback = new Playback(this.buffer.buffer); // cast to ArrayBuffer proper
+            const playback = new Playback(this.audioBuffer.buffer); // cast to ArrayBuffer proper
             await playback.prepare();
             return playback;
         });
@@ -294,7 +300,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
         if (this.mxc) return this.mxc;
 
         this.emit(RecordingState.Uploading);
-        this.mxc = await this.client.uploadContent(new Blob([this.buffer], {
+        this.mxc = await this.client.uploadContent(new Blob([this.audioBuffer], {
             type: this.contentType,
         }), {
             onlyContentUri: false, // to stop the warnings in the console

From c2d37af1cb2e4471988d32e27c95dbee9a57c89c Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 27 Apr 2021 20:27:36 -0600
Subject: [PATCH 06/14] Move playback to its own set of classes

This all started with a bug where the clock wouldn't update appropriately, and ended with a whole refactoring to support later playback in the timeline.

Playback and recording instances are now independent, and this applies to the <Playback* /> components as well. Instead of those playback components taking a recording, they take a playback instance which has all the information the components need.

The clock was incredibly difficult to do because of the audio context's time tracking and the source's inability to say where it is at in the buffer/in time. This means we have to track when we started playing the clip so we can capture the audio context's current time, which may be a few seconds by the first time the user hits play. We also track stops so we know when to reset that flag.

Waveform calculations have also been moved into the base component, deduplicating the math a bit.
---
 res/css/_components.scss                      |  1 +
 .../views/rooms/_VoiceRecordComposerTile.scss | 30 +------
 .../voice_messages/_PlaybackContainer.scss    | 48 ++++++++++++
 .../views/rooms/VoiceRecordComposerTile.tsx   | 35 +++------
 src/components/views/voice_messages/Clock.tsx |  8 +-
 .../IRecordingWaveformStateProps.ts           | 26 -------
 .../voice_messages/LiveRecordingClock.tsx     |  8 +-
 .../voice_messages/LiveRecordingWaveform.tsx  | 16 +++-
 .../views/voice_messages/PlayPauseButton.tsx  | 44 +++--------
 .../views/voice_messages/PlaybackClock.tsx    | 71 +++++++++++++++++
 .../views/voice_messages/PlaybackWaveform.tsx | 35 ++++++---
 .../voice_messages/RecordingPlayback.tsx      | 62 +++++++++++++++
 src/voice/Playback.ts                         | 66 +++++++++++++---
 src/voice/PlaybackClock.ts                    | 78 +++++++++++++++++++
 src/voice/VoiceRecording.ts                   | 26 ++++---
 15 files changed, 400 insertions(+), 154 deletions(-)
 create mode 100644 res/css/views/voice_messages/_PlaybackContainer.scss
 delete mode 100644 src/components/views/voice_messages/IRecordingWaveformStateProps.ts
 create mode 100644 src/components/views/voice_messages/PlaybackClock.tsx
 create mode 100644 src/components/views/voice_messages/RecordingPlayback.tsx
 create mode 100644 src/voice/PlaybackClock.ts

diff --git a/res/css/_components.scss b/res/css/_components.scss
index 0179d146d1..0057f8a8fc 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -249,6 +249,7 @@
 @import "./views/toasts/_NonUrgentEchoFailureToast.scss";
 @import "./views/verification/_VerificationShowSas.scss";
 @import "./views/voice_messages/_PlayPauseButton.scss";
+@import "./views/voice_messages/_PlaybackContainer.scss";
 @import "./views/voice_messages/_Waveform.scss";
 @import "./views/voip/_CallContainer.scss";
 @import "./views/voip/_CallView.scss";
diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss
index cb77878666..e99e1a00e1 100644
--- a/res/css/views/rooms/_VoiceRecordComposerTile.scss
+++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss
@@ -46,23 +46,14 @@ limitations under the License.
     mask-image: url('$(res)/img/element-icons/trashcan.svg');
 }
 
-.mx_VoiceRecordComposerTile_waveformContainer {
-    padding: 6px; // makes us 4px taller than the send/stop button
-    padding-right: 5px; // there's 1px from the waveform itself, so account for that
-    background-color: $voice-record-waveform-bg-color;
-    border-radius: 12px;
+.mx_VoiceMessagePrimaryContainer {
+    // Note: remaining class properties are in the PlayerContainer CSS.
+
     margin: 6px; // force the composer area to put a gutter around us
     margin-right: 12px; // isolate from stop button
 
-    // Cheat at alignment a bit
-    display: flex;
-    align-items: center;
-
     position: relative; // important for the live circle
 
-    color: $voice-record-waveform-fg-color;
-    font-size: $font-14px;
-
     &.mx_VoiceRecordComposerTile_recording {
         padding-left: 16px; // +10px for the live circle, +6px for regular padding
 
@@ -79,21 +70,6 @@ limitations under the License.
             border-radius: 10px;
         }
     }
-
-    .mx_Waveform {
-        // We want the bars to be 2px shorter than the play/pause button in the waveform control
-        height: 28px; // default is 30px, so we're subtracting the 2px border off the bars
-
-        .mx_Waveform_bar {
-            background-color: $voice-record-waveform-fg-color;
-        }
-    }
-
-    .mx_Clock {
-        padding-right: 4px; // isolate from waveform
-        padding-left: 8px; // isolate from live circle
-        width: 40px; // we're not using a monospace font, so fake it
-    }
 }
 
 // The keyframes are slightly weird here to help make a ramping/punch effect
diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/voice_messages/_PlaybackContainer.scss
new file mode 100644
index 0000000000..a9ebc19667
--- /dev/null
+++ b/res/css/views/voice_messages/_PlaybackContainer.scss
@@ -0,0 +1,48 @@
+/*
+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.
+*/
+
+// Dev note: there's no actual component called <PlaybackContainer />. These classes
+// are shared amongst multiple voice message components.
+
+// Container for live recording and playback controls
+.mx_VoiceMessagePrimaryContainer {
+    padding: 6px; // makes us 4px taller than the send/stop button
+    padding-right: 5px; // there's 1px from the waveform itself, so account for that
+    background-color: $voice-record-waveform-bg-color;
+    border-radius: 12px;
+
+    // Cheat at alignment a bit
+    display: flex;
+    align-items: center;
+
+    color: $voice-record-waveform-fg-color;
+    font-size: $font-14px;
+
+    .mx_Waveform {
+        // We want the bars to be 2px shorter than the play/pause button in the waveform control
+        height: 28px; // default is 30px, so we're subtracting the 2px border off the bars
+
+        .mx_Waveform_bar {
+            background-color: $voice-record-waveform-fg-color;
+        }
+    }
+
+    .mx_Clock {
+        padding-right: 4px; // isolate from waveform
+        padding-left: 8px; // isolate from live circle
+        width: 40px; // we're not using a monospace font, so fake it
+    }
+}
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index b02c6a4bae..bab95291ba 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -16,7 +16,7 @@ limitations under the License.
 
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import {_t} from "../../../languageHandler";
-import React from "react";
+import React, {ReactNode} from "react";
 import {RecordingState, VoiceRecording} from "../../../voice/VoiceRecording";
 import {Room} from "matrix-js-sdk/src/models/room";
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
@@ -26,8 +26,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
 import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
 import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
 import {UPDATE_EVENT} from "../../../stores/AsyncStore";
-import PlaybackWaveform from "../voice_messages/PlaybackWaveform";
-import PlayPauseButton from "../voice_messages/PlayPauseButton";
+import RecordingPlayback from "../voice_messages/RecordingPlayback";
 
 interface IProps {
     room: Room;
@@ -94,7 +93,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
                 // should come out largely sane.
                 //
                 // We're expecting about one data point per second of audio.
-                waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)),
+                waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)),
             },
         });
         await this.disposeRecording();
@@ -128,34 +127,22 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
         this.setState({recorder, recordingPhase: RecordingState.Started});
     };
 
-    private renderWaveformArea() {
-        if (!this.state.recorder) return null;
+    private renderWaveformArea(): ReactNode {
+        if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
 
-        const classes = classNames({
-            'mx_VoiceRecordComposerTile_waveformContainer': true,
-            'mx_VoiceRecordComposerTile_recording': this.state.recordingPhase === RecordingState.Started,
-        });
-
-        const clock = <LiveRecordingClock recorder={this.state.recorder} />;
-        let waveform = <LiveRecordingWaveform recorder={this.state.recorder} />;
         if (this.state.recordingPhase !== RecordingState.Started) {
-            waveform = <PlaybackWaveform recorder={this.state.recorder} />;
-        }
-
-        let playPause = null;
-        if (this.state.recordingPhase === RecordingState.Ended) {
             // TODO: @@ TR: Should we disable this during upload? What does a failed upload look like?
-            playPause = <PlayPauseButton recorder={this.state.recorder} />;
+            return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
         }
 
-        return <div className={classes}>
-            {playPause}
-            {clock}
-            {waveform}
+        // only other UI is the recording-in-progress UI
+        return <div className="mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
+            <LiveRecordingClock recorder={this.state.recorder} />
+            <LiveRecordingWaveform recorder={this.state.recorder} />
         </div>;
     }
 
-    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<IProps, IState> {
+export default class Clock extends React.Component<IProps, IState> {
     public constructor(props) {
         super(props);
     }
 
+    shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, 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<IProps, IState> {
+export default class LiveRecordingClock extends React.PureComponent<IProps, IState> {
     public constructor(props) {
         super(props);
 
@@ -39,12 +39,6 @@ export default class LiveRecordingClock extends React.Component<IProps, IState>
         this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
     }
 
-    shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, 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<IRecordingWaveformProps, IRecordingWaveformState> {
+export default class LiveRecordingWaveform extends React.PureComponent<IProps, IState> {
     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<IProps, IState> {
     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<IProps, IState> {
+    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 <Clock seconds={seconds} />;
+    }
+}
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<IRecordingWaveformProps, IRecordingWaveformState> {
+@replaceableComponent("views.voice_messages.PlaybackWaveform")
+export default class PlaybackWaveform extends React.PureComponent<IProps, IState> {
     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 <Waveform relHeights={this.state.heights} />;
     }
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<IProps, IState> {
+    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 <div className='mx_VoiceMessagePrimaryContainer'>
+            <PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
+            <PlaybackClock playback={this.props.playback} />
+            <PlaybackWaveform playback={this.props.playback} />
+        </div>
+    }
+}
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<number[]>();
+    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<number[]> {
+        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<number[]>();
+    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<number[]> {
+        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 = <number><any>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<IRecordingUpdate>;
     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<Playback> {
-        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<string> {

From c4d85c457b084db57b676ce488984d616d8068b5 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 27 Apr 2021 22:59:16 -0600
Subject: [PATCH 07/14] Add progress effect to playback waveform

---
 .../voice_messages/_PlaybackContainer.scss      |  8 +++++++-
 res/themes/legacy-light/css/_legacy-light.scss  |  1 +
 res/themes/light/css/_light.scss                |  1 +
 .../views/voice_messages/PlaybackWaveform.tsx   | 15 +++++++++++++--
 .../views/voice_messages/Waveform.tsx           | 17 ++++++++++++++++-
 5 files changed, 38 insertions(+), 4 deletions(-)

diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/voice_messages/_PlaybackContainer.scss
index a9ebc19667..49bd81ef81 100644
--- a/res/css/views/voice_messages/_PlaybackContainer.scss
+++ b/res/css/views/voice_messages/_PlaybackContainer.scss
@@ -36,7 +36,13 @@ limitations under the License.
         height: 28px; // default is 30px, so we're subtracting the 2px border off the bars
 
         .mx_Waveform_bar {
-            background-color: $voice-record-waveform-fg-color;
+            background-color: $voice-record-waveform-incomplete-fg-color;
+
+            &.mx_Waveform_bar_100pct {
+                // Small animation to remove the mechanical feel of progress
+                transition: background-color 250ms ease;
+                background-color: $voice-record-waveform-fg-color;
+            }
         }
     }
 
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index e05285721e..d7352e5684 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -196,6 +196,7 @@ $voice-record-stop-border-color: #E3E8F0;
 $voice-record-stop-symbol-color: #ff4b55;
 $voice-record-waveform-bg-color: #E3E8F0;
 $voice-record-waveform-fg-color: $muted-fg-color;
+$voice-record-waveform-incomplete-fg-color: #C1C6CD;
 $voice-record-live-circle-color: #ff4b55;
 
 $roomtile-preview-color: #9e9e9e;
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 342b5dfd9a..20ccc2ee41 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -186,6 +186,7 @@ $voice-record-stop-border-color: #E3E8F0;
 $voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes
 $voice-record-waveform-bg-color: #E3E8F0;
 $voice-record-waveform-fg-color: $muted-fg-color;
+$voice-record-waveform-incomplete-fg-color: #C1C6CD;
 $voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes
 
 $roomtile-preview-color: $secondary-fg-color;
diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx
index 89de908575..de38de63bb 100644
--- a/src/components/views/voice_messages/PlaybackWaveform.tsx
+++ b/src/components/views/voice_messages/PlaybackWaveform.tsx
@@ -19,6 +19,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
 import {arraySeed, arrayTrimFill} from "../../../utils/arrays";
 import Waveform from "./Waveform";
 import {Playback, PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback";
+import {percentageOf} from "../../../utils/numbers";
 
 interface IProps {
     playback: Playback;
@@ -26,6 +27,7 @@ interface IProps {
 
 interface IState {
     heights: number[];
+    progress: number;
 }
 
 /**
@@ -36,9 +38,13 @@ export default class PlaybackWaveform extends React.PureComponent<IProps, IState
     public constructor(props) {
         super(props);
 
-        this.state = {heights: this.toHeights(this.props.playback.waveform)};
+        this.state = {
+            heights: this.toHeights(this.props.playback.waveform),
+            progress: 0, // default no progress
+        };
 
         this.props.playback.waveformData.onUpdate(this.onWaveformUpdate);
+        this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
     }
 
     private toHeights(waveform: number[]) {
@@ -50,7 +56,12 @@ export default class PlaybackWaveform extends React.PureComponent<IProps, IState
         this.setState({heights: this.toHeights(waveform)});
     };
 
+    private onTimeUpdate = (time: number[]) => {
+        const progress = percentageOf(time[0], 0, time[1]);
+        this.setState({progress});
+    };
+
     public render() {
-        return <Waveform relHeights={this.state.heights} />;
+        return <Waveform relHeights={this.state.heights} progress={this.state.progress} />;
     }
 }
diff --git a/src/components/views/voice_messages/Waveform.tsx b/src/components/views/voice_messages/Waveform.tsx
index 5fa68dcadc..840a5a12b3 100644
--- a/src/components/views/voice_messages/Waveform.tsx
+++ b/src/components/views/voice_messages/Waveform.tsx
@@ -16,9 +16,11 @@ limitations under the License.
 
 import React from "react";
 import {replaceableComponent} from "../../../utils/replaceableComponent";
+import classNames from "classnames";
 
 interface IProps {
     relHeights: number[]; // relative heights (0-1)
+    progress: number; // percent complete, 0-1, default 100%
 }
 
 interface IState {
@@ -28,9 +30,16 @@ interface IState {
  * A simple waveform component. This renders bars (centered vertically) for each
  * height provided in the component properties. Updating the properties will update
  * the rendered waveform.
+ *
+ * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be
+ * "filled", as a demonstration of the progress property.
  */
 @replaceableComponent("views.voice_messages.Waveform")
 export default class Waveform extends React.PureComponent<IProps, IState> {
+    public static defaultProps = {
+        progress: 1,
+    };
+
     public constructor(props) {
         super(props);
     }
@@ -38,7 +47,13 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
     public render() {
         return <div className='mx_Waveform'>
             {this.props.relHeights.map((h, i) => {
-                return <span key={i} style={{height: (h * 100) + '%'}} className='mx_Waveform_bar' />;
+                const progress = this.props.progress;
+                const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0;
+                const classes = classNames({
+                    'mx_Waveform_bar': true,
+                    'mx_Waveform_bar_100pct': isCompleteBar,
+                });
+                return <span key={i} style={{height: (h * 100) + '%'}} className={classes} />;
             })}
         </div>;
     }

From c2bcdae8a9bb26ab2aa69879c51c37e5ac079a14 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 27 Apr 2021 23:04:49 -0600
Subject: [PATCH 08/14] Switch global var to the store for easier debugging

---
 src/@types/global.d.ts            | 4 ++--
 src/stores/VoiceRecordingStore.ts | 2 ++
 src/voice/VoiceRecording.ts       | 2 --
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 01bb51732e..0ab26ef943 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -39,9 +39,9 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore";
 import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
 import VoipUserMapper from "../VoipUserMapper";
 import {SpaceStoreClass} from "../stores/SpaceStore";
-import {VoiceRecording} from "../voice/VoiceRecording";
 import TypingStore from "../stores/TypingStore";
 import { EventIndexPeg } from "../indexing/EventIndexPeg";
+import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
 
 declare global {
     interface Window {
@@ -73,7 +73,7 @@ declare global {
         mxModalWidgetStore: ModalWidgetStore;
         mxVoipUserMapper: VoipUserMapper;
         mxSpaceStore: SpaceStoreClass;
-        mxVoiceRecorder: typeof VoiceRecording;
+        mxVoiceRecordingStore: VoiceRecordingStore;
         mxTypingStore: TypingStore;
         mxEventIndexPeg: EventIndexPeg;
     }
diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts
index cc999f23f8..8ee44359fb 100644
--- a/src/stores/VoiceRecordingStore.ts
+++ b/src/stores/VoiceRecordingStore.ts
@@ -78,3 +78,5 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
         return this.updateState({recording: null});
     }
 }
+
+window.mxVoiceRecordingStore = VoiceRecordingStore.instance;
diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts
index 6b0b84ad18..3a083a60b1 100644
--- a/src/voice/VoiceRecording.ts
+++ b/src/voice/VoiceRecording.ts
@@ -315,5 +315,3 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
         return this.mxc;
     }
 }
-
-window.mxVoiceRecorder = VoiceRecording;

From 617d74f9cdbf8b6cd4d7613088a2b3dee0e1ad13 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 27 Apr 2021 23:07:45 -0600
Subject: [PATCH 09/14] Treat 119.68 seconds as 1:59 instead of 1:60

---
 src/components/views/voice_messages/Clock.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/views/voice_messages/Clock.tsx b/src/components/views/voice_messages/Clock.tsx
index 8b71f6b7fe..23e6762c52 100644
--- a/src/components/views/voice_messages/Clock.tsx
+++ b/src/components/views/voice_messages/Clock.tsx
@@ -42,7 +42,7 @@ export default class Clock extends React.Component<IProps, IState> {
 
     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
+        const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
         return <span className='mx_Clock'>{minutes}:{seconds}</span>;
     }
 }

From f0ff2fc38d7d43cb09314c5106f9ec88e59a3461 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 27 Apr 2021 23:30:54 -0600
Subject: [PATCH 10/14] Ensure we capture an absolute maximum amount of audio
 samples

We say the limit is 2 minutes, not 1m59s, so let's give the user that last frame.
---
 src/voice/VoiceRecording.ts | 17 ++++++++++++++---
 1 file changed, 14 insertions(+), 3 deletions(-)

diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts
index 3a083a60b1..eb705200ca 100644
--- a/src/voice/VoiceRecording.ts
+++ b/src/voice/VoiceRecording.ts
@@ -217,8 +217,19 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
 
         // Now that we've updated the data/waveform, let's do a time check. We don't want to
         // go horribly over the limit. We also emit a warning state if needed.
-        const secondsLeft = TARGET_MAX_LENGTH - timeSeconds;
-        if (secondsLeft <= 0) {
+        //
+        // We use the recorder's perspective of time to make sure we don't cut off the last
+        // frame of audio, otherwise we end up with a 1:59 clip (119.68 seconds). This extra
+        // safety can allow us to overshoot the target a bit, but at least when we say 2min
+        // maximum we actually mean it.
+        //
+        // In testing, recorder time and worker time lag by about 400ms, which is roughly the
+        // time needed to encode a sample/frame.
+        //
+        // Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields
+        const recorderSeconds = this.recorder.encodedSamplePosition / 48000;
+        const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds;
+        if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame
             // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
             this.stop();
         } else if (secondsLeft <= TARGET_WARN_TIME_LEFT) {
@@ -253,9 +264,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
             }
 
             // Disconnect the source early to start shutting down resources
+            await this.recorder.stop(); // stop first to flush the last frame
             this.recorderSource.disconnect();
             this.recorderWorklet.disconnect();
-            await this.recorder.stop();
 
             // close the context after the recorder so the recorder doesn't try to
             // connect anything to the context (this would generate a warning)

From 8213c48b7f8e1947803faa8ddee8c0679aaa5ab5 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 27 Apr 2021 23:34:26 -0600
Subject: [PATCH 11/14] Fix first waveform bar highlighting in playback at 0%

---
 src/components/views/voice_messages/PlaybackWaveform.tsx | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx
index de38de63bb..123af5dfa5 100644
--- a/src/components/views/voice_messages/PlaybackWaveform.tsx
+++ b/src/components/views/voice_messages/PlaybackWaveform.tsx
@@ -57,7 +57,8 @@ export default class PlaybackWaveform extends React.PureComponent<IProps, IState
     };
 
     private onTimeUpdate = (time: number[]) => {
-        const progress = percentageOf(time[0], 0, time[1]);
+        // Track percentages to very coarse precision, otherwise 0.002 ends up highlighting a bar.
+        const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1));
         this.setState({progress});
     };
 

From 8fca32d651c672bd7c26213e6ca879d8b48d2eb7 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 27 Apr 2021 23:48:07 -0600
Subject: [PATCH 12/14] Clean up imports from refactoring

---
 src/components/views/voice_messages/PlayPauseButton.tsx | 2 --
 1 file changed, 2 deletions(-)

diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx
index b4f69b02bc..2ed0368467 100644
--- a/src/components/views/voice_messages/PlayPauseButton.tsx
+++ b/src/components/views/voice_messages/PlayPauseButton.tsx
@@ -16,12 +16,10 @@ limitations under the License.
 
 import React, {ReactNode} from "react";
 import {replaceableComponent} from "../../../utils/replaceableComponent";
-import {VoiceRecording} from "../../../voice/VoiceRecording";
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import {_t} from "../../../languageHandler";
 import {Playback, PlaybackState} from "../../../voice/Playback";
 import classNames from "classnames";
-import {UPDATE_EVENT} from "../../../stores/AsyncStore";
 
 interface IProps {
     // Playback instance to manipulate. Cannot change during the component lifecycle.

From d4acd0e41ceddf74b7123df07def785ed5b48f73 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Wed, 28 Apr 2021 09:28:46 -0600
Subject: [PATCH 13/14] Remove excess IState

---
 src/components/views/voice_messages/PlayPauseButton.tsx | 6 +-----
 1 file changed, 1 insertion(+), 5 deletions(-)

diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx
index 2ed0368467..1f87eb012d 100644
--- a/src/components/views/voice_messages/PlayPauseButton.tsx
+++ b/src/components/views/voice_messages/PlayPauseButton.tsx
@@ -29,18 +29,14 @@ interface IProps {
     playbackPhase: PlaybackState;
 }
 
-interface IState {
-}
-
 /**
  * Displays a play/pause button (activating the play/pause function of the recorder)
  * to be displayed in reference to a recording.
  */
 @replaceableComponent("views.voice_messages.PlayPauseButton")
-export default class PlayPauseButton extends React.PureComponent<IProps, IState> {
+export default class PlayPauseButton extends React.PureComponent<IProps> {
     public constructor(props) {
         super(props);
-        this.state = {};
     }
 
     private onClick = async () => {

From 6764b8d645df5cdac8977836f3386dd8fbf5bad5 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Wed, 28 Apr 2021 09:29:31 -0600
Subject: [PATCH 14/14] Change symbol names

---
 res/css/views/voice_messages/_PlayPauseButton.scss    | 4 ++--
 res/img/element-icons/{pause-symbol.svg => pause.svg} | 0
 res/img/element-icons/{play-symbol.svg => play.svg}   | 0
 3 files changed, 2 insertions(+), 2 deletions(-)
 rename res/img/element-icons/{pause-symbol.svg => pause.svg} (100%)
 rename res/img/element-icons/{play-symbol.svg => play.svg} (100%)

diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss
index 0fd31be28a..c8ab162694 100644
--- a/res/css/views/voice_messages/_PlayPauseButton.scss
+++ b/res/css/views/voice_messages/_PlayPauseButton.scss
@@ -38,7 +38,7 @@ limitations under the License.
         height: 16px;
         top: 8px; // center
         left: 12px; // center
-        mask-image: url('$(res)/img/element-icons/play-symbol.svg');
+        mask-image: url('$(res)/img/element-icons/play.svg');
     }
 
     &.mx_PlayPauseButton_pause::before {
@@ -46,6 +46,6 @@ limitations under the License.
         height: 12px;
         top: 10px; // center
         left: 11px; // center
-        mask-image: url('$(res)/img/element-icons/pause-symbol.svg');
+        mask-image: url('$(res)/img/element-icons/pause.svg');
     }
 }
diff --git a/res/img/element-icons/pause-symbol.svg b/res/img/element-icons/pause.svg
similarity index 100%
rename from res/img/element-icons/pause-symbol.svg
rename to res/img/element-icons/pause.svg
diff --git a/res/img/element-icons/play-symbol.svg b/res/img/element-icons/play.svg
similarity index 100%
rename from res/img/element-icons/play-symbol.svg
rename to res/img/element-icons/play.svg