diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 30b12070..744b1f54 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -35,7 +35,7 @@ jobs: - name: Build and test env: AMY_TEST_THRESHOLD_DB: "-70.0" - run: make test + run: make ctest && make test web: # Build the emscripten / WASM target (`make web`) so breaks there are caught. diff --git a/Makefile b/Makefile index 1a53dd43..5a2d8537 100644 --- a/Makefile +++ b/Makefile @@ -84,7 +84,7 @@ check-c-api: $(PYTHON) scripts/gen_pcm_presets_js.py --check SOURCES += src/algorithms.c src/amy.c src/envelope.c src/examples.c src/parse.c \ - src/filters.c src/oscillators.c src/pcm.c src/interp_partials.c src/custom.c \ + src/filters.c src/oscillators.c src/pcm.c src/pcm_stream.c src/interp_partials.c src/custom.c \ src/delay.c src/log2_exp2.c src/patches.c src/transfer.c src/sequencer.c \ src/libminiaudio-audio.c src/instrument.c src/amy_midi.c src/api.c src/midi_mappings.c \ src/cv_trigger.c @@ -127,7 +127,7 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ - tests/test_voice_osc_range + tests/test_voice_osc_range tests/test_pcm_stream # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). diff --git a/daisy/Makefile b/daisy/Makefile index 29ac58c4..40de6602 100644 --- a/daisy/Makefile +++ b/daisy/Makefile @@ -7,7 +7,7 @@ OPT = -O3 # Sources CPP_SOURCES = amy_daisy.cpp -C_SOURCES = ${AMY}/amy.c ${AMY}/oscillators.c ${AMY}/algorithms.c ${AMY}/envelope.c ${AMY}/examples.c ${AMY}/filters.c ${AMY}/pcm.c ${AMY}/custom.c ${AMY}/delay.c ${AMY}/log2_exp2.c ${AMY}/patches.c ${AMY}/api.c ${AMY}/sequencer.c ${AMY}/amy_midi.c ${AMY}/instrument.c ${AMY}/parse.c ${AMY}/transfer.c ${AMY}/interp_partials.c ${AMY}/midi_mappings.c +C_SOURCES = ${AMY}/amy.c ${AMY}/oscillators.c ${AMY}/algorithms.c ${AMY}/envelope.c ${AMY}/examples.c ${AMY}/filters.c ${AMY}/pcm.c ${AMY}/pcm_stream.c ${AMY}/custom.c ${AMY}/delay.c ${AMY}/log2_exp2.c ${AMY}/patches.c ${AMY}/api.c ${AMY}/sequencer.c ${AMY}/amy_midi.c ${AMY}/instrument.c ${AMY}/parse.c ${AMY}/transfer.c ${AMY}/interp_partials.c ${AMY}/midi_mappings.c C_INCLUDES += -I${AMY} -DAMY_DAISY -Wno-strict-aliasing -Wextra -Wno-unused-parameter -Wpointer-arith -Wno-float-conversion -Wno-missing-declarations diff --git a/godot/SConstruct b/godot/SConstruct index 455dd598..6be7430d 100644 --- a/godot/SConstruct +++ b/godot/SConstruct @@ -50,6 +50,7 @@ amy_sources = [ "parse.c", "patches.c", "pcm.c", + "pcm_stream.c", "sequencer.c", "transfer.c", ] diff --git a/setup.py b/setup.py index d9ea96ac..4c293abe 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ import subprocess import sys # the c++ extension module -sources = ['algorithms.c', 'amy.c', 'delay.c', 'envelope.c', 'filters.c', 'parse.c', 'sequencer.c', 'transfer.c', 'midi_mappings.c', 'custom.c', 'patches.c', 'libminiaudio-audio.c', 'oscillators.c', 'interp_partials.c', 'pcm.c', 'pyamy.c', 'log2_exp2.c', 'instrument.c', 'amy_midi.c', 'api.c', 'cv_trigger.c'] +sources = ['algorithms.c', 'amy.c', 'delay.c', 'envelope.c', 'filters.c', 'parse.c', 'sequencer.c', 'transfer.c', 'midi_mappings.c', 'custom.c', 'patches.c', 'libminiaudio-audio.c', 'oscillators.c', 'interp_partials.c', 'pcm.c', 'pcm_stream.c', 'pyamy.c', 'log2_exp2.c', 'instrument.c', 'amy_midi.c', 'api.c', 'cv_trigger.c'] for i in range(len(sources)): sources[i] = "src/"+sources[i] diff --git a/src/pcm.c b/src/pcm.c index 552085b7..4de9df0e 100644 --- a/src/pcm.c +++ b/src/pcm.c @@ -2,6 +2,7 @@ #include "amy.h" #include "transfer.h" +#include "pcm_stream_internal.h" #ifdef __EMSCRIPTEN__ #include "emscripten.h" @@ -124,6 +125,10 @@ memorypcm_preset_t * get_preset_for_preset_number(uint16_t preset_number, } const int16_t *pcm_get_sample_ram_for_preset(uint16_t preset_number, uint32_t *length) { + if (pcm_stream_preset_registered(preset_number)) { + if (length != NULL) *length = 0; + return NULL; + } memorypcm_preset_t rom_local; memorypcm_preset_t *preset = get_preset_for_preset_number(preset_number, &rom_local); if (length != NULL) { @@ -208,6 +213,11 @@ bool pcm_loop_config_allowed(uint16_t osc, uint16_t mode, uint16_t preset_number // mode means nothing outside PCM, so don't second-guess other waves. if (synth[osc]->wave != PCM) return true; if (!mode_is_looping(mode)) return true; + if (pcm_stream_preset_registered(preset_number)) { + fprintf(stderr, "amy: appendable PCM stream preset %d cannot use PCM_LOOP modes; " + "queue loop data again from the producer instead\n", preset_number); + return false; + } const char *filename = NULL; if (!preset_is_file(preset_number, &filename)) return true; if (mode_is_the_new_part) { @@ -546,6 +556,7 @@ static SAMPLE render_pcm_stretch(SAMPLE *buf, uint16_t osc, memorypcm_preset_t * } void pcm_note_on(uint16_t osc) { + if (pcm_stream_note_on(osc)) return; if(AMY_IS_SET(synth[osc]->preset)) { memorypcm_preset_t rom_local; memorypcm_preset_t *preset = @@ -620,6 +631,7 @@ void pcm_mod_trigger(uint16_t osc) { void pcm_note_off(uint16_t osc) { + if (pcm_stream_note_off(osc)) return; if(AMY_IS_SET(synth[osc]->preset)) { if (msynth[osc]->state == PCM_PLAY_STOP || msynth[osc]->state == PCM_LOOP_STOP) { @@ -669,6 +681,8 @@ uint32_t fill_sample_from_file(memorypcm_preset_t *preset_p, uint32_t frames_nee } SAMPLE render_pcm(SAMPLE* buf, uint16_t osc) { + SAMPLE stream_max = 0; + if (pcm_stream_render(buf, osc, &stream_max)) return stream_max; if(AMY_IS_SET(synth[osc]->preset)) { SAMPLE max_value = 0; memorypcm_preset_t rom_local; @@ -746,7 +760,7 @@ SAMPLE render_pcm(SAMPLE* buf, uint16_t osc) { msynth[osc]->loopstart = preset->loopstart; msynth[osc]->loopend = preset->loopend; } - //fprintf(stderr, "time %.3f sample %d LOOP: old_index %d new_index %d phase 0x%lx\n", amy_global.time, i, base_index, base_index_base, phase); + //fprintf(stderr, "time %.3f sample %d LOOP: old_index %d new_index %d phase 0x%lx\n", amy_global.time, i, base_index, sample_length, base_index_base, phase); base_index = base_index_base; } else if(base_index >= sample_length) { // end synth[osc]->status = SYNTH_OFF;// is this right? @@ -908,6 +922,7 @@ int16_t * pcm_load(uint16_t preset_number, uint32_t length, uint32_t samplerate, } void pcm_unload_preset(uint16_t preset_number) { + pcm_stream_unregister_preset(preset_number); // run through the LL looking for the preset memorypcm_ll_t **preset_pointer = &memorypcm_ll_start; while(*preset_pointer != NULL) { @@ -927,6 +942,7 @@ void pcm_unload_preset(uint16_t preset_number) { } void pcm_unload_all_presets() { + pcm_stream_unregister_all(); memorypcm_ll_t *preset_pointer = memorypcm_ll_start; while(preset_pointer != NULL) { memorypcm_ll_t *next_pointer = preset_pointer->next; diff --git a/src/pcm_stream.c b/src/pcm_stream.c new file mode 100644 index 00000000..4ada5a6c --- /dev/null +++ b/src/pcm_stream.c @@ -0,0 +1,349 @@ +// Appendable zero-copy PCM source for callers that fill buffers asynchronously +// (for example, an SD-card task). Existing ROM, memory and file PCM paths stay +// in pcm.c; this module is selected only for explicitly registered presets. + +#include "amy.h" +#include "pcm_stream.h" +#include "pcm_stream_internal.h" + +#if defined(__GNUC__) || defined(__clang__) +#define PCM_STREAM_LOAD32(p) __atomic_load_n((p), __ATOMIC_ACQUIRE) +#define PCM_STREAM_STORE32(p, v) __atomic_store_n((p), (v), __ATOMIC_RELEASE) +#else +// AMY's concurrent embedded targets are GCC/Clang. Keep other host compilers +// buildable; their tests use one thread, so volatile accesses are sufficient. +#define PCM_STREAM_LOAD32(p) (*(p)) +#define PCM_STREAM_STORE32(p, v) (*(p) = (v)) +#endif + +static amy_pcm_stream_t *pcm_streams = NULL; + +static amy_pcm_stream_t *stream_for_preset(uint16_t preset_number) { + amy_pcm_stream_t *s = pcm_streams; + while (s != NULL) { + if (s->registered && s->preset_number == preset_number) return s; + s = s->next; + } + return NULL; +} + +static amy_pcm_stream_t *stream_for_osc(uint16_t osc) { + if (osc >= AMY_OSCS || synth[osc] == NULL || AMY_IS_UNSET(synth[osc]->preset)) return NULL; + return stream_for_preset(synth[osc]->preset); +} + +static void cancel_pending(amy_pcm_stream_t *stream) { + if (stream == NULL || stream->chunks == NULL) return; + // Prevent new producer submissions before changing ownership states. + PCM_STREAM_STORE32(&stream->finished, 1); + for (uint32_t i = 0; i < stream->capacity; ++i) { + uint32_t state = PCM_STREAM_LOAD32(&stream->chunks[i].state); + if (state == AMY_PCM_STREAM_CHUNK_READY || state == AMY_PCM_STREAM_CHUNK_PLAYING) + PCM_STREAM_STORE32(&stream->chunks[i].state, AMY_PCM_STREAM_CHUNK_CANCELLED); + } +} + +void amy_pcm_stream_init(amy_pcm_stream_t *stream, + amy_pcm_stream_chunk_t *chunks, + uint32_t capacity) { + if (stream == NULL) return; + memset(stream, 0, sizeof(*stream)); + stream->chunks = chunks; + stream->capacity = capacity; + stream->active_osc = AMY_PCM_STREAM_NO_OSC; + if (chunks != NULL) { + for (uint32_t i = 0; i < capacity; ++i) { + memset(&chunks[i], 0, sizeof(chunks[i])); + chunks[i].state = AMY_PCM_STREAM_CHUNK_FREE; + } + } +} + +int amy_pcm_stream_reset(amy_pcm_stream_t *stream) { + if (stream == NULL || stream->chunks == NULL || stream->capacity == 0) return -1; + if (PCM_STREAM_LOAD32(&stream->active_osc) != AMY_PCM_STREAM_NO_OSC) return -1; + for (uint32_t i = 0; i < stream->capacity; ++i) { + stream->chunks[i].samples = NULL; + stream->chunks[i].frames = 0; + stream->chunks[i].sequence = 0; + PCM_STREAM_STORE32(&stream->chunks[i].state, AMY_PCM_STREAM_CHUNK_FREE); + } + PCM_STREAM_STORE32(&stream->write_index, 0); + PCM_STREAM_STORE32(&stream->read_index, 0); + PCM_STREAM_STORE32(&stream->chunks_submitted, 0); + PCM_STREAM_STORE32(&stream->chunks_played, 0); + PCM_STREAM_STORE32(&stream->underruns, 0); + PCM_STREAM_STORE32(&stream->finished, 0); + stream->phase_q16 = 0; + return 0; +} + +int amy_pcm_stream_append(amy_pcm_stream_t *stream, + const int16_t *samples, + uint32_t frames) { + if (stream == NULL || stream->chunks == NULL || stream->capacity == 0 + || samples == NULL || frames == 0) return -1; + if (PCM_STREAM_LOAD32(&stream->finished)) return -3; + + uint32_t slot = PCM_STREAM_LOAD32(&stream->write_index) % stream->capacity; + amy_pcm_stream_chunk_t *chunk = &stream->chunks[slot]; + uint32_t state = PCM_STREAM_LOAD32(&chunk->state); + if (state == AMY_PCM_STREAM_CHUNK_READY || state == AMY_PCM_STREAM_CHUNK_PLAYING) + return -2; + + uint32_t sequence = PCM_STREAM_LOAD32(&stream->chunks_submitted) + 1; + chunk->samples = samples; + chunk->frames = frames; + chunk->sequence = sequence; + // Publish READY last: the acquire load in the renderer then sees pointer, + // frame count and sequence as one complete descriptor. + PCM_STREAM_STORE32(&chunk->state, AMY_PCM_STREAM_CHUNK_READY); + PCM_STREAM_STORE32(&stream->chunks_submitted, sequence); + PCM_STREAM_STORE32(&stream->write_index, (slot + 1) % stream->capacity); + return (int)slot; +} + +void amy_pcm_stream_finish(amy_pcm_stream_t *stream) { + if (stream != NULL) PCM_STREAM_STORE32(&stream->finished, 1); +} + +uint32_t amy_pcm_stream_chunk_state(const amy_pcm_stream_t *stream, + uint32_t slot) { + if (stream == NULL || stream->chunks == NULL || slot >= stream->capacity) + return AMY_PCM_STREAM_CHUNK_FREE; + return PCM_STREAM_LOAD32(&stream->chunks[slot].state); +} + +bool pcm_stream_preset_registered(uint16_t preset_number) { + return stream_for_preset(preset_number) != NULL; +} + +void pcm_stream_unregister_preset(uint16_t preset_number) { + amy_pcm_stream_t **p = &pcm_streams; + while (*p != NULL) { + amy_pcm_stream_t *s = *p; + if (s->registered && s->preset_number == preset_number) { + *p = s->next; + cancel_pending(s); + PCM_STREAM_STORE32(&s->active_osc, AMY_PCM_STREAM_NO_OSC); + s->registered = 0; + s->next = NULL; + return; + } + p = &s->next; + } +} + +void pcm_stream_unregister_all(void) { + amy_pcm_stream_t *s = pcm_streams; + pcm_streams = NULL; + while (s != NULL) { + amy_pcm_stream_t *next = s->next; + cancel_pending(s); + PCM_STREAM_STORE32(&s->active_osc, AMY_PCM_STREAM_NO_OSC); + s->registered = 0; + s->next = NULL; + s = next; + } +} + +int pcm_load_stream(uint16_t preset_number, + amy_pcm_stream_t *stream, + uint32_t samplerate, + uint8_t channels, + float midinote) { + if (stream == NULL || stream->chunks == NULL || stream->capacity == 0 + || samplerate == 0 || (channels != 1 && channels != 2)) return -1; + + // Preserve normal preset shadowing semantics: loading a stream replaces + // an existing memory/file/stream preset with the same number. + pcm_unload_preset(preset_number); + if (stream->registered) pcm_stream_unregister_preset(stream->preset_number); + + stream->preset_number = preset_number; + stream->samplerate = samplerate; + stream->channels = channels; + stream->midinote = midinote; + stream->registered = 1; + stream->next = pcm_streams; + pcm_streams = stream; + return 0; +} + +static inline LUTSAMPLE stream_read_frame(const amy_pcm_stream_t *stream, + const amy_pcm_stream_chunk_t *chunk, + uint16_t wave, + uint32_t frame) { + if (stream->channels == 2) { + uint32_t off = frame * 2; + if (wave == PCM_LEFT) return chunk->samples[off]; + if (wave == PCM_RIGHT) return chunk->samples[off + 1]; + return (LUTSAMPLE)(((int32_t)chunk->samples[off] + + (int32_t)chunk->samples[off + 1]) / 2); + } + return chunk->samples[frame]; +} + +static void mark_current_done(amy_pcm_stream_t *stream, + amy_pcm_stream_chunk_t *chunk, + uint32_t slot) { + PCM_STREAM_STORE32(&chunk->state, AMY_PCM_STREAM_CHUNK_DONE); + PCM_STREAM_STORE32(&stream->chunks_played, + PCM_STREAM_LOAD32(&stream->chunks_played) + 1); + PCM_STREAM_STORE32(&stream->read_index, (slot + 1) % stream->capacity); +} + +// Resolve the descriptor containing stream->phase_q16. Crossing a descriptor +// publishes DONE before advancing, so the producer can immediately recycle a +// ping-pong buffer. The residual phase is carried into the next descriptor, +// which also makes pitch ratios >1 skip frames correctly across boundaries. +static amy_pcm_stream_chunk_t *current_chunk(amy_pcm_stream_t *stream) { + for (uint32_t n = 0; n <= stream->capacity; ++n) { + uint32_t slot = PCM_STREAM_LOAD32(&stream->read_index) % stream->capacity; + amy_pcm_stream_chunk_t *chunk = &stream->chunks[slot]; + uint32_t state = PCM_STREAM_LOAD32(&chunk->state); + if (state == AMY_PCM_STREAM_CHUNK_READY) { + PCM_STREAM_STORE32(&chunk->state, AMY_PCM_STREAM_CHUNK_PLAYING); + state = AMY_PCM_STREAM_CHUNK_PLAYING; + } + if (state != AMY_PCM_STREAM_CHUNK_PLAYING) return NULL; + + uint64_t end_q16 = ((uint64_t)chunk->frames) << 16; + if (stream->phase_q16 < end_q16) return chunk; + + stream->phase_q16 -= end_q16; + mark_current_done(stream, chunk, slot); + } + return NULL; +} + +static LUTSAMPLE next_frame_or_hold(amy_pcm_stream_t *stream, + amy_pcm_stream_chunk_t *chunk, + uint16_t wave, + uint32_t base_frame, + LUTSAMPLE hold) { + if (base_frame + 1 < chunk->frames) + return stream_read_frame(stream, chunk, wave, base_frame + 1); + + uint32_t slot = PCM_STREAM_LOAD32(&stream->read_index) % stream->capacity; + uint32_t next_slot = (slot + 1) % stream->capacity; + amy_pcm_stream_chunk_t *next = &stream->chunks[next_slot]; + if (PCM_STREAM_LOAD32(&next->state) == AMY_PCM_STREAM_CHUNK_READY + && next->frames > 0 && next->samples != NULL) + return stream_read_frame(stream, next, wave, 0); + return hold; +} + +static void stop_stream_osc(amy_pcm_stream_t *stream, uint16_t osc, bool cancel) { + if (cancel) cancel_pending(stream); + synth[osc]->status = SYNTH_OFF; + PCM_STREAM_STORE32(&stream->active_osc, AMY_PCM_STREAM_NO_OSC); +} + +bool pcm_stream_note_on(uint16_t osc) { + amy_pcm_stream_t *stream = stream_for_osc(osc); + if (stream == NULL) return false; + + uint32_t active = PCM_STREAM_LOAD32(&stream->active_osc); + if (active != AMY_PCM_STREAM_NO_OSC && active != osc) { + fprintf(stderr, "amy: PCM stream preset %u is already consumed by osc %u; " + "use a separate stream/preset for polyphony\n", + stream->preset_number, (unsigned)active); + synth[osc]->status = SYNTH_OFF; + return true; + } + + PCM_STREAM_STORE32(&stream->active_osc, osc); + stream->phase_q16 = 0; + synth[osc]->phase = 0; + synth[osc]->stretch.active = 0; // sequential sources cannot random-access grains + msynth[osc]->loopstart = 0; + msynth[osc]->loopend = 0; + msynth[osc]->state = synth[osc]->mode; + msynth[osc]->pcm_delay = 0; + if (AMY_IS_SET(synth[osc]->sample_offset)) + msynth[osc]->pcm_delay = synth[osc]->sample_offset % AMY_BLOCK_SIZE; + synth[osc]->terminate_on_silence = 0; + + if (AMY_IS_SET(synth[osc]->fit_ticks)) + fprintf(stderr, "amy: fit is not supported on appendable PCM streams; ignoring fit\n"); + if (AMY_IS_SET(synth[osc]->trigger_phase) && synth[osc]->trigger_phase != 0) + fprintf(stderr, "amy: trigger_phase is not supported on appendable PCM streams; starting at 0\n"); + return true; +} + +bool pcm_stream_note_off(uint16_t osc) { + amy_pcm_stream_t *stream = stream_for_osc(osc); + if (stream == NULL) return false; + + if (msynth[osc]->state == PCM_PLAY_STOP || msynth[osc]->state == PCM_LOOP_STOP) { + stop_stream_osc(stream, osc, true); + } else if (msynth[osc]->state == PCM_LOOP_FOREVER) { + msynth[osc]->state = PCM_LOOP; + synth[osc]->terminate_on_silence = 1; + } else if (msynth[osc]->state == PCM_LOOP || msynth[osc]->state == PCM_PLAY) { + msynth[osc]->state = PCM_PLAY_STOP; + } + return true; +} + +bool pcm_stream_render(SAMPLE *buf, uint16_t osc, SAMPLE *max_value_out) { + amy_pcm_stream_t *stream = stream_for_osc(osc); + if (stream == NULL) return false; + + SAMPLE max_value = 0; + if (max_value_out != NULL) *max_value_out = 0; + if (PCM_STREAM_LOAD32(&stream->active_osc) != osc) return true; + + float logfreq = msynth[osc]->logfreq; + if (AMY_IS_SET(synth[osc]->midi_note)) + logfreq -= logfreq_for_midi_note(stream->midinote); + float log2sr = log2f((float)stream->samplerate / ZERO_LOGFREQ_IN_HZ); + float playback_freq = freq_of_logfreq(log2sr + logfreq); + if (logfreq == 0 && AMY_IS_UNSET(synth[osc]->midi_note)) + playback_freq = (float)stream->samplerate; + uint32_t step_q16 = (uint32_t)((playback_freq / (float)AMY_SAMPLE_RATE) * 65536.0f); + if (step_q16 == 0) step_q16 = 1; + + SAMPLE amp = F2S(msynth[osc]->amp); + uint16_t i = 0; + if (msynth[osc]->pcm_delay) { + i = msynth[osc]->pcm_delay; + msynth[osc]->pcm_delay = 0; + } + bool starved = false; + for (; i < AMY_BLOCK_SIZE; ++i) { + amy_pcm_stream_chunk_t *chunk = current_chunk(stream); + if (chunk == NULL) { + if (PCM_STREAM_LOAD32(&stream->finished)) + stop_stream_osc(stream, osc, false); + else + starved = true; + break; + } + + uint32_t base_frame = (uint32_t)(stream->phase_q16 >> 16); + uint32_t frac_q16 = (uint32_t)(stream->phase_q16 & 0xffffu); + LUTSAMPLE b = stream_read_frame(stream, chunk, synth[osc]->wave, base_frame); + LUTSAMPLE c = next_frame_or_hold(stream, chunk, synth[osc]->wave, base_frame, b); + SAMPLE frac = (SAMPLE)frac_q16 << (S_FRAC_BITS - 16); + SAMPLE sample = L2S(b) + MUL4_SS(L2S(c - b), frac); + SAMPLE value = buf[i] + MUL4_SS(amp, sample); + buf[i] = value; + if (value < 0) value = -value; + if (value > max_value) max_value = value; + stream->phase_q16 += step_q16; + } + + // Publish a chunk that ended exactly on the final output frame immediately; + // otherwise a ping-pong producer would wait an unnecessary whole block. + amy_pcm_stream_chunk_t *after = current_chunk(stream); + if (after == NULL && PCM_STREAM_LOAD32(&stream->finished)) + stop_stream_osc(stream, osc, false); + if (starved) + PCM_STREAM_STORE32(&stream->underruns, + PCM_STREAM_LOAD32(&stream->underruns) + 1); + + if (max_value_out != NULL) *max_value_out = max_value; + return true; +} diff --git a/src/pcm_stream.h b/src/pcm_stream.h new file mode 100644 index 00000000..79964656 --- /dev/null +++ b/src/pcm_stream.h @@ -0,0 +1,105 @@ +#ifndef __AMY_PCM_STREAM_H +#define __AMY_PCM_STREAM_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// A PCM chunk stream is a caller-owned, single-producer/single-consumer ring. +// The producer (for example an SD-card task) supplies already-filled memory +// regions. AMY consumes them in order without allocating or copying them. +// +// Chunk state is the ownership contract for ping-pong buffers: +// FREE descriptor has never been queued / was reset +// READY producer published it; AMY may read the sample memory +// PLAYING AMY is currently reading the sample memory +// DONE AMY has passed the end; sample memory is safe to overwrite +// CANCELLED playback stopped before this chunk was consumed; also safe +// to overwrite +// +// The producer must not modify samples while a descriptor is READY or +// PLAYING. amy_pcm_stream_append() only reuses FREE, DONE or CANCELLED slots. +#define AMY_PCM_STREAM_CHUNK_FREE 0u +#define AMY_PCM_STREAM_CHUNK_READY 1u +#define AMY_PCM_STREAM_CHUNK_PLAYING 2u +#define AMY_PCM_STREAM_CHUNK_DONE 3u +#define AMY_PCM_STREAM_CHUNK_CANCELLED 4u + +#define AMY_PCM_STREAM_NO_OSC UINT32_MAX + +typedef struct { + const int16_t *samples; // interleaved when channels == 2 + uint32_t frames; // frames, not int16 sample count + volatile uint32_t state; // AMY_PCM_STREAM_CHUNK_* + uint32_t sequence; // monotonically increasing submission id +} amy_pcm_stream_chunk_t; + +typedef struct amy_pcm_stream_t { + amy_pcm_stream_chunk_t *chunks; + uint32_t capacity; + + // Producer / consumer progress. These are deliberately visible so an + // embedded host can inspect progress without a callback or allocation. + volatile uint32_t write_index; + volatile uint32_t read_index; + volatile uint32_t chunks_submitted; + volatile uint32_t chunks_played; + volatile uint32_t underruns; + volatile uint32_t finished; + volatile uint32_t active_osc; + + // Playback metadata for the preset registered with pcm_load_stream(). + uint16_t preset_number; + uint8_t channels; + uint8_t registered; + uint32_t samplerate; + float midinote; + + // Consumer-only state. Hosts should treat these fields as opaque. + uint64_t phase_q16; + struct amy_pcm_stream_t *next; +} amy_pcm_stream_t; + +// Initialize caller-owned stream + descriptor storage. No heap allocation. +// capacity may be 1, though >=2 is useful for ping-pong streaming. +void amy_pcm_stream_init(amy_pcm_stream_t *stream, + amy_pcm_stream_chunk_t *chunks, + uint32_t capacity); + +// Reset descriptors and counters for a new playback. Returns -1 if an +// oscillator is still actively consuming this stream. +int amy_pcm_stream_reset(amy_pcm_stream_t *stream); + +// Append one memory region. Returns the descriptor slot index, or: +// -1 invalid arguments +// -2 ring full (next descriptor is still READY/PLAYING) +// -3 stream has been marked finished; reset before appending again +int amy_pcm_stream_append(amy_pcm_stream_t *stream, + const int16_t *samples, + uint32_t frames); + +// Tell the consumer that no chunks will follow. Playback stops after the +// queued data drains. Without finish(), an empty ring is an underrun/wait: +// the oscillator remains alive so the producer can append later. +void amy_pcm_stream_finish(amy_pcm_stream_t *stream); + +// Acquire-safe state read for producer code. Invalid slot -> FREE. +uint32_t amy_pcm_stream_chunk_state(const amy_pcm_stream_t *stream, + uint32_t slot); + +// Register this stream as a PCM preset. The stream memory remains caller-owned. +// This shadows a ROM/memory/file preset with the same number until unloaded. +// Returns 0 on success, -1 for invalid arguments. +int pcm_load_stream(uint16_t preset_number, + amy_pcm_stream_t *stream, + uint32_t samplerate, + uint8_t channels, + float midinote); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/pcm_stream_internal.h b/src/pcm_stream_internal.h new file mode 100644 index 00000000..84188ff6 --- /dev/null +++ b/src/pcm_stream_internal.h @@ -0,0 +1,17 @@ +#ifndef __AMY_PCM_STREAM_INTERNAL_H +#define __AMY_PCM_STREAM_INTERNAL_H + +#include "amy.h" +#include "pcm_stream.h" + +// Internal hooks used only by pcm.c. They return true when the oscillator's +// preset is a registered chunk stream and the normal PCM path must not run. +bool pcm_stream_note_on(uint16_t osc); +bool pcm_stream_note_off(uint16_t osc); +bool pcm_stream_render(SAMPLE *buf, uint16_t osc, SAMPLE *max_value); + +bool pcm_stream_preset_registered(uint16_t preset_number); +void pcm_stream_unregister_preset(uint16_t preset_number); +void pcm_stream_unregister_all(void); + +#endif diff --git a/tests/test_pcm_stream.c b/tests/test_pcm_stream.c new file mode 100644 index 00000000..94787f84 --- /dev/null +++ b/tests/test_pcm_stream.c @@ -0,0 +1,219 @@ +// Regression tests for appendable zero-copy PCM streams. +// Build/run with `make tests/test_pcm_stream` or `make ctest`. + +#include +#include +#include + +#include "amy.h" +#include "pcm_stream.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +#define STREAM_PRESET_A 60000 +#define STREAM_PRESET_B 60001 +#define STREAM_PRESET_C 60002 +#define MEMORY_PRESET 60003 + +static int16_t a[AMY_BLOCK_SIZE]; +static int16_t b[AMY_BLOCK_SIZE]; +static int16_t c[AMY_BLOCK_SIZE]; +static int16_t short_chunk[AMY_BLOCK_SIZE / 2]; + +void delay_ms(uint32_t ms) { (void)ms; } + +static void fill_buffers(void) { + for (uint32_t i = 0; i < AMY_BLOCK_SIZE; ++i) { + a[i] = 1000; + b[i] = 2000; + c[i] = 3000; + } + for (uint32_t i = 0; i < AMY_BLOCK_SIZE / 2; ++i) + short_chunk[i] = 4000; +} + +static void render_blocks(int n) { + for (int i = 0; i < n; ++i) amy_simple_fill_buffer(); +} + +static void start_raw_pcm(uint16_t osc, uint16_t preset) { + char msg[80]; + snprintf(msg, sizeof(msg), "v%uw%dp%ul1Z", osc, PCM, preset); + amy_add_message(msg); + render_blocks(1); +} + +static void stop_raw_pcm(uint16_t osc) { + char msg[32]; + snprintf(msg, sizeof(msg), "v%ul0Z", osc); + amy_add_message(msg); + render_blocks(1); +} + +static void test_ring_ownership(void) { + printf("descriptor ring ownership\n"); + amy_pcm_stream_t stream; + amy_pcm_stream_chunk_t chunks[2]; + amy_pcm_stream_init(&stream, chunks, 2); + + CHECK(amy_pcm_stream_chunk_state(&stream, 0) == AMY_PCM_STREAM_CHUNK_FREE, + "slot 0 starts FREE"); + CHECK(amy_pcm_stream_chunk_state(&stream, 1) == AMY_PCM_STREAM_CHUNK_FREE, + "slot 1 starts FREE"); + CHECK(amy_pcm_stream_append(&stream, a, AMY_BLOCK_SIZE) == 0, + "first append publishes slot 0"); + CHECK(amy_pcm_stream_append(&stream, b, AMY_BLOCK_SIZE) == 1, + "second append publishes slot 1"); + CHECK(amy_pcm_stream_append(&stream, c, AMY_BLOCK_SIZE) == -2, + "third append refuses to overwrite READY data"); + CHECK(chunks[0].sequence == 1 && chunks[1].sequence == 2, + "submission sequence is monotonic"); + amy_pcm_stream_finish(&stream); + CHECK(amy_pcm_stream_append(&stream, c, AMY_BLOCK_SIZE) == -3, + "append after finish requires reset"); +} + +static void test_ping_pong_reuse(void) { + printf("played chunks become reusable while playback continues\n"); + amy_pcm_stream_t stream; + amy_pcm_stream_chunk_t chunks[2]; + amy_pcm_stream_init(&stream, chunks, 2); + CHECK(pcm_load_stream(STREAM_PRESET_A, &stream, AMY_SAMPLE_RATE, 1, 60.0f) == 0, + "stream preset registers"); + CHECK(amy_pcm_stream_append(&stream, a, AMY_BLOCK_SIZE) == 0, + "queue buffer A"); + CHECK(amy_pcm_stream_append(&stream, b, AMY_BLOCK_SIZE) == 1, + "queue buffer B"); + + start_raw_pcm(0, STREAM_PRESET_A); + for (int i = 0; i < 4 && amy_pcm_stream_chunk_state(&stream, 0) != AMY_PCM_STREAM_CHUNK_DONE; ++i) + render_blocks(1); + + CHECK(amy_pcm_stream_chunk_state(&stream, 0) == AMY_PCM_STREAM_CHUNK_DONE, + "buffer A is DONE after AMY passes it"); + CHECK(amy_pcm_stream_chunk_state(&stream, 1) == AMY_PCM_STREAM_CHUNK_READY + || amy_pcm_stream_chunk_state(&stream, 1) == AMY_PCM_STREAM_CHUNK_PLAYING, + "buffer B is still owned by AMY"); + CHECK(amy_pcm_stream_append(&stream, c, AMY_BLOCK_SIZE) == 0, + "producer can immediately refill/requeue buffer A's slot"); + CHECK(chunks[0].sequence == 3, + "reused slot receives the next sequence number"); + + amy_pcm_stream_finish(&stream); + for (int i = 0; i < 8 && stream.active_osc != AMY_PCM_STREAM_NO_OSC; ++i) + render_blocks(1); + CHECK(stream.active_osc == AMY_PCM_STREAM_NO_OSC, + "finished stream stops after all queued chunks drain"); + CHECK(stream.chunks_played == 3, + "all three chunks were consumed exactly once"); + pcm_unload_preset(STREAM_PRESET_A); +} + +static void test_underrun_resume(void) { + printf("an unfinished empty queue waits and resumes\n"); + amy_pcm_stream_t stream; + amy_pcm_stream_chunk_t chunks[2]; + amy_pcm_stream_init(&stream, chunks, 2); + CHECK(pcm_load_stream(STREAM_PRESET_B, &stream, AMY_SAMPLE_RATE, 1, 60.0f) == 0, + "stream preset registers"); + CHECK(amy_pcm_stream_append(&stream, short_chunk, AMY_BLOCK_SIZE / 2) == 0, + "queue first short chunk"); + + start_raw_pcm(1, STREAM_PRESET_B); + render_blocks(2); + CHECK(amy_pcm_stream_chunk_state(&stream, 0) == AMY_PCM_STREAM_CHUNK_DONE, + "first short chunk is released"); + CHECK(stream.underruns > 0, + "empty unfinished stream records an underrun"); + CHECK(stream.active_osc == 1, + "underrun does not kill the oscillator"); + + CHECK(amy_pcm_stream_append(&stream, b, AMY_BLOCK_SIZE) == 1, + "new data may be appended after the underrun"); + amy_pcm_stream_finish(&stream); + for (int i = 0; i < 8 && stream.active_osc != AMY_PCM_STREAM_NO_OSC; ++i) + render_blocks(1); + CHECK(amy_pcm_stream_chunk_state(&stream, 1) == AMY_PCM_STREAM_CHUNK_DONE, + "appended data is consumed after resume"); + CHECK(stream.active_osc == AMY_PCM_STREAM_NO_OSC, + "finished resumed stream stops normally"); + pcm_unload_preset(STREAM_PRESET_B); +} + +static void test_note_off_releases_buffers(void) { + printf("note-off cancels buffers that AMY still owns\n"); + amy_pcm_stream_t stream; + amy_pcm_stream_chunk_t chunks[2]; + amy_pcm_stream_init(&stream, chunks, 2); + CHECK(pcm_load_stream(STREAM_PRESET_C, &stream, AMY_SAMPLE_RATE, 1, 60.0f) == 0, + "stream preset registers"); + CHECK(amy_pcm_stream_append(&stream, a, AMY_BLOCK_SIZE) == 0, + "queue buffer A"); + CHECK(amy_pcm_stream_append(&stream, b, AMY_BLOCK_SIZE) == 1, + "queue buffer B"); + + start_raw_pcm(2, STREAM_PRESET_C); + stop_raw_pcm(2); + uint32_t s0 = amy_pcm_stream_chunk_state(&stream, 0); + uint32_t s1 = amy_pcm_stream_chunk_state(&stream, 1); + CHECK(s0 == AMY_PCM_STREAM_CHUNK_DONE || s0 == AMY_PCM_STREAM_CHUNK_CANCELLED, + "first slot is safe after stop"); + CHECK(s1 == AMY_PCM_STREAM_CHUNK_DONE || s1 == AMY_PCM_STREAM_CHUNK_CANCELLED, + "second slot is safe after stop"); + CHECK(stream.active_osc == AMY_PCM_STREAM_NO_OSC, + "note-off relinquishes stream ownership"); + pcm_unload_preset(STREAM_PRESET_C); +} + +static void test_contiguous_pcm_unchanged(void) { + printf("existing contiguous memory PCM stays on its original path\n"); + int16_t *ram = pcm_load(MEMORY_PRESET, 32, AMY_SAMPLE_RATE, 1, 60.0f, 0, 0); + CHECK(ram != NULL, "pcm_load still allocates one contiguous sample"); + if (ram != NULL) { + for (int i = 0; i < 32; ++i) ram[i] = (int16_t)(i * 100); + uint32_t length = 0; + const int16_t *readback = pcm_get_sample_ram_for_preset(MEMORY_PRESET, &length); + CHECK(readback == ram, "contiguous preset exposes the same memory pointer"); + CHECK(length == 32, "contiguous preset length is unchanged"); + } + pcm_unload_preset(MEMORY_PRESET); + + amy_pcm_stream_t stream; + amy_pcm_stream_chunk_t chunks[1]; + amy_pcm_stream_init(&stream, chunks, 1); + CHECK(pcm_load_stream(MEMORY_PRESET, &stream, AMY_SAMPLE_RATE, 1, 60.0f) == 0, + "stream can use the same preset namespace"); + uint32_t length = 123; + CHECK(pcm_get_sample_ram_for_preset(MEMORY_PRESET, &length) == NULL, + "stream preset never masquerades as contiguous RAM"); + CHECK(length == 0, "stream contiguous-length readback is zero"); + pcm_unload_preset(MEMORY_PRESET); +} + +int main(void) { + fill_buffers(); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.features.default_synths = 0; + amy_start(config); + render_blocks(2); + + test_ring_ownership(); + test_ping_pong_reuse(); + test_underrun_resume(); + test_note_off_releases_buffers(); + test_contiguous_pcm_unchanged(); + + amy_stop(); + if (failures) { + printf("%d FAILURES\n", failures); + return 1; + } + printf("all ok\n"); + return 0; +}