Skip to content

Commit

Permalink
Offload some more waveform processing onto a worker (#9223)
Browse files Browse the repository at this point in the history
  • Loading branch information
t3chguy committed Apr 27, 2023
1 parent ca25c8f commit e1f7b0a
Show file tree
Hide file tree
Showing 15 changed files with 231 additions and 72 deletions.
12 changes: 7 additions & 5 deletions cypress/e2e/audio-player/audio-player.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,9 @@ describe("Audio player", () => {
// Assert that the counter is zero before clicking the play button
cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist");

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

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

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

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

// Assert that "Play" button can be found
cy.findByRole("button", { name: "Play" }).should("exist");
cy.findByRole("button", { name: "Play" }).should("exist").should("not.have.attr", "disabled");
});
})
.realHover()
Expand Down
34 changes: 4 additions & 30 deletions src/BlurhashEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { defer, IDeferred } from "matrix-js-sdk/src/utils";

// @ts-ignore - `.ts` is needed here to make TS happy
import BlurhashWorker from "./workers/blurhash.worker.ts";

interface IBlurhashWorkerResponse {
seq: number;
blurhash: string;
}
import BlurhashWorker, { Request, Response } from "./workers/blurhash.worker.ts";
import { WorkerManager } from "./WorkerManager";

export class BlurhashEncoder {
private static internalInstance = new BlurhashEncoder();
Expand All @@ -31,29 +25,9 @@ export class BlurhashEncoder {
return BlurhashEncoder.internalInstance;
}

private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, IDeferred<string>>();

public constructor() {
this.worker = new BlurhashWorker();
this.worker.onmessage = this.onMessage;
}

private onMessage = (ev: MessageEvent<IBlurhashWorkerResponse>): void => {
const { seq, blurhash } = ev.data;
const deferred = this.pendingDeferredMap.get(seq);
if (deferred) {
this.pendingDeferredMap.delete(seq);
deferred.resolve(blurhash);
}
};
private readonly worker = new WorkerManager<Request, Response>(BlurhashWorker);

public getBlurhash(imageData: ImageData): Promise<string> {
const seq = this.seq++;
const deferred = defer<string>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, imageData });
return deferred.promise;
return this.worker.call({ imageData }).then((resp) => resp.blurhash);
}
}
46 changes: 46 additions & 0 deletions src/WorkerManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
Copyright 2022 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 { defer, IDeferred } from "matrix-js-sdk/src/utils";

import { WorkerPayload } from "./workers/worker";

export class WorkerManager<Request extends {}, Response> {
private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, IDeferred<Response>>();

public constructor(WorkerConstructor: { new (): Worker }) {
this.worker = new WorkerConstructor();
this.worker.onmessage = this.onMessage;
}

private onMessage = (ev: MessageEvent<Response & WorkerPayload>): void => {
const deferred = this.pendingDeferredMap.get(ev.data.seq);
if (deferred) {
this.pendingDeferredMap.delete(ev.data.seq);
deferred.resolve(ev.data);
}
};

public call(request: Request): Promise<Response> {
const seq = this.seq++;
const deferred = defer<Response>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, ...request });
return deferred.promise;
}
}
3 changes: 2 additions & 1 deletion src/audio/ManagedPlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { DEFAULT_WAVEFORM, Playback } from "./Playback";
import { Playback } from "./Playback";
import { PlaybackManager } from "./PlaybackManager";
import { DEFAULT_WAVEFORM } from "./consts";

/**
* A managed playback is a Playback instance that is guided by a PlaybackManager.
Expand Down
52 changes: 21 additions & 31 deletions src/audio/Playback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ limitations under the License.
import EventEmitter from "events";
import { SimpleObservable } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { defer } from "matrix-js-sdk/src/utils";

// @ts-ignore - `.ts` is needed here to make TS happy
import PlaybackWorker, { Request, Response } from "../workers/playback.worker.ts";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays";
import { arrayFastResample } from "../utils/arrays";
import { IDestroyable } from "../utils/IDestroyable";
import { PlaybackClock } from "./PlaybackClock";
import { createAudioContext, decodeOgg } from "./compat";
import { clamp } from "../utils/numbers";
import { WorkerManager } from "../WorkerManager";
import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts";

export enum PlaybackState {
Decoding = "decoding",
Expand All @@ -32,25 +37,7 @@ export enum PlaybackState {
Playing = "playing", // active progress through timeline
}

export interface PlaybackInterface {
readonly liveData: SimpleObservable<number[]>;
readonly timeSeconds: number;
readonly durationSeconds: number;
skipTo(timeSeconds: number): Promise<void>;
}

export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);

function makePlaybackWaveform(input: number[]): number[] {
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
const noiseWaveform = input.map((v) => Math.abs(v));

// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
}

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

private readonly context: AudioContext;
private source: AudioBufferSourceNode | MediaElementAudioSourceNode;
private source?: AudioBufferSourceNode | MediaElementAudioSourceNode;
private state = PlaybackState.Decoding;
private audioBuf: AudioBuffer;
private element: HTMLAudioElement;
private audioBuf?: AudioBuffer;
private element?: HTMLAudioElement;
private resampledWaveform: number[];
private waveformObservable = new SimpleObservable<number[]>();
private readonly clock: PlaybackClock;
private readonly fileSize: number;
private readonly worker = new WorkerManager<Request, Response>(PlaybackWorker);

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

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

this.waveformObservable.update(this.resampledWaveform);

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

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

private makePlaybackWaveform(input: Float32Array): Promise<number[]> {
return this.worker.call({ data: Array.from(input) }).then((resp) => resp.waveform);
}

private onPlaybackEnd = async (): Promise<void> => {
await this.context.suspend();
this.emit(PlaybackState.Stopped);
Expand Down Expand Up @@ -269,7 +259,7 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
this.source = this.context.createMediaElementSource(this.element);
} else {
this.source = this.context.createBufferSource();
this.source.buffer = this.audioBuf;
this.source.buffer = this.audioBuf ?? null;
}

this.source.addEventListener("ended", this.onPlaybackEnd);
Expand Down
3 changes: 2 additions & 1 deletion src/audio/PlaybackManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { DEFAULT_WAVEFORM, Playback, PlaybackState } from "./Playback";
import { Playback, PlaybackState } from "./Playback";
import { ManagedPlayback } from "./ManagedPlayback";
import { DEFAULT_WAVEFORM } from "./consts";

/**
* Handles management of playback instances to ensure certain functionality, like
Expand Down
5 changes: 5 additions & 0 deletions src/audio/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { arraySeed } from "../utils/arrays";

export const WORKLET_NAME = "mx-voice-worklet";

export enum PayloadEvent {
Expand All @@ -35,3 +37,6 @@ export interface IAmplitudePayload extends IPayload {
forIndex: number;
amplitude: number;
}

export const PLAYBACK_WAVEFORM_SAMPLES = 39;
export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
3 changes: 2 additions & 1 deletion src/components/views/audio_messages/PlaybackWaveform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import React from "react";

import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
import Waveform from "./Waveform";
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
import { Playback } from "../../../audio/Playback";
import { percentageOf } from "../../../utils/numbers";
import { PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/consts";

interface IProps {
playback: Playback;
Expand Down
2 changes: 2 additions & 0 deletions src/utils/arrays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function arrayFastResample(input: number[], points: number): number[] {
* @param {number} points The number of samples to end up with.
* @returns {number[]} The resampled array.
*/
// ts-prune-ignore-next
export function arraySmoothingResample(input: number[], points: number): number[] {
if (input.length === points) return input; // short-circuit a complicated call

Expand Down Expand Up @@ -99,6 +100,7 @@ export function arraySmoothingResample(input: number[], points: number): number[
* @param {number} newMax The maximum value to scale to.
* @returns {number[]} The rescaled array.
*/
// ts-prune-ignore-next
export function arrayRescale(input: number[], newMin: number, newMax: number): number[] {
const min: number = Math.min(...input);
const max: number = Math.max(...input);
Expand Down
11 changes: 8 additions & 3 deletions src/workers/blurhash.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ limitations under the License.

import { encode } from "blurhash";

import { WorkerPayload } from "./worker";

const ctx: Worker = self as any;

interface IBlurhashWorkerRequest {
seq: number;
export interface Request {
imageData: ImageData;
}

ctx.addEventListener("message", (event: MessageEvent<IBlurhashWorkerRequest>): void => {
export interface Response {
blurhash: string;
}

ctx.addEventListener("message", (event: MessageEvent<Request & WorkerPayload>): void => {
const { seq, imageData } = event.data;
const blurhash = encode(
imageData.data,
Expand Down
42 changes: 42 additions & 0 deletions src/workers/playback.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
Copyright 2022 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 { WorkerPayload } from "./worker";
import { arrayRescale, arraySmoothingResample } from "../utils/arrays";
import { PLAYBACK_WAVEFORM_SAMPLES } from "../audio/consts";

const ctx: Worker = self as any;

export interface Request {
data: number[];
}

export interface Response {
waveform: number[];
}

ctx.addEventListener("message", async (event: MessageEvent<Request & WorkerPayload>): Promise<void> => {
const { seq, data } = event.data;

// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
const noiseWaveform = data.map((v) => Math.abs(v));

// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
const waveform = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);

ctx.postMessage({ seq, waveform });
});
19 changes: 19 additions & 0 deletions src/workers/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
Copyright 2022 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.
*/

export interface WorkerPayload {
seq: number;
}
Loading

0 comments on commit e1f7b0a

Please sign in to comment.