@@ -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' | |||
} |
@@ -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<BrowserWindow> => { | |||
// 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<BrowserWindow> => { | |||
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) | |||
} | |||
}) | |||
}) | |||
@@ -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<ActivityProps> = ({ | |||
device, | |||
currentDeviceActive = [ | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false | |||
] | |||
}) => { | |||
const deviceDisplayName = getDeviceDisplayName(device) | |||
return ( | |||
<div> | |||
<div className="text-xs"> | |||
Input device:{' '} | |||
{typeof deviceDisplayName === 'undefined' && <span className="opacity-50">(None)</span>} | |||
{typeof deviceDisplayName !== 'undefined' && deviceDisplayName} | |||
</div> | |||
<div className="h-1.5 flex gap-1 items-center"> | |||
<div | |||
className="rounded-full w-1.5 h-1.5 bg-yellow-400" | |||
style={{ | |||
opacity: device?.state === 'connected' ? 1 : 0.25 | |||
}} | |||
/> | |||
{currentDeviceActive.map((a, i) => ( | |||
<div | |||
className="rounded-full w-1.5 h-1.5 bg-green-400" | |||
key={i} | |||
style={{ | |||
opacity: a ? 1 : 0.25 | |||
}} | |||
/> | |||
))} | |||
</div> | |||
</div> | |||
) | |||
} |
@@ -0,0 +1,20 @@ | |||
import * as React from 'react' | |||
export interface DropdownInputProps extends React.HTMLProps<HTMLElementTagNameMap['select']> { | |||
label?: string | |||
} | |||
export const DropdownInput = React.forwardRef<HTMLElementTagNameMap['select'], DropdownInputProps>( | |||
({ label, ...etcProps }, forwardedRef) => { | |||
return ( | |||
<label className="block border rounded overflow-hidden box-border"> | |||
<span className="flex items-center h-3 text-xs -mb-3 relative pointer-events-none pt-1 px-1"> | |||
{label} | |||
</span> | |||
<select {...etcProps} ref={forwardedRef} className="w-full bg-black h-10 appearance-none pt-2 px-1" /> | |||
</label> | |||
) | |||
} | |||
) | |||
DropdownInput.displayName = 'DropdownInput' |
@@ -0,0 +1,149 @@ | |||
import * as React from 'react' | |||
import { DropdownInput } from '../DropdownInput' | |||
import { Config } from '../../../../common/config' | |||
export interface SettingsFormProps extends React.HTMLProps<HTMLElementTagNameMap['form']> { | |||
defaultValues?: Config | |||
ranges: Record<string, string> | |||
scaleFactors: Record<string, string> | |||
midiAccess?: MIDIAccess | |||
lastStateChangeTimestamp?: number | |||
naturalKeyWidths: number[] | |||
} | |||
export const SettingsForm: React.FC<SettingsFormProps> = ({ | |||
disabled, | |||
midiAccess, | |||
ranges, | |||
defaultValues = {}, | |||
scaleFactors, | |||
lastStateChangeTimestamp, | |||
naturalKeyWidths, | |||
...etcProps | |||
}) => { | |||
const inputs = typeof midiAccess !== 'undefined' ? Array.from(midiAccess.inputs) : undefined | |||
return ( | |||
<form {...etcProps}> | |||
<fieldset disabled={disabled} className="contents"> | |||
<legend className="sr-only">Settings</legend> | |||
<div className="flex flex-col gap-4"> | |||
<div> | |||
<DropdownInput | |||
label="Input device" | |||
name="queryDeviceKey" | |||
defaultValue={defaultValues?.['queryDeviceKey']} | |||
key={lastStateChangeTimestamp} | |||
> | |||
{inputs?.map(([name, device]) => ( | |||
<option key={name} value={name}> | |||
{device.name} | |||
</option> | |||
))} | |||
</DropdownInput> | |||
</div> | |||
<div className="flex-auto"> | |||
<DropdownInput | |||
name="range" | |||
label="Keyboard range" | |||
defaultValue={defaultValues?.['range']} | |||
> | |||
{Object.entries(ranges).map(([name, range]) => ( | |||
<option key={name} value={range}> | |||
{name} | |||
</option> | |||
))} | |||
</DropdownInput> | |||
</div> | |||
<div className="flex justify-between items-center gap-8 h-10"> | |||
<div>Scale factor</div> | |||
<div className="flex items-center gap-8"> | |||
{Object.entries(scaleFactors).map(([label, value]) => ( | |||
<div key={value}> | |||
<label> | |||
<input | |||
name="scaleFactor" | |||
type="radio" | |||
value={value} | |||
defaultChecked={ | |||
defaultValues?.['scaleFactor']?.toString() === value.toString() | |||
} | |||
/>{' '} | |||
{label} | |||
</label> | |||
</div> | |||
))} | |||
</div> | |||
</div> | |||
<div className="flex-auto"> | |||
<DropdownInput | |||
name="naturalKeyWidth" | |||
label="Natural key width" | |||
defaultValue={defaultValues?.['naturalKeyWidth']} | |||
> | |||
{naturalKeyWidths.map((n) => ( | |||
<option key={n} value={n}> | |||
{n} | |||
</option> | |||
))} | |||
</DropdownInput> | |||
</div> | |||
<div className="flex justify-between items-center gap-8"> | |||
<div>Natural key color</div> | |||
<div> | |||
<input | |||
name="naturalKeyColor" | |||
type="color" | |||
className="h-10" | |||
defaultValue={defaultValues?.['naturalKeyColor']} | |||
/> | |||
</div> | |||
</div> | |||
<div className="flex justify-between items-center gap-8"> | |||
<div>Accidental key color</div> | |||
<div> | |||
<input | |||
name="accidentalKeyColor" | |||
type="color" | |||
className="h-10" | |||
defaultValue={defaultValues?.['accidentalKeyColor']} | |||
/> | |||
</div> | |||
</div> | |||
<div className="mt-12 flex gap-4"> | |||
<div className="w-0 flex-auto"> | |||
<button | |||
className="w-full h-10 rounded border overflow-hidden" | |||
type="submit" | |||
name="action" | |||
value="cancelSaveConfig" | |||
> | |||
Cancel | |||
</button> | |||
</div> | |||
<div className="w-0 flex-auto"> | |||
<button | |||
className="w-full h-10 rounded border overflow-hidden" | |||
type="submit" | |||
name="action" | |||
value="resetConfig" | |||
> | |||
Reset to Defaults | |||
</button> | |||
</div> | |||
</div> | |||
<div> | |||
<button | |||
className="w-full h-10 rounded border bg-white text-black overflow-hidden" | |||
type="submit" | |||
name="action" | |||
value="saveConfig" | |||
> | |||
Save | |||
</button> | |||
</div> | |||
</div> | |||
</fieldset> | |||
</form> | |||
) | |||
} |
@@ -0,0 +1,18 @@ | |||
import * as React from 'react' | |||
export const useForm = () => { | |||
const handleAction: React.FormEventHandler<HTMLElementTagNameMap['form']> = (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 | |||
} | |||
} |
@@ -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<number>() | |||
const [midiAccess, setMidiAccess] = React.useState<MIDIAccess>() | |||
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<DeviceChannelActive>([ | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false, | |||
false | |||
]) | |||
const currentDeviceActiveTimeoutRef = React.useRef<number>() | |||
const [unaCorda, setUnaCorda] = React.useState(0) | |||
const [sostenuto, setSostenuto] = React.useState(0) | |||
const [sustain, setSustain] = React.useState(0) | |||
const [keyChannels, setKeyChannels] = React.useState<ChannelData[]>([]) | |||
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 | |||
} | |||
} |
@@ -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( | |||
<React.StrictMode> | |||
<App /> | |||
<Page params={search} /> | |||
</React.StrictMode> | |||
) |
@@ -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<IndexPageProps> = ({ 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 ( | |||
<div | |||
className="flex flex-col items-center h-full w-full group relative select-none" | |||
key={devicesLoadedTimestamp} | |||
> | |||
<div className="absolute bottom-1 left-1"> | |||
<Activity device={currentDevice} currentDeviceActive={isChannelActive} /> | |||
</div> | |||
<div className="flex-auto relative w-full"> | |||
<div | |||
className="absolute w-full h-full" | |||
style={{ | |||
paddingRight: 'calc(var(--size-scale-factor) * 1px)' | |||
}} | |||
> | |||
<Keyboard | |||
key={range} | |||
height="100%" | |||
startKey={startKey} | |||
endKey={endKey} | |||
keyChannels={keyChannels} | |||
keyComponents={{ | |||
natural: StyledNaturalKey, | |||
accidental: StyledAccidentalKey | |||
}} | |||
/> | |||
</div> | |||
</div> | |||
<div className="flex sm:gap-16 w-full justify-between sm:justify-center items-center px-4 gap-4"> | |||
<div className="w=0 flex-auto hidden sm:block" /> | |||
<div className="flex-shrink-0 pt-2 pb-8"> | |||
<PedalBoard unaCorda={unaCorda} sostenuto={sostenuto} sustain={sustain} /> | |||
</div> | |||
<div className="w-0 flex-auto flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity"> | |||
<form className="contents" onSubmit={handleAction}> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="showSettings" | |||
className="h-10 border rounded overflow-hidden px-4" | |||
> | |||
Settings | |||
</button> | |||
</form> | |||
</div> | |||
</div> | |||
</div> | |||
) | |||
} | |||
export default IndexPage |
@@ -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<SettingsPageProps> = ({ 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 ( | |||
<main className="py-16 select-none"> | |||
<div className="px-4 mx-auto max-w-screen-sm"> | |||
<h1 className="text-5xl mb-8"> | |||
Settings | |||
</h1> | |||
<SettingsForm | |||
lastStateChangeTimestamp={lastStateChangeTimestamp} | |||
midiAccess={midiAccess} | |||
defaultValues={defaultValues} | |||
ranges={RANGES} | |||
scaleFactors={SCALE_FACTORS} | |||
naturalKeyWidths={NATURAL_KEY_WIDTHS} | |||
onSubmit={handleAction} | |||
/> | |||
</div> | |||
</main> | |||
) | |||
} | |||
export default SettingsPage |
@@ -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 | |||
} |
@@ -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"] | |||
@@ -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", | |||