├── libs ├── .gitkeep └── shared-types │ ├── src │ ├── utils │ │ ├── index.ts │ │ └── optional.type.ts │ ├── enums │ │ ├── index.ts │ │ └── http-status.enum.ts │ ├── index.ts │ └── dtos │ │ ├── download-url-reponse.dto.ts │ │ ├── index.ts │ │ └── media-info.dto.ts │ ├── .babelrc │ ├── tsconfig.lib.json │ ├── README.md │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── project.json ├── apps ├── api │ ├── Dockerfile │ ├── src │ │ ├── uploader │ │ │ ├── enums │ │ │ │ └── upload-errors.enum.ts │ │ │ ├── exceptions │ │ │ │ └── not-chunks-found.exception.ts │ │ │ ├── dto │ │ │ │ └── alive-response.dto.ts │ │ │ ├── uploader.module.ts │ │ │ ├── uploader.controller.ts │ │ │ └── uploader.service.ts │ │ ├── s3 │ │ │ ├── s3.types.ts │ │ │ ├── s3.constants.ts │ │ │ ├── decorators │ │ │ │ └── s3-service.decorator.ts │ │ │ └── providers │ │ │ │ └── s3.provider.ts │ │ ├── api.constants.ts │ │ ├── health │ │ │ ├── health.module.ts │ │ │ └── health.controller.ts │ │ ├── helpers │ │ │ └── wait-for-stram-close.helper.ts │ │ ├── middlewares │ │ │ ├── redoc.middleware.ts │ │ │ └── helpers │ │ │ │ ├── redoc-html-template.ts │ │ │ │ └── redoc-html-template.spec.ts │ │ ├── uploads-cleaner │ │ │ ├── uploads-cleaner.module.ts │ │ │ └── uploads-cleaner.service.ts │ │ ├── config │ │ │ ├── global-config.interface.ts │ │ │ ├── global.schema.ts │ │ │ └── global.config.ts │ │ ├── app.module.ts │ │ ├── multer │ │ │ └── multer.config.ts │ │ └── main.ts │ ├── tsconfig.json │ ├── tsconfig.spec.json │ ├── webpack.config.js │ ├── .eslintrc.json │ ├── tsconfig.app.json │ ├── jest.config.ts │ ├── sample.env │ ├── compose.yml │ └── project.json └── frontend │ ├── features │ ├── HomePage │ │ ├── index.ts │ │ ├── Error.component.tsx │ │ ├── HomePage.component.tsx │ │ ├── use-homepage.hook.ts │ │ ├── contexts │ │ │ └── home.context.tsx │ │ └── Recorder.component.tsx │ └── RecordingPage │ │ ├── index.ts │ │ └── RecordingPage.component.tsx │ ├── lib │ ├── types │ │ └── optional.type.ts │ ├── dto │ │ ├── download-url-reponse.dto.ts │ │ └── media-info.dto.ts │ ├── recording │ │ ├── types │ │ │ └── queue-item.type.ts │ │ ├── enums │ │ │ ├── recorder-status.enum.ts │ │ │ └── encoder-status.enum.ts │ │ ├── interfaces │ │ │ ├── on-uploader-progress-payload.interface.ts │ │ │ ├── media-info.interface.ts │ │ │ ├── retryable-fetch-init-options.interface.ts │ │ │ ├── uploader-options.interface.ts │ │ │ └── abortable-promise.interface.ts │ │ ├── errors │ │ │ ├── submit.error.ts │ │ │ ├── abort-request.error.ts │ │ │ ├── no-chunk-found.error.ts │ │ │ └── max-chunk-count.error.ts │ │ ├── index.ts │ │ ├── helpers │ │ │ ├── generate-random-id.helper.ts │ │ │ └── create-upload-id.helper.ts │ │ ├── audio-encoding │ │ │ ├── audio-encoder-config.interface.ts │ │ │ └── audio-encoder.ts │ │ ├── recording-store.ts │ │ ├── timer.ts │ │ ├── upload-queue.ts │ │ ├── http │ │ │ └── retryable-fetch.helper.ts │ │ ├── recorder.ts │ │ ├── uploader.ts │ │ └── recording.ts │ ├── enums │ │ └── http-status.enum.ts │ ├── hooks │ │ ├── index.ts │ │ ├── use-timer.hook.ts │ │ ├── use-recording.hook.ts │ │ └── use-audio-player.hook.ts │ ├── helpers │ │ ├── url.helpers.ts │ │ ├── utils.helper.ts │ │ ├── browser │ │ │ └── browser.helpers.ts │ │ └── colors.ts │ ├── error-handler │ │ └── error-parser.ts │ └── services │ │ └── recording.service.ts │ ├── components │ ├── templates │ │ ├── index.ts │ │ └── MainLayout │ │ │ └── MainLayout.component.tsx │ ├── atoms │ │ ├── Alert │ │ │ ├── index.ts │ │ │ ├── alert.constants.tsx │ │ │ └── Alert.component.tsx │ │ ├── index.ts │ │ ├── Button │ │ │ ├── ButtonIcon.component.tsx │ │ │ └── Button.component.tsx │ │ ├── Card │ │ │ └── Card.component.tsx │ │ ├── Stack │ │ │ └── Stack.component.tsx │ │ ├── Input │ │ │ └── Input.component.tsx │ │ ├── Chronometer │ │ │ └── Chronometer.component.tsx │ │ ├── Logo │ │ │ └── Logo.component.tsx │ │ └── Slider │ │ │ └── Slider.component.tsx │ ├── organisms │ │ ├── RecorderControls │ │ │ ├── recorder-controlls.constants.ts │ │ │ └── RecorderControls.component.tsx │ │ ├── index.ts │ │ ├── Header │ │ │ └── Header.component.tsx │ │ ├── RecordingPlayer │ │ │ └── RecordingPlayer.component.tsx │ │ ├── AudioPlayer │ │ │ └── AudioPlayer.component.tsx │ │ ├── SaveRecording │ │ │ └── SaveRecording.component.tsx │ │ └── UploadResult │ │ │ └── UploadResult.component.tsx │ └── molecules │ │ ├── index.ts │ │ ├── CopyInput │ │ └── CopyInput.component.tsx │ │ ├── SlilderTimer │ │ └── SliderTimer.component.tsx │ │ ├── SocialShare │ │ └── SocialShare.component.tsx │ │ └── PlayPauseButton │ │ └── PlayPauseButton.component.tsx │ ├── .prettierrc │ ├── public │ ├── logo.png │ ├── favicon.ico │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── apple-icon-precomposed.png │ ├── processor.js │ └── manifest.json │ ├── .eslintrc.json │ ├── fonts │ ├── Nexa Bold.otf │ └── index.ts │ ├── pages │ ├── index.tsx │ ├── _app.tsx │ ├── styles.css │ ├── [recordingId].tsx │ ├── 404.tsx │ └── _document.tsx │ ├── next-env.d.ts │ ├── __tests__ │ └── index.spec.tsx │ ├── .env.local │ ├── jest.config.ts │ ├── index.d.ts │ ├── next.config.js │ ├── tsconfig.json │ └── package.json ├── Procfile ├── babel.config.json ├── .prettierrc ├── .prettierignore ├── jest.preset.js ├── jest.config.ts ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── .editorconfig ├── tsconfig.base.json ├── .gitignore ├── .eslintrc.json ├── nx.json ├── README.md ├── package.json └── migrations.json /libs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/api/Dockerfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node dist/apps/api/main.js 2 | -------------------------------------------------------------------------------- /apps/api/src/uploader/enums/upload-errors.enum.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/api/src/s3/s3.types.ts: -------------------------------------------------------------------------------- 1 | export type S3 = AWS.S3; 2 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "babelrcRoots": ["*"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/api/src/api.constants.ts: -------------------------------------------------------------------------------- 1 | export const APP_PREFIX = '/api'; 2 | -------------------------------------------------------------------------------- /libs/shared-types/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './optional.type'; 2 | -------------------------------------------------------------------------------- /libs/shared-types/src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-status.enum'; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "arrowParens": "avoid" 4 | } 5 | -------------------------------------------------------------------------------- /apps/api/src/s3/s3.constants.ts: -------------------------------------------------------------------------------- 1 | export const S3_SERVICE = Symbol('S3_SERVICE'); 2 | -------------------------------------------------------------------------------- /apps/frontend/features/HomePage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HomePage.component'; 2 | -------------------------------------------------------------------------------- /apps/frontend/lib/types/optional.type.ts: -------------------------------------------------------------------------------- 1 | export type Optional = T | undefined; 2 | -------------------------------------------------------------------------------- /apps/frontend/features/RecordingPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RecordingPage.component'; 2 | -------------------------------------------------------------------------------- /libs/shared-types/src/utils/optional.type.ts: -------------------------------------------------------------------------------- 1 | export type Optional = T | undefined; 2 | -------------------------------------------------------------------------------- /apps/frontend/components/templates/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MainLayout/MainLayout.component'; 2 | -------------------------------------------------------------------------------- /apps/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /libs/shared-types/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/lib/dto/download-url-reponse.dto.ts: -------------------------------------------------------------------------------- 1 | export class DownloadUrlReponseDto { 2 | url: string; 3 | } 4 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nrwl/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /libs/shared-types/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dtos'; 2 | export * from './utils'; 3 | export * from './enums'; 4 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Alert/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Alert.component'; 2 | export * from './alert.constants'; 3 | -------------------------------------------------------------------------------- /apps/frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/logo.png -------------------------------------------------------------------------------- /libs/shared-types/src/dtos/download-url-reponse.dto.ts: -------------------------------------------------------------------------------- 1 | export class DownloadUrlReponseDto { 2 | url: string; 3 | } 4 | -------------------------------------------------------------------------------- /libs/shared-types/src/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './media-info.dto'; 2 | export * from './download-url-reponse.dto'; 3 | -------------------------------------------------------------------------------- /apps/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "env": { 4 | "jest": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/favicon.ico -------------------------------------------------------------------------------- /apps/frontend/components/organisms/RecorderControls/recorder-controlls.constants.ts: -------------------------------------------------------------------------------- 1 | export const BACKGROUND_CIRCLE_SIZE = 100; 2 | -------------------------------------------------------------------------------- /apps/frontend/fonts/Nexa Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/fonts/Nexa Bold.otf -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { getJestProjects } from '@nrwl/jest'; 2 | 3 | export default { 4 | projects: getJestProjects(), 5 | }; 6 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/types/queue-item.type.ts: -------------------------------------------------------------------------------- 1 | export type QueueItem = { 2 | chunk: Blob; 3 | chunkIndex: number; 4 | }; 5 | -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon.png -------------------------------------------------------------------------------- /apps/frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /apps/frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /apps/frontend/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/favicon-96x96.png -------------------------------------------------------------------------------- /apps/frontend/public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /apps/frontend/lib/enums/http-status.enum.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common/enums/http-status.enum'; 2 | 3 | export { HttpStatus }; 4 | -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /apps/frontend/public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /apps/frontend/public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /apps/frontend/public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /apps/frontend/lib/dto/media-info.dto.ts: -------------------------------------------------------------------------------- 1 | export class MediaInfoDto { 2 | mediaId: string; 3 | ownerToken?: string; 4 | status: number; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/android-icon-36x36.png -------------------------------------------------------------------------------- /apps/frontend/public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/android-icon-48x48.png -------------------------------------------------------------------------------- /apps/frontend/public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/android-icon-72x72.png -------------------------------------------------------------------------------- /apps/frontend/public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/android-icon-96x96.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /libs/shared-types/src/enums/http-status.enum.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common/enums/http-status.enum'; 2 | 3 | export { HttpStatus }; 4 | -------------------------------------------------------------------------------- /apps/frontend/lib/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-audio-player.hook'; 2 | export * from './use-recording.hook'; 3 | export * from './use-timer.hook'; 4 | -------------------------------------------------------------------------------- /apps/frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { HomePage } from '@features/HomePage'; 2 | 3 | export default function Index() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/android-icon-144x144.png -------------------------------------------------------------------------------- /apps/frontend/public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/android-icon-192x192.png -------------------------------------------------------------------------------- /apps/frontend/public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dantehemerson/voice-recorder/HEAD/apps/frontend/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /libs/shared-types/src/dtos/media-info.dto.ts: -------------------------------------------------------------------------------- 1 | export class MediaInfoDto { 2 | mediaId: string; 3 | ownerToken?: string; 4 | status: number; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/enums/recorder-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum RecorderStatus { 2 | STOPPED = 0, 3 | RECORDING, 4 | PAUSED, 5 | STARTING, 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/interfaces/on-uploader-progress-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface OnUploaderProgressPayload { 2 | bytesUploaded: number; 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/interfaces/media-info.interface.ts: -------------------------------------------------------------------------------- 1 | export interface MediaInfo { 2 | mediaId: string; 3 | ownerToken: string; 4 | time?: number; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/lib/helpers/url.helpers.ts: -------------------------------------------------------------------------------- 1 | export function getDownloadAudioUrl(audioId: string): string { 2 | return `${process.env.NEXT_PUBLIC_WEB_URL}/${audioId}`; 3 | } 4 | -------------------------------------------------------------------------------- /apps/api/src/uploader/exceptions/not-chunks-found.exception.ts: -------------------------------------------------------------------------------- 1 | export class NotChunksFoundException extends Error { 2 | constructor() { 3 | super('No chunks found'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/lib/helpers/utils.helper.ts: -------------------------------------------------------------------------------- 1 | export class Utils { 2 | static mergeObjects(objectA: T, objectB: U): T & U { 3 | return Object.assign({}, objectA, objectB); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/enums/encoder-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum EncoderStatus { 2 | INACTIVE = 'inactive', 3 | LOADING = 'loading', 4 | READY = 'ready', 5 | ERROR = 'error', 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/errors/submit.error.ts: -------------------------------------------------------------------------------- 1 | export class SubmitError extends Error { 2 | constructor(message?: string) { 3 | super(message); 4 | this.name = 'SubmitError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "firsttris.vscode-jest-runner", 6 | "dbaeumer.vscode-eslint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /apps/api/src/s3/decorators/s3-service.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { S3_SERVICE } from '../../s3/s3.constants'; 3 | 4 | export const S3Service = () => Inject(S3_SERVICE); 5 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/errors/abort-request.error.ts: -------------------------------------------------------------------------------- 1 | export class AbortRequestError extends Error { 2 | constructor(message?: string) { 3 | super(message); 4 | this.name = 'AbortError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/interfaces/retryable-fetch-init-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface RetryableFetchInitOptions extends RequestInit { 2 | parseJson?: boolean; 3 | resolveWhenNotOk?: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/errors/no-chunk-found.error.ts: -------------------------------------------------------------------------------- 1 | export class NoChunksFoundError extends Error { 2 | constructor(message?: string) { 3 | super(message); 4 | this.name = 'NoChunksFound'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/errors/max-chunk-count.error.ts: -------------------------------------------------------------------------------- 1 | export class MaxChunkCountError extends Error { 2 | constructor(message?: string) { 3 | super(message); 4 | this.name = 'MaxChunkCount'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/interfaces/uploader-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface UploaderOptions { 2 | chunkSize?: number; 3 | maxChunkCount?: number; 4 | maxConcurrentUploads?: number; 5 | uploadUrl?: string; 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/interfaces/abortable-promise.interface.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export class AbortablePromise extends Promise { 3 | public abort: () => void; 4 | } 5 | -------------------------------------------------------------------------------- /apps/api/src/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { HealthController } from './health.controller'; 3 | 4 | @Module({ 5 | controllers: [HealthController], 6 | }) 7 | export class HealthModule {} 8 | -------------------------------------------------------------------------------- /apps/frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/frontend/lib/hooks/use-timer.hook.ts: -------------------------------------------------------------------------------- 1 | import { Timer } from '@lib/recording'; 2 | import { useMemo } from 'react'; 3 | 4 | export function useTimer(): Timer { 5 | const timer = useMemo(() => new Timer(), []); 6 | 7 | return timer; 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/components/molecules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CopyInput/CopyInput.component'; 2 | export * from './PlayPauseButton/PlayPauseButton.component'; 3 | export * from './SlilderTimer/SliderTimer.component'; 4 | export * from './SocialShare/SocialShare.component'; 5 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/index.ts: -------------------------------------------------------------------------------- 1 | export * from './recorder'; 2 | export * from './interfaces/media-info.interface'; 3 | export * from './recording'; 4 | export * from './timer'; 5 | export * from './enums/encoder-status.enum'; 6 | export * from './enums/recorder-status.enum'; 7 | -------------------------------------------------------------------------------- /apps/frontend/public/processor.js: -------------------------------------------------------------------------------- 1 | class ScriptProcessorReplacement extends AudioWorkletProcessor { 2 | process(inputs, outputs) { 3 | this.port.postMessage(inputs[0]); 4 | return true; 5 | } 6 | } 7 | 8 | registerProcessor('script-processor-replacement', ScriptProcessorReplacement); 9 | -------------------------------------------------------------------------------- /libs/shared-types/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": [] 7 | }, 8 | "include": ["**/*.ts"], 9 | "exclude": ["jest.config.ts", "**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/api/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/fonts/index.ts: -------------------------------------------------------------------------------- 1 | import { Chivo } from '@next/font/google'; 2 | import localFont from '@next/font/local'; 3 | 4 | export const MonoFont = Chivo({ subsets: ['latin'], weight: ['800'] }); 5 | export const NexaFont = localFont({ 6 | src: [{ path: './Nexa Bold.otf', weight: 'normal' }], 7 | }); 8 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/helpers/generate-random-id.helper.ts: -------------------------------------------------------------------------------- 1 | export function generateRandomId(): string { 2 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => { 3 | const t = (16 * Math.random()) | 0; 4 | return ('x' == char ? t : (3 & t) | 8).toString(16); 5 | }); 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /apps/api/src/helpers/wait-for-stram-close.helper.ts: -------------------------------------------------------------------------------- 1 | import { WriteStream } from 'fs'; 2 | 3 | export async function waitForStreamClose(stream: WriteStream): Promise { 4 | stream.end(); 5 | return new Promise(resolve => { 6 | stream.once('finish', () => { 7 | resolve(); 8 | }); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /apps/api/src/middlewares/redoc.middleware.ts: -------------------------------------------------------------------------------- 1 | import { redocHtml, RedocOptions } from './helpers/redoc-html-template'; 2 | 3 | export function redocMiddleware(options: RedocOptions): any { 4 | return function middleware(_req: any, res: any): void { 5 | res.type('html'); 6 | res.send(redocHtml(options)); 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /libs/shared-types/README.md: -------------------------------------------------------------------------------- 1 | # shared-types 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test shared-types` to execute the unit tests via [Jest](https://jestjs.io). 8 | 9 | ## Running lint 10 | 11 | Run `nx lint shared-types` to execute the lint via [ESLint](https://eslint.org/). 12 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Chronometer/Chronometer.component'; 2 | export * from './Slider/Slider.component'; 3 | export * from './Input/Input.component'; 4 | export * from './Button/Button.component'; 5 | export * from './Card/Card.component'; 6 | export * from './Logo/Logo.component'; 7 | export * from './Stack/Stack.component'; 8 | -------------------------------------------------------------------------------- /apps/frontend/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import Index from '../pages/index'; 5 | 6 | describe('Index', () => { 7 | it('should render successfully', () => { 8 | const { baseElement } = render(); 9 | expect(baseElement).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /apps/frontend/.env.local: -------------------------------------------------------------------------------- 1 | # Proxy configured at proxy.conf.json file 2 | NEXT_PUBLIC_API_URL="http://localhost:4200/api" 3 | NEXT_PUBLIC_WEB_URL="http://localhost:4200" 4 | NEXT_PUBLIC_STORE_URL="https://recordings.dantecalderon.com.s3.us-east-1.amazonaws.com" 5 | 6 | # For production(using custom domain): 7 | # NEXT_PUBLIC_STORE_URL="https://recordings.dantecalderon.dev" 8 | -------------------------------------------------------------------------------- /apps/api/src/uploader/dto/alive-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class AliveResponseDto { 4 | @ApiProperty({ 5 | description: 'Boolean that indicates if the server is alive', 6 | }) 7 | ok: boolean; 8 | 9 | @ApiProperty({ 10 | description: 'Current server time', 11 | }) 12 | serverTime: Date; 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { composePlugins, withNx } = require('@nx/webpack'); 2 | 3 | // Nx plugins for webpack. 4 | module.exports = composePlugins(withNx(), config => { 5 | // Note: This was added by an Nx migration. Webpack builds are required to have a corresponding Webpack config file. 6 | // See: https://nx.dev/recipes/webpack/webpack-config-setup 7 | return config; 8 | }); 9 | -------------------------------------------------------------------------------- /apps/api/src/uploads-cleaner/uploads-cleaner.module.ts: -------------------------------------------------------------------------------- 1 | /** Module */ 2 | 3 | import { Module } from '@nestjs/common'; 4 | import { ScheduleModule } from '@nestjs/schedule'; 5 | import { UploadsCleanerService } from './uploads-cleaner.service'; 6 | 7 | @Module({ 8 | imports: [ScheduleModule.forRoot()], 9 | providers: [UploadsCleanerService], 10 | }) 11 | export class UplaodsCleanerModule {} 12 | -------------------------------------------------------------------------------- /apps/api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["node"], 7 | "resolveJsonModule": true, 8 | "emitDecoratorMetadata": true, 9 | "target": "es2015" 10 | }, 11 | "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], 12 | "include": ["**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Alert/alert.constants.tsx: -------------------------------------------------------------------------------- 1 | export enum AlertStatus { 2 | ERROR, 3 | } 4 | 5 | export const AlertStatuses = { 6 | [AlertStatus.ERROR]: { 7 | icon: (i), 8 | colorScheme: '#ffe5e9', 9 | border: '#ff4d4d', 10 | color: '#d02828', 11 | }, 12 | }; 13 | 14 | export function getAlertColor(status: AlertStatus) { 15 | return AlertStatuses[status]; 16 | } 17 | -------------------------------------------------------------------------------- /libs/shared-types/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Button/ButtonIcon.component.tsx: -------------------------------------------------------------------------------- 1 | import styled, { CSSProperties } from 'styled-components'; 2 | 3 | type ButtonIconProps = Pick & { 4 | children: React.ReactElement; 5 | }; 6 | 7 | export function ButtonIcon(props: ButtonIconProps) { 8 | return {props.children}; 9 | } 10 | 11 | const Wrapper = styled.span``; 12 | -------------------------------------------------------------------------------- /apps/frontend/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'frontend', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest', 7 | '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/next/babel'] }], 8 | }, 9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 10 | coverageDirectory: '../../coverage/apps/frontend', 11 | }; 12 | -------------------------------------------------------------------------------- /apps/frontend/lib/helpers/browser/browser.helpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export function isEdge(): boolean { 3 | return ( 4 | navigator.userAgent.indexOf('Edge') !== -1 && 5 | (!!(navigator as any).msSaveOrOpenBlob || !!(navigator as any).msSaveBlob) 6 | ); 7 | } 8 | 9 | export function isSafari(): boolean { 10 | return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 11 | } 12 | -------------------------------------------------------------------------------- /apps/api/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'api', 4 | preset: '../../jest.preset.js', 5 | globals: { 6 | 'ts-jest': { 7 | tsconfig: '/tsconfig.spec.json', 8 | }, 9 | }, 10 | testEnvironment: 'node', 11 | transform: { 12 | '^.+\\.[tj]s$': 'ts-jest', 13 | }, 14 | moduleFileExtensions: ['ts', 'js', 'html'], 15 | coverageDirectory: '../../coverage/apps/api', 16 | }; 17 | -------------------------------------------------------------------------------- /apps/frontend/components/organisms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Header/Header.component'; 2 | export * from './SaveRecording/SaveRecording.component'; 3 | export * from './UploadResult/UploadResult.component'; 4 | export * from './RecorderControls/RecorderControls.component'; 5 | export * from './RecorderControls/recorder-controlls.constants'; 6 | export * from './AudioPlayer/AudioPlayer.component'; 7 | export * from './RecordingPlayer/RecordingPlayer.component'; 8 | -------------------------------------------------------------------------------- /libs/shared-types/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'shared-types', 4 | preset: '../../jest.preset.js', 5 | globals: { 6 | 'ts-jest': { 7 | tsconfig: '/tsconfig.spec.json', 8 | }, 9 | }, 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/shared-types', 15 | }; 16 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/helpers/create-upload-id.helper.ts: -------------------------------------------------------------------------------- 1 | import baseX from 'base-x'; 2 | 3 | const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 4 | 5 | const base62 = baseX(BASE62); 6 | 7 | export function createUploadId(): string { 8 | const randomValues = ( 9 | window.crypto || (window as MsWindow).msCrypto 10 | ).getRandomValues(new Uint8Array(16)); 11 | 12 | return base62.encode(randomValues); 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/sample.env: -------------------------------------------------------------------------------- 1 | # Copy this to .env 2 | 3 | # AWS configuration for S3 4 | AWS__ACCESS_KEY_ID="" 5 | AWS__SECRET_ACCESS_KEY="" 6 | AWS__REGION="us-east-1" 7 | 8 | # S3 bucket name 9 | AWS__BUCKETS__RECORDINGS="recordings.dantecalderon.com" 10 | 11 | # Where the chunks will be stored? 12 | UPLOADS__DIR="./.uploads" 13 | 14 | ## Time in minutes 15 | UPLOADS__DELETE_AFTER=1 16 | 17 | # Only use this for production: 18 | # CORS__ORIGIN="http://localhost:4200" 19 | -------------------------------------------------------------------------------- /libs/shared-types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/shared-types/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "**/*.test.ts", 11 | "**/*.spec.ts", 12 | "**/*.test.tsx", 13 | "**/*.spec.tsx", 14 | "**/*.test.js", 15 | "**/*.spec.js", 16 | "**/*.test.jsx", 17 | "**/*.spec.jsx", 18 | "**/*.d.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/audio-encoding/audio-encoder-config.interface.ts: -------------------------------------------------------------------------------- 1 | export interface AudioEncoderConstraints { 2 | autoGainControl: boolean; 3 | echoCancellation: boolean; 4 | noiseSuppression: boolean; 5 | } 6 | 7 | export interface AudioEncoderConfig { 8 | recordingGain: number; 9 | numberOfChannels: number; 10 | bufferSize: number; 11 | constraints: AudioEncoderConstraints; 12 | useAudioWorklet: boolean; 13 | encoderBitRate: number; 14 | originalSampleRate: number; 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | declare module '*.svg' { 3 | const content: any; 4 | export const ReactComponent: any; 5 | export default content; 6 | } 7 | 8 | interface __MSWindow extends Window { 9 | msCrypto: Crypto; 10 | } 11 | 12 | type MsWindow = typeof globalThis & __MSWindow; 13 | 14 | interface __WebkitWindow extends Window { 15 | webkitAudioContext: typeof AudioContext; 16 | } 17 | 18 | type WebkitWindow = typeof globalThis & __WebkitWindow; 19 | -------------------------------------------------------------------------------- /apps/api/src/config/global-config.interface.ts: -------------------------------------------------------------------------------- 1 | export interface GlobalConf { 2 | databases: { 3 | mongo: { 4 | uri: string; 5 | }; 6 | redis: { 7 | host: string; 8 | // port: number; 9 | }; 10 | }; 11 | aws: { 12 | accessKeyId: string; 13 | secretAccessKey: string; 14 | region: string; 15 | }; 16 | recordings: { 17 | bucket: string; 18 | }; 19 | api: { 20 | baseURL: string; 21 | }; 22 | uploads: { 23 | dir: string; 24 | deleteAfterMinutes: number; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /apps/api/src/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class HealthController { 5 | @Get('/health') 6 | health() { 7 | return { 8 | ok: true, 9 | serverTime: new Date(), 10 | bucket: process.env.AWS__BUCKETS__RECORDINGS, 11 | uploadsDir: process.env.UPLOADS__DIR, 12 | uploadsDeleteAfter: process.env.UPLOADS__DELETE_AFTER, 13 | NODE_ENV: process.env.NODE_ENV, 14 | corsOrigin: process.env.CORS__ORIGIN || '*', 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/frontend/lib/error-handler/error-parser.ts: -------------------------------------------------------------------------------- 1 | import { ErrorStore } from '@features/HomePage/Error.component'; 2 | 3 | export function errorParser(error: Error): ErrorStore { 4 | if (error instanceof Error) { 5 | if (error.name === 'NotAllowedError') { 6 | return { 7 | message: 'Permission Denied', 8 | details: 'You need to allow microphone access to record audio.', 9 | }; 10 | } 11 | } 12 | 13 | return { 14 | message: error?.name || 'Error', 15 | details: error?.message || 'An unknown error occurred', 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /apps/api/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | s3: 3 | image: minio/minio:RELEASE.2025-02-18T16-25-55Z-cpuv1 4 | command: server /data 5 | ports: 6 | - "9000:9000" 7 | volumes: 8 | - data:/data 9 | environment: 10 | MINIO_ROOT_USER: minio 11 | MINIO_ROOT_PASSWORD: minio123 12 | MINIO_ACCESS_KEY: minio_access_key 13 | MINIO_SECRET_KEY: minio_secret_key 14 | MINIO_DOMAIN: s3.us-east-1.amazonaws.com 15 | restart: always 16 | networks: 17 | default: 18 | aliases: 19 | - voice-recorder-2025.s3.us-east-1.amazonaws.com 20 | 21 | volumes: 22 | data: 23 | -------------------------------------------------------------------------------- /apps/api/src/config/global.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | 3 | export const globalConfigValidationSchema = Joi.object({ 4 | // MONGODB_URI: Joi.string().uri().required(), 5 | 6 | AWS__ACCESS_KEY_ID: Joi.string().required().not().empty(), 7 | AWS__SECRET_ACCESS_KEY: Joi.string().required().not().empty(), 8 | AWS__REGION: Joi.string().not().empty(), 9 | 10 | AWS__BUCKETS__RECORDINGS: Joi.string().required().not().empty(), 11 | 12 | // API__BASE_URL: Joi.string().required().not().empty(), 13 | UPLOADS__DIR: Joi.string().required().not().empty(), 14 | UPLOADS__DELETE_AFTER: Joi.number().required().not().empty(), 15 | }); 16 | -------------------------------------------------------------------------------- /apps/frontend/lib/helpers/colors.ts: -------------------------------------------------------------------------------- 1 | export function darkenColor(color: string): string { 2 | if (color.startsWith('#')) { 3 | let r = parseInt(color.slice(1, 3), 16); 4 | let g = parseInt(color.slice(3, 5), 16); 5 | let b = parseInt(color.slice(5, 7), 16); 6 | 7 | // Darken the color by decreasing its red, green, and blue values 8 | r = Math.floor(r * 0.8); 9 | g = Math.floor(g * 0.8); 10 | b = Math.floor(b * 0.8); 11 | 12 | // Convert the RGB values back to hexadecimal and return the result 13 | return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 14 | } else { 15 | return color; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/recording-store.ts: -------------------------------------------------------------------------------- 1 | export class RecordingStore { 2 | private audioData: Int8Array[] = []; 3 | 4 | isEmpty(): boolean { 5 | return this.audioData.length === 0; 6 | } 7 | 8 | getAudioData(): Int8Array[] { 9 | return this.audioData; 10 | } 11 | 12 | appendData(data: Int8Array) { 13 | this.audioData.push(data); 14 | } 15 | 16 | generateAudioBlob(): Blob { 17 | return new Blob(this.audioData, { 18 | type: 'audio/mpeg', 19 | }); 20 | } 21 | 22 | generateAudioBlobUrl(): string { 23 | return URL.createObjectURL(this.generateAudioBlob()); 24 | } 25 | 26 | reset() { 27 | this.audioData = []; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | 5 | const nextConfig = { 6 | async rewrites() { 7 | return [ 8 | { 9 | source: '/api/:path*', 10 | destination: 'http://localhost:3333/api/:path*', 11 | }, 12 | ]; 13 | }, 14 | compiler: { 15 | styledComponents: true, 16 | /** Prev babel config: 17 | * { "ssr": true, "displayName": true, "preprocess": false } 18 | */ 19 | }, 20 | eslint: { 21 | // Warning: This allows production builds to successfully complete even if 22 | // your project has ESLint errors. 23 | ignoreDuringBuilds: true, 24 | }, 25 | }; 26 | 27 | module.exports = nextConfig; 28 | -------------------------------------------------------------------------------- /apps/api/src/uploader/uploader.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheModule, Module } from '@nestjs/common'; 2 | import { MulterModule } from '@nestjs/platform-express'; 3 | import { MulterConfigService } from '../multer/multer.config'; 4 | import { S3Provider } from '../s3/providers/s3.provider'; 5 | import { UploaderController } from './uploader.controller'; 6 | import { UploaderService } from './uploader.service'; 7 | 8 | @Module({ 9 | imports: [ 10 | CacheModule.register({ 11 | ttl: 60 * 60 * 24 * 7, 12 | }), 13 | MulterModule.registerAsync({ 14 | useClass: MulterConfigService, 15 | }), 16 | ], 17 | controllers: [UploaderController], 18 | providers: [S3Provider, UploaderService], 19 | }) 20 | export class UploaderModule {} 21 | -------------------------------------------------------------------------------- /apps/frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { config } from '@fortawesome/fontawesome-svg-core'; 2 | import '@fortawesome/fontawesome-svg-core/styles.css'; 3 | import { AppProps } from 'next/app'; 4 | import Head from 'next/head'; 5 | import { NexaFont } from '~/fonts'; 6 | import './styles.css'; 7 | 8 | config.autoAddCss = false; 9 | 10 | function CustomApp({ Component, pageProps }: AppProps) { 11 | return ( 12 | <> 13 | 14 | 15 | Voice Recorder Online - Free Audio Recording, Storage, and Sharing 16 | 17 | 18 |
19 | 20 |
21 | 22 | ); 23 | } 24 | 25 | export default CustomApp; 26 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Card/Card.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | type CardProps = HTMLAttributes & { 5 | children: React.ReactNode; 6 | padding?: string; 7 | }; 8 | 9 | export function Card({ ...props }: CardProps) { 10 | return {props.children}; 11 | } 12 | 13 | const Wrapper = styled.div` 14 | display: flex; 15 | background: white; 16 | border-radius: 0.875rem; 17 | padding: 1rem !important; 18 | flex-direction: 'column'; 19 | justify-content: 'center'; 20 | align-items: 'center'; 21 | word-wrap: break-word; 22 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 23 | `; 24 | -------------------------------------------------------------------------------- /libs/shared-types/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared-types", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "libs/shared-types/src", 5 | "projectType": "library", 6 | "targets": { 7 | "lint": { 8 | "executor": "@nrwl/linter:eslint", 9 | "outputs": ["{options.outputFile}"], 10 | "options": { 11 | "lintFilePatterns": ["libs/shared-types/**/*.ts"] 12 | } 13 | }, 14 | "test": { 15 | "executor": "@nrwl/jest:jest", 16 | "outputs": ["{workspaceRoot}/coverage/libs/shared-types"], 17 | "options": { 18 | "jestConfig": "libs/shared-types/jest.config.ts", 19 | "passWithNoTests": true 20 | } 21 | } 22 | }, 23 | "tags": ["types"] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "esnext", 13 | "lib": ["es2017", "dom"], 14 | "strict": false, 15 | "strictNullChecks": false, 16 | "skipLibCheck": true, 17 | "skipDefaultLibCheck": true, 18 | "strictPropertyInitialization": false, 19 | "baseUrl": ".", 20 | "paths": { 21 | "@voice-recorder/shared-types": ["libs/shared-types/src/index.ts"] 22 | } 23 | }, 24 | "exclude": ["node_modules", "tmp"] 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | # Next.js 42 | .next 43 | *.mp3 44 | .env 45 | 46 | _uploads 47 | .uploads 48 | .nx 49 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "workbench.iconTheme": "vscode-icons", 4 | "vsicons.presets.nestjs": true, 5 | "files.exclude": { 6 | "**/dist": true, 7 | "**/node_modules": true, 8 | "**/yarn.lock": true, 9 | "**/.cache": true, 10 | "**/.next": true, 11 | "**/public": true, 12 | /** Barely updated files **/ 13 | "**/index.d.ts": true, 14 | "**/jest.config.ts": true, 15 | "**/next-env.d.ts": true 16 | }, 17 | "search.exclude": { 18 | "dist/**": true, 19 | "public/**": true, 20 | "node_modules/**": false 21 | }, 22 | "files.associations": { 23 | "**/*.env*": "dotenv", 24 | "**/Procfile2": "heroku" 25 | }, 26 | "typescript.preferences.importModuleSpecifier": "non-relative", 27 | } 28 | -------------------------------------------------------------------------------- /apps/frontend/components/organisms/Header/Header.component.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from '@components/atoms'; 2 | import styled from 'styled-components'; 3 | 4 | export function Header() { 5 | return ( 6 | 7 | 8 |

Voice Recorder

9 |
10 | ); 11 | } 12 | 13 | const Wrapper = styled.div` 14 | margin-top: 30px; 15 | background: #383738; 16 | padding: 10px; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | gap: 14px; 21 | border-radius: 56px; 22 | flex-direction: row; 23 | margin-bottom: 30px; 24 | 25 | h1 { 26 | font-size: 1rem; 27 | line-height: 0.85; 28 | height: 10px; 29 | text-align: center; 30 | color: white; 31 | font-weight: bold; 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Stack/Stack.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | type StackProps = { 5 | children: React.ReactNode; 6 | hSpacing?: number; 7 | vSpacing?: number; 8 | width?: string; 9 | marginTop?: string; 10 | marginBottom?: string; 11 | justifyContent?: string; 12 | }; 13 | 14 | export function Stack({ 15 | hSpacing = 0, 16 | vSpacing = 0, 17 | children, 18 | ...props 19 | }: StackProps) { 20 | return ( 21 | 28 | {children} 29 | 30 | ); 31 | } 32 | 33 | const Wrapper = styled.div` 34 | display: flex; 35 | `; 36 | -------------------------------------------------------------------------------- /apps/frontend/components/templates/MainLayout/MainLayout.component.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from '@components/organisms'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | type MainLayoutProps = { 6 | children: React.ReactNode; 7 | }; 8 | 9 | export function MainLayout(props: MainLayoutProps) { 10 | return ( 11 | 12 | 13 |
14 | {props.children} 15 | 16 | 17 | ); 18 | } 19 | 20 | const Container = styled.div` 21 | background-color: #d7e4eb; 22 | width: 100%; 23 | height: 100vh; 24 | display: flex; 25 | justify-content: center; 26 | `; 27 | 28 | const Wrapper = styled.div` 29 | width: 100%; 30 | max-width: 600px; 31 | height: fit-content; 32 | `; 33 | -------------------------------------------------------------------------------- /apps/api/src/s3/providers/s3.provider.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import * as AWS from 'aws-sdk'; 4 | import { GlobalConf } from '../../config/global-config.interface'; 5 | import { S3_SERVICE } from '../s3.constants'; 6 | 7 | export const S3Provider: Provider = { 8 | provide: S3_SERVICE, 9 | useFactory(configService: ConfigService) { 10 | const awsConfig = configService.get('aws'); 11 | 12 | return new AWS.S3({ 13 | accessKeyId: awsConfig.accessKeyId, 14 | secretAccessKey: awsConfig.secretAccessKey, 15 | region: awsConfig.region, 16 | signatureVersion: 'v4', 17 | 18 | // Only for Minio 19 | s3ForcePathStyle: false ? undefined : true, 20 | endpoint: false ? undefined : 'http://127.0.0.1:9000', 21 | }); 22 | }, 23 | inject: [ConfigService], 24 | }; 25 | -------------------------------------------------------------------------------- /apps/frontend/features/HomePage/Error.component.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertStatus } from '@components/atoms/Alert'; 2 | import { useAtomValue } from 'jotai'; 3 | import styled from 'styled-components'; 4 | import { atom } from 'jotai'; 5 | 6 | export type ErrorStore = { 7 | message: string; 8 | details: string; 9 | }; 10 | 11 | export const errorStoreAtom = atom(undefined as ErrorStore); 12 | 13 | export function ErrorComponent() { 14 | const error = useAtomValue(errorStoreAtom); 15 | 16 | if (!error) { 17 | return null; 18 | } 19 | 20 | return ( 21 | 22 | 27 | 28 | ); 29 | } 30 | 31 | const ErroWrapper = styled.div` 32 | display: flex; 33 | padding: 20px 16px; 34 | justify-content: center; 35 | `; 36 | -------------------------------------------------------------------------------- /apps/api/src/config/global.config.ts: -------------------------------------------------------------------------------- 1 | import { GlobalConf } from './global-config.interface'; 2 | 3 | export const globalConfiguration = (): GlobalConf => 4 | ({ 5 | databases: { 6 | mongo: { 7 | uri: process.env.MONGODB_URI, 8 | }, 9 | redis: { 10 | host: process.env.REDIS__HOST, 11 | // port: parseIntEnv(process.env.REDIS__PORT), 12 | }, 13 | }, 14 | aws: { 15 | accessKeyId: process.env.AWS__ACCESS_KEY_ID, 16 | secretAccessKey: process.env.AWS__SECRET_ACCESS_KEY, 17 | region: process.env.AWS__REGION || 'us-east-1', 18 | }, 19 | recordings: { 20 | bucket: process.env.AWS__BUCKETS__RECORDINGS, 21 | }, 22 | api: { 23 | baseURL: process.env.API__BASE_URL, 24 | }, 25 | uploads: { 26 | dir: process.env.UPLOADS__DIR, 27 | deleteAfterMinutes: parseInt(process.env.UPLOADS__DELETE_AFTER), 28 | }, 29 | } as GlobalConf); 30 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Input/Input.component.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes } from 'react'; 2 | import styled from 'styled-components'; 3 | import { NexaFont } from '~/fonts'; 4 | 5 | type InputProps = InputHTMLAttributes; 6 | 7 | export function Input(props: InputProps) { 8 | return ; 9 | } 10 | 11 | const StyledInput = styled.input` 12 | outline: transparent solid 2px; 13 | font-family: ${NexaFont.style.fontFamily}; 14 | color: #5a6a73; 15 | outline-offset: 0px; 16 | border-radius: 0.875rem; 17 | height: 2.5rem; 18 | font-size: 0.9rem; 19 | padding-inline-start: 1rem; 20 | padding-inline-end: 1rem; 21 | transition: 0.2s ease-in-out; 22 | background: #d7e4eb; 23 | border-width: 2px; 24 | border-color: #aec1cb; 25 | 26 | &:focus-visible { 27 | z-index: 1; 28 | border-color: #aec1cb; 29 | box-shadow: 0 0 0 2px rgb(5 145 255 / 10%); 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Chronometer/Chronometer.component.tsx: -------------------------------------------------------------------------------- 1 | import { Timer } from '@lib/recording'; 2 | import { useEffect, useState } from 'react'; 3 | import styled from 'styled-components'; 4 | import { MonoFont } from '~/fonts'; 5 | 6 | export type ChronometerProps = { 7 | timer: Timer; 8 | style?: React.CSSProperties; 9 | }; 10 | 11 | export function Chronometer({ timer, ...props }: ChronometerProps) { 12 | const [time, setTime] = useState(timer.getTime()); 13 | 14 | useEffect(() => { 15 | const interval = setInterval(() => { 16 | setTime(timer.getTime()); 17 | }, 90); 18 | 19 | return () => clearInterval(interval); 20 | }, []); 21 | 22 | return ( 23 | 24 |
{Timer.makeTimeStrMMSS(time)}
25 |
26 | ); 27 | } 28 | 29 | const Wrapper = styled.div` 30 | display: flex; 31 | font-family: ${MonoFont.style.fontFamily}; 32 | font-size: 44px; 33 | `; 34 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug NestJS API", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run start:api" 9 | }, 10 | { 11 | "name": "Next.js: debug server-side", 12 | "type": "node-terminal", 13 | "request": "launch", 14 | "command": "npm run start:frontend" 15 | }, 16 | { 17 | "name": "Next.js: debug client-side", 18 | "type": "chrome", 19 | "request": "launch", 20 | "url": "http://localhost:4200" 21 | }, 22 | { 23 | "name": "Next.js: debug full stack", 24 | "type": "node-terminal", 25 | "request": "launch", 26 | "command": "npm run start:frontend", 27 | "serverReadyAction": { 28 | "pattern": "started server on .+, url: (https?://.+)", 29 | "uriFormat": "%s", 30 | "action": "debugWithChrome" 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /apps/api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { ThrottlerModule } from '@nestjs/throttler'; 4 | import { globalConfiguration } from './config/global.config'; 5 | import { globalConfigValidationSchema } from './config/global.schema'; 6 | import { HealthModule } from './health/health.module'; 7 | import { UploaderModule } from './uploader/uploader.module'; 8 | import { UplaodsCleanerModule } from './uploads-cleaner/uploads-cleaner.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | HealthModule, 13 | ThrottlerModule.forRoot({ 14 | ttl: 60, 15 | limit: 15, 16 | }), 17 | ConfigModule.forRoot({ 18 | isGlobal: true, 19 | validationSchema: globalConfigValidationSchema, 20 | load: [globalConfiguration], 21 | validationOptions: { 22 | allowUnknown: true, 23 | }, 24 | }), 25 | UplaodsCleanerModule, 26 | UploaderModule, 27 | ], 28 | }) 29 | export class AppModule {} 30 | -------------------------------------------------------------------------------- /apps/frontend/pages/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | -webkit-text-size-adjust: 100%; 3 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 4 | Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, 5 | Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; 6 | line-height: 1.5; 7 | tab-size: 4; 8 | scroll-behavior: smooth; 9 | } 10 | body { 11 | margin: 0; 12 | color: #383738; 13 | } 14 | h1, 15 | h2, 16 | p, 17 | pre { 18 | margin: 0; 19 | } 20 | *, 21 | ::before, 22 | ::after { 23 | box-sizing: border-box; 24 | border-width: 0; 25 | border-style: solid; 26 | border-color: currentColor; 27 | } 28 | h1, 29 | h2 { 30 | font-size: inherit; 31 | font-weight: inherit; 32 | } 33 | a { 34 | color: inherit; 35 | text-decoration: inherit; 36 | } 37 | pre { 38 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 39 | Liberation Mono, Courier New, monospace; 40 | } 41 | 42 | button, input, optgroup, select, textarea { 43 | margin: 0; 44 | } 45 | -------------------------------------------------------------------------------- /apps/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Voice Recorder", 3 | "icons": [ 4 | { 5 | "src": "/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nrwl/nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nrwl/nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nrwl/nx/typescript"], 27 | "rules": { 28 | "@typescript-eslint/no-explicit-any": "off", 29 | "@typescript-eslint/no-uninitialized-properties ": "off" 30 | } 31 | }, 32 | { 33 | "files": ["*.js", "*.jsx"], 34 | "extends": ["plugin:@nrwl/nx/javascript"], 35 | "rules": {} 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipDefaultLibCheck": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "strictNullChecks": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "noImplicitAny": false, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "baseUrl": ".", 21 | "strictPropertyInitialization": false, 22 | "paths": { 23 | "@components/*": ["./components/*"], 24 | "@features/*": ["./features/*"], 25 | "@lib/*": ["./lib/*"], 26 | "~/*": ["./*"], 27 | }, 28 | "plugins": [ 29 | { 30 | "name": "next" 31 | } 32 | ] 33 | }, 34 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 35 | "exclude": ["node_modules"] 36 | } 37 | -------------------------------------------------------------------------------- /apps/frontend/pages/[recordingId].tsx: -------------------------------------------------------------------------------- 1 | import { RecordingPage } from '@features/RecordingPage'; 2 | import { MediaInfoDto } from '@lib/dto/media-info.dto'; 3 | import { getRecordingById } from '@lib/services/recording.service'; 4 | import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'; 5 | 6 | export default function RecordPage(props: { media: MediaInfoDto }) { 7 | return ; 8 | } 9 | 10 | export async function getServerSideProps( 11 | context: GetServerSidePropsContext 12 | ): Promise> { 13 | try { 14 | const recordingData = await getRecordingById( 15 | context.params.recordingId as string 16 | ); 17 | 18 | return { 19 | props: { 20 | media: { 21 | mediaId: recordingData.mediaId, 22 | ownerToken: recordingData.ownerToken, 23 | status: 0, 24 | }, 25 | }, 26 | }; 27 | } catch (error) { 28 | console.error('Error loading data from server', error); 29 | return { 30 | notFound: true, 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/frontend/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Stack } from '@components/atoms'; 2 | import { Alert, AlertStatus } from '@components/atoms/Alert'; 3 | import { MainLayout } from '@components/templates'; 4 | import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 6 | import { useRouter } from 'next/router'; 7 | 8 | export default function Index() { 9 | const router = useRouter(); 10 | 11 | function handleClickGoHome() { 12 | router.push('/'); 13 | } 14 | 15 | return ( 16 | 17 | 18 | 23 | 24 |
25 | 26 | 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/frontend/components/organisms/RecordingPlayer/RecordingPlayer.component.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, Stack } from '@components/atoms'; 2 | import { AudioPlayer } from '@components/organisms'; 3 | import { faRotateRight } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import styled from 'styled-components'; 6 | 7 | type RecordFinishedViewProps = { 8 | url: string; 9 | onClickNewRecording: () => void; 10 | }; 11 | 12 | export function RecordingPlayer(props: RecordFinishedViewProps) { 13 | return ( 14 | 15 | 16 | 17 | 23 | 24 | 25 | ); 26 | } 27 | 28 | const RecordFinishedViewContainer = styled(Card)` 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | justify-content: center; 33 | width: 100%; 34 | box-sizing: border-box; 35 | padding: 1rem; 36 | `; 37 | -------------------------------------------------------------------------------- /apps/api/src/multer/multer.config.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { 4 | MulterModuleOptions, 5 | MulterOptionsFactory, 6 | } from '@nestjs/platform-express'; 7 | import * as multer from 'multer'; 8 | import * as fs from 'fs'; 9 | import { GlobalConf } from '../config/global-config.interface'; 10 | 11 | @Injectable() 12 | export class MulterConfigService implements MulterOptionsFactory { 13 | constructor(private readonly configService: ConfigService) {} 14 | 15 | createMulterOptions(): MulterModuleOptions { 16 | return { 17 | dest: this.configService.get('uploads').dir, 18 | storage: multer.diskStorage({ 19 | destination: (req, _file, callback) => { 20 | const uploadId = req.params.uploadId; 21 | const path = `${this.configService.get('uploads').dir}/${uploadId}`; 22 | 23 | fs.mkdirSync(path, { recursive: true }); 24 | 25 | callback(null, path); 26 | }, 27 | filename: (req, _file, callback) => { 28 | const chunkNumber = req.params.chunkNumber; 29 | callback(null, chunkNumber); 30 | }, 31 | }), 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/frontend/lib/services/recording.service.ts: -------------------------------------------------------------------------------- 1 | import { DownloadUrlReponseDto } from '@lib/dto/download-url-reponse.dto'; 2 | import { MediaInfoDto } from '@lib/dto/media-info.dto'; 3 | import { HttpStatus } from '@lib/enums/http-status.enum'; 4 | 5 | export async function getRecordingById( 6 | recordingId: string 7 | ): Promise { 8 | const response = await fetch( 9 | `${process.env.NEXT_PUBLIC_API_URL}/upload/${recordingId}` 10 | ); 11 | 12 | if (response.status === HttpStatus.NOT_FOUND) { 13 | throw new Error('Recording not found.'); 14 | } 15 | 16 | return response.json(); 17 | } 18 | 19 | export async function deleteRecording(recordingId: string) { 20 | return fetch(`${process.env.NEXT_PUBLIC_API_URL}/upload/${recordingId}`, { 21 | method: 'DELETE', 22 | }).then(res => { 23 | if (res.status === HttpStatus.NO_CONTENT) { 24 | return undefined; 25 | } else { 26 | return res.json(); 27 | } 28 | }); 29 | } 30 | 31 | export async function getRecordingDownloadUrl( 32 | recordingId: string 33 | ): Promise { 34 | return fetch( 35 | `${process.env.NEXT_PUBLIC_API_URL}/upload/download-url/${recordingId}` 36 | ).then(res => res.json()); 37 | } 38 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Logo/Logo.component.tsx: -------------------------------------------------------------------------------- 1 | import { faMicrophoneLines } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | 6 | export function Logo() { 7 | return ( 8 | 9 |
10 | 11 |
12 |
13 | ); 14 | } 15 | 16 | const Wrapper = styled.div` 17 | border-radius: 50%; 18 | padding: 10px; 19 | zoom: 0.2; 20 | background: rgb(254, 82, 47); 21 | background: linear-gradient( 22 | 0deg, 23 | rgba(245, 174, 25, 1) 0%, 24 | rgba(254, 82, 45, 1) 69% 25 | ); 26 | box-sizing: content-box; 27 | border: 12px solid white; 28 | filter: drop-shadow(0px 8px 12px rgb(0 0 0 / 8%)); 29 | 30 | & > div { 31 | background: rgb(245, 174, 25); 32 | width: 140px; 33 | height: 140px; 34 | 35 | align-items: center; 36 | justify-content: center; 37 | display: flex; 38 | 39 | background: linear-gradient( 40 | 0deg, 41 | rgba(254, 82, 47, 1) 0%, 42 | rgba(240, 151, 26, 1) 87% 43 | ); 44 | border-radius: 50%; 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /apps/frontend/features/RecordingPage/RecordingPage.component.tsx: -------------------------------------------------------------------------------- 1 | import { RecordingPlayer, UploadResult } from '@components/organisms'; 2 | import { MainLayout } from '@components/templates'; 3 | import { MediaInfoDto } from '@lib/dto/media-info.dto'; 4 | import { deleteRecording } from '@lib/services/recording.service'; 5 | import { useRouter } from 'next/router'; 6 | 7 | type RecordingPageProps = { 8 | media: MediaInfoDto; 9 | }; 10 | 11 | export function RecordingPage({ media }: RecordingPageProps) { 12 | console.log(media); 13 | const router = useRouter(); 14 | 15 | async function handleClickedNewRecording() { 16 | router.push('/'); 17 | } 18 | 19 | async function handleDeleteMedia() { 20 | try { 21 | await deleteRecording(media.mediaId); 22 | } catch (error) { 23 | console.error('Error deleting recording', error); 24 | } finally { 25 | router.push('/'); 26 | } 27 | } 28 | 29 | return ( 30 | 31 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/frontend/features/HomePage/HomePage.component.tsx: -------------------------------------------------------------------------------- 1 | import { SaveRecording } from '@components/organisms'; 2 | import { MainLayout } from '@components/templates'; 3 | import { HomeContextProvider } from './contexts/home.context'; 4 | import { ErrorComponent } from './Error.component'; 5 | import { RecorderComponent } from './Recorder.component'; 6 | import { useHomePage } from './use-homepage.hook'; 7 | 8 | export function HomePage() { 9 | const { 10 | recording: recordingResult, 11 | onNewRecording, 12 | onSaveRecording, 13 | onStartRecording, 14 | onDeleteMedia, 15 | deleteCount, 16 | onErrorRecording, 17 | } = useHomePage(); 18 | 19 | return ( 20 | 21 | 22 | 28 | 29 | {recordingResult && ( 30 | 35 | )} 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Alert/Alert.component.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { AlertStatus, getAlertColor } from './alert.constants'; 3 | import { motion } from 'motion/react'; 4 | 5 | type AlertProps = { 6 | status: AlertStatus; 7 | title: string; 8 | description: string; 9 | }; 10 | 11 | export function Alert({ status = AlertStatus.ERROR, ...props }: AlertProps) { 12 | return ( 13 | 24 | {props.title} 25 | {props.description} 26 | 27 | ); 28 | } 29 | 30 | const Wrapper = styled(motion.div)>` 31 | background: ${(props) => getAlertColor(props.status).colorScheme}; 32 | border-radius: 5px; 33 | padding: 10px 20px; 34 | max-width: 340px; 35 | color: ${(props) => getAlertColor(props.status).color}; 36 | box-shadow: 0px 0px 1px ${(props) => getAlertColor(props.status).border}; 37 | `; 38 | 39 | const Title = styled.b` 40 | font-size: 15px; 41 | `; 42 | 43 | const Description = styled.p` 44 | font-size: 12px; 45 | padding-top: 6px; 46 | `; 47 | -------------------------------------------------------------------------------- /apps/api/src/uploads-cleaner/uploads-cleaner.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { Cron, CronExpression } from '@nestjs/schedule'; 4 | import * as fs from 'fs'; 5 | 6 | @Injectable() 7 | export class UploadsCleanerService { 8 | constructor(private readonly configService: ConfigService) {} 9 | 10 | @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) 11 | cleanUploads() { 12 | const uploads = fs.readdirSync(this.configService.get('uploads.dir')); 13 | 14 | uploads.forEach(upload => { 15 | try { 16 | const creationDate = fs.statSync( 17 | `${this.configService.get('uploads.dir')}/${upload}` 18 | ).birthtime; 19 | 20 | const diff = Math.abs(Date.now() - creationDate.getTime()); 21 | const diffMinutes = Math.ceil(diff / (1000 * 60)); 22 | 23 | const deleteAfter = this.configService.get( 24 | 'uploads.deleteAfterMinutes' 25 | ); 26 | 27 | if (diffMinutes >= deleteAfter) { 28 | fs.rmSync(`${this.configService.get('uploads.dir')}/${upload}`, { 29 | recursive: true, 30 | force: true, 31 | }); 32 | } 33 | } catch (error) { 34 | console.log('Error deleting upload', upload, error); 35 | } 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/frontend/lib/hooks/use-recording.hook.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Recording } from '@lib/recording'; 3 | import { Optional } from '@lib/types/optional.type'; 4 | 5 | interface UseRecordingOptions { 6 | removeBackgroundNoise: boolean; 7 | autoGainControl: boolean; 8 | } 9 | 10 | export function useRecording(options?: Partial) { 11 | /** Create as an object to avoid unnecessary rerenders */ 12 | const [recordingState] = useState<{ recording: Optional }>({ 13 | recording: undefined, 14 | }); 15 | 16 | function initRecording() { 17 | recordingState.recording = new Recording({ 18 | removeBackgroundNoise: true, 19 | autoGainControl: true, 20 | ...options, 21 | }); 22 | } 23 | 24 | function clearRecording() { 25 | if (recordingState.recording) { 26 | recordingState.recording.onError = undefined; 27 | recordingState.recording.onStart = undefined; 28 | recordingState.recording.onStop = undefined; 29 | recordingState.recording.onPause = undefined; 30 | recordingState.recording.onResume = undefined; 31 | 32 | recordingState.recording = undefined; 33 | } 34 | } 35 | 36 | return { 37 | get recording(): Recording { 38 | return recordingState.recording as Recording; 39 | }, 40 | initRecording, 41 | clearRecording, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "affected": { 4 | "defaultBase": "main" 5 | }, 6 | "tasksRunnerOptions": { 7 | "default": { 8 | "runner": "nx/tasks-runners/default", 9 | "options": { 10 | "cacheableOperations": ["build", "lint", "test", "e2e"] 11 | } 12 | } 13 | }, 14 | "targetDefaults": { 15 | "build": { 16 | "dependsOn": ["^build"], 17 | "inputs": ["production", "^production"] 18 | }, 19 | "test": { 20 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"] 21 | }, 22 | "lint": { 23 | "inputs": ["default", "{workspaceRoot}/.eslintrc.json"] 24 | } 25 | }, 26 | "generators": { 27 | "@nrwl/react": { 28 | "application": { 29 | "babel": true 30 | } 31 | }, 32 | "@nrwl/next": { 33 | "application": { 34 | "style": "styled-components", 35 | "linter": "eslint" 36 | } 37 | } 38 | }, 39 | "defaultProject": "frontend", 40 | "namedInputs": { 41 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 42 | "sharedGlobals": ["{workspaceRoot}/babel.config.json"], 43 | "production": [ 44 | "default", 45 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 46 | "!{projectRoot}/tsconfig.spec.json", 47 | "!{projectRoot}/jest.config.[jt]s", 48 | "!{projectRoot}/.eslintrc.json" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/api/src/middlewares/helpers/redoc-html-template.ts: -------------------------------------------------------------------------------- 1 | export interface RedocOptions { 2 | title: string; 3 | specUrl: string; 4 | nonce?: string; 5 | redocOptions?: object; 6 | } 7 | 8 | const html = ` 9 | 10 | 11 | [[title]] 12 | 13 | 14 | 15 | 21 | 22 | 23 |
24 | 25 | 26 | 33 | `; 34 | 35 | export function redocHtml( 36 | options: RedocOptions = { 37 | title: 'ReDoc', 38 | specUrl: 'http://petstore.swagger.io/v2/swagger.json', 39 | } 40 | ): string { 41 | const { title, specUrl, nonce = '', redocOptions = {} } = options; 42 | return html 43 | .replace('[[title]]', title) 44 | .replace('[[spec-url]]', specUrl) 45 | .replace('[[nonce]]', nonce) 46 | .replace('[[options]]', JSON.stringify(redocOptions)); 47 | } 48 | -------------------------------------------------------------------------------- /apps/frontend/components/molecules/CopyInput/CopyInput.component.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input } from '@components/atoms'; 2 | import { useState } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | type CopyInputProps = { 6 | value: string; 7 | }; 8 | 9 | export function CopyInput(props: CopyInputProps) { 10 | const [isCopied, setIsCopied] = useState(false); 11 | 12 | const handleCopy = () => { 13 | if (!isCopied) { 14 | navigator.clipboard.writeText(props.value); 15 | setIsCopied(true); 16 | setTimeout(() => setIsCopied(false), 2000); 17 | } 18 | }; 19 | 20 | const selectText = event => { 21 | event.target.select(); 22 | }; 23 | 24 | return ( 25 | 26 | selectText(event)} 28 | value={props.value} 29 | readOnly 30 | /> 31 | 32 | {isCopied ? 'Copied!' : 'Copy'} 33 | 34 | 35 | ); 36 | } 37 | 38 | const Wrapper = styled.div` 39 | display: flex; 40 | width: 100%; 41 | `; 42 | 43 | const StyledInput = styled(Input)` 44 | border-right-width: 0px !important; 45 | border-top-right-radius: 0px !important; 46 | border-bottom-right-radius: 0px !important; 47 | width: 100%; 48 | `; 49 | 50 | const StyledButton = styled(Button)` 51 | border-top-left-radius: 0px !important; 52 | border-bottom-left-radius: 0px !important; 53 | width: 82px; 54 | `; 55 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Slider/Slider.component.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface SliderProps extends Omit, 'onChange'> { 5 | value?: number; 6 | onChange?: (newValue: number) => void; 7 | } 8 | 9 | export function Slider({ min = 0, ...props }: SliderProps) { 10 | return ( 11 | 12 | props.onChange?.(Number(e.target.value))} 17 | > 18 | 19 | ); 20 | } 21 | 22 | const Wrapper = styled.div` 23 | width: 100%; 24 | display: flex; 25 | margin: 8px 0; 26 | justify-content: center; 27 | align-items: center; 28 | `; 29 | 30 | const StyledSlider = styled.input` 31 | -webkit-appearance: none; 32 | width: 100%; 33 | height: 6px; 34 | background: #d9e0e7; 35 | outline: none; 36 | opacity: 0.7; 37 | -webkit-transition: 0.2s; 38 | transition: opacity 0.2s; 39 | border-radius: 25px; 40 | 41 | &:hover { 42 | opacity: 1; 43 | } 44 | 45 | &::-webkit-slider-thumb { 46 | -webkit-appearance: none; 47 | appearance: none; 48 | width: 14px; 49 | height: 14px; 50 | background: #9aabac; 51 | cursor: pointer; 52 | border-radius: 50%; 53 | } 54 | 55 | &::-moz-range-thumb { 56 | width: 14px; 57 | height: 14px; 58 | background: #9aabac; 59 | cursor: pointer; 60 | } 61 | `; 62 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/timer.ts: -------------------------------------------------------------------------------- 1 | export class Timer { 2 | private startTime: number; 3 | private stoppedTime: number; 4 | 5 | constructor() { 6 | this.reset(); 7 | } 8 | 9 | reset() { 10 | this.startTime = null; 11 | this.stoppedTime = null; 12 | } 13 | 14 | start() { 15 | if (!this.startTime) { 16 | this.startTime = Date.now(); 17 | } 18 | 19 | if (this.stoppedTime) { 20 | this.startTime += Date.now() - this.stoppedTime; 21 | this.stoppedTime = null; 22 | } 23 | } 24 | 25 | resetAndStart() { 26 | this.reset(); 27 | this.start(); 28 | } 29 | 30 | stop() { 31 | if (!this.stoppedTime) { 32 | this.stoppedTime = Date.now(); 33 | } 34 | } 35 | 36 | getTime(): number { 37 | if (this.startTime) { 38 | if (this.stoppedTime) { 39 | return this.stoppedTime - this.startTime; 40 | } else { 41 | return Date.now() - this.startTime; 42 | } 43 | } 44 | 45 | return 0; 46 | } 47 | 48 | static makeTimeStrMMSS(time: number): string { 49 | const minutes = Math.floor(time / 60000); 50 | 51 | const minutesStr = minutes.toString().padStart(2, '0'); 52 | const secondsStr = Math.floor((time - minutes * 60000) / 1000) 53 | .toString() 54 | .padStart(2, '0'); 55 | 56 | const miliseconds = (time % 1000) 57 | .toString() 58 | .padStart(3, '0') 59 | .substring(0, 2); 60 | 61 | return `${minutesStr}:${secondsStr}.${miliseconds}`; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /apps/frontend/components/organisms/AudioPlayer/AudioPlayer.component.tsx: -------------------------------------------------------------------------------- 1 | import { PlayPauseButton, SliderTimer } from '@components/molecules'; 2 | import { useAudioPlayer } from '@lib/hooks'; 3 | import { RecorderStatus } from '@lib/recording'; 4 | import styled from 'styled-components'; 5 | 6 | type AudioPlayerProps = { 7 | src: string; 8 | }; 9 | 10 | export function AudioPlayer(props: AudioPlayerProps) { 11 | const { 12 | audioRef, 13 | isPlaying, 14 | pause, 15 | play, 16 | duration, 17 | currentTime, 18 | setCurrentTime, 19 | } = useAudioPlayer(); 20 | 21 | return ( 22 | 23 | 38 | ); 39 | } 40 | 41 | const Wrapper = styled.div` 42 | width: 100%; 43 | padding: 1rem; 44 | border: 1px solid #d7e4eb; 45 | border-radius: 0.875rem; 46 | display: flex; 47 | justify-content: center; 48 | align-items: center; 49 | flex-direction: row; 50 | `; 51 | 52 | const ButtonWraper = styled.div` 53 | display: flex; 54 | margin-right: 12px; 55 | `; 56 | -------------------------------------------------------------------------------- /apps/api/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "apps/api/src", 5 | "projectType": "application", 6 | "targets": { 7 | "build": { 8 | "executor": "@nrwl/webpack:webpack", 9 | "outputs": ["{options.outputPath}"], 10 | "options": { 11 | "outputPath": "dist/apps/api", 12 | "main": "apps/api/src/main.ts", 13 | "tsConfig": "apps/api/tsconfig.app.json", 14 | "target": "node", 15 | "compiler": "tsc" 16 | }, 17 | "configurations": { 18 | "production": { 19 | "optimization": true, 20 | "extractLicenses": true, 21 | "inspect": false, 22 | "fileReplacements": [ 23 | { 24 | "replace": "apps/api/src/environments/environment.ts", 25 | "with": "apps/api/src/environments/environment.prod.ts" 26 | } 27 | ] 28 | } 29 | } 30 | }, 31 | "serve": { 32 | "executor": "@nrwl/node:node", 33 | "options": { 34 | "buildTarget": "api:build" 35 | }, 36 | "configurations": { 37 | "production": { 38 | "buildTarget": "api:build:production" 39 | } 40 | } 41 | }, 42 | "lint": { 43 | "executor": "@nrwl/linter:eslint", 44 | "outputs": ["{options.outputFile}"], 45 | "options": { 46 | "lintFilePatterns": ["apps/api/**/*.ts"] 47 | } 48 | }, 49 | "test": { 50 | "executor": "@nrwl/jest:jest", 51 | "outputs": ["{workspaceRoot}/coverage/apps/api"], 52 | "options": { 53 | "jestConfig": "apps/api/jest.config.ts", 54 | "passWithNoTests": true 55 | } 56 | } 57 | }, 58 | "tags": [] 59 | } 60 | -------------------------------------------------------------------------------- /apps/frontend/components/atoms/Button/Button.component.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonIcon } from '@components/atoms/Button/ButtonIcon.component'; 2 | import { darkenColor } from '@lib/helpers/colors'; 3 | import { ButtonHTMLAttributes } from 'react'; 4 | import styled from 'styled-components'; 5 | import { NexaFont } from '~/fonts'; 6 | 7 | type ButtonProps = ButtonHTMLAttributes & { 8 | leftIcon?: React.ReactElement; 9 | rightIcon?: React.ReactElement; 10 | color?: string; 11 | }; 12 | 13 | export function Button(props: ButtonProps) { 14 | return ( 15 | 16 | {props.leftIcon && ( 17 | {props.leftIcon} 18 | )} 19 | {props.children} 20 | {props.rightIcon && ( 21 | {props.rightIcon} 22 | )} 23 | 24 | ); 25 | } 26 | 27 | const StyledButton = styled.button` 28 | background: ${props => props.color}; 29 | cursor: pointer; 30 | outline: none; 31 | outline: transparent solid 2px; 32 | border-radius: 0.875rem; 33 | height: 2.5rem; 34 | font-family: ${NexaFont.style.fontFamily}; 35 | font-size: 0.85rem; 36 | color: white; 37 | transition-duration: 0.2s; 38 | white-space: nowrap; 39 | user-select: none; 40 | padding-inline-start: 1rem; 41 | padding-inline-end: 1rem; 42 | 43 | &:disabled { 44 | background: #a0a0a0; 45 | cursor: default; 46 | 47 | &:hover { 48 | background: #a0a0a0; 49 | cursor: default; 50 | } 51 | } 52 | 53 | &:focus-visible { 54 | box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.6); 55 | } 56 | 57 | &:hover { 58 | background: ${props => darkenColor(props.color)}; 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /apps/frontend/lib/hooks/use-audio-player.hook.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | export enum AudioPlayerState { 4 | LOADING, 5 | PAUSED, 6 | PLAYING, 7 | FAILED, 8 | } 9 | 10 | export function useAudioPlayer() { 11 | const audioRef = useRef(null); 12 | const [state, setState] = useState(AudioPlayerState.PAUSED); 13 | const [currentTime, _setCurrentTime] = useState(0); 14 | const [duration, setDuration] = useState(0); 15 | 16 | useEffect(() => { 17 | audioRef.current.onplay = () => { 18 | setState(AudioPlayerState.PLAYING); 19 | }; 20 | 21 | audioRef.current.onpause = () => { 22 | setState(AudioPlayerState.PAUSED); 23 | }; 24 | 25 | if (audioRef.current.readyState > 0) { 26 | setDuration(audioRef.current.duration); 27 | } 28 | 29 | audioRef.current.onloadedmetadata = (event: any) => { 30 | setDuration(event.target.duration); 31 | }; 32 | 33 | audioRef.current.ontimeupdate = (event: any) => { 34 | _setCurrentTime(event.target.currentTime); 35 | }; 36 | }, []); 37 | 38 | function pause() { 39 | audioRef.current?.pause(); 40 | } 41 | 42 | function play() { 43 | audioRef.current?.play(); 44 | } 45 | 46 | function setCurrentTime(newTime: number) { 47 | _setCurrentTime(newTime); 48 | if (audioRef.current) { 49 | audioRef.current.currentTime = newTime; 50 | } 51 | } 52 | 53 | return { 54 | audioRef, 55 | state, 56 | duration, 57 | currentTime, 58 | setCurrentTime, 59 | pause, 60 | play, 61 | get isPlaying() { 62 | switch (state) { 63 | case AudioPlayerState.LOADING: 64 | case AudioPlayerState.PLAYING: 65 | return true; 66 | default: 67 | return false; 68 | } 69 | }, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Voice Recorder App

2 | 3 |
4 | 5 | Voice Recorder App 6 | 7 |
8 |

9 | Go to the application 10 |

11 |
12 | 13 | A simple and open-source voice recorder service 14 | 15 | Voice Recorder App 16 | 17 | 18 | ## Development 19 | 20 | **Install packages**: 21 | 22 | ```bash 23 | npm ci 24 | ``` 25 | 26 | **Run application**: 27 | 28 | ```bash 29 | # It runs frontend and backend application: 30 | npm run start:all 31 | ``` 32 | 33 | ## Development server 34 | 35 | Run `nx serve my-app` for a dev server. Navigate to http://localhost:4200/. The app will automatically reload if you change any of the source files. 36 | 37 | ## Code scaffolding 38 | 39 | Run `nx g @nrwl/react:component my-component --project=my-app` to generate a new component. 40 | 41 | ## Build 42 | 43 | Run `nx build my-app` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 44 | 45 | ## Running unit tests 46 | 47 | Run `nx test my-app` to execute the unit tests via [Jest](https://jestjs.io). 48 | 49 | Run `nx affected:test` to execute the unit tests affected by a change. 50 | 51 | ## Running end-to-end tests 52 | 53 | Run `nx e2e my-app` to execute the end-to-end tests via [Cypress](https://www.cypress.io). 54 | 55 | Run `nx affected:e2e` to execute the end-to-end tests affected by a change. 56 | 57 | ## Understand your workspace 58 | 59 | Run `nx graph` to see a diagram of the dependencies of your projects. 60 | -------------------------------------------------------------------------------- /apps/frontend/features/HomePage/use-homepage.hook.ts: -------------------------------------------------------------------------------- 1 | import { errorStoreAtom } from '@features/HomePage/Error.component'; 2 | import { errorParser } from '@lib/error-handler/error-parser'; 3 | import { MediaInfo, Recording } from '@lib/recording'; 4 | import { useSetAtom } from 'jotai'; 5 | import { useState } from 'react'; 6 | 7 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 8 | 9 | export function useHomePage() { 10 | // Holds the recording after it's done 11 | const [recordingResult, setRecordingResult] = useState(undefined); 12 | const [deleteCount, setDeleteCount] = useState(0); 13 | // Holds error to show to the user. 14 | const setError = useSetAtom(errorStoreAtom); 15 | 16 | const onStartRecording = (toHome = false) => { 17 | toHome && window.history.pushState({}, '', '/'); 18 | setRecordingResult(undefined); 19 | setError(undefined); 20 | }; 21 | 22 | const onNewRecording = (recording: Recording) => { 23 | setRecordingResult(recording); 24 | }; 25 | 26 | const onSaveRecording = (mediaInfo: MediaInfo) => { 27 | window.history.pushState({}, '', `/${mediaInfo.mediaId}`); 28 | }; 29 | 30 | const onErrorRecording = async (error: Error) => { 31 | const parsedError = errorParser(error); 32 | setError(undefined); 33 | // Delay to show a blink when error occurs in short period of time 34 | await sleep(20); 35 | setError(parsedError); 36 | }; 37 | 38 | const onDeleteMedia = () => { 39 | window.history.pushState({}, '', '/'); 40 | setRecordingResult(undefined); 41 | setError(undefined); 42 | setDeleteCount((prev) => prev + 1); 43 | }; 44 | 45 | return { 46 | recording: recordingResult, 47 | deleteCount, 48 | onStartRecording, 49 | onNewRecording, 50 | onSaveRecording, 51 | onErrorRecording, 52 | onDeleteMedia, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { Logger } from '@nestjs/common'; 4 | import { NestFactory } from '@nestjs/core'; 5 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 6 | import * as cors from 'cors'; 7 | import helmet from 'helmet'; 8 | import { APP_PREFIX } from './api.constants'; 9 | import { AppModule } from './app.module'; 10 | import { redocMiddleware } from './middlewares/redoc.middleware'; 11 | 12 | async function bootstrap() { 13 | const app = await NestFactory.create(AppModule); 14 | app.setGlobalPrefix(APP_PREFIX); 15 | app.use( 16 | cors({ 17 | origin: '*', 18 | }) 19 | ); 20 | app.use( 21 | helmet({ 22 | crossOriginEmbedderPolicy: false, 23 | contentSecurityPolicy: false, 24 | }) 25 | ); 26 | 27 | const config = new DocumentBuilder() 28 | .setTitle('Voice Recorder API') 29 | .setDescription( 30 | 'An API that allows you to save and retrieve voice recordings.' 31 | ) 32 | .setVersion('1.0') 33 | .setBasePath(APP_PREFIX) 34 | .setLicense('MIT License', 'https://opensource.org/licenses/MIT') 35 | .setContact('Dante Calderon', 'https://dantecalderon.com', '') 36 | .build(); 37 | 38 | const document = SwaggerModule.createDocument(app, config); 39 | 40 | const SWAGGER_DOCS_PATH = APP_PREFIX + '/docs-spec'; 41 | 42 | SwaggerModule.setup(SWAGGER_DOCS_PATH, app, document); 43 | 44 | const redocOptions = { 45 | title: 'Voice Recorder API', 46 | version: '1.0', 47 | specUrl: `${SWAGGER_DOCS_PATH}-json`, // Endpoint of OpenAPI Specification URL in JSON format 48 | }; 49 | 50 | app.use(`${APP_PREFIX}/docs`, redocMiddleware(redocOptions)); 51 | 52 | const port = process.env.PORT || 3333; 53 | 54 | await app.listen(port); 55 | 56 | Logger.log(`🚀 Application is running on: http://localhost:${port}/api`); 57 | } 58 | 59 | bootstrap(); 60 | -------------------------------------------------------------------------------- /apps/frontend/components/molecules/SlilderTimer/SliderTimer.component.tsx: -------------------------------------------------------------------------------- 1 | import { Slider } from '@components/atoms'; 2 | import React, { useEffect, useState } from 'react'; 3 | import styled from 'styled-components'; 4 | import { MonoFont } from '~/fonts'; 5 | 6 | type SliderTimerProps = { 7 | currentTime?: number; 8 | duration?: number; 9 | onChangePosition?: (newPosition: number) => void; 10 | }; 11 | 12 | const calculateTime = (secs = 0) => { 13 | const minutes = Math.floor(secs / 60); 14 | const seconds = Math.floor(secs % 60); 15 | return `${minutes.toString().padStart(2, '0')}:${seconds 16 | .toString() 17 | .padStart(2, '0')}`; 18 | }; 19 | 20 | export function SliderTimer(props: SliderTimerProps) { 21 | const [ownValue, setOwnValue] = useState(props.currentTime || 0); 22 | const [isSliding, setIsSliding] = useState(false); 23 | 24 | useEffect(() => { 25 | if (!isSliding) { 26 | setOwnValue(props.currentTime || 0); 27 | } 28 | }, [props.currentTime]); 29 | 30 | return ( 31 | 32 | { 34 | props.onChangePosition?.(ownValue); 35 | setIsSliding(false); 36 | }} 37 | onMouseDown={() => setIsSliding(true)} 38 | onChange={newValue => setOwnValue(newValue)} 39 | value={ownValue} 40 | max={props.duration} 41 | /> 42 | 43 | {calculateTime(isSliding ? ownValue : props.currentTime)} 44 | {calculateTime(props.duration)} 45 | 46 | 47 | ); 48 | } 49 | 50 | const Wrapper = styled.div` 51 | width: 100%; 52 | `; 53 | 54 | const TimerContainer = styled.div` 55 | display: flex; 56 | justify-content: space-between; 57 | 58 | span { 59 | font-size: 0.8rem; 60 | font-family: ${MonoFont.style.fontFamily}; 61 | color: #a9afb2; 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /apps/frontend/components/molecules/SocialShare/SocialShare.component.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@components/atoms'; 2 | import { 3 | faFacebook, 4 | faTwitter, 5 | faWhatsapp, 6 | } from '@fortawesome/free-brands-svg-icons'; 7 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 8 | import styled from 'styled-components'; 9 | 10 | type SocialShareProps = { 11 | url: string; 12 | }; 13 | 14 | function getShareInfo(to, url) { 15 | const message = 'Check out this recording!'; 16 | const hashtags = 'recording'; 17 | 18 | switch (to) { 19 | case 'whatsapp': 20 | return { 21 | url: `https://api.whatsapp.com/send?text=${message} ${url}`, 22 | }; 23 | case 'twitter': 24 | return { 25 | url: `https://twitter.com/intent/tweet?url=${url}&text=${message}&hashtags=${hashtags}`, 26 | }; 27 | 28 | case 'facebook': 29 | return { 30 | url: `https://www.facebook.com/sharer/sharer.php?u=${url}`, 31 | }; 32 | default: 33 | console.error('Unknown social network', to); 34 | break; 35 | } 36 | } 37 | 38 | export function SocialShare(props: SocialShareProps) { 39 | function handleShare(to) { 40 | const { url } = getShareInfo(to, props.url); 41 | window.open(url, '_blank'); 42 | } 43 | 44 | return ( 45 | 46 | handleShare('whatsapp')} color="#26B840"> 47 | 48 | 49 | handleShare('facebook')} color="#3b5998"> 50 | 51 | 52 | handleShare('twitter')} color="#1da1f2"> 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | const Wrapper = styled.div``; 60 | 61 | const StyledButton = styled(Button)` 62 | margin-right: 5px; 63 | width: 44px; 64 | padding-left: 8px; 65 | padding-right: 8px; 66 | `; 67 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/upload-queue.ts: -------------------------------------------------------------------------------- 1 | import { retryableFetch } from './http/retryable-fetch.helper'; 2 | import { QueueItem } from './types/queue-item.type'; 3 | 4 | export class UploadQueue { 5 | private uploadURLWithId = null; 6 | private queue = []; 7 | private inProgress = 0; 8 | private chunksAdded = 0; 9 | private completed = false; 10 | private totalUploadedBytes = 0; 11 | private started = false; 12 | 13 | public onProgress: (bytesUploaded: number) => void; 14 | 15 | constructor( 16 | private onUploadsFinished: () => void, 17 | private readonly maxConcurrentUploads: number 18 | ) {} 19 | 20 | start(uploadURLWithId: string) { 21 | if (!this.started) { 22 | this.started = true; 23 | this.uploadURLWithId = uploadURLWithId; 24 | 25 | if (this.queue.length) { 26 | this.startUploads(); 27 | } 28 | } 29 | } 30 | 31 | private startUploads() { 32 | const toUpload = this.queue.splice( 33 | 0, 34 | this.maxConcurrentUploads - this.inProgress 35 | ); 36 | this.inProgress += toUpload.length; 37 | 38 | for (const queueItem of toUpload) { 39 | this.startChunkUpload(queueItem); 40 | } 41 | } 42 | 43 | private async startChunkUpload(queueItem: QueueItem) { 44 | try { 45 | const data = new FormData(); 46 | data.append('chunk', queueItem.chunk, 'chunk'); 47 | 48 | await retryableFetch( 49 | `${this.uploadURLWithId}/chunk/${queueItem.chunkIndex}`, 50 | { 51 | method: 'POST', 52 | body: data, 53 | } 54 | ); 55 | 56 | this.inProgress--; 57 | this.totalUploadedBytes += queueItem.chunk.size; 58 | this.onProgress?.(this.totalUploadedBytes); 59 | 60 | if (this.completed && this.inProgress === 0) { 61 | this.onUploadsFinished?.(); 62 | } else { 63 | this.startUploads(); 64 | } 65 | } catch (error) { 66 | console.error('Error uploading chunk', { error, queueItem }); 67 | } 68 | } 69 | 70 | add(chunk) { 71 | this.queue.push({ 72 | chunk, 73 | chunkIndex: this.chunksAdded, 74 | }); 75 | this.chunksAdded++; 76 | 77 | if (this.started) { 78 | this.startUploads(); 79 | } 80 | } 81 | 82 | complete() { 83 | this.completed = true; 84 | if (this.started && this.inProgress === 0) { 85 | this.onUploadsFinished?.(); 86 | } 87 | } 88 | 89 | abort() { 90 | this.queue = []; 91 | this.onUploadsFinished = null; 92 | this.onProgress = null; 93 | this.complete(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /apps/frontend/components/organisms/SaveRecording/SaveRecording.component.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card } from '@components/atoms'; 2 | import { UploadResult } from '@components/organisms'; 3 | import { faCloudArrowUp } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { MediaInfo, Recording } from '@lib/recording'; 6 | import { deleteRecording } from '@lib/services/recording.service'; 7 | import { useEffect, useState } from 'react'; 8 | import styled from 'styled-components'; 9 | 10 | type SaveRecordingProps = { 11 | recordingResult: Recording; 12 | onSaveRecording: (media: MediaInfo) => void; 13 | onDeleteMedia: () => void; 14 | }; 15 | 16 | export function SaveRecording({ 17 | recordingResult, 18 | ...props 19 | }: SaveRecordingProps) { 20 | const [isSaving, setIsSaving] = useState(false); 21 | const [percentageComplete, setPercentageComplete] = useState(0); 22 | const [media, setMedia] = useState(); 23 | 24 | useEffect(() => { 25 | recordingResult.onSavePercent = (percent) => { 26 | setPercentageComplete(percent); 27 | }; 28 | 29 | recordingResult.onSaveError = (error) => { 30 | console.log('error saving', error); 31 | setIsSaving(false); 32 | }; 33 | 34 | recordingResult.onSaveSuccess = (media) => { 35 | setMedia(media); 36 | setIsSaving(false); 37 | props.onSaveRecording(media); 38 | }; 39 | 40 | return () => { 41 | recordingResult.onSavePercent = undefined; 42 | recordingResult.onSaveError = undefined; 43 | }; 44 | }, []); 45 | 46 | function handleSave() { 47 | setIsSaving(true); 48 | setPercentageComplete(0); 49 | recordingResult.save(); 50 | } 51 | 52 | async function handleDeleteMedia() { 53 | try { 54 | await deleteRecording(media.mediaId); 55 | } catch (error) { 56 | console.error('Error deleting recording', error); 57 | } 58 | 59 | props.onDeleteMedia(); 60 | } 61 | 62 | if (!media) { 63 | return ( 64 | 65 | 73 | 74 | ); 75 | } 76 | 77 | return ( 78 | 79 | ); 80 | } 81 | 82 | const Wrapper = styled(Card)` 83 | margin-top: 20px; 84 | padding: 1.5rem 1.5rem; 85 | justify-content: center; 86 | `; 87 | -------------------------------------------------------------------------------- /apps/api/src/uploader/uploader.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Delete, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Param, 8 | ParseIntPipe, 9 | Post, 10 | UploadedFile, 11 | UseInterceptors, 12 | } from '@nestjs/common'; 13 | import { FileInterceptor } from '@nestjs/platform-express'; 14 | import { 15 | DownloadUrlReponseDto, 16 | MediaInfoDto, 17 | } from '@voice-recorder/shared-types'; 18 | import 'multer'; 19 | import { NotChunksFoundException } from '../uploader/exceptions/not-chunks-found.exception'; 20 | import { UploaderService } from './uploader.service'; 21 | import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; 22 | import { AliveResponseDto } from './dto/alive-response.dto'; 23 | 24 | @ApiTags('Uploader') 25 | @Controller('upload') 26 | export class UploaderController { 27 | constructor(private readonly uploaderService: UploaderService) {} 28 | 29 | @Get('alive') 30 | @ApiOperation({ 31 | summary: 'Check if the server is alive', 32 | description: 'Check if the server is alive(available to receive requests)', 33 | }) 34 | @ApiOkResponse({ 35 | type: AliveResponseDto, 36 | }) 37 | alive() { 38 | return { 39 | ok: true, 40 | serverTime: new Date(), 41 | NODE_ENV: process.env.NODE_ENV, 42 | }; 43 | } 44 | 45 | @UseInterceptors(FileInterceptor('chunk')) 46 | @Post('/:uploadId/chunk/:chunkNumber') 47 | uploadChunk( 48 | @Param('uploadId') uploadId: string, 49 | @Param('chunkNumber', new ParseIntPipe()) chunkNumber: number, 50 | @UploadedFile() chunks: Express.Multer.File 51 | ) { 52 | return this.uploaderService.uploadChunk(uploadId, chunkNumber, chunks); 53 | } 54 | 55 | @Post('/:uploadId/finalize') 56 | async finalize(@Param('uploadId') uploadId: string): Promise { 57 | try { 58 | return this.uploaderService.finalizeUpload(uploadId); 59 | } catch (error) { 60 | if (error instanceof NotChunksFoundException) { 61 | return { 62 | mediaId: uploadId, 63 | status: 0, 64 | }; 65 | } 66 | 67 | throw error; 68 | } 69 | } 70 | 71 | @HttpCode(HttpStatus.NO_CONTENT) 72 | @Delete('/:recordingId') 73 | deleteRecording(@Param('recordingId') uploadId: string) { 74 | return this.uploaderService.deleteRecording(uploadId); 75 | } 76 | 77 | @Get('/download-url/:mediaId') 78 | getDownloadUrl( 79 | @Param('mediaId') mediaId: string 80 | ): Promise { 81 | return this.uploaderService.getDownloadUrl(mediaId); 82 | } 83 | 84 | @Get('/:recordingId') 85 | getRecording(@Param('recordingId') uploadId: string) { 86 | return this.uploaderService.getRecording(uploadId); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/audio-encoding/audio-encoder.ts: -------------------------------------------------------------------------------- 1 | import { EncoderStatus } from '../enums/encoder-status.enum'; 2 | import { generateRandomId } from '../helpers/generate-random-id.helper'; 3 | import { AudioEncoderConfig } from './audio-encoder-config.interface'; 4 | 5 | export class AudioEncoder { 6 | private config: Partial; 7 | private jobId: string; 8 | private worker: Worker; 9 | private state: EncoderStatus; 10 | 11 | public onDataAvailable: (data: Int8Array) => void; 12 | public onStopped: () => void; 13 | 14 | constructor(config: Partial) { 15 | this.jobId = generateRandomId(); 16 | this.config = config; 17 | this.state = EncoderStatus.INACTIVE; 18 | } 19 | 20 | getState(): EncoderStatus { 21 | return this.state; 22 | } 23 | 24 | start() { 25 | this.worker.postMessage({ 26 | command: 'start', 27 | jobId: this.jobId, 28 | config: this.config, 29 | }); 30 | } 31 | 32 | sendData(buffers: Float32Array[]) { 33 | this.worker.postMessage({ 34 | command: 'data', 35 | jobId: this.jobId, 36 | buffers, 37 | }); 38 | } 39 | 40 | stop() { 41 | this.worker.postMessage({ 42 | command: 'stop', 43 | jobId: this.jobId, 44 | }); 45 | } 46 | 47 | waitForWorker() { 48 | try { 49 | if (this.state === EncoderStatus.READY) { 50 | return; 51 | } 52 | 53 | // if ( 54 | // this.state !== EncoderStatus.INACTIVE && 55 | // this.state !== EncoderStatus.ERROR 56 | // ) { 57 | // this.prestart(); 58 | // } 59 | this.prestart(); 60 | } catch (error) { 61 | console.error('MP3 worker failed'); 62 | 63 | throw error; 64 | } 65 | } 66 | 67 | // TODO: Start worker only once on app start 68 | private prestart() { 69 | this.state = EncoderStatus.LOADING; 70 | 71 | // TODO: Is base url required? 72 | const mp3WorkerUrl = `${process.env.NEXT_PUBLIC_WEB_URL}/mp3worker.min.js`; 73 | 74 | const workerScript = `importScripts("${mp3WorkerUrl}");`; 75 | 76 | const url = URL.createObjectURL( 77 | new Blob([workerScript], { type: 'application/javascript' }) 78 | ); 79 | 80 | this.worker = new Worker(url); 81 | 82 | this.worker.onmessage = event => { 83 | switch (event.data.message) { 84 | case 'ready': 85 | this.state = EncoderStatus.READY; 86 | break; 87 | case 'data': 88 | this.onDataAvailable?.(event.data.data); 89 | break; 90 | case 'stopped': 91 | this.onStopped?.(); 92 | break; 93 | } 94 | }; 95 | 96 | this.worker.onerror = error => { 97 | this.state = EncoderStatus.ERROR; 98 | console.error('MP3 worker failed', error); 99 | }; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/http/retryable-fetch.helper.ts: -------------------------------------------------------------------------------- 1 | import { AbortRequestError } from '../errors/abort-request.error'; 2 | import { AbortablePromise } from '../interfaces/abortable-promise.interface'; 3 | import { RetryableFetchInitOptions } from '../interfaces/retryable-fetch-init-options.interface'; 4 | 5 | export function retryableFetch( 6 | input: RequestInfo | URL, 7 | init: RetryableFetchInitOptions 8 | ): AbortablePromise { 9 | let aborted = false; 10 | let abort!: () => void; 11 | 12 | const { parseJson, resolveWhenNotOk } = init; 13 | 14 | const promise = new AbortablePromise((resolve, reject) => { 15 | const u = cancellableRetryTimeout( 16 | retryMethod => { 17 | fetch(input, init) 18 | .then(response => { 19 | if (!aborted) { 20 | if (response.ok || resolveWhenNotOk) { 21 | if (parseJson) { 22 | response 23 | .json() 24 | .then(json => { 25 | resolve(json); 26 | }) 27 | .catch(error => { 28 | console.log('Error parsing json', error); 29 | 30 | retryMethod(); 31 | }); 32 | } else { 33 | resolve(response); 34 | } 35 | } else { 36 | retryMethod(); 37 | } 38 | } 39 | }) 40 | .catch(error => { 41 | console.error(`Error on fetching ${input}`, error); 42 | 43 | if (!aborted) { 44 | retryMethod(); 45 | } 46 | }); 47 | }, 48 | [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000] 49 | ); 50 | 51 | abort = () => { 52 | aborted = true; 53 | u.cancel(); 54 | reject(new AbortRequestError('Request aborted')); 55 | }; 56 | }) as AbortablePromise; 57 | 58 | promise.abort = abort; 59 | 60 | return promise; 61 | } 62 | 63 | /** 64 | * Function to retry a function with a timeout 65 | * 66 | * @param callback - callback to be called after each timeout 67 | * @param timeouts Array of timeouts in milliseconds 68 | * @param delayedExecution Indicates if the first execution should be delayed 69 | * @returns Function to cancel the execution 70 | */ 71 | function cancellableRetryTimeout( 72 | callback, 73 | timeouts: number[], 74 | delayedExecution?: boolean 75 | ) { 76 | let canceled = false; 77 | 78 | const retryMethod = () => { 79 | if (!canceled) { 80 | if (timeouts.length > 0) { 81 | const timeout = timeouts.shift(); 82 | 83 | setTimeout(() => { 84 | if (!canceled) { 85 | callback(retryMethod); 86 | } 87 | }, timeout); 88 | } 89 | } 90 | }; 91 | 92 | delayedExecution ? retryMethod() : callback(retryMethod); 93 | 94 | return { 95 | cancel: () => { 96 | canceled = true; 97 | }, 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /apps/frontend/components/molecules/PlayPauseButton/PlayPauseButton.component.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faMicrophone, 3 | faPause, 4 | faPlay, 5 | IconDefinition, 6 | } from '@fortawesome/free-solid-svg-icons'; 7 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 8 | import { RecorderStatus } from '@lib/recording'; 9 | import styled from 'styled-components'; 10 | 11 | const iconMap: Record = { 12 | [RecorderStatus.PAUSED]: faPlay, 13 | [RecorderStatus.RECORDING]: faPause, 14 | [RecorderStatus.STOPPED]: faMicrophone, 15 | [RecorderStatus.STARTING]: faPlay, 16 | }; 17 | 18 | type PlayPauseButtonProps = { 19 | status: RecorderStatus; 20 | size?: number; 21 | iconSize?: 'xs' | 'sm' | 'lg' | 'xl'; 22 | onStartClick?: () => void; 23 | onPauseClick?: () => void; 24 | onPlayClick?: () => void; 25 | }; 26 | 27 | export function PlayPauseButton({ 28 | status = RecorderStatus.STOPPED, 29 | size = 40, 30 | iconSize = 'xl', 31 | ...props 32 | }: PlayPauseButtonProps) { 33 | const icon = iconMap[status]; 34 | 35 | if (!icon) { 36 | throw new Error('Invalid status'); 37 | } 38 | 39 | function handleClick() { 40 | switch (status) { 41 | case RecorderStatus.STOPPED: 42 | props.onStartClick?.(); 43 | break; 44 | case RecorderStatus.RECORDING: 45 | props.onPauseClick?.(); 46 | break; 47 | case RecorderStatus.STARTING: 48 | case RecorderStatus.PAUSED: 49 | props.onPlayClick?.(); 50 | break; 51 | default: 52 | break; 53 | } 54 | } 55 | 56 | function getButtonTitle() { 57 | switch (status) { 58 | case RecorderStatus.STOPPED: 59 | return 'Start recording'; 60 | case RecorderStatus.RECORDING: 61 | return 'Pause recording'; 62 | case RecorderStatus.STARTING: 63 | case RecorderStatus.PAUSED: 64 | return 'Resume recording'; 65 | default: 66 | return ''; 67 | } 68 | } 69 | 70 | return ( 71 | 72 | 77 | 78 | 79 | 80 | ); 81 | } 82 | 83 | const Wrapper = styled('div')` 84 | position: relative; 85 | width: ${props => props.size}px; 86 | height: 50px; 87 | `; 88 | 89 | const RecordingButton = styled('div')` 90 | position: absolute; 91 | background: #d6e2ea; 92 | border-radius: 50%; 93 | width: ${props => props.size}px; 94 | height: ${props => props.size}px; 95 | display: flex; 96 | justify-content: center; 97 | align-items: center; 98 | cursor: pointer; 99 | left: calc(50% - ${props => props.size / 2}px); 100 | top: calc(50% - ${props => props.size / 2}px); 101 | transition: 0.2s; 102 | 103 | &:hover { 104 | background: #c4d4e0; 105 | } 106 | `; 107 | 108 | const Icon = styled(FontAwesomeIcon)` 109 | margin-left: 2px; 110 | `; 111 | -------------------------------------------------------------------------------- /apps/frontend/components/organisms/UploadResult/UploadResult.component.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, Stack } from '@components/atoms'; 2 | import { CopyInput, SocialShare } from '@components/molecules'; 3 | import { faCircleDown, faTrash } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { getDownloadAudioUrl } from '@lib/helpers/url.helpers'; 6 | import { getRecordingDownloadUrl } from '@lib/services/recording.service'; 7 | import { useState } from 'react'; 8 | import styled from 'styled-components'; 9 | 10 | type UploadResultProps = { 11 | mediaId: string; 12 | onClickDelete: () => void; 13 | }; 14 | 15 | export function UploadResult(props: UploadResultProps) { 16 | const [isDeleting, setIsDeleting] = useState(false); 17 | 18 | const url = getDownloadAudioUrl(props.mediaId); 19 | 20 | async function handleClickDownload() { 21 | try { 22 | const { url } = await getRecordingDownloadUrl(props.mediaId); 23 | 24 | const anchor = document.createElement('a'); 25 | anchor.href = url; 26 | anchor.style.display = 'none'; 27 | anchor.setAttribute('download', `${props.mediaId}.mp3`); 28 | anchor.setAttribute('target', '_blank'); 29 | 30 | document.body.appendChild(anchor); 31 | anchor.click(); 32 | document.body.removeChild(anchor); 33 | } catch (error) { 34 | console.error('Error downloading file', error); 35 | } 36 | } 37 | 38 | async function handleClickDelete() { 39 | try { 40 | setIsDeleting(true); 41 | 42 | await props.onClickDelete(); 43 | } finally { 44 | setIsDeleting(false); 45 | } 46 | } 47 | 48 | return ( 49 | 50 |
51 | Share Recording: 52 | 53 | 54 | 55 | 56 | 57 | 58 |
59 | 66 | {/* */} 74 |
75 |
76 |
77 |
78 | ); 79 | } 80 | 81 | const Wrapper = styled(Card)` 82 | display: flex; 83 | margin-top: 20px; 84 | flex-direction: column; 85 | padding: 1rem 1rem; 86 | justify-content: center; 87 | `; 88 | 89 | const Title = styled.p` 90 | font-weight: bold; 91 | padding-bottom: 14px; 92 | padding-top: 4px; 93 | line-height: 1; 94 | `; 95 | 96 | const ButtonsWrapper = styled.div` 97 | margin-top: 1.5rem; 98 | display: flex; 99 | justify-content: space-between; 100 | `; 101 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voice-recorder-frontend", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "next dev -p ${PORT:-4200}", 7 | "build": "next build", 8 | "start": "next start -p ${PORT:-4200}", 9 | "lint": "next lint", 10 | "test": "jest --watch", 11 | "test:ci": "jest --ci" 12 | }, 13 | "engines": { 14 | "node": ">=20" 15 | }, 16 | "private": true, 17 | "dependencies": { 18 | "@fortawesome/fontawesome-svg-core": "^6.2.0", 19 | "@fortawesome/free-brands-svg-icons": "^6.2.1", 20 | "@fortawesome/free-solid-svg-icons": "^6.2.0", 21 | "@fortawesome/react-fontawesome": "^0.2.0", 22 | "@nestjs/common": "9.4.2", 23 | "@nestjs/config": "^2.2.0", 24 | "@nestjs/core": "9.4.2", 25 | "@nestjs/platform-express": "9.4.2", 26 | "@nestjs/schedule": "^2.1.0", 27 | "@nestjs/swagger": "^8.1.1", 28 | "@nestjs/throttler": "^4.0.0", 29 | "@next/font": "^13.4.4", 30 | "@nrwl/next": "16.2.2", 31 | "@nrwl/webpack": "^16.2.2", 32 | "aws-sdk": "^2.1282.0", 33 | "base-x": "^4.0.0", 34 | "browser-monads-ts": "^2.0.1", 35 | "cache-manager": "^4.1.0", 36 | "clsx": "^1.2.1", 37 | "core-js": "^3.6.5", 38 | "cors": "^2.8.5", 39 | "helmet": "^6.0.1", 40 | "joi": "^17.7.0", 41 | "jotai": "^1.11.1", 42 | "motion": "^12.4.7", 43 | "next": "13.3.0", 44 | "react": "^18.2.0", 45 | "react-dom": "^18.2.0", 46 | "reflect-metadata": "^0.1.13", 47 | "regenerator-runtime": "0.13.7", 48 | "rxjs": "^7.0.0", 49 | "styled-components": "5.3.6", 50 | "tslib": "^2.3.0" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "7.13.0", 54 | "@babel/preset-react": "^7.14.5", 55 | "@babel/preset-typescript": "7.12.13", 56 | "@nestjs/schematics": "9.2.0", 57 | "@nestjs/testing": "9.4.2", 58 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", 59 | "@svgr/webpack": "^6.1.2", 60 | "@testing-library/react": "13.4.0", 61 | "@types/cron": "^2.0.0", 62 | "@types/jest": "29.4.4", 63 | "@types/multer": "^1.4.7", 64 | "@types/node": "18.11.9", 65 | "@types/react": "18.0.25", 66 | "@types/react-dom": "18.0.9", 67 | "@types/styled-components": "5.1.26", 68 | "@typescript-eslint/eslint-plugin": "5.59.7", 69 | "@typescript-eslint/parser": "5.59.7", 70 | "babel-jest": "29.4.3", 71 | "babel-loader": "8.1.0", 72 | "css-loader": "^6.4.0", 73 | "cypress": "^11.0.0", 74 | "eslint": "~8.15.0", 75 | "eslint-config-next": "13.1.1", 76 | "eslint-config-prettier": "8.1.0", 77 | "eslint-plugin-cypress": "^2.10.3", 78 | "eslint-plugin-import": "2.26.0", 79 | "eslint-plugin-jsx-a11y": "6.6.1", 80 | "eslint-plugin-react": "7.31.11", 81 | "eslint-plugin-react-hooks": "4.6.0", 82 | "jest": "29.4.3", 83 | "nx": "20.4.6", 84 | "prettier": "^2.6.2", 85 | "react-refresh": "^0.10.0", 86 | "react-test-renderer": "18.2.0", 87 | "style-loader": "^3.3.0", 88 | "stylus": "^0.55.0", 89 | "stylus-loader": "^7.1.0", 90 | "ts-jest": "29.1.0", 91 | "ts-node": "10.9.1", 92 | "typescript": "5.0.4", 93 | "url-loader": "^4.1.1", 94 | "webpack": "^5.75.0", 95 | "webpack-merge": "^5.8.0" 96 | } 97 | } -------------------------------------------------------------------------------- /apps/frontend/features/HomePage/contexts/home.context.tsx: -------------------------------------------------------------------------------- 1 | import { Optional } from '@lib/types/optional.type'; 2 | import React, { useReducer } from 'react'; 3 | 4 | export enum HomeScreen { 5 | INITIAL, 6 | RECORDING, 7 | PAUSED, 8 | PREVIEWING, 9 | ENCODING, 10 | } 11 | 12 | type HomeState = { 13 | screen: HomeScreen; 14 | audioBlobUrl: Optional; 15 | }; 16 | 17 | enum HomeAction { 18 | CHANGE_SCREEN, 19 | RECORD_RESULT, 20 | START_NEW_RECORDING, 21 | } 22 | 23 | type HomeEvent = 24 | | { 25 | type: HomeAction.START_NEW_RECORDING; 26 | } 27 | | { 28 | type: HomeAction.CHANGE_SCREEN; 29 | newScreen: HomeScreen; 30 | } 31 | | { 32 | type: HomeAction.RECORD_RESULT; 33 | audioBlobUrl: Optional; 34 | }; 35 | 36 | type HomeContextValue = { 37 | homeState: HomeState; 38 | dispatchHomeEvent: React.Dispatch; 39 | }; 40 | 41 | function homeContextReducer(state: HomeState, action: HomeEvent): HomeState { 42 | switch (action.type) { 43 | case HomeAction.CHANGE_SCREEN: 44 | return { 45 | ...state, 46 | screen: action.newScreen, 47 | }; 48 | 49 | case HomeAction.START_NEW_RECORDING: 50 | return { 51 | ...state, 52 | screen: HomeScreen.INITIAL, 53 | audioBlobUrl: undefined, 54 | }; 55 | 56 | case HomeAction.RECORD_RESULT: 57 | return { 58 | ...state, 59 | audioBlobUrl: action.audioBlobUrl, 60 | }; 61 | default: 62 | return state; 63 | } 64 | } 65 | 66 | const HomeContext = React.createContext( 67 | undefined 68 | ); 69 | 70 | export function HomeContextProvider({ 71 | children, 72 | }: { 73 | children: React.ReactNode; 74 | }) { 75 | const [homeState, dispatchHomeEvent] = useReducer(homeContextReducer, { 76 | screen: HomeScreen.INITIAL, 77 | audioBlobUrl: undefined, 78 | }); 79 | 80 | const value: HomeContextValue = { 81 | homeState, 82 | dispatchHomeEvent, 83 | }; 84 | 85 | return {children}; 86 | } 87 | 88 | export function useHomeState() { 89 | const context = React.useContext(HomeContext); 90 | 91 | if (context === undefined) { 92 | throw new Error('useHomeState must be used within a HomeContextProvider'); 93 | } 94 | 95 | const actions = { 96 | startRecording: () => { 97 | context.dispatchHomeEvent({ 98 | type: HomeAction.CHANGE_SCREEN, 99 | newScreen: HomeScreen.RECORDING, 100 | }); 101 | }, 102 | stopRecording: () => { 103 | context.dispatchHomeEvent({ 104 | type: HomeAction.CHANGE_SCREEN, 105 | newScreen: HomeScreen.PREVIEWING, 106 | }); 107 | }, 108 | recordResult: (data: Pick) => { 109 | context.dispatchHomeEvent({ 110 | type: HomeAction.RECORD_RESULT, 111 | audioBlobUrl: data.audioBlobUrl, 112 | }); 113 | }, 114 | startNewRecording: () => { 115 | context.dispatchHomeEvent({ 116 | type: HomeAction.START_NEW_RECORDING, 117 | }); 118 | }, 119 | }; 120 | 121 | return { homeState: context.homeState, dispatch: actions }; 122 | } 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voice-recorder", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "nx": "nx", 7 | "start": "nx serve", 8 | "start:all": "nx run-many --parallel --target=serve --projects=frontend,api", 9 | "start:frontend": "nx serve frontend", 10 | "start:api": "nx serve api", 11 | "build": "nx run-many --parallel --target build --projects=api", 12 | "build:web": "npm run build frontend", 13 | "build:api": "nx build api", 14 | "test": "nx test" 15 | }, 16 | "engines": { 17 | "node": ">=18" 18 | }, 19 | "private": true, 20 | "dependencies": { 21 | "@fortawesome/fontawesome-svg-core": "^6.2.0", 22 | "@fortawesome/free-brands-svg-icons": "^6.2.1", 23 | "@fortawesome/free-solid-svg-icons": "^6.2.0", 24 | "@fortawesome/react-fontawesome": "^0.2.0", 25 | "@nestjs/common": "9.4.2", 26 | "@nestjs/config": "^2.2.0", 27 | "@nestjs/core": "9.4.2", 28 | "@nestjs/platform-express": "9.4.2", 29 | "@nestjs/schedule": "^2.1.0", 30 | "@nestjs/swagger": "^8.1.1", 31 | "@nestjs/throttler": "^4.0.0", 32 | "@next/font": "^13.4.4", 33 | "@nrwl/next": "16.2.2", 34 | "@nrwl/webpack": "^16.2.2", 35 | "aws-sdk": "^2.1282.0", 36 | "base-x": "^4.0.0", 37 | "browser-monads-ts": "^2.0.1", 38 | "cache-manager": "^4.1.0", 39 | "clsx": "^1.2.1", 40 | "core-js": "^3.6.5", 41 | "cors": "^2.8.5", 42 | "helmet": "^6.0.1", 43 | "joi": "^17.7.0", 44 | "jotai": "^1.11.1", 45 | "next": "13.3.0", 46 | "react": "^18.2.0", 47 | "react-dom": "^18.2.0", 48 | "reflect-metadata": "^0.1.13", 49 | "regenerator-runtime": "0.13.7", 50 | "rxjs": "^7.0.0", 51 | "styled-components": "5.3.6", 52 | "tslib": "^2.3.0" 53 | }, 54 | "devDependencies": { 55 | "@babel/core": "7.13.0", 56 | "@babel/preset-react": "^7.14.5", 57 | "@babel/preset-typescript": "7.12.13", 58 | "@nestjs/schematics": "9.2.0", 59 | "@nestjs/testing": "9.4.2", 60 | "@nrwl/cli": "15.3.0", 61 | "@nrwl/cypress": "16.2.2", 62 | "@nrwl/eslint-plugin-nx": "16.2.2", 63 | "@nrwl/jest": "16.2.2", 64 | "@nrwl/linter": "16.2.2", 65 | "@nrwl/nest": "16.2.2", 66 | "@nrwl/node": "16.2.2", 67 | "@nrwl/react": "16.2.2", 68 | "@nrwl/web": "16.2.2", 69 | "@nrwl/workspace": "16.2.2", 70 | "@nx/webpack": "16.2.2", 71 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", 72 | "@svgr/webpack": "^6.1.2", 73 | "@testing-library/react": "13.4.0", 74 | "@types/cron": "^2.0.0", 75 | "@types/jest": "29.4.4", 76 | "@types/multer": "^1.4.7", 77 | "@types/node": "18.11.9", 78 | "@types/react": "18.0.25", 79 | "@types/react-dom": "18.0.9", 80 | "@types/styled-components": "5.1.26", 81 | "@typescript-eslint/eslint-plugin": "5.59.7", 82 | "@typescript-eslint/parser": "5.59.7", 83 | "babel-jest": "29.4.3", 84 | "babel-loader": "8.1.0", 85 | "css-loader": "^6.4.0", 86 | "cypress": "^11.0.0", 87 | "eslint": "~8.15.0", 88 | "eslint-config-next": "13.1.1", 89 | "eslint-config-prettier": "8.1.0", 90 | "eslint-plugin-cypress": "^2.10.3", 91 | "eslint-plugin-import": "2.26.0", 92 | "eslint-plugin-jsx-a11y": "6.6.1", 93 | "eslint-plugin-react": "7.31.11", 94 | "eslint-plugin-react-hooks": "4.6.0", 95 | "jest": "29.4.3", 96 | "nx": "16.10.0", 97 | "prettier": "^2.6.2", 98 | "react-refresh": "^0.10.0", 99 | "react-test-renderer": "18.2.0", 100 | "style-loader": "^3.3.0", 101 | "stylus": "^0.55.0", 102 | "stylus-loader": "^7.1.0", 103 | "ts-jest": "29.1.0", 104 | "ts-node": "10.9.1", 105 | "typescript": "5.0.4", 106 | "url-loader": "^4.1.1", 107 | "webpack": "^5.75.0", 108 | "webpack-merge": "^5.8.0" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /apps/api/src/middlewares/helpers/redoc-html-template.spec.ts: -------------------------------------------------------------------------------- 1 | import { redocHtml } from './redoc-html-template'; 2 | 3 | test('should return redocHtml Template', async () => { 4 | const redocHtmlTemplate = ` 5 | 6 | 7 | ReDoc 8 | 9 | 10 | 11 | 17 | 18 | 19 |
20 | 21 | 22 | 29 | `; 30 | 31 | expect( 32 | redocHtml({ 33 | title: 'ReDoc', 34 | specUrl: 'http://petstore.swagger.io/v2/swagger.json', 35 | }) 36 | ).toBe(redocHtmlTemplate); 37 | }); 38 | 39 | test('should return redocHtml Template [nonce]', async () => { 40 | const redocHtmlTemplate = ` 41 | 42 | 43 | ReDoc 44 | 45 | 46 | 47 | 53 | 54 | 55 |
56 | 57 | 58 | 65 | `; 66 | 67 | expect( 68 | redocHtml({ 69 | title: 'ReDoc', 70 | specUrl: 'http://petstore.swagger.io/v2/swagger.json', 71 | nonce: 'rAnd0m', 72 | }) 73 | ).toBe(redocHtmlTemplate); 74 | }); 75 | 76 | test('should return redocHtml Template [redoc options]', async () => { 77 | const redocHtmlTemplate = ` 78 | 79 | 80 | ReDoc 81 | 82 | 83 | 84 | 90 | 91 | 92 |
93 | 94 | 95 | 102 | `; 103 | 104 | expect( 105 | redocHtml({ 106 | title: 'ReDoc', 107 | specUrl: 'http://petstore.swagger.io/v2/swagger.json', 108 | nonce: 'rAnd0m', 109 | redocOptions: { 110 | theme: { 111 | colors: { 112 | primary: { 113 | main: '#6EC5AB', 114 | }, 115 | }, 116 | typography: { 117 | fontFamily: `"museo-sans", 'Helvetica Neue', Helvetica, Arial, sans-serif`, 118 | }, 119 | }, 120 | }, 121 | }) 122 | ).toBe(redocHtmlTemplate); 123 | }); 124 | -------------------------------------------------------------------------------- /apps/frontend/features/HomePage/Recorder.component.tsx: -------------------------------------------------------------------------------- 1 | import { Chronometer } from '@components/atoms'; 2 | import { RecorderControls, RecordingPlayer } from '@components/organisms'; 3 | import { useRecording, useTimer } from '@lib/hooks'; 4 | import { Recording } from '@lib/recording'; 5 | import { useSetAtom } from 'jotai'; 6 | import { useEffect } from 'react'; 7 | import styled from 'styled-components'; 8 | import { HomeScreen, useHomeState } from './contexts/home.context'; 9 | import { errorStoreAtom } from '@features/HomePage/Error.component'; 10 | 11 | type RecorderProps = { 12 | onNewRecording: (recording: Recording) => void; 13 | onStartRecording: (toHome?: boolean) => void; 14 | onErrorRecording: (error: Error) => void; 15 | }; 16 | 17 | export function RecorderComponent(props: RecorderProps) { 18 | const { homeState, dispatch } = useHomeState(); 19 | const recorder = useRecording(); 20 | const timer = useTimer(); 21 | 22 | const setError = useSetAtom(errorStoreAtom); 23 | 24 | function initRecording() { 25 | recorder.clearRecording(); 26 | recorder.initRecording(); 27 | timer.reset(); 28 | 29 | recorder.recording.onStart = () => { 30 | timer.resetAndStart(); 31 | }; 32 | 33 | recorder.recording.onStop = (sucess) => { 34 | if (sucess) { 35 | dispatch.recordResult({ 36 | audioBlobUrl: recorder.recording.getAudioBlobUrl(), 37 | }); 38 | } else { 39 | dispatch.startNewRecording(); 40 | } 41 | 42 | props.onNewRecording?.(recorder.recording); 43 | }; 44 | 45 | recorder.recording.onPause = () => { 46 | timer.stop(); 47 | }; 48 | 49 | recorder.recording.onResume = () => { 50 | timer.start(); 51 | }; 52 | 53 | recorder.recording.onError = (error) => { 54 | setError({ 55 | message: error.name, 56 | details: error.message, 57 | }); 58 | dispatch.startNewRecording(); 59 | }; 60 | } 61 | 62 | useEffect(() => { 63 | timer.reset(); 64 | dispatch.startNewRecording(); 65 | 66 | return () => { 67 | try { 68 | recorder.recording.abort(); 69 | timer.reset(); 70 | recorder.clearRecording(); 71 | } catch {} 72 | }; 73 | }, []); 74 | 75 | async function startRecording() { 76 | try { 77 | // Init Recorder 78 | initRecording(); 79 | 80 | // Start recording 81 | await recorder.recording.start(); 82 | 83 | // Change UI state: 84 | dispatch.startRecording(); 85 | 86 | props.onStartRecording(); 87 | } catch (error) { 88 | console.error('Error while starting recording', error); 89 | console.error(error); 90 | 91 | props.onErrorRecording(error as Error); 92 | } 93 | } 94 | 95 | async function handleStop() { 96 | dispatch.stopRecording(); 97 | recorder.recording.stop(); 98 | timer.stop(); 99 | } 100 | 101 | async function handleClickNewRecording() { 102 | timer.reset(); 103 | dispatch.startNewRecording(); 104 | props.onStartRecording(true); 105 | } 106 | 107 | function handleClickCancel() { 108 | recorder.recording.abort(); 109 | timer.reset(); 110 | recorder.clearRecording(); 111 | dispatch.startNewRecording(); 112 | } 113 | 114 | return ( 115 | 116 | {homeState.screen === HomeScreen.PREVIEWING ? ( 117 | 121 | ) : ( 122 | <> 123 | 129 | 130 | startRecording()} 133 | onPauseClick={() => recorder.recording.pause()} 134 | onPlayClick={() => recorder.recording.resume()} 135 | onStopClick={() => handleStop()} 136 | onCancelClick={handleClickCancel} 137 | /> 138 | 139 | 140 | )} 141 | 142 | ); 143 | } 144 | 145 | const Wrapper = styled.div` 146 | display: flex; 147 | flex-direction: column; 148 | align-items: center; 149 | `; 150 | 151 | const RecorderControlsWrapper = styled.div` 152 | margin-top: 5vh; 153 | width: 100%; 154 | `; 155 | -------------------------------------------------------------------------------- /apps/frontend/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import Document, { 3 | Html, 4 | Head, 5 | Main, 6 | NextScript, 7 | DocumentContext, 8 | DocumentInitialProps, 9 | } from 'next/document'; 10 | import { ServerStyleSheet } from 'styled-components'; 11 | 12 | export default class CustomDocument extends Document { 13 | static async getInitialProps( 14 | ctx: DocumentContext 15 | ): Promise { 16 | const originalRenderPage = ctx.renderPage; 17 | 18 | const sheet = new ServerStyleSheet(); 19 | 20 | ctx.renderPage = () => 21 | originalRenderPage({ 22 | enhanceApp: App => props => sheet.collectStyles(), 23 | enhanceComponent: Component => Component, 24 | }); 25 | 26 | const intialProps = await Document.getInitialProps(ctx); 27 | const styles = sheet.getStyleElement(); 28 | 29 | return { ...intialProps, styles }; 30 | } 31 | 32 | render() { 33 | return ( 34 | 35 | 36 | {this.props.styles} 37 | 42 | 47 | 52 | 57 | 62 | 67 | 72 | 77 | 82 | 88 | 94 | 100 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | {/* Sharing metatags */} 114 | 115 | 116 | 120 | 124 | 128 | 129 | {/* WhatsApp meta tags */} 130 | 134 | 135 | 136 |
137 | 138 | 139 | 140 | ); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /apps/frontend/components/organisms/RecorderControls/RecorderControls.component.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card } from '@components/atoms'; 2 | import { PlayPauseButton } from '@components/molecules'; 3 | import { BACKGROUND_CIRCLE_SIZE } from '@components/organisms/RecorderControls/recorder-controlls.constants'; 4 | import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons'; 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 6 | import { RecorderStatus } from '@lib/recording'; 7 | import { useState } from 'react'; 8 | import styled from 'styled-components'; 9 | 10 | type RecorderControlsProps = { 11 | isRecording: boolean; 12 | onStartClick?: () => void; 13 | onPauseClick?: () => void; 14 | onPlayClick?: () => void; 15 | onStopClick?: () => void; 16 | onCancelClick?: () => void; 17 | }; 18 | 19 | export function RecorderControls(props: RecorderControlsProps) { 20 | const [isPaused, setIsPaused] = useState(false); 21 | 22 | function handlePlayPause() { 23 | const pause = !isPaused ? true : false; 24 | pause ? props.onPauseClick?.() : props.onPlayClick?.(); 25 | 26 | setIsPaused(!isPaused); 27 | } 28 | 29 | function getStatus() { 30 | if (props.isRecording) { 31 | return isPaused ? RecorderStatus.PAUSED : RecorderStatus.RECORDING; 32 | } 33 | return RecorderStatus.STOPPED; 34 | } 35 | 36 | function recorderStatusMessage(): string { 37 | switch (getStatus()) { 38 | case RecorderStatus.RECORDING: 39 | return 'Recording...'; 40 | case RecorderStatus.PAUSED: 41 | return 'Paused'; 42 | case RecorderStatus.STOPPED: 43 | return 'Ready to record'; 44 | } 45 | } 46 | 47 | function handleCancelClick() { 48 | setIsPaused(false); 49 | props.onCancelClick?.(); 50 | } 51 | 52 | return ( 53 | <> 54 |

62 | {recorderStatusMessage()} 63 |

64 |
71 | 72 | 73 | 81 | 82 | 83 | 90 | 99 | 100 | 101 | 102 | 103 | 104 |
105 | 106 | ); 107 | } 108 | 109 | const ButtonsWrapper = styled(Card)` 110 | margin-top: 35px; 111 | margin-bottom: 35px; 112 | padding: 4px 0; 113 | background-color: #fefefe; 114 | padding: 0.5rem !important; 115 | position: relative; 116 | width: ${props => (props.isRecording ? '260px' : '90px')}; 117 | border-radius: 20px; 118 | filter: drop-shadow(0px 8px 12px rgb(0 0 0 / 8%)); 119 | transition-property: width; 120 | transition-duration: 0.3s; 121 | `; 122 | 123 | const BandButton = styled(Button).attrs(props => ({ 124 | color: '#fefefe', 125 | }))` 126 | filter: drop-shadow(0px 2px 8px rgb(0 0 0 / 10%)); 127 | border-radius: 9px; 128 | position: absolute; 129 | border: 1px solid #f4f4f4; 130 | z-index: -1; 131 | opacity: 0; 132 | width: 45px; 133 | &.show { 134 | opacity: 1; 135 | } 136 | 137 | &:hover { 138 | background: ${props => (props.ok ? '#d0ece6' : '#f9e4e1')}; 139 | } 140 | `; 141 | 142 | const ButtonsContainer = styled('div')` 143 | display: flex; 144 | align-items: center; 145 | width: 100%; 146 | z-index: 2; 147 | padding: 0 20px; 148 | justify-content: center; 149 | `; 150 | 151 | const BackgroundCircle = styled('div')` 152 | position: absolute; 153 | left: calc(50% - ${props => props.size / 2}px); 154 | background: #fefefe; 155 | top: calc(50% - ${props => props.size / 2}px); 156 | border-radius: 50%; 157 | overflow: hidden; 158 | display: flex; 159 | justify-content: center; 160 | align-items: center; 161 | width: ${props => props.size}px; 162 | height: ${props => props.size}px; 163 | `; 164 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/recorder.ts: -------------------------------------------------------------------------------- 1 | import { isEdge } from '@lib/helpers/browser/browser.helpers'; 2 | import { Utils } from '@lib/helpers/utils.helper'; 3 | import { AudioEncoder } from './audio-encoding/audio-encoder'; 4 | import { AudioEncoderConfig } from './audio-encoding/audio-encoder-config.interface'; 5 | import { RecorderStatus } from './enums/recorder-status.enum'; 6 | 7 | export class Recorder { 8 | private state: RecorderStatus; 9 | private encoder: AudioEncoder; 10 | 11 | private audioContext: AudioContext; 12 | private scriptProcessorNode: AudioWorkletNode; 13 | private sourceNode: MediaStreamAudioSourceNode; 14 | private stream: MediaStream; 15 | private config: Partial; 16 | 17 | private aborted = false; 18 | 19 | public onStart: () => void; 20 | public onDataAvailable: (data: Int8Array) => void; 21 | public onStop: () => void; 22 | public onError: (error: Error) => void; 23 | 24 | constructor(config: Partial) { 25 | this.state = RecorderStatus.STOPPED; 26 | this.config = Utils.mergeObjects( 27 | { 28 | recordingGain: 1, 29 | numberOfChannels: 1, 30 | bufferSize: 4096, 31 | constraints: {}, 32 | useAudioWorklet: false, 33 | }, 34 | config 35 | ); 36 | } 37 | 38 | async start() { 39 | if (this.state === RecorderStatus.STOPPED) { 40 | this.state = RecorderStatus.STARTING; 41 | 42 | try { 43 | this.stream = await navigator.mediaDevices.getUserMedia({ 44 | audio: isEdge() 45 | ? true 46 | : { 47 | echoCancellation: false, 48 | }, 49 | }); 50 | 51 | this.state = RecorderStatus.RECORDING; 52 | 53 | await this.createAudioContext(); 54 | 55 | await this.createAndStartEncoder(); 56 | 57 | !this.aborted && this.onStart?.(); 58 | } catch (error) { 59 | this.state = RecorderStatus.STOPPED; 60 | 61 | console.log('Error while starting recorder', error); 62 | 63 | throw error; 64 | } 65 | } 66 | } 67 | 68 | private async createAudioContext() { 69 | const AudioContextRef = 70 | window.AudioContext || (window as WebkitWindow).webkitAudioContext; 71 | 72 | this.audioContext = new AudioContextRef(); 73 | 74 | await this.audioContext.audioWorklet.addModule('/processor.js'); 75 | 76 | this.scriptProcessorNode = new AudioWorkletNode( 77 | this.audioContext, 78 | 'script-processor-replacement' 79 | ); 80 | 81 | this.scriptProcessorNode.port.onmessage = (event) => { 82 | if (this.state === RecorderStatus.RECORDING) { 83 | this.encoder.sendData(event.data); 84 | } 85 | }; 86 | 87 | this.sourceNode = this.audioContext.createMediaStreamSource(this.stream); 88 | this.sourceNode.connect(this.scriptProcessorNode); 89 | this.scriptProcessorNode.connect(this.audioContext.destination); 90 | } 91 | 92 | private async createAndStartEncoder() { 93 | this.encoder = new AudioEncoder( 94 | Object.assign({}, this.config, { 95 | originalSampleRate: this.audioContext.sampleRate, 96 | }) 97 | ); 98 | 99 | await this.encoder.waitForWorker(); 100 | 101 | this.encoder.onDataAvailable = (data) => { 102 | !this.aborted && this.onDataAvailable?.(data); 103 | }; 104 | 105 | this.encoder.onStopped = () => { 106 | !this.aborted && this.onStop?.(); 107 | }; 108 | 109 | await this.encoder.start(); 110 | } 111 | 112 | private async destroyAudioContext() { 113 | if (this.audioContext.audioWorklet) { 114 | this.scriptProcessorNode.port.onmessage = null; 115 | this.scriptProcessorNode.disconnect(); 116 | } 117 | 118 | this.sourceNode.disconnect(); 119 | this.audioContext.close(); 120 | delete this.audioContext; 121 | } 122 | 123 | stop() { 124 | if ( 125 | this.state === RecorderStatus.RECORDING || 126 | this.state === RecorderStatus.PAUSED 127 | ) { 128 | this.state = RecorderStatus.STOPPED; 129 | this.encoder.stop(); 130 | this.destroyAudioContext(); 131 | this.destroyStream(); 132 | } else if (this.state === RecorderStatus.STARTING) { 133 | /** TODO: Add cancelStartCallback here */ 134 | this.state = RecorderStatus.STOPPED; 135 | } 136 | } 137 | 138 | abort() { 139 | this.aborted = true; 140 | this.stop(); 141 | } 142 | 143 | destroyStream() { 144 | this.stopStream(); 145 | delete this.stream; 146 | } 147 | 148 | stopStream() { 149 | this.stream.getTracks().forEach((track) => { 150 | track.stop(); 151 | }); 152 | } 153 | 154 | pause() { 155 | if (this.state === RecorderStatus.RECORDING) { 156 | this.state = RecorderStatus.PAUSED; 157 | } 158 | } 159 | 160 | resume() { 161 | if (this.state === RecorderStatus.PAUSED) { 162 | this.state = RecorderStatus.RECORDING; 163 | } 164 | } 165 | 166 | static isRecordingSupported(): boolean { 167 | return Boolean(navigator?.mediaDevices?.getUserMedia); 168 | } 169 | 170 | preload() { 171 | throw new Error('Method not implemented.'); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/uploader.ts: -------------------------------------------------------------------------------- 1 | import { Utils } from '@lib/helpers/utils.helper'; 2 | import { MaxChunkCountError } from './errors/max-chunk-count.error'; 3 | import { NoChunksFoundError } from './errors/no-chunk-found.error'; 4 | import { SubmitError } from './errors/submit.error'; 5 | import { createUploadId } from './helpers/create-upload-id.helper'; 6 | import { retryableFetch } from './http/retryable-fetch.helper'; 7 | import { MediaInfo } from './interfaces/media-info.interface'; 8 | import { OnUploaderProgressPayload } from './interfaces/on-uploader-progress-payload.interface'; 9 | import { UploaderOptions } from './interfaces/uploader-options.interface'; 10 | import { UploadQueue } from './upload-queue'; 11 | import { MediaInfoDto } from '@lib/dto/media-info.dto'; 12 | 13 | export class Uploader { 14 | private chunks: Blob[] = []; 15 | 16 | private uploadId = createUploadId(); 17 | private uploadQueue: UploadQueue; 18 | private uploadURLWithId: string; 19 | 20 | private aborted = false; 21 | private uploadsFinished = false; 22 | private shouldFinalize = false; 23 | 24 | private options: UploaderOptions; 25 | private defaultOptions: UploaderOptions = { 26 | chunkSize: 100000, 27 | maxChunkCount: 5000, 28 | maxConcurrentUploads: 3, 29 | uploadUrl: `${process.env.NEXT_PUBLIC_API_URL}/upload`, 30 | }; 31 | 32 | /** Upload Listeners */ 33 | public onSuccess: (data: Omit) => void; 34 | public onProgress: (data: OnUploaderProgressPayload) => void; 35 | public onError: (error: Error) => void; 36 | public onFinalizeError: (error: Error) => void; 37 | 38 | constructor(options?: UploaderOptions) { 39 | this.options = Utils.mergeObjects(this.defaultOptions, options); 40 | 41 | this.uploadQueue = new UploadQueue( 42 | this.onUploadsFinished.bind(this), 43 | this.options.maxConcurrentUploads 44 | ); 45 | 46 | this.uploadQueue.onProgress = this.onQueueProgress.bind(this); 47 | 48 | this.alive(); 49 | } 50 | 51 | alive() { 52 | const aliveRequest = retryableFetch(this.options.uploadUrl + '/alive', { 53 | method: 'HEAD', 54 | }); 55 | 56 | aliveRequest.then(() => { 57 | try { 58 | this.uploadURLWithId = `${this.options.uploadUrl}/${this.uploadId}`; 59 | this.uploadQueue.start(this.uploadURLWithId); 60 | } catch (error) { 61 | console.error('Error starting upload', { error }); 62 | } 63 | }); 64 | } 65 | 66 | abort() { 67 | this.uploadQueue.abort(); 68 | this.aborted = true; 69 | } 70 | 71 | complete() { 72 | if (this.chunks.length >= 1 && this.chunks[0].size !== 0) { 73 | this.addLatestChunk(); 74 | this.uploadQueue.complete(); 75 | } 76 | } 77 | 78 | finalize() { 79 | if (this.uploadsFinished) { 80 | this.remoteFinalize(); 81 | } else { 82 | this.shouldFinalize = true; 83 | } 84 | } 85 | 86 | private addLatestChunk() { 87 | if (this.chunks.length > this.options.maxChunkCount) { 88 | if (this.chunks.length === this.options.maxChunkCount + 1) { 89 | this.onError?.( 90 | new MaxChunkCountError( 91 | 'Max upload chunk count exceeded(file too large)' 92 | ) 93 | ); 94 | } 95 | } else { 96 | this.uploadQueue.add(this.chunks[this.chunks.length - 1]); 97 | } 98 | } 99 | 100 | addData(data) { 101 | const newBlob = new Blob([data]); 102 | if (newBlob.size !== 0) { 103 | if (this.chunks.length === 0) { 104 | this.chunks.push(new Blob()); 105 | } 106 | 107 | for (let totalUploadedSize = 0; totalUploadedSize < newBlob.size; ) { 108 | const lastChunk = this.chunks[this.chunks.length - 1]; 109 | const lastChunkSize = lastChunk.size; 110 | const missingChunkSize = this.options.chunkSize - lastChunkSize; 111 | 112 | if (newBlob.size - totalUploadedSize <= missingChunkSize) { 113 | this.chunks[this.chunks.length - 1] = new Blob([ 114 | lastChunk, 115 | newBlob.slice(totalUploadedSize), 116 | ]); 117 | break; 118 | } 119 | 120 | const missingChunks = newBlob.slice( 121 | totalUploadedSize, 122 | totalUploadedSize + missingChunkSize 123 | ); 124 | totalUploadedSize += missingChunkSize; 125 | 126 | this.chunks[this.chunks.length - 1] = new Blob([ 127 | lastChunk, 128 | missingChunks, 129 | ]); 130 | 131 | this.addLatestChunk(); 132 | this.chunks.push(new Blob()); 133 | } 134 | } 135 | } 136 | 137 | private async onUploadsFinished() { 138 | this.uploadsFinished = true; 139 | if (this.shouldFinalize) { 140 | await this.remoteFinalize(); 141 | } 142 | } 143 | 144 | async remoteFinalize() { 145 | try { 146 | const result = await retryableFetch( 147 | `${this.uploadURLWithId}/finalize`, 148 | { 149 | method: 'POST', 150 | parseJson: true, 151 | } 152 | ); 153 | 154 | if (result.status !== 0) { 155 | if (result.status === 1) { 156 | throw new NoChunksFoundError('No chunks found on the server'); 157 | } else if (result.status === 2) { 158 | throw new SubmitError('Submission to processing queue failed.'); 159 | } else { 160 | throw new Error('Finalize failed for unspecified reason.'); 161 | } 162 | } 163 | 164 | if (!result.mediaId) { 165 | throw new Error('no mediaId'); 166 | } 167 | if (!result.ownerToken) { 168 | throw new Error('no owner token'); 169 | } 170 | 171 | if (!this.aborted) { 172 | this.onSuccess?.({ 173 | mediaId: result.mediaId, 174 | ownerToken: result.ownerToken, 175 | }); 176 | } 177 | } catch (error) { 178 | console.error('Error finalizing upload', { error }); 179 | if (!this.aborted) { 180 | this.onFinalizeError?.(error as Error); 181 | } 182 | } 183 | } 184 | 185 | private onQueueProgress(bytesUploaded: number) { 186 | if (!this.aborted) { 187 | this.onProgress?.({ 188 | bytesUploaded, 189 | }); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /apps/api/src/uploader/uploader.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CACHE_MANAGER, 3 | ConflictException, 4 | Inject, 5 | Injectable, 6 | NotFoundException, 7 | } from '@nestjs/common'; 8 | import { ConfigService } from '@nestjs/config'; 9 | import { 10 | DownloadUrlReponseDto, 11 | MediaInfoDto, 12 | } from '@voice-recorder/shared-types'; 13 | import { Cache } from 'cache-manager'; 14 | import * as fs from 'fs'; 15 | import { GlobalConf } from '../config/global-config.interface'; 16 | import { waitForStreamClose } from '../helpers/wait-for-stram-close.helper'; 17 | import { S3Service } from '../s3/decorators/s3-service.decorator'; 18 | import { S3 } from '../s3/s3.types'; 19 | 20 | @Injectable() 21 | export class UploaderService { 22 | private data: any = {}; 23 | constructor( 24 | @S3Service() private readonly s3: S3, 25 | @Inject(CACHE_MANAGER) private cacheManager: Cache, 26 | private readonly configService: ConfigService 27 | ) {} 28 | 29 | async uploadChunk( 30 | uploadId: string, 31 | chunkNumber: number, 32 | chunks: Express.Multer.File 33 | ): Promise { 34 | const multipartUploadId = await this.getMultipartUploadId(uploadId); 35 | 36 | const partUploadResult = await this.s3 37 | .uploadPart({ 38 | Bucket: this.configService.get('recordings').bucket, 39 | Key: `${uploadId}.mp3`, 40 | PartNumber: chunkNumber + 1, 41 | UploadId: multipartUploadId, 42 | Body: chunks.buffer, 43 | }) 44 | .promise(); 45 | 46 | this.data[uploadId] = (this.data[uploadId] || []).concat({ 47 | ETag: partUploadResult.ETag, 48 | PartNumber: chunkNumber + 1, 49 | }); 50 | 51 | return partUploadResult as any; 52 | } 53 | 54 | private async getMultipartUploadId(uploadId: string): Promise { 55 | const multipartUploadId = await this.cacheManager.get(uploadId); 56 | 57 | if (multipartUploadId) { 58 | return multipartUploadId; 59 | } 60 | 61 | try { 62 | const result = await this.createMultipartUpload(uploadId); 63 | 64 | await this.cacheManager.set(uploadId, result.UploadId); 65 | 66 | return result.UploadId; 67 | } catch (error) { 68 | console.log(error); 69 | 70 | throw new ConflictException( 71 | `Could not create multipart upload: ${error.message}` 72 | ); 73 | } 74 | } 75 | 76 | private createMultipartUpload(key: string) { 77 | return this.s3 78 | .createMultipartUpload({ 79 | Bucket: this.configService.get('recordings').bucket, 80 | Key: `${key}.mp3`, 81 | ContentType: 'audio/mpeg', 82 | }) 83 | .promise(); 84 | } 85 | 86 | async finalizeUpload(uploadId: string) { 87 | const recordingPath = await this.generateRecordingFile(uploadId); 88 | 89 | try { 90 | const uploadResult = await this.s3 91 | .upload({ 92 | Bucket: this.configService.get('recordings').bucket, 93 | Key: `${uploadId}.mp3`, 94 | ContentType: 'audio/mpeg', 95 | Body: fs.createReadStream(recordingPath), 96 | ACL: 'public-read', 97 | }) 98 | .promise(); 99 | 100 | try { 101 | fs.rmSync(`${this.configService.get('uploads').dir}/${uploadId}/`, { 102 | recursive: true, 103 | force: true, 104 | }); 105 | } catch (error) { 106 | console.log('Error deleting upload folder', error); 107 | } 108 | 109 | return { 110 | mediaUrl: uploadResult.Location, 111 | status: 0, 112 | mediaId: uploadId, 113 | ownerToken: 'ip-address-id', 114 | }; 115 | } catch (error) { 116 | throw new ConflictException( 117 | `Could not upload finalize recording: ${error.message}` 118 | ); 119 | } 120 | } 121 | 122 | private async generateRecordingFile(uploadId: string): Promise { 123 | const files = fs.readdirSync( 124 | `${this.configService.get('uploads').dir}/${uploadId}` 125 | ); 126 | 127 | files.sort((a, b) => parseInt(a) - parseInt(b)); 128 | 129 | const recordingFilePath = `${ 130 | this.configService.get('uploads').dir 131 | }/${uploadId}/${uploadId}.mp3`; 132 | 133 | const recordingFile = fs.createWriteStream(recordingFilePath); 134 | 135 | for (const fileChunk of files) { 136 | const chunk = fs.readFileSync( 137 | `${this.configService.get('uploads').dir}/${uploadId}/${fileChunk}` 138 | ); 139 | 140 | recordingFile.write(chunk); 141 | } 142 | 143 | recordingFile.end(); 144 | recordingFile.close(); 145 | 146 | await waitForStreamClose(recordingFile); 147 | 148 | return recordingFilePath; 149 | } 150 | 151 | async deleteRecording(recordingId: string) { 152 | const exists = await this.s3 153 | .headObject({ 154 | Bucket: this.configService.get('recordings').bucket, 155 | Key: `${recordingId}.mp3`, 156 | }) 157 | .promise() 158 | .then(() => true) 159 | .catch(() => false); 160 | 161 | if (!exists) { 162 | throw new NotFoundException(`Couldn't delete. Recording not found.`); 163 | } 164 | 165 | const response = await this.s3 166 | .deleteObject({ 167 | Bucket: this.configService.get('recordings').bucket, 168 | Key: `${recordingId}.mp3`, 169 | }) 170 | .promise(); 171 | 172 | return response; 173 | } 174 | 175 | async getRecording(recordingId: string): Promise { 176 | const exists = await this.s3 177 | .headObject({ 178 | Bucket: this.configService.get('recordings').bucket, 179 | Key: `${recordingId}.mp3`, 180 | }) 181 | .promise() 182 | .then(() => true) 183 | .catch(() => false); 184 | 185 | if (!exists) { 186 | throw new NotFoundException('Recording not found.'); 187 | } 188 | 189 | return { 190 | mediaId: recordingId, 191 | status: 0, 192 | ownerToken: '', 193 | }; 194 | } 195 | 196 | async getDownloadUrl(uploadId: string): Promise { 197 | const url = await this.s3.getSignedUrlPromise('getObject', { 198 | Bucket: this.configService.get('recordings').bucket, 199 | Key: `${uploadId}.mp3`, 200 | Expires: 60, 201 | ResponseContentDisposition: 'attachment', 202 | }); 203 | 204 | return { 205 | url, 206 | }; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /apps/frontend/lib/recording/recording.ts: -------------------------------------------------------------------------------- 1 | import { Utils } from '@lib/helpers/utils.helper'; 2 | import { AudioEncoderConstraints } from './audio-encoding/audio-encoder-config.interface'; 3 | import { MaxChunkCountError } from './errors/max-chunk-count.error'; 4 | import { NoChunksFoundError } from './errors/no-chunk-found.error'; 5 | import { SubmitError } from './errors/submit.error'; 6 | import { MediaInfo } from './interfaces/media-info.interface'; 7 | import { Recorder } from './recorder'; 8 | import { RecordingStore } from './recording-store'; 9 | import { Uploader } from './uploader'; 10 | 11 | export class Recording { 12 | private store: RecordingStore; 13 | private recorder: Recorder; 14 | private uploader: Uploader; 15 | private unixTime: number; 16 | private audioBlob: Blob; 17 | private audioBlobUrl: string; 18 | private media: MediaInfo; 19 | private wasNoChunksFoundError = false; 20 | 21 | /** Recorder events */ 22 | public onStart?: () => void; 23 | public onStop?: (success: boolean) => void; 24 | public onPause?: () => void; 25 | public onResume?: () => void; 26 | public onError?: (error: Error) => void; 27 | 28 | /** Saving events */ 29 | public onSavePercent?: (percentage: number) => void; 30 | public onSaveSuccess?: (mediaInfo: MediaInfo) => void; 31 | public onSaveError?: (error: Error) => void; 32 | 33 | constructor(private readonly options: Partial) { 34 | this.options = Utils.mergeObjects( 35 | { 36 | autoGainControl: true, 37 | echoCancellation: true, 38 | noiseSuppression: true, 39 | }, 40 | options 41 | ); 42 | 43 | this.store = new RecordingStore(); 44 | this.recorder = new Recorder({ 45 | recordingGain: 1, 46 | numberOfChannels: 1, 47 | encoderBitRate: 96, 48 | constraints: { 49 | autoGainControl: this.options.autoGainControl, 50 | echoCancellation: this.options.echoCancellation, 51 | noiseSuppression: this.options.noiseSuppression, 52 | }, 53 | }); 54 | 55 | this.recorder.onDataAvailable = this.onRecorderDataAvailable.bind(this); 56 | this.recorder.onStart = this.onRecordingStarted.bind(this); 57 | this.recorder.onStop = this.onRecordingStopped.bind(this); 58 | this.recorder.onError = this.onRecordingError.bind(this); 59 | } 60 | 61 | getAudioBlobUrl(): string { 62 | return ( 63 | this.audioBlobUrl || 64 | (this.audioBlobUrl = this.store.generateAudioBlobUrl()) 65 | ); 66 | } 67 | 68 | createUploader() { 69 | this.destroyUploader(); 70 | this.uploader = new Uploader(); 71 | 72 | this.uploader.onProgress = (progress) => { 73 | if (this.audioBlob) { 74 | this.onSavePercent?.( 75 | Math.round((progress.bytesUploaded / this.audioBlob.size) * 100) 76 | ); 77 | } 78 | }; 79 | 80 | this.uploader.onSuccess = (response) => { 81 | this.media = { 82 | mediaId: response.mediaId, 83 | ownerToken: response.ownerToken, 84 | time: this.unixTime, 85 | }; 86 | this.onSaveSuccess?.(this.media); 87 | }; 88 | 89 | this.uploader.onError = (error: Error) => { 90 | if (error instanceof MaxChunkCountError) { 91 | this.stop(); 92 | } 93 | }; 94 | 95 | this.uploader.onFinalizeError = (error: Error) => { 96 | if ( 97 | !(error instanceof NoChunksFoundError) || 98 | this.wasNoChunksFoundError 99 | ) { 100 | if (!(error instanceof SubmitError)) { 101 | this.destroyUploader(); 102 | } 103 | this.onSaveError?.(error); 104 | } else { 105 | this.wasNoChunksFoundError = true; 106 | this.createUploader(); 107 | this.uploader.addData(this.audioBlob); 108 | this.uploader.complete(); 109 | this.uploader.finalize(); 110 | } 111 | }; 112 | } 113 | 114 | destroyUploader() { 115 | if (this.uploader) { 116 | this.uploader.onSuccess = null; 117 | this.uploader.onFinalizeError = null; 118 | this.uploader.onProgress = null; 119 | this.uploader = null; 120 | } 121 | } 122 | 123 | private onRecorderDataAvailable(blob: Int8Array) { 124 | if (blob.length > 0) { 125 | this.store.appendData(blob); 126 | this.uploader.addData(blob); 127 | } 128 | } 129 | 130 | private onRecordingStarted() { 131 | this.store.reset(); 132 | this.audioBlob = null; 133 | this.audioBlobUrl = null; 134 | this.unixTime = Date.now(); 135 | this.createUploader(); 136 | this.onStart?.(); 137 | } 138 | 139 | private onRecordingStopped() { 140 | if (!this.store.isEmpty()) { 141 | this.uploader.complete(); 142 | this.audioBlob = this.store.generateAudioBlob(); 143 | this.audioBlobUrl = this.store.generateAudioBlobUrl(); 144 | this.onStop?.(true); 145 | } else { 146 | this.error('Nothing recorded', 'No audio data available'); 147 | this.onStop?.(false); 148 | } 149 | } 150 | 151 | private onRecordingError(error: Error) { 152 | if (error.name === 'WorkerError') { 153 | this.error( 154 | 'WorkerError', 155 | 'Recording worker failed to load. Please check your internet connection and try again.' 156 | ); 157 | } else { 158 | this.error( 159 | 'MicAccessError', 160 | 'Could not start recording! Please allow microphone access in your web browser settings.' 161 | ); 162 | } 163 | } 164 | 165 | private error(name: string, details: string) { 166 | const error = new Error(details); 167 | error.name = name; 168 | 169 | this.onError?.(error); 170 | } 171 | 172 | async start() { 173 | if (!Recorder.isRecordingSupported()) { 174 | this.error( 175 | 'RecordingNotSupported', 176 | 'Your web browser does not support audio recording! Please update to a newer browser.' 177 | ); 178 | return; 179 | } 180 | 181 | if (this.store.isEmpty() && !this.uploader && !this.media) { 182 | await this.recorder.start(); 183 | } 184 | } 185 | 186 | async stop() { 187 | await this.recorder.stop(); 188 | } 189 | 190 | abort() { 191 | return this.recorder.abort(); 192 | } 193 | 194 | async pause() { 195 | await this.recorder.pause(); 196 | this.onPause?.(); 197 | } 198 | 199 | async resume() { 200 | await this.recorder.resume(); 201 | this.onResume?.(); 202 | } 203 | 204 | getMedia(): MediaInfo { 205 | return this.media; 206 | } 207 | 208 | save() { 209 | if (this.uploader) { 210 | this.uploader.finalize(); 211 | } else if (this.audioBlob) { 212 | this.createUploader(); 213 | this.uploader.addData(this.audioBlob); 214 | this.uploader.complete(); 215 | this.uploader.finalize(); 216 | } 217 | } 218 | 219 | static preload() { 220 | // return Recorder.preload(); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /migrations.json: -------------------------------------------------------------------------------- 1 | { 2 | "migrations": [ 3 | { 4 | "version": "15.7.0-beta.0", 5 | "description": "Split global configuration files into individual project.json files. This migration has been added automatically to the beginning of your migration set to retroactively make them work with the new version of Nx.", 6 | "cli": "nx", 7 | "implementation": "./src/migrations/update-15-7-0/split-configuration-into-project-json-files", 8 | "package": "@nrwl/workspace", 9 | "name": "15-7-0-split-configuration-into-project-json-files" 10 | }, 11 | { 12 | "cli": "nx", 13 | "version": "15.8.2-beta.0", 14 | "description": "Updates the nx wrapper.", 15 | "implementation": "./src/migrations/update-15-8-2/update-nxw", 16 | "package": "nx", 17 | "name": "15.8.2-update-nx-wrapper" 18 | }, 19 | { 20 | "cli": "nx", 21 | "version": "16.0.0-beta.0", 22 | "description": "Remove @nrwl/cli.", 23 | "implementation": "./src/migrations/update-16-0-0/remove-nrwl-cli", 24 | "package": "nx", 25 | "name": "16.0.0-remove-nrwl-cli" 26 | }, 27 | { 28 | "cli": "nx", 29 | "version": "16.0.0-beta.9", 30 | "description": "Replace `dependsOn.projects` and `inputs` definitions with new configuration format.", 31 | "implementation": "./src/migrations/update-16-0-0/update-depends-on-to-tokens", 32 | "package": "nx", 33 | "name": "16.0.0-tokens-for-depends-on" 34 | }, 35 | { 36 | "cli": "nx", 37 | "version": "16.0.0-beta.0", 38 | "description": "Replace @nrwl/nx-cloud with nx-cloud", 39 | "implementation": "./src/migrations/update-16-0-0/update-nx-cloud-runner", 40 | "package": "nx", 41 | "name": "16.0.0-update-nx-cloud-runner" 42 | }, 43 | { 44 | "cli": "nx", 45 | "version": "16.2.0-beta.0", 46 | "description": "Remove outputPath from run commands", 47 | "implementation": "./src/migrations/update-16-2-0/remove-run-commands-output-path", 48 | "package": "nx", 49 | "name": "16.2.0-remove-output-path-from-run-commands" 50 | }, 51 | { 52 | "version": "15.7.0-beta.0", 53 | "description": "Split global configuration files (e.g., workspace.json) into individual project.json files.", 54 | "cli": "nx", 55 | "implementation": "./src/migrations/update-15-7-0/split-configuration-into-project-json-files", 56 | "package": "@nrwl/workspace", 57 | "name": "15-7-0-split-configuration-into-project-json-files" 58 | }, 59 | { 60 | "cli": "nx", 61 | "version": "16.0.0-beta.1", 62 | "description": "Replace @nrwl/workspace with @nx/workspace", 63 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 64 | "package": "@nrwl/workspace", 65 | "name": "update-16-0-0-add-nx-packages" 66 | }, 67 | { 68 | "version": "16.0.0-beta.4", 69 | "description": "Generates a plugin called 'workspace-plugin' containing your workspace generators.", 70 | "cli": "nx", 71 | "implementation": "./src/migrations/update-16-0-0/move-workspace-generators-to-local-plugin", 72 | "package": "@nrwl/workspace", 73 | "name": "16-0-0-move-workspace-generators-into-local-plugin" 74 | }, 75 | { 76 | "version": "16.0.0-beta.9", 77 | "description": "Fix .babelrc presets if it contains an invalid entry for @nx/web/babel.", 78 | "cli": "nx", 79 | "implementation": "./src/migrations/update-16-0-0/fix-invalid-babelrc", 80 | "package": "@nrwl/workspace", 81 | "name": "16-0-0-fix-invalid-babelrc" 82 | }, 83 | { 84 | "cli": "nx", 85 | "version": "15.5.0-beta.0", 86 | "description": "Update to Cypress v12. Cypress 12 contains a handful of breaking changes that might causes tests to start failing that nx cannot directly fix. Read more Cypress 12 changes: https://docs.cypress.io/guides/references/migration-guide#Migrating-to-Cypress-12-0.This migration will only run if you are already using Cypress v11.", 87 | "factory": "./src/migrations/update-15-5-0/update-to-cypress-12", 88 | "package": "@nrwl/cypress", 89 | "name": "update-to-cypress-12" 90 | }, 91 | { 92 | "cli": "nx", 93 | "version": "16.0.0-beta.1", 94 | "description": "Replace @nrwl/cypress with @nx/cypress", 95 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 96 | "package": "@nrwl/cypress", 97 | "name": "update-16-0-0-add-nx-packages" 98 | }, 99 | { 100 | "cli": "nx", 101 | "version": "16.2.0-beta.0", 102 | "description": "Normalize tsconfig.cy.json files to be located at '/cypress/tsconfig.json'", 103 | "implementation": "./src/migrations/update-16-2-0/update-cy-tsconfig", 104 | "package": "@nrwl/cypress", 105 | "name": "update-16-2-0-normalize-tsconfigs" 106 | }, 107 | { 108 | "cli": "nx", 109 | "version": "15.7.1-beta.0", 110 | "description": "Add node_modules to root eslint ignore", 111 | "factory": "./src/migrations/update-15-7-1/add-eslint-ignore", 112 | "package": "@nrwl/linter", 113 | "name": "add-eslint-ignore" 114 | }, 115 | { 116 | "cli": "nx", 117 | "version": "16.0.0-beta.1", 118 | "description": "Replace @nrwl/linter with @nx/linter", 119 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 120 | "package": "@nrwl/linter", 121 | "name": "update-16-0-0-add-nx-packages" 122 | }, 123 | { 124 | "cli": "nx", 125 | "version": "16.0.0-beta.1", 126 | "description": "Replace @nrwl/eslint-plugin-nx with @nx/eslint-plugin", 127 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 128 | "package": "@nrwl/eslint-plugin-nx", 129 | "name": "update-16-0-0-add-nx-packages" 130 | }, 131 | { 132 | "cli": "nx", 133 | "version": "15.5.4-beta.0", 134 | "description": "Update `@nrwl/web/babel` preset to `@nrwl/js/babel` for projects that have a .babelrc file.", 135 | "factory": "./src/migrations/update-15-5-4/update-babel-preset", 136 | "package": "@nrwl/web", 137 | "name": "update-babel-preset" 138 | }, 139 | { 140 | "cli": "nx", 141 | "version": "15.9.1", 142 | "description": "Add @nrwl/linter, @nrwl/cypress, @nrwl/jest, @nrwl/rollup if they are used", 143 | "factory": "./src/migrations/update-15-9-1/add-dropped-dependencies", 144 | "package": "@nrwl/web", 145 | "name": "add-dropped-dependencies" 146 | }, 147 | { 148 | "cli": "nx", 149 | "version": "16.0.0-beta.1", 150 | "description": "Replace @nrwl/web with @nx/web", 151 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 152 | "package": "@nrwl/web", 153 | "name": "update-16-0-0-add-nx-packages" 154 | }, 155 | { 156 | "cli": "nx", 157 | "version": "16.0.0-beta.4", 158 | "description": "Replace @nrwl/web executors with @nx/webpack and @nx/rollup", 159 | "implementation": "./src/migrations/update-16-0-0-update-executors/update-16-0-0-update-executors", 160 | "package": "@nrwl/web", 161 | "name": "update-16-0-0-update-executors" 162 | }, 163 | { 164 | "cli": "nx", 165 | "version": "15.6.3-beta.0", 166 | "description": "Creates or updates webpack.config.js file with the new options for webpack.", 167 | "factory": "./src/migrations/update-15-6-3/webpack-config-setup", 168 | "package": "@nrwl/react", 169 | "name": "react-webpack-config-setup" 170 | }, 171 | { 172 | "cli": "nx", 173 | "version": "16.0.0-beta.1", 174 | "description": "Replace @nrwl/react with @nx/react", 175 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 176 | "package": "@nrwl/react", 177 | "name": "update-16-0-0-add-nx-packages" 178 | }, 179 | { 180 | "cli": "nx", 181 | "version": "16.2.0-beta.0", 182 | "description": "Remove react-test-renderer from package.json", 183 | "implementation": "./src/migrations/update-16-2-0-remove-package/update-16-2-0-remove-package", 184 | "package": "@nrwl/react", 185 | "name": "update-16-2-0-remove-package" 186 | }, 187 | { 188 | "cli": "nx", 189 | "version": "15.8.8-beta.0", 190 | "description": "Add less and stylus packages if used.", 191 | "factory": "./src/migrations/update-15-8-8/add-style-packages", 192 | "package": "@nrwl/next", 193 | "name": "add-style-packages" 194 | }, 195 | { 196 | "cli": "nx", 197 | "version": "16.0.0-beta.1", 198 | "description": "Replace @nrwl/next with @nx/next", 199 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 200 | "package": "@nrwl/next", 201 | "name": "update-16-0-0-add-nx-packages" 202 | }, 203 | { 204 | "version": "15.8.0-beta.0", 205 | "cli": "nx", 206 | "description": "Update jest configs to support jest 29 changes (https://jestjs.io/docs/upgrading-to-jest29)", 207 | "factory": "./src/migrations/update-15-8-0/update-configs-jest-29", 208 | "package": "@nrwl/jest", 209 | "name": "update-configs-jest-29" 210 | }, 211 | { 212 | "version": "15.8.0-beta.0", 213 | "cli": "nx", 214 | "description": "Update jest test files to support jest 29 changes (https://jestjs.io/docs/upgrading-to-jest29)", 215 | "factory": "./src/migrations/update-15-8-0/update-tests-jest-29", 216 | "package": "@nrwl/jest", 217 | "name": "update-tests-jest-29" 218 | }, 219 | { 220 | "cli": "nx", 221 | "version": "16.0.0-beta.1", 222 | "description": "Replace @nrwl/jest with @nx/jest", 223 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 224 | "package": "@nrwl/jest", 225 | "name": "update-16-0-0-add-nx-packages" 226 | }, 227 | { 228 | "cli": "nx", 229 | "version": "16.0.0-beta.1", 230 | "description": "Replace @nrwl/nest with @nx/nest", 231 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 232 | "package": "@nrwl/nest", 233 | "name": "update-16-0-0-add-nx-packages" 234 | }, 235 | { 236 | "cli": "nx", 237 | "version": "16.0.0-beta.1", 238 | "description": "Replace @nrwl/node with @nx/node", 239 | "implementation": "./src/migrations/update-16-0-0-add-nx-packages/update-16-0-0-add-nx-packages", 240 | "package": "@nrwl/node", 241 | "name": "update-16-0-0-add-nx-packages" 242 | }, 243 | { 244 | "cli": "nx", 245 | "version": "16.0.0-beta.5", 246 | "description": "Replace @nrwl/node:webpack with @nx/node:webpack", 247 | "implementation": "./src/migrations/update-16-0-0/update-webpack-executor", 248 | "package": "@nrwl/node", 249 | "name": "update-16-0-0-update-executor" 250 | } 251 | ] 252 | } 253 | --------------------------------------------------------------------------------