424 lines
14 KiB
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;
|
|
}
|