Drop audio here
MP3, WAV, OGG, FLAC - stays in your browser
Manually map beats - or trace pitch as a soundwave - to music. Serve them as an API. Sync anything to sound: lights, animations, particles, cameras, pitch.
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.A soundwave is a continuous pitch contour over time, not discrete beats. You record it by moving your mouse up and down to trace the melody - machines do automatic pitch detection badly, so this is the human-timed equivalent for pitch.
A soundwave is a list of points, each with a time in milliseconds and a normalized pitch from 0.0 (bottom of the recording area) to 1.0 (top). Map that range to whatever you want on the consumer side - a frequency, a note, a Y position, an emission rate.
{
"name": "my-melody",
"type": "wave",
"easing": 1,
"grid": 50,
"pitchSteps": 1000,
"waves": [
{ "t": 0, "p": 0.50 },
{ "t": 50, "p": 0.62 },
{ "t": 100, "p": 0.81 },
{ "t": 150, "p": 0.74 }
],
"waveCount": 4,
"encoded": "W~1~O;0~..."
}1 means interpolate smoothly between points, 0 means hold each value until the next (stepped). grid: the resample interval in ms, or 0 if the points are raw, un-chopped samples.Soundwaves use the same base-74 charset, with a leading W~<easing>~<grid>; header so a wave string can never be confused with a beat string. The body is delta-encoded time paired with the pitch quantized to 0..1000:
The /next endpoint is wave-aware. Pass ?t= and it returns the interpolated pitch at that moment plus the next point - ideal for driving something live without pre-scheduling.
GET /api/beats/my-melody/next?t=120
{
"name": "my-melody",
"type": "wave",
"currentMs": 120,
"pitch": 0.778,
"nextPoint": { "t": 150, "p": 0.74 },
"msUntilNext": 30
}const { waves, easing } = await (await fetch('/api/beats/my-melody')).json(); function pitchAt(ms) { if (ms <= waves[0].t) return waves[0].p; const last = waves[waves.length - 1]; if (ms >= last.t) return last.p; let i = waves.findIndex(p => p.t > ms); const a = waves[i - 1], b = waves[i]; if (!easing) return a.p; // stepped return a.p + (b.p - a.p) * ((ms - a.t) / (b.t - a.t)); } audio.addEventListener('timeupdate', () => { const pitch = pitchAt(audio.currentTime * 1000); osc.frequency.value = 110 + pitch * 770; // map 0..1 to 110โ880 Hz });
The editor's Luau button exports a soundWave table. Sample it the same way to move a part, tween a value, or pick a note.
local soundWave = { easing = true, points = { { t = 0, pitch = 0.500 }, { t = 50, pitch = 0.620 }, { t = 100, pitch = 0.810 }, }, } local function pitchAt(ms: number): number local pts = soundWave.points if ms <= pts[1].t then return pts[1].pitch end if ms >= pts[#pts].t then return pts[#pts].pitch end for i = 2, #pts do if pts[i].t > ms then local a, b = pts[i - 1], pts[i] if not soundWave.easing then return a.pitch end return a.pitch + (b.pitch - a.pitch) * ((ms - a.t) / (b.t - a.t)) end end return pts[#pts].pitch end game:GetService("RunService").Heartbeat:Connect(function() local p = pitchAt(sound.TimePosition * 1000) workspace.Bar.Position = Vector3.new(0, 5 + p * 20, 0) end)
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.
Switch the mode toggle at the top of the editor from Beats to Soundwave. Press Record and move your mouse up and down over the timeline - high in the area is a high pitch, low is a low pitch. Your contour is traced live in blue. Recording always starts a fresh take from the very beginning of the song (or from your continuation line), not from wherever the playhead happens to sit.
soundWave table exactly like a beat map.The speed buttons in the transport bar slow the song down (0.5ร) or speed it up (1.5ร) without changing its pitch - handy for tapping or tracing a fast passage accurately. Click 1ร to reset. Speed only changes how the audio plays back; every beat and soundwave point is still recorded at its true position in the song, so a take recorded at 0.5ร lines up perfectly at full speed. The count-in adapts automatically so it still lands you on the downbeat.
| Key | Action |
|---|---|
| Space / F / J (tap) | Place a beat at the current position. Starts playback instead if paused. F and J work the same as Space - handy if Space scrolls or misbehaves. In Soundwave mode these just toggle play/pause. |
| 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 |
The editor autosaves your beats, holds and soundwaves to this browser as you go. If you refresh or accidentally close the tab, your work is recovered when you come back (you'll just need to re-load the audio file). If you try to leave with unsaved changes, the browser asks you to confirm first. Saving to the API or loading another map clears the local copy.