Skip to content

Commit

Permalink
Version 1.0
Browse files Browse the repository at this point in the history
- Basic waveform editor (very crude)
- Send patch to mGB directly via MIDI
- Download .syx file to store and use from some other software
  • Loading branch information
tstirrat committed Aug 29, 2024
0 parents commit 9f87414
Show file tree
Hide file tree
Showing 21 changed files with 679 additions and 0 deletions.
24 changes: 24 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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?
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
Binary file added bun.lockb
Binary file not shown.
28 changes: 28 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -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 },
],
},
},
)
13 changes: 13 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mGB Waveform Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
33 changes: 33 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
28 changes: 28 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<Waveform>(INITIAL_WAVEFORM);

return (
<Flex row justify="center" align="center">
<Flex col align="stretch" gap={8} style={{ maxWidth: 800 }}>
<WaveformEditor waveform={waveform} onChange={setWaveform} />
<SendSysex waveform={waveform} />
<SysexPreview waveform={waveform} />
</Flex>
</Flex>
);
}

export default App;
1 change: 1 addition & 0 deletions src/assets/react.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions src/components/Flex.tsx
Original file line number Diff line number Diff line change
@@ -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;`}
`;
105 changes: 105 additions & 0 deletions src/components/SendSysex.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(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<Callback<KnobChangeEvent>>(
(e) => setWaveIndex(e.value),
[]
);

if (!perm) return <strong>Error: Permission unavailable</strong>;

if (!midi?.sysexEnabled) return <strong>Error: SysEx not enabled</strong>;

if (!midi) return <strong>Error: No MIDIAccess</strong>;

if (!portId && midi.outputs.size) {
setPortId([...midi.outputs.values()][0].id);
}

return (
<Fieldset>
<Flex
row
align="center"
justify="start"
gap={8}
as="form"
onSubmit={sendSysex}
>
<Field label="Port">
{(id) => (
<Dropdown
inputId={id}
name="midiPort"
options={[...midi.outputs.values()]}
optionLabel="name"
optionValue="id"
value={portId}
valueTemplate={(
option: MIDIOutput | undefined,
{ placeholder }
) => <span>{option?.name ?? placeholder}</span>}
onChange={(e) => setPortId(e.value)}
placeholder="MIDI Port"
/>
)}
</Field>
<Field label="Waveform index">
{(id) => (
<Knob
name="waveIndex"
id={id}
onChange={handleWaveIndexChange}
value={waveIndex}
max={16}
/>
)}
</Field>
<Button label="Send" icon={PrimeIcons.PLAY} />
</Flex>
</Fieldset>
);
};

const Field: React.FC<{
label: string;
children: (id: string) => React.ReactNode;
}> = ({ label, children }) => {
const id = useId();
return (
<Flex row align="center" gap={8} style={{ alignContent: "center" }}>
<label htmlFor={id}>{label}</label>
{children(id)}
</Flex>
);
};
51 changes: 51 additions & 0 deletions src/components/SysexPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 data>
${wave.join(", ")}
</wave data>
${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 (
<Card title="Sysex data">
<code>
<pre>{output}</pre>
</code>
<Flex row align="end" grow="1">
<a href={blobUrl} download="mGB-patch.syx">
Download .syx file
</a>
</Flex>
</Card>
);
};
Loading

0 comments on commit 9f87414

Please sign in to comment.