Implementation of the HDA Link interface on RP2040

—

от автора

Русская версия

When it comes to outputting or inputting audio to or from a microcontroller — if the system is even slightly more complex than a simple beeper — I²S is almost always the standard. Many microcontrollers have the necessary peripherals, and a wide range of compatible audio DACs and ADCs is available. However, this typically means a maximum of 2 channels for audio output and 2 for input (and even then, not always simultaneously).

On the other hand, there is a whole world called High Definition Audio, developed by Intel in 2004. It is highly likely that the sound on the PC or laptop you are using to read this article is transmitted via this interface. HDA supports 15 input channels and 15 output channels, with a bit depth of up to 32 bits and a sampling rate of up to 192 kHz. Admittedly, not all at once — the channel bandwidth is limited to just over 46 Mbit/s per audio output channel; nevertheless, its capabilities are much broader than those of I²S.

But I haven’t come across any microcontrollers that natively support HDA. For some reason, it’s generally assumed that only chipsets (south bridges) — that is, the “full-fledged” system logic of a PC — can act as hosts. You could also cobble something together using an FPGA, but for many “knee-on-the-floor DIY” enthusiasts, that’s too high a hurdle.

I thought that way for a long time, too. And at first, the news about the release of the RP2040 just went right over my head — I figured, “Just another microcontroller; there are thousands of them.” That is, until I stumbled upon the ZX_RGBI2VGA-HDMI project, where this microcontroller captures an RGB video signal from a ZX Spectrum in real time and outputs it as an HDMI (or rather, pseudo-HDMI) signal required by modern monitors.

And I thought: Why not try an HDA link on it? Well, if not, then not, I told myself, and got to work — luckily, I still had a board with that codec, the ALC889, left over from my previous attempt.

What's Left of the Previous Project

What’s Left of the Previous Project

HDA-Link Basics

Lines and Data Transmission

HDA link, also known as Azalia, is a simple interface that uses only 4 wires to transmit audio data, but it’s tricky and has specific requirements. The first problem is that the BCLK clock signal must be exactly 24 MHz — the PLL inside the codec relies on it. This means you can’t just generate a clock signal via GPIO, as you would for SPI or I²C, because you need a stable, meander waveform, like the one from a generator.

The next two signals — SYNC and SDO — operate synchronously, and their state can change on every BCLK edge, that is, at a frequency of 48 MHz (pure DDR). The output is divided into frames of 1,000 bits each, resulting in a frame rate of 48 kHz — this is the base audio sampling rate, with each frame representing a single sample. The other frequencies, 96 and 192, are obtained by doubling or quadrupling the number of samples per frame. As for the frequencies derived from CDs and based on 44.1 kHz, they are calculated the same way, except that for every 160 frames, 13 empty frames (without audio data) must be transmitted, and these 13 empty frames must be evenly distributed throughout the 160 frames.

The start of the frame — or, more precisely, its end — is determined by the SYNC signal; at that moment, a sequence of 8 ones is transmitted there.

Start of Frame

Start of Frame

The first 40 bits of each frame on the SDO line are reserved for control sequences, known as “verbs.” When the host needs to configure the codec or request something from it, such a sequence is inserted into the stream; otherwise, zeros are transmitted there. Only one verb can be transmitted in each 48 kHz frame, but this needs to be done infrequently, so there are no issues here.

The SYNC signal also contains markers that indicate the numbered streams for each stereo channel. After each such SYNC marker, data transmission for that channel begins on the SDO line.

The last signal, SDI, differs from SDO. Its state changes only on the rising edge of BCLK, meaning its bandwidth is half that of SDO. In addition, the tags for each stereo channel are carried within the signal itself, which further narrows the bandwidth. And at the beginning of each frame, the first 36 bits are reserved for responses. A response may or may not be present — this is determined by the “valid” bit. Responses can be either solicited (a response to a command) or unsolicited (a reaction, for example, to plugging a jack into a socket).

The specified SDO bandwidth allows for the tight fit of 10 channels of 24-bit audio at 192 kHz — which is exactly how many (5 stereo channels) are supported by the ALC889 codec. The situation with SDI is a bit more disappointing — 3 stereo channels at 24/192 don’t fit within the bandwidth, so something has to be sacrificed — either the sample rate, the bit depth, or the number of channels. Why Intel did this is a mystery to me. Apparently, they decided that 640 KB 4 channels would be enough for everyone.

Codec address

For the codec to work, it’s not enough to generate all the signals. You need to take the penultimate step and assign it an address on the bus, which will then be used in all verb requests. Without this, the codec won’t respond to them.

Codec initialisation: reset, codec address (CAD)

Codec initialisation: reset, codec address (CAD)

Here’s how it works:

  • At a specific moment, after power is applied or a reset is triggered, the codec generates a pulse on the SDI line, then sets it to a high-impedance state. This is indicated by the number (1) in the figure. The host must detect this moment.

  • The host begins to drive this line and, in sync with the next SYNC signal, outputs a 1 on it — indicated by number (3) in the diagram.

  • After 4 BCLK cycles (“start of frame” on SYNC), the host can extend this 1 on the SDI line for several more cycles — the address that the codec will adopt as its own depends on this. If it is not extended (i.e., SDI goes to 0 simultaneously with SYNC), the codec will accept address 0, as shown by number (4) in the figure.

  • The host releases the SDI line, and starting with the next frame, the codec takes control of it again (8).

After that, the codec begins to respond to commands accompanied by the specified address, and you can request its configuration and set it up for operation.

Configuring nodes and signal paths

This is the final step in codec initialization. A node is a small component of the codec that performs a single function. ADCs, DACs, mixers, and input/output pins are all nodes.

Structure of the ALC889. The numbers in the ovals are the node numbers.

Structure of the ALC889. The numbers in the ovals are the node numbers.

For example, if we want to output audio from DAC No. 1 “Front” (node 02h) to the “Front” pins (also known as Port-D), node 14h, we must:

  • configure the DAC (node 02) — specify the stream number, sampling rate, bit depth, etc., which is done using the 2h and 706h commands;

  • configure intermediate node 0Ch — the volume control/mixer;

  • configure pin 14h as an output, specifying that it should take the signal from node 0Ch. The output pin contains a multiplexer switch that allows it to take a signal from one of the sources. I’ll explain how to configure all of this below, in the project description.

Once the codec is configured, you can send it an audio stream specifying the channel number, and the signal will appear at the output.


Implementing HDA Link on the RP2040

A distinctive feature of this microcontroller — one that sets it apart from “just another Cortex-M0+ microcontroller” — is the presence of PIO (programmable input/output) blocks. In short: in addition to classic control via GPIO or built-in peripherals, the microcontroller’s pins can interact with the PIO blocks, which contain several state machines (SMs) that operate independently of the central processor. Each SM runs its own short program with a small set of instructions and processed data. Each instruction is executed in a single clock cycle, which allows for precise timing and synchronous operation of multiple SMs. Each SM has input and output shift registers, a FIFO for communication with the CPU/DMA, a pair of 32-bit registers, and several other useful features. Most of these are used in the implementation of the HDA link.

Demo project

A project has been created to pair the RP2040 with the HDA Link codec. All references to source code primarily pertain to this project. (There will be a lot of links to the relevant code snippets in the repository below. I think this is better than pasting them here. Let me know if you think I’m wrong.)

Signals I/O

HDA link signals are generated and received using PIO controllers that are started simultaneously and operate in sync. The source assembly code for them is contained in hda.pio.

The SMs are used as follows:

SM0 — SDO signal output. This is handled by the out_1000bit program. At the appropriate moment, the SM outputs the next bit to the outside using the output shift register. The bit sequence itself is generated in software (by the main processor) in a buffer dedicated for this purpose and is transferred to the SM FIFO using the two DMA channels alternately. Auto-pull is used to load the shift register. Since the frame length is 1,000 bits and is not a multiple of 32, an output bit counter is implemented on the SM register. Loading a constant longer than 5 bits into the SM registers is problematic, so it is loaded from the main program and stored in another register. After calculating the required number of bits, the remainder in the shift register (24 bits) is output externally, which causes the following sequence to be loaded into it.

SM1 — the SYNC signal output; it operates similarly to SM0 and executes the exact same code. The difference is that it uses a different buffer and DMA channels.

SM2 — SDI input, in_500bit program. Input shift register is filled one bit at a time. As it is filled, an autopush occurs, and upon reaching the 499th cycle, a shift is performed immediately across the entire length of the register (in pins, 32), which triggers an out-of-order autopush. Next, the FIFO–DMA–RAM buffer chain takes over. This same code uses a so-called “side-set” to generate the BCLK signal and the auxiliary Start of Frame (SoF) signal (which is useful for synchronizing an oscilloscope during debugging).

SM3 — Assigning an address to a codec, program codec_address. The circuit waits for a pulse on the SDI line, sets it to the active state, counts down the required interval, generates a pulse of the required length, and sets the line to the inactive state. After completing its task, it sends some garbage to the CPU and enters an infinite loop. After that, it may be stopped and used for other tasks. The CPU, upon receiving the garbage that was sent, realizes that the address assignment phase has been successfully completed and that it can begin initialization of the codec. This SM starts up a little later than the others, after the reset; otherwise, it might trigger prematurely due to noise on the SDI line when the BCLK appears.

This is shown in the oscillogram below. The SDI line is pulled up to a level of approximately +0.9 V by a resistive divider, and you can see how it transitions to a high-impedance state and back:

Codec address (CAD)

Codec address (CAD)

The following oscillogram shows the entire codec initialization sequence, including the reset, address assignment, and receipt of responses to commands.

Codec initialization sequence

Codec initialization sequence

The numbers indicate:

  1. Noise on the SDI line that appears after BCLK is applied (the codec was running, then the microcontroller was reset and the BCLK signal disappeared; when it reappeared, the codec sent the ADC samples that had accumulated in the buffer).

  2. CAD.

  3. Responses to commands (the commands themselves are not shown on SDO because there weren’t enough channels).

Assembly and Disassembly of Digital Sequences

The DAC/ADC samples transmitted over the SDO/SDI lines are packed tightly together and are not aligned to a 32-bit boundary. To make them easier to work with (each sample in its own int32_t), software repackaging is performed; core #1 is dedicated to this task (the RP2040 is a dual-core microcontroller). Upon receiving an interrupt from the DMA — that is, when the next batch of data has been sent out to the SDO and received from the SDI — the kernel begins to pack a set of samples into a sequence that the codec can understand. If necessary, a control verb is added to it.

Next, the CPU unpacks the data received via SDI from the ADC, simultaneously separating the wheat from the chaff the audio data from the responses, storing the former in a single buffer and the latter in their respective response queues (depending on whether the response was requested or not). All the packing logic is described in the data_pack.c file; the appropriate functions are called depending on the current mode.

Sine Wave source

It’s included in the project as well. For maximum performance, it’s organized as a table — 1,024 points per quadrant — followed by piecewise-linear interpolation. Not Chebyshev polynomials, of course (I tried those too, but they run very slowly on a Cortex-M0 without DSP instructions), and there are harmonics, but the strongest one — the 3rd — doesn’t exceed −123 dB in simulation, which is more than enough for a demo project. You can also connect a larger sine wave table, with 4,096 points and −142 dB — I have one of those, too. To do this, you need to edit CMakeLists.txt. The code size will increase, but we have the resources to handle it. The benefit of this, however, is practically zero — the analog parts of the codec introduce far more distortion, and the improvement will most likely be imperceptible.

Using the DACs

The ALC889 contains 5 stereo DACs, meaning 10 independent channels. As you’ve probably already guessed, this project uses them to output sinusoidal signals with varying, starting at 880 Hz at a sampling rate of 192 kHz and increasing by a factor of 1.5 in each subsequent channel. That makes them fifths, then. The last two channels output sine waves with frequencies equal to 0.333 and 0.400 times the sampling rate.

Using the ADC

All 6 ADC channels (3 stereo inputs) are connected to an LED on the RP2040 board: as soon as a signal slightly above the noise level appears at any of the inputs, the LED begins to flash. My imagination took a break at that point, and I couldn’t come up with anything more interesting.

Overclocking

You can achieve a sampling rate higher than 192 kHz by running the microcontroller at a higher frequency. To do this, in the main function, in the line set_sys_clock_pll(24000000 * LINK_BCLK_LEN*2 * 6, 3, 2), replace the number 24000000 with another value — the microcontroller’s system frequency and BCLK will change proportionally. The coefficients are chosen such that 24000000 is, in fact, the BCLK signal frequency (Hz). I successfully overclocked it to 32 MHz (the sampling rate was 256 kHz) — the codec worked, but it got so hot that I could barely keep my finger on it.

Well, if you’ve added calculations to your project and you’re finding that the performance per sample is a little lacking, you can increase the constant LINK_BCLK_LEN; by default, it is 4. The MCU’s system clock frequency will increase proportionally, while the BCLK frequency will remain unchanged.

Signal Paths

The codec can be flexibly configured. You can specify signal paths, pin orientations, volume levels, and more. Here, I’ll briefly explain how path search and configuration are implemented.

If a node can receive a signal from other nodes, then there is a list of those source nodes for it. You can query the codec for their number using the verb F00h:0Eh, and for the list using F02h verb. In the table showing the initialization results, this information is found in the corresponding columns, closer to the right edge. Any node can be connected to virtually any other node, and sometimes there is more than one such path.

In this project, the pins are hardwired to the DAC/ADC; the connections are described in a small table.

After receiving the properties of the codec and its nodes, the function, which recursively searches for the path to the destination node. If one is found, then the following are configured all nodes participating in it.

USB Audio Device Project

Another project demonstrates the ALC889 and RP2040 working together as a UAC 2.0 device. I should note right away that the project is still in its early stages, and its codebase lags significantly behind the one described above. You can use it, but there are some caveats…

I won’t go into detail about it here; anyone who needs to can figure it out on their own. I’ll just mention that the CherryUSB library is used for the USB stack, although it probably would have been better to choose tinyUSB from the Pico SDK. Well, it is what it is.

The bin folder contains several compiled files for the firmware, each offering its own trade-off among “bit depth, sampling rate, and number of channels.” I had to resort to this because the USB bus bandwidth in Full-Speed mode is significantly lower than what’s needed to take full advantage of the HDA link. Each firmware version has a unique USB DID hardcoded into it to avoid the hassle of dealing with Windows when switching to the next one.

Hidden text

I hate USB…


Conclusions, Observations, and Comments

1. The codec’s digital filters cut off, among other things, the DC component. They behave like a first-order high-pass filter with a cutoff frequency of about 4 Hz at 192 kHz SR (I haven’t tried it at lower sample rates, but it will most likely be proportional). I don’t think this can be disabled. The filters are located both before the DAC and after the ADC. Therefore, it won’t be possible to use the codec either as a regulated DC voltage source or for digitizing, for example, the positions of potentiometers. Only AC, only hardcore.

2. When in operation, the ALC889 consumes a significant amount of power and converts it into heat. The table below shows how many mA the codec board draws from the +3.3 V supply, depending on the sampling rate and the number of active channels. (The codec requires two voltages — +3.3 V and +5.0 V. The first is supplied externally, while the second is derived from it via a step-up DC-DC converter.)

SR, kHz

3+5

1+1

256

271

—

192

244

198

176

237

—

96

218

—

88

215

—

48

204

—

44

202

187

idle

147

Here, 3+5 and 1+1 represent the number of DAC+ADC stereo channels that were active.

3. A previous attempt to implement the HDA codec on a microcontroller was made using a combination of an STM32F103, a Spartan-3, and an ALC889. However, the USB peripheral buffer size was limited to 512 bytes for all USB endpoints and dual buffers, which resulted in zero USB bandwidth, so that project was successfully scrapped and buried (though the board with the codec remained). The attempt mentioned in this article turned out to be slightly better, as the limitations are of a different nature — the total volume for all isochronous transfers within a 1-ms USB frame is limited to 1 KB (which is close to the 1.5 KB bandwidth of Full-Speed USB).

For a codec like this to really shine, it requires bandwidth that’s roughly an order of magnitude higher — which falls into the “High-Speed” category — and that involves entirely different microcontrollers. Maybe RP will treat us to something like that someday.

4. It’s worth mentioning how samples are organized into streams, since the images alone may not immediately make it clear how this actually works. Let’s say we’re playing back 3 stereo channels at 48 kHz. The sequence of samples looks like this:

(1) R L (2) R L (3) R L,

where the number in parentheses is the stereo channel number (its starting point), and R and L are its right and left halves (or vice versa — it doesn’t matter here). At 48 kHz, everything is clear and logical. But at 192 kHz, where there are four times as many samples, the order will be as follows:

(1) R L R L R L R L (2) R L R L R L R L (3) R L R L R L R L

In other words, all the samples for the first DAC are processed first, then those for the second, and so on. This must be taken into account when filling the DAC buffers and flushing the ADC buffers. In the sine wave generator, this is handled 3 nested loops; in the USB project, similar code repackages the stream before sending it over USB.

5. The USB audio version of this project was in danger of being put on indefinite hold due to the peculiarities of the RP2040. The problem was as follows: when two DMA channels were operating simultaneously (transferring data from RAM to the PIO FIFO) and USB communication began — especially while a command was being fetched from the XIP (SPI FLASH), one of the DMA channels would send an extra word to the already full PIO FIFO, causing it to overflow, resulting in the loss of 32 bits and, consequently, desynchronization on the HDA link between the SYNC signal and the rest. Thanks to the guys at Raspberry Pi — they eventually found the cause and proposed a working solution. It’s working fine now, but just in case because I didn’t want to delete it, there’s still a piece that monitors this and triggers the debug pin.

6. While learning the RP2040, I made extensive use of Google AI; it almost always provided useful advice and quite often generated working code that I was able to incorporate into my project with minimal changes — you can recognize it by its specific comments. To sum up — he does an excellent job as a mentor (I recommend him while he’s still available).

7. What’s the point of all this? My imagination refuses to come back from its vacation, so all I can suggest is: a) a multi-channel USB audio interface; b) a standalone signal generator. As a variation on the latter — a multi-channel signal generator with feedback: receive a signal (or signals), process it, and send it back. But in this case, it’s better to use the RP2350 — it has a Cortex-M33 with DSP instructions and floating-point capabilities, and processing signals will be more fun. If you happen to have your own ideas, please share them.

That’s all for now. Thank you for reading to the end.

Best regards, Victor. I’d love to hear your comments.

ссылка на оригинал статьи https://habr.com/ru/articles/1096724/