Compare commits

...

32 Commits

Author SHA1 Message Date
Adam Drew 72473709cc
Update directions for linux (#126) 2023-03-02 14:23:04 +01:00
Stefano Brilli cb0ecf25d8 Add web minidisc pro banner 2022-09-29 12:14:40 +02:00
dependabot[bot] 314bf9272a
build(deps): bump moment from 2.29.1 to 2.29.3 (#94)
Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.3.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/2.29.3/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.3)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-18 11:23:19 +02:00
Stefano Brilli 21d0a3be7a
Merge pull request #90 from cybercase/dependabot/npm_and_yarn/lodash-4.17.21
build(deps): bump lodash from 4.17.15 to 4.17.21
2022-03-28 22:19:52 +02:00
Stefano Brilli 4e9860e770
Merge pull request #89 from cybercase/dependabot/npm_and_yarn/follow-redirects-1.14.9
build(deps): bump follow-redirects from 1.10.0 to 1.14.9
2022-03-28 22:19:44 +02:00
Stefano Brilli 05de503bbd
Merge pull request #88 from cybercase/dependabot/npm_and_yarn/node-fetch-2.6.7
build(deps): bump node-fetch from 2.6.1 to 2.6.7
2022-03-28 22:19:35 +02:00
Stefano Brilli 4853480e66
Merge pull request #87 from cybercase/dependabot/npm_and_yarn/simple-get-3.1.1
build(deps): bump simple-get from 3.1.0 to 3.1.1
2022-03-28 22:19:25 +02:00
Stefano Brilli 7ec1ad7850
Merge pull request #85 from cybercase/dependabot/npm_and_yarn/url-parse-1.5.10
build(deps): bump url-parse from 1.4.7 to 1.5.10
2022-03-28 22:19:07 +02:00
dependabot[bot] a800155624
build(deps): bump lodash from 4.17.15 to 4.17.21
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.21.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.21)

---
updated-dependencies:
- dependency-name: lodash
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 20:18:30 +00:00
dependabot[bot] b8bdb0fdfc
build(deps): bump url-parse from 1.4.7 to 1.5.10
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.4.7 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.4.7...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 20:18:29 +00:00
dependabot[bot] 08b000bed5
build(deps): bump follow-redirects from 1.10.0 to 1.14.9
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.10.0 to 1.14.9.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.10.0...v1.14.9)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 20:18:06 +00:00
dependabot[bot] a911df955d
build(deps): bump node-fetch from 2.6.1 to 2.6.7
Bumps [node-fetch](https://github.com/node-fetch/node-fetch) from 2.6.1 to 2.6.7.
- [Release notes](https://github.com/node-fetch/node-fetch/releases)
- [Commits](https://github.com/node-fetch/node-fetch/compare/v2.6.1...v2.6.7)

---
updated-dependencies:
- dependency-name: node-fetch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 20:17:42 +00:00
dependabot[bot] c168a03001
build(deps): bump simple-get from 3.1.0 to 3.1.1
Bumps [simple-get](https://github.com/feross/simple-get) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/feross/simple-get/releases)
- [Commits](https://github.com/feross/simple-get/compare/v3.1.0...v3.1.1)

---
updated-dependencies:
- dependency-name: simple-get
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 20:17:36 +00:00
Stefano Brilli 1bdad5d3e8
Merge pull request #82 from cybercase/dependabot/npm_and_yarn/ajv-6.12.6
build(deps): bump ajv from 6.11.0 to 6.12.6
2022-03-28 22:15:55 +02:00
dependabot[bot] 837149e934
build(deps): bump ajv from 6.11.0 to 6.12.6
Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.11.0 to 6.12.6.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.11.0...v6.12.6)

---
updated-dependencies:
- dependency-name: ajv
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-13 04:45:25 +00:00
Stefano Brilli 94ad175c51 0.2.7 2021-11-18 08:37:14 +01:00
Stefano Brilli 77035f9599 Update netmd-js 2021-11-18 08:36:58 +01:00
Stefano Brilli 6d45e30eba 0.2.6 2021-11-06 11:32:12 +01:00
Stefano Brilli 48af377a33 Update netmd-js to the latest version 2021-11-06 11:23:55 +01:00
Stefano Brilli 8049ed9dff Skip compression of atrac3 wave files 2021-11-01 14:25:06 +01:00
Stefano Brilli 251e0bedcc Update button text 2021-11-01 14:25:06 +01:00
Stefano Brilli c41d69c6c5
Merge pull request #65 from cybercase/asivery_fixes
Asivery fixes
2021-09-23 11:33:35 +02:00
Stefano Brilli db39e9239e Remove some unused vars. Memoize getSortedTracks 2021-09-21 00:27:52 +02:00
Stefano Brilli c647826f58 Add minor optimization in media-session service 2021-09-21 00:17:10 +02:00
Stefano Brilli e63ca90c38 Use MiniDisc icon for album artwork 2021-09-21 00:00:05 +02:00
Stefano Brilli ed96e7a770 Remove unused file 2021-09-20 23:34:37 +02:00
Stefano Brilli 2542fd7bf4 Run prettier 2021-09-20 23:21:34 +02:00
Stefano Brilli 33c0b02973 Moves media-session feature into dedicated service 2021-09-20 23:19:41 +02:00
Stefano Brilli 2c6c1e8b69 Add TS mediasession definitions 2021-09-20 23:03:31 +02:00
Stefano Brilli 039b078c6d
Merge pull request #64 from asivery/master
Create a branch with asivery's fixes
2021-09-18 15:29:42 +02:00
asivery 8f8779a327 Fix: Random crashes related to the mediaSession integration 2021-09-18 01:10:35 +02:00
asivery 852a7129d1 Fixed: half-width problems, pause button in track row, Added: MediaSession integration, support for electron versions of WebMinidisc 2021-09-16 16:28:28 +02:00
20 changed files with 8535 additions and 10033 deletions

View File

@ -12,7 +12,17 @@ Requires *Chrome* or any other browser that supports both **WASM** and **WebUSB*
_it just works ®_ ... no need to download or install any software.
#### Linux
Follow the instructions here [https://github.com/glaubitz/linux-minidisc/tree/master/netmd/etc](https://github.com/glaubitz/linux-minidisc/tree/master/netmd/etc) to grant your user access to the device. If you skip this step you'll likely get an *Access denied* message when trying to connect.
To grant permission to the browser to access the USB device you'll need to add UDEV rules that grant access to a group called `plugdev`. You will also need to ensure your user is added to that `plugdev` group.
1. Copy https://github.com/glaubitz/linux-minidisc/blob/master/netmd/etc/netmd.rules into `/etc/udev/rules.d/70-netmd.rules`
2. Add yourself to the `plugdev` group (create the group if it doesn't exist)
```
$ groupadd plugdev
$ usermod -a -G plugdev YourUserName
```
3. Reboot
4. Plug-in your netmd device after loggin in.
Also note that you will need to use a browser packaged by your distro vendor; browsers installed via snap or flatpak wont work.
#### Windows 10
The Windows USB stack requires a driver to be installed to communicate with any USB device. The bad news is that there are no official Windows 10 drivers for NetMD devices. The good news is that we don't need it!

526
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "webmd",
"version": "0.2.5",
"version": "0.2.7",
"private": true,
"dependencies": {
"@ffmpeg/ffmpeg": "0.6.1",
@ -74,6 +74,7 @@
"devDependencies": {
"@types/dom-mediacapture-record": "^1.0.7",
"@types/react-beautiful-dnd": "^13.1.1",
"@types/wicg-mediasession": "^1.1.3",
"async-mutex": "^0.2.6",
"gh-pages": "^2.2.0"
}

View File

@ -30,8 +30,8 @@
<body>
<script>
try {
if (location.protocol !== 'https:' && !location.host.match(/^localhost/gm)) {
// Make sure we're on https, or WebUSB won't work.
if (location.protocol !== 'file:' && location.protocol !== 'https:' && !location.host.match(/^localhost/gm)) {
// Make sure we're on https/file, or WebUSB won't work.
location.replace(`https:${location.href.substring(location.protocol.length)}`);
console.log('Redirecting to https....');
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useRef, useEffect, useState } from 'react';
import React, { useCallback, useRef, useEffect, useState, useMemo } from 'react';
import clsx from 'clsx';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
@ -134,7 +134,7 @@ export const Controls = () => {
let deviceState = deviceStatus?.state ?? null;
let discPresent = deviceStatus?.discPresent ?? false;
let paused = deviceStatus?.state === 'paused';
const tracks = getSortedTracks(disc);
const tracks = useMemo(() => getSortedTracks(disc), [disc]);
if (!discPresent) {
message = ``;
} else if (deviceState === 'readingTOC') {
@ -143,9 +143,7 @@ export const Controls = () => {
message = `BLANKDISC`;
} else if (deviceStatus && deviceStatus.track !== null && tracks[deviceStatus.track]) {
let title = tracks[deviceStatus.track].fullWidthTitle || tracks[deviceStatus.track].title;
message =
(deviceStatus.track + 1).toString().padStart(3, '0') +
(title ? ' - ' + title : '');
message = (deviceStatus.track + 1).toString().padStart(3, '0') + (title ? ' - ' + title : '');
}
const [lcdScroll, setLcdScroll] = useState(0);

View File

@ -355,7 +355,7 @@ export const ConvertDialog = (props: { files: File[] }) => {
</DialogContent>
<DialogActions>
<Button onClick={handleToggleTracksOrder} className={classes.showTracksOrderBtn}>
{`${tracksOrderVisible ? 'Hide' : 'Show'} Tracks`}
{`${tracksOrderVisible ? 'Hide' : 'Edit'} Tracks`}
</Button>
<div className={classes.spacer}></div>
<Button onClick={handleClose}>Cancel</Button>

View File

@ -328,11 +328,11 @@ export const Main = (props: {}) => {
}
if (deviceStatus.track !== track) {
dispatch(control('goto', track));
if (deviceStatus.state !== 'playing') {
dispatch(control('play'));
}
dispatch(control('play'));
} else if (deviceStatus.state === 'playing') {
dispatch(control('pause'));
} else {
dispatch(control('play'));
}
},
[dispatch, deviceStatus]

View File

@ -17,6 +17,8 @@ import { TopMenu } from './topmenu';
import ChromeIconPath from '../images/chrome-icon.svg';
import { W95Welcome } from './win95/welcome';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import Snackbar from '@material-ui/core/Snackbar';
import Alert from '@material-ui/lab/Alert';
const useStyles = makeStyles(theme => ({
main: {
@ -34,6 +36,9 @@ const useStyles = makeStyles(theme => ({
spacing: {
marginTop: theme.spacing(1),
},
spacing2: {
marginTop: theme.spacing(2),
},
chromeLogo: {
marginTop: theme.spacing(1),
width: 96,
@ -117,6 +122,11 @@ export const Welcome = (props: {}) => {
>
<FormHelperText>{pairingMessage}</FormHelperText>
</FormControl>
<Alert severity="warning" className={classes.spacing2}>
Want an updated version of Web Minidisc? Try{' '}
<Link href="https://web.minidisc.wiki/">Web MiniDisc Pro.</Link>
</Alert>
</div>
<div>
<Typography component="h2" variant="subtitle1" align="center" className={classes.spacing}>

View File

@ -133,7 +133,7 @@ export const W95ConvertDialog = (props: {
) : null}
<DialogFooter>
<Button onClick={props.handleToggleTracksOrder}>{`${props.tracksOrderVisible ? 'Hide' : 'Show'} Tracks`}</Button>
<Button onClick={props.handleToggleTracksOrder}>{`${props.tracksOrderVisible ? 'Hide' : 'Edit'} Tracks`}</Button>
<div style={{ flex: '1 1 auto' }}></div>
<FooterButton onClick={props.handleConvert}>OK</FooterButton>
<FooterButton onClick={props.handleClose}>Cancel</FooterButton>

37
src/create-empty-wave.ts Normal file
View File

@ -0,0 +1,37 @@
export function createEmptyWave(time: number, sampleRate = 22050) {
const numOfChan = 1;
const depthInBytes = 2;
const length = time * sampleRate * numOfChan * depthInBytes + 44;
const buffer = new ArrayBuffer(length);
const view = new DataView(buffer);
let pos = 0;
function setUint16(data: number) {
view.setUint16(pos, data, true);
pos += 2;
}
function setUint32(data: number) {
view.setUint32(pos, data, true);
pos += 4;
}
// write WAVE header
setUint32(0x46464952);
setUint32(length - 8);
setUint32(0x45564157);
setUint32(0x20746d66);
setUint32(16);
setUint16(1);
setUint16(numOfChan);
setUint32(sampleRate);
setUint32(sampleRate * numOfChan * depthInBytes);
setUint16(numOfChan * depthInBytes);
setUint16(depthInBytes * 8);
setUint32(0x61746164);
setUint32(length - pos - 4);
return new Blob([buffer], { type: 'audio/wav' });
}

View File

@ -18,11 +18,13 @@ import './fonts/fonts.css';
import { FFMpegAudioExportService } from './services/audio-export';
import { MediaRecorderService } from './services/mediarecorder';
import { BrowserMediaSessionService } from './services/media-session';
serviceRegistry.netmdService = new NetMDUSBService({ debug: true });
serviceRegistry.netmdService = (window as any).native?.interface || new NetMDUSBService({ debug: true });
// serviceRegistry.netmdService = new NetMDMockService(); // Uncomment to work without a device attached
serviceRegistry.audioExportService = new FFMpegAudioExportService();
serviceRegistry.mediaRecorderService = new MediaRecorderService();
serviceRegistry.mediaSessionService = new BrowserMediaSessionService(store);
(function setupEventHandlers() {
window.addEventListener('beforeunload', ev => {

View File

@ -17,13 +17,14 @@ import {
askNotificationPermission,
getGroupedTracks,
getHalfWidthTitleLength,
timeToSeekArgs,
} from '../utils';
import * as mm from 'music-metadata-browser';
import { TitleFormatType, UploadFormat } from './convert-dialog-feature';
import NotificationCompleteIconUrl from '../images/record-complete-notification-icon.png';
import { assertNumber } from 'netmd-js/dist/utils';
export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pause', params?: unknown) {
export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pause' | 'seek', params?: unknown) {
return async function(dispatch: AppDispatch, getState: () => RootState) {
switch (action) {
case 'play':
@ -41,10 +42,22 @@ export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pa
case 'pause':
await serviceRegistry.netmdService!.pause();
break;
case 'goto':
case 'goto': {
const trackNumber = assertNumber(params, 'Invalid track number for "goto" command');
await serviceRegistry.netmdService!.gotoTrack(trackNumber);
break;
}
case 'seek': {
if (!(params instanceof Object)) {
throw new Error('"seek" command has wrong params');
}
const typedParams: { trackNumber: number; time: number } = params as any;
const trackNumber = assertNumber(typedParams.trackNumber, 'Invalid track number for "seek" command');
const time = assertNumber(typedParams.time, 'Invalid time for "seek" command');
const timeArgs = timeToSeekArgs(time);
await serviceRegistry.netmdService!.gotoTime(trackNumber, timeArgs[0], timeArgs[1], timeArgs[2], timeArgs[3]);
break;
}
}
// CAVEAT: change-track might take a up to a few seconds to complete.
// We wait 500ms and let the monitor do further updates
@ -184,6 +197,7 @@ export function pair() {
return async function(dispatch: AppDispatch, getState: () => RootState) {
dispatch(appStateActions.setPairingFailed(false));
serviceRegistry.mediaSessionService?.init(); // no need to await
await serviceRegistry.audioExportService!.init();
try {
@ -436,10 +450,9 @@ async function getTrackNameFromMediaTags(file: File, titleFormat: TitleFormatTyp
}
}
export function convertAndUpload(files: File[], format: UploadFormat, titleFormat: TitleFormatType) {
export function convertAndUpload(files: File[], requestedFormat: UploadFormat, titleFormat: TitleFormatType) {
return async function(dispatch: AppDispatch, getState: () => RootState) {
const { audioExportService, netmdService } = serviceRegistry;
const wireformat = WireformatDict[format];
await netmdService?.stop();
dispatch(batchActions([uploadDialogActions.setVisible(true), uploadDialogActions.setCancelUpload(false)]));
@ -484,7 +497,7 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
};
let conversionIterator = async function*(files: File[]) {
let converted: Promise<{ file: File; data: ArrayBuffer }>[] = [];
let converted: Promise<{ file: File; data: ArrayBuffer; format: Wireformat }>[] = [];
let i = 0;
function convertNext() {
@ -504,11 +517,12 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
converted.push(
new Promise(async (resolve, reject) => {
let data: ArrayBuffer;
let format: Wireformat;
try {
await audioExportService!.prepare(f);
data = await audioExportService!.export({ format });
({ data, format } = await audioExportService!.export({ requestedFormat }));
convertNext();
resolve({ file: f, data: data });
resolve({ file: f, data: data, format: format });
} catch (err) {
error = err;
errorMessage = `${f.name}: Unsupported or unrecognized format`;
@ -539,7 +553,7 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
break;
}
const { file, data } = item;
const { file, data, format } = item;
let title = file.name;
try {
@ -563,7 +577,7 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
updateTrack();
updateProgressCallback({ written: 0, encrypted: 0, total: 100 });
try {
await netmdService?.upload(halfWidthTitle, fullWidthTitle, data, wireformat, updateProgressCallback);
await netmdService?.upload(halfWidthTitle, fullWidthTitle, data, format, updateProgressCallback);
} catch (err) {
error = err;
errorMessage = `${file.name}: Error uploading to device. There might not be enough space left.`;

View File

@ -50,5 +50,8 @@ export const store = configureStore({
const initialState = Object.freeze(store.getState());
export type AppStore = typeof store;
export type AppSubscribe = typeof store.subscribe;
export type AppGetState = typeof store.getState;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -1,6 +1,8 @@
import { createWorker, setLogging } from '@ffmpeg/ffmpeg';
import { AtracdencProcess } from './atracdenc-worker';
import { getPublicPathFor } from '../utils';
import { getAtrac3Info, getPublicPathFor } from '../utils';
import { Wireformat } from 'netmd-js';
import { WireformatDict } from '../redux/actions';
const AtracdencWorker = require('worker-loader!./atracdenc-worker'); // eslint-disable-line import/no-webpack-loader-syntax
interface LogPayload {
@ -10,7 +12,7 @@ interface LogPayload {
export interface AudioExportService {
init(): Promise<void>;
export(params: { format: string }): Promise<ArrayBuffer>;
export(params: { requestedFormat: 'SP' | 'LP2' | 'LP4' }): Promise<{ data: ArrayBuffer; format: Wireformat }>;
info(): Promise<{ format: string | null; input: string | null }>;
prepare(file: File): Promise<void>;
}
@ -21,12 +23,14 @@ export class FFMpegAudioExportService implements AudioExportService {
public loglines: { action: string; message: string }[] = [];
public inFileName: string = ``;
public outFileNameNoExt: string = ``;
public inFile?: File;
async init() {
setLogging(true);
}
async prepare(file: File) {
this.inFile = file;
this.loglines = [];
this.ffmpegProcess = createWorker({
logger: (payload: LogPayload) => {
@ -79,33 +83,45 @@ export class FFMpegAudioExportService implements AudioExportService {
return { format, input };
}
async export({ format }: { format: string }) {
async export({ requestedFormat }: { requestedFormat: 'SP' | 'LP2' | 'LP4' | 'LP105' }) {
let result: ArrayBuffer;
if (format === `SP`) {
let format: Wireformat;
const atrac3Info = await getAtrac3Info(this.inFile!);
if (atrac3Info) {
format = WireformatDict[atrac3Info.mode];
result = (await this.inFile!.arrayBuffer()).slice(atrac3Info.dataOffset);
} else if (requestedFormat === `SP`) {
const outFileName = `${this.outFileNameNoExt}.raw`;
await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-ac 2 -ar 44100 -f s16be');
let { data } = await this.ffmpegProcess.read(outFileName);
result = data.buffer;
format = Wireformat.pcm;
} else {
const outFileName = `${this.outFileNameNoExt}.wav`;
await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-f wav -ar 44100 -ac 2');
let { data } = await this.ffmpegProcess.read(outFileName);
let bitrate: string = `0`;
switch (format) {
switch (requestedFormat) {
case `LP2`:
bitrate = `128`;
format = Wireformat.lp2;
break;
case `LP105`:
bitrate = `102`;
format = Wireformat.l105kbps;
break;
case `LP4`:
bitrate = `64`;
format = Wireformat.lp4;
break;
}
result = await this.atracdencProcess!.encode(data.buffer, bitrate);
}
this.ffmpegProcess.worker.terminate();
this.atracdencProcess!.terminate();
return result;
return {
data: result,
format,
};
}
}

View File

@ -0,0 +1,161 @@
import { debounce, DisplayTrack, getSortedTracks, sleep } from '../utils';
import { createEmptyWave } from '../create-empty-wave';
import { control } from '../redux/actions';
import { AppStore, AppSubscribe, AppGetState } from '../redux/store';
import { Dispatch } from '@reduxjs/toolkit';
import { Disc } from 'netmd-js';
export interface MediaSessionService {
init(): Promise<void>;
}
export class BrowserMediaSessionService {
private initialized = false;
private audioEl?: HTMLAudioElement;
private dispatch: Dispatch<any>; // CAVEAT: AppDispatch type doesn't have an overload for redux thunk actions
private subscribe: AppSubscribe;
private getState: AppGetState;
constructor(appStore: AppStore) {
this.dispatch = appStore.dispatch.bind(appStore) as Dispatch<any>;
this.subscribe = appStore.subscribe.bind(appStore);
this.getState = appStore.getState.bind(appStore);
}
async init() {
if (this.initialized || !navigator.mediaSession) {
return;
}
this.initialized = true;
// Audio el
const audioEl = document.createElement('audio');
audioEl.id = 'browser-media-session-helper';
document.body.appendChild(audioEl);
audioEl.setAttribute('loop', 'true');
audioEl.src = URL.createObjectURL(createEmptyWave(6));
audioEl.volume = 0;
this.audioEl = audioEl;
// Blocks media session events during initialization
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('previoustrack', null);
navigator.mediaSession.setActionHandler('nexttrack', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.setActionHandler('seekto', null);
navigator.mediaSession.metadata = null;
audioEl.play();
await sleep(5000); // CAVEAT: 5secs is the minimum playing time for media info to show up
audioEl.pause();
if (this.getState().main.deviceStatus?.state === 'playing') {
// restore current state
audioEl.play();
}
console.log('MediaSession ready');
// Set mediaSession event handlers
navigator.mediaSession.setActionHandler('previoustrack', () => {
this.dispatch(control('prev'));
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
this.dispatch(control('next'));
});
navigator.mediaSession.setActionHandler('pause', () => {
audioEl.pause();
this.dispatch(control('pause'));
});
navigator.mediaSession.setActionHandler('play', () => {
audioEl.play();
this.dispatch(control('play'));
});
const debouncedSeek = debounce((time: number, trackNumber: number) => {
this.dispatch(control('seek', { time, trackNumber }));
audioEl.currentTime = time;
}, 100);
navigator.mediaSession.setActionHandler('seekto', details => {
const trackNumber = this.getState().main.deviceStatus?.track ?? -1;
if (trackNumber === -1 || details.seekTime === null) {
return; // can't seek without knowing the track number or the seek time
}
debouncedSeek(details.seekTime, trackNumber);
});
this.subscribe(() => {
this.syncState();
});
}
// This will save cpu cycles when just playing music. Might replace in the future with some memoization library
private sortedTracks: DisplayTrack[] = [];
private sortedTracksDisc: Disc | null = null;
getSortedTracksWithCache(disc: Disc | null) {
if (disc !== this.sortedTracksDisc) {
this.sortedTracks = getSortedTracks(disc);
this.sortedTracksDisc = disc;
}
return this.sortedTracks;
}
syncState() {
if (!this.initialized) {
return;
}
const audioEl = this.audioEl!;
const {
main: { deviceStatus, disc },
} = this.getState();
const isPlaying = deviceStatus?.state === 'playing';
const currentDiscTitle = disc?.title;
const currentTrackIndex = deviceStatus?.track ?? -1;
const allTracks = this.getSortedTracksWithCache(disc);
const currentTrack: DisplayTrack | undefined = allTracks[currentTrackIndex];
const currentTrackTitle = currentTrack ? currentTrack.fullWidthTitle || currentTrack?.title : '';
const currentTrackDurationInSecs = Math.round(currentTrack?.durationInSecs ?? -1);
const oldTrackTitle = navigator.mediaSession.metadata?.title;
const oldDiscTitle = navigator.mediaSession.metadata?.album;
// Sync MmediaSession
if (isPlaying && navigator.mediaSession.playbackState !== 'playing') {
navigator.mediaSession.playbackState = 'playing';
} else if (!isPlaying && navigator.mediaSession.playbackState !== 'paused') {
navigator.mediaSession.playbackState = 'paused';
}
// Sync MediaMetadata
if (oldTrackTitle !== currentTrackTitle || oldDiscTitle !== currentDiscTitle) {
navigator.mediaSession.metadata = new MediaMetadata({
title: currentTrackTitle,
album: currentDiscTitle,
artwork: [
{ src: window.location.pathname + 'MiniDisc192.png', sizes: '192x192', type: 'image/png' },
{ src: window.location.pathname + 'MiniDisc512.png', sizes: '512x512', type: 'image/png' },
],
});
}
// Sync audio duration.
// CAVEAT: replacing the src may change the audioEl paused state.
if (audioEl && audioEl.duration !== currentTrackDurationInSecs && isPlaying) {
URL.revokeObjectURL(audioEl.src ?? '');
audioEl.src = URL.createObjectURL(createEmptyWave(Math.max(currentTrackDurationInSecs, 1)));
}
// Sync <audio> state
if (isPlaying && audioEl.paused) {
audioEl.play();
} else if (!isPlaying && !audioEl.paused) {
audioEl.pause();
}
}
}

View File

@ -13,7 +13,7 @@ class NetMDMockService implements NetMDService {
public _discCapacity: number = 80 * 60 * 512;
public _tracks: Track[] = [
{
duration: 5 * 60 * 512,
duration: 3 * 60 * 512,
encoding: Encoding.sp,
index: 0,
channel: Channels.stereo,
@ -360,6 +360,11 @@ class NetMDMockService implements NetMDService {
await sleep(500);
}
async gotoTime(index: number, hour = 0, minute = 0, second = 0, frame = 0) {
this._status.track = index;
await sleep(500);
}
async getPosition() {
if (this._status.track === null || this._status.time === null) {
return null;

View File

@ -58,6 +58,7 @@ export interface NetMDService {
next(): Promise<void>;
prev(): Promise<void>;
gotoTrack(index: number): Promise<void>;
gotoTime(index: number, hour: number, minute: number, second: number, frame: number): Promise<void>;
getPosition(): Promise<number[] | null>;
}
@ -384,6 +385,11 @@ export class NetMDUSBService implements NetMDService {
await this.netmdInterface!.gotoTrack(index);
}
@asyncMutex
async gotoTime(index: number, h: number, m: number, s: number, f: number) {
await this.netmdInterface!.gotoTime(index, h, m, s, f);
}
@asyncMutex
async getPosition() {
return await this.netmdInterface!.getPosition();

View File

@ -1,11 +1,13 @@
import { NetMDService } from './netmd';
import { AudioExportService } from './audio-export';
import { MediaRecorderService } from './mediarecorder';
import { MediaSessionService } from './media-session';
interface ServiceRegistry {
netmdService?: NetMDService;
audioExportService?: AudioExportService;
mediaRecorderService?: MediaRecorderService;
mediaSessionService?: MediaSessionService;
}
const ServiceRegistry: ServiceRegistry = {};

View File

@ -12,6 +12,17 @@ export function sleep(ms: number) {
});
}
export function debounce<T extends Function>(func: T, timeout = 300): T {
let timer: ReturnType<typeof setTimeout>;
const debouncedFn = (...args: any) => {
clearTimeout(timer);
timer = setTimeout(() => {
func(...args);
}, timeout);
};
return (debouncedFn as any) as T;
}
export async function sleepWithProgressCallback(ms: number, cb: (perc: number) => void) {
let elapsedSecs = 1;
let interval = setInterval(() => {
@ -84,6 +95,20 @@ export function framesToSec(frames: number) {
return frames / 512;
}
export function timeToSeekArgs(timeInSecs: number): number[] {
let value = Math.round(timeInSecs); // ignore frames
let s = value % 60;
value = (value - s) / 60; // min
let m = value % 60;
value = (value - m) / 60; // hour
let h = value;
return [h, m, s, 0];
}
export function sanitizeTitle(title: string) {
return title.normalize('NFD').replace(/[^\x00-\x7F]/g, '');
}
@ -91,6 +116,7 @@ export function sanitizeTitle(title: string) {
export function getHalfWidthTitleLength(title: string) {
// Some characters are written as 2 bytes
// prettier-ignore
// '\u309C': -1, '\uFF9F': -1, '\u309B': -1, '\uFF9E': -1 but when they become part of a multi byte character, it will sum up to 0
const multiByteChars: { [key: string]: number } = { "ガ": 1, "ギ": 1, "グ": 1, "ゲ": 1, "ゴ": 1, "ザ": 1, "ジ": 1, "ズ": 1, "ゼ": 1, "ゾ": 1, "ダ": 1, "ヂ": 1, "ヅ": 1, "デ": 1, "ド": 1, "バ": 1, "パ": 1, "ビ": 1, "ピ": 1, "ブ": 1, "プ": 1, "ベ": 1, "ペ": 1, "ボ": 1, "ポ": 1, "ヮ": 1, "ヰ": 1, "ヱ": 1, "ヵ": 1, "ヶ": 1, "ヴ": 1, "ヽ": 1, "ヾ": 1, "が": 1, "ぎ": 1, "ぐ": 1, "げ": 1, "ご": 1, "ざ": 1, "じ": 1, "ず": 1, "ぜ": 1, "ぞ": 1, "だ": 1, "ぢ": 1, "づ": 1, "で": 1, "ど": 1, "ば": 1, "ぱ": 1, "び": 1, "ぴ": 1, "ぶ": 1, "ぷ": 1, "べ": 1, "ぺ": 1, "ぼ": 1, "ぽ": 1, "ゎ": 1, "ゐ": 1, "ゑ": 1, "ゕ": 1, "ゖ": 1, "ゔ": 1, "ゝ": 1, "ゞ": 1 };
return (
title.length +
@ -102,8 +128,59 @@ export function getHalfWidthTitleLength(title: string) {
}
export function sanitizeHalfWidthTitle(title: string) {
enum CharType {
normal,
dakuten,
handakuten,
}
const handakutenPossible = 'はひふへほハヒフヘホ'.split('');
const dakutenPossible = 'かきくけこさしすせそたちつてとカキクケコサシスセソタチツテト'.split('').concat(handakutenPossible);
//'Flatten' all the characters followed by the (han)dakuten character into one
let dakutenFix = [];
let type = CharType.normal;
for (const char of sanitizeFullWidthTitle(title, true)
.split('')
.reverse()) {
//This only works for full-width kana. It will get converted to half-width later anyway...
switch (type) {
case CharType.dakuten:
if (dakutenPossible.includes(char)) {
dakutenFix.push(String.fromCharCode(char.charCodeAt(0) + 1));
type = CharType.normal;
break;
} //Else fall through
case CharType.handakuten:
if (handakutenPossible.includes(char)) {
dakutenFix.push(String.fromCharCode(char.charCodeAt(0) + 2));
type = CharType.normal;
break;
} //Else fall through
case CharType.normal:
switch (char) {
case '\u309B':
case '\u3099':
case '\uFF9E':
type = CharType.dakuten;
break;
case '\u309C':
case '\u309A':
case '\uFF9F':
type = CharType.handakuten;
break;
default:
type = CharType.normal;
dakutenFix.push(char);
break;
}
break;
}
}
title = dakutenFix.reverse().join('');
// prettier-ignore
const mappings: { [key: string]: string } = { 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', '': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '': '!', '': '"', '': '#', '': '$', '': '%', '': '&', '': "'", '': '(', '': ')', '': '*', '': '+', '': ',', '': '-', '': '.', '': '/', '': ':', '': ';', '': '<', '': '=', '': '>', '': '?', '': '@', '': 'A', '': 'B', '': 'C', '': 'D', '': 'E', '': 'F', '': 'G', '': 'H', '': 'I', '': 'J', '': 'K', '': 'L', '': 'M', '': 'N', '': 'O', '': 'P', '': 'Q', '': 'R', '': 'S', '': 'T', '': 'U', '': 'V', '': 'W', '': 'X', '': 'Y', '': 'Z', '': '[', '': '\\', '': ']', '': '^', '_': '_', '': '`', '': 'a', '': 'b', '': 'c', '': 'd', '': 'e', '': 'f', '': 'g', '': 'h', '': 'i', '': 'j', '': 'k', '': 'l', '': 'm', '': 'n', '': 'o', '': 'p', '': 'q', '': 'r', '': 's', '': 't', '': 'u', '': 'v', '': 'w', '': 'x', '': 'y', '': 'z', '': '{', '': '|', '': '}', '': '~', '\u3000': ' ', '': '0', '': '1', '': '2', '': '3', '': '4', '': '5', '': '6', '': '7', '': '8', '': '9', 'ぁ': 'ァ', 'あ': 'ア', 'ぃ': 'ィ', 'い': 'イ', 'ぅ': 'ゥ', 'う': 'ウ', 'ぇ': 'ェ', 'え': 'エ', 'ぉ': 'ォ', 'お': 'オ', 'か': 'カ', 'が': 'ガ', 'き': 'キ', 'ぎ': 'ギ', 'く': 'ク', 'ぐ': 'グ', 'け': 'ケ', 'げ': 'ゲ', 'こ': 'コ', 'ご': 'ゴ', 'さ': 'サ', 'ざ': 'ザ', 'し': 'シ', 'じ': 'ジ', 'す': 'ス', 'ず': 'ズ', 'せ': 'セ', 'ぜ': 'ゼ', 'そ': 'ソ', 'ぞ': 'ゾ', 'た': 'タ', 'だ': 'ダ', 'ち': 'チ', 'ぢ': 'ヂ', 'っ': 'ッ', 'つ': 'ツ', 'づ': 'ヅ', 'て': 'テ', 'で': 'デ', 'と': 'ト', 'ど': 'ド', 'な': 'ナ', 'に': 'ニ', 'ぬ': 'ヌ', 'ね': 'ネ', 'の': 'ノ', 'は': 'ハ', 'ば': 'バ', 'ぱ': 'パ', 'ひ': 'ヒ', 'び': 'ビ', 'ぴ': 'ピ', 'ふ': 'フ', 'ぶ': 'ブ', 'ぷ': 'プ', 'へ': 'ヘ', 'べ': 'ベ', 'ぺ': 'ペ', 'ほ': 'ホ', 'ぼ': 'ボ', 'ぽ': 'ポ', 'ま': 'マ', 'み': 'ミ', 'む': 'ム', 'め': 'メ', 'も': 'モ', 'ゃ': 'ャ', 'や': 'ヤ', 'ゅ': 'ュ', 'ゆ': 'ユ', 'ょ': 'ョ', 'よ': 'ヨ', 'ら': 'ラ', 'り': 'リ', 'る': 'ル', 'れ': 'レ', 'ろ': 'ロ', 'わ': 'ワ', 'を': 'ヲ', 'ん': 'ン', 'ゎ': 'ヮ', 'ゐ': 'ヰ', 'ゑ': 'ヱ', 'ゕ': 'ヵ', 'ゖ': 'ヶ', 'ゔ': 'ヴ', 'ゝ': 'ヽ', 'ゞ': 'ヾ' };
const mappings: { [key: string]: string } = { '': '-', 'ー': '-', 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', '': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': '-', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '': '!', '': '"', '': '#', '': '$', '': '%', '': '&', '': "'", '': '(', '': ')', '': '*', '': '+', '': ',', '': '.', '': '/', '': ':', '': ';', '': '<', '': '=', '': '>', '': '?', '': '@', '': 'A', '': 'B', '': 'C', '': 'D', '': 'E', '': 'F', '': 'G', '': 'H', '': 'I', '': 'J', '': 'K', '': 'L', '': 'M', '': 'N', '': 'O', '': 'P', '': 'Q', '': 'R', '': 'S', '': 'T', '': 'U', '': 'V', '': 'W', '': 'X', '': 'Y', '': 'Z', '': '[', '': '\\', '': ']', '': '^', '_': '_', '': '`', '': 'a', '': 'b', '': 'c', '': 'd', '': 'e', '': 'f', '': 'g', '': 'h', '': 'i', '': 'j', '': 'k', '': 'l', '': 'm', '': 'n', '': 'o', '': 'p', '': 'q', '': 'r', '': 's', '': 't', '': 'u', '': 'v', '': 'w', '': 'x', '': 'y', '': 'z', '': '{', '': '|', '': '}', '': '~', '\u3000': ' ', '': '0', '': '1', '': '2', '': '3', '': '4', '': '5', '': '6', '': '7', '': '8', '': '9', 'ぁ': 'ァ', 'あ': 'ア', 'ぃ': 'ィ', 'い': 'イ', 'ぅ': 'ゥ', 'う': 'ウ', 'ぇ': 'ェ', 'え': 'エ', 'ぉ': 'ォ', 'お': 'オ', 'か': 'カ', 'が': 'ガ', 'き': 'キ', 'ぎ': 'ギ', 'く': 'ク', 'ぐ': 'グ', 'け': 'ケ', 'げ': 'ゲ', 'こ': 'コ', 'ご': 'ゴ', 'さ': 'サ', 'ざ': 'ザ', 'し': 'シ', 'じ': 'ジ', 'す': 'ス', 'ず': 'ズ', 'せ': 'セ', 'ぜ': 'ゼ', 'そ': 'ソ', 'ぞ': 'ゾ', 'た': 'タ', 'だ': 'ダ', 'ち': 'チ', 'ぢ': 'ヂ', 'っ': 'ッ', 'つ': 'ツ', 'づ': 'ヅ', 'て': 'テ', 'で': 'デ', 'と': 'ト', 'ど': 'ド', 'な': 'ナ', 'に': 'ニ', 'ぬ': 'ヌ', 'ね': 'ネ', 'の': 'ノ', 'は': 'ハ', 'ば': 'バ', 'ぱ': 'パ', 'ひ': 'ヒ', 'び': 'ビ', 'ぴ': 'ピ', 'ふ': 'フ', 'ぶ': 'ブ', 'ぷ': 'プ', 'へ': 'ヘ', 'べ': 'ベ', 'ぺ': 'ペ', 'ほ': 'ホ', 'ぼ': 'ボ', 'ぽ': 'ポ', 'ま': 'マ', 'み': 'ミ', 'む': 'ム', 'め': 'メ', 'も': 'モ', 'ゃ': 'ャ', 'や': 'ヤ', 'ゅ': 'ュ', 'ゆ': 'ユ', 'ょ': 'ョ', 'よ': 'ヨ', 'ら': 'ラ', 'り': 'リ', 'る': 'ル', 'れ': 'レ', 'ろ': 'ロ', 'わ': 'ワ', 'を': 'ヲ', 'ん': 'ン', 'ゎ': 'ヮ', 'ゐ': 'ヰ', 'ゑ': 'ヱ', 'ゕ': 'ヵ', 'ゖ': 'ヶ', 'ゔ': 'ヴ', 'ゝ': 'ヽ', 'ゞ': 'ヾ' };
const allowedHalfWidthKana: string[] = Object.values(mappings);
const newTitle = title
@ -120,7 +197,7 @@ export function sanitizeHalfWidthTitle(title: string) {
return newTitle;
}
export function sanitizeFullWidthTitle(title: string) {
export function sanitizeFullWidthTitle(title: string, justRemap: boolean = false) {
// prettier-ignore
const mappings: { [key: string]: string } = { '!': '', '"': '', '#': '', '$': '', '%': '', '&': '', "'": '', '(': '', ')': '', '*': '', '+': '', ',': '', '-': '', '.': '', '/': '', ':': '', ';': '', '<': '', '=': '', '>': '', '?': '', '@': '', 'A': '', 'B': '', 'C': '', 'D': '', 'E': '', 'F': '', 'G': '', 'H': '', 'I': '', 'J': '', 'K': '', 'L': '', 'M': '', 'N': '', 'O': '', 'P': '', 'Q': '', 'R': '', 'S': '', 'T': '', 'U': '', 'V': '', 'W': '', 'X': '', 'Y': '', 'Z': '', '[': '', '\\': '', ']': '', '^': '', '_': '_', '`': '', 'a': '', 'b': '', 'c': '', 'd': '', 'e': '', 'f': '', 'g': '', 'h': '', 'i': '', 'j': '', 'k': '', 'l': '', 'm': '', 'n': '', 'o': '', 'p': '', 'q': '', 'r': '', 's': '', 't': '', 'u': '', 'v': '', 'w': '', 'x': '', 'y': '', 'z': '', '{': '', '|': '', '}': '', '~': '', ' ': '\u3000', '0': '', '1': '', '2': '', '3': '', '4': '', '5': '', '6': '', '7': '', '8': '', '9': '', 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': '', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、' };
@ -129,6 +206,8 @@ export function sanitizeFullWidthTitle(title: string) {
.map(n => mappings[n] ?? n)
.join('');
if (justRemap) return newTitle;
const sjisEncoded = jconv.encode(newTitle, 'SJIS');
if (jconv.decode(sjisEncoded, 'SJIS') !== newTitle) return sanitizeTitle(title); // Fallback
if (sjisEncoded.length !== title.length * 2) return sanitizeTitle(title); // Fallback (every character in the full-width title is 2 bytes)
@ -148,6 +227,7 @@ export type DisplayTrack = {
group: string | null;
duration: string;
encoding: string;
durationInSecs: number;
};
export function getSortedTracks(disc: Disc | null) {
@ -162,6 +242,7 @@ export function getSortedTracks(disc: Disc | null) {
group: group.title ?? null,
encoding: EncodingName[track.encoding],
duration: formatTimeFromFrames(track.duration, false),
durationInSecs: track.duration / 512, // CAVEAT: 1s = 512 frames
});
}
}
@ -391,4 +472,79 @@ export function askNotificationPermission(): Promise<NotificationPermission> {
}
}
export async function getAtrac3Info(file: File) {
// see: http://soundfile.sapp.org/doc/WaveFormat/
// and: https://www.fatalerrors.org/a/detailed-explanation-of-wav-file-format.html
const fileData = await file.arrayBuffer();
if (fileData.byteLength < 44) {
return null;
}
const riffDescriptor = new Uint32Array(fileData.slice(0, 12));
if (riffDescriptor[0] !== 0x46464952 || riffDescriptor[2] !== 0x45564157) {
// 'RIFF' && 'WAVE'
return null;
}
// WAVE format
const waveDescriptor = new Uint32Array(fileData.slice(12, 20));
if (waveDescriptor[0] !== 0x20746d66) {
return false;
}
const audioFormatAndChanneld = new Uint16Array(fileData.slice(20, 24));
if (audioFormatAndChanneld[0] !== 0x270 || audioFormatAndChanneld[1] !== 2) {
// 'atrac3' && 2 channels
return null;
}
const sampleRateAndByteRate = new Uint32Array(fileData.slice(24, 32));
if (sampleRateAndByteRate[0] !== 44100) {
// Sample rate
return null;
}
const byteRate = sampleRateAndByteRate[1];
let mode: 'LP2' | 'LP105' | 'LP4' | null = null;
if (byteRate > 16000) {
mode = 'LP2';
} else if (byteRate > 13000) {
mode = 'LP105';
} else if (byteRate > 8000) {
mode = 'LP4';
} else {
mode = null;
}
if (mode === null) {
return null;
}
const waveBlockEndOffset = new Uint16Array(fileData.slice(36, 38));
let dataOffset = -1;
const nextBlockStartOffset = waveBlockEndOffset[0] + 38;
const nextBlockEndOffset = nextBlockStartOffset + 8;
const nextBlock = new Uint32Array(fileData.slice(nextBlockStartOffset, nextBlockEndOffset));
if (nextBlock[0] === 0x61746164) {
// data
dataOffset = nextBlockEndOffset;
} else if (nextBlock[0] === 0x74636166) {
// fact
const dataBlockLength = 8;
dataOffset = nextBlockEndOffset + nextBlock[1] + dataBlockLength;
}
if (dataOffset === -1) {
return null;
}
return {
mode,
dataOffset,
};
}
declare let process: any;

17555
yarn.lock

File diff suppressed because it is too large Load Diff