A project log for Cassette interface for FPGAs

Now your FPGA-based retro computer can have its own mass storage too - just like it did circa 1980!

zpekiczpekic 11/02/2020 at 06:510 Comments

Instruction to retrieve data from the tape:

  1. select baudrate and serial mode on Mercury baseboard switches 7 - 2, for example 000110XX will set 300bps, 8 bits, odd parity, 1 stop bits (Note: 1200 bps will work with high quality recorder/tape and finely tuned volume and tone. 600 and 300 is much less demanding)
  2. use same setting on your Terminal software on the PC (for example TeraTerm)
  3. establish all wire connections (AUDIO_OUT > MIC, AUDIO IN < SPKR, PMOD USB to PC USB)
  4. rewind the tape to required position (as marked in your catalog)
  5. press PLAY
  6. watch data from the tape on the terminal console - if these were valid ASCII, it will be text, otherwise bytes beyond ASCII range will be printed according to current character set mapping (and the BELL character 0x07 will sound too!) HEX file or similar format can encode binary while still be human readable and are good candidates to use as format due to wide support and basic checksum integrity checking.
  7. press STOP when UART blinking stops (otherwise junk beyond last good written position can be picked up)

Principle of operation:

(refer to )

Remember, recording was done using simple "binary frequency shift keying" with following parameters:

bpsmark ("1") frequency (Hz)space ("0") frequency (Hz)fmark

Result is a distorted waveform on the tape, but which still holds the primary mark and space frequencies. The task is to figure out those frequencies in real time, and based on which one is detected, output 0 or 1 which will be the UART output (TXD).

This is of course a classic FFT or Goertzel algorithm problem, but neither of those is used here. The brute force of FPGA and high bandwidth of modern A/D adapters is used instead, with some simple hacky tricks. Here are the steps:

1. Connect the available A/D converter and drive it to sample tape data

The Mercury baseboard uses the A/D converter described here. I used the driver code provided as a sample. The clock is 25MHz, and the sampling frequency 0.75MHz - both are clearly overkill for the purpose, but they are easily obtainable by simple clock dividers. The channel is either of the audio left or right.

  -- Mercury ADC component
  ADC : entity work.MercuryADC
    port map(
      clock    => adc_clk,
      trigger  => adc_trigger,
      diffn    => '0',
      channel  => "000",    -- channel 0 = left audio
      Dout     => adc_dout,
      OutVal   => adc_done,
      adc_miso => ADC_MISO,
      adc_mosi => ADC_MOSI,
      adc_cs   => ADC_CSN,
      adc_clk  => ADC_SCK

2. Process each A/D conversion 

adc_done signal will pulse when the conversion is ready, at which point the data reading (strength of the signal from tape in 10-bit resolution should be analyzed) is presented at Dout port. We don't really care about the value, but the point where the value is very close to zero, hoping that moment indicates the period of the waveform that captures the main recorded mark/space frequency.

Picking this threshold value can be tricky and depends on the characteristics of the signal path between audio input and A/D converter. In this case hex value 0x12 is selected, but to make it easier to figure it out, the min and max value (wave amplitude) is captured and can be displayed through debug port.

When it is detected that previous value was close to zero but is now greater then 0x12 then 1 output is generated, otherwise 0. Note that there is a certain level of hysteresis in this as the previous value of f_in_audio is taken into account. 

-- ADC sampling process
on_adc_done : process (adc_done, f_in_audio)
 if (rising_edge(adc_done)) then
        if (f_in_audio = '0') then
            if (unsigned(adc_dout) > "00" & X"12") then -- 24
                f_in_audio <= '1';
            end if;
            if (unsigned(adc_dout) < "00" & X"12") then -- 24
                f_in_audio <= '0';
            end if;
        end if;
        if (unsigned(adc_dout) > max) then
            max <= unsigned(adc_dout);
        end if;

        if (unsigned(adc_dout) < min) then
            min <= unsigned(adc_dout);
        end if;

    end if;
end process;

 The result here is a binary signal f_in_audio, which has the maximum frequency determined by sample rate of A/D converter (roughly 75kHz in this case == 750kHz trigger frequency / 10 or so clock cycles needed per 1 conversion). This signal will be mostly 1, but will have streams of 0s where the analog signal "crossed zero".

3. Establishing time interval baselines

If the tape had recorded all 1 at 600bps, meaning mark frequency 9600, one would expect these sequences of 0 in f_in_audio to occur every 1/9600 s, at about 10.4 ms intervals, and if 0 was recorded at 20.8ms. So figuring out if 0 or 1 was recorded becomes a problem of time interval measurement.

Both mark and space frequencies are available in the component, as they were needed to record onto the tape. They can now be used to establish the time interval against some convenient baseline, for example A/D trigger frequency ("tick" value):

on_freq_space: process(freq_space, tick, prev0)
    if (rising_edge(freq_space)) then
        limit0 <= tick - prev0;
        prev0 <= tick;
    end if;
end process;

on_freq_mark: process(freq_mark, tick, prev1)
    if (rising_edge(freq_mark)) then
        limit1 <= tick - prev1;
        prev1 <= tick;
    end if;
end process;

on_adc_samplefreq: process(adc_samplefreq, adc_done)
    if (adc_done = '1') then
        adc_trigger <= '0';
        if (rising_edge(adc_samplefreq)) then
            adc_trigger <= not adc_done;
            tick <= tick + 1;
        end if;
    end if;
end process;

This will result in registers limit0 and limit1 to have two "stopwatch" values - their absolute value is not important, but at all times limit1 ~= 2 * limit0. 

4. Measure current input signal intervals

Similar to the baselines, we can "stopwatch" the incoming f_in (== f_in_audio) signal against the same "tick" frequency:

on_f_in: process(f_in, tick, prev)
    if (rising_edge(f_in)) then
        delta <= tick - prev;
        prev <= tick;
    end if;
end process;

the value in register "delta" measures the time since last zeros were detected in the input stream, so that now we have 3 registers with 3 values - 2 for known frequencies, and 1 for unknown one.

5. Decide if input time interval is closer to either known ones

At this point, we just have to decide if measured value is closer to either and use them as inputs to R/S flip-flop (again, hysteresis as simplest way of filtering the noise)

detect0 <= '1' when (delta > (limit0 - 15)) else '0'; 
detect1 <= '1' when (delta < (limit1 + 15)) else '0'; 

ntxd <= not (detect0 or txd);
txd <= not (detect1 or ntxd);

The value 15 is just some "slack" to allow for various errors in sampling, tape speed etc. 

More sophisticated circuit could also detect neither frequency ("no data") state and report to higher level design component if needed, to drive something like a DCD signal.

6. Output the UART signal

serout <= not (txd);