Bildschirmtext page reconstruction
Simon Waite
Post date: Sun 18, Oct 2026 12:30:00 PM
Getting Bildschirmtext pages off old cassettes
Some of the Btx pages that still exist only survive because someone in the mid-80s saved the pages to a tape recorder from their terminal somehow. On playback you get a couple of minutes of what sounds like a fax machine, and somewhere inside that sound is an advert for a Commodore 64 in a German VideoText format.
I had two of these tapes as FLACs: tape 01, labelled “CI TELECOMPASS (Standard-Elemente) (GRAU) GRAFIK I”, and tape 63. Little did I realise someone else had already pulled the pages off them with their own decoder, until I had downloaded a few hour-long flac files over bittorrent (archive.org downloaded 75% of a 25Gb zip file, then dropped the connection) so later on I had a reference to check against. This post covers what it does and the places where it nearly went wrong.
The result is a folder called tapedecode with one command:
./tapedecode audio/ out/
Point it at a folder of FLACs. For each recording you get a folder of pageNN.cept
(the raw page bytes), pageNN.txt (an annotated hex dump) and pageNN.png (a
rendering).
Sound to bits
I was given a hint that these tapes were saved as V.23: 1200 baud, a frequency-shift keyed audio data standard, with 1300 Hz for a one and 2100 Hz for a zero. Standard stuff, except that this signal was originally written on a cassette deck forty years ago.
The tapes run slow, and I don’t mean 1200 baud is slow (it is), the actual tapedeck was slow. Tape 01 comes out at about 1180-1190 baud and tape 63 at about 1175-1190. That’s 2-2.5% slow, plus wow (the varying speed of the tape running through the mechanism), so the speed also drifts within a page.
The demodulator is pretty basic:
- Find the bits of tape that have carrier on them, using RMS in windows of 100 ms. Anything under two seconds is a click and gets ignored.
- Filter audio to 800–2800 Hz (Band pass), track frequency changes over time via phase differentiation, and remove rapid variations above 900 Hz with a low-pass filter.
- Then set a 1700 Hz threshold to separate two tones, providing a safety margin for speed errors due to wow-ing.
And a data stream of ones and zeros drops out. Hurrah!
Bits to bytes
Assuming BTX is the common 1200 baud 8N1 standard, rather than the weird 1200/75 baud 7e1 the British Prestel system used.
| Type | Start Bit(s) | Data bits | Parity Bits | Stop Bit | Total number of bits |
|---|---|---|---|---|---|
| BTX | 1 | 8 | 0 | 1 | 10 |
| Prestel | 1 | 7 | 1 | 1 | 10 |
So you find a start bit, step one bit period at a time, read eight data bits, and check for a stop bit.
But what if the “one-bit period” on the tape is running 2% slower than it should?
The first idea was to measure it from the run lengths in the bit stream. That turned out to be unreliable. Some recordings had odd 1.5-bit and 2.5-bit runs that drag the fit off by a wide margin, and then everything after it decodes as nonsense.
So to keep it simple (stupid), it was decided to take 10 seconds from the middle of the page burst (to avoid leader noise), for each bit period from -8% to +8% in quarter-sample steps, and note the number of framing errors for each pass. You end up with a brief window of good quality results. The middle of that window is the obvious bit period to use.
Wow is another issue. After determining the correct average speed, the tape will speed up and down within a page burst, so by bit 7 of a byte the sample point could have slipped off the edge. To fix that, the UART resyncs every bit: if there’s a transition within a third of a bit of where it expects a boundary, the UART moves the boundary there. That change got rid of most of the remaining errors.
Finally, split the byte stream wherever the line idles for more than 0.4 s. Each piece is one transmission, or noise.
Which bytes are the page?
So now each tape gives you a pile of pieces. On tape 63 a typical burst looks like this:
| Filename | Size |
|---|---|
| 63a_0_0.cept | 40 bytes |
| 63a_0_1.cept | 2604 bytes |
| 63a_0_2.cept | 2604 bytes |
| 63a_0_3.cept | 45 bytes |
63a_0_0.cept (40 bytes): the tone at the start of the recording, before the data. It decodes as noise.
00000000: fe5f 3dfd ffff eecc ff1f e77f aff6 bfeb ._=.............
00000010: dffb e5fd bfff fbff ede8 3fee fbff 7f5f ..........?...._
00000020: ae76 5feb fbfe bfff .v_.....
63a_0_3.cept (45 bytes): the tone at the end of the recording, after the data. Also noise.
00000000: feec bf8d f1cf 7cf1 fefe 7e77 ddfa ff7e ......|...~w...~
00000010: bf1f f3f6 7fff fade 17db fcff cdef 9fb7 ................
00000020: ffbf fb3b 57af 7ff5 7ced efbd d7 ...;W...|....
The 40- and 45-byte bits are the modem settling with a leader/trailer tone. They’re easy to spot because about 75% of their bytes have the top bit set. The actual pages are statistically lower. CEPT1 uses plenty of 8-bit control codes, but not that many! But you also can’t throw away data with a high-bit set or strip it. Anything under 64 bytes or more than 50% bytes with the high bit set can get in the bin.
The two 2604-byte blocks are duplicates of the same page. Whoever made these tapes recorded every page twice in a row, which was possibly a policy as tapes are horribly analogue and there are plenty of ways that can ruin a recording. Which takes me back to my ZX Spectrum and R-Tape Loading errors (The computer couldn’t read the tape - usually 75% of the way through), which make me wince to read even today… Anyway…
Thankfully, most of the time both copies are identical, and there’s nothing to do. However, there were three pages that weren’t:
- Tape 63, page 07 (the Olivetti CeBIT stand legend). In the first copy, right after
the word “Bildschirmtext”, the bytes
84 09(blue, cursor right) came out as84 89. One bit flipped, and “cursor right” became “blink off”. - Tape 63, page 09. A cursor positioning command
1F 4F 5E(row 15, column 30) became1F 4F BE, which asks for column 126 on a 40-column screen. - Tape 01, page 07. A proper dropout. 18 bytes in the middle of a DRCS bitmap came out scrambled, and further on three bytes came out as two, so the copies aren’t even the same length (1463 vs 1465).
So the pattern looks like; the top bit gets set where it shouldn’t be. That makes sense if a dropout looks like a mark (one) to the UART, and bit 7 is the last data bit before the stop bit.
So the heuristic for picking a copy is:
- If several copies agree byte for byte, use that.
- Otherwise, prefer the one with fewer framing errors,
- Then fewer high-bit bytes…
- Then the longer one.
That picks the right copy in all three cases, or at least correct as per the reconstruction! Each page it chose decoded with zero framing errors, though only tested with limited pages. Copies are grouped by content, not by burst: tape 01’s first set held two different pages, an early short version of the frame (769 bytes) and the full one (1044 bytes), so a heuristic of “one burst = one page” would have merged them.
Bytes to pictures
CEPT T/CD 06-01 is the European videotex standard. If you’ve seen Teletext, it’s that on steroids: redefinable character sets (DRCS) in up to 16 colours, four colour tables, serial and parallel attribute modes, and a lot of escape sequences … a lot. A page starts like this:
1f 2f 41 # serial mode
1f 26 21 # reset palette
1f 26 20 # start defining colors
1f 26 31 36 40 40 78 7f 78 47 47 7f 4f 7a 6d 6d # define colors 16+: "#000", "#bbb", ...
...
1f 23 20 47 42 # define characters 12x10, 4 colors
That’s the annotated dump format from decode_cept.c in the
bildschirmtext project. I ported it
to Python 3, to keep things Pythony, and checked the port against the C program’s output on all 17 pages.
16 came out identical. The 17th differed in the last line, where the C version prints three bytes
that aren’t in the file: a buffer overflow when a page ends in the middle of a sequence. The Python version
doesn’t. We don’t need Rust here ;-)
Things I learned about rendering CEPT1:
- G1 codes 0x40-0x5F aren’t letters or mosaic blocks. They’re weird smooth diagonal mosaics, the same shapes as G3 0x20-0x3F. One wrong assumption, and every slanted edge in every logo turns into nonsense.
- In multicolour DRCS, the first bitplane sent is the least significant bit. One of the docs in the Bildschirmtext repo says the opposite. The reference renders agree with LSB first on every page, so perhaps the docs are wrong.
- A transparent background shows the row colour if there is one set and only falls through to the screen colour if not.
1F 2D 41 70sets the screen format. The other decoder prints that trailing70as a stray “p” in the top-left corner of every page.
Flashing, conceal, and scrolling regions are parsed and then ignored, as we’re outputting a PNG.
Did it work?
I used the other decoder’s output (noticed later) as something to verify against; I probably wouldn’t have bothered on this side quest otherwise. All 17 pages, 8 from tape 01 and 9 from tape 63, come out byte-identical to the independent decoding, including the three where one copy was damaged. Unfortunately, the second side of each tape is blank: 01b has two low thumps and no carrier to be found, and 63b is silence.
The whole run takes about 45 seconds for four half-hour tape sides on my M1 Max MacBook Pro.
Tape 63 turned out to be interesting:
- A Hill and Knowlton picture puzzle advertising Commodore
- A set of pages from H. Geisler for Olivetti at the Hannover Messe, back when CeBIT was small, and still a hall at it (“CeBIT-Nord”). There’s a floor plan of the stand, a cafeteria and the M24 “Personal Computer für den Arbeitsplatz”.
- Each page still carries its Btx page number in the header line,
*1891913aand so on.
Tape 63

Tape 1

If you have tapes
The code is in tapedecode/. You need Python 3, ffmpeg, and maybe change line 584 for a suitable font,
as I’m on a Mac. The first run sets up its own venv. After a run, read index.md in each output folder.
It’ll tell you where on the tape each page was and which copy was used.
- “copies all differ” means the tape was damaged in at least one copy, and you should look at the result.
- “single copy” means there was nothing to check it against:
- tape 01 page 01 is one of those, and it’s fine, but only because it matches the reference.
The code can be found: https://github.com/opless/btx-v23-recovery-code The tapes are here: https://archive.org/details/bildschirmtext_tapes The original reconstruction is here: https://github.com/bildschirmtext/reconstructed_pages