DVX_GUI/src/libs/kpunch/audio/audio.dhs
2026-09-29 17:03:51 -05:00

229 lines
21 KiB
Text

# The MIT License (MIT)
#
# Copyright (C) 2026 Scott Duensing
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
.section Technical Reference (for Developers)|Libraries
.topic lib.audio
.title Sound Library
.toc 0 Sound Library
.index audio
.index audio.h
.index audioDrv.h
.index audioOpen
.index audioClose
.index audioPlay
.index audioPlayFile
.index audioTone
.index audioStreamOpen
.index audioStreamWrite
.index audioSampleLoad
.index audioGetInfo
.index AudioInfoT
.index AudioDriverT
.index sound
.index sound.bas
.index PlayWave
.index PlayTone
.index SoundInfo$
.index Sound Blaster
.index PC speaker
.index audioFmAlloc
.index audioFmWrite
.index audioFmRawAcquire
.index audioMidiAcquire
.index AudioSynthDriverT
.index OPL
.index MPU-401
.index audioNotesOpen
.index audioNotesAdd
.index AudioNotesT
.index audioDeskPlay
.index audioDeskPlayFile
.index audioDriverNames
.index Desktop sounds
.h1 Sound Library
Sound for DVX programs through whatever sound hardware the computer has: sampled sound from WAV and VOC files, tones and noise, and streams a program feeds itself (music, speech). Every program opens its own audio context; any number of programs play at the same time through one mixer, and everything a program opened is stopped and released when it ends, normally, killed or crashed.
Header: audio/audio.h (programs), audio/audioDrv.h (sound drivers)
Loaded as: bin/libs/kpunch/audio/audio.lib, drivers in bin/libs/kpunch/audio/drivers/*.drv
.h2 Contexts and the Device
.code
AudioCtxT *audio = audioOpen(); // NULL: no driver could start
int32_t voice = audioPlayFile(audio, "BOOM.WAV", AUDIO_VOLUME_MAX, AUDIO_PAN_CENTER, false);
if (voice < 0) {
dvxLog("no boom: %s", audioErrorText(voice));
}
...
audioClose(audio); // or let the program end
.endcode
The first audioOpen starts the sound device and the last audioClose stops it. In between, after AUDIO_IDLE_CLOSE_SEC (3) seconds with nothing playing, the device's output is closed so it costs no CPU time (desktop sounds would otherwise keep it mixing silence), and the next sound opens it again with the same driver before it starts, so nothing of it is lost; audioGetInfo still reports the device. Functions returning int32_t give AUDIO_OK or a voice id (above 0) on success and a negative AUDIO_ERR_* code on failure; audioErrorText says what a code means. audioGetInfo describes the device: the driver's name and description, the mixing rate it achieved, its output bits and channels, the voice limit, and whether it plays sampled sound (hasDac), has a hardware synthesizer (hasSynth) or plays tones on a hardware tone generator (hasTone, the PC speaker). audioGetStats counts mixed blocks and frames, stream underruns, clipped samples, dropped voices and the voices playing.
None of the functions may be called from an interrupt (a timer-service callback).
.h2 Samples and Voices
audioSampleLoad (file), audioSampleLoadMem (memory) and audioSampleCreate (raw PCM in an AudioFormatT: rate, 8 bits unsigned or 16 bits signed, 1 or 2 channels) make an AudioSampleT; audioSampleFree frees it and stops the voices playing it. audioPlay(sample, volume, pan, loop) plays it on a new voice; audioPlayFile loads a file and plays it, freeing the sample when the voice ends.
.table
Setting Range
------- -----
volume 0 .. AUDIO_VOLUME_MAX (256)
pan AUDIO_PAN_LEFT (-256) .. AUDIO_PAN_RIGHT (256); centre plays at full volume on both sides and panning turns the other side down
pitch AUDIO_PITCH_MIN (64) .. AUDIO_PITCH_MAX (1024), AUDIO_PITCH_NORMAL (256) = recorded speed
.endtable
audioVoiceSetVolume, audioVoiceSetPan, audioVoiceSetPitch, audioVoiceStop and audioVoiceIsPlaying take the context and the voice id; a voice id of another context, or of a voice that has ended, gives AUDIO_ERR_VOICE. audioStopAll stops every sample and tone of a context.
audioSetEndFn(ctx, fn, userData) asks for a call when a voice of ctx ends, with the reason: AUDIO_END_FINISHED (played out), AUDIO_END_STOPPED (stopped, or its sample or stream went away) or AUDIO_END_DROPPED (the voice limit made room for a louder sound). The call comes from audioPump or the shell's idle time, as the program that owns the context, never from an interrupt. The mixer runs up to one buffer (about 50 ms) ahead of what is heard, so a notification can come that much before the sound has finished playing.
.h2 Tones
audioTone(ctx, wave, hz, volume, ms) plays a square (AUDIO_WAVE_SQUARE), triangle (AUDIO_WAVE_TRIANGLE) or noise (AUDIO_WAVE_NOISE) tone of hz (AUDIO_TONE_MIN_HZ 20 to AUDIO_TONE_MAX_HZ 20000) for ms milliseconds, or until stopped when ms is 0. For noise, hz is how fast the noise changes: higher sounds brighter. audioVoiceSetFrequency changes a playing tone. When the computer has an FM chip (and DVX.INI [audio] fmTones is not false), a square or triangle tone plays on a free FM voice (a square as a carrier modulated at twice its pitch, which gives odd harmonics like a square wave; a triangle as a sine) at up to the chip's highest note (about 6,200 Hz); when every FM voice is taken it is mixed like any sound. Its voice id, volume, pan, pitch and end notification work the same either way. On a device with a DAC tones are mixed like any sound; on the PC speaker in its tones mode the loudest square or triangle tone playing is sounded by the timer chip.
.h2 Note Queues
.code
AudioNotesT *tune = NULL;
audioNotesOpen(audio, AUDIO_WAVE_SQUARE, AUDIO_VOLUME_MAX, &tune);
audioNotesAdd(tune, 440 * AUDIO_MILLIHZ_PER_HZ, 250000); // A for 1/4 s
audioNotesAdd(tune, 0, 50000); // a rest
.endcode
A note queue plays tones and rests one after the other, each lasting exactly as long as asked (its length is counted in output frames from the time queued since the queue last went quiet, so many short notes do not drift). audioNotesAdd(notes, milliHz, us) appends a tone of milliHz thousandths of a hertz (AUDIO_TONE_MIN_HZ to AUDIO_TONE_MAX_HZ, or 0 for a rest) lasting us microseconds; up to AUDIO_NOTES_MAX entries may wait (AUDIO_ERR_BUSY beyond). audioNotesQueued says how many entries have not finished (and how many of them are tones), audioNotesSpace how many more fit, audioNotesClear silences the queue at once. The queue plays on its own voice (audioNotesVoice, for volume and pan) that the voice limit never drops, in the mixer or on the PC speaker's tone generator (never on the FM chip); it is silent while empty. music.lib plays QBasic's PLAY and SOUND through note queues.
.h2 Streams
.code
AudioFormatT fmt = { 22050, 16, 2 };
AudioStreamT *stream = NULL;
audioStreamOpen(audio, &fmt, 250, fillMusic, &player, &stream);
.endcode
audioStreamOpen(ctx, fmt, bufferMs, fill, userData, &stream) opens a stream of any rate from AUDIO_RATE_MIN (4000) to AUDIO_RATE_MAX (48000), 8 or 16 bits, mono or stereo, with a buffer of bufferMs milliseconds (0: 250). It plays at once on its own voice (audioStreamVoice), silence until samples arrive. Without fill the program writes samples with audioStreamWrite, which takes as many frames as fit and returns how many; audioStreamSpace and audioStreamQueued tell how much room and how much sound are left. With fill, the library asks for samples whenever the buffer has room: fill(stream, buf, frames, userData) writes up to frames frames in the stream's format and returns how many. Fill functions run from audioPump and the shell's idle time; a program that keeps the desktop busy every frame should call audioPump once a frame. A stream that runs dry plays silence and counts underruns. audioStreamClose stops and frees it.
.h2 FM Synthesizer
.code
int32_t v = audioFmAlloc(audio); // AUDIO_ERR_NO_VOICE: all taken
audioFmWrite(audio, v, AUDIO_FM_CAR_LEVEL, 0);
audioFmWrite(audio, v, AUDIO_FM_FNUM_LOW, fnum & 0xFF);
audioFmWrite(audio, v, AUDIO_FM_KEY_BLOCK, AUDIO_FM_KEY_ON | (block << AUDIO_FM_BLOCK_SHIFT) | (fnum >> AUDIO_FM_FNUM_HI_SHIFT));
audioFmFree(audio, v); // key off, back to everyone
.endcode
An OPL2 (9 voices) or OPL3 (18 voices, each playing left, right or both) found by the opl driver is shared by every program: audioFmAlloc(ctx) takes a two-operator voice (the least recently given back first, so a released note's tail is cut last) and returns its id, AUDIO_ERR_NO_VOICE when all are taken, AUDIO_ERR_BUSY while a program has the whole chip, AUDIO_ERR_NO_SYNTH without a chip. audioFmWrite(ctx, voice, reg, value) sets a register of the voice's channel, numbered as for the first channel of an OPL2 (AUDIO_FM_MOD_CHAR 20h, AUDIO_FM_CAR_CHAR 23h, levels 40h/43h, attack 60h/63h, sustain 80h/83h, waveforms E0h/E3h, AUDIO_FM_FNUM_LOW A0h, AUDIO_FM_KEY_BLOCK B0h, AUDIO_FM_FEEDBACK C0h); the library adds the channel's offsets and bank. On an OPL3 a feedback value without an output bit (AUDIO_FM_OUT_LEFT, AUDIO_FM_OUT_RIGHT) plays on both sides. Frequencies: hz = fnum x AUDIO_FM_CHIP_HZ (49,716) x 2^block / 2^20. audioFmFree keys the voice off and gives it back; closing the context gives back all of its voices. audioFmGetInfo tells the chip, its voices and how many are free.
audioFmRawAcquire(ctx) gives one program the whole chip: every voice the others hold is keyed off and taken back (their tones go on in the mixer), new voices are refused, and audioFmRawWrite(ctx, reg, value) sets any register (bank x AUDIO_FM_BANK_REGS + index). A second program asking gets AUDIO_ERR_BUSY. audioFmRawRelease (or closing the context) silences the chip, sets it up again and gives its voices back to everyone.
audioFmAlloc, audioFmFree, audioFmWrite and audioFmRawWrite may also be called from a timer callback (platformTimerAdd); music.lib plays MIDI that way.
.h2 MIDI Port
With DVX.INI [audio] midi = mpu401 the mpu401 driver opens an MPU-401 (or compatible, such as a Sound Blaster 16's) in UART mode, for an external synthesizer such as a Roland MT-32 or SC-55. The port belongs to one program at a time: audioMidiAcquire(ctx) (AUDIO_ERR_BUSY when another has it, AUDIO_ERR_NO_SYNTH without a port), audioMidiPut(ctx, byte) sends a byte and returns AUDIO_ERR_BUSY when the port cannot take one yet (send it again later; it may be called from a timer callback), audioMidiRelease gives it back (closing the context does too). audioMidiGetInfo tells whether there is a port and whether it is in use. music.lib uses the port for MIDI music.
.h2 Volume and the Voice Limit
audioSetMasterVolume sets the volume of all sound (DVX.INI [audio] volume, in percent, sets it when the device starts); audioSetVolume sets the volume of everything one context plays. At most the voice limit (DVX.INI [audio] voices, 1 to AUDIO_VOICES_MAX, default 16) plays at once. Starting another sample or tone then stops the quietest one playing (it ends with AUDIO_END_DROPPED); when the new sound would itself be the quietest it is not started (AUDIO_ERR_NO_VOICE). Streams are never dropped.
.h2 Files
WAV: uncompressed PCM, 8-bit unsigned or 16-bit signed, mono or stereo, 4000 to 48000 Hz; WAVE_FORMAT_EXTENSIBLE with the PCM sub-format is accepted, other chunks are skipped, and a data chunk that claims more bytes than the file holds plays what is there. VOC (Creative Voice File, header checksum checked): sound blocks with 8-bit unsigned samples, continuation, silence, extended (rate and stereo for the next sound block) and new-format blocks with 8-bit or 16-bit PCM; markers and text are skipped, and a repeated section plays once. Compressed sound (ADPCM, 24-bit, floating point) gives AUDIO_ERR_UNSUPPORTED, a damaged file AUDIO_ERR_CORRUPT, a file that is neither AUDIO_ERR_FORMAT.
.h2 The Mixer
All sound is mixed in 16-bit integers at the rate the device achieved (the mixer never assumes the rate it asked for: a Sound Blaster Pro asked for 22,050 Hz plays at 22,222 Hz), and every source is resampled to it with linear interpolation. Gains combine volume, pan, context volume and master volume; the sum is clipped to 16 bits. The mixer runs from the sound card's own interrupt (the Sound Blaster, at the end of each half buffer of about 23 ms), or from the timer service for devices without one, so sound keeps playing however busy the programs are. It uses no floating point and touches only memory pinned for interrupts (sample and stream buffers are pinned when they are made).
.h2 Settings
DVX.INI section [audio]:
.table
Key Meaning
--- -------
driver auto (default: find the hardware), or a driver name: sb, speaker, null
rate mixing rate asked for, 4000 to 48000 (default 22050)
bits 0 (the device's best), 8 or 16
channels 0 (the device's best), 1 or 2
voices voices mixed at once, 1 to 32 (default 16)
volume master volume in percent (default 100)
speakerPcm true: sampled sound on the PC speaker (see below); default false
fmTones false: tones always in the mixer, never on FM voices; default true
oplPort the FM chip's port, hex (default 388)
midi where MIDI music plays: auto (default: the FM chip, else music.lib's software synthesizer), mpu401, fm, software
mpuPort the MPU-401's port, hex (default 330); the port is only looked for with midi = mpu401
mpuMode what is on the MPU-401 (music.lib): gm (default), mt32 or raw
midiBank the OP2 instrument bank for FM MIDI (music.lib), a path from the DVX folder
.endtable
.h2 Desktop Sounds
The library plays the sounds DVX makes for its events (dvxPlaySound in dvxApp.h): when it loads it sets DVX's sound hook to audioDeskPlay(event, wait). The file of each event is read from DVX.INI [sounds] at the first sound: the key is audioDeskKey(event) and the value a WAV or VOC file (a path from the DVX folder), empty or missing for none; audioDeskReload makes the next sound read the section again (the Control Panel calls it after saving). The sounds play on a context of the library's own, so no program's end cuts one short, at full volume under the master volume. When no sound device starts, the sounds stay silent without trying again until the next reload. With wait the call returns once the sound has played (at most 5 seconds); after the exit sound further events are quiet (DVX is ending). audioDeskLabel(event) names an event for people, audioDeskPlayFile(path) plays any file the same way (to try one out). The default set, made by DVX's own sound generator at build time, is in AUDIO_SOUNDS_DIR (config/sounds).
.table
Key Event
--- -----
default the default beep (dvxBeep; a message box without an icon)
startup DVX started
exit DVX is ending
error a message box with MB_ICONERROR
warning MB_ICONWARNING
question MB_ICONQUESTION
information MB_ICONINFO
windowOpen a window opened (not a dialog)
windowClose a window closed (not a dialog)
.endtable
.h2 Drivers
.table
Driver Device
------ ------
sb Sound Blaster family by DSP version: 1.x (8-bit single-cycle), 1.5 / 2.0 (8-bit auto-init, high-speed above 23,000 Hz on 2.0), Pro and Pro 2 (8-bit stereo at 11,025 or 22,050 Hz), 16 (8- or 16-bit, mono or stereo, any rate from 5,000 to 44,100 Hz). The port, IRQ and DMA channels come from the BLASTER variable (e.g. SET BLASTER=A220 I5 D1 H5); without it an SB16 is found at 220h or 240h and asked for its IRQ and DMA. If no interrupt arrives from the card within half a second (a wrong IRQ), the next driver is tried.
speaker The PC speaker. By default tones only: the timer chip plays the loudest square or triangle tone as a square wave, with no interrupts and no CPU cost; sampled sound and noise are not heard. With speakerPcm = true it plays everything by pulse-width modulation: the timer interrupt runs at 22,096 Hz and each interrupt sets the next pulse (about 5.7 bits). That costs a lot of CPU time: 22,096 interrupts a second plus the mixing. A 486DX2-66 spends a large share of its time on it (estimated 10 to 15 percent for the interrupts alone on plain DOS, much more under a memory manager or Windows) and can lose interrupts, which slows the sound and the DVX clock down. Meant for Pentium-class machines.
null No sound device: plays nothing, but voices still run their course and end, so programs behave the same.
opl (synthesizer) The OPL2 or OPL3 FM chip of an AdLib or Sound Blaster at 388h (oplPort), found by the AdLib timer test; the status register tells an OPL2 from an OPL3. Register writes are timed by status reads (3.3 and 23 us on an OPL2) with interrupts off around each index/data pair. Closing zeroes every register.
mpu401 (synthesizer) An MPU-401 MIDI port in UART mode at mpuPort, only with midi = mpu401: reset (FFh, answered FEh; tried twice, since a port left in UART mode takes the first FFh as data), then UART mode (3Fh). Bytes are sent when the port says it is ready (a short wait, else the caller sends later); input the port has (active sensing) is read and dropped. Closing sends All Notes Off on every channel and resets the port.
.endtable
audioDriverNames(names, max) lists the short names of the sound drivers installed, the one tried first first (for a settings screen).
Sound drivers are tried in this order: the one DVX.INI names, then from the highest priority down (Sound Blaster, PC speaker, null). The first that starts is used. Synthesizer drivers are opened with the sound device, beside it: of each kind (FM chip, MIDI port) the first that finds its hardware is kept, and they close with the device. The sound device's description and hasSynth in AudioInfoT tell whether there is a synthesizer.
.h2 Writing a Driver
A driver is a driver module (see dvxDrv.h in the API reference): <name>.drv in bin/libs/kpunch/audio/drivers, built by the audio Makefile from each drivers/<name>/ folder, that exports one AudioDriverT named audioDriver, written with AUDIO_DRIVER(name, AUDIO_PRIORITY_*, .open = ..., .play = ..., .close = ...). The descriptor carries DVX_ABI_VERSION; a driver built for another version is refused. audio.lib loads the drivers when the first program opens sound, keeps the one that starts (and the synthesizers that find their hardware) and unloads the rest. open detects the hardware and fills AudioDrvInfoT (description, achieved rate, bits, channels, hasDac, hasSynth, a hardware tone function) without making a sound; play starts output; close stops it and releases everything, after which the mixer is not called again. After a few seconds of silence audio.lib calls close and later open and play again on the same driver, so open must work more than once and must not make a sound (the driver's module stays loaded in between). While playing, the driver calls audioMixRender(buffer, frames) for each block of output from its own interrupt, or calls audioClockStart to have the timer service drive the mixer (the output is thrown away) when it only plays tones or nothing. Code and data an interrupt touches must be pinned: audioLockSpan pins a module's listed functions and data (a module's code comes before its data, so one span covers them), and the library is built without hot/cold function splitting and without loops turned into library calls. Interrupt code must not call the C library, DVX or DOS and must not use floating point. audio.lib stops the device from a shutdown hook if DVX ends without closing it.
A synthesizer driver exports an AudioSynthDriverT named audioSynth instead, written with AUDIO_SYNTH_DRIVER(name, priority, .kind = AUDIO_SYNTH_FM or AUDIO_SYNTH_MIDI, .open = ..., .close = ..., .write = ... or .put = ...). open detects the hardware, fills AudioSynthDrvInfoT (description; for FM the voices and whether it is stereo) and leaves it silent; close silences it. write(reg, value) sets an FM register (bank x AUDIO_FM_BANK_REGS + index) and must keep the index/data pair together (audioIrqOff / audioIrqRestore); put(byte) sends a MIDI byte, returning false when the port is not ready. Both run at interrupt time too (timer callbacks), so they follow the rules above and are pinned by the driver.
.h2 BASIC
src/include/basic/sound.bas (DECLARE LIBRARY "audio"): PlayWave(fileName), PlayWaveLoop(fileName), PlayTone(frequency, milliseconds, volume), PlayNoise(frequency, milliseconds, volume) return a voice number (0 on failure: SoundError$ says why); StopWave(voice), StopAllSounds, SoundPlaying(voice), SoundVolume(percent), SoundInfo$ (the device, e.g. "Sound Blaster 16 (DSP 4.05) at 220h, 22050 Hz, 16-bit stereo"). Volumes are percent. Each BASIC program plays through its own context, closed when it ends.