Skip to content

Commit e1f7b0a

Browse files
authored
Offload some more waveform processing onto a worker (matrix-org#9223)
1 parent ca25c8f commit e1f7b0a

15 files changed

+231
-72
lines changed

cypress/e2e/audio-player/audio-player.spec.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,9 @@ describe("Audio player", () => {
204204
// Assert that the counter is zero before clicking the play button
205205
cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist");
206206

207-
// Find and click "Play" button
208-
cy.findByRole("button", { name: "Play" }).click();
207+
// Find and click "Play" button, the wait is to make the test less flaky
208+
cy.findByRole("button", { name: "Play" }).should("exist");
209+
cy.wait(500).findByRole("button", { name: "Play" }).click();
209210

210211
// Assert that "Pause" button can be found
211212
cy.findByRole("button", { name: "Pause" }).should("exist");
@@ -339,8 +340,9 @@ describe("Audio player", () => {
339340
// Assert that the counter is zero before clicking the play button
340341
cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist");
341342

342-
// Find and click "Play" button
343-
cy.findByRole("button", { name: "Play" }).click();
343+
// Find and click "Play" button, the wait is to make the test less flaky
344+
cy.findByRole("button", { name: "Play" }).should("exist");
345+
cy.wait(500).findByRole("button", { name: "Play" }).click();
344346

345347
// Assert that "Pause" button can be found
346348
cy.findByRole("button", { name: "Pause" }).should("exist");
@@ -349,7 +351,7 @@ describe("Audio player", () => {
349351
cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist");
350352

351353
// Assert that "Play" button can be found
352-
cy.findByRole("button", { name: "Play" }).should("exist");
354+
cy.findByRole("button", { name: "Play" }).should("exist").should("not.have.attr", "disabled");
353355
});
354356
})
355357
.realHover()

src/BlurhashEncoder.ts

+4-30
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,9 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
18-
1917
// @ts-ignore - `.ts` is needed here to make TS happy
20-
import BlurhashWorker from "./workers/blurhash.worker.ts";
21-
22-
interface IBlurhashWorkerResponse {
23-
seq: number;
24-
blurhash: string;
25-
}
18+
import BlurhashWorker, { Request, Response } from "./workers/blurhash.worker.ts";
19+
import { WorkerManager } from "./WorkerManager";
2620

2721
export class BlurhashEncoder {
2822
private static internalInstance = new BlurhashEncoder();
@@ -31,29 +25,9 @@ export class BlurhashEncoder {
3125
return BlurhashEncoder.internalInstance;
3226
}
3327

34-
private readonly worker: Worker;
35-
private seq = 0;
36-
private pendingDeferredMap = new Map<number, IDeferred<string>>();
37-
38-
public constructor() {
39-
this.worker = new BlurhashWorker();
40-
this.worker.onmessage = this.onMessage;
41-
}
42-
43-
private onMessage = (ev: MessageEvent<IBlurhashWorkerResponse>): void => {
44-
const { seq, blurhash } = ev.data;
45-
const deferred = this.pendingDeferredMap.get(seq);
46-
if (deferred) {
47-
this.pendingDeferredMap.delete(seq);
48-
deferred.resolve(blurhash);
49-
}
50-
};
28+
private readonly worker = new WorkerManager<Request, Response>(BlurhashWorker);
5129

5230
public getBlurhash(imageData: ImageData): Promise<string> {
53-
const seq = this.seq++;
54-
const deferred = defer<string>();
55-
this.pendingDeferredMap.set(seq, deferred);
56-
this.worker.postMessage({ seq, imageData });
57-
return deferred.promise;
31+
return this.worker.call({ imageData }).then((resp) => resp.blurhash);
5832
}
5933
}

src/WorkerManager.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
18+
19+
import { WorkerPayload } from "./workers/worker";
20+
21+
export class WorkerManager<Request extends {}, Response> {
22+
private readonly worker: Worker;
23+
private seq = 0;
24+
private pendingDeferredMap = new Map<number, IDeferred<Response>>();
25+
26+
public constructor(WorkerConstructor: { new (): Worker }) {
27+
this.worker = new WorkerConstructor();
28+
this.worker.onmessage = this.onMessage;
29+
}
30+
31+
private onMessage = (ev: MessageEvent<Response & WorkerPayload>): void => {
32+
const deferred = this.pendingDeferredMap.get(ev.data.seq);
33+
if (deferred) {
34+
this.pendingDeferredMap.delete(ev.data.seq);
35+
deferred.resolve(ev.data);
36+
}
37+
};
38+
39+
public call(request: Request): Promise<Response> {
40+
const seq = this.seq++;
41+
const deferred = defer<Response>();
42+
this.pendingDeferredMap.set(seq, deferred);
43+
this.worker.postMessage({ seq, ...request });
44+
return deferred.promise;
45+
}
46+
}

src/audio/ManagedPlayback.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { DEFAULT_WAVEFORM, Playback } from "./Playback";
17+
import { Playback } from "./Playback";
1818
import { PlaybackManager } from "./PlaybackManager";
19+
import { DEFAULT_WAVEFORM } from "./consts";
1920

2021
/**
2122
* A managed playback is a Playback instance that is guided by a PlaybackManager.

src/audio/Playback.ts

+21-31
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,18 @@ limitations under the License.
1717
import EventEmitter from "events";
1818
import { SimpleObservable } from "matrix-widget-api";
1919
import { logger } from "matrix-js-sdk/src/logger";
20+
import { defer } from "matrix-js-sdk/src/utils";
2021

22+
// @ts-ignore - `.ts` is needed here to make TS happy
23+
import PlaybackWorker, { Request, Response } from "../workers/playback.worker.ts";
2124
import { UPDATE_EVENT } from "../stores/AsyncStore";
22-
import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays";
25+
import { arrayFastResample } from "../utils/arrays";
2326
import { IDestroyable } from "../utils/IDestroyable";
2427
import { PlaybackClock } from "./PlaybackClock";
2528
import { createAudioContext, decodeOgg } from "./compat";
2629
import { clamp } from "../utils/numbers";
30+
import { WorkerManager } from "../WorkerManager";
31+
import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts";
2732

2833
export enum PlaybackState {
2934
Decoding = "decoding",
@@ -32,25 +37,7 @@ export enum PlaybackState {
3237
Playing = "playing", // active progress through timeline
3338
}
3439

35-
export interface PlaybackInterface {
36-
readonly liveData: SimpleObservable<number[]>;
37-
readonly timeSeconds: number;
38-
readonly durationSeconds: number;
39-
skipTo(timeSeconds: number): Promise<void>;
40-
}
41-
42-
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
4340
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
44-
export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
45-
46-
function makePlaybackWaveform(input: number[]): number[] {
47-
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
48-
const noiseWaveform = input.map((v) => Math.abs(v));
49-
50-
// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
51-
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
52-
return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
53-
}
5441

5542
export interface PlaybackInterface {
5643
readonly currentState: PlaybackState;
@@ -68,14 +55,15 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
6855
public readonly thumbnailWaveform: number[];
6956

7057
private readonly context: AudioContext;
71-
private source: AudioBufferSourceNode | MediaElementAudioSourceNode;
58+
private source?: AudioBufferSourceNode | MediaElementAudioSourceNode;
7259
private state = PlaybackState.Decoding;
73-
private audioBuf: AudioBuffer;
74-
private element: HTMLAudioElement;
60+
private audioBuf?: AudioBuffer;
61+
private element?: HTMLAudioElement;
7562
private resampledWaveform: number[];
7663
private waveformObservable = new SimpleObservable<number[]>();
7764
private readonly clock: PlaybackClock;
7865
private readonly fileSize: number;
66+
private readonly worker = new WorkerManager<Request, Response>(PlaybackWorker);
7967

8068
/**
8169
* Creates a new playback instance from a buffer.
@@ -178,12 +166,11 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
178166
// 5mb
179167
logger.log("Audio file too large: processing through <audio /> element");
180168
this.element = document.createElement("AUDIO") as HTMLAudioElement;
181-
const prom = new Promise((resolve, reject) => {
182-
this.element.onloadeddata = () => resolve(null);
183-
this.element.onerror = (e) => reject(e);
184-
});
169+
const deferred = defer<unknown>();
170+
this.element.onloadeddata = deferred.resolve;
171+
this.element.onerror = deferred.reject;
185172
this.element.src = URL.createObjectURL(new Blob([this.buf]));
186-
await prom; // make sure the audio element is ready for us
173+
await deferred.promise; // make sure the audio element is ready for us
187174
} else {
188175
// Safari compat: promise API not supported on this function
189176
this.audioBuf = await new Promise((resolve, reject) => {
@@ -218,20 +205,23 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
218205

219206
// Update the waveform to the real waveform once we have channel data to use. We don't
220207
// exactly trust the user-provided waveform to be accurate...
221-
const waveform = Array.from(this.audioBuf.getChannelData(0));
222-
this.resampledWaveform = makePlaybackWaveform(waveform);
208+
this.resampledWaveform = await this.makePlaybackWaveform(this.audioBuf.getChannelData(0));
223209
}
224210

225211
this.waveformObservable.update(this.resampledWaveform);
226212

227213
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
228-
this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration;
214+
this.clock.durationSeconds = this.element?.duration ?? this.audioBuf!.duration;
229215

230216
// Signal that we're not decoding anymore. This is done last to ensure the clock is updated for
231217
// when the downstream callers try to use it.
232218
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
233219
}
234220

221+
private makePlaybackWaveform(input: Float32Array): Promise<number[]> {
222+
return this.worker.call({ data: Array.from(input) }).then((resp) => resp.waveform);
223+
}
224+
235225
private onPlaybackEnd = async (): Promise<void> => {
236226
await this.context.suspend();
237227
this.emit(PlaybackState.Stopped);
@@ -269,7 +259,7 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
269259
this.source = this.context.createMediaElementSource(this.element);
270260
} else {
271261
this.source = this.context.createBufferSource();
272-
this.source.buffer = this.audioBuf;
262+
this.source.buffer = this.audioBuf ?? null;
273263
}
274264

275265
this.source.addEventListener("ended", this.onPlaybackEnd);

src/audio/PlaybackManager.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { DEFAULT_WAVEFORM, Playback, PlaybackState } from "./Playback";
17+
import { Playback, PlaybackState } from "./Playback";
1818
import { ManagedPlayback } from "./ManagedPlayback";
19+
import { DEFAULT_WAVEFORM } from "./consts";
1920

2021
/**
2122
* Handles management of playback instances to ensure certain functionality, like

src/audio/consts.ts

+5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17+
import { arraySeed } from "../utils/arrays";
18+
1719
export const WORKLET_NAME = "mx-voice-worklet";
1820

1921
export enum PayloadEvent {
@@ -35,3 +37,6 @@ export interface IAmplitudePayload extends IPayload {
3537
forIndex: number;
3638
amplitude: number;
3739
}
40+
41+
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
42+
export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);

src/components/views/audio_messages/PlaybackWaveform.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ import React from "react";
1818

1919
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
2020
import Waveform from "./Waveform";
21-
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
21+
import { Playback } from "../../../audio/Playback";
2222
import { percentageOf } from "../../../utils/numbers";
23+
import { PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/consts";
2324

2425
interface IProps {
2526
playback: Playback;

src/utils/arrays.ts

+2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export function arrayFastResample(input: number[], points: number): number[] {
5757
* @param {number} points The number of samples to end up with.
5858
* @returns {number[]} The resampled array.
5959
*/
60+
// ts-prune-ignore-next
6061
export function arraySmoothingResample(input: number[], points: number): number[] {
6162
if (input.length === points) return input; // short-circuit a complicated call
6263

@@ -99,6 +100,7 @@ export function arraySmoothingResample(input: number[], points: number): number[
99100
* @param {number} newMax The maximum value to scale to.
100101
* @returns {number[]} The rescaled array.
101102
*/
103+
// ts-prune-ignore-next
102104
export function arrayRescale(input: number[], newMin: number, newMax: number): number[] {
103105
const min: number = Math.min(...input);
104106
const max: number = Math.max(...input);

src/workers/blurhash.worker.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,19 @@ limitations under the License.
1616

1717
import { encode } from "blurhash";
1818

19+
import { WorkerPayload } from "./worker";
20+
1921
const ctx: Worker = self as any;
2022

21-
interface IBlurhashWorkerRequest {
22-
seq: number;
23+
export interface Request {
2324
imageData: ImageData;
2425
}
2526

26-
ctx.addEventListener("message", (event: MessageEvent<IBlurhashWorkerRequest>): void => {
27+
export interface Response {
28+
blurhash: string;
29+
}
30+
31+
ctx.addEventListener("message", (event: MessageEvent<Request & WorkerPayload>): void => {
2732
const { seq, imageData } = event.data;
2833
const blurhash = encode(
2934
imageData.data,

src/workers/playback.worker.ts

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { WorkerPayload } from "./worker";
18+
import { arrayRescale, arraySmoothingResample } from "../utils/arrays";
19+
import { PLAYBACK_WAVEFORM_SAMPLES } from "../audio/consts";
20+
21+
const ctx: Worker = self as any;
22+
23+
export interface Request {
24+
data: number[];
25+
}
26+
27+
export interface Response {
28+
waveform: number[];
29+
}
30+
31+
ctx.addEventListener("message", async (event: MessageEvent<Request & WorkerPayload>): Promise<void> => {
32+
const { seq, data } = event.data;
33+
34+
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
35+
const noiseWaveform = data.map((v) => Math.abs(v));
36+
37+
// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
38+
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
39+
const waveform = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
40+
41+
ctx.postMessage({ seq, waveform });
42+
});

src/workers/worker.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
export interface WorkerPayload {
18+
seq: number;
19+
}

0 commit comments

Comments
 (0)