feat: add diagnostics page and functionality, enhance transport settings in index.html
- Introduced a new diagnostics page (diagnostics.html) with real-time monitoring for Art-Net, sACN, MQTT, and KNX. - Added JavaScript functionality (diagnostics.js) to handle diagnostics data and render updates. - Enhanced index.html with new transport settings including timeline length, beat snap options, and diagnostics button. - Updated styles.css to accommodate new diagnostics layout and transport settings. - Added splash screen (splash.html) for improved user experience during startup. - Included new assets: Time4Midi.psd and updated executable files for version 0.3.6.
This commit is contained in:
@@ -1,42 +1,284 @@
|
||||
<<<<<<< HEAD
|
||||
# Time4Midi
|
||||
|
||||
Desktop-Prototyp fuer einen plattformuebergreifenden Timeline-Editor fuer:
|
||||
<p align="center">
|
||||
<img src="./time4midi.png" alt="Time4Midi Logo" width="220">
|
||||
</p>
|
||||
|
||||
- MIDI Notes
|
||||
- Art-Net DMX Values
|
||||
- externe Synchronisation via MIDI Timecode (MTC)
|
||||
- externe Synchronisation via Art-Net Timecode
|
||||
<p align="center">
|
||||
Desktop-Timeline fuer Show-Control, MIDI, DMX, Audio und technische Automationen.
|
||||
</p>
|
||||
|
||||
## Aktueller Stand
|
||||
<p align="center">
|
||||
<img src="./JHArtworks2023_wh.png" alt="J.H. Artworks" width="180">
|
||||
</p>
|
||||
|
||||
Die erste Version enthaelt bereits:
|
||||
## Was ist Time4Midi?
|
||||
|
||||
- mehrere Timelines pro Projekt
|
||||
- Speichern und Laden als JSON
|
||||
- Tracks fuer `MIDI Note` und `Art-Net Channel`
|
||||
- Punkt-/Keyframe-Editing direkt auf dem Zeitstrahl
|
||||
- `Hold` und `Linear` pro Segment
|
||||
- konfigurierbare MIDI In/Out Ports
|
||||
- konfigurierbares Art-Net Ziel inklusive Broadcast/Unicast
|
||||
- Live-Playhead fuer `Internal`, `MTC` und `Art-Net Timecode`
|
||||
Time4Midi ist eine Electron-Desktop-App fuer zeitbasierte Show-Automation. Du legst Punkte auf einer Timeline an und steuerst damit je nach Track-Typ zum Beispiel:
|
||||
|
||||
## Starten
|
||||
- MIDI Notes und MIDI CC
|
||||
- Art-Net und sACN / E1.31
|
||||
- OSC
|
||||
- MQTT
|
||||
- Serial
|
||||
- Modbus TCP / RTU
|
||||
- KNX
|
||||
- Audio-Tracks mit Waveform, Beat-Raster und Fade-Automation
|
||||
|
||||
Die App ist darauf ausgelegt, waehrend einer Show schnell lesbar zu bleiben: links Projekt und Tracks, in der Mitte die Timeline, rechts der Inspector fuer den aktuell gewaehlten Track oder Punkt.
|
||||
|
||||
## Auf einen Blick
|
||||
|
||||
- Mehrere Timelines pro Projekt
|
||||
- Freie Timeline-Laenge bis 24 Stunden
|
||||
- Zoomen mit `Strg + Mausrad`
|
||||
- Horizontal scrollen mit dem Mausrad
|
||||
- Auto-Follow des Timecursors beim Abspielen
|
||||
- Audio-Waveform mit Beat-, Takt- und Drop-Markern
|
||||
- Beat-Snap fuer Punkte auf anderen Tracks
|
||||
- gelbe Snap-Linien ueber alle Tracks mit einstellbarer Staerke
|
||||
- Diagnosefenster fuer Art-Net, sACN, MQTT und KNX
|
||||
- Mapping-Bibliotheken fuer wiederverwendbare Zieldefinitionen
|
||||
- Splashscreen beim Start mit Logo und Ladebalken
|
||||
- Speichern und Laden als JSON-Projektdatei
|
||||
|
||||
## Bedienkonzept
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Projekt anlegen"] --> B["Timeline waehlen"]
|
||||
B --> C["Tracks hinzufuegen"]
|
||||
C --> D["Punkte setzen oder Audio laden"]
|
||||
D --> E["Ausgaenge konfigurieren"]
|
||||
E --> F["Play / Timecode / Diagnose"]
|
||||
```
|
||||
|
||||
## Aufbau der Oberflaeche
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["Sidebar<br/>Projekt, Timelines, Tracks"] --> B["Editor<br/>Timeline, Zoom, Scroll, Playhead"]
|
||||
B --> C["Inspector<br/>Track- und Punktparameter"]
|
||||
B --> D["Transportleiste<br/>Play, Sync, Timeline-Laenge, Beat-Snap"]
|
||||
D --> E["Diagnosefenster<br/>Live-Werte fuer Netzwerk-Outputs"]
|
||||
```
|
||||
|
||||
## Wichtige Features im Detail
|
||||
|
||||
### 1. Timeline und Keyframes
|
||||
|
||||
- Punkte lassen sich per Doppelklick setzen.
|
||||
- Punkte koennen mit der Maus verschoben werden.
|
||||
- Je nach Track-Typ stehen `Hold` oder `Linear` als Interpolation zur Verfuegung.
|
||||
- Die Timeline-Laenge kann direkt oben in der Transportleiste eingestellt werden.
|
||||
|
||||
### 2. Audio-Analyse
|
||||
|
||||
Audio-Tracks koennen mehr als nur abspielen:
|
||||
|
||||
- Waveform wird direkt in der Timeline angezeigt
|
||||
- erkannte Beats werden als feine Marker eingezeichnet
|
||||
- erkannte Taktanfange werden hervorgehoben
|
||||
- erkannte Drops werden markiert
|
||||
- Audio-Clips koennen auf der Timeline nach links und rechts verschoben werden
|
||||
- Fade-Punkte im Audiotrack steuern den Gain ueber die Zeit
|
||||
- das aktuell gewaehlte Snap-Muster wird als gelbes Raster sichtbar
|
||||
|
||||
Hinweis:
|
||||
Wenn ein geladenes Audio laenger ist als die aktuelle Timeline, wird die Timeline automatisch verlaengert. Ist die Timeline schon laenger als das Audio, bleibt sie unveraendert.
|
||||
|
||||
### 3. Beat-Snap
|
||||
|
||||
Wenn ein Audiotrack analysiert wurde, kann dessen Beat-Raster als Fangnetz fuer andere Tracks dienen.
|
||||
|
||||
- `Beat Snap` aktivieren
|
||||
- `Snap Raster` waehlen
|
||||
- Punkte auf MIDI-, DMX-, OSC-, MQTT- oder anderen Tracks setzen oder ziehen
|
||||
- Punkte rasten auf das Audio-Raster ein
|
||||
|
||||
Verfuegbare Raster:
|
||||
|
||||
- `1/4 Beat`
|
||||
- `1/2 Beat`
|
||||
- `1 Beat`
|
||||
- `2 Beats`
|
||||
- `1 Takt`
|
||||
- `2 Takte`
|
||||
|
||||
Damit kannst du z. B. in einem 4/4-Track nur jeden zweiten Takt oder eben halbe Beats als Raster verwenden.
|
||||
|
||||
Zusaetzlich:
|
||||
|
||||
- das aktive Snap-Muster wird als gelbe Linien in der Timeline gespiegelt
|
||||
- die Linienstaerke kann direkt oben in der Transportleiste eingestellt werden
|
||||
- Punkte lassen sich trotz Snap weiter sauber ziehen und springen stabil Raster fuer Raster
|
||||
|
||||
### 4. Diagnosefenster
|
||||
|
||||
Das Diagnosefenster ist gedacht, damit du waehrend einer laufenden Show sehen kannst, was wirklich rausgeht.
|
||||
|
||||
Aktuell enthalten:
|
||||
|
||||
- Art-Net Universe + aktive Kanaele
|
||||
- sACN Universe + aktive Kanaele
|
||||
- MQTT Topics + letzter gesendeter Wert
|
||||
- KNX Gruppenadresse + letzter gesendeter Wert
|
||||
- Verlauf der letzten Events
|
||||
|
||||
### 5. Mapping-Bibliotheken
|
||||
|
||||
Mappings koennen projektunabhaengig gespeichert werden. Damit kannst du technische Ziele mit sprechenden Namen versehen, zum Beispiel:
|
||||
|
||||
- `MIDI Note 1 = Strobe`
|
||||
- `Art-Net Universe 4 / Channel 7 = Shutter`
|
||||
- `KNX 1/2/20 = Preset Licht`
|
||||
|
||||
Diese Bibliotheken lassen sich in den Einstellungen pro Treiber pflegen, speichern und laden.
|
||||
|
||||
## Unterstuetzte Sync-Quellen
|
||||
|
||||
- Internal
|
||||
- MIDI Timecode `MTC`
|
||||
- Art-Net Timecode
|
||||
|
||||
## Unterstuetzte Track-Typen
|
||||
|
||||
| Bereich | Track-Typen |
|
||||
| --- | --- |
|
||||
| MIDI | Note, CC |
|
||||
| DMX | Art-Net Channel, sACN Channel |
|
||||
| Netzwerk | OSC, MQTT |
|
||||
| Feldbus / Technik | KNX, Modbus TCP, Modbus RTU |
|
||||
| Serielle Ausgaben | Serial |
|
||||
| Media | Audio File |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Node.js
|
||||
- npm
|
||||
- Windows fuer den Portable-Build
|
||||
|
||||
### Entwicklung starten
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Unter Windows kannst du alternativ einfach die Datei `start-time4midi.cmd` im Projektordner doppelklicken.
|
||||
Unter Windows kannst du alternativ auch `start-time4midi.cmd` benutzen.
|
||||
|
||||
## Naechste sinnvolle Ausbaustufen
|
||||
### Windows Portable Build
|
||||
|
||||
1. Mehr Track-Typen: MIDI CC, Program Change, komplette DMX-Ranges
|
||||
2. Copy/Paste, Zoom, Snapping, Loop, Undo/Redo
|
||||
3. bessere MTC-Unterstuetzung inkl. Full-Frame und Locate
|
||||
4. Projektbrowser, mehrere Cue-Listen, Szenen und Trigger
|
||||
5. Packaging fuer Windows, macOS und Linux
|
||||
=======
|
||||
# Time4Midi
|
||||
>>>>>>> 15ae412fbc06f021a6c052af2af6ea2f12d870e6
|
||||
```bash
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
## Schritt-fuer-Schritt Anleitung
|
||||
|
||||
### Ein erstes Projekt anlegen
|
||||
|
||||
1. App starten.
|
||||
2. Ein neues Projekt anlegen.
|
||||
3. Eine Timeline waehlen oder eine neue erzeugen.
|
||||
4. Oben die gewuenschte Timeline-Laenge eintragen.
|
||||
5. Die benoetigten Tracks links hinzufuegen.
|
||||
|
||||
### MIDI oder DMX programmieren
|
||||
|
||||
1. Einen passenden Track hinzufuegen.
|
||||
2. Rechts im Inspector Ziel, Kanal, Universe oder Adresse setzen.
|
||||
3. Punkte per Doppelklick in die Timeline setzen.
|
||||
4. Werte und Interpolation im Inspector feinjustieren.
|
||||
|
||||
### Audio laden und als Raster nutzen
|
||||
|
||||
1. Einen `Audio`-Track anlegen.
|
||||
2. Eine Datei waehlen.
|
||||
3. Warten, bis Waveform und Analyse erscheinen.
|
||||
4. Falls gewuenscht `Beat Snap` aktivieren.
|
||||
5. Das `Snap Raster` waehlen.
|
||||
6. Optional die Staerke der gelben Snap-Linien einstellen.
|
||||
7. Punkte auf anderen Tracks setzen oder verschieben.
|
||||
|
||||
### Audio-Fades zeichnen
|
||||
|
||||
1. Den Audio-Track auswaehlen.
|
||||
2. Per Doppelklick direkt im Audiotrack Fade-Punkte setzen.
|
||||
3. Punkte ziehen, um Zeit und Gain zu aendern.
|
||||
4. Im Inspector `Zeit im Clip`, `Gain` und Interpolation anpassen.
|
||||
|
||||
### Tastatursteuerung fuer Snap
|
||||
|
||||
Wenn ein Punkt markiert ist:
|
||||
|
||||
1. `Pfeil links` springt zum vorherigen Beat bzw. Takt des aktiven Snap-Rasters.
|
||||
2. `Pfeil rechts` springt zum naechsten Beat bzw. Takt.
|
||||
3. `Strg + Pfeil links/rechts` springt um 4 Takte zurueck oder vor.
|
||||
4. `Bild auf / Bild ab` scrollt horizontal durch die Timeline.
|
||||
|
||||
### Netzwerk-Ausgaenge pruefen
|
||||
|
||||
1. Transport starten.
|
||||
2. `Diagnose` oeffnen.
|
||||
3. Live beobachten, welche Werte auf welchen Universes, Kanaelen oder Topics ankommen.
|
||||
|
||||
## Projektdateien
|
||||
|
||||
Time4Midi speichert Projekte als JSON. Darin enthalten sind unter anderem:
|
||||
|
||||
- Projektname
|
||||
- Timelines
|
||||
- Tracks
|
||||
- Punkte / Automation
|
||||
- Routing
|
||||
- ausgewaehlte Sync-Quelle
|
||||
|
||||
Mapping-Bibliotheken werden getrennt vom Projekt gespeichert und koennen in mehreren Projekten wiederverwendet werden.
|
||||
|
||||
## Dateien im Projekt
|
||||
|
||||
| Datei / Ordner | Bedeutung |
|
||||
| --- | --- |
|
||||
| `main.js` | Electron Main Process |
|
||||
| `preload.js` | Bridge zwischen Renderer und Node / Treibern |
|
||||
| `src/app.js` | Hauptlogik der App |
|
||||
| `src/index.html` | Hauptfenster |
|
||||
| `src/diagnostics.html` | Diagnosefenster |
|
||||
| `src/splash.html` | Start-Splashscreen |
|
||||
| `time4midi.png` | App-Logo |
|
||||
| `JHArtworks2023_wh.png` | Brand-Logo |
|
||||
|
||||
## Status
|
||||
|
||||
Aktueller Paketstand laut `package.json`:
|
||||
|
||||
- Version `0.3.3`
|
||||
|
||||
Bereits integriert:
|
||||
|
||||
- Art-Net
|
||||
- sACN
|
||||
- KNX
|
||||
- MQTT
|
||||
- OSC
|
||||
- Audio-Waveform und Beat-Snap
|
||||
- gelbe Snap-Linien mit einstellbarer Staerke
|
||||
- Tastatursteuerung fuer Snap-Punkte
|
||||
- Diagnosefenster
|
||||
- Splashscreen
|
||||
|
||||
## Roadmap / Ideen
|
||||
|
||||
- noch praezisere Beat- und Drop-Erkennung
|
||||
- manuelle Wahl der Snap-Quelle bei mehreren Audio-Tracks
|
||||
- mehr Audio-Effekte direkt in der Timeline
|
||||
- Undo / Redo
|
||||
- Copy / Paste fuer Punkte und Tracks
|
||||
- Loop-Bereiche und Cue-Strukturen
|
||||
- bessere Paketierung fuer Release-Builds
|
||||
|
||||
## Credits
|
||||
|
||||
Entwickelt von `J.H. Artworks`.
|
||||
|
||||
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -4,14 +4,162 @@ const path = require("node:path");
|
||||
|
||||
app.commandLine.appendSwitch("enable-blink-features", "WebMidi");
|
||||
|
||||
let mainWindow = null;
|
||||
let diagnosticsWindow = null;
|
||||
let splashWindow = null;
|
||||
|
||||
const diagnosticsState = {
|
||||
artnet: { universes: {} },
|
||||
sacn: { universes: {} },
|
||||
mqtt: { topics: {} },
|
||||
knx: { groupAddresses: {} },
|
||||
recentEvents: []
|
||||
};
|
||||
|
||||
function rememberRecentEvent(entry) {
|
||||
diagnosticsState.recentEvents.unshift(entry);
|
||||
diagnosticsState.recentEvents = diagnosticsState.recentEvents.slice(0, 200);
|
||||
}
|
||||
|
||||
function sameChannelValues(left, right) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (Number(left[index] || 0) !== Number(right[index] || 0)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateDmxDiagnostics(transport, record) {
|
||||
const safeTransport = transport === "artnet" ? "artnet" : "sacn";
|
||||
const previous = diagnosticsState[safeTransport].universes[String(record.universe)];
|
||||
const nextValues = Array.isArray(record.values) ? record.values.slice(0, 512) : [];
|
||||
const changed = !previous || !sameChannelValues(previous.values, nextValues);
|
||||
|
||||
diagnosticsState[safeTransport].universes[String(record.universe)] = {
|
||||
universe: Number(record.universe) || 0,
|
||||
values: nextValues,
|
||||
updatedAt: record.timestamp
|
||||
};
|
||||
|
||||
if (changed) {
|
||||
rememberRecentEvent({
|
||||
kind: "dmx-frame",
|
||||
transport: safeTransport,
|
||||
universe: Number(record.universe) || 0,
|
||||
activeChannels: nextValues.filter((value) => Number(value) > 0).length,
|
||||
timestamp: record.timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateMqttDiagnostics(record) {
|
||||
diagnosticsState.mqtt.topics[String(record.topic || "")] = {
|
||||
topic: String(record.topic || ""),
|
||||
value: String(record.value ?? ""),
|
||||
updatedAt: record.timestamp
|
||||
};
|
||||
rememberRecentEvent({
|
||||
kind: "mqtt-publish",
|
||||
topic: String(record.topic || ""),
|
||||
value: String(record.value ?? ""),
|
||||
timestamp: record.timestamp
|
||||
});
|
||||
}
|
||||
|
||||
function updateKnxDiagnostics(record) {
|
||||
const key = `${record.groupAddress || ""}:${record.dpt || ""}`;
|
||||
diagnosticsState.knx.groupAddresses[key] = {
|
||||
groupAddress: String(record.groupAddress || ""),
|
||||
dpt: String(record.dpt || ""),
|
||||
value: record.value,
|
||||
updatedAt: record.timestamp
|
||||
};
|
||||
rememberRecentEvent({
|
||||
kind: "knx-write",
|
||||
groupAddress: String(record.groupAddress || ""),
|
||||
dpt: String(record.dpt || ""),
|
||||
value: record.value,
|
||||
timestamp: record.timestamp
|
||||
});
|
||||
}
|
||||
|
||||
function applyDiagnosticsRecord(record) {
|
||||
if (!record || typeof record !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.kind === "dmx-frame") {
|
||||
updateDmxDiagnostics(record.transport, record);
|
||||
} else if (record.kind === "mqtt-publish") {
|
||||
updateMqttDiagnostics(record);
|
||||
} else if (record.kind === "knx-write") {
|
||||
updateKnxDiagnostics(record);
|
||||
}
|
||||
}
|
||||
|
||||
function sendDiagnosticsUpdate(record) {
|
||||
if (!diagnosticsWindow || diagnosticsWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
diagnosticsWindow.webContents.send("diagnostics:update", record);
|
||||
}
|
||||
|
||||
function closeSplashWindow() {
|
||||
if (!splashWindow || splashWindow.isDestroyed()) {
|
||||
splashWindow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
splashWindow.close();
|
||||
splashWindow = null;
|
||||
}
|
||||
|
||||
function createSplashWindow() {
|
||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||
return splashWindow;
|
||||
}
|
||||
|
||||
splashWindow = new BrowserWindow({
|
||||
width: 520,
|
||||
height: 420,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
movable: true,
|
||||
show: true,
|
||||
center: true,
|
||||
backgroundColor: "#0f1117",
|
||||
icon: path.join(__dirname, "time4midi.png"),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
sandbox: false
|
||||
}
|
||||
});
|
||||
|
||||
splashWindow.on("closed", () => {
|
||||
splashWindow = null;
|
||||
});
|
||||
|
||||
splashWindow.loadFile(path.join(__dirname, "src", "splash.html"));
|
||||
return splashWindow;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1600,
|
||||
height: 980,
|
||||
minWidth: 1200,
|
||||
minHeight: 760,
|
||||
backgroundColor: "#0f1117",
|
||||
icon: path.join(__dirname, "time4midi.png"),
|
||||
show: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
@@ -19,7 +167,49 @@ function createWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
win.loadFile(path.join(__dirname, "src", "index.html"));
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
setTimeout(() => {
|
||||
closeSplashWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
}, 650);
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, "src", "index.html"));
|
||||
}
|
||||
|
||||
function createDiagnosticsWindow() {
|
||||
if (diagnosticsWindow && !diagnosticsWindow.isDestroyed()) {
|
||||
diagnosticsWindow.focus();
|
||||
return diagnosticsWindow;
|
||||
}
|
||||
|
||||
diagnosticsWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 820,
|
||||
minWidth: 900,
|
||||
minHeight: 640,
|
||||
backgroundColor: "#0f1117",
|
||||
icon: path.join(__dirname, "time4midi.png"),
|
||||
title: "Time4Midi Diagnose",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
preload: path.join(__dirname, "preload.js")
|
||||
}
|
||||
});
|
||||
|
||||
diagnosticsWindow.on("closed", () => {
|
||||
diagnosticsWindow = null;
|
||||
});
|
||||
|
||||
diagnosticsWindow.loadFile(path.join(__dirname, "src", "diagnostics.html"));
|
||||
return diagnosticsWindow;
|
||||
}
|
||||
|
||||
ipcMain.handle("project:save", async (_event, payload) => {
|
||||
@@ -119,17 +309,38 @@ ipcMain.handle("mappingLibrary:pickSaveFile", async (_event, payload) => {
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("diagnostics:openWindow", () => {
|
||||
createDiagnosticsWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("diagnostics:getSnapshot", () => {
|
||||
return diagnosticsState;
|
||||
});
|
||||
|
||||
ipcMain.on("diagnostics:record", (_event, record) => {
|
||||
const normalizedRecord = {
|
||||
...record,
|
||||
timestamp: Number(record?.timestamp) || Date.now()
|
||||
};
|
||||
applyDiagnosticsRecord(normalizedRecord);
|
||||
sendDiagnosticsUpdate(normalizedRecord);
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createSplashWindow();
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
createSplashWindow();
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
closeSplashWindow();
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"src/**/*",
|
||||
"JHArtworks2023_wh.png",
|
||||
"time4midi.png",
|
||||
"package.json"
|
||||
],
|
||||
|
||||
+100
-4
@@ -69,6 +69,28 @@ function addStatusApi(target, handler) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDiagnosticsChannelValues(values) {
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return values.slice(0, 512).map((value) => {
|
||||
const safeValue = Math.round(Number(value) || 0);
|
||||
return Math.max(0, Math.min(255, safeValue));
|
||||
});
|
||||
}
|
||||
|
||||
function recordDiagnostics(record) {
|
||||
try {
|
||||
ipcRenderer.send("diagnostics:record", {
|
||||
...record,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (_error) {
|
||||
// Ignore diagnostics forwarding issues to avoid blocking output.
|
||||
}
|
||||
}
|
||||
|
||||
const artnet = {
|
||||
...createStatusEmitter(),
|
||||
sendSocket: null,
|
||||
@@ -297,7 +319,13 @@ function closeUdpSocket(target, key, signatureKey) {
|
||||
}
|
||||
|
||||
async function ensureUdpSocket(target, key, signatureKey, label, config, options = {}) {
|
||||
const signature = `${config.localAddress || ""}|${options.broadcast ? "1" : "0"}`;
|
||||
const signature = [
|
||||
config.localAddress || "",
|
||||
options.broadcast ? "1" : "0",
|
||||
options.multicast ? "1" : "0",
|
||||
options.multicastInterface || "",
|
||||
Number.isFinite(Number(options.multicastTtl)) ? String(Number(options.multicastTtl)) : ""
|
||||
].join("|");
|
||||
if (target[key] && target[signatureKey] === signature) {
|
||||
return target[key];
|
||||
}
|
||||
@@ -308,8 +336,14 @@ async function ensureUdpSocket(target, key, signatureKey, label, config, options
|
||||
target[signatureKey] = signature;
|
||||
|
||||
const bindAddress = config.localAddress || "0.0.0.0";
|
||||
const requiresBinding = Boolean(config.localAddress || options.broadcast);
|
||||
const requiresBinding = Boolean(
|
||||
config.localAddress
|
||||
|| options.broadcast
|
||||
|| options.multicast
|
||||
|| options.forceBind
|
||||
);
|
||||
if (!requiresBinding) {
|
||||
emitStatus(target, "");
|
||||
return socket;
|
||||
}
|
||||
|
||||
@@ -346,6 +380,14 @@ async function ensureUdpSocket(target, key, signatureKey, label, config, options
|
||||
if (options.broadcast) {
|
||||
socket.setBroadcast(true);
|
||||
}
|
||||
if (options.multicast) {
|
||||
if (Number.isFinite(Number(options.multicastTtl))) {
|
||||
socket.setMulticastTTL(Number(options.multicastTtl));
|
||||
}
|
||||
if (options.multicastInterface) {
|
||||
socket.setMulticastInterface(options.multicastInterface);
|
||||
}
|
||||
}
|
||||
|
||||
emitStatus(target, "");
|
||||
return socket;
|
||||
@@ -453,6 +495,7 @@ async function sendArtNetDmx(payload) {
|
||||
|
||||
const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0));
|
||||
const values = Array.isArray(payload.values) ? payload.values : [];
|
||||
const diagnosticsValues = normalizeDiagnosticsChannelValues(values);
|
||||
const length = Math.max(2, Math.min(512, values.length || 512));
|
||||
const evenLength = length % 2 === 0 ? length : length + 1;
|
||||
const packet = Buffer.alloc(18 + evenLength);
|
||||
@@ -487,6 +530,12 @@ async function sendArtNetDmx(payload) {
|
||||
return;
|
||||
}
|
||||
emitStatus(artnet, "");
|
||||
recordDiagnostics({
|
||||
kind: "dmx-frame",
|
||||
transport: "artnet",
|
||||
universe,
|
||||
values: diagnosticsValues
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
@@ -550,8 +599,12 @@ async function sendOsc(payload) {
|
||||
|
||||
function configureSacn(nextConfig) {
|
||||
const previousLocalAddress = sacn.config.localAddress;
|
||||
const previousMulticast = sacn.config.multicast;
|
||||
sacn.config = normalizeSacnConfig(nextConfig);
|
||||
if (previousLocalAddress !== sacn.config.localAddress) {
|
||||
if (
|
||||
previousLocalAddress !== sacn.config.localAddress
|
||||
|| previousMulticast !== sacn.config.multicast
|
||||
) {
|
||||
closeUdpSocket(sacn, "socket", "socketSignature");
|
||||
}
|
||||
emitStatus(sacn, "");
|
||||
@@ -588,6 +641,7 @@ async function sendSacnDmx(payload) {
|
||||
|
||||
const universe = clampSacnUniverse(payload.universe);
|
||||
const values = Array.isArray(payload.values) ? payload.values : [];
|
||||
const diagnosticsValues = normalizeDiagnosticsChannelValues(values);
|
||||
const slotCount = Math.max(1, Math.min(512, values.length || 512));
|
||||
const packetLength = 126 + slotCount;
|
||||
const propertyValueCount = slotCount + 1;
|
||||
@@ -624,7 +678,19 @@ async function sendSacnDmx(payload) {
|
||||
|
||||
sacn.sequence = sacn.sequence >= 255 ? 1 : sacn.sequence + 1;
|
||||
const host = sacnDestination(universe);
|
||||
const socket = await ensureUdpSocket(sacn, "socket", "socketSignature", "sACN", sacn.config);
|
||||
const socket = await ensureUdpSocket(
|
||||
sacn,
|
||||
"socket",
|
||||
"socketSignature",
|
||||
"sACN",
|
||||
sacn.config,
|
||||
{
|
||||
multicast: sacn.config.multicast,
|
||||
multicastInterface: sacn.config.localAddress || "",
|
||||
multicastTtl: 32,
|
||||
forceBind: sacn.config.multicast
|
||||
}
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => {
|
||||
@@ -634,6 +700,12 @@ async function sendSacnDmx(payload) {
|
||||
return;
|
||||
}
|
||||
emitStatus(sacn, "");
|
||||
recordDiagnostics({
|
||||
kind: "dmx-frame",
|
||||
transport: "sacn",
|
||||
universe,
|
||||
values: diagnosticsValues
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
@@ -707,6 +779,11 @@ function publishMqtt(payload) {
|
||||
return;
|
||||
}
|
||||
emitStatus(mqttState, "");
|
||||
recordDiagnostics({
|
||||
kind: "mqtt-publish",
|
||||
topic: String(payload.topic || ""),
|
||||
value: String(payload.value ?? "")
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
@@ -1104,6 +1181,12 @@ async function sendKnx(payload) {
|
||||
return;
|
||||
}
|
||||
emitStatus(knxState, "");
|
||||
recordDiagnostics({
|
||||
kind: "knx-write",
|
||||
groupAddress: String(payload.groupAddress || "1/1/1"),
|
||||
dpt,
|
||||
value
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1134,6 +1217,19 @@ contextBridge.exposeInMainWorld("time4midi", {
|
||||
importFile: (defaultPath) => importMappingLibrary(defaultPath),
|
||||
exportFile: (defaultPath, data) => exportMappingLibrary(defaultPath, data)
|
||||
},
|
||||
diagnostics: {
|
||||
openWindow: () => ipcRenderer.invoke("diagnostics:openWindow"),
|
||||
getSnapshot: () => ipcRenderer.invoke("diagnostics:getSnapshot"),
|
||||
subscribe: (handler) => {
|
||||
const listener = (_event, record) => {
|
||||
handler(record);
|
||||
};
|
||||
ipcRenderer.on("diagnostics:update", listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("diagnostics:update", listener);
|
||||
};
|
||||
}
|
||||
},
|
||||
network: {
|
||||
listInterfaces: () => listNetworkInterfaces()
|
||||
},
|
||||
|
||||
+854
-59
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Time4Midi Diagnose</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="diagnostics-shell">
|
||||
<section class="panel diagnostics-header">
|
||||
<div class="diagnostics-header-copy">
|
||||
<div class="eyebrow">Live Monitor</div>
|
||||
<h1>Diagnose</h1>
|
||||
<p>Zeigt die letzten gesendeten Werte fuer Art-Net, sACN, MQTT und KNX waehrend die Show laeuft.</p>
|
||||
</div>
|
||||
<div class="diagnostics-summary">
|
||||
<div class="diagnostics-summary-card">
|
||||
<span class="transport-label">Art-Net Universes</span>
|
||||
<strong id="artnetUniverseCount">0</strong>
|
||||
</div>
|
||||
<div class="diagnostics-summary-card">
|
||||
<span class="transport-label">sACN Universes</span>
|
||||
<strong id="sacnUniverseCount">0</strong>
|
||||
</div>
|
||||
<div class="diagnostics-summary-card">
|
||||
<span class="transport-label">MQTT Topics</span>
|
||||
<strong id="mqttTopicCount">0</strong>
|
||||
</div>
|
||||
<div class="diagnostics-summary-card">
|
||||
<span class="transport-label">KNX Ziele</span>
|
||||
<strong id="knxValueCount">0</strong>
|
||||
</div>
|
||||
<div class="diagnostics-summary-card">
|
||||
<span class="transport-label">Letztes Update</span>
|
||||
<strong id="lastDiagnosticsUpdate">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="diagnostics-grid">
|
||||
<section class="panel diagnostics-card">
|
||||
<div class="panel-header">
|
||||
<h2>Art-Net</h2>
|
||||
<span class="diagnostics-transport-pill artnet">DMX</span>
|
||||
</div>
|
||||
<div id="artnetDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel diagnostics-card">
|
||||
<div class="panel-header">
|
||||
<h2>sACN</h2>
|
||||
<span class="diagnostics-transport-pill sacn">DMX</span>
|
||||
</div>
|
||||
<div id="sacnDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel diagnostics-card">
|
||||
<div class="panel-header">
|
||||
<h2>MQTT</h2>
|
||||
<span class="diagnostics-transport-pill mqtt">Topics</span>
|
||||
</div>
|
||||
<div id="mqttDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel diagnostics-card">
|
||||
<div class="panel-header">
|
||||
<h2>KNX</h2>
|
||||
<span class="diagnostics-transport-pill knx">Write</span>
|
||||
</div>
|
||||
<div id="knxDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel diagnostics-card wide">
|
||||
<div class="panel-header">
|
||||
<h2>Letzte Events</h2>
|
||||
</div>
|
||||
<div id="recentDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="./diagnostics.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,386 @@
|
||||
(function () {
|
||||
const refs = {
|
||||
artnetUniverseCount: document.getElementById("artnetUniverseCount"),
|
||||
sacnUniverseCount: document.getElementById("sacnUniverseCount"),
|
||||
mqttTopicCount: document.getElementById("mqttTopicCount"),
|
||||
knxValueCount: document.getElementById("knxValueCount"),
|
||||
lastDiagnosticsUpdate: document.getElementById("lastDiagnosticsUpdate"),
|
||||
artnetDiagnostics: document.getElementById("artnetDiagnostics"),
|
||||
sacnDiagnostics: document.getElementById("sacnDiagnostics"),
|
||||
mqttDiagnostics: document.getElementById("mqttDiagnostics"),
|
||||
knxDiagnostics: document.getElementById("knxDiagnostics"),
|
||||
recentDiagnostics: document.getElementById("recentDiagnostics")
|
||||
};
|
||||
|
||||
const state = {
|
||||
artnet: { universes: {} },
|
||||
sacn: { universes: {} },
|
||||
mqtt: { topics: {} },
|
||||
knx: { groupAddresses: {} },
|
||||
recentEvents: []
|
||||
};
|
||||
|
||||
let unsubscribe = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function sameChannelValues(left, right) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (Number(left[index] || 0) !== Number(right[index] || 0)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function rememberRecentEvent(entry) {
|
||||
state.recentEvents.unshift(entry);
|
||||
state.recentEvents = state.recentEvents.slice(0, 200);
|
||||
}
|
||||
|
||||
function updateDmxDiagnostics(transport, record) {
|
||||
const safeTransport = transport === "artnet" ? "artnet" : "sacn";
|
||||
const previous = state[safeTransport].universes[String(record.universe)];
|
||||
const nextValues = Array.isArray(record.values) ? record.values.slice(0, 512) : [];
|
||||
const changed = !previous || !sameChannelValues(previous.values, nextValues);
|
||||
|
||||
state[safeTransport].universes[String(record.universe)] = {
|
||||
universe: Number(record.universe) || 0,
|
||||
values: nextValues,
|
||||
updatedAt: Number(record.timestamp) || Date.now()
|
||||
};
|
||||
|
||||
if (changed) {
|
||||
rememberRecentEvent({
|
||||
kind: "dmx-frame",
|
||||
transport: safeTransport,
|
||||
universe: Number(record.universe) || 0,
|
||||
activeChannels: nextValues.filter((value) => Number(value) > 0).length,
|
||||
timestamp: Number(record.timestamp) || Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateMqttDiagnostics(record) {
|
||||
state.mqtt.topics[String(record.topic || "")] = {
|
||||
topic: String(record.topic || ""),
|
||||
value: String(record.value ?? ""),
|
||||
updatedAt: Number(record.timestamp) || Date.now()
|
||||
};
|
||||
rememberRecentEvent({
|
||||
kind: "mqtt-publish",
|
||||
topic: String(record.topic || ""),
|
||||
value: String(record.value ?? ""),
|
||||
timestamp: Number(record.timestamp) || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function updateKnxDiagnostics(record) {
|
||||
const key = `${record.groupAddress || ""}:${record.dpt || ""}`;
|
||||
state.knx.groupAddresses[key] = {
|
||||
groupAddress: String(record.groupAddress || ""),
|
||||
dpt: String(record.dpt || ""),
|
||||
value: record.value,
|
||||
updatedAt: Number(record.timestamp) || Date.now()
|
||||
};
|
||||
rememberRecentEvent({
|
||||
kind: "knx-write",
|
||||
groupAddress: String(record.groupAddress || ""),
|
||||
dpt: String(record.dpt || ""),
|
||||
value: record.value,
|
||||
timestamp: Number(record.timestamp) || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function applyDiagnosticsRecord(record) {
|
||||
if (!record || typeof record !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.kind === "dmx-frame") {
|
||||
updateDmxDiagnostics(record.transport, record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.kind === "mqtt-publish") {
|
||||
updateMqttDiagnostics(record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.kind === "knx-write") {
|
||||
updateKnxDiagnostics(record);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceState(snapshot) {
|
||||
state.artnet.universes = { ...(snapshot?.artnet?.universes || {}) };
|
||||
state.sacn.universes = { ...(snapshot?.sacn?.universes || {}) };
|
||||
state.mqtt.topics = { ...(snapshot?.mqtt?.topics || {}) };
|
||||
state.knx.groupAddresses = { ...(snapshot?.knx?.groupAddresses || {}) };
|
||||
state.recentEvents = Array.isArray(snapshot?.recentEvents) ? snapshot.recentEvents.slice(0, 200) : [];
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "1" : "0";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
}
|
||||
return String(value ?? "");
|
||||
}
|
||||
|
||||
function getActiveChannels(values) {
|
||||
const channels = [];
|
||||
(values || []).forEach((value, index) => {
|
||||
const safeValue = Number(value) || 0;
|
||||
if (safeValue > 0) {
|
||||
channels.push({
|
||||
channel: index + 1,
|
||||
value: safeValue
|
||||
});
|
||||
}
|
||||
});
|
||||
return channels;
|
||||
}
|
||||
|
||||
function renderDmxContainer(container, universes, emptyMessage) {
|
||||
const entries = Object.values(universes || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
|
||||
if (!entries.length) {
|
||||
container.innerHTML = `<div class="diagnostics-empty">${escapeHtml(emptyMessage)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="diagnostics-list">
|
||||
${entries.map((entry) => {
|
||||
const activeChannels = getActiveChannels(entry.values);
|
||||
const visibleChannels = activeChannels.slice(0, 48);
|
||||
const hiddenCount = Math.max(0, activeChannels.length - visibleChannels.length);
|
||||
|
||||
return `
|
||||
<article class="diagnostics-entry">
|
||||
<div class="diagnostics-entry-header">
|
||||
<strong>Universe ${entry.universe}</strong>
|
||||
<span>${activeChannels.length} aktive Kanaele</span>
|
||||
</div>
|
||||
<div class="diagnostics-entry-meta">
|
||||
<span>Letztes Update ${formatTimestamp(entry.updatedAt)}</span>
|
||||
</div>
|
||||
${visibleChannels.length ? `
|
||||
<div class="diagnostics-channel-grid">
|
||||
${visibleChannels.map((channel) => `
|
||||
<div class="diagnostics-channel-chip diagnostics-mono">Ch ${channel.channel}: ${channel.value}</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
${hiddenCount > 0 ? `<div class="diagnostics-entry-meta"><span>+ ${hiddenCount} weitere Kanaele</span></div>` : ""}
|
||||
` : `
|
||||
<div class="diagnostics-entry-meta">
|
||||
<span>Alle Kanaele aktuell auf 0</span>
|
||||
</div>
|
||||
`}
|
||||
</article>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMqttContainer() {
|
||||
const entries = Object.values(state.mqtt.topics || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
|
||||
if (!entries.length) {
|
||||
refs.mqttDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine MQTT-Nachrichten gesendet.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
refs.mqttDiagnostics.innerHTML = `
|
||||
<div class="diagnostics-list">
|
||||
${entries.map((entry) => `
|
||||
<article class="diagnostics-entry">
|
||||
<div class="diagnostics-entry-header">
|
||||
<strong class="diagnostics-mono">${escapeHtml(entry.topic)}</strong>
|
||||
<span>${formatTimestamp(entry.updatedAt)}</span>
|
||||
</div>
|
||||
<div class="diagnostics-entry-meta">
|
||||
<span>Wert</span>
|
||||
<strong class="diagnostics-mono">${escapeHtml(formatValue(entry.value))}</strong>
|
||||
</div>
|
||||
</article>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderKnxContainer() {
|
||||
const entries = Object.values(state.knx.groupAddresses || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
|
||||
if (!entries.length) {
|
||||
refs.knxDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine KNX-Schreibvorgaenge gesendet.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
refs.knxDiagnostics.innerHTML = `
|
||||
<div class="diagnostics-list">
|
||||
${entries.map((entry) => `
|
||||
<article class="diagnostics-entry">
|
||||
<div class="diagnostics-entry-header">
|
||||
<strong class="diagnostics-mono">${escapeHtml(entry.groupAddress)}</strong>
|
||||
<span>${escapeHtml(entry.dpt)}</span>
|
||||
</div>
|
||||
<div class="diagnostics-entry-meta">
|
||||
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
|
||||
<span>${formatTimestamp(entry.updatedAt)}</span>
|
||||
</div>
|
||||
</article>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function describeRecentEvent(entry) {
|
||||
if (entry.kind === "dmx-frame") {
|
||||
return `
|
||||
<article class="diagnostics-entry">
|
||||
<div class="diagnostics-entry-header">
|
||||
<strong>${escapeHtml(entry.transport === "artnet" ? "Art-Net" : "sACN")} Universe ${escapeHtml(entry.universe)}</strong>
|
||||
<span class="diagnostics-transport-pill ${escapeHtml(entry.transport)}">${escapeHtml(entry.transport)}</span>
|
||||
</div>
|
||||
<div class="diagnostics-entry-meta">
|
||||
<span>${escapeHtml(entry.activeChannels)} aktive Kanaele</span>
|
||||
<span>${formatTimestamp(entry.timestamp)}</span>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
if (entry.kind === "mqtt-publish") {
|
||||
return `
|
||||
<article class="diagnostics-entry">
|
||||
<div class="diagnostics-entry-header">
|
||||
<strong class="diagnostics-mono">${escapeHtml(entry.topic)}</strong>
|
||||
<span class="diagnostics-transport-pill mqtt">mqtt</span>
|
||||
</div>
|
||||
<div class="diagnostics-entry-meta">
|
||||
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
|
||||
<span>${formatTimestamp(entry.timestamp)}</span>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
if (entry.kind === "knx-write") {
|
||||
return `
|
||||
<article class="diagnostics-entry">
|
||||
<div class="diagnostics-entry-header">
|
||||
<strong class="diagnostics-mono">${escapeHtml(entry.groupAddress)}</strong>
|
||||
<span class="diagnostics-transport-pill knx">${escapeHtml(entry.dpt)}</span>
|
||||
</div>
|
||||
<div class="diagnostics-entry-meta">
|
||||
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
|
||||
<span>${formatTimestamp(entry.timestamp)}</span>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderRecentEvents() {
|
||||
const entries = state.recentEvents.slice(0, 80);
|
||||
if (!entries.length) {
|
||||
refs.recentDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine Diagnose-Events vorhanden.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
refs.recentDiagnostics.innerHTML = `
|
||||
<div class="diagnostics-history">
|
||||
${entries.map((entry) => describeRecentEvent(entry)).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const universeCounts = {
|
||||
artnet: Object.keys(state.artnet.universes || {}).length,
|
||||
sacn: Object.keys(state.sacn.universes || {}).length,
|
||||
mqtt: Object.keys(state.mqtt.topics || {}).length,
|
||||
knx: Object.keys(state.knx.groupAddresses || {}).length
|
||||
};
|
||||
const timestamps = [
|
||||
...Object.values(state.artnet.universes || {}).map((entry) => entry.updatedAt || 0),
|
||||
...Object.values(state.sacn.universes || {}).map((entry) => entry.updatedAt || 0),
|
||||
...Object.values(state.mqtt.topics || {}).map((entry) => entry.updatedAt || 0),
|
||||
...Object.values(state.knx.groupAddresses || {}).map((entry) => entry.updatedAt || 0)
|
||||
];
|
||||
const lastUpdate = timestamps.length ? Math.max(...timestamps) : 0;
|
||||
|
||||
refs.artnetUniverseCount.textContent = String(universeCounts.artnet);
|
||||
refs.sacnUniverseCount.textContent = String(universeCounts.sacn);
|
||||
refs.mqttTopicCount.textContent = String(universeCounts.mqtt);
|
||||
refs.knxValueCount.textContent = String(universeCounts.knx);
|
||||
refs.lastDiagnosticsUpdate.textContent = formatTimestamp(lastUpdate);
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderSummary();
|
||||
renderDmxContainer(refs.artnetDiagnostics, state.artnet.universes, "Noch keine Art-Net Frames gesendet.");
|
||||
renderDmxContainer(refs.sacnDiagnostics, state.sacn.universes, "Noch keine sACN Frames gesendet.");
|
||||
renderMqttContainer();
|
||||
renderKnxContainer();
|
||||
renderRecentEvents();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!window.time4midi?.diagnostics) {
|
||||
document.body.innerHTML = `<main class="diagnostics-shell"><section class="panel"><div class="diagnostics-empty">Diagnose-Bridge nicht verfuegbar. Bitte App neu starten.</div></section></main>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await window.time4midi.diagnostics.getSnapshot();
|
||||
replaceState(snapshot);
|
||||
renderAll();
|
||||
|
||||
unsubscribe = window.time4midi.diagnostics.subscribe((record) => {
|
||||
applyDiagnosticsRecord(record);
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
});
|
||||
|
||||
void init();
|
||||
})();
|
||||
@@ -86,7 +86,40 @@
|
||||
<span class="transport-label">Status</span>
|
||||
<strong id="transportStatusLabel">Idle</strong>
|
||||
</div>
|
||||
<label class="transport-block transport-field">
|
||||
<span class="transport-label">Timeline Laenge</span>
|
||||
<input id="timelineDurationInput" type="number" min="1" max="86400" step="1" value="120">
|
||||
</label>
|
||||
<label class="transport-block transport-checkbox">
|
||||
<span class="transport-label">Beat Snap</span>
|
||||
<input id="beatSnapEnabledInput" type="checkbox">
|
||||
</label>
|
||||
<label class="transport-block transport-field">
|
||||
<span class="transport-label">Snap Raster</span>
|
||||
<select id="beatSnapGridSelect">
|
||||
<option value="0.25">1/4 Beat</option>
|
||||
<option value="0.5">1/2 Beat</option>
|
||||
<option value="1" selected>1 Beat</option>
|
||||
<option value="2">2 Beats</option>
|
||||
<option value="4">1 Takt</option>
|
||||
<option value="8">2 Takte</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="transport-block transport-field transport-field-small">
|
||||
<span class="transport-label">Linien</span>
|
||||
<select id="beatSnapLineWidthSelect">
|
||||
<option value="1">Duenn</option>
|
||||
<option value="2" selected>Mittel</option>
|
||||
<option value="3">Dick</option>
|
||||
<option value="4">Extra</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="transport-block transport-source">
|
||||
<span class="transport-label">Snap Quelle</span>
|
||||
<strong id="beatSnapSourceLabel">Kein Audio-Raster</strong>
|
||||
</div>
|
||||
<div class="transport-controls">
|
||||
<button id="openDiagnosticsButton" class="ghost">Diagnose</button>
|
||||
<button id="openSettingsButton" class="ghost">Einstellungen</button>
|
||||
<button id="playButton">Play</button>
|
||||
<button id="pauseButton" class="ghost">Pause</button>
|
||||
@@ -104,6 +137,7 @@
|
||||
<div class="editor-hints">
|
||||
<span>Doppelklick: Punkt setzen</span>
|
||||
<span>Ziehen: Zeit/Wert aendern</span>
|
||||
<span>Audio ziehen: Clip verschieben</span>
|
||||
<span>Strg+Mausrad: Zoom</span>
|
||||
<span>Mausrad: horizontal scrollen</span>
|
||||
<span>Entf: Punkt loeschen</span>
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Time4Midi startet</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0f15;
|
||||
--panel: rgba(18, 24, 34, 0.96);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--text: #ecf2ff;
|
||||
--muted: #9ca8bf;
|
||||
--accent: #6ec2f2;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(110, 194, 242, 0.2), transparent 34%),
|
||||
radial-gradient(circle at bottom right, rgba(110, 232, 200, 0.14), transparent 28%),
|
||||
linear-gradient(180deg, #09111b 0%, #101a28 100%);
|
||||
color: var(--text);
|
||||
font-family: "Trebuchet MS", "Segoe UI Variable Text", "Aptos", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.brand-row img:first-child {
|
||||
width: 156px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-row img:last-child {
|
||||
width: 132px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.loader-bar {
|
||||
overflow: hidden;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.loader-bar::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 38%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgba(110, 194, 242, 0.25), rgba(110, 194, 242, 0.95), rgba(142, 232, 200, 0.55));
|
||||
animation: loading 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(290%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="panel">
|
||||
<div class="brand-row">
|
||||
<img src="../time4midi.png" alt="Time4Midi">
|
||||
<img src="../JHArtworks2023_wh.png" alt="J.H. Artworks">
|
||||
</div>
|
||||
|
||||
<div class="copy">
|
||||
<div class="eyebrow">Startup</div>
|
||||
<h1>Time4Midi startet</h1>
|
||||
<p>Die Engine, Treiber und das Projekt-Setup werden vorbereitet. Gleich geht es los.</p>
|
||||
</div>
|
||||
|
||||
<div class="loader">
|
||||
<div class="loader-bar" aria-hidden="true"></div>
|
||||
<div class="loader-meta">
|
||||
<span>Bitte kurz warten</span>
|
||||
<span>Renderer wird geladen</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+197
@@ -180,6 +180,43 @@ p {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.transport-field {
|
||||
min-width: 132px;
|
||||
max-width: 156px;
|
||||
}
|
||||
|
||||
.transport-field-small {
|
||||
min-width: 96px;
|
||||
max-width: 112px;
|
||||
}
|
||||
|
||||
.transport-field input {
|
||||
padding: 0.42rem 0.6rem;
|
||||
}
|
||||
|
||||
.transport-field select {
|
||||
padding: 0.42rem 0.6rem;
|
||||
}
|
||||
|
||||
.transport-checkbox {
|
||||
min-width: 90px;
|
||||
align-content: end;
|
||||
}
|
||||
|
||||
.transport-checkbox input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.transport-source {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.transport-source strong {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.editor-hints span,
|
||||
.meta-row span,
|
||||
.transport-label {
|
||||
@@ -704,3 +741,163 @@ p {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.diagnostics-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 100vh;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.diagnostics-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagnostics-header-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.diagnostics-summary {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diagnostics-summary-card {
|
||||
min-width: 110px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.diagnostics-summary-card strong {
|
||||
display: block;
|
||||
font-size: 1.1rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagnostics-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.diagnostics-card.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.diagnostics-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diagnostics-scroll {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diagnostics-list,
|
||||
.diagnostics-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diagnostics-entry {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.diagnostics-entry-header,
|
||||
.diagnostics-entry-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diagnostics-entry-meta {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.diagnostics-channel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.diagnostics-channel-chip {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(110, 194, 242, 0.1);
|
||||
border: 1px solid rgba(110, 194, 242, 0.18);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.diagnostics-transport-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.22rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.diagnostics-transport-pill.artnet {
|
||||
background: rgba(255, 179, 92, 0.18);
|
||||
border: 1px solid rgba(255, 179, 92, 0.28);
|
||||
}
|
||||
|
||||
.diagnostics-transport-pill.sacn {
|
||||
background: rgba(110, 194, 242, 0.18);
|
||||
border: 1px solid rgba(110, 194, 242, 0.28);
|
||||
}
|
||||
|
||||
.diagnostics-transport-pill.mqtt,
|
||||
.diagnostics-transport-pill.knx {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.diagnostics-empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.diagnostics-mono {
|
||||
font-family: "Consolas", "Cascadia Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.diagnostics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.diagnostics-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user