diff --git a/README.md b/README.md index 642b76e..7c1824d 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,21 @@ on a peak of 32767. - A bar-length linter in the score parser catches more composition bugs than any amount of staring at note lists. +## OPL3 rendering (the authentic one) + +For real Sound Blaster flavour — FM synthesis, no soundfont, like the +actual YMF262 chip: + +```bash +./tools/opl/build.sh # one-time: fetches + builds libADLMIDI +./tools/render_opl.sh # renders all midi/*.mid through OPL3 +OPL_MODEL=4 ./tools/render_opl.sh midi/03_late_equaliser.mid +``` + +Volume models mimic real game sound drivers: `2` NativeOPL3 (id-style), +`3` DMX (Doom, default), `4` APOGEE (Duke Nukem 3D), `8` AIL, `10` HMI. +Output is normalised (OPL runs quiet). Needs `cmake` + `g++` to build. + ## Licence & soundfonts MIT — see [LICENSE](LICENSE). Rendering needs a General MIDI soundfont, diff --git a/tools/opl/build.sh b/tools/opl/build.sh new file mode 100755 index 0000000..bbab148 --- /dev/null +++ b/tools/opl/build.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# build.sh — fetch and build libADLMIDI, then compile render_opl. +# Idempotent: skips anything already present. Run once per machine. +set -euo pipefail +cd "$(dirname "$0")" +LIBDIR="${OPL_LIBDIR:-/opt/libADLMIDI}" + +if [ ! -f "$LIBDIR/build/libADLMIDI.a" ]; then + echo "cloning libADLMIDI..." + git clone --depth 1 -q https://github.com/Wohlstand/libADLMIDI.git "$LIBDIR" + echo "building (a few minutes)..." + cmake -B "$LIBDIR/build" -S "$LIBDIR" -DCMAKE_BUILD_TYPE=Release >/dev/null + cmake --build "$LIBDIR/build" -j"$(nproc)" --target ADLMIDI_static >/dev/null +fi + +g++ -O2 -I "$LIBDIR/include" render_opl.cpp "$LIBDIR/build/libADLMIDI.a" \ + -o render_opl -lpthread +echo "built: $(pwd)/render_opl" diff --git a/tools/opl/render_opl b/tools/opl/render_opl new file mode 100755 index 0000000..372e9a0 Binary files /dev/null and b/tools/opl/render_opl differ diff --git a/tools/opl/render_opl.cpp b/tools/opl/render_opl.cpp new file mode 100644 index 0000000..5b19d41 --- /dev/null +++ b/tools/opl/render_opl.cpp @@ -0,0 +1,92 @@ +// render_opl.cpp — render a MIDI file to WAV through OPL3 FM emulation +// (libADLMIDI), i.e. the authentic Sound Blaster / AdLib sound. No +// soundfont involved: pure FM synthesis, like the real 1993 silicon. +// +// Build: see build.sh in this directory. +// Usage: render_opl in.mid out.wav seconds [volume_model] +// +// Volume models (the fun bit — they mimic real game sound drivers): +// 0 auto 1 generic 2 NativeOPL3 (CMF/id-style) 3 DMX (Doom) +// 4 APOGEE (Duke Nukem 3D) 5 9X 8 AIL (Another World era) +// 10 HMI +#include "adlmidi.h" +#include +#include +#include +#include + +static void wr32(FILE *f, unsigned v) { + unsigned char b[4] = {ADL_UInt8(v), ADL_UInt8(v >> 8), ADL_UInt8(v >> 16), ADL_UInt8(v >> 24)}; + fwrite(b, 1, 4, f); +} +static void wr16(FILE *f, unsigned v) { + unsigned char b[2] = {ADL_UInt8(v), ADL_UInt8(v >> 8)}; + fwrite(b, 1, 2, f); +} + +int main(int argc, char **argv) { + if (argc < 4) { + fprintf(stderr, "usage: render_opl in.mid out.wav seconds [volume_model]\n"); + return 2; + } + const long rate = 44100; + double secs = atof(argv[3]); + int model = (argc > 4) ? atoi(argv[4]) : 3; // default: DMX (Doom-ish) + + ADL_MIDIPlayer *adl = adl_init(rate); + if (!adl) { fprintf(stderr, "adl_init failed\n"); return 1; } + adl_setVolumeRangeModel(adl, model); + adl_setNumChips(adl, 2); // two OPL3s: our arrangements are dense + + if (adl_openFile(adl, argv[1]) != 0) { + fprintf(stderr, "cannot open %s: %s\n", argv[1], adl_errorString()); + adl_close(adl); return 1; + } + + FILE *f = fopen(argv[2], "wb"); + if (!f) { fprintf(stderr, "cannot write %s\n", argv[2]); adl_close(adl); return 1; } + + // WAV header (sizes patched to actual bytes written at the end) + const long data_off = 40; + fwrite("RIFF", 1, 4, f); wr32(f, 0); fwrite("WAVE", 1, 4, f); + fwrite("fmt ", 1, 4, f); wr32(f, 16); wr16(f, 1); wr16(f, 2); + wr32(f, unsigned(rate)); wr32(f, unsigned(rate * 4)); wr16(f, 4); wr16(f, 16); + fwrite("data", 1, 4, f); wr32(f, 0); + + std::vector buf(2048 * 2); + std::vector all; + int frames_done = 0, frames_total = int(secs * rate); + while (frames_done < frames_total) { + int n = frames_total - frames_done; + if (n > 2048) n = 2048; + if (adl_play(adl, n * 2, buf.data()) != 0) { + all.insert(all.end(), buf.begin(), buf.begin() + n * 2); + frames_done += n; + } else { + break; // end of sequence (or error; errorString empty at EOF) + } + } + + // Normalise: OPL FM runs quiet; bring the peak up to ~-17dBFS, capped + // so we never amplify a near-silent track into hiss. + int peak = 0; + for (short s : all) peak = std::max(peak, std::abs(int(s))); + double gain = (peak > 0 && peak < 24000) ? std::min(24000.0 / peak, 12.0) : 1.0; + if (gain != 1.0) { + for (short &s : all) { + int v = int(s * gain); + s = short(v > 32767 ? 32767 : (v < -32767 ? -32767 : v)); + } + } + fprintf(stderr, "peak %d, gain x%.1f\n", peak, gain); + + long bytes_written = long(all.size()) * 2; + fwrite(all.data(), 2, all.size(), f); + fflush(f); + fseek(f, 4, SEEK_SET); wr32(f, unsigned(36 + bytes_written)); + fseek(f, data_off, SEEK_SET); wr32(f, unsigned(bytes_written)); + fclose(f); + adl_close(adl); + fprintf(stderr, "rendered %d frames of OPL3\n", frames_done); + return 0; +} diff --git a/tools/render_opl.sh b/tools/render_opl.sh new file mode 100755 index 0000000..272dd5b --- /dev/null +++ b/tools/render_opl.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# render_opl.sh — render tunes through OPL3 FM (authentic Sound Blaster +# flavour, no soundfont). Usage: +# ./tools/render_opl.sh [tune.mid ...] (default: all midi/*.mid) +# OPL_MODEL=4 ./tools/render_opl.sh ... (4 = Apogee/Duke Nukem volumes) +# +# Volume models: 2 NativeOPL3 (id-style) 3 DMX (Doom, default) +# 4 APOGEE (Duke Nukem 3D) 8 AIL 10 HMI +set -euo pipefail +cd "$(dirname "$0")/.." +[ -x tools/opl/render_opl ] || ./tools/opl/build.sh +mkdir -p build/opl +MODEL="${OPL_MODEL:-3}" + +files=("$@") +if [ ${#files[@]} -eq 0 ]; then files=(midi/*.mid); fi + +for f in "${files[@]}"; do + dur=$(python3 -c " +import sys; sys.path.insert(0,'lib') +from midikit import read +print(read('$f')['duration_sec'] + 3)") + out="build/opl/$(basename "${f%.mid}").wav" + echo "== $f -> $out (model $MODEL)" + ./tools/opl/render_opl "$f" "$out" "$dur" "$MODEL" + python3 tools/check_wav.py "$out" "$dur" +done