Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useContext, useEffect, useReducer } from "react";
import {
Lifetime,
Patch,
clearUrlState,
getLocalStorage,
Expand All @@ -17,6 +18,7 @@ import Splainer from "./ui/Splainer";
import "./App.scss";
import { BpmContext } from "./lib/PlaybackContext";
import { core } from "./lib/webRenderer";
import useLife from "./lib/useLife";

const numTracks = 16;
const numSteps = 16;
Expand All @@ -42,6 +44,8 @@ const getInitialPatch = () => {
bassTracks: initTracks(7),
tone: "stab",
useKick: false,
life: false,
lifetime: Lifetime.QUARTER,
} as Patch;

const urlPatch = getUrlState();
Expand Down Expand Up @@ -75,6 +79,17 @@ const App = () => {
bpm: bpm,
});

const handleLife = useCallback((field:any) => {
updatePatch({ type: "setTracks", tracks: field });
}, []);

useLife({
field: patch.tracks,
setField: handleLife,
life: patch.life,
lifetime: patch.lifetime,
});

const toggleNote = useCallback(
(note: number, step: number, value: number) => {
const newTracks = [...patch.tracks];
Expand Down Expand Up @@ -123,6 +138,14 @@ const App = () => {
(mute) => updatePatch({ type: "setMute", mute }),
[],
)}
onSetLife={useCallback(
(life) => updatePatch({ type: "setLife", life }),
[],
)}
onSetLifetime={useCallback(
(lifetime) => updatePatch({ type: "setLifetime", lifetime }),
[],
)}
/>
<Grid
canTranspose={true}
Expand Down
31 changes: 29 additions & 2 deletions src/lib/patch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,27 @@ export type Patch = {
bassTracks: number[][];
useKick: boolean;
tone: string;
mute?: boolean;
mute: boolean;
life: boolean;
lifetime: Lifetime;
};

export enum Lifetime {
WHOLE = "whole",
HALF = "half",
QUARTER = "quarter"
}

export type Action =
| { type: "setScale"; scale: number[] }
| { type: "setBassScale"; scale: number[] }
| { type: "setTracks"; tracks: number[][] }
| { type: "setBassTracks"; tracks: number[][] }
| { type: "setTone"; tone: string }
| { type: "setUseKick"; useKick: boolean }
| { type: "setMute"; mute: boolean };
| { type: "setMute"; mute: boolean }
| { type: "setLife"; life: boolean }
| { type: "setLifetime"; lifetime: Lifetime };

export const patchReducer: Reducer<Patch, Action> = (patch, action) => {
switch (action.type) {
Expand All @@ -39,6 +49,10 @@ export const patchReducer: Reducer<Patch, Action> = (patch, action) => {
return { ...patch, useKick: action.useKick };
case "setMute":
return { ...patch, mute: action.mute };
case "setLife":
return { ...patch, life: action.life };
case "setLifetime":
return { ...patch, lifetime: action.lifetime };
default:
throw new Error(
`Tried to perform ${action}, which is not a valid action`,
Expand Down Expand Up @@ -90,6 +104,17 @@ export const getUrlState = () => {
if (presetParams.length > 0) {
state.tone = presetParams[0];
}

const lifeParams = p.getAll("life");
if (lifeParams.length > 0) {
state.life = Boolean(Number(lifeParams[0]));
}

const lifetimeParams = p.getAll("lifetime");
if (lifetimeParams.length > 0) {
state.lifetime = lifetimeParams[0] as Lifetime;
}

return state;
} catch (e) {
console.log("Recall failed:", e);
Expand Down Expand Up @@ -170,6 +195,8 @@ export const encodeUrlParams = (patch: Patch) => {
let out = "?";
out += "&kick=" + (patch.useKick ? 1 : 0);
out += "&tone=" + patch.tone;
out += "&life=" + (patch.life ? 1 : 0);
out += "&lifetime=" + patch.lifetime;
out += "&bassTracks=" + encodeBassTracks(patch.bassTracks);
out += "&tracks=" + encodeTracks(patch.tracks).join("-");
return out;
Expand Down
90 changes: 90 additions & 0 deletions src/lib/useLife.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useCallback, useContext, useEffect, useRef, useState } from "react";

import PlaybackContext, { BpmContext } from "./PlaybackContext";
import useAnimationFrame from "./useAnimationFrame";
import { deepCopy, range } from "./utils";
import { Lifetime } from "./patch";

function getAliveNeighborsAt(x: number, y: number, field: number[][]) {
let count = 0;

[-1, 0, 1].forEach(dy => {
[-1, 0, 1].forEach(dx => {
if (dx === 0 && dy === 0) return;

let nx = x + dx, ny = y + dy;
if (ny >= 0 && ny < field.length && nx >= 0 && nx < field[ny].length) {
count += field[ny][nx];
}
});
});

return count;
}

const doLife = (field: number[][]) => {
let newField: number[][] = new Array(field.length).fill(null).map(() => new Array(field[0].length).fill(0));

field.forEach((row, y) => {
row.forEach((cell, x) => {
const aliveNeighbors = getAliveNeighborsAt(x, y, field);

if (cell === 1) {
newField[y][x] = aliveNeighbors === 2 || aliveNeighbors === 3 ? 1 : 0;
} else {
newField[y][x] = aliveNeighbors === 3 ? 1 : 0;
}
});
});

return newField;
};

type Props = {
field: number[][];
setField: (field: number[][]) => void;
life: boolean;
lifetime: Lifetime
};

function noteLength(lifetime: Lifetime): number {
switch (lifetime) {
case Lifetime.WHOLE:
return 16;
case Lifetime.HALF:
return 8;
case Lifetime.QUARTER:
return 4;
default:
return 16;
}
}

const useLife = ({ field, setField, life, lifetime }: Props) => {
const nextField = useRef<number[][]>(deepCopy(field));
const { bpm } = useContext(BpmContext);
const { playheadPos } = useContext(PlaybackContext);
const [lastCol, setLastCol] = useState<number>(0);
const [updateReady, setUpdateReady] = useState<boolean>(false);

const refreshRate = life ? 60 * 1000 / (bpm * 8) : 1000;
useAnimationFrame(refreshRate, "life");

const doRefresh = useCallback(() => {
if (lastCol !== playheadPos) {
setLastCol(playheadPos);
setUpdateReady(true);
}
}, [ playheadPos, updateReady, setUpdateReady]);

useEffect(() => {
doRefresh();
if (Math.round(lastCol) % noteLength(lifetime) !== 0 || !life || !updateReady) return;
nextField.current = doLife(deepCopy(field));
setField(nextField.current);
setUpdateReady(false);
}, [field, setField, doRefresh, life, updateReady, setUpdateReady, lifetime, noteLength]);
};


export default useLife;
64 changes: 39 additions & 25 deletions src/ui/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "react";

import Icons from "../assets/icons";
import { encodeUrlParams, Patch } from "../lib/patch";
import { encodeUrlParams, Lifetime, Patch } from "../lib/patch";
import PlaybackContext from "../lib/PlaybackContext";
import useAnimationFrame from "../lib/useAnimationFrame";
import useClickAway from "../lib/useClickAway";
Expand All @@ -19,6 +19,7 @@ import { Logo } from "./Logo";
import "./Panel.scss";
import Scope from "./Scope";
import BpmInput from "./BpmInput";
import Picker from "./Picker";

const cls = "eg-panel";

Expand Down Expand Up @@ -110,7 +111,7 @@ const Switch = ({
);
};

type ToneName = "stab" | "ding";
type ToneName = "stab" | "ding" | "bell";

type Tone = {
name: ToneName;
Expand All @@ -123,31 +124,36 @@ const tones = [
{ name: "bell", label: "Bell" },
] as Array<Tone>;


type TonePickerProps = {
currentTone: ToneName;
onSetTone: (name: ToneName) => void;
};

const TonePicker = ({ currentTone, onSetTone }: TonePickerProps) => {
return (
<div className={`${cls}__tone-picker`}>
{tones.map((tone) => {
return (
<div className={`${cls}__option`} key={tone.name}>
<input
type="radio"
className={`${cls}__led`}
id={tone.name}
value={tone.name}
checked={tone.name === currentTone}
onChange={() => onSetTone(tone.name)}
/>
<label htmlFor={tone.name}>{tone.label}</label>
</div>
);
})}
</div>
);
return <Picker items={tones} currentName={currentTone} onSet={onSetTone} />;
};

type LifeTimeOption = {
name: Lifetime;
label: string;
};

const lifeTimeOptions = [
{ name: Lifetime.QUARTER, label: "1/4" },
{ name: Lifetime.HALF, label: "2/4" },
{ name: Lifetime.WHOLE, label: "4/4" },
] as Array<LifeTimeOption>;


type LifetimePickerProps = {
currentLifetime: Lifetime;
onSetLifetime: (lifetime: Lifetime) => void;
};


const LifetimePicker = ({ currentLifetime, onSetLifetime }: LifetimePickerProps) => {
return <Picker items={lifeTimeOptions} currentName={currentLifetime} onSet={onSetLifetime}/>;
};

type MeterProps = {
Expand Down Expand Up @@ -181,11 +187,13 @@ type Props = {
onSetKick: (useKick: boolean) => void;
onSetTone: (tone: string) => void;
onSetMute: (mute: boolean) => void;
onSetLife: (life: boolean) => void;
onSetLifetime: (lifetime: Lifetime) => void;
};

const getUseFancyLayout = () => window.matchMedia("(min-width: 35.001em)").matches;

function Panel({ patch, onClear, onSetKick, onSetTone, onSetMute }: Props) {
function Panel({ patch, onClear, onSetKick, onSetTone, onSetMute, onSetLife, onSetLifetime }: Props) {
const [skip, setSkip] = useState(1);
const [fancyLayout, setFancyLayout] = useState<boolean>(getUseFancyLayout());
const [showMeters, setShowMeters] = useState<boolean>(false);
Expand Down Expand Up @@ -234,9 +242,15 @@ function Panel({ patch, onClear, onSetKick, onSetTone, onSetMute }: Props) {

<TonePicker currentTone={patch.tone as ToneName} onSetTone={onSetTone} />

<Switch label="Kick" active={patch.useKick} setActive={onSetKick} />

<Switch label="Mute" active={patch.mute || false} setActive={onSetMute} />
<div className={`${cls}__option`}>
<Switch label="Life" active={patch.life} setActive={onSetLife} />
<LifetimePicker currentLifetime={patch.lifetime} onSetLifetime={onSetLifetime} />
</div>

<div>
<Switch label="Kick" active={patch.useKick} setActive={onSetKick} />
<Switch label="Mute" active={patch.mute || false} setActive={onSetMute} />
</div>

{fancyLayout && <ShareWidget patch={patch} />}
</div>
Expand Down
35 changes: 35 additions & 0 deletions src/ui/Picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import "./Panel.scss";
const cls = "eg-panel";

type PickerItem = {
name: string;
label: string;
};

type PickerProps = {
items: PickerItem[];
currentName: string;
onSet: (name: any) => void;
};

const Picker = ({ items, currentName, onSet }: PickerProps) => {
return (
<div className={`${cls}__picker`}>
{items.map((item) => (
<div className={`${cls}__option`} key={item.name}>
<input
type="radio"
className={`${cls}__led`}
id={item.name}
value={item.name}
checked={item.name === currentName}
onChange={() => onSet(item.name)}
/>
<label htmlFor={item.name}>{item.label}</label>
</div>
))}
</div>
);
};

export default Picker;