diff --git a/Sources/FMSynthesis/YM2608.swift b/Sources/FMSynthesis/YM2608.swift index b3dcaed..58dd4b0 100644 --- a/Sources/FMSynthesis/YM2608.swift +++ b/Sources/FMSynthesis/YM2608.swift @@ -401,11 +401,22 @@ package final class YM2608 { package var pseudoStereoEnabled: Bool = false /// Set to true when any FM channel's pan register (0xB4) is written with non-center value. - /// Once set, pseudo-stereo is suppressed for FM until reset. + /// Once set, pseudo-stereo is suppressed until reset — for SSG as well as FM: a program + /// that places its FM channels itself authored a stereo image, and widening the SSG + /// underneath it would fight that image rather than complement it. + /// + /// Tracked unconditionally, not only while `pseudoStereoEnabled` is set, so that + /// enabling pseudo-stereo mid-tune still sees the pans the program wrote earlier. package private(set) var fmPanDetected: Bool = false package var chorusFM = ChorusEffect() // L=dry, R=delayed package var chorusSSG = ChorusEffect(delayLeft: true) // L=delayed, R=dry + /// Rebuild `fmPanDetected` from the current channel pans. Used after a save + /// state load, where the latch itself is not stored but the pans are. + package func refreshPanDetection() { + fmPanDetected = (0..<6).contains { !(fmSynth.ch[$0].panLeft && fmSynth.ch[$0].panRight) } + } + /// Audio sample rate for output (default 44100 Hz) package static let sampleRate = 44100 @@ -720,9 +731,10 @@ package final class YM2608 { if mask.contains(.ssg) { let ssgScaled = Int((ssgSample * 16384.0).rounded()) - // Immersive takes priority: never apply pseudo-stereo chorus when - // spatial output is active (the two modes are mutually exclusive). - if pseudoStereoEnabled && !immersiveOutputEnabled { + // Same gating as the FM path in generateFMSamples(): suppressed once the + // program pans an FM channel itself, and immersive takes priority (the + // two modes are mutually exclusive). + if pseudoStereoEnabled && !fmPanDetected && !immersiveOutputEnabled { let (sl, sr) = chorusSSG.process(monoSample: ssgScaled) mixL = Self.storeSample16(mixL, sl) mixR = Self.storeSample16(mixR, sr) diff --git a/Sources/FMSynthesis/YM2608Serialize.swift b/Sources/FMSynthesis/YM2608Serialize.swift index 42650b1..f28ef6f 100644 --- a/Sources/FMSynthesis/YM2608Serialize.swift +++ b/Sources/FMSynthesis/YM2608Serialize.swift @@ -235,11 +235,20 @@ extension YM2608 { adpcmReadPrefetch = pos < data.count ? data[pos] : 0 if pos < data.count { pos += 1 } - // Clear transient audio buffer. The CD-mix delay lines are not part of the - // save state either, so drop their contents rather than carrying a tail - // from the pre-load audio into the restored scene. + // Clear transient audio buffer. The CD-mix and chorus delay lines are not + // part of the save state either, so drop their contents rather than + // carrying a tail from the pre-load audio into the restored scene. audioBuffer.removeAll() resetPostProcessors() + chorusFM.reset() + chorusSSG.reset() + + // Rebuild the pan latch from the restored channels instead of storing it: + // FMCh.panLeft/panRight are part of the FMSynthesizer blob, so a load can + // recover it without a format change. A tune that pans a channel and later + // returns it to centre loses the latch across a load; the register writes + // that follow set it again. + refreshPanDetection() return true } diff --git a/Tests/Bubilator88CoreTests/YM2608PseudoStereoTests.swift b/Tests/Bubilator88CoreTests/YM2608PseudoStereoTests.swift new file mode 100644 index 0000000..7fa1c59 --- /dev/null +++ b/Tests/Bubilator88CoreTests/YM2608PseudoStereoTests.swift @@ -0,0 +1,79 @@ +import Testing +@testable import FMSynthesis + +@Suite("YM2608 pseudo-stereo gating") +struct YM2608PseudoStereoTests { + + /// SSG tone on channel A, loud enough that the Haas delay shows up as a + /// left/right difference in the mixed output. + private func makeSSGTone(pseudoStereo: Bool) -> YM2608 { + let sound = YM2608() + sound.reset() + sound.pseudoStereoEnabled = pseudoStereo + sound.writeAddr(0x00); sound.writeData(0x40) // tone period low + sound.writeAddr(0x01); sound.writeData(0x01) // tone period high + sound.writeAddr(0x07); sound.writeData(0x3E) // mixer: tone A only + sound.writeAddr(0x08); sound.writeData(0x0F) // channel A volume + return sound + } + + private func hasStereoDifference(_ sound: YM2608) -> Bool { + stride(from: 0, to: sound.audioBuffer.count - 1, by: 2).contains { + sound.audioBuffer[$0] != sound.audioBuffer[$0 + 1] + } + } + + /// Writes a non-centre pan to FM channel 1 (register 0xB4, right only). + private func panChannel1Right(_ sound: YM2608) { + sound.writeAddr(0xB4) + sound.writeData(0x40) + } + + @Test("SSG output is widened while no FM channel is panned") + func ssgWidenedWhenCentred() { + let sound = makeSSGTone(pseudoStereo: true) + sound.tick(tStates: 80_000) + #expect(!sound.audioBuffer.isEmpty) + #expect(hasStereoDifference(sound)) + } + + @Test("SSG widening stops once the program pans an FM channel") + func ssgFollowsFMPanDetection() { + let sound = makeSSGTone(pseudoStereo: true) + panChannel1Right(sound) + #expect(sound.fmPanDetected) + sound.tick(tStates: 80_000) + #expect(!sound.audioBuffer.isEmpty) + #expect(!hasStereoDifference(sound)) + } + + @Test("Pan detection runs even while pseudo-stereo is off") + func panDetectedWithPseudoStereoDisabled() { + let sound = makeSSGTone(pseudoStereo: false) + panChannel1Right(sound) + #expect(sound.fmPanDetected) + } + + @Test("A save state load rebuilds the pan latch from the restored channels") + func panLatchSurvivesSaveStateRoundTrip() { + let source = makeSSGTone(pseudoStereo: true) + panChannel1Right(source) + let state = source.serializeState() + + let restored = makeSSGTone(pseudoStereo: true) + #expect(!restored.fmPanDetected) + #expect(restored.deserializeState(state)) + #expect(restored.fmPanDetected) + } + + @Test("A save state taken before any pan write leaves the latch clear") + func centredStateLoadsWithLatchClear() { + let source = makeSSGTone(pseudoStereo: true) + let state = source.serializeState() + + let restored = makeSSGTone(pseudoStereo: true) + panChannel1Right(restored) + #expect(restored.deserializeState(state)) + #expect(!restored.fmPanDetected) + } +}