Redbook
Reading the Red Book – decoding Compact Disc Digital Audio
Install / Use
/learn @carrotIndustries/RedbookREADME
Reading the red book – decoding compact disc digital audio
Much has been written and said about the technical details of compact disc digital audio, but so far, I've found no public record [^1] of anyone actually decoding the pits and lands on a compact disc to PCM samples. So I decided to do so. For an introduction on how compact discs work, I highly recommend the linked Wikipedia article and Technolgy Connection's video series.
Ingredients
Literature
There's a lot of secondary literature on compact disc digital audio,
but nothing's better than getting the information straight from the horses
mouth. In our case, this horse is called "IEC 60908 Audio recording –
Compact disc digital audio system". It's available for purchase from
several publishers at the totally reasonable price of just €345.
Fortunately someone uploaded it to
archive.org
, which is where such a fundamental standard belongs.
The PDF is bilingual with every other page being in french, but that's
noting that can't be fixed with poppler's pdfseparate and pdfunite.
Reading pits and lands
Since we don't want to read the pits and lands from the disc with a microscope[^1], we need some kind of machine that converts them to some form that easier to process. Luckily such machines exist in the form of CD players, we just need to get directly to the electrical signal from the optical pickup that corresponds to pits and lands on the disc and ignore the fact that it already decodes everything.
So I got hold of an old DVD player, put in an audio CD and started probing pins on the connector that connects the optical pickup to the main board. I quickly found a plausible-looking signal of about 300 mVpp amplitude.

For some reason, ~probably dust or scratches on the disc~ (see later), the signal sometimes drops out. To make my life easier, I captured a 4 MSa portion of the signal without any dropouts at a rate of 20MSa/s[^2] and transferred it to my computer for further analysis in python.
Interpolation and slicing
For reference, here's what the captured signal looks like, rendered using linear interpolation:

It's important to note that this signal isn't generated by an electronic circuit, but rather by the pits and lands flying past the pickup.
The signal captured from the pickup is an analog two-level signal that we need to convert to ones and zeros for further processing. It's tempting to just look at each sample individually and turn it into a '1' if it's > 0 and to a '0' if not. However, we lose some significant information that way since the exact voltage at the zero crossing carries sub-sample timing information that comes in handy for easier clock recovery as it reduces jitter. One lazy way around this is to interpolate the acquired samples and then do the threshold detection.
<img src="media/interpolation.svg" width="80%" alt="Diagram showing how interpolation is used to accurately find zero crossings.">
I've found that linear interpolation gives comparable results to proper sinc interpolation, so that's what I went with. To avoid glitches, I added some hysteresis to the threshold detection. The interpolation ratio is 20.
Clock recovery
The first step in decoding any kind of signal without an explicit clock is recovering the clock from the signal itself. This usually aided by some kind of line coding that ensures that there are only so many consecutive bits without a transition. In our case the line coding is Eight-to-fourteen modulation that maps one byte to 14 channel bits. It is then pressed onto the disc using NRZ-I (Non-return to zero inverted) encoding, that is a '1' is encoded as a transition and a '0' as no transition. The combination of these two encodings guarantees that there are no more than 11 and no less than 3 unchanging consecutive bits.
This means that when looking at the signal we can't assume that the shortest time between two transitions is one unit interval, instead it's three.
The usual way of recovering the clock from a signal with an embedded clock is by means of a phase-locked loop. While these are usually implemented in a mixed-signal circuit, implementing one in software can be surprisingly easy. In this instance, it can be seen as a discrete-time simulation that's clocked at 400 MHz, i.e. on every interpolated bit.
Same as with a hardware PLL we need three main components. VCO, phase detector and loop filter.
VCO
A simple way of implementing a VCO in software is by means of an Numerically-controlled oscillator. Since all we need is know when to sample the input signal, the phase-to-amplitude converter part of the NCO can be reduced to detecting if the accumulator has wrapped around.
The phase accumulator is as simple as adding the frequency tuning word to the accumulator modulo the accumulator size on every clock cycle :
acc = 0
last_acc = 0
ftw = 42
acc_size = 1000
for bit in all_bits :
if acc < last_acc :
# integrator has wrapped around, sample the input
last_acc = acc
acc = (acc+ftw)%acc_size # that's the actual NCO
Phase detector
The job of the phase detector is to convert the phase difference between the output of the VCO and the incoming data stream into a proportional voltage. With the VCO being an NCO, implementing the phase detector is as simple as sampling the value of the phase accumulator whenever the input signal changes. That way, the phase detector also keeps its output constant in the absence of transitions at the input. To keep the sampling point as far away from the input transitions as possible, we want the phase of the VCO to be 180° at the transitions.
last_bit = False
phase_delta = 0
for bit in all_bits :
if last_bit != bit :
phase_delta = (acc_size/2 - acc)
last_bit = bit
Loop filter
To smooth the output of the phase detector before feeding it into the VCO, we need some kind of low-pass filter. I went with the equivalent of a first order low pass since that's trivial to implement and turned out be good enough.
last_bit = False
phase_delta = 0
delta_filtered = 0
for bit in all_bits :
...
alpha = .005
delta_filtered = delta*alpha + delta_filtered*(1-alpha)
...
Putting it all together
Here's how it all looks connected:
I found it really instructive to discover that a PLL-based clock recovery can be implemented in about a dozen lines of Python or any other imperative language without the use of any high-level tools such as Simulink. Apart from that it's quite fascinating how little it takes to implement a system that exhibits complex dynamic behaviour. Contrast that to other code, where the same number of lines just adds a couple of buttons to a window or so and requires calling into thousands of lines of library code.
Making it lock
Anyone who has ever dealt with closed-loop feedback systems will tell you that debugging them can be really difficult since it's hard to separate cause from effect.
To get around this, we first operate our PLL open-loop by setting the loop gain to zero. It's also worth noting that a PLL with this kind of phase detector that's not sensitive to frequency will have a fairly tight lock range, which means that we need to get the VCO center frequency fairly close to its nominal frequency.
After playing with the center frequency of the VCO and the loop filter corner frequency this is what we get:

We can see that the phase detector outputs a sawtooth waveform which indicates that there's a frequency offset between the VCO and input frequency. Closing the loop by increasing the loop gain, the PLL locks and the phase error becomes constant. Why constant and not zero, you may ask? To bring the VCO to the correct frequency, its tuning voltage, i.e. the output of the phase detector and loop filter must be non-zero, resulting in a residual phase offset. We can eliminate that offset by introducing and integrator the loop so that we can get a zero phase detector output and non-zero tuning voltage. Tweaking the integrator gain, we get this:

The output of the phase detector being relatively close to zero indicates that our PLL is working as intended so we can move on to the next step, that is sampling the input signal with the recovered clock. As mentioned in the VCO section, this is as simple as capturing the value of the input signal every time the phase accumulator overflows.
sampled_bits = []
for bit in all_bits :
if acc < last_acc :
sampled_bits.append(bit)
...
This leaves us with a stream of bits that we need to make sense of.
Here's another plot to verify that the clock recovery is working as it should:

We see that the VCO's phase is close to 180° when the input transitions and thus the phase wraparounds are as fa
