// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useRef, useEffect, useState } from 'react'; import classNames from 'classnames'; import { noop } from 'lodash'; import { assert } from '../../util/assert'; import { LocalizerType } from '../../types/Util'; import { WaveformCache } from '../../types/Audio'; import { hasNotDownloaded, AttachmentType } from '../../types/Attachment'; export type Props = { direction?: 'incoming' | 'outgoing'; id: string; i18n: LocalizerType; attachment: AttachmentType; withContentAbove: boolean; withContentBelow: boolean; // See: GlobalAudioContext.tsx audio: HTMLAudioElement; audioContext: AudioContext; waveformCache: WaveformCache; buttonRef: React.RefObject; kickOffAttachmentDownload(): void; onCorrupted(): void; activeAudioID: string | undefined; setActiveAudioID: (id: string | undefined) => void; }; type ButtonProps = { i18n: LocalizerType; buttonRef: React.RefObject; mod: string; label: string; onClick: () => void; }; type LoadAudioOptions = { audioContext: AudioContext; waveformCache: WaveformCache; url: string; }; type LoadAudioResult = { duration: number; peaks: ReadonlyArray; }; enum State { NotDownloaded = 'NotDownloaded', Pending = 'Pending', Normal = 'Normal', } // Constants const CSS_BASE = 'module-message__audio-attachment'; const BAR_COUNT = 47; const BAR_NOT_DOWNLOADED_HEIGHT = 2; const BAR_MIN_HEIGHT = 4; const BAR_MAX_HEIGHT = 20; const REWIND_BAR_COUNT = 2; // Increments for keyboard audio seek (in seconds) const SMALL_INCREMENT = 1; const BIG_INCREMENT = 5; // Utils const timeToText = (time: number): string => { const hours = Math.floor(time / 3600); let minutes = Math.floor((time % 3600) / 60).toString(); let seconds = Math.floor(time % 60).toString(); if (hours !== 0 && minutes.length < 2) { minutes = `0${minutes}`; } if (seconds.length < 2) { seconds = `0${seconds}`; } return hours ? `${hours}:${minutes}:${seconds}` : `${minutes}:${seconds}`; }; /** * Load audio from `url`, decode PCM data, and compute RMS peaks for displaying * the waveform. * * The results are cached in the `waveformCache` which is shared across * messages in the conversation and provided by GlobalAudioContext. */ // TODO(indutny): move this to GlobalAudioContext and limit the concurrency. // see DESKTOP-1267 async function loadAudio(options: LoadAudioOptions): Promise { const { audioContext, waveformCache, url } = options; const existing = waveformCache.get(url); if (existing) { window.log.info('MessageAudio: waveform cache hit', url); return Promise.resolve(existing); } window.log.info('MessageAudio: waveform cache miss', url); // Load and decode `url` into a raw PCM const response = await fetch(url); const raw = await response.arrayBuffer(); const data = await audioContext.decodeAudioData(raw); // Compute RMS peaks const peaks = new Array(BAR_COUNT).fill(0); const norms = new Array(BAR_COUNT).fill(0); const samplesPerPeak = data.length / peaks.length; for ( let channelNum = 0; channelNum < data.numberOfChannels; channelNum += 1 ) { const channel = data.getChannelData(channelNum); for (let sample = 0; sample < channel.length; sample += 1) { const i = Math.floor(sample / samplesPerPeak); peaks[i] += channel[sample] ** 2; norms[i] += 1; } } // Average let max = 1e-23; for (let i = 0; i < peaks.length; i += 1) { peaks[i] = Math.sqrt(peaks[i] / Math.max(1, norms[i])); max = Math.max(max, peaks[i]); } // Normalize for (let i = 0; i < peaks.length; i += 1) { peaks[i] /= max; } const result = { peaks, duration: data.duration }; waveformCache.set(url, result); return result; } const Button: React.FC = props => { const { i18n, buttonRef, mod, label, onClick } = props; // Clicking button toggle playback const onButtonClick = (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); onClick(); }; // Keyboard playback toggle const onButtonKeyDown = (event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== 'Space') { return; } event.stopPropagation(); event.preventDefault(); onClick(); }; return (