Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions ports/espressif/common-hal/audiobusio/__init__.c
Original file line number Diff line number Diff line change
Expand Up @@ -39,25 +39,32 @@ static void i2s_fill_buffer(i2s_t *self) {
self->next_buffer_size = 0;
return;
}
while (!self->stopping && output_buffer_size > 0) {
while (output_buffer_size > 0) {
if (self->sample_data == self->sample_end) {
if (self->last_buffer) {
// The final buffer has been fully output; stop now instead of
// fetching another (which for a single-buffer sample would just
// replay it).
self->stopping = true;
break;
}
uint32_t sample_buffer_length;
audioio_get_buffer_result_t get_buffer_result =
audiosample_get_buffer(self->sample, false, 0,
&self->sample_data, &sample_buffer_length);
self->sample_end = self->sample_data + sample_buffer_length;
if (get_buffer_result == GET_BUFFER_ERROR || sample_buffer_length == 0) {
self->stopping = true;
break;
}
if (get_buffer_result == GET_BUFFER_DONE) {
if (self->loop) {
audiosample_reset_buffer(self->sample, false, 0);
} else {
self->stopping = true;
break;
// Output this final buffer before stopping; don't fetch again.
self->last_buffer = true;
}
}
if (get_buffer_result == GET_BUFFER_ERROR || sample_buffer_length == 0) {
self->stopping = true;
break;
}
}
size_t sample_bytecount = self->sample_end - self->sample_data;
// The framecount is the minimum of space left in the output buffer or left in the incoming sample.
Expand Down Expand Up @@ -96,6 +103,11 @@ static void i2s_fill_buffer(i2s_t *self) {
output_buffer += framecount * CIRCUITPY_OUTPUT_SLOTS;
output_buffer_size -= framecount * bytes_per_output_frame;
}
// Sample ended mid-buffer (or was empty/errored): silence the rest so the
// DMA doesn't play stale data.
if (output_buffer_size > 0) {
memset(output_buffer, 0, output_buffer_size);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to duplicate the last valid value? Won't this click instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I discussed this with Claude during the session. In nordic there is a hold value.

> ... question is whether the espressif port should use the hold_value fill rather than zeros. Is that better?

Short answer: no β€” for espressif, zeros are the better fit. The hold_value trick is a nordic-specific necessity, not a general improvement. Here's the reasoning:

Why nordic needs hold_value: nordic's pause (common_hal_audiobusio_i2sout_pause, line 297) just sets a flag — it does not stop the I2S clock. So the DMA keeps running during a pause, and the fill loop has to keep emitting something that freezes the waveform. Replicating the last sample holds the output steady so a pause→resume is seamless (no gap/click mid-waveform). Nordic then reuses that same fill for end-of-sample as a side effect.

Why espressif doesn't: espressif pauses by disabling the channel in hardware (port_i2s_pause β†’ i2s_channel_disable) β€” the clock stops and the line holds on its own. So espressif's tail fill is only ever reached at end of sample, where the goal is genuine silence. There, zero is the right target:

  • Zero returns the output to the true quiescent level. hold_value parks a DC offset on the line until the stream actually stops β€” no tone, but DC through an AC-coupled output/speaker decays with a thump.
  • hold_value doesn't remove the end-of-sample click, it just relocates it: instead of stepping last-sampleβ†’0 at the sample boundary, you step last-sampleβ†’(whatever-on-stop) when the channel finally disables. Same magnitude, arguably worse because of the DC in between.
  • For the zero-length / empty case, there is no "last sample," so a hold approach would emit a stale hold_value from a previous play (nordic guards this with if (buffer != buffer_start), leaving it stale). Zero-fill is unconditionally correct.

The one thing neither does: a true click-free end needs a short fade-out ramp. That's a real enhancement, but it's separate, applies to all ports equally, and I'd keep it out of this bugfix branch.

So I'd leave espressif on zeros. If you ever want click-free endings, the right move is a shared fade-out, not adopting nordic's DC-hold. ...

}
self->next_buffer = NULL;
self->next_buffer_size = 0;
}
Expand Down Expand Up @@ -172,14 +184,15 @@ void port_i2s_play(i2s_t *self, mp_obj_t sample, bool loop) {
self->playing = true;
self->paused = false;
self->stopping = false;
self->last_buffer = false;
// This will be slow but we can't rewind the underlying sample. So, we will
// preload one frame at a time and drop the last sample that can't fit.
// We cap ourselves at the max DMA set to prevent a sample drop if starting
// fresh.
uint32_t starting_frame;
size_t bytes_loaded = 4;
size_t preloaded = 0;
while (bytes_loaded > 0 && preloaded < CIRCUITPY_BUFFER_SIZE * CIRCUITPY_BUFFER_COUNT) {
while (bytes_loaded > 0 && preloaded < I2S_DMA_BUFFER_MAX_SIZE * CIRCUITPY_BUFFER_COUNT) {
self->next_buffer = &starting_frame;
self->next_buffer_size = sizeof(starting_frame);
i2s_fill_buffer(self);
Expand Down Expand Up @@ -209,6 +222,7 @@ void port_i2s_stop(i2s_t *self) {
self->sample = NULL;
self->playing = false;
self->stopping = false;
self->last_buffer = false;
}

void port_i2s_pause(i2s_t *self) {
Expand Down
1 change: 1 addition & 0 deletions ports/espressif/common-hal/audiobusio/__init__.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ typedef struct {
bool paused; // True when the I2S channel is configured but disabled.
bool playing; // True when the I2S channel is configured.
bool stopping;
bool last_buffer; // True once the sample's final buffer has been fetched but not yet fully output.
bool samples_signed;
int8_t bytes_per_sample;
int8_t channel_count;
Expand Down
20 changes: 14 additions & 6 deletions ports/nordic/common-hal/audiobusio/I2SOut.c
Original file line number Diff line number Diff line change
Expand Up @@ -107,23 +107,30 @@ static void i2s_buffer_fill(audiobusio_i2sout_obj_t *self) {

while (!self->paused && !self->stopping && bytesleft) {
if (self->sample_data == self->sample_end) {
if (self->last_buffer) {
// The final buffer has been fully output; stop now instead of
// fetching another (which for a single-buffer sample would just
// replay it).
self->stopping = true;
break;
}
uint32_t sample_buffer_length;
audioio_get_buffer_result_t get_buffer_result =
audiosample_get_buffer(self->sample, false, 0,
&self->sample_data, &sample_buffer_length);
self->sample_end = self->sample_data + sample_buffer_length;
if (get_buffer_result == GET_BUFFER_ERROR || sample_buffer_length == 0) {
self->stopping = true;
break;
}
if (get_buffer_result == GET_BUFFER_DONE) {
if (self->loop) {
audiosample_reset_buffer(self->sample, false, 0);
} else {
self->stopping = true;
break;
// Output this final buffer before stopping; don't fetch again.
self->last_buffer = true;
}
}
if (get_buffer_result == GET_BUFFER_ERROR || sample_buffer_length == 0) {
self->stopping = true;
break;
}
}
uint16_t bytecount = MIN(bytesleft, (size_t)(self->sample_end - self->sample_data));
if (self->samples_signed) {
Expand Down Expand Up @@ -280,6 +287,7 @@ void common_hal_audiobusio_i2sout_play(audiobusio_i2sout_obj_t *self,
self->playing = true;
self->paused = false;
self->stopping = false;
self->last_buffer = false;
i2s_buffer_fill(self);

NRF_I2S->RXTXD.MAXCNT = self->buffer_length / 4;
Expand Down
1 change: 1 addition & 0 deletions ports/nordic/common-hal/audiobusio/I2SOut.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ typedef struct {
bool left_justified : 1;
bool playing : 1;
bool stopping : 1;
bool last_buffer : 1;
bool paused : 1;
bool loop : 1;
bool samples_signed : 1;
Expand Down
10 changes: 10 additions & 0 deletions ports/raspberrypi/audio_dma.c
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ audio_dma_result audio_dma_setup_playback(
uint32_t max_buffer_length;
audiosample_get_buffer_structure(sample, single_channel_output, &single_buffer, &samples_signed,
&max_buffer_length, &dma->sample_spacing);
dma->single_buffer = single_buffer;

// Check to see if we have to scale the resolution up.
if (dma->sample_resolution <= 8 && dma->output_resolution > 8) {
Expand Down Expand Up @@ -487,6 +488,15 @@ bool audio_dma_get_playing(audio_dma_t *dma) {
if (dma->channel[0] == NUM_DMA_CHANNELS) {
return false;
}
// A single-buffer, non-looping sample plays entirely in hardware via DMA
// chaining, with no completion interrupt to stop it. Detect when its DMA
// channel has drained and finish so that playing_in_progress is cleared.
if (dma->single_buffer && !dma->loop &&
dma->playing_in_progress && !dma->paused &&
!dma_channel_is_busy(dma->channel[0])) {
audio_dma_stop(dma);
return false;
}
return dma->playing_in_progress;
}

Expand Down
1 change: 1 addition & 0 deletions ports/raspberrypi/audio_dma.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ typedef struct {
uint8_t sample_resolution; // in bits
audio_dma_result dma_result;
bool loop;
bool single_buffer;
bool single_channel_output;
bool signed_to_unsigned;
bool unsigned_to_signed;
Expand Down
23 changes: 13 additions & 10 deletions ports/zephyr-cp/common-hal/audiobusio/I2SOut.c
Original file line number Diff line number Diff line change
Expand Up @@ -87,27 +87,30 @@ static void fill_buffer(audiobusio_i2sout_obj_t *self, uint8_t *buffer, size_t b
return;
}

// Copy the returned data first, even when this is the final buffer
// (GET_BUFFER_DONE). A single-buffer sample returns its entire buffer
// together with GET_BUFFER_DONE on the first call, so handling DONE
// before the copy would drop the audio and produce silence.
uint32_t bytes_to_copy = sample_buffer_length;
if (bytes_filled + bytes_to_copy > buffer_size) {
bytes_to_copy = buffer_size - bytes_filled;
}

memcpy(buffer + bytes_filled, sample_buffer, bytes_to_copy);
bytes_filled += bytes_to_copy;

if (result == GET_BUFFER_DONE) {
if (self->loop) {
// Reset to beginning
audiosample_reset_buffer(self->sample, false, 0);
} else {
// Done playing, fill rest with silence
// Final buffer copied; stop once this block drains.
self->stopping = true;
i2s_trigger(self->i2s_dev, I2S_DIR_TX, I2S_TRIGGER_DRAIN);
memset(buffer + bytes_filled, 0, buffer_size - bytes_filled);
return;
}
}

// Copy data to buffer
uint32_t bytes_to_copy = sample_buffer_length;
if (bytes_filled + bytes_to_copy > buffer_size) {
bytes_to_copy = buffer_size - bytes_filled;
}

memcpy(buffer + bytes_filled, sample_buffer, bytes_to_copy);
bytes_filled += bytes_to_copy;
}
}

Expand Down
74 changes: 74 additions & 0 deletions ports/zephyr-cp/tests/test_audiobusio.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,77 @@ def test_i2s_pause_resume(circuitpython):
assert "paused" in output
assert "resumed" in output
assert "done" in output


I2S_PLAY_LOOP_FALSE_CODE = """\
import array
import math
import audiocore
import board
import time

# 440 Hz sine, 16-bit signed stereo at 16000 Hz, several periods.
sample_rate = 16000
length = sample_rate // 440
periods = 10
values = []
for _ in range(periods):
for i in range(length):
v = int(math.sin(math.pi * 2 * i / length) * 30000)
values.append(v) # left
values.append(v) # right

# Default single_buffer=True: get_buffer returns the whole buffer together with
# GET_BUFFER_DONE on the very first call. With loop=False that buffer used to be
# discarded before being copied, producing silence (issue #10539).
sample = audiocore.RawSample(
array.array("h", values),
sample_rate=sample_rate,
channel_count=2,
)

dac = board.I2S0()
print("playing")
dac.play(sample, loop=False)
time.sleep(0.5)
# Stop explicitly so this test does not depend on `playing` clearing on its own.
dac.stop()
print("done")
"""


@pytest.mark.duration(10)
@pytest.mark.circuitpy_drive({"code.py": I2S_PLAY_LOOP_FALSE_CODE})
def test_i2s_play_non_looping_single_buffer(circuitpython):
"""A single-buffer RawSample played with loop=False must be heard, not dropped.

Regression test for #10539: the final buffer, returned together with
GET_BUFFER_DONE, was discarded before being copied, so a single-buffer
sample (whole buffer + DONE on the first get_buffer call) produced silence.
"""
circuitpython.wait_until_done()

output = circuitpython.serial.all_output
assert "playing" in output
assert "done" in output

left_trace = parse_i2s_trace(circuitpython.trace_file, "Left")
right_trace = parse_i2s_trace(circuitpython.trace_file, "Right")

# The sine wave must actually reach the I2S output. On the bug every value is
# zero (silence) because the sole buffer was dropped.
left_values = [v for _, v in left_trace if v != 0]
right_values = [v for _, v in right_trace if v != 0]
assert len(left_values) > 5, "Left channel is silent; non-looping sample was dropped"
assert len(right_values) > 5, "Right channel is silent; non-looping sample was dropped"

# A sine wave has both positive and negative excursions at the expected amplitude.
assert max(left_values) > 20000, f"Left max {max(left_values)} too low"
assert min(left_values) < -20000, f"Left min {min(left_values)} too high"

# We wrote the same value to both channels.
left_by_ts = dict(left_trace)
right_by_ts = dict(right_trace)
common_ts = sorted(set(left_by_ts) & set(right_by_ts))
mismatches = sum(1 for ts in common_ts[:100] if left_by_ts[ts] != right_by_ts[ts])
assert mismatches == 0, f"{mismatches} L/R mismatches in first common timestamps"
Loading