Synthesise tones on three voices
There was no audio API at all: SDL3_mixer was vendored and AKGL_REGISTRY_MUSIC was declared, but nothing opened a mixer, and a mixer plays recordings anyway. BASIC 7.0's SOUND, PLAY, ENVELOPE and VOL describe notes, so this adds a tone generator -- three voices, five waveforms, an ADSR envelope each, mixed to one float stream and fed to an SDL_AudioStream. The voice table works whether or not a device is open. akgl_audio_init connects it to one; a host that owns its own audio pipeline can call akgl_audio_mix instead. That is also what makes the tests deterministic: a device pulls samples on SDL's audio thread whenever it likes, so tests/audio.c mixes by hand and only opens a device in its last test. Phase is computed from the frame counter rather than accumulated, because a float increment of hz/44100 added 44100 times a second walks a held note off pitch. A voice that has never been configured defaults to a square wave at full level, since a zeroed voice would have a sustain of 0.0 and make no sound with no error to explain why. Voices summing past full scale are clamped rather than scaled, so one voice plays at the level it was asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,7 @@ add_library(akgl SHARED
|
||||
deps/semver/semver.c
|
||||
src/actor.c
|
||||
src/actor_state_string_names.c
|
||||
src/audio.c
|
||||
src/text.c
|
||||
src/assets.c
|
||||
src/character.c
|
||||
@@ -154,6 +155,7 @@ add_executable(akgl_test_semver_unit deps/semver/semver_unit.c)
|
||||
# akgl_test_<name> and registered with CTest under <name>.
|
||||
set(AKGL_TEST_SUITES
|
||||
actor
|
||||
audio
|
||||
bitmasks
|
||||
character
|
||||
controller
|
||||
@@ -333,6 +335,7 @@ install(FILES "include/akgl/actor.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/types.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/text.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/assets.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/audio.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/character.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/error.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/draw.h" DESTINATION "include/akgl/")
|
||||
|
||||
29
TODO.md
29
TODO.md
@@ -725,6 +725,35 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
|
||||
of the four and the one most worth designing before writing. Tests can run under the dummy
|
||||
audio driver and assert state transitions rather than sound.
|
||||
|
||||
**Resolved.** `include/akgl/audio.h` and `src/audio.c` add a three-voice tone
|
||||
generator over `SDL_AudioStream`: `akgl_audio_init`, `_shutdown`, `_tone`,
|
||||
`_stop`, `_waveform`, `_envelope`, `_volume`, `_voice_active` and `_mix`.
|
||||
It is deliberately separate from the SDL3_mixer side of the library, which
|
||||
plays audio *assets*; nothing in `audio.c` reads a file. Decisions worth
|
||||
knowing:
|
||||
|
||||
- **The voice table works with no device open.** `akgl_audio_init` connects
|
||||
it to one; without that a host can still pull samples itself through
|
||||
`akgl_audio_mix`. That is not only for embedding — it is what makes the
|
||||
suite deterministic. A device pulls samples on SDL's audio thread whenever
|
||||
it likes, so a test that opened one and then asserted on voice state would
|
||||
be racing the callback. `tests/audio.c` mixes by hand and opens a device
|
||||
only in its last test.
|
||||
- **Phase is derived from the frame counter, not accumulated.** A float
|
||||
increment of `hz / 44100` is not exact, and adding it 44100 times a second
|
||||
walks a held note off pitch.
|
||||
- **A voice that was never configured is audible.** A zeroed voice has a
|
||||
sustain of 0.0, which is silence with no error to explain it, so the table
|
||||
defaults to a square wave held at full level.
|
||||
- **Three voices summing past full scale are clamped, not scaled**, so one
|
||||
voice plays at the level it was asked for rather than a third of it.
|
||||
- Everything that touches the table takes the stream lock when a device is
|
||||
open, since the callback reads it on another thread.
|
||||
|
||||
Still missing for a complete BASIC sound vocabulary: `FILTER` (SDL3 has no
|
||||
filter primitive; this would need writing), `TEMPO` and the `PLAY` note-string
|
||||
parser, both of which belong in the interpreter rather than here.
|
||||
|
||||
4. **No non-blocking keystroke read.** `include/akgl/controller.h` is built around SDL event
|
||||
handlers the host pumps (`akgl_controller_handle_event` and friends), which suits a game
|
||||
loop and does not suit `GET` and `GETKEY` -- those ask "is there a keystroke waiting, yes or
|
||||
|
||||
204
include/akgl/audio.h
Normal file
204
include/akgl/audio.h
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @file audio.h
|
||||
* @brief Declares the public audio API.
|
||||
*
|
||||
* A small tone generator: a fixed set of voices, each with a waveform, a
|
||||
* frequency, a gate length and an ADSR envelope, mixed to one stream of float
|
||||
* samples. This is what a synthesised-voice vocabulary needs -- SOUND, PLAY,
|
||||
* ENVELOPE and VOL all describe a note rather than a recording -- and it is
|
||||
* deliberately separate from the SDL3_mixer side of the library, which loads
|
||||
* and plays audio *assets*. Nothing here reads a file.
|
||||
*
|
||||
* The voice table exists whether or not an audio device is open.
|
||||
* akgl_audio_init() connects it to one; without that a caller can still set up
|
||||
* voices and pull samples itself with akgl_audio_mix(), which is how the test
|
||||
* suite exercises the synthesis without depending on a sound card's timing.
|
||||
* What a caller cannot do is set up voices and expect to hear them with no
|
||||
* device open.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_AUDIO_H_
|
||||
#define _AKGL_AUDIO_H_
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/types.h>
|
||||
|
||||
/**
|
||||
* @brief Voices that can sound at once.
|
||||
*
|
||||
* Three, because the machine this vocabulary comes from had three and its
|
||||
* music is written for three. A voice is addressed here by a zero-based index;
|
||||
* a language whose own voices are numbered from one maps them itself.
|
||||
*/
|
||||
#define AKGL_AUDIO_MAX_VOICES 3
|
||||
|
||||
/** @brief Sample rate of the generated stream, in frames per second. */
|
||||
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
||||
|
||||
/** @brief Frames the device callback generates per pass through the mixer. */
|
||||
#define AKGL_AUDIO_MIX_FRAMES 512
|
||||
|
||||
/**
|
||||
* @brief Shape of one voice's oscillator.
|
||||
*
|
||||
* The trailing comment on each is the waveform number the C128 SOUND statement
|
||||
* uses for the same shape, for a caller translating one to the other.
|
||||
*/
|
||||
typedef enum {
|
||||
AKGL_AUDIO_WAVE_TRIANGLE = 0, /** SOUND waveform 0. Soft, flute-like. */
|
||||
AKGL_AUDIO_WAVE_SAWTOOTH = 1, /** SOUND waveform 1. Bright, brassy. */
|
||||
AKGL_AUDIO_WAVE_SQUARE = 2, /** SOUND waveform 2. Hollow, reedy. The default. */
|
||||
AKGL_AUDIO_WAVE_NOISE = 3, /** SOUND waveform 3. Unpitched; percussion. */
|
||||
AKGL_AUDIO_WAVE_SINE = 4 /** No SOUND equivalent. A pure tone. */
|
||||
} akgl_AudioWaveform;
|
||||
|
||||
/** @brief Holds one voice's oscillator, envelope, and how far through it is. */
|
||||
typedef struct {
|
||||
bool active;
|
||||
akgl_AudioWaveform waveform;
|
||||
float32_t hz;
|
||||
/**
|
||||
* @brief Position through one cycle, 0.0 to 1.0.
|
||||
*
|
||||
* Derived from `elapsed_frames` and `hz` each sample rather than
|
||||
* accumulated, so a long note does not drift off pitch. Reading it is
|
||||
* meaningful; writing it is not.
|
||||
*/
|
||||
float32_t phase;
|
||||
/** @brief Frames the gate stays open, before the release begins. */
|
||||
uint32_t duration_frames;
|
||||
/** @brief Frames generated since the tone started, gate and release. */
|
||||
uint32_t elapsed_frames;
|
||||
uint32_t attack_frames;
|
||||
uint32_t decay_frames;
|
||||
uint32_t release_frames;
|
||||
/** @brief Level the envelope decays to and holds, 0.0 to 1.0. */
|
||||
float32_t sustain;
|
||||
} akgl_AudioVoice;
|
||||
|
||||
/** @brief The process-wide voice table. */
|
||||
extern akgl_AudioVoice akgl_audio_voices[AKGL_AUDIO_MAX_VOICES];
|
||||
|
||||
/**
|
||||
* @brief Open an audio device and start pulling samples from the voice table.
|
||||
*
|
||||
* Requires SDL's audio subsystem to be initialized. Repeating the call is a
|
||||
* no-op, so a program that cannot order its initialization precisely may call
|
||||
* it more than once. The voice table is reset only on the first call, so this
|
||||
* does not silence a voice that is already sounding.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_init(void);
|
||||
|
||||
/**
|
||||
* @brief Close the audio device and silence every voice.
|
||||
*
|
||||
* Safe to call when no device is open.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_shutdown(void);
|
||||
|
||||
/**
|
||||
* @brief Sound a note on one voice for a fixed time.
|
||||
*
|
||||
* The voice's envelope and waveform are whatever akgl_audio_envelope() and
|
||||
* akgl_audio_waveform() last set them to. @p ms is the length of the gate: the
|
||||
* voice's release runs *after* it, so a voice with a release stays audible
|
||||
* slightly longer than @p ms. Sounding a voice that is already sounding
|
||||
* restarts it from the beginning of its envelope.
|
||||
*
|
||||
* @param voice Zero-based voice index.
|
||||
* @param hz Frequency in hertz.
|
||||
* @param ms Gate length in milliseconds.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_tone(int voice, float32_t hz, uint32_t ms);
|
||||
|
||||
/**
|
||||
* @brief Silence one voice immediately, skipping its release.
|
||||
* @param voice Zero-based voice index.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_stop(int voice);
|
||||
|
||||
/**
|
||||
* @brief Choose the oscillator shape one voice will use.
|
||||
*
|
||||
* Takes effect on the next akgl_audio_tone(); it does not reshape a note that
|
||||
* is already sounding.
|
||||
*
|
||||
* @param voice Zero-based voice index.
|
||||
* @param waveform Oscillator shape to use.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_waveform(int voice, akgl_AudioWaveform waveform);
|
||||
|
||||
/**
|
||||
* @brief Set one voice's ADSR envelope.
|
||||
*
|
||||
* @p attack, @p decay and @p release are milliseconds; @p sustain is the level
|
||||
* the envelope decays to and holds while the gate is open, from 0.0 to 1.0. A
|
||||
* zero-length stage is skipped rather than divided by.
|
||||
*
|
||||
* Takes effect on the next akgl_audio_tone().
|
||||
*
|
||||
* @param voice Zero-based voice index.
|
||||
* @param attack Milliseconds to rise from silence to full level.
|
||||
* @param decay Milliseconds to fall from full level to @p sustain.
|
||||
* @param sustain Held level while the gate is open, 0.0 to 1.0.
|
||||
* @param release Milliseconds to fall to silence once the gate closes.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_envelope(int voice, uint32_t attack, uint32_t decay, float32_t sustain, uint32_t release);
|
||||
|
||||
/**
|
||||
* @brief Set the level every voice is scaled by.
|
||||
* @param level Master level, 0.0 to 1.0.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_volume(float32_t level);
|
||||
|
||||
/**
|
||||
* @brief Report whether a voice is still sounding.
|
||||
*
|
||||
* A voice goes quiet on its own when its gate and release have both elapsed, so
|
||||
* this is how a caller waits out a note without keeping its own clock.
|
||||
*
|
||||
* @param voice Zero-based voice index.
|
||||
* @param active Output destination set to `true` while the voice is sounding.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_voice_active(int voice, bool *active);
|
||||
|
||||
/**
|
||||
* @brief Generate the next @p frames mono samples from the voice table.
|
||||
*
|
||||
* The device callback installed by akgl_audio_init() is a loop around this. A
|
||||
* host that owns its own audio pipeline can call it directly instead and never
|
||||
* open a device here at all. Samples are single-precision, one channel, in the
|
||||
* range -1.0 to 1.0, and every active voice is advanced by @p frames.
|
||||
*
|
||||
* @param dest Output destination populated with @p frames samples.
|
||||
* @param frames Number of samples to generate.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames);
|
||||
|
||||
#endif // _AKGL_AUDIO_H_
|
||||
431
src/audio.c
Normal file
431
src/audio.c
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* @file audio.c
|
||||
* @brief Implements the audio subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/audio.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
akgl_AudioVoice akgl_audio_voices[AKGL_AUDIO_MAX_VOICES];
|
||||
|
||||
/*
|
||||
* The device akgl_audio_init() opened, or NULL when the voice table is not
|
||||
* connected to one. Everything that mutates a voice locks this stream when it
|
||||
* is open, because the device callback reads the same table on SDL's audio
|
||||
* thread. When it is NULL there is no other thread to race with.
|
||||
*/
|
||||
static SDL_AudioStream *audiostream = NULL;
|
||||
|
||||
/** @brief Level every voice is scaled by before the mix is clamped. */
|
||||
static float32_t mastervolume = 1.0f;
|
||||
|
||||
/** @brief Scratch the device callback mixes into before handing it to SDL. */
|
||||
static float32_t mixbuffer[AKGL_AUDIO_MIX_FRAMES];
|
||||
|
||||
/*
|
||||
* State for the noise waveform. A 32-bit xorshift rather than rand(): it needs
|
||||
* no allocation, no locking beyond what already guards the voice table, and it
|
||||
* produces the same sequence every run, so a test can assert on noise output.
|
||||
*/
|
||||
static uint32_t noisestate = 0x13579bdfu;
|
||||
|
||||
/** @brief Whether the voice table has been given its defaults yet. */
|
||||
static bool voicesready = false;
|
||||
|
||||
/**
|
||||
* @brief Put every voice back to a flat, audible default.
|
||||
*
|
||||
* A zeroed voice has a sustain of 0.0, which is silence -- so a caller who
|
||||
* sounded a note without first describing an envelope would get nothing and no
|
||||
* error saying why. The default is instead the simplest thing that makes noise:
|
||||
* a square wave with no attack, no decay and no release, held at full level for
|
||||
* as long as the gate is open.
|
||||
*/
|
||||
static void reset_voices(void)
|
||||
{
|
||||
int i = 0;
|
||||
|
||||
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
|
||||
akgl_audio_voices[i].active = false;
|
||||
akgl_audio_voices[i].waveform = AKGL_AUDIO_WAVE_SQUARE;
|
||||
akgl_audio_voices[i].hz = 0.0f;
|
||||
akgl_audio_voices[i].phase = 0.0f;
|
||||
akgl_audio_voices[i].duration_frames = 0;
|
||||
akgl_audio_voices[i].elapsed_frames = 0;
|
||||
akgl_audio_voices[i].attack_frames = 0;
|
||||
akgl_audio_voices[i].decay_frames = 0;
|
||||
akgl_audio_voices[i].release_frames = 0;
|
||||
akgl_audio_voices[i].sustain = 1.0f;
|
||||
}
|
||||
mastervolume = 1.0f;
|
||||
voicesready = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Give the voice table its defaults the first time anything touches it.
|
||||
*
|
||||
* The table is process-wide static storage, so it is reachable before
|
||||
* akgl_audio_init() has run. Rather than make every entry point document an
|
||||
* ordering requirement, the first one to arrive sets the defaults.
|
||||
*/
|
||||
static void ensure_voices(void)
|
||||
{
|
||||
if ( voicesready == false ) {
|
||||
reset_voices();
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Lock the voice table against the device callback, if one is running. */
|
||||
static void lock_voices(void)
|
||||
{
|
||||
if ( audiostream != NULL ) {
|
||||
SDL_LockAudioStream(audiostream);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Release the lock taken by lock_voices(). */
|
||||
static void unlock_voices(void)
|
||||
{
|
||||
if ( audiostream != NULL ) {
|
||||
SDL_UnlockAudioStream(audiostream);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Convert a duration in milliseconds to a whole number of frames. */
|
||||
static uint32_t frames_for_ms(uint32_t ms)
|
||||
{
|
||||
return (uint32_t)(((uint64_t)ms * AKGL_AUDIO_SAMPLE_RATE) / 1000);
|
||||
}
|
||||
|
||||
/** @brief Next value of the noise oscillator, in the range -1.0 to 1.0. */
|
||||
static float32_t noise_sample(void)
|
||||
{
|
||||
noisestate ^= noisestate << 13;
|
||||
noisestate ^= noisestate >> 17;
|
||||
noisestate ^= noisestate << 5;
|
||||
// The top 24 bits are the well-mixed ones; scale them to -1..1.
|
||||
return (((float32_t)(noisestate >> 8) / 8388607.5f) - 1.0f);
|
||||
}
|
||||
|
||||
/** @brief One sample of @p voice's oscillator at its current phase. */
|
||||
static float32_t voice_oscillator(akgl_AudioVoice *voice)
|
||||
{
|
||||
float32_t value = 0.0f;
|
||||
|
||||
switch ( voice->waveform ) {
|
||||
case AKGL_AUDIO_WAVE_TRIANGLE:
|
||||
if ( voice->phase < 0.5f ) {
|
||||
value = (4.0f * voice->phase) - 1.0f;
|
||||
} else {
|
||||
value = 3.0f - (4.0f * voice->phase);
|
||||
}
|
||||
break;
|
||||
case AKGL_AUDIO_WAVE_SAWTOOTH:
|
||||
value = (2.0f * voice->phase) - 1.0f;
|
||||
break;
|
||||
case AKGL_AUDIO_WAVE_SQUARE:
|
||||
value = ( voice->phase < 0.5f ) ? 1.0f : -1.0f;
|
||||
break;
|
||||
case AKGL_AUDIO_WAVE_NOISE:
|
||||
value = noise_sample();
|
||||
break;
|
||||
case AKGL_AUDIO_WAVE_SINE:
|
||||
value = SDL_sinf(voice->phase * 2.0f * SDL_PI_F);
|
||||
break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Envelope level @p frame frames into the gate, before the release.
|
||||
*
|
||||
* Split out because the release has to start from wherever the gate left off,
|
||||
* which for a gate shorter than attack plus decay is partway up or down a
|
||||
* ramp rather than at the sustain level.
|
||||
*/
|
||||
static float32_t voice_gate_level(akgl_AudioVoice *voice, uint32_t frame)
|
||||
{
|
||||
uint32_t elapsed = frame;
|
||||
|
||||
if ( elapsed < voice->attack_frames ) {
|
||||
return (float32_t)elapsed / (float32_t)voice->attack_frames;
|
||||
}
|
||||
elapsed -= voice->attack_frames;
|
||||
if ( elapsed < voice->decay_frames ) {
|
||||
return 1.0f - ((1.0f - voice->sustain) * ((float32_t)elapsed / (float32_t)voice->decay_frames));
|
||||
}
|
||||
return voice->sustain;
|
||||
}
|
||||
|
||||
/** @brief Envelope level for @p voice where it currently stands. */
|
||||
static float32_t voice_envelope(akgl_AudioVoice *voice)
|
||||
{
|
||||
uint32_t released = 0;
|
||||
float32_t gatelevel = 0.0f;
|
||||
|
||||
if ( voice->elapsed_frames < voice->duration_frames ) {
|
||||
return voice_gate_level(voice, voice->elapsed_frames);
|
||||
}
|
||||
released = voice->elapsed_frames - voice->duration_frames;
|
||||
if ( released >= voice->release_frames ) {
|
||||
return 0.0f;
|
||||
}
|
||||
gatelevel = voice_gate_level(voice, voice->duration_frames);
|
||||
return gatelevel * (1.0f - ((float32_t)released / (float32_t)voice->release_frames));
|
||||
}
|
||||
|
||||
/** @brief Refuse a voice index that is not in the table. */
|
||||
static akerr_ErrorContext *check_voice(int voice)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((voice < 0) || (voice >= AKGL_AUDIO_MAX_VOICES)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Voice %d is not in the range 0 to %d",
|
||||
voice,
|
||||
AKGL_AUDIO_MAX_VOICES - 1);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Fill SDL's request from the voice table.
|
||||
*
|
||||
* SDL holds the stream lock for the duration of this callback, which is the
|
||||
* same lock lock_voices() takes, so the voice table cannot change underneath a
|
||||
* mix in progress.
|
||||
*/
|
||||
static void SDLCALL audio_stream_callback(void *userdata, SDL_AudioStream *stream, int additional_amount, int total_amount)
|
||||
{
|
||||
akerr_ErrorContext *errctx = NULL;
|
||||
int frames = 0;
|
||||
|
||||
while ( additional_amount > 0 ) {
|
||||
frames = additional_amount / (int)sizeof(float32_t);
|
||||
if ( frames > AKGL_AUDIO_MIX_FRAMES ) {
|
||||
frames = AKGL_AUDIO_MIX_FRAMES;
|
||||
}
|
||||
if ( frames <= 0 ) {
|
||||
return;
|
||||
}
|
||||
errctx = akgl_audio_mix(mixbuffer, frames);
|
||||
if ( errctx != NULL ) {
|
||||
// There is nobody to return an error to on the audio thread, and
|
||||
// refusing to write leaves SDL underrunning. Report and go quiet.
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "** AUDIO CALLBACK **");
|
||||
errctx->handled = true;
|
||||
errctx = akerr_release_error(errctx);
|
||||
return;
|
||||
}
|
||||
SDL_PutAudioStreamData(stream, mixbuffer, frames * (int)sizeof(float32_t));
|
||||
additional_amount -= frames * (int)sizeof(float32_t);
|
||||
}
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_init(void)
|
||||
{
|
||||
SDL_AudioSpec spec;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
if ( audiostream != NULL ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
ensure_voices();
|
||||
|
||||
spec.format = SDL_AUDIO_F32;
|
||||
spec.channels = 1;
|
||||
spec.freq = AKGL_AUDIO_SAMPLE_RATE;
|
||||
|
||||
audiostream = SDL_OpenAudioDeviceStream(
|
||||
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
|
||||
&spec,
|
||||
&audio_stream_callback,
|
||||
NULL);
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
audiostream,
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't open an audio device: %s",
|
||||
SDL_GetError());
|
||||
|
||||
// Devices open paused so a caller can set a stream up before it is heard.
|
||||
// Nothing here needs that, and a caller who expected akgl_audio_tone() to
|
||||
// make a sound would otherwise get silence with no error to explain it.
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_ResumeAudioStreamDevice(audiostream),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't start the audio device: %s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_shutdown(void)
|
||||
{
|
||||
SDL_AudioStream *closing = audiostream;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
// Clear the pointer before destroying the stream: lock_voices() checks it,
|
||||
// and SDL_DestroyAudioStream can run the callback one last time.
|
||||
audiostream = NULL;
|
||||
if ( closing != NULL ) {
|
||||
SDL_DestroyAudioStream(closing);
|
||||
}
|
||||
reset_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_tone(int voice, float32_t hz, uint32_t ms)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_NONZERO_RETURN(errctx, (hz <= 0.0f), AKERR_OUTOFBOUNDS, "Frequency %f is not positive", hz);
|
||||
FAIL_ZERO_RETURN(errctx, ms, AKERR_OUTOFBOUNDS, "A tone needs a duration; use akgl_audio_stop to silence a voice");
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].hz = hz;
|
||||
akgl_audio_voices[voice].phase = 0.0f;
|
||||
akgl_audio_voices[voice].duration_frames = frames_for_ms(ms);
|
||||
akgl_audio_voices[voice].elapsed_frames = 0;
|
||||
akgl_audio_voices[voice].active = true;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_stop(int voice)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].active = false;
|
||||
akgl_audio_voices[voice].elapsed_frames = 0;
|
||||
akgl_audio_voices[voice].phase = 0.0f;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_waveform(int voice, akgl_AudioWaveform waveform)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((waveform < AKGL_AUDIO_WAVE_TRIANGLE) || (waveform > AKGL_AUDIO_WAVE_SINE)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Waveform %d is not one of the %d shapes",
|
||||
(int)waveform,
|
||||
(int)AKGL_AUDIO_WAVE_SINE + 1);
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].waveform = waveform;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_envelope(int voice, uint32_t attack, uint32_t decay, float32_t sustain, uint32_t release)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((sustain < 0.0f) || (sustain > 1.0f)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Sustain level %f is not between 0.0 and 1.0",
|
||||
sustain);
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].attack_frames = frames_for_ms(attack);
|
||||
akgl_audio_voices[voice].decay_frames = frames_for_ms(decay);
|
||||
akgl_audio_voices[voice].release_frames = frames_for_ms(release);
|
||||
akgl_audio_voices[voice].sustain = sustain;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_volume(float32_t level)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((level < 0.0f) || (level > 1.0f)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Volume level %f is not between 0.0 and 1.0",
|
||||
level);
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
mastervolume = level;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_voice_active(int voice, bool *active)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_ZERO_RETURN(errctx, active, AKERR_NULLPOINTER, "NULL activity destination");
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
*active = akgl_audio_voices[voice].active;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames)
|
||||
{
|
||||
akgl_AudioVoice *voice = NULL;
|
||||
float32_t sum = 0.0f;
|
||||
int i = 0;
|
||||
int v = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL sample destination");
|
||||
FAIL_NONZERO_RETURN(errctx, (frames < 0), AKERR_OUTOFBOUNDS, "Frame count %d is negative", frames);
|
||||
|
||||
for ( i = 0; i < frames; i++ ) {
|
||||
sum = 0.0f;
|
||||
for ( v = 0; v < AKGL_AUDIO_MAX_VOICES; v++ ) {
|
||||
voice = &akgl_audio_voices[v];
|
||||
if ( voice->active == false ) {
|
||||
continue;
|
||||
}
|
||||
if ( voice->elapsed_frames >= (voice->duration_frames + voice->release_frames) ) {
|
||||
// Gate and release are both spent. The voice goes quiet on its
|
||||
// own so a caller does not have to keep a clock to stop it.
|
||||
voice->active = false;
|
||||
voice->elapsed_frames = 0;
|
||||
voice->phase = 0.0f;
|
||||
continue;
|
||||
}
|
||||
// Derived from the frame counter rather than accumulated one
|
||||
// increment at a time. A float increment of hz/rate is not exact,
|
||||
// and adding it 44100 times a second walks the pitch off over the
|
||||
// length of a held note.
|
||||
voice->phase = (float32_t)SDL_fmod(
|
||||
((double)voice->elapsed_frames * (double)voice->hz) / (double)AKGL_AUDIO_SAMPLE_RATE,
|
||||
1.0);
|
||||
sum += voice_oscillator(voice) * voice_envelope(voice);
|
||||
voice->elapsed_frames += 1;
|
||||
}
|
||||
sum = sum * mastervolume;
|
||||
// Three voices at full level can sum past full scale. Clamping rather
|
||||
// than scaling by the voice count keeps a single voice at the level it
|
||||
// was asked for instead of a third of it.
|
||||
if ( sum > 1.0f ) {
|
||||
sum = 1.0f;
|
||||
} else if ( sum < -1.0f ) {
|
||||
sum = -1.0f;
|
||||
}
|
||||
dest[i] = sum;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
446
tests/audio.c
Normal file
446
tests/audio.c
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* @file audio.c
|
||||
* @brief Unit tests for the tone generator.
|
||||
*
|
||||
* Almost everything here drives akgl_audio_mix() by hand rather than opening a
|
||||
* device. That is not a workaround: a device pulls samples on SDL's audio
|
||||
* thread at whatever rate it likes, so a test that opened one and then asserted
|
||||
* on voice state would be racing the callback. Pulling the samples ourselves
|
||||
* makes the synthesis deterministic -- the same input produces the same
|
||||
* waveform, sample for sample, every run.
|
||||
*
|
||||
* The device is opened once, at the end, to prove akgl_audio_init() and
|
||||
* akgl_audio_shutdown() work against the dummy driver.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/audio.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief Frames of output most tests generate at a time. */
|
||||
#define TEST_MIX_FRAMES 512
|
||||
|
||||
/** @brief 441 Hz at 44100 frames per second is exactly 100 frames per cycle. */
|
||||
#define TEST_TONE_HZ 441.0f
|
||||
/** @brief Frames in one cycle of TEST_TONE_HZ. */
|
||||
#define TEST_TONE_PERIOD 100
|
||||
|
||||
/** @brief Somewhere to mix into. */
|
||||
static float32_t samples[TEST_MIX_FRAMES];
|
||||
|
||||
/** @brief Silence every voice and put the master level back. */
|
||||
static akerr_ErrorContext *reset_audio(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
|
||||
CATCH(errctx, akgl_audio_stop(i));
|
||||
CATCH(errctx, akgl_audio_waveform(i, AKGL_AUDIO_WAVE_SQUARE));
|
||||
CATCH(errctx, akgl_audio_envelope(i, 0, 0, 1.0f, 0));
|
||||
}
|
||||
CATCH(errctx, akgl_audio_volume(1.0f));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Report the largest absolute sample in the first @p frames. */
|
||||
static float32_t peak_of(int frames)
|
||||
{
|
||||
float32_t peak = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
for ( i = 0; i < frames; i++ ) {
|
||||
if ( fabsf(samples[i]) > peak ) {
|
||||
peak = fabsf(samples[i]);
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_silence(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool silent = true;
|
||||
bool active = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// With nothing sounding, the mixer produces silence rather than
|
||||
// whatever was left in the buffer.
|
||||
SDL_memset((void *)&samples, 0xff, sizeof(samples));
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES), "mixing an idle voice table");
|
||||
for ( i = 0; i < TEST_MIX_FRAMES; i++ ) {
|
||||
TEST_ASSERT_FLAG(silent, samples[i] == 0.0f);
|
||||
}
|
||||
TEST_ASSERT(errctx, silent == true, "an idle voice table did not mix to silence");
|
||||
|
||||
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
|
||||
CATCH(errctx, akgl_audio_voice_active(i, &active));
|
||||
TEST_ASSERT(errctx, active == false, "voice %d reports active with nothing playing", i);
|
||||
}
|
||||
|
||||
// Zero frames is a legal request that writes nothing.
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, 0), "mixing zero frames");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_audio_mix(NULL, 16),
|
||||
"mixing into a NULL destination");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_mix(samples, -1),
|
||||
"mixing a negative number of frames");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_square_tone(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool firsthalf = true;
|
||||
bool secondhalf = true;
|
||||
bool active = false;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// A square wave with a flat envelope is the one waveform whose every
|
||||
// sample is known exactly: +1 for the first half of each cycle, -1 for
|
||||
// the second.
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000), "sounding a tone");
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == true, "a sounded voice does not report itself active");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES), "mixing a square wave");
|
||||
for ( i = 0; i < (TEST_TONE_PERIOD / 2); i++ ) {
|
||||
TEST_ASSERT_FLAG(firsthalf, samples[i] == 1.0f);
|
||||
}
|
||||
for ( i = (TEST_TONE_PERIOD / 2); i < TEST_TONE_PERIOD; i++ ) {
|
||||
TEST_ASSERT_FLAG(secondhalf, samples[i] == -1.0f);
|
||||
}
|
||||
TEST_ASSERT(errctx, firsthalf == true,
|
||||
"the first half cycle of a square wave is not at full positive level");
|
||||
TEST_ASSERT(errctx, secondhalf == true,
|
||||
"the second half cycle of a square wave is not at full negative level");
|
||||
|
||||
// The wave repeats: the second cycle matches the first.
|
||||
TEST_ASSERT(errctx, samples[TEST_TONE_PERIOD] == samples[0],
|
||||
"the wave did not repeat after one period");
|
||||
TEST_ASSERT(errctx, samples[TEST_TONE_PERIOD + 60] == samples[60],
|
||||
"the wave did not repeat after one period");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, 0.0f, 100),
|
||||
"sounding a tone at zero hertz");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, -100.0f, 100),
|
||||
"sounding a tone at a negative frequency");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, TEST_TONE_HZ, 0),
|
||||
"sounding a tone with no duration");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(-1, TEST_TONE_HZ, 100),
|
||||
"sounding a tone on a negative voice");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_tone(AKGL_AUDIO_MAX_VOICES, TEST_TONE_HZ, 100),
|
||||
"sounding a tone on a voice past the last one");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_waveforms(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool inrange = true;
|
||||
int i = 0;
|
||||
int w = 0;
|
||||
akgl_AudioWaveform shapes[4] = {
|
||||
AKGL_AUDIO_WAVE_TRIANGLE,
|
||||
AKGL_AUDIO_WAVE_SAWTOOTH,
|
||||
AKGL_AUDIO_WAVE_NOISE,
|
||||
AKGL_AUDIO_WAVE_SINE,
|
||||
};
|
||||
|
||||
ATTEMPT {
|
||||
// Every shape has to stay inside full scale and actually move. The
|
||||
// exact sample values differ per shape; these two properties do not.
|
||||
for ( w = 0; w < 4; w++ ) {
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_waveform(0, shapes[w]));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
for ( i = 0; i < TEST_MIX_FRAMES; i++ ) {
|
||||
TEST_ASSERT_FLAG(inrange, (samples[i] >= -1.0f) && (samples[i] <= 1.0f));
|
||||
}
|
||||
TEST_ASSERT_FLAG(inrange, peak_of(TEST_MIX_FRAMES) > 0.5f);
|
||||
}
|
||||
TEST_ASSERT(errctx, inrange == true,
|
||||
"a waveform either left full scale or produced nothing");
|
||||
|
||||
// The triangle is symmetric about the middle of its cycle, which the
|
||||
// square and sawtooth are not -- enough to tell it was really selected.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_waveform(0, AKGL_AUDIO_WAVE_TRIANGLE));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[25], 0.0f,
|
||||
"a triangle wave is at %f a quarter of the way up, expected 0",
|
||||
samples[25]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[50], 1.0f,
|
||||
"a triangle wave is at %f at its peak, expected 1", samples[50]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[75], 0.0f,
|
||||
"a triangle wave is at %f three quarters through, expected 0",
|
||||
samples[75]);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_waveform(0, (akgl_AudioWaveform)(AKGL_AUDIO_WAVE_SINE + 1)),
|
||||
"selecting a waveform past the last one");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_waveform(AKGL_AUDIO_MAX_VOICES, AKGL_AUDIO_WAVE_SINE),
|
||||
"selecting a waveform on a voice past the last one");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_envelope(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool rising = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// A 10 ms attack at 44100 is 441 frames, so the level should climb from
|
||||
// nothing to full across the first 441 samples of a square wave and
|
||||
// then hold at the sustain level.
|
||||
CATCH(errctx, akgl_audio_envelope(0, 10, 0, 1.0f, 0));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 0.0f,
|
||||
"an attack does not start from silence (first sample %f)", samples[0]);
|
||||
TEST_ASSERT(errctx, fabsf(samples[100]) > fabsf(samples[10]),
|
||||
"the attack is not climbing (%f at frame 10, %f at frame 100)",
|
||||
samples[10], samples[100]);
|
||||
// Frames 0..49 are the positive half of the square, so their level is
|
||||
// the envelope value directly.
|
||||
for ( i = 1; i < 50; i++ ) {
|
||||
TEST_ASSERT_FLAG(rising, samples[i] > samples[i - 1]);
|
||||
}
|
||||
TEST_ASSERT(errctx, rising == true, "the attack ramp is not monotonic");
|
||||
|
||||
// Half a millisecond of decay to a half sustain level, no attack: the
|
||||
// very first sample is at full level and it settles at the sustain.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_envelope(0, 0, 10, 0.5f, 0));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 1.0f,
|
||||
"with no attack the first sample is %f, expected full level", samples[0]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[450], -0.5f,
|
||||
"after the decay the level is %f, expected the 0.5 sustain", samples[450]);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_envelope(0, 1, 1, -0.1f, 1),
|
||||
"setting a negative sustain level");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_envelope(0, 1, 1, 1.5f, 1),
|
||||
"setting a sustain level past full");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_envelope(AKGL_AUDIO_MAX_VOICES, 1, 1, 1.0f, 1),
|
||||
"setting an envelope on a voice past the last one");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_duration_and_release(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool active = false;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// A 10 ms gate is 441 frames. The voice is still sounding while they
|
||||
// are being generated and goes quiet on its own afterwards, without the
|
||||
// caller having to stop it.
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10));
|
||||
CATCH(errctx, akgl_audio_mix(samples, 440));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == true, "the voice went quiet before its gate closed");
|
||||
|
||||
CATCH(errctx, akgl_audio_mix(samples, 8));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "the voice is still sounding past its gate");
|
||||
CATCH(errctx, akgl_audio_mix(samples, 64));
|
||||
TEST_ASSERT_FEQ(errctx, peak_of(64), 0.0f,
|
||||
"a finished voice is still producing sound (peak %f)", peak_of(64));
|
||||
|
||||
// With a release, the voice outlives its gate and fades rather than
|
||||
// stopping dead.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_envelope(0, 0, 0, 1.0f, 10));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10));
|
||||
CATCH(errctx, akgl_audio_mix(samples, 441));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == true, "a voice with a release stopped when its gate closed");
|
||||
// The release starts from the level the gate left off at and falls from
|
||||
// there, so the end of this window has to be quieter than its start.
|
||||
CATCH(errctx, akgl_audio_mix(samples, 220));
|
||||
TEST_ASSERT(errctx, fabsf(samples[219]) < fabsf(samples[0]),
|
||||
"the release is not attenuating (%f at its start, %f 220 frames later)",
|
||||
samples[0], samples[219]);
|
||||
TEST_ASSERT(errctx, peak_of(220) > 0.0f, "the release went silent immediately");
|
||||
CATCH(errctx, akgl_audio_mix(samples, 250));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "the voice outlived its gate and its release");
|
||||
|
||||
// Stopping cuts a voice off where it stands, release and all.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_envelope(0, 0, 0, 1.0f, 1000));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, 64));
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_stop(0), "stopping a sounding voice");
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "a stopped voice still reports itself active");
|
||||
CATCH(errctx, akgl_audio_mix(samples, 64));
|
||||
TEST_ASSERT_FEQ(errctx, peak_of(64), 0.0f,
|
||||
"a stopped voice is still producing sound (peak %f)", peak_of(64));
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_stop(AKGL_AUDIO_MAX_VOICES),
|
||||
"stopping a voice past the last one");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_voice_active(AKGL_AUDIO_MAX_VOICES, &active),
|
||||
"asking about a voice past the last one");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_audio_voice_active(0, NULL),
|
||||
"asking about a voice with nowhere to put the answer");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_volume_and_mixing(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// The master level scales everything.
|
||||
CATCH(errctx, akgl_audio_volume(0.25f));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 0.25f,
|
||||
"at a quarter volume the first sample is %f, expected 0.25", samples[0]);
|
||||
|
||||
CATCH(errctx, akgl_audio_volume(0.0f));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, peak_of(TEST_MIX_FRAMES), 0.0f,
|
||||
"at zero volume the peak is %f, expected silence",
|
||||
peak_of(TEST_MIX_FRAMES));
|
||||
|
||||
// Voices sum, and the sum is clamped rather than wrapped: three square
|
||||
// waves in phase at full level would be 3.0 without the clamp.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_tone(1, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_tone(2, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 1.0f,
|
||||
"three voices in phase mixed to %f, expected a clamp at 1.0", samples[0]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[50], -1.0f,
|
||||
"three voices in phase mixed to %f, expected a clamp at -1.0", samples[50]);
|
||||
|
||||
// Two voices at different levels sum to their total rather than to
|
||||
// either one of them, which is only visible below the clamp.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_volume(0.5f));
|
||||
CATCH(errctx, akgl_audio_envelope(1, 0, 0, 0.5f, 0));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_tone(1, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 0.75f,
|
||||
"a full voice and a half voice at half volume mixed to %f, expected 0.75",
|
||||
samples[0]);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_volume(-0.1f),
|
||||
"setting a negative master volume");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_volume(1.1f),
|
||||
"setting a master volume past full");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_device(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool active = true;
|
||||
|
||||
ATTEMPT {
|
||||
// Everything above ran without a device. This is the one test that
|
||||
// opens one, so nothing that asserts on voice state runs after it.
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_init(), "opening an audio device");
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_init(), "opening an audio device a second time");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10), "sounding a tone on a device");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_shutdown(), "closing the audio device");
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "shutting down left a voice sounding");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_shutdown(), "closing an audio device that is not open");
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_audio_shutdown());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_Init(SDL_INIT_AUDIO),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s",
|
||||
SDL_GetError());
|
||||
|
||||
CATCH(errctx, test_audio_silence());
|
||||
CATCH(errctx, test_audio_square_tone());
|
||||
CATCH(errctx, test_audio_waveforms());
|
||||
CATCH(errctx, test_audio_envelope());
|
||||
CATCH(errctx, test_audio_duration_and_release());
|
||||
CATCH(errctx, test_audio_volume_and_mixing());
|
||||
CATCH(errctx, test_audio_device());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
Reference in New Issue
Block a user