Add new dump-track feature
This commit is contained in:
parent
93eecf8ca0
commit
c606d78753
|
@ -1613,6 +1613,12 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
|
||||||
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ=="
|
"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": {
|
"@types/eslint-visitor-keys": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz",
|
"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"
|
"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": {
|
"recursive-readdir": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz",
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"react-dropzone": "^10.2.1",
|
"react-dropzone": "^10.2.1",
|
||||||
"react-redux": "^7.2.0",
|
"react-redux": "^7.2.0",
|
||||||
"react-scripts": "3.3.1",
|
"react-scripts": "3.3.1",
|
||||||
|
"recorderjs": "^1.0.1",
|
||||||
"redux-batched-actions": "^0.4.1",
|
"redux-batched-actions": "^0.4.1",
|
||||||
"typescript": "~3.7.2",
|
"typescript": "~3.7.2",
|
||||||
"worker-loader": "^2.0.0"
|
"worker-loader": "^2.0.0"
|
||||||
|
@ -65,6 +66,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dom-mediacapture-record": "^1.0.4",
|
||||||
"gh-pages": "^2.2.0"
|
"gh-pages": "^2.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// List of extra files to be precached for offline use.
|
// List of extra files to be precached for offline use.
|
||||||
// WARNING: don't forget to update the revision before deploy
|
// WARNING: don't forget to update the revision before deploy
|
||||||
var revision = "7";
|
var revision = "8";
|
||||||
self.__precacheManifest = (self.__precacheManifest || []).concat([
|
self.__precacheManifest = (self.__precacheManifest || []).concat([
|
||||||
{
|
{
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
|
@ -14,4 +14,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
"url": "/webminidisc/ffmpeg-core.js"
|
"url": "/webminidisc/ffmpeg-core.js"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"revision": revision,
|
||||||
|
"url": "/webminidisc/recorderWorker.js"
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
../node_modules/recorderjs/recorderWorker.js
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -5,6 +5,7 @@ import { useDropzone } from 'react-dropzone';
|
||||||
import { listContent, deleteTracks, moveTrack } from '../redux/actions';
|
import { listContent, deleteTracks, moveTrack } from '../redux/actions';
|
||||||
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
|
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
|
||||||
import { actions as convertDialogActions } from '../redux/convert-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';
|
import { formatTimeFromFrames, getTracks, Encoding } from 'netmd-js';
|
||||||
|
|
||||||
|
@ -32,9 +33,11 @@ import { batchActions } from 'redux-batched-actions';
|
||||||
|
|
||||||
import { RenameDialog } from './rename-dialog';
|
import { RenameDialog } from './rename-dialog';
|
||||||
import { UploadDialog } from './upload-dialog';
|
import { UploadDialog } from './upload-dialog';
|
||||||
|
import { RecordDialog } from './record-dialog';
|
||||||
import { ErrorDialog } from './error-dialog';
|
import { ErrorDialog } from './error-dialog';
|
||||||
import { ConvertDialog } from './convert-dialog';
|
import { ConvertDialog } from './convert-dialog';
|
||||||
import { AboutDialog } from './about-dialog';
|
import { AboutDialog } from './about-dialog';
|
||||||
|
import { DumpDialog } from './dump-dialog';
|
||||||
import { TopMenu } from './topmenu';
|
import { TopMenu } from './topmenu';
|
||||||
import Checkbox from '@material-ui/core/Checkbox';
|
import Checkbox from '@material-ui/core/Checkbox';
|
||||||
import * as BadgeImpl from '@material-ui/core/Badge/Badge';
|
import * as BadgeImpl from '@material-ui/core/Badge/Badge';
|
||||||
|
@ -136,9 +139,13 @@ export const Main = (props: {}) => {
|
||||||
dispatch(moveTrack(selected[0], destIndex));
|
dispatch(moveTrack(selected[0], destIndex));
|
||||||
handleCloseMoveMenu();
|
handleCloseMoveMenu();
|
||||||
},
|
},
|
||||||
[dispatch, selected]
|
[dispatch, selected, handleCloseMoveMenu]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleShowDumpDialog = useCallback(() => {
|
||||||
|
dispatch(dumpDialogActions.setVisible(true));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(listContent());
|
dispatch(listContent());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
@ -248,7 +255,6 @@ export const Main = (props: {}) => {
|
||||||
{disc?.title || `Untitled Disc`}
|
{disc?.title || `Untitled Disc`}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedCount === 1 ? (
|
{selectedCount === 1 ? (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Tooltip title="Move to Position">
|
<Tooltip title="Move to Position">
|
||||||
|
@ -280,6 +286,16 @@ export const Main = (props: {}) => {
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<React.Fragment>
|
||||||
|
<Tooltip title="Record from MD">
|
||||||
|
<Button aria-label="Record" onClick={handleShowDumpDialog}>
|
||||||
|
Record
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</React.Fragment>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
<Tooltip title="Delete">
|
<Tooltip title="Delete">
|
||||||
<IconButton aria-label="delete" onClick={handleDeleteSelected}>
|
<IconButton aria-label="delete" onClick={handleDeleteSelected}>
|
||||||
|
@ -340,6 +356,8 @@ export const Main = (props: {}) => {
|
||||||
<RenameDialog />
|
<RenameDialog />
|
||||||
<ErrorDialog />
|
<ErrorDialog />
|
||||||
<ConvertDialog files={uploadedFiles} />
|
<ConvertDialog files={uploadedFiles} />
|
||||||
|
<RecordDialog />
|
||||||
|
<DumpDialog trackIndexes={selected} />
|
||||||
<AboutDialog />
|
<AboutDialog />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -14,10 +14,12 @@ import App from './components/app';
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { FFMpegAudioExportService } from './services/audio-export';
|
import { FFMpegAudioExportService } from './services/audio-export';
|
||||||
|
import { MediaRecorderService } from './services/mediarecorder';
|
||||||
|
|
||||||
serviceRegistry.netmdService = new NetMDUSBService({ debug: true });
|
serviceRegistry.netmdService = new NetMDUSBService({ debug: true });
|
||||||
// serviceRegistry.netmdService = new NetMDMockService(); // Uncomment to work without a device attached
|
// serviceRegistry.netmdService = new NetMDMockService(); // Uncomment to work without a device attached
|
||||||
serviceRegistry.audioExportService = new FFMpegAudioExportService();
|
serviceRegistry.audioExportService = new FFMpegAudioExportService();
|
||||||
|
serviceRegistry.mediaRecorderService = new MediaRecorderService();
|
||||||
|
|
||||||
(function setupEventHandlers() {
|
(function setupEventHandlers() {
|
||||||
window.addEventListener('beforeunload', ev => {
|
window.addEventListener('beforeunload', ev => {
|
||||||
|
|
|
@ -3,12 +3,13 @@ import { AppDispatch, RootState } from './store';
|
||||||
import { actions as uploadDialogActions } from './upload-dialog-feature';
|
import { actions as uploadDialogActions } from './upload-dialog-feature';
|
||||||
import { actions as renameDialogActions } from './rename-dialog-feature';
|
import { actions as renameDialogActions } from './rename-dialog-feature';
|
||||||
import { actions as errorDialogAction } from './error-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 appStateActions } from './app-feature';
|
||||||
import { actions as mainActions } from './main-feature';
|
import { actions as mainActions } from './main-feature';
|
||||||
import serviceRegistry from '../services/registry';
|
import serviceRegistry from '../services/registry';
|
||||||
import { Wireformat, getTracks } from 'netmd-js';
|
import { Wireformat, getTracks } from 'netmd-js';
|
||||||
import { AnyAction } from '@reduxjs/toolkit';
|
import { AnyAction } from '@reduxjs/toolkit';
|
||||||
import { getAvailableCharsForTrackTitle } from '../utils';
|
import { getAvailableCharsForTrackTitle, framesToSec, sleepWithProgressCallback, sleep } from '../utils';
|
||||||
|
|
||||||
export function pair() {
|
export function pair() {
|
||||||
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
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 } = {
|
export const WireformatDict: { [k: string]: Wireformat } = {
|
||||||
SP: Wireformat.pcm,
|
SP: Wireformat.pcm,
|
||||||
LP2: Wireformat.lp2,
|
LP2: Wireformat.lp2,
|
||||||
|
|
|
@ -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);
|
|
@ -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);
|
|
@ -3,6 +3,8 @@ import uploadDialog from './upload-dialog-feature';
|
||||||
import renameDialog from './rename-dialog-feature';
|
import renameDialog from './rename-dialog-feature';
|
||||||
import errorDialog from './error-dialog-feature';
|
import errorDialog from './error-dialog-feature';
|
||||||
import convertDialog from './convert-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 appState from './app-feature';
|
||||||
import main from './main-feature';
|
import main from './main-feature';
|
||||||
|
|
||||||
|
@ -12,6 +14,8 @@ export const store = configureStore({
|
||||||
uploadDialog,
|
uploadDialog,
|
||||||
errorDialog,
|
errorDialog,
|
||||||
convertDialog,
|
convertDialog,
|
||||||
|
dumpDialog,
|
||||||
|
recordDialog,
|
||||||
appState,
|
appState,
|
||||||
main,
|
main,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import { sleep, sanitizeTitle } from '../utils';
|
||||||
import { assert } from 'netmd-js/dist/utils';
|
import { assert } from 'netmd-js/dist/utils';
|
||||||
|
|
||||||
class NetMDMockService implements NetMDService {
|
class NetMDMockService implements NetMDService {
|
||||||
|
public _currentTrack: number = 0;
|
||||||
public _tracksTitlesMaxLength = 1700;
|
public _tracksTitlesMaxLength = 1700;
|
||||||
public _discTitle: string = 'Mock Disc';
|
public _discTitle: string = 'Mock Disc';
|
||||||
public _discCapacity: number = 80 * 60 * 512;
|
public _discCapacity: number = 80 * 60 * 512;
|
||||||
|
@ -150,6 +151,9 @@ class NetMDMockService implements NetMDService {
|
||||||
async play() {
|
async play() {
|
||||||
console.log('play');
|
console.log('play');
|
||||||
}
|
}
|
||||||
|
async pause() {
|
||||||
|
console.log('pause');
|
||||||
|
}
|
||||||
async stop() {
|
async stop() {
|
||||||
console.log('stop');
|
console.log('stop');
|
||||||
}
|
}
|
||||||
|
@ -159,6 +163,14 @@ class NetMDMockService implements NetMDService {
|
||||||
async prev() {
|
async prev() {
|
||||||
console.log('prev');
|
console.log('prev');
|
||||||
}
|
}
|
||||||
|
async gotoTrack(index: number) {
|
||||||
|
this._currentTrack = index;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPosition() {
|
||||||
|
return [this._currentTrack, 0, 0, 1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { NetMDMockService };
|
export { NetMDMockService };
|
||||||
|
|
|
@ -24,9 +24,12 @@ export interface NetMDService {
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
play(): Promise<void>;
|
play(): Promise<void>;
|
||||||
|
pause(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
next(): Promise<void>;
|
next(): Promise<void>;
|
||||||
prev(): Promise<void>;
|
prev(): Promise<void>;
|
||||||
|
gotoTrack(index: number): Promise<void>;
|
||||||
|
getPosition(): Promise<number[] | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NetMDUSBService implements NetMDService {
|
export class NetMDUSBService implements NetMDService {
|
||||||
|
@ -144,6 +147,9 @@ export class NetMDUSBService implements NetMDService {
|
||||||
async play() {
|
async play() {
|
||||||
await this.netmdInterface!.play();
|
await this.netmdInterface!.play();
|
||||||
}
|
}
|
||||||
|
async pause() {
|
||||||
|
await this.netmdInterface!.pause();
|
||||||
|
}
|
||||||
async stop() {
|
async stop() {
|
||||||
await this.netmdInterface!.stop();
|
await this.netmdInterface!.stop();
|
||||||
}
|
}
|
||||||
|
@ -153,4 +159,10 @@ export class NetMDUSBService implements NetMDService {
|
||||||
async prev() {
|
async prev() {
|
||||||
await this.netmdInterface!.previousTrack();
|
await this.netmdInterface!.previousTrack();
|
||||||
}
|
}
|
||||||
|
async gotoTrack(index: number) {
|
||||||
|
await this.netmdInterface!.gotoTrack(index);
|
||||||
|
}
|
||||||
|
async getPosition() {
|
||||||
|
return await this.netmdInterface!.getPosition();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { NetMDService } from './netmd';
|
import { NetMDService } from './netmd';
|
||||||
import { AudioExportService } from './audio-export';
|
import { AudioExportService } from './audio-export';
|
||||||
|
import { MediaRecorderService } from './mediarecorder';
|
||||||
|
|
||||||
interface ServiceRegistry {
|
interface ServiceRegistry {
|
||||||
netmdService?: NetMDService;
|
netmdService?: NetMDService;
|
||||||
audioExportService?: AudioExportService;
|
audioExportService?: AudioExportService;
|
||||||
|
mediaRecorderService?: MediaRecorderService;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServiceRegistry: ServiceRegistry = {};
|
const ServiceRegistry: ServiceRegistry = {};
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
declare module '@ffmpeg/ffmpeg';
|
declare module '@ffmpeg/ffmpeg';
|
||||||
declare module '@ffmpeg/ffmpeg/src/index';
|
declare module '@ffmpeg/ffmpeg/src/index';
|
||||||
|
declare module 'recorderjs';
|
||||||
|
|
10
src/utils.ts
10
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<TState = RootState, TSelected = unknown>(selector: (state: TState) => TSelected): TSelected {
|
export function useShallowEqualSelector<TState = RootState, TSelected = unknown>(selector: (state: TState) => TSelected): TSelected {
|
||||||
return useSelector(selector, shallowEqual);
|
return useSelector(selector, shallowEqual);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue