Drop audio here
MP3, WAV, OGG, FLAC โ stays in your browser
Manually map beats to music. Serve them as an API. Sync anything to sound - lights, animations, particles, cameras.
No beat maps yet.
Open the editor and tap your first beat.
MP3, WAV, OGG, FLAC โ stays in your browser
Tap beats to a song in the editor. The API serves those timestamps to any client. Timestamps are human-timed against the actual audio file, so they reflect the real feel of the music rather than a detected grid.
Beat timestamps compress into a short string using base-74 delta encoding. Even a 300-beat map fits in a few hundred characters.
Instead of storing every timestamp in full, it stores the first one absolute, then only the gap between each beat:
Each number encodes as base-74. Parts join with ~. Gap 12 becomes c, gap 7 stays 7:
Hold beats append after a ; separator. Maps with no holds stay byte-for-byte identical to the original format.
const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%^*()_-=+'; const BASE = CHARSET.length; function decodeNum(s) { let r = 0; for (const c of s) r = r * BASE + CHARSET.indexOf(c); return r; } function decode(encoded) { const parts = encoded.split('~'); const ts = [decodeNum(parts[0])]; for (let i = 1; i < parts.length; i++) ts.push(ts[ts.length - 1] + decodeNum(parts[i])); return ts; }
Base URL: https://wspacebot.com ยท All routes under /api/beats
[
{
"name": "my-song",
"title": "Artist - Track",
"beatCount": 312,
"duration": 183420,
"encoded": "Ln~c~7~...",
"canEdit": true,
"updatedAt": "2024-01-01T00:00:00.000Z"
}
]{
"name": "my-song",
"title": "Artist - Track",
"timestamps": [3000, 3012, 3019, 3031, ...],
"beatCount": 312,
"encoded": "Ln~c~7~...",
"charset": "0123456789abc...",
"base": 74,
"canEdit": true
}Pass the current audio position in ms as ?t=. Returns the surrounding beats and exactly how many ms until the next one. Best for polling loops when you need live sync without pre-scheduling everything.
{
"name": "my-song",
"currentMs": 3010,
"nextBeat": 3019,
"prevBeat": 3012,
"msUntilNext": 9
}Returns the raw encoded string, charset, and base. Use this to decode client-side without fetching the full timestamp array.
{
"name": "my-song",
"encoded": "Ln~c~7~...",
"charset": "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%^*()_-=+",
"base": 74
}Creates the map if the name is new, updates it if you own it. Requires authentication.
| Field | Type | Notes |
|---|---|---|
| name | string | Required. Letters, numbers, _ and - only. |
| timestamps | number[] | Required. Beat timestamps in milliseconds. |
| title | string | Optional. Display name for the song. |
{
"success": true,
"name": "my-song",
"beatCount": 312,
"encoded": "Ln~c~7~..."
}Permanently removes the map. You must own it.
{
"success": true
}Fetch timestamps once when the song starts. Schedule effects with setTimeout using each timestamp offset from the current playback position.
const { timestamps } = await (await fetch('/api/beats/my-song')).json(); let idx = 0; function schedule(audio) { if (idx >= timestamps.length) return; const ms = timestamps[idx] - audio.currentTime * 1000; setTimeout(() => { shakeWindow(); idx++; schedule(audio); }, Math.max(0, ms)); } audio.addEventListener('play', () => { idx = 0; schedule(audio); });
For visual effects where timing precision under 100ms doesn't matter, polling timeupdate and setting a timeout is sufficient.
const { timestamps } = await (await fetch('/api/beats/my-song')).json(); audio.addEventListener('timeupdate', () => { const nowMs = audio.currentTime * 1000; const next = timestamps.find(t => t > nowMs); if (!next) return; clearTimeout(window._beatTimer); window._beatTimer = setTimeout(() => { document.body.classList.add('beat'); setTimeout(() => document.body.classList.remove('beat'), 80); }, next - nowMs); });
import requests data = requests.get('https://wspacebot.com/api/beats/my-song').json() timestamps = data['timestamps'] print(f"Loaded {len(timestamps)} beats") print(f"First beat at {timestamps[0]}ms")
HttpService. Requests fail silently without it.All timestamps are in milliseconds. Divide by 1000 before passing to task.delay, which expects seconds.
Fetch the beat map once when the sound plays, then schedule all effects upfront with task.delay. This is more accurate than polling sound.TimePosition in a loop.
local HttpService = game:GetService("HttpService") local data = HttpService:JSONDecode( HttpService:GetAsync("https://wspacebot.com/api/beats/my-song") ) local timestamps = data.timestamps local sound = workspace.MySound local part = workspace.BeatPart sound:Play() local startedAt = tick() for _, ms in ipairs(timestamps) do local delay = (ms / 1000) - (tick() - startedAt) if delay > 0 then task.delay(delay, function() part.BrickColor = BrickColor.new("Bright yellow") task.delay(0.06, function() part.BrickColor = BrickColor.new("Medium stone grey") end) end) end end
Copy the encoded string from the editor's save bar and paste it into your script. This skips the HTTP request entirely and works without HttpService being enabled.
local CHARSET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%^*()_-=+" local BASE = #CHARSET local function decodeNum(s: string): number local n = 0 for i = 1, #s do local idx = CHARSET:find(s:sub(i, i), 1, true) or 1 n = n * BASE + (idx - 1) end return n end local function decodeBeats(encoded: string): {number} local parts = encoded:split("~") local out = { decodeNum(parts[1]) } for i = 2, #parts do out[i] = out[i - 1] + decodeNum(parts[i]) end return out end local ENCODED = "Ln~c~7~..." local timestamps = decodeBeats(ENCODED)
ENCODED. Update it whenever you re-map the song.Hold beats have a start and duration in ms. In the editor, hold SPACE to record one. The duration is determined by how long you hold the key. Use the Luau button in the save bar to export a table containing both regular and hold beats.
local beatMap = { timestamps = { 1000, 1500, 2000, 2500 }, holdTimestamps = { { start = 3000, duration = 800 }, { start = 5200, duration = 400 }, }, } local sound = workspace.MySound local part = workspace.GlowPart sound:Play() local startedAt = tick() for _, ms in ipairs(beatMap.timestamps) do local delay = (ms / 1000) - (tick() - startedAt) if delay > 0 then task.delay(delay, function() part.BrickColor = BrickColor.new("Bright yellow") task.delay(0.06, function() part.BrickColor = BrickColor.new("Medium stone grey") end) end) end end for _, hold in ipairs(beatMap.holdTimestamps) do local startDelay = (hold.start / 1000) - (tick() - startedAt) local endDelay = ((hold.start + hold.duration) / 1000) - (tick() - startedAt) if startDelay > 0 then task.delay(startDelay, function() part.Material = Enum.Material.Neon end) end if endDelay > 0 then task.delay(endDelay, function() part.Material = Enum.Material.SmoothPlastic end) end end
Use this when the playback position can change at runtime, such as when the sound loops or seeks. Poll at a fixed interval and schedule the next beat on each tick.
local HttpService = game:GetService("HttpService") local sound = workspace.MySound local lastFired = -1 local function onBeat() workspace.BeatPart.BrickColor = BrickColor.new("Bright yellow") task.delay(0.06, function() workspace.BeatPart.BrickColor = BrickColor.new("Medium stone grey") end) end task.spawn(function() while sound.Playing do local t = math.floor(sound.TimePosition * 1000) local url = "https://wspacebot.com/api/beats/my-song/next?t=" .. t local ok, res = pcall(function() return HttpService:JSONDecode(HttpService:GetAsync(url)) end) if ok and res.nextBeat and res.nextBeat ~= lastFired then task.delay(res.msUntilNext / 1000, onBeat) lastFired = res.nextBeat end task.wait(0.1) end end)
/next polling when the playback position can change mid-session.Drop an audio file in, hit play, tap beats as the song runs. Each tap places a marker at the current audio position. Multiple passes are supported; each pass adds to existing beats without replacing them.
Hold SPACE for 80ms or more, then release. The hold duration is determined by how long you held the key. Holds appear as amber blocks on the timeline. Click a block to remove it. Use the Luau button to export a table that includes them.
| Key | Action |
|---|---|
| Space (tap) | Place a beat at the current position. Starts playback instead if paused. |
| Space (hold) | Hold 80ms or more then release to place a hold beat. An amber bar grows under the TAP button while you hold. |
| Z | Undo the last placed beat or hold, whichever came most recently. |
| P | Toggle play / pause |
| Scroll | Zoom the timeline in or out at the cursor position |