Source: cloud-5.js

/**
 * This script defines custom HTML elements and supporting code for the 
 * cloud-5 system. Once a custom element is used in the body of a Web page, 
 * its DOM object can be obtained, and then not only DOM methods but also 
 * custom methods can be called.
 * 
 * There are two methods of extending the cloud-5 system:
 * 
 * 1. Set addon functions, code text, and other properties of the custom 
 *    elements. All such user-defined properties have names ending in 
 *    `_addon`.
 * 2. Define new custom elements that derive from the `Cloud5Element` base 
 *    class, or from other cloud-5 custom elements. These new custom elements 
 *    will automatically be given menu buttons and show/hide behavior by the
 * 
 * To simplify both usage and maintenance, internal styles are usually not 
 * used. Default styles are defined in the csound-5.css cascading style sheet. 
 * These styles can be overridden by the user.
 * 
 * Usage:
 * 
 * 1. Include the csound-5.js and csound-5.css scripts in the Web page.
 * 2. Lay out and style the required custom elements as with any other HTNL 
 *    elements. The w3.css style sheet is used internally and should also be 
 *    included.
 * 3. In a script element of the Web page:
 *    a. Define addons as JavaScript variables.
 *    b. Obtain DOM objects from custom elements.
 *    c. Assign custom elements and addons to their respective properties.
 */

/**
 * Holds refernces to windows that must be closed on exit.
 */
globalThis.windows_to_close = [];

/**
 * Close all secondary windows on exit.
 */
window.addEventListener("beforeunload", (event) => {
  for (let window_to_close of globalThis.windows_to_close) {
    try {
      window_to_close.close()
    } catch (ex) {
      console.warn(ex);
    }
  }
  globalThis.windows_to_close = [];
});

/**
 * Create a full-viewport WebGL2 canvas inside `container` and return {gl, canvas}.
 * Transparent by default so lower layers (e.g., your shader background) can show through.
 */
function obtainWebGL2(container, {
  alpha = true,
  antialias = true,
  premultipliedAlpha = true,
  preserveDrawingBuffer = false,
  desynchronized = true,          // lower latency on some browsers
  powerPreference = 'high-performance'
} = {}) {
  const canvas = document.createElement('canvas');
  Object.assign(canvas.style, {
    position: 'fixed', inset: '0', width: '100%', height: '100%', border: '0',
    display: 'block', pointerEvents: 'none' // keep clicks for UI above
  });
  container.appendChild(canvas);

  // Try WebGL2 first.
  let gl = canvas.getContext('webgl2', {
    alpha, antialias, premultipliedAlpha, preserveDrawingBuffer,
    desynchronized, powerPreference
  });

  // Optional fallback to WebGL1 (if you want it)
  if (!gl) {
    gl = canvas.getContext('webgl', {
      alpha, antialias, premultipliedAlpha, preserveDrawingBuffer,
      desynchronized, powerPreference
    }) || canvas.getContext('experimental-webgl');
  }
  if (!gl) throw new Error('Unable to obtain WebGL or WebGL2 context.');

  // Transparent clear so underlying layers remain visible.
  gl.clearColor(0, 0, 0, 0);

  // Handle HiDPI and resizing
  function resize() {
    const dpr = Math.max(1, window.devicePixelRatio || 1);
    const w = Math.floor(canvas.clientWidth * dpr);
    const h = Math.floor(canvas.clientHeight * dpr);
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w; canvas.height = h;
      gl.viewport(0, 0, w, h);
    }
  }
  resize();
  new ResizeObserver(resize).observe(canvas);
  window.addEventListener('orientationchange', resize);

  // Context loss / restore
  canvas.addEventListener('webglcontextlost', (e) => {
    e.preventDefault();
    const msg = 'WebGL context lost (obtainWebGL2)';
    console.error(msg);
    try {
      globalThis.cloud5_piece?.csound_message_callback?.(msg + '\n');
    } catch { }
    // stop anim loops / releases as needed
  });
  canvas.addEventListener('webglcontextrestored', () => {
    const msg = 'WebGL context restored (obtainWebGL2)';
    console.log(msg);
    try {
      globalThis.cloud5_piece?.csound_message_callback?.(msg + '\n');
    } catch { }
    // re-create programs/buffers/textures, then:
    resize();
  });

  // Useful diagnostics
  // console.log(gl.getParameter(gl.VERSION), gl.getParameter(gl.SHADING_LANGUAGE_VERSION));

  // WebGL2-only? Check once:
  const isWebGL2 = (typeof WebGL2RenderingContext !== 'undefined') && (gl instanceof WebGL2RenderingContext);

  // Enable common extensions when available (WebGL2: only EXT_color_buffer_float typically needed)
  if (isWebGL2) {
    gl.getExtension('EXT_color_buffer_float'); // renderable float targets
    gl.getExtension('EXT_texture_filter_anisotropic'); // better texture filtering
  } else {
    gl.getExtension('OES_texture_float');
    gl.getExtension('OES_texture_float_linear');
    gl.getExtension('OES_element_index_uint');
    gl.getExtension('EXT_shader_texture_lod');
    gl.getExtension('EXT_texture_filter_anisotropic');
  }

  return { gl, canvas, isWebGL2 };
}

function cloud5_can_persist_state() {
  return cloud5_is_local_context();
}

function cloud5_state_filename_for_piece() {
  const base = document.title || 'piece';
  return `${base}.state.json`;
}

function cloud5_is_local_context() {
  const protocol = window.location?.protocol || '';
  const host = window.location?.hostname || '';

  // Classic browser-local signals.
  if (protocol === 'file:') {
    return true;
  }

  if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
    return true;
  }

  // NW.js: if we're running as an NW.js app (has a manifest/package.json),
  // treat this as local context.
  //
  // In NW.js, the renderer may be http(s): (embedded server, custom routing, etc.)
  // so window.location is not a reliable locality indicator.
  try {
    if (typeof process !== 'undefined' && process.versions && process.versions.nw) {
      if (typeof nw !== 'undefined' && nw.App && nw.App.manifest) {
        // nw.App.manifest is the parsed package.json when running as an app.
        return true;
      }
      // If NW.js is present but manifest is not accessible for some reason,
      // it's still overwhelmingly likely you're in the app context.
      return true;
    }
  } catch (e) {
    // If anything about NW.js probing fails, fall through to non-local.
  }

  // Not file:, not loopback, not NW.js app context.
  return false;
}

function cloud5_get_by_path(obj, path) {
  if (!obj || typeof path !== 'string' || path.length === 0) {
    return undefined;
  }

  const parts = path.split('.');
  let current = obj;

  for (const part of parts) {
    if (current === null || (typeof current !== 'object' && typeof current !== 'function')) {
      return undefined;
    }
    if (!(part in current)) {
      return undefined;
    }
    current = current[part]; // supports prototype getters
  }

  return current;
}

function cloud5_set_by_path(obj, path, value) {
  if (!obj || typeof path !== 'string' || path.length === 0) {
    return;
  }

  const parts = path.split('.');
  let current = obj;

  for (let i = 0; i < parts.length - 1; i++) {
    const part = parts[i];

    if (current === null || (typeof current !== 'object' && typeof current !== 'function')) {
      return;
    }

    // Use existing value if it looks like an object; otherwise create.
    if (!(part in current) || current[part] === null || typeof current[part] !== 'object') {
      current[part] = {};
    }

    current = current[part];
  }

  // Final assignment: will invoke a setter if present.
  current[parts[parts.length - 1]] = value;
}

function cloud5_snapshot_fields(obj, field_names) {
  const values = {};

  for (const path of field_names) {
    try {
      const raw_value = cloud5_get_by_path(obj, path);
      if (raw_value === undefined) {
        continue;
      }

      const detached = JSON.parse(JSON.stringify(raw_value));
      values[path] = detached;
    } catch {
      console.warn(`cloud5_snapshot_fields: field ${path} could not be serialized; skipping.`);
    }
  }

  return values;
}

function cloud5_path_exists_strict(root_obj, path) {
  if (!root_obj || typeof path !== 'string' || path.length === 0) {
    return false;
  }

  const parts = path.split('.');
  let cur = root_obj;

  for (let i = 0; i < parts.length; i++) {
    const key = parts[i];

    if (cur === null || (typeof cur !== 'object' && typeof cur !== 'function')) {
      return false;
    }

    if (!(key in cur)) {
      return false;
    }

    cur = cur[key];
  }

  return true;
}

function cloud5_restore_fields(obj, values) {
  if (!obj || !values || typeof values !== 'object') {
    return;
  }

  for (const path of Object.keys(values)) {
    try {
      if (!cloud5_path_exists_strict(obj, path)) {
        console.log('skip', path);
        continue;
      }

      cloud5_set_by_path(obj, path, values[path]); // your existing setter is fine now
    }
    catch {
      console.warn(`cloud5_restore_fields: field ${path} could not be restored; skipping.`);
    }
  }
}

function cloud5_apply_state_bindings(host, options = {}) {
  if (!host) return;

  const include_shadow = options.include_shadow !== false;
  const roots = [];
  roots.push(host);
  if (include_shadow && host.shadowRoot) {
    roots.push(host.shadowRoot);
  }

  const apply_to_element = (el, value) => {
    if (!el) return;

    // Optional affine transform: value' = value * scale + offset
    let v = value;
    const scale_attr = el.getAttribute?.('data-cloud5-bind-scale');
    const offset_attr = el.getAttribute?.('data-cloud5-bind-offset');
    const scale = scale_attr !== null ? parseFloat(scale_attr) : NaN;
    const offset = offset_attr !== null ? parseFloat(offset_attr) : NaN;
    if (typeof v === 'number') {
      if (!Number.isNaN(scale)) v = v * scale;
      if (!Number.isNaN(offset)) v = v + offset;
    }

    const explicit_prop = el.getAttribute?.('data-cloud5-bind-prop');
    if (explicit_prop) {
      try {
        el[explicit_prop] = v;
      } catch (e) { }
      return;
    }

    // Default mapping.
    const tag = (el.tagName || '').toLowerCase();
    if (tag === 'input') {
      const type = (el.getAttribute('type') || 'text').toLowerCase();
      if (type === 'checkbox' || type === 'radio') {
        el.checked = !!v;
      } else {
        el.value = (v ?? '').toString();
      }
      return;
    }

    if (tag === 'textarea' || tag === 'select') {
      el.value = (v ?? '').toString();
      return;
    }

    el.textContent = (v ?? '').toString();
  };

  for (const root of roots) {
    const bound = root.querySelectorAll?.('[data-cloud5-bind]') || [];
    for (const el of bound) {
      const bind_path = el.getAttribute('data-cloud5-bind');
      if (!bind_path) continue;
      const value = cloud5_get_by_path(host, bind_path);
      if (value === undefined) continue;
      apply_to_element(el, value);
    }
  }
}

function cloud5_notify_state_restored(piece, restored_state) {
  const notify_one = (obj) => {
    if (!obj) return;
    try {
      obj.on_state_restored?.(restored_state);
    } catch (e) {
      console.warn('on_state_restored failed:', e);
    }
  };

  notify_one(piece);

  try {
    if (typeof piece?._get_all_overlays === 'function') {
      for (const overlay of piece._get_all_overlays()) {
        notify_one(overlay);
      }
    }
  } catch (e) { }
}

async function cloud5_clipboard_and_download(json_text, filename) {
  // Clipboard
  try {
    await navigator.clipboard.writeText(json_text);
  } catch (e) {
    console.warn('Clipboard write failed:', e);
  }

  // Automatic download
  const blob = new Blob([json_text], { type: 'application/json' });
  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.style.display = 'none';

  document.body.appendChild(a);
  a.click();

  setTimeout(() => {
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }, 0);
}

function cloud5_sanitize_for_filename(s) {
  return String(s || "snapshot")
    .trim()
    .replace(/\s+/g, "-")
    .replace(/[^a-zA-Z0-9_.-]/g, "_")
    .replace(/-+/g, "-");
}

function cloud5_snapshot_filenames_from_title(title) {
  const base = cloud5_sanitize_for_filename(title || "snapshot");
  return {
    html_name: `${base}.html`,
    state_name: `${base}.state.json`,
  };
}

function cloud5_generated_csd_filename_from_title(title) {
  const base = cloud5_sanitize_for_filename(title || "snapshot");
  return `${base}-generated-parameters.csd`;
}

async function cloud5_write_text_to_snapshot_dir_if_available(filename, text, mime_type = "text/plain") {
  const dir_handle = await cloud5_try_get_snapshot_dir_handle();
  if (!dir_handle) {
    return false;
  }

  const file_handle = await dir_handle.getFileHandle(filename, { create: true });
  const writable = await file_handle.createWritable();
  await writable.write(new Blob([text], { type: mime_type }));
  await writable.close();
  return true;
}

function cloud5_format_elapsed_time(seconds_total) {
  const s = Math.max(0, Number(seconds_total) || 0);
  let delta = s;
  const days = Math.floor(delta / 86400);
  delta -= days * 86400;
  const hours = Math.floor(delta / 3600) % 24;
  delta -= hours * 3600;
  const minutes = Math.floor(delta / 60) % 60;
  delta -= minutes * 60;
  const seconds = delta % 60;
  return sprintf("d:%4d h:%02d m:%02d s:%06.3f", days, hours, minutes, seconds);
}


function cloud5_strip_extension(filename) {
  return filename.replace(/\.[^/.]+$/, "");
}

function cloud5_ensure_html_extension(filename) {
  if (!filename) {
    return filename;
  }
  if (/\.html?$/i.test(filename)) {
    return filename;
  }
  return filename + ".html";
}

async function cloud5_get_current_html_text() {
  // Prefer fetch when available (localhost/http/https).
  try {
    const response = await fetch(location.href, { cache: "no-store" });
    if (response && response.ok) {
      return await response.text();
    }
  } catch (e) {
    // Fall through.
  }

  // NW.js / file: fallback: try filesystem.
  try {
    if (typeof fs !== "undefined" && fs?.readFileSync) {
      const pathname = decodeURIComponent(window.location.pathname || "");
      if (pathname) {
        return fs.readFileSync(pathname, "utf8");
      }
    }
  } catch (e) {
    // Fall through.
  }

  // Last resort: DOM serialization (may omit doctype).
  try {
    return document.documentElement?.outerHTML || "";
  } catch (e) {
    return "";
  }
}

const CLOUD5_LAST_SNAPSHOT_TITLE_KEY = "cloud5.last_snapshot_title";
const CLOUD5_SNAPSHOT_DIR_HANDLE_KEY = "cloud5.snapshot_dir_handle";

// Minimal IndexedDB KV store for persisting a FileSystemDirectoryHandle.
function cloud5_idb_open() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("cloud5_kv", 1);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains("kv")) {
        db.createObjectStore("kv");
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function cloud5_idb_get(key) {
  const db = await cloud5_idb_open();
  return new Promise((resolve, reject) => {
    const tx = db.transaction("kv", "readonly");
    const store = tx.objectStore("kv");
    const req = store.get(key);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function cloud5_idb_put(key, value) {
  const db = await cloud5_idb_open();
  return new Promise((resolve, reject) => {
    const tx = db.transaction("kv", "readwrite");
    const store = tx.objectStore("kv");
    const req = store.put(value, key);
    req.onsuccess = () => resolve(true);
    req.onerror = () => reject(req.error);
  });
}

async function cloud5_idb_delete(key) {
  const db = await cloud5_idb_open();
  return new Promise((resolve, reject) => {
    const tx = db.transaction("kv", "readwrite");
    const store = tx.objectStore("kv");
    const req = store.delete(key);
    req.onsuccess = () => resolve(true);
    req.onerror = () => reject(req.error);
  });
}

async function cloud5_try_get_snapshot_dir_handle() {
  try {
    const handle = await cloud5_idb_get(CLOUD5_SNAPSHOT_DIR_HANDLE_KEY);
    if (!handle) {
      return null;
    }
    if (typeof handle.requestPermission === "function") {
      const perm = await handle.requestPermission({ mode: "readwrite" });
      if (perm !== "granted") {
        return null;
      }
    }
    return handle;
  }
  catch (e) {
    console.warn(e);
    return null;
  }
}

async function cloud5_get_or_pick_snapshot_dir_handle() {
  let handle = await cloud5_try_get_snapshot_dir_handle();
  if (handle) {
    return handle;
  }
  if (!window.showDirectoryPicker) {
    throw new Error("File System Access API (showDirectoryPicker) is not available in this browser.");
  }
  handle = await window.showDirectoryPicker({ mode: "readwrite" });
  await cloud5_idb_put(CLOUD5_SNAPSHOT_DIR_HANDLE_KEY, handle);
  return handle;
}

async function cloud5_forget_snapshot_dir_handle() {
  await cloud5_idb_delete(CLOUD5_SNAPSHOT_DIR_HANDLE_KEY);
}

async function cloud5_run_snapshot_dialog(suggested_title) {
  const last_saved_title = localStorage.getItem(CLOUD5_LAST_SNAPSHOT_TITLE_KEY) || "";
  const initial_title = last_saved_title || "snapshot";

  let dialog = document.getElementById("cloud5_snapshot_dialog");
  if (!dialog) {
    dialog = document.createElement("dialog");
    dialog.id = "cloud5_snapshot_dialog";
    dialog.innerHTML = `
      <form method="dialog" style="min-width: 520px;">
        <h3 style="margin: 0 0 10px 0;">Snapshot</h3>

        <div style="margin-bottom: 10px;">
          <label style="display:block; font-weight: 600; margin-bottom: 4px;">Title</label>
          <input id="cloud5_snapshot_title" type="text" style="width: 100%;" />
        </div>

        <div style="margin-bottom: 10px;">
          <label style="display:block; font-weight: 600; margin-bottom: 4px;">Directory</label>
          <div style="display:flex; gap: 8px; align-items:center;">
            <button id="cloud5_snapshot_choose_dir" type="button">Choose…</button>
            <div id="cloud5_snapshot_dir_label" style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></div>
            <button id="cloud5_snapshot_forget_dir" type="button" title="Forget remembered directory">Forget</button>
          </div>
        </div>

        <div style="display:flex; justify-content: flex-end; gap: 10px;">
          <button value="cancel">Cancel</button>
          <button id="cloud5_snapshot_ok" value="ok">OK</button>
        </div>
      </form>
    `;
    document.body.appendChild(dialog);
  }

  const title_input = dialog.querySelector("#cloud5_snapshot_title");
  const dir_label = dialog.querySelector("#cloud5_snapshot_dir_label");
  const choose_dir_btn = dialog.querySelector("#cloud5_snapshot_choose_dir");
  const forget_dir_btn = dialog.querySelector("#cloud5_snapshot_forget_dir");

  title_input.value = last_saved_title ? last_saved_title : initial_title;

  let dir_handle = await cloud5_try_get_snapshot_dir_handle();
  dir_label.textContent = dir_handle ? dir_handle.name : "(none selected)";

  choose_dir_btn.onclick = async () => {
    try {
      dir_handle = await cloud5_get_or_pick_snapshot_dir_handle();
      dir_label.textContent = dir_handle ? dir_handle.name : "(none selected)";
    }
    catch (e) {
      console.warn(e);
      dir_label.textContent = "(directory selection failed)";
    }
  };

  forget_dir_btn.onclick = async () => {
    await cloud5_forget_snapshot_dir_handle();
    dir_handle = null;
    dir_label.textContent = "(none selected)";
  };

  const result = await new Promise((resolve) => {
    dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true });
    dialog.showModal();
  });

  if (result !== "ok") {
    return null;
  }

  const title = title_input.value.trim() || "snapshot";
  if (!dir_handle) {
    // If the user didn't choose a directory in the dialog, try to reuse or prompt now.
    dir_handle = await cloud5_get_or_pick_snapshot_dir_handle();
  }

  return { title, dir_handle };
}


async function cloud5_select_snapshot_target(suggested_title) {
  // One-step modal dialog for title + directory.
  // Returns an object: { title, dir_handle } or null if canceled.
  return await cloud5_run_snapshot_dialog(suggested_title);
}

async function cloud5_write_text_atomic_nwjs(full_path, text) {
  if (typeof fs === "undefined" || !fs?.writeFileSync) {
    throw new Error("fs is not available.");
  }
  const tmp = full_path + ".tmp";
  fs.writeFileSync(tmp, text, "utf8");
  fs.renameSync(tmp, full_path);
}

function cloud5_get_snapshot_dialog() {
  let dlg = document.getElementById("cloud5_snapshot_dialog");
  if (dlg) {
    return dlg;
  }

  dlg = document.createElement("dialog");
  dlg.id = "cloud5_snapshot_dialog";
  dlg.style.maxWidth = "720px";
  dlg.style.width = "92vw";

  dlg.innerHTML = `
    <form method="dialog" style="display:flex;flex-direction:column;gap:12px;">
      <div style="font-weight:600;">Create snapshot</div>

      <label style="display:flex;flex-direction:column;gap:6px;">
        <div>Title</div>
        <input id="cloud5_snapshot_title" type="text" style="width:100%;padding:6px;" />
        <div style="font-size:0.9em;opacity:0.8;">
          Files will be saved as: &lt;title&gt;.html and &lt;title&gt;.state.&lt;.json
        </div>
      </label>

      <div style="display:flex;flex-direction:column;gap:6px;">
        <div>Folder</div>
        <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
          <div id="cloud5_snapshot_folder_label" style="flex:1;min-width:240px;opacity:0.9;"></div>
          <button id="cloud5_snapshot_choose_folder" type="button">Choose…</button>
          <button id="cloud5_snapshot_clear_folder" type="button" title="Forget remembered folder">Forget</button>
        </div>
      </div>

      <div style="display:flex;gap:10px;justify-content:flex-end;margin-top:6px;">
        <button value="cancel">Cancel</button>
        <button id="cloud5_snapshot_save" value="save">Save</button>
      </div>
    </form>
  `;

  document.body.appendChild(dlg);
  return dlg;
}

async function cloud5_snapshot_to_new_version(piece) {
  // Snapshot the current page HTML and Cloud5 state into the chosen directory.
  const suggested_title = document.title || "snapshot";
  const dlg = await cloud5_select_snapshot_target(suggested_title);
  if (!dlg) {
    return;
  }

  const title = dlg.title || suggested_title || "snapshot";
  const dir_handle = dlg.dir_handle;

  const { html_name, state_name } = cloud5_snapshot_filenames_from_title(title);

  // Capture current HTML.
  const html_text = "<!DOCTYPE html>" + document.documentElement.outerHTML;

  // Capture state.
  const state_obj = cloud5_snapshot_fields(piece, piece.fields_to_serialize);
  const save_object = piece?.gui?.getSaveObject();
  if (save_object) {
    state_obj.control_parameters_addon = save_object.remembered.Default[0];
  }
  const state_text = JSON.stringify(state_obj, null, 2);

  // Write files.
  const html_handle = await dir_handle.getFileHandle(html_name, { create: true });
  const state_handle = await dir_handle.getFileHandle(state_name, { create: true });

  {
    const writable = await html_handle.createWritable();
    await writable.write(new Blob([html_text], { type: "text/html" }));
    await writable.close();
  }
  {
    const writable = await state_handle.createWritable();
    await writable.write(new Blob([state_text], { type: "application/json" }));
    await writable.close();
  }

  localStorage.setItem(CLOUD5_LAST_SNAPSHOT_TITLE_KEY, title);

  console.log(`Snapshot saved: ${dir_handle.name}/${html_name} and ${dir_handle.name}/${state_name}`);
}

async function cloud5_save_state_if_needed(piece) {
  if (!cloud5_is_local_context()) {
    return;
  }
  if (!piece.fields_to_serialize || piece.fields_to_serialize.length === 0) {
    return;
  }
  const base = document.title || 'piece';
  const filename = `${base}.state.json`;
  const state_obj = cloud5_snapshot_fields(piece, piece.fields_to_serialize);
  const save_object = piece?.gui?.getSaveObject();
  if (save_object) {
    state_obj.control_parameters_addon = save_object.remembered.Default[0];
  }
  const json_text = JSON.stringify(state_obj, null, 2);
  cloud5_save_data(json_text, filename);
}

async function cloud5_load_state_if_present(piece) {
  if (!cloud5_is_local_context()) {
    return;
  }
  if (!piece?.fields_to_serialize || piece.fields_to_serialize.length === 0) {
    return;
  }
  const filename = cloud5_state_filename_for_piece(document.title || 'piece');
  // Try FileSystem API first (user-selected directory with persisted handle).
  const directory_handle = await cloud5_try_get_snapshot_dir_handle();
  if (directory_handle) {
    try {
      const file_handle = await directory_handle.getFileHandle(filename);
      if (file_handle) {
        const file = await file_handle.getFile();
        const text = await file.text();
        const obj = JSON.parse(text);
        cloud5_restore_fields(piece, obj);
        cloud5_notify_state_restored(piece, obj);
      }
    } catch (e) {
      console.warn(`Failed to load state via FileSystem from directory "${directory_handle.name}":`, e);
    }
  } else {
    // If FileSystem fails, try fetch.
    try {
      const url = filename;
      const url_ = new URL(url, location.href).href; // resolve relative to current page
      const response = await fetch(url_, { cache: 'no-store' });
      if (!response.ok) {
        return;
      }
      const text = await response.text();
      const obj = JSON.parse(text);
      cloud5_restore_fields(piece, obj);
      cloud5_notify_state_restored(piece, obj);
    } catch (e) {
      console.warn(`Failed to load state via fetch from "${filename}": `, e);
    }
  }
  // FIXME: This is a hack to work around a bug where
  // something unknown is duplicating the main menu element.
  const menus = document.querySelectorAll('#main_menu');
  for (let i = 1; i < menus.length; i++) {
    menus[i].remove();
  }
}

/**
 * Base class for Cloud5 overlay-like elements.
 * Currently lightweight, but centralizes a few common conventions:
 * - Optional `data-cloud5-stay-visible` attribute parsed into `cloud5_stay_visible`.
 * - Optional `on_shown()` / `on_hidden()` lifecycle hooks.
 *
 * Subclasses should call `super.connectedCallback()` if they override it.
 */
class Cloud5Element extends HTMLElement {
  #fields_to_serialize = [];
  #cloud5_state_restored = false;

  constructor() {
    super();
    this.cloud5_stay_visible = false;
  }

  set fields_to_serialize(fields) {
    if (!Array.isArray(fields)) {
      this.#fields_to_serialize = [];
    } else {
      this.#fields_to_serialize = fields.slice();
    }
    // Do NOT restore here — overlays may not exist yet.
  }

  get fields_to_serialize() {
    // Lazy, one-shot restore
    if (!this.#cloud5_state_restored) {
      this.#cloud5_state_restored = true;

      if (cloud5_can_persist_state() && this.#fields_to_serialize.length > 0) {
        cloud5_load_state_if_present(this);
      }
    }
    return this.#fields_to_serialize;
  }

  connectedCallback() {

    try { globalThis.cloud5_piece = this; } catch (e) { }
    const attr = this.getAttribute('data-cloud5-stay-visible');
    if (attr !== null) {
      const v = String(attr).toLowerCase();
      this.cloud5_stay_visible =
        (v === '' || v === 'true' || v === '1' || v === 'yes');
    }
  }
  /**
   * Optional lifecycle hook called by the piece when this element is shown.
   * Subclasses may override this method to change behavior when shown.
   */
  on_shown() { }
  /** 
   * Optional lifecycle hook called by the piece when this element is hidden.
   * Subclasses may override this method to change behavior when hidden.
   */
  on_hidden() { }
  /**
   * Optional lifecycle hook called by the piece when it stops its performance. 
   * Subclasses may override this method to change behavior when the piece stops.  
   */
  on_stop() { }
  /**
   * Optional lifecycle hook called by the piece when it is cleared. 
   * Subclasses may override this method to change behavior when the piece is 
   * cleared, such as clearing any performance-related internal state in 
   * preparation for a new performance.
   */
  on_clear() { }
  /**
   * Optional lifecycle hook called by the piece when it generates a new 
   * Score. Subclasses may override this method to add new Events to the 
   * Score, or to modify existing Events.
   */
  on_generate() { }
  /**
   * Optional lifecycle hook called by the piece when it starts its 
   * performance. Subclasses may override this method to change behavior when 
   * the piece starts, such as to schedule real-time events.
   */
  on_play() { }

  /**
   * Optional lifecycle hook called after a persisted state has been restored
   * into this element (or the owning piece).
   *
   * Default behavior: update any DOM elements that declare a
   * `data-cloud5-bind="path"` attribute, where `path` is resolved relative to
   * `this`.
   *
   * Subclasses may override. If you still want the default binding behavior,
   * call `super.on_state_restored(restored_state)`.
   */
  on_state_restored(restored_state) {
    this.cloud5_refresh_dom_from_state();
  }

  /**
   * Applies `data-cloud5-bind` updates for this element.
   */
  cloud5_refresh_dom_from_state(options = {}) {
    cloud5_apply_state_bindings(this, options);
  }

  sync_to_controls() {
    cloud6_apply_state_bindings(this);
  }

  sync_from_controls() { }
}

/**
 * Sets up the piece, and defines menu buttons. The user may assign the DOM 
 * objects of other cloud-5 elements to the `_overlay` properties. 
 */
class Cloud5Piece extends Cloud5Element {
  constructor() {
    super();
    this.csound = null;
    this.csoundac = null;
    this.score = null
    this.is_rendering = false;
    // Default duration and fadeout for rendering to a soundfile.
    // These may be overridden by control parameters, and are somewhat less 
    // than the setTimeout maximum.
    this.duration = 2e6;
    this.fadeout = 6;
    this.safe_tail = 4;
    this.total_duration = this.duration + this.fadeout + this.safe_tail;

    // Performance/UI state.
    this.is_performing = false;
    this.latest_score_time = 0;
    this._midi_perf_start_ms = null;
    this._midi_perf_running = false;
    this._display_raf_id = 0;
    this._display_last_ms = 0;
    this._ui_timer_id = 0;
    this.ui_timer_interval_ms = 250; // 4 Hz UI/log/meter drain independent of RAF.

    // Csound message / UI throttling to avoid main-thread overload that can
    // induce GPU device/context loss.
    this.csound_message_queue = [];
    this.csound_message_queue_limit = 2000;
    this.csound_message_max_per_tick = 200;
    this.log_flush_max_chars = 12000;
    this.log_flush_max_messages = 400;
    this.log_flush_interval_ms = 200;
    this._last_log_flush_ms = 0;
    this.meter_update_interval_ms = 50; // 20 Hz
    this._last_meter_update_ms = 0;
    this._meter_poll_in_flight = false;
    this._last_meter_poll_start_ms = 0;
    this._meter_poll_token = 0;
  }

  on_midi_start() {
    this._midi_perf_start_ms = performance.now();
    this._midi_perf_running = true;
  }

  on_midi_stop() {
    this._midi_perf_running = false;
  }

  #csound_code_addon = null;
  /**
    * May be assigned the text of a Csound .csd patch. If so, the Csound 
    * patch will be compiled and run for every performance.
    */
  set csound_code_addon(code) {
    this.#csound_code_addon = code;
  }
  get csound_code_addon() {
    return this.#csound_code_addon;
  }
  #shader_overlay = null;
  /**
  * May be assigned an instance of a cloud5-shadertoy overlay. If so, 
  * the GLSL shader will run at all times, and will normally create the 
  * background for other overlays. The shader overlay may call 
  * addon functions either to visualize the audio of the performance, 
  * and/or to sample the video canvas to generate notes for performance by 
  * Csound.
  */
  set shader_overlay(shader) {
    this.#shader_overlay = shader;
    // Back reference for shader to access the piece.
    shader.cloud5_piece = this;
    this.show(this.#shader_overlay);
  }
  get shader_overlay() {
    return this.#shader_overlay;
  }
  /**
   * May be assigned the URL of a Web page to implement HTML-based controls 
   * for the performance. This is normally used only if there is a secondary 
   * display to use for these controls, so that the primary display can become 
   * fullscreen. The resulting Web page can obtain a reference to this piece 
   * from its `window.opener`, which is the window that opens the HTML 
   * controls window, and the opener can be used to control all aspects of the 
   * piece.
   * 
   * For this to work the HTML controls and the piece must have the same 
   * origin.
   */
  html_controls_url_addon = null;
  /**
   * Stores a reference to the HTML controls window; this reference will be 
   * used to close the HTML controls window upon leaving fullscreen.
   */
  html_controls_window = null;
  #control_parameters_addon = null;
  /**
    * May be assigned a JavaScript object consisting of Csound control 
    * parameters, with default values. The naming convention must be global 
    * Csound variable type, underscore{ , Csound instrument name}, 
    * underscore, Csound control channel name. For example:
    * 
    * control_parameters_addon = {
    *  "gk_Duration_factor": 0.8682696259761612,
    *  "gk_Iterations": 4,
    *  "gk_MasterOutput_level": -2.383888203863542,
    *  "gk_Shimmer_wetDry": 0.06843403205918619
    * };
    *
    * The Csound orchestra should define matching control channels. Such 
    * parameters may also be used to control other processes.
    *   saveAs: function saveAs(presetName) {
    if (!this.load.remembered) {
      this.load.remembered = {};
      this.load.remembered[DEFAULT_DEFAULT_PRESET_NAME] = getCurrentPreset(this, true);
    }
    this.load.remembered[presetName] = getCurrentPreset(this);
    this.preset = presetName;
    addPresetOption(this, presetName, true);
    this.saveToLocalStorageIfPossible();
  },

    */
  set control_parameters_addon(parameters) {
      if (!this.#control_parameters_addon) {
          this.#control_parameters_addon = parameters;
      } else if (parameters && parameters !== this.#control_parameters_addon) {
          deep_merge_into(this.#control_parameters_addon, parameters, {
              allow_new_keys: false,
              ignore_null: true
          });
      }
      this.create_dat_gui_menu();
  }
  get control_parameters_addon() {
    return this.#control_parameters_addon;
  }
  #score_generator_function_addon = null;
  /**
   * May be assigned a score generating function. If so, the score generator 
   * will be called for each performance, and must generate and return a 
   * CsoundAC Score, which will be translated to a Csound score in text 
   * format, appended to the Csound patch, displayed in the piano roll 
   * overlay, and played or rendered by Csound.
   */
  set score_generator_function_addon(score_generator_function) {
    this.#score_generator_function_addon = score_generator_function;
  }
  get score_generator_function_addon() {
    return this.#score_generator_function_addon;
  }

  #piano_roll_overlay = null;
  /**
   * May be assigned the DOM object of a <cloud5-piano-roll> element overlay. 
   * If so, the Score button will show or hide an animated, zoomable piano 
   * roll display of the generated CsoundAC Score.
   */
  set piano_roll_overlay(piano_roll) {
    this.#piano_roll_overlay = piano_roll;
    if (this.#piano_roll_overlay) {
      this.#piano_roll_overlay.cloud5_piece = this;
    }
  }
  get piano_roll_overlay() {
    return this.#piano_roll_overlay;
  }

  /**
   * May be assigned the DOM object of a <cloud5-log> element overlay. If so, 
   * the Log button will show or hide a scrolling view of messages from Csound or 
   * other sources.
   */
  #log_overlay = null;
  set log_overlay(overlay) {
    this.#log_overlay = overlay;
    if (this.#log_overlay) {
      this.#log_overlay.cloud5_piece = this;
    }
  }
  get log_overlay() {
    return this.#log_overlay;
  }

  /**
   * May be assigned the DOM object of a <cloud5-about> element overlay. If 
   * so, the About button will show or hide the overlay. The inner HTML of 
   * this element may contain license information, authorship, credits, 
   * program notes for the piece, or other information.
   */
  #about_overlay = null;
  set about_overlay(overlay) {
    this.#about_overlay = overlay;
  }
  get about_overlay() {
    return this.#about_overlay;
  }

  /**
   * May be assigned the DOM object of a <cloud5-strudel> element overlay. If 
   * so, the Strudel button will show or hide the Strudel REPL.
   */
  #strudel_overlay = null;
  set strudel_overlay(overlay) {
    this.#strudel_overlay = overlay;
    if (this.#strudel_overlay) {
      this.#strudel_overlay.cloud5_piece = this;
    }
  }
  get strudel_overlay() {
    return this.#strudel_overlay;
  }
  /**
   * Called on a timer as long as the piece exists.
   */
  update_display = async () => {
    // Refresh metering/time and drive any overlays that follow score position.
    try {
      this.process_csnd_messages_and_meters(performance.now());
    } catch (e) {
      console.warn(e);
    }

    // Update piano-roll progress (red ball) if present.
    try {
      this?.piano_roll_overlay?.show_score_time?.();
    } catch (e) {
      console.warn(e);
    }

    const t = this.latest_score_time;
    if (typeof t === 'number' && isFinite(t)) {
      // Total duration policy:
      // - During Csound performance: use ONLY the CsoundAC score duration.
      // - During MIDI playback: use ONLY the Silencio score duration, or (if absent)
      //   a derived duration from the MIDI scheduler.
      const is_good_total = (v) => (typeof v === 'number' && isFinite(v) && v > 0);

      let total = 0;

      // MIDI playback path.
      if (typeof __midi !== 'undefined' && __midi?.playing) {
        const pdur = this.piano_roll_overlay?.silencio_score?.getDuration?.();
        if (is_good_total(pdur)) {
          total = pdur;
        } else if (is_good_total(__midi?.totalBeats) && is_good_total(__midi?.bpm)) {
          // __midi.totalBeats is in beats; convert to seconds using the playback bpm.
          total = (msFromBeats(__midi.totalBeats, __midi.bpm) / 1000.0);
        }
      } else {
        // Csound performance path.
        const sdur = this.score?.getDuration?.();
        if (is_good_total(sdur)) {
          total = sdur;
        }
      }

      for (const overlay of this._get_all_overlays()) {
        try {
          overlay?.on_score_time?.(t, total || 0);
        } catch (e) {
        }
      }
    }
  };

  _is_overlay_visible(overlay) {
    if (!overlay) return false;

    // Treat checkVisibility() as advisory: only short-circuit on true.
    try {
      if (typeof overlay.checkVisibility === 'function') {
        if (overlay.checkVisibility()) {
          return true;
        }
        // Fall through to style-based checks if it says "not visible".
      }
    } catch (e) { }

    try {
      const style = getComputedStyle(overlay);
      if (style.display === 'none' || style.visibility === 'hidden') {
        return false;
      }
      return true;
    } catch (e) {
      return false;
    }
  }

  _any_score_time_overlay_visible() {
    for (const overlay of this._get_all_overlays()) {
      if (!overlay) continue;
      if (typeof overlay.on_score_time !== 'function') continue;
      if (this._is_overlay_visible(overlay)) return true;
    }
    // Piano roll follows score time even if it does not implement on_score_time.
    if (this.piano_roll_overlay && this._is_overlay_visible(this.piano_roll_overlay)) return true;
    return false;
  }

  _reset_score_time_followers() {
    try { this.latest_score_time = 0; } catch (e) { }
    try {
      this?.piano_roll_overlay?.update_score_time?.(0);
      this?.piano_roll_overlay?.show_score_time?.();
    } catch (e) { }
    try {
      // Notify overlays that follow score time (e.g. ROI playheads).
      for (const overlay of this._get_all_overlays()) {
        overlay?.on_score_time?.(0, this.total_duration ?? this.duration ?? 0);
      }
    } catch (e) { }
  }

  _start_display_loop() {
    if (this._display_raf_id) return;
    const tick = async (now_ms) => {
      // Throttle to reduce overhead while still smooth enough for playheads.
      if (!this._display_last_ms) this._display_last_ms = now_ms;
      const elapsed_ms = now_ms - this._display_last_ms;

      const should_update = this.is_performing && this._any_score_time_overlay_visible();
      if (should_update && elapsed_ms >= 100) { // ~10 Hz
        this._display_last_ms = now_ms;
        try { await this.update_display(); } catch (e) { }
      }
      this._display_raf_id = requestAnimationFrame(tick);
    };
    this._display_raf_id = requestAnimationFrame(tick);
  }

  _stop_display_loop() {
    if (!this._display_raf_id) return;
    try { cancelAnimationFrame(this._display_raf_id); } catch (e) { }
    this._display_raf_id = 0;
  }

  /**
     * Called by Csound during performance, and prints the message to the 
     * scrolling text area of a <csound5-log> element overlay. This function may 
     * also be called by user code.
     * 
     * @param {string} message 
     */
  csound_message_callback = async (message) => {
    // Keep this callback extremely cheap. Csound can emit messages at a high
    // rate during performance; doing awaits/DOM writes per message can starve
    // the main thread and trigger GPU device/context loss.
    if (!message) {
      return;
    }
    if (!this.csound_message_queue) {
      this.csound_message_queue = [];
    }
    if (this.csound_message_queue.length >= (this.csound_message_queue_limit || 2000)) {
      // Drop oldest to cap memory and prevent feedback loops.
      this.csound_message_queue.shift();
    }
    this.csound_message_queue.push(message);
  };

  /**
   * Drain queued Csound messages and update metering/time at a bounded rate.
   * Call this from update_display (RAF) rather than doing expensive work from
   * the realtime Csound message callback.
   *
   * @param {number} now_ms performance.now()
   */
  process_csnd_messages_and_meters = async (now_ms) => {
    // 1) Flush queued messages at a controlled cadence (default: 2 Hz).
    // This keeps the log responsive without forcing an Ace reflow per message.
    this.maybe_flush_log_queue(now_ms);

    // 2) Meter/time polling must not block RAF. If it is time, kick off an
    // async poll but never await it here.
    this.maybe_start_meter_poll(now_ms);
  };

  maybe_flush_log_queue = (now_ms) => {
    const q = this.csound_message_queue || [];
    if (!q.length || !this.log_overlay) {
      return;
    }
    const interval = this.log_flush_interval_ms || 500;
    const last = this._last_log_flush_ms || 0;
    if ((now_ms - last) < interval) {
      return;
    }
    this._last_log_flush_ms = now_ms;
    const max_messages = this.log_flush_max_messages || 400;
    const max_chars = this.log_flush_max_chars || 12000;
    let chunk = "";
    for (let i = 0; i < max_messages && q.length > 0; i++) {
      const msg = q.shift();
      if (!msg) {
        continue;
      }
      const line = msg.endsWith("\n") ? msg : (msg + "\n");
      if ((chunk.length + line.length) > max_chars) {
        // If we have at least one line, flush now; otherwise take the line anyway.
        if (chunk.length > 0) {
          break;
        }
      }
      chunk += line;
      if (chunk.length >= max_chars) {
        break;
      }
    }
    if (chunk) {
      try {
        this.log_overlay.log(chunk);
      } catch (e) {
      }
    }
  };

  maybe_start_meter_poll = (now_ms) => {
    if (!globalThis.csound) {
      return;
    }
    const interval = this.meter_update_interval_ms || 50;
    const last = this._last_meter_update_ms || 0;
    if ((now_ms - last) < interval) {
      return;
    }

    // Watchdog: if a prior poll wedged, allow recovery.
    if (this._meter_poll_in_flight) {
      const started = this._last_meter_poll_start_ms || 0;
      if ((now_ms - started) > 2000) {
        console.warn("meter poll watchdog: releasing wedged poll");
        this._meter_poll_in_flight = false;
      } else {
        return;
      }
    }

    this._last_meter_update_ms = now_ms;
    this._meter_poll_in_flight = true;
    this._last_meter_poll_start_ms = now_ms;

    // Token prevents late completions from writing stale UI.
    const token = (this._meter_poll_token = (this._meter_poll_token || 0) + 1);

    // Fire-and-forget; completion updates UI.
    this.poll_meters_and_update_ui(token).finally(() => {
      // Only clear if this is still the most recent poll.
      if (this._meter_poll_token === token) {
        this._meter_poll_in_flight = false;
      }
    });
  };

  poll_meters_and_update_ui = async (token) => {
    let level_left = -100;
    let level_right = -100;

    let score_time = 0;
    if (this._midi_perf_running && typeof this._midi_perf_start_ms === "number") {
      score_time = (performance.now() - this._midi_perf_start_ms) / 1000.0;
    }
    else {
      score_time = await csound.getScoreTime();
    }
    this.latest_score_time = score_time;

    level_left = await csound.getControlChannel("gk_MasterOutput_output_level_left");
    level_right = await csound.getControlChannel("gk_MasterOutput_output_level_right");

    let delta = score_time;

    // calculate (and subtract) whole days
    let days = Math.floor(delta / 86400);
    delta -= days * 86400;
    // calculate (and subtract) whole hours
    let hours = Math.floor(delta / 3600) % 24;
    delta -= hours * 3600;
    // calculate (and subtract) whole minutes
    let minutes = Math.floor(delta / 60) % 60;
    delta -= minutes * 60;
    // what's left is seconds
    let seconds = delta % 60;


    // If a newer poll has started, ignore these results.
    if ((this._meter_poll_token || 0) !== token) {
      return;
    }

    if (level_left > 0) {
      $("#vu_meter_left").css("color", "red");
    } else if (level_left > -12) {
      $("#vu_meter_left").css("color", "orange");
    } else {
      $("#vu_meter_left").css("color", "lightgreen");
    }
    if (level_right > 0) {
      $("#vu_meter_right").css("color", "red");
    } else if (level_right > -12) {
      $("#vu_meter_right").css("color", "orange");
    } else {
      $("#vu_meter_right").css("color", "lightgreen");
    }

    $("#mini_console").html(sprintf("d:%4d h:%02d m:%02d s:%06.3f", days, hours, minutes, seconds));
    $("#vu_meter_left").html(sprintf("L%+7.1f dBA", level_left));
    $("#vu_meter_right").html(sprintf("R%+7.1f dBA", level_right));

    try {
      this?.piano_roll_overlay?.update_score_time?.(score_time);
    } catch (e) {
    }
  };


  /**
   * A convenience function for printing the message in the 
   * scrolling <csound5-log> element overlay.
   * @param {string} message 
   */
  log(message) {
    this.csound_message_callback(message);
  }
  /**
    * Metadata to be written to output files. The user may assign 
    * values to any of these fields.
    */
  metadata = {
    "artist": null,
    "copyright": null,
    "performer": null,
    "title": null,
    "album": null,
    "track": null,
    "tracknumber": null,
    "date": null,
    "publisher": null,
    "comment": null,
    "license": null,
    "genre": null,
  };
  connectedCallback() {

    const filename = document.location.pathname.split("/").pop();

    this.innerHTML = `
    <div class="w3-bar cloud5-menu" id="main_menu">
      <ul class="menu" id="main_menu_list">
        <li id="menu_item_play"
            title="Play piece on audio output"
            class="w3-btn w3-hover-text-light-green">Play</li>

        <li id="menu_item_render"
            title="Render piece to soundfile then play on audio output"
            class="w3-btn w3-hover-text-light-green">Render</li>

        <li id="menu_item_stop"
            title="Stop performance"
            class="w3-btn w3-hover-text-light-green">Stop</li>

        <li id="menu_item_fullscreen"
            class="w3-btn w3-hover-text-light-green">Fullscreen</li>

        <!-- Built-in overlay items (if their overlays exist) will reuse these.
             Generic overlays will get new <li> items injected before About. -->
        <li id="menu_item_strudel"
            class="w3-btn w3-hover-text-light-green"
            style="display:none;">Strudel</li>

        <li id="menu_item_piano_roll"
            title="Show/hide piano roll score"
            class="w3-btn w3-hover-text-light-green"
            style="display:none;">Score</li>

        <li id="menu_item_log"
            title="Show/hide message log"
            class="w3-btn w3-hover-text-light-green">Log</li>

        <li id="menu_item_snapshot"
            title="Create a new version snapshot (HTML + state) in a chosen directory"
            class="w3-btn w3-hover-text-light-green"
            style="display:none;">Snapshot...</li>

        <li id="menu_item_about"
            title="Show/hide information about this piece"
            class="w3-btn w3-hover-text-light-green">About ${filename}</li>

        <li id="mini_console"
            class="w3-btn w3-text-green w3-hover-text-light-green"></li>

        <li id="vu_meter_left"
            class="w3-btn w3-hover-text-light-green"></li>

        <li id="vu_meter_right"
            class="w3-btn w3-hover-text-light-green"></li>

        <li id="menu_item_dat_gui"
            title="Show/hide performance controls; 'Save' copies all control parameters to system clipboard"
            class="w3-btn w3-left-align w3-hover-text-light-green w3-right"></li>
      </ul>
    </div>`;

    // Cache status elements
    this.vu_meter_left = this.querySelector("#vu_meter_left");
    this.vu_meter_right = this.querySelector("#vu_meter_right");
    this.mini_console = this.querySelector("#mini_console");

    // Transport buttons
    const menu_item_play = this.querySelector('#menu_item_play');
    menu_item_play.onclick = (event) => {
      console.info("menu_item_play click...");
      this.cancel_scheduled_stop();
      // Show shader background if available
      if (this.shader_overlay) {
        this.show(this.shader_overlay);
      }
      // Hide overlays that should not be visible while playing
      this.hide(this.piano_roll_overlay);
      this.hide(this.log_overlay);
      this.hide(this.about_overlay);
      this.hide(this.strudel_overlay);
      // Start performance
      (() => this.render(1))();
    };

    const menu_item_render = this.querySelector('#menu_item_render');
    menu_item_render.onclick = (event) => {
      console.info("menu_item_render click...");
      this.cancel_scheduled_stop();
      this.show(this.piano_roll_overlay);
      this.hide(this.strudel_overlay);
      this.hide(this.log_overlay);
      this.hide(this.about_overlay);

      let duration;
      let fadeout;
      if (this.#control_parameters_addon) {
        duration = this.#control_parameters_addon.gi_cloud5_duration;
        if (this.#control_parameters_addon.gi_cloud5_fadeout) {
          fadeout = this.#control_parameters_addon.gi_cloud5_fadeout;
        }
      }
      if (duration) {
        this.duration = duration;
      }
      if (fadeout) {
        this.fadeout = fadeout;
      }
      this.total_duration = this.duration + this.fadeout + this.safe_tail;
      this?.csound_message_callback(
        `Duration: ${this.duration} fadeout: ${this.fadeout}\n`
      );
      this?.csound_message_callback(
        `Rendering will be stopped ${this.total_duration} seconds after starting...\n`
      );
      (() => this.render(4))();
    };

    const menu_item_stop = this.querySelector('#menu_item_stop');
    menu_item_stop.onclick = (event) => {
      console.info("menu_item_stop click...");
      this.csound?.setControlChannel("gk_cloud5_performance_mode", 0);
      this.stop();
      if (this.is_rendering) {
        const soundfile_url = url_for_soundfile(this.csound);
        this.is_rendering = false;
        this.cancel_scheduled_stop();
        // Optionally, do something with soundfile_url.
      }
    };

    const menu_item_fullscreen = this.querySelector('#menu_item_fullscreen');
    menu_item_fullscreen.onclick = async (event) => {
      console.info("menu_item_fullscreen click...");
      try {
        if (this.#shader_overlay?.canvas?.requestFullscreen) {
          let new_window = null;
          // Make the shader canvas fullscreen in the primary window.
          await this.#shader_overlay.canvas.requestFullscreen();
        }
      } catch (e) {
        console.warn("Fullscreen failed:", e);
      }
    };

    // Snapshot... (version export)
    const menu_item_snapshot = this.querySelector("#menu_item_snapshot");
    if (menu_item_snapshot) {
      // Visibility depends on whether any state is serializable.
      const ok = Array.isArray(this.fields_to_serialize) && this.fields_to_serialize.length > 0;
      menu_item_snapshot.style.display = ok ? "inline" : "none";
      menu_item_snapshot.onclick = async (event) => {
        await cloud5_snapshot_to_new_version(this);
        // Re-evaluate visibility (fields may have been populated later).
        const ok2 = Array.isArray(this.fields_to_serialize) && this.fields_to_serialize.length > 0;
        menu_item_snapshot.style.display = ok2 ? "inline" : "none";
      };
    }

    // After base menu is in place, interrogate the DOM and wire overlays,
    // but do it *after* the whole document has been parsed so that
    // overlays declared later (like <mandelbrot-julia>) exist.
    const wireOverlays = () => this.init_overlays_from_dom();

    if (document.readyState === 'loading') {
      window.addEventListener('DOMContentLoaded', wireOverlays, { once: true });
    } else {
      wireOverlays();
      // Lightweight UI update loop; does work only while performing and when needed.
      this._start_display_loop();
    }
    // queueMicrotask(() => {
    //   cloud5_load_state_if_present(this);
    // });
  }

  _get_all_overlays() {
    const s = new Set(this._registered_overlays || []);
    if (this.piano_roll_overlay) s.add(this.piano_roll_overlay);
    if (this.log_overlay) s.add(this.log_overlay);
    if (this.about_overlay) s.add(this.about_overlay);
    if (this.strudel_overlay) s.add(this.strudel_overlay);
    return [...s];
  }

  /**
   * Scans the DOM for overlays belonging to this piece and ensures each has
   * a menu item that toggles its visibility. Built-in overlays reuse existing
   * menu items; generic overlays get new <li> entries inserted before About.
   */
  init_overlays_from_dom() {
    const menu_list = document.querySelector('#main_menu_list');
    if (!menu_list) {
      console.warn("Cloud5Piece.init_overlays_from_dom: no #main_menu_list found.");
      return;
    }

    const menu_item_about = document.querySelector('#menu_item_about');
    const mini_console = document.querySelector('#mini_console');

    const overlays = [];

    // --- Built-in overlays --------------------------------------------------
    const piano_roll = document.querySelector('cloud5-piano-roll');
    if (piano_roll) {
      this.piano_roll_overlay = piano_roll;     // setter already sets cloud5_piece
      overlays.push({
        element: piano_roll,
        existingMenuId: 'menu_item_piano_roll',
        label: 'Score',
        stayVisible: this._read_stay_visible_flag(piano_roll)
      });
    }

    const log = document.querySelector('cloud5-log');
    if (log) {
      this.log_overlay = log;                   // setter already sets cloud5_piece
      overlays.push({
        element: log,
        existingMenuId: 'menu_item_log',
        label: 'Log',
        stayVisible: this._read_stay_visible_flag(log)
      });
    }

    const about = document.querySelector('cloud5-about');
    if (about) {
      this.about_overlay = about;
      const filename = document.location.pathname.split("/").pop();
      overlays.push({
        element: about,
        existingMenuId: 'menu_item_about',
        label: about.getAttribute('data-cloud5-label') || `About ${filename}`,
        stayVisible: this._read_stay_visible_flag(about)
      });
    }

    const strudel = document.querySelector('cloud5-strudel');
    if (strudel) {
      this.strudel_overlay = strudel;           // setter already sets cloud5_piece
      overlays.push({
        element: strudel,
        existingMenuId: 'menu_item_strudel',
        label: 'Strudel',
        stayVisible: this._read_stay_visible_flag(strudel)
      });
    }

    // --- Generic overlays ---------------------------------------------------
    const generic_candidates = Array.from(
      document.querySelectorAll('[data-cloud5-overlay], .cloud5-overlay')
    );

    const built_in_set = new Set(
      [piano_roll, log, about, strudel].filter(Boolean)
    );

    generic_candidates
      .filter(el => !built_in_set.has(el))
      // TODO: Undo this?
      .filter(el => !el.closest('#main_menu'))   // prevents menu DOM becoming overlays
      .forEach(el => {
        const label =
          el.getAttribute('data-cloud5-label') ||
          el.getAttribute('data-overlay-label') ||
          el.getAttribute('title') ||
          el.id ||
          el.tagName.toLowerCase();

        const isDefault =
          el.hasAttribute('data-cloud5-default-visible') ||
          el.hasAttribute('data-cloud5-default-overlay');

        overlays.push({
          element: el,
          existingMenuId: null,
          label,
          isDefault
        });
      });

    // --- Create / wire menu items ------------------------------------------
    const registered = [];

    overlays.forEach(cfg => {
      const overlay = cfg.element;
      if (!overlay) return;

      // Back-reference for ALL overlays (built-in + generic).
      overlay.cloud5_piece = this;

      // Remember whether this overlay wants to stay visible when others toggle.
      overlay._cloud5_stay_visible = !!cfg.stayVisible;

      // Start hidden by default; menu click will toggle.
      this.hide(overlay);

      let li = null;
      if (cfg.existingMenuId) {
        li = document.querySelector(`#${cfg.existingMenuId}`);
      }

      if (!li) {
        li = document.createElement('li');
        li.className = 'w3-btn w3-hover-text-light-green';
        li.textContent = cfg.label;

        // Insert new button before About (if present), otherwise before mini_console,
        // otherwise appended at end.
        if (menu_item_about && menu_item_about.parentNode === menu_list) {
          menu_list.insertBefore(li, menu_item_about);
        } else if (mini_console && mini_console.parentNode === menu_list) {
          menu_list.insertBefore(li, mini_console);
        } else {
          menu_list.appendChild(li);
        }
      } else {
        // Reuse existing button but ensure it has a sensible label.
        if (!li.textContent.trim()) {
          li.textContent = cfg.label;
        }
      }

      li.style.display = 'inline';
      li.dataset.cloud5Overlay = 'true';

      li.addEventListener('click', () => {
        this._toggleOverlayExclusive(overlay);
      });

      registered.push(overlay);
    });

    this._registered_overlays = registered;
    // Now that overlay references exist, restore state once.
    cloud5_load_state_if_present(this);

    // Show whichever overlay is marked as default, if any.
    const defaultCfg = overlays.find(o => o.isDefault && o.element);
    if (defaultCfg) {
      this.show(defaultCfg.element);
    }
  }

  /**
   * Reads the "stay visible" flag from overlay attributes.
   * Recognized:
   *   data-cloud5-stay-visible="true" | "1"
   *   data-overlay-stay-visible="true" | "1"
   *   visibility="keep"
   */
  _read_stay_visible_flag(el) {
    if (!el || !el.getAttribute) return false;
    const raw =
      el.getAttribute('data-cloud5-stay-visible') ??
      el.getAttribute('data-overlay-stay-visible') ??
      el.getAttribute('visibility');

    if (!raw) return false;
    const v = String(raw).toLowerCase().trim();
    return (v === 'true' || v === '1' || v === 'keep');
  }


  /**
   * Toggles one overlay and hides all others that we know about.
   * Overlays marked with _cloud5_stay_visible (via attributes) are not hidden.
   * Does NOT touch the shader overlay (which can remain as background).
   */
  _toggleOverlayExclusive(target) {
    if (!target) return;

    const all = new Set(this._registered_overlays || []);
    if (this.piano_roll_overlay) all.add(this.piano_roll_overlay);
    if (this.log_overlay) all.add(this.log_overlay);
    if (this.about_overlay) all.add(this.about_overlay);
    if (this.strudel_overlay) all.add(this.strudel_overlay);

    all.forEach(overlay => {
      if (!overlay) return;

      const stayVisible = !!overlay._cloud5_stay_visible;

      if (overlay === target) {
        // Always toggle the requested overlay.
        this.toggle(overlay);
      } else if (!stayVisible) {
        // Only hide overlays that are not marked as "stay visible".
        this.hide(overlay);
      }
      // If stayVisible === true and overlay !== target, we leave it as-is.
    });
  }

  /**
    * Copies all _current_ dat.gui parameters to the system clipboard in 
    * JSON format.
    * 
    * @param {Object} parameters A dictionary containing the current state of all 
    * controls; keys are control parameter names, values are control parameter 
    * values. This can be pasted from the clipboard into source code, as a 
    * convenient method of updating a piece with parameters that have been tweaked 
    * during performance.
    */
  copy_parameters() {
    let copied_parameters = JSON.parse(JSON.stringify(this?.control_parameters_addon))
    delete copied_parameters?.load;
    const json_text = JSON.stringify(copied_parameters, null, 4);
    navigator.clipboard.writeText(json_text);
    if (this.csound) {
      this.csound.Message("Copied all control parameters to system clipboard.\n");
    }
  }

  stop_and_download = async () => {
    await this.stop();
    if (this.is_rendering) {
      const soundfile_name = await url_for_soundfile(this.csound);
      this.is_rendering = false;
      this.csound_message_callback(`Rendering has stopped; automatically downloaded ${soundfile_name}\n`);
    }
  };

  schedule_stop_after(seconds) {
    this.csound_message_callback(`Scheduling stop ${seconds} seconds from now...\n`);
    const milliseconds = seconds * 1000;
    const deadline = performance.now() + milliseconds;
    this._stop_timer = setTimeout(() => {
      const remaining = Math.ceil(deadline - performance.now());
      if (remaining > 0) {
        this.csound_message_callback(
          `Timed out early, rescheduling in ${(remaining / 1000).toFixed(3)} seconds...\n`
        );
        this._stop_timer = setTimeout(() => this.stop_and_download(), remaining);
        return; // <-- critical: do not fall through to the immediate stop
      }
      this.stop_and_download();
    }, milliseconds);
  }

  cancel_scheduled_stop() {
    if (this._stop_timer) {
      clearTimeout(this._stop_timer);
      this.csound_message_callback(`Canceled scheduled stop.\n`);
    }
    this._stop_timer = null;
  }

  /**
   * @function render
   * 
   * @memberof Cloud5Piece
   * 
   * @description Invokes Csound and/or Strudel to perform music, by default 
   * to the audio output interface, but optionally also to a local soundfile. 
   * Acts as an async member function because it is bound to this.
   * 
   * @param {Number}  gk_cloud5_performance_mode
   *
   * Possible values of gk_cloud5_performance_mode, which is sent as a control
   * channel value to Csound before performance:
   * 1. Start the Csound performance to audio out, and continue indefinitely
   *    until the user stops it manually (default).
   * 2. Start recording the audio output to the output soundfile.
   * 3. Pause recording, and automatically download the output soundfile.
   * 4. Start the Csound performance to the audio output for a fixed duration 
   *    with a fadeout, and also record the audio output to the output soundfile;
   *    when the performance has finished or is stopped, automatically download
   *    the output soundfile.
   */
  render = async function (gk_cloud5_performance_mode) {
    this.log(`render(${gk_cloud5_performance_mode})...\n`);
    this.csound = await get_csound(this.csound_message_callback);
    this.csoundac = await get_csound_ac();
    if (gk_cloud5_performance_mode == 2 || gk_cloud5_performance_mode == 4) {
      this.is_rendering = true;
    }
    if (non_csound(this.csound)) return;
    // Stop any current performance first.
    await this.stop();
    for (const overlay of this._get_all_overlays()) {
      overlay?.on_stop();
    }
    this._reset_score_time_followers();
    // Clear performance-related state from all components.
    await this?.log_overlay?.clear?.();
    for (const overlay of this._get_all_overlays()) {
      overlay?.on_clear();
    }

    // Reset score-following UI for a fresh performance.
    this.is_performing = false;
    this._display_last_ms = 0;
    this._reset_score_time_followers();
    for (const key in this.metadata) {
      const value = this.metadata[key];
      if (value !== null) {
        // CsoundAudioNode does not have the metadata facility,
        // csound.nwjs does have it.
        if (this.csound.setMetadata) {
          this.csound?.setMetadata(key, value);
        }
      }
    }
    let csd = this.csound_code_addon.slice();
    if (this.score_generator_function_addon) {
      this.score = await this.score_generator_function_addon();
    } else {
      this.score = new globalThis.csound_ac.Score();
    }
    if (this.score) {
      // Generate score from all components.
      for (const overlay of this._get_all_overlays()) {
        await overlay?.on_generate(this.score);
      }
      if (this.piano_roll_overlay && this.piano_roll_overlay.silencio_score) {
        this?.piano_roll_overlay?.draw_csoundac_score(this.score);
        this?.piano_roll_overlay?.on_shown?.()
        this?.piano_roll_overlay?.show_score_time();
      }
      let csound_score = await this.score.getCsoundScore(12., false);
      csound_score = csound_score.concat("\n</CsScore>");
      csd = this.csound_code_addon.replace("</CsScore>", csound_score);
    }
    const output_soundfile_name = document.title + ".wav";
    const orc_globals = `
<CsInstruments>

; The following global variables were defined by cloud-5 and are available to 
; use in the rest of the orchestra for controlling the performance mode, 
; duration, and fadeout time of the piece.

gi_cloud5_performance_mode init ${gk_cloud5_performance_mode}
gi_cloud5_duration init ${this.duration}
gi_cloud5_fadeout init ${this.fadeout}
gS_cloud5_soundfile_name init "${output_soundfile_name}"

`
    csd = csd.replace("<CsInstruments>", orc_globals);
    // Save the .csd file so we can debug a failing orchestra,
    // instead of it just nullifying Csound.        
    const csd_filename = document.title + '-generated.csd';
    write_file(csd_filename, csd);
    try {
      let result = await this.csound.compileCsdText(csd);
      this.csound_message_callback("CompileCsdText returned: " + result + "\n");
    } catch (e) {
      alert(e);
    }
    /// await cloud5_save_state_if_needed(this);
    await this.csound.start();

    this.is_performing = true;
    this._start_display_loop();
    this._start_ui_timer();
    try { await this.update_display(); } catch (e) { }
    if (gk_cloud5_performance_mode == 4) {
      this.csound_message_callback("Csound has started rendering to " + output_soundfile_name + "...\n");
      this.schedule_stop_after(this.total_duration);
    } else {
      this.csound_message_callback("Csound is not rendering a soundfile.\n")
    }
    this.csound_message_callback("Csound has started...\n");
    // Send _current_ dat.gui parameter values to Csound 
    // before actually performing.
    this.send_parameters(this.control_parameters_addon);

    // Also save the generated .csd file again, this time with current control 
    // parameter values.
    const csd_filename_parameters = cloud5_generated_csd_filename_from_title(document.title);
    // Replace all values defined in global control channel init statements with the 
    // values defined in the control parameters addon.
    csd = this.update_parameters_in_csd(csd, this.control_parameters_addon);
    write_file(csd_filename_parameters, csd);
    await cloud5_write_text_to_snapshot_dir_if_available(csd_filename_parameters, csd, "application/x-csound");

    // Start performance in all components.
    if (!(this?.csound.getNode)) {
      this.csound.perform();
      for (const overlay of this._get_all_overlays()) {
        overlay?.on_play();
      }
    }
    if (typeof strudel_view !== 'undefined') {
      if (strudel_view !== null) {
        console.info("strudel_view:", this.strudel_view);
        strudel_view?.setCsound(this.csound);
        strudel_view?.setCsoundAC(this.csoundac);
        strudel_view?.setParameters(this.control_parameters_addon);
        strudel_view?.startPlaying();
      }
    }
    this?.csound_message_callback("Csound is playing...\n");
  }
  /**
   * Stops both Csound and Strudel from performing.
   */
  stop = async function () {
    this.csound_message_callback("cloud-5 is stopping...\n");
    this.is_performing = false;
    // Stop performance in all components.
    await this.csound.stop();
    await this.csound.cleanup();
    this.csound.reset();
    this.strudel_overlay?.stop();
    for (const overlay of this._get_all_overlays()) {
      overlay?.on_stop();
    }
    /// await cloud5_save_state_if_needed(this);
    this._stop_display_loop();
    this._stop_ui_timer();
    this.csound_message_callback("cloud-5 has stopped.\n");
  };

  /**
   * Helper function to show custom element overlays. Resizes overlay 
   * if required to fit layout.
   * 
   * @param {Object} overlay 
   */
  show(overlay) {
    if (!overlay) return;

    // Force visibility even if a stylesheet uses display:none !important.
    overlay.style.setProperty('display', 'block', 'important');

    const is_built_in_overlay =
      ['CLOUD5-LOG', 'CLOUD5-PIANO-ROLL', 'CLOUD5-ABOUT'].includes(overlay.tagName);

    const is_generic_overlay =
      overlay.classList && overlay.classList.contains('cloud5-overlay');

    if (is_built_in_overlay || is_generic_overlay) {
      // Use the menu bar (or its list) to determine bottom edge.
      let menu_bar = document.getElementById('main_menu');
      if (!menu_bar) {
        menu_bar = document.getElementById('main_menu_list');
      }

      let menu_bar_bottom = 0;
      if (menu_bar) {
        const rect = menu_bar.getBoundingClientRect();
        menu_bar_bottom = rect.bottom;
      }

      // Pin the overlay to fill the viewport below the menu.
      overlay.style.position = 'fixed';
      overlay.style.left = '0';
      overlay.style.right = '0';
      overlay.style.top = `${menu_bar_bottom}px`;
      overlay.style.height = `calc(100% - ${menu_bar_bottom}px)`;
      overlay.style.width = '100vw';

      // Ensure generic overlays sit above the shader but below the menu.
      if (is_generic_overlay) {
        // Only override if author CSS hasn't already set an explicit z-index.
        if (!overlay.style.zIndex) {
          overlay.style.zIndex = '1300'; // between log/score/strudel and menu
        }
      }
    }

    // If the overlay has its own hook, let it know it's now visible.
    if (typeof overlay.on_shown === 'function') {
      overlay.on_shown();
    }
  }

  /**
   * Helper function to hide custom element overlays.
   * 
   * @param {Object} overlay 
   */
  hide(overlay) {
    if (!overlay) return;
    // Force hidden even in the presence of CSS !important.
    overlay.style.setProperty('display', 'none', 'important');
    // Give overlays a chance to stop expensive work (e.g. GPU render loops)
    // while they are not visible.
    if (typeof overlay.on_hidden === 'function') {
      try { overlay.on_hidden(); } catch (e) { }
    }
  }

  /**
   * Helper function to show the overlay if it is 
   * hidden, or to hide the overlay if it is visible.
   * 
   * @param {Object} overlay 
   */
  toggle(overlay) {
    if (!overlay) return;

    const visible = overlay.checkVisibility
      ? overlay.checkVisibility()
      : getComputedStyle(overlay).display !== 'none';

    if (visible) {
      this.hide(overlay);
    } else {
      this.show(overlay);
    }
  }

  /**
   * Walks the dat.gui menu and returns a dictionary of all current
   * values of all controls in the menu.
   */
  snapshot_dat_gui_menu() {
    const snapshot = {};
    (function walk(pane, path = '') {
      pane.__controllers.forEach(ctrl => {
        const name = ctrl._name || ctrl.property;
        const key = path ? `${path}.${name}` : name;
        snapshot[key] = ctrl.object[ctrl.property];   // now committed
      });
      Object.values(pane.__folders || {}).forEach((folder, fname) => {
        walk(folder, path ? `${path}.${fname}` : fname);
      });
    })(this.gui);
    return snapshot;
  }

  create_dat_gui_menu()
  {
      if (!this.control_parameters_addon) {
          this.control_parameters_addon = this.get_default_preset();
      }

      if (!this.gui) {
          const dat_gui_parameters = {
              autoPlace: false,
              closeOnTop: true,
              closed: true,
              width: 400,
              useLocalStorage: false
          };

          this.gui = new dat.GUI(dat_gui_parameters);

          const dat_gui = document.getElementById('menu_item_dat_gui');

          if (dat_gui.children.length === 0) {
              dat_gui.appendChild(this.gui.domElement);
          } else if (dat_gui.children.item(0) !== this.gui.domElement) {
              dat_gui.replaceChild(this.gui.domElement, dat_gui.children.item(0));
          }
      }

      const menu_item_snapshot = this.querySelector("#menu_item_snapshot");
      if (menu_item_snapshot) {
          const ok = Array.isArray(this.fields_to_serialize) && this.fields_to_serialize.length > 0;
          menu_item_snapshot.style.display = ok ? "inline" : "none";
      }

      update_gui_displays(this.gui);
  }

  get_default_preset() {
    if (this.#control_parameters_addon.hasOwnProperty('preset')) {
      const preset_name = this.#control_parameters_addon.preset;
      const preset = this.#control_parameters_addon.remembered[preset_name][0];
      return preset;
    } else {
      return this.#control_parameters_addon;
    }
  }
  /**
   * Sends a dictionary of parameters to Csound at the start of performance. 
   * The keys are the literal Csound control channel names, and the values are 
   * the values of those channels.
   * 
   * @param {Object} parameters 
   */
  send_parameters(parameters) {
    if (non_csound(this.csound) == false) {
      this.csound_message_callback("Sending initial state of control perameters to Csound...\n")
      let parameters_ = this.snapshot_dat_gui_menu();
      for (const [name, value] of Object.entries(parameters)) {
        this.csound_message_callback(name + ": " + value + "\n");
        this.csound?.setControlChannel(name, parseFloat(value));
      }
    }
  }
  /**
   * Replaces global variables initialized in global scope, that is, not 
   * within instr definitions, with values defined in the parameters addon
   * (from ChatGPT).
   */
  update_parameters_in_csd(csdText, parameters) {
    let inInstrBlock = false;
    let lines = csdText.split("\n");
    for (let i = 0; i < lines.length; i++) {
      let line = lines[i].trim();
      // Detect entering/exiting an instr block
      if (/^instr\b/i.test(line)) {
        inInstrBlock = true;
      } else if (/^endin\b/i.test(line)) {
        inInstrBlock = false;
      }
      // Only modify lines outside instr blocks.
      if (!inInstrBlock) {
        // If the line contains " init " we may update it.
        let parts = line.split(' ');
        if (parts[1] === "init") {
          let parameter = parts[0]
          if (parameter in parameters) {
            let new_value = parameters[parameter];
            // If the first token is one of our parameters, we will give it a 
            // new value, and append the original line as a comment.
            let new_line = `${parameter} init ${new_value} ; Updated from: ${line}`
            lines[i] = new_line;
          }
        }
      }
    }
    return lines.join("\n");
  }

  /**
   * Adds a new folder to the Controls menu of the piece.
   * 
   * @param {string} name The name of the folder. 
   * @returns {Object} The new folder.
   */
  menu_folder_addon(name) {
    let folder = this.gui.addFolder(name);
    return folder;
  }
  /**
   * Adds a new slider to a folder of the Controls menu of the piece.
   * 
   * @param {Object} gui_folder The folder in which to place the slider.
   * @param {string} token The name of the slider, usually the name of a 
   * Csound message channel.
   * @param {number} minimum The minimum value of the slider (may be 
   * negative).
   * @param {number} maximum The maximum value of the slider.
   * @param {number} step An optional value for the granularity of values.
   */
  menu_slider_addon(gui_folder, token, minimum, maximum, step, name_) {
    const on_parameter_change = (value) => this.gk_update(token, value);

    const ctrl = name_
      ? gui_folder.add(this.get_default_preset(), token, minimum, maximum, step).name(name_)
      : gui_folder.add(this.get_default_preset(), token, minimum, maximum, step);

    // live while dragging slider:
    ctrl.onChange(on_parameter_change);
    // commit when user finishes typing (Enter/blur):
    ctrl.onFinishChange(on_parameter_change);

    this.gui.remember(this.control_parameters_addon);
  }
  /**
   * Called by the browser when the user updates the value of a control in the 
   * Controls menu, and sends the update to the Csound control channel with 
   * the same name.
   * 
   * @param {string} name The literal name of the Csound control channel.
   * @param {number} value The current value of that channel.
   */
  gk_update(name, value) {
    const numberValue = parseFloat(value);
    console.info("gk_update: name: " + name + " value: " + numberValue);
    if (non_csound(this.csound) == false) {
      this.csound?.setControlChannel(name, numberValue);
    }
  };
  /**
   * Adds a user-defined onclick handler function to the Controls menu of the 
   * piece.
   * 
   * @param {Object} control_parameters_addon Dictionary containing all control parameters.
   * @param {Object} gui_folder The folder to which the command will be added.
   * @param {string} name The name of the command.
   * @param {Function} onclick User-defined function to execute the command.
   */f
  menu_add_command(control_parameters_addon, gui_folder, name, onclick) {
    control_parameters_addon['name'] = onclick;
    gui_folder.add(this.control_parameters_addon, name)
  }
  _start_ui_timer() {
    if (this._ui_timer_id) {
      return;
    }
    const tick = () => {
      // Drain log queue + meter polling at a modest cadence without tying it to RAF
      // or overlay visibility.
      try {
        this.process_csnd_messages_and_meters(performance.now());
      } catch (e) {
      }
    };
    this._ui_timer_id = setInterval(tick, this.ui_timer_interval_ms || 250);
  }

  _stop_ui_timer() {
    if (!this._ui_timer_id) {
      return;
    }
    try {
      clearInterval(this._ui_timer_id);
    } catch (e) {
    }
    this._ui_timer_id = 0;
  }

  on_midi_start(start_ms) {
    // Start an independent mini_console clock during MIDI playback.
    try {
      this._midi_start_ms = (typeof start_ms === "number" && isFinite(start_ms)) ? start_ms : performance.now();
      this._start_midi_clock();
    } catch (e) {
    }
  }

  on_midi_stop() {
    try {
      this._stop_midi_clock();
      // Leave the last time displayed.
    } catch (e) {
    }
  }

  _start_midi_clock() {
    if (this._midi_timer_id) {
      return;
    }
    const tick = () => {
      try {
        if (!globalThis.__midi?.playing) {
          this._stop_midi_clock();
          return;
        }
        const start = this._midi_start_ms || globalThis.__midi.startMS || performance.now();
        const elapsed_sec = (performance.now() - start) / 1000.0;
        // Drive mini_console in the same format as Csound rendering time.
        const txt = cloud5_format_elapsed_time(elapsed_sec);
        const el = this.mini_console || document.getElementById("mini_console");
        if (el) {
          el.innerHTML = txt;
        }
        // Also expose elapsed as latest_score_time so score-following overlays can move.
        this.latest_score_time = elapsed_sec;
      } catch (e) {
      }
    };
    // 20 Hz update is smooth enough for playheads.
    this._midi_timer_id = setInterval(tick, 50);
    tick();
  }

  _stop_midi_clock() {
    if (!this._midi_timer_id) {
      return;
    }
    try { clearInterval(this._midi_timer_id); } catch (e) { }
    this._midi_timer_id = 0;
  }

}
customElements.define("cloud5-piece", Cloud5Piece);

/**
 * Displays a CsoundAC Score as a 3-dimensional piano roll. During 
 * performance, a moving red ball indicates the current position of 
 * the performance in the score. The user may use the trackball 
 * to zoom in or out of the score, to drag it, or to spin it around.
 */
class Cloud5PianoRoll extends Cloud5Element {
  constructor() {
    super();
    this.silencio_score = new Silencio.Score();
    this.csoundac_score = null;
    this.canvas = null;
  }

  _onWindowResize = () => {
    const visible = this.checkVisibility
      ? this.checkVisibility()
      : getComputedStyle(this).display !== 'none';
    if (!visible) return;
    requestAnimationFrame(() => this.on_shown());
  };

  connectedCallback() {
    super.connectedCallback?.();
    this.innerHTML = `
     <canvas id="display" class="cloud5-score-canvas">
    `;
    this.canvas = this.querySelector('#display');
    window.addEventListener('resize', this._onWindowResize, { passive: true });
    window.visualViewport?.addEventListener('resize', this._onWindowResize, { passive: true });
    if (this.csoundac_score !== null) {
      this.draw_csoundac_score(this.csoundac_score);
    }
    let menu_button = document.getElementById("menu_item_piano_roll");
    menu_button.style.display = 'inline';
  }
  disconnectedCallback() {
    window.removeEventListener('resize', this._onWindowResize);
    window.visualViewport?.removeEventListener('resize', this._onWindowResize);
    if (this._raf) {
      cancelAnimationFrame(this._raf);
      this._raf = 0;
    }
  }
  /**
   * Called by the browser to update the display of the Score. It is 
   * translated to a Silencio.Score object, which is what is actually 
   * displayed.
   * 
   * @param {CsoundAC.Score} score A generated CsoundAC.Score object.
   */
  draw_csoundac_score(score) {
    this.silencio_score = new Silencio.Score();
    let i;
    let n = score.size();
    for (i = 0; i < n; ++i) {
      let event = score.get(i);
      let p0_time = event.getTime();
      let p1_duration = event.getDuration();
      let p2_status = event.getStatus();
      let p3_channel = event.getChannel();
      let p4_key = event.getKey();
      let p5_velocity = event.getVelocity();
      let p6_x = event.getHeight();
      let p7_y = event.getPan();
      let p8_z = event.getDepth();
      let p9_phase = event.getPhase();
      this.silencio_score.add(p0_time, p1_duration, p2_status, p3_channel, p4_key, p5_velocity, p6_x, p7_y, p8_z, p9_phase);
    }
    this.draw_silencio_score(this.silencio_score);
  }
  /**
   * A updates the WebGL display of the generated Silencio Score object.
   * 
   * @param {Silencio.Score} score 
   */
  draw_silencio_score(score) {
    this.silencio_score = score;
    this.silencio_score.draw3D(this.canvas);
  }

  update_score_time(score_time) {
    if (!this.silencio_score) return;
    this.silencio_score.progress3D(score_time);
  }

  /**
   * Called by a timer during performance to update the play 
   * position in the piano roll display.
   */

  _raf_guard = false;

  show_score_time = async () => {
    // Skip if piano-roll not visible
    const visible = this.checkVisibility
      ? this.checkVisibility()
      : getComputedStyle(this).display !== 'none';
    if (!visible) return;

    // Need a piece & Silencio score
    const piece = this.cloud5_piece;
    if (!piece || typeof piece.latest_score_time !== 'number') return;
    if (!this.silencio_score) return;

    // Throttle to one progress3D per animation frame
    if (this._raf_guard) return;
    this._raf_guard = true;
    const t = piece.latest_score_time;
    requestAnimationFrame(() => {
      this._raf_guard = false;
      this.silencio_score.progress3D(t);
    });
  };

  /**
   * Stops the timer that is updating the play position of the score.
   */
  recenter() {
    this.silencio_score.lookAtFullScore3D();
  }
  on_shown() {
    const dpr = window.devicePixelRatio || 1;
    const cssW = document.documentElement.clientWidth;
    const cssH = document.documentElement.clientHeight;
    // 2a) backing store
    this.canvas.width = Math.max(1, Math.floor(cssW * dpr));
    this.canvas.height = Math.max(1, Math.floor(cssH * dpr));
    // 2b–d) if Silencio exposes hooks; otherwise re-call draw3D
    if (this.silencio_score?.resize3D) {
      this.silencio_score.resize3D(this.canvas.width, this.canvas.height);
      if (this.silencio_score.lookAtFullScore3D) this.silencio_score.lookAtFullScore3D();
      if (this.silencio_score.redraw3D) this.silencio_score.redraw3D();
    } else {
      // Fallback: rebuild the renderer with the now-correct size
      this.silencio_score?.draw3D?.(this.canvas);
      this.silencio_score?.lookAtFullScore3D?.();
    }
  }
}
customElements.define("cloud5-piano-roll", Cloud5PianoRoll);

/**
 * Contains an instance of the Strudel REPL that can use Csound as an output,
 * and that starts and stops along wth Csound.
 */
class Cloud5Strudel extends Cloud5Element {
  constructor() {
    super();
  }

  connectedCallback() {
    super.connectedCallback?.();
    this.innerHTML = `
    <strudel-repl-component id="strudel_view" class='cloud5-strudel-repl'>
        <!--
        ${this.#strudel_code_addon}
        -->
    </strudel-repl-component>
    `;
    this.strudel_component = this.querySelector('#strudel_view');
    this.strudel_component.addEventListener("focusout", (event) => {
      console.log("strudel_component lost focus.");
    });

    let menu_button = document.getElementById("menu_item_strudel");
    menu_button.style.display = 'inline';
  }  /**
   * Starts the Strudel performance loop (the Cyclist).
   */
  start() {
    this.strudel_component.startPlaying();

  }
  /**
   * Stops the Strudel performance loop (the Cyclist).
   */
  stop() {
    this.strudel_component.stopPlaying();

  }
  #strudel_code_addon = null;
  /**
    * Contains the text of a user-defined Strudel patch, exactly as would 
    * normally be entered by the user in the Strudel REPL. This patch may 
    * also import and reference modules defined by the cloud-5 system, such 
    * as statefulpatterns.mjs or csoundac.js.
    */
  set strudel_code_addon(code) {
    this.#strudel_code_addon = code;
    // Reconstruct the element.
    this.connectedCallback();
  }
  get strudel_code_addon() {
    return this.#strudel_code_addon;
  }
  #control_parameters_addon = null;
  /**
    * Gets or sets optional control parameters.
    */
  set control_parameters_addon(parameters_) {
    this.#control_parameters_addon = parameters_;
    globalThis.parameters = parameters_;
    // Reconstruct the element.
    this.connectedCallback();
  }
  get control_parameters_addon() {
    return this.#control_parameters_addon;
  }
}
customElements.define("cloud5-strudel", Cloud5Strudel);

/**
 * Presents visuals generated by a GLSL shader. These visuals can show a 
 * visualization of the music, or be sampled to generate notes for Csound to 
 * perform.
 * 
 * This class is specifically designed to simplify the use of shaders 
 * developed in or adapted from the ShaderToy Web site. Other types of shader 
 * also can be used.
 */class Cloud5ShaderToy extends Cloud5Element {
  gl = null;
  shader_program = null;
  analyser = null;
  uniforms = {};
  uniform_locations = {};
  attributes = null;
  set_uniforms = null;
  get_attributes = null;
  frequency_domain_data = null;
  time_domain_data = null;
  image_sample_buffer = null;
  channel0_texture_unit = null;
  channel0_texture = null;
  channel0_sampler = null;
  current_events = {};
  prior_events = {};
  rendering_frame = 0;
  image_sample_buffer = null;
  prior_image_sample_buffer = null;
  vertex_shader_code_addon = `#version 300 es
  in vec2 inPos;
  void main() {
      gl_Position = vec4(inPos.xy, 0.0, 1.0);
  }
  `;
  constructor() {
    super();
  }

  connectedCallback() {
    super.connectedCallback?.();
    this.innerHTML = `
     <canvas id="display" class="cloud5-shader-canvas">
    `;
    this.canvas = this.querySelector('#display');

    // Attach WebGL context-loss handlers to this canvas (Cloud5ShaderToy does not
    // use obtainWebGL2, so we must handle this here).
    if (!this._context_listeners_installed) {
      this._context_listeners_installed = true;
      this.canvas.addEventListener('webglcontextlost', (e) => {
        e.preventDefault();
        this._context_lost = true;

        const msg = 'WebGL context lost (Cloud5ShaderToy)';
        console.error(msg);
        try {
          this.cloud5_piece?.csound_message_callback?.(msg + '\n');
        } catch { }

        if (this._raf) {
          cancelAnimationFrame(this._raf);
          this._raf = 0;
        }
      }, false);
      this.canvas.addEventListener('webglcontextrestored', () => {
        const msg = 'WebGL context restored (Cloud5ShaderToy)';
        console.log(msg);
        try {
          this.cloud5_piece?.csound_message_callback?.(msg + '\n');
        } catch { }

        this._context_lost = false;
        this._rebuild_after_context_restore();
      }, false);
    }
    window.addEventListener('resize', this._onWindowResize, { passive: true });
    window.visualViewport?.addEventListener('resize', this._onWindowResize, { passive: true });
  }

  // ...rest of Cloud5ShaderToy unchanged...
  disconnectedCallback() {
    if (this._raf) {
      cancelAnimationFrame(this._raf);
      this._raf = 0;
    }
    window.removeEventListener('resize', this._onWindowResize);
    window.visualViewport?.removeEventListener('resize', this._onWindowResize);
  }
  #cloud5_piece = null;
  /**
   * Back reference to the piece, which can be used e.g. to get a reference to 
   * Csound.
   */
  set cloud5_piece(piece) {
    this.#cloud5_piece = piece;
  }
  get cloud5_piece() {
    return this.#cloud5_piece;
  }
  #shader_parameters_addon = null;
  /**
   * A number of parameters must be up to date at the same time before the 
   * shader program can be compiled. These are passed to this property in an 
   * object, upon which the shader is compiled and begins to run. The 
   * paramameters are:
   * <pre>
   * {
   *  fragment_shader_addon: code,    \\ Required GLSL code.
   *  vertex_shader_addon: code,      \\ Has a default value, but may be 
   *                                  \\ overridden with custom GLSL code.
   *  pre_draw_frame_function_addon,  \\ Optional JavaScript function to be 
   *                                  \\ caalled in the animation loop before 
   *                                  \\ drawing each frame, e.g. for setting 
   *                                  \\ program uniforms.
   *  post_draw_frame_function_addon, \\ Optional JavaScript function to called 
   *                                  \\ the animation loop immediately after 
   *                                  \\ drawing each frame, e.g. for getting 
   *                                  \\ attributes or reading buffers.                
   * }
   * </pre>
   */
  set shader_parameters_addon(shader_parameters) {
    this.#shader_parameters_addon = shader_parameters;
    this.create_shader();
  }
  get shader_parameters_addon() {
    return this.#shader_parameters_addon;
  }
  /**
   * Compiles the shader program, and starts rendering the shader.
   */
  create_shader() {
    // Assign parameters fields to this.
    if (this.#shader_parameters_addon.vertex_shader_code_addon) {
      this.vertex_shader_code_addon = this.#shader_parameters_addon.vertex_shader_code_addon;
    }
    this.fragment_shader_code_addon = this.#shader_parameters_addon.fragment_shader_code_addon;
    this.pre_draw_frame_function_addon = this.#shader_parameters_addon.pre_draw_frame_function_addon;
    this.post_draw_frame_function_addon = this.#shader_parameters_addon.post_draw_frame_function_addon;
    this.prepare_canvas();
    this.compile_shader();
    this.get_uniforms();
    this?.set_attributes();
    requestAnimationFrame((milliseconds) => this.on_shown(milliseconds));
  }
  /**
   * Called by the browser when the element is resized.
   */
  resize() {
    this.webgl_viewport_size = [window.innerWidth, window.innerHeight];
    this.canvas.width = this.webgl_viewport_size[0] * window.devicePixelRatio;
    this.canvas.height = this.webgl_viewport_size[1] * window.devicePixelRatio;
    this.image_sample_buffer = new Uint8ClampedArray(this.canvas.width * 4);
    this.prior_image_sample_buffer = new Uint8ClampedArray(this.canvas.width * 4);
    console.info("resize: image_sample_buffer.length: " + this.image_sample_buffer.length);
  }
  /**
   * Prepares the element's canvas for use by WebGL.
   */
  prepare_canvas() {
    // Set up for high-resolution displays.
    let devicePixelRatio_ = window.devicePixelRatio || 1
    this.canvas.width = this.canvas.clientWidth * devicePixelRatio_;
    this.canvas.height = this.canvas.clientHeight * devicePixelRatio_;
    console.info("canvas.height: " + this.canvas.height);
    console.info("canvas.width:  " + this.canvas.width);
    this.gl = this.canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false });
    if (!this.gl) {
      throw new Error('WebGL2 is required for #version 300 es shaders.');
    }
    let extensions = this.gl.getSupportedExtensions();
    console.info("Supported extensions:\n" + extensions);
    if ("gpu" in navigator) {
      var gpu_adapter = navigator.gpu.requestAdapter();
      console.info("WebGPU adapter: " + gpu_adapter);
    } else {
      console.warn("WebGPU is not available on this platform.");
    }
    var EXT_color_buffer_float = this.gl.getExtension("EXT_color_buffer_float");
    if (!EXT_color_buffer_float) {
      alert("EXT_color_buffer_float is not available on this platform.");
    }
    this.mouse_position = [0, 0, 0, 0];
    this.canvas.addEventListener('mousemove', ((e) => {
      this.mouse_position = [e.clientX, e.clientY];
    }));
    this.audio_texture_level = 0;
    this.audio_texture_internalFormat = this.gl.R32F;
    this.audio_texture_width = 512;
    this.audio_texture_height = 2;
    this.audio_texture_border = 0;
    this.audio_texture_srcFormat = this.gl.RED;
    this.audio_texture_srcType = this.gl.FLOAT;
    this.frequency_domain_data = new Uint8Array(this.audio_texture_width * 2);
    this.time_domain_data = new Uint8Array(this.audio_texture_width * 2);
    this.audio_data = new Float32Array(this.audio_texture_width * 2);
    this.image_sample_buffer = new Uint8ClampedArray();
    this.channel0_texture_unit = 0;
    this.channel0_texture = this.gl.createTexture();
    this.channel0_texture.name = "channel0_texture";
    this.channel0_sampler = this.gl.createSampler();
    this.channel0_sampler.name = "channel0_sampler";
    this.current_events = {};
    this.prior_events = {};
    this.rendering_frame = 0;
    this.midpoint = this.audio_texture_width / 2;
  }

  write_audio_texture(analyser, texture_unit, texture, sampler) {
    if (analyser != null) {
      analyser.getByteFrequencyData(this.frequency_domain_data);
      analyser.getByteTimeDomainData(this.time_domain_data);
      for (let i = 0; i < this.audio_texture_width; ++i) {
        // Map frequency domain magnitudes to [0, 1].
        let sample = this.frequency_domain_data[i];
        sample = sample / 255.;
        this.audio_data[i] = sample;
      }
      this.audio_data_width = this.audio_texture_width * 2;
      for (let j = 0; j < this.audio_texture_width; ++j) {
        // Map time domain amplitudes to [-1, 1].
        let sample = this.time_domain_data[j];
        sample = sample / 255.;
        this.audio_data[this.audio_texture_width + j] = sample;
      }
    }
    this.gl.activeTexture(this.gl.TEXTURE0 + texture_unit);
    this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
    this.gl.bindSampler(texture_unit, sampler);
    this.gl.texImage2D(this.gl.TEXTURE_2D,
      this.audio_texture_level,
      this.audio_texture_internalFormat,
      this.audio_texture_width,
      this.audio_texture_height,
      this.audio_texture_border,
      this.audio_texture_srcFormat,
      this.audio_texture_srcType,
      this.audio_data);
    this.gl.samplerParameteri(sampler, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
    this.gl.samplerParameteri(sampler, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
    this.gl.samplerParameteri(sampler, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
    this.gl.samplerParameteri(sampler, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
    this.gl.samplerParameteri(sampler, this.gl.TEXTURE_WRAP_R, this.gl.CLAMP_TO_EDGE);
    this.gl.samplerParameteri(sampler, this.gl.TEXTURE_COMPARE_MODE, this.gl.NONE);
    this.gl.samplerParameteri(sampler, this.gl.TEXTURE_COMPARE_FUNC, this.gl.LEQUAL);
    if (false && analyser) { // For debugging.
      let is_texture = gl.isTexture(texture);
      let uniform_count = gl.getProgramParameter(shader_program, gl.ACTIVE_UNIFORMS);
      let uniform_index;
      for (let uniform_index = 0; uniform_index < uniform_count; ++uniform_index) {
        uniform_info = gl.getActiveUniform(shader_program, uniform_index);
        console.log(uniform_info);
        const location = gl.getUniformLocation(shader_program, uniform_info.name);
        const value = gl.getUniform(shader_program, location);
        console.log("Uniform location: " + location);
        console.log("Uniform value: " + value);
      }
      const unit = gl.getUniform(shader_program, shader_program.iChannel0);
      console.log("Sampler texture unit: " + unit);
      console.log("Texture unit: " + texture_unit);
      gl.activeTexture(gl.TEXTURE0 + texture_unit);
      let texture2D = gl.getParameter(gl.TEXTURE_BINDING_2D);
      console.log("Texture binding 2D " + texture2D);
      var debug_framebuffer = gl.createFramebuffer();
      gl.bindFramebuffer(gl.FRAMEBUFFER, debug_framebuffer);
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture2D, 0);
      if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
        console.log("These attachments don't work.");
      }
      // Read the contents of the debug_framebuffer (data stores the pixel data).
      var data = new Float32Array(1024);
      // What comes out, should be what went in.
      gl.readPixels(0, 0, 512, 2, gl.RED, gl.FLOAT, data);
      //console.log("\nfrequency domain: \n" + data.slice(0, 512));
      //console.log("time domain: \n" + data.slice(512));
      gl.deleteFramebuffer(debug_framebuffer);
    }
  }

  /**
   * Actually compiles and links the vertex shader and fragment shader.
   */
  compile_shader() {
    let WEBGL_debug_shaders = this.gl.getExtension("WEBGL_debug_shaders");
    this.webgl_viewport_size = null;
    this.webgl_buffers = {};
    this.shader_program = this.gl.createProgram();
    for (let i = 0; i < 2; ++i) {
      let shader_code = (i == 0 ? this.vertex_shader_code_addon : this.fragment_shader_code_addon);
      let shader_object = this.gl.createShader(i == 0 ? this.gl.VERTEX_SHADER : this.gl.FRAGMENT_SHADER);
      this.gl.shaderSource(shader_object, shader_code);
      this.gl.compileShader(shader_object);
      let status = this.gl.getShaderParameter(shader_object, this.gl.COMPILE_STATUS);
      if (!status) {
        console.warn(this.gl.getShaderInfoLog(shader_object));
      }
      this.gl.attachShader(this.shader_program, shader_object);
      this.gl.linkProgram(this.shader_program);
      let translated_shader = WEBGL_debug_shaders.getTranslatedShaderSource(shader_object);
    }
    status = this.gl.getProgramParameter(this.shader_program, this.gl.LINK_STATUS);
    if (!status) {
      console.warn(this.gl.getProgramInfoLog(this.shader_program));
    }
    this.shader_program.inPos = this.gl.getAttribLocation(this.shader_program, "inPos");
    this.shader_program.iMouse = this.gl.getUniformLocation(this.shader_program, "iMouse");
    this.shader_program.iResolution = this.gl.getUniformLocation(this.shader_program, "iResolution");
    this.shader_program.iTime = this.gl.getUniformLocation(this.shader_program, "iTime");
    this.shader_program.iTimeDelta = this.gl.getUniformLocation(this.shader_program, "iTimeDelta");
    this.shader_program.iFrame = this.gl.getUniformLocation(this.shader_program, "iFrame");
    this.shader_program.iChannel0 = this.gl.getUniformLocation(this.shader_program, "iChannel0");
    this.shader_program.iChannel1 = this.gl.getUniformLocation(this.shader_program, "iChannel1");
    this.shader_program.iChannel2 = this.gl.getUniformLocation(this.shader_program, "iChannel2");
    this.shader_program.iChannel3 = this.gl.getUniformLocation(this.shader_program, "iChannel3");
    this.shader_program.iSampleRate = this.gl.getUniformLocation(this.shader_program, "iSampleRate");
    this.gl.useProgram(this.shader_program);

    this.gl.uniform1f(this.shader_program.iSampleRate, 48000.);
    var pos = [-1, -1,
      1, -1,
      1, 1,
    -1, 1];
    var inx = [0, 1, 2, 0, 2, 3];
    this.webgl_buffers.pos = this.gl.createBuffer();
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.webgl_buffers.pos);
    this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(pos), this.gl.STATIC_DRAW);
    this.webgl_buffers.inx = this.gl.createBuffer();
    this.webgl_buffers.inx.len = inx.length;
    this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.webgl_buffers.inx);
    this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(inx), this.gl.STATIC_DRAW);
    this.gl.enableVertexAttribArray(this.shader_program.inPos);
    this.gl.vertexAttribPointer(this.shader_program.inPos, 2, this.gl.FLOAT, false, 0, 0);
    this.gl.enable(this.gl.DEPTH_TEST);
    this.gl.clearColor(0.0, 0.0, 0.0, 1.0);
    this?.write_audio_texture(this.analyser, this.channel0_texture_unit, this.channel0_texture, this.channel0_sampler);
    // Do not overwrite the page's global resize handler.
    this.resize();
    if (!this._raf) {
      this._raf = requestAnimationFrame((milliseconds) => this.render_frame(milliseconds));
    }
  }

  set_attributes() {

  }
  /**
   * Obtains all active uniforms from the compiled GLSL shader.
   * Addons may use these to control the shader.
   */
  get_uniforms() {
    // Obtains all active uniforms and their locations, with which visuals 
    // can be controlled.
    this.uniforms = {};
    this.uniform_locations = {};
    let uniform_count = this.gl.getProgramParameter(this.shader_program, this.gl.ACTIVE_UNIFORMS);
    for (let uniform_index = 0; uniform_index < uniform_count; ++uniform_index) {
      let uniform_info = this.gl.getActiveUniform(this.shader_program, uniform_index);
      this.uniforms[uniform_info.name] = uniform_info;
      let uniform_location = this.gl.getUniformLocation(this.shader_program, uniform_info.name);
      this.uniform_locations[uniform_info.name] = uniform_location;
    }
  }
  // Obtains all program attributes, which can be used to sample the visuals in 
  // order to generate notes.
  get_attributes() {

  }
  /**
   * Called by the browseer to run the shader in an endless loop of animation frames.
   * 
   * @param {number} milliseconds The time since the start of the loop.
   */
  async render_frame(milliseconds) {
    const visible = this.checkVisibility
      ? this.checkVisibility()
      : getComputedStyle(this).display !== 'none';
    if (!visible) {
      if (this._raf) {
        cancelAnimationFrame(this._raf);
        this._raf = 0;
      }
      return;
    }
    if (this._context_lost || !this.gl) {
      this._raf = 0;
      return;
    }
    // Here we create an AnalyserNode as soon as Csound is available.
    if (this.analyser) {
    } else {
      let csound = this?.cloud5_piece?.csound;
      if (csound) {
        var node;
        if (typeof csound.getNode == 'undefined') {
        } else {
          node = await csound.getNode()
          this.analyser = new AnalyserNode(node.context);
          this.analyser.fftSize = 2048;
          console.info("Analyzer buffer size: " + this.analyser.frequencyBinCount);
          node.connect(this.analyser);
        }
      }
    }
    this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
    this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
    // Custom uniforms may be set in this addon. Such uniforms can be used 
    // e.g. to control audio visualizations.
    if (this.pre_draw_frame_function_addon) {
      this.pre_draw_frame_function_addon();
    }
    // There are some default uniforms, modeled on ShaderToy.
    let seconds = milliseconds / 1000;
    this.gl.uniform1f(this.shader_program.iTime, seconds);
    this.gl.uniform3f(this.shader_program.iResolution, this.canvas.width, this.canvas.height, 0);
    this.gl.uniform4f(this.shader_program.iMouse, this.mouse_position[0], this.mouse_position[1], 0, 0);
    this?.write_audio_texture(this.analyser, this.channel0_texture_unit, this.channel0_texture, this.channel0_sampler);
    // Actually render the frame.
    this.gl.drawElements(this.gl.TRIANGLES, this.webgl_buffers.inx.len, this.gl.UNSIGNED_SHORT, 0);
    // Ï attributes may be accessed in this addon. Such attributes can be 
    // used e.g. to sample visuals and translate them to musical notes.
    if (this.post_draw_frame_function_addon) {
      this.post_draw_frame_function_addon();
    }
    this.rendering_frame++;
    this._raf = requestAnimationFrame((milliseconds) => this.render_frame(milliseconds));
  }

  on_shown() {
    if (!this.canvas) return;

    // 1) Compute visible CSS size; fall back to viewport when display:none had zeroed sizes
    const dpr = window.devicePixelRatio || 1;
    let cssW = this.clientWidth, cssH = this.clientHeight;
    if (!cssW || !cssH) { cssW = document.documentElement.clientWidth; cssH = document.documentElement.clientHeight; }

    // 2) Backing store = CSS size × DPR (>=1)
    const bw = Math.max(1, Math.floor(cssW * dpr));
    const bh = Math.max(1, Math.floor(cssH * dpr));
    if (this.canvas.width !== bw || this.canvas.height !== bh) {
      this.canvas.width = bw;
      this.canvas.height = bh;
      // keep the CSS size in sync so hit-testing/mouse coords match
      this.canvas.style.width = cssW + 'px';
      this.canvas.style.height = cssH + 'px';
      // (re)allocate any size-dependent scratch buffers if you use them
      this.image_sample_buffer = new Uint8ClampedArray(this.canvas.width * 4);
      this.prior_image_sample_buffer = new Uint8ClampedArray(this.canvas.width * 4);
    }

    // 3) Ensure GL is ready, then update viewport & size uniforms
    if (!this.gl) {
      // Your class already has these helpers:
      // - prepare_canvas() creates the context and sets up state
      // - compile_shader() builds the program
      // - get_uniforms() caches uniform locations
      // - set_attributes() binds VBOs/IBOs
      this.prepare_canvas?.();
      this.compile_shader?.();
      this.get_uniforms?.();
      this?.set_attributes?.();
    }
    if (!this.gl) return;

    this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);

    // ShaderToy-style uniforms if present
    if (this.shader_program) {
      this.gl.useProgram(this.shader_program);
      if (this.shader_program.iResolution) {
        // pass DPR in .z like ShaderToy (optional; many shaders ignore it)
        this.gl.uniform3f(this.shader_program.iResolution, this.canvas.width, this.canvas.height, dpr);
      }
    }

    // 4) Make sure the render loop is running
    if (!this._raf) {
      this._raf = requestAnimationFrame(ms => this.render_frame(ms));
    }
  }
  on_hidden() {
    if (this._raf) {
      cancelAnimationFrame(this._raf);
      this._raf = 0;
    }
  }

  _raf = 0;
  _onWindowResize = () => {
    const visible = this.checkVisibility
      ? this.checkVisibility()
      : getComputedStyle(this).display !== 'none';
    if (!visible) return;
    requestAnimationFrame(() => this.on_shown?.());
  };

  _raf = 0;
  _context_lost = false;
  _context_listeners_installed = false;

  _rebuild_after_context_restore() {
    // GL resources are invalidated on context restore; rebuild program/buffers.
    try {
      this.prepare_canvas?.();
      this.compile_shader?.();
      this.get_uniforms?.();
      this.set_attributes?.();
      this.on_shown?.();
    } catch (e) {
      console.warn('Cloud5ShaderToy: rebuild after context restore failed:', e);
    }
  }


}
customElements.define("cloud5-shadertoy", Cloud5ShaderToy);

/**
 * Displays a scrolling list of runtime messages from Csound and/or other sources.
 */
class Cloud5Log extends Cloud5Element {
  constructor() {
    super();
  }

  connectedCallback() {
    super.connectedCallback?.();
    this.innerHTML = `<div id="console_view" class="cloud5-log-editor no-scroll"></div>`;
    this.console_editor = ace.edit("console_view");
    this.console_editor.setShowPrintMargin(false);
    this.console_editor.setDisplayIndentGuides(false);
    this.console_editor.renderer.setOption("showGutter", true);
  }

  // ...rest of Cloud5Log unchanged...
  _pin_to_bottom() {
    if (!this.console_editor) {
      return;
    }
    const editor = this.console_editor;
    const session = editor.getSession();
    // When the log overlay has been display:none, Ace often needs a resize
    // before its scroller metrics are valid.
    try { editor.resize(true); } catch (e) { }
    const lastRow = Math.max(0, session.getLength() - 1);
    // Ace's own helper is more reliable than manual Y math when soft-wrap is enabled.
    try {
      editor.scrollToLine(lastRow, true, true, function () { });
    } catch (e) {
      // Fallback: best-effort renderer scroll.
      const renderer = editor.renderer;
      const scroller = renderer && renderer.scroller;
      const scrollerHeight = (renderer && renderer.$size && renderer.$size.scrollerHeight) || (scroller && scroller.clientHeight) || 0;
      if (scrollerHeight > 0) {
        const lineHeight = (renderer && renderer.lineHeight) || 16;
        const maxY = Math.max(0, lastRow * lineHeight - scrollerHeight);
        try { renderer.scrollToY(maxY); } catch (e2) { }
      }
    }
  }

  /**
   * Appends message text to the end of the editor, trimming old lines if needed.
   * @param {string} message
   */
  log(message) {
    if (!this.console_editor) return;
    const editor = this.console_editor;
    const session = editor.getSession();
    const doc = session.getDocument();
    // Trim if too large (prevent runaway memory).
    let lines = session.getLength();
    if (lines > 5000) {
      session.removeFullLines(0, 2500);
      lines = session.getLength();
    }
    // Append at end-of-document (regardless of visibility or cursor position).
    const lastRow = Math.max(0, doc.getLength() - 1);
    const lastCol = (session.getLine(lastRow) || "").length;
    session.insert({ row: lastRow, column: lastCol }, message);
    this._pin_to_bottom();
    // Some browsers/Ace builds only settle scroll metrics after paint.
    requestAnimationFrame(() => this._pin_to_bottom());
  }
}
customElements.define("cloud5-log", Cloud5Log);


/**
 * May contain license, authorship, credits, and program notes as inner HTML.
 */
class Cloud5About extends Cloud5Element {
  constructor() {
    super();
  }
}
customElements.define("cloud5-about", Cloud5About);

// A sad workaround....
// In some environments a stub `require` exists but Node's 'fs' module is unavailable.
// Guard carefully to avoid crashing in browsers.
var __dirname = ".";
try {
  if (typeof require === "function") {
    const fs = require("fs");
    if (fs && typeof fs.realpathSync === "function") {
      __dirname = fs.realpathSync(".");
    }
  }
}
catch (e) {
  // Ignore.
}

/**
 * The title of the document and of the piece is always the filename part, 
 * without extension, of the HTML file.
 */
document.title = get_filename(document.location.href)

/**
 * Tries to clear all browser caches upon loading.
 */
if ('caches' in window) {
  caches.keys().then(function (names) {
    for (let name of names)
      caches.delete(name);
    console.info(`deleted ${name} from caches.`);
  });
}

/**
 * Tests if Csound is null or undefined.
 */
function non_csound(csound_) {
  if (typeof csound_ === 'undefined') {
    console.warn("csound is undefined.");
    console.trace();
    return true;
  }
  if (csound_ === null) {
    console.warn("csound is null.");
    console.trace();
    return true;
  }
  return false;
}

/**
 * Replaces the order of instruments in a Silencio Score with a new order.
 * Instrument numbers are re-ordered as if they are integers. The 
 * new_order parameter is a map, e.g. `{1:5, 3:1, 4:17}`. The map need not 
 * be complete.
 *
 * @param {Silencio.Score} score A generated CsoundAC Score.
 * @param {Object} new_order_ A map assigning new Csound instrument numbers 
 * (values) to old Csound instrument numbers (keys)
 */
function arrange_silencio(score, new_order_) {
  console.info("arrange: reassigning instrument numbers...")
  let new_order = new Map(Object.entries(new_order_));
  // Renumber the insnos in the Score. Fractional parts of old insnos are 
  // preserved.
  for (i = 0, n = score.data.length; i < n; ++i) {
    let event_ = score.data[i];
    let current_insno = event_.channel;
    let current_insno_integer = Math.floor(current_insno);
    let string_key = current_insno_integer.toString();
    if (new_order.has(string_key)) {
      let new_insno_integer = new_order.get(string_key);
      let new_insno_fraction = current_insno - current_insno_integer;
      let new_insno = new_insno_integer + new_insno_fraction;
      console.info("renumbered: " + event_.toIStatement());
      event_.channel = new_insno;
      score.data[i] = event_;
      console.info("        to: " + score.data[i].toIStatement());
    }
  }
  console.info("arrange: finished reassigning instrument numbers.\n")
}

/**
 * Replaces the order of instruments in a generated CsoundAC Score with a new 
 * order. Instrument numbers are re-ordered as if they are integers. The 
 * new_order parameter is a map, e.g. `{1:5, 3:1, 4:17}`. The map need not 
 * be complete.
 *
 * @param {CsoundAC.Score} score A generated CsoundAC Score.
 * @param {Object} new_order_ A map assigning new Csound instrument numbers 
 * (values) to old Csound instrument numbers (keys)
 */
function arrange(score, new_order_) {
  console.info("arrange: reassigning instrument numbers...\n")
  let new_order = new Map(Object.entries(new_order_));
  // Renumber the insnos in the Score. Fractional parts of old insnos are 
  // preserved.
  for (i = 0, n = score.size(); i < n; ++i) {
    let event_ = score.get(i);
    let current_insno = event_.getInstrument();
    let current_insno_integer = Math.floor(current_insno);
    let string_key = current_insno_integer.toString();
    if (new_order.has(string_key)) {
      let new_insno_integer = new_order.get(string_key);
      let new_insno_fraction = current_insno - current_insno_integer;
      let new_insno = new_insno_integer + new_insno_fraction;
      console.info("renumbered: " + event_.toIStatement());
      event_.setInstrument(new_insno);
      score.set(i, event_);
      console.info("        to: " + event_.toIStatement());
    }
  }
  console.info("arrange: finished reassigning instrument numbers.\n")
}

/**
 * May be called to store data to the local filesystem. This is used e.g. to 
 * save a copy of the generated Csound .csd file before starting the 
 * performance. This can help with debugging. Note that in a browser-based 
 * performance, the local filesystem is inside the sandbox.
 * 
 * @param {string} filepath 
 * @param {string} data 
 */
function write_file(filepath, data) {
  try {
    // Sync, so a bad .csd file doesn't blow up Csound 
    // before the .csd file is written so it can be tested!
    fs.writeFileSync(filepath, data, function (err) {
      console.warn(err);
    });
  } catch (err) {
    try {
      navigator.clipboard.writeText(data);
      console.info("Copied generated csd to system clipboard.\n")
    } catch (err1) {
      console.warn(err1);
    }
  }
}

/**
 * Called by the browser to resize arrays that are used to sample the WebGL
 * canvas during performance.
 */
function resize() {
  webgl_viewport_size = [window.innerWidth, window.innerHeight];
  canvas.width = webgl_viewport_size[0] * window.devicePixelRatio;
  canvas.height = webgl_viewport_size[1] * window.devicePixelRatio;
  image_sample_buffer = new Uint8ClampedArray(canvas.width * 4);
  prior_image_sample_buffer = new Uint8ClampedArray(canvas.width * 4);
  console.info("resize: image_sample_buffer.length: " + image_sample_buffer.length);
}

function client_wait_async(gl, sync, flags, interval_ms) {
  return new Promise((resolve, reject) => {
    function test() {
      const result = gl.clientWaitSync(sync, flags, 0);
      if (result === gl.WAIT_FAILED) {
        reject();
        return;
      }
      // This is the workaround for platforms where maximum 
      // timeout is always 0.
      if (result === gl.TIMEOUT_EXPIRED) {
        setTimeout(test, interval_ms);
        return;
      }
      resolve();
    }
    test();
  });
}

async function get_buffer_sub_data_async(gl, target, buffer, srcByteOffset, dstBuffer,
  /* optional */ dstOffset, /* optional */ length) {
  const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
  gl.flush();
  await client_wait_async(gl, sync, 0, 10);
  gl.deleteSync(sync);
  gl.bindBuffer(target, buffer);
  gl.getBufferSubData(target, srcByteOffset, dstBuffer, dstOffset, length);
  gl.bindBuffer(target, null);
}

/**
 * @function rgb_to_hsv 
 * 
 * @description Converts an RGB color value to HSV. The formula is 
 * adapted from http://en.wikipedia.org/wiki/HSV_color_space.
 * 
 * @param {Array} rgb RGB color in [0, 255]
 * @returns {Array} An HSV color in [0, 1].
 */
var rgb_to_hsv = function (rgb) {
  r = rgb[0] / 255;
  g = rgb[1] / 255;
  b = rgb[2] / 255;
  let max = Math.max(r, g, b);
  let min = Math.min(r, g, b);
  let h, s, v = max;
  let d = max - min;
  s = max === 0 ? 0 : d / max;
  if (max == min) {
    h = 0;
  } else {
    // More efficient than switch?
    if (max == r) {
      h = (g - b) / d + (g < b ? 6 : 0);
    } else if (max == g) {
      h = (b - r) / d + 2;
    } else if (max == b) {
      h = (r - g) / d + 4;
    }
    h /= 6;
  }
  return [h, s, v];
}

async function read_pixels_async(gl, x, y, w, h, format, type, sample) {
  // Reuse a single PBO per context rather than creating/deleting one per call.
  // Frequent buffer churn + sync readback is a common trigger for WebGL context loss.
  if (!gl.__cloud5_read_pbo) {
    gl.__cloud5_read_pbo = gl.createBuffer();
    gl.__cloud5_read_pbo_size = 0;
  }
  const buffer = gl.__cloud5_read_pbo;
  gl.bindBuffer(gl.PIXEL_PACK_BUFFER, buffer);
  if ((gl.__cloud5_read_pbo_size | 0) < sample.byteLength) {
    gl.bufferData(gl.PIXEL_PACK_BUFFER, sample.byteLength, gl.DYNAMIC_READ);
    gl.__cloud5_read_pbo_size = sample.byteLength;
  }
  gl.readPixels(x, y, w, h, format, type, 0);
  gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
  await get_buffer_sub_data_async(gl, gl.PIXEL_PACK_BUFFER, buffer, 0, sample);
}

/**
 * Called by cloud-5 when sampling the WebGL video canvas to 
 * reduce the bandwidth of the data, e.g. in preparation for 
 * translating it to musical notes.
 * 
 * Adapts https://github.com/pingec/downsample-lttb from time 
 * series data to vectors of float HSV pixels. Our data is not 
 * [[time, value], [time, value],...], but rather 
 * [[column, value, [h,s,v]],...]. The algorithm uses 
 * column and value, but [h,s,v] go along for the ride.
 * 
 * @param {Array} data Data from the canvas, e.g. a row of pixels.
 * @param {Array} buckets Placeholder data.
 * @returns {Array} The downsampled data.
 */
function downsample_lttb(data, buckets) {
  if (buckets >= data.length || buckets === 0) {
    return data; // Nothing to do
  }
  let sampled_data = [],
    sampled_data_index = 0;
  // Bucket size. Leave room for start and end data points
  let bucket_size = (data.length - 2) / (buckets - 2);
  // Triangles are points {a, b, c}.
  let a = 0,  // Initially a is the first point in the triangle
    max_area_point,
    max_area,
    area,
    next_a;
  sampled_data[sampled_data_index++] = data[a]; // Always add the first point
  for (let i = 0; i < buckets - 2; i++) {
    // Calculate point average for next bucket (containing c)
    let avg_x = 0,
      avg_y = 0,
      avg_range_start = Math.floor((i + 1) * bucket_size) + 1,
      avg_range_end = Math.floor((i + 2) * bucket_size) + 1;
    avg_range_end = avg_range_end < data.length ? avg_range_end : data.length;
    let avg_range_length = avg_range_end - avg_range_start;
    for (; avg_range_start < avg_range_end; avg_range_start++) {
      avg_x += data[avg_range_start][0] * 1; // * 1 enforces Number (value may be Date)
      avg_y += data[avg_range_start][1] * 1;
    }
    avg_x /= avg_range_length;
    avg_y /= avg_range_length;
    // Get the range for this bucket
    let range_offs = Math.floor((i + 0) * bucket_size) + 1,
      range_to = Math.floor((i + 1) * bucket_size) + 1;
    // Point a
    let point_a_x = data[a][0] * 1, // enforce Number (value may be Date)
      point_a_y = data[a][1] * 1;
    max_area = area = -1;
    for (; range_offs < range_to; range_offs++) {
      // Calculate triangle area over three buckets
      area = Math.abs((point_a_x - avg_x) * (data[range_offs][1] - point_a_y) -
        (point_a_x - data[range_offs][0]) * (avg_y - point_a_y)
      ) * 0.5;
      if (area > max_area) {
        max_area = area;
        max_area_point = data[range_offs];
        // Next a is this b
        next_a = range_offs;
      }
    }
    // Pick this point from the bucket; it is the point with the maximum
    // area by value, but that point actually includes [h,s,v].
    sampled_data[sampled_data_index++] = max_area_point;
    a = next_a; // This a is the next a (chosen b)
  }
  sampled_data[sampled_data_index++] = data[data.length - 1]; // Always add last
  return sampled_data; ///sampled_data;
}

/**
  * In the browser, creates a URL for a soundfile recorded by this piece.
  * All such files exist by default in the Emscripten MEMFS filesystem 
  * at '/', and are automatically downloaded to the user's Downloads 
  * directory. The filename is returned.
  * 
  * Does nothing in NW.js, where files are written directly to the 
  * local filesystem.
  * 
  * @param {Csound} csound The Csound instance.
  * @returns {string} The name of the soundfile, if successful.
  */
async function url_for_soundfile(csound) {
  try {
    if (non_csound(csound)) {
      return;
    }
    if (!csound.GetFileData) {
      console.warn("csound.GetFileData() is not available.");
      return;
    }
    let soundfile_name = document.title + ".wav";
    let mime_type = "audio/wav";
    let file_data = await csound.GetFileData(soundfile_name);
    console.info(`Offering download of "${soundfile_name}", with ${file_data.length} bytes...`);
    var a = document.createElement('a');
    a.download = soundfile_name;
    a.href = URL.createObjectURL(new Blob([file_data], { type: mime_type }));
    a.style.display = 'none';
    document.body.appendChild(a);
    requestAnimationFrame(() => a.click());
    setTimeout(() => {
      document.body.removeChild(a);
      URL.revokeObjectURL(a.href);
    }, 2000);
    return soundfile_name;
  } catch (x) {
    console.error("Download failed:", x);
  }
}

/**
 * Generates a new copy(s) of the Score in canon to the original, at the 
 * specified delay in seconds and transposition in semitones (which may be 
 * fractional). If a Scale is supplied, the new Score is conformed to that 
 * Scale.
 * @param {CsoundAC.Score} Score or fragment of score. 
 * @param {number} delay in seconds.
 * @param {number} transposition in semitones.
 * @param {number} layers.
 * @param {CsoundAC.Scale} csoundac_scale if supplied, the new voice will 
 * be conformed to this scale.
 * @returns a modified {Score}
 */
function canon(CsoundAC, csoundac_score, delay, transposition, layers, csoundac_scale) {
  let new_score = new CsoundAC.Score();
  // Append both an event and that event in canon to the new Score.
  for (let layer = 1; layer <= layers; ++layer) {
    for (let i = 0; i < csoundac_score.size(); ++i) {
      let event = csoundac_score.get(i);
      if (layer == 1) {
        new_score.append_event(event);
      }
      let new_time = event.getTime() + (delay * layer);
      event.setTime(new_time);
      let new_key = event.getKey() + (transposition * layer);
      event.setKey(new_key);
      new_score.append_event(event);
    }
  }
  if (csoundac_scale) {
    CsoundAC.apply(new_score, csoundac_scale, 0, 1000000, true);
  }
  return new_score;
}

function get_filename(pathOrUrl) {
  const parts = pathOrUrl.split('/');
  let filename = parts.pop() || parts.pop(); // handles trailing slash
  const dotIndex = filename.lastIndexOf('.');
  if (dotIndex > 0) {
    return filename.slice(0, dotIndex);
  } else {
    return filename; // No extension
  }
}

// --- Monkey-patch CsoundAC.Score with Web MIDI playback (mirrors MandelbrotJulia.playMIDIFromScore) ---
(function attachScoreMIDIPatch(root = (typeof window !== "undefined" ? window : globalThis)) {
  if (globalThis.cloud5_score_midi_patch_installed) {
    return;
  }
  globalThis.cloud5_score_midi_patch_installed = true;
  const C = globalThis?.CsoundAC;
  if (!C || !C.Score) {
    console.warn('CsoundAC.Score not found; MIDI patch skipped.');
    return;
  }

  const Score = C.Score;

  // Private-ish state keys (per-document, not exported)
  const __midi = {
    access: null,
    outIdKey: 'cloud5.midiOutId',
    playing: false,
    timers: new Set(),
    startMS: 0,
    totalBeats: 0,
    bpm: 120,
  };

  // Expose MIDI scheduler state for the rest of cloud-5.
  globalThis.__midi = __midi;


  function clearTimers() {
    for (const id of __midi.timers) clearTimeout(id);
    __midi.timers.clear();
  }

  function msFromBeats(beats, bpm) {
    return (beats * 60 / Math.max(20, bpm | 0)) * 1000;
  }

  async function getMIDIAccess() {
    if (__midi.access) return __midi.access;
    if (!navigator.requestMIDIAccess) throw new Error('Web MIDI not supported in this browser.');
    __midi.access = await navigator.requestMIDIAccess({ sysex: false });
    return __midi.access;
  }

  function currentMIDIOutput() {
    const acc = __midi.access;
    if (!acc) return null;
    const outs = acc.outputs;
    if (!outs || outs.size === 0) return null;

    const saved = localStorage.getItem(__midi.outIdKey);
    if (saved && outs.has(saved)) return outs.get(saved);

    // Prefer IAC / Loopback / Bus style outputs; else first available
    for (const o of outs.values()) {
      if (/iac|loop|bus/i.test(o.name)) {
        localStorage.setItem(__midi.outIdKey, o.id);
        return o;
      }
    }
    const first = outs.values().next().value;
    if (first) localStorage.setItem(__midi.outIdKey, first.id);
    return first || null;
  }

  function panicAllNotes() {
    const out = currentMIDIOutput();
    if (!out) return;
    for (let ch = 0; ch < 16; ch++) {
      out.send([0xB0 | ch, 120, 0]); // All Sound Off
      out.send([0xB0 | ch, 123, 0]); // All Notes Off
      out.send([0xB0 | ch, 121, 0]); // Reset All Controllers
      for (let k = 0; k < 128; k += 8) out.send([0x80 | ch, k, 0]);
    }
  }

  // ---------------------------
  // Class (static) helper APIs
  // ---------------------------

  /**
   * Choose an output by (partial, case-insensitive) name; falls back to first match.
   * Returns the chosen MIDIOutput or null.
   */
  Score.select_midi_output_by_name = async function (namePart) {
    await getMIDIAccess();
    const outs = __midi.access?.outputs;
    if (!outs?.size) return null;
    const lower = String(namePart || '').toLowerCase();
    for (const o of outs.values()) {
      if (o.name.toLowerCase().includes(lower)) {
        localStorage.setItem(__midi.outIdKey, o.id);
        return o;
      }
    }
    return currentMIDIOutput();
  };

  /** Get the current MIDI output (or null). */
  Score.get_current_midi_output = function () {
    return currentMIDIOutput();
  };

  /** Global stop: cancels timers and sends panic. */
  Score.stop_play_midi = function () {
    __midi.playing = false;
    try { globalThis.cloud5_piece?.on_midi_stop?.(); } catch (e) { }

    __midi.totalBeats = 0;
    clearTimers();
    panicAllNotes();
  };

  /** Global panic only (does not clear timers). */
  Score.panic_midi = function () {
    panicAllNotes();
  };

  // ----------------------------------
  // Instance method: play this score
  // ----------------------------------

  /**
   * Play this CsoundAC.Score to Web MIDI.
   * @param {Object} opts
   * @param {number} [opts.bpm=120]            Beats per minute if times are in beats.
   * @param {boolean} [opts.time_is_beats=true]If false, interpret time/duration as seconds.
   */
  Score.prototype.play_midi = async function (opts = {}) {
    const { bpm = 120, time_is_beats = true } = opts;

    await getMIDIAccess();
    const out = currentMIDIOutput();
    if (!out) {
      console.warn('Score.play_midi: no MIDI outputs available.');
      return;
    }

    const n = (typeof this.size === 'function') ? this.size() : 0;
    if (!n) {
      console.warn('Score.play_midi: empty score.');
      return;
    }

    // Normalize to [ch, tBeats, dBeats, key, vel]
    const toBeat = (x) => time_is_beats ? x : (x * bpm / 60.0);
    const notes = [];

    for (let i = 0; i < n; ++i) {
      const ev = this.get(i);
      const ch = (ev.getChannel ? ev.getChannel() : ev.getInstrument?.()) | 0;
      const t = toBeat(ev.getTime ? ev.getTime() : 0);
      const d = toBeat(ev.getDuration ? ev.getDuration() : 0.25);
      const key = (ev.getKey ? ev.getKey() : 60) | 0;
      const vel = (ev.getVelocity ? ev.getVelocity() : 100) | 0;
      notes.push([(ch | 0) & 0x0F, t, d, (key | 0) & 0x7F, Math.max(1, Math.min(127, vel | 0))]);
    }

    __midi.bpm = bpm;
    __midi.playing = true;
    clearTimers();
    __midi.startMS = performance.now();

    try { globalThis.cloud5_piece?.on_midi_start?.(__midi.startMS); } catch (e) { }
    __midi.totalBeats = Math.max(0, ...notes.map(n => n[1] + n[2]));
    const t0 = __midi.startMS;

    for (const [ch, tBeats, dBeats, key, vel] of notes) {
      const on = 0x90 | ch;
      const off = 0x80 | ch;
      const whenOn = t0 + msFromBeats(tBeats, bpm);
      const whenOff = t0 + msFromBeats(tBeats + dBeats, bpm);

      __midi.timers.add(setTimeout(() => {
        if (__midi.playing) out.send([on, key, vel]);
      }, Math.max(0, whenOn - performance.now())));

      __midi.timers.add(setTimeout(() => {
        if (__midi.playing) out.send([off, key, 0]);
      }, Math.max(0, whenOff - performance.now())));
    }

    // Auto-stop slightly after last note
    __midi.timers.add(setTimeout(() => Score.stop_play_midi(),
      Math.ceil(msFromBeats(__midi.totalBeats, bpm) + 50)));
  };

  // Optional: convenience alias (static) to play any score
  Score.play_midi = async function (score, opts) {
    if (!score || typeof score.play_midi !== 'function') {
      throw new Error('Score.play_midi(score, opts): invalid score object');
    }
    return score.play_midi(opts);
  };

  console.info('CsoundAC.Score MIDI monkey-patch installed.');
})();

function is_plain_object(value) {
  if (value === null || typeof value !== 'object') {
    return false;
  }
  const prototype = Object.getPrototypeOf(value);
  return prototype === Object.prototype || prototype === null;
}

function deep_merge_into(target, patch, options = {}) {
  const {
    ignore_null = true,
    allow_new_keys = true
  } = options;

  if (target === null || target === undefined) {
    return target;
  }

  if (patch === null || patch === undefined) {
    return target;
  }

  for (const key of Object.keys(patch)) {
    if (!allow_new_keys && !(key in target)) {
      continue;
    }

    const incoming = patch[key];

    if (incoming === null && ignore_null) {
      continue;
    }

    const current = target[key];

    // Arrays: preserve array identity by mutating in place.
    if (Array.isArray(incoming)) {
      if (Array.isArray(current)) {
        current.length = incoming.length;

        for (let i = 0; i < incoming.length; ++i) {
          const incoming_item = incoming[i];
          const current_item = current[i];

          if (is_plain_object(incoming_item) && is_plain_object(current_item)) {
            deep_merge_into(current_item, incoming_item, options);
          } else if (
            is_plain_object(incoming_item) &&
            current_item &&
            typeof current_item === 'object' &&
            !is_plain_object(current_item) &&
            !Array.isArray(current_item)
          ) {
            deep_merge_into(current_item, incoming_item, options);
          } else if (Array.isArray(incoming_item) && Array.isArray(current_item)) {
            current[i] = current_item;
            deep_merge_into_array(current_item, incoming_item, options);
          } else {
            current[i] = clone_for_merge(incoming_item);
          }
        }
      } else {
        target[key] = clone_for_merge(incoming);
      }

      continue;
    }

    // Plain object into plain object: mutate existing object in place.
    if (is_plain_object(incoming) && is_plain_object(current)) {
      deep_merge_into(current, incoming, options);
      continue;
    }

    // Plain object into class instance: mutate existing instance in place.
    if (
      is_plain_object(incoming) &&
      current &&
      typeof current === 'object' &&
      !is_plain_object(current) &&
      !Array.isArray(current)
    ) {
      deep_merge_into(current, incoming, options);
      continue;
    }

    // Primitive or replacement value.
    target[key] = clone_for_merge(incoming);
  }

  return target;
}

function deep_merge_into_array(target_array, patch_array, options = {}) {
  target_array.length = patch_array.length;

  for (let i = 0; i < patch_array.length; ++i) {
    const incoming_item = patch_array[i];
    const current_item = target_array[i];

    if (Array.isArray(incoming_item) && Array.isArray(current_item)) {
      deep_merge_into_array(current_item, incoming_item, options);
    } else if (is_plain_object(incoming_item) && is_plain_object(current_item)) {
      deep_merge_into(current_item, incoming_item, options);
    } else if (
      is_plain_object(incoming_item) &&
      current_item &&
      typeof current_item === 'object' &&
      !is_plain_object(current_item) &&
      !Array.isArray(current_item)
    ) {
      deep_merge_into(current_item, incoming_item, options);
    } else {
      target_array[i] = clone_for_merge(incoming_item);
    }
  }

  return target_array;
}

function clone_for_merge(value) {
  if (Array.isArray(value)) {
    return value.map(clone_for_merge);
  }

  if (is_plain_object(value)) {
    const result = {};

    for (const key of Object.keys(value)) {
      result[key] = clone_for_merge(value[key]);
    }

    return result;
  }

  return value;
}

function merge_json_into_instance(instance, json_string, options) {
  const patch = JSON.parse(json_string);
  return deep_merge_into(instance, patch, options);
}

function is_object(value) {
  return value !== null && typeof value === 'object' && !Array.isArray(value);
}

function update_gui_displays(gui) {
  if (gui.__controllers) {
    for (const c of gui.__controllers) {
      c.updateDisplay();
    }
  }
  if (gui.__folders) {
    for (const name of Object.keys(gui.__folders)) {
      update_gui_displays(gui.__folders[name]);
    }
  }
}

function cloud5_get_last_snapshot_title() {
  try {
    const v = localStorage.getItem(CLOUD5_LAST_SNAPSHOT_TITLE_KEY);
    return v ? String(v) : null;
  } catch (e) {
    return null;
  }
}

function cloud5_set_last_snapshot_title(title) {
  try {
    localStorage.setItem(CLOUD5_LAST_SNAPSHOT_TITLE_KEY, String(title || ""));
  } catch (e) {
  }
}

async function cloud5_save_data(data, filename, directory_handle) {
  try {
    if (!directory_handle) {
      directory_handle = await cloud5_try_get_snapshot_dir_handle();
    }
    if (directory_handle) {
      const file_handle = await directory_handle.getFileHandle(filename, { create: true });
      const writable = await file_handle.createWritable();
      await writable.write(data);
      await writable.close();
      return;
    }
  } catch (e) {
    // fall through to blob download.
  }
  const content = (typeof data === 'string')
    ? data
    : JSON.stringify(data, null, 2);
  const blob = new Blob([content], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}