diff --git a/public/sounds/tape/motor_off.wav b/public/sounds/tape/motor_off.wav new file mode 100644 index 00000000..9ce0ea27 Binary files /dev/null and b/public/sounds/tape/motor_off.wav differ diff --git a/public/sounds/tape/motor_on.wav b/public/sounds/tape/motor_on.wav new file mode 100644 index 00000000..631fb332 Binary files /dev/null and b/public/sounds/tape/motor_on.wav differ diff --git a/src/6502.js b/src/6502.js index ea0e8956..b8c5b857 100644 --- a/src/6502.js +++ b/src/6502.js @@ -569,7 +569,7 @@ function is1MHzAccess(addr) { } export class Cpu6502 extends Base6502 { - constructor(model, dbgr, video_, soundChip_, ddNoise_, music5000_, cmos, config, econet_) { + constructor(model, dbgr, video_, soundChip_, ddNoise_, relayNoise_, music5000_, cmos, config, econet_) { super(model); this.config = fixUpConfig(config); this.debugFlags = this.config.debugFlags; @@ -582,6 +582,7 @@ export class Cpu6502 extends Base6502 { this.soundChip = soundChip_; this.music5000 = music5000_; this.ddNoise = ddNoise_; + this.relayNoise = relayNoise_; this.memStatOffsetByIFetchBank = 0; this.memStatOffset = 0; this.memStat = new Uint8Array(512); @@ -630,7 +631,7 @@ export class Cpu6502 extends Base6502 { this.config.getGamepads, ); this.uservia = new via.UserVia(this, this.scheduler, this.model.isMaster, this.config.userPort); - this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen); + this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen, this.relayNoise); this.serial = new Serial(this.acia); this.adconverter = new Adc(this.sysvia, this.scheduler); this.soundChip.setScheduler(this.scheduler); diff --git a/src/acia.js b/src/acia.js index 5e25edbf..63a6509d 100644 --- a/src/acia.js +++ b/src/acia.js @@ -5,10 +5,11 @@ // http://www.classiccmp.org/dunfield/r/6850.pdf export class Acia { - constructor(cpu, toneGen, scheduler, rs423Handler) { + constructor(cpu, toneGen, scheduler, rs423Handler, relayNoise) { this.cpu = cpu; this.toneGen = toneGen; this.rs423Handler = rs423Handler; + this.relayNoise = relayNoise; this.sr = 0x00; this.cr = 0x00; @@ -58,10 +59,16 @@ export class Acia { setMotor(on) { if (on && !this.motorOn) { this.runTape(); + if (this.relayNoise) { + this.relayNoise.motorOn(); + } } else if (!on && this.motorOn) { this.toneGen.mute(); this.runTapeTask.cancel(); this.setTapeCarrier(false); + if (this.relayNoise) { + this.relayNoise.motorOff(); + } } this.motorOn = on; } diff --git a/src/audio-utils.js b/src/audio-utils.js new file mode 100644 index 00000000..057657f6 --- /dev/null +++ b/src/audio-utils.js @@ -0,0 +1,56 @@ +"use strict"; +import * as utils from "./utils.js"; +import _ from "underscore"; + +export async function loadSounds(context, sounds) { + const loaded = await Promise.all( + _.map(sounds, async (sound) => { + // Safari doesn't support the Promise stuff directly, so we create + // our own Promise here. + const data = await utils.loadData(sound); + return await new Promise((resolve) => { + context.decodeAudioData(data.buffer, (decodedData) => { + resolve(decodedData); + }); + }); + }), + ); + const keys = _.keys(sounds); + const result = {}; + for (let i = 0; i < keys.length; ++i) { + result[keys[i]] = loaded[i]; + } + return result; +} + +export class BaseAudioNoise { + constructor(context, volume = 0.25) { + this.context = context; + this.sounds = {}; + this.gain = context.createGain(); + this.gain.gain.value = volume; + this.gain.connect(context.destination); + // workaround for older safaris that GC sounds when they're playing... + this.playing = []; + this.volume = volume; + } + + oneShot(sound) { + const duration = sound.duration; + const context = this.context; + if (context.state !== "running") return duration; + const source = context.createBufferSource(); + source.buffer = sound; + source.connect(this.gain); + source.start(); + return duration; + } + + mute() { + this.gain.gain.value = 0; + } + + unmute() { + this.gain.gain.value = this.volume; + } +} diff --git a/src/ddnoise.js b/src/ddnoise.js index e3aaa880..26da40ff 100644 --- a/src/ddnoise.js +++ b/src/ddnoise.js @@ -1,23 +1,16 @@ "use strict"; -import * as utils from "./utils.js"; +import { loadSounds, BaseAudioNoise } from "./audio-utils.js"; import _ from "underscore"; const IDLE = 0; const SPIN_UP = 1; const SPINNING = 2; -const VOLUME = 0.25; -export class DdNoise { +export class DdNoise extends BaseAudioNoise { constructor(context) { - this.context = context; - this.sounds = {}; + super(context, 0.25); this.state = IDLE; this.motor = null; - this.gain = context.createGain(); - this.gain.gain.value = VOLUME; - this.gain.connect(context.destination); - // workaround for older safaris that GC sounds when they're playing... - this.playing = []; } async initialise() { const sounds = await loadSounds(this.context, { @@ -31,16 +24,6 @@ export class DdNoise { }); this.sounds = sounds; } - oneShot(sound) { - const duration = sound.duration; - const context = this.context; - if (context.state !== "running") return duration; - const source = context.createBufferSource(); - source.buffer = sound; - source.connect(this.gain); - source.start(); - return duration; - } play(sound, loop) { if (this.context.state !== "running") return Promise.reject(); return new Promise((resolve) => { @@ -94,33 +77,6 @@ export class DdNoise { else if (diff <= 40) return this.oneShot(this.sounds.seek2); else return this.oneShot(this.sounds.seek3); } - mute() { - this.gain.gain.value = 0; - } - unmute() { - this.gain.gain.value = VOLUME; - } -} - -async function loadSounds(context, sounds) { - const loaded = await Promise.all( - _.map(sounds, async (sound) => { - // Safari doesn't support the Promise stuff directly, so we create - // our own Promise here. - const data = await utils.loadData(sound); - return await new Promise((resolve) => { - context.decodeAudioData(data.buffer, (decodedData) => { - resolve(decodedData); - }); - }); - }), - ); - const keys = _.keys(sounds); - const result = {}; - for (let i = 0; i < keys.length; ++i) { - result[keys[i]] = loaded[i]; - } - return result; } export class FakeDdNoise { diff --git a/src/fake6502.js b/src/fake6502.js index b980d5b4..cb1b7ca1 100644 --- a/src/fake6502.js +++ b/src/fake6502.js @@ -5,6 +5,7 @@ import { FakeVideo } from "./video.js"; import { FakeSoundChip } from "./soundchip.js"; import { findModel, TEST_6502, TEST_65C02, TEST_65C12 } from "./models.js"; import { FakeDdNoise } from "./ddnoise.js"; +import { FakeRelayNoise } from "./relaynoise.js"; import { Cpu6502 } from "./6502.js"; import { Cmos } from "./cmos.js"; import { FakeMusic5000 } from "./music5000.js"; @@ -20,7 +21,16 @@ export function fake6502(model, opts) { const video = opts.video || fakeVideo; model = model || TEST_6502; if (opts.tube) model.tube = findModel("Tube65c02"); - return new Cpu6502(model, dbgr, video, soundChip, new FakeDdNoise(), new FakeMusic5000(), new Cmos()); + return new Cpu6502( + model, + dbgr, + video, + soundChip, + new FakeDdNoise(), + new FakeRelayNoise(), + new FakeMusic5000(), + new Cmos(), + ); } export function fake65C02() { diff --git a/src/main.js b/src/main.js index 4ef8939d..a122b172 100644 --- a/src/main.js +++ b/src/main.js @@ -522,6 +522,7 @@ processor = new Cpu6502( video, audioHandler.soundChip, audioHandler.ddNoise, + audioHandler.relayNoise, model.hasMusic5000 ? audioHandler.music5000 : null, cmos, emulationConfig, diff --git a/src/relaynoise.js b/src/relaynoise.js new file mode 100644 index 00000000..9d79f127 --- /dev/null +++ b/src/relaynoise.js @@ -0,0 +1,39 @@ +"use strict"; +import { loadSounds, BaseAudioNoise } from "./audio-utils.js"; + +export class RelayNoise extends BaseAudioNoise { + constructor(context) { + super(context, 0.25); + } + + async initialise() { + const sounds = await loadSounds(this.context, { + motorOn: "sounds/tape/motor_on.wav", + motorOff: "sounds/tape/motor_off.wav", + }); + this.sounds = sounds; + } + + motorOn() { + if (this.sounds.motorOn) { + this.oneShot(this.sounds.motorOn); + } + } + + motorOff() { + if (this.sounds.motorOff) { + this.oneShot(this.sounds.motorOff); + } + } +} + +export class FakeRelayNoise { + constructor() {} + initialise() { + return Promise.resolve(); + } + motorOn() {} + motorOff() {} + mute() {} + unmute() {} +} diff --git a/src/web/audio-handler.js b/src/web/audio-handler.js index d84f66cb..408bee4e 100644 --- a/src/web/audio-handler.js +++ b/src/web/audio-handler.js @@ -1,6 +1,7 @@ import { SmoothieChart, TimeSeries } from "smoothie"; import { FakeSoundChip, SoundChip } from "../soundchip.js"; import { DdNoise, FakeDdNoise } from "../ddnoise.js"; +import { RelayNoise, FakeRelayNoise } from "../relaynoise.js"; import { Music5000, FakeMusic5000 } from "../music5000.js"; // Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit. @@ -35,6 +36,7 @@ export class AudioHandler { this.audioContext.onstatechange = () => this.checkStatus(); this.soundChip = new SoundChip((buffer, time) => this._onBuffer(buffer, time)); this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext); + this.relayNoise = new RelayNoise(this.audioContext); this._setup(audioFilterFreq, audioFilterQ).then(); } else { if (this.audioContext && !this.audioContext.audioWorklet) { @@ -52,6 +54,7 @@ export class AudioHandler { } this.soundChip = new FakeSoundChip(); this.ddNoise = new FakeDdNoise(); + this.relayNoise = new FakeRelayNoise(); } this.warningNode.on("mousedown", () => this.tryResume()); @@ -132,15 +135,18 @@ export class AudioHandler { async initialise() { await this.ddNoise.initialise(); + await this.relayNoise.initialise(); } mute() { this.soundChip.mute(); this.ddNoise.mute(); + this.relayNoise.mute(); } unmute() { this.soundChip.unmute(); this.ddNoise.unmute(); + this.relayNoise.unmute(); } } diff --git a/tests/unit/test-acia-relay-integration.js b/tests/unit/test-acia-relay-integration.js new file mode 100644 index 00000000..a5bae826 --- /dev/null +++ b/tests/unit/test-acia-relay-integration.js @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Acia } from "../../src/acia.js"; + +describe("ACIA relay noise integration", () => { + let mockCpu, mockToneGen, mockScheduler, mockRs423Handler, mockRelayNoise; + let acia; + + beforeEach(() => { + mockCpu = { interrupt: 0 }; + mockToneGen = { mute: vi.fn(), tone: vi.fn() }; + mockScheduler = { + newTask: vi.fn((_fn) => ({ + cancel: vi.fn(), + ensureScheduled: vi.fn(), + })), + }; + mockRs423Handler = {}; + mockRelayNoise = { + motorOn: vi.fn(), + motorOff: vi.fn(), + }; + + acia = new Acia(mockCpu, mockToneGen, mockScheduler, mockRs423Handler, mockRelayNoise); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("setMotor with relay noise", () => { + it("should call relay noise motorOn when motor turns on", () => { + acia.motorOn = false; + + acia.setMotor(true); + + expect(mockRelayNoise.motorOn).toHaveBeenCalledOnce(); + expect(acia.motorOn).toBe(true); + }); + + it("should call relay noise motorOff when motor turns off", () => { + acia.motorOn = true; + + acia.setMotor(false); + + expect(mockRelayNoise.motorOff).toHaveBeenCalledOnce(); + expect(acia.motorOn).toBe(false); + }); + + it("should not call relay noise methods when motor state doesn't change", () => { + acia.motorOn = true; + + acia.setMotor(true); + + expect(mockRelayNoise.motorOn).not.toHaveBeenCalled(); + expect(mockRelayNoise.motorOff).not.toHaveBeenCalled(); + }); + + it("should handle missing relay noise gracefully", () => { + const aciaWithoutRelayNoise = new Acia(mockCpu, mockToneGen, mockScheduler, mockRs423Handler, null); + aciaWithoutRelayNoise.motorOn = false; + + expect(() => aciaWithoutRelayNoise.setMotor(true)).not.toThrow(); + expect(aciaWithoutRelayNoise.motorOn).toBe(true); + }); + }); +}); diff --git a/tests/unit/test-relaynoise.js b/tests/unit/test-relaynoise.js new file mode 100644 index 00000000..1ba5dcad --- /dev/null +++ b/tests/unit/test-relaynoise.js @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { RelayNoise, FakeRelayNoise } from "../../src/relaynoise.js"; + +describe("RelayNoise", () => { + let mockContext; + let relayNoise; + + beforeEach(() => { + mockContext = { + state: "running", + createGain: vi.fn(() => ({ + gain: { value: 0 }, + connect: vi.fn(), + })), + createBufferSource: vi.fn(() => ({ + buffer: null, + connect: vi.fn(), + start: vi.fn(), + })), + destination: {}, + decodeAudioData: vi.fn((buffer, callback) => { + // Mock decoded audio data + const mockDecodedData = { duration: 0.05 }; + callback(mockDecodedData); + }), + }; + + global.fetch = vi.fn(() => + Promise.resolve({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)), + }), + ); + + relayNoise = new RelayNoise(mockContext); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("RelayNoise class", () => { + it("should create gain node and connect to destination", () => { + expect(mockContext.createGain).toHaveBeenCalled(); + }); + + it("should initialize with sound files", async () => { + await relayNoise.initialise(); + expect(relayNoise.sounds).toBeDefined(); + }); + + it("should play motor on sound when motorOn is called", () => { + const mockSound = { duration: 0.05 }; + relayNoise.sounds = { motorOn: mockSound }; + + relayNoise.motorOn(); + + expect(mockContext.createBufferSource).toHaveBeenCalled(); + }); + + it("should play motor off sound when motorOff is called", () => { + const mockSound = { duration: 0.05 }; + relayNoise.sounds = { motorOff: mockSound }; + + relayNoise.motorOff(); + + expect(mockContext.createBufferSource).toHaveBeenCalled(); + }); + + it("should handle mute/unmute", () => { + const mockGain = { gain: { value: 0.25 } }; + relayNoise.gain = mockGain; + + relayNoise.mute(); + expect(mockGain.gain.value).toBe(0); + + relayNoise.unmute(); + expect(mockGain.gain.value).toBe(0.25); + }); + }); + + describe("FakeRelayNoise class", () => { + it("should create fake implementation", () => { + const fakeRelayNoise = new FakeRelayNoise(); + + expect(() => fakeRelayNoise.initialise()).not.toThrow(); + expect(() => fakeRelayNoise.motorOn()).not.toThrow(); + expect(() => fakeRelayNoise.motorOff()).not.toThrow(); + expect(() => fakeRelayNoise.mute()).not.toThrow(); + expect(() => fakeRelayNoise.unmute()).not.toThrow(); + }); + + it("should return resolved promise for initialise", async () => { + const fakeRelayNoise = new FakeRelayNoise(); + const result = await fakeRelayNoise.initialise(); + expect(result).toBeUndefined(); + }); + }); +});