diff --git a/package-lock.json b/package-lock.json index 0dd3ff0..e319130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1613,6 +1613,12 @@ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "@types/dom-mediacapture-record": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.4.tgz", + "integrity": "sha512-3u6HCFY83HQ0Aqs5wq+bTSGRfSb4QAYERz+7nvm4sJf2h48NkJlGlLi082j3+xwC6ce3en9mRl3QUCH8mlLWmg==", + "dev": true + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -12074,6 +12080,11 @@ "util.promisify": "^1.0.0" } }, + "recorderjs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recorderjs/-/recorderjs-1.0.1.tgz", + "integrity": "sha1-+CEaD5C9zc+i9DGll7bLlKG5NXE=" + }, "recursive-readdir": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz", diff --git a/package.json b/package.json index 78b6708..123608a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react-dropzone": "^10.2.1", "react-redux": "^7.2.0", "react-scripts": "3.3.1", + "recorderjs": "^1.0.1", "redux-batched-actions": "^0.4.1", "typescript": "~3.7.2", "worker-loader": "^2.0.0" @@ -65,6 +66,7 @@ ] }, "devDependencies": { + "@types/dom-mediacapture-record": "^1.0.4", "gh-pages": "^2.2.0" } } diff --git a/public/extra-service-worker.js b/public/extra-service-worker.js index c7bce3e..40bfda8 100644 --- a/public/extra-service-worker.js +++ b/public/extra-service-worker.js @@ -1,6 +1,6 @@ // List of extra files to be precached for offline use. // WARNING: don't forget to update the revision before deploy -var revision = "7"; +var revision = "8"; self.__precacheManifest = (self.__precacheManifest || []).concat([ { "revision": revision, @@ -14,4 +14,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([ "revision": revision, "url": "/webminidisc/ffmpeg-core.js" }, + { + "revision": revision, + "url": "/webminidisc/recorderWorker.js" + }, ]); diff --git a/public/recorderWorker.js b/public/recorderWorker.js new file mode 120000 index 0000000..0feb165 --- /dev/null +++ b/public/recorderWorker.js @@ -0,0 +1 @@ +../node_modules/recorderjs/recorderWorker.js \ No newline at end of file diff --git a/src/components/dump-dialog.tsx b/src/components/dump-dialog.tsx new file mode 100644 index 0000000..b0d09f4 --- /dev/null +++ b/src/components/dump-dialog.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { useShallowEqualSelector } from '../utils'; + +import { recordTracks } from '../redux/actions'; +import { actions as dumpDialogActions } from '../redux/dump-dialog-feature'; + +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Slide from '@material-ui/core/Slide'; +import Button from '@material-ui/core/Button'; +import { makeStyles } from '@material-ui/core/styles'; +import FormControl from '@material-ui/core/FormControl'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import Typography from '@material-ui/core/Typography'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import { Controls } from './controls'; +import Box from '@material-ui/core/Box'; +import serviceRegistry from '../services/registry'; + +const Transition = React.forwardRef(function Transition(props, ref) { + return ; +}); + +const useStyles = makeStyles(theme => ({ + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + marginRight: -theme.spacing(2), + }, + formControl: { + minWidth: 120, + }, + selectEmpty: { + marginTop: theme.spacing(2), + }, + head: { + textShadow: '0px 0px 12px rgba(150, 150, 150, 1)', + fontSize: theme.typography.h2.fontSize, + textAlign: 'center', + marginBottom: theme.spacing(2), + }, +})); + +export const DumpDialog = ({ trackIndexes }: { trackIndexes: number[] }) => { + const dispatch = useDispatch(); + const classes = useStyles(); + + const [devices, setDevices] = useState<{ deviceId: string; label: string }[]>([]); + const [inputDeviceId, setInputDeviceId] = useState(''); + + let { visible } = useShallowEqualSelector(state => state.dumpDialog); + + const handleClose = useCallback(() => { + setInputDeviceId(''); + serviceRegistry.mediaRecorderService?.stopTestInput(); + dispatch(dumpDialogActions.setVisible(false)); + }, [dispatch]); + + const handleChange = useCallback( + (ev: React.ChangeEvent<{ value: unknown }>) => { + const deviceId = ev.target.value as string; + setInputDeviceId(deviceId); + serviceRegistry.mediaRecorderService?.stopTestInput(); + serviceRegistry.mediaRecorderService?.playTestInput(deviceId); + }, + [setInputDeviceId] + ); + + const handleStartTransfer = useCallback(() => { + dispatch(recordTracks(trackIndexes, inputDeviceId)); + handleClose(); + }, [trackIndexes, inputDeviceId, dispatch, handleClose]); + + useEffect(() => { + async function updateDeviceList() { + let devices = await navigator.mediaDevices.enumerateDevices(); + let inputDevices = devices + .filter(device => device.kind === 'audioinput') + .map(device => ({ deviceId: device.deviceId, label: device.label })); + setDevices(inputDevices); + } + if (visible) { + updateDeviceList(); + } + }, [visible, setDevices]); + + return ( + + Record Selected Tracks + + + {`💻 ⬅ 💽`} + + + 1. Connect your MD Player line-out to your PC audio line-in. + + + 2. Use the controls at the bottom right to play some tracks. + + + 3. Select the input source. You should hear the tracks playing on your PC. + + + 4. Adjust the input gain and the line-out volume of your device. + + + + + Input Source + + + + + + + + + + ); +}; diff --git a/src/components/main.tsx b/src/components/main.tsx index 0aa2889..17b9914 100644 --- a/src/components/main.tsx +++ b/src/components/main.tsx @@ -5,6 +5,7 @@ import { useDropzone } from 'react-dropzone'; import { listContent, deleteTracks, moveTrack } from '../redux/actions'; import { actions as renameDialogActions } from '../redux/rename-dialog-feature'; import { actions as convertDialogActions } from '../redux/convert-dialog-feature'; +import { actions as dumpDialogActions } from '../redux/dump-dialog-feature'; import { formatTimeFromFrames, getTracks, Encoding } from 'netmd-js'; @@ -32,9 +33,11 @@ import { batchActions } from 'redux-batched-actions'; import { RenameDialog } from './rename-dialog'; import { UploadDialog } from './upload-dialog'; +import { RecordDialog } from './record-dialog'; import { ErrorDialog } from './error-dialog'; import { ConvertDialog } from './convert-dialog'; import { AboutDialog } from './about-dialog'; +import { DumpDialog } from './dump-dialog'; import { TopMenu } from './topmenu'; import Checkbox from '@material-ui/core/Checkbox'; import * as BadgeImpl from '@material-ui/core/Badge/Badge'; @@ -136,9 +139,13 @@ export const Main = (props: {}) => { dispatch(moveTrack(selected[0], destIndex)); handleCloseMoveMenu(); }, - [dispatch, selected] + [dispatch, selected, handleCloseMoveMenu] ); + const handleShowDumpDialog = useCallback(() => { + dispatch(dumpDialogActions.setVisible(true)); + }, [dispatch]); + useEffect(() => { dispatch(listContent()); }, [dispatch]); @@ -248,7 +255,6 @@ export const Main = (props: {}) => { {disc?.title || `Untitled Disc`} )} - {selectedCount === 1 ? ( @@ -280,6 +286,16 @@ export const Main = (props: {}) => { ) : null} + {selectedCount > 0 ? ( + + + + + + ) : null} + {selectedCount > 0 ? ( @@ -340,6 +356,8 @@ export const Main = (props: {}) => { + + ); diff --git a/src/components/record-dialog.tsx b/src/components/record-dialog.tsx new file mode 100644 index 0000000..369d960 --- /dev/null +++ b/src/components/record-dialog.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useShallowEqualSelector } from '../utils'; + +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Slide from '@material-ui/core/Slide'; +import LinearProgress from '@material-ui/core/LinearProgress'; +import Box from '@material-ui/core/Box'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + progressPerc: { + marginTop: theme.spacing(1), + }, + progressBar: { + marginTop: theme.spacing(3), + }, +})); + +const Transition = React.forwardRef(function Transition(props, ref) { + return ; +}); + +export const RecordDialog = (props: {}) => { + const classes = useStyles(); + + let { visible, trackTotal, trackDone, trackCurrent, titleCurrent } = useShallowEqualSelector(state => state.recordDialog); + + let progressValue = Math.round(trackCurrent); + return ( + + Recording... + + + {`Recording track ${trackDone + 1} of ${trackTotal}: ${titleCurrent}`} + + = 0 ? 'determinate' : 'indeterminate'} + color="primary" + value={progressValue} + /> + {progressValue >= 0 ? `${progressValue}%` : ``} + + + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index 87bd3ba..1a1e61b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,10 +14,12 @@ import App from './components/app'; import './index.css'; import { FFMpegAudioExportService } from './services/audio-export'; +import { MediaRecorderService } from './services/mediarecorder'; serviceRegistry.netmdService = new NetMDUSBService({ debug: true }); // serviceRegistry.netmdService = new NetMDMockService(); // Uncomment to work without a device attached serviceRegistry.audioExportService = new FFMpegAudioExportService(); +serviceRegistry.mediaRecorderService = new MediaRecorderService(); (function setupEventHandlers() { window.addEventListener('beforeunload', ev => { diff --git a/src/redux/actions.ts b/src/redux/actions.ts index f3dc31b..d70f58e 100644 --- a/src/redux/actions.ts +++ b/src/redux/actions.ts @@ -3,12 +3,13 @@ import { AppDispatch, RootState } from './store'; import { actions as uploadDialogActions } from './upload-dialog-feature'; import { actions as renameDialogActions } from './rename-dialog-feature'; import { actions as errorDialogAction } from './error-dialog-feature'; +import { actions as recordDialogAction } from './record-dialog-feature'; import { actions as appStateActions } from './app-feature'; import { actions as mainActions } from './main-feature'; import serviceRegistry from '../services/registry'; import { Wireformat, getTracks } from 'netmd-js'; import { AnyAction } from '@reduxjs/toolkit'; -import { getAvailableCharsForTrackTitle } from '../utils'; +import { getAvailableCharsForTrackTitle, framesToSec, sleepWithProgressCallback, sleep } from '../utils'; export function pair() { return async function(dispatch: AppDispatch, getState: () => RootState) { @@ -105,6 +106,75 @@ export function moveTrack(srcIndex: number, destIndex: number) { }; } +export function recordTracks(indexes: number[], deviceId: string) { + return async function(dispatch: AppDispatch, getState: () => RootState) { + dispatch( + batchActions([ + recordDialogAction.setVisible(true), + recordDialogAction.setProgress({ trackTotal: indexes.length, trackDone: 0, trackCurrent: 0, titleCurrent: '' }), + ]) + ); + + let disc = getState().main.disc; + let tracks = getTracks(disc!).filter(t => indexes.indexOf(t.index) >= 0); + + const { netmdService, mediaRecorderService } = serviceRegistry; + + for (let [i, track] of tracks.entries()) { + dispatch( + recordDialogAction.setProgress({ + trackTotal: tracks.length, + trackDone: i, + trackCurrent: -1, + titleCurrent: track.title ?? '', + }) + ); + + // Wait for the track to be ready to play from 0:00 + await netmdService!.gotoTrack(track.index); + await netmdService!.play(); + console.log('Waiting for track to be ready to play'); + let position = await netmdService!.getPosition(); + let expected = [track.index, 0, 0, 1]; + while (position === null || !expected.every((_, i) => expected[i] === position![i])) { + await sleep(250); + position = await netmdService!.getPosition(); + } + await netmdService!.pause(); + await netmdService?.gotoTrack(track.index); + console.log('Track is ready to play'); + + // Start recording and play track + await mediaRecorderService?.initStream(deviceId); + await mediaRecorderService?.startRecording(); + await netmdService!.play(); + + // Wait until track is finished + let durationInSec = framesToSec(track.duration); + // await sleep(durationInSec * 1000); + await sleepWithProgressCallback(durationInSec * 1000, (perc: number) => { + dispatch( + recordDialogAction.setProgress({ + trackTotal: tracks.length, + trackDone: i, + trackCurrent: perc, + titleCurrent: track.title ?? '', + }) + ); + }); + + // Stop recording and download the wav + await mediaRecorderService?.stopRecording(); + mediaRecorderService?.downloadRecorded(`${track.title}`); + + await mediaRecorderService?.closeStream(); + } + + await netmdService!.stop(); + dispatch(recordDialogAction.setVisible(false)); + }; +} + export const WireformatDict: { [k: string]: Wireformat } = { SP: Wireformat.pcm, LP2: Wireformat.lp2, diff --git a/src/redux/dump-dialog-feature.ts b/src/redux/dump-dialog-feature.ts new file mode 100644 index 0000000..c3cded0 --- /dev/null +++ b/src/redux/dump-dialog-feature.ts @@ -0,0 +1,28 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { enableBatching } from 'redux-batched-actions'; + +export interface DumpDialogState { + visible: boolean; + inputDeviceId: string; +} + +const initialState: DumpDialogState = { + visible: false, + inputDeviceId: '', +}; + +export const slice = createSlice({ + name: 'dumpDialog', + initialState, + reducers: { + setVisible: (state, action: PayloadAction) => { + state.visible = action.payload; + }, + setInputDeviceId: (state, action: PayloadAction) => { + state.inputDeviceId = action.payload; + }, + }, +}); + +export const { reducer, actions } = slice; +export default enableBatching(reducer); diff --git a/src/redux/record-dialog-feature.ts b/src/redux/record-dialog-feature.ts new file mode 100644 index 0000000..ac0ad03 --- /dev/null +++ b/src/redux/record-dialog-feature.ts @@ -0,0 +1,51 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { enableBatching } from 'redux-batched-actions'; + +export interface RecordingDialogState { + visible: boolean; + + trackTotal: number; + trackDone: number; + trackCurrent: number; + + titleCurrent: string; +} + +const initialState: RecordingDialogState = { + visible: false, + + trackTotal: 1, + trackDone: 0, + trackCurrent: 0, + + titleCurrent: '', + + // visible: true, + // trackTotal: 4, + // trackDone: 1, + // trackCurrent: 25, + + // titleCurrent: 'Seconda traccia', +}; + +export const slice = createSlice({ + name: 'recordDialog', + initialState, + reducers: { + setVisible: (state, action: PayloadAction) => { + state.visible = action.payload; + }, + setProgress: ( + state, + action: PayloadAction<{ trackTotal: number; trackDone: number; trackCurrent: number; titleCurrent: string }> + ) => { + state.trackTotal = action.payload.trackTotal; + state.trackDone = action.payload.trackDone; + state.trackCurrent = action.payload.trackCurrent; + state.titleCurrent = action.payload.titleCurrent; + }, + }, +}); + +export const { reducer, actions } = slice; +export default enableBatching(reducer); diff --git a/src/redux/store.ts b/src/redux/store.ts index 9be41dd..dd08b0d 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -3,6 +3,8 @@ import uploadDialog from './upload-dialog-feature'; import renameDialog from './rename-dialog-feature'; import errorDialog from './error-dialog-feature'; import convertDialog from './convert-dialog-feature'; +import dumpDialog from './dump-dialog-feature'; +import recordDialog from './record-dialog-feature'; import appState from './app-feature'; import main from './main-feature'; @@ -12,6 +14,8 @@ export const store = configureStore({ uploadDialog, errorDialog, convertDialog, + dumpDialog, + recordDialog, appState, main, }, diff --git a/src/services/mediarecorder.ts b/src/services/mediarecorder.ts new file mode 100644 index 0000000..8ae4410 --- /dev/null +++ b/src/services/mediarecorder.ts @@ -0,0 +1,81 @@ +import { sanitizeTitle, getPublicPathFor } from '../utils'; +import Recorder from 'recorderjs'; + +export class MediaRecorderService { + public recorder: any; + public stream?: MediaStream; + public audioContext?: AudioContext; + public analyserNode?: AnalyserNode; + public gainNode?: GainNode; + + playTestInput(deviceId: string) { + this.audioContext = new AudioContext(); + this.gainNode = this.audioContext.createGain(); + this.analyserNode = this.audioContext.createAnalyser(); + + this.initStream(deviceId).then(() => { + const source = this.audioContext!.createMediaStreamSource(this.stream!); + source.connect(this.gainNode!); + this.gainNode!.connect(this.analyserNode!); + this.analyserNode!.connect(this.audioContext!.destination); + }); + } + + stopTestInput() { + if (!this.audioContext) { + return; + } + this.audioContext?.close(); + delete this.audioContext; + this.closeStream(); + } + + async initStream(deviceId: string) { + const recordConstraints = { + // Try to set the best recording params for ripping the audio tracks + autoGainControl: false, + channelCount: 2, + deviceId: deviceId, + echoCancellation: false, + noiseSuppression: false, + sampleRate: 44100, + highpassFilter: false, + }; + this.stream = await navigator.mediaDevices.getUserMedia({ audio: recordConstraints }); + + // Dump recording settings + const audioTracks = this.stream.getAudioTracks(); + if (audioTracks.length > 0) { + console.log('Record Setings:', audioTracks[0].getSettings()); + } + } + + async startRecording() { + this.audioContext = new AudioContext(); + const input = this.audioContext.createMediaStreamSource(this.stream!); + this.recorder = new Recorder(input, { workerPath: getPublicPathFor(`recorderWorker.js`) }); + this.recorder.record(); + } + + async stopRecording() { + this.recorder.stop(); + } + + async closeStream() { + this.stream?.getTracks().forEach(track => track.stop()); + } + + downloadRecorded(title: string) { + this.recorder.exportWAV((buffer: Blob) => { + let url = URL.createObjectURL(buffer); + let a = document.createElement('a'); + document.body.appendChild(a); + a.style.display = 'none'; + a.href = url; + a.download = `${sanitizeTitle(title)}.wav`; + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }); + } +} diff --git a/src/services/netmd-mock.ts b/src/services/netmd-mock.ts index 42c7de3..1e7109f 100644 --- a/src/services/netmd-mock.ts +++ b/src/services/netmd-mock.ts @@ -4,6 +4,7 @@ import { sleep, sanitizeTitle } from '../utils'; import { assert } from 'netmd-js/dist/utils'; class NetMDMockService implements NetMDService { + public _currentTrack: number = 0; public _tracksTitlesMaxLength = 1700; public _discTitle: string = 'Mock Disc'; public _discCapacity: number = 80 * 60 * 512; @@ -150,6 +151,9 @@ class NetMDMockService implements NetMDService { async play() { console.log('play'); } + async pause() { + console.log('pause'); + } async stop() { console.log('stop'); } @@ -159,6 +163,14 @@ class NetMDMockService implements NetMDService { async prev() { console.log('prev'); } + async gotoTrack(index: number) { + this._currentTrack = index; + await sleep(500); + } + + async getPosition() { + return [this._currentTrack, 0, 0, 1]; + } } export { NetMDMockService }; diff --git a/src/services/netmd.ts b/src/services/netmd.ts index b8a7ec4..f2d9b95 100644 --- a/src/services/netmd.ts +++ b/src/services/netmd.ts @@ -24,9 +24,12 @@ export interface NetMDService { ): Promise; play(): Promise; + pause(): Promise; stop(): Promise; next(): Promise; prev(): Promise; + gotoTrack(index: number): Promise; + getPosition(): Promise; } export class NetMDUSBService implements NetMDService { @@ -144,6 +147,9 @@ export class NetMDUSBService implements NetMDService { async play() { await this.netmdInterface!.play(); } + async pause() { + await this.netmdInterface!.pause(); + } async stop() { await this.netmdInterface!.stop(); } @@ -153,4 +159,10 @@ export class NetMDUSBService implements NetMDService { async prev() { await this.netmdInterface!.previousTrack(); } + async gotoTrack(index: number) { + await this.netmdInterface!.gotoTrack(index); + } + async getPosition() { + return await this.netmdInterface!.getPosition(); + } } diff --git a/src/services/registry.ts b/src/services/registry.ts index 123a7d5..399f4f5 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -1,9 +1,11 @@ import { NetMDService } from './netmd'; import { AudioExportService } from './audio-export'; +import { MediaRecorderService } from './mediarecorder'; interface ServiceRegistry { netmdService?: NetMDService; audioExportService?: AudioExportService; + mediaRecorderService?: MediaRecorderService; } const ServiceRegistry: ServiceRegistry = {}; diff --git a/src/types.d.ts b/src/types.d.ts index ca7c877..ceecc40 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,2 +1,3 @@ declare module '@ffmpeg/ffmpeg'; declare module '@ffmpeg/ffmpeg/src/index'; +declare module 'recorderjs'; diff --git a/src/utils.ts b/src/utils.ts index 092ece9..67beecb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,16 @@ export function sleep(ms: number) { }); } +export async function sleepWithProgressCallback(ms: number, cb: (perc: number) => void) { + let elapsedSecs = 1; + let interval = setInterval(() => { + elapsedSecs++; + cb(Math.min(100, ((elapsedSecs * 1000) / ms) * 100)); + }, 1000); + await sleep(ms); + window.clearInterval(interval); +} + export function useShallowEqualSelector(selector: (state: TState) => TSelected): TSelected { return useSelector(selector, shallowEqual); }