Add new dump-track feature

This commit is contained in:
Stefano Brilli 2020-04-10 16:31:15 +02:00
parent 93eecf8ca0
commit c606d78753
18 changed files with 515 additions and 4 deletions

11
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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"
},
]);

1
public/recorderWorker.js Symbolic link
View File

@ -0,0 +1 @@
../node_modules/recorderjs/recorderWorker.js

View File

@ -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 <Slide direction="up" ref={ref} {...props} />;
});
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<string>('');
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 (
<Dialog
open={visible}
maxWidth={'sm'}
fullWidth={true}
TransitionComponent={Transition as any}
aria-labelledby="dump-dialog-slide-title"
aria-describedby="dump-dialog-slide-description"
>
<DialogTitle id="dump-dialog-slide-title">Record Selected Tracks</DialogTitle>
<DialogContent>
<Typography component="p" variant="h2" className={classes.head}>
{`💻 ⬅ 💽`}
</Typography>
<Typography component="p" variant="body2">
1. Connect your MD Player line-out to your PC audio line-in.
</Typography>
<Typography component="p" variant="body2">
2. Use the controls at the bottom right to play some tracks.
</Typography>
<Typography component="p" variant="body2">
3. Select the input source. You should hear the tracks playing on your PC.
</Typography>
<Typography component="p" variant="body2">
4. Adjust the input gain and the line-out volume of your device.
</Typography>
<Box className={classes.container}>
<FormControl className={classes.formControl}>
<Select value={inputDeviceId} onChange={handleChange} displayEmpty className={classes.selectEmpty}>
<MenuItem value="" disabled>
Input Source
</MenuItem>
{devices.map(device => (
<MenuItem key={device.deviceId} value={device.deviceId}>
{device.label}
</MenuItem>
))}
</Select>
<FormHelperText>Input Source</FormHelperText>
</FormControl>
<Controls />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleStartTransfer} disabled={inputDeviceId === ''}>
Start Record
</Button>
</DialogActions>
</Dialog>
);
};

View File

@ -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`}
</Typography>
)}
{selectedCount === 1 ? (
<React.Fragment>
<Tooltip title="Move to Position">
@ -280,6 +286,16 @@ export const Main = (props: {}) => {
</React.Fragment>
) : null}
{selectedCount > 0 ? (
<React.Fragment>
<Tooltip title="Record from MD">
<Button aria-label="Record" onClick={handleShowDumpDialog}>
Record
</Button>
</Tooltip>
</React.Fragment>
) : null}
{selectedCount > 0 ? (
<Tooltip title="Delete">
<IconButton aria-label="delete" onClick={handleDeleteSelected}>
@ -340,6 +356,8 @@ export const Main = (props: {}) => {
<RenameDialog />
<ErrorDialog />
<ConvertDialog files={uploadedFiles} />
<RecordDialog />
<DumpDialog trackIndexes={selected} />
<AboutDialog />
</React.Fragment>
);

View File

@ -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 <Slide direction="up" ref={ref} {...props} />;
});
export const RecordDialog = (props: {}) => {
const classes = useStyles();
let { visible, trackTotal, trackDone, trackCurrent, titleCurrent } = useShallowEqualSelector(state => state.recordDialog);
let progressValue = Math.round(trackCurrent);
return (
<Dialog
open={visible}
maxWidth={'sm'}
fullWidth={true}
TransitionComponent={Transition as any}
aria-labelledby="record-dialog-slide-title"
aria-describedby="record-dialog-slide-description"
>
<DialogTitle id="record-dialog-slide-title">Recording...</DialogTitle>
<DialogContent>
<DialogContentText id="record-dialog-slide-description">
{`Recording track ${trackDone + 1} of ${trackTotal}: ${titleCurrent}`}
</DialogContentText>
<LinearProgress
className={classes.progressBar}
variant={trackCurrent >= 0 ? 'determinate' : 'indeterminate'}
color="primary"
value={progressValue}
/>
<Box className={classes.progressPerc}>{progressValue >= 0 ? `${progressValue}%` : ``}</Box>
</DialogContent>
<DialogActions></DialogActions>
</Dialog>
);
};

View File

@ -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 => {

View File

@ -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,

View File

@ -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<boolean>) => {
state.visible = action.payload;
},
setInputDeviceId: (state, action: PayloadAction<string>) => {
state.inputDeviceId = action.payload;
},
},
});
export const { reducer, actions } = slice;
export default enableBatching(reducer);

View File

@ -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<boolean>) => {
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);

View File

@ -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,
},

View File

@ -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);
});
}
}

View File

@ -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 };

View File

@ -24,9 +24,12 @@ export interface NetMDService {
): Promise<void>;
play(): Promise<void>;
pause(): Promise<void>;
stop(): Promise<void>;
next(): Promise<void>;
prev(): Promise<void>;
gotoTrack(index: number): Promise<void>;
getPosition(): Promise<number[] | null>;
}
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();
}
}

View File

@ -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 = {};

1
src/types.d.ts vendored
View File

@ -1,2 +1,3 @@
declare module '@ffmpeg/ffmpeg';
declare module '@ffmpeg/ffmpeg/src/index';
declare module 'recorderjs';

View File

@ -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<TState = RootState, TSelected = unknown>(selector: (state: TState) => TSelected): TSelected {
return useSelector(selector, shallowEqual);
}