diff --git a/js/activity-context.js b/js/activity-context.js new file mode 100644 index 0000000000..397820222a --- /dev/null +++ b/js/activity-context.js @@ -0,0 +1,59 @@ +/* + * ActivityContext + * + * Single authority for accessing the runtime Activity instance. + * + * NOTE: This repo uses RequireJS shims + globals for browser builds. + * This module supports AMD (RequireJS), CommonJS (Jest), and a browser global + * fallback (ActivityContext) without exporting the Activity instance onto + * window.activity. + */ + +(function (root, factory) { + // Lazy singleton: factory runs once, under the loader's control when + // possible (AMD), but always exposes a global for non-AMD consumers. + let _mod; + + function getModule() { + if (!_mod) _mod = factory(); + return _mod; + } + + if (typeof define === "function" && define.amd) { + define([], function () { + return getModule(); + }); + } else if (typeof module !== "undefined" && module.exports) { + module.exports = getModule(); + } + + // Ensure a global reference exists so non-AMD code (activity.js, synthutils.js) + // can access it immediately. + try { + root.ActivityContext = getModule(); + } catch (e) { + // ignore if root is not writable in some hostile environments + } +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + + let _activity = null; + + function setActivity(activityInstance) { + if (!activityInstance) { + throw new Error("Cannot set ActivityContext with a falsy value"); + } + _activity = activityInstance; + } + + function getActivity() { + if (!_activity) { + throw new Error( + "Activity not initialized yet. Use dependency injection or wait for initialization." + ); + } + return _activity; + } + + return { setActivity, getActivity }; +}); diff --git a/js/activity.js b/js/activity.js index 67b3c35fa0..529ce4a21a 100644 --- a/js/activity.js +++ b/js/activity.js @@ -2969,7 +2969,19 @@ class Activity { // Expose activity instance for external checks if (typeof window !== "undefined") { - window.activity = this; + // Single authority: ActivityContext + // TODO: window.activity is deprecated; use ActivityContext instead + if ( + window.ActivityContext && + typeof window.ActivityContext.setActivity === "function" + ) { + window.ActivityContext.setActivity(this); + } + + // TEMP compatibility bridge + if (!window.activity) { + window.activity = this; + } } }; @@ -3034,7 +3046,19 @@ class Activity { // Expose activity instance for external checks if (typeof window !== "undefined") { - window.activity = this; + // Single authority: ActivityContext + // TODO: window.activity is deprecated; use ActivityContext instead + if ( + window.ActivityContext && + typeof window.ActivityContext.setActivity === "function" + ) { + window.ActivityContext.setActivity(this); + } + + // TEMP compatibility bridge + if (!window.activity) { + window.activity = this; + } } }; diff --git a/js/loader.js b/js/loader.js index 5ddca264b0..214216508c 100644 --- a/js/loader.js +++ b/js/loader.js @@ -83,7 +83,7 @@ requirejs.config({ exports: "Notation" }, "utils/synthutils": { - deps: ["utils/utils"], + deps: ["utils/utils", "activity/activity-context"], exports: "Synth" }, "activity/logo": { @@ -96,7 +96,13 @@ requirejs.config({ exports: "Logo" }, "activity/activity": { - deps: ["utils/utils", "activity/logo", "activity/blocks", "activity/turtles"], + deps: [ + "utils/utils", + "activity/activity-context", + "activity/logo", + "activity/blocks", + "activity/turtles" + ], exports: "Activity" }, "materialize": { diff --git a/js/utils/synthutils.js b/js/utils/synthutils.js index 8fa7271a52..e1d20a89c0 100644 --- a/js/utils/synthutils.js +++ b/js/utils/synthutils.js @@ -2378,24 +2378,39 @@ function Synth() { * @returns {Promise} */ this.startTuner = async () => { - // Initialize required components for pie menu - if (!window.activity) { - window.activity = { - blocks: { - blockList: [], - setPitchOctave: () => {}, - findPitchOctave: () => 4, - stageClick: false - }, - logo: { - synth: this - }, - canvas: document.createElement("canvas"), - blocksContainer: { x: 0, y: 0 }, - getStageScale: () => 1, - KeySignatureEnv: ["A", "major", false] - }; - } + const getSafeActivity = () => { + try { + if ( + typeof window !== "undefined" && + window.ActivityContext && + typeof window.ActivityContext.getActivity === "function" + ) { + return window.ActivityContext.getActivity(); + } + } catch (e) { + // Fall through to warning below. + } + + // In practice this runs in the browser; keep a safe fallback for tests. + try { + if (typeof module !== "undefined" && module.exports) { + // eslint-disable-next-line global-require + const ctx = require("../activity-context"); + if (ctx && typeof ctx.getActivity === "function") { + return ctx.getActivity(); + } + } + } catch (e) { + // Ignore. + } + + console.warn("Activity not ready yet in synthutils"); + return null; + }; + + // No fake globals: if Activity isn't ready, fail fast. + const activity = getSafeActivity(); + if (!activity) return; // Initialize wheelnav if not already done if (typeof wheelnav !== "function") { @@ -2676,13 +2691,30 @@ function Synth() { } try { - // Create a temporary block object to use with piemenuPitches + // Prepare a non-mutating activity proxy with a local logo fallback + const defaultLogo = { + synth: { + createDefaultSynth: () => {}, + loadSynth: () => {}, + setMasterVolume: () => {}, + trigger: () => {}, + inTemperament: "equal" + }, + errorMsg: msg => { + console.warn(msg); + } + }; + + const logo = activity.logo || defaultLogo; + const activityProxy = Object.create(activity); + activityProxy.logo = logo; + const tempBlock = { container: { x: targetNoteSelector.offsetLeft, y: targetNoteSelector.offsetTop }, - activity: window.activity, + activity: activityProxy, blocks: { blockList: [ { @@ -2730,54 +2762,8 @@ function Synth() { _triggerLock: false // This is needed for pitch preview }; - // Add required activity properties for preview - if (!window.activity.logo) { - window.activity.logo = { - synth: { - createDefaultSynth: () => {}, - loadSynth: () => {}, - setMasterVolume: () => {}, - trigger: (turtle, note, duration, instrument) => { - // Use the Web Audio API to play the preview note - const audioContext = new (window.AudioContext || - window.webkitAudioContext)(); - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - - // Convert note to frequency - const freq = pitchToFrequency(note[0], "equal"); - oscillator.frequency.value = freq; - - // Set volume - gainNode.gain.value = 0.1; // Low volume for preview - - // Schedule note - oscillator.start(); - gainNode.gain.setValueAtTime( - 0.1, - audioContext.currentTime - ); - gainNode.gain.linearRampToValueAtTime( - 0, - audioContext.currentTime + duration - ); - oscillator.stop( - audioContext.currentTime + duration - ); - }, - inTemperament: "equal" - }, - errorMsg: msg => { - console.warn(msg); - } - }; - } - - // Add key signature environment - window.activity.KeySignatureEnv = ["C", "major", false]; + // Add key signature environment (on proxy, not real activity) + activityProxy.KeySignatureEnv = ["C", "major", false]; // Make sure wheelDiv is properly positioned and visible const wheelDiv = docById("wheelDiv");