Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
Binary file added public/sounds/tape/motor_off.wav
Binary file not shown.
Binary file added public/sounds/tape/motor_on.wav
Binary file not shown.
5 changes: 3 additions & 2 deletions src/6502.js
Original file line number Diff line number Diff line change
Expand Up @@ -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_, tapeNoise_, music5000_, cmos, config, econet_) {
super(model);
this.config = fixUpConfig(config);
this.debugFlags = this.config.debugFlags;
Expand All @@ -582,6 +582,7 @@ export class Cpu6502 extends Base6502 {
this.soundChip = soundChip_;
this.music5000 = music5000_;
this.ddNoise = ddNoise_;
this.tapeNoise = tapeNoise_;
this.memStatOffsetByIFetchBank = 0;
this.memStatOffset = 0;
this.memStat = new Uint8Array(512);
Expand Down Expand Up @@ -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.tapeNoise);
this.serial = new Serial(this.acia);
this.adconverter = new Adc(this.sysvia, this.scheduler);
this.soundChip.setScheduler(this.scheduler);
Expand Down
9 changes: 8 additions & 1 deletion src/acia.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, tapeNoise) {
this.cpu = cpu;
this.toneGen = toneGen;
this.rs423Handler = rs423Handler;
this.tapeNoise = tapeNoise;

this.sr = 0x00;
this.cr = 0x00;
Expand Down Expand Up @@ -58,10 +59,16 @@ export class Acia {
setMotor(on) {
if (on && !this.motorOn) {
this.runTape();
if (this.tapeNoise) {
this.tapeNoise.motorOn();
}
} else if (!on && this.motorOn) {
this.toneGen.mute();
this.runTapeTask.cancel();
this.setTapeCarrier(false);
if (this.tapeNoise) {
this.tapeNoise.motorOff();
}
}
this.motorOn = on;
}
Expand Down
12 changes: 11 additions & 1 deletion src/fake6502.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 { FakeTapeNoise } from "./tapenoise.js";
import { Cpu6502 } from "./6502.js";
import { Cmos } from "./cmos.js";
import { FakeMusic5000 } from "./music5000.js";
Expand All @@ -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 FakeTapeNoise(),
new FakeMusic5000(),
new Cmos(),
);
}

export function fake65C02() {
Expand Down
1 change: 1 addition & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ processor = new Cpu6502(
video,
audioHandler.soundChip,
audioHandler.ddNoise,
audioHandler.tapeNoise,
model.hasMusic5000 ? audioHandler.music5000 : null,
cmos,
emulationConfig,
Expand Down
88 changes: 88 additions & 0 deletions src/tapenoise.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use strict";
import * as utils from "./utils.js";
import _ from "underscore";

const VOLUME = 0.25;

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 TapeNoise {
constructor(context) {
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 = [];
}

async initialise() {
const sounds = await loadSounds(this.context, {
motorOn: "sounds/tape/motor_on.wav",
motorOff: "sounds/tape/motor_off.wav",
});
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;
}

motorOn() {
if (this.sounds.motorOn) {
this.oneShot(this.sounds.motorOn);
}
}

motorOff() {
if (this.sounds.motorOff) {
this.oneShot(this.sounds.motorOff);
}
}

mute() {
this.gain.gain.value = 0;
}

unmute() {
this.gain.gain.value = VOLUME;
}
}

export class FakeTapeNoise {
constructor() {}
initialise() {
return Promise.resolve();
}
motorOn() {}
motorOff() {}
mute() {}
unmute() {}
}
6 changes: 6 additions & 0 deletions src/web/audio-handler.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SmoothieChart, TimeSeries } from "smoothie";
import { FakeSoundChip, SoundChip } from "../soundchip.js";
import { DdNoise, FakeDdNoise } from "../ddnoise.js";
import { TapeNoise, FakeTapeNoise } from "../tapenoise.js";
import { Music5000, FakeMusic5000 } from "../music5000.js";

// Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit.
Expand Down Expand Up @@ -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.tapeNoise = new TapeNoise(this.audioContext);
this._setup(audioFilterFreq, audioFilterQ).then();
} else {
if (this.audioContext && !this.audioContext.audioWorklet) {
Expand All @@ -52,6 +54,7 @@ export class AudioHandler {
}
this.soundChip = new FakeSoundChip();
this.ddNoise = new FakeDdNoise();
this.tapeNoise = new FakeTapeNoise();
}

this.warningNode.on("mousedown", () => this.tryResume());
Expand Down Expand Up @@ -132,15 +135,18 @@ export class AudioHandler {

async initialise() {
await this.ddNoise.initialise();
await this.tapeNoise.initialise();
}

mute() {
this.soundChip.mute();
this.ddNoise.mute();
this.tapeNoise.mute();
}

unmute() {
this.soundChip.unmute();
this.ddNoise.unmute();
this.tapeNoise.unmute();
}
}
66 changes: 66 additions & 0 deletions tests/unit/test-acia-tape-integration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Acia } from "../../src/acia.js";

describe("ACIA tape noise integration", () => {
let mockCpu, mockToneGen, mockScheduler, mockRs423Handler, mockTapeNoise;
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 = {};
mockTapeNoise = {
motorOn: vi.fn(),
motorOff: vi.fn(),
};

acia = new Acia(mockCpu, mockToneGen, mockScheduler, mockRs423Handler, mockTapeNoise);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("setMotor with tape noise", () => {
it("should call tape noise motorOn when motor turns on", () => {
acia.motorOn = false;

acia.setMotor(true);

expect(mockTapeNoise.motorOn).toHaveBeenCalledOnce();
expect(acia.motorOn).toBe(true);
});

it("should call tape noise motorOff when motor turns off", () => {
acia.motorOn = true;

acia.setMotor(false);

expect(mockTapeNoise.motorOff).toHaveBeenCalledOnce();
expect(acia.motorOn).toBe(false);
});

it("should not call tape noise methods when motor state doesn't change", () => {
acia.motorOn = true;

acia.setMotor(true);

expect(mockTapeNoise.motorOn).not.toHaveBeenCalled();
expect(mockTapeNoise.motorOff).not.toHaveBeenCalled();
});

it("should handle missing tape noise gracefully", () => {
const aciaWithoutTapeNoise = new Acia(mockCpu, mockToneGen, mockScheduler, mockRs423Handler, null);
aciaWithoutTapeNoise.motorOn = false;

expect(() => aciaWithoutTapeNoise.setMotor(true)).not.toThrow();
expect(aciaWithoutTapeNoise.motorOn).toBe(true);
});
});
});
98 changes: 98 additions & 0 deletions tests/unit/test-tapenoise.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TapeNoise, FakeTapeNoise } from "../../src/tapenoise.js";

describe("TapeNoise", () => {
let mockContext;
let tapeNoise;

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)),
}),
);

tapeNoise = new TapeNoise(mockContext);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("TapeNoise class", () => {
it("should create gain node and connect to destination", () => {
expect(mockContext.createGain).toHaveBeenCalled();
});

it("should initialize with sound files", async () => {
await tapeNoise.initialise();
expect(tapeNoise.sounds).toBeDefined();
});

it("should play motor on sound when motorOn is called", () => {
const mockSound = { duration: 0.05 };
tapeNoise.sounds = { motorOn: mockSound };

tapeNoise.motorOn();

expect(mockContext.createBufferSource).toHaveBeenCalled();
});

it("should play motor off sound when motorOff is called", () => {
const mockSound = { duration: 0.05 };
tapeNoise.sounds = { motorOff: mockSound };

tapeNoise.motorOff();

expect(mockContext.createBufferSource).toHaveBeenCalled();
});

it("should handle mute/unmute", () => {
const mockGain = { gain: { value: 0.25 } };
tapeNoise.gain = mockGain;

tapeNoise.mute();
expect(mockGain.gain.value).toBe(0);

tapeNoise.unmute();
expect(mockGain.gain.value).toBe(0.25);
});
});

describe("FakeTapeNoise class", () => {
it("should create fake implementation", () => {
const fakeTapeNoise = new FakeTapeNoise();

expect(() => fakeTapeNoise.initialise()).not.toThrow();
expect(() => fakeTapeNoise.motorOn()).not.toThrow();
expect(() => fakeTapeNoise.motorOff()).not.toThrow();
expect(() => fakeTapeNoise.mute()).not.toThrow();
expect(() => fakeTapeNoise.unmute()).not.toThrow();
});

it("should return resolved promise for initialise", async () => {
const fakeTapeNoise = new FakeTapeNoise();
const result = await fakeTapeNoise.initialise();
expect(result).toBeUndefined();
});
});
});