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",
|
||||
"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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -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 { 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>
|
||||
);
|
||||
|
|
|
@ -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 { 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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 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,
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
||||
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 };
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,2 +1,3 @@
|
|||
declare module '@ffmpeg/ffmpeg';
|
||||
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 {
|
||||
return useSelector(selector, shallowEqual);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue