├── .gitignore ├── LICENSE ├── README.md ├── index.css ├── index.html ├── index.js ├── package-lock.json ├── package.json └── scripts └── deploy.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | node_modules/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 webvrnsfw 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 | # vibe sequencer 2 | 3 | https://webvrnsfw.github.io/vibe-sequencer/ 4 | 5 | A [Buttplug](https://buttplug.io/) client application that lets you control your vibrating sex toys with sequencers. 6 | 7 | ![screenshot of vibe sequencer](https://user-images.githubusercontent.com/30336388/113837925-4bd57d80-975c-11eb-8c7a-20cb05f7bdb6.png) 8 | -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #222; 3 | } 4 | 5 | body, select, button { 6 | background: var(--bg); 7 | color: white; 8 | } 9 | select, button { 10 | border: 2px solid grey; 11 | } 12 | button:disabled { 13 | color: darkgrey; 14 | } 15 | button { 16 | padding: 8px 16px; 17 | } 18 | 19 | body { 20 | padding: 0 16px; 21 | } 22 | * { 23 | font-size: 16pt; 24 | } 25 | h1 { 26 | font-size: 20pt; 27 | } 28 | 29 | .device { 30 | display: block; 31 | } 32 | .device select { 33 | width: 16em; 34 | } 35 | 36 | .sequencer { 37 | margin: 32px 0; 38 | width: 852px; 39 | } 40 | .sequencer input, .sequencer button { 41 | width: 115px; 42 | margin: 4px 16px; 43 | } 44 | .spacer { 45 | flex: 1; 46 | } 47 | .controls { 48 | display: flex; 49 | } 50 | .grid { 51 | display: flex; 52 | user-select: none; 53 | margin: 8px; 54 | } 55 | 56 | .column { 57 | border: 2px solid var(--bg); 58 | display: flex; 59 | flex-direction: column-reverse; 60 | padding: 0 2px; 61 | } 62 | .column.playing { 63 | border: 2px solid #aaf; 64 | } 65 | 66 | .cell { 67 | width: 30px; 68 | height: 30px; 69 | margin: 2px 0; 70 | border: 2px solid grey; 71 | } 72 | .cell:hover { 73 | background: #ccc; 74 | } 75 | .cell.selected { 76 | background: #ddf; 77 | } 78 | .cell.selected:hover { 79 | background: #ccf; 80 | } 81 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import cx from "classnames"; 4 | import "@spectrum-web-components/theme/sp-theme.js"; 5 | import "@spectrum-web-components/theme/theme-dark.js"; 6 | import "@spectrum-web-components/theme/scale-medium.js"; 7 | import "@spectrum-web-components/slider/sp-slider.js"; 8 | import "./index.css"; 9 | 10 | function range(n) { 11 | return Array.from({ length: n }).map((_, i) => i); 12 | } 13 | 14 | function Cell({ select, selected }) { 15 | return ( 16 |
e.buttons && select()} 20 | >
21 | ); 22 | } 23 | 24 | function Column({ playing, val, setVal }) { 25 | return ( 26 |
27 | {range(5).map((i) => ( 28 | setVal(i)} selected={val === i} /> 29 | ))} 30 |
31 | ); 32 | } 33 | 34 | function Sequencer({ 35 | sequence, 36 | setSequence, 37 | device, 38 | paused, 39 | onToggle, 40 | onRemove, 41 | }) { 42 | const [playing, setPlaying] = useState(0); 43 | 44 | const setVal = useCallback( 45 | (v, i) => { 46 | const newSequence = {...sequence}; 47 | newSequence.values[i] = v; 48 | setSequence(newSequence); 49 | }, 50 | [sequence, setSequence] 51 | ); 52 | 53 | const setDuration = useCallback( 54 | (duration) => { 55 | const newSequence = {...sequence}; 56 | newSequence.duration = duration; 57 | setSequence(newSequence); 58 | }, 59 | [sequence, setSequence] 60 | ); 61 | 62 | useEffect(() => { 63 | if (paused) return; 64 | 65 | const id = setInterval(() => { 66 | setPlaying((playing) => { 67 | playing = (playing + 1) % 20; 68 | const allowedMessages = device.AllowedMessages; 69 | const messageTypes = Buttplug.ButtplugDeviceMessageType; 70 | if (allowedMessages.includes(messageTypes.LinearCmd)) { 71 | device.linear(sequence.values[playing] / 4, Math.floor(sequence.duration * 0.9)); 72 | } else if (allowedMessages.includes(messageTypes.VibrateCmd)) { 73 | device.vibrate(sequence.values[playing] / 4); 74 | } 75 | return playing; 76 | }); 77 | }, sequence.duration); 78 | 79 | return () => clearInterval(id); 80 | }, [device, paused, sequence]); 81 | 82 | return ( 83 |
84 |
85 | {sequence.values.map((v, i) => ( 86 | setVal(v, i)} 91 | /> 92 | ))} 93 |
94 |
95 | 98 | setDuration(parseInt(e.target.value, 10))} 105 | > 106 |
107 | 108 |
109 |
110 | ); 111 | } 112 | 113 | const initialSequences = localStorage.sequences 114 | ? JSON.parse(localStorage.sequences) 115 | : [{values: range(20).map(() => 0), duration: 250}]; 116 | 117 | function App() { 118 | const [connecting, setConnecting] = useState(true); 119 | const [devices, setDevices] = useState([]); 120 | const [device, setDevice] = useState(); 121 | const [sequences, setSequences] = useState(initialSequences); 122 | const [playing, setPlaying] = useState(); 123 | 124 | const setDeviceIndex = useCallback((index, devices) => { 125 | index = Number(index); 126 | if (isNaN(index)) return; 127 | localStorage.deviceIndex = index; 128 | const device = devices.find((device) => device.Index === index); 129 | if (device) { 130 | setDevice(device); 131 | } else if (devices.length) { 132 | setDevice(devices[0]); 133 | localStorage.deviceIndex = 0; 134 | } 135 | }); 136 | 137 | useEffect(async () => { 138 | await Buttplug.buttplugInit(); 139 | 140 | const client = new Buttplug.ButtplugClient("vibe sequencer"); 141 | client.on("deviceadded", () => { 142 | setDevices(client.Devices); 143 | setDeviceIndex(localStorage.deviceIndex, client.Devices); 144 | }); 145 | 146 | await client.connect(new Buttplug.ButtplugWebsocketConnectorOptions()); 147 | 148 | setDevices(client.Devices); 149 | setDeviceIndex(localStorage.deviceIndex, client.Devices); 150 | 151 | client.startScanning(); 152 | 153 | setConnecting(false); 154 | }, []); 155 | 156 | return ( 157 | 158 |

vibe sequencer

159 | 160 | 176 | 177 | {sequences.map((sequence, i) => { 178 | return ( 179 | { 182 | if (play) setPlaying(i); 183 | else { 184 | device.stop(); 185 | setPlaying(null); 186 | } 187 | }} 188 | onRemove={() => { 189 | if (playing === i) { 190 | if (device) device.stop(); 191 | setPlaying(null); 192 | } 193 | const newSequences = sequences.slice(0); 194 | newSequences.splice(i, 1); 195 | localStorage.sequences = JSON.stringify(newSequences); 196 | setSequences(newSequences); 197 | }} 198 | key={i} 199 | sequence={sequence} 200 | setSequence={(sequence) => { 201 | const newSequences = sequences.slice(0); 202 | newSequences[i] = sequence; 203 | localStorage.sequences = JSON.stringify(newSequences); 204 | setSequences(newSequences); 205 | }} 206 | device={device} 207 | /> 208 | ); 209 | })} 210 | 211 | 221 |
222 | ); 223 | } 224 | 225 | ReactDOM.render(, document.getElementById("root")); 226 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "parcel serve index.html", 5 | "build": "parcel build --public-url './' index.html" 6 | }, 7 | "browserslist": [ 8 | "last 3 Chrome versions", 9 | "last 3 Firefox versions", 10 | "last 3 Safari versions" 11 | ], 12 | "dependencies": { 13 | "@spectrum-web-components/slider": "^0.9.3", 14 | "@spectrum-web-components/theme": "^0.8.4", 15 | "classnames": "^2.3.1", 16 | "react": "^17.0.2", 17 | "react-dom": "^17.0.2" 18 | }, 19 | "devDependencies": { 20 | "parcel-bundler": "^1.12.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | rm -rf dist 2 | npm run build 3 | pushd dist 4 | git init 5 | git add . 6 | git commit -m 'deploy' 7 | git remote add origin https://github.com/webvrnsfw/vibe-sequencer 8 | git push -u origin --force master:gh-pages 9 | popd 10 | rm -rf dist 11 | --------------------------------------------------------------------------------