diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000..4dfd806 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# mGB Waveform Editor + +This tool allows one to replace the waveform that is selected in the WAV channel. It sends a custom +SysEx message so you will need an ArduinoBoy. + +I have not tested it in RetroPlug, but if VSTs allow SysEx, then that would be possible too. + +### Running the development server + +``` +bun run dev +``` diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..7d10723 Binary files /dev/null and b/bun.lockb differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..3b32c45 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + mGB Waveform Editor + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..33e5052 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "waveform-edit", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "primeicons": "^7.0.0", + "primereact": "^10.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..93d12db --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import { SendSysex } from "./components/SendSysex"; +import { WaveformEditor } from "./components/WaveformEditor"; +import { Waveform } from "./types"; +import { SysexPreview } from "./components/SysexPreview"; +import { Flex } from "./components/Flex"; + +const INITIAL_WAVEFORM: number[] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + // + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, +]; + +function App() { + const [waveform, setWaveform] = useState(INITIAL_WAVEFORM); + + return ( + + + + + + + + ); +} + +export default App; diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Flex.tsx b/src/components/Flex.tsx new file mode 100644 index 0000000..fbe94ea --- /dev/null +++ b/src/components/Flex.tsx @@ -0,0 +1,28 @@ +import styled from "@emotion/styled"; + +export const Flex = styled.div<{ + row?: boolean; + rowReverse?: boolean; + col?: boolean; + colReverse?: boolean; + justify?: string; + align?: string; + grow?: string; + shrink?: string; + gap?: number; +}>` + ${({ row }) => row && `display: flex; flex-direction: row;`} + ${({ rowReverse }) => + rowReverse && `display: flex; flex-direction: row-reverse;`} + + ${({ col }) => col && `display: flex; flex-direction: column;`} + ${({ colReverse }) => + colReverse && `display: flex; flex-direction: column-reverse;`} + + ${({ justify }) => justify && `justify-content: ${justify};`} + ${({ align }) => align && `align-items: ${align};`} + ${({ gap }) => gap && `gap: ${gap}px;`} + + ${({ grow }) => grow && `flex-grow: ${grow};`} + ${({ shrink }) => shrink && `flex-shrink: ${shrink}; min-width: 0;`} +`; diff --git a/src/components/SendSysex.tsx b/src/components/SendSysex.tsx new file mode 100644 index 0000000..49d1e25 --- /dev/null +++ b/src/components/SendSysex.tsx @@ -0,0 +1,105 @@ +import { Dropdown } from "primereact/dropdown"; +import { useMidiAccess, useMidiPermission } from "../hooks/use_midi"; +import { Flex } from "./Flex"; +import { FormEventHandler, useCallback, useId, useState } from "react"; +import { Button } from "primereact/button"; +import { PrimeIcons } from "primereact/api"; +import { Knob, KnobChangeEvent } from "primereact/knob"; +import { Callback, Waveform } from "../types"; +import { Fieldset } from "primereact/fieldset"; +import { sendWaveformSysex } from "../lib/sysex"; + +export const SendSysex: React.FC<{ readonly waveform: Waveform }> = ({ + waveform, +}) => { + const perm = useMidiPermission(); + const midi = useMidiAccess(); + + const [portId, setPortId] = useState(undefined); + const [waveIndex, setWaveIndex] = useState(0); + + const sendSysex: FormEventHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (!portId) throw new Error("You must select a port"); + + const port = midi?.outputs.get(portId); + + if (!port) throw new Error(`Invalid port ${portId}`); + + sendWaveformSysex(port, waveform); + }; + + const handleWaveIndexChange = useCallback>( + (e) => setWaveIndex(e.value), + [] + ); + + if (!perm) return Error: Permission unavailable; + + if (!midi?.sysexEnabled) return Error: SysEx not enabled; + + if (!midi) return Error: No MIDIAccess; + + if (!portId && midi.outputs.size) { + setPortId([...midi.outputs.values()][0].id); + } + + return ( +
+ + + {(id) => ( + {option?.name ?? placeholder}} + onChange={(e) => setPortId(e.value)} + placeholder="MIDI Port" + /> + )} + + + {(id) => ( + + )} + +
+ ); +}; + +const Field: React.FC<{ + label: string; + children: (id: string) => React.ReactNode; +}> = ({ label, children }) => { + const id = useId(); + return ( + + + {children(id)} + + ); +}; diff --git a/src/components/SysexPreview.tsx b/src/components/SysexPreview.tsx new file mode 100644 index 0000000..b26b654 --- /dev/null +++ b/src/components/SysexPreview.tsx @@ -0,0 +1,51 @@ +import { Card } from "primereact/card"; +import { Waveform } from "../types"; +import { sysexWaveformMessage, toHex } from "../lib/sysex"; +import { Button } from "primereact/button"; +import { PrimeIcons } from "primereact/api"; +import { Flex } from "./Flex"; +import { useMemo } from "react"; +import { IconUtils } from "primereact/utils"; + +export const SysexPreview: React.FC<{ waveform: Waveform }> = ({ + waveform, +}) => { + const sysex = toHex(sysexWaveformMessage(waveform)); + + const wave = sysex.slice(4, 23); + + const output = `${sysex[0]} = SYSEX header +${sysex[1]} = SYSEX type +${sysex[2]} = mGB id +${sysex[3]} = mGB channel + + +${wave.join(", ")} + + +${sysex[23]} = SYSEX EOF +`; + + // Convert Blob to URL + const blobUrl = useMemo(() => { + // Convert object to Blob + const blobConfig = new Blob( + [Uint8Array.from(sysexWaveformMessage(waveform))], + { type: "application/octet-stream" } + ); + return URL.createObjectURL(blobConfig); + }, [waveform]); + + return ( + + +
{output}
+
+ + + Download .syx file + + +
+ ); +}; diff --git a/src/components/WaveformEditor.tsx b/src/components/WaveformEditor.tsx new file mode 100644 index 0000000..026e0d8 --- /dev/null +++ b/src/components/WaveformEditor.tsx @@ -0,0 +1,130 @@ +import styled from "@emotion/styled"; +import { memo, MouseEventHandler, useCallback, useMemo } from "react"; +import { Flex } from "./Flex"; +import { BIT_DEPTH, Callback, SAMPLES_PER_WAVEFORM, Waveform } from "../types"; + +export const WaveformEditor: React.FC<{ + readonly waveform: Waveform; + readonly onChange: Callback; +}> = ({ waveform, onChange }) => { + if (waveform.length !== SAMPLES_PER_WAVEFORM) { + throw new Error( + `Waveform has incorrect sample size ${waveform.length}. Should be ${SAMPLES_PER_WAVEFORM}` + ); + } + + const handleChange = useCallback( + (sampleIndex, value) => { + const newWaveform = [...waveform]; + newWaveform[sampleIndex] = value; + + onChange(newWaveform); + }, + [onChange, waveform] + ); + + return ( + + + {waveform.slice(0, SAMPLES_PER_WAVEFORM).map((val, i) => ( + + {val.toString(16).toUpperCase()} + + ))} + + + {waveform.map((sample, i) => ( + + ))} + + + ); +}; + +const POINT_SIZE = 20; + +const Block = styled(Flex)({ + width: POINT_SIZE, + height: POINT_SIZE, + textAlign: "center", + verticalAlign: "middle", +}); + +const SAMPLES = new Array(BIT_DEPTH).fill(0); + +const SampleColumn: React.FC<{ + readonly index: number; + readonly value: number; + readonly onChange: OnSampleChange; +}> = ({ index, value, onChange }) => { + const samples = useMemo(() => [...SAMPLES], []); + + if (value >= BIT_DEPTH || value < 0) { + throw new Error( + `Invalid sample value: ${value.toString(16)}. Range is 0 - F` + ); + } + + const handleChange = useCallback( + (newValue: number) => { + onChange(index, newValue); + }, + [index, onChange] + ); + + return ( + + {samples.map((_, i) => ( + + ))} + + ); +}; + +const SamplePointWrapper = styled.div<{ isActive: boolean }>( + ({ isActive: isActive }) => ({ + width: POINT_SIZE, + height: POINT_SIZE, + + backgroundColor: isActive ? "white" : undefined, + }) +); + +type OnSampleChange = (index: number, value: number) => void; + +const LEFT_BUTTON = 1; + +const SamplePoint: React.FC<{ + readonly index: number; + readonly onChange: Callback; + readonly value: number; + readonly isActive: boolean; +}> = memo(({ value, isActive, onChange }) => { + const setPoint: MouseEventHandler = (e) => { + if (e.buttons & LEFT_BUTTON) { + // console.log(`${index} = ${value}`); + onChange(value); + } + e.preventDefault(); + e.stopPropagation(); + }; + + return ( + + ); +}); diff --git a/src/hooks/use_midi.ts b/src/hooks/use_midi.ts new file mode 100644 index 0000000..663f2e7 --- /dev/null +++ b/src/hooks/use_midi.ts @@ -0,0 +1,36 @@ +import { useState } from "react"; + +export function useMidiPermission() { + const [granted, setGranted] = useState( + undefined + ); + + (async () => { + try { + const { state } = await navigator.permissions.query({ + name: "midi", + sysex: true, + }); + setGranted(state); + } catch (e) { + console.error(`Failed to get MIDI permission`, e); + } + })(); + + return granted; +} + +export function useMidiAccess() { + const [midi, setMidi] = useState(undefined); + + (async () => { + try { + const midiAccess = await navigator.requestMIDIAccess({ sysex: true }); + setMidi(midiAccess); + } catch (e) { + console.error(`Failed to get MIDI access`, e); + } + })(); + + return midi; +} diff --git a/src/lib/sysex.ts b/src/lib/sysex.ts new file mode 100644 index 0000000..4926d82 --- /dev/null +++ b/src/lib/sysex.ts @@ -0,0 +1,100 @@ +import { BYTES_PER_WAVEFORM, SAMPLES_PER_WAVEFORM, Waveform } from "../types"; + +const MIDI_STATUS_SYSEX = 0xf0; +const SYSEX_NON_COMMERCIAL = 0x7d; +const SYSEX_EOF = 0xf7; + +const SYSEX_MGB_ID = 0x69; +const MGB_WAV_CHANNEL = 0x02; + +export function sendWaveformSysex(port: MIDIOutput, waveform: Waveform) { + port.send(sysexWaveformMessage(waveform)); +} + +export function toBytes(waveform: Waveform) { + return waveform.reduce((out: number[], sample: number, index: number) => { + const isHighNibble = index % 2; + if (isHighNibble) { + const lowNibble = out[out.length - 1]; + + const byte = lowNibble + (sample << 4); + + out[out.length - 1] = byte; + } else { + out[out.length] = sample; + } + + return out; + }, []); +} + +export function sysexMessage(message: number[]) { + return [MIDI_STATUS_SYSEX, SYSEX_NON_COMMERCIAL, ...message, SYSEX_EOF]; +} + +export function sysexWaveformMessage(waveform: number[]) { + if (waveform.length !== SAMPLES_PER_WAVEFORM) + throw new Error( + `Incorrect wav bytes ${waveform.length}. Should be ${SAMPLES_PER_WAVEFORM}` + ); + + const wavBytes = toBytes(waveform); + + if (wavBytes.length !== BYTES_PER_WAVEFORM) + throw new Error( + `Incorrect wav bytes ${wavBytes.length}. Should be ${BYTES_PER_WAVEFORM}` + ); + + return sysexMessage([ + SYSEX_MGB_ID, + MGB_WAV_CHANNEL, + ...encodeSysEx7Bit(wavBytes), + ]); +} + +/** + * Encode System Exclusive messages. + * + * SysEx messages are encoded to guarantee transmission of data bytes higher than + * 127 without breaking the MIDI protocol. Use this static method to convert the + * data you want to send. + * + * Code inspired from Ruin & Wesen's SysEx encoder/decoder - http://ruinwesen.com + */ +function encodeSysEx7Bit(inData: number[]): number[] { + const outSysEx: number[] = []; + + // const outLength = 0; // Num bytes in output array. + let count = 0; // Num 7bytes in a block. + + let msbIndex = 0; + outSysEx[msbIndex] = 0; + for (let i = 0; i < inData.length; ++i) { + const data = inData[i]; + + if (data > 0xff) + throw new Error(`Can only encode 8 bit numbers, got ${data}`); + + const msb = data >> 7; + const body = data & 0x7f; + + outSysEx[msbIndex] |= msb << (6 - count); + outSysEx[msbIndex + 1 + count] = body; + + if (count++ == 6) { + msbIndex += 8; + // outLength += 8; + outSysEx[msbIndex] = 0; + count = 0; + } + } + return outSysEx; +} + +export function toHex(message: number[]) { + return message.map((b) => b.toString(16)); +} + +export function toHexWithPrefix(message: number[]) { + return message.map((b) => "0x" + b.toString(16)); +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..f6757fe --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; +import { PrimeReactProvider } from "primereact/api"; + +import "primereact/resources/themes/lara-dark-teal/theme.css"; +import "primeicons/primeicons.css"; + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4bec316 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +export type Callback = (v: T) => void; + +/** The representation of a waveform. 32 nibbles long. */ +export type Waveform = number[]; + +export const SAMPLES_PER_WAVEFORM = 32; + +export const BYTES_PER_WAVEFORM = 16; + +export const BIT_DEPTH = 16; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..f0a2350 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..0d3d714 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f9e0483 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import mkcert from "vite-plugin-mkcert"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + // mkcert(), + ], +});