@@ -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 { readFile, stat, writeFile } from 'fs/promises' | ||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils' | import { electronApp, optimizer, is } from '@electron-toolkit/utils' | ||||
import icon from '../../resources/icon.png?asset' | 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 configPath = './config.json' | ||||
const naturalKeyWidth = 20 | |||||
const height = 175 | |||||
const height = 160 | |||||
const getNaturalKeys = (range: string): number => { | const getNaturalKeys = (range: string): number => { | ||||
const [startKeyRaw, endKeyRaw] = range.split('|') | const [startKeyRaw, endKeyRaw] = range.split('|') | ||||
@@ -43,10 +31,10 @@ const getNaturalKeys = (range: string): number => { | |||||
return naturalKeys | return naturalKeys | ||||
} | } | ||||
function createWindow(config: Config): void { | |||||
const createMainWindow = async (config: Config): Promise<BrowserWindow> => { | |||||
// Create the browser window. | // Create the browser window. | ||||
const naturalKeys = getNaturalKeys(config.range) | 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({ | const mainWindow = new BrowserWindow({ | ||||
width, | width, | ||||
height, | height, | ||||
@@ -66,7 +54,7 @@ function createWindow(config: Config): void { | |||||
maxWidth: is.dev ? undefined : width, | maxWidth: is.dev ? undefined : width, | ||||
fullscreenable: false, | fullscreenable: false, | ||||
resizable: is.dev, | resizable: is.dev, | ||||
useContentSize: true, | |||||
useContentSize: true | |||||
}) | }) | ||||
mainWindow.on('ready-to-show', () => { | mainWindow.on('ready-to-show', () => { | ||||
@@ -78,6 +66,17 @@ function createWindow(config: Config): void { | |||||
return { action: 'deny' } | return { action: 'deny' } | ||||
}) | }) | ||||
mainWindow.webContents.session.setPermissionRequestHandler( | |||||
(_webContents, permission, callback) => { | |||||
if (permission === 'midi') { | |||||
callback(true) | |||||
return | |||||
} | |||||
callback(false) | |||||
} | |||||
) | |||||
let search: URLSearchParams | let search: URLSearchParams | ||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { | ||||
const url = new URL(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]) => { | Object.entries(config).forEach(([key, value]) => { | ||||
search.set(key, value) | search.set(key, value) | ||||
}) | }) | ||||
search.set('window', 'main') | |||||
url.search = search.toString() | url.search = search.toString() | ||||
void mainWindow.loadURL(url.toString()) | |||||
return | |||||
await mainWindow.loadURL(url.toString()) | |||||
return mainWindow | |||||
} | } | ||||
search = new URLSearchParams() | search = new URLSearchParams() | ||||
Object.entries(config).forEach(([key, value]) => { | Object.entries(config).forEach(([key, value]) => { | ||||
search.set(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() | search: search.toString() | ||||
}) | }) | ||||
return mainWindow | |||||
} | } | ||||
app.whenReady().then(async () => { | app.whenReady().then(async () => { | ||||
@@ -121,41 +123,117 @@ app.whenReady().then(async () => { | |||||
optimizer.watchWindowShortcuts(window) | 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 webContents = event.sender | ||||
const win = BrowserWindow.fromWebContents(webContents) | const win = BrowserWindow.fromWebContents(webContents) | ||||
if (!win) { | if (!win) { | ||||
return | 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) { | 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 React from 'react' | ||||
import ReactDOM from 'react-dom/client' | 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) | 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( | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( | ||||
<React.StrictMode> | <React.StrictMode> | ||||
<App /> | |||||
<Page params={search} /> | |||||
</React.StrictMode> | </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", | "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": { | "compilerOptions": { | ||||
"composite": true, | "composite": true, | ||||
"types": ["electron-vite/node"] | "types": ["electron-vite/node"] | ||||
@@ -1,6 +1,7 @@ | |||||
{ | { | ||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json", | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", | ||||
"include": [ | "include": [ | ||||
"src/common/**/*", | |||||
"src/renderer/src/env.d.ts", | "src/renderer/src/env.d.ts", | ||||
"src/renderer/src/**/*", | "src/renderer/src/**/*", | ||||
"src/renderer/src/**/*.tsx", | "src/renderer/src/**/*.tsx", | ||||