diff --git a/src/Spice86.Core/CLI/Configuration.cs b/src/Spice86.Core/CLI/Configuration.cs
index 62d46f7024..b5a40c3f11 100644
--- a/src/Spice86.Core/CLI/Configuration.cs
+++ b/src/Spice86.Core/CLI/Configuration.cs
@@ -370,4 +370,30 @@ public sealed class Configuration : CommandSettings {
[DefaultValue(true)]
public bool EnableSpeculativeCfgExploration { get; init; } = true;
+ ///
+ /// Gravis UltraSound I/O base address (default: 0x240).
+ /// Common values: 0x210, 0x220, 0x230, 0x240, 0x250, 0x260.
+ ///
+ [CommandOption("--GusBase ")]
+ [DefaultValue((ushort)0x240)]
+ public ushort GusBase { get; init; } = 0x240;
+
+ ///
+ /// Gravis UltraSound IRQ number (default: 5).
+ /// Valid values: 2, 3, 5, 7, 11, 12, 15.
+ /// Default of 5 allows coexistence with a Sound Blaster card.
+ ///
+ [CommandOption("--GusIrq ")]
+ [DefaultValue((byte)5)]
+ public byte GusIrq { get; init; } = 5;
+
+ ///
+ /// Gravis UltraSound DMA channel (default: 3).
+ /// Valid values: 1, 3, 5, 6, 7.
+ /// Default of 3 allows coexistence with a Sound Blaster card.
+ ///
+ [CommandOption("--GusDma ")]
+ [DefaultValue((byte)3)]
+ public byte GusDma { get; init; } = 3;
+
}
diff --git a/src/Spice86.Core/CLI/RuntimeOptions/AudioRuntimeOptions.cs b/src/Spice86.Core/CLI/RuntimeOptions/AudioRuntimeOptions.cs
index 3f34946900..10f7024b2f 100644
--- a/src/Spice86.Core/CLI/RuntimeOptions/AudioRuntimeOptions.cs
+++ b/src/Spice86.Core/CLI/RuntimeOptions/AudioRuntimeOptions.cs
@@ -16,6 +16,9 @@ namespace Spice86.Core.CLI.RuntimeOptions;
/// 8-bit DMA channel used by the emulated Sound Blaster card.
/// 16-bit DMA channel used by the emulated Sound Blaster card.
/// Sound Blaster model to emulate.
+/// Gravis UltraSound I/O base address.
+/// Gravis UltraSound IRQ number.
+/// Gravis UltraSound DMA channel.
public sealed record class AudioRuntimeOptions(
AudioEngine AudioEngine,
string? Mt32RomsPath,
@@ -25,7 +28,10 @@ public sealed record class AudioRuntimeOptions(
byte SbIrq,
byte SbDma,
byte SbHdma,
- SbType SbType) {
+ SbType SbType,
+ ushort GusBase,
+ byte GusIrq,
+ byte GusDma) {
///
/// Sound Blaster base I/O address.
///
diff --git a/src/Spice86.Core/CLI/RuntimeOptions/RuntimeOptionsMapper.cs b/src/Spice86.Core/CLI/RuntimeOptions/RuntimeOptionsMapper.cs
index 3a95e7a659..a8c446d27c 100644
--- a/src/Spice86.Core/CLI/RuntimeOptions/RuntimeOptionsMapper.cs
+++ b/src/Spice86.Core/CLI/RuntimeOptions/RuntimeOptionsMapper.cs
@@ -113,6 +113,9 @@ public static AudioRuntimeOptions ToAudioRuntimeOptions(Configuration configurat
configuration.SbIrq,
configuration.SbDma,
configuration.SbHdma,
- configuration.SbType);
+ configuration.SbType,
+ configuration.GusBase,
+ configuration.GusIrq,
+ configuration.GusDma);
}
}
\ No newline at end of file
diff --git a/src/Spice86.Core/Emulator/Devices/Sound/GravisUltraSound.cs b/src/Spice86.Core/Emulator/Devices/Sound/GravisUltraSound.cs
index 0cace645f1..ee3c31c63c 100644
--- a/src/Spice86.Core/Emulator/Devices/Sound/GravisUltraSound.cs
+++ b/src/Spice86.Core/Emulator/Devices/Sound/GravisUltraSound.cs
@@ -1,49 +1,886 @@
namespace Spice86.Core.Emulator.Devices.Sound;
+using Spice86.Audio.Backend;
+using Spice86.Audio.Common;
+using Spice86.Core.CLI.RuntimeOptions;
using Microsoft.Extensions.Logging;
using Spice86.Core.Emulator.CPU;
+using Spice86.Core.Emulator.Devices.DirectMemoryAccess;
+using Spice86.Core.Emulator.Devices.ExternalInput;
using Spice86.Core.Emulator.IOPorts;
+using Spice86.Core.Emulator.VM.DeviceScheduler;
using Spice86.Shared.Interfaces;
+using System;
+using System.Collections.Generic;
+
///
-/// Gravis Ultra Sound implementation. Emulates an absent card :)
+/// Gravis UltraSound (GF1 chip) emulation.
+/// Supports 14-32 independent PCM voices with independent frequency, panning,
+/// volume-ramp envelopes, and 8/16-bit sample playback from on-board DRAM.
///
-public class GravisUltraSound : DefaultIOPortHandler {
- private const int IrqControlRegister = 0x24B;
- private const int IrqStatusRegister = 0x246;
- private const int MixControlRegister = 0x240;
- private const int ReadDataOrTriggerStatus = 0x241;
- private const int RegisterControls = 0x24F;
- private const int TimerControlRegister = 0x248;
+///
+/// Port map (at default base 0x240, portBase = gusBase - 0x200 = 0x40):
+/// 0x200+pb – mix control (w); 0x206+pb – IRQ status (r); 0x208+pb – timer status (r/w);
+/// 0x209+pb – timer control (w); 0x20A+pb – AdLib command mirror (r);
+/// 0x20B+pb – IRQ/DMA select (w);
+/// 0x302+pb – voice index (r/w); 0x303+pb – GF1 register select (r/w);
+/// 0x304+pb – data word (r/w); 0x305+pb – data high byte (r/w);
+/// 0x307+pb – DRAM byte (r/w).
+///
+/// Reference implementation: dosbox-staging gus.cpp / gus.h
+/// (c) 2022-2025 The DOSBox Staging Team
+///
+public sealed class GravisUltraSound : DefaultIOPortHandler, IRequestInterrupt, IAudioQueueDevice, IMixerQueueNotifier {
+
+ // Public constants used by GusVoice
+
+ /// Number of pan-position slots (0-15).
+ public const byte PanPositions = 16;
+
+ /// Default (centre) pan-position index.
+ public const byte PanDefaultPosition = 7;
+
+ /// Number of distinct volume-scalar entries in the lookup table.
+ public const int VolLevels = 4096;
+
+ /// Fixed-point scale applied to volume-ramp position values.
+ public const int VolumeIncScalar = 512;
+
+ // Hardware constants
+
+ private const int MaxVoices = 32;
+ private const int MinVoices = 14;
+ private const int RamSizeBytes = 1024 * 1024; // 1 MiB on-board DRAM
+
+ // GF1 timer base delays in milliseconds (80 µs and 320 µs)
+ private const double Timer1DefaultDelayMs = 0.080;
+ private const double Timer2DefaultDelayMs = 0.320;
+
+ // Volume scaling constant: 0.0235 dB per increment step
+ private const double DeltaDb = 0.002709201;
+
+ // Default AdLib command register value
+ private const byte AdlibCmdDefault = 85;
+
+ // Default mix control register state: latches enabled, line-in and line-out disabled
+ private const byte MixControlDefault = 0x0B;
+
+ // IRQ status byte bits
+ private const byte IrqWaveStateBit = 0x20;
+ private const byte IrqVolStateBit = 0x40;
+ private const byte IrqDmaFinished = 0x80;
+ private const byte IrqTimer1Bit = 0x04;
+ private const byte IrqTimer2Bit = 0x08;
+
+ // Mix control register bits
+ private const byte MixCtrlLatchesEnabled = 0x08;
+ private const byte MixCtrlIrqCtrlSelected = 0x40;
+
+ // Reset register bits
+ private const byte ResetRegIsRunning = 0x01;
+ private const byte ResetRegDacEnabled = 0x02;
+ private const byte ResetRegIrqsEnabled = 0x04;
+
+ // DMA control register bits
+ private const byte DmaCtrlEnabled = 0x01;
+ private const byte DmaCtrlGusToHost = 0x02;
+ private const byte DmaCtrlChannel16Bit = 0x04;
+ private const byte DmaCtrlWantsIrqOnTc = 0x20;
+ private const byte DmaCtrlSamples16Bit = 0x40; // write: samples are 16-bit
+ private const byte DmaCtrlTcIrqPending = 0x40; // read: TC IRQ pending
+ private const byte DmaCtrlInvertHighBit = 0x80;
+
+ // IRQ address lookup table (index → IRQ number), per GUS SDK section 2.14
+ private static readonly byte[] IrqAddresses = { 0, 2, 5, 3, 7, 11, 12, 15 };
+
+ // DMA address lookup table (index → DMA channel), per GUS SDK section 2.15
+ private static readonly byte[] DmaAddresses = { 0, 1, 3, 5, 6, 7 };
+
+ // Fields
+
+ private readonly byte[] _ram = new byte[RamSizeBytes];
+
+ private readonly GusVoice[] _voices;
+ private readonly GusVoiceIrq _voiceIrq = new GusVoiceIrq();
+ private readonly GusTimer[] _timers = new GusTimer[2];
+
+ private readonly float[] _volScalars = new float[VolLevels];
+ private readonly AudioFrame[] _panScalars = new AudioFrame[PanPositions];
+
+ private readonly DmaBus _dmaBus;
+ private readonly DualPic _dualPic;
+ private readonly SoftwareMixer _mixer;
+ private readonly DeviceScheduler _scheduler;
+ private readonly SoundChannel _channel;
+ private readonly RWQueue _outputQueue = new RWQueue(4096);
+
+ private readonly byte _irq;
+ private readonly byte _dma;
+ private readonly ushort _gusBase;
+
+ // GF1 register / I/O state
+ private byte _voiceIndex;
+ private byte _selectedReg;
+ private int _dramAddr;
+ private byte _dmaControlReg;
+ private byte _irqStatus;
+ private ushort _registerData;
+ private byte _resetReg;
+ private byte _mixControl = MixControlDefault;
+ private byte _timerCtrl;
+ private byte _sampleCtrl;
+ private byte _adlibCommandReg = AdlibCmdDefault;
+ private ushort _dmaAddr;
+ private byte _dmaAddressNibble;
+ private bool _shouldChangeIrqDma;
+ private bool _irqPreviouslyInterrupted;
+
+ private int _activeVoices = MinVoices;
+
+ // Render scratch buffer (grows on demand, never shrinks)
+ private AudioFrame[] _renderBuf;
+
+ // Constructor
///
- /// Initializes a new instance of the Gravis Ultrasound sound card.
+ /// Initialises the GUS emulation and registers all hardware I/O ports.
///
- /// The CPU state.
- /// The class that is responsible for dispatching ports reads and writes to classes that respond to them.
- /// Whether we throw an exception when an I/O port wasn't handled.
- /// The logger service implementation.
- public GravisUltraSound(State state, IOPortDispatcher ioPortDispatcher, bool failOnUnhandledPort,
- ILogger loggerService) : base(state, failOnUnhandledPort, loggerService) {
- InitPortHandlers(ioPortDispatcher);
- }
-
- private void InitPortHandlers(IOPortDispatcher ioPortDispatcher) {
- ioPortDispatcher.AddIOPortHandler(MixControlRegister, this);
- ioPortDispatcher.AddIOPortHandler(ReadDataOrTriggerStatus, this);
-
- // Not sure what those are but some programs search the card in those ports as well
- ioPortDispatcher.AddIOPortHandler(0x243, this);
- ioPortDispatcher.AddIOPortHandler(0x280, this);
- ioPortDispatcher.AddIOPortHandler(0x281, this);
- ioPortDispatcher.AddIOPortHandler(0x283, this);
- ioPortDispatcher.AddIOPortHandler(0x2C0, this);
- ioPortDispatcher.AddIOPortHandler(0x2C1, this);
- ioPortDispatcher.AddIOPortHandler(0x2C3, this);
- ioPortDispatcher.AddIOPortHandler(IrqStatusRegister, this);
- ioPortDispatcher.AddIOPortHandler(TimerControlRegister, this);
- ioPortDispatcher.AddIOPortHandler(IrqControlRegister, this);
- ioPortDispatcher.AddIOPortHandler(RegisterControls, this);
- }
-}
\ No newline at end of file
+ public GravisUltraSound(
+ State state,
+ IOPortDispatcher ioPortDispatcher,
+ bool failOnUnhandledPort,
+ ILogger loggerService,
+ DmaBus dmaBus,
+ DualPic dualPic,
+ SoftwareMixer mixer,
+ DeviceScheduler scheduler,
+ AudioRuntimeOptions audioOptions)
+ : base(state, failOnUnhandledPort, loggerService) {
+
+ _dmaBus = dmaBus;
+ _dualPic = dualPic;
+ _mixer = mixer;
+ _scheduler = scheduler;
+ _irq = audioOptions.GusIrq;
+ _dma = audioOptions.GusDma;
+ _gusBase = audioOptions.GusBase;
+
+ int portBase = _gusBase - 0x200;
+
+ BuildVolScalars();
+ BuildPanScalars();
+
+ _voices = new GusVoice[MaxVoices];
+ for (byte v = 0; v < MaxVoices; v++) {
+ _voices[v] = new GusVoice(v, _voiceIrq);
+ }
+
+ _timers[0] = new GusTimer(Timer1DefaultDelayMs);
+ _timers[1] = new GusTimer(Timer2DefaultDelayMs);
+
+ InitPortHandlers(ioPortDispatcher, portBase);
+
+ // Mixer setup
+ mixer.RegisterQueueNotifier(this);
+ mixer.LockMixerThread();
+
+ HashSet features = new HashSet {
+ ChannelFeature.DigitalAudio,
+ ChannelFeature.Stereo,
+ ChannelFeature.Sleep
+ };
+ _channel = mixer.AddChannel(MixerCallback, GetSampleRate(), "GravisUltraSound", features);
+
+ int queueCapacity = (int)Math.Ceiling(_channel.FramesPerBlock * 2.0f);
+ _outputQueue.Resize(queueCapacity);
+ _renderBuf = new AudioFrame[Math.Max(1, (int)_channel.FramesPerBlock)];
+
+ mixer.UnlockMixerThread();
+
+ _dualPic.SetIrqMask(_irq, false);
+
+ DmaChannel? dmaChannel = _dmaBus.GetChannel(_dma);
+ dmaChannel?.ReserveFor("GravisUltraSound", OnDmaChannelEvicted);
+ dmaChannel?.RegisterCallback(OnDmaEvent);
+ }
+
+ // Interface implementations
+
+ ///
+ public RWQueue OutputQueue => _outputQueue;
+
+ ///
+ public SoundChannel Channel => _channel;
+
+ ///
+ public void NotifyLockMixer() => _outputQueue.Stop();
+
+ ///
+ public void NotifyUnlockMixer() => _outputQueue.Start();
+
+ ///
+ public void RaiseInterruptRequest() {
+ bool shouldInterrupt = (_irqStatus & (IsIrqsEnabled() ? 0xFF : 0x9F)) != 0;
+ if (shouldInterrupt && IsLatchesEnabled()) {
+ _dualPic.ActivateIrq(_irq);
+ } else if (_irqPreviouslyInterrupted) {
+ _dualPic.DeactivateIrq(_irq);
+ }
+ _irqPreviouslyInterrupted = shouldInterrupt;
+ }
+
+ /// Returns the ULTRASND environment variable value.
+ public string UltraSndString =>
+ $"{_gusBase:X3},{_dma},{_dma},{_irq},{_irq}";
+
+ // I/O port overrides
+
+ ///
+ public override byte ReadByte(ushort port) {
+ int offset = port - _gusBase;
+ switch (offset) {
+ case 0x06: // IRQ status
+ return GetIrqStatus();
+ case 0x08: // Timer/AdLib status
+ return GetTimerStatus();
+ case 0x0A: // AdLib command mirror
+ return _adlibCommandReg;
+ default:
+ break;
+ }
+
+ int highOffset = port - (_gusBase + 0x100);
+ switch (highOffset) {
+ case 0x02: // voice index
+ return _voiceIndex;
+ case 0x03: // GF1 register select
+ return _selectedReg;
+ case 0x04: // data low byte
+ return (byte)ReadFromRegister();
+ case 0x05: // data high byte
+ return (byte)(ReadFromRegister() >> 8);
+ case 0x07: // DRAM byte
+ return _dramAddr < RamSizeBytes ? _ram[_dramAddr] : (byte)0;
+ default:
+ return base.ReadByte(port);
+ }
+ }
+
+ ///
+ public override ushort ReadWord(ushort port) {
+ int highOffset = port - (_gusBase + 0x100);
+ if (highOffset == 0x04) {
+ return ReadFromRegister();
+ }
+ return base.ReadWord(port);
+ }
+
+ ///
+ public override void WriteByte(ushort port, byte value) {
+ int offset = port - _gusBase;
+ switch (offset) {
+ case 0x00: // mix control
+ _mixControl = value;
+ _shouldChangeIrqDma = true;
+ return;
+ case 0x08: // AdLib command mirror
+ _adlibCommandReg = value;
+ return;
+ case 0x09: // timer control
+ OnTimerControl(value);
+ return;
+ case 0x0B: // IRQ/DMA select
+ OnIrqDmaSelect(value);
+ return;
+ default:
+ break;
+ }
+
+ int highOffset = port - (_gusBase + 0x100);
+ switch (highOffset) {
+ case 0x02: // voice index
+ _voiceIndex = (byte)(value & (MaxVoices - 1));
+ return;
+ case 0x03: // GF1 register select
+ _selectedReg = value;
+ _registerData = 0;
+ return;
+ case 0x04: // data low byte – latch only, no register write yet
+ _registerData = (ushort)((_registerData & 0xFF00) | value);
+ return;
+ case 0x05: // data high byte – latch then commit
+ _registerData = (ushort)((_registerData & 0x00FF) | (value << 8));
+ WriteToRegister();
+ return;
+ case 0x07: // DRAM byte
+ if (_dramAddr < RamSizeBytes) {
+ _ram[_dramAddr] = value;
+ }
+ return;
+ default:
+ base.WriteByte(port, value);
+ return;
+ }
+ }
+
+ ///
+ public override void WriteWord(ushort port, ushort value) {
+ int highOffset = port - (_gusBase + 0x100);
+ if (highOffset == 0x04) {
+ _registerData = value;
+ WriteToRegister();
+ return;
+ }
+ base.WriteWord(port, value);
+ }
+
+ // Mixer callback
+
+ private void MixerCallback(int framesRequested) {
+ EnsureRenderBuf(framesRequested);
+ Array.Clear(_renderBuf, 0, framesRequested);
+
+ // Only render when the GF1 is running and the DAC is enabled
+ if (IsRunning() && IsDacEnabled()) {
+ for (int v = 0; v < _activeVoices; v++) {
+ _voices[v].RenderFrames(_ram, _volScalars, _panScalars, _renderBuf, framesRequested);
+ }
+ }
+
+ CheckVoiceIrqs();
+
+ _outputQueue.NonblockingBulkEnqueue(_renderBuf.AsSpan(0, framesRequested), framesRequested);
+ SoftwareMixer.PullFromQueueCallback(framesRequested, this);
+ }
+
+ // GF1 register reads
+
+ private ushort ReadFromRegister() {
+ // Global DSP registers
+ switch (_selectedReg) {
+ case 0x41: { // DMA control register – read clears TC IRQ
+ byte reg = _dmaControlReg;
+ if ((_irqStatus & IrqDmaFinished) != 0) {
+ reg |= DmaCtrlTcIrqPending;
+ }
+ _irqStatus &= unchecked((byte)~IrqDmaFinished);
+ RaiseInterruptRequest();
+ return (ushort)(reg << 8);
+ }
+ case 0x42: // DMA address register
+ return _dmaAddr;
+ case 0x45: // Timer control
+ return (ushort)(_timerCtrl << 8);
+ case 0x49: // DMA sample control
+ return (ushort)(_dmaControlReg << 8);
+ case 0x4C: // Reset register
+ return (ushort)(_resetReg << 8);
+ case 0x8F: // Voice IRQ status
+ return GetVoiceIrqStatus();
+ default:
+ break;
+ }
+
+ // Voice-specific registers
+ GusVoice voice = _voices[_voiceIndex];
+ switch (_selectedReg) {
+ case 0x80: // Voice wave control
+ return (ushort)(voice.ReadWaveState() << 8);
+ case 0x82: // Voice wave start MSW
+ return (ushort)(voice.WaveStart >> 16);
+ case 0x83: // Voice wave start LSW
+ return (ushort)(voice.WaveStart & 0xFFFF);
+ case 0x89: { // Voice volume position
+ int i = CeilSdivide(voice.VolPos, VolumeIncScalar);
+ i = Math.Max(0, Math.Min(i, VolLevels - 1));
+ return (ushort)(i << 4);
+ }
+ case 0x8A: // Voice wave current position MSW
+ return (ushort)(voice.WavePos >> 16);
+ case 0x8B: // Voice wave current position LSW
+ return (ushort)(voice.WavePos & 0xFFFF);
+ case 0x8D: // Voice volume control
+ return (ushort)(voice.ReadVolState() << 8);
+ default:
+ return _registerData; // echo back last written value
+ }
+ }
+
+ // GF1 register writes
+
+ private void WriteToRegister() {
+ GusVoice voice = _voices[_voiceIndex];
+
+ // Global DSP registers
+ switch (_selectedReg) {
+ case 0x0E: { // Set number of active voices
+ // Jazz Jackrabbit reads back the register select from this write
+ _selectedReg = (byte)(_registerData >> 8);
+ byte num = (byte)(1 + ((_registerData >> 8) & 31));
+ SetActiveVoices(num);
+ return;
+ }
+ case 0x10: // Undocumented register (Fast Tracker 2)
+ return;
+ case 0x41: // DMA control register
+ _dmaControlReg = (byte)(_registerData >> 8);
+ if ((_dmaControlReg & DmaCtrlEnabled) != 0) {
+ StartDmaTransfer();
+ }
+ return;
+ case 0x42: // DMA DRAM address register
+ _dmaAddr = _registerData;
+ _dmaAddressNibble = 0;
+ return;
+ case 0x43: // DRAM address LSW (bits 0-15)
+ _dramAddr = (_dramAddr & 0xF0000) | _registerData;
+ return;
+ case 0x44: // DRAM address MSW (bits 16-19 in upper nibble of high byte)
+ _dramAddr = (_dramAddr & 0x0FFFF) | ((_registerData & 0x0F00) << 8);
+ return;
+ case 0x45: // Timer control register
+ _timerCtrl = (byte)(_registerData >> 8);
+ _timers[0].ShouldRaiseIrq = (_timerCtrl & IrqTimer1Bit) != 0;
+ _timers[1].ShouldRaiseIrq = (_timerCtrl & IrqTimer2Bit) != 0;
+ if (!_timers[0].ShouldRaiseIrq) {
+ _irqStatus &= unchecked((byte)~IrqTimer1Bit);
+ }
+ if (!_timers[1].ShouldRaiseIrq) {
+ _irqStatus &= unchecked((byte)~IrqTimer2Bit);
+ }
+ if (!_timers[0].ShouldRaiseIrq && !_timers[1].ShouldRaiseIrq) {
+ RaiseInterruptRequest();
+ }
+ return;
+ case 0x46: // Timer 1 value
+ _timers[0].Value = (byte)(_registerData >> 8);
+ _timers[0].Delay = (0x100 - _timers[0].Value) * Timer1DefaultDelayMs;
+ return;
+ case 0x47: // Timer 2 value
+ _timers[1].Value = (byte)(_registerData >> 8);
+ _timers[1].Delay = (0x100 - _timers[1].Value) * Timer2DefaultDelayMs;
+ return;
+ case 0x49: // DMA sampling control register
+ _sampleCtrl = (byte)(_registerData >> 8);
+ if ((_sampleCtrl & 0x01) != 0) {
+ StartDmaTransfer();
+ }
+ return;
+ case 0x4C: // Reset register
+ _resetReg = (byte)(_registerData >> 8);
+ if ((_resetReg & ResetRegIsRunning) == 0) {
+ DoReset();
+ }
+ return;
+ default:
+ break;
+ }
+
+ // Voice-specific registers
+ switch (_selectedReg) {
+ case 0x00: // Voice wave control
+ if (voice.UpdateWaveState((byte)(_registerData >> 8))) {
+ CheckVoiceIrqs();
+ }
+ break;
+ case 0x01: // Voice wave rate
+ voice.WriteWaveRate(_registerData);
+ break;
+ case 0x02: // Voice wave start MSW
+ voice.WaveStart = UpdateWaveMsw(voice.WaveStart, _registerData);
+ break;
+ case 0x03: // Voice wave start LSW
+ voice.WaveStart = UpdateWaveLsw(voice.WaveStart, _registerData);
+ break;
+ case 0x04: // Voice wave end MSW
+ voice.WaveEnd = UpdateWaveMsw(voice.WaveEnd, _registerData);
+ break;
+ case 0x05: // Voice wave end LSW
+ voice.WaveEnd = UpdateWaveLsw(voice.WaveEnd, _registerData);
+ break;
+ case 0x06: // Voice volume rate
+ voice.WriteVolRate((byte)(_registerData >> 8));
+ break;
+ case 0x07: { // Voice volume start (EEEEMMMM format)
+ byte data = (byte)(_registerData >> 8);
+ voice.VolStart = (data << 4) * VolumeIncScalar;
+ break;
+ }
+ case 0x08: { // Voice volume end (EEEEMMMM format)
+ byte data = (byte)(_registerData >> 8);
+ voice.VolEnd = (data << 4) * VolumeIncScalar;
+ break;
+ }
+ case 0x09: // Voice volume current position
+ voice.VolPos = (_registerData >> 4) * VolumeIncScalar;
+ break;
+ case 0x0A: // Voice wave current position MSW
+ voice.WavePos = UpdateWaveMsw(voice.WavePos, _registerData);
+ break;
+ case 0x0B: // Voice wave current position LSW
+ voice.WavePos = UpdateWaveLsw(voice.WavePos, _registerData);
+ break;
+ case 0x0C: // Voice pan pot
+ voice.WritePanPot((byte)(_registerData >> 8));
+ break;
+ case 0x0D: // Voice volume control
+ if (voice.UpdateVolState((byte)(_registerData >> 8))) {
+ CheckVoiceIrqs();
+ }
+ break;
+ }
+ }
+
+ // Wave address helpers (match dosbox UpdateWaveMsw / UpdateWaveLsw)
+
+ private static int UpdateWaveMsw(int addr, ushort regData) {
+ // Keep bits 0-15 of addr; replace bits 16-28 with the 13-bit regData value.
+ int lower = addr & 0x0000FFFF;
+ int upper = (regData & 0x1FFF) << 16;
+ return lower | upper;
+ }
+
+ private static int UpdateWaveLsw(int addr, ushort regData) {
+ // Keep bits 16-31 of addr; replace bits 0-15 with regData.
+ int upper = addr & unchecked((int)0xFFFF0000);
+ return upper | regData;
+ }
+
+ // Timer handling
+
+ private void OnTimerControl(byte value) {
+ if ((value & 0x80) != 0) {
+ // Reset timer expired flags
+ _timers[0].HasExpired = false;
+ _timers[1].HasExpired = false;
+ return;
+ }
+
+ _timers[0].IsMasked = (value & 0x40) != 0;
+ _timers[1].IsMasked = (value & 0x20) != 0;
+
+ for (int t = 0; t < _timers.Length; t++) {
+ GusTimer timer = _timers[t];
+ bool start = (value & (1 << t)) != 0;
+ if (start && !timer.IsCountingDown) {
+ timer.IsCountingDown = true;
+ timer.HasExpired = false;
+ int timerIndex = t;
+ _scheduler.AddEvent(_ => OnTimerExpired(timerIndex), timer.Delay, 0);
+ } else if (!start) {
+ timer.IsCountingDown = false;
+ }
+ }
+ }
+
+ private void OnTimerExpired(int timerIndex) {
+ GusTimer timer = _timers[timerIndex];
+ if (!timer.IsCountingDown) { return; }
+ if (!timer.IsMasked) {
+ timer.HasExpired = true;
+ }
+ timer.IsCountingDown = false;
+ if (timer.ShouldRaiseIrq) {
+ _irqStatus |= timerIndex == 0 ? IrqTimer1Bit : IrqTimer2Bit;
+ RaiseInterruptRequest();
+ }
+ }
+
+ private byte GetTimerStatus() {
+ byte status = 0;
+ if (_timers[0].HasExpired) { status |= 0x40; }
+ if (_timers[1].HasExpired) { status |= 0x20; }
+ if ((status & 0x60) != 0) { status |= 0x80; } // combined expired bit
+ if ((_irqStatus & IrqTimer1Bit) != 0) { status |= 0x04; }
+ if ((_irqStatus & IrqTimer2Bit) != 0) { status |= 0x02; }
+ return status;
+ }
+
+ // IRQ/DMA port 0x20B selection
+
+ private void OnIrqDmaSelect(byte value) {
+ if (!_shouldChangeIrqDma) {
+ return;
+ }
+ _shouldChangeIrqDma = false;
+
+ byte ch1Selector = (byte)(value & 0x07);
+ byte ch2Selector = (byte)((value >> 3) & 0x07);
+ bool ch2Combined = (value & 0x40) != 0;
+
+ if ((_mixControl & MixCtrlIrqCtrlSelected) != 0) {
+ // Selecting IRQ numbers
+ if (ch1Selector < IrqAddresses.Length && IrqAddresses[ch1Selector] != 0) {
+ // IRQ reassignment is not supported at runtime; configuration is fixed at startup.
+ }
+ } else {
+ // Selecting DMA channels
+ if (ch1Selector < DmaAddresses.Length && DmaAddresses[ch1Selector] != 0) {
+ byte newDma = DmaAddresses[ch1Selector];
+ UpdatePlaybackDmaAddress(newDma);
+ }
+ if (ch2Combined) {
+ // ch2 mirrors ch1
+ } else if (ch2Selector < DmaAddresses.Length && DmaAddresses[ch2Selector] != 0) {
+ // Recording DMA – not currently emulated
+ }
+ }
+ }
+
+ // IRQ handling
+
+ private byte GetIrqStatus() {
+ byte result = _irqStatus;
+ _irqStatus = 0;
+ _irqPreviouslyInterrupted = false;
+ _dualPic.DeactivateIrq(_irq);
+ return result;
+ }
+
+ private ushort GetVoiceIrqStatus() {
+ // Returns the voice index with pending IRQ and clears its bits.
+ // Bit 5 is always set; bit 6 clear = vol IRQ, bit 7 clear = wave IRQ.
+ byte reg = (byte)(_voiceIrq.Status | 0x20);
+ uint mask = 1u << _voiceIrq.Status;
+
+ if ((_voiceIrq.VolState & mask) == 0) {
+ reg |= 0x40; // no vol IRQ for this voice
+ }
+ if ((_voiceIrq.WaveState & mask) == 0) {
+ reg |= 0x80; // no wave IRQ for this voice
+ }
+
+ _voiceIrq.VolState &= ~mask;
+ _voiceIrq.WaveState &= ~mask;
+ CheckVoiceIrqs();
+ return (ushort)(reg << 8);
+ }
+
+ private void CheckVoiceIrqs() {
+ // Clear voice IRQ bits in irq_status then re-evaluate
+ _irqStatus &= 0x9F;
+ uint activeVoiceMask = 0xFFFFFFFFu >> (MaxVoices - _activeVoices);
+ uint totalMask = (_voiceIrq.VolState | _voiceIrq.WaveState) & activeVoiceMask;
+
+ if (totalMask == 0) {
+ RaiseInterruptRequest();
+ return;
+ }
+
+ if (_voiceIrq.VolState != 0) {
+ _irqStatus |= IrqVolStateBit;
+ }
+ if (_voiceIrq.WaveState != 0) {
+ _irqStatus |= IrqWaveStateBit;
+ }
+
+ RaiseInterruptRequest();
+
+ // Advance status to the next voice with a pending IRQ
+ while ((totalMask & (1u << _voiceIrq.Status)) == 0) {
+ _voiceIrq.Status++;
+ if (_voiceIrq.Status >= _activeVoices) {
+ _voiceIrq.Status = 0;
+ }
+ }
+ }
+
+ // Reset
+
+ private void DoReset() {
+ _channel.Enable(false);
+
+ _irqStatus = 0;
+ _irqPreviouslyInterrupted = false;
+ _adlibCommandReg = AdlibCmdDefault;
+ _dmaControlReg = 0;
+ _sampleCtrl = 0;
+ _timerCtrl = 0;
+ _timers[0] = new GusTimer(Timer1DefaultDelayMs);
+ _timers[1] = new GusTimer(Timer2DefaultDelayMs);
+
+ for (int v = 0; v < MaxVoices; v++) {
+ _voices[v].ResetCtrls();
+ }
+
+ _voiceIrq.VolState = 0;
+ _voiceIrq.WaveState = 0;
+ _voiceIrq.Status = 0;
+
+ UpdateDmaAddr(0);
+ _dramAddr = 0;
+ _registerData = 0;
+ _selectedReg = 0;
+ _shouldChangeIrqDma = false;
+ _mixControl = MixControlDefault;
+ _activeVoices = MinVoices;
+ }
+
+ // DMA handling
+
+ private void StartDmaTransfer() {
+ DmaChannel? channel = _dmaBus.GetChannel(_dma);
+ channel?.RegisterCallback(OnDmaEvent);
+ }
+
+ private void UpdatePlaybackDmaAddress(byte newDma) {
+ DmaChannel? oldChannel = _dmaBus.GetChannel(_dma);
+ oldChannel?.Reset();
+ DmaChannel? newChannel = _dmaBus.GetChannel(newDma);
+ newChannel?.ReserveFor("GravisUltraSound", OnDmaChannelEvicted);
+ newChannel?.RegisterCallback(OnDmaEvent);
+ }
+
+ private void OnDmaEvent(DmaChannel channel, DmaChannel.DmaEvent evt) {
+ if (evt != DmaChannel.DmaEvent.IsUnmasked) { return; }
+ PerformDmaTransfer(channel);
+ }
+
+ private void OnDmaChannelEvicted() {
+ // DMA was forcibly released by another device.
+ }
+
+ private void PerformDmaTransfer(DmaChannel channel) {
+ const int ChunkSizeBytes = 512;
+ byte[] chunk = new byte[ChunkSizeBytes];
+ int chunkWords = ChunkSizeBytes >> channel.ShiftCount;
+
+ uint dmaOffset = GetDmaOffset();
+ bool invert = (_dmaControlReg & DmaCtrlInvertHighBit) != 0;
+ bool samples16 = (_dmaControlReg & DmaCtrlSamples16Bit) != 0;
+
+ while (true) {
+ int wordsRead = channel.Read(chunkWords, chunk.AsSpan());
+ if (wordsRead <= 0) { break; }
+ int bytesRead = wordsRead << channel.ShiftCount;
+
+ for (int i = 0; i < bytesRead; i++) {
+ int dest = (int)((dmaOffset + (uint)i) & 0xFFFFF);
+ byte sample = chunk[i];
+ if (invert && (!samples16 || ((dmaOffset + (uint)i) & 1) != 0)) {
+ sample ^= 0x80;
+ }
+ _ram[dest] = sample;
+ }
+
+ dmaOffset = (dmaOffset + (uint)bytesRead) & 0xFFFFF;
+ }
+
+ UpdateDmaAddr(dmaOffset);
+
+ if ((_dmaControlReg & DmaCtrlWantsIrqOnTc) != 0) {
+ _irqStatus |= IrqDmaFinished;
+ RaiseInterruptRequest();
+ }
+ }
+
+ private uint GetDmaOffset() {
+ uint adjusted;
+ if (IsDmaXfer16Bit()) {
+ uint upper = (uint)_dmaAddr & 0xC000u;
+ uint lower = (uint)_dmaAddr & 0x1FFFu;
+ adjusted = upper | (lower << 1);
+ } else {
+ adjusted = _dmaAddr;
+ }
+ return (adjusted << 4) + _dmaAddressNibble;
+ }
+
+ private void UpdateDmaAddr(uint offset) {
+ uint adjusted;
+ if (IsDmaXfer16Bit()) {
+ uint upper = offset & 0xC0000u;
+ uint lower = offset & 0x3FFFEu;
+ adjusted = upper | (lower >> 1);
+ } else {
+ adjusted = offset & 0xFFFF0u;
+ }
+ _dmaAddr = (ushort)(adjusted >> 4);
+ _dmaAddressNibble = (byte)(adjusted & 0x0F);
+ }
+
+ private bool IsDmaXfer16Bit() {
+ return (_dmaControlReg & DmaCtrlChannel16Bit) != 0 && _dma >= 4;
+ }
+
+ // Voice and sample-rate management
+
+ private void SetActiveVoices(byte count) {
+ int clamped = Math.Max(MinVoices, Math.Min(MaxVoices, (int)count));
+ if (clamped != _activeVoices) {
+ _activeVoices = clamped;
+ _channel.SampleRate = GetSampleRate();
+ }
+ }
+
+ private int GetSampleRate() {
+ return (int)(1000000.0 / (1.619695497 * _activeVoices));
+ }
+
+ // Lookup-table construction
+
+ private void BuildVolScalars() {
+ // Build the table from the end downward, dividing by (1 + DeltaDb) each step.
+ // This produces constant-dB spacing, matching the GUS hardware.
+ double scalar = 1.0;
+ double divisor = 1.0 + DeltaDb;
+ for (int i = VolLevels - 1; i >= 1; i--) {
+ _volScalars[i] = (float)scalar;
+ scalar /= divisor;
+ }
+ _volScalars[0] = 0.0f;
+ }
+
+ private void BuildPanScalars() {
+ // Constant-power panning: positions 0-15 map from full-left to full-right,
+ // with position 7 exactly at centre. The asymmetric normalization (÷7 vs ÷8)
+ // keeps position 7 at exactly π/4.
+ for (int p = 0; p < PanPositions; p++) {
+ double norm = (p - 7.0) / (p < 7 ? 7.0 : 8.0);
+ double angle = (norm + 1.0) * Math.PI / 4.0;
+ _panScalars[p] = new AudioFrame(
+ (float)Math.Cos(angle),
+ (float)Math.Sin(angle));
+ }
+ }
+
+ // Port registration
+
+ private void InitPortHandlers(IOPortDispatcher dispatcher, int portBase) {
+ // Low group (around gusBase)
+ dispatcher.AddIOPortHandler((ushort)(0x200 + portBase), this); // mix control
+ dispatcher.AddIOPortHandler((ushort)(0x206 + portBase), this); // IRQ status
+ dispatcher.AddIOPortHandler((ushort)(0x208 + portBase), this); // timer status / AdLib mirror
+ dispatcher.AddIOPortHandler((ushort)(0x209 + portBase), this); // timer control
+ dispatcher.AddIOPortHandler((ushort)(0x20A + portBase), this); // adlib command mirror
+ dispatcher.AddIOPortHandler((ushort)(0x20B + portBase), this); // IRQ/DMA select
+
+ // High group (gusBase + 0x100)
+ dispatcher.AddIOPortHandler((ushort)(0x302 + portBase), this); // voice index
+ dispatcher.AddIOPortHandler((ushort)(0x303 + portBase), this); // GF1 reg select
+ dispatcher.AddIOPortHandler((ushort)(0x304 + portBase), this); // data word
+ dispatcher.AddIOPortHandler((ushort)(0x305 + portBase), this); // data high byte
+ dispatcher.AddIOPortHandler((ushort)(0x307 + portBase), this); // DRAM byte
+ }
+
+ // Helpers
+
+ private void EnsureRenderBuf(int size) {
+ if (_renderBuf.Length < size) {
+ _renderBuf = new AudioFrame[size];
+ }
+ }
+
+ private bool IsRunning() => (_resetReg & ResetRegIsRunning) != 0;
+ private bool IsDacEnabled() => (_resetReg & ResetRegDacEnabled) != 0;
+ private bool IsIrqsEnabled() => (_resetReg & ResetRegIrqsEnabled) != 0;
+ private bool IsLatchesEnabled() => (_mixControl & MixCtrlLatchesEnabled) != 0;
+
+ private static int CeilSdivide(int a, int b) {
+ if (b == 0) { return 0; }
+ if (a >= 0) { return (a + b - 1) / b; }
+ return a / b;
+ }
+}
diff --git a/src/Spice86.Core/Emulator/Devices/Sound/GusTimer.cs b/src/Spice86.Core/Emulator/Devices/Sound/GusTimer.cs
new file mode 100644
index 0000000000..2b3fbc5ed3
--- /dev/null
+++ b/src/Spice86.Core/Emulator/Devices/Sound/GusTimer.cs
@@ -0,0 +1,32 @@
+namespace Spice86.Core.Emulator.Devices.Sound;
+
+///
+/// State for one of the two GUS hardware timers.
+///
+///
+/// 2022-2025 The DOSBox Staging Team
+///
+internal sealed class GusTimer {
+ /// Countdown period in milliseconds.
+ public double Delay { get; set; }
+
+ /// Reload value written to the timer register.
+ public byte Value { get; set; } = 0xFF;
+
+ /// True once the timer has fired since the last reset.
+ public bool HasExpired { get; set; } = true;
+
+ /// True while the timer is actively counting down.
+ public bool IsCountingDown { get; set; }
+
+ /// True when the timer is masked and will not fire IRQs.
+ public bool IsMasked { get; set; }
+
+ /// True when the timer should raise an IRQ on expiry.
+ public bool ShouldRaiseIrq { get; set; }
+
+ /// Initialises a timer with the given period in milliseconds.
+ public GusTimer(double delayMs) {
+ Delay = delayMs;
+ }
+}
diff --git a/src/Spice86.Core/Emulator/Devices/Sound/GusVoice.cs b/src/Spice86.Core/Emulator/Devices/Sound/GusVoice.cs
new file mode 100644
index 0000000000..02ad1a8354
--- /dev/null
+++ b/src/Spice86.Core/Emulator/Devices/Sound/GusVoice.cs
@@ -0,0 +1,279 @@
+namespace Spice86.Core.Emulator.Devices.Sound;
+
+using Spice86.Audio.Common;
+
+using System;
+
+///
+/// One of the 32 independent sample-playing voices on the Gravis UltraSound GF1 chip.
+/// Each voice has independent wave-playback control (position, start, end, rate, loop)
+/// and a volume-ramp envelope, plus stereo panning.
+///
+///
+/// 2022-2025 The DOSBox Staging Team
+///
+internal sealed class GusVoice {
+ // Control-state bit flags shared by wave and volume controls.
+ private const byte CtrlReset = 0x01;
+ private const byte CtrlStopped = 0x02;
+ private const byte CtrlDisabled = CtrlReset | CtrlStopped;
+ private const byte CtrlBit16 = 0x04;
+ private const byte CtrlLoop = 0x08;
+ private const byte CtrlBidirectional = 0x10;
+ private const byte CtrlRaiseIrq = 0x20;
+ private const byte CtrlDecreasing = 0x40;
+
+ /// Width used for wave-position fractional interpolation (2^9 = 512 sub-steps per sample).
+ private const int WaveWidth = 1 << 9;
+
+ /// 8-bit samples are scaled to match the 16-bit range.
+ private const float To16BitRange = 256f; // = 2^(16-8)
+
+ private readonly GusVoiceIrq _voiceIrq;
+ private readonly uint _irqMask;
+
+ // Wave-control state
+ public int WaveStart { get; set; }
+ public int WaveEnd { get; set; }
+ public int WavePos { get; set; }
+ public int WaveInc { get; set; }
+ public ushort WaveRate { get; set; }
+ public byte WaveState { get; set; } = CtrlDisabled;
+
+ // Volume-ramp state
+ public int VolStart { get; set; }
+ public int VolEnd { get; set; }
+ public int VolPos { get; set; }
+ public int VolInc { get; set; }
+ public ushort VolRate { get; set; }
+ public byte VolState { get; set; } = CtrlDisabled;
+
+ public byte PanPosition { get; private set; } = GravisUltraSound.PanDefaultPosition;
+
+ /// Milliseconds of 8-bit audio generated (for statistics).
+ public uint Generated8BitMs { get; set; }
+
+ /// Milliseconds of 16-bit audio generated (for statistics).
+ public uint Generated16BitMs { get; set; }
+
+ /// Initialises a voice with its index and a reference to the shared IRQ state.
+ public GusVoice(byte num, GusVoiceIrq voiceIrq) {
+ _voiceIrq = voiceIrq;
+ _irqMask = 1u << num;
+ }
+
+ // Public interface
+
+ ///
+ /// Accumulates this voice's rendered samples into the first entries of .
+ ///
+ public void RenderFrames(byte[] ram, float[] volScalars, AudioFrame[] panScalars, AudioFrame[] frames, int count) {
+ // Skip rendering only when BOTH wave AND vol controls are independently disabled,
+ // matching the GF1 hardware behaviour documented in the UltraSound SDK.
+ if ((WaveState & VolState & CtrlDisabled) != 0) {
+ return;
+ }
+
+ AudioFrame pan = panScalars[PanPosition];
+
+ for (int i = 0; i < count; i++) {
+ float sample = GetSample(ram);
+ sample *= PopVolScalar(volScalars);
+ frames[i] = new AudioFrame(
+ frames[i].Left + sample * pan.Left,
+ frames[i].Right + sample * pan.Right);
+ }
+
+ if (Is16Bit()) {
+ Generated16BitMs++;
+ } else {
+ Generated8BitMs++;
+ }
+ }
+
+ public byte ReadWaveState() => ReadCtrlState(WaveState, _voiceIrq.WaveState);
+ public byte ReadVolState() => ReadCtrlState(VolState, _voiceIrq.VolState);
+
+ public void ResetCtrls() {
+ VolPos = 0;
+ UpdateVolState(0x01);
+ UpdateWaveState(0x01);
+ WritePanPot(GravisUltraSound.PanDefaultPosition);
+ }
+
+ public void WritePanPot(byte pos) {
+ const byte MaxPos = GravisUltraSound.PanPositions - 1;
+ PanPosition = Math.Min(pos, MaxPos);
+ }
+
+ /// Decodes a wave-rate register value into a per-sample position increment.
+ public void WriteWaveRate(ushort val) {
+ WaveRate = val;
+ WaveInc = CeilUdivide(val, 2u);
+ }
+
+ ///
+ /// Decodes a volume-rate register value into a per-sample volume-index increment.
+ /// The register encodes four banks of fractional increments via bits 6-7.
+ ///
+ public void WriteVolRate(byte val) {
+ VolRate = val;
+ const byte BankLength = 63;
+ int posInBank = val & BankLength;
+ int decimator = 1 << (3 * (val >> 6));
+ VolInc = CeilSdivide(posInBank * GravisUltraSound.VolumeIncScalar, decimator);
+ }
+
+ /// Updates wave control state; returns true when the voice IRQ flag changed.
+ public bool UpdateWaveState(byte state) {
+ uint origIrqState = _voiceIrq.WaveState;
+ if ((state & 0xA0) == 0xA0) {
+ _voiceIrq.WaveState |= _irqMask;
+ } else {
+ _voiceIrq.WaveState &= ~_irqMask;
+ }
+ WaveState = (byte)(state & 0x7F);
+ return origIrqState != _voiceIrq.WaveState;
+ }
+
+ /// Updates volume control state; returns true when the voice IRQ flag changed.
+ public bool UpdateVolState(byte state) {
+ uint origIrqState = _voiceIrq.VolState;
+ if ((state & 0xA0) == 0xA0) {
+ _voiceIrq.VolState |= _irqMask;
+ } else {
+ _voiceIrq.VolState &= ~_irqMask;
+ }
+ VolState = (byte)(state & 0x7F);
+ return origIrqState != _voiceIrq.VolState;
+ }
+
+ // Private rendering helpers
+
+ private bool Is16Bit() => (WaveState & CtrlBit16) != 0;
+
+ private float GetSample(byte[] ram) {
+ int pos = PopWavePos();
+ int addr = pos / WaveWidth;
+ int fraction = pos & (WaveWidth - 1);
+ bool shouldInterpolate = WaveInc < WaveWidth && fraction != 0;
+ float sample = Is16Bit() ? Read16BitSample(ram, addr) : Read8BitSample(ram, addr);
+
+ if (shouldInterpolate) {
+ float next = Is16Bit() ? Read16BitSample(ram, addr + 1) : Read8BitSample(ram, addr + 1);
+ sample += (next - sample) * fraction / (float)WaveWidth;
+ }
+
+ return sample;
+ }
+
+ private int PopWavePos() {
+ int current = WavePos;
+ CtrlPosUpdate update = IncrementCtrlPos(WavePos, WaveState, WaveInc, WaveStart, WaveEnd,
+ _voiceIrq.WaveState, CheckWaveRolloverCondition());
+ WavePos = update.Pos;
+ WaveState = update.State;
+ _voiceIrq.WaveState = update.SharedIrqState;
+ return current;
+ }
+
+ private float PopVolScalar(float[] volScalars) {
+ int i = CeilSdivide(VolPos, GravisUltraSound.VolumeIncScalar);
+ i = Math.Max(0, Math.Min(i, volScalars.Length - 1));
+ CtrlPosUpdate update = IncrementCtrlPos(VolPos, VolState, VolInc, VolStart, VolEnd,
+ _voiceIrq.VolState, false);
+ VolPos = update.Pos;
+ VolState = update.State;
+ _voiceIrq.VolState = update.SharedIrqState;
+ return volScalars[i];
+ }
+
+ private bool CheckWaveRolloverCondition() {
+ // Rollover: volume control has BIT16 set and wave control has no LOOP.
+ return (VolState & CtrlBit16) != 0 && (WaveState & CtrlLoop) == 0;
+ }
+
+ private readonly struct CtrlPosUpdate {
+ public CtrlPosUpdate(int pos, byte state, uint sharedIrqState) {
+ Pos = pos;
+ State = state;
+ SharedIrqState = sharedIrqState;
+ }
+
+ public int Pos { get; }
+ public byte State { get; }
+ public uint SharedIrqState { get; }
+ }
+
+ private CtrlPosUpdate IncrementCtrlPos(
+ int pos,
+ byte state,
+ int inc,
+ int start,
+ int end,
+ uint sharedIrqState,
+ bool skipLoopOrRestart) {
+
+ if ((state & CtrlDisabled) != 0) { return new CtrlPosUpdate(pos, state, sharedIrqState); }
+
+ int remaining;
+ if ((state & CtrlDecreasing) != 0) {
+ pos -= inc;
+ remaining = start - pos;
+ } else {
+ pos += inc;
+ remaining = pos - end;
+ }
+
+ if (remaining < 0) { return new CtrlPosUpdate(pos, state, sharedIrqState); }
+
+ if ((state & CtrlRaiseIrq) != 0) {
+ sharedIrqState |= _irqMask;
+ }
+
+ if (skipLoopOrRestart) { return new CtrlPosUpdate(pos, state, sharedIrqState); }
+
+ if ((state & CtrlLoop) != 0) {
+ if ((state & CtrlBidirectional) != 0) {
+ state ^= CtrlDecreasing;
+ }
+ pos = (state & CtrlDecreasing) != 0 ? end - remaining : start + remaining;
+ } else {
+ state |= 1; // stop the voice
+ pos = (state & CtrlDecreasing) != 0 ? start : end;
+ }
+
+ return new CtrlPosUpdate(pos, state, sharedIrqState);
+ }
+
+ private static float Read8BitSample(byte[] ram, int addr) {
+ int i = addr & 0xFFFFF;
+ return (sbyte)ram[i] * To16BitRange;
+ }
+
+ private static float Read16BitSample(byte[] ram, int addr) {
+ uint upper = (uint)addr & 0xC0000u;
+ uint lower = (uint)addr & 0x1FFFFu;
+ int i = (int)(upper | (lower << 1));
+ return (short)(ram[i] | (ram[i + 1] << 8));
+ }
+
+ private byte ReadCtrlState(byte state, uint irqState) {
+ byte result = state;
+ if ((irqState & _irqMask) != 0) {
+ result |= 0x80;
+ }
+ return result;
+ }
+
+ private static int CeilSdivide(int a, int b) {
+ if (b == 0) { return 0; }
+ if (a >= 0) { return (a + b - 1) / b; }
+ return a / b;
+ }
+
+ private static int CeilUdivide(uint a, uint b) {
+ if (b == 0) { return 0; }
+ return (int)((a + b - 1) / b);
+ }
+}
diff --git a/src/Spice86.Core/Emulator/Devices/Sound/GusVoiceIrq.cs b/src/Spice86.Core/Emulator/Devices/Sound/GusVoiceIrq.cs
new file mode 100644
index 0000000000..ef4624727d
--- /dev/null
+++ b/src/Spice86.Core/Emulator/Devices/Sound/GusVoiceIrq.cs
@@ -0,0 +1,20 @@
+namespace Spice86.Core.Emulator.Devices.Sound;
+
+///
+/// Shared IRQ state for all GUS voices.
+/// Wave and volume IRQs accumulate here as bitmasks, one bit per voice.
+/// Fields (not auto-properties) so voice code can pass them by reference.
+///
+///
+/// 2022-2025 The DOSBox Staging Team
+///
+internal sealed class GusVoiceIrq {
+ /// Volume-ramp IRQ bitmask: bit N is set when voice N requests a volume IRQ.
+ public uint VolState;
+
+ /// Wave-position IRQ bitmask: bit N is set when voice N requests a wave IRQ.
+ public uint WaveState;
+
+ /// Index of the next voice whose IRQ should be reported via the IRQ-status register.
+ public byte Status;
+}
diff --git a/src/Spice86/Spice86DependencyInjection.cs b/src/Spice86/Spice86DependencyInjection.cs
index e80e1dffea..e5ff287439 100644
--- a/src/Spice86/Spice86DependencyInjection.cs
+++ b/src/Spice86/Spice86DependencyInjection.cs
@@ -447,7 +447,8 @@ internal Spice86DependencyInjection(Configuration configuration, MainWindow? mai
emulationLoopScheduler, _emulatedClock,
audioRuntimeOptions);
GravisUltraSound gravisUltraSound = new(state, ioPortDispatcher,
- configuration.FailOnUnhandledPort, loggerService);
+ configuration.FailOnUnhandledPort, loggerService, dmaSystem, dualPic,
+ mixer, emulationLoopScheduler, audioRuntimeOptions);
loggerService.LogInformation("Sound devices created...");
@@ -498,7 +499,8 @@ internal Spice86DependencyInjection(Configuration configuration, MainWindow? mai
state, biosKeyboardBuffer,
keyboardInt16Handler, biosDataArea, vgaFunctionality,
new Dictionary {
- { "BLASTER", soundBlaster.BlasterString } }, ioPortDispatcher, loggerService,
+ { "BLASTER", soundBlaster.BlasterString },
+ { "ULTRASND", gravisUltraSound.UltraSndString } }, ioPortDispatcher, loggerService,
floppyDiskTimingService,
mixer, driveActivityNotifier, xms);