Tutorial and Code: PCM5102A Pushed to 60kHz and Demo of Floating-Point, On-the-Fly Synthesis

StanfordEE

Well-known member
As part of our effort to preserve and enhance mixed-signal design knowledge in the community (after 25 years of teaching the topic at Stanford), I have just posted a tutorial that shows how to do "look-up-table-free" DDS (direct digital synthesis) at 384kSPS harnessing the insane floating point power of the Teensy 4.X. This approach kills off a lot of the spurs due to LUT-truncation (LUT = "Look Up Table") which would take up a lot of memory for high-resolution DDS. A very simple analog filter (described) allows, with an SFDR (spur-free dynamic range of 60 dB), creating of clean, two-channel sine waves or noise, with optional AM, up to 60 kHz with flat passband. The other concept in the tutorial is digital amplitude control (and the trade-off of using it).

In the folder, there are videos of the harmonics moving and a demo of operation.

Zachariah Magee took my rough stuff and wrote some very nice code.

Please take a look and enjoy! I ran into attachment size limits, even with a compressed PDF of the whole tutorial (down to 3.1 MB... oh well). So here it is at the link we update:

https://urldefense.proofpoint.com/v...Z_qpBUaDrSvj0LVZAnAAnrKstCeNNMV3mB9Vuad5mY&e=

Thanks,
Greg
 
Here is the full-featured code, which works with any PCM5102A modules you can buy from Adafruit or other sources:

C++:
/**
 * PCM5102A_VoidLoop_R0_2.ino
 * @version 1.2.0
 * @date 08-28-26, 9/16/26, 9/17/26
 *
 * VoidLoopEngine is brought to you by VoidLoop. See www.voidloop.com for details.
 * Two-channel DDS signal generator driving a PCM5102A I2S DAC from SAI1.
 *
 * Board: Teensy 4.1 only (Basecamp). Pins 36 and 37 carry the SAI1 transmit
 * clocks on the 4.1; on the Teensy 4.0 the same pin numbers are SD card pads.
 *
 * Each channel (right and left) is set up independently, at compile time, as
 * one of three sources:
 *   SOURCE_FIXED  one sine frequency, with an optional starting phase
 *   SOURCE_SWEEP  a stepped sine sweep from a start to a stop frequency,
 *                 rising or falling
 *   SOURCE_NOISE  a new random number every sample, no DDS
 * A fixed sine or a noise source can also be amplitude modulated (AM) by a
 * second, slower sine.
 *
 * Sine frequencies are limited to 60 kHz. Noise is not band-limited
 * by the sketch. At 384 kSPS, the generated noise spans frequencies
 * up to 192 kHz. The DAC's filtering and any external output filter
 * shape the analog noise spectrum.
 *
 * A 32-bit phase accumulator represents one complete sine cycle.
 * Each sample advances the phase by a fixed increment, and sin()
 * calculates the sample value from that phase.
 *
 * Output frequency = (phase increment / 2^32) x sample rate.
 *
 * Using only the upper phase bits to index a lookup table introduces
 * phase truncation, which can produce spurious tones. This sketch
 * uses all 32 phase bits in the sine calculation, avoiding that
 * source of error.
 *
 * Changing frequency changes the phase increment without resetting
 * the accumulator, so the waveform's phase remains continuous.
 *
 * This demonstrates:
 * - Register-level SAI1 (I2S) master transmit on the Teensy 4.1
 * - Audio PLL (PLL4) configuration for an arbitrary sample rate
 * - Direct digital synthesis with a phase accumulator, computed on the fly
 * - Sweeps, white noise and amplitude modulation done entirely in software
 *
 * Hardware, purple PCM5102A breakout:
 * - Teensy pin 36 -> BCK   (SAI1 TX bit clock)
 * - Teensy pin 37 -> LCK   (SAI1 TX frame sync / LRCLK)
 * - Teensy pin 7  -> DIN   (SAI1 TX data)
 * - GND           -> SCK   (grounding SCK selects the DAC's internal PLL,
 *                           so no master clock wire is needed)
 * - 5V -> VIN, GND -> GND
 * - Solder jumpers on the back: 1(FLT)=L, 2(DEMP)=L, 3(XSMT)=H, 4(FMT)=L.
 *   XSMT=H unmutes the output; FMT=L selects I2S.
 *
 * Hardware, Adafruit PCM5102A module:
 * - Teensy pin 36 -> BCK, pin 37 -> WSEL, pin 7 -> DIN
 * - SCK (MCK pad) left unconnected; the board's default selects the
 *   internal PLL. There are no solder jumpers to set.
 * - 5V -> VIN, GND -> GND
 *
 * The output is about 6 Vpp (2.1 Vrms) at full scale and centered on ground.
 * It is a line-level output for loads of 1 kilohm or more: a scope, the line
 * input of a powered speaker, or a headphone amplifier. It cannot drive
 * headphones directly. Do not wire it straight to a Teensy ADC pin — it
 * swings below ground.
 *
 * Sketch outline:
 * - The "Channel setup" section below is the only part you need to edit.
 * - setup() seeds the noise generators, loads the starting frequencies, then
 *   programs the audio PLL and SAI1 and enables the SAI1 interrupt.
 * - The interrupt computes one left and one right sample per audio frame.
 * - loop() steps any sweeps and prints a status line every two seconds.
 *
 * Things to try:
 * - For a circle on an X/Y scope display, set both channels to SOURCE_FIXED
 *   at 1000 Hz with equal amplitudes and modulationEnabled = false.
 *   Set startPhaseDegrees to 0 on one channel and 90 on the other.
 *   Connect the outputs to the scope's X and Y inputs, select X/Y mode,
 *   and use equal volts-per-division settings on both axes.
 * - Swap a sweep's start and stop frequencies and watch it fall instead.
 * - Set modulationDepth to 1.0 and look at the envelope on a scope.
 * - Halve a channel's amplitude and compare the spurs on a spectrum analyzer.
 *
 * Based on PCM5102 bring-up code by G. Kovacs and M. Flamer (2023), extended
 * by Z. Magee and G. Kovacs (2026).
 *
 * set_audioClock() and the clock setup in config_i2s() are adapted from the
 * PJRC Teensy Audio Library (output_i2s.cpp, utility/imxrt_hw.cpp):
 *
 *   Copyright (c) 2014, Paul Stoffregen, paul@pjrc.com
 *
 *   Development of this audio library was funded by PJRC.COM, LLC by sales
 *   of Teensy and Audio Adaptor boards.  Please support PJRC's efforts to
 *   develop open source software by purchasing Teensy or other PJRC products.
 *
 *   Permission is hereby granted, free of charge, to any person obtaining a
 *   copy of this software and associated documentation files (the
 *   "Software"), to deal in the Software without restriction, including
 *   without limitation the rights to use, copy, modify, merge, publish,
 *   distribute, sublicense, and/or sell copies of the Software, and to
 *   permit persons to whom the Software is furnished to do so, subject to
 *   the following conditions:
 *
 *   The above copyright notice, development funding notice, and this
 *   permission notice shall be included in all copies or substantial
 *   portions of the Software.
 *
 *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
 *   OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 *   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
 *   IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
 *   CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
 *   TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
 *   SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

#include <math.h>
#include <Entropy.h>

#if !defined(ARDUINO_TEENSY41)
#error "This sketch needs a Teensy 4.1: pins 36 and 37 are SAI1 clock pins only on that board."
#endif

enum SourceType { SOURCE_FIXED, SOURCE_SWEEP, SOURCE_NOISE };

// ChannelConfig groups the settings for one output channel.
// rightChannel and leftChannel below each supply a set of these values.
// Edit those values to choose the signals; this declaration defines
// the settings available to both channels.
struct ChannelConfig {
  SourceType sourceType;
  // Output level. 1.0 is full scale, about 6 Vpp. A negative value inverts
  // the waveform.
  double amplitude;
  // SOURCE_FIXED only.
  double frequencyHz;
  double startPhaseDegrees;
  // SOURCE_SWEEP only. The frequency starts at sweepStartFrequencyHz and
  // moves toward sweepStopFrequencyHz by sweepStepFrequencyHz. Each frequency
  // is held for at least sweepHoldMilliseconds. If start is above stop the
  // sweep falls. After the stop frequency has played, the sweep begins again
  // from start.
  double   sweepStartFrequencyHz;
  double   sweepStopFrequencyHz;
  double   sweepStepFrequencyHz;
  uint32_t sweepHoldMilliseconds;
  // Amplitude modulation, for SOURCE_FIXED and SOURCE_NOISE only.
  // modulationDepth ranges from 0.0 (no modulation) to 1.0 (100 %).
  // amplitude sets the maximum envelope level. The carrier amplitude is
  // amplitude / (1.0 + modulationDepth), so increasing depth reduces the
  // carrier level. At 100 % depth the envelope reaches zero but does not
  // reverse the carrier's polarity.
  bool   modulationEnabled;
  double modulationFrequencyHz;
  double modulationDepth;
};

// ChannelState holds the working values that change as a channel runs.
// setup() initializes these values from that channel's configuration.
// The interrupt advances the waveform phase or noise sequence;
// loop() updates the sweep frequency and timing.
// Each channel has its own state. No user settings are entered here.
struct ChannelState {
  volatile uint32_t phaseAccumulator;      // carrier phase, 2^32 counts per turn
  volatile uint32_t phaseIncrement;        // carrier phase step per sample
  volatile uint32_t modulationPhaseAccumulator; // phase of the modulating sine
  volatile uint32_t modulationPhaseIncrement;   // its phase step per sample
  volatile uint32_t noiseState;            // last random number drawn
  volatile float    amplitude;             // output scale initialized from ChannelConfig
  double   sweepFrequencyHz;               // frequency the sweep is playing now
  uint32_t sweepHoldStartedMs;             // millis() when that frequency began
};

// ---- Channel setup --------------------------------------------------------
// Edit rightChannel and leftChannel independently, then compile and upload.
// constexpr makes these settings fixed for that build.
//
// For each channel:
// - Choose sourceType and amplitude. An amplitude of 0.0 silences the channel.
// - SOURCE_FIXED: set frequencyHz and startPhaseDegrees.
// - SOURCE_SWEEP: set the start and stop frequencies, a positive frequency
//   step, and the hold time. Start above stop gives a falling sweep.
//   Set modulationEnabled to false.
// - SOURCE_NOISE: no frequency or starting phase is needed.
// - For AM on a fixed sine or noise, set modulationEnabled to true,
//   then choose modulationFrequencyHz and modulationDepth.
//
// Leave unused fields in place; their values are ignored.
// Invalid combinations stop compilation with an explanatory message.

// Example: for an unmodulated 1 kHz sine, set sourceType to SOURCE_FIXED,
// frequencyHz to 1000.0, startPhaseDegrees to 0.0, amplitude to 0.5,
// and modulationEnabled to false. Leave the sweep settings unchanged.

constexpr ChannelConfig rightChannel = {
  // SOURCE_FIXED, SOURCE_SWEEP or SOURCE_NOISE.
  .sourceType            = SOURCE_NOISE,
  // Used by every source. 1.0 is full scale; negative inverts.
  .amplitude             = 1.0,
  // Used by SOURCE_FIXED only.
  .frequencyHz           = 10000.0,
  .startPhaseDegrees     = 0.0,
  // Used by SOURCE_SWEEP only. Start above stop gives a falling sweep.
  .sweepStartFrequencyHz = 200.0,
  .sweepStopFrequencyHz  = 6000.0,
  .sweepStepFrequencyHz  = 10.0,
  .sweepHoldMilliseconds = 50,
  // Used by SOURCE_FIXED and SOURCE_NOISE. Not allowed with SOURCE_SWEEP.
  // The frequency and depth are ignored while modulationEnabled is false.
  .modulationEnabled     = false,
  .modulationFrequencyHz = 1000.0,
  .modulationDepth       = 1.0,
};

constexpr ChannelConfig leftChannel = {
  // SOURCE_FIXED, SOURCE_SWEEP or SOURCE_NOISE.
  .sourceType            = SOURCE_SWEEP,
  // Used by every source. 1.0 is full scale; negative inverts.
  .amplitude             = 0.5,
  // Used by SOURCE_FIXED only.
  .frequencyHz           = 2000.1,
  .startPhaseDegrees     = 0.0,
  // Used by SOURCE_SWEEP only. Start above stop gives a falling sweep.
  .sweepStartFrequencyHz = 60000.0,
  .sweepStopFrequencyHz  = 20.0,
  .sweepStepFrequencyHz  = 100.0,
  .sweepHoldMilliseconds = 50,
  // Used by SOURCE_FIXED and SOURCE_NOISE. Not allowed with SOURCE_SWEEP.
  // The frequency and depth are ignored while modulationEnabled is false.
  .modulationEnabled     = false,
  .modulationFrequencyHz = 100.0,
  .modulationDepth       = 1.0,
};

// Samples per second on each channel. The SAI1 interrupt fires once per
// sample pair, so raising this raises interrupt load one-for-one.
static const int sampleRateHz = 384000;

// ---- Limits ---------------------------------------------------------------

// Highest frequency the generator is specified for.
constexpr double maximumFrequencyHz = 60000.0;

// AM puts a sideband on each side of the carrier, at carrier +/- modulation
// frequency. Holding the modulation to 20 % of the carrier keeps the
// sidebands close to it, and the upper sideband must also stay under
// maximumFrequencyHz. Noise has no carrier frequency, so this sketch holds
// noise modulation to 20 % of maximumFrequencyHz, 12 kHz. These limits apply
// to the sines only; they do not narrow the bandwidth of the noise itself.
constexpr double maximumModulationRatio = 0.20;

constexpr bool frequencyInRange(double frequencyHz) {
  return frequencyHz >= 0.0 && frequencyHz <= maximumFrequencyHz;
}

#define CHECK_CHANNEL(config)                                                       \
  static_assert(config.amplitude >= -1.0 && config.amplitude <= 1.0,                \
                #config ": amplitude must be within -1.0 .. 1.0");                 \
  static_assert(config.sourceType != SOURCE_FIXED                                  \
                || frequencyInRange(config.frequencyHz),                           \
                #config ": frequencyHz must be within 0 .. 60 kHz");               \
  static_assert(config.sourceType != SOURCE_SWEEP                                  \
                || (frequencyInRange(config.sweepStartFrequencyHz)                 \
                    && frequencyInRange(config.sweepStopFrequencyHz)),             \
                #config ": sweep start and stop must be within 0 .. 60 kHz");      \
  static_assert(config.sourceType != SOURCE_SWEEP                                  \
                || (config.sweepStepFrequencyHz > 0.0                              \
                    && config.sweepHoldMilliseconds > 0),                          \
                #config ": sweep step size and step time must be above zero");     \
  static_assert(!(config.modulationEnabled && config.sourceType == SOURCE_SWEEP),   \
                #config ": modulation is not available on a sweep");               \
  static_assert(!config.modulationEnabled                                          \
                || (config.modulationDepth >= 0.0 && config.modulationDepth <= 1.0),\
                #config ": modulationDepth must be within 0.0 .. 1.0");            \
  static_assert(!(config.modulationEnabled && config.sourceType == SOURCE_FIXED)    \
                || (config.modulationFrequencyHz >= 0.0                            \
                    && config.modulationFrequencyHz                                \
                       <= maximumModulationRatio * config.frequencyHz),            \
                #config ": modulationFrequencyHz must be at most 20 % of frequencyHz"); \
  static_assert(!(config.modulationEnabled && config.sourceType == SOURCE_FIXED)    \
                || config.frequencyHz + config.modulationFrequencyHz                \
                   <= maximumFrequencyHz,                                         \
                #config ": frequencyHz + modulationFrequencyHz must stay under 60 kHz"); \
  static_assert(!(config.modulationEnabled && config.sourceType == SOURCE_NOISE)    \
                || (config.modulationFrequencyHz >= 0.0                            \
                    && config.modulationFrequencyHz                                \
                       <= maximumModulationRatio * maximumFrequencyHz),           \
                #config ": noise modulationFrequencyHz must be at most 12 kHz")

CHECK_CHANNEL(rightChannel);
CHECK_CHANNEL(leftChannel);

// ---- Generator state ------------------------------------------------------

static ChannelState rightState;
static ChannelState leftState;

static const double countsPerTurn        = 4294967296.0;  // 2^32
static const double radiansPerPhaseCount = 2.0 * M_PI / countsPerTurn;

// I2S data words are signed 32-bit two's complement; the int32_t cast in the
// interrupt produces exactly that.
static const double fullScale = 2147483647.0;

// Worst-case time spent in the interrupt, in CPU clock cycles, and the number
// of times the transmit FIFO ran empty. Both are printed by loop(). The
// interrupt has one sample period (2.6 us at 384 kHz) to finish. Two fixed
// sines with AM on both is the heaviest setup. If the interrupt is made
// slower than the sample period, loop() never gets to run and the status
// lines stop.
static volatile uint32_t longestInterruptCycles = 0;
static volatile uint32_t fifoErrorCount = 0;

static uint32_t incrementForFrequency(double frequencyHz) {
  return (uint32_t)((frequencyHz / (double)sampleRateHz) * countsPerTurn + 0.5);
}

// Returns the next sample for one channel, from -1.0 to +1.0 at full
// amplitude. Because each ChannelConfig is a compile-time constant, the
// compiler keeps only the branches that channel uses.
static inline double nextSample(const ChannelConfig &config, ChannelState &state) {
  double sample;

  if (config.sourceType == SOURCE_NOISE) {
    // xorshift32: three shift-and-XOR steps turn one 32-bit number into the
    // next, visiting every non-zero value before repeating. Read as a signed
    // number, the result is white noise spread evenly over full scale.
    uint32_t randomBits = state.noiseState;
    randomBits ^= randomBits << 13;
    randomBits ^= randomBits >> 17;
    randomBits ^= randomBits << 5;
    state.noiseState = randomBits;
    sample = (double)(int32_t)randomBits / 2147483648.0;
  } else {
    sample = sin(state.phaseAccumulator * radiansPerPhaseCount);
    state.phaseAccumulator += state.phaseIncrement;
  }

  if (config.modulationEnabled) {
    // envelope = 1 + depth x modulating sine, which swings between
    // (1 - depth) and (1 + depth). Dividing by (1 + depth) keeps the peaks at
    // the amplitude setting, so 100 % modulation cannot overflow the DAC.
    // Carrier, modulating sine and amplitude are all signed floating-point
    // numbers, so every product carries the correct sign. With depth limited
    // to 1.0 the envelope never goes below zero.
    double modulatingSine = sin(state.modulationPhaseAccumulator * radiansPerPhaseCount);
    state.modulationPhaseAccumulator += state.modulationPhaseIncrement;
    sample *= (1.0 + config.modulationDepth * modulatingSine)
            / (1.0 + config.modulationDepth);
  }

  return state.amplitude * sample;
}

// Runs every time the SAI1 TX FIFO drains to its watermark — once per audio
// frame in steady state. Pushes one left and one right sample.
static void sai1_isr(void) {
  uint32_t startCycles = ARM_DWT_CYCCNT;

  // An I2S frame carries the left word first, then the right word.
  I2S1_TDR0 = (int32_t)(fullScale * nextSample(leftChannel, leftState));
  I2S1_TDR0 = (int32_t)(fullScale * nextSample(rightChannel, rightState));

  // FEF is set by the SAI when the FIFO ran empty; writing the bit back
  // clears it.
  if (I2S1_TCSR & I2S_TCSR_FEF) {
    fifoErrorCount++;
    I2S1_TCSR |= I2S_TCSR_FEF;
  }

  uint32_t cycles = ARM_DWT_CYCCNT - startCycles;
  if (cycles > longestInterruptCycles) longestInterruptCycles = cycles;
}

// Loads one channel's starting values. seed must not be zero: xorshift32
// stays at zero forever once it gets there.
static void startChannel(const ChannelConfig &config, ChannelState &state, uint32_t seed) {
  state.amplitude = config.amplitude;
  state.noiseState = seed ? seed : 1;
  state.phaseAccumulator = 0;
  state.phaseIncrement = 0;
  state.modulationPhaseAccumulator = 0;
  state.modulationPhaseIncrement = 0;

  if (config.sourceType == SOURCE_FIXED) {
    state.phaseAccumulator = (uint32_t)(int64_t)(config.startPhaseDegrees / 360.0 * countsPerTurn);
    state.phaseIncrement = incrementForFrequency(config.frequencyHz);
  } else if (config.sourceType == SOURCE_SWEEP) {
    state.sweepFrequencyHz = config.sweepStartFrequencyHz;
    state.sweepHoldStartedMs = millis();
    state.phaseIncrement = incrementForFrequency(state.sweepFrequencyHz);
  }

  if (config.modulationEnabled) {
    state.modulationPhaseIncrement = incrementForFrequency(config.modulationFrequencyHz);
  }
}

// Moves a sweeping channel to its next frequency once the current one has
// played for sweepHoldMilliseconds. The hold time restarts when the frequency
// actually changes, so a late call never shortens the next hold. The last
// frequency step is made smaller if needed so the stop frequency itself is
// always played.
static void updateSweep(const ChannelConfig &config, ChannelState &state) {
  if (config.sourceType != SOURCE_SWEEP) return;
  if (millis() - state.sweepHoldStartedMs < config.sweepHoldMilliseconds) return;
  state.sweepHoldStartedMs = millis();

  const bool rising = config.sweepStopFrequencyHz >= config.sweepStartFrequencyHz;
  double frequencyHz = state.sweepFrequencyHz;

  if (frequencyHz == config.sweepStopFrequencyHz) {
    frequencyHz = config.sweepStartFrequencyHz;
  } else if (rising) {
    frequencyHz += config.sweepStepFrequencyHz;
    if (frequencyHz > config.sweepStopFrequencyHz) frequencyHz = config.sweepStopFrequencyHz;
  } else {
    frequencyHz -= config.sweepStepFrequencyHz;
    if (frequencyHz < config.sweepStopFrequencyHz) frequencyHz = config.sweepStopFrequencyHz;
  }

  state.sweepFrequencyHz = frequencyHz;
  state.phaseIncrement = incrementForFrequency(frequencyHz);
}

static void printChannel(const char *name, const ChannelConfig &config, const ChannelState &state) {
  Serial.printf("  %s: ", name);
  if (config.sourceType == SOURCE_FIXED) {
    Serial.printf("fixed %.3f Hz", config.frequencyHz);
  } else if (config.sourceType == SOURCE_SWEEP) {
    Serial.printf("sweep %.1f -> %.1f Hz, now %.1f Hz", config.sweepStartFrequencyHz,
                  config.sweepStopFrequencyHz, state.sweepFrequencyHz);
  } else {
    Serial.printf("noise");
  }
  Serial.printf(", amplitude %.3f", (double)state.amplitude);
  if (config.modulationEnabled) {
    Serial.printf(", AM %.1f Hz at %.0f %%", config.modulationFrequencyHz,
                  config.modulationDepth * 100.0);
  }
  Serial.println();
}

// Sets PLL4 (the audio PLL) to 24 MHz x (nfact + nmult/ndiv). A PLL that is
// already enabled is left untouched.
static void set_audioClock(int nfact, int32_t nmult, uint32_t ndiv) {
  if (CCM_ANALOG_PLL_AUDIO & CCM_ANALOG_PLL_AUDIO_ENABLE) return;

  CCM_ANALOG_PLL_AUDIO = CCM_ANALOG_PLL_AUDIO_BYPASS | CCM_ANALOG_PLL_AUDIO_ENABLE
                       | CCM_ANALOG_PLL_AUDIO_POST_DIV_SELECT(2)
                       | CCM_ANALOG_PLL_AUDIO_DIV_SELECT(nfact);
  CCM_ANALOG_PLL_AUDIO_NUM   = nmult & CCM_ANALOG_PLL_AUDIO_NUM_MASK;
  CCM_ANALOG_PLL_AUDIO_DENOM = ndiv & CCM_ANALOG_PLL_AUDIO_DENOM_MASK;

  CCM_ANALOG_PLL_AUDIO &= ~CCM_ANALOG_PLL_AUDIO_POWERDOWN;
  while (!(CCM_ANALOG_PLL_AUDIO & CCM_ANALOG_PLL_AUDIO_LOCK)) {}

  CCM_ANALOG_MISC2 &= ~(CCM_ANALOG_MISC2_DIV_MSB | CCM_ANALOG_MISC2_DIV_LSB);

  CCM_ANALOG_PLL_AUDIO &= ~CCM_ANALOG_PLL_AUDIO_BYPASS;
}

// Clocks SAI1 from PLL4 and configures it as I2S master: two 32-bit words
// per frame, MSB first, data one bit clock after each frame-sync edge.
static void config_i2s(int fs) {
  CCM_CCGR5 |= CCM_CCGR5_SAI1(CCM_CCGR_ON);

  // PLL4 must run between 648 MHz (24 MHz x 27) and 1296 MHz (x 54); the
  // n1/n2 dividers scale the target so the multiplier C lands in range.
  int n1 = 4;
  int n2 = 1 + (24000000 * 27) / (fs * 256 * n1);
  double C = ((double)fs * 256 * n1 * n2) / 24000000;
  int c0 = C;
  int c2 = 10000;
  int c1 = C * c2 - (c0 * c2);
  set_audioClock(c0, c1, c2);

  CCM_CSCMR1 = (CCM_CSCMR1 & ~(CCM_CSCMR1_SAI1_CLK_SEL_MASK))
             | CCM_CSCMR1_SAI1_CLK_SEL(2);  // 2 = PLL4
  CCM_CS1CDR = (CCM_CS1CDR & ~(CCM_CS1CDR_SAI1_CLK_PRED_MASK | CCM_CS1CDR_SAI1_CLK_PODF_MASK))
             | CCM_CS1CDR_SAI1_CLK_PRED(n1 - 1)
             | CCM_CS1CDR_SAI1_CLK_PODF(n2 - 1);

  // MCLK1, selected as the bit-clock source by MSEL below, is the SAI1
  // clock root. The MCLK pad itself is never muxed out; the DAC recovers
  // its clock from BCK.
  IOMUXC_GPR_GPR1 = (IOMUXC_GPR_GPR1 & ~(IOMUXC_GPR_GPR1_SAI1_MCLK1_SEL_MASK))
                  | (IOMUXC_GPR_GPR1_SAI1_MCLK_DIR | IOMUXC_GPR_GPR1_SAI1_MCLK1_SEL(0));

  // These SAI1 TX pins are free on Basecamp; on Ascent the same pins belong
  // to the parallel sampler.
  CORE_PIN36_CONFIG = 3;  // pad B1_02 -> SAI1_TX_BCLK  (BCK)
  CORE_PIN37_CONFIG = 3;  // pad B1_03 -> SAI1_TX_SYNC  (LCK)
  CORE_PIN7_CONFIG  = 3;  // pad B1_01 -> SAI1_TX_DATA0 (DIN)

  // Transmitter as asynchronous master: it generates BCK and LRCLK itself.
  // The FIFO watermark of 8 fires the refill interrupt with room to spare.
  I2S1_TMR = 0;
  I2S1_TCR1 = I2S_TCR1_RFW(8);
  I2S1_TCR2 = I2S_TCR2_SYNC(0) | I2S_TCR2_BCP
            | I2S_TCR2_BCD | I2S_TCR2_DIV(1) | I2S_TCR2_MSEL(1);
  I2S1_TCR3 = I2S_TCR3_TCE;
  I2S1_TCR4 = I2S_TCR4_FRSZ(2 - 1) | I2S_TCR4_SYWD(32 - 1) | I2S_TCR4_MF
            | I2S_TCR4_FSD | I2S_TCR4_FSE | I2S_TCR4_FSP | I2S_TCR4_FCONT;
  I2S1_TCR5 = I2S_TCR5_WNW(32 - 1) | I2S_TCR5_W0W(32 - 1) | I2S_TCR5_FBT(32 - 1);

  // Prime the FIFO with silence so the transmitter has data the moment it
  // starts.
  for (int i = 0; i < 32; i++) {
    I2S1_TDR0 = 0;
  }

  attachInterruptVector(IRQ_SAI1, sai1_isr);
  NVIC_ENABLE_IRQ(IRQ_SAI1);
  I2S1_TCSR = I2S_TCSR_TE | I2S_TCSR_BCE | I2S_TCSR_FRIE;
}

void setup() {
  Serial.begin(115200);

  // Seed each channel from the hardware random number generator. Each
  // channel then generates pseudorandom samples without waiting for more
  // hardware entropy, which arrives far too slowly for one number every
  // 2.6 us.
  Entropy.Initialize();
  uint32_t rightSeed = Entropy.random();
  uint32_t leftSeed  = Entropy.random();

  startChannel(rightChannel, rightState, rightSeed);
  startChannel(leftChannel, leftState, leftSeed);
  config_i2s(sampleRateHz);
}

void loop() {
  updateSweep(rightChannel, rightState);
  updateSweep(leftChannel, leftState);

  // Status line, so a silent DAC is distinguishable from a board that never
  // booted or never took the flash.
  static uint32_t lastPrintMs = 0;
  if (millis() - lastPrintMs >= 2000) {
    lastPrintMs = millis();
    double longestInterruptMicroseconds = longestInterruptCycles * 1.0e6 / F_CPU_ACTUAL;
    Serial.printf("PCM5102A DDS at %d samples/s, up %lu s\n", sampleRateHz, millis() / 1000);
    printChannel("right", rightChannel, rightState);
    printChannel("left ", leftChannel, leftState);
    Serial.printf("  longest interrupt %.2f us of %.2f us available, FIFO errors %lu\n",
                  longestInterruptMicroseconds, 1.0e6 / sampleRateHz,
                  (unsigned long)fifoErrorCount);
  }
}
 
Why does the source reference "PCM5102 bring-up code" when the PCM5102 doesn't need any setup?
 
In the full tutorial, I included much simpler "bringup code" as used to make the SFDR measurements, and also the "full-featured" code, which I posted in this thread. I always like a bare-bones "bring-up" at first, and it is generally easier to understand for beginner programmers. Does that answer your question?
 
Trying to interpret your terse question with the best of intentions here. The references in the code to "PCM5102 bring-up code" was to the basic code, written in 2024 for another purpose, to give credit to the authors. Are you thinking that meant some specific PCM5102 "setup" code - not at all. It is - for sure - general I2S/SAI setup. So maybe just semantics? I always, always call the stripped-down, most basic code for testing hardware "bring-up code" and will continue to do so.
 
Nice. But it should use float, not double. Float is sufficient(SNR > 140 dB). And use sinf().
 
Trying to interpret your terse question with the best of intentions here. The references in the code to "PCM5102 bring-up code" was to the basic code, written in 2024 for another purpose, to give credit to the authors. Are you thinking that meant some specific PCM5102 "setup" code - not at all. It is - for sure - general I2S/SAI setup. So maybe just semantics? I always, always call the stripped-down, most basic code for testing hardware "bring-up code" and will continue to do so.
I'm trying to figure out which code, specifically, the "PCM5102 bring-up code" comment refers to.
The only hw setup functions I see are set_audioClock() and config_i2s() which are from the audio library, as credited. So I'm really at a loss as to what the other reference is for or what the source for it was.
 
Nice. But it should use float, not double. Float is sufficient(SNR > 140 dB). And use sinf().
Thanks - great feedback. I'm a hardware guy. :) Passed along to Zachariah. Looks like 2X faster than double, so worth it. Should not change signal quality though, so likely no need to repeat all of the measurements.
 
I'm trying to figure out which code, specifically, the "PCM5102 bring-up code" comment refers to.
The only hw setup functions I see are set_audioClock() and config_i2s() which are from the audio library, as credited. So I'm really at a loss as to what the other reference is for or what the source for it was.
Please let me try again. The sentence, "Based on PCM5102 bring-up code by G. Kovacs and M. Flamer (2023)," simply means that I wanted to put in the comments a note to trace the lineage of the publicly offered tutorial code from internal prototype code. This is so I or someone else on our end can go back and find the original code where the ideas were first tested. There is no implication that some other code is needed, such as this bring-up code from 2023, to use the stuff in the tutorial. It isn't.

There is no dependency.

It is just a note in a comment field.

I cannot understand - but not judging and only trying to help - what the issue is here.

Being a hardware guy at heart, I did come up with a practical, physical solution though. I printed out the offending pages and blacked out the comment with a Sharpie...
 

Attachments

  • IMG_9706c.jpg
    IMG_9706c.jpg
    169 KB · Views: 28
Thanks - great feedback. I'm a hardware guy. :) Passed along to Zachariah. Looks like 2X faster than double, so worth it. Should not change signal quality though, so likely no need to repeat all of the measurements.
Question - it seems to me (hardware guy) that the ISR service time is the rate limiter. With IntervalTimer() it maxes out around 1MHz, so 1us. The floating point jazz is way faster. Is there any pro way to speed up the clocked interrupt servicing? Probably not using proper "bro-grammer" terms, but you know what I mean, right?
 
You should rather use DMA to transfer data to I2S in blocks (say 128 sample blocks) rather than servicing I2S data register from interrupt directly. With DMA you would get interrupts much slower at rate of sample_rate/dma_buffer_length. You would get 2 interrupts each handling one half of DMA buffer, and you would fill entire block at once in a loop that is much faster than what you are doing now.

That is precisely what Teensyduino/audiolib code does so just lookup audio code in Teensyduino/audiolib source to get all the details.

And float prec is enough because it has 24 bit mantissa (+sign and 7 bit exponent), so it delivers 24*6 dB = 144dB SNR, more than your DAC is able to deliver. Note on C code that single precision floating point constants should be written like 100.0f (with f suffix) otherwise the compiler would treat them as double precision and perform calculations in double prec and you would not get desired performance gains from single-cycle 32bit float execution.
 
Last edited:
Don't forget the 32-deep FIFOs on the SAI transmit and receive. If you half-fill / empty those (16 samples) at 384ksps, you could reasonably do so under interrupt (24kHz) with a latency of 41.7µs, and without getting into the complexities of DMA. The PCM5102A can be set for either a 3.5- or 20-sample latency using the FLT pin, so you can juggle the FIFO update block size to achieve the best compromise for the purposes of the moment.
 
Are you sure that you would get interrupts @24kHz, not 48khz with half empty FIFO interrupt? Keep in mind he is using 32 bit and stereo (i.e. 2*32bit), so 2 FiFO entries per sample. 384/(16/2)=48kHz
 
Last edited:
Good point, I’d missed the 32 bit output. 48kHz interrupts are still very doable, of course … and the latency is halved to 20.8μs :)
 
You should rather use DMA to transfer data to I2S in blocks (say 128 sample blocks) rather than servicing I2S data register from interrupt directly. With DMA you would get interrupts much slower at rate of sample_rate/dma_buffer_length. You would get 2 interrupts each handling one half of DMA buffer, and you would fill entire block at once in a loop that is much faster than what you are doing now.

That is precisely what Teensyduino/audiolib code does so just lookup audio code in Teensyduino/audiolib source to get all the details.

And float prec is enough because it has 24 bit mantissa (+sign and 7 bit exponent), so it delivers 24*6 dB = 144dB SNR, more than your DAC is able to deliver. Note on C code that single precision floating point constants should be written like 100.0f (with f suffix) otherwise the compiler would treat them as double precision and perform calculations in double prec and you would not get desired performance gains from single-cycle 32bit float execution.
Thank you! Good that you bring up DMA. For this tutorial we did not feel that there was a need right now, but we'll get to it (favoring putting out different tutorials first). We have a full, open-source DMA library almost ready to release, but the focus is on high-speed parallel - the example is a 10-bit, 40MSPS, gapless-double-buffered ADC. Coming soon.

If you look at the "full-featured" code example, it is actually running four DDS accumulators, two for AM of the outputs. We'll need to think about how that would be coordinated if using DMA. Interesting.

Also good point on the single precision - as also mentioned by Mcu32. 1/2 the time spent on those calculations. But still, the ISR service time seems to dominate, and we'll eventually speed things up. For a tutorial though, with the CPU not needed for anything else, these good comments are icing on the cake.
 
I had the normal Audio library running at 384kHz (ok, not float - 16Bit int) - it modulated a teensy PLL that was running @~90MHz to create a FM transmitter with Stereo and RDS data. The Teensy is a beast and had no problems.
 
Back
Top