diff --git a/apple2.go b/apple2.go index 74b1b3bb..de1d77f1 100644 --- a/apple2.go +++ b/apple2.go @@ -17,8 +17,10 @@ type Apple2 struct { io *ioC0Page video screen.VideoSource cg *CharacterGenerator - cards [8]Card - tracers []executionTracer + cards [8]Card + tracers []executionTracer + tickerCards []cardTicker + irqRequests uint8 // Bitmask of the slots asserting the IRQ line softVideoSwitch softVideoSwitchProvider board string @@ -50,15 +52,32 @@ func (a *Apple2) GetCards() [8]Card { return a.cards } +// A cardTicker is a card that needs to run logic on every instruction +type cardTicker interface { + tick() +} + +func (a *Apple2) registerTickerCard(c cardTicker) { + a.tickerCards = append(a.tickerCards, c) +} + +// requestIRQ sets or clears the IRQ request of a slot. The CPU IRQ line +// is asserted while any slot requests it. To be called only from the +// emulation goroutine. +func (a *Apple2) requestIRQ(slot int, asserted bool) { + if asserted { + a.irqRequests |= 1 << slot + } else { + a.irqRequests &^= 1 << slot + } + a.cpu.SetIRQ(a.irqRequests != 0) +} + // SetKeyboardProvider attaches an external keyboard provider func (a *Apple2) SetKeyboardProvider(kb KeyboardProvider) { a.io.setKeyboardProvider(kb) } -// SetSpeakerProvider attaches an external speaker provider -func (a *Apple2) SetSpeakerProvider(s SpeakerProvider) { - a.io.setSpeakerProvider(s) -} // SetJoysticksProvider attaches an external joysticks provider func (a *Apple2) SetJoysticksProvider(j JoysticksProvider) { diff --git a/apple2Run.go b/apple2Run.go index e0f9120d..a4e63644 100644 --- a/apple2Run.go +++ b/apple2Run.go @@ -48,6 +48,7 @@ func (a *Apple2) Start(paused bool) { a.cpu.ExecuteInstruction() a.cycles += a.cpu.GetCycles() - startCycles + a.tickCards() a.executionTrace() } } else { @@ -57,6 +58,7 @@ func (a *Apple2) Start(paused bool) { card.runDMACycle() a.cycles++ + a.tickCards() a.executionTrace() } } @@ -137,6 +139,12 @@ func (a *Apple2) reset() { } } +func (a *Apple2) tickCards() { + for _, c := range a.tickerCards { + c.tick() + } +} + func (a *Apple2) executionTrace() { for _, v := range a.tracers { v.inspect() diff --git a/audio/mixer.go b/audio/mixer.go new file mode 100644 index 00000000..10745f11 --- /dev/null +++ b/audio/mixer.go @@ -0,0 +1,196 @@ +/* +Package audio synthesizes the sound of the Apple II. + +The emulation core reports the sound activity as events with the CPU +cycle when they happen: speaker clicks and output level changes of the +sound cards. This package reconstructs a mixed 48 kHz float32 mono +stream from those events. It is shared by the frontends, that only need +to send the samples to their audio device. + +The synthesis works as follows: + + - The Mixer owns a render clock, measured in CPU cycles, that advances + exactly one sample period per sample generated, whether there is + sound or not. + - Each Source holds the timestamped level changes of one sound + generator. For every output sample, the level of each source is + averaged over the sample window of ~21.31 CPU cycles, with the + changes applied at their exact fractional position (box filter). + This avoids most of the aliasing of naive point sampling. + - The render clock lags the newest received event by a target latency, + building a cushion against jitter between the emulation and audio + clocks. If the events get too far ahead (fast forward) or fall + behind (pause, hiccup) the render clock resynchronizes. + - The sources are summed and a DC-blocking high-pass filter models the + AC coupling of a real speaker: constant levels decay to silence. +*/ +package audio + +const ( + // SampleRate is the sample rate of the generated audio stream + SampleRate = 48000 + + // The render clock stays this far behind the newest event, as a + // cushion against jitter of the emulation clock + targetLatencyMs = 30 + + // Resynchronize if the newest event is further ahead than this + maxLatencyMs = 200 + + // Pole of the DC-blocking filter, ~30 Hz cutoff at 48 kHz + dcFilterPole = 0.996 + + eventChannelSize = 10_000 +) + +type levelEvent struct { + cycle uint64 + level float32 +} + +// Source receives the output level changes of one sound generator. It +// implements the izapple2.AudioSink interface. +type Source struct { + events chan levelEvent + pending []levelEvent + consumed int + level float64 // Current output level of the generator +} + +// PushLevel reports that the generator output changed to level at the +// given CPU cycle. It can be called from any goroutine. +func (s *Source) PushLevel(cycle uint64, level float32) { + select { + case s.events <- levelEvent{cycle, level}: + default: + // The channel is full, the event is lost + } +} + +// renderWindow returns the average level over the sample window, +// applying the pending events that fall inside it +func (s *Source) renderWindow(start, end float64) float64 { + acc := 0.0 + pos := start + for s.consumed < len(s.pending) { + event := s.pending[s.consumed] + cycle := float64(event.cycle) + if cycle >= end { + break + } + if cycle > pos { + acc += s.level * (cycle - pos) + pos = cycle + } + s.level = float64(event.level) + s.consumed++ + } + acc += s.level * (end - pos) + return acc / (end - start) +} + +// Mixer generates the audio stream of the machine, summing the streams +// of its sources. +type Mixer struct { + sources []*Source + + // Conversions for the configured CPU clock + cyclesPerSample float64 // ~21.31 cycles at the standard clock + targetLatencyCycles float64 + maxLatencyCycles float64 + + synced bool // False until the first event arrives + renderCycle float64 // Render clock, the CPU cycle of the next sample + + // DC-blocking filter state + dcPrevIn float64 + dcPrevOut float64 +} + +// NewMixer creates a Mixer for a CPU running at clockMhz +func NewMixer(clockMhz float64) *Mixer { + var m Mixer + m.cyclesPerSample = 1000 * clockMhz * 1000 / SampleRate + m.targetLatencyCycles = 1000 * clockMhz * targetLatencyMs + m.maxLatencyCycles = 1000 * clockMhz * maxLatencyMs + return &m +} + +// NewSource adds a sound source to the mix. All the sources must be +// created before the audio device starts calling ReadSamples. +func (m *Mixer) NewSource() *Source { + s := &Source{ + events: make(chan levelEvent, eventChannelSize), + pending: make([]levelEvent, 0, eventChannelSize), + } + m.sources = append(m.sources, s) + return s +} + +// ReadSamples fills buf with the next samples of the audio stream. Mono, +// values in [-1.0, 1.0]. It must always be called from the same goroutine +// or audio callback thread. +func (m *Mixer) ReadSamples(buf []float32) { + // Take the queued events of each source + someEvents := false + var oldest, newest float64 + for _, s := range m.sources { + for done := false; !done; { + select { + case event := <-s.events: + s.pending = append(s.pending, event) + default: + done = true + } + } + if len(s.pending) > 0 { + first := float64(s.pending[0].cycle) + last := float64(s.pending[len(s.pending)-1].cycle) + if !someEvents || first < oldest { + oldest = first + } + if !someEvents || last > newest { + newest = last + } + someEvents = true + } + } + + // Synchronize the render clock with the event timestamps + if someEvents { + if !m.synced || oldest < m.renderCycle || newest > m.renderCycle+m.maxLatencyCycles { + m.renderCycle = newest - m.targetLatencyCycles + m.synced = true + } + } + + if !m.synced { + // No events received yet, keep silent + for i := range buf { + buf[i] = 0 + } + return + } + + for i := range buf { + windowEnd := m.renderCycle + m.cyclesPerSample + sample := 0.0 + for _, s := range m.sources { + sample += s.renderWindow(m.renderCycle, windowEnd) + } + + // DC-blocking filter + out := sample - m.dcPrevIn + dcFilterPole*m.dcPrevOut + m.dcPrevIn = sample + m.dcPrevOut = out + + buf[i] = float32(out) + m.renderCycle = windowEnd + } + + // Keep the events not rendered yet for the next call + for _, s := range m.sources { + s.pending = s.pending[:copy(s.pending, s.pending[s.consumed:])] + s.consumed = 0 + } +} diff --git a/audio/mixer_test.go b/audio/mixer_test.go new file mode 100644 index 00000000..30943df8 --- /dev/null +++ b/audio/mixer_test.go @@ -0,0 +1,62 @@ +package audio + +import ( + "testing" +) + +func waveAmplitude(buf []float32) float32 { + peak := float32(0) + for _, v := range buf { + if v > peak { + peak = v + } + } + return peak +} + +func TestMixerSumsSources(t *testing.T) { + // The same square wave on one speaker and on two in-phase speakers + single := newWaveDriver(1_000_000, 1) + double := newWaveDriver(1_000_000, 2) + + singlePeak := waveAmplitude(single.run(512, 20)) + doublePeak := waveAmplitude(double.run(512, 20)) + + ratio := doublePeak / singlePeak + if ratio < 1.8 || ratio > 2.2 { + t.Errorf("expected two in-phase sources to double the amplitude, got a ratio of %v", ratio) + } +} + +func TestMixerSilentSourceDoesNotBlock(t *testing.T) { + // An extra source with no events must not prevent the others from + // playing + d := newWaveDriver(1_000_000, 1) + d.m.NewSource() + + out := d.run(512, 20) + if waveAmplitude(out) < 0.1 { + t.Error("expected the speaker to play with a silent source attached") + } +} + +func TestMixerSourceHoldsLevel(t *testing.T) { + // A source that reports one level change holds it, and the DC filter + // slowly brings the output back to zero + m := NewMixer(testClockMhz) + s := m.NewSource() + + s.PushLevel(1_000_000, 0.5) + buf := make([]float32, SampleRate) // One second + m.ReadSamples(buf) + + if waveAmplitude(buf) < 0.4 { + t.Error("expected the level step to reach the output") + } + tail := buf[len(buf)-100:] + for _, v := range tail { + if v > 0.01 || v < -0.01 { + t.Fatalf("expected the DC filter to decay a constant level, got %v", v) + } + } +} diff --git a/audio/speaker.go b/audio/speaker.go deleted file mode 100644 index b5024317..00000000 --- a/audio/speaker.go +++ /dev/null @@ -1,156 +0,0 @@ -/* -Package audio synthesizes the sound of the Apple II speaker. - -The Apple II speaker is a 1-bit device: each access to $C030 toggles the -speaker cone position. The emulator core reports those accesses as clicks -with the CPU cycle when they happen. This package reconstructs a 48 kHz -float32 audio stream from that stream of click timestamps. It is shared by -the frontends, that only need to send the samples to their audio device. - -The synthesis works as follows: - - - A render clock, measured in CPU cycles, advances exactly one sample - period per sample generated, whether there is sound or not. - - Each output sample is the average of the speaker position over its - window of ~21.31 CPU cycles, with the toggles placed at their exact - fractional position (box filter). This avoids most of the aliasing of - naive point sampling. - - The render clock lags the newest received click by a target latency, - building a cushion against jitter between the emulation and audio - clocks. If the clicks get too far ahead (fast forward) or fall behind - (pause, hiccup) the render clock resynchronizes. - - A DC-blocking high-pass filter models the AC coupling of a real - speaker: constant levels decay to silence and no artificial decay or - resting level is needed. -*/ -package audio - -const ( - // SampleRate is the sample rate of the generated audio stream - SampleRate = 48000 - - // The render clock stays this far behind the newest click, as a - // cushion against jitter of the emulation clock - targetLatencyMs = 30 - - // Resynchronize if the newest click is further ahead than this - maxLatencyMs = 200 - - // Speaker cone positions, giving half of the full scale peak to peak - speakerLevel = 0.25 - - // Pole of the DC-blocking filter, ~30 Hz cutoff at 48 kHz - dcFilterPole = 0.996 - - clickChannelSize = 10_000 -) - -// Speaker generates the audio stream of the Apple II speaker. It -// implements the izapple2.SpeakerProvider interface. -type Speaker struct { - clickChannel chan uint64 - pendingClicks []uint64 - - // Conversions for the configured CPU clock - cyclesPerSample float64 // ~21.31 cycles at the standard clock - targetLatencyCycles float64 - maxLatencyCycles float64 - - synced bool // False until the first click arrives - renderCycle float64 // Render clock, the CPU cycle of the next sample - level float64 // Current speaker position - - // DC-blocking filter state - dcPrevIn float64 - dcPrevOut float64 -} - -// NewSpeaker creates a Speaker for a CPU running at clockMhz -func NewSpeaker(clockMhz float64) *Speaker { - var s Speaker - s.clickChannel = make(chan uint64, clickChannelSize) - s.pendingClicks = make([]uint64, 0, clickChannelSize) - s.cyclesPerSample = 1000 * clockMhz * 1000 / SampleRate - s.targetLatencyCycles = 1000 * clockMhz * targetLatencyMs - s.maxLatencyCycles = 1000 * clockMhz * maxLatencyMs - s.level = speakerLevel - return &s -} - -// Click receives a speaker click. The argument is the CPU cycle when it is -// generated. It can be called from any goroutine. -func (s *Speaker) Click(cycle uint64) { - select { - case s.clickChannel <- cycle: - default: - // The channel is full, the click is lost - } -} - -// ReadSamples fills buf with the next samples of the audio stream. Mono, -// values in [-1.0, 1.0]. It must always be called from the same goroutine -// or audio callback thread. -func (s *Speaker) ReadSamples(buf []float32) { - // Take the queued clicks - for done := false; !done; { - select { - case cycle := <-s.clickChannel: - s.pendingClicks = append(s.pendingClicks, cycle) - default: - done = true - } - } - - // Synchronize the render clock with the click timestamps - if len(s.pendingClicks) > 0 { - oldest := float64(s.pendingClicks[0]) - newest := float64(s.pendingClicks[len(s.pendingClicks)-1]) - if !s.synced || oldest < s.renderCycle || newest > s.renderCycle+s.maxLatencyCycles { - s.renderCycle = newest - s.targetLatencyCycles - s.synced = true - } - } - - if !s.synced { - // No clicks received yet, keep silent - for i := range buf { - buf[i] = 0 - } - return - } - - consumed := 0 - for i := range buf { - windowEnd := s.renderCycle + s.cyclesPerSample - - // Average the speaker position over the sample window, toggling - // at the exact click positions - acc := 0.0 - pos := s.renderCycle - for consumed < len(s.pendingClicks) { - click := float64(s.pendingClicks[consumed]) - if click >= windowEnd { - break - } - if click > pos { - acc += s.level * (click - pos) - pos = click - } - s.level = -s.level - consumed++ - } - acc += s.level * (windowEnd - pos) - sample := acc / s.cyclesPerSample - - // DC-blocking filter - out := sample - s.dcPrevIn + dcFilterPole*s.dcPrevOut - s.dcPrevIn = sample - s.dcPrevOut = out - - buf[i] = float32(out) - s.renderCycle = windowEnd - } - - // Keep the clicks not rendered yet for the next call - s.pendingClicks = s.pendingClicks[:copy(s.pendingClicks, s.pendingClicks[consumed:])] -} diff --git a/audio/speaker_test.go b/audio/wave_test.go similarity index 74% rename from audio/speaker_test.go rename to audio/wave_test.go index e8112c16..60ef9280 100644 --- a/audio/speaker_test.go +++ b/audio/wave_test.go @@ -8,14 +8,32 @@ import ( // The standard NTSC Apple II clock const testClockMhz = 14.318 / 14 -func TestSilenceBeforeFirstClick(t *testing.T) { - s := NewSpeaker(testClockMhz) +// testToggler pushes speaker-like level toggles to a source +type testToggler struct { + source *Source + high bool +} + +const testSpeakerLevel = 0.25 + +func (tg *testToggler) click(cycle uint64) { + tg.high = !tg.high + if tg.high { + tg.source.PushLevel(cycle, testSpeakerLevel) + } else { + tg.source.PushLevel(cycle, -testSpeakerLevel) + } +} + +func TestSilenceBeforeFirstEvent(t *testing.T) { + m := NewMixer(testClockMhz) + m.NewSource() buf := make([]float32, 1024) buf[10] = 42.0 // Ensure the buffer is overwritten - s.ReadSamples(buf) + m.ReadSamples(buf) for i, v := range buf { if v != 0 { - t.Fatalf("expected silence before the first click, got %v at sample %v", v, i) + t.Fatalf("expected silence before the first event, got %v at sample %v", v, i) } } } @@ -24,35 +42,42 @@ func TestSilenceBeforeFirstClick(t *testing.T) { // parallel: on each step it queues the clicks of a square wave for the // next chunk of emulated time and reads the equivalent audio samples. type waveDriver struct { - s *Speaker + m *Mixer + togglers []*testToggler nextClick uint64 cycle uint64 } const driverChunkSamples = 480 // 10 ms -func newWaveDriver(startCycle uint64) *waveDriver { - return &waveDriver{ - s: NewSpeaker(testClockMhz), +func newWaveDriver(startCycle uint64, togglers int) *waveDriver { + d := &waveDriver{ + m: NewMixer(testClockMhz), nextClick: startCycle, cycle: startCycle, } + for range togglers { + d.togglers = append(d.togglers, &testToggler{source: d.m.NewSource()}) + } + return d } // step advances 10 ms, toggling every halfPeriodCycles, 0 for silence func (d *waveDriver) step(halfPeriodCycles uint64) []float32 { - chunkCycles := float64(driverChunkSamples) * d.s.cyclesPerSample + chunkCycles := float64(driverChunkSamples) * d.m.cyclesPerSample d.cycle += uint64(chunkCycles) if halfPeriodCycles != 0 { for d.nextClick < d.cycle { - d.s.Click(d.nextClick) + for _, tg := range d.togglers { + tg.click(d.nextClick) + } d.nextClick += halfPeriodCycles } } else { d.nextClick = d.cycle } buf := make([]float32, driverChunkSamples) - d.s.ReadSamples(buf) + d.m.ReadSamples(buf) return buf } @@ -66,7 +91,7 @@ func (d *waveDriver) run(halfPeriodCycles uint64, steps int) []float32 { func TestSquareWaveFrequency(t *testing.T) { // A square wave toggling every 512 cycles, ~977 Hz, for 400 ms - d := newWaveDriver(1_000_000) + d := newWaveDriver(1_000_000, 1) out := d.run(512, 40) // Check the average period on the second half, once in steady state @@ -82,14 +107,14 @@ func TestSquareWaveFrequency(t *testing.T) { } gotPeriod := float64(risings[len(risings)-1]-risings[0]) / float64(len(risings)-1) - wantPeriod := 2 * 512 / d.s.cyclesPerSample // ~48 samples + wantPeriod := 2 * 512 / d.m.cyclesPerSample // ~48 samples if math.Abs(gotPeriod-wantPeriod) > 0.1 { t.Errorf("expected a period of %.2f samples, got %.2f", wantPeriod, gotPeriod) } } func TestAmplitudeAndDCRemoval(t *testing.T) { - d := newWaveDriver(1_000_000) + d := newWaveDriver(1_000_000, 1) out := d.run(512, 40) sum := 0.0 @@ -106,7 +131,7 @@ func TestAmplitudeAndDCRemoval(t *testing.T) { } func TestSilenceDecaysAfterSound(t *testing.T) { - d := newWaveDriver(1_000_000) + d := newWaveDriver(1_000_000, 1) d.run(512, 20) // A second of silence @@ -121,7 +146,7 @@ func TestSilenceDecaysAfterSound(t *testing.T) { } func TestSoundResumesAfterPause(t *testing.T) { - d := newWaveDriver(1_000_000) + d := newWaveDriver(1_000_000, 1) d.run(512, 20) d.run(0, 100) // A second of silence @@ -140,7 +165,7 @@ func TestSoundResumesAfterPause(t *testing.T) { } func TestResyncAfterFastForward(t *testing.T) { - d := newWaveDriver(1_000_000) + d := newWaveDriver(1_000_000, 1) d.run(512, 20) // The emulation fast-forwards ten seconds ahead of the audio clock diff --git a/audioSource.go b/audioSource.go new file mode 100644 index 00000000..07c896be --- /dev/null +++ b/audioSource.go @@ -0,0 +1,64 @@ +package izapple2 + +/* +Audio interface between the sound generators and the frontends. + +The built-in speaker and the cards that generate sound implement +AudioSource. The frontend attaches an AudioSink to each source and +renders the reported level changes, typically with the audio package +Mixer. +*/ + +// AudioSink receives the output level changes of a sound source with the +// CPU cycle when they happen. Implemented by the frontends. +type AudioSink interface { + PushLevel(cycle uint64, level float32) +} + +// AudioSource is implemented by the cards that generate sound. +type AudioSource interface { + GetAudioSourceName() string + SetAudioSink(sink AudioSink) +} + +// GetAudioSources returns the sound generators of the machine: the +// built-in speaker and the sound cards +func (a *Apple2) GetAudioSources() []AudioSource { + sources := []AudioSource{&a.io.speaker} + for _, card := range a.cards { + if source, ok := card.(AudioSource); ok { + sources = append(sources, source) + } + } + return sources +} + +// Position of the speaker cone, giving half of the full scale peak to peak +const speakerLevel = 0.25 + +// speakerAudioSource is the AudioSource of the built-in speaker. Each +// access to $C030 toggles the position of the speaker cone. +type speakerAudioSource struct { + sink AudioSink + high bool // Position of the speaker cone +} + +func (s *speakerAudioSource) GetAudioSourceName() string { + return "speaker" +} + +func (s *speakerAudioSource) SetAudioSink(sink AudioSink) { + s.sink = sink +} + +func (s *speakerAudioSource) click(cycle uint64) { + if s.sink == nil { + return + } + s.high = !s.high + if s.high { + s.sink.PushLevel(cycle, speakerLevel) + } else { + s.sink.PushLevel(cycle, -speakerLevel) + } +} diff --git a/boardApple2.go b/boardApple2.go index eb13f0cc..6c5dbbbc 100644 --- a/boardApple2.go +++ b/boardApple2.go @@ -123,9 +123,7 @@ func getSoftSwitch(io *ioC0Page, ioFlag uint8, isSet bool) softSwitchR { func buildSpeakerSoftSwitch(io *ioC0Page) softSwitchR { return func() uint8 { - if io.speaker != nil { - io.speaker.Click(io.apple2.GetCycles()) - } + io.speaker.click(io.apple2.GetCycles()) return 0 } } diff --git a/cardBuilder.go b/cardBuilder.go index ef4fab35..6ac1d2e8 100644 --- a/cardBuilder.go +++ b/cardBuilder.go @@ -51,6 +51,7 @@ func getCardFactory() map[string]*cardBuilder { cardFactory["language"] = newCardLanguageBuilder() cardFactory["softswitchlogger"] = newCardLoggerBuilder() cardFactory["memexp"] = newCardMemoryExpansionBuilder() + cardFactory["mockingboard"] = newCardMockingboardBuilder() cardFactory["mouse"] = newCardMouseBuilder() cardFactory["multirom"] = newMultiRomCardBuilder() cardFactory["parallel"] = newCardParallelPrinterBuilder() diff --git a/cardCat_test.go b/cardCat_test.go index c9b0f85d..008cf96f 100644 --- a/cardCat_test.go +++ b/cardCat_test.go @@ -80,6 +80,10 @@ func TestCardsDetected(t *testing.T) { testCardDetectedInternal(t, "2enh", "4096", "ramworks", 55_000_000, "RAMWorks 4096K Card in Aux Slot") }) + t.Run("test Mockingboard card", func(t *testing.T) { + testCardDetectedInternal(t, "2enh", "mockingboard", "s4", 55_000_000, "4 MockingBoard Card") + }) + // Swyftcard not compatible with Card Cat // Unknonw cards: prodosromcard3 } diff --git a/cardMockingboard.go b/cardMockingboard.go new file mode 100644 index 00000000..aaff26c3 --- /dev/null +++ b/cardMockingboard.go @@ -0,0 +1,185 @@ +package izapple2 + +import ( + "github.com/ivanizag/izapple2/component" +) + +/* +Mockingboard sound card. Two MOS 6522 VIAs driving two AY-3-8913 +programmable sound generators. + +See: + https://en.wikipedia.org/wiki/Mockingboard + https://github.com/AppleWin/AppleWin/blob/master/source/Mockingboard.cpp + +The card is addressed on its slot ROM page, there is no ROM: + + $Cn00-$Cn0F: VIA 1 registers, mirrored up to $Cn7F + $Cn80-$Cn8F: VIA 2 registers, mirrored up to $CnFF + +Each VIA drives one AY-3-8913: + + Port A: data bus + Port B bit 0: BC1 + Port B bit 1: BDIR + Port B bit 2: /RESET, active low + +The IRQ outputs of both VIAs are wired to the 6502 IRQ line. The VIA +timers are caught up on every access and on every instruction, so the +timer reads used by the Mockingboard detection routines are cycle exact. + +The mixed output level of both PSGs is reported to the frontend as an +AudioSource, stepping the chips every 8 CPU cycles. +*/ +type CardMockingboard struct { + cardBase + via [2]component.MOS6522 + psg [2]component.AY38913 + lastBusOp [2]uint8 + + sink AudioSink + lastCycle uint64 // The chips are caught up to this cycle + psgCycle uint64 // Start of the next PSG synthesis step + lastLevel float32 +} + +const ( + mockingboardBusRead uint8 = 1 + mockingboardBusWrite uint8 = 2 + mockingboardBusLatch uint8 = 3 + + mockingboardPsgStepCycles = 8 +) + +func newCardMockingboardBuilder() *cardBuilder { + return &cardBuilder{ + name: "Mockingboard", + description: "Mockingboard sound card with two AY-3-8913 sound generators", + buildFunc: func(params map[string]string) (Card, error) { + var c CardMockingboard + return &c, nil + }, + } +} + +func (c *CardMockingboard) assign(a *Apple2, slot int) { + c.cardBase.assign(a, slot) + if slot != 0 { + a.mmu.setCardROM(slot, traceMemory(c, c.name, c.traceMemory)) + } + a.registerTickerCard(c) + c.lastCycle = a.GetCycles() + c.psgCycle = c.lastCycle + c.psg[0].Reset() + c.psg[1].Reset() +} + +func (c *CardMockingboard) reset() { + for i := range 2 { + c.via[i].Reset() + c.psg[i].Reset() + c.lastBusOp[i] = 0 + } + if c.a != nil { + c.a.requestIRQ(c.slot, false) + } +} + +// peek and poke serve the $Cn00-$CnFF page, implementing memoryHandler +func (c *CardMockingboard) peek(address uint16) uint8 { + c.catchUp() + n := int(address>>7) & 1 + value := c.via[n].Read(uint8(address & 0x0f)) + c.updateIRQ() // Some register reads clear interrupt flags + return value +} + +func (c *CardMockingboard) poke(address uint16, value uint8) { + c.catchUp() + n := int(address>>7) & 1 + reg := uint8(address & 0x0f) + c.via[n].Write(reg, value) + if reg == 0 || reg == 2 { + // ORB or DDRB writes can change the PSG bus control lines + c.updatePsgBus(n) + } + c.updateIRQ() +} + +// updatePsgBus runs the AY-3-8913 bus protocol wired to the VIA port B +func (c *CardMockingboard) updatePsgBus(n int) { + portB := c.via[n].GetPortB() + if portB&0x04 == 0 { + // /RESET is active low + c.psg[n].Reset() + c.lastBusOp[n] = 0 + return + } + + // The operations execute when BDIR-BC1 change + busOp := portB & 0x03 + if busOp != c.lastBusOp[n] { + switch busOp { + case mockingboardBusRead: + c.via[n].SetInputA(c.psg[n].ReadData()) + case mockingboardBusWrite: + c.psg[n].WriteData(c.via[n].GetPortA()) + case mockingboardBusLatch: + c.psg[n].LatchAddress(c.via[n].GetPortA()) + } + c.lastBusOp[n] = busOp + } +} + +// tick is called on every instruction to keep the timers, the interrupt +// line and the sound synthesis up to date +func (c *CardMockingboard) tick() { + c.catchUp() +} + +func (c *CardMockingboard) catchUp() { + current := c.a.GetCycles() + if current <= c.lastCycle { + return + } + elapsed := current - c.lastCycle + c.lastCycle = current + + c.via[0].Tick(elapsed) + c.via[1].Tick(elapsed) + c.updateIRQ() + c.synthesize(current) +} + +func (c *CardMockingboard) updateIRQ() { + c.a.requestIRQ(c.slot, c.via[0].InterruptAsserted() || c.via[1].InterruptAsserted()) +} + +// synthesize advances both PSGs up to the given cycle in steps of 8 CPU +// cycles, reporting the changes of the mixed level to the audio sink +func (c *CardMockingboard) synthesize(toCycle uint64) { + if c.sink == nil { + c.psgCycle = toCycle + return + } + + for ; c.psgCycle+mockingboardPsgStepCycles <= toCycle; c.psgCycle += mockingboardPsgStepCycles { + // Each PSG ranges from 0.0 to 3.0, scale the sum of both to a + // range comparable to the speaker + level := (c.psg[0].Step() + c.psg[1].Step()) * 0.25 + if level != c.lastLevel { + c.sink.PushLevel(c.psgCycle, level) + c.lastLevel = level + } + } +} + +// GetAudioSourceName implements the AudioSource interface +func (c *CardMockingboard) GetAudioSourceName() string { + return "mockingboard" +} + +// SetAudioSink implements the AudioSource interface +func (c *CardMockingboard) SetAudioSink(sink AudioSink) { + c.sink = sink +} diff --git a/cardMockingboard_test.go b/cardMockingboard_test.go new file mode 100644 index 00000000..13a6a5d5 --- /dev/null +++ b/cardMockingboard_test.go @@ -0,0 +1,150 @@ +package izapple2 + +import ( + "testing" +) + +func makeMockingboardTester(t *testing.T) (*Apple2, *CardMockingboard) { + overrides := newConfiguration() + overrides.set(confS4, "mockingboard") + at, err := makeApple2Tester("2plus", overrides) + if err != nil { + t.Fatal(err) + } + a := at.a + card, ok := a.cards[4].(*CardMockingboard) + if !ok { + t.Fatal("The Mockingboard should be in slot 4") + } + return a, card +} + +func TestCardMockingboardTimerReadIsCycleExact(t *testing.T) { + a, _ := makeMockingboardTester(t) + + // Start VIA 1 T1 with 0x2000 cycles + a.mmu.Poke(0xc404, 0x00) + a.mmu.Poke(0xc405, 0x20) + + a.cycles += 100 + counter := uint16(a.mmu.Peek(0xc404)) | uint16(a.mmu.Peek(0xc405))<<8 + if counter != 0x2000-100 { + t.Errorf("The timer should decrement with the CPU cycles, got 0x%04x", counter) + } +} + +func TestCardMockingboardTimerIRQ(t *testing.T) { + a, card := makeMockingboardTester(t) + + // Program VIA 1 T1 free running with interrupts every 0x1000 cycles + a.mmu.Poke(0xc40b, 0x40) // ACR: T1 free running + a.mmu.Poke(0xc40e, 0xc0) // IER: enable T1 + a.mmu.Poke(0xc404, 0x00) + a.mmu.Poke(0xc405, 0x10) + + a.cycles += 0x800 + card.tick() + if a.irqRequests != 0 { + t.Error("The IRQ should not be requested before the timer underflows") + } + + a.cycles += 0x900 + card.tick() + if a.irqRequests == 0 { + t.Error("The IRQ should be requested when the timer underflows") + } + + // Reading T1C-L clears the interrupt + a.mmu.Peek(0xc404) + if a.irqRequests != 0 { + t.Error("The IRQ should be released after reading T1C-L") + } +} + +func TestCardMockingboardSecondVIA(t *testing.T) { + a, card := makeMockingboardTester(t) + + // The second VIA is at $Cn80, enable its T2 + a.mmu.Poke(0xc48e, 0xa0) // IER: enable T2 + a.mmu.Poke(0xc488, 0x50) + a.mmu.Poke(0xc489, 0x00) + + a.cycles += 0x100 + card.tick() + if a.irqRequests == 0 { + t.Error("The IRQ should be requested by the second VIA") + } + + a.mmu.Poke(0xc48d, 0x20) // Clear the T2 flag on IFR + if a.irqRequests != 0 { + t.Error("The IRQ should be released after clearing IFR") + } +} + +// writePsg drives the AY bus protocol like the Mockingboard drivers do +func writePsg(a *Apple2, base uint16, reg uint8, value uint8) { + a.mmu.Poke(base+1, reg) // ORA: register number + a.mmu.Poke(base, 4|3) // Latch address + a.mmu.Poke(base, 4) // Inactive + a.mmu.Poke(base+1, value) + a.mmu.Poke(base, 4|2) // Write data + a.mmu.Poke(base, 4) // Inactive +} + +func TestCardMockingboardPsgBus(t *testing.T) { + a, _ := makeMockingboardTester(t) + + // Set the VIA 1 ports as the drivers do + a.mmu.Poke(0xc403, 0xff) // DDRA: all output + a.mmu.Poke(0xc402, 0x07) // DDRB: control lines output + + writePsg(a, 0xc400, 7, 0x3e) // Mixer: only tone A + + // Read the register back through the bus + a.mmu.Poke(0xc401, 7) + a.mmu.Poke(0xc400, 4|3) // Latch address + a.mmu.Poke(0xc400, 4) + a.mmu.Poke(0xc403, 0x00) // DDRA: all input to read + a.mmu.Poke(0xc400, 4|1) // Read data + if v := a.mmu.Peek(0xc401); v != 0x3e { + t.Errorf("The PSG register should read back 0x3e, got 0x%02x", v) + } +} + +type testAudioSink struct { + events int +} + +func (s *testAudioSink) PushLevel(cycle uint64, level float32) { + s.events++ +} + +func TestCardMockingboardGeneratesSound(t *testing.T) { + a, card := makeMockingboardTester(t) + + var card2 AudioSource + for _, source := range a.GetAudioSources() { + if source.GetAudioSourceName() == "mockingboard" { + card2 = source + } + } + if card2 == nil { + t.Fatal("The card should be published as an audio source") + } + + var sink testAudioSink + card2.SetAudioSink(&sink) + + a.mmu.Poke(0xc403, 0xff) + a.mmu.Poke(0xc402, 0x07) + writePsg(a, 0xc400, 0, 100) // Tone A period + writePsg(a, 0xc400, 7, 0x3e) // Mixer: only tone A + writePsg(a, 0xc400, 8, 15) // Full volume + + // Run 10000 cycles, the tone toggles every 800 cycles + a.cycles += 10_000 + card.tick() + if sink.events < 10 { + t.Errorf("The sink should receive the tone level changes, got %v", sink.events) + } +} diff --git a/component/ay38913.go b/component/ay38913.go new file mode 100644 index 00000000..2e1780ad --- /dev/null +++ b/component/ay38913.go @@ -0,0 +1,214 @@ +package component + +/* +General Instrument AY-3-8913 Programmable Sound Generator +See: + + https://map.grauw.nl/resources/sound/generalinstrument_ay-3-8910.pdf + https://en.wikipedia.org/wiki/General_Instrument_AY-3-8910 + +The 8913 is the variant of the AY-3-8910 without I/O ports. Two of them +are used by the Mockingboard card, driven by the 6522 VIAs. + +Registers: + + R0-R1: channel A tone period, 12 bits + R2-R3: channel B tone period, 12 bits + R4-R5: channel C tone period, 12 bits + R6: noise period, 5 bits + R7: mixer, bits 0-2 disable tone A-C, bits 3-5 disable noise A-C + R8-R10: channel A-C amplitude, bit 4 selects the envelope + R11-R12: envelope period, 16 bits + R13: envelope shape: CONTINUE, ATTACK, ALTERNATE, HOLD + +The bus operations selected by the BDIR and BC1 pins are exposed as the +LatchAddress, WriteData and ReadData methods. + +Synthesis is done in steps of 8 chip clocks with Step(), the granularity +of the tone generators. A tone with period TP completes a full cycle +every 2*TP steps (16*TP clocks as per the datasheet). +*/ +type AY38913 struct { + regs [16]uint8 + address uint8 + + toneCounter [3]uint16 + toneOut [3]bool + + noiseCounter uint16 + noiseShift uint32 // 17 bit LFSR + + envCounter uint32 + envPosition uint8 // 0 to 15 within a ramp + envAttack bool // Direction of the current ramp + envHolding bool + envHeldLevel uint8 +} + +var ay38913RegisterMasks = [16]uint8{ + 0xff, 0x0f, 0xff, 0x0f, 0xff, 0x0f, 0x1f, 0xff, + 0x1f, 0x1f, 0x1f, 0xff, 0xff, 0x0f, 0xff, 0xff, +} + +/* +Volume of each of the 16 amplitude levels. The DAC is logarithmic with +steps of ~3dB per level. Normalized so that one channel at full volume +gives 1.0. +*/ +var ay38913Volumes = [16]float32{ + 0.0000, 0.0106, 0.0150, 0.0222, 0.0320, 0.0466, 0.0665, 0.1039, + 0.1237, 0.1986, 0.2803, 0.3548, 0.4702, 0.6030, 0.7530, 1.0000, +} + +// LatchAddress selects the register for the next data operations +func (ay *AY38913) LatchAddress(value uint8) { + // The chip compares the high nibble with its hardwired mask + if value&0xf0 == 0 { + ay.address = value & 0x0f + } +} + +// WriteData writes to the selected register +func (ay *AY38913) WriteData(value uint8) { + ay.regs[ay.address] = value & ay38913RegisterMasks[ay.address] + + if ay.address == 13 { + // Writing the envelope shape restarts the envelope + ay.envCounter = 0 + ay.envPosition = 0 + ay.envAttack = value&0x04 != 0 + ay.envHolding = false + } +} + +// ReadData reads the selected register +func (ay *AY38913) ReadData() uint8 { + return ay.regs[ay.address] +} + +// Reset clears the registers and the generators +func (ay *AY38913) Reset() { + for i := range ay.regs { + ay.regs[i] = 0 + } + ay.address = 0 + for i := range 3 { + ay.toneCounter[i] = 0 + ay.toneOut[i] = false + } + ay.noiseCounter = 0 + ay.noiseShift = 1 + ay.envCounter = 0 + ay.envPosition = 0 + ay.envAttack = false + ay.envHolding = false +} + +// Step advances the generators 8 chip clocks and returns the mixed level +// of the three channels, from 0.0 to 3.0 +func (ay *AY38913) Step() float32 { + // Tone generators, a half period is TP steps + for i := range 3 { + period := uint16(ay.regs[2*i]) | uint16(ay.regs[2*i+1])<<8 + if period == 0 { + period = 1 + } + ay.toneCounter[i]++ + if ay.toneCounter[i] >= period { + ay.toneCounter[i] = 0 + ay.toneOut[i] = !ay.toneOut[i] + } + } + + // Noise generator, the LFSR shifts every 2*NP steps + noisePeriod := 2 * uint16(ay.regs[6]) + if noisePeriod == 0 { + noisePeriod = 2 + } + if ay.noiseShift == 0 { + ay.noiseShift = 1 // Make the zero value usable + } + ay.noiseCounter++ + if ay.noiseCounter >= noisePeriod { + ay.noiseCounter = 0 + // 17 bit LFSR with taps on bits 0 and 3 + bit := (ay.noiseShift ^ (ay.noiseShift >> 3)) & 1 + ay.noiseShift = (ay.noiseShift >> 1) | (bit << 16) + } + noiseOut := ay.noiseShift&1 != 0 + + // Envelope generator, one of the 16 levels every 2*EP steps + if !ay.envHolding { + envPeriod := 2 * (uint32(ay.regs[11]) | uint32(ay.regs[12])<<8) + if envPeriod == 0 { + envPeriod = 2 + } + ay.envCounter++ + if ay.envCounter >= envPeriod { + ay.envCounter = 0 + if ay.envPosition < 15 { + ay.envPosition++ + } else { + ay.endOfEnvelopeRamp() + } + } + } + + // Mix the channels + level := float32(0) + mixer := ay.regs[7] + for i := range 3 { + toneEnabled := mixer&(1< last { + t.Fatalf("The envelope should never raise on decay, got %v after %v", level, last) + } + last = level + } + if last != 0.0 { + t.Errorf("The envelope should end at zero, got %v", last) + } +} + +func TestAY38913EnvelopeAttackHold(t *testing.T) { + var ay AY38913 + ay38913SetRegister(&ay, 7, 0x3f) + ay38913SetRegister(&ay, 8, 0x10) + ay38913SetRegister(&ay, 11, 1) + ay38913SetRegister(&ay, 13, 0x0d) // Attack then hold at full level + + var last float32 + for range 100 { + last = ay.Step() + } + if last != 1.0 { + t.Errorf("The envelope should hold at full level, got %v", last) + } +} + +func TestAY38913Noise(t *testing.T) { + var ay AY38913 + ay38913SetRegister(&ay, 6, 1) // Fastest noise + ay38913SetRegister(&ay, 7, 0x37) // Only noise A enabled + ay38913SetRegister(&ay, 8, 15) + + changes := 0 + last := ay.Step() + for range 1000 { + level := ay.Step() + if level != last { + changes++ + last = level + } + } + if changes < 100 { + t.Errorf("The noise should toggle the output often, got %v changes", changes) + } +} diff --git a/component/mos6522.go b/component/mos6522.go new file mode 100644 index 00000000..990b2c0b --- /dev/null +++ b/component/mos6522.go @@ -0,0 +1,202 @@ +package component + +/* +MOS 6522 Versatile Interface Adapter (VIA) +See: + + http://archive.6502.org/datasheets/mos_6522_preliminary_nov_1977.pdf + http://archive.6502.org/datasheets/rockwell_r6522_via.pdf + +Used by the Mockingboard card to drive the AY-3-8913 sound generators +and to raise interrupts with the timers. + +Implemented: ports A and B, timers T1 and T2, the IFR/IER interrupt logic. +Not implemented: the shift register, the CA/CB control lines, handshaking +and the T2 pulse counting mode. + +Registers: + + 0: ORB/IRB 1: ORA/IRA 2: DDRB 3: DDRA + 4: T1C-L 5: T1C-H 6: T1L-L 7: T1L-H + 8: T2C-L 9: T2C-H 10: SR 11: ACR + 12: PCR 13: IFR 14: IER 15: ORA no handshake + +The timers advance with Tick(elapsedCycles). Catching up with several +cycles at a time is supported, including multiple T1 underflows in free +running mode. +*/ +type MOS6522 struct { + ora, orb uint8 // Output registers + ira, irb uint8 // Input registers, the values on the input pins + ddra, ddrb uint8 // Data direction registers, 1 is output + + t1counter int64 // Signed to detect underflows when catching up + t1latch uint16 + t1fired bool + t2counter int64 + t2latchL uint8 + t2fired bool + + sr, acr, pcr uint8 + ifr, ier uint8 +} + +const ( + mos6522IntT1 uint8 = 1 << 6 + mos6522IntT2 uint8 = 1 << 5 + + mos6522AcrT1FreeRunning uint8 = 1 << 6 +) + +// Read returns the value of a register +func (v *MOS6522) Read(reg uint8) uint8 { + switch reg & 0x0f { + case 0: + return (v.irb &^ v.ddrb) | (v.orb & v.ddrb) + case 1, 15: + return (v.ira &^ v.ddra) | (v.ora & v.ddra) + case 2: + return v.ddrb + case 3: + return v.ddra + case 4: + v.ifr &^= mos6522IntT1 + return uint8(uint16(v.t1counter)) + case 5: + return uint8(uint16(v.t1counter) >> 8) + case 6: + return uint8(v.t1latch) + case 7: + return uint8(v.t1latch >> 8) + case 8: + v.ifr &^= mos6522IntT2 + return uint8(uint16(v.t2counter)) + case 9: + return uint8(uint16(v.t2counter) >> 8) + case 10: + return v.sr + case 11: + return v.acr + case 12: + return v.pcr + case 13: + ifr := v.ifr & 0x7f + if ifr&v.ier != 0 { + ifr |= 0x80 + } + return ifr + case 14: + return v.ier | 0x80 + } + return 0 +} + +// Write sets the value of a register +func (v *MOS6522) Write(reg uint8, value uint8) { + switch reg & 0x0f { + case 0: + v.orb = value + case 1, 15: + v.ora = value + case 2: + v.ddrb = value + case 3: + v.ddra = value + case 4, 6: + v.t1latch = (v.t1latch & 0xff00) | uint16(value) + case 5: + // Load the counter from the latch and start counting + v.t1latch = (v.t1latch & 0x00ff) | uint16(value)<<8 + v.t1counter = int64(v.t1latch) + v.t1fired = false + v.ifr &^= mos6522IntT1 + case 7: + v.t1latch = (v.t1latch & 0x00ff) | uint16(value)<<8 + v.ifr &^= mos6522IntT1 + case 8: + v.t2latchL = value + case 9: + v.t2counter = int64(value)<<8 | int64(v.t2latchL) + v.t2fired = false + v.ifr &^= mos6522IntT2 + case 10: + v.sr = value + case 11: + v.acr = value + case 12: + v.pcr = value + case 13: + // Writing ones clears the flags + v.ifr &^= value & 0x7f + case 14: + if value&0x80 != 0 { + v.ier |= value & 0x7f + } else { + v.ier &^= value & 0x7f + } + } +} + +// Tick advances the timers by the elapsed CPU cycles +func (v *MOS6522) Tick(elapsedCycles uint64) { + v.t1counter -= int64(elapsedCycles) + if v.t1counter < 0 { + if v.acr&mos6522AcrT1FreeRunning != 0 { + // Reload from the latch and interrupt on each underflow + period := int64(v.t1latch) + 2 + v.t1counter += period * (1 + (-v.t1counter-1)/period) + v.ifr |= mos6522IntT1 + } else { + // One shot: interrupt once, the counter rolls over + if !v.t1fired { + v.ifr |= mos6522IntT1 + v.t1fired = true + } + v.t1counter = int64(uint16(v.t1counter)) + } + } + + v.t2counter -= int64(elapsedCycles) + if v.t2counter < 0 { + // One shot: interrupt once, the counter rolls over + if !v.t2fired { + v.ifr |= mos6522IntT2 + v.t2fired = true + } + v.t2counter = int64(uint16(v.t2counter)) + } +} + +// InterruptAsserted returns the state of the IRQ output line +func (v *MOS6522) InterruptAsserted() bool { + return v.ifr&v.ier&0x7f != 0 +} + +// Reset clears the registers as the RES pin. The timers and the shift +// register are not affected +func (v *MOS6522) Reset() { + v.ora, v.orb = 0, 0 + v.ddra, v.ddrb = 0, 0 + v.acr, v.pcr = 0, 0 + v.ifr, v.ier = 0, 0 +} + +// GetPortA returns the values on the port A pins +func (v *MOS6522) GetPortA() uint8 { + return (v.ora & v.ddra) | (v.ira &^ v.ddra) +} + +// GetPortB returns the values on the port B pins +func (v *MOS6522) GetPortB() uint8 { + return (v.orb & v.ddrb) | (v.irb &^ v.ddrb) +} + +// SetInputA sets the values on the port A input pins +func (v *MOS6522) SetInputA(value uint8) { + v.ira = value +} + +// SetInputB sets the values on the port B input pins +func (v *MOS6522) SetInputB(value uint8) { + v.irb = value +} diff --git a/component/mos6522_test.go b/component/mos6522_test.go new file mode 100644 index 00000000..7efbaa1d --- /dev/null +++ b/component/mos6522_test.go @@ -0,0 +1,167 @@ +package component + +import ( + "testing" +) + +func TestMOS6522Timer1OneShot(t *testing.T) { + var v MOS6522 + v.Write(14, 0x80|0x40) // Enable the T1 interrupt + v.Write(4, 100) // T1 latch low + v.Write(5, 0) // T1 counter high, starts the timer + + v.Tick(100) + if v.InterruptAsserted() { + t.Error("The interrupt should not fire before the timer underflows") + } + + v.Tick(2) + if !v.InterruptAsserted() { + t.Error("The interrupt should fire when the timer underflows") + } + + // In one shot mode it must fire only once + v.Write(13, 0x40) // Clear the T1 flag + v.Tick(1000) + if v.InterruptAsserted() { + t.Error("The one shot timer should not fire again") + } +} + +func TestMOS6522Timer1FreeRunning(t *testing.T) { + var v MOS6522 + v.Write(11, 0x40) // ACR: T1 free running + v.Write(14, 0x80|0x40) // Enable the T1 interrupt + v.Write(4, 100) + v.Write(5, 0) + + v.Tick(102) + if !v.InterruptAsserted() { + t.Error("The interrupt should fire on the first underflow") + } + + v.Write(13, 0x40) // Clear the T1 flag + v.Tick(102) // A full period without reprogramming the timer + if !v.InterruptAsserted() { + t.Error("The interrupt should fire again in free running mode") + } + + // Catching up with many periods at once must not hang and must + // leave the counter in range + v.Write(13, 0x40) + v.Tick(1_000_000) + if !v.InterruptAsserted() { + t.Error("The interrupt should fire after catching up many periods") + } + counter := uint16(v.Read(4)) | uint16(v.Read(5))<<8 + if counter > 101 { + t.Errorf("The counter should stay in the period range, got %v", counter) + } +} + +func TestMOS6522Timer1ReadClearsFlag(t *testing.T) { + var v MOS6522 + v.Write(14, 0x80|0x40) + v.Write(4, 10) + v.Write(5, 0) + v.Tick(50) + + if !v.InterruptAsserted() { + t.Error("The interrupt should be asserted") + } + v.Read(4) // Reading T1C-L clears the flag + if v.InterruptAsserted() { + t.Error("Reading T1C-L should clear the T1 flag") + } +} + +func TestMOS6522Timer2(t *testing.T) { + var v MOS6522 + v.Write(14, 0x80|0x20) // Enable the T2 interrupt + v.Write(8, 50) // T2 latch low + v.Write(9, 0) // T2 counter high, starts the timer + + v.Tick(50) + if v.InterruptAsserted() { + t.Error("The interrupt should not fire before the timer underflows") + } + v.Tick(2) + if !v.InterruptAsserted() { + t.Error("The interrupt should fire when the timer underflows") + } + + v.Write(13, 0x20) + v.Tick(1000) + if v.InterruptAsserted() { + t.Error("T2 should not fire again") + } +} + +func TestMOS6522InterruptEnable(t *testing.T) { + var v MOS6522 + v.Write(4, 10) + v.Write(5, 0) + v.Tick(50) // T1 underflows but the interrupt is not enabled + + if v.InterruptAsserted() { + t.Error("The interrupt should be masked by IER") + } + if v.Read(13)&0x40 == 0 { + t.Error("The T1 flag should be set on IFR even when masked") + } + if v.Read(13)&0x80 != 0 { + t.Error("The IFR bit 7 should be clear when masked") + } + + v.Write(14, 0x80|0x40) // Enable the T1 interrupt afterwards + if !v.InterruptAsserted() { + t.Error("The interrupt should be asserted once enabled") + } + if v.Read(13)&0x80 == 0 { + t.Error("The IFR bit 7 should be set when interrupting") + } + + v.Write(14, 0x40) // Disable the T1 interrupt + if v.InterruptAsserted() { + t.Error("The interrupt should be masked again") + } + if v.Read(14) != 0x80 { + t.Errorf("IER should read as 0x80, got 0x%02x", v.Read(14)) + } +} + +func TestMOS6522Ports(t *testing.T) { + var v MOS6522 + v.Write(3, 0x0f) // DDRA: half output, half input + v.Write(1, 0xa5) // ORA + v.SetInputA(0x5a) + + if port := v.GetPortA(); port != 0x55 { + t.Errorf("Port A should mix output and input bits, got 0x%02x", port) + } + if value := v.Read(1); value != 0x55 { + t.Errorf("Reading port A should mix output and input bits, got 0x%02x", value) + } + + v.Write(2, 0xff) // DDRB: all output + v.Write(0, 0x07) + if port := v.GetPortB(); port != 0x07 { + t.Errorf("Port B should return the output register, got 0x%02x", port) + } +} + +func TestMOS6522Reset(t *testing.T) { + var v MOS6522 + v.Write(14, 0x80|0x40) + v.Write(4, 10) + v.Write(5, 0) + v.Tick(50) + + v.Reset() + if v.InterruptAsserted() { + t.Error("Reset should clear the interrupt state") + } + if v.Read(2) != 0 || v.Read(3) != 0 { + t.Error("Reset should clear the data direction registers") + } +} diff --git a/configs/2enh.cfg b/configs/2enh.cfg index 4351f9fb..14ed7861 100644 --- a/configs/2enh.cfg +++ b/configs/2enh.cfg @@ -9,4 +9,5 @@ nsc: main s0: language s2: vidhd s3: fastchip +s4: mockingboard s6: diskii,disk1=/dos33.dsk \ No newline at end of file diff --git a/doc/command_line.md b/doc/command_line.md index 263b4f41..aa45a4e7 100644 --- a/doc/command_line.md +++ b/doc/command_line.md @@ -240,7 +240,7 @@ Usage: izapple2 [file] -s3 string slot 3 configuration. (default "fastchip") -s4 string - slot 4 configuration. (default "empty") + slot 4 configuration. (default "mockingboard") -s5 string slot 5 configuration. (default "empty") -s6 string @@ -282,6 +282,7 @@ The available cards are: fujinet: SmartPort interface card hosting the Fujinet language: Language card with 16 extra KB for the Apple ][ and ][+ memexp: Memory expansion card + mockingboard: Mockingboard sound card with two AY-3-8913 sound generators mouse: Mouse card implementation, does not emulate a real card, only the firmware behaviour multirom: Multiple Image ROM card parallel: Card to dump to a file what would be printed to a parallel printer diff --git a/doc/usage.txt b/doc/usage.txt index e00281c0..58bd68cb 100644 --- a/doc/usage.txt +++ b/doc/usage.txt @@ -32,7 +32,7 @@ Usage: izapple2 [file] -s3 string slot 3 configuration. (default "fastchip") -s4 string - slot 4 configuration. (default "empty") + slot 4 configuration. (default "mockingboard") -s5 string slot 5 configuration. (default "empty") -s6 string @@ -74,6 +74,7 @@ The available cards are: fujinet: SmartPort interface card hosting the Fujinet language: Language card with 16 extra KB for the Apple ][ and ][+ memexp: Memory expansion card + mockingboard: Mockingboard sound card with two AY-3-8913 sound generators mouse: Mouse card implementation, does not emulate a real card, only the firmware behaviour multirom: Multiple Image ROM card parallel: Card to dump to a file what would be printed to a parallel printer diff --git a/frontend/a2ebiten/ebitenSpeaker.go b/frontend/a2ebiten/ebitenAudio.go similarity index 67% rename from frontend/a2ebiten/ebitenSpeaker.go rename to frontend/a2ebiten/ebitenAudio.go index 41721644..c8a5400b 100644 --- a/frontend/a2ebiten/ebitenSpeaker.go +++ b/frontend/a2ebiten/ebitenAudio.go @@ -9,27 +9,24 @@ import ( a2audio "github.com/ivanizag/izapple2/audio" ) -// ebitenSpeaker sends the audio from the shared synthesizer to the ebiten -// audio player. It implements izapple2.SpeakerProvider. -type ebitenSpeaker struct { - speaker *a2audio.Speaker +// ebitenAudio sends the mixed audio of the machine to the ebiten audio +// player. The audio sources of the machine attach to the mixer. +type ebitenAudio struct { + mixer *a2audio.Mixer audioContext *audio.Context audioPlayer *audio.Player samples []float32 } -func newEbitenSpeaker(clockMhz float64) *ebitenSpeaker { - return &ebitenSpeaker{speaker: a2audio.NewSpeaker(clockMhz)} -} - -// Click receives a speaker click. The argument is the CPU cycle when it is generated -func (s *ebitenSpeaker) Click(cycle uint64) { - s.speaker.Click(cycle) +func newEbitenAudio(clockMhz float64) *ebitenAudio { + return &ebitenAudio{ + mixer: a2audio.NewMixer(clockMhz), + } } // Read is io.Reader's Read, it fills the buffer with audio samples -func (s *ebitenSpeaker) Read(buf []byte) (n int, err error) { +func (s *ebitenAudio) Read(buf []byte) (n int, err error) { const bytesPerSample = 8 // Two float32, one for each channel samples := len(buf) / bytesPerSample @@ -37,7 +34,7 @@ func (s *ebitenSpeaker) Read(buf []byte) (n int, err error) { s.samples = make([]float32, samples) } s.samples = s.samples[:samples] - s.speaker.ReadSamples(s.samples) + s.mixer.ReadSamples(s.samples) for i, v := range s.samples { putFloat32InBuffer(buf, i, v) @@ -57,7 +54,7 @@ func putFloat32InBuffer(buf []byte, i int, f float32) { buf[i*8+7] = byte(v >> 24) } -func (s *ebitenSpeaker) update() error { +func (s *ebitenAudio) update() error { if s.audioContext == nil { s.audioContext = audio.NewContext(a2audio.SampleRate) } diff --git a/frontend/a2ebiten/main.go b/frontend/a2ebiten/main.go index 560e9a55..e317c9f7 100644 --- a/frontend/a2ebiten/main.go +++ b/frontend/a2ebiten/main.go @@ -19,7 +19,7 @@ type Game struct { a *izapple2.Apple2 image *ebiten.Image keyboard *ebitenKeyboard - speaker *ebitenSpeaker + speaker *ebitenAudio fontSource *text.GoTextFaceSource paused bool @@ -132,9 +132,11 @@ func ebitenRun(a *izapple2.Apple2) { game := &Game{ a: a, keyboard: newEbitenKeyBoard(a), - speaker: newEbitenSpeaker(a.GetClockMhz()), + speaker: newEbitenAudio(a.GetClockMhz()), + } + for _, source := range a.GetAudioSources() { + source.SetAudioSink(game.speaker.mixer.NewSource()) } - a.SetSpeakerProvider(game.speaker) var err error game.fontSource, err = text.NewGoTextFaceSource(bytes.NewReader(fonts.MPlus1pRegular_ttf)) diff --git a/frontend/a2sdl/main.go b/frontend/a2sdl/main.go index 9e908edd..d2e1e6bd 100644 --- a/frontend/a2sdl/main.go +++ b/frontend/a2sdl/main.go @@ -49,9 +49,11 @@ func sdlRun(a *izapple2.Apple2) { kp := newSDLKeyBoard(a) - s := newSDLSpeaker(a.GetClockMhz()) + s := newSDLAudio(a.GetClockMhz()) + for _, source := range a.GetAudioSources() { + source.SetAudioSink(s.mixer.NewSource()) + } s.start() - a.SetSpeakerProvider(s) j := newSDLJoysticks(!a.UsesMouse()) a.SetJoysticksProvider(j) diff --git a/frontend/a2sdl/sdlSpeaker.go b/frontend/a2sdl/sdlAudio.go similarity index 65% rename from frontend/a2sdl/sdlSpeaker.go rename to frontend/a2sdl/sdlAudio.go index d48153ec..f1624f54 100644 --- a/frontend/a2sdl/sdlSpeaker.go +++ b/frontend/a2sdl/sdlAudio.go @@ -17,26 +17,23 @@ import ( // Samples per SDL audio buffer, ~21 ms const bufferSamples = 1024 -// sdlSpeaker sends the audio from the shared synthesizer to the SDL audio -// device. It implements izapple2.SpeakerProvider. -type sdlSpeaker struct { - speaker *audio.Speaker +// sdlAudio sends the mixed audio of the machine to the SDL audio device. +// The audio sources of the machine attach to the mixer. +type sdlAudio struct { + mixer *audio.Mixer } /* -I have not found a way to encode the pointer to sdlSpeaker on the userdata of +I have not found a way to encode the pointer to sdlAudio on the userdata of the call to SpeakerCallback(). I use a global as workaround... It is atomic because the callback runs on the SDL audio thread. */ -var theSDLSpeaker atomic.Pointer[sdlSpeaker] +var theSDLAudio atomic.Pointer[sdlAudio] -func newSDLSpeaker(clockMhz float64) *sdlSpeaker { - return &sdlSpeaker{speaker: audio.NewSpeaker(clockMhz)} -} - -// Click receives a speaker click. The argument is the CPU cycle when it is generated -func (s *sdlSpeaker) Click(cycle uint64) { - s.speaker.Click(cycle) +func newSDLAudio(clockMhz float64) *sdlAudio { + return &sdlAudio{ + mixer: audio.NewMixer(clockMhz), + } } // SpeakerCallback is called to get more sound buffer data @@ -46,7 +43,7 @@ func SpeakerCallback(userdata unsafe.Pointer, stream *C.Uint8, length C.int) { // Adapt the C buffer to a slice of float32 samples buf := unsafe.Slice((*float32)(unsafe.Pointer(stream)), int(length)/4) - s := theSDLSpeaker.Load() + s := theSDLAudio.Load() if s == nil { // SDL does not guarantee the buffer to be initialized for i := range buf { @@ -55,10 +52,10 @@ func SpeakerCallback(userdata unsafe.Pointer, stream *C.Uint8, length C.int) { return } - s.speaker.ReadSamples(buf) + s.mixer.ReadSamples(buf) } -func (s *sdlSpeaker) start() { +func (s *sdlAudio) start() { err := sdl.Init(sdl.INIT_AUDIO) if err != nil { fmt.Printf("Error starting SDL audio: %v.\n", err) @@ -77,11 +74,11 @@ func (s *sdlSpeaker) start() { fmt.Printf("Error opening the SDL audio channel: %v.\n", err) return } - theSDLSpeaker.Store(s) + theSDLAudio.Store(s) sdl.PauseAudio(false) } -func (s *sdlSpeaker) close() { +func (s *sdlAudio) close() { sdl.CloseAudio() sdl.Quit() } diff --git a/frontend/a2wasm/go/main.go b/frontend/a2wasm/go/main.go index 568d58ec..227fe7a8 100644 --- a/frontend/a2wasm/go/main.go +++ b/frontend/a2wasm/go/main.go @@ -16,7 +16,7 @@ type Game struct { a *izapple2.Apple2 image *ebiten.Image keyboard *wasmKeyboard - speaker *wasmSpeaker + speaker *wasmAudio updates uint64 screenMode int @@ -107,13 +107,15 @@ func ebitenRun(a *izapple2.Apple2) { game := &Game{ a: a, - speaker: newWasmSpeaker(a.GetClockMhz()), + speaker: newWasmAudio(a.GetClockMhz()), keyboard: newWasmKeyBoard(a), screenMode: a_screen.ScreenModeNTSC, } // Set up providers - a.SetSpeakerProvider(game.speaker) + for _, source := range a.GetAudioSources() { + source.SetAudioSink(game.speaker.mixer.NewSource()) + } // Setup API exports for React setupAPI(a, game) diff --git a/frontend/a2wasm/go/wasmSpeaker.go b/frontend/a2wasm/go/wasmAudio.go similarity index 69% rename from frontend/a2wasm/go/wasmSpeaker.go rename to frontend/a2wasm/go/wasmAudio.go index 95923d3b..811793b1 100644 --- a/frontend/a2wasm/go/wasmSpeaker.go +++ b/frontend/a2wasm/go/wasmAudio.go @@ -11,27 +11,24 @@ import ( a2audio "github.com/ivanizag/izapple2/audio" ) -// wasmSpeaker sends the audio from the shared synthesizer to the ebiten -// audio player. It implements izapple2.SpeakerProvider. -type wasmSpeaker struct { - speaker *a2audio.Speaker +// wasmAudio sends the mixed audio of the machine to the ebiten audio +// player. The audio sources of the machine attach to the mixer. +type wasmAudio struct { + mixer *a2audio.Mixer audioContext *audio.Context audioPlayer *audio.Player samples []float32 } -func newWasmSpeaker(clockMhz float64) *wasmSpeaker { - return &wasmSpeaker{speaker: a2audio.NewSpeaker(clockMhz)} -} - -// Click receives a speaker click. The argument is the CPU cycle when it is generated -func (s *wasmSpeaker) Click(cycle uint64) { - s.speaker.Click(cycle) +func newWasmAudio(clockMhz float64) *wasmAudio { + return &wasmAudio{ + mixer: a2audio.NewMixer(clockMhz), + } } // Read is io.Reader's Read, it fills the buffer with audio samples -func (s *wasmSpeaker) Read(buf []byte) (n int, err error) { +func (s *wasmAudio) Read(buf []byte) (n int, err error) { const bytesPerSample = 8 // Two float32, one for each channel samples := len(buf) / bytesPerSample @@ -39,7 +36,7 @@ func (s *wasmSpeaker) Read(buf []byte) (n int, err error) { s.samples = make([]float32, samples) } s.samples = s.samples[:samples] - s.speaker.ReadSamples(s.samples) + s.mixer.ReadSamples(s.samples) for i, v := range s.samples { putFloat32InBuffer(buf, i, v) @@ -59,7 +56,7 @@ func putFloat32InBuffer(buf []byte, i int, f float32) { buf[i*8+7] = byte(v >> 24) } -func (s *wasmSpeaker) update() error { +func (s *wasmAudio) update() error { if s.audioContext == nil { s.audioContext = audio.NewContext(a2audio.SampleRate) } diff --git a/go.mod b/go.mod index 2aefd933..da9e91eb 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( fyne.io/fyne/v2 v2.6.1 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 - github.com/ivanizag/iz6502 v1.4.0 + github.com/ivanizag/iz6502 v1.5.0 github.com/koron-go/z80 v0.10.1 github.com/pkg/profile v1.7.0 github.com/veandco/go-sdl2 v0.4.40 diff --git a/go.sum b/go.sum index b20693f1..c15c4220 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,8 @@ github.com/hajimehoshi/ebiten/v2 v2.8.8 h1:xyMxOAn52T1tQ+j3vdieZ7auDBOXmvjUprSrx github.com/hajimehoshi/ebiten/v2 v2.8.8/go.mod h1:durJ05+OYnio9b8q0sEtOgaNeBEQG7Yr7lRviAciYbs= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= -github.com/ivanizag/iz6502 v1.4.0 h1:7eYygUkCPwFRH0tf2JSg1k+Sy27wwPi3ActuVNVv1Uc= -github.com/ivanizag/iz6502 v1.4.0/go.mod h1:h4gbw3IK6WCYawi00kBhQ4ACeQkGWgqbUeAgDaQpy6s= +github.com/ivanizag/iz6502 v1.5.0 h1:hWNfjuZHbSjBZr1lzlFNgF6O1gCI/1NIHaQwa+W2+cQ= +github.com/ivanizag/iz6502 v1.5.0/go.mod h1:h4gbw3IK6WCYawi00kBhQ4ACeQkGWgqbUeAgDaQpy6s= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= diff --git a/ioC0Page.go b/ioC0Page.go index 0f9b537e..83c9ae13 100644 --- a/ioC0Page.go +++ b/ioC0Page.go @@ -11,7 +11,7 @@ type ioC0Page struct { softSwitchesWName [256]string softSwitchesData [128]uint8 keyboard KeyboardProvider - speaker SpeakerProvider + speaker speakerAudioSource paddlesStrobeCycle uint64 joysticks JoysticksProvider mouse MouseProvider @@ -24,12 +24,6 @@ type ioC0Page struct { type softSwitchR func() uint8 type softSwitchW func(value uint8) -// SpeakerProvider provides a speaker implementation -type SpeakerProvider interface { - // Click receives a speaker click. The argument is the CPU cycle when it is generated - Click(cycle uint64) -} - // JoysticksProvider abstracts the joysticks type JoysticksProvider interface { ReadButton(i int) bool @@ -114,10 +108,6 @@ func (p *ioC0Page) setKeyboardProvider(kb KeyboardProvider) { p.keyboard = kb } -func (p *ioC0Page) setSpeakerProvider(s SpeakerProvider) { - p.speaker = s -} - func (p *ioC0Page) setJoysticksProvider(j JoysticksProvider) { p.joysticks = j }