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 (
+
+ );
+};
+
+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(),
+ ],
+});