From 4782dbb7d27af4851dfa870383e0bce9ceb0f9f1 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 27 Oct 2026 23:26:55 -0500 Subject: [PATCH] Add pure PSG and PIT effect state merger --- docs/FX-MERGE.md | 44 ++++++++++++++++++++++++++++++++++++++++++ sound/FXMERGE.C | 32 ++++++++++++++++++++++++++++++ sound/FXMERGE.H | 20 +++++++++++++++++++ sound/tests/fxmerge.c | 29 ++++++++++++++++++++++++++++ sound/tests/fxmerge.py | 16 +++++++++++++++ sound/tests/run.py | 1 + 6 files changed, 142 insertions(+) create mode 100644 docs/FX-MERGE.md create mode 100644 sound/FXMERGE.C create mode 100644 sound/FXMERGE.H create mode 100644 sound/tests/fxmerge.c create mode 100644 sound/tests/fxmerge.py diff --git a/docs/FX-MERGE.md b/docs/FX-MERGE.md new file mode 100644 index 0000000..c5da9ba --- /dev/null +++ b/docs/FX-MERGE.md @@ -0,0 +1,44 @@ +# Prepared effects and music + +`sound/FXMERGE.H` and `FXMERGE.C` provide a pure C89 state merger and delta +encoder. They own no timer, hardware, memory allocation, or sound claim. +The application supplies a validated base PSG/PIT state and five effect +slots: tone0, tone1, tone2, noise, and speaker. Periods are 1..1023, +attenuation is 0..15, noise mode is 0..7, and PIT is 0 (off) or 151..65535. +Inactive slots restore the current base state. A new trigger replaces its +lane's prior effect; cursor, retrigger and clock policy belong to the producer. + +Pitched noise sets `borrow2` in slot3. It wins over both music and an effect +on tone2, sets that divider, and mutes tone2's audible output. The reservation +continues during gated/silent rows. When the noise slot becomes inactive, +tone2's current state returns, including a tone effect that continued advancing +while hidden. Fixed-rate noise leaves tone2 alone. An active speaker slot +replaces the current base PIT divisor, including zero for a gate-off row. + +`FXMMerge(out,base,layers)` produces a complete state. `FXMBytes` compares it +with the last emitted state and writes at most 11 bytes into a 16-byte buffer. +Set `initial` only at whole-owner startup. Set `noiseAttack` for an actual +noise retrigger, including restoration of a sounding base noise voice. +Other updates write the noise level without resetting the LFSR. Buffer and +input objects must not overlap; out may alias base. Inputs must be validated. + +An owning application should merge first, publish the resulting timestamped +PSG/PIT writes to its existing transport, then cache the complete state. +Never insert Effects Lab `FXBytes`' initial whole-PSG mute sequence into an +individual song lane. The compiler's trajectory rows can feed the slots +directly. These are synthesized PSG/PIT effects; there is no PCM layer. + +OEMSound Beats Lab supplies the application producer, TFX1 patch import, +per-step transposition and DWA2 song persistence. libwintandy's +`libtandywin/AUDIO/SOURCE/FXMERGE.*` is the matching Windows source mirror. +Existing MIDI banks, driver APIs, and transport ordinals are unchanged. + +Run the standalone check on Windows with the managed GCC launcher: + +``` +python sound/tests/fxmerge.py --out +``` + +The merger uses 78 bytes of state/layers on a 16-bit target, plus the +caller's previous state and output buffer. It contains no tables. Host checks +do not establish native code size, IRQ timing, or audible quality. diff --git a/sound/FXMERGE.C b/sound/FXMERGE.C new file mode 100644 index 0000000..c1b5712 --- /dev/null +++ b/sound/FXMERGE.C @@ -0,0 +1,32 @@ +/* SPDX-License-Identifier: MIT */ +#include "FXMERGE.H" +void FXMMerge(FXMSTATE *out,const FXMSTATE *base,const FXMLAYER *fx) +{ + unsigned i;*out=*base; + for(i=0;i<3;++i)if(fx[i].active){ + out->period[i]=fx[i].period;out->level[i]=fx[i].level; + } + if(fx[3].active){ + out->noise=fx[3].noise;out->level[3]=fx[3].level; + if(fx[3].borrow2){out->period[2]=fx[3].period;out->level[2]=15;} + } + if(fx[4].active)out->pit=fx[4].pit; +} +unsigned FXMBytes(const FXMSTATE *s,const FXMSTATE *old,unsigned initial, + unsigned noiseAttack,unsigned char *b) +{ + unsigned i,n=0,p; + for(i=0;i<3;++i){p=s->period[i]; + if(initial||p!=old->period[i]){ + b[n++]=(unsigned char)(0x80|(i<<5)|(p&15)); + b[n++]=(unsigned char)(p>>4); + } + if(initial||s->level[i]!=old->level[i]) + b[n++]=(unsigned char)(0x90|(i<<5)|s->level[i]); + } + if(initial||noiseAttack||s->noise!=old->noise) + b[n++]=(unsigned char)(0xe0|s->noise); + if(initial||s->level[3]!=old->level[3]) + b[n++]=(unsigned char)(0xf0|s->level[3]); + return n; +} diff --git a/sound/FXMERGE.H b/sound/FXMERGE.H new file mode 100644 index 0000000..d4f6470 --- /dev/null +++ b/sound/FXMERGE.H @@ -0,0 +1,20 @@ +/* SPDX-License-Identifier: MIT. Pure C89 PSG/PIT state merger. No IO/clock. */ +#ifndef FXMERGE_H +#define FXMERGE_H +typedef struct { + unsigned period[3],level[4],noise,pit; +} FXMSTATE; +/* Slots 0..2 are tones, 3 noise, 4 speaker. Inactive slots restore base. + borrow2 is valid only for noise: it owns tone2's divider AND mutes tone2. + It wins over slot2 until inactive, even during a gated/silent noise row. + A speaker slot replaces base.pit. Retrigger/cursor ownership is caller's. */ +typedef struct { + unsigned active,period,level,noise,pit,borrow2; +} FXMLAYER; +void FXMMerge(FXMSTATE *,const FXMSTATE *,const FXMLAYER *); +/* Delta encoder: at most 11 bytes. Noise latch written only on mode change, + explicit noise attack, or initial state; level-only rows never reset LFSR. + initial is a whole-owner start, never a per-effect global mute prefix. */ +unsigned FXMBytes(const FXMSTATE *,const FXMSTATE *,unsigned,unsigned, + unsigned char *); +#endif diff --git a/sound/tests/fxmerge.c b/sound/tests/fxmerge.c new file mode 100644 index 0000000..bbaf5db --- /dev/null +++ b/sound/tests/fxmerge.c @@ -0,0 +1,29 @@ +/* SPDX-License-Identifier: MIT */ +#include +#include +#include "FXMERGE.H" +static unsigned checks; +#define CK(x) do{++checks;if(!(x)){printf("FAIL %u %s\n",(unsigned)__LINE__,#x);return 1;}}while(0) +int main(void) +{ + FXMSTATE base,out,old;FXMLAYER fx[5];unsigned i,n;unsigned char bytes[16]; + memset(&base,0,sizeof(base));memset(fx,0,sizeof(fx)); + for(i=0;i<3;++i){base.period[i]=100+i;base.level[i]=i+2;} + base.noise=4;base.level[3]=6;base.pit=21000; + FXMMerge(&out,&base,fx);CK(!memcmp(&out,&base,sizeof(out))); + fx[0].active=1;fx[0].period=400;fx[0].level=1; + fx[2].active=1;fx[2].period=500;fx[2].level=3; + fx[3].active=fx[3].borrow2=1;fx[3].period=600;fx[3].level=2;fx[3].noise=7; + fx[4].active=1;fx[4].pit=1234;FXMMerge(&out,&base,fx); + CK(out.period[0]==400&&out.level[0]==1&&out.period[1]==101&&out.level[1]==3); + CK(out.period[2]==600&&out.level[2]==15&&out.level[3]==2&&out.noise==7&&out.pit==1234); + fx[3].level=15;FXMMerge(&out,&base,fx);CK(out.level[2]==15); + fx[3].active=fx[4].active=0;FXMMerge(&out,&base,fx); + CK(out.period[2]==500&&out.level[2]==3&&out.noise==4&&out.pit==21000); + old=out;CK(!FXMBytes(&out,&old,0,0,bytes)); + out.level[3]=4;n=FXMBytes(&out,&old,0,0,bytes);CK(n==1&&bytes[0]==0xf4); + n=FXMBytes(&out,&old,0,1,bytes);CK(n==2&&bytes[0]==0xe4&&bytes[1]==0xf4); + n=FXMBytes(&out,&old,1,0,bytes);CK(n==11); + for(i=0;i<3;++i)CK(bytes[i*3+2]!=(0x9f|(i<<5))); + printf("PASS shared FX merger checks=%u\n",checks);return 0; +} diff --git a/sound/tests/fxmerge.py b/sound/tests/fxmerge.py new file mode 100644 index 0000000..0f776c9 --- /dev/null +++ b/sound/tests/fxmerge.py @@ -0,0 +1,16 @@ +"""Standalone pure C89 merger checks, also usable against a Windows mirror.""" +from pathlib import Path +import argparse, hashlib, json, os, subprocess, sys +p=argparse.ArgumentParser(description=__doc__) +p.add_argument('--source',type=Path,default=Path(__file__).resolve().parents[1]) +p.add_argument('--out',type=Path,required=True) +a=p.parse_args();out=a.out.resolve();out.mkdir(parents=True,exist_ok=False) +source=a.source.resolve();test=Path(__file__).with_suffix('.c') +cc=[sys.executable,'C:/Users/chad/Documents/Codex/toolchains/gcc-ucrt64.py','--'] if os.name=='nt' else ['cc'] +env=dict(os.environ) +if os.name=='nt':env['PATH']='C:/msys64/ucrt64/bin;'+env.get('PATH','') +cmd=cc+['-x','c','-std=c89','-pedantic-errors','-Wall','-Wextra','-Werror','-I',str(source),str(source/'FXMERGE.C'),str(test),'-o',str(out/'fxmerge.exe')] +subprocess.run(cmd,check=True,env=env,timeout=30) +r=subprocess.run([str(out/'fxmerge.exe')],check=True,capture_output=True,text=True,env=env,timeout=10) +(out/'RESULT.JSON').write_text(json.dumps(dict(status='PASS',result=r.stdout.strip(),sources={str(s):hashlib.sha256(s.read_bytes()).hexdigest() for s in (source/'FXMERGE.C',source/'FXMERGE.H',test)}),indent=2)) +print(r.stdout.strip()) diff --git a/sound/tests/run.py b/sound/tests/run.py index 7dfaed5..f20327e 100644 --- a/sound/tests/run.py +++ b/sound/tests/run.py @@ -23,6 +23,7 @@ def main(): out.mkdir(parents=True, exist_ok=True) for name, extra, defs in (('portable_midi', ['SMF.C'], []), ('bank_test', [], []), + ('fxmerge', ['FXMERGE.C'], []), ('trace_default', [], []), ('trace_default', [], ['-DWITH_BANK'])): tag = name + ('_bank' if defs else '')