joeylib2/examples/audio/audio.c

424 lines
14 KiB
C

// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
// See LICENSE.md for license information.
// SPDX-License-Identifier: MIT
//
// Audio demo: starts the baked chip-tracker song (demoSong.song ->
// demoSong.h via tools/songbake) on entry and exits on ESC. The keys:
// SPACE a short digital SFX, test.sfx from the DATA folder
// S a falling tone, streamed: a callback writes each chunk as the
// engine asks for it, already in the port's own sample format
// (jlAudioPlaySfxStreamRaw), so it needs no sample data and could
// run as long as it liked
// N the noise generator swept through its 32 pitches, bright to
// dark (jlAudioNoise); the song pauses for it
// M the .MOD loaded from the DATA folder (make/examples.mk says what
// each platform stages)
// T back to the tracker song
// P pause / resume the tracker song in place
// The tracker and the MOD share output hardware on the ST/Amiga, so the
// demo always stops one before starting the other.
//
// Built with AUDIO_PROBE (the AUDIOPRB test program) it presses its own
// keys on a timetable -- N once, S every 1.5 seconds -- for the IIgs and
// X68000 audio gates, which read the sound chips but cannot type.
//
// On a machine whose sound does not start (jlInit logs it), every audio
// call is a quiet no-op and the demo runs as a silent input loop -- you
// can confirm the build links and the input plumbing still works without
// hearing anything.
#include <stdio.h>
#include <stdlib.h>
#include <joey/joey.h>
#include <joey/file.h>
#include "demoSong.h"
// Each platform encodes the MOD differently. The IIgs build converts
// test.mod -> test.ntp via joeymod at build time and ships the .NTP;
// every other platform ships the raw .MOD. test.sfx is the same raw
// PCM blob everywhere. The tracker song needs no per-platform form at
// all -- the baked JYM1 bytes are identical on every port. The names
// are relative to DATA/ (jlDataOpen), which is not always the current
// directory's DATA/ (DVX: beside the program).
#if defined(JOEYLIB_PLATFORM_IIGS)
# define TEST_MOD_NAME "TEST.NTP"
#else
# define TEST_MOD_NAME "test.mod"
#endif
#define TEST_SFX_NAME "test.sfx"
#define DATA_FILE_MAX (64UL * 1024UL)
// Slot 3: the tracker's three voices ride SFX slots 0-2 on the IIgs,
// so the demo's one-shot SFX stays out of their way. The stream takes
// slot 4, so it never cuts the one-shot off.
#define SFX_SLOT 3
#define SFX_RATE_HZ 8000
#define STREAM_SLOT 4
// The streamed tone: STREAM_BLOCKS blocks of STREAM_BLOCK samples, each
// at one pitch and one level, falling STREAM_DROP_HZ from its first pitch
// while it fades from STREAM_PEAK to STREAM_FLOOR. Each press starts
// STREAM_TAKE_HZ higher than the last, over STREAM_TAKES takes, so two
// takes in a row never sound alike. The square wave's phase is a 16-bit
// accumulator: one cycle per 2^STREAM_PHASE_BITS.
#define STREAM_RATE_HZ 8000u
#define STREAM_BLOCK 256u
#define STREAM_BLOCKS 48u
#define STREAM_SAMPLES (STREAM_BLOCK * STREAM_BLOCKS)
#define STREAM_START_HZ 330u
#define STREAM_DROP_HZ 220u
#define STREAM_TAKE_HZ 55u
#define STREAM_TAKES 4u
#define STREAM_PEAK 96
#define STREAM_FLOOR 16
#define STREAM_PHASE_BITS 16u
#define STREAM_PHASE_HALF (1u << (STREAM_PHASE_BITS - 1u))
// Signed sample -> the unsigned form a port that streams unsigned bytes
// wants (JL_SFX_RAW_UNSIGNED8_NOZERO). |level| <= STREAM_PEAK keeps every
// byte off 0x00, the IIgs DOC's stop byte.
#define RAW_BIAS 0x80
// The noise sweep: every pitch in turn for NOISE_STEP_FRAMES frames.
#define NOISE_PITCHES 32u
#define NOISE_STEP_FRAMES 6u
#define NOISE_ATTEN 4u
#define ATTEN_SILENT 15u
// AUDIO_PROBE's timetable, in frames of the main loop. A new stream starts
// about when the last one ends (STREAM_SAMPLES at STREAM_RATE_HZ is 1.5 s),
// so whenever a gate looks, one is playing.
#define PROBE_NOISE_FRAME 60u
#define PROBE_STREAM_FIRST 30u
#define PROBE_STREAM_FRAMES 90u
#define FLASH_FRAMES 8
#define COLOR_BG 0
#define COLOR_HINT 1
#define COLOR_BAR 2
#define BAR_X 16
#define BAR_Y 88
#define BAR_W (JL_SURFACE_WIDTH - 32)
#define BAR_H 16
// The streamed tone's state, which streamFill advances.
typedef struct {
uint32_t pos; // samples delivered so far
uint16_t phase; // square-wave phase accumulator
uint16_t startHz; // this take's first pitch
} StreamT;
static void buildPalette(jlSurfaceT *screen);
static void initialPaint(jlSurfaceT *screen);
static bool keyPressed(jlKeyE key, uint16_t frame);
static bool loadFile(const char *name, uint32_t maxLen, uint8_t **outBytes, uint32_t *outLen);
static bool noiseStep(uint16_t sweepFrame);
static jlKeyE probeKey(uint16_t frame);
static uint8_t rawSample(int16_t level);
static uint32_t streamFill(void *ctx, int8_t *dst, uint32_t count);
static void streamStart(void);
static uint8_t gRawFormat = JL_SFX_RAW_SIGNED8;
static StreamT gStream;
static uint8_t gStreamTake = 0;
static void buildPalette(jlSurfaceT *screen) {
uint16_t colors[JL_SURFACE_COLORS_PER_PALETTE];
uint16_t i;
for (i = 0; i < JL_SURFACE_COLORS_PER_PALETTE; i++) {
colors[i] = 0x0000;
}
colors[COLOR_BG] = 0x0000;
colors[COLOR_HINT] = 0x0444;
colors[COLOR_BAR] = 0x00F0;
jlPaletteSet(screen, 0, colors);
}
// Visual feedback for "audio is running, but you cannot tell on a
// stub HAL": pulse a horizontal bar between the hint color and the
// active color whenever a sound has fired this frame.
static void initialPaint(jlSurfaceT *screen) {
jlSurfaceClear(screen, COLOR_BG);
jlFillRect(screen, BAR_X, BAR_Y, BAR_W, BAR_H, COLOR_HINT);
jlStagePresent();
}
// True when the player pressed key this frame -- or, in the probe build,
// when the timetable presses it.
static bool keyPressed(jlKeyE key, uint16_t frame) {
return jlKeyPressed(key) || probeKey(frame) == key;
}
// Read an entire DATA/ file into a freshly allocated buffer. Caller must
// free(*outBytes) once the audio engine has consumed the data.
// Returns false if the file is missing, empty, or larger than maxLen.
static bool loadFile(const char *name, uint32_t maxLen, uint8_t **outBytes, uint32_t *outLen) {
FILE *fp;
long fileSize;
uint8_t *buf;
size_t readBytes;
fp = jlDataOpen(name, "rb");
if (fp == NULL) {
return false;
}
if (fseek(fp, 0L, SEEK_END) != 0) {
fclose(fp);
return false;
}
fileSize = ftell(fp);
if (fileSize <= 0 || (uint32_t)fileSize > maxLen) {
fclose(fp);
return false;
}
if (fseek(fp, 0L, SEEK_SET) != 0) {
fclose(fp);
return false;
}
buf = (uint8_t *)malloc((size_t)fileSize);
if (buf == NULL) {
fclose(fp);
return false;
}
readBytes = fread(buf, 1, (size_t)fileSize, fp);
fclose(fp);
if (readBytes != (size_t)fileSize) {
free(buf);
return false;
}
*outBytes = buf;
*outLen = (uint32_t)fileSize;
return true;
}
// One frame of the noise sweep, sweepFrame frames in: this frame's pitch,
// or silence once every pitch has had its turn. Re-asserted every frame,
// because the IIgs holds a noise burst for only ~80 ms. Returns false when
// the sweep is over.
static bool noiseStep(uint16_t sweepFrame) {
uint16_t pitch;
pitch = (uint16_t)(sweepFrame / NOISE_STEP_FRAMES);
if (pitch >= NOISE_PITCHES) {
jlAudioNoise(0, ATTEN_SILENT);
return false;
}
jlAudioNoise((uint8_t)pitch, NOISE_ATTEN);
return true;
}
#if defined(AUDIO_PROBE)
// The key the probe build presses this frame: N once, then S over and over,
// so a gate finds the noise sweep and a stream running.
static jlKeyE probeKey(uint16_t frame) {
if (frame == PROBE_NOISE_FRAME) {
return JL_KEY_N;
}
if (frame % PROBE_STREAM_FRAMES == PROBE_STREAM_FIRST) {
return JL_KEY_S;
}
return JL_KEY_NONE;
}
#else
static jlKeyE probeKey(uint16_t frame) {
(void)frame;
return JL_KEY_NONE;
}
#endif
// One sample of the streamed tone in this port's raw stream format.
static uint8_t rawSample(int16_t level) {
if (gRawFormat == JL_SFX_RAW_UNSIGNED8_NOZERO) {
return (uint8_t)(level + RAW_BIAS);
}
return (uint8_t)(int8_t)level;
}
// The stream's fill callback: up to count samples of the falling tone,
// written straight in the raw format; 0 once it has all been delivered,
// which ends the stream. A block's pitch and level are worked out once,
// so each sample costs an add and a store -- it may run in an interrupt.
static uint32_t streamFill(void *ctx, int8_t *dst, uint32_t count) {
StreamT *stream;
uint8_t *out;
uint32_t done;
uint16_t run;
uint16_t i;
uint16_t block;
uint16_t hz;
uint16_t step;
int16_t level;
uint8_t hi;
uint8_t lo;
stream = (StreamT *)ctx;
out = (uint8_t *)dst;
done = 0;
while (done < count && stream->pos < STREAM_SAMPLES) {
block = (uint16_t)(stream->pos / STREAM_BLOCK);
run = (uint16_t)(STREAM_BLOCK - stream->pos % STREAM_BLOCK);
if (run > count - done) {
run = (uint16_t)(count - done);
}
hz = (uint16_t)(stream->startHz - (STREAM_DROP_HZ * block) / STREAM_BLOCKS);
step = (uint16_t)(((uint32_t)hz << STREAM_PHASE_BITS) / STREAM_RATE_HZ);
level = (int16_t)(STREAM_PEAK - ((STREAM_PEAK - STREAM_FLOOR) * (int16_t)block) / (int16_t)STREAM_BLOCKS);
hi = rawSample(level);
lo = rawSample((int16_t)-level);
for (i = 0; i < run; i++) {
stream->phase = (uint16_t)(stream->phase + step);
*out++ = (stream->phase & STREAM_PHASE_HALF) ? hi : lo;
}
done += run;
stream->pos += run;
}
return done;
}
// Start the falling tone over, a take higher than the last one.
static void streamStart(void) {
jlAudioStopSfx(STREAM_SLOT);
gStream.pos = 0;
gStream.phase = 0;
gStream.startHz = (uint16_t)(STREAM_START_HZ + STREAM_TAKE_HZ * gStreamTake);
gStreamTake = (uint8_t)((gStreamTake + 1u) % STREAM_TAKES);
jlAudioPlaySfxStreamRaw(STREAM_SLOT, streamFill, &gStream, STREAM_RATE_HZ);
}
int main(void) {
jlConfigT config;
jlSurfaceT *screen;
bool musicPaused;
bool noiseOn;
bool noiseResumes;
uint16_t noiseFrame;
uint16_t frame;
int16_t flashFrames;
uint8_t *modBytes;
uint32_t modLen;
uint8_t *sfxBytes;
uint32_t sfxLen;
config.codegenBytes = 8 * 1024;
config.noAudio = false;
if (!jlInit(&config)) {
fprintf(stderr, "jlInit failed: %s\n", jlLastError());
return 1;
}
screen = jlStageGet();
if (screen == NULL) {
fprintf(stderr, "jlStageGet returned NULL\n");
jlShutdown();
return 1;
}
modBytes = NULL;
sfxBytes = NULL;
modLen = 0;
sfxLen = 0;
// Keep the MOD bytes resident so M can (re)start it later;
// jlAudioPlayMod copies them at play time.
(void)loadFile(TEST_MOD_NAME, DATA_FILE_MAX, &modBytes, &modLen);
(void)loadFile(TEST_SFX_NAME, DATA_FILE_MAX, &sfxBytes, &sfxLen);
gRawFormat = jlAudioSfxRawFormat();
jlMusicPlay(gDemoSong, (uint32_t)sizeof(gDemoSong), true);
buildPalette(screen);
jlScbSetRange(screen, 0, JL_SURFACE_HEIGHT - 1, 0);
initialPaint(screen);
musicPaused = false;
noiseOn = false;
noiseResumes = false;
noiseFrame = 0;
flashFrames = 0;
for (frame = 0; ; frame++) {
jlWaitVBL();
jlInputPoll();
jlAudioFrameTick();
if (keyPressed(JL_KEY_ESCAPE, frame)) {
break;
}
if (keyPressed(JL_KEY_SPACE, frame) && sfxBytes != NULL) {
jlAudioPlaySfx(SFX_SLOT, sfxBytes, sfxLen, SFX_RATE_HZ);
flashFrames = FLASH_FRAMES;
}
if (keyPressed(JL_KEY_S, frame)) {
streamStart();
flashFrames = FLASH_FRAMES;
}
if (keyPressed(JL_KEY_N, frame) && !noiseOn) {
// The song's own drums would fight the sweep for the noise
// generator, so it pauses and picks up again afterwards.
noiseResumes = jlMusicIsPlaying() && !musicPaused;
jlMusicPause();
noiseOn = true;
noiseFrame = 0;
}
if (keyPressed(JL_KEY_M, frame) && modBytes != NULL) {
jlMusicStop();
jlAudioPlayMod(modBytes, modLen, true);
}
if (keyPressed(JL_KEY_T, frame)) {
jlAudioStopMod();
jlMusicPlay(gDemoSong, (uint32_t)sizeof(gDemoSong), true);
}
if (keyPressed(JL_KEY_P, frame)) {
if (musicPaused) {
jlMusicResume();
} else {
jlMusicPause();
}
musicPaused = !musicPaused;
}
if (noiseOn) {
noiseOn = noiseStep(noiseFrame);
noiseFrame++;
if (!noiseOn && noiseResumes && !musicPaused) {
jlMusicResume();
}
}
if (flashFrames > 0) {
jlFillRect(screen, BAR_X, BAR_Y, BAR_W, BAR_H, COLOR_BAR);
jlStagePresent();
flashFrames--;
if (flashFrames == 0) {
jlFillRect(screen, BAR_X, BAR_Y, BAR_W, BAR_H, COLOR_HINT);
jlStagePresent();
}
}
}
jlMusicStop();
jlAudioStopMod();
if (modBytes != NULL) {
free(modBytes);
}
if (sfxBytes != NULL) {
free(sfxBytes);
}
jlShutdown();
return 0;
}