Inject a sine tone into tic80 audio pipeline

Inject a sine tone into tic80 audio pipeline

task id: tic80-sine-tone

2024-07-09 08:19: Initial investigation completed, now onto initial challenge: sine tone test. #investigate-tic80-audio #tic80-sine-tone

I think I know about enough to get started on hacking in a sine tone into the TIC-80 sound codebase.

A sine tone is ideal because it's a pitched pure tone, and it can be easy to tell if I'm getting the buffering and sample rate correct. If I can get a sine.

2024-07-10 11:14: This would be a good day to try to work on this #tic80-sine-tone

2024-07-10 15:20: Attempts to get a sine tone playing in tic80 #tic80-sine-tone #timelog:01:19:56

2024-07-10 15:24: =update_amp()=, what does that do? #tic80-sine-tone

Ah, I don't think I have to care about that, I can process data directly.

2024-07-10 16:22: runPcm not working. going a level down. #tic80-sine-tone

2024-07-10 16:55: Wow, I am having a hard time grokking this code #tic80-sine-tone

I don't understand what update_amp is doing. It seems to be some kind of delta encoding.

I'm just putting printf statements in functions now to see what runs and happens.

2024-07-10 17:39: the =tic_sound_register_data= is key #tic80-sine-tone

It can be found in core.h. The value I think that is relevant for an arbitrary sine tone is "amp", which is the "current amp in the delta buffer". Does that mean the amp is delta encoded or PCM encoded?

There are 4 channels of sound.

2024-07-10 17:45: Follow-ups #tic80-sine-tone

How can I print any non-zero sound PCM values to terminal?

What's the deal with registers? How do they end up in a PCM buffer?

2024-07-11 08:33: I have a few more ideas on how to do this #tic80-sine-tone

Look at tic_core_sound_tick_start and tic_core_sound_tick_end.

2024-07-11 09:01: Another stab at this tic80 sound stuff #tic80-sine-tone

2024-07-11 09:06: looking at ringbuf in tick end function #tic80-sine-tone

It seems like there's a ring buffer of registers, and a register holds the data for one frame of audio. A frame consists of 4 channels of sound. Each channel is represented as a 32-bit signed integer.

When the end tick function is called, it gets the current register in the ring buffer, copies the register information in memory (RAM) to that ring buffer, then copies the "pcm" and "stereo" data bits as well. The ring buffer head is then incremented, but it's inside some conditional. I can't fully grok the conditional, but it seems to be some kind of bounds checking? I'm going to ignore it mostly for now.

2024-07-11 09:18: Flipping back to =studio_sound= again. Does it get called? #tic80-sine-tone

Yup, it's getting called.

2024-07-11 09:22: Back to sokol. Are we in sokol? #tic80-sine-tone

No. No we are not in Sokol.

2024-07-11 09:26: We are in SDL! #tic80-sine-tone

I've zoomed up to the top-most callback level which looks familiar enough to me. Still, it's doing stuff in a less than straight-forward way than usual.

This is the code worth digging into a bit, which is responsible for copying TIC-80 PCM data into SDL's audio buffer. I have reformatted it a bit to make it more readable. Initially, it was all on one line.

*stream++ =
((u8*)tic->product.samples.buffer[
    tic->product.samples.count *
    TIC80_SAMPLESIZE -
    platform.audio.bufferRemaining--
];

The stream variable here is a chunk of u8 vars.

The TIC80_SAMPLESIZE ends up being 2. This could either mean it's a stereo frame of 8-bit values, or the sample type is 16-bit and the samples are interleaved if it's stereo. Unclear.

The bufferRemaining variable is decreasing by 1 every sample, and being subtracted from the total number of bytes that the buffer has. This feels like a bit of round-a-about way to avoid using a for loop structure.

When the audio buffer counter goes to zero, it fills up the tic80 buffer again using studio_sound. It continues doing this until the SDL buffer is filled.

2024-07-11 09:50: =tic_core_synth_sound= updates tail #tic80-sine-tone

Meanwhile, tic_core_synth_head updates the head. The relevant bits of code look almost identical.

2024-07-11 09:53: Just printed tick start/ends and sound synth #tic80-sine-tone

There's some kind of async stuff happening I think. A tick start always follows up with a tick end, so that's being called on one thread. Meanwhile, the synth sound function can be called a few times in between these ticks. Sometimes once or twice, or not at all.

2024-07-11 09:56: Where are the tick start/end functions being called? #tic80-sine-tone

There's mention of a tic80_tick function, but when I try to printf from it, nothing shows up.

Okay found it! inside gpuTick() from SDL, the call chain is studio_tick, renderStudio, then calls to tic_core_tick_start and tic_core_tick_end, which makes calls to the sound tick starts and ends.

2024-07-11 10:10: That's enough for now #tic80-sine-tone #timelog:01:13:35

It's looking to be an interesting sound problem, because the timing of sound rendering is related to the drawing callback. There's things like jitter and frame-drops. How does it handle that sort of thing? Does the so-called "blip buffer" smooth things out somehow?

2024-07-13 14:09: Let's try to get back to this tic80 things #tic80-sine-tone #timelog:01:34:09

2024-07-13 14:11: I need to better understand the timing relationships #tic80-sine-tone

I'm not entirely convinced that DSP works at a constant samplerate, because I think the timing is controlled from the drawing function.

My belief right now is that the audio callback works by taking any samples produced since the last time it was set, and then stretching it out to fit the required number of samples needed for the host audio callback.

2024-07-13 14:19: "opaqueness" these pointers are very opaque #tic80-sine-tone

I was trying to describe my issue with this codebase. What makes this impenetrable is that is that the use of macros makes it very opaque. I can't simply grep or use ctags to find definitions in the codebase.

Take, for instance, this line (breaks and indentation my own):

2024-07-13 14:36: printf-ing some constants #tic80-sine-tone

The output of blip_read_samples is reading a pretty constant 735 samples at a time, which is the number of samples read at 60fps. So it is actually working at the host audio rate. My timing theory is feeling a little shaky.

TIC80_FRAMERATE is hard coded to be 60, so it's filling the max amount. Based on the READMEs, I think it doesn't have to be? It seems like the blip_buf library is designed to discretize sound chips that don't necessarily have a clock. You just note the times when amplitude changes, and then it turns that into a buffer of audio samples.

2024-07-13 14:53: =blip_end_frame= is weird. #tic80-sine-tone

For starters: there is no blip_start_frame. A call to blip_end_frame implicitly begins a new frame.

When it is called, a constant called END_TIME is used.

The END_TIME is defined as CLOCKRATE / TIC80_FRAMERATE. The framerate I already knows being hardcoded as 60. What is CLOCKRATE, and where is it defined?

CLOCKRATE is defined as (255<<13) in core.h, or 2088960. I do not understand the signifigance of this value. Going to try to look up that number, as well as the hex version of 0x1fe000.

I put it into chatGPT, this is what it said:

2024-07-13 15:18: =ENDTIME=, how is it used in =sound.c=? #tic80-sine-tone

It's used in runPcm, there's some math done so that the loop works on a fixed PCM buffer size of TIC_PCM_SIZE, which is 128, but the incrementor is in these "clock units", not sample units.

2024-07-13 15:27: tic80 audio is weird because =blip-buf= is weird #tic80-sine-tone

2024-07-13 15:34: When/where is =update_amp= called? #tic80-sine-tone

Whenever this is called, a new delta is added to the blip buffer (delta-encoding). This seems to basically mean, "add a sample to the resampling buffer".

called in runPcm. It seems to be writing PCM data (128 samples) to the blip buffer.

called in runEnvelope and runNoise. There's a similar pattern to both of these, where a "period" is determined from some frequency value.

2024-07-13 16:28: Another attempt. #tic80-sine-tone #timelog:02:07:33

2024-07-13 16:29: Let's isolate where those opening blips are coming from #tic80-sine-tone

In the sfx callback, which is in tic_core_sound_tick_start.

2024-07-13 16:38: runPcm isn't doing anything. #tic80-sine-tone

when I comment the call runPcm, sound still works. This must some way to get PCM data in like sample playback stuff.

2024-07-13 16:41: Making test noise now. #tic80-sine-tone

If my intuition is understanding this correctly, I think this program takes in a pile of delta time values, and turns that into a workable buffer? I have two optional things to try. One could be: write new deltas directly to the blip. Another could be, write to the existing PCM channel.

I got noise working! But, It's stereo noise, and I was expecting mono noise. I forgot that stereo synthesize is called twice, once for each channel.

Noise is working! It is being written to the ringbuf PCM data before calls to stereo_synthesize are made. I'm noticing gaps in the noise when I go between the editor and the console.

2024-07-13 17:03: now, to make it a square. #tic80-sine-tone

I get the sense that I might not be handling the data type correctly. It might be short (16-bit) ints, not 8-bit ones. A pitched square will for sure be able to figure it out.

The pitch is wrong. I'm assuming a 44.1 samplerate in the square calculation. But the blip-buf might be targetting another samplerate like 8kHz or something.

2024-07-13 17:18: trying to understand how blip initialization works #tic80-sine-tone

So the function blip_set_rates is definitely being set to be the host sample rate of 44.1kHz, however, the blip buffer gets initialized to be only a 10th of that size, making it effectively ~4khz max. Right? And that's assuming 1 channel. There are 4 channels that need to share that buffer, so that's like 1kHz?

2024-07-13 17:26: Trying the square the blip-buf way. #tic80-sine-tone

Okay. I'm fighting blip-buf itself somehow? I don't want to waste any more time trying to figure that out.

2024-07-13 17:44: Writing a square after blip-buf #tic80-sine-tone

2024-07-13 17:59: Finally found the tic80 struct? #tic80-sine-tone

It wasn't opaque, I just wasn't looking in the top level directory. It's in include/tic80.h. Wow. That's embarassing.

2024-07-13 18:14: What could be causing the crackling? #tic80-sine-tone

My square shouldn't be crackling. I refuse to believe that it's buffer xrun. Sure does sound like it though.

2024-07-13 18:17: zeroing out the buffer should have caused the blip to be off #tic80-sine-tone

This is a clue.

2024-07-13 18:25: Good lord we have a square now. #tic80-sine-tone

I just messed up the logic for filling up interleaved audio.

2024-07-13 18:26: Getting ready to make my damn sine. #tic80-sine-tone

2024-07-13 18:30: sine tone created. it is done now. #tic80-sine-tone

2024-07-14 09:23: A couple notes on blip-buffer size #tic80-sine-tone

This is going to bother me if I don't write it down. I made a mention that having a buffer size of (sr/10) would effectively downsample the buffer by 10x, and this is not true. It's not true because that buffer is not filling in a second of audio, it's 1/60th of a second of audio.

Let it be known that many incorrect assumptions and guesses were made here. I leave them intact for historical reasons.