diff --git a/docker/base-php/Dockerfile b/docker/base-php/Dockerfile index e209bdc4b3..e2d38b65c9 100644 --- a/docker/base-php/Dockerfile +++ b/docker/base-php/Dockerfile @@ -4,7 +4,8 @@ FROM php:7.3.28-apache # p7zip-full - used by LF application for unzipping lexicon uploads # unzip - used by LF application for unzipping lexicon uploads # curl - used by LF application -RUN apt-get update && apt-get -y install p7zip-full unzip curl tini && rm -rf /var/lib/apt/lists/* +# ffmpeg - used by LF audio upload method +RUN apt-get update && apt-get -y install p7zip-full unzip curl tini ffmpeg && rm -rf /var/lib/apt/lists/* # see https://github.com/mlocati/docker-php-extension-installer # PHP extensions required by the LF application diff --git a/package-lock.json b/package-lock.json index 830f37d133..7906c988a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "font-awesome": "^4.7.0", "intl-tel-input": "9.2.7", "jquery": "^3.6.0", - "lamejs": "^1.2.0", "localforage": "^1.7.1", "ng-drag-to-reorder": "^1.0.8", "ng-file-upload": "12.0.4", @@ -30,6 +29,7 @@ "npm-run-all": "^4.1.5", "oclazyload": "^1.1.0", "offline-js": "0.7.11", + "webm-fix-duration": "^1.0.1", "zxcvbn": "^4.4.2" }, "devDependencies": { @@ -43,6 +43,7 @@ "@types/bootstrap": "^3.3.35", "@types/core-js": "^0.9.42", "@types/deep-diff": "^1.0.1", + "@types/dom-mediacapture-record": "^1.0.11", "@types/intl-tel-input": "0.0.7", "@types/jasmine": "^2.8.4", "@types/jasminewd2": "^2.0.3", @@ -2156,6 +2157,12 @@ "integrity": "sha512-cZIq2GFcPmW0/M7dtLuphyoU8f3zpTcBgV+wkFFJ0CK0lwRVGGLaBSJZ98qs4LjtLimPq1Bb2VJnhGn6SEE4IA==", "dev": true }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.11.tgz", + "integrity": "sha512-ODVOH95x08arZhbQOjH3no7Iye64akdO+55nM+IGtTzpu2ACKr9CQTrI//CCVieIjlI/eL+rK1hQjMycxIgylQ==", + "dev": true + }, "node_modules/@types/eslint": { "version": "7.2.7", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.7.tgz", @@ -6121,14 +6128,6 @@ "immediate": "~3.0.5" } }, - "node_modules/lamejs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/lamejs/-/lamejs-1.2.0.tgz", - "integrity": "sha1-Aln4PbRmYUGntnG4yqY2nZUXfQg=", - "dependencies": { - "use-strict": "1.0.1" - } - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -9910,11 +9909,6 @@ "node": ">=8.9.0" } }, - "node_modules/use-strict": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/use-strict/-/use-strict-1.0.1.tgz", - "integrity": "sha1-C7gNlPSaSgUZK4Sox9NOlfGn46A=" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10155,6 +10149,11 @@ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "dev": true }, + "node_modules/webm-fix-duration": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/webm-fix-duration/-/webm-fix-duration-1.0.1.tgz", + "integrity": "sha512-KaL2yq5BQ1ngRlWVpwQ0bI8INW3ZMzrbPfz8L50GvZHhReCeG5+zKqzk4BcpK6vEdMRHSxoNHn/ixiDqVdKZTw==" + }, "node_modules/webpack": { "version": "5.27.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.27.1.tgz", @@ -12633,6 +12632,12 @@ "integrity": "sha512-cZIq2GFcPmW0/M7dtLuphyoU8f3zpTcBgV+wkFFJ0CK0lwRVGGLaBSJZ98qs4LjtLimPq1Bb2VJnhGn6SEE4IA==", "dev": true }, + "@types/dom-mediacapture-record": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.11.tgz", + "integrity": "sha512-ODVOH95x08arZhbQOjH3no7Iye64akdO+55nM+IGtTzpu2ACKr9CQTrI//CCVieIjlI/eL+rK1hQjMycxIgylQ==", + "dev": true + }, "@types/eslint": { "version": "7.2.7", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.7.tgz", @@ -15785,14 +15790,6 @@ } } }, - "lamejs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/lamejs/-/lamejs-1.2.0.tgz", - "integrity": "sha1-Aln4PbRmYUGntnG4yqY2nZUXfQg=", - "requires": { - "use-strict": "1.0.1" - } - }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -18713,11 +18710,6 @@ } } }, - "use-strict": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/use-strict/-/use-strict-1.0.1.tgz", - "integrity": "sha1-C7gNlPSaSgUZK4Sox9NOlfGn46A=" - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -18909,6 +18901,11 @@ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "dev": true }, + "webm-fix-duration": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/webm-fix-duration/-/webm-fix-duration-1.0.1.tgz", + "integrity": "sha512-KaL2yq5BQ1ngRlWVpwQ0bI8INW3ZMzrbPfz8L50GvZHhReCeG5+zKqzk4BcpK6vEdMRHSxoNHn/ixiDqVdKZTw==" + }, "webpack": { "version": "5.27.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.27.1.tgz", diff --git a/package.json b/package.json index ed57f4e0f0..09e5039960 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "font-awesome": "^4.7.0", "intl-tel-input": "9.2.7", "jquery": "^3.6.0", - "lamejs": "^1.2.0", "localforage": "^1.7.1", "ng-drag-to-reorder": "^1.0.8", "ng-file-upload": "12.0.4", @@ -45,6 +44,7 @@ "npm-run-all": "^4.1.5", "oclazyload": "^1.1.0", "offline-js": "0.7.11", + "webm-fix-duration": "^1.0.1", "zxcvbn": "^4.4.2" }, "devDependencies": { @@ -58,6 +58,7 @@ "@types/bootstrap": "^3.3.35", "@types/core-js": "^0.9.42", "@types/deep-diff": "^1.0.1", + "@types/dom-mediacapture-record": "^1.0.11", "@types/intl-tel-input": "0.0.7", "@types/jasmine": "^2.8.4", "@types/jasminewd2": "^2.0.3", diff --git a/src/Api/Model/Languageforge/Lexicon/Command/LexUploadCommands.php b/src/Api/Model/Languageforge/Lexicon/Command/LexUploadCommands.php index e5ac7e68b6..6558796400 100644 --- a/src/Api/Model/Languageforge/Lexicon/Command/LexUploadCommands.php +++ b/src/Api/Model/Languageforge/Lexicon/Command/LexUploadCommands.php @@ -18,6 +18,7 @@ class LexUploadCommands ".lift" ); + /** * Upload an audio file * @@ -53,9 +54,11 @@ public static function uploadAudioFile($projectId, $mediaType, $tmpFilePath) $allowedTypes = array( "application/octet-stream", - // allow m4a audio uploads, which curiously has a mime type of video/mp4 + // allow m4a audio uploads, which curiously has a mime type of video/mp4, audio/m4a, audio/mp4, or audio/x-m4a "video/mp4", - + "audio/m4a", + "audio/mp4", + "audio/x-m4a", "audio/mpeg", "audio/x-mpeg", "audio/mp3", @@ -66,27 +69,70 @@ public static function uploadAudioFile($projectId, $mediaType, $tmpFilePath) "audio/x-mpg", "audio/x-mpegaudio", "audio/x-wav", - "audio/wav" + "audio/wav", + "audio/flac", + "audio/x-flac", + "audio/ogg", + "audio/webm", + // allow Google Chrome to handle MediaRecorder recordings as video/webm MimeType + "video/webm" ); $allowedExtensions = array( ".mp3", ".mpa", ".mpg", ".m4a", - ".wav" + ".wav", + ".ogg", + ".flac", + ".webm", ); $response = new UploadResponse(); + if (in_array(strtolower($fileType), $allowedTypes) && in_array(strtolower($fileExt), $allowedExtensions)) { // make the folders if they don't exist $project->createAssetsFolders(); $folderPath = $project->getAudioFolderPath(); - // move uploaded file from tmp location to assets + // move uploaded/recorded file from tmp location to assets $filePath = self::mediaFilePath($folderPath, $fileNamePrefix, $fileName); $moveOk = copy($tmpFilePath, $filePath); - @unlink($tmpFilePath); + + // convert audio file to mp3 or wav format if necessary + // FLEx only supports mp3 or wav format as of 2022-09 + + if (strcmp(strtolower($fileExt), ".mp3") !== 0 && strcmp(strtolower($fileExt), ".wav") !== 0) { + //First, find the duration of the file + $sanitizedTmpFilePath = escapeshellarg($tmpFilePath); + $ffprobeCommand = `ffprobe -i $sanitizedTmpFilePath -show_entries format=duration -v quiet -of csv="p=0" 2> /dev/null`; + $audioDuration = floatval($ffprobeCommand); + + // Convert to .wav if the result will be less than 1 MB at 165Kb/s (recording is shorter than 6 seconds) + // and .mp3 otherwise (recording is longer than 6 seconds) + $extensionlessTmpFilePath = substr($sanitizedTmpFilePath, 0, strrpos($sanitizedTmpFilePath, strtolower($fileExt))); + $extensionlessFileName = substr($fileName, 0, strrpos($fileName, strtolower($fileExt))); + + if($audioDuration < 6){ + `ffmpeg -i $sanitizedTmpFilePath -b:a 165K -maxrate 165K -bufsize 80K $extensionlessTmpFilePath.wav 2> /dev/null')`; + `mv $sanitizedTmpFilePath $extensionlessTmpFilePath.wav 2> /dev/null`; + $fileName = $extensionlessFileName . '.wav'; + $tmpFilePath = $extensionlessTmpFilePath. '.wav'; + } + else{ + `ffmpeg -i $sanitizedTmpFilePath -b:a 165K -maxrate 165K -bufsize 80K $extensionlessTmpFilePath.mp3 2> /dev/null')`; + `mv $sanitizedTmpFilePath $extensionlessTmpFilePath.mp3 2> /dev/null`; + $fileName = $extensionlessFileName . '.mp3'; + $tmpFilePath = $extensionlessTmpFilePath . '.mp3'; + } + + + // move uploaded file from tmp location to assets + $filePath = self::mediaFilePath($folderPath, $fileNamePrefix, $fileName); + $moveOk = copy($tmpFilePath, $filePath); + @unlink($tmpFilePath); + } // construct server response if ($moveOk && $tmpFilePath) { @@ -95,10 +141,11 @@ public static function uploadAudioFile($projectId, $mediaType, $tmpFilePath) $data->fileName = $fileNamePrefix . '_' . $fileName; $response->result = true; - if (array_key_exists('previousFilename', $_POST)) { - $previousFilename = $_POST['previousFilename']; - self::deleteMediaFile($projectId, $mediaType, $previousFilename); - } + //Uncomment to ensure that only one format for each audio file is stored in the assets. We want to keep up to two formats right now (09-2022): the original and if needed, a FLEx-compatible one + // if (array_key_exists('previousFilename', $_POST)) { + // $previousFilename = $_POST['previousFilename']; + // self::deleteMediaFile($projectId, $mediaType, $previousFilename); + // } } else { $data = new ErrorResult(); $data->errorType = 'UserMessage'; diff --git a/src/angular-app/bellows/shared/audio-recorder/audio-recorder.component.ts b/src/angular-app/bellows/shared/audio-recorder/audio-recorder.component.ts index d77ad9dd45..ac268dab40 100644 --- a/src/angular-app/bellows/shared/audio-recorder/audio-recorder.component.ts +++ b/src/angular-app/bellows/shared/audio-recorder/audio-recorder.component.ts @@ -1,25 +1,99 @@ -import * as angular from 'angular'; -import * as lamejs from 'lamejs'; - -declare var webkitAudioContext: { - new(): AudioContext; -}; +import { webmFixDuration } from "webm-fix-duration"; +import * as angular from "angular"; export class AudioRecorderController implements angular.IController { + static $inject = ["$interval", "$scope"]; - static $inject = ['$interval', '$scope']; - constructor(private $interval: angular.IIntervalService, private $scope: angular.IScope) {} - + mediaRecorder: MediaRecorder; + chunks: string[] = []; isRecording = false; hasRecorded = false; + recordingStartTime: Date; audioSrc: string; blob: Blob; recordingTime: string; errorMessage: string; - stopMediaStream: () => void; callback: (blob: Blob) => void; + durationInMilliseconds: number; interval: angular.IPromise; + constructor( + private $interval: angular.IIntervalService, + private $scope: angular.IScope + ) {} + + private startRecording() { + this.recordingTime = "0:00"; + + navigator.mediaDevices.getUserMedia({ audio: true }).then( + (stream) => { + this.mediaRecorder = new MediaRecorder(stream); + + this.$scope.$apply(() => { + this.hasRecorded = true; + this.errorMessage = null; + this.isRecording = true; + }); + + this.mediaRecorder.addEventListener( + "dataavailable", + async (e: { data: any }) => { + this.chunks.push(e.data); + var roughBlob = new Blob(this.chunks, { + type: "audio/webm; codecs=opus", + }); + //In some browsers (Chrome, Edge, ...) navigator.mediaDevices.getUserMedia with MediaRecorder creates WEBM files without duration metadata //2022-09 + //webmFixDuration appends missing duration metadata to a WEBM file blob. + this.blob = await webmFixDuration( + roughBlob, + this.durationInMilliseconds, + "audio/webm; codecs=opus" + ); + this.chunks = []; + this.audioSrc = window.URL.createObjectURL(this.blob); + this.$scope.$digest(); + } + ); + + this.recordingStartTime = new Date(); + + this.interval = this.$interval(() => { + const seconds = Math.floor( + (new Date().getTime() - this.recordingStartTime.getTime()) / 1000 + ); + this.recordingTime = + Math.floor(seconds / 60) + + ":" + + (seconds % 60 < 10 ? "0" : "") + + (seconds % 60); + }, 1000); + + this.mediaRecorder.start(); + }, + (err) => { + this.$scope.$apply(() => { + this.errorMessage = "Unable to record audio from your microphone."; + this.isRecording = false; + this.hasRecorded = false; + }); + + console.error(err); + } + ); + } + + private stopRecording() { + this.durationInMilliseconds = Math.floor( + new Date().getTime() - this.recordingStartTime.getTime() + ); + + this.mediaRecorder.stop(); + + if (this.interval) { + this.$interval.cancel(this.interval); + } + } + toggleRecording() { if (this.isRecording) this.stopRecording(); else this.startRecording(); @@ -27,7 +101,9 @@ export class AudioRecorderController implements angular.IController { } close() { - this.stopRecording(); + if (this.isRecording) { + this.stopRecording(); + } this.callback(null); } @@ -36,132 +112,26 @@ export class AudioRecorderController implements angular.IController { } recordingSupported() { - return navigator.mediaDevices && navigator.mediaDevices.enumerateDevices && navigator.mediaDevices.getUserMedia && - ((window as any).AudioContext || (window as any).webkitAudioContext); + return ( + navigator.mediaDevices && + navigator.mediaDevices.enumerateDevices && + navigator.mediaDevices.getUserMedia && + ((window as any).AudioContext || (window as any).webkitAudioContext) + ); } $onDestroy() { - this.stopRecording(); - } - - private startRecording() { - this.recordingTime = '0:00'; - - navigator.mediaDevices.getUserMedia({audio: true}).then(stream => { - - this.$scope.$apply(() => { - this.hasRecorded = true; - this.errorMessage = null; - this.isRecording = true; - }); - - const recordingStartTime = new Date(); - // webkit prefix required for Safari - const context = (window as any).AudioContext ? new AudioContext() : new webkitAudioContext(); - const bufferSize = 0; - const channels = 1; - const processor = context.createScriptProcessor(bufferSize, channels, channels); - context.createMediaStreamSource(stream).connect(processor); - processor.connect(context.destination); - const sampleRate = context.sampleRate; - const bitrate = 128; - const mp3Encoder = new MP3Encoder(channels, sampleRate, bitrate); - - function handleAudioData(event: AudioProcessingEvent) { - mp3Encoder.appendData(event.inputBuffer.getChannelData(0)); - } - processor.addEventListener('audioprocess', handleAudioData); - - mp3Encoder.onMp3Blob(blob => { - this.blob = blob; - this.audioSrc = URL.createObjectURL(blob); - }); - - this.interval = this.$interval(() => { - const seconds = Math.floor((new Date().getTime() - recordingStartTime.getTime()) / 1000); - this.recordingTime = Math.floor(seconds / 60) + ':' + (seconds % 60 < 10 ? '0' : '') + seconds % 60; - }, 1000); - - this.stopMediaStream = () => { - processor.removeEventListener('audioprocess', handleAudioData); - mp3Encoder.end(); - stream.getAudioTracks()[0].stop(); - }; - - }, err => { - this.$scope.$apply(() => { - this.errorMessage = 'Unable to record audio from your microphone.'; - this.isRecording = false; - this.hasRecorded = false; - }); - console.error(err); - }); - } - - private stopRecording() { - if (this.interval) this.$interval.cancel(this.interval); - if (this.stopMediaStream) this.stopMediaStream(); - } - -} - -export class MP3Encoder { - - constructor(private channels: number, private sampleRate: number, private bitrate: number) {} - - buffer: Float32Array[] = []; - mp3BlobListeners: Array<(data: Blob) => void> = []; - - lame = new lamejs.Mp3Encoder(this.channels, this.sampleRate, this.bitrate); - - appendData(data: Float32Array) { - // copy the data, because Chromium 66.0.3359.139 overwrites it (FF 60.0.1 does not) - this.buffer.push(data.slice(0, data.length)); - } - - end() { - // Flatten the buffer array while converting it from Float32 to Int16 - const pcmData = new Int16Array(this.buffer.length * this.buffer[0].length); - - // max and min 16-bit values - const max = 0x7FFF; - const min = 0x8000; - let index = 0; - for (let i = 0, len1 = this.buffer.length; i < len1; ++i) { - const chunk = this.buffer[i]; - for (let j = 0, len2 = chunk.length; j < len2; ++j) { - pcmData[index] = chunk[j] < 0 ? chunk[j] * min : chunk[j] * max; - ++index; - } - } - - const blockSize = 1152; - const mp3Data: Int8Array[] = []; - - let encoded: Int8Array; - for (let i = 0; i < pcmData.length; i += blockSize) { - const chunk = pcmData.subarray(i, i + blockSize); - // This line takes the most time of this function, about 90% - encoded = this.lame.encodeBuffer(chunk); - if (chunk.length > 0) mp3Data.push(new Int8Array(encoded)); + if (this.isRecording) { + this.stopRecording(); } - - encoded = this.lame.flush(); - if (encoded.length > 0) mp3Data.push(new Int8Array(encoded)); - const blob = new Blob(mp3Data, {type: 'audio/mp3'}); - - this.mp3BlobListeners.forEach(cb => cb(blob)); - } - - onMp3Blob(cb: (data: Blob) => void) { - this.mp3BlobListeners.push(cb); } } export const AudioRecorderComponent: angular.IComponentOptions = { bindings: { - callback: '<' // TODO probably change to > or <, not sure which + callback: "<", }, controller: AudioRecorderController, - templateUrl: '/angular-app/bellows/shared/audio-recorder/audio-recorder.component.html' + templateUrl: + "/angular-app/bellows/shared/audio-recorder/audio-recorder.component.html", }; diff --git a/src/angular-app/bellows/shared/sound-player.component.html b/src/angular-app/bellows/shared/sound-player.component.html index f20786653c..fd49deec1a 100644 --- a/src/angular-app/bellows/shared/sound-player.component.html +++ b/src/angular-app/bellows/shared/sound-player.component.html @@ -6,5 +6,6 @@ {{$ctrl.currentTime()}} / {{$ctrl.duration()}} - + + diff --git a/src/angular-app/bellows/shared/sound-player.component.ts b/src/angular-app/bellows/shared/sound-player.component.ts index 30b938f0d6..cdf1f3285d 100644 --- a/src/angular-app/bellows/shared/sound-player.component.ts +++ b/src/angular-app/bellows/shared/sound-player.component.ts @@ -4,15 +4,19 @@ export class SoundController implements angular.IController { puiUrl: string; audioElement = document.createElement('audio'); + playing = false; private isUserMovingSlider: boolean = false; private slider: HTMLInputElement; static $inject = ['$scope', '$element']; - constructor(private $scope: angular.IScope, private $element: angular.IRootElementService) { } + + constructor(private $scope: angular.IScope, private $element: angular.IRootElementService) {} $onInit(): void { + + this.slider = this.$element.find('.seek-slider').get(0) as HTMLInputElement; this.audioElement.addEventListener('ended', () => { @@ -21,13 +25,9 @@ export class SoundController implements angular.IController { this.togglePlayback(); } - this.audioElement.currentTime = 0; }); }); - this.audioElement.addEventListener('loadedmetadata', () => { - this.$scope.$digest(); - }); const previousFormattedTime: string = null; this.audioElement.addEventListener('timeupdate', () => { @@ -65,21 +65,37 @@ export class SoundController implements angular.IController { } $onDestroy(): void { - this.audioElement.pause(); + if (!this.audioElement.paused){ + this.audioElement.pause(); + } } iconClass(): string { return this.playing ? 'fa-pause' : 'fa-play'; } + + async playAudio() { + try{ + let loadedAudioPlayer = await this.audioElement.play(); + return loadedAudioPlayer; + } catch (e) { + + } + } + togglePlayback(): void { this.playing = !this.playing; if (this.playing) { - this.audioElement.play(); + this.audioElement.currentTime = 0; + this.playAudio(); } else { - this.audioElement.pause(); + if(!this.audioElement.paused){ + this.audioElement.pause(); + } } + } currentTimeInSeconds(): number { diff --git a/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts b/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts index 90e42f3b52..657cf1d233 100644 --- a/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts +++ b/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts @@ -1,3 +1,4 @@ + import * as angular from 'angular'; import { format, addMinutes } from 'date-fns'; @@ -145,7 +146,7 @@ export class FieldAudioController implements angular.IController { audioRecorderCallback = (blob: Blob) => { if (blob) { const date = new Date(); - const fileName = 'recording_' + format(addMinutes(date, date.getTimezoneOffset()), 'yyyy_MM_dd_HH_mm_ss') + '.mp3'; + const fileName = 'recording_' + format(addMinutes(date, date.getTimezoneOffset()), 'yyyy_MM_dd_HH_mm_ss') + '.webm'; const file = new File([blob], fileName); this.uploadAudio(file); }