From 15763b360e6b7c10d080c93615643440407b1c0b Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Wed, 10 Apr 2024 20:25:05 +0800 Subject: [PATCH] Update settings Define a new window for settings. --- docs/screenshots/screenshot-00.png | Bin 17197 -> 17161 bytes src/common/config.ts | 40 ++ src/main/index.ts | 164 ++++++-- src/renderer/src/App.tsx | 390 ------------------ .../src/components/Activity/index.tsx | 68 +++ .../src/components/DropdownInput/index.tsx | 20 + .../components/MidiDeviceProvider/index.tsx | 0 .../src/components/PedalBoard/index.tsx | 72 ++++ .../src/components/SettingsForm/index.tsx | 149 +++++++ src/renderer/src/hooks/form.ts | 18 + src/renderer/src/hooks/midi.ts | 199 +++++++++ src/renderer/src/main.tsx | 30 +- src/renderer/src/pages/index.tsx | 83 ++++ src/renderer/src/pages/settings.tsx | 43 ++ src/renderer/src/utils/midi.ts | 21 + tsconfig.node.json | 2 +- tsconfig.web.json | 1 + 17 files changed, 860 insertions(+), 440 deletions(-) create mode 100644 src/common/config.ts delete mode 100644 src/renderer/src/App.tsx create mode 100644 src/renderer/src/components/Activity/index.tsx create mode 100644 src/renderer/src/components/DropdownInput/index.tsx create mode 100644 src/renderer/src/components/MidiDeviceProvider/index.tsx create mode 100644 src/renderer/src/components/PedalBoard/index.tsx create mode 100644 src/renderer/src/components/SettingsForm/index.tsx create mode 100644 src/renderer/src/hooks/form.ts create mode 100644 src/renderer/src/hooks/midi.ts create mode 100644 src/renderer/src/pages/index.tsx create mode 100644 src/renderer/src/pages/settings.tsx create mode 100644 src/renderer/src/utils/midi.ts diff --git a/docs/screenshots/screenshot-00.png b/docs/screenshots/screenshot-00.png index 3a4dc3feb8481415351ad807a6c82114b105f333..2fc3b59123de122f0f6b812c95dafe9e9e4306a5 100644 GIT binary patch literal 17161 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 Jf9l%3{|2krlJ@`r literal 17197 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 { const [startKeyRaw, endKeyRaw] = range.split('|') @@ -43,10 +31,10 @@ const getNaturalKeys = (range: string): number => { return naturalKeys } -function createWindow(config: Config): void { +const createMainWindow = async (config: Config): Promise => { // Create the browser window. const naturalKeys = getNaturalKeys(config.range) - const width = naturalKeys * naturalKeyWidth + Number(config.scaleFactor) + const width = naturalKeys * Number(config.naturalKeyWidth || 20) + Number(config.scaleFactor) const mainWindow = new BrowserWindow({ width, height, @@ -66,7 +54,7 @@ function createWindow(config: Config): void { maxWidth: is.dev ? undefined : width, fullscreenable: false, resizable: is.dev, - useContentSize: true, + useContentSize: true }) mainWindow.on('ready-to-show', () => { @@ -78,6 +66,17 @@ function createWindow(config: Config): void { return { action: 'deny' } }) + mainWindow.webContents.session.setPermissionRequestHandler( + (_webContents, permission, callback) => { + if (permission === 'midi') { + callback(true) + return + } + + callback(false) + } + ) + let search: URLSearchParams if (is.dev && process.env['ELECTRON_RENDERER_URL']) { const url = new URL(process.env['ELECTRON_RENDERER_URL']) @@ -85,18 +84,21 @@ function createWindow(config: Config): void { Object.entries(config).forEach(([key, value]) => { search.set(key, value) }) + search.set('window', 'main') url.search = search.toString() - void mainWindow.loadURL(url.toString()) - return + await mainWindow.loadURL(url.toString()) + return mainWindow } search = new URLSearchParams() Object.entries(config).forEach(([key, value]) => { search.set(key, value) }) - void mainWindow.loadFile(join(__dirname, '../renderer/index.html'), { + search.set('window', 'main') + await mainWindow.loadFile(join(__dirname, '../renderer/index.html'), { search: search.toString() }) + return mainWindow } app.whenReady().then(async () => { @@ -121,41 +123,117 @@ app.whenReady().then(async () => { optimizer.watchWindowShortcuts(window) }) - ipcMain.on('querydevicekeychange', async (_event, value) => { - effectiveConfig.queryDeviceKey = value - await writeFile(configPath, JSON.stringify(effectiveConfig)) - }) + const createSettingsWindow = async ( + parent: BrowserWindow, + config: Config + ): Promise => { + const settingsWindow = new BrowserWindow({ + width: 360, + height: 640, + show: false, + autoHideMenuBar: true, + ...(process.platform === 'linux' ? { icon } : {}), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + devTools: is.dev + }, + maximizable: false, + minimizable: false, + fullscreenable: false, + resizable: false, + useContentSize: true, + parent + }) - ipcMain.on('scalefactorchange', async (event, value) => { - effectiveConfig.scaleFactor = value - await writeFile(configPath, JSON.stringify(effectiveConfig)) - await writeFile(configPath, JSON.stringify(effectiveConfig)) - const webContents = event.sender - const win = BrowserWindow.fromWebContents(webContents) - if (!win) { - return + settingsWindow.on('ready-to-show', () => { + settingsWindow.show() + }) + + settingsWindow.webContents.setWindowOpenHandler((details) => { + void shell.openExternal(details.url) + return { action: 'deny' } + }) + + settingsWindow.webContents.session.setPermissionRequestHandler( + (_webContents, permission, callback) => { + if (permission === 'midi') { + callback(true) + return + } + + callback(false) + } + ) + + let search: URLSearchParams + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + const url = new URL(process.env['ELECTRON_RENDERER_URL']) + search = new URLSearchParams(url.searchParams) + Object.entries(config).forEach(([key, value]) => { + search.set(key, value) + }) + search.set('window', 'settings') + url.search = search.toString() + await settingsWindow.loadURL(url.toString()) + return settingsWindow } - win.close() - createWindow(effectiveConfig) - }) - ipcMain.on('rangechange', async (event, value) => { - effectiveConfig.range = value - await writeFile(configPath, JSON.stringify(effectiveConfig)) + search = new URLSearchParams() + Object.entries(config).forEach(([key, value]) => { + search.set(key, value) + }) + search.set('window', 'settings') + await settingsWindow.loadFile(join(__dirname, '../renderer/index.html'), { + search: search.toString() + }) + + return settingsWindow + } + + ipcMain.on('action', async (event, value, formData) => { const webContents = event.sender const win = BrowserWindow.fromWebContents(webContents) if (!win) { return } - win.close() - createWindow(effectiveConfig) + switch (value) { + case 'showSettings': { + await createSettingsWindow(win, effectiveConfig) + return + } + case 'cancelSaveConfig': { + win.close() + return + } + case 'resetConfig': { + Object.entries(defaultConfig).forEach(([key, value]) => { + effectiveConfig[key] = value + }) + await writeFile(configPath, JSON.stringify(effectiveConfig)) + win.close() + win.getParentWindow()?.close() + await createMainWindow(effectiveConfig) + return + } + case 'saveConfig': { + Object.entries(formData).forEach(([key, value]) => { + effectiveConfig[key] = value + }) + await writeFile(configPath, JSON.stringify(effectiveConfig)) + win.close() + win.getParentWindow()?.close() + await createMainWindow(effectiveConfig) + return + } + } }) - createWindow(effectiveConfig) + await createMainWindow(effectiveConfig) - app.on('activate', function () { + app.on('activate', async () => { if (BrowserWindow.getAllWindows().length === 0) { - createWindow(effectiveConfig) + await createMainWindow(effectiveConfig) } }) }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx deleted file mode 100644 index 0a52877..0000000 --- a/src/renderer/src/App.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import Keyboard, { - StyledNaturalKey, - StyledAccidentalKey -} from '@theoryofnekomata/react-musical-keyboard' -import * as React from 'react' - -interface ChannelData { - channel: number - key: number - velocity: number -} - -const RANGES = { - 'C3-C5': '48|72', - 'C3-C6': '48|84', - 'C2-C6': '36|84', - 'C2-C7': '36|96', - 'E1-E7': '28|100', - 'E1-G7': '28|103', - 'A0-C8': '21|108', - 'C0-B9': '12|119', - 'Full MIDI': '0|127' -} - -const App: React.FC = () => { - const [queryDeviceKey, setQueryDeviceKey] = React.useState( - () => new URLSearchParams(window.location.search).get('queryDeviceKey') || '' - ) - const [range, setRange] = React.useState( - () => new URLSearchParams(window.location.search).get('range') || RANGES['A0-C8'] - ) - const [scaleFactor, setScaleFactor] = React.useState(() => - Number(new URLSearchParams(window.location.search).get('scaleFactor') || 1) - ) - const [currentDeviceActive, setCurrentDeviceActive] = React.useState([ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ]) - const currentDeviceActiveTimeoutRef = React.useRef() - - const [devicesLoadedTimestamp, setDevicesLoadedTimestamp] = React.useState() - const [midiAccess, setMidiAccess] = React.useState() - const [unaCorda, setUnaCorda] = React.useState(0) - const [sostenuto, setSostenuto] = React.useState(0) - const [sustain, setSustain] = React.useState(0) - - const [keyChannels, setKeyChannels] = React.useState([]) - - interface MidiMessageEvent extends Event { - data: [number, number, number] - } - - const handleChangeRange: React.ChangeEventHandler = (e) => { - const { value } = e.currentTarget - setRange(value) - window.electron.ipcRenderer.send('rangechange', value) - } - - const handleChangeDevice: React.ChangeEventHandler = (e) => { - const { value } = e.currentTarget - setQueryDeviceKey((oldDeviceKey) => { - midiAccess?.inputs.get(oldDeviceKey)?.close() - return value - }) - if (queryDeviceKey) { - window.electron.ipcRenderer.send('querydevicekeychange', value) - } - } - - const handleChangeScaleFactor: React.ChangeEventHandler = (e) => { - const { value: valueRaw } = e.currentTarget - const value = Number(valueRaw) - setScaleFactor(value) - if (typeof value !== 'undefined') { - window.electron.ipcRenderer.send('scalefactorchange', value) - window.document.documentElement.style.setProperty('--size-scale-factor', value.toString()) - } - } - - React.useEffect(() => { - window.navigator.requestMIDIAccess().then((midiAccess) => { - setMidiAccess(midiAccess) - const inputDevices = Array.from(midiAccess.inputs.entries()) - const search = new URLSearchParams(window.location.search) - const loadedQueryDeviceKey = search.get('queryDeviceKey') || inputDevices[0][0] - setQueryDeviceKey(loadedQueryDeviceKey) - if (loadedQueryDeviceKey) { - window.electron.ipcRenderer.send('querydevicekeychange', loadedQueryDeviceKey) - } - setDevicesLoadedTimestamp(Date.now()) - }) - }, []) - - React.useEffect(() => { - const search = new URLSearchParams(window.location.search) - const scaleFactorRaw = search.get('scaleFactor') || '1' - const scaleFactorTryNumber = Number(scaleFactorRaw) - const scaleFactor = Number.isFinite(scaleFactorTryNumber) ? scaleFactorTryNumber : 1 - setScaleFactor(scaleFactor) - }, []) - - React.useEffect(() => { - const devices = - typeof midiAccess?.inputs !== 'undefined' ? Array.from(midiAccess.inputs) : undefined - - if (typeof devices === 'undefined') { - return - } - - if (typeof queryDeviceKey === 'undefined') { - return - } - - const currentDeviceEntry = devices.find(([key]) => key === queryDeviceKey) - if (typeof currentDeviceEntry === 'undefined') { - return - } - - const [, currentDevice] = currentDeviceEntry - const listener = (e: Event): void => { - if (e.type !== 'midimessage') { - return - } - - const addActivity = (channel: number) => { - setCurrentDeviceActive((oldCurrentDeviceActive) => - oldCurrentDeviceActive.map((state, i) => (i === channel ? true : state)) - ) - window.clearTimeout(currentDeviceActiveTimeoutRef.current) - currentDeviceActiveTimeoutRef.current = window.setTimeout(() => { - setCurrentDeviceActive((oldCurrentDeviceActive) => - oldCurrentDeviceActive.map((state, i) => (i === channel ? false : state)) - ) - }, 100) - } - - const midiEvent = e as MidiMessageEvent - const [messageType, param1, param2] = midiEvent.data - const channel = messageType & 0b00001111 - addActivity(channel) - - switch (messageType & 0b11110000) { - case 0b10110000: { - if (channel === 9) { - return - } - - const controlNumber = param1 - const value = param2 - - switch (controlNumber) { - case 64: - setSustain(value) - return - case 66: - setSostenuto(value) - return - case 67: - setUnaCorda(value) - return - default: - break - } - return - } - case 0b10010000: { - const keyNumber = param1 - const velocity = param2 - - setKeyChannels((oldKeyChannels) => { - if (velocity > 0) { - return [ - ...oldKeyChannels, - { - channel, - key: keyNumber, - velocity - } - ] - } - - return oldKeyChannels.filter((c) => !(c.channel === channel && c.key === keyNumber)) - }) - return - } - case 0b10000000: { - const keyNumber = param1 - - setKeyChannels((oldKeyChannels) => { - return oldKeyChannels.filter((c) => !(c.channel === channel && c.key === keyNumber)) - }) - return - } - default: - break - } - } - currentDevice.addEventListener('midimessage', listener) - return () => { - currentDevice.removeEventListener('midimessage', listener) - } - }, [queryDeviceKey, midiAccess]) - - const [startKeyRaw, endKeyRaw] = range.split('|') - const startKey = Number(startKeyRaw) - const endKey = Number(endKeyRaw) - - const devices = - typeof midiAccess?.inputs !== 'undefined' ? Array.from(midiAccess.inputs) : undefined - - return ( -
-
-
- -
-
-
-
- -
-
key === queryDeviceKey)?.[1]?.state === 'connected' - ? 1 - : 0.25 - }} - /> - {currentDeviceActive.map((a, i) => ( -
- ))} -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- -
-
-
- -
-
- -
-
-
-
-
- ) -} - -export default App diff --git a/src/renderer/src/components/Activity/index.tsx b/src/renderer/src/components/Activity/index.tsx new file mode 100644 index 0000000..e52a0d3 --- /dev/null +++ b/src/renderer/src/components/Activity/index.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import {DeviceChannelActive} from '../../hooks/midi'; + +export interface ActivityProps { + device?: MIDIInput + currentDeviceActive?: DeviceChannelActive +} + +const getDeviceDisplayName = (device?: MIDIInput): string | undefined => { + if (typeof device === 'undefined') { + return undefined + } + + return [device.name, device.version ? `v${device.version}` : null] + .filter((s) => Boolean(s)) + .join(' ') +} + +export const Activity: React.FC = ({ + device, + currentDeviceActive = [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] +}) => { + const deviceDisplayName = getDeviceDisplayName(device) + + return ( +
+
+ Input device:{' '} + {typeof deviceDisplayName === 'undefined' && (None)} + {typeof deviceDisplayName !== 'undefined' && deviceDisplayName} +
+
+
+ {currentDeviceActive.map((a, i) => ( +
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/DropdownInput/index.tsx b/src/renderer/src/components/DropdownInput/index.tsx new file mode 100644 index 0000000..fffbcfc --- /dev/null +++ b/src/renderer/src/components/DropdownInput/index.tsx @@ -0,0 +1,20 @@ +import * as React from 'react' + +export interface DropdownInputProps extends React.HTMLProps { + label?: string +} + +export const DropdownInput = React.forwardRef( + ({ label, ...etcProps }, forwardedRef) => { + return ( + +
+ ))} +
+
+
+ + {naturalKeyWidths.map((n) => ( + + ))} + +
+
+
Natural key color
+
+ +
+
+
+
Accidental key color
+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
+ + + ) +} diff --git a/src/renderer/src/hooks/form.ts b/src/renderer/src/hooks/form.ts new file mode 100644 index 0000000..9a1bc15 --- /dev/null +++ b/src/renderer/src/hooks/form.ts @@ -0,0 +1,18 @@ +import * as React from 'react' + +export const useForm = () => { + const handleAction: React.FormEventHandler = (e) => { + e.preventDefault() + const { submitter } = e.nativeEvent as unknown as { submitter: HTMLElementTagNameMap['button'] } + if (submitter.name !== 'action') { + return + } + const formValue = new FormData(e.currentTarget) + const values = Object.fromEntries(formValue.entries()) + window.electron.ipcRenderer.send('action', submitter.value, values) + } + + return { + handleAction + } +} diff --git a/src/renderer/src/hooks/midi.ts b/src/renderer/src/hooks/midi.ts new file mode 100644 index 0000000..3370dcb --- /dev/null +++ b/src/renderer/src/hooks/midi.ts @@ -0,0 +1,199 @@ +import * as React from 'react' +import { messages } from '../utils/midi' + +export type DeviceChannelActive = [ + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean +] + +interface MidiMessageEvent extends Event { + data: [number, number, number] +} + +interface UseMidiReturn { + midiAccess?: MIDIAccess + lastStateChangeTimestamp?: number +} +interface ChannelData { + channel: number + key: number + velocity: number +} + +export const useMidi = (): UseMidiReturn => { + const [lastStateChangeTimestamp, setLastStateChangeTimestamp] = React.useState() + const [midiAccess, setMidiAccess] = React.useState() + + React.useEffect(() => { + const stateChangeListener = (e: Event): void => { + setLastStateChangeTimestamp(e.timeStamp) + } + + window.navigator.requestMIDIAccess().then((midiAccess) => { + setMidiAccess(midiAccess) + setLastStateChangeTimestamp(Date.now()) + midiAccess.addEventListener('statechange', stateChangeListener) + }) + + return (): void => { + midiAccess?.removeEventListener('statechange', stateChangeListener) + } + }, []) + + return { + midiAccess, + lastStateChangeTimestamp + } +} + +interface UseMidiActivityReturn { + isChannelActive: DeviceChannelActive + unaCorda: number + sostenuto: number + sustain: number + keyChannels: ChannelData[] +} + +export const useMidiActivity = (currentDevice?: MIDIInput): UseMidiActivityReturn => { + const [isChannelActive, setIsChannelActive] = React.useState([ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ]) + const currentDeviceActiveTimeoutRef = React.useRef() + const [unaCorda, setUnaCorda] = React.useState(0) + const [sostenuto, setSostenuto] = React.useState(0) + const [sustain, setSustain] = React.useState(0) + + const [keyChannels, setKeyChannels] = React.useState([]) + + React.useEffect(() => { + if (typeof currentDevice === 'undefined') { + return + } + + const addActivity = (channel: number): void => { + setIsChannelActive( + (oldCurrentDeviceActive) => + oldCurrentDeviceActive.map((state, i) => + i === channel ? true : state + ) as DeviceChannelActive + ) + window.clearTimeout(currentDeviceActiveTimeoutRef.current) + currentDeviceActiveTimeoutRef.current = window.setTimeout(() => { + setIsChannelActive( + (oldCurrentDeviceActive) => + oldCurrentDeviceActive.map((state, i) => + i === channel ? false : state + ) as DeviceChannelActive + ) + }, 100) + } + + const listener = (e: Event): void => { + if (e.type !== 'midimessage') { + return + } + + const midiEvent = e as MidiMessageEvent + const [messageType, param1, param2] = midiEvent.data + const channel = messageType & messages.CHANNEL_BITMASK + addActivity(channel) + + if (channel === messages.CHANNEL_INDEX_PERCUSSION) { + return + } + + switch (messageType & messages.TYPE_BITMASK) { + case messages.types.CONTINUOUS_CONTROL: { + const controlNumber = param1 + const value = param2 + + switch (controlNumber) { + case messages.params.continuousControl.sustain: + setSustain(value) + return + case messages.params.continuousControl.sostenuto: + setSostenuto(value) + return + case messages.params.continuousControl.unaCorda: + setUnaCorda(value) + return + default: + break + } + return + } + case messages.types.NOTE_ON: { + const keyNumber = param1 + const velocity = param2 + + setKeyChannels((oldKeyChannels) => { + if (velocity > 0) { + return [ + ...oldKeyChannels, + { + channel, + key: keyNumber, + velocity + } + ] + } + + return oldKeyChannels.filter((c) => !(c.channel === channel && c.key === keyNumber)) + }) + return + } + case messages.types.NOTE_OFF: { + const keyNumber = param1 + + setKeyChannels((oldKeyChannels) => { + return oldKeyChannels.filter((c) => !(c.channel === channel && c.key === keyNumber)) + }) + return + } + default: + break + } + } + currentDevice.addEventListener('midimessage', listener) + return () => { + currentDevice.removeEventListener('midimessage', listener) + } + }, [currentDevice]) + + return { + isChannelActive, + unaCorda, + sostenuto, + sustain, + keyChannels + } +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 38399be..379e9b2 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,17 +2,35 @@ import './assets/main.css' import React from 'react' import ReactDOM from 'react-dom/client' -import App from './App' +import IndexPage from './pages' +import SettingsPage from './pages/settings' +import { defaultConfig } from '../../common/config' const search = new URLSearchParams(window.location.search) -window.document.documentElement.style.setProperty('--size-scale-factor', search.get('scaleFactor')) -window.document.documentElement.style.setProperty('--color-natural-key', search.get('colorNaturalKey')) -window.document.documentElement.style.setProperty('--color-accidental-key', search.get('colorAccidentalKey')) -window.document.documentElement.style.setProperty('--color-channel-0', search.get('colorHighlight')) +const scaleFactorRaw = search.get('scaleFactor') || defaultConfig.scaleFactor +const scaleFactorTryNumber = Number(scaleFactorRaw) +const scaleFactor = Number.isFinite(scaleFactorTryNumber) ? scaleFactorTryNumber : 1 +window.document.documentElement.style.setProperty('--size-scale-factor', scaleFactor.toString()) + +const naturalKeyColor = search.get('naturalKeyColor') || defaultConfig.naturalKeyColor +window.document.documentElement.style.setProperty('--color-natural-key', naturalKeyColor) + +const accidentalKeyColor = search.get('accidentalKeyColor') || defaultConfig.accidentalKeyColor +window.document.documentElement.style.setProperty('--color-accidental-key', accidentalKeyColor) + +window.document.documentElement.style.setProperty('--color-channel-0', search.get('highlightColor')) + +const windows = { + main: IndexPage, + settings: SettingsPage +} + +const currentWindowKey = search.get('window') ?? 'main' +const Page = windows[currentWindowKey] ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + ) diff --git a/src/renderer/src/pages/index.tsx b/src/renderer/src/pages/index.tsx new file mode 100644 index 0000000..e03d973 --- /dev/null +++ b/src/renderer/src/pages/index.tsx @@ -0,0 +1,83 @@ +import Keyboard, { + StyledNaturalKey, + StyledAccidentalKey +} from '@theoryofnekomata/react-musical-keyboard' +import * as React from 'react' +import { PedalBoard } from '../components/PedalBoard' +import { Activity } from '../components/Activity' +import { useMidi, useMidiActivity } from '../hooks/midi' +import { RANGES } from '../../../common/config' +import { useForm } from '../hooks/form' + +interface IndexPageProps { + params: URLSearchParams +} + +const IndexPage: React.FC = ({ params: search }) => { + const { handleAction } = useForm() + + const { midiAccess, lastStateChangeTimestamp: devicesLoadedTimestamp } = useMidi() + const inputDevices = Array.from(midiAccess?.inputs.entries() ?? []) + const queryDeviceKey = search.get('queryDeviceKey') || inputDevices?.[0]?.[0] || '' + const currentDevice = + typeof midiAccess?.inputs !== 'undefined' ? midiAccess.inputs.get(queryDeviceKey) : undefined + + const { keyChannels, unaCorda, isChannelActive, sustain, sostenuto } = + useMidiActivity(currentDevice) + + const range = search.get('range') || RANGES['A0-C8'] + const [startKeyRaw, endKeyRaw] = range.split('|') + const startKey = Number(startKeyRaw) + const endKey = Number(endKeyRaw) + + return ( +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+ ) +} + +export default IndexPage diff --git a/src/renderer/src/pages/settings.tsx b/src/renderer/src/pages/settings.tsx new file mode 100644 index 0000000..bcc49fc --- /dev/null +++ b/src/renderer/src/pages/settings.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' +import { SettingsForm } from '../components/SettingsForm' +import {Config, defaultConfig, NATURAL_KEY_WIDTHS, RANGES, SCALE_FACTORS} from '../../../common/config' +import { useMidi } from '../hooks/midi' +import { useForm } from '../hooks/form' + +interface SettingsPageProps { + params: URLSearchParams +} + +const SettingsPage: React.FC = ({ params }) => { + const { handleAction } = useForm() + const { midiAccess, lastStateChangeTimestamp } = useMidi() + + const defaultValues = Object.entries(defaultConfig).reduce( + (theConfig, [key, value]) => ({ + ...theConfig, + [key]: params.get(key) || value + }), + {} as Config + ) + + return ( +
+
+

+ Settings +

+ +
+
+ ) +} + +export default SettingsPage diff --git a/src/renderer/src/utils/midi.ts b/src/renderer/src/utils/midi.ts new file mode 100644 index 0000000..142e46f --- /dev/null +++ b/src/renderer/src/utils/midi.ts @@ -0,0 +1,21 @@ +export namespace messages { + export namespace types { + export const NOTE_ON = 0b10010000 + export const NOTE_OFF = 0b10000000 + export const CONTINUOUS_CONTROL = 0b10110000 + } + + export namespace params { + export namespace continuousControl { + export const sustain = 64 + export const sostenuto = 66 + export const unaCorda = 67 + } + } + + export const TYPE_BITMASK = 0b11110000 + + export const CHANNEL_BITMASK = 0b00001111 + + export const CHANNEL_INDEX_PERCUSSION = 9 +} diff --git a/tsconfig.node.json b/tsconfig.node.json index db23a68..8e148e3 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,6 +1,6 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"], + "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/common/**/*"], "compilerOptions": { "composite": true, "types": ["electron-vite/node"] diff --git a/tsconfig.web.json b/tsconfig.web.json index 9c16b66..7ca0fef 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -1,6 +1,7 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", "include": [ + "src/common/**/*", "src/renderer/src/env.d.ts", "src/renderer/src/**/*", "src/renderer/src/**/*.tsx",