Skip to content

sb16: OPL2/OPL3 via AudioWorklet, ADPCM, MPU-401 UART, AdLib compat ports, and mixer/DSP fixes#1517

Open
luckyyyyy wants to merge 9 commits intocopy:masterfrom
luckyyyyy:opl2-audioworklet
Open

sb16: OPL2/OPL3 via AudioWorklet, ADPCM, MPU-401 UART, AdLib compat ports, and mixer/DSP fixes#1517
luckyyyyy wants to merge 9 commits intocopy:masterfrom
luckyyyyy:opl2-audioworklet

Conversation

@luckyyyyy
Copy link
Copy Markdown

@luckyyyyy luckyyyyy commented Mar 8, 2026

Replace the ScriptProcessorNode-based OPL2 synthesizer with an AudioWorklet and implement a large set of previously missing SB16 features.

OPL2/OPL3 Synthesizer (opl2-worklet.js)

  • Port the DBOPL FM synthesizer from DOSBox (GPL v2+) as an AudioWorkletProcessor
  • Full OPL2 operator: ADSR envelope, KSR, tremolo, vibrato, waveform selection (all 8), MASK_SUSTAIN
  • OPL3 mode: 4-op channels (reg 0x104), stereo panning (L/R/both), extended synthesis modes, OPL3 enable (reg 0x105)
  • OPL2 mono output: copy left channel to right
  • Wire OPL through SpeakerMixer so hardware FM volume registers (0x34/0x35) take effect
  • Bank-1 writes (port 0x222/0x223) forwarded as addr | 0x100 so the worklet routes them to the correct OPL3 operator/channel set

AdLib Compatible Ports

Port Dir Before After
0x222 read R returned 0xFF returns 0x00 (OPL3 bits 1-2 LOW, used for chip detection)
0x228 read R returned 0xFF delegates to port2x0_read() — correct timer/status value
0x228 write W no-op sets fm_current_address0 (AdLib compat address latch)
0x229 write W no-op dispatches FM_HANDLERS like port 0x221 (AdLib compat data)

Creative ADPCM Decoding

Commands Format Expansion
0x16 / 0x17 / 0x1F 2-bit ADPCM 4 samples/byte
0x74 / 0x75 / 0x7D 4-bit ADPCM 2 samples/byte
0x76 / 0x77 / 0x7F 3-bit ADPCM 3 samples/byte

Reference-byte and auto-init variants all supported.

DSP Commands

Cmd Function Before After
0x04 ASP set mode register missing implemented; tracks asp_init_in_progress
0x05 ASP set codec parameter missing no-op consuming 2 bytes (matches DOSBox)
0x08 ASP get version missing sub=0x03 returns 0x18 (matches DOSBox)
0x0F ASP get register basic reg 0x83 toggles when init in progress
0x24 8-bit single-cycle DMA ADC empty stub faked: fill DMA with 0x80 silence, raise SB_IRQ_8BIT (mirrors DOSBox DSP_ADC_CallBack)
0x2C 8-bit auto-init DMA ADC silent no-op explicit stub with log (matches DOSBox — not implemented there either)
0x38 MIDI output byte size=0 (byte lost) size=1; byte consumed and discarded
0x80 Silence DAC empty stub fires SB_IRQ_8BIT after computed delay via setTimeout
0xF8 Undocumented pre-SB16 missing returns 0x00 (matches DOSBox)

MPU-401 (port 0x330/0x331)

  • UART mode (command 0x3F) and reset (0xFF) with ACK byte (0xFE)
  • Generic ACK for any unrecognised command (prevents driver hang)
  • Status register: bits 0–5 always set, bit 7 = input-not-ready
  • MIDI data writes silently discarded (no Web MIDI to avoid permission popup)

Mixer Registers

Register Function Before After
0x0A Mic Level (SBPro compat) TODO stub read (mic>>2)&7, write mic=(val&7)<<2|1, shares 0x3A storage
0x0E SBPro Stereo/Filter Select missing bit 1 → dsp_stereo; bit 5 stored for read-back
0x36/0x37 CD Volume L/R TODO stored with bits 2:0 masked (no CD audio source in v86)
0x38/0x39 Line-in Volume L/R TODO stored with bits 2:0 masked (no line-in source)
0x3A Mic Volume (SB16) TODO val>>3 / read val<<3, shared with 0x0A
0x3C Output Mixer Switches TODO stored for read-back
0x3D/0x3E Input Mixer Switches TODO stored for read-back
0x3F/0x40 Input Gain L/R TODO stored
0x43 Mic AGC TODO stored
0x30–0x35 Master/Voice/FM volume wrong defaults reset to 31<<3 (0 dB), correct attenuation formula

Remaining Known Gaps (vs DOSBox)

Area Notes
0x2C auto-init DMA ADC stub; DOSBox also does not implement this
0x91/0x98/0x99 high-speed ADC stub; no recording hardware in v86
MPU-401 intelligent mode UART mode is sufficient for the vast majority of DOS games
GUS (Gravis UltraSound) separate device, out of scope
Tandy Sound (SN76496) separate device, out of scope
Analogue filter (mixer 0x0E bit 5) stored for read-back; no Web Audio biquad filter applied

@luckyyyyy luckyyyyy force-pushed the opl2-audioworklet branch from cbfb8f0 to 01eb760 Compare March 8, 2026 02:21
@luckyyyyy luckyyyyy changed the title sb16: replace ScriptProcessorNode OPL2 with AudioWorkletNode sb16: replace OPL2 synthesizer with AudioWorklet-based DOSBox DBOPL port Mar 8, 2026
Replace the ScriptProcessorNode-based OPL2 synthesizer with an
AudioWorklet-based implementation for lower latency and better
browser compatibility.

OPL2/OPL3 synthesizer:
- Port DBOPL FM synthesizer from DOSBox (GPL v2+) as an AudioWorklet
- Implement full OPL2 operator: ADSR envelope, KSR, tremolo, vibrato,
  waveform selection (all 8 waveforms), MASK_SUSTAIN
- Add OPL3 mode: 4-op channels, panning (left/right/both), extended
  stereo synthesis modes (FM/AM combinations), OPL3 enable flag
- OPL2 mono output: copy left channel to right for stereo playback
- Wire OPL through SpeakerMixer for per-channel volume control

Creative ADPCM decoding (DSP commands):
- 0x16/0x17/0x1F: 2-bit ADPCM (4 samples/byte), single-cycle and auto-init
- 0x74/0x75/0x7D: 4-bit ADPCM (2 samples/byte), single-cycle and auto-init
- 0x76/0x77/0x7F: 3-bit ADPCM (3 samples/byte), single-cycle and auto-init
- Reference byte support for commands 0x17/0x1F/0x75/0x7D/0x77/0x7F

MPU-401 (port 0x330/0x331):
- Implement UART mode (command 0x3F) and reset (0xFF) with ACK
- Status register: bits 0-5 always set, bit 7 = input-not-ready
- MIDI data write silently discarded (no external MIDI output)

Mixer fixes:
- Default SB16 volume registers 0x30-0x35 set to 31<<3 (0 dB) matching
  the hardware reset state
- Volume formula corrected: db = (31-amount)*2, subtract 1 when count>20
  matching SB16 hardware attenuation curve
- OPL gain_hidden=1.5 matching adlib mixer scale factor
- DAC gain_hidden=1 (PCM samples already normalised to -1..+1)
- set_gain_hidden() immediately applies gain to the Web Audio GainNode
- AudioWorkletNode created with outputChannelCount:[2] for stereo output
@luckyyyyy luckyyyyy force-pushed the opl2-audioworklet branch from 509e5ae to 774f21d Compare March 8, 2026 03:16
@luckyyyyy luckyyyyy changed the title sb16: replace OPL2 synthesizer with AudioWorklet-based DOSBox DBOPL port sb16: OPL2 via AudioWorklet, Creative ADPCM decoding, MPU-401 UART, and mixer fixes Mar 8, 2026
… mixer 0x0A/0x0E/0x36-0x43

DSP commands:
- 0x04: ASP set mode register, track asp_init_in_progress flag
- 0x05: ASP set codec parameter (2-byte consume, no-op like DOSBox)
- 0x08: ASP get version (sub=0x03 returns 0x18, matching DOSBox)
- 0x0F: ASP get register; reg 0x83 toggles when init in progress
- 0x24: faked 8-bit DMA ADC — fill DMA memory with 0x80 silence then
  raise SB_IRQ_8BIT, mirroring DOSBox DSP_ADC_CallBack; respects
  channel-mask/unmask via dma_on_unmask
- 0x2C: add explicit handler with log (was silent no-op)
- 0x38: consume the data byte (was registered with size 0)
- 0x80: Silence DAC — fire SB_IRQ_8BIT after computed delay via setTimeout
- 0xF8: undocumented pre-SB16 command returns 0x00 (matches DOSBox)

FM / AdLib ports:
- port2x2_read (0x222): return 0x00; on OPL3 bits 1-2 are LOW — used
  by programs to distinguish OPL3 from OPL2 during chip detection
- port2x8_read (0x228): delegate to port2x0_read() so the OPL2-compat
  status/timer register reads correctly (was returning 0xFF)
- port2x8_write (0x228): set fm_current_address0 (AdLib compat address)
- port2x9_write (0x229): dispatch FM_HANDLERS like port2x1_write (AdLib
  compat data); was a complete no-op

Mixer registers:
- 0x0A: Mic Level — read (mic>>2)&7, write mic=(val&7)<<2|1 sharing
  the 0x3A storage field, matching DOSBox SBT_16 behaviour
- 0x0E: SBPro Stereo/Filter Select — bit 1 drives dsp_stereo, bit 5
  (filter) stored for read-back but not applied (no analogue filter)
- 0x36/0x37: CD Volume L/R — mask bits 2:0 to zero on write (DOSBox
  cda[x]=val>>3 / read cda[x]<<3)
- 0x38/0x39: Line-in Volume L/R — same masking scheme
- 0x3A: Mic Volume (SB16 5-bit field, val>>3 / read val<<3)
- 0x3C: Output Mixer Switches — stored for read-back, no audio source
- 0x3D/0x3E: Input Mixer Switches — same
- 0x3F/0x40: Input Gain L/R — stored
- 0x41/0x42: Output Gain L/R — stored (already had 0x41 handler,
  added 0x42)
- 0x43: Mic AGC — stored

State serialisation: persist dma_adc_left in state slot 36 and
asp_init_in_progress is reset in dsp_reset().
@luckyyyyy luckyyyyy changed the title sb16: OPL2 via AudioWorklet, Creative ADPCM decoding, MPU-401 UART, and mixer fixes sb16: OPL2/OPL3 via AudioWorklet, ADPCM, MPU-401 UART, AdLib compat ports, and mixer/DSP fixes Mar 8, 2026
Three bugs prevented OPL3 from ever being activated:

- port2x3_write (bank1 data): pass 0x100|address to handler so bank1
  registers carry the high bit the worklet uses to distinguish banks.
  Without this, all bank1 operator/channel writes were silently routed
  to bank0.

- reg 0x104 (four-operator enable): the case-1 branch was a no-op
  comment; now sends opl2-reg-write so the worklet updates reg104 and
  calls updateSynths().

- reg 0x105 (OPL3 mode enable): the else branch was a no-op comment;
  now sends opl2-reg-write so the worklet sets opl3Active=0xff and
  switches to 18-channel mode.
0x91 is the high-speed single-cycle DAC output, symmetric with 0x90
(auto-init variant). Unlike 0x90, dma_autoinit is false so the DMA
transfer does not auto-restart after completion.

Also corrects the erroneous comment that labelled 0x91 as an ADC input
command. Per the SB2.0 programmer's guide and DOSBox source:
  0x90 – high-speed auto-init DMA DAC (already implemented)
  0x91 – high-speed single-cycle DMA DAC (now implemented)
  0x98 – high-speed auto-init DMA ADC (stub, input not supported)
  0x99 – high-speed single-cycle DMA ADC (stub, input not supported)
Register 0x0E bit 5 is active-low: 0 = filter ON, 1 = filter OFF.
DOSBox models this as a first-order RC lowpass with cutoff ~3.2 kHz
in mono mode and ~8 kHz in stereo mode.

- Add node_sbpro_filter_left/right (BiquadFilterNode, type=lowpass,
  Q=0.5) to SpeakerMixer, inserted between the gain nodes and the
  channel merger.
- Default frequency set to Nyquist so the node is transparent when
  the filter is disabled.
- Register 'mixer-sbpro-filter' bus handler in SpeakerMixer to
  update the cutoff frequency on demand.
- On mixer 0x0E write, send 'mixer-sbpro-filter' with the enabled
  flag and the current stereo state; mixer_full_update() at reset
  triggers the initial state automatically.
…ignment

Two bugs causing muffled audio in DOS games:

1. mixer_registers[0x0E] was uninitialized (Uint8Array defaults to 0).
   mixer_full_update() then called mixer_write(0x0E, 0), which sent
   'mixer-sbpro-filter' with enabled=true (bit 5 = 0 = active-low),
   engaging the 3.2 kHz mono low-pass filter on ALL audio at startup.
   Root cause: DOSBox's CTMIXER_Reset() never resets 'filtered', and
   DosBo x.mixer.filtered is structurally zero-initialized (false) AND
   DOSBox never actually wires this flag into the audio graph anyway.
   Fix: set mixer_registers[0x0E] = 0x20 (bit 5 = 1 = filter bypass)
   in mixer_reset() so the filter defaults to transparent (Nyquist).

2. Register 0x46 (Bass Left) was sending to 'mixer-bass-right' instead
   of 'mixer-bass-left', swapping left and right bass channels.
   Fix: correct bus event name to 'mixer-bass-left'.
…obals)

- Remove 'use strict' directive (unnecessary inside ES modules)
- Remove spaces after if/for/while/switch keywords (keyword-spacing rule)
- Add /* global sampleRate, registerProcessor, AudioWorkletProcessor */
  comment so ESLint recognises AudioWorklet-specific globals
…sure Compiler compat

Closure Compiler v20210601 does not support import.meta, causing
build/libv86-debug.js to fail with JSC_CANNOT_CONVERT. Fix by accepting
opl2_worklet_url as a V86 option (similar to wasm_path) and forwarding
it through SpeakerAdapter -> OPL2Source. ES-module users (e.g. pal.html)
pass the URL explicitly via new URL(..., import.meta.url).
@luckyyyyy
Copy link
Copy Markdown
Author

@copy done

- Guard addModule() call with '&& worklet_url' to handle undefined case
  (fixes JSC_TYPE_MISMATCH: actual parameter 1 of addModule does not match
  formal parameter - string vs string|undefined)
- Cast worklet_url to string with @type annotation for Closure Compiler
- Add missing third argument 'null' to bus.register() in OPL2Source
  (fixes JSC_WRONG_ARGUMENT_COUNT: Function requires 3 arguments)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant