Game Instance


Let the games begin

STM32 Oscilloscope with FFT and SD export

The Arduino code

This is not just another STM32 oscilloscope, it's mine. As such, it was designed to my liking. I squeezed-out the maximum conversion rate - 2.57 Msps - from a single ADC, I used the built-in DMA controller to maximize data transfer speed and I made it export the acquired and calculated data on an SD card. It was built on a general purpose board and has a flexible input circuitry.

STM32 Oscilloscope - Representation of a 500 kHz square signal 50% duty cycle. STM32 Oscilloscope - 500 kHz square signal 50% duty cycle. Left - wave shape. Right - the FFT power spectrum.

This is a follow-up of my previous article on the STM32 oscilloscope construction. So far this project involved schematics design, PCB fabrication, TH/SMD part soldering, low level C/C++ programming, some contact with STM32's assembler, FAT32 file system, memory management and a lot of fun with interface design.

Specifications:

* One input channel
* 2.57 Msps ADC, accepting signal frequencies up to 1.28 MHz
* Calculates minimum, maximum and average values
* Spectrum FFT analysis
* Fundamental frequency detection
* SD card export of signal wave shape and spectrum
* Freeze function
* Sampling rate selection

STM32 Oscilloscope - Representation of a 1 MHz square signal 50% duty cycle. STM32 Oscilloscope - 1 MHz square signal 50% duty cycle. Left - wave shape. Right - the FFT power spectrum.

The STM32F103C8 can deploy half of its clock speed for ADC sampling - by setting the prescaler to 2, see RCC_ADCPRE_PCLK_DIV_2 - and computes FFT for 1024 samples in 2 ms. The sampling capacitor is allowed to charge in 1.5 ADC cycles using the ADC_SMPR_1_5 setting and the sample quantization takes another 12.5 ADC cycles to complete. That means an acquisition frequency of 2.571 MHz (= 72 MHz / 2 / (1.5 + 12.5)).

To attain reasonable SNR levels at this sampling rate, one must satisfy the RC time constant for the sampling capacitor. The good news is that the CADC is small, 8pF. The bad part is that there's a series resistance RADC of 1 kOhm (maximum rating) just before the sampling capacitor. Now, according to the MCU's specifications, the input resistence RAIN must obey the following:
R A I N < T S f A D C C A D C ln ( 2 N + 2 ) - R A D C R_(AIN)
where TS = 1.5 ADC cycles, fADC = 36 MHz, CADC = 8 pF, RADC <= 1 kOhm and N is the effective number of bits storing error-less data.

The result is not encouraging as one can only use the 5 most significant bits of the converted data. The only feasible choices yielding 12 bit resolution would be lowering the fADC to 18 MHz or using a higher sampling time (the next being 7.5 ADC cycles).

Experiments

have indicated that a 250 kHz signal sampled at 2.57 Msps, 1.8 Msps, 1.38 Msps or even 529 ksps gives similar amplitude measurements leading to believe that RADC is in fact much lower than (maybe half of) the maximum mentioned in the specs and that sampling signals at 2.57 MHz won't pose significant problems.

STM32 Oscilloscope - 250 kHz square signal 50% duty cycle. A 20 mV error was observed at different sampling rates. Left - sampled at 2.57 MSPS. Right - sampled at 529 kSPS. STM32 Oscilloscope - 250 kHz square signal 50% duty cycle. A 20 mV error was observed at different sampling rates. Left - at 2.57 MSPS. Right - at 529 kSPS.

That is good news considering that the input stage is totally passive: composed of a voltage divider - the current drawn by the MCU input makes R3 act as a current limiter - on the generator side, a current limiting resistor and a high-pass filtering capacitor in series with the ADC input. Needless to say, the input should contain at least a voltage follower.

STM32 Oscilloscope - Simple input stage circuit. The test signal is generated by an AtMega328p powered at 3.3V. STM32 Oscilloscope - Simple input stage circuit. The test signal is generated by an AtMega328p powered at 3.3V.

The code

still uses Arduino structure and libraries except for STM32 related ADC, DMA and FFT routines. It assumes that a NT35702 TFT is hooked as described here, the SD card pins go to the STM32's SPI2 port and the buttons and inputs are connected as per the previous post. One must also install the SdFat library to make the SD card code work and add (with drag-and-drop) the cr4_fft_1024_stm32 related assembler files to the Arduino IDE project.

The state machine iterates through the key probing, acquisition, FFT and display states. Yes, I gave-up interrupt based key management in favor of key probing and that's because the machine no longer waits on other states. The 1024 samples are acquired and stored directly into the memory using the built-in DMA functionality. Then the samples are chewed and transformed into frequency bins by the assembler FFT implementation. The display uses a linear scale to display the signal spectrum while the power of the fundamental is expressed in dB.

/*
 * STM32 Digital Oscilloscope
 * using the STM32F103C8 MCU and the NT35702 2.4 inch TFT display 
 * https://www.gameinstance.com/post/80/STM32-Oscilloscope-with-FFT-and-SD-export
 * 
 *  GameInstance.com
 *  2016-2018
 */
#include <Adafruit_ILI9341_8bit_STM.h>
#include <Adafruit_GFX.h>
#include <SPI.h>
#include "SdFat.h"

#include <table_fft.h>
#include <cr4_fft_stm32.h>

static const uint8_t SD_CHIP_SELECT = PB12;
static const uint8_t TIME_BUTTON = PA15;
static const uint8_t TRIGGER_BUTTON = PB10;
static const uint8_t FREEZE_BUTTON = PB11;
static const uint8_t TEST_SIGNAL = PA8;
static const uint8_t CHANNEL_1 = PB0;
static const uint8_t CHANNEL_2 = PB1;

static const uint16_t BLACK   = 0x0000;
static const uint16_t BLUE    = 0x001F;
static const uint16_t RED     = 0xF800;
static const uint16_t GREEN   = 0x07E0;
static const uint16_t CYAN    = 0x07FF;
static const uint16_t MAGENTA = 0xF81F;
static const uint16_t YELLOW  = 0xFFE0;
static const uint16_t WHITE   = 0xFFFF;

static const uint16_t BACKGROUND_COLOR = BLUE;
static const uint16_t DIV_LINE_COLOR = GREEN;
static const uint16_t CH1_SIGNAL_COLOR = YELLOW;

static const uint16_t ADC_RESOLUTION = 4096; // units
static const uint16_t EFFECTIVE_VERTICAL_RESOLUTION = 200; // pixels
static const uint16_t SCREEN_HORIZONTAL_RESOLUTION = 320; // pixels
static const uint16_t SCREEN_VERTICAL_RESOLUTION = 240; // pixels
static const uint16_t DIVISION_SIZE = 40; // pixels
static const uint16_t SUBDIVISION_SIZE = 8; // pixels (DIVISION_SIZE / 5)
static const uint16_t BUFFER_SIZE = 1024; // bytes
static const uint8_t TRIGGER_THRESOLD = 127; // units
static const float ADC_SCREEN_FACTOR = (float)EFFECTIVE_VERTICAL_RESOLUTION / (float)ADC_RESOLUTION;
static const float VCC_3_3 = 3.3; // volts

const uint8_t DT_DT[]   = {4,       2,     1,     1,     1,     1,     1,     1,     1,     1,     1};
const uint8_t DT_PRE[]  = {0,       0,     0,     0,     0,     0,     0,     0,     0,     0,     1};
const uint8_t DT_SMPR[] = {0,       0,     0,     1,     2,     3,     4,     5,     6,     7,     7};
const float DT_FS[]     = {2571, 2571,  2571,  1800,  1384,   878,   667,   529,   429,   143,  71.4};
const float DT_DIV[]    = {3.9,  7.81, 15.63, 22.73, 29.41, 45.45, 55.55, 83.33, 95.24, 293.3, 586.6};

Adafruit_ILI9341_8bit_STM tft;
SdFat sd(2);
SdFile file;

uint8_t bk[SCREEN_HORIZONTAL_RESOLUTION];
uint16_t data16[BUFFER_SIZE];
uint32_t data32[BUFFER_SIZE];
uint32_t y[BUFFER_SIZE];
uint8_t time_base = 7;
uint16_t i, j;
uint8_t state = 0;
uint16_t maxy, avgy, miny;

volatile uint8_t h = 1, h2 = -1;
volatile uint8_t trigger = 1, freeze = 0;
volatile bool bPress[3], bTitleChange = true, bScreenChange = true;
volatile static bool dma1_ch1_Active;

bool wasPressed(int pin, int index) {
  //
  if (HIGH == digitalRead(pin)) {
    // isn't pressed
    if (bPress[index]) {
      // but was before
      bPress[index] = false;
    }
    return false;
  }
  // is pressed
  if (!bPress[index]) {
    // and wasn't before
    bPress[index] = true;
    return true;
  }
  // but was before
  return false;
}

// ------------------------------------------------------------------------------------
// The following section was inspired by http://www.stm32duino.com/viewtopic.php?t=1145

void setADCs() {
  // 
  switch (DT_PRE[time_base]) {
    //
    case 0: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_2); break;
    case 1: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_4); break;
    case 2: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_6); break;
    case 3: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_8); break;
    default: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_8);
  }
  switch (DT_SMPR[time_base]) {
    //
    case 0: adc_set_sample_rate(ADC1, ADC_SMPR_1_5); break;
    case 1: adc_set_sample_rate(ADC1, ADC_SMPR_7_5); break;
    case 2: adc_set_sample_rate(ADC1, ADC_SMPR_13_5); break;
    case 3: adc_set_sample_rate(ADC1, ADC_SMPR_28_5); break;
    case 4: adc_set_sample_rate(ADC1, ADC_SMPR_41_5); break;
    case 5: adc_set_sample_rate(ADC1, ADC_SMPR_55_5); break;
    case 6: adc_set_sample_rate(ADC1, ADC_SMPR_71_5); break;
    case 7: adc_set_sample_rate(ADC1, ADC_SMPR_239_5); break;
    default: adc_set_sample_rate(ADC1, ADC_SMPR_239_5);
  }
  adc_set_reg_seqlen(ADC1, 1);
  ADC1->regs->SQR3 = PIN_MAP[CHANNEL_1].adc_channel;
  ADC1->regs->CR2 |= ADC_CR2_CONT; // | ADC_CR2_DMA; // Set continuous mode and DMA
  ADC1->regs->CR2 |= ADC_CR2_SWSTART;
}

void real_to_complex(uint16_t * in, uint32_t * out, int len) {
  //
  for (int i = 0; i < len; i++) out[i] = in[i];
}

uint16_t asqrt(uint32_t x) { //good enough precision, 10x faster than regular sqrt
  //
  int32_t op, res, one;
  op = x;
  res = 0;
  one = 1 << 30;
  while (one > op) one >>= 2;
  while (one != 0) {
    if (op >= res + one) {
      op = op - (res + one);
      res = res +  2 * one;
    }
    res /= 2;
    one /= 4;
  }
  return (uint16_t) (res);
}

void inplace_magnitude(uint32_t * target, uint16_t len) {
  // 
  uint16_t * p16;
  for (int i = 0; i < len; i ++) {
    //
    int16_t real = target[i] & 0xFFFF;
    int16_t imag = target[i] >> 16;
//    target[i] = 10 * log10(real*real + imag*imag);
    uint32_t magnitude = asqrt(real*real + imag*imag);
    target[i] = magnitude; 
  }
}

uint32_t perform_fft(uint32_t * indata, uint32_t * outdata, const int len) {
  //
  cr4_fft_1024_stm32(outdata, indata, len);
  inplace_magnitude(outdata, len);
}

static void DMA1_CH1_Event() {
  //
  dma1_ch1_Active = 0;
}

void adc_dma_enable(const adc_dev * dev) {
  //
  bb_peri_set_bit(&dev->regs->CR2, ADC_CR2_DMA_BIT, 1);
}
// ------------------------------------------------------------------------------------

void export_to_sd() {
  //
  tft.setCursor(170, 20);
  tft.setTextColor(WHITE);
  tft.setTextSize(1);
  tft.print("Writing to SD ...");
  tft.setCursor(170, 20);
  if (!sd.cardBegin(SD_CHIP_SELECT, SD_SCK_HZ(F_CPU/4))) {
    //
    tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
    tft.print("No SD card detected");
    return;
  }
  delay(500);
  if (!sd.fsBegin()) {
    //
    tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
    tft.print("File system init failed.");
    return;
  }
  uint8_t index;
  if (!sd.exists("DSO")) {
    // no pre-exising folder structure
    if (!sd.mkdir("DSO")) {
      //
      tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
      tft.print("Can't create folder");
      return;
    }
  }
  if (!sd.exists("DSO/data.idx")) {
    // no index file
    index = 1;
    if (!file.open("DSO/data.idx", O_CREAT | O_WRITE)) {
      //
      tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
      tft.print("Can't create idx file");
      return;
    }
    file.write(index);
    if (!file.sync() || file.getWriteError()) {
      //
      tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
      tft.print("Idx file write error");
      return;
    }
    file.close();
  } else {
    //
    if (!file.open("DSO/data.idx", O_READ)) {
      //
      tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
      tft.print("Can't open idx file");
      return;
    }
    if (!file.read(&index, 1)) {
      // 
      tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
      tft.print("Can't read idx file");
      return;
    }
    if (!file.sync() || file.getWriteError()) {
      //
      tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
      tft.print("File write error");
      return;
    }
    file.close();
  }
  String s = "DSO/Exp";
  s += index;
  s += ".dat";
  if (!file.open(s.c_str(), O_CREAT | O_WRITE | O_EXCL)) {
    //
    tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
    tft.print("Can't create data file");
    return;
  }
  file.println("Time series");
  for (uint16_t i = 0; i < BUFFER_SIZE; i ++) {
    //
    file.print(data16[i], DEC);file.print(", ");
  }
  file.println(" ");
  file.print("Fs: ");file.print(DT_FS[time_base]);file.println("kHz");
  file.println("Spectrum");
  for (uint16_t i = 0; i < BUFFER_SIZE/2; i ++) {
    //
    file.print(y[i], DEC);file.print(", ");
  }
  file.println(" ");
  if (!file.sync() || file.getWriteError()) {
    //
    tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
    tft.print("File write error");
    return;
  }
  file.close();
  s += ".img";
  
  if (!file.open(s.c_str(), O_CREAT | O_WRITE | O_EXCL)) {
    //
    tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
    tft.print("Can't create image file");
    return;
  }
  file.println("IMX");
  for (uint16_t i = 0; i < BUFFER_SIZE; i ++) {
    //
    file.print(data16[i], DEC);file.print(", ");
  }
  file.println(" ");
  file.print("Fs: ");file.print(DT_FS[time_base]);file.println("kHz");
  file.println("Spectrum");
  for (uint16_t i = 0; i < BUFFER_SIZE/2; i ++) {
    //
    file.print(y[i], DEC);file.print(", ");
  }
  file.println(" ");
  if (!file.sync() || file.getWriteError()) {
    //
    tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
    tft.print("File write error");
    return;
  }
  file.close();
  
  index ++;
  if (!file.open("DSO/data.idx", O_CREAT | O_WRITE)) {
    //
    tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
    tft.print("Can't create idx file");
    return;
  }
  file.write(index);
  if (!file.sync() || file.getWriteError()) {
    //
    tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
    tft.print("Idx file write error");
    return;
  }
  file.close();
  tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR);
  tft.print("File write success");
  delay(2000);
  tft.fillRect(170, 19, 150, 9, BACKGROUND_COLOR);
}

void setup() {
  // 
  tft.begin();
  tft.setRotation(3);

  bPress[0] = false;
  bPress[1] = false;
  bPress[2] = false;

  adc_calibrate(ADC1);
}

void loop() {
  //
  if (state == 0) {
    // 
    tft.fillScreen(BACKGROUND_COLOR);
    tft.setCursor(15, 100);
    tft.setTextColor(YELLOW);
    tft.setTextSize(3);
    tft.println("GameInstance.com");
//    analogWrite(TEST_SIGNAL, 127);
    delay(1500);
    tft.fillScreen(BACKGROUND_COLOR);
    state = 1;
  }
  if (state == 1) {
    // init
    state = 2;
  }
  if (state == 2) {
    // buttons check
    if (wasPressed(TIME_BUTTON, 0)) {
      // toggling the time division modes
      time_base ++;
      if (trigger == 0) {
        // spectrum
        if (time_base <= 2) time_base = 3;
      }
      time_base = time_base % sizeof(DT_DT);
      h = DT_DT[time_base];
      bScreenChange = true;
    }
    if (wasPressed(TRIGGER_BUTTON, 1)) {
      // toggling the trigger mode
      trigger ++;
      trigger = trigger % 4;
      bScreenChange = true;
      bTitleChange = true;
    }
    if (wasPressed(FREEZE_BUTTON, 2)) {
      // toggling the freeze screen
      freeze = (freeze > 0) ? 0 : 3;
      bTitleChange = true;
    }
    if (freeze) {
      // frozen screen
      state = 5;
    } else {
      // live screen
      state = 3;
    }
  }
  if (state == 3) {
    // acquisition

    setADCs();
    dma_init(DMA1);
    dma_attach_interrupt(DMA1, DMA_CH1, DMA1_CH1_Event);
    adc_dma_enable(ADC1);
    dma_setup_transfer(DMA1, DMA_CH1, &ADC1->regs->DR, DMA_SIZE_16BITS, data16, DMA_SIZE_16BITS, (DMA_MINC_MODE | DMA_TRNS_CMPLT));
    dma_set_num_transfers(DMA1, DMA_CH1, BUFFER_SIZE);
    dma1_ch1_Active = 1;
    dma_enable(DMA1, DMA_CH1);                     // enable the DMA channel and start the transfer

    while (dma1_ch1_Active) {};                    // waiting for the DMA to complete
    dma_disable(DMA1, DMA_CH1);                    // end of DMA trasfer
    
    real_to_complex(data16, data32, BUFFER_SIZE);  // data format conversion
    perform_fft(data32, y, BUFFER_SIZE);           // FFT computation

    state = 4;
  }
  if (state == 4) {
    // display signal screen
    if (bScreenChange) {
      // massive change on screen
      bScreenChange = false;
      tft.fillScreen(BACKGROUND_COLOR);
      bTitleChange = true;
    } else {
      // clear previous wave
      if (trigger == 0) {
        // clear previous spectrum
        for (i = 1; i < SCREEN_HORIZONTAL_RESOLUTION; i ++) {
          // 
          tft.drawLine(
            i, 
            bk[i], 
            i + 1, 
            bk[i + 1], 
            BACKGROUND_COLOR);
        }
      } else {
        // clear previous time samples
        for (i = 0, j = 0; j < SCREEN_HORIZONTAL_RESOLUTION; i ++, j += h2) {
          // 
          tft.drawLine(
            j, 
            bk[i], 
            j + h2, 
            bk[i + 1], 
            BACKGROUND_COLOR);
        }
      }

    }
    // re-draw the divisions
    for (i = 0; i < SCREEN_HORIZONTAL_RESOLUTION; i += DIVISION_SIZE) {
      // 
      for (j = SCREEN_VERTICAL_RESOLUTION; j > 13; j -= ((i == 160) ? SUBDIVISION_SIZE : DIVISION_SIZE)) {
        // 
        tft.drawLine(i - 1, j, i + 1, j, DIV_LINE_COLOR);
      }
    }
    for (i = SCREEN_VERTICAL_RESOLUTION; i > 13; i -= DIVISION_SIZE) {
      // 
      for (j = 0; j < SCREEN_HORIZONTAL_RESOLUTION; j += ((i == 120) ? SUBDIVISION_SIZE : DIVISION_SIZE)) {
        //
        tft.drawLine(j, i - 1, j, i + 1, DIV_LINE_COLOR);
      }
    }
    // draw current wave
    if (trigger == 0) {
      // display spectrum
      uint16_t max_y = 0, max_x = 0;
      uint16_t i_0, i_1;
      bool hit_max = false;
      for (i = 1; i < BUFFER_SIZE / 2; i ++) {
        //
        if (y[i] > max_y) {
          //
          max_y = y[i];
          max_x = i;
        }
      }
      max_y = max(max_y, EFFECTIVE_VERTICAL_RESOLUTION);
      tft.setTextColor(WHITE);
      tft.setTextSize(1);
      for (i = 1; i < SCREEN_HORIZONTAL_RESOLUTION; i ++) {
        // 
        i_0 = (int)((float)i * (float)BUFFER_SIZE / (float)SCREEN_HORIZONTAL_RESOLUTION / 2.0);
        i_1 = (int)((float)(i + 1) * (float)BUFFER_SIZE / (float)SCREEN_HORIZONTAL_RESOLUTION / 2.0);
        if (hit_max) {
          // was in the vicinity of max
          i_0 = max_x;
          hit_max = false;
        } else if ((max_x <= i_1) && (i_0 <= max_x)) {
          // is in the vicinity of max
          if ((i_1 - max_x) <= (max_x - i_0)) {
            //
            hit_max = true;
            i_1 = max_x;
          } else {
            //
            i_0 = max_x;
          }
        }
        bk[i] = SCREEN_VERTICAL_RESOLUTION - (10 + ((float)y[i_0] / (float)max_y) * (float)(EFFECTIVE_VERTICAL_RESOLUTION - 10));
        tft.drawLine(
          i, 
          bk[i], 
          i + 1, 
          SCREEN_VERTICAL_RESOLUTION - (10 + ((float)y[i_1] / (float)max_y) * (float)(EFFECTIVE_VERTICAL_RESOLUTION - 10)), 
          CH1_SIGNAL_COLOR);
        if (i % DIVISION_SIZE == 0) {
          //
          float freq = ((float)i / (float)SCREEN_HORIZONTAL_RESOLUTION * (float)DT_FS[time_base]) / 2.0;
          tft.setCursor(i - (freq > 100 ? 8 : 5) - (freq > (int)freq ? 4 : 0), SCREEN_VERTICAL_RESOLUTION - 7);
          tft.print(freq, 1);
        }
      }
      // clear previous stats
      tft.fillRect(7, 19, 150, 9, BACKGROUND_COLOR);
      tft.setCursor(8, 20);
      tft.setTextColor(WHITE);
      tft.setTextSize(1);
      String s;
      s = "F: ";
      s += (float)max_x / (float)BUFFER_SIZE * (float)DT_FS[time_base];
      s += "kHz ";
      s += (float)20 * log10(max_y);
      s += "dB";
      tft.print(s);

    } else {
      // display time samples
      uint16_t maxy = 0;
      uint16_t miny = ADC_RESOLUTION;
      uint32_t avgy = 0;
      for (i = 1; i < BUFFER_SIZE; i ++) {
        //
        maxy = max(maxy, data16[i]);
        miny = min(miny, data16[i]);
        avgy += data16[i];
      }
      avgy /= BUFFER_SIZE;
      for (i = 0, j = 0; j < SCREEN_HORIZONTAL_RESOLUTION; i ++, j += h) {
        // 
        bk[i] = SCREEN_VERTICAL_RESOLUTION - (20 + (data16[i] * ADC_SCREEN_FACTOR));
        bk[i + 1] = SCREEN_VERTICAL_RESOLUTION - (20 + (data16[i + 1] * ADC_SCREEN_FACTOR));
        tft.drawLine(
          j, 
          bk[i], 
          j + h,
          bk[i + 1], 
          CH1_SIGNAL_COLOR);
        if (h > 1) tft.drawPixel(j, bk[i] - 1, GREEN);
      }
      // clear previous stats
      tft.fillRect(7, 19, 60, 9, BLUE);
      tft.setCursor(8, 20);
      tft.setTextColor(WHITE);
      tft.setTextSize(1);
      String s;
      s = "Max: ";
      s += (float)maxy / (float)ADC_RESOLUTION * VCC_3_3;
      s += "V";
      tft.print(s);

      tft.fillRect(SCREEN_HORIZONTAL_RESOLUTION / 2 - 30, SCREEN_VERTICAL_RESOLUTION - 20, 60, 9, BLUE);
      tft.setCursor(SCREEN_HORIZONTAL_RESOLUTION / 2 - 29, SCREEN_VERTICAL_RESOLUTION - 19);
      tft.setTextColor(WHITE);
      tft.setTextSize(1);
      s = "Avg: ";
      s += (float)avgy / (float)ADC_RESOLUTION * VCC_3_3;
      s += "V";
      tft.print(s);
      
      tft.fillRect(7, SCREEN_VERTICAL_RESOLUTION - 20, 60, 9, BLUE);
      tft.setCursor(8, SCREEN_VERTICAL_RESOLUTION - 19);
      tft.setTextColor(WHITE);
      tft.setTextSize(1);
      s = "Min: ";
      s += (float)miny / (float)ADC_RESOLUTION * VCC_3_3;
      s += "V";
      tft.print(s);
      h2 = h;
    }
    
    state = 5;
  }
  if (state == 5) {
    // 
    if (bTitleChange) {
      // title change
      bTitleChange = false;
      tft.fillRect(0, 0, SCREEN_HORIZONTAL_RESOLUTION, 12, CH1_SIGNAL_COLOR);
      tft.setCursor(8, 3);
      tft.setTextColor(BLUE);
      tft.setTextSize(1);
      String s = "CH1 ";
      s += .65;
      s += "V ";
      if (trigger == 0) {
        // spectrum
        s += (int)DT_FS[time_base];
        s += "kHz ";
      } else {
        // time samples
        s += DT_DIV[time_base];
        s += "us ";
      }
      if (trigger == 1) {
        // raising front trigger
        s += "Raising ";
      } else if (trigger == 2) {
        // descending front trigger
        s += "Falling ";
      } else if (trigger == 3) {
        // no trigger
        s += "None ";
      } else {
        // spectrum scope
        s += "Spectrum ";
      }
      tft.print(s);
      if (freeze) {
        // 
        tft.setCursor(170, 3);
        tft.setTextColor(RED);
        tft.setTextSize(1);
        tft.print("Freeze");
      }
      tft.setCursor(215, 3);
      tft.setTextColor(BLACK);
      tft.setTextSize(1);
      tft.print("GameInstance.com");
    }
    if (freeze == 3) {
      //
      freeze = 1;
      export_to_sd();
      bScreenChange = true;
    }
  }

  delay(50);
  state = 1;
}

For the up-to-date version, check-out the GitHub repo.

Pressing the Freeze button also triggers the SD data export, if an SD card is present. For the moment, the export is made in text format. The file contains the 1024 acquired samples, the calculated 512 frequency bins as well as the sampling rate. Each file is created in a dedicated folder, using filenames suffixed with successive indices.

The conclusion

if any, is that a project such as this one is never complete. There's always something missing, something partially implemented or a hack that keeps things in place. Nonetheless, this became a serious stand-alone tool that already helps me debug other MCU based projects or probe audio signals.
Stick around for updates!