diff --git a/Data/WebUI/src/nodes/Alerting/Node_Alert_Sound.jsx b/Data/WebUI/src/nodes/Alerting/Node_Alert_Sound.jsx new file mode 100644 index 0000000..9a11fb6 --- /dev/null +++ b/Data/WebUI/src/nodes/Alerting/Node_Alert_Sound.jsx @@ -0,0 +1,323 @@ +/** + * ================================================== + * Borealis - Alert Sound Node (with Base64 Restore) + * ================================================== + * + * COMPONENT ROLE: + * Plays a sound when input = "1". Provides a visual indicator: + * - Green dot: input is 0 + * - Red dot: input is 1 + * + * Modes: + * - "Once": Triggers once when going 0 -> 1 + * - "Constant": Triggers repeatedly every X ms while input = 1 + * + * Supports embedding base64 audio directly into the workflow. + */ + +import React, { useEffect, useRef, useState } from "react"; +import { Handle, Position, useReactFlow, useStore } from "reactflow"; + +if (!window.BorealisValueBus) window.BorealisValueBus = {}; +if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100; + +const AlertSoundNode = ({ id, data }) => { + const edges = useStore(state => state.edges); + const { setNodes } = useReactFlow(); + + const [alertType, setAlertType] = useState(data?.alertType || "Once"); + const [intervalMs, setIntervalMs] = useState(data?.interval || 1000); + const [prevInput, setPrevInput] = useState("0"); + const [customAudioBase64, setCustomAudioBase64] = useState(data?.audio || null); + const [currentInput, setCurrentInput] = useState("0"); + + const audioRef = useRef(null); + + const playSound = () => { + if (audioRef.current) { + console.log(`[Alert Node ${id}] Attempting to play sound`); + try { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + audioRef.current.load(); + audioRef.current.play().then(() => { + console.log(`[Alert Node ${id}] Sound played successfully`); + }).catch((err) => { + console.warn(`[Alert Node ${id}] Audio play blocked or errored:`, err); + }); + } catch (err) { + console.error(`[Alert Node ${id}] Failed to play sound:`, err); + } + } else { + console.warn(`[Alert Node ${id}] No audioRef loaded`); + } + }; + + const handleFileUpload = (event) => { + const file = event.target.files[0]; + if (!file) return; + + console.log(`[Alert Node ${id}] File selected:`, file.name, file.type); + + const supportedTypes = ["audio/wav", "audio/mp3", "audio/mpeg", "audio/ogg"]; + if (!supportedTypes.includes(file.type)) { + console.warn(`[Alert Node ${id}] Unsupported audio type: ${file.type}`); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const base64 = e.target.result; + const mimeType = file.type || "audio/mpeg"; + const safeURL = base64.startsWith("data:") + ? base64 + : `data:${mimeType};base64,${base64}`; + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ""; + audioRef.current.load(); + audioRef.current = null; + } + + const newAudio = new Audio(); + newAudio.src = safeURL; + + let readyFired = false; + + newAudio.addEventListener("canplaythrough", () => { + if (readyFired) return; + readyFired = true; + console.log(`[Alert Node ${id}] Audio is decodable and ready: ${file.name}`); + + setCustomAudioBase64(safeURL); + audioRef.current = newAudio; + newAudio.load(); + + setNodes(nds => + nds.map(n => + n.id === id + ? { ...n, data: { ...n.data, audio: safeURL } } + : n + ) + ); + }); + + setTimeout(() => { + if (!readyFired) { + console.warn(`[Alert Node ${id}] WARNING: Audio not marked ready in time. May fail silently.`); + } + }, 2000); + }; + + reader.onerror = (e) => { + console.error(`[Alert Node ${id}] File read error:`, e); + }; + + reader.readAsDataURL(file); + }; + + // Restore embedded audio from saved workflow + useEffect(() => { + if (customAudioBase64) { + console.log(`[Alert Node ${id}] Loading embedded audio from workflow`); + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ""; + audioRef.current.load(); + audioRef.current = null; + } + + const loadedAudio = new Audio(customAudioBase64); + loadedAudio.addEventListener("canplaythrough", () => { + console.log(`[Alert Node ${id}] Embedded audio ready`); + }); + + audioRef.current = loadedAudio; + loadedAudio.load(); + } else { + console.log(`[Alert Node ${id}] No custom audio, using fallback silent wav`); + audioRef.current = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YRAAAAAA"); + audioRef.current.load(); + } + }, [customAudioBase64]); + + useEffect(() => { + let currentRate = window.BorealisUpdateRate; + let intervalId = null; + + const runLogic = () => { + const inputEdge = edges.find(e => e.target === id); + const sourceId = inputEdge?.source || null; + const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0"; + + setCurrentInput(val); + + if (alertType === "Once") { + if (val === "1" && prevInput !== "1") { + console.log(`[Alert Node ${id}] Triggered ONCE playback`); + playSound(); + } + } + + setPrevInput(val); + }; + + const start = () => { + if (alertType === "Constant") { + intervalId = setInterval(() => { + const inputEdge = edges.find(e => e.target === id); + const sourceId = inputEdge?.source || null; + const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0"; + setCurrentInput(val); + if (String(val) === "1") { + console.log(`[Alert Node ${id}] Triggered CONSTANT playback`); + playSound(); + } + }, intervalMs); + } else { + intervalId = setInterval(runLogic, currentRate); + } + }; + + start(); + + const monitor = setInterval(() => { + const newRate = window.BorealisUpdateRate; + if (newRate !== currentRate && alertType === "Once") { + currentRate = newRate; + clearInterval(intervalId); + start(); + } + }, 250); + + return () => { + clearInterval(intervalId); + clearInterval(monitor); + }; + }, [edges, alertType, intervalMs, prevInput]); + + const indicatorColor = currentInput === "1" ? "#ff4444" : "#44ff44"; + + return ( +