diff --git a/docs/screenshots/screenshot-00.png b/docs/screenshots/screenshot-00.png index 3a4dc3f..2fc3b59 100644 Binary files a/docs/screenshots/screenshot-00.png and b/docs/screenshots/screenshot-00.png differ diff --git a/src/common/config.ts b/src/common/config.ts new file mode 100644 index 0000000..1ffa8ce --- /dev/null +++ b/src/common/config.ts @@ -0,0 +1,40 @@ +export 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' +} + +export const SCALE_FACTORS = { + '1x': '1', + '2x': '2' +} + +export const NATURAL_KEY_WIDTHS = [ + 16, + 18, + 20 +] + +export interface Config { + range: string + queryDeviceKey: string + scaleFactor: string + naturalKeyColor: string + accidentalKeyColor: string + naturalKeyWidth: string +} + +export const defaultConfig: Config = { + range: '21|108', + queryDeviceKey: '', + scaleFactor: '2', + naturalKeyColor: '#e3e3e5', + accidentalKeyColor: '#35313b', + naturalKeyWidth: '18' +} diff --git a/src/main/index.ts b/src/main/index.ts index 00e7607..c088567 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,22 +3,10 @@ import { join } from 'path' import { readFile, stat, writeFile } from 'fs/promises' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' - -interface Config { - range: string - queryDeviceKey: string - scaleFactor: string -} - -const defaultConfig: Config = { - range: '21|108', - queryDeviceKey: '', - scaleFactor: '1' -} +import { Config, defaultConfig } from '../common/config' const configPath = './config.json' -const naturalKeyWidth = 20 -const height = 175 +const height = 160 const getNaturalKeys = (range: string): number => { 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",