← Back to Sensor Ticker and Virtual MIDI Port
Source Code
Sensor Ticker and Virtual MIDI Port · javascript
'use strict';
require('dotenv').config();
let droneCycleCounter = 0; // Counts how many times the drone engine has run
const express = require('express');
const http = require('http');
const { Server } = require("socket.io");
const { ArduinoIoTCloud } = require('arduino-iot-js');
const fs = require('fs').promises;
const path = require('path');
const easymidi = require('easymidi');
// =================================================================
// --- Configuration ---
// =================================================================
const port = Number(process.env.PORT) || 3000;
const DRONE_INTERVAL_MS = 10000; // Check to see if the drone notes need to change every 10 seconds.
const INITIAL_RECONNECT_DELAY_MS = 2000;
const MAX_RECONNECT_DELAY_MS = 30000;
const thingsToMonitor = [
{
thingId: process.env.THING_ID_1,
variableNames: ['waterTempF', 'airLvl', 'humLvl', 'tdsPPM', 'waterLvl','ldrPinLvl']
},
{
thingId: process.env.THING_ID_2,
variableNames: ['foodStatus', 'serverStatus']
}
];
const options = {
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
onDisconnect: () => {
isCloudConnected = false;
console.warn('Disconnected from Arduino Cloud. Will attempt reconnect.');
if (!isShuttingDown && !reconnectInProgress) {
connectCloudWithRetry().catch(error => {
console.error('Reconnect loop failed:', error);
});
}
},
};
const LOG_FILE_PATH = path.join(__dirname, 'sensor_log.json');
const MAX_LOG_ENTRIES = 50;
const MIDI_DEBUG = process.env.MIDI_DEBUG === 'true';
// =================================================================
// --- Global State Management ---
// =================================================================
let sensorReadings = {};
let midiOutput;
let midiOutputName = null;
try {
const configuredOutputName = process.env.MIDI_OUTPUT_NAME;
const availableOutputs = easymidi.getOutputs();
const isWindows = process.platform === 'win32';
const useVirtualOutput = !isWindows && process.env.MIDI_USE_VIRTUAL !== 'false';
if (useVirtualOutput) {
midiOutputName = 'Aquarium Symphony';
midiOutput = new easymidi.Output(midiOutputName, true);
console.log(`Virtual MIDI port '${midiOutputName}' created with easymidi.`);
} else {
const selectedOutput = configuredOutputName && availableOutputs.includes(configuredOutputName)
? configuredOutputName
: availableOutputs[0];
if (!selectedOutput) {
throw new Error(
'No MIDI output ports are available. On Windows, create one with loopMIDI and set MIDI_OUTPUT_NAME.'
);
}
midiOutputName = selectedOutput;
midiOutput = new easymidi.Output(selectedOutput);
console.log(`Using existing MIDI output port: ${selectedOutput}`);
}
if (availableOutputs.length > 0) {
console.log(`Detected MIDI outputs: ${availableOutputs.join(', ')}`);
} else {
console.warn('No existing MIDI outputs detected.');
}
} catch (error) {
console.error('Failed to create MIDI output port:', error);
process.exit(1);
}
// A pleasant C-major 7 scale for our notes
const musicalScale = [48, 52, 55, 59, 60, 64, 67, 71]; // C3, E3, G3, B3, C4, E4, G4, B4
let latestSensorValues = {}; // The most recent value for each sensor.
let activeDrones = { // The MIDI note number of the currently playing drone notes.
airLvlNote: null,
humLvlNote: null,
waterTempFNote: null,
tdsPPMNote: null,
ldrPinLvlNote: null,
};
let lastWaterLvlState = -1; // State for the water level alert to prevent spam.
// State for file writing queue
let isWritingFile = false;
let writeIsPending = false;
let droneIntervalId = null;
let isShuttingDown = false;
let isCloudConnected = false;
let reconnectInProgress = false;
let callbacksRegistered = false;
// =================================================================
// --- Helper Functions ---
// =================================================================
function isFiniteNumber(value) {
return typeof value === 'number' && Number.isFinite(value);
}
function toFiniteNumber(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
function positiveModulo(value, divisor) {
return ((value % divisor) + divisor) % divisor;
}
function clampMidiNote(note) {
return Math.max(0, Math.min(127, Math.floor(note)));
}
function safeMidiSend(messageType, payload) {
try {
midiOutput.send(messageType, payload);
if (MIDI_DEBUG) {
console.log(`MIDI -> ${messageType} on ${midiOutputName}:`, payload);
}
} catch (error) {
console.error(`MIDI send failed for ${messageType}:`, error);
}
}
function ensureSensorBuckets() {
thingsToMonitor.forEach(thing => {
thing.variableNames.forEach(name => {
if (!Array.isArray(sensorReadings[name])) {
sensorReadings[name] = [];
}
});
});
}
// This new helper function handles the logic for a single drone voice.
function updateDroneNote(sensorName, currentValue, activeNote, velocity, octaveOffset = 0) {
const parsedValue = toFiniteNumber(currentValue);
if (parsedValue !== null) {
// Use a different calculation for TDS because its range is small
const intValue = (sensorName === 'tdsPPM') ? Math.floor(parsedValue) : Math.floor(parsedValue * 10);
const scaleIndex = positiveModulo(intValue, musicalScale.length);
let newNote = clampMidiNote(musicalScale[scaleIndex] + octaveOffset);
if (newNote !== activeNote) {
if (activeNote !== null) {
safeMidiSend('noteoff', { note: activeNote, velocity: 0, channel: 0 });
}
safeMidiSend('noteon', { note: newNote, velocity: velocity, channel: 0 });
console.log(`DRONE ENGINE -> Note for ${sensorName} changed to: ${newNote}`);
return newNote; // Return the new note so we can update the state
}
}
return activeNote; // Return the old note if no change
}
// =================================================================
// --- THE DRONE ENGINE ---
// This function runs on a timer to sustain the atmospheric notes.
// =================================================================
function updateDrones() {
// Increment the counter each time the function is called
droneCycleCounter++;
/*
// --- The new logic: On every 4th cycle, send a command to the website ---
if (droneCycleCounter % 4 === 0) {
console.log('DRONE ENGINE -> Cycle 4 reached. Sending command to randomize LFO.');
io.emit('trigger-function', { action: 'randomize-lfo' });
}
*/
// The rest of the note-playing logic remains the same
activeDrones.airLvlNote = updateDroneNote('airLvl', latestSensorValues.airLvl, activeDrones.airLvlNote, 70, 0); // Bass note
activeDrones.humLvlNote = updateDroneNote('humLvl', latestSensorValues.humLvl, activeDrones.humLvlNote, 60, 12); // Harmony 1 (one octave up)
activeDrones.waterTempFNote = updateDroneNote('waterTempF', latestSensorValues.waterTempF, activeDrones.waterTempFNote, 65, 7); // Harmony 2 (a fifth up)
activeDrones.tdsPPMNote = updateDroneNote('tdsPPM', latestSensorValues.tdsPPM, activeDrones.tdsPPMNote, 55, -12); // Sub-bass (one octave down)
activeDrones.ldrPinLvlNote = updateDroneNote('ldrPinLvl', latestSensorValues.ldrPinLvl, activeDrones.ldrPinLvl, 44, -6); // I dunno just guessing here
}
// =================================================================
// --- Main Application Setup ---
// =================================================================
// Web Server and Socket.IO
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static('public'));
app.get('/midi-test', (req, res) => {
const note = 72;
const velocity = 100;
safeMidiSend('noteon', { note, velocity, channel: 0 });
setTimeout(() => {
safeMidiSend('noteoff', { note, velocity: 0, channel: 0 });
}, 250);
res.json({
ok: true,
message: 'Sent MIDI test pulse (C5).',
output: midiOutputName,
channel: 0,
note,
});
});
io.on('connection', (socket) => {
console.log('A user connected to the webpage.');
});
function registerThingCallbacks() {
if (callbacksRegistered) {
return;
}
thingsToMonitor.forEach(thing => {
if (!thing.thingId) {
console.warn('Skipping thing with missing thingId in environment variables.');
return;
}
thing.variableNames.forEach(variableName => {
ArduinoIoTCloud.onPropertyValue(
thing.thingId,
variableName,
(value) => {
console.log(`Received value for ${variableName}:`, value);
latestSensorValues[variableName] = value;
// The main callback now ONLY handles the water level alert.
// All other musical logic is in the Drone Engine.
if (variableName === 'waterLvl') {
const numericValue = toFiniteNumber(value);
if (numericValue !== null) {
const currentState = Math.floor(numericValue);
if (currentState !== lastWaterLvlState) {
if (currentState < 1) { // Water is LOW
safeMidiSend('noteon', { note: 36, velocity: 127, channel: 0 }); // Low C alert
} else { // Water is OK
safeMidiSend('noteoff', { note: 36, velocity: 0, channel: 0 });
}
lastWaterLvlState = currentState;
}
}
}
// Handle logging and web dashboard updates (unchanged).
if (!sensorReadings[variableName]) sensorReadings[variableName] = [];
const timestamp = new Date().toISOString();
sensorReadings[variableName].unshift({ value, timestamp });
if (sensorReadings[variableName].length > MAX_LOG_ENTRIES) sensorReadings[variableName].pop();
writeIsPending = true;
processWriteQueue();
io.emit('variable-update', { name: variableName, value: value, timestamp });
}
);
console.log(`Callback registered for Variable: ${variableName} on Thing: ${thing.thingId}`);
});
});
callbacksRegistered = true;
}
async function connectCloudWithRetry() {
if (reconnectInProgress) return;
reconnectInProgress = true;
let reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
try {
while (!isShuttingDown) {
try {
await ArduinoIoTCloud.connect(options);
isCloudConnected = true;
console.log('Connected to Arduino IoT Cloud');
registerThingCallbacks();
return;
} catch (error) {
console.error(`Cloud connection failed. Retrying in ${reconnectDelay / 1000}s.`, error);
await new Promise(resolve => setTimeout(resolve, reconnectDelay));
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
}
}
} finally {
reconnectInProgress = false;
}
}
function stopAllActiveNotes() {
const notes = [
activeDrones.airLvlNote,
activeDrones.humLvlNote,
activeDrones.waterTempFNote,
activeDrones.tdsPPMNote,
activeDrones.ldrPinLvl,
36,
];
notes.forEach(note => {
if (isFiniteNumber(note)) {
safeMidiSend('noteoff', { note, velocity: 0, channel: 0 });
}
});
}
async function shutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`Received ${signal}. Shutting down gracefully...`);
if (droneIntervalId) {
clearInterval(droneIntervalId);
droneIntervalId = null;
}
stopAllActiveNotes();
if (writeIsPending) {
await processWriteQueue();
}
await new Promise(resolve => {
server.close(() => resolve());
});
try {
midiOutput.close();
} catch (error) {
console.error('Error closing MIDI output:', error);
}
process.exit(0);
}
process.on('SIGINT', () => {
shutdown('SIGINT').catch(error => {
console.error('Shutdown failure:', error);
process.exit(1);
});
});
process.on('SIGTERM', () => {
shutdown('SIGTERM').catch(error => {
console.error('Shutdown failure:', error);
process.exit(1);
});
});
process.on('uncaughtException', error => {
console.error('Uncaught exception:', error);
});
process.on('unhandledRejection', reason => {
console.error('Unhandled rejection:', reason);
});
// Main execution function
async function main() {
await loadInitialData();
droneIntervalId = setInterval(updateDrones, DRONE_INTERVAL_MS);
console.log(`Drone Engine started. Will check notes every ${DRONE_INTERVAL_MS / 1000} seconds.`);
await connectCloudWithRetry();
}
// Start the server
server.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
main().catch(error => {
console.error('Fatal startup error:', error);
process.exit(1);
});
});
// --- File I/O Helper Functions (Unchanged) ---
async function processWriteQueue() {
if (isWritingFile || !writeIsPending) return;
isWritingFile = true;
writeIsPending = false;
try {
const jsonString = JSON.stringify(sensorReadings, null, 2);
await fs.writeFile(LOG_FILE_PATH, jsonString, 'utf8');
} catch (err) {
console.error("Error writing to log file:", err);
} finally {
isWritingFile = false;
if (writeIsPending) processWriteQueue();
}
}
async function loadInitialData() {
try {
await fs.access(LOG_FILE_PATH);
const data = await fs.readFile(LOG_FILE_PATH, 'utf8');
sensorReadings = JSON.parse(data);
ensureSensorBuckets();
} catch (err) {
console.warn('No valid existing sensor log found. Initializing empty readings.');
sensorReadings = {};
ensureSensorBuckets();
}
}