├── .github └── workflows │ └── buildanddeploy.yaml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── client └── polyfills.js ├── components ├── controls.js ├── dropdown.js ├── editor.js ├── field.js ├── hittracker.js ├── instruments.js ├── main.js ├── metrics │ ├── CloudflareAnalytics.jsx │ └── MicrosoftClarity.jsx ├── ratiopicker.js ├── topbar.js └── visualisation.js ├── hooks ├── useStore.js └── useTheme.js ├── lib ├── clipboard.js ├── global_context.js ├── helpers.js ├── instruments.js ├── scheduleSections.js ├── sectionsToImgURL.js ├── sectionsToMIDI.js └── serialization.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── _document.jsx └── index.js ├── public ├── favicon.png ├── sounds │ ├── bell.wav │ ├── bongo.wav │ ├── bonk.wav │ ├── clave1.wav │ ├── clave2.wav │ ├── clave3.wav │ ├── cow1.wav │ ├── cow2.wav │ ├── cow3.wav │ └── kick.wav ├── sw.js ├── sw.js.map ├── workbox-6b19f60b.js └── workbox-6b19f60b.js.map ├── readme ├── control.png ├── editor.png ├── instruments.png ├── right.png └── visual.png └── styles ├── Home.module.sass ├── controls.module.sass ├── dropdown.module.sass ├── editor.module.sass ├── field.module.sass ├── globals.sass ├── instruments.module.sass ├── main.module.sass ├── ratiopicker.module.sass ├── topbar.module.sass ├── vars.sass └── visualisation.module.sass /.github/workflows/buildanddeploy.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2.3.1 12 | with: 13 | persist-credentials: false 14 | 15 | - name: Build 16 | run: | 17 | npm ci 18 | npm run build 19 | 20 | - name: Deploy 21 | uses: JamesIves/github-pages-deploy-action@3.6.1 22 | with: 23 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 24 | BRANCH: gh-pages 25 | FOLDER: out 26 | CLEAN: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # workbox 37 | /public/sw.js 38 | /public/sw.js.map 39 | /public/workbox-* 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Angramme 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fangramme.github.io%2Fpolyrhythm3%2Findex.html&count_bg=%23303250&title_bg=%232D2D2D&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 3 | 4 | ## Polyrhythm 3 5 | > This is an alpha of the new release of polyrhythm metronome, it is in the test phase right now but will soon replace the current polyrhythm. 6 | 7 | 8 | ## Tutorial 9 | 10 | ### Visualization block 11 | ![visual block](readme/visual.png) 12 | 13 | The UI is divided into blocks, the first block is the visualization block, here you can see your "sections" displayed in a midi-esque fashion. 14 | It allows you to do the following actions: 15 | - add new sections with the add button to the right 16 | - select the section that you are currently editing in the editor by clicking on it. 17 | - swap two sections by dragging one section over another. 18 | 19 | ### Control block 20 | ![control block](readme/control.png) 21 | 22 | Next block under the visualization block is the transport control block. It allows you to change the bpm as well as to pause, play and stop. 23 | You can also see a button that opens [additional settings](#instrument-block), we will get to it later. 24 | 25 | ### Editor block 26 | ![editor block](readme/editor.png) 27 | 28 | The last visible block is the editor block, it allows you to edit the [section's parameters](#section-properties) like the ratios of the polyrythms etc. The properties are mostly the same as the previous polyrhythm versions however some new features like the offsets and swing were added. 29 | 30 | ### Instrument block 31 | ![instrument block](readme/instruments.png) 32 | 33 | This block allows you to select the instrument for a specific track. You can open this block by using the most right button in the control block. 34 | 35 | ### Top right buttons 36 | ![top right buttons](readme/right.png) 37 | 38 | These buttons are respectively: 39 | - share button 40 | - export as MIDI file button 41 | - change light/dark mode 42 | 43 | ### Section properties 44 | 45 | * RATIOS: this is your musical ratio like 3:4 polyrhythm for example, you're not limited in any way here. 46 | - For example 3:5 is a polyrhythm where in the duration of one bar you get 3 beats and 5 beats simultaneously. 47 | * SUBDIVIDES: this lets you subdivide every beat into n non-accentuated beats. 48 | * OFFSETS: this lets you offset every beat by some value. 0 means no offset and 1 means an offset of the length of one beat which is the maximum offset i.e the maximum offset is the `(bar length) / (beat ratio)` 49 | * SCALE: this is basically a mutliplier on the bar length. 50 | For example 2 would mean that the current section has a length of 2 bars. Fractions are allowed... 51 | * REPEAT: how many times to repeat the section 52 | * SWING: swing amount 53 | - 0 : none 54 | - 1 : max swing 55 | * REMOVE: this button deletes the selected section. 56 | 57 | > for additional information refer to polyrhythm2 wiki 58 | 59 | ## Running the project locally 60 | 61 | ``` 62 | git clone 63 | cd polyrhythm3 64 | npm install 65 | npm run dev 66 | ``` 67 | then open localhost:3000 in your browser... 68 | 69 | > for more information refer to Next.js documentation. 70 | -------------------------------------------------------------------------------- /client/polyfills.js: -------------------------------------------------------------------------------- 1 | if (!Element.prototype.matches) { 2 | Element.prototype.matches = 3 | Element.prototype.msMatchesSelector || 4 | Element.prototype.webkitMatchesSelector; 5 | } 6 | 7 | if (!Element.prototype.closest) { 8 | Element.prototype.closest = function (s) { 9 | var el = this; 10 | 11 | do { 12 | if (Element.prototype.matches.call(el, s)) return el; 13 | el = el.parentElement || el.parentNode; 14 | } while (el !== null && el.nodeType === 1); 15 | return null; 16 | }; 17 | } 18 | 19 | console.log("loading polyfills...") -------------------------------------------------------------------------------- /components/controls.js: -------------------------------------------------------------------------------- 1 | import {Field} from './field'; 2 | 3 | import {RiPauseLine, RiPlayLine, RiStopLine, RiSoundModuleLine} from 'react-icons/ri' 4 | import { useTheme } from '../hooks/useTheme'; 5 | 6 | import useStore from '../hooks/useStore'; 7 | import { useCallback } from 'react'; 8 | import shallow from 'zustand/shallow'; 9 | 10 | export default function Controls({ 11 | style, 12 | }){ 13 | 14 | const [ paused, pause, play, bpm, setBpm, editMode, setEditMode ] = useStore(useCallback(state => 15 | [state.paused, state.pause, state.play, state.bpm, state.setBpm, state.editMode, state.setEditMode], []), shallow); 16 | 17 | const styles = useTheme(require('../styles/controls.module.sass')); 18 | 19 | return
20 | {/*
21 | Controls 22 |
*/} 23 |
24 |
(paused ? play : pause)(), [paused])} 27 | > 28 | {paused ? 29 | : 30 | 31 | } 32 |
33 |
{ 36 | require('tone').Transport.stop(); 37 | pause(); 38 | }, [])} 39 | > 40 | 41 |
42 |
43 | bpm 44 |
45 |
46 | setBpm(value)} 51 | /> 52 |
53 |
56 | setEditMode(editMode == 'section' ? 'instrument' : 'section'), 57 | [editMode])}> 58 | 59 |
60 | 61 |
62 |
63 | } -------------------------------------------------------------------------------- /components/dropdown.js: -------------------------------------------------------------------------------- 1 | import RDropdown from 'react-dropdown' 2 | 3 | import rstyles from '../styles/dropdown.module.sass' 4 | import { useTheme } from '../hooks/useTheme' 5 | 6 | export default function Dropdown({...props}){ 7 | const styles = useTheme(rstyles); 8 | 9 | return 15 | } -------------------------------------------------------------------------------- /components/editor.js: -------------------------------------------------------------------------------- 1 | import {Field} from './field' 2 | import RatioPicker from './ratiopicker' 3 | 4 | import clone from 'just-clone' 5 | 6 | import { useCallback, useEffect, useMemo } from 'react'; 7 | import { useTheme } from '../hooks/useTheme'; 8 | 9 | import useStore from '../hooks/useStore'; 10 | import shallow from 'zustand/shallow'; 11 | 12 | export default function Editor(){ 13 | 14 | const [sections, setSections, curSection, setCurSection] = 15 | useStore(useCallback(state => 16 | [state.sections, state.setSections, state.curSection, state.setCurSection], 17 | []), shallow); 18 | 19 | const styles = useTheme(require('../styles/editor.module.sass')); 20 | 21 | const S = useMemo(()=>sections[curSection], [sections, curSection]); 22 | 23 | const up = (cb)=>(vals)=>{ 24 | if(vals == null || vals == undefined) return; 25 | cb(vals); 26 | setSections(clone(sections)); // we have to deep clone, otherwise React does weird shit 27 | }; 28 | 29 | return
30 |
31 | 32 | 33 | 34 | 35 | 43 | 44 | 45 | 46 | 53 | 54 | 55 | 56 | 63 | 64 | 65 |
ratios 36 | S.ratios=vals)}/> 42 |
subdivides 47 | S.subdivide=vals)}/> 52 |
offsets 57 | S.offsets=vals)}/> 62 |
66 | 67 | 68 | 69 | 70 | 75 | 76 | 77 | 78 | 83 | 84 | 85 | 86 | 91 | 92 | 93 | 94 | 103 | 104 | 105 |
scale 71 | S.length = val)}/> 74 |
repeat 79 | S.repeat = val)}/> 82 |
swing 87 | S.swing = val)}/> 90 |
remove 95 | { 97 | if(sections.length > 1){ 98 | setCurSection(Math.max(0, curSection-1)); 99 | return sections.splice(sections.indexOf(S), 1); 100 | } 101 | })}/> 102 |
106 |
107 |
108 | } -------------------------------------------------------------------------------- /components/field.js: -------------------------------------------------------------------------------- 1 | 2 | import { useEffect, useRef, useState } from 'react' 3 | import { useTheme } from '../hooks/useTheme'; 4 | 5 | function eval_math_expr (exp) { 6 | let reg = /(?:[a-z$_][a-z0-9$_]*)|(?:[;={}\[\]"'!&<>^\\?:])/ig, 7 | valid = true, 8 | res = null; 9 | exp = exp.replace(reg, function ($0) { 10 | // If the name is a direct member of Math, allow 11 | if (Math.hasOwnProperty($0)) return "Math."+$0; 12 | // Otherwise the expression is invalid 13 | else valid = false; 14 | }); 15 | 16 | if (!valid) return null; 17 | try { res = eval(exp); } catch (e) { return null; }; 18 | return res; 19 | } 20 | 21 | export function Field({className, type, style, onInput, ...args}){ 22 | const styles = useTheme(require('../styles/field.module.sass')); 23 | 24 | let ref = useRef(); 25 | 26 | const [wrong, setWrong] = useState(false); 27 | 28 | useEffect(()=>{ 29 | if(args.defaultValue == null || args.defaultValue == undefined) return; 30 | ref.current.value = args.defaultValue; 31 | }, [args.defaultValue]); 32 | 33 | return
34 | {args.name ? 35 |
{args.name}
36 | : '' 37 | } 38 | { 47 | const x = X[0].target; 48 | let sval = x.value; 49 | let val = sval; 50 | 51 | if(type == 'number'){ 52 | const exp = eval_math_expr(x.value); 53 | if(exp == null || exp == undefined) return setWrong(true); 54 | if(args.min && exp < Number(args.min)) return setWrong(true); 55 | if(args.max && exp > Number(args.max)) return setWrong(true); 56 | if(args.step && exp % Number(args.step) != 0) return setWrong(true); 57 | val = exp; 58 | } 59 | 60 | if(wrong) setWrong(false); 61 | 62 | onInput(val, sval); 63 | }} 64 | > 65 |
66 | } -------------------------------------------------------------------------------- /components/hittracker.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export default function HitTracker(){ 4 | return
10 | 11 |
12 | } -------------------------------------------------------------------------------- /components/instruments.js: -------------------------------------------------------------------------------- 1 | import { useTheme } from "../hooks/useTheme" 2 | import { getSynthNames, getSynthIconTypes } from "../lib/instruments"; 3 | import { FaDrum } from "react-icons/fa"; 4 | import { FaDrumSteelpan } from "react-icons/fa6"; 5 | import { FaBell } from "react-icons/fa"; 6 | import { ImBell } from "react-icons/im"; 7 | import { GiChopsticks } from "react-icons/gi"; 8 | 9 | import Dropdown from '../components/dropdown' 10 | 11 | import clone from 'just-clone' 12 | import useStore from "../hooks/useStore"; 13 | import { useCallback, useMemo } from "react"; 14 | import shallow from "zustand/shallow"; 15 | 16 | export default function instruments({ 17 | style, 18 | }){ 19 | const [instrumentIDs, setInstrumentIDs, sections] = useStore(useCallback( 20 | state => [state.instrumentIDs, state.setInstrumentIDs, state.sections], []), shallow); 21 | 22 | const styles = useTheme(require('../styles/instruments.module.sass')); 23 | const synth_names = getSynthNames(); 24 | const synth_types = getSynthIconTypes(); 25 | 26 | const number_of_tracks = useMemo( 27 | () => sections.reduce((s, v) => Math.max(s, v.ratios.length), -1), 28 | [sections] 29 | ); 30 | 31 | const handler = (id)=>(val)=>{ 32 | instrumentIDs[id] = val; 33 | setInstrumentIDs(clone(instrumentIDs)); 34 | } 35 | 36 | const iconMap = { 37 | 'kickdrum': , 38 | 'bongo': , 39 | 'bell': , 40 | 'cowbell': , 41 | 'clave': , 42 | }; 43 | 44 | return
45 | 46 | {instrumentIDs 47 | .slice(0, number_of_tracks) 48 | .map((inst, i)=> 49 | 50 | 51 | 59 | 60 | )} 61 |
track {i+1} uses {synth_names[inst]} 52 | {synth_types.map((type, j)=> 53 |
handler(i)(j)} value={j} style={{display: 'inline', position: 'relative', margin: '0.5rem', border:'solid 2px black', borderColor: inst==j ? 'red' : 'rgba(0,0,0,0)', padding: '2px', borderRadius: '2px', cursor: 'pointer'}}> 54 | {iconMap[type]} 55 |
56 | ) 57 | } 58 |
62 |
63 | } -------------------------------------------------------------------------------- /components/main.js: -------------------------------------------------------------------------------- 1 | import Visualisation from "./visualisation" 2 | import Editor from "./editor"; 3 | import Controls from "./controls"; 4 | import TopBar from "./topbar"; 5 | import Instruments from "./instruments"; 6 | 7 | import { useTheme } from "../hooks/useTheme"; 8 | import useStore from "../hooks/useStore"; 9 | import { useCallback } from "react"; 10 | 11 | 12 | export default function Main(){ 13 | const styles = useTheme(require("../styles/main.module.sass")); 14 | 15 | const editMode = useStore(useCallback(state => state.editMode, [])); 16 | 17 | return
18 | 19 | 20 | 21 | {editMode == 'section' ? 22 | 23 | : ''} 24 | {editMode == 'instrument' ? 25 | 26 | : ''} 27 |
28 | } -------------------------------------------------------------------------------- /components/metrics/CloudflareAnalytics.jsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | export const CloudflareAnalytics = () => { 4 | return <> 5 | 9 | 10 | } -------------------------------------------------------------------------------- /components/metrics/MicrosoftClarity.jsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Script from "next/script" 4 | 5 | export const MicrosoftClarity = () => { 6 | return 14 | // return 22 | // return