diff --git a/README.md b/README.md index ac22737..c832d3c 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,284 @@ -<<<<<<< HEAD # Time4Midi -Desktop-Prototyp fuer einen plattformuebergreifenden Timeline-Editor fuer: +

+ Time4Midi Logo +

-- MIDI Notes -- Art-Net DMX Values -- externe Synchronisation via MIDI Timecode (MTC) -- externe Synchronisation via Art-Net Timecode +

+ Desktop-Timeline fuer Show-Control, MIDI, DMX, Audio und technische Automationen. +

-## Aktueller Stand +

+ J.H. Artworks +

-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
Projekt, Timelines, Tracks"] --> B["Editor
Timeline, Zoom, Scroll, Playhead"] + B --> C["Inspector
Track- und Punktparameter"] + B --> D["Transportleiste
Play, Sync, Timeline-Laenge, Beat-Snap"] + D --> E["Diagnosefenster
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`. diff --git a/Time4Midi.psd b/Time4Midi.psd new file mode 100644 index 0000000..9a72a88 Binary files /dev/null and b/Time4Midi.psd differ diff --git a/dist/Time4Midi 0.3.6.exe b/dist/Time4Midi 0.3.6.exe new file mode 100644 index 0000000..ffd1bed Binary files /dev/null and b/dist/Time4Midi 0.3.6.exe differ diff --git a/dist/Time4Midi 0.3.3.exe b/dist/old_versions/Time4Midi 0.3.3.exe similarity index 100% rename from dist/Time4Midi 0.3.3.exe rename to dist/old_versions/Time4Midi 0.3.3.exe diff --git a/dist/old_versions/Time4Midi 0.3.4.exe b/dist/old_versions/Time4Midi 0.3.4.exe new file mode 100644 index 0000000..6746aa8 Binary files /dev/null and b/dist/old_versions/Time4Midi 0.3.4.exe differ diff --git a/main.js b/main.js index 21e2049..98ab88e 100644 --- a/main.js +++ b/main.js @@ -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(); } diff --git a/package.json b/package.json index f277180..4578423 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "main.js", "preload.js", "src/**/*", + "JHArtworks2023_wh.png", "time4midi.png", "package.json" ], diff --git a/preload.js b/preload.js index 4edd9ad..6afc34b 100644 --- a/preload.js +++ b/preload.js @@ -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() }, diff --git a/src/app.js b/src/app.js index 4193405..9a006e1 100644 --- a/src/app.js +++ b/src/app.js @@ -12,6 +12,8 @@ const EXTERNAL_IDLE_MS = 1500; const AUDIO_WAVEFORM_SAMPLES = 720; const AUDIO_SYNC_TOLERANCE = 0.08; + const AUDIO_SNAP_GRID_OPTIONS = ["0.25", "0.5", "1", "2", "4", "8"]; + const AUDIO_SNAP_LINE_WIDTH_OPTIONS = ["1", "2", "3", "4"]; const KNX_DPT_OPTIONS = [ { value: "DPT1.001", @@ -251,6 +253,12 @@ playButton: document.getElementById("playButton"), pauseButton: document.getElementById("pauseButton"), stopButton: document.getElementById("stopButton"), + timelineDurationInput: document.getElementById("timelineDurationInput"), + beatSnapEnabledInput: document.getElementById("beatSnapEnabledInput"), + beatSnapGridSelect: document.getElementById("beatSnapGridSelect"), + beatSnapLineWidthSelect: document.getElementById("beatSnapLineWidthSelect"), + beatSnapSourceLabel: document.getElementById("beatSnapSourceLabel"), + openDiagnosticsButton: document.getElementById("openDiagnosticsButton"), openSettingsButton: document.getElementById("openSettingsButton"), settingsModal: document.getElementById("settingsModal"), settingsBackdrop: document.getElementById("settingsBackdrop"), @@ -330,6 +338,13 @@ audioOutputDevices: [], audioAnalysisContext: null, audioTracks: new Map(), + audioSnapCache: { + key: "", + sourceTrackId: "", + values: [], + clipStart: 0, + clipEnd: 0 + }, outputState: { midiNotes: new Map(), midiCcs: new Map(), @@ -435,6 +450,9 @@ id: uid("timeline"), name: "Timeline A", duration: 120, + beatSnapEnabled: false, + beatSnapGrid: "1", + beatSnapLineWidth: "2", tracks: [ { id: uid("track"), @@ -501,6 +519,29 @@ ); } + function normalizeBeatSnapGrid(value) { + const normalized = String(value || "1"); + return AUDIO_SNAP_GRID_OPTIONS.includes(normalized) ? normalized : "1"; + } + + function normalizeBeatSnapLineWidth(value) { + const normalized = String(value || "2"); + return AUDIO_SNAP_LINE_WIDTH_OPTIONS.includes(normalized) ? normalized : "2"; + } + + function buildAutoAudioTrackName(filePath) { + const stem = basenameFromPath(filePath).replace(/\.[^.]+$/, "").trim(); + if (!stem) { + return "Audio"; + } + + if (stem.length <= 26) { + return stem; + } + + return `${stem.slice(0, 23).trimEnd()}...`; + } + function getMappingLibraryConfig(libraryKey) { return MAPPING_LIBRARY_DEFINITIONS[libraryKey] || null; } @@ -671,6 +712,15 @@ } } + if (track.type === "audio-file") { + const maxPointTime = Math.max(0, getAudioTrackDuration(track)); + track.gain = clamp(Number(track.gain) || 1, 0, 2); + track.points = track.points.map((entry) => ({ + ...entry, + time: clamp(Number(entry.time) || 0, 0, maxPointTime) + })); + } + track.points = track.points.map((entry) => ({ ...entry, value: quantizeTrackValue(track, entry.value), @@ -747,6 +797,9 @@ if (track.type === "midi-note" || track.type === "midi-cc") { return { min: 0, max: 127, step: 1, integer: true }; } + if (track.type === "audio-file") { + return { min: 0, max: 2, step: 0.01, integer: false }; + } if (track.type === "modbus-tcp" || track.type === "modbus-rtu") { if (track.registerType === "coil") { return { min: 0, max: 1, step: 1, integer: true }; @@ -791,6 +844,392 @@ track.points.sort((left, right) => left.time - right.time); } + function getAudioTrackDuration(track) { + const runtimeDuration = runtime.audioTracks.get(track.id)?.duration || 0; + return Math.max(0, runtimeDuration, Number(track.analysisDuration) || 0); + } + + function getAudioTrackEndTime(track) { + return Math.max(0, Number(track.startTime) || 0) + getAudioTrackDuration(track); + } + + function ensureTimelineCoversAudioTrack(timeline, track) { + if (!timeline || !track || track.type !== "audio-file") { + return; + } + + const clipEnd = getAudioTrackEndTime(track); + if (clipEnd > timeline.duration) { + timeline.duration = clamp(Math.ceil(clipEnd), 1, 86400); + } + } + + function getBeatSnapSourceTrack(timeline) { + if (!timeline) { + return null; + } + + const hasBeatGrid = (track) => { + const entry = runtime.audioTracks.get(track.id); + return track.type === "audio-file" && (entry?.beatTimes?.length || 0) >= 2; + }; + + const selectedTrack = timeline.tracks.find((track) => track.id === state.selectedTrackId); + if (selectedTrack && hasBeatGrid(selectedTrack)) { + return selectedTrack; + } + + return timeline.tracks.find(hasBeatGrid) || null; + } + + function getBeatSnapSourceLabel(timeline) { + const sourceTrack = getBeatSnapSourceTrack(timeline); + if (!sourceTrack) { + return "Kein Audio-Raster"; + } + + const beatText = sourceTrack.analysisBpm ? `${Math.round(sourceTrack.analysisBpm)} BPM` : "Audio Raster"; + return `${shortenLabel(sourceTrack.name || sourceTrack.mediaName || "Audio", 18)} / ${beatText}`; + } + + function buildAudioSnapCacheKey(timeline, sourceTrack, runtimeEntry) { + return [ + timeline.id || "", + sourceTrack.id || "", + normalizeBeatSnapGrid(timeline.beatSnapGrid), + Number(sourceTrack.startTime || 0).toFixed(3), + Number(sourceTrack.analysisDuration || 0).toFixed(3), + Number(runtimeEntry?.beatInterval || 0).toFixed(6), + runtimeEntry?.beatTimes?.length || 0, + runtimeEntry?.barTimes?.length || 0, + Number(runtimeEntry?.beatTimes?.[0] || 0).toFixed(3), + Number(runtimeEntry?.beatTimes?.[(runtimeEntry?.beatTimes?.length || 1) - 1] || 0).toFixed(3), + Number(runtimeEntry?.barTimes?.[0] || 0).toFixed(3) + ].join("|"); + } + + function getAudioSnapData(timeline) { + if (!timeline?.beatSnapEnabled) { + return { sourceTrack: null, values: [], clipStart: 0, clipEnd: 0 }; + } + + const sourceTrack = getBeatSnapSourceTrack(timeline); + if (!sourceTrack) { + return { sourceTrack: null, values: [], clipStart: 0, clipEnd: 0 }; + } + + const runtimeEntry = runtime.audioTracks.get(sourceTrack.id); + const beatTimes = runtimeEntry?.beatTimes || []; + const beatInterval = Number(runtimeEntry?.beatInterval) || (sourceTrack.analysisBpm ? 60 / sourceTrack.analysisBpm : 0); + if (!beatTimes.length || beatInterval <= 0) { + return { sourceTrack, values: [], clipStart: 0, clipEnd: 0 }; + } + + const stepInBeats = Number(timeline.beatSnapGrid || "1"); + if (!Number.isFinite(stepInBeats) || stepInBeats <= 0) { + return { sourceTrack, values: [], clipStart: 0, clipEnd: 0 }; + } + + const clipStart = Math.max(0, Number(sourceTrack.startTime) || 0); + const clipEnd = getAudioTrackEndTime(sourceTrack); + const anchorRelative = stepInBeats >= 4 && runtimeEntry?.barTimes?.length + ? runtimeEntry.barTimes[0] + : beatTimes[0]; + const interval = beatInterval * stepInBeats; + if (!Number.isFinite(anchorRelative) || !Number.isFinite(interval) || interval <= 0) { + return { sourceTrack, values: [], clipStart, clipEnd }; + } + + const cacheKey = buildAudioSnapCacheKey(timeline, sourceTrack, runtimeEntry); + if (runtime.audioSnapCache.key === cacheKey) { + return { + sourceTrack, + values: runtime.audioSnapCache.values, + clipStart: runtime.audioSnapCache.clipStart, + clipEnd: runtime.audioSnapCache.clipEnd + }; + } + + const values = []; + const startTime = clipStart + anchorRelative; + for (let time = startTime; time <= clipEnd + 0.0001; time += interval) { + values.push(Number(time.toFixed(3))); + } + for (let time = startTime - interval; time >= clipStart - 0.0001; time -= interval) { + values.push(Number(time.toFixed(3))); + } + + values.sort((left, right) => left - right); + runtime.audioSnapCache = { + key: cacheKey, + sourceTrackId: sourceTrack.id, + values, + clipStart, + clipEnd + }; + + return { sourceTrack, values, clipStart, clipEnd }; + } + + function getAudioSnapTimes(timeline) { + return getAudioSnapData(timeline).values; + } + + function getCurrentPointAbsoluteTime(track, point) { + if (!track || !point) { + return 0; + } + + return track.type === "audio-file" + ? Math.max(0, Number(track.startTime) || 0) + (Number(point.time) || 0) + : (Number(point.time) || 0); + } + + function setPointAbsoluteTime(track, point, absoluteTime, timeline) { + if (!track || !point || !timeline) { + return; + } + + if (track.type === "audio-file") { + point.time = clamp( + Number((absoluteTime - Math.max(0, Number(track.startTime) || 0)).toFixed(2)), + 0, + getAudioTrackDuration(track) + ); + } else { + point.time = clamp(Number(absoluteTime.toFixed(3)), 0, timeline.duration); + } + + sortTrackPoints(track); + } + + function getSnapShortcutStepCount(timeline, accelerated) { + if (!accelerated) { + return 1; + } + + const gridInBeats = Number(timeline?.beatSnapGrid || "1"); + return Math.max(1, Math.round(16 / Math.max(0.25, gridInBeats))); + } + + function moveSelectedPointBySnap(direction, accelerated = false) { + const timeline = getSelectedTimeline(); + const track = getSelectedTrack(); + const point = getSelectedPoint(); + if (!timeline || !track || !point || direction === 0) { + return false; + } + + const snapData = getAudioSnapData(timeline); + const snapTimes = snapData.values || []; + if (!snapTimes.length) { + return false; + } + + const currentAbsoluteTime = getCurrentPointAbsoluteTime(track, point); + const currentIndex = findNearestSnapIndex(snapTimes, currentAbsoluteTime); + if (currentIndex < 0) { + return false; + } + + const epsilon = 0.001; + const stepCount = getSnapShortcutStepCount(timeline, accelerated); + let baseIndex = currentIndex; + + if (direction > 0 && Math.abs(snapTimes[currentIndex] - currentAbsoluteTime) > epsilon) { + const nextTime = findNextSnapAfter(snapTimes, currentAbsoluteTime); + if (nextTime == null) { + return false; + } + baseIndex = snapTimes.indexOf(nextTime) + (stepCount - 1); + } else if (direction < 0 && Math.abs(snapTimes[currentIndex] - currentAbsoluteTime) > epsilon) { + const previousTime = findPreviousSnapBefore(snapTimes, currentAbsoluteTime); + if (previousTime == null) { + return false; + } + baseIndex = snapTimes.indexOf(previousTime) - (stepCount - 1); + } else { + baseIndex = currentIndex + (direction * stepCount); + } + + const targetIndex = clamp(baseIndex, 0, snapTimes.length - 1); + const targetTime = snapTimes[targetIndex]; + if (!Number.isFinite(targetTime)) { + return false; + } + + setPointAbsoluteTime(track, point, targetTime, timeline); + if (track.type === "audio-file") { + void syncAudioTracks(true); + } + renderAll(); + return true; + } + + function snapTimelineTimeToAudioGrid(time, timeline, track) { + if (!timeline?.beatSnapEnabled || track?.type === "audio-file") { + return time; + } + + const sourceTrack = getBeatSnapSourceTrack(timeline); + if (!sourceTrack) { + return time; + } + + const clipStart = Math.max(0, Number(sourceTrack.startTime) || 0); + const clipEnd = getAudioTrackEndTime(sourceTrack); + if (time < clipStart || time > clipEnd) { + return time; + } + + const snapTimes = getAudioSnapTimes(timeline); + if (!snapTimes.length) { + return time; + } + + let nearestTime = time; + let nearestDistance = Infinity; + for (const snapTime of snapTimes) { + const distance = Math.abs(snapTime - time); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestTime = snapTime; + } + } + + return Number(nearestTime.toFixed(3)); + } + + function findNearestSnapIndex(snapTimes, time) { + if (!snapTimes.length) { + return -1; + } + + let nearestIndex = 0; + let nearestDistance = Infinity; + for (let index = 0; index < snapTimes.length; index += 1) { + const distance = Math.abs(snapTimes[index] - time); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + } + return nearestIndex; + } + + function findNextSnapAfter(snapTimes, time) { + for (const snapTime of snapTimes) { + if (snapTime > time) { + return snapTime; + } + } + return null; + } + + function findPreviousSnapBefore(snapTimes, time) { + for (let index = snapTimes.length - 1; index >= 0; index -= 1) { + if (snapTimes[index] < time) { + return snapTimes[index]; + } + } + return null; + } + + function snapDraggedPointTime(rawTime, timeline, track, point) { + if (!timeline?.beatSnapEnabled || track?.type === "audio-file") { + return rawTime; + } + + const snapTimes = getAudioSnapTimes(timeline); + if (!snapTimes.length) { + return rawTime; + } + + const sourceTrack = getBeatSnapSourceTrack(timeline); + if (!sourceTrack) { + return rawTime; + } + + const clipStart = Math.max(0, Number(sourceTrack.startTime) || 0); + const clipEnd = getAudioTrackEndTime(sourceTrack); + if (rawTime < clipStart || rawTime > clipEnd) { + return rawTime; + } + + const currentTime = Number(point?.time) || 0; + const nearestIndex = findNearestSnapIndex(snapTimes, rawTime); + if (nearestIndex < 0) { + return rawTime; + } + + let snappedTime = snapTimes[nearestIndex]; + const moveDelta = rawTime - currentTime; + + if (moveDelta > 0.001 && Math.abs(snappedTime - currentTime) < 0.001) { + snappedTime = findNextSnapAfter(snapTimes, currentTime + 0.0001) ?? snappedTime; + } else if (moveDelta < -0.001 && Math.abs(snappedTime - currentTime) < 0.001) { + snappedTime = findPreviousSnapBefore(snapTimes, currentTime - 0.0001) ?? snappedTime; + } + + return Number(snappedTime.toFixed(3)); + } + + function snapDraggedPointFromOrigin(rawTime, timeline, track, dragState) { + if (!timeline?.beatSnapEnabled || track?.type === "audio-file" || !dragState?.snapInterval) { + return rawTime; + } + + const clipStart = Number(dragState.snapClipStart) || 0; + const clipEnd = Number(dragState.snapClipEnd) || 0; + if (rawTime < clipStart || rawTime > clipEnd) { + return rawTime; + } + + const relativeDelta = rawTime - (Number(dragState.pointerOriginTime) || 0); + const snapStepDelta = Math.round(relativeDelta / dragState.snapInterval); + const targetIndex = clamp( + (Number(dragState.snapOriginIndex) || 0) + snapStepDelta, + 0, + dragState.snapValues.length - 1 + ); + const targetTime = dragState.snapValues[targetIndex]; + return Number((Number.isFinite(targetTime) ? targetTime : rawTime).toFixed(3)); + } + + function evaluatePointCurve(track, time, fallbackValue) { + if (!track.points.length) { + return quantizeTrackValue(track, fallbackValue); + } + + sortTrackPoints(track); + if (time <= track.points[0].time) { + return track.points[0].value; + } + + for (let index = 0; index < track.points.length - 1; index += 1) { + const current = track.points[index]; + const next = track.points[index + 1]; + if (time >= current.time && time <= next.time) { + if (track.type !== "midi-note" && current.mode === "linear" && next.time > current.time) { + const blend = (time - current.time) / (next.time - current.time); + return quantizeTrackValue(track, current.value + ((next.value - current.value) * blend)); + } + return current.value; + } + } + + return track.points[track.points.length - 1].value; + } + + function evaluateAudioGainAtOffset(track, offset) { + const defaultGain = clamp(Number(track.gain) || 1, 0, 2); + if (offset < 0) { + return defaultGain; + } + + return evaluatePointCurve(track, offset, defaultGain); + } + function normalizeTrack(track) { const normalized = { ...track, @@ -811,7 +1250,9 @@ normalized.channel = clamp(Number(track.channel) || 1, 1, 16); normalized.controller = clamp(Number(track.controller) || 1, 0, 127); } else if (track.type === "artnet-channel" || track.type === "sacn-channel") { - normalized.universe = clamp(Number(track.universe) || 0, 0, 32767); + normalized.universe = track.type === "sacn-channel" + ? clamp(Number(track.universe) || 1, 1, 63999) + : clamp(Number(track.universe) || 0, 0, 32767); normalized.address = clamp(Number(track.address) || 1, 1, 512); } else if (track.type === "osc-value") { normalized.address = String(track.address || "/time4midi/value"); @@ -848,7 +1289,6 @@ normalized.gain = clamp(Number(track.gain) || 1, 0, 2); normalized.analysisDuration = Math.max(0, Number(track.analysisDuration) || 0); normalized.analysisBpm = Math.max(0, Number(track.analysisBpm) || 0); - normalized.points = []; } normalized.points = normalized.points.map((point) => ({ @@ -859,6 +1299,18 @@ return normalized; } + function normalizeTimeline(timeline) { + return { + id: timeline.id || uid("timeline"), + name: timeline.name || "Timeline", + duration: clamp(Number(timeline.duration) || 120, 1, 86400), + beatSnapEnabled: Boolean(timeline.beatSnapEnabled), + beatSnapGrid: normalizeBeatSnapGrid(timeline.beatSnapGrid), + beatSnapLineWidth: normalizeBeatSnapLineWidth(timeline.beatSnapLineWidth), + tracks: Array.isArray(timeline.tracks) ? timeline.tracks.map(normalizeTrack) : [] + }; + } + function getSelectedTimeline() { return state.project.timelines.find((timeline) => timeline.id === state.selectedTimelineId) || null; } @@ -961,6 +1413,9 @@ fileUrl: "", peaks: [], beatTimes: [], + barTimes: [], + dropTimes: [], + beatInterval: 0, duration: 0, estimatedBpm: 0, analysisReady: false, @@ -1026,9 +1481,22 @@ return peaks.map((entry) => entry / normalization); } + function median(values) { + if (!values?.length) { + return 0; + } + + const sorted = values.slice().sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[middle - 1] + sorted[middle]) / 2; + } + return sorted[middle]; + } + function analyzeBeatGrid(samples, sampleRate) { if (!samples?.length || !sampleRate) { - return { beatTimes: [], estimatedBpm: 0 }; + return { beatTimes: [], barTimes: [], dropTimes: [], beatInterval: 0, estimatedBpm: 0 }; } const windowSize = 2048; @@ -1045,7 +1513,7 @@ } if (!energies.length) { - return { beatTimes: [], estimatedBpm: 0 }; + return { beatTimes: [], barTimes: [], dropTimes: [], beatInterval: 0, estimatedBpm: 0 }; } const averageEnergy = energies.reduce((sum, value) => sum + value, 0) / energies.length; @@ -1065,13 +1533,15 @@ } if (beatTimes.length < 2) { - return { beatTimes, estimatedBpm: 0 }; + return { beatTimes, barTimes: beatTimes.slice(), dropTimes: [], beatInterval: 0, estimatedBpm: 0 }; } const bpmBuckets = new Map(); + const beatIntervals = []; for (let index = 1; index < beatTimes.length; index += 1) { const interval = beatTimes[index] - beatTimes[index - 1]; if (interval <= 0) continue; + beatIntervals.push(interval); let bpm = 60 / interval; while (bpm < 70) bpm *= 2; while (bpm > 180) bpm /= 2; @@ -1081,8 +1551,55 @@ const estimatedBpm = Array.from(bpmBuckets.entries()) .sort((left, right) => right[1] - left[1])[0]?.[0] || 0; + const beatInterval = median(beatIntervals); + const beatStrengths = beatTimes.map((time) => { + const energyIndex = clamp(Math.round((time * sampleRate) / hopSize), 0, energies.length - 1); + const prev = energies[Math.max(0, energyIndex - 1)] || 0; + const current = energies[energyIndex] || 0; + const next = energies[Math.min(energies.length - 1, energyIndex + 1)] || 0; + return Math.max(prev, current, next); + }); - return { beatTimes, estimatedBpm }; + let barPhase = 0; + let strongestPhase = -Infinity; + for (let phase = 0; phase < 4; phase += 1) { + let sum = 0; + for (let index = phase; index < beatStrengths.length; index += 4) { + sum += beatStrengths[index] || 0; + } + if (sum > strongestPhase) { + strongestPhase = sum; + barPhase = phase; + } + } + + const barTimes = beatTimes.filter((_time, index) => ((index - barPhase) % 4) === 0); + const barStrengths = []; + for (let index = barPhase; index < beatStrengths.length; index += 4) { + const slice = beatStrengths.slice(index, index + 4); + if (!slice.length) { + continue; + } + const average = slice.reduce((sum, value) => sum + value, 0) / slice.length; + barStrengths.push(average); + } + + const dropTimes = []; + for (let index = 1; index < barStrengths.length; index += 1) { + const previousWindow = barStrengths.slice(Math.max(0, index - 2), index); + const previousAverage = previousWindow.length + ? previousWindow.reduce((sum, value) => sum + value, 0) / previousWindow.length + : 0; + const currentStrength = barStrengths[index]; + if (previousAverage > 0 && currentStrength > (previousAverage * 1.28)) { + const barTime = barTimes[index]; + if (Number.isFinite(barTime)) { + dropTimes.push(barTime); + } + } + } + + return { beatTimes, barTimes, dropTimes, beatInterval, estimatedBpm }; } async function analyzeAudioTrack(track) { @@ -1109,6 +1626,9 @@ entry.peaks = buildWaveformPeaks(channelData, AUDIO_WAVEFORM_SAMPLES); const beatAnalysis = analyzeBeatGrid(channelData, decoded.sampleRate); entry.beatTimes = beatAnalysis.beatTimes; + entry.barTimes = beatAnalysis.barTimes; + entry.dropTimes = beatAnalysis.dropTimes; + entry.beatInterval = beatAnalysis.beatInterval; entry.estimatedBpm = beatAnalysis.estimatedBpm; entry.duration = decoded.duration; entry.analysisReady = true; @@ -1117,8 +1637,9 @@ track.analysisBpm = beatAnalysis.estimatedBpm; const timeline = getSelectedTimeline(); if (timeline) { - timeline.duration = Math.max(timeline.duration, Math.ceil(track.startTime + decoded.duration)); + ensureTimelineCoversAudioTrack(timeline, track); } + syncTrackDerivedValues(track); renderAll(); } catch (error) { entry.decodeError = error.message; @@ -1143,7 +1664,7 @@ entry.duration = 0; entry.element.load(); } - entry.element.volume = clamp(Number(track.gain) || 1, 0, 2) / 2; + entry.element.volume = evaluateAudioGainAtOffset(track, 0) / 2; if (state.routing.audioOutputId && typeof entry.element.setSinkId === "function") { try { await entry.element.setSinkId(state.routing.audioOutputId); @@ -1163,8 +1684,13 @@ return false; } + const previousAutoName = buildAutoAudioTrackName(track.filePath || track.mediaName || ""); + const shouldReplaceName = !track.name || track.name === previousAutoName; track.filePath = result.filePath; track.mediaName = basenameFromPath(result.filePath); + if (shouldReplaceName) { + track.name = buildAutoAudioTrackName(result.filePath); + } track.analysisDuration = 0; track.analysisBpm = 0; releaseAudioTrackState(track.id); @@ -1198,6 +1724,7 @@ const offset = state.transport.currentTime - track.startTime; const duration = entry.duration || track.analysisDuration || Number.MAX_SAFE_INTEGER; const active = offset >= 0 && offset <= duration; + entry.element.volume = evaluateAudioGainAtOffset(track, Math.max(0, offset)) / 2; if (!active) { entry.element.pause(); @@ -1349,6 +1876,23 @@ return (relative / metrics.plotWidth) * safeDuration; } + function getVisibleTimeRange(duration, metrics) { + if (!refs.canvasFrame) { + return { + start: 0, + end: Math.max(1, duration) + }; + } + + const overscan = 120; + const viewLeft = refs.canvasFrame.scrollLeft - overscan; + const viewRight = refs.canvasFrame.scrollLeft + refs.canvasFrame.clientWidth + overscan; + return { + start: xToTime(viewLeft, duration, metrics), + end: xToTime(viewRight, duration, metrics) + }; + } + function followPlayheadIfNeeded() { const timeline = getSelectedTimeline(); if (!timeline || !refs.canvasFrame) { @@ -1520,14 +2064,40 @@ } } + function drawAudioSnapOverlay(duration, metrics) { + const timeline = getSelectedTimeline(); + const snapData = getAudioSnapData(timeline); + if (!snapData.values.length) { + return; + } + + const visibleRange = getVisibleTimeRange(duration, metrics); + ctx.strokeStyle = "rgba(247, 223, 104, 0.42)"; + ctx.lineWidth = Number(timeline?.beatSnapLineWidth || 2); + + for (const snapTime of snapData.values) { + if (snapTime < visibleRange.start || snapTime > visibleRange.end) { + continue; + } + + const x = timeToX(snapTime, duration, metrics); + ctx.beginPath(); + ctx.moveTo(x, RULER_HEIGHT); + ctx.lineTo(x, metrics.height); + ctx.stroke(); + } + ctx.lineWidth = 1; + } + function drawAudioTrackWaveform(track, trackIndex, duration, metrics) { const entry = runtime.audioTracks.get(track.id); const startTime = Math.max(0, Number(track.startTime) || 0); - const trackDuration = entry?.duration || track.analysisDuration || 0; + const trackDuration = getAudioTrackDuration(track); const waveformTop = RULER_HEIGHT + (trackIndex * TRACK_HEIGHT) + 18; const waveformHeight = TRACK_HEIGHT - 36; const centerY = waveformTop + (waveformHeight / 2); const color = getTrackColor(track); + const visibleRange = getVisibleTimeRange(duration, metrics); if (trackDuration > 0) { const startX = timeToX(startTime, duration, metrics); @@ -1546,7 +2116,7 @@ ctx.strokeStyle = "rgba(142, 232, 200, 0.18)"; entry.beatTimes.forEach((beatTime) => { const absoluteTime = startTime + beatTime; - if (absoluteTime < 0 || absoluteTime > duration) return; + if (absoluteTime < visibleRange.start || absoluteTime > visibleRange.end) return; const x = timeToX(absoluteTime, duration, metrics); ctx.beginPath(); ctx.moveTo(x, waveformTop); @@ -1555,6 +2125,41 @@ }); } + if (entry?.barTimes?.length) { + ctx.strokeStyle = "rgba(110, 194, 242, 0.38)"; + ctx.lineWidth = 1.3; + entry.barTimes.forEach((barTime) => { + const absoluteTime = startTime + barTime; + if (absoluteTime < visibleRange.start || absoluteTime > visibleRange.end) return; + const x = timeToX(absoluteTime, duration, metrics); + ctx.beginPath(); + ctx.moveTo(x, waveformTop - 6); + ctx.lineTo(x, waveformTop + waveformHeight + 6); + ctx.stroke(); + }); + ctx.lineWidth = 1; + } + + if (entry?.dropTimes?.length) { + ctx.strokeStyle = "rgba(255, 126, 135, 0.72)"; + ctx.fillStyle = "rgba(255, 126, 135, 0.92)"; + entry.dropTimes.forEach((dropTime) => { + const absoluteTime = startTime + dropTime; + if (absoluteTime < visibleRange.start || absoluteTime > visibleRange.end) return; + const x = timeToX(absoluteTime, duration, metrics); + ctx.beginPath(); + ctx.moveTo(x, waveformTop - 10); + ctx.lineTo(x, waveformTop + waveformHeight + 10); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, waveformTop - 12); + ctx.lineTo(x - 6, waveformTop - 22); + ctx.lineTo(x + 6, waveformTop - 22); + ctx.closePath(); + ctx.fill(); + }); + } + if (!entry?.peaks?.length) { ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.font = "12px Trebuchet MS"; @@ -1594,10 +2199,53 @@ ctx.lineWidth = 1; } + function drawAudioTrackAutomation(track, trackIndex, duration, metrics) { + if (!track.points.length) { + return; + } + + const startTime = Math.max(0, Number(track.startTime) || 0); + const color = getTrackColor(track); + sortTrackPoints(track); + + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + track.points.forEach((point, pointIndex) => { + const absoluteTime = startTime + point.time; + const x = timeToX(Math.min(duration, absoluteTime), duration, metrics); + const y = valueToY(track, point.value, trackIndex); + if (pointIndex === 0) { + ctx.moveTo(x, y); + } else { + const previous = track.points[pointIndex - 1]; + const prevY = valueToY(track, previous.value, trackIndex); + if (previous.mode === "linear" && point.time > previous.time) { + ctx.lineTo(x, y); + } else { + ctx.lineTo(x, prevY); + ctx.lineTo(x, y); + } + } + }); + ctx.stroke(); + + track.points.forEach((point) => { + const x = timeToX(Math.min(duration, startTime + point.time), duration, metrics); + const y = valueToY(track, point.value, trackIndex); + const isSelected = point.id === state.selectedPointId && track.id === state.selectedTrackId; + ctx.fillStyle = isSelected ? "#ffffff" : color; + ctx.beginPath(); + ctx.arc(x, y, isSelected ? POINT_RADIUS + 2 : POINT_RADIUS, 0, Math.PI * 2); + ctx.fill(); + }); + } + function drawTracks(duration, tracks, metrics) { tracks.forEach((track, trackIndex) => { if (track.type === "audio-file") { drawAudioTrackWaveform(track, trackIndex, duration, metrics); + drawAudioTrackAutomation(track, trackIndex, duration, metrics); return; } sortTrackPoints(track); @@ -1661,6 +2309,7 @@ const duration = Math.max(1, timeline.duration); drawGrid(duration, timeline.tracks, metrics); + drawAudioSnapOverlay(duration, metrics); drawTracks(duration, timeline.tracks, metrics); drawPlayhead(duration, metrics); } @@ -1706,9 +2355,9 @@ for (const track of timeline.tracks) { const fullName = track.name || "Unbenannt"; - const shortName = shortenLabel(fullName, 24); + const shortName = shortenLabel(fullName, track.type === "audio-file" ? 18 : 24); const detail = describeTrack(track); - const shortDetail = shortenLabel(detail, 34); + const shortDetail = shortenLabel(detail, track.type === "audio-file" ? 26 : 34); const card = document.createElement("div"); card.className = `track-card ${track.id === state.selectedTrackId ? "active" : ""}`; card.dataset.trackId = track.id; @@ -1963,14 +2612,16 @@
Dauer: ${track.analysisDuration ? `${track.analysisDuration.toFixed(2)} s` : "noch nicht erkannt"}
BPM: ${track.analysisBpm ? Math.round(track.analysisBpm) : "nicht erkannt"}
- Waveform: ${audioState?.analysisReady ? "bereit" : (audioState?.decodeError ? escapeHtml(audioState.decodeError) : "wird analysiert")} + Beats: ${audioState?.beatTimes?.length || 0} / Takte: ${audioState?.barTimes?.length || 0} / Drops: ${audioState?.dropTimes?.length || 0}
+ Waveform: ${audioState?.analysisReady ? "bereit" : (audioState?.decodeError ? escapeHtml(audioState.decodeError) : "wird analysiert")}
+ Fade-Punkte: ${track.points.length ? `${track.points.length} aktiv` : "noch keine"}
`; } else if (track.type === "osc-value") { @@ -2023,16 +2674,14 @@ `; - const interpolationField = track.type === "audio-file" - ? "" - : ` - - `; + const interpolationField = ` + + `; let pointFields = `
Waehle einen Punkt im Editor, um Zeit, Wert und Modus zu aendern.
`; if (point) { @@ -2040,11 +2689,11 @@
Punkt
@@ -2413,8 +3062,15 @@ } function renderTransportState() { + const timeline = getSelectedTimeline(); refs.syncSourceSelect.value = state.transport.syncSource; refs.timecodeLabel.textContent = formatTimecode(state.transport.currentTime, state.transport.fps); + refs.timelineDurationInput.value = String(timeline?.duration || 120); + refs.beatSnapEnabledInput.checked = Boolean(timeline?.beatSnapEnabled); + refs.beatSnapGridSelect.value = normalizeBeatSnapGrid(timeline?.beatSnapGrid); + refs.beatSnapLineWidthSelect.value = normalizeBeatSnapLineWidth(timeline?.beatSnapLineWidth); + refs.beatSnapSourceLabel.textContent = getBeatSnapSourceLabel(timeline); + refs.beatSnapSourceLabel.title = refs.beatSnapSourceLabel.textContent; refs.playButton.textContent = state.transport.playing ? "Resume" : "Play"; if (state.transport.playing) { refs.transportStatusLabel.textContent = `Playing (${state.transport.syncSource})`; @@ -2660,6 +3316,8 @@ id: uid("timeline"), name: `Timeline ${String.fromCharCode(65 + state.project.timelines.length)}`, duration: 120, + beatSnapEnabled: false, + beatSnapGrid: "1", tracks: [] }; state.project.timelines.push(timeline); @@ -2784,14 +3442,13 @@ gain: 1, analysisDuration: 0, analysisBpm: 0, - interpolationDefault: "hold", + interpolationDefault: "linear", points: [] }; const picked = await chooseAudioFileForTrack(track); if (!picked) { return; } - track.name = track.mediaName.replace(/\.[^.]+$/, "") || track.name; } else { track = { id: uid("track"), @@ -2903,28 +3560,7 @@ } function evaluateTrackAt(track, time) { - if (!track.points.length) { - return trackRange(track).min; - } - - sortTrackPoints(track); - if (time <= track.points[0].time) { - return track.points[0].value; - } - - for (let index = 0; index < track.points.length - 1; index += 1) { - const current = track.points[index]; - const next = track.points[index + 1]; - if (time >= current.time && time <= next.time) { - if (track.type !== "midi-note" && current.mode === "linear" && next.time > current.time) { - const blend = (time - current.time) / (next.time - current.time); - return quantizeTrackValue(track, current.value + ((next.value - current.value) * blend)); - } - return current.value; - } - } - - return track.points[track.points.length - 1].value; + return evaluatePointCurve(track, time, trackRange(track).min); } function sendMidiMessage(bytes) { @@ -3400,12 +4036,7 @@ state.project = { version: parsed.version || 6, name: parsed.name || "Geladenes Projekt", - timelines: Array.isArray(parsed.timelines) ? parsed.timelines.map((timeline) => ({ - id: timeline.id || uid("timeline"), - name: timeline.name || "Timeline", - duration: clamp(Number(timeline.duration) || 120, 1, 86400), - tracks: Array.isArray(timeline.tracks) ? timeline.tracks.map(normalizeTrack) : [] - })) : [] + timelines: Array.isArray(parsed.timelines) ? parsed.timelines.map(normalizeTimeline) : [] }; state.selectedTimelineId = parsed.selectedTimelineId || state.project.timelines[0]?.id || ""; state.routing = { @@ -3439,12 +4070,36 @@ for (let trackIndex = 0; trackIndex < timeline.tracks.length; trackIndex += 1) { const track = timeline.tracks[trackIndex]; for (const point of track.points) { - const pointX = timeToX(point.time, duration, metrics); + const pointTime = track.type === "audio-file" + ? Math.max(0, Number(track.startTime) || 0) + point.time + : point.time; + const pointX = timeToX(pointTime, duration, metrics); const pointY = valueToY(track, point.value, trackIndex); if (Math.hypot(pointX - x, pointY - y) <= 12) { return { track, point, trackIndex }; } } + + if (track.type === "audio-file") { + const clipDuration = getAudioTrackDuration(track); + const clipTop = RULER_HEIGHT + (trackIndex * TRACK_HEIGHT) + 14; + const clipBottom = clipTop + (TRACK_HEIGHT - 28); + if (clipDuration > 0 && y >= clipTop && y <= clipBottom) { + const clipStartX = timeToX(track.startTime, duration, metrics); + const clipEndX = timeToX(Math.min(duration, track.startTime + clipDuration), duration, metrics); + if (x >= clipStartX && x <= clipEndX) { + return { + track, + point: null, + trackIndex, + clip: { + startTime: track.startTime, + duration: clipDuration + } + }; + } + } + } } if (y < RULER_HEIGHT) { @@ -3470,11 +4125,24 @@ if (!timeline) return; const hit = findCanvasHit(x, y); if (!hit || !hit.track) return; - if (hit.track.type === "audio-file") return; const metrics = getPlotMetrics(); + let pointTime = Number(xToTime(x, timeline.duration, metrics).toFixed(2)); + pointTime = snapTimelineTimeToAudioGrid(pointTime, timeline, hit.track); + if (hit.track.type === "audio-file") { + const clipDuration = getAudioTrackDuration(hit.track); + if (clipDuration <= 0) { + return; + } + pointTime = clamp( + Number((pointTime - Math.max(0, Number(hit.track.startTime) || 0)).toFixed(2)), + 0, + clipDuration + ); + } + const point = { id: uid("point"), - time: Number(xToTime(x, timeline.duration, metrics).toFixed(2)), + time: pointTime, value: yToValue(hit.track, y, hit.trackIndex), mode: hit.track.interpolationDefault }; @@ -3554,15 +4222,25 @@ track.argType = rawValue; } else if (binding === "track.startTime" && track) { track.startTime = Math.max(0, Number(rawValue) || 0); + if (timeline) { + ensureTimelineCoversAudioTrack(timeline, track); + } void syncAudioTracks(true); } else if (binding === "track.gain" && track) { track.gain = clamp(Number(rawValue) || 1, 0, 2); const entry = runtime.audioTracks.get(track.id); if (entry?.element) { - entry.element.volume = track.gain / 2; + const offset = Math.max(0, state.transport.currentTime - track.startTime); + entry.element.volume = evaluateAudioGainAtOffset(track, offset) / 2; } } else if (binding === "point.time" && point && timeline && track) { - point.time = clamp(Number(rawValue) || 0, 0, timeline.duration); + point.time = track.type === "audio-file" + ? clamp(Number(rawValue) || 0, 0, getAudioTrackDuration(track)) + : clamp( + snapTimelineTimeToAudioGrid(Number(rawValue) || 0, timeline, track), + 0, + timeline.duration + ); sortTrackPoints(track); } else if (binding === "point.value" && point && track) { point.value = quantizeTrackValue(track, rawValue); @@ -3608,6 +4286,9 @@ entry.analysisReady = false; entry.peaks = []; entry.beatTimes = []; + entry.barTimes = []; + entry.dropTimes = []; + entry.beatInterval = 0; void analyzeAudioTrack(track); } } @@ -3815,6 +4496,44 @@ refs.playButton.addEventListener("click", playTransport); refs.pauseButton.addEventListener("click", pauseTransport); refs.stopButton.addEventListener("click", () => stopTransport(true)); + refs.timelineDurationInput.addEventListener("change", () => { + const timeline = getSelectedTimeline(); + if (!timeline) { + return; + } + timeline.duration = clamp(Number(refs.timelineDurationInput.value) || 1, 1, 86400); + renderAll(); + }); + refs.beatSnapEnabledInput.addEventListener("change", () => { + const timeline = getSelectedTimeline(); + if (!timeline) { + return; + } + timeline.beatSnapEnabled = refs.beatSnapEnabledInput.checked; + renderTransportState(); + drawCanvas(); + }); + refs.beatSnapGridSelect.addEventListener("change", () => { + const timeline = getSelectedTimeline(); + if (!timeline) { + return; + } + timeline.beatSnapGrid = normalizeBeatSnapGrid(refs.beatSnapGridSelect.value); + renderTransportState(); + drawCanvas(); + }); + refs.beatSnapLineWidthSelect.addEventListener("change", () => { + const timeline = getSelectedTimeline(); + if (!timeline) { + return; + } + timeline.beatSnapLineWidth = normalizeBeatSnapLineWidth(refs.beatSnapLineWidthSelect.value); + renderTransportState(); + drawCanvas(); + }); + refs.openDiagnosticsButton.addEventListener("click", () => { + void window.time4midi.diagnostics.openWindow(); + }); refs.openSettingsButton.addEventListener("click", () => openSettings()); refs.closeSettingsButton.addEventListener("click", closeSettings); refs.settingsBackdrop.addEventListener("click", closeSettings); @@ -3978,12 +4697,37 @@ state.selectedPointId = hit.point ? hit.point.id : ""; } + if (event.detail > 1) { + renderAll(); + return; + } + if (hit.point) { + const pointerOriginTime = Number(xToTime(x, Math.max(1, getSelectedTimeline()?.duration || 1), getPlotMetrics()).toFixed(3)); + const pointOriginTime = getCurrentPointAbsoluteTime(hit.track, hit.point); + const snapData = getAudioSnapData(getSelectedTimeline()); runtime.drag = { type: "point", trackId: hit.track.id, pointId: hit.point.id, - trackIndex: hit.trackIndex + trackIndex: hit.trackIndex, + pointerOriginTime, + pointOriginTime, + snapOriginIndex: hit.track.type === "audio-file" ? -1 : findNearestSnapIndex(snapData.values || [], pointOriginTime), + snapInterval: (snapData.values?.length || 0) >= 2 ? (snapData.values[1] - snapData.values[0]) : 0, + snapValues: snapData.values || [], + snapClipStart: snapData.clipStart || 0, + snapClipEnd: snapData.clipEnd || 0 + }; + renderAll(); + return; + } + + if (hit.clip && hit.track?.type === "audio-file") { + runtime.drag = { + type: "audio-clip", + trackId: hit.track.id, + clipOffset: x - timeToX(hit.track.startTime, Math.max(1, getSelectedTimeline()?.duration || 1), getPlotMetrics()) }; renderAll(); return; @@ -4012,6 +4756,19 @@ const timeline = getSelectedTimeline(); if (!timeline) return; + if (runtime.drag.type === "audio-clip") { + const track = timeline.tracks.find((entry) => entry.id === runtime.drag.trackId); + if (!track) { + return; + } + const metrics = getPlotMetrics(); + const clipX = clamp(x - runtime.drag.clipOffset, metrics.left, metrics.right); + track.startTime = Math.max(0, Number(xToTime(clipX, timeline.duration, metrics).toFixed(2))); + ensureTimelineCoversAudioTrack(timeline, track); + drawCanvas(); + return; + } + const track = timeline.tracks.find((entry) => entry.id === runtime.drag.trackId); const point = track?.points.find((entry) => entry.id === runtime.drag.pointId); if (!track || !point) return; @@ -4019,15 +4776,34 @@ const metrics = getPlotMetrics(); const clampedX = clamp(x, metrics.left, metrics.right); const clampedY = clamp(y, RULER_HEIGHT, refs.timelineCanvas.clientHeight); - point.time = Number(xToTime(clampedX, timeline.duration, metrics).toFixed(2)); + const rawAbsoluteTime = Number(( + (Number(runtime.drag.pointOriginTime) || getCurrentPointAbsoluteTime(track, point)) + + (xToTime(clampedX, timeline.duration, metrics) - (Number(runtime.drag.pointerOriginTime) || 0)) + ).toFixed(3)); + const absoluteTime = snapDraggedPointFromOrigin( + rawAbsoluteTime, + timeline, + track, + runtime.drag + ); + setPointAbsoluteTime(track, point, absoluteTime, timeline); point.value = yToValue(track, clampedY, runtime.drag.trackIndex); - sortTrackPoints(track); - renderSelectionInspector(); drawCanvas(); }); window.addEventListener("mouseup", () => { + const completedDrag = runtime.drag; runtime.drag = null; + if (!completedDrag) { + return; + } + + const timeline = getSelectedTimeline(); + const track = timeline?.tracks.find((entry) => entry.id === completedDrag.trackId) || null; + if (track?.type === "audio-file") { + void syncAudioTracks(true); + } + renderAll(); }); window.addEventListener("keydown", (event) => { @@ -4047,6 +4823,25 @@ return; } + if (event.key === "PageDown" || event.key === "PageUp") { + event.preventDefault(); + const direction = event.key === "PageDown" ? 1 : -1; + const pageWidth = refs.canvasFrame?.clientWidth || 0; + setCanvasScrollLeft((refs.canvasFrame?.scrollLeft || 0) + (direction * Math.max(180, pageWidth * 0.9))); + return; + } + + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + const moved = moveSelectedPointBySnap( + event.key === "ArrowRight" ? 1 : -1, + Boolean(event.ctrlKey || event.metaKey) + ); + if (moved) { + event.preventDefault(); + } + return; + } + if (event.key !== "Delete" && event.key !== "Backspace") { return; } diff --git a/src/diagnostics.html b/src/diagnostics.html new file mode 100644 index 0000000..5adc73e --- /dev/null +++ b/src/diagnostics.html @@ -0,0 +1,85 @@ + + + + + + Time4Midi Diagnose + + + +
+
+
+
Live Monitor
+

Diagnose

+

Zeigt die letzten gesendeten Werte fuer Art-Net, sACN, MQTT und KNX waehrend die Show laeuft.

+
+
+
+ Art-Net Universes + 0 +
+
+ sACN Universes + 0 +
+
+ MQTT Topics + 0 +
+
+ KNX Ziele + 0 +
+
+ Letztes Update + - +
+
+
+ +
+
+
+

Art-Net

+ DMX +
+
+
+ +
+
+

sACN

+ DMX +
+
+
+ +
+
+

MQTT

+ Topics +
+
+
+ +
+
+

KNX

+ Write +
+
+
+ +
+
+

Letzte Events

+
+
+
+
+
+ + + + diff --git a/src/diagnostics.js b/src/diagnostics.js new file mode 100644 index 0000000..c8817d8 --- /dev/null +++ b/src/diagnostics.js @@ -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 = `
${escapeHtml(emptyMessage)}
`; + return; + } + + container.innerHTML = ` +
+ ${entries.map((entry) => { + const activeChannels = getActiveChannels(entry.values); + const visibleChannels = activeChannels.slice(0, 48); + const hiddenCount = Math.max(0, activeChannels.length - visibleChannels.length); + + return ` +
+
+ Universe ${entry.universe} + ${activeChannels.length} aktive Kanaele +
+ + ${visibleChannels.length ? ` +
+ ${visibleChannels.map((channel) => ` +
Ch ${channel.channel}: ${channel.value}
+ `).join("")} +
+ ${hiddenCount > 0 ? `` : ""} + ` : ` + + `} +
+ `; + }).join("")} +
+ `; + } + + function renderMqttContainer() { + const entries = Object.values(state.mqtt.topics || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0)); + if (!entries.length) { + refs.mqttDiagnostics.innerHTML = `
Noch keine MQTT-Nachrichten gesendet.
`; + return; + } + + refs.mqttDiagnostics.innerHTML = ` +
+ ${entries.map((entry) => ` +
+
+ ${escapeHtml(entry.topic)} + ${formatTimestamp(entry.updatedAt)} +
+ +
+ `).join("")} +
+ `; + } + + function renderKnxContainer() { + const entries = Object.values(state.knx.groupAddresses || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0)); + if (!entries.length) { + refs.knxDiagnostics.innerHTML = `
Noch keine KNX-Schreibvorgaenge gesendet.
`; + return; + } + + refs.knxDiagnostics.innerHTML = ` +
+ ${entries.map((entry) => ` +
+
+ ${escapeHtml(entry.groupAddress)} + ${escapeHtml(entry.dpt)} +
+ +
+ `).join("")} +
+ `; + } + + function describeRecentEvent(entry) { + if (entry.kind === "dmx-frame") { + return ` +
+
+ ${escapeHtml(entry.transport === "artnet" ? "Art-Net" : "sACN")} Universe ${escapeHtml(entry.universe)} + ${escapeHtml(entry.transport)} +
+
+ ${escapeHtml(entry.activeChannels)} aktive Kanaele + ${formatTimestamp(entry.timestamp)} +
+
+ `; + } + + if (entry.kind === "mqtt-publish") { + return ` +
+
+ ${escapeHtml(entry.topic)} + mqtt +
+
+ Wert ${escapeHtml(formatValue(entry.value))} + ${formatTimestamp(entry.timestamp)} +
+
+ `; + } + + if (entry.kind === "knx-write") { + return ` +
+
+ ${escapeHtml(entry.groupAddress)} + ${escapeHtml(entry.dpt)} +
+
+ Wert ${escapeHtml(formatValue(entry.value))} + ${formatTimestamp(entry.timestamp)} +
+
+ `; + } + + return ""; + } + + function renderRecentEvents() { + const entries = state.recentEvents.slice(0, 80); + if (!entries.length) { + refs.recentDiagnostics.innerHTML = `
Noch keine Diagnose-Events vorhanden.
`; + return; + } + + refs.recentDiagnostics.innerHTML = ` +
+ ${entries.map((entry) => describeRecentEvent(entry)).join("")} +
+ `; + } + + 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 = `
Diagnose-Bridge nicht verfuegbar. Bitte App neu starten.
`; + 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(); +})(); diff --git a/src/index.html b/src/index.html index 05ca968..76e4a82 100644 --- a/src/index.html +++ b/src/index.html @@ -86,7 +86,40 @@ Status Idle + + + + +
+ Snap Quelle + Kein Audio-Raster +
+ @@ -104,6 +137,7 @@
Doppelklick: Punkt setzen Ziehen: Zeit/Wert aendern + Audio ziehen: Clip verschieben Strg+Mausrad: Zoom Mausrad: horizontal scrollen Entf: Punkt loeschen diff --git a/src/splash.html b/src/splash.html new file mode 100644 index 0000000..d271614 --- /dev/null +++ b/src/splash.html @@ -0,0 +1,168 @@ + + + + + + Time4Midi startet + + + +
+
+
+ Time4Midi + J.H. Artworks +
+ +
+
Startup
+

Time4Midi startet

+

Die Engine, Treiber und das Projekt-Setup werden vorbereitet. Gleich geht es los.

+
+ +
+ +
+ Bitte kurz warten + Renderer wird geladen +
+
+
+
+ + diff --git a/src/styles.css b/src/styles.css index 4bfec97..32ac433 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; + } +}