Skip to content

Commit

Permalink
Added two new node types, Trigger and Smooth (#39)
Browse files Browse the repository at this point in the history
* added a function generator component but not quite 100% there yet

* trying with setInternal, since we seem to lack a global update loop

* trying with setInternal, since we seem to lack a global update loop

* trying with setInternal, since we seem to lack a global update loop

* renamed Ugen to Oscillator, because it's easier to understand Ugen is too obscure... added some documentation notes to self

* fixed two bugs, the breaks were missing in the switch statement and options were not automagically updated by the base class, so actual settings need to be obtained from this.options, not from the private object variables

* added missing step suggested by xiduzo

* Oscillator shows tips in node

* added Trigger node

* added new node type

* added Trigger node

* was missing the dropdown to choose the random generator

* added new node Smooth

* added a more complete documentation entry about how to create your own node in the documentation website

* removed temporary documentation and placed the content where it belongs

* added a configurable timeout to the Trigger so that it can be used, like a sustain in a ASDR envelope

* added some OCD feedback

* added some OCD feedback

* chore: reactify

---------

Co-authored-by: boringrgb <boringrgb@nordzee>
Co-authored-by: Sander <mail@sanderboer.nl>
  • Loading branch information
3 people authored Dec 14, 2024
1 parent d559f07 commit ef9726f
Show file tree
Hide file tree
Showing 11 changed files with 480 additions and 6 deletions.
42 changes: 42 additions & 0 deletions FEEDBACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,45 @@ Ninja-level nodes:
## Bugs?
- [x] refresh rate of debug is waaaaaaay slow
- [x] interval rate of oscillator is set to 50fps but only 41fps are produced, is that the maximum refresh rate of johnny-five or where does this limitation come from?

## Developer happines stuff
Mostly API-level stuff, pet-peeves, OCD stuff, things I wish (as a developer) were easier to do.

- [ ] adding a new node requires quite some boilerplate, I see myself copying and pasting quite a lot of stuff from other components.
- [ ] somewhat incoherent API, for example when I needed an attribute value I expected a function like `const value = useNodeAttribute<OscillatorData>(id, 'period', 0);`, but it turns out it was a lot easier and all I needed was `const { id, data } = useNode();` and then I could use the `data` object to fetch stuff from it like so `const waveform = data['waveform'];`. I prefer the way it is, but the inconsistency had me going around in circles for a while. It is slightly maddedning that intuition doesn't work in this codebase. I blame React for this, and the fact that some things have a more reactish API, while others are closer to the cleaner object model under it. I don't think there's an easy way to fix this that doesn't involve writing a ton of stupid wrappers. You probably do not feel any of this because React is second-nature to you by now. But for a React-virgin this way of doing things is weird.
- [ ] more about coherence, Node properties are called Data in the code, Properties in some places, Settings in the `.tsx` file. Not a biggie but would be nice to settle on one word and name it the same across the entire codebase. I would propose *attributes*, because `settings` applies to too many things in app development and `properties` is more related to object-orientedness and can also get confusing.
- [ ] same thing about the words `Component` and `Node`, one has `BaseComponent.ts` in `@microflow/components` and `Node.tsx` in the presentation layer. You get used to it pretty quickly, and it makes some sense I guess, but makes the codebase a bit disorienting for a noob.
- [ ] As an outsider non-React developer I would very much love if the model component `.ts` file and the presentation Node `.tsx` file where alongside each other in the project structure. I don't know how many times I had to fish out a file in the five-subfolder structure of the electron-app to change something that felt like it belonged in the model. Made me cringe in frustration a few times.
- [ ] `BaseComponent.ts` assumes that `change` happens when the value changes, but this is not always true. When writing the `Trigger` node I found situations were I wanted to send an output signal that didn't have a specific value. In the end I settled for sending out a 1.0 for `trigger happened` (or `bang` as it's called in other flow-based languages) and a `0.0` when `trigger didn't happen`. My first implementation was like this:

```
/**
* @TODO apparently this doesn't work
*
* bang the output 'change' gate
*/
public bang() {
let value: number = 1.0;
this.eventEmitter.emit('change', JSON.stringify(value));
}
/**
* "unbang"
*/
public quiet() {
let value: number = 0.0;
this.eventEmitter.emit('change', JSON.stringify(value));
}
```

You can still see the vestiges of this approach in `BaseComponent.ts`. This never worked, and after staring at it for hours I honestly do not know why. Instead I had to do this in the child component:

```
if (retval) {
this.value = 1.0; // not ideal, I don't really want to send a specific value here but rather a gate bang
setTimeout(() => {
this.value = 0.0;
}, this.options.duration);
```

In my opinion it would be nice if there was a very straight-foward way in `BaseComponent.ts` to just send a pulse/bang to the next node, a way that is independant of value just so that the developer doesn't have to thing about it. Something happened in this node and I just want to notify the next node down the chain.
6 changes: 6 additions & 0 deletions apps/electron-app/src/common/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
DEFAULT_OSCILLATOR_DATA,
Oscillator,
} from '../render/components/react-flow/nodes/Oscillator';
import { DEFAULT_TRIGGER_DATA, Trigger } from '../render/components/react-flow/nodes/Trigger';
import { DEFAULT_SMOOTH_DATA, Smooth } from '../render/components/react-flow/nodes/Smooth';
import { DEFAULT_LED_DATA, Led } from '../render/components/react-flow/nodes/Led';
import { DEFAULT_MATRIX_DATA, Matrix } from '../render/components/react-flow/nodes/matrix/Matrix';
import { DEFAULT_MOTION_DATA, Motion } from '../render/components/react-flow/nodes/Motion';
Expand All @@ -29,6 +31,8 @@ export const NODE_TYPES = {
Compare: Compare,
Interval: Interval,
Oscillator: Oscillator,
Trigger: Trigger,
Smooth: Smooth,
Led: Led,
Matrix: Matrix,
Motion: Motion,
Expand All @@ -53,6 +57,8 @@ DEFAULT_NODE_DATA.set('Figma', DEFAULT_FIGMA_DATA);
DEFAULT_NODE_DATA.set('Compare', DEFAULT_COMPARE_DATA);
DEFAULT_NODE_DATA.set('Interval', DEFAULT_INTERVAL_DATA);
DEFAULT_NODE_DATA.set('Oscillator', DEFAULT_OSCILLATOR_DATA);
DEFAULT_NODE_DATA.set('Trigger', DEFAULT_TRIGGER_DATA);
DEFAULT_NODE_DATA.set('Smooth', DEFAULT_SMOOTH_DATA);
DEFAULT_NODE_DATA.set('Led', DEFAULT_LED_DATA);
DEFAULT_NODE_DATA.set('Matrix', DEFAULT_MATRIX_DATA);
DEFAULT_NODE_DATA.set('Motion', DEFAULT_MOTION_DATA);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { Position } from '@xyflow/react';
import { useEffect } from 'react';
import { Handle } from './Handle';
import { BaseNode, NodeContainer, useNode, useNodeSettingsPane } from './Node';
import { useNodeValue } from '../../../stores/node-data';

const numberFormat = new Intl.NumberFormat();
import { Icons } from '@ui/index';

export function Oscillator(props: Props) {
return (
Expand All @@ -21,10 +19,18 @@ export function Oscillator(props: Props) {
}

function Value() {
const { id } = useNode();
const value = useNodeValue<OscillatorValueType>(id, 0);
const { data } = useNode<OscillatorData>();

return <section className="tabular-nums">{numberFormat.format(Math.round(value))}</section>;
return (
<section className="flex flex-col text-center gap-1">
{data.waveform === 'random' && <Icons.Dices size={48} />}
{data.waveform === 'sawtooth' && <Icons.TriangleRight size={48} />}
{data.waveform === 'sinus' && <Icons.AudioWaveform size={48} />}
{data.waveform === 'square' && <Icons.Square size={48} />}
{data.waveform === 'triangle' && <Icons.Triangle size={48} />}
<div className="text-muted-foreground text-xs">{data.period / 1000}s</div>
</section>
);
}

function Settings() {
Expand All @@ -42,11 +48,13 @@ function Settings() {
{ value: 'triangle', text: 'triangle' },
{ value: 'sawtooth', text: 'sawtooth' },
{ value: 'square', text: 'square' },
{ value: 'random', text: 'random' },
],
});

pane.addBinding(settings, 'period', {
index: 1,
step: 1,
min: 100,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { SmoothData, SmoothValueType } from '@microflow/components';
import { Position } from '@xyflow/react';
import { useEffect } from 'react';
import { Handle } from './Handle';
import { BaseNode, NodeContainer, useNode, useNodeSettingsPane } from './Node';

export function Smooth(props: Props) {
return (
<NodeContainer {...props}>
<Value />
<Settings />
<Handle type="target" position={Position.Left} id="signal" offset={-0.5} />
<Handle type="source" position={Position.Bottom} id="change" />
</NodeContainer>
);
}

function Value() {
const { data } = useNode<SmoothData>();

return <section className="tabular-nums">{data.attenuation.toFixed(3)}</section>;
}

function Settings() {
const { pane, settings } = useNodeSettingsPane<SmoothData>();

useEffect(() => {
if (!pane) return;

pane.addBinding(settings, 'attenuation', {
index: 0,
min: 0.0,
max: 1.0,
step: 0.001,
label: 'attenuation',
});
}, [pane, settings]);

return null;
}

type Props = BaseNode<SmoothData, SmoothValueType>;
export const DEFAULT_SMOOTH_DATA: Props['data'] = {
label: 'Smooth',
attenuation: 0.995,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { TriggerData, TriggerValueType } from '@microflow/components';
import { Position } from '@xyflow/react';
import { useEffect } from 'react';
import { Handle } from './Handle';
import { BaseNode, NodeContainer, useNode, useNodeSettingsPane } from './Node';
import { Icons } from '@ui/index';

export function Trigger(props: Props) {
return (
<NodeContainer {...props}>
<Value />
<Settings />
<Handle type="target" position={Position.Left} id="signal" offset={-0.5} />
<Handle type="source" position={Position.Bottom} id="change" />
</NodeContainer>
);
}

function Value() {
const { data } = useNode<TriggerData>();

return (
<section className="flex flex-col text-center gap-1">
{data.behaviour === 'exact' && <Icons.Equal size={48} />}
{data.behaviour === 'increasing' && <Icons.TrendingUp size={48} />}
{data.behaviour === 'decreasing' && <Icons.TrendingDown size={48} />}
<div className="text-muted-foreground text-xs">{data.threshold}</div>
</section>
);
}

function Settings() {
const { pane, settings } = useNodeSettingsPane<TriggerData>();

useEffect(() => {
if (!pane) return;

pane.addBinding(settings, 'behaviour', {
index: 0,
view: 'list',
label: 'behaviour',
options: [
{ value: 'increasing', text: 'when increasing' },
{ value: 'exact', text: 'when exactly equal' },
{ value: 'decreasing', text: 'when decreasing' },
],
});

pane.addBinding(settings, 'threshold', {
index: 1,
label: 'threshold value',
});

pane.addBinding(settings, 'duration', {
index: 2,
min: 0.1,
max: 1000,
step: 0.1,
label: 'duration',
});
}, [pane, settings]);

return null;
}

type Props = BaseNode<TriggerData, TriggerValueType>;
export const DEFAULT_TRIGGER_DATA: Props['data'] = {
label: 'Trigger',
behaviour: 'exact',
threshold: 0.5,
duration: 250,
};
12 changes: 12 additions & 0 deletions apps/electron-app/src/render/providers/NewNodeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,24 @@ export const NewNodeCommandDialog = memo(function NewNodeCommandDialog() {
<Badge variant="outline">Generator</Badge>
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={selectNode('Smooth')}>
Smooth
<CommandShortcut>
<Badge variant="outline">Transformation</Badge>
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={selectNode('Debug')}>
Debug
<CommandShortcut className="space-x-1">
<Badge variant="outline">Output</Badge>
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={selectNode('Trigger')}>
Trigger
<CommandShortcut>
<Badge variant="outline">Control</Badge>
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={selectNode('Compare')}>
Compare
<CommandShortcut>
Expand Down
133 changes: 133 additions & 0 deletions apps/nextjs-app/app/docs/contributing/nodes/page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
title: How to add your own node
---

Adding your own node requires a bit of boilerplate and manual work at the moment.


## Step 1: creating your own component type

Let's say you want to create your own node type called `MyNode`. First you need to create a file with it's own class in `packages/components/src/YourNode.ts` your type must extend the BaseComponent class. Here's an example declaration:

```
export type MyNodeValueType = number;
export class MyNode extends BaseComponent<MyNodeValueType> {}
```

Let's now say that your node type will have a configuration panel where you can change some attributes, for now let's say the attributes are a drop-down that let's you choose between `happy` and `sad`, and a numeric value we call `joy`. To be able to contain the values of these attributes you will need a data type associated to your node.

```
export type EmotionType = 'happy' | 'sad';
export type MyNodeData = {
emotion: EmotionType;
joy: number;
};
type MyNodeOptions = BaseComponentOptions & MyNodeData;
```

Add a constructor to your type that takes the attributes and passes them to the superclass. Like this:

```
constructor(private readonly options: MyNodeOptions) {
super(options, 0);
}
```

## Step 2: expose your new type in the components packages and refresh build

- Include your newly created component in the `index.ts` file in `packages/components`. This will make your new components available in the `@microflow/components` package, so that they can be used later in the electron app.
- run `yarn build` in the `microflow/packages/components` directory, you need to do this before you run yarn at the `app` level directories

## Step 3: create a react wrapper in the electron app

- Create a reactflow wrapper for your node type in `apps/electron-app/src/common/render/componenets/react-flow/nodes/YourNode.tsx`
- Implement here your JSX

```
export function MyNode(props: Props) {
return (
<NodeContainer {...props}>
<Value />
<Settings />
<Handle type="target" position={Position.Left} id="input" />
<Handle type="source" position={Position.Bottom} id="change" />
</NodeContainer>
);
}
```

- Your config panel (@TODO link to documentation)

```
function Settings() {
const { pane, settings } = useNodeSettingsPane<MyNodeData>();
useEffect(() => {
if (!pane) return;
pane.addBinding(settings, 'emotion', {
index: 0,
view: 'list',
label: 'validate',
options: [
{ value: 'happy', text: 'Happy' },
{ value: 'sad', text: 'Sad' },
],
});
pane.addBinding(settings, 'joy', {
index: 1,
min: 1,
max: 100,
step: 0.5,
});
}, [pane, settings]);
return null;
}
```

- And your panel setting defaults.

```
type Props = BaseNode<MyNodeData, MyNodeValueType>;
export const DEFAULT_MYNODE_DATA: Props['data'] = {
label: 'MyNode',
emotion: 'happy',
joy: 95,
};
```

- Add a reference in `apps/electron-app/src/common/nodes.ts`

```
import { DEFAULT_MYNODE_DATA, MyNode } from '../render/components/react-flow/nodes/MyNode';
```

Add the correct entry to the `NODE_TYPES` list:
```
export const NODE_TYPES = {
...
MyNode: MyNode,
...
};```
And last but not least i that same file, add an entry that specifies the default attribute values for the node:
```
DEFAULT_MYNODE_DATA.set('MyNode', DEFAULT_MYNODE_DATA);
```
- Add some JSX in `apps/electron-app/src/render/NewNodeProvider.tsx` so that it appears in the search menu.
```
<CommandItem onSelect={selectNode('MyNode')}>
MyNode
<CommandShortcut>
<Badge variant="outline">Custom</Badge>
</CommandShortcut>
</CommandItem>
```
Loading

0 comments on commit ef9726f

Please sign in to comment.