On the Teensy however I have no idea how to interface with the output_i2s module without using audio block types etc.
Here's a bit more explanation about how the code works. I want to emphasize this is intended to help you (or anyone else who later finds this thread) get started. It's not meant to be step-by-step instructions.
Near the top of output_i2s.cpp you'll see the begin() function. It calls config_i2s(), which turns on the audio clock and sets up the I2S hardware. You can find the config_i2s() near the end of that file. You'll see it's mostly just writing to the many hardware registers to turn on the I2S communication. Those registers are all documented in the
reference manual for the chip you're using.
You can see that config_i2s() sets up the clocks and audio sample rate, and that clock config code is very different depending on which Teensy you use. The good news is Teensy 4.0 has highly configurable hardware, so it just takes the actual sample frequency. On the older boards things were much harder, requiring integer divisors from specific clocks.
Once the I2S hardware is turned on, you can see the begin() function sets up a DMA channel which will automatically copy data from the "i2s_tx_buffer" array to "I2S0_TDR0", which is the actual hardware register which causes data to go into the I2S FIFO and ultimately transmit on the data pin. The triggerAtHardwareEvent() function is what causes the DMA channel to actually service the I2S hardware requests for data. You'll also see "dma.attachInterrupt(isr);" which causes the isr() function to run when the DMA controller needs more data copied into the i2s_tx_buffer array.
The "dma" object is a hardware abstraction for the DMA controller, which is defined in DMAChannel.h. In this case, we're really only using DMAChannel to assign which of the DMA hardware channels get used (so we automatically share DMA hardware with other libraries and even other parts of this audio library), but the actual DMA config is done by directly accessing the DMA controller's TCD registers. Those TCD registers are documented in the reference manual.
Right below begin() is that isr() function. Hopefully you can see it's figuring out which half of the buffer the DMA is currently using. Then it just copies a block of audio data into the other half of the i2s_tx_buffer buffer. It's really that simple.
So if you don't want to use AudioStream.h buffer definitions, you would modify that isr() interrupt code to put your data into whichever half of the i2s_tx_buffer the DMA isn't using at that moment. That isr() function gets run by interrupts generated from the DMA controller. It generates those interrupts when it reaches the middle and the end of the buffer, because the DMA channel's CSR register is assigned to "DMA_TCD_CSR_INTHALF | DMA_TCD_CSR_INTMAJOR". (unless you're using Teensy LC... it's less capable DMA controller works differently....)
The other key piece of the isr() function is the call to AudioStream::update_all(), if the update_responsibility flag is set. This causes code in AudioStream.cpp to call ALL the update() functions of every audio instance you've created. One of those will be the update() function you can see in this file. If you read that code, you'll see all it does is save a pointer to the block of sample it gets from the rest of the library. That pointer is what the isr() function later uses to actually copy the samples to the i2s_tx_buffer.
How often isr() gets called depends on how often the I2S hardware requests data, which comes from the audio sample rate, and the size of i2s_tx_buffer. The size of i2s_tx_buffer in this code is set to be twice the size of audio blocks from AudioStream.h, so each interrupt will require exactly one of those blocks to fill half of i2s_tx_buffer.
So, if you want to reuse the low-level I2S and DMA code, but discard all the infrastructure and work of the audio library, you'll probably delete that update function and the AudioStream::update_all() call. In their place, maybe you'll have the isr() function cause your audio code to run? Or maybe you'll have that code store data somehow and modify the isr() function to copy it from whatever buffers your synth code uses to the i2s_tx_buffer array.
How you go about all that is up to you. Whatever approach you take, I hope you'll consider sharing your code or at least anything you learn along the way which might help others who wish to use the code this way. While I can write a lengthy message like this with the basic concepts, it's the real little issues that come up along the way that people who try to follow this path in the future can really use to help. So I hope you'll consider sharing that experience on this thread.