diff --git a/js/widgets/__tests__/sampler.test.js b/js/widgets/__tests__/sampler.test.js
new file mode 100644
index 0000000000..530b3e1c2a
--- /dev/null
+++ b/js/widgets/__tests__/sampler.test.js
@@ -0,0 +1,1011 @@
+/**
+ * MusicBlocks
+ *
+ * @author Om-A-osc
+ *
+ * @license
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+global._ = s => s;
+global.DOUBLEFLAT = "bb";
+global.FLAT = "b";
+global.NATURAL = "n";
+global.SHARP = "#";
+global.DOUBLESHARP = "x";
+
+global.instruments = [{}];
+global.TunerUtils = {
+ calculatePlaybackRate: jest.fn(),
+ frequencyToPitch: jest.fn()
+};
+
+global.cancelAnimationFrame = jest.fn();
+global.setTimeout = setTimeout;
+global.Tone = {
+ Analyser: jest.fn(() => ({
+ connect: jest.fn()
+ }))
+};
+global.requestAnimationFrame = jest.fn(() => 1);
+global.A0 = 27.5;
+global.pitchToNumber = jest.fn(() => 57);
+global.CUSTOMSAMPLES = [];
+global.DRUMS = ["kick", "snare"];
+global.platformColor = {
+ pitchWheelcolors: ["#111", "#222", "#333", "#444", "#555", "#666", "#777"],
+ accidentalsWheelcolors: ["#111", "#222", "#333", "#444", "#555"],
+ accidentalsWheelcolorspush: "#999",
+ octavesWheelcolors: ["#111", "#222", "#333", "#444", "#555"],
+ exitWheelcolors: ["#000", "#000"],
+ textColor: "#000"
+};
+global.slicePath = () => ({
+ DonutSlice: "DonutSlice",
+ DonutSliceCustomization: () => ({})
+});
+global.wheelnav = function WheelNav() {
+ this.raphael = {};
+ this.navItems = [];
+ this.colors = [];
+ this.createWheel = labels => {
+ this.navItems = labels.map(label => ({
+ title: label,
+ navItem: { hide: jest.fn() }
+ }));
+ };
+ this.setTooltips = jest.fn();
+ this.navigateWheel = jest.fn();
+ this.removeWheel = jest.fn();
+};
+global.getVoiceSynthName = jest.fn(() => "voice");
+global.Singer = { ToneActions: { setTimbre: jest.fn() } };
+global.docById = id => document.getElementById(id);
+global.docByClass = cls => document.getElementsByClassName(cls);
+global.alert = jest.fn();
+global.TunerDisplay = class {
+ constructor(canvas, width, height) {
+ this.canvas = canvas;
+ this.width = width;
+ this.height = height;
+ this.update = jest.fn();
+ }
+};
+
+const { SampleWidget, PitchSmoother } = require("../sampler.js");
+
+describe("Sampler Widget", () => {
+ beforeAll(() => {
+ if (!HTMLCanvasElement.prototype.getContext) {
+ HTMLCanvasElement.prototype.getContext = jest.fn();
+ }
+ HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
+ clearRect: jest.fn(),
+ fillRect: jest.fn(),
+ fillText: jest.fn(),
+ beginPath: jest.fn(),
+ moveTo: jest.fn(),
+ lineTo: jest.fn(),
+ stroke: jest.fn()
+ }));
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ global.instruments = [{}];
+ global.CUSTOMSAMPLES = [];
+ document.body.innerHTML = `
+
+
+
+ `;
+ });
+
+ describe("PitchSmoother", () => {
+ test("smooths pitches and drops outliers", () => {
+ const smoother = new PitchSmoother(5);
+ smoother.addPitch(100);
+ smoother.addPitch(101);
+ smoother.addPitch(99);
+ smoother.addPitch(5000);
+ smoother.addPitch(102);
+
+ const smoothed = smoother.getSmoothedPitch();
+ expect(smoothed).toBeCloseTo(100.5, 1);
+ });
+
+ test("ignores non-positive pitches and resets history", () => {
+ const smoother = new PitchSmoother(3);
+ smoother.addPitch(0);
+ smoother.addPitch(-20);
+
+ expect(smoother.getSmoothedPitch()).toBe(-1);
+
+ smoother.addPitch(200);
+ expect(smoother.getSmoothedPitch()).toBe(200);
+
+ smoother.reset();
+ expect(smoother.getSmoothedPitch()).toBe(-1);
+ });
+ });
+
+ describe("SampleWidget methods", () => {
+ let widget;
+ let mockActivity;
+ let widgetWindow;
+ let realSetTimbre;
+ let realUpdateBlocks;
+ let realPlayDelayedSample;
+ let realEndPlaying;
+
+ beforeEach(() => {
+ widget = new SampleWidget();
+ realSetTimbre = widget.setTimbre;
+ realUpdateBlocks = widget._updateBlocks;
+ realPlayDelayedSample = widget._playDelayedSample;
+ realEndPlaying = widget._endPlaying;
+ const widgetBody = document.createElement("div");
+ const widgetFrame = document.createElement("div");
+ widgetBody.getBoundingClientRect = () => ({ width: 800, height: 500 });
+ widgetFrame.getBoundingClientRect = () => ({ height: 500 });
+ const toolbar = document.createElement("div");
+ document.body.appendChild(document.createElement("canvas"));
+ document.body.appendChild(widgetBody);
+ const buttons = [];
+ widgetWindow = {
+ _toolbar: toolbar,
+ addButton: jest.fn((icon, size, tip) => {
+ const btn = document.createElement("button");
+ btn.getElementsByTagName = jest.fn(() => [{ src: "" }]);
+ btn.classList = { add: jest.fn(), remove: jest.fn() };
+ btn.tip = tip;
+ buttons.push(btn);
+ return btn;
+ }),
+ clearScreen: jest.fn(),
+ getWidgetBody: jest.fn(() => widgetBody),
+ getWidgetFrame: jest.fn(() => widgetFrame),
+ isMaximized: jest.fn(() => false),
+ clear: jest.fn(),
+ show: jest.fn(),
+ sendToCenter: jest.fn(),
+ destroy: jest.fn(),
+ _buttons: buttons
+ };
+ window.widgetWindows = {
+ windowFor: jest.fn(() => widgetWindow)
+ };
+ mockActivity = {
+ logo: {
+ synth: {
+ trigger: jest.fn(),
+ loadSynth: jest.fn(),
+ startRecording: jest.fn().mockResolvedValue(),
+ stopRecording: jest.fn().mockResolvedValue("recording-url"),
+ LiveWaveForm: jest.fn(),
+ playRecording: jest.fn(),
+ stopPlayBackRecording: jest.fn(),
+ startTuner: jest.fn().mockResolvedValue(),
+ stopTuner: jest.fn(),
+ getWaveFormValues: jest.fn(() => [0, 0.5, -0.5]),
+ startRecordingTimer: jest.fn()
+ }
+ },
+ canvas: { width: 1000, height: 800 },
+ getStageScale: () => 1,
+ textMsg: jest.fn()
+ };
+ global.activity = mockActivity;
+ widget.activity = mockActivity;
+ widget._updateBlocks = jest.fn();
+ widget._playDelayedSample = jest.fn();
+ widget.setTimbre = jest.fn();
+ widget.pitchBtn = { value: "" };
+ widget.frequencyDisplay = { textContent: "" };
+ });
+
+ test("toggleTuner toggles state, updates icon, and rescales", () => {
+ const img = { src: "" };
+ widget._tunerBtn = {
+ getElementsByTagName: jest.fn(() => [img])
+ };
+ widget._scale = jest.fn();
+
+ expect(widget.tunerEnabled).toBe(false);
+ widget.toggleTuner();
+ expect(widget.tunerEnabled).toBe(true);
+ expect(img.src).toBe("header-icons/tuner-active.svg");
+ expect(widget._scale).toHaveBeenCalledTimes(1);
+
+ widget.toggleTuner();
+ expect(widget.tunerEnabled).toBe(false);
+ expect(img.src).toBe("header-icons/tuner.svg");
+ expect(widget._scale).toHaveBeenCalledTimes(2);
+ });
+
+ test("applyCentsAdjustment sets playback rate when instrument exists", () => {
+ widget.sampleName = "customsample_test";
+ widget.originalSampleName = "test";
+ widget.centsValue = 25;
+
+ const playbackRate = { value: 1 };
+ global.instruments[0].customsample_test = { playbackRate };
+ global.TunerUtils.calculatePlaybackRate.mockReturnValue(1.5);
+
+ widget.applyCentsAdjustment();
+
+ expect(global.TunerUtils.calculatePlaybackRate).toHaveBeenCalledWith(0, 25);
+ expect(playbackRate.value).toBe(1.5);
+ });
+
+ test("stopPitchDetection releases audio resources", async () => {
+ const trackStop = jest.fn();
+ const close = jest.fn().mockResolvedValue();
+ widget.pitchDetectionAnimationId = 101;
+ widget.pitchDetectionStream = { getTracks: () => [{ stop: trackStop }] };
+ widget.pitchDetectionAudioContext = { close };
+ widget.isPitchDetectionRunning = true;
+
+ widget.stopPitchDetection();
+
+ expect(global.cancelAnimationFrame).toHaveBeenCalledWith(101);
+ expect(trackStop).toHaveBeenCalled();
+ expect(close).toHaveBeenCalled();
+ expect(widget.pitchDetectionAnimationId).toBeNull();
+ expect(widget.pitchDetectionStream).toBeNull();
+ expect(widget.pitchDetectionAudioContext).toBeNull();
+ expect(widget.isPitchDetectionRunning).toBe(false);
+ });
+
+ test("applyCentAdjustment updates playbackRate and restarts when playing", () => {
+ jest.useFakeTimers();
+ const playbackRate = { value: 1 };
+ widget.sampleName = "customsample_test";
+ widget.originalSampleName = "test";
+ global.instruments[0].customsample_test = { playbackRate };
+ widget.isMoving = true;
+ widget.pause = jest.fn();
+ widget._playReferencePitch = jest.fn();
+
+ widget.applyCentAdjustment(120);
+
+ expect(widget.centAdjustmentValue).toBe(120);
+ expect(playbackRate.value).toBeCloseTo(Math.pow(2, 120 / 1200), 6);
+ expect(widget.pause).toHaveBeenCalled();
+
+ jest.runOnlyPendingTimers();
+ expect(widget._playReferencePitch).toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+
+ test("_parseSamplePitch sets pitch, accidental, and octave centers", () => {
+ widget.samplePitch = "re#";
+ widget.sampleOctave = "3";
+ widget._parseSamplePitch();
+ expect(widget.pitchCenter).toBe(1);
+ expect(widget.accidentalCenter).toBe(3);
+ expect(widget.octaveCenter).toBe("3");
+
+ widget.samplePitch = "sobb";
+ widget.sampleOctave = "5";
+ widget._parseSamplePitch();
+ expect(widget.pitchCenter).toBe(4);
+ expect(widget.accidentalCenter).toBe(1);
+ expect(widget.octaveCenter).toBe("5");
+ });
+
+ test("_calculateFrequency uses pitch centers to compute frequency", () => {
+ widget.pitchCenter = 5; // A
+ widget.accidentalCenter = 2; // natural
+ widget.octaveCenter = 4;
+
+ expect(widget._calculateFrequency()).toBe(440);
+ });
+
+ test("_calculateFrequency accounts for accidentals", () => {
+ widget.pitchCenter = 5; // A
+ widget.octaveCenter = 4;
+ widget.accidentalCenter = 3; // sharp
+ expect(widget._calculateFrequency()).toBe(466);
+ widget.accidentalCenter = 1; // flat
+ expect(widget._calculateFrequency()).toBe(415);
+ });
+
+ test("_updateSamplePitchValues builds sample pitch and octave", () => {
+ widget.pitchCenter = 3; // fa
+ widget.accidentalCenter = 4; // sharp
+ widget.octaveCenter = 6;
+
+ widget._updateSamplePitchValues();
+ expect(widget.samplePitch).toBe("fax");
+ expect(widget.sampleOctave).toBe("6");
+ });
+
+ test("getPitchName updates pitch name and frequency display", () => {
+ widget.pitchCenter = 5; // A
+ widget.accidentalCenter = 2;
+ widget.octaveCenter = 4;
+
+ const name = widget.getPitchName();
+ expect(name).toBe("A4");
+ expect(widget.pitchBtn.value).toBe("A4");
+ expect(widget.frequencyDisplay.textContent).toBe("440 Hz");
+ });
+
+ test("_playReferencePitch triggers reference synth and schedules sample", () => {
+ widget.pitchCenter = 5;
+ widget.accidentalCenter = 2;
+ widget.octaveCenter = 4;
+
+ widget._playReferencePitch();
+
+ expect(widget._updateBlocks).toHaveBeenCalled();
+ expect(mockActivity.logo.synth.trigger).toHaveBeenCalledWith(
+ 0,
+ [440],
+ 0.5,
+ "electronic synth",
+ null,
+ null,
+ false
+ );
+ expect(widget.setTimbre).toHaveBeenCalled();
+ expect(widget._playDelayedSample).toHaveBeenCalled();
+ });
+
+ test("_playSample loads synth, computes cent-adjusted frequency, and triggers playback", () => {
+ widget.sampleName = "customsample_test";
+ widget.originalSampleName = "test";
+ widget.sampleLength = 1200;
+ widget.centAdjustmentValue = 120;
+ widget.reconnectSynthsToAnalyser = jest.fn();
+
+ const instrument = { connect: jest.fn() };
+ global.instruments[0] = {};
+ global.TunerUtils.frequencyToPitch.mockReturnValue({ note: "A4", cents: 0 });
+
+ mockActivity.logo.synth.loadSynth.mockImplementation(() => {
+ global.instruments[0].customsample_test = instrument;
+ });
+
+ widget._playSample();
+
+ expect(widget.reconnectSynthsToAnalyser).toHaveBeenCalled();
+ expect(global.TunerUtils.frequencyToPitch).toHaveBeenCalled();
+ expect(mockActivity.logo.synth.loadSynth).toHaveBeenCalledWith(0, "customsample_test");
+ const expectedFrequency = 220 * Math.pow(2, 120 / 1200);
+ expect(mockActivity.logo.synth.trigger).toHaveBeenCalledWith(
+ 0,
+ [expectedFrequency],
+ 1.2,
+ "customsample_test",
+ null,
+ null,
+ false
+ );
+ });
+
+ test("_playSample reuses existing instrument without loadSynth", () => {
+ widget.sampleName = "customsample_test";
+ widget.originalSampleName = "test";
+ widget.sampleLength = 1000;
+ widget.reconnectSynthsToAnalyser = jest.fn();
+ global.instruments[0] = { customsample_test: { connect: jest.fn() } };
+ global.TunerUtils.frequencyToPitch.mockReturnValue({ note: "A4", cents: 0 });
+
+ widget._playSample();
+
+ expect(mockActivity.logo.synth.loadSynth).not.toHaveBeenCalled();
+ expect(mockActivity.logo.synth.trigger).toHaveBeenCalled();
+ });
+
+ test("applyCentAdjustment logs when instrument missing", () => {
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
+ widget.sampleName = "customsample_test";
+ widget.originalSampleName = "test";
+ widget.isMoving = false;
+
+ widget.applyCentAdjustment(100);
+
+ expect(logSpy).toHaveBeenCalled();
+ logSpy.mockRestore();
+ });
+
+ test("reconnectSynthsToAnalyser sets up analysers and connects instruments", () => {
+ widget.originalSampleName = "test";
+ global.instruments[0] = {
+ "electronic synth": { connect: jest.fn() },
+ "customsample_test": { connect: jest.fn() }
+ };
+
+ widget.reconnectSynthsToAnalyser();
+
+ expect(global.Tone.Analyser).toHaveBeenCalledTimes(2);
+ expect(global.instruments[0]["electronic synth"].connect).toHaveBeenCalled();
+ expect(global.instruments[0].customsample_test.connect).toHaveBeenCalled();
+ });
+
+ test("_updateBlocks updates related blocks and appends cent text", () => {
+ widget._updateBlocks = realUpdateBlocks;
+ widget.timbreBlock = 0;
+ widget.sampleName = "sample";
+ widget.sampleData = "data";
+ widget.samplePitch = "la";
+ widget.sampleOctave = "4";
+ widget.centAdjustmentValue = 10;
+ const mainSampleBlock = {
+ value: null,
+ updateCache: jest.fn(),
+ connections: [null, 2, 3, 4],
+ text: { text: "Sample" }
+ };
+ const audiofileBlock = { value: null, updateCache: jest.fn(), text: { text: "" } };
+ const solfegeBlock = { value: null, updateCache: jest.fn(), text: { text: "" } };
+ const octaveBlock = { value: null, updateCache: jest.fn(), text: { text: "" } };
+ widget.activity = {
+ blocks: {
+ blockList: [
+ { connections: [null, 1] },
+ mainSampleBlock,
+ audiofileBlock,
+ solfegeBlock,
+ octaveBlock
+ ]
+ },
+ refreshCanvas: jest.fn(),
+ saveLocally: jest.fn()
+ };
+
+ widget._updateBlocks();
+
+ expect(mainSampleBlock.value).toEqual(["sample", "data", "la", "4", 10]);
+ expect(audiofileBlock.value).toEqual(["sample", "data"]);
+ expect(solfegeBlock.value).toBe("la");
+ expect(octaveBlock.value).toBe("4");
+ expect(mainSampleBlock.text.text).toContain("10¢");
+ expect(widget.activity.refreshCanvas).toHaveBeenCalled();
+ expect(widget.activity.saveLocally).toHaveBeenCalled();
+ });
+
+ test("setTimbre triggers Singer.ToneActions.setTimbre", () => {
+ widget.setTimbre = realSetTimbre;
+ widget.sampleName = "name";
+ widget.sampleData = "data";
+ widget.timbreBlock = 3;
+
+ widget.setTimbre();
+
+ expect(global.Singer.ToneActions.setTimbre).toHaveBeenCalledWith(
+ ["name_original", "data", "la", 4],
+ 0,
+ 3
+ );
+ });
+
+ test("pause and resume update play state", () => {
+ widget.playBtn = document.createElement("button");
+ widget.pause();
+ expect(widget.isMoving).toBe(false);
+
+ widget.resume();
+ expect(widget.isMoving).toBe(true);
+ });
+
+ test("_usePitch/_useAccidental/_useOctave update centers", () => {
+ widget._usePitch("mi");
+ widget._useAccidental(global.SHARP);
+ widget._useOctave("5");
+
+ expect(widget.pitchCenter).toBe(2);
+ expect(widget.accidentalCenter).toBe(3);
+ expect(widget.octaveCenter).toBe(5);
+ });
+
+ test("getSampleLength and showSampleTypeError notify activity", () => {
+ widget.activity = { errorMsg: jest.fn() };
+ widget.sampleData = "x".repeat(1333334);
+ widget.getSampleLength();
+ expect(widget.activity.errorMsg).toHaveBeenCalled();
+
+ widget.showSampleTypeError();
+ expect(widget.activity.errorMsg).toHaveBeenCalledTimes(2);
+ });
+
+ test("__save and _saveSample generate blocks with cent adjustment", () => {
+ jest.useFakeTimers();
+ widget.activity = {
+ blocks: { loadNewBlocks: jest.fn() }
+ };
+ global.activity = { textMsg: jest.fn() };
+ widget.sampleName = "sample";
+ widget.sampleData = "data";
+ widget.samplePitch = "la";
+ widget.sampleOctave = "4";
+ widget.centAdjustmentValue = 7;
+ widget._addSample = jest.fn();
+
+ widget.__save();
+ jest.advanceTimersByTime(1000);
+
+ expect(widget._addSample).toHaveBeenCalled();
+ expect(widget.activity.blocks.loadNewBlocks).toHaveBeenCalled();
+ expect(global.activity.textMsg).toHaveBeenCalled();
+
+ const saveSpy = jest.spyOn(widget, "__save");
+ widget._saveSample();
+ expect(saveSpy).toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+
+ test("_get_save_lock reflects state", () => {
+ widget._save_lock = true;
+ expect(widget._get_save_lock()).toBe(true);
+ widget._save_lock = false;
+ expect(widget._get_save_lock()).toBe(false);
+ });
+
+ test("_playDelayedSample and _endPlaying await helpers", async () => {
+ widget._playDelayedSample = realPlayDelayedSample;
+ widget._endPlaying = realEndPlaying;
+ widget._waitAndPlaySample = jest.fn().mockResolvedValue("played");
+ widget._waitAndEndPlaying = jest.fn().mockResolvedValue("ended");
+
+ await widget._playDelayedSample();
+ await widget._endPlaying();
+
+ expect(widget._waitAndPlaySample).toHaveBeenCalled();
+ expect(widget._waitAndEndPlaying).toHaveBeenCalled();
+ });
+
+ test("_waitAndPlaySample plays then ends after delay", async () => {
+ jest.useFakeTimers();
+ widget._playSample = jest.fn();
+ widget._endPlaying = jest.fn();
+
+ const promise = widget._waitAndPlaySample();
+ jest.advanceTimersByTime(500);
+ await promise;
+
+ expect(widget._playSample).toHaveBeenCalled();
+ expect(widget._endPlaying).toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+
+ test("_waitAndEndPlaying pauses after sample length", async () => {
+ jest.useFakeTimers();
+ widget.pause = jest.fn();
+ widget.sampleLength = 250;
+
+ const promise = widget._waitAndEndPlaying();
+ jest.advanceTimersByTime(250);
+ await promise;
+
+ expect(widget.pause).toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+
+ test("_scale resizes and redraws canvas", () => {
+ const canvas = document.createElement("canvas");
+ canvas.className = "samplerCanvas";
+ widgetWindow.getWidgetBody().appendChild(canvas);
+ widget.widgetWindow = widgetWindow;
+ widget.makeCanvas = jest.fn();
+ widget.reconnectSynthsToAnalyser = jest.fn();
+
+ widget._scale();
+
+ expect(widget.makeCanvas).toHaveBeenCalledWith(800, 400, 0, true);
+ expect(widget.reconnectSynthsToAnalyser).toHaveBeenCalled();
+ });
+
+ test("_scale uses maximized dimensions", () => {
+ const canvas = document.createElement("canvas");
+ canvas.className = "samplerCanvas";
+ widgetWindow.getWidgetBody().appendChild(canvas);
+ widget.widgetWindow = widgetWindow;
+ widgetWindow.isMaximized.mockReturnValue(true);
+ widget.makeCanvas = jest.fn();
+ widget.reconnectSynthsToAnalyser = jest.fn();
+
+ widget._scale();
+
+ expect(widget.makeCanvas).toHaveBeenCalledWith(800, 430, 0, true);
+ });
+
+ test("drag_and_drop wires drop handler to call handleFiles", () => {
+ const samplerCanvas = document.createElement("canvas");
+ samplerCanvas.className = "samplerCanvas";
+ const dropHandlers = {};
+ samplerCanvas.addEventListener = jest.fn((evt, handler) => {
+ dropHandlers[evt] = handler;
+ });
+ widgetWindow.getWidgetBody().appendChild(samplerCanvas);
+ widget.handleFiles = jest.fn();
+
+ widget.drag_and_drop();
+
+ const dropEvent = {
+ preventDefault: jest.fn(),
+ dataTransfer: { files: ["file"] }
+ };
+ dropHandlers.drop(dropEvent);
+ expect(widget.handleFiles).toHaveBeenCalledWith("file");
+ });
+
+ test("_addSample updates or inserts custom samples", () => {
+ widget.sampleName = "sample1";
+ widget.sampleData = "data1";
+ widget.samplePitch = "la";
+ widget.sampleOctave = "4";
+ widget.centAdjustmentValue = 5;
+ widget._addSample();
+ expect(global.CUSTOMSAMPLES).toHaveLength(1);
+ expect(global.CUSTOMSAMPLES[0][0]).toBe("sample1");
+
+ widget.sampleData = "data2";
+ widget._addSample();
+ expect(global.CUSTOMSAMPLES).toHaveLength(1);
+ expect(global.CUSTOMSAMPLES[0][1]).toBe("data2");
+ });
+
+ test("init wires up buttons and handlers", async () => {
+ const addSpy = jest.spyOn(HTMLCanvasElement.prototype, "addEventListener");
+ const samplerCanvas = document.createElement("canvas");
+ samplerCanvas.className = "samplerCanvas";
+ widgetWindow.getWidgetBody().appendChild(samplerCanvas);
+
+ const fileChooser = docById("myOpenAll");
+ Object.defineProperty(fileChooser, "files", {
+ value: [{ name: "sample.wav" }]
+ });
+ fileChooser.addEventListener = jest.fn((_, cb) => cb({}));
+ fileChooser.removeEventListener = jest.fn();
+ fileChooser.focus = jest.fn();
+ fileChooser.click = jest.fn();
+ window.scroll = jest.fn();
+
+ widget.handleFiles = jest.fn();
+ widget.resume = jest.fn();
+ widget.pause = jest.fn();
+ widget._playReferencePitch = jest.fn();
+
+ widget.init(mockActivity, 1);
+
+ widget.sampleName = "test";
+ widget.playBtn.onclick();
+ expect(widget.resume).toHaveBeenCalled();
+ expect(widget._playReferencePitch).toHaveBeenCalled();
+
+ await widget._recordBtn.onclick();
+ expect(widget.is_recording).toBe(true);
+ await widget._recordBtn.onclick();
+ expect(widget.is_recording).toBe(false);
+
+ widget.recordingURL = "recording-url";
+ widget._addSample = jest.fn();
+ widget._playbackBtn.onclick();
+ expect(widget._addSample).toHaveBeenCalled();
+ widget._playbackBtn.onclick();
+ expect(mockActivity.logo.synth.stopPlayBackRecording).toHaveBeenCalled();
+
+ await widget._tunerBtn.onclick();
+ await widget._tunerBtn.onclick();
+
+ widget.centsSliderBtn.onclick();
+ const slider = docById("centAdjustmentContainer").querySelector("input[type=range]");
+ slider.value = "10";
+ slider.oninput();
+ widget.centsSliderBtn.onclick();
+
+ expect(addSpy).toHaveBeenCalled();
+ addSpy.mockRestore();
+ });
+
+ test("init save button debounces and onclose cleans up", () => {
+ widget.init(mockActivity, 1);
+ const saveButton = widgetWindow._buttons.find(btn => btn.tip === "Save sample");
+ widget._saveSample = jest.fn();
+ jest.useFakeTimers();
+
+ saveButton.onclick();
+ saveButton.onclick();
+ jest.advanceTimersByTime(1000);
+
+ expect(widget._saveSample).toHaveBeenCalledTimes(1);
+
+ widget._scale = jest.fn();
+ widget._updateContainerPositions = jest.fn();
+ widgetWindow.onmaximize();
+ widgetWindow.onrestore();
+ expect(widget._scale).toHaveBeenCalledTimes(2);
+ expect(widget._updateContainerPositions).toHaveBeenCalledTimes(2);
+
+ widget._pitchWheel = { removeWheel: jest.fn() };
+ widget._exitWheel = { removeWheel: jest.fn() };
+ widget._accidentalsWheel = { removeWheel: jest.fn() };
+ widget._octavesWheel = { removeWheel: jest.fn() };
+ widget.stopPitchDetection = jest.fn();
+ widget.drawVisualIDs = { 1: 11 };
+ const wheelDiv = docById("wheelDiv");
+ wheelDiv.style.display = "block";
+ const wheelDivptm = docById("wheelDivptm");
+ wheelDivptm.style.display = "block";
+
+ widgetWindow.onclose();
+ expect(widget.stopPitchDetection).toHaveBeenCalled();
+ expect(widgetWindow.destroy).toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+
+ test("prompt UI handles submit, preview, and save", async () => {
+ widget.init(mockActivity, 1);
+ widget._promptBtn.onclick();
+
+ const container = docById("samplerPrompt");
+ const textArea = container.querySelector("textarea");
+ const buttons = Array.from(container.querySelectorAll("button"));
+ const submit = buttons.find(btn => btn.innerHTML === "Submit");
+ const preview = buttons.find(btn => btn.innerHTML === "Preview");
+ const save = buttons.find(btn => btn.innerHTML === "Save");
+
+ textArea.value = "hello";
+ textArea.dispatchEvent(new Event("input"));
+ expect(submit.disabled).toBe(false);
+
+ global.fetch = jest.fn().mockResolvedValue({
+ json: jest.fn().mockResolvedValue({ status: "success" })
+ });
+ jest.useFakeTimers();
+ await submit.onclick();
+ jest.runOnlyPendingTimers();
+
+ preview.disabled = false;
+ save.disabled = false;
+ const playSpy = jest.fn();
+ global.Audio = class {
+ constructor() {
+ this.play = playSpy;
+ }
+ };
+ preview.onclick();
+ expect(playSpy).toHaveBeenCalled();
+
+ const clickSpy = jest
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => {});
+ save.onclick();
+ expect(clickSpy).toHaveBeenCalled();
+ clickSpy.mockRestore();
+ jest.useRealTimers();
+ });
+
+ test("prompt submit handles failure and errors", async () => {
+ widget.init(mockActivity, 1);
+ widget._promptBtn.onclick();
+
+ const container = docById("samplerPrompt");
+ const textArea = container.querySelector("textarea");
+ const submit = Array.from(container.querySelectorAll("button")).find(
+ btn => btn.innerHTML === "Submit"
+ );
+
+ textArea.value = "fail";
+ global.fetch = jest.fn().mockResolvedValue({
+ json: jest.fn().mockResolvedValue({ status: "fail" })
+ });
+ await submit.onclick();
+
+ global.fetch = jest.fn().mockRejectedValue(new Error("boom"));
+ await submit.onclick();
+ });
+
+ test("_updateContainerPositions updates layouts for maximized and normal", () => {
+ widget.init(mockActivity, 1);
+ const tunerContainer = document.createElement("div");
+ tunerContainer.id = "tunerContainer";
+ document.body.appendChild(tunerContainer);
+ const valueDisplay = document.createElement("div");
+ valueDisplay.id = "centValueDisplay";
+ document.body.appendChild(valueDisplay);
+
+ widget.widgetWindow.isMaximized.mockReturnValue(true);
+ widget._updateContainerPositions();
+ expect(tunerContainer.style.marginTop).toBe("150px");
+ expect(valueDisplay.style.marginTop).toBe("50px");
+
+ widget.widgetWindow.isMaximized.mockReturnValue(false);
+ widget._updateContainerPositions();
+ expect(tunerContainer.style.marginTop).toBe("100px");
+ expect(valueDisplay.style.marginTop).toBe("30px");
+ });
+
+ test("_createPieMenu builds wheels and positions menu", () => {
+ widget.init(mockActivity, 1);
+ widget.pitchBtn.getBoundingClientRect = () => ({ x: 10, y: 20 });
+
+ widget._createPieMenu();
+
+ expect(docById("wheelDivptm").style.display).toBe("");
+ expect(widget._pitchWheel.createWheel).toBeDefined();
+ expect(widget._accidentalsWheel.setTooltips).toHaveBeenCalled();
+ });
+
+ test("_createPieMenu selection and exit handlers update pitch and close", () => {
+ widget.init(mockActivity, 1);
+ widget.pitchBtn.getBoundingClientRect = () => ({ x: 10, y: 20 });
+ widget._playReferencePitch = jest.fn();
+ widget._createPieMenu();
+
+ widget._pitchWheel.selectedNavItemIndex = 0;
+ widget._accidentalsWheel.selectedNavItemIndex = 1;
+ widget._octavesWheel.selectedNavItemIndex = 2;
+ widget._pitchWheel.navItems[0].title = "do";
+ widget._accidentalsWheel.navItems[1].title = global.SHARP;
+ widget._octavesWheel.navItems[2].title = "4";
+
+ widget._pitchWheel.navItems[0].navigateFunction();
+ expect(widget._playReferencePitch).toHaveBeenCalled();
+
+ widget._exitWheel.navItems[0].navigateFunction();
+ expect(docById("wheelDivptm").style.display).toBe("none");
+ });
+
+ test("tuner toggle handles maximized mode and mode toggle clicks", async () => {
+ widget.init(mockActivity, 1);
+ widget.widgetWindow.isMaximized.mockReturnValue(true);
+ const tunerContainer = document.createElement("div");
+ tunerContainer.id = "tunerContainer";
+ document.body.appendChild(tunerContainer);
+
+ await widget._tunerBtn.onclick();
+ const toggle = docById("modeToggle");
+ const buttons = Array.from(toggle.querySelectorAll("div"));
+ buttons[0].onclick();
+ buttons[1].onclick();
+ });
+
+ test("makeCanvas draws waveform and updates tuner when enabled", () => {
+ widget.widgetWindow = widgetWindow;
+ widget.tunerEnabled = true;
+ widget.pitchCenter = 5;
+ widget.accidentalCenter = 2;
+ widget.octaveCenter = 4;
+ widget.centsValue = 0;
+ widget.sampleName = "sample";
+ widget.drawVisualIDs = {};
+ widget.is_recording = true;
+ widget.running = true;
+ widget.pitchAnalysers = {
+ 0: { getValue: jest.fn(() => [0, 0.5, -0.5]) },
+ 1: { getValue: jest.fn(() => [0, 0.5, -0.5]) }
+ };
+ global.TunerUtils.frequencyToPitch.mockReturnValue(["A4", 0]);
+ global.detectPitch = jest.fn(() => 440);
+ document.querySelectorAll = jest.fn(() => [
+ { setAttribute: jest.fn() },
+ { setAttribute: jest.fn() }
+ ]);
+
+ widget.makeCanvas(400, 300, 0, true);
+
+ expect(widget.tunerDisplay).toBeTruthy();
+ expect(widget.tunerDisplay.update).toHaveBeenCalled();
+ });
+
+ test("makeCanvas removes tuner canvas when disabled", () => {
+ widget.widgetWindow = widgetWindow;
+ widget.tunerEnabled = false;
+ widget.drawVisualIDs = {};
+ const tunerCanvas = document.createElement("canvas");
+ tunerCanvas.className = "tunerCanvas";
+ widget.widgetWindow.getWidgetBody().appendChild(tunerCanvas);
+ widget.tunerDisplay = { canvas: tunerCanvas };
+
+ widget.makeCanvas(400, 300, 0, false);
+
+ expect(widget.tunerDisplay).toBeNull();
+ });
+
+ test("makeCanvas updates existing tuner display and draws non-recording path", () => {
+ widget.widgetWindow = widgetWindow;
+ widget.tunerEnabled = true;
+ widget.drawVisualIDs = {};
+ widget.running = true;
+ widget.pitchAnalysers = {
+ 0: { getValue: jest.fn(() => [0.1, -0.1]) },
+ 1: { getValue: jest.fn(() => [0.2, -0.2]) }
+ };
+ widget.tunerDisplay = new global.TunerDisplay(
+ document.createElement("canvas"),
+ 100,
+ 100
+ );
+ global.TunerUtils.frequencyToPitch.mockReturnValue(["A4", 0]);
+ global.detectPitch = jest.fn(() => 440);
+ document.querySelectorAll = jest.fn(() => [{ setAttribute: jest.fn() }]);
+
+ widget.makeCanvas(400, 300, 0, true);
+ expect(widget.tunerDisplay.canvas).toBeTruthy();
+ });
+
+ test("makeTuner builds UI and triggers pitch detection", async () => {
+ widget.widgetWindow = widgetWindow;
+ const audioContext = {
+ sampleRate: 44100,
+ createMediaStreamSource: jest.fn(() => ({ connect: jest.fn() })),
+ createAnalyser: jest.fn(() => ({
+ fftSize: 0,
+ getFloatTimeDomainData: jest.fn()
+ })),
+ close: jest.fn().mockResolvedValue()
+ };
+ global.AudioContext = jest.fn(() => audioContext);
+ const stream = { getTracks: jest.fn(() => [{ stop: jest.fn() }]) };
+ Object.defineProperty(window, "navigator", {
+ value: {
+ mediaDevices: {
+ getUserMedia: jest.fn().mockResolvedValue(stream)
+ }
+ },
+ configurable: true
+ });
+
+ let rafCalls = 0;
+ global.requestAnimationFrame = jest.fn(cb => {
+ rafCalls += 1;
+ if (rafCalls === 1) cb();
+ return rafCalls;
+ });
+
+ widget.makeTuner(400, 300);
+
+ const startButton = document.getElementById("start");
+ startButton.click();
+
+ await Promise.resolve();
+ expect(window.navigator.mediaDevices.getUserMedia).toHaveBeenCalled();
+ });
+
+ test("startPitchDetection handles getUserMedia failure", async () => {
+ widget.widgetWindow = widgetWindow;
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+ global.AudioContext = jest.fn(() => ({
+ sampleRate: 44100,
+ createMediaStreamSource: jest.fn(() => ({ connect: jest.fn() })),
+ createAnalyser: jest.fn(() => ({
+ fftSize: 0,
+ getFloatTimeDomainData: jest.fn()
+ })),
+ close: jest.fn().mockResolvedValue()
+ }));
+ Object.defineProperty(window, "navigator", {
+ value: {
+ mediaDevices: {
+ getUserMedia: jest.fn().mockRejectedValue(new Error("no mic"))
+ }
+ },
+ configurable: true
+ });
+ global.alert = jest.fn();
+
+ widget.makeTuner(400, 300);
+ const startButton = document.getElementById("start");
+ startButton.click();
+
+ await Promise.resolve();
+ expect(global.alert).toHaveBeenCalled();
+ errorSpy.mockRestore();
+ });
+ });
+});
diff --git a/js/widgets/sampler.js b/js/widgets/sampler.js
index e1a75740b2..99ac566204 100644
--- a/js/widgets/sampler.js
+++ b/js/widgets/sampler.js
@@ -189,8 +189,8 @@ function SampleWidget() {
this.activity.blocks.blockList[mainSampleBlock].text.text
) {
// Append cent adjustment to the block text if possible
- const currentText = this.activity.blocks.blockList[mainSampleBlock].text
- .text;
+ const currentText =
+ this.activity.blocks.blockList[mainSampleBlock].text.text;
if (!currentText.includes("¢")) {
this.activity.blocks.blockList[mainSampleBlock].text.text +=
" " + centText;
@@ -522,29 +522,25 @@ function SampleWidget() {
}
};
- widgetWindow.addButton(
- "load-media.svg",
- ICONSIZE,
- _("Upload sample"),
- ""
- ).onclick = function () {
- stopTuner();
- const fileChooser = docById("myOpenAll");
+ widgetWindow.addButton("load-media.svg", ICONSIZE, _("Upload sample"), "").onclick =
+ function () {
+ stopTuner();
+ const fileChooser = docById("myOpenAll");
+
+ // eslint-disable-next-line no-unused-vars
+ const __readerAction = function (event) {
+ window.scroll(0, 0);
+ const sampleFile = fileChooser.files[0];
+ that.handleFiles(sampleFile);
+ fileChooser.removeEventListener("change", __readerAction);
+ };
- // eslint-disable-next-line no-unused-vars
- const __readerAction = function (event) {
+ fileChooser.addEventListener("change", __readerAction, false);
+ fileChooser.focus();
+ fileChooser.click();
window.scroll(0, 0);
- const sampleFile = fileChooser.files[0];
- that.handleFiles(sampleFile);
- fileChooser.removeEventListener("change", __readerAction);
};
- fileChooser.addEventListener("change", __readerAction, false);
- fileChooser.focus();
- fileChooser.click();
- window.scroll(0, 0);
- };
-
// Create a container for the pitch button and frequency display
this.pitchBtnContainer = document.createElement("div");
this.pitchBtnContainer.className = "wfbtItem";
@@ -576,22 +572,18 @@ function SampleWidget() {
};
this._save_lock = false;
- widgetWindow.addButton(
- "export-chunk.svg",
- ICONSIZE,
- _("Save sample"),
- ""
- ).onclick = function () {
- stopTuner();
- // Debounce button
- if (!that._get_save_lock()) {
- that._save_lock = true;
- that._saveSample();
- setTimeout(function () {
- that._save_lock = false;
- }, 1000);
- }
- };
+ widgetWindow.addButton("export-chunk.svg", ICONSIZE, _("Save sample"), "").onclick =
+ function () {
+ stopTuner();
+ // Debounce button
+ if (!that._get_save_lock()) {
+ that._save_lock = true;
+ that._saveSample();
+ setTimeout(function () {
+ that._save_lock = false;
+ }, 1000);
+ }
+ };
this._recordBtn = widgetWindow.addButton("mic.svg", ICONSIZE, _("Toggle Mic"), "");
@@ -1783,9 +1775,8 @@ function SampleWidget() {
const __selectionChanged = () => {
const label = this._pitchWheel.navItems[this._pitchWheel.selectedNavItemIndex].title;
- const attr = this._accidentalsWheel.navItems[
- this._accidentalsWheel.selectedNavItemIndex
- ].title;
+ const attr =
+ this._accidentalsWheel.navItems[this._accidentalsWheel.selectedNavItemIndex].title;
const octave = Number(
this._octavesWheel.navItems[this._octavesWheel.selectedNavItemIndex].title
);
@@ -2047,9 +2038,8 @@ function SampleWidget() {
const playbackRate = TunerUtils.calculatePlaybackRate(0, this.centsValue);
// Apply the playback rate to the sample
if (instruments[0]["customsample_" + this.originalSampleName]) {
- instruments[0][
- "customsample_" + this.originalSampleName
- ].playbackRate.value = playbackRate;
+ instruments[0]["customsample_" + this.originalSampleName].playbackRate.value =
+ playbackRate;
}
}
};
@@ -2386,3 +2376,7 @@ class PitchSmoother {
this.pitchHistory = [];
}
}
+
+if (typeof module !== "undefined") {
+ module.exports = { SampleWidget, PitchSmoother };
+}