Browser-side batch converter for Smash Drums .indies — drop DTX song zips or folders, convert all, download each. Everything runs locally; nothing is uploaded.
.indies file?An .indies file is a ZIP archive (just renamed) that Smash Drums reads from its /Indies/ folder. It contains exactly four files at the archive root:
| File | Purpose | How this tool produces it |
|---|---|---|
| cover.png | Song selection artwork | PNG passthrough → transcode other formats → generated if none |
| audio.ogg | Full song audio | OGG copied byte-for-byte (lossless); MP3/WAV/MP4/FLAC/WebM auto-transcoded to OGG Vorbis (lossy) |
| preview.wav | Short menu preview | Decoded from #PREVIEW, or a slice of the song, re-encoded to WAV |
| meta.json | All chart & timing data | Built from the DTX headers, notes and BPM map |
Smash Drums has six note lanes. DTXMania channels are mapped by their drum-kit semantics:
| Indies Id | Instrument | DTX channels |
|---|---|---|
| 0 | Bass (blue) | 13, 1C |
| 1 | Snare (red) | 12 |
| 2 | Cymbal (yellow) | 16, 19, 1A |
| 3 | Tom (green) | 14, 15, 17 |
| 4 | Hi-Hat (pink) | 11, 18, 1B |
| 5 | Clapfire | — (not auto-mapped) |
Field names are case-sensitive (the game deserializes them directly).
NameArtist / NameSong / NameCharter — from DTX #ARTIST / #TITLE / #COMMENTSongTiming — beat anchors: each has an integer beat and a float timer (seconds). At least two, ascending.SongPhases — visual intensity phases. A single default Main phase is emitted (phase 2 = verse, power 1).ChartEasy/Normal/Hard/Extreme — note lists. Only ChartExtreme is required; the game generates missing easier charts itself. Which of these are written is governed by the Difficulty slotting selector (see below) — Extreme only emits ChartExtreme alone and lets the game synthesize the rest.Each note has a float Beat, a Strength (0 crystal / 1 neutral / 2 burning — all notes are emitted as 1) and an Id (0–5 as above).
DTX packs often ship several difficulty files. The toolbar Difficulty slotting selector controls how they map to the four Indies slots. The choice applies to every file in the queue and is reflected live in each card’s preview.
Auto (default) sorts by #DLEVEL and spreads the charts so the hardest is always Extreme:
Extreme only writes just the single hardest chart into ChartExtreme and leaves Easy/Normal/Hard out of meta.json entirely. Because the game generates any missing easier charts itself, this hands the lower difficulties to Smash Drums’ own down-scaling instead of pinning a DTX’s middle charts into fixed slots. Regardless of how many difficulty files the pack ships, the output carries only your top hand-authored chart:
meta.json differs.SongTiming must start at {beat:0, timer:0.0} — a negative timer is rejected, so a DTX’s delayed-BGM start cannot be expressed as a negative offset. Since v1.6.2 this tool keeps every note’s beat coordinate exactly as authored and preserves the chart’s intended run-up by padding the BGM delay as silence in front of audio.ogg: the timeline then uses plain absolute DTX seconds (timer(b) = beatToSeconds(b)), the first anchor is naturally {beat:0, timer:0.0}, and notes before the BGM onset keep a valid position (no more E20 dropping). The cost is a lossy re-encode when the delay is non-zero (surfaced as E21). For the common no-delay case the OGG is still copied byte-for-byte — no silence padding, no re-encoding, byte-identical output. Verifiable either way. One caveat the tool can only warn about (E22): if the DTX itself uses a fractional #xxx02 bar length (4×len non-integer, e.g. an auto-generated lead-in bar), every later bar line lands on a non-integer beat — notes then sit off the editor’s integer-beat grid even though they play at the correct time. Coordinates are never altered; fix the DTX source if grid alignment matters.audio.ogg. If a DTX’s BGM is MP3/WAV/MP4/etc., the tool decodes it with the Web Audio API and re-encodes it to OGG Vorbis automatically — surfaced as a lossy re-encode (E07), not done silently. For guaranteed lossless audio, supply a native .ogg BGM. If the browser cannot decode the source, you’ll be asked to convert it manually (E19).Copy your .indies files into Android/data/com.PotamWorks.SmashDrums/files/Indies/ on the headset (via USB/Explorer or SideQuest’s file manager), then pick the “Indies” category in the song menu. Full instructions live at discord.smashdrums.com.
This page doubles as the version log for the tool. Newest first.
#xxx02 bar length makes 4×len non-integer — typically an auto-generated lead-in bar like #00002: 0.577375 (2.3095 beats) used to align measure 001’s downbeat with the audio. Every later bar line then falls on a non-integer beat, so every note’s Beat carries a constant fractional offset (e.g. +0.3095) and sits visibly off the integer-beat anchors/grid in the Smash Drums editor. Audio timing is unaffected — SongTiming interpolates linearly, so notes still play at the correct time; this is a chart-grid cosmetic/authoring issue, not a sync bug.0.75 = 3 beats, 0.5 = 2 beats are still integer). Detection only; note coordinates are never altered (the keep-beats-as-authored principle since v1.4.0). The proper fix belongs in the DTX source: keep bars full-length and express the audio offset as a delayed BGM chip (absorbed as padded silence, E21) or as leading silence in the audio file itself.DtxParser.getBarGridMisalignment() detector, the E22 table entry, warning plumbing in preview/convert, and unit tests for fractional, odd-meter, and realigned-grid cases.VmRSS (same number Task Manager shows). Crucially the peak scaled with queue length, not per-file work: an 8-file batch of 226 MB packages peaked at only 272 MB, while 89 smaller files hit 2.1 GB — and after a GC the process fell back to ~217 MB (baseline ~150 MB), so it was never a resident leak but a transient-allocation pileup inside GC windows. The cause was never the audio (200 MB×N BGA video never entered memory — the on-demand zip reader works; the transcode path itself showed no growth). It was renderQueue(): on every file completion it did queueEl.innerHTML='' and rebuilt every card in the queue — re-decoding all N cover-image base64 data-URIs into bitmaps and re-emitting all N×4 timeline SVGs. Cost was O(whole queue) per completed file; across 89 files converting every 4–7 s, GC couldn’t keep up with the short-lived strings and decoded bitmaps. Earlier “memory” fixes (v1.5 OfflineAudioContext, v1.6.3 AudioBuffer churn + on-demand zip + blob revoke) all targeted resident memory and the transcode path, so the render-driven peak never moved — which is why they felt like they did nothing on Task Manager.renderQueue() (full rebuild) is called only on structural changes — adding files, removing, clearing, run-state toggles, downloads. During conversion each file’s status change goes through a new upsertCard() that updates only that one card, dropping per-file DOM cost from O(queue) to O(1). Cover <img> nodes are preserved across updates when their src is unchanged (the already-decoded bitmap is reused instead of forcing a re-decode). Cards also get content-visibility:auto so off-screen covers skip decode entirely, and covers carry decoding="async". Output bytes are unchanged — this is a pure rendering-path change, no effect on the produced .indies.#DLEVEL with the hardest always on Extreme (output byte-identical to v1.6.5). Extreme only writes just the single hardest chart into ChartExtreme and omits the easier slots from meta.json, so Smash Drums generates Easy/Normal/Hard itself (meta.json only requires ChartExtreme). This lets a pack’s intermediate DTX charts be discarded in favor of the game’s own down-scaling — useful when the pack’s easier files are weak or absent.meta.json changes between modes — the audio.ogg (still byte-for-byte passthrough when lossless), preview.wav, cover.png, SongTiming and archive packaging are unaffected. The assignDifficultySlots core takes a mode option and defaults to Auto when unspecified (backward compatible); the shared unit suite’s existing slotting assertions are unchanged and a new set covers the Extreme only path.preview.wav, the whole BGM was run through decodeAudioData — a full-length AudioBuffer (~44MB for a 120s stereo track) plus the decoder’s internal transient buffers, per file. Previews are now cut at the Ogg page layer: pages carry absolute granulepos (for Vorbis, the PCM sample index at page end), so the tool splices [header pages] + [pages covering 30%→30%+12s] — verbatim byte copies, CRCs untouched — and decodes only that few-hundred-KB stream. Verified against ffmpeg (the same decoder family Chrome uses for decodeAudioData): the spliced stream decodes warning-free with correct duration, and its samples are bit-exact with the original from 1s into the slice onward, on both a real libvorbis file and a web-encoder-produced file (the first packet is truncated by design — MDCT overlap — absorbed by a one-page lead plus trimming). Falls back to full decode if slicing fails (non-Vorbis, malformed pages). Preview and #PREVIEW decoding also now use the shared OfflineAudioContext.JSZip.loadAsync expanding every package in full, once at parse and again at convert, while most of a DTX package’s bytes (keysounds, BGA video) are never used by this converter (measured sample: 45% unused in a 6.4MB pack; video-bearing packs are far worse). Each load produced package-sized short-lived allocations; across a large batch those stack inside GC windows into the multi-GB Task-Manager peak. Zip reading now goes through an embedded on-demand reader: it reads only the end-of-file central directory (KBs) and then File.slices just the requested entries (.dtx, cover, BGM), inflating DEFLATE via the native DecompressionStream('deflate-raw'). Node reproduction, 64MB pack × 89 files: full-expand path 33.8s / +129MB RSS peak; on-demand path 0.3s / +0MB. Entry bytes verified md5-identical to python zipfile for every entry of the real sample (27/27).JSZip.loadAsync (previous behavior). Output packaging still uses JSZip. Filenames honor the UTF-8 flag, then strict UTF-8, then Shift-JIS — strictly more correct than the previous UTF-8-only decode for the common Shift-JIS-named DTX packs.AudioBuffers per file (decode → padded copy → another full decode of the just-encoded OGG for the preview slice) plus redundant ArrayBuffer copies. Per 130s stereo file that was ~155MB of short-lived audio buffers — and since these live on Blink’s side (Oilpan GC, reclaimed lazily), batch-converting many files stacked them into a multi-GB process peak in Chrome’s Task Manager. Now: lead-in silence is fed to the encoder as zero blocks (no padded-copy buffer; PCM output verified byte-identical to the copy approach via Node + ffmpeg decode), the preview is sliced from the already-decoded BGM (no second full decode), and source ArrayBuffers are handed to decodeAudioData without a defensive .slice(0) (it detaches its input; byte counts are read beforehand). Net: one full-length AudioBuffer per file instead of three, ~106MB less transient allocation per 130s file.ArrayBuffer in meta for the preview button (N files = N resident BGMs); it stores the zip-entry path and loads lazily on play (files are disk-backed). Re-converting an item now revokes its previous blob URL. A yield is inserted between queue items so the engine gets a collection window. Decoding now reuses a single shared OfflineAudioContext instead of newing one per decode (never-rendered contexts pile up graph/handler resources; measured live on Chrome 151, the per-decode-context pipeline degraded from 15.3s to 32.3s to 45s+ per identical file across a batch).performance.memory, Chrome-only) showing live usage and the session peak during conversion — note it covers V8 heap + ArrayBuffers but not Blink-side audio buffers or blob storage, so Task Manager will read higher. High-frequency encode-progress updates now patch the single card badge instead of re-rendering the whole queue (which re-parsed every base64 cover each tick).#xxx02 bar-length now persists until the next change, per the DTX spec — the old parser fell back to 1.0 for every measure without an explicit setting (BMS semantics). Charts that set a length once and let it run (e.g. a 22-measure 0.75 section) had their beat axis inflated, drifting every note off the music — up to 8+ seconds on affected charts — and pushing the final notes past the end of audio.ogg. Charts that never change bar length, or re-state it every measure, were unaffected (which is why most conversions looked fine).audio.ogg instead of clamping it away. A chart that starts its BGM at the tail of measure 000 (a common DTX idiom to give players a run-up) keeps that run-up: the first note no longer arrives at t≈0. Costs a lossy re-encode when the source is OGG (surfaced as E21); the common no-delay case is untouched and stays byte-identical. E20 note-dropping is retired — pre-onset notes now have a valid position in the padded timeline..zip song packages (instead of loose SET.def/.dtx files) silently ignored every nested zip. The recursive walk did read them into the file map, but the song-package detector (findSongPackages) only treated a folder as a song when it directly held a SET.def or a .dtx — .zip was never a recognised package type, so a folder full of zips resolved to zero packages and surfaced as E17 No DTX song found in folder..zip again. The folder branch now peels every .zip out of the file map first and enqueues each as its own standalone zip package (loaded through JSZip.loadAsync, identical to a top-level dropped zip); the remaining non-zip files are then passed to the loose SET.def/.dtx detector. Loose song folders and nested zips can now coexist in a single drop — both are picked up, and E17 only fires when neither is present.setTimeout(0) to a MessageChannel message. Background tabs clamp setTimeout to ~1s (and to ~once/min after 5 minutes hidden); with the encoder yielding every 32 blocks, v1.5.0 fixed decode but encoding still crawled to the point of being unusable in a background tab.MessageChannel message tasks are not on the background wake-up throttling list (the same trick React’s scheduler uses). The yield still hands back a macrotask boundary so a foreground tab stays responsive, while a background tab runs at full speed.OfflineAudioContext’s decode thread, encode yields via MessageChannel, and JSZip compresses synchronously without timers. A backgrounded tab now drains the whole queue. Output bytes are unchanged from v1.5.0 (swapping the yield mechanism doesn’t affect the encoded result). The toolbar batch-download still uses a 400ms setTimeout gap (background downloads are separately limited by the browser) — unrelated to conversion, left as-is.OfflineAudioContext instead of the shared realtime AudioContext. A backgrounded tab has its realtime context auto-suspended by the browser, so the old path could stall on await ctx.resume() (that promise never resolves without a user gesture while the tab is hidden) and on decodeAudioData against a suspended context — freezing the whole convert queue until the tab returned to the foreground.OfflineAudioContext has no “waiting for hardware resume” state and runs decoding on a separate decode thread, so it is not subject to Page Visibility throttling. The convert queue now keeps draining in a background tab through the decode stage.sampleRate (read as a property, which does not require resume); decodeAudioData resamples to the context rate exactly as before, so on a given machine the decoded PCM — and therefore the OGG bytes — match v1.4.0. Decode-only fix: the encoder’s setTimeout(0) yields are still on the main thread and still throttled in the background, so encoding stays slow (not frozen) while the tab is hidden.Beat minus β, the BGM’s first-chip beat) so the BGM onset landed on beat 0. That mutated note coordinates and — whenever β wasn’t a whole number of beats — knocked on-grid notes onto fractional beats, corrupting the authored rhythm grid.SongTiming map: timer(b) = beatToSeconds(b) − δ (δ = BGM start time). The first anchor is still exactly {beat:0, timer:0.0}; pre-onset timers (which would be negative) are clamped to 0, and a ⌊β⌋ zero-anchor plus a ⌈β⌉ exact-anchor compress the bridge segment so post-onset interpolation is exact (zero error for whole-beat β).E20 (no audio to play against), but surviving notes keep their coordinates. The no-delay case (β=0) is a no-op — output is byte-identical to v1.3.0. Audio pipeline, CJK romaji prefixing and batch download are unchanged..indies — nothing is re-zipped into one combined archive.SongTiming to begin at {beat:0, timer:0.0} and rejects a negative timer; the previous build encoded a BGM start offset directly into the anchors, which produced a negative first timer for any delayed BGM and was refused by the game.beat 0. audio.ogg is still copied byte-for-byte (no silence padding, no re-encode), and the first anchor is always exactly {beat:0, timer:0.0} with all subsequent timers non-negative.E20 with a count. The no-delay case is a no-op — output is byte-identical to v1.1.0.E07 warning in the conversion log, and a transcoded-size readout. OGG BGM still passes through byte-for-byte (lossless), unchanged.E19 message asking you to convert to .ogg manually, rather than emitting an unplayable Indies file..indies conversion: meta.json, audio.ogg, preview.wav, cover.png.SongTiming anchors with full per-segment BPM-change support; BGM offset handled via anchors (no audio re-encode).#DLEVEL (hardest always Extreme), or Extreme only emits just the hardest chart and lets the game generate the easier ones.preview.wav rendered with the Web Audio API (decode → PCM → WAV); preview playback uses an AudioBufferSourceNode, no <audio> element..dtx both supported), parse-on-add, pause/resume conversion, duplicate detection.The DTX parsing and meta-building logic ships as a single shared module covered by an automated suite (81 unit assertions) plus an end-to-end pass that builds a real .indies from a generated OGG/DTX zip and verifies the archive structure, OGG passthrough and WAV header with ffmpeg (48 assertions).