A format for storing what a tape did, not what it meant.
A cassette carries a two-state signal: the level is high, then low, then high
again. Every format built to preserve those tapes has had to decide how much of
the loader to understand — .cas understands the bytes, .tap the bits, .tzx
the blocks — and each of those decisions eventually meets a tape that does not
fit it. TSM decides nothing. It records when the signal changed, to a tick,
and leaves the understanding to whatever plays it back.
That makes it independent of the machine. The same file describes a standard KCS tape from an SC-3000 or an MSX, a ZX Spectrum ROM loader, a turbo loader nobody documented, a protection scheme built on timing, or a tape that changes format half way through — because none of that is the format's business.
Current version: v5.2 "Compact Escape" — magic TSM5, version 5.
A tape is a sequence of regions, and there are three kinds:
| Region | Value | What it holds |
|---|---|---|
ZERO_SILENCE |
4 | A true gap: no signal at all. |
SILENCE / hold |
0 | The level stays where it was. No edges, a few bytes. |
INDEXED_DELTA |
3 | The signal itself, as the intervals between its edges. |
A file may also carry a label section, appended after the payloads and found from the end: what the programs on the tape are called, which system wrote each region, where the recording came from, what has to be typed to load it. It is optional and invisible to a reader that does not want it — nothing before it moves, so a TSM with labels is a TSM without them plus bytes at the end.
Long pilot tones and the silences between blocks cost almost nothing; the data blocks are a compact nibble stream with an escape for the intervals that do not fit the table. The tick is configurable and defaults to 4 µs, which is finer than any tape loader cares about and far cheaper than storing samples.
An 80-byte header, a table of 104-byte region entries, then the payload. The
whole thing is in SPECIFICATION.txt — header fields,
region fields, the delta encoding, and the reasoning behind each.
| Folder | What it is |
|---|---|
SPECIFICATION.txt |
The format itself: every field, the encoding, and the reasoning behind each. |
src/ |
The reference implementation in C99: reading and writing TSM, converting WAV in and out, and the repair tools built on top. |
batch/ |
Windows drag-and-drop wrappers: drop a WAV on one and it does the job. |
web/ |
The same sources built to WebAssembly, and a player page that runs them. |
.github/ |
The workflow that builds the player and publishes it. |
make # five tools into bin/
make check # build them, then run each one
C99, nothing beyond the standard library and libm, clean under -Wall -Wextra. On Windows without make, batch/build_v5_2_compact.bat does the
same with plain gcc lines.
web/build.sh # needs emcc on PATH; writes web/tsm.js and web/tsm.wasm
Two entry points: tsm_web_describe() returns the regions and labels as JSON,
tsm_web_render() returns the signal as float samples ready for Web Audio.
Both are thin: the renderer is tsm2wav_v5.c exactly as it ships, with only
its entry point renamed, so the page and bin/tsm2wav_v5 cannot drift apart.
On a 106-second tape the two agree sample for sample, and the render takes
about a third of a second.
web/index.html is a deck built on them, and is what runs at
tsm-format.org. Drop a .tsm on it and it plays,
winds, and runs backwards; it lists the regions, and the labels of section 2.5
if the file carries any. Serve the folder over HTTP — a browser will not fetch
WebAssembly from file://:
cd web && python -m http.server 8000 # then open http://localhost:8000/
Pushing to main publishes it. .github/workflows/pages.yml installs
Emscripten, builds the tools, builds the decoder from these same sources and
deploys to GitHub Pages, which serves it over HTTPS. The decoder is never
committed — it is a build product — so what goes out is always a page and a
decoder made together, and a clone of this repository is enough to reproduce
the site exactly.
To host it elsewhere instead, run web/build.sh and upload four files from
web/: index.html, player.js, tsm.js and tsm.wasm. On Apache add
web/.htaccess, which gives .wasm its type; GitHub Pages already serves it
correctly, which is why the workflow does not publish that file.
There is no server side either way. The decoding happens in the visitor's browser and no tape ever leaves their machine.
What it draws over the tape is the rate at which the signal changes, not a peak envelope. A carrier tone fills every column of an envelope from edge to edge, so the whole tape comes out one solid block; the rate of change is the thing a TSM actually stores, and it tells a pilot tone from data and one protocol from another at a glance. There is no recording feature. A player writes nothing.
bin/wav2tsm_indexed_v5 tape.wav tape.tsm 4000 16 0.35 1 8.0
bin/tsm_v5_indexed_audit tape.tsm
bin/tsm2wav_v5 tape.tsm again.wav 44100
On a Sega SC-3000 recording of 600 known bytes: a 910 KB WAV becomes an 18 KB TSM — fifty times smaller — and renders back to a WAV of exactly the same length, which decodes to the same 600 bytes across the same 34,793 edges. The saving is not in throwing anything away. It is in not storing the shape of a sine wave nobody needs.
| Tool | What it does |
|---|---|
wav2tsm_indexed_v5 |
A WAV recording of a tape becomes a TSM. |
tsm2wav_v5 |
And back again, for listening or for a real machine. |
tsm_v5_indexed_audit |
Checks a TSM against itself: regions, deltas, totals. |
wav_gap_scan |
Finds the silences, which is where a tape divides. |
wav_tsm_advisor_v5_indexed |
Suggests the encoding parameters for a given recording. |
Storing the samples is honest and enormous: a 90-minute cassette at 48 kHz is half a gigabyte, and almost all of it describes the shape of a sine wave nobody needs. Storing the decoded bytes is small and lossy in the way that matters — it throws away the timing, and the timing is the protection scheme, the turbo loader, and the evidence when a tape does not load.
Edges keep what a tape actually did. A TSM of that same cassette is a couple of hundred kilobytes, and a machine reading it back through its own demodulator behaves exactly as it did with the tape: the loader is not bypassed, it is fed.
TSM was written by Francesco De Simone while building an emulator that needed a
tape format able to hold anything a cassette could carry. That emulator is a
separate project and is not required here: nothing in this repository depends on
it, and nothing in the format is about any one machine. It is worth a mention
only because the format has been implemented twice, independently, from the
document in SPECIFICATION.txt — which is the test a specification has to
pass.
What would make it a standard rather than one person's format: readers in other emulators, converters to and from the formats that already exist, and other people's tapes exercising the parts of the model that a single collection cannot. The specification is the whole contract. If something in it is ambiguous, that is a bug and worth reporting.
Two licences, because there are two different things here.
The specification (SPECIFICATION.txt) is under
CC BY 4.0. Implement TSM in any
language, for any purpose, commercial or not, without asking and without using
a line of this code. Name the format as TSM, by Francesco De Simone. A format
nobody may implement freely is not a format.
The reference implementation (src/, batch/, Makefile) is under the
Apache License 2.0. Use it, change it, ship it inside a product,
sell it. What Apache asks in return is its section 4(d): the attribution in
NOTICE travels with the work, so wherever the code ends up, where
it came from goes with it.
Copyright (c) 2026 Francesco De Simone.