Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions Sources/FMSynthesis/YM2608.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
15 changes: 12 additions & 3 deletions Sources/FMSynthesis/YM2608Serialize.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
79 changes: 79 additions & 0 deletions Tests/Bubilator88CoreTests/YM2608PseudoStereoTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}