From a7f8bfb58f0a59c91a8fcb77ed39016080f322f6 Mon Sep 17 00:00:00 2001 From: jhartworks Date: Tue, 16 Jun 2026 12:00:27 +0200 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- JHArtworks2023_wh.png | Bin 0 -> 27066 bytes main.js | 18 + package.json | 1 + preload.js | 217 ++++++++-- src/app.js | 895 ++++++++++++++++++++++++++++++++++++++++-- src/index.html | 528 +++++++++++++++---------- src/styles.css | 363 ++++++++++------- 7 files changed, 1596 insertions(+), 426 deletions(-) create mode 100644 JHArtworks2023_wh.png diff --git a/JHArtworks2023_wh.png b/JHArtworks2023_wh.png new file mode 100644 index 0000000000000000000000000000000000000000..cdc0ff519efc9efdb1e1fb0e181879127547acc3 GIT binary patch literal 27066 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 literal 0 HcmV?d00001 diff --git a/main.js b/main.js index 5362231..1cec2f4 100644 --- a/main.js +++ b/main.js @@ -59,6 +59,24 @@ ipcMain.handle("project:load", async () => { return { canceled: false, filePath, data }; }); +ipcMain.handle("audio:pickFile", async () => { + const result = await dialog.showOpenDialog({ + properties: ["openFile"], + filters: [ + { name: "Audio", extensions: ["mp3", "m4a", "flac", "wma"] } + ] + }); + + if (result.canceled || result.filePaths.length === 0) { + return { canceled: true }; + } + + return { + canceled: false, + filePath: result.filePaths[0] + }; +}); + app.whenReady().then(() => { createWindow(); diff --git a/package.json b/package.json index 30f6b47..bcdbbd6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "main.js", "preload.js", "src/**/*", + "JHArtworks2023_wh.png", "package.json" ], "directories": { diff --git a/preload.js b/preload.js index 36f8d86..142c3f9 100644 --- a/preload.js +++ b/preload.js @@ -1,6 +1,9 @@ const { contextBridge, ipcRenderer } = require("electron"); const crypto = require("node:crypto"); const dgram = require("node:dgram"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const { pathToFileURL } = require("node:url"); const mqtt = require("mqtt"); const { SerialPort } = require("serialport"); const ModbusRTU = require("modbus-serial"); @@ -44,11 +47,13 @@ function addStatusApi(target, handler) { const artnet = { ...createStatusEmitter(), - sendSocket: dgram.createSocket("udp4"), + sendSocket: null, + sendSocketSignature: "", listenSocket: null, listeners: new Set(), config: { enabled: true, + localAddress: "", host: "255.255.255.255", port: ARTNET_PORT, broadcast: true, @@ -59,9 +64,11 @@ const artnet = { const sacn = { ...createStatusEmitter(), - socket: dgram.createSocket("udp4"), + socket: null, + socketSignature: "", config: { enabled: false, + localAddress: "", host: "", port: SACN_PORT, multicast: true, @@ -73,9 +80,11 @@ const sacn = { const osc = { ...createStatusEmitter(), - socket: dgram.createSocket("udp4"), + socket: null, + socketSignature: "", config: { enabled: false, + localAddress: "", host: "127.0.0.1", port: 8000 } @@ -87,6 +96,7 @@ const mqttState = { connected: false, config: { enabled: false, + localAddress: "", brokerUrl: "mqtt://127.0.0.1:1883", username: "", password: "", @@ -115,6 +125,7 @@ const modbusTcpState = { signature: "", config: { enabled: false, + localAddress: "", host: "127.0.0.1", port: 502, timeout: 1000 @@ -144,6 +155,7 @@ const knxState = { connected: false, config: { enabled: false, + interface: "", ipAddr: "224.0.23.12", ipPort: KNX_PORT, physAddr: "15.15.15", @@ -154,6 +166,7 @@ const knxState = { function normalizeArtNetConfig(nextConfig = {}) { return { enabled: nextConfig.enabled !== false, + localAddress: String(nextConfig.localAddress || "").trim(), host: nextConfig.host || "255.255.255.255", port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : ARTNET_PORT, broadcast: Boolean(nextConfig.broadcast), @@ -166,6 +179,7 @@ function normalizeArtNetConfig(nextConfig = {}) { function normalizeSacnConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), + localAddress: String(nextConfig.localAddress || "").trim(), host: String(nextConfig.host || ""), port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : SACN_PORT, multicast: nextConfig.multicast !== false, @@ -176,6 +190,7 @@ function normalizeSacnConfig(nextConfig = {}) { function normalizeOscConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), + localAddress: String(nextConfig.localAddress || "").trim(), host: nextConfig.host || "127.0.0.1", port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : 8000 }; @@ -184,6 +199,7 @@ function normalizeOscConfig(nextConfig = {}) { function normalizeMqttConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), + localAddress: String(nextConfig.localAddress || "").trim(), brokerUrl: String(nextConfig.brokerUrl || "").trim() || "mqtt://127.0.0.1:1883", username: String(nextConfig.username || ""), password: String(nextConfig.password || ""), @@ -206,6 +222,7 @@ function normalizeSerialConfig(nextConfig = {}) { function normalizeModbusTcpConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), + localAddress: String(nextConfig.localAddress || "").trim(), host: String(nextConfig.host || "127.0.0.1"), port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : 502, timeout: Number.isFinite(Number(nextConfig.timeout)) ? Number(nextConfig.timeout) : 1000 @@ -227,6 +244,7 @@ function normalizeModbusRtuConfig(nextConfig = {}) { function normalizeKnxConfig(nextConfig = {}) { return { enabled: Boolean(nextConfig.enabled), + interface: String(nextConfig.interface || "").trim(), ipAddr: String(nextConfig.ipAddr || "224.0.23.12"), ipPort: Number.isFinite(Number(nextConfig.ipPort)) ? Number(nextConfig.ipPort) : KNX_PORT, physAddr: String(nextConfig.physAddr || "15.15.15"), @@ -234,6 +252,79 @@ function normalizeKnxConfig(nextConfig = {}) { }; } +function createUdpSocket(target, label) { + const socket = dgram.createSocket("udp4"); + socket.on("error", (error) => { + emitStatus(target, `${label} Socket Fehler: ${error.message}`); + }); + return socket; +} + +function closeUdpSocket(target, key, signatureKey) { + if (target[key]) { + try { + target[key].close(); + } catch (_error) { + // Ignore close races while reconfiguring sockets. + } + } + target[key] = null; + target[signatureKey] = ""; +} + +async function ensureUdpSocket(target, key, signatureKey, label, config, options = {}) { + const signature = `${config.localAddress || ""}|${options.broadcast ? "1" : "0"}`; + if (target[key] && target[signatureKey] === signature) { + return target[key]; + } + + closeUdpSocket(target, key, signatureKey); + const socket = createUdpSocket(target, label); + target[key] = socket; + target[signatureKey] = signature; + + if (options.broadcast) { + socket.setBroadcast(true); + } + + if (!config.localAddress) { + return socket; + } + + await new Promise((resolve, reject) => { + let settled = false; + const handleError = (error) => { + if (settled) { + return; + } + settled = true; + socket.off("listening", handleListening); + reject(error); + }; + const handleListening = () => { + if (settled) { + return; + } + settled = true; + socket.off("error", handleError); + resolve(true); + }; + + socket.once("error", handleError); + socket.once("listening", handleListening); + try { + socket.bind(0, config.localAddress); + } catch (error) { + socket.off("error", handleError); + socket.off("listening", handleListening); + reject(error); + } + }); + + emitStatus(target, ""); + return socket; +} + function closeArtNetListener() { if (artnet.listenSocket) { try { @@ -273,10 +364,7 @@ function ensureArtNetListener() { return; } - artnet.listenSocket = dgram.createSocket("udp4"); - artnet.listenSocket.on("error", (error) => { - emitStatus(artnet, `Art-Net Listen Fehler: ${error.message}`); - }); + artnet.listenSocket = createUdpSocket(artnet, "Art-Net"); artnet.listenSocket.on("listening", () => { emitStatus(artnet, ""); }); @@ -289,7 +377,11 @@ function ensureArtNetListener() { }); try { - artnet.listenSocket.bind(artnet.config.timecodeListenPort); + if (artnet.config.localAddress) { + artnet.listenSocket.bind(artnet.config.timecodeListenPort, artnet.config.localAddress); + } else { + artnet.listenSocket.bind(artnet.config.timecodeListenPort); + } } catch (error) { emitStatus(artnet, `Art-Net Listen Fehler: ${error.message}`); } @@ -297,32 +389,40 @@ function ensureArtNetListener() { function configureArtNet(nextConfig) { const previousPort = artnet.config.timecodeListenPort; + const previousLocalAddress = artnet.config.localAddress; artnet.config = normalizeArtNetConfig(nextConfig); - try { - artnet.sendSocket.setBroadcast(artnet.config.broadcast); - } catch (error) { - emitStatus(artnet, `Art-Net Konfig Fehler: ${error.message}`); - } - if (!artnet.config.enabled) { closeArtNetListener(); + closeUdpSocket(artnet, "sendSocket", "sendSocketSignature"); emitStatus(artnet, ""); return artnet.config; } - if (previousPort !== artnet.config.timecodeListenPort) { + if ( + previousPort !== artnet.config.timecodeListenPort + || previousLocalAddress !== artnet.config.localAddress + ) { closeArtNetListener(); } + if ( + artnet.sendSocket + && ( + previousLocalAddress !== artnet.config.localAddress + || Boolean(artnet.config.broadcast) !== artnet.sendSocketSignature.endsWith("|1") + ) + ) { + closeUdpSocket(artnet, "sendSocket", "sendSocketSignature"); + } if (artnet.listeners.size > 0) { ensureArtNetListener(); } return artnet.config; } -function sendArtNetDmx(payload) { +async function sendArtNetDmx(payload) { if (!artnet.config.enabled) { - return Promise.resolve(false); + return false; } const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0)); @@ -344,8 +444,17 @@ function sendArtNetDmx(payload) { } artnet.sequence = artnet.sequence >= 255 ? 1 : artnet.sequence + 1; + const socket = await ensureUdpSocket( + artnet, + "sendSocket", + "sendSocketSignature", + "Art-Net", + artnet.config, + { broadcast: artnet.config.broadcast } + ); + return new Promise((resolve, reject) => { - artnet.sendSocket.send(packet, 0, packet.length, artnet.config.port, artnet.config.host, (error) => { + socket.send(packet, 0, packet.length, artnet.config.port, artnet.config.host, (error) => { if (error) { emitStatus(artnet, `Art-Net Send Fehler: ${error.message}`); reject(error); @@ -358,7 +467,11 @@ function sendArtNetDmx(payload) { } function configureOsc(nextConfig) { + const previousLocalAddress = osc.config.localAddress; osc.config = normalizeOscConfig(nextConfig); + if (previousLocalAddress !== osc.config.localAddress) { + closeUdpSocket(osc, "socket", "socketSignature"); + } emitStatus(osc, ""); return osc.config; } @@ -383,9 +496,9 @@ function encodeOscArgument(value, argType) { return { tag: "f", data }; } -function sendOsc(payload) { +async function sendOsc(payload) { if (!osc.config.enabled) { - return Promise.resolve(false); + return false; } const address = String(payload.address || "/time4midi"); const normalizedAddress = address.startsWith("/") ? address : `/${address}`; @@ -395,8 +508,9 @@ function sendOsc(payload) { encodeOscString(`,${argument.tag}`), argument.data ]); + const socket = await ensureUdpSocket(osc, "socket", "socketSignature", "OSC", osc.config); return new Promise((resolve, reject) => { - osc.socket.send(packet, 0, packet.length, osc.config.port, osc.config.host, (error) => { + socket.send(packet, 0, packet.length, osc.config.port, osc.config.host, (error) => { if (error) { emitStatus(osc, `OSC Send Fehler: ${error.message}`); reject(error); @@ -409,7 +523,11 @@ function sendOsc(payload) { } function configureSacn(nextConfig) { + const previousLocalAddress = sacn.config.localAddress; sacn.config = normalizeSacnConfig(nextConfig); + if (previousLocalAddress !== sacn.config.localAddress) { + closeUdpSocket(sacn, "socket", "socketSignature"); + } emitStatus(sacn, ""); return sacn.config; } @@ -437,9 +555,9 @@ function writeFixedString(buffer, offset, size, value) { Buffer.from(String(value || ""), "utf8").copy(buffer, offset, 0, size); } -function sendSacnDmx(payload) { +async function sendSacnDmx(payload) { if (!sacn.config.enabled) { - return Promise.resolve(false); + return false; } const universe = clampSacnUniverse(payload.universe); @@ -480,9 +598,10 @@ 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); return new Promise((resolve, reject) => { - sacn.socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => { + socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => { if (error) { emitStatus(sacn, `sACN Send Fehler: ${error.message}`); reject(error); @@ -520,6 +639,7 @@ function configureMqtt(nextConfig) { if (mqttState.config.clientId) options.clientId = mqttState.config.clientId; if (mqttState.config.username) options.username = mqttState.config.username; if (mqttState.config.password) options.password = mqttState.config.password; + if (mqttState.config.localAddress) options.localAddress = mqttState.config.localAddress; try { mqttState.client = mqtt.connect(mqttState.config.brokerUrl, options); @@ -656,6 +776,28 @@ async function listSerialPorts() { })); } +async function listNetworkInterfaces() { + const interfaces = os.networkInterfaces(); + const items = []; + + Object.entries(interfaces).forEach(([name, entries]) => { + (entries || []).forEach((entry) => { + const family = typeof entry.family === "string" ? entry.family : String(entry.family); + if (family !== "IPv4" || entry.internal || !entry.address) { + return; + } + + items.push({ + id: entry.address, + name: `${name} - ${entry.address}` + }); + }); + }); + + items.sort((left, right) => left.name.localeCompare(right.name)); + return items; +} + async function disconnectModbusClient(state) { if (state.client?.isOpen) { try { @@ -693,7 +835,8 @@ async function ensureModbusTcpConnected() { await disconnectModbusClient(modbusTcpState); await modbusTcpState.client.connectTCP(modbusTcpState.config.host, { port: modbusTcpState.config.port, - timeout: modbusTcpState.config.timeout + timeout: modbusTcpState.config.timeout, + localAddress: modbusTcpState.config.localAddress || undefined }); modbusTcpState.client.setTimeout(modbusTcpState.config.timeout); modbusTcpState.connected = true; @@ -806,6 +949,7 @@ async function ensureKnxConnection() { const connection = knx.Connection({ ipAddr: knxState.config.ipAddr, ipPort: knxState.config.ipPort, + interface: knxState.config.interface || undefined, physAddr: knxState.config.physAddr, forceTunneling: knxState.config.forceTunneling, handlers: { @@ -871,18 +1015,6 @@ async function sendKnx(payload) { }); } -artnet.sendSocket.on("error", (error) => { - emitStatus(artnet, `Art-Net Socket Fehler: ${error.message}`); -}); - -osc.socket.on("error", (error) => { - emitStatus(osc, `OSC Socket Fehler: ${error.message}`); -}); - -sacn.socket.on("error", (error) => { - emitStatus(sacn, `sACN Socket Fehler: ${error.message}`); -}); - configureMqtt(mqttState.config); contextBridge.exposeInMainWorld("time4midi", { @@ -890,6 +1022,17 @@ contextBridge.exposeInMainWorld("time4midi", { save: (payload) => ipcRenderer.invoke("project:save", payload), load: () => ipcRenderer.invoke("project:load") }, + audio: { + pickFile: () => ipcRenderer.invoke("audio:pickFile"), + readFile: async (filePath) => { + const buffer = await fs.readFile(filePath); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + }, + fileUrl: (filePath) => pathToFileURL(filePath).href + }, + network: { + listInterfaces: () => listNetworkInterfaces() + }, artnet: { configure: (config) => configureArtNet(config), sendDmx: (payload) => sendArtNetDmx(payload), diff --git a/src/app.js b/src/app.js index 8c0066c..646e661 100644 --- a/src/app.js +++ b/src/app.js @@ -8,11 +8,53 @@ const MIN_CANVAS_HEIGHT = 420; const EXTERNAL_RESYNC_MS = 1000; const EXTERNAL_IDLE_MS = 1500; + const AUDIO_WAVEFORM_SAMPLES = 720; + const AUDIO_SYNC_TOLERANCE = 0.08; + const KNX_DPT_OPTIONS = [ + { + value: "DPT1.001", + label: "DPT1.001 - Schalten", + description: "1 Bit. 0 = Aus, 1 = Ein.", + fixedRange: true, + min: 0, + max: 1, + step: 1, + integer: true + }, + { + value: "DPT5.001", + label: "DPT5.001 - 8 Bit Wert", + description: "1 Byte unsigned. Typisch fuer Dimmer, Szenen und Level von 0 bis 255.", + fixedRange: true, + min: 0, + max: 255, + step: 1, + integer: true + }, + { + value: "DPT9.001", + label: "DPT9.001 - Float", + description: "2 Byte Float. Gut fuer Temperatur, Sensorwerte und andere Fliesskommazahlen.", + fixedRange: false, + defaultMin: 0, + defaultMax: 100, + step: 0.01, + integer: false + }, + { + value: "DPT13.001", + label: "DPT13.001 - Integer", + description: "4 Byte signed Integer. Fuer groessere Zaehler- oder Statuswerte.", + fixedRange: false, + defaultMin: 0, + defaultMax: 1000, + step: 1, + integer: true + } + ]; const refs = { appShell: document.querySelector(".app-shell"), - inspectorPane: document.getElementById("inspectorPane"), - inspectorToggleButton: document.getElementById("inspectorToggleButton"), projectNameInput: document.getElementById("projectNameInput"), projectPathLabel: document.getElementById("projectPathLabel"), newProjectButton: document.getElementById("newProjectButton"), @@ -30,31 +72,43 @@ addKnxTrackButton: document.getElementById("addKnxTrackButton"), addOscTrackButton: document.getElementById("addOscTrackButton"), addMqttTrackButton: document.getElementById("addMqttTrackButton"), + addAudioTrackButton: document.getElementById("addAudioTrackButton"), trackList: document.getElementById("trackList"), syncSourceSelect: document.getElementById("syncSourceSelect"), playButton: document.getElementById("playButton"), pauseButton: document.getElementById("pauseButton"), stopButton: document.getElementById("stopButton"), + openSettingsButton: document.getElementById("openSettingsButton"), + settingsModal: document.getElementById("settingsModal"), + settingsBackdrop: document.getElementById("settingsBackdrop"), + closeSettingsButton: document.getElementById("closeSettingsButton"), + settingsTabButtons: Array.from(document.querySelectorAll("[data-settings-tab]")), + settingsPanels: Array.from(document.querySelectorAll("[data-settings-panel]")), timecodeLabel: document.getElementById("timecodeLabel"), transportStatusLabel: document.getElementById("transportStatusLabel"), timelineTitle: document.getElementById("timelineTitle"), timelineCanvas: document.getElementById("timelineCanvas"), midiInputSelect: document.getElementById("midiInputSelect"), midiOutputSelect: document.getElementById("midiOutputSelect"), + audioOutputSelect: document.getElementById("audioOutputSelect"), + artnetAdapterSelect: document.getElementById("artnetAdapterSelect"), artnetHostInput: document.getElementById("artnetHostInput"), artnetEnabledInput: document.getElementById("artnetEnabledInput"), artnetPortInput: document.getElementById("artnetPortInput"), artnetUniverseInput: document.getElementById("artnetUniverseInput"), artnetTimecodePortInput: document.getElementById("artnetTimecodePortInput"), artnetBroadcastInput: document.getElementById("artnetBroadcastInput"), + sacnAdapterSelect: document.getElementById("sacnAdapterSelect"), sacnHostInput: document.getElementById("sacnHostInput"), sacnEnabledInput: document.getElementById("sacnEnabledInput"), sacnPortInput: document.getElementById("sacnPortInput"), sacnSourceNameInput: document.getElementById("sacnSourceNameInput"), sacnMulticastInput: document.getElementById("sacnMulticastInput"), + oscAdapterSelect: document.getElementById("oscAdapterSelect"), oscHostInput: document.getElementById("oscHostInput"), oscEnabledInput: document.getElementById("oscEnabledInput"), oscPortInput: document.getElementById("oscPortInput"), + mqttAdapterSelect: document.getElementById("mqttAdapterSelect"), mqttBrokerInput: document.getElementById("mqttBrokerInput"), mqttEnabledInput: document.getElementById("mqttEnabledInput"), mqttUsernameInput: document.getElementById("mqttUsernameInput"), @@ -65,6 +119,7 @@ serialParitySelect: document.getElementById("serialParitySelect"), serialModeSelect: document.getElementById("serialModeSelect"), serialEnabledInput: document.getElementById("serialEnabledInput"), + modbusTcpAdapterSelect: document.getElementById("modbusTcpAdapterSelect"), modbusTcpHostInput: document.getElementById("modbusTcpHostInput"), modbusTcpPortInput: document.getElementById("modbusTcpPortInput"), modbusTcpEnabledInput: document.getElementById("modbusTcpEnabledInput"), @@ -72,6 +127,7 @@ modbusRtuBaudInput: document.getElementById("modbusRtuBaudInput"), modbusRtuParitySelect: document.getElementById("modbusRtuParitySelect"), modbusRtuEnabledInput: document.getElementById("modbusRtuEnabledInput"), + knxAdapterSelect: document.getElementById("knxAdapterSelect"), knxIpInput: document.getElementById("knxIpInput"), knxPortInput: document.getElementById("knxPortInput"), knxPhysAddrInput: document.getElementById("knxPhysAddrInput"), @@ -92,8 +148,13 @@ midiInputs: [], midiOutputs: [], drag: null, + trackListDrag: null, animationFrameId: 0, mtcNibbles: Array(8).fill(0), + networkAdapters: [], + audioOutputDevices: [], + audioAnalysisContext: null, + audioTracks: new Map(), outputState: { midiNotes: new Map(), midiCcs: new Map(), @@ -127,7 +188,8 @@ }, routing: createDefaultRouting(), ui: { - inspectorCollapsed: false + settingsOpen: false, + settingsTab: "routing" } }; @@ -141,21 +203,26 @@ return { midiInputId: "", midiOutputId: "", + audioOutputId: "", artnetEnabled: true, + artnetInterface: "", artnetHost: "255.255.255.255", artnetPort: 6454, artnetUniverse: 0, artnetTimecodePort: 6454, artnetBroadcast: true, sacnEnabled: false, + sacnInterface: "", sacnHost: "", sacnPort: 5568, sacnSourceName: "Time4Midi", sacnMulticast: true, oscEnabled: false, + oscInterface: "", oscHost: "127.0.0.1", oscPort: 8000, mqttEnabled: false, + mqttInterface: "", mqttBrokerUrl: "mqtt://127.0.0.1:1883", mqttUsername: "", mqttPassword: "", @@ -166,6 +233,7 @@ serialParity: "none", serialMode: "rs232", modbusTcpEnabled: false, + modbusTcpInterface: "", modbusTcpHost: "127.0.0.1", modbusTcpPort: 502, modbusRtuEnabled: false, @@ -173,6 +241,7 @@ modbusRtuBaud: 9600, modbusRtuParity: "none", knxEnabled: false, + knxInterface: "", knxIp: "224.0.23.12", knxPort: 3671, knxPhysAddr: "15.15.15", @@ -260,6 +329,11 @@ return Math.round(value / step) * step; } + function getKnxDptMeta(dpt) { + const normalized = String(dpt || "DPT5.001").toUpperCase(); + return KNX_DPT_OPTIONS.find((entry) => entry.value === normalized) || KNX_DPT_OPTIONS[1]; + } + function trackRange(track) { if (track.type === "artnet-channel" || track.type === "sacn-channel") { return { min: 0, max: 255, step: 1, integer: true }; @@ -274,12 +348,18 @@ return { min: 0, max: 65535, step: 1, integer: true }; } if (track.type === "knx-value") { - if (String(track.dpt || "").startsWith("DPT1")) { - return { min: 0, max: 1, step: 1, integer: true }; - } - if (String(track.dpt || "").startsWith("DPT5")) { - return { min: 0, max: 255, step: 1, integer: true }; + const dptMeta = getKnxDptMeta(track.dpt); + if (dptMeta.fixedRange) { + return { min: dptMeta.min, max: dptMeta.max, step: dptMeta.step, integer: dptMeta.integer }; } + const minValue = Number.isFinite(Number(track.minValue)) ? Number(track.minValue) : dptMeta.defaultMin; + const maxValue = Number.isFinite(Number(track.maxValue)) ? Number(track.maxValue) : dptMeta.defaultMax; + return { + min: Math.min(minValue, maxValue), + max: Math.max(minValue, maxValue), + step: dptMeta.step, + integer: dptMeta.integer + }; } const minValue = Number.isFinite(Number(track.minValue)) ? Number(track.minValue) : 0; @@ -346,9 +426,23 @@ normalized.registerType = track.registerType || "holding-register"; } else if (track.type === "knx-value") { normalized.groupAddress = String(track.groupAddress || "1/1/1"); - normalized.dpt = String(track.dpt || "DPT5.001"); - normalized.minValue = Number.isFinite(Number(track.minValue)) ? Number(track.minValue) : 0; - normalized.maxValue = Number.isFinite(Number(track.maxValue)) ? Number(track.maxValue) : 255; + normalized.dpt = String(track.dpt || "DPT5.001").toUpperCase(); + const dptMeta = getKnxDptMeta(normalized.dpt); + if (dptMeta.fixedRange) { + normalized.minValue = dptMeta.min; + normalized.maxValue = dptMeta.max; + } else { + normalized.minValue = Number.isFinite(Number(track.minValue)) ? Number(track.minValue) : dptMeta.defaultMin; + normalized.maxValue = Number.isFinite(Number(track.maxValue)) ? Number(track.maxValue) : dptMeta.defaultMax; + } + } else if (track.type === "audio-file") { + normalized.filePath = String(track.filePath || ""); + normalized.mediaName = String(track.mediaName || basenameFromPath(normalized.filePath) || "Audio"); + normalized.startTime = Math.max(0, Number(track.startTime) || 0); + 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) => ({ @@ -407,6 +501,7 @@ if (track.type === "midi-cc") return "CC"; if (track.type === "artnet-channel") return "ART"; if (track.type === "sacn-channel") return "sACN"; + if (track.type === "audio-file") return "AUD"; if (track.type === "serial-value") return "SER"; if (track.type === "modbus-tcp") return "MBT"; if (track.type === "modbus-rtu") return "MBR"; @@ -417,12 +512,13 @@ function getTrackColor(track) { if (track.type === "midi-note") return "#61c8ff"; - if (track.type === "midi-cc") return "#8fd86f"; + if (track.type === "midi-cc") return "#7ed5ff"; if (track.type === "artnet-channel") return "#ffb35c"; if (track.type === "sacn-channel") return "#9ca8ff"; + if (track.type === "audio-file") return "#8ee8c8"; if (track.type === "serial-value") return "#ff9e6b"; - if (track.type === "modbus-tcp") return "#63e0d3"; - if (track.type === "modbus-rtu") return "#49b6ab"; + if (track.type === "modbus-tcp") return "#6fc2de"; + if (track.type === "modbus-rtu") return "#58a9cf"; if (track.type === "knx-value") return "#f26f7d"; if (track.type === "osc-value") return "#ff8fd1"; return "#f7df68"; @@ -433,6 +529,10 @@ if (track.type === "midi-cc") return `Ch ${track.channel} / CC ${track.controller}`; if (track.type === "artnet-channel") return `Uni ${track.universe} / Addr ${track.address}`; if (track.type === "sacn-channel") return `Uni ${track.universe} / Addr ${track.address}`; + if (track.type === "audio-file") { + const bpmText = track.analysisBpm ? ` / ${Math.round(track.analysisBpm)} BPM` : ""; + return `${escapeHtml(track.mediaName || basenameFromPath(track.filePath))}${bpmText}`; + } if (track.type === "serial-value") return escapeHtml(track.template); if (track.type === "modbus-tcp" || track.type === "modbus-rtu") return `ID ${track.unitId} / Addr ${track.address}`; if (track.type === "knx-value") return `${escapeHtml(track.groupAddress)} / ${escapeHtml(track.dpt)}`; @@ -448,6 +548,276 @@ return parts[parts.length - 1] || filePath; } + function getAudioTrackState(trackId) { + if (!runtime.audioTracks.has(trackId)) { + runtime.audioTracks.set(trackId, { + element: null, + fileUrl: "", + peaks: [], + beatTimes: [], + duration: 0, + estimatedBpm: 0, + analysisReady: false, + decodeError: "" + }); + } + return runtime.audioTracks.get(trackId); + } + + function releaseAudioTrackState(trackId) { + const entry = runtime.audioTracks.get(trackId); + if (!entry) return; + if (entry.element) { + entry.element.pause(); + entry.element.removeAttribute("src"); + entry.element.load(); + } + runtime.audioTracks.delete(trackId); + } + + function cleanupMissingAudioTracks() { + const activeTrackIds = new Set( + state.project.timelines.flatMap((timeline) => timeline.tracks) + .filter((track) => track.type === "audio-file") + .map((track) => track.id) + ); + + Array.from(runtime.audioTracks.keys()).forEach((trackId) => { + if (!activeTrackIds.has(trackId)) { + releaseAudioTrackState(trackId); + } + }); + } + + function getAudioAnalysisContext() { + if (!runtime.audioAnalysisContext) { + const ContextClass = window.AudioContext || window.webkitAudioContext; + runtime.audioAnalysisContext = ContextClass ? new ContextClass() : null; + } + return runtime.audioAnalysisContext; + } + + function buildWaveformPeaks(samples, desiredSamples = AUDIO_WAVEFORM_SAMPLES) { + if (!samples || !samples.length) { + return []; + } + + const peaks = []; + const blockSize = Math.max(1, Math.floor(samples.length / desiredSamples)); + for (let index = 0; index < samples.length; index += blockSize) { + let maxValue = 0; + const limit = Math.min(samples.length, index + blockSize); + for (let cursor = index; cursor < limit; cursor += 1) { + const magnitude = Math.abs(samples[cursor]); + if (magnitude > maxValue) { + maxValue = magnitude; + } + } + peaks.push(maxValue); + } + + const normalization = Math.max(...peaks, 1); + return peaks.map((entry) => entry / normalization); + } + + function analyzeBeatGrid(samples, sampleRate) { + if (!samples?.length || !sampleRate) { + return { beatTimes: [], estimatedBpm: 0 }; + } + + const windowSize = 2048; + const hopSize = 1024; + const energies = []; + + for (let index = 0; index + windowSize < samples.length; index += hopSize) { + let sum = 0; + for (let cursor = 0; cursor < windowSize; cursor += 1) { + const value = samples[index + cursor]; + sum += value * value; + } + energies.push(sum / windowSize); + } + + if (!energies.length) { + return { beatTimes: [], estimatedBpm: 0 }; + } + + const averageEnergy = energies.reduce((sum, value) => sum + value, 0) / energies.length; + const threshold = averageEnergy * 1.45; + const beatTimes = []; + let lastBeatTime = -Infinity; + + for (let index = 1; index < energies.length - 1; index += 1) { + const current = energies[index]; + if (current < threshold) continue; + if (current < energies[index - 1] || current < energies[index + 1]) continue; + + const time = (index * hopSize) / sampleRate; + if ((time - lastBeatTime) < 0.22) continue; + beatTimes.push(Number(time.toFixed(3))); + lastBeatTime = time; + } + + if (beatTimes.length < 2) { + return { beatTimes, estimatedBpm: 0 }; + } + + const bpmBuckets = new Map(); + for (let index = 1; index < beatTimes.length; index += 1) { + const interval = beatTimes[index] - beatTimes[index - 1]; + if (interval <= 0) continue; + let bpm = 60 / interval; + while (bpm < 70) bpm *= 2; + while (bpm > 180) bpm /= 2; + const rounded = Math.round(bpm); + bpmBuckets.set(rounded, (bpmBuckets.get(rounded) || 0) + 1); + } + + const estimatedBpm = Array.from(bpmBuckets.entries()) + .sort((left, right) => right[1] - left[1])[0]?.[0] || 0; + + return { beatTimes, estimatedBpm }; + } + + async function analyzeAudioTrack(track) { + if (!track.filePath || !window.time4midi.audio?.readFile) { + return; + } + + const entry = getAudioTrackState(track.id); + entry.analysisReady = false; + entry.decodeError = ""; + + try { + const bufferData = await window.time4midi.audio.readFile(track.filePath); + const context = getAudioAnalysisContext(); + if (!context) { + throw new Error("AudioContext nicht verfuegbar"); + } + + const decoded = await context.decodeAudioData(bufferData.slice(0)); + const channelData = decoded.numberOfChannels > 1 + ? decoded.getChannelData(0).map((sample, index) => (sample + decoded.getChannelData(1)[index]) * 0.5) + : decoded.getChannelData(0); + + entry.peaks = buildWaveformPeaks(channelData, AUDIO_WAVEFORM_SAMPLES); + const beatAnalysis = analyzeBeatGrid(channelData, decoded.sampleRate); + entry.beatTimes = beatAnalysis.beatTimes; + entry.estimatedBpm = beatAnalysis.estimatedBpm; + entry.duration = decoded.duration; + entry.analysisReady = true; + + track.analysisDuration = Number(decoded.duration.toFixed(3)); + track.analysisBpm = beatAnalysis.estimatedBpm; + const timeline = getSelectedTimeline(); + if (timeline) { + timeline.duration = Math.max(timeline.duration, Math.ceil(track.startTime + decoded.duration)); + } + renderAll(); + } catch (error) { + entry.decodeError = error.message; + entry.analysisReady = false; + track.analysisDuration = 0; + track.analysisBpm = 0; + refs.transportStatusLabel.textContent = `Audio Analyse: ${basenameFromPath(track.filePath)} konnte nicht analysiert werden`; + renderAll(); + } + } + + async function ensureAudioTrackReady(track) { + const entry = getAudioTrackState(track.id); + const fileUrl = window.time4midi.audio?.fileUrl ? window.time4midi.audio.fileUrl(track.filePath) : ""; + if (!entry.element) { + entry.element = new Audio(); + entry.element.preload = "auto"; + } + if (fileUrl && entry.fileUrl !== fileUrl) { + entry.fileUrl = fileUrl; + entry.element.src = fileUrl; + entry.duration = 0; + entry.element.load(); + } + entry.element.volume = clamp(Number(track.gain) || 1, 0, 2) / 2; + if (state.routing.audioOutputId && typeof entry.element.setSinkId === "function") { + try { + await entry.element.setSinkId(state.routing.audioOutputId); + } catch (error) { + refs.transportStatusLabel.textContent = `Audio Output Fehler: ${error.message}`; + } + } + if (!entry.analysisReady && !entry.decodeError && track.filePath) { + void analyzeAudioTrack(track); + } + return entry; + } + + async function chooseAudioFileForTrack(track) { + const result = await window.time4midi.audio.pickFile(); + if (result.canceled) { + return false; + } + + track.filePath = result.filePath; + track.mediaName = basenameFromPath(result.filePath); + track.analysisDuration = 0; + track.analysisBpm = 0; + releaseAudioTrackState(track.id); + await ensureAudioTrackReady(track); + return true; + } + + async function refreshAudioTrackAssets() { + cleanupMissingAudioTracks(); + const audioTracks = state.project.timelines.flatMap((timeline) => timeline.tracks) + .filter((track) => track.type === "audio-file" && track.filePath); + await Promise.allSettled(audioTracks.map((track) => ensureAudioTrackReady(track))); + } + + function pauseAllAudioTracks() { + runtime.audioTracks.forEach((entry) => { + entry.element?.pause(); + }); + } + + async function syncAudioTracks(forceSeek = false) { + const timeline = getSelectedTimeline(); + if (!timeline) { + pauseAllAudioTracks(); + return; + } + + const audioTracks = timeline.tracks.filter((track) => track.type === "audio-file" && track.filePath); + for (const track of audioTracks) { + const entry = await ensureAudioTrackReady(track); + const offset = state.transport.currentTime - track.startTime; + const duration = entry.duration || track.analysisDuration || Number.MAX_SAFE_INTEGER; + const active = offset >= 0 && offset <= duration; + + if (!active) { + entry.element.pause(); + continue; + } + + if (forceSeek || Math.abs((entry.element.currentTime || 0) - offset) > AUDIO_SYNC_TOLERANCE) { + try { + entry.element.currentTime = Math.max(0, offset); + } catch (_error) { + // Ignore seek races while metadata is loading. + } + } + + if (state.transport.playing) { + try { + await entry.element.play(); + } catch (_error) { + // Autoplay issues are non-fatal; the transport still runs. + } + } else { + entry.element.pause(); + } + } + } + function escapeHtml(value) { return String(value) .replaceAll("&", "&") @@ -572,7 +942,7 @@ ctx.restore(); if (track.id === state.selectedTrackId) { - ctx.strokeStyle = "rgba(110,242,198,0.7)"; + ctx.strokeStyle = "rgba(110,194,242,0.76)"; ctx.lineWidth = 2; ctx.strokeRect(4, top + 4, LEFT_GUTTER - 8, TRACK_HEIGHT - 8); ctx.lineWidth = 1; @@ -619,8 +989,86 @@ } } + 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 waveformTop = RULER_HEIGHT + (trackIndex * TRACK_HEIGHT) + 18; + const waveformHeight = TRACK_HEIGHT - 36; + const centerY = waveformTop + (waveformHeight / 2); + const color = getTrackColor(track); + + if (trackDuration > 0) { + const startX = timeToX(startTime, duration, metrics); + const endX = timeToX(Math.min(duration, startTime + trackDuration), duration, metrics); + ctx.fillStyle = "rgba(142, 232, 200, 0.07)"; + ctx.fillRect(startX, waveformTop - 4, Math.max(2, endX - startX), waveformHeight + 8); + } + + ctx.strokeStyle = "rgba(255,255,255,0.12)"; + ctx.beginPath(); + ctx.moveTo(metrics.left, centerY); + ctx.lineTo(metrics.right, centerY); + ctx.stroke(); + + if (entry?.beatTimes?.length) { + ctx.strokeStyle = "rgba(142, 232, 200, 0.18)"; + entry.beatTimes.forEach((beatTime) => { + const absoluteTime = startTime + beatTime; + if (absoluteTime < 0 || absoluteTime > duration) return; + const x = timeToX(absoluteTime, duration, metrics); + ctx.beginPath(); + ctx.moveTo(x, waveformTop); + ctx.lineTo(x, waveformTop + waveformHeight); + ctx.stroke(); + }); + } + + if (!entry?.peaks?.length) { + ctx.fillStyle = "rgba(255,255,255,0.55)"; + ctx.font = "12px Trebuchet MS"; + const text = entry?.decodeError + ? "Analyse nicht verfuegbar" + : "Audio wird analysiert..."; + ctx.fillText(text, metrics.left + 10, centerY + 4); + return; + } + + ctx.strokeStyle = color; + ctx.lineWidth = 1.4; + ctx.beginPath(); + entry.peaks.forEach((peak, index) => { + const progress = entry.peaks.length <= 1 ? 0 : index / (entry.peaks.length - 1); + const absoluteTime = startTime + (progress * trackDuration); + const x = timeToX(Math.min(duration, absoluteTime), duration, metrics); + const amplitude = peak * (waveformHeight / 2); + if (index === 0) { + ctx.moveTo(x, centerY - amplitude); + } else { + ctx.lineTo(x, centerY - amplitude); + } + }); + for (let index = entry.peaks.length - 1; index >= 0; index -= 1) { + const peak = entry.peaks[index]; + const progress = entry.peaks.length <= 1 ? 0 : index / (entry.peaks.length - 1); + const absoluteTime = startTime + (progress * trackDuration); + const x = timeToX(Math.min(duration, absoluteTime), duration, metrics); + const amplitude = peak * (waveformHeight / 2); + ctx.lineTo(x, centerY + amplitude); + } + ctx.closePath(); + ctx.stroke(); + ctx.fillStyle = "rgba(142, 232, 200, 0.12)"; + ctx.fill(); + ctx.lineWidth = 1; + } + function drawTracks(duration, tracks, metrics) { tracks.forEach((track, trackIndex) => { + if (track.type === "audio-file") { + drawAudioTrackWaveform(track, trackIndex, duration, metrics); + return; + } sortTrackPoints(track); const color = getTrackColor(track); ctx.strokeStyle = color; @@ -659,7 +1107,7 @@ function drawPlayhead(duration, metrics) { const x = timeToX(state.transport.currentTime, duration, metrics); - ctx.strokeStyle = "#6ef2c6"; + ctx.strokeStyle = "#6ec2f2"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, 0); @@ -707,6 +1155,7 @@ if (track.type === "artnet-channel") return "artnet"; if (track.type === "sacn-channel") return "sacn"; if (track.type === "midi-cc") return "midicc"; + if (track.type === "audio-file") return "audio"; if (track.type === "serial-value") return "serial"; if (track.type === "modbus-tcp" || track.type === "modbus-rtu") return "modbus"; if (track.type === "knx-value") return "knx"; @@ -726,6 +1175,7 @@ for (const track of timeline.tracks) { const card = document.createElement("div"); card.className = `track-card ${track.id === state.selectedTrackId ? "active" : ""}`; + card.dataset.trackId = track.id; card.innerHTML = `
@@ -739,8 +1189,7 @@
- - +
@@ -788,6 +1237,7 @@ } const range = trackRange(track); + const knxDptMeta = track.type === "knx-value" ? getKnxDptMeta(track.dpt) : null; let typeFields = ""; if (track.type === "midi-note") { @@ -875,23 +1325,60 @@
+
+ ${escapeHtml(knxDptMeta.label)}
+ ${escapeHtml(knxDptMeta.description)} +
+ ${knxDptMeta.fixedRange ? "" : ` +
+ + +
+ `} + `; + } else if (track.type === "audio-file") { + const audioState = runtime.audioTracks.get(track.id); + typeFields = ` + +
+ + +
+
+ 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")} +
`; } else if (track.type === "osc-value") { typeFields = ` @@ -943,6 +1430,16 @@ `; + const interpolationField = track.type === "audio-file" + ? "" + : ` + + `; let pointFields = `
Waehle einen Punkt im Editor, um Zeit, Wert und Modus zu aendern.
`; if (point) { @@ -973,12 +1470,7 @@ Name - + ${interpolationField} ${typeFields} ${pointFields} `; @@ -1001,11 +1493,30 @@ }); } + function renderNetworkAdapterSelectors() { + const adapterSelects = [ + [refs.artnetAdapterSelect, state.routing.artnetInterface], + [refs.sacnAdapterSelect, state.routing.sacnInterface], + [refs.oscAdapterSelect, state.routing.oscInterface], + [refs.mqttAdapterSelect, state.routing.mqttInterface], + [refs.modbusTcpAdapterSelect, state.routing.modbusTcpInterface], + [refs.knxAdapterSelect, state.routing.knxInterface] + ]; + + adapterSelects.forEach(([select, selectedValue]) => { + renderPortSelect(select, runtime.networkAdapters, selectedValue, "Automatisch"); + }); + } + function renderMidiSelectors() { renderPortSelect(refs.midiInputSelect, runtime.midiInputs, state.routing.midiInputId, "Kein MIDI Input"); renderPortSelect(refs.midiOutputSelect, runtime.midiOutputs, state.routing.midiOutputId, "Kein MIDI Output"); } + function renderAudioOutputSelector() { + renderPortSelect(refs.audioOutputSelect, runtime.audioOutputDevices, state.routing.audioOutputId, "Standard Output"); + } + function renderTransportState() { refs.syncSourceSelect.value = state.transport.syncSource; refs.timecodeLabel.textContent = formatTimecode(state.transport.currentTime, state.transport.fps); @@ -1019,29 +1530,40 @@ function renderAll() { ensureSelectionIntegrity(); - refs.appShell.classList.toggle("inspector-collapsed", state.ui.inspectorCollapsed); - refs.inspectorToggleButton.textContent = state.ui.inspectorCollapsed ? "<" : ">"; - refs.inspectorToggleButton.title = state.ui.inspectorCollapsed - ? "Rechte Leiste ausklappen" - : "Rechte Leiste einklappen"; + cleanupMissingAudioTracks(); + refs.settingsModal.classList.toggle("open", state.ui.settingsOpen); + refs.settingsModal.setAttribute("aria-hidden", String(!state.ui.settingsOpen)); + refs.settingsTabButtons.forEach((button) => { + const isActive = button.dataset.settingsTab === state.ui.settingsTab; + button.classList.toggle("active", isActive); + button.setAttribute("aria-selected", String(isActive)); + }); + refs.settingsPanels.forEach((panel) => { + panel.classList.toggle("active", panel.dataset.settingsPanel === state.ui.settingsTab); + }); refs.projectNameInput.value = state.project.name; refs.projectPathLabel.textContent = basenameFromPath(state.filePath); refs.projectPathLabel.title = state.filePath || ""; + refs.audioOutputSelect.value = state.routing.audioOutputId; refs.artnetEnabledInput.checked = Boolean(state.routing.artnetEnabled); + refs.artnetAdapterSelect.value = state.routing.artnetInterface; refs.artnetHostInput.value = state.routing.artnetHost; refs.artnetPortInput.value = state.routing.artnetPort; refs.artnetUniverseInput.value = state.routing.artnetUniverse; refs.artnetTimecodePortInput.value = state.routing.artnetTimecodePort; refs.artnetBroadcastInput.checked = state.routing.artnetBroadcast; refs.sacnEnabledInput.checked = Boolean(state.routing.sacnEnabled); + refs.sacnAdapterSelect.value = state.routing.sacnInterface; refs.sacnHostInput.value = state.routing.sacnHost; refs.sacnPortInput.value = state.routing.sacnPort; refs.sacnSourceNameInput.value = state.routing.sacnSourceName; refs.sacnMulticastInput.checked = state.routing.sacnMulticast; refs.oscEnabledInput.checked = Boolean(state.routing.oscEnabled); + refs.oscAdapterSelect.value = state.routing.oscInterface; refs.oscHostInput.value = state.routing.oscHost; refs.oscPortInput.value = state.routing.oscPort; refs.mqttEnabledInput.checked = Boolean(state.routing.mqttEnabled); + refs.mqttAdapterSelect.value = state.routing.mqttInterface; refs.mqttBrokerInput.value = state.routing.mqttBrokerUrl; refs.mqttUsernameInput.value = state.routing.mqttUsername; refs.mqttPasswordInput.value = state.routing.mqttPassword; @@ -1052,6 +1574,7 @@ refs.serialParitySelect.value = state.routing.serialParity; refs.serialModeSelect.value = state.routing.serialMode; refs.modbusTcpEnabledInput.checked = Boolean(state.routing.modbusTcpEnabled); + refs.modbusTcpAdapterSelect.value = state.routing.modbusTcpInterface; refs.modbusTcpHostInput.value = state.routing.modbusTcpHost; refs.modbusTcpPortInput.value = state.routing.modbusTcpPort; refs.modbusRtuEnabledInput.checked = Boolean(state.routing.modbusRtuEnabled); @@ -1059,6 +1582,7 @@ refs.modbusRtuBaudInput.value = state.routing.modbusRtuBaud; refs.modbusRtuParitySelect.value = state.routing.modbusRtuParity; refs.knxEnabledInput.checked = Boolean(state.routing.knxEnabled); + refs.knxAdapterSelect.value = state.routing.knxInterface; refs.knxIpInput.value = state.routing.knxIp; refs.knxPortInput.value = state.routing.knxPort; refs.knxPhysAddrInput.value = state.routing.knxPhysAddr; @@ -1069,12 +1593,63 @@ renderTimelineInspector(); renderSelectionInspector(); renderMidiSelectors(); + renderAudioOutputSelector(); + renderNetworkAdapterSelectors(); drawCanvas(); } + async function refreshNetworkAdapters() { + if (!window.time4midi.network?.listInterfaces) { + return; + } + + try { + runtime.networkAdapters = await window.time4midi.network.listInterfaces(); + renderNetworkAdapterSelectors(); + } catch (error) { + refs.transportStatusLabel.textContent = `Netzwerkadapter Fehler: ${error.message}`; + } + } + + async function refreshAudioOutputs() { + if (!navigator.mediaDevices?.enumerateDevices) { + return; + } + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + runtime.audioOutputDevices = devices + .filter((device) => device.kind === "audiooutput") + .map((device, index) => ({ + id: device.deviceId, + name: device.label || `Audio Output ${index + 1}` + })); + if (!state.routing.audioOutputId && runtime.audioOutputDevices[0]) { + state.routing.audioOutputId = runtime.audioOutputDevices[0].id; + } + renderAudioOutputSelector(); + } catch (error) { + refs.transportStatusLabel.textContent = `Audio Output Fehler: ${error.message}`; + } + } + + function openSettings(tab = state.ui.settingsTab) { + state.ui.settingsOpen = true; + state.ui.settingsTab = tab; + void refreshNetworkAdapters(); + void refreshAudioOutputs(); + renderAll(); + } + + function closeSettings() { + state.ui.settingsOpen = false; + renderAll(); + } + function configureArtNetTransport() { window.time4midi.artnet.configure({ enabled: state.routing.artnetEnabled, + localAddress: state.routing.artnetInterface, host: state.routing.artnetHost, port: state.routing.artnetPort, broadcast: state.routing.artnetBroadcast, @@ -1085,6 +1660,7 @@ function configureOscTransport() { window.time4midi.osc.configure({ enabled: state.routing.oscEnabled, + localAddress: state.routing.oscInterface, host: state.routing.oscHost, port: state.routing.oscPort }); @@ -1093,6 +1669,7 @@ function configureSacnTransport() { window.time4midi.sacn.configure({ enabled: state.routing.sacnEnabled, + localAddress: state.routing.sacnInterface, host: state.routing.sacnHost, port: state.routing.sacnPort, sourceName: state.routing.sacnSourceName, @@ -1103,6 +1680,7 @@ function configureMqttTransport() { window.time4midi.mqtt.configure({ enabled: state.routing.mqttEnabled, + localAddress: state.routing.mqttInterface, brokerUrl: state.routing.mqttBrokerUrl, username: state.routing.mqttUsername, password: state.routing.mqttPassword, @@ -1128,6 +1706,7 @@ try { await window.time4midi.modbusTcp.configure({ enabled: state.routing.modbusTcpEnabled, + localAddress: state.routing.modbusTcpInterface, host: state.routing.modbusTcpHost, port: state.routing.modbusTcpPort }); @@ -1153,6 +1732,7 @@ try { await window.time4midi.knx.configure({ enabled: state.routing.knxEnabled, + interface: state.routing.knxInterface, ipAddr: state.routing.knxIp, ipPort: state.routing.knxPort, physAddr: state.routing.knxPhysAddr, @@ -1177,7 +1757,7 @@ renderAll(); } - function addTrack(type) { + async function addTrack(type) { const timeline = getSelectedTimeline(); if (!timeline) { return; @@ -1281,6 +1861,25 @@ interpolationDefault: "linear", points: [] }; + } else if (type === "audio-file") { + track = { + id: uid("track"), + name: `Audio ${timeline.tracks.length + 1}`, + type, + filePath: "", + mediaName: "", + startTime: 0, + gain: 1, + analysisDuration: 0, + analysisBpm: 0, + interpolationDefault: "hold", + points: [] + }; + const picked = await chooseAudioFileForTrack(track); + if (!picked) { + return; + } + track.name = track.mediaName.replace(/\.[^.]+$/, "") || track.name; } else { track = { id: uid("track"), @@ -1298,6 +1897,9 @@ state.selectedTrackId = track.id; state.selectedPointId = ""; renderAll(); + if (track.type === "audio-file") { + void syncAudioTracks(true); + } } function moveTrack(trackId, direction) { @@ -1312,6 +1914,43 @@ renderAll(); } + function clearTrackListDragState() { + runtime.trackListDrag = null; + refs.trackList.querySelectorAll(".track-card").forEach((card) => { + card.classList.remove("drag-source", "drag-before", "drag-after"); + }); + } + + function moveTrackToPosition(trackId, targetTrackId, position = "before") { + const timeline = getSelectedTimeline(); + if (!timeline) return; + + const sourceIndex = timeline.tracks.findIndex((track) => track.id === trackId); + if (sourceIndex < 0) return; + + const [track] = timeline.tracks.splice(sourceIndex, 1); + + if (!targetTrackId) { + timeline.tracks.push(track); + renderAll(); + return; + } + + let targetIndex = timeline.tracks.findIndex((entry) => entry.id === targetTrackId); + if (targetIndex < 0) { + timeline.tracks.push(track); + renderAll(); + return; + } + + if (position === "after") { + targetIndex += 1; + } + + timeline.tracks.splice(targetIndex, 0, track); + renderAll(); + } + function clearOutputCaches(sendNoteOffs) { if (sendNoteOffs) { const timeline = getSelectedTimeline(); @@ -1338,7 +1977,11 @@ function deleteTrack(trackId) { const timeline = getSelectedTimeline(); if (!timeline) return; + const trackToDelete = timeline.tracks.find((track) => track.id === trackId); timeline.tracks = timeline.tracks.filter((track) => track.id !== trackId); + if (trackToDelete?.type === "audio-file") { + releaseAudioTrackState(trackId); + } if (state.selectedTrackId === trackId) { state.selectedTrackId = timeline.tracks[0]?.id || ""; state.selectedPointId = ""; @@ -1597,6 +2240,7 @@ } renderTransportState(); applyOutputsAtCurrentTime(); + void syncAudioTracks(); drawCanvas(); } @@ -1606,6 +2250,7 @@ cancelAnimationFrame(runtime.animationFrameId); runtime.animationFrameId = 0; } + pauseAllAudioTracks(); renderTransportState(); drawCanvas(); } @@ -1616,6 +2261,7 @@ if (resetTime) { state.transport.currentTime = 0; } + void syncAudioTracks(true); renderTransportState(); drawCanvas(); } @@ -1630,6 +2276,7 @@ state.transport.playing = true; state.transport.playStartPerf = performance.now(); state.transport.playStartTime = state.transport.currentTime; + void syncAudioTracks(true); renderTransportState(); ensureTransportLoop(); } @@ -1647,6 +2294,7 @@ state.transport.lastExternalUpdateAt = now; state.transport.lastExternalCorrectionAt = now; state.transport.currentTime = nextAnchor; + void syncAudioTracks(true); renderTransportState(); drawCanvas(); ensureTransportLoop(); @@ -1709,6 +2357,7 @@ state.transport.externalAnchorPerf = now; state.transport.lastExternalCorrectionAt = now; state.transport.currentTime = totalSeconds; + void syncAudioTracks(true); renderTransportState(); drawCanvas(); } @@ -1830,6 +2479,8 @@ const result = await window.time4midi.project.load(); if (result.canceled) return; + pauseAllAudioTracks(); + runtime.audioTracks.forEach((_entry, trackId) => releaseAudioTrackState(trackId)); const parsed = JSON.parse(result.data); state.project = { version: parsed.version || 6, @@ -1861,6 +2512,7 @@ configureModbusRtuTransport(), configureKnxTransport() ]); + await refreshAudioTrackAssets(); renderAll(); } @@ -1903,6 +2555,7 @@ if (!timeline) return; const hit = findCanvasHit(x, y); if (!hit || !hit.track) return; + if (hit.track.type === "audio-file") return; const metrics = getPlotMetrics(); const point = { id: uid("point"), @@ -1965,13 +2618,33 @@ } else if (binding === "track.groupAddress" && track) { track.groupAddress = rawValue; } else if (binding === "track.dpt" && track) { - track.dpt = rawValue; + track.dpt = String(rawValue || "DPT5.001").toUpperCase(); + const dptMeta = getKnxDptMeta(track.dpt); + if (dptMeta.fixedRange) { + track.minValue = dptMeta.min; + track.maxValue = dptMeta.max; + } else { + const hasCustomRange = Number.isFinite(Number(track.minValue)) && Number.isFinite(Number(track.maxValue)); + if (!hasCustomRange) { + track.minValue = dptMeta.defaultMin; + track.maxValue = dptMeta.defaultMax; + } + } } else if (binding === "track.minValue" && track) { track.minValue = Number(rawValue) || 0; } else if (binding === "track.maxValue" && track) { track.maxValue = Number(rawValue) || 0; } else if (binding === "track.argType" && track) { track.argType = rawValue; + } else if (binding === "track.startTime" && track) { + track.startTime = Math.max(0, Number(rawValue) || 0); + 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; + } } else if (binding === "point.time" && point && timeline && track) { point.time = clamp(Number(rawValue) || 0, 0, timeline.duration); sortTrackPoints(track); @@ -1997,22 +2670,49 @@ renderAll(); } + async function handleInspectorCommand(command) { + const track = getSelectedTrack(); + if (!track) return; + + if (command === "change-audio-file" && track.type === "audio-file") { + const picked = await chooseAudioFileForTrack(track); + if (picked) { + renderAll(); + void syncAudioTracks(true); + } + return; + } + + if (command === "reanalyze-audio" && track.type === "audio-file") { + const entry = getAudioTrackState(track.id); + entry.decodeError = ""; + entry.analysisReady = false; + entry.peaks = []; + entry.beatTimes = []; + void analyzeAudioTrack(track); + } + } + async function handleRoutingChanges() { state.routing.artnetEnabled = refs.artnetEnabledInput.checked; + state.routing.artnetInterface = refs.artnetAdapterSelect.value || ""; state.routing.artnetHost = refs.artnetHostInput.value || "255.255.255.255"; state.routing.artnetPort = clamp(Number(refs.artnetPortInput.value) || 6454, 1, 65535); state.routing.artnetUniverse = clamp(Number(refs.artnetUniverseInput.value) || 0, 0, 32767); state.routing.artnetTimecodePort = clamp(Number(refs.artnetTimecodePortInput.value) || 6454, 1, 65535); state.routing.artnetBroadcast = refs.artnetBroadcastInput.checked; state.routing.sacnEnabled = refs.sacnEnabledInput.checked; + state.routing.sacnInterface = refs.sacnAdapterSelect.value || ""; state.routing.sacnHost = refs.sacnHostInput.value || ""; state.routing.sacnPort = clamp(Number(refs.sacnPortInput.value) || 5568, 1, 65535); state.routing.sacnSourceName = (refs.sacnSourceNameInput.value || "Time4Midi").slice(0, 63); state.routing.sacnMulticast = refs.sacnMulticastInput.checked; state.routing.oscEnabled = refs.oscEnabledInput.checked; + state.routing.oscInterface = refs.oscAdapterSelect.value || ""; state.routing.oscHost = refs.oscHostInput.value || "127.0.0.1"; state.routing.oscPort = clamp(Number(refs.oscPortInput.value) || 8000, 1, 65535); state.routing.mqttEnabled = refs.mqttEnabledInput.checked; + state.routing.mqttInterface = refs.mqttAdapterSelect.value || ""; state.routing.mqttBrokerUrl = refs.mqttBrokerInput.value || "mqtt://127.0.0.1:1883"; state.routing.mqttUsername = refs.mqttUsernameInput.value || ""; state.routing.mqttPassword = refs.mqttPasswordInput.value || ""; @@ -2023,6 +2723,7 @@ state.routing.serialParity = refs.serialParitySelect.value || "none"; state.routing.serialMode = refs.serialModeSelect.value || "rs232"; state.routing.modbusTcpEnabled = refs.modbusTcpEnabledInput.checked; + state.routing.modbusTcpInterface = refs.modbusTcpAdapterSelect.value || ""; state.routing.modbusTcpHost = refs.modbusTcpHostInput.value || "127.0.0.1"; state.routing.modbusTcpPort = clamp(Number(refs.modbusTcpPortInput.value) || 502, 1, 65535); state.routing.modbusRtuEnabled = refs.modbusRtuEnabledInput.checked; @@ -2030,6 +2731,7 @@ state.routing.modbusRtuBaud = clamp(Number(refs.modbusRtuBaudInput.value) || 9600, 300, 921600); state.routing.modbusRtuParity = refs.modbusRtuParitySelect.value || "none"; state.routing.knxEnabled = refs.knxEnabledInput.checked; + state.routing.knxInterface = refs.knxAdapterSelect.value || ""; state.routing.knxIp = refs.knxIpInput.value || "224.0.23.12"; state.routing.knxPort = clamp(Number(refs.knxPortInput.value) || 3671, 1, 65535); state.routing.knxPhysAddr = refs.knxPhysAddrInput.value || "15.15.15"; @@ -2055,6 +2757,8 @@ }); refs.newProjectButton.addEventListener("click", () => { + pauseAllAudioTracks(); + runtime.audioTracks.forEach((_entry, trackId) => releaseAudioTrackState(trackId)); state.project = createDefaultProject(); state.routing = createDefaultRouting(); state.selectedTimelineId = state.project.timelines[0]?.id || ""; @@ -2070,6 +2774,7 @@ void configureModbusTcpTransport(); void configureModbusRtuTransport(); void configureKnxTransport(); + void refreshAudioTrackAssets(); renderAll(); }); @@ -2086,9 +2791,8 @@ refs.addKnxTrackButton.addEventListener("click", () => addTrack("knx-value")); refs.addOscTrackButton.addEventListener("click", () => addTrack("osc-value")); refs.addMqttTrackButton.addEventListener("click", () => addTrack("mqtt-value")); - refs.inspectorToggleButton.addEventListener("click", () => { - state.ui.inspectorCollapsed = !state.ui.inspectorCollapsed; - renderAll(); + refs.addAudioTrackButton.addEventListener("click", () => { + void addTrack("audio-file"); }); refs.timelineList.addEventListener("click", (event) => { @@ -2106,8 +2810,6 @@ if (actionButton) { const trackId = actionButton.dataset.trackId; const action = actionButton.dataset.trackAction; - if (action === "up") moveTrack(trackId, -1); - if (action === "down") moveTrack(trackId, 1); if (action === "delete") deleteTrack(trackId); return; } @@ -2119,6 +2821,72 @@ renderAll(); }); + refs.trackList.addEventListener("dragstart", (event) => { + const handle = event.target.closest("[data-track-handle]"); + if (!handle) return; + + const trackId = handle.dataset.trackId; + runtime.trackListDrag = { + trackId, + targetTrackId: "", + position: "after" + }; + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", trackId); + } + + const card = handle.closest(".track-card"); + if (card) { + card.classList.add("drag-source"); + } + }); + + refs.trackList.addEventListener("dragover", (event) => { + if (!runtime.trackListDrag) return; + event.preventDefault(); + + const currentDragTrackId = runtime.trackListDrag.trackId; + const targetCard = event.target.closest(".track-card"); + + refs.trackList.querySelectorAll(".track-card").forEach((card) => { + if (!card.classList.contains("drag-source")) { + card.classList.remove("drag-before", "drag-after"); + } + }); + + if (!targetCard || targetCard.dataset.trackId === currentDragTrackId) { + runtime.trackListDrag.targetTrackId = ""; + runtime.trackListDrag.position = "after"; + return; + } + + const rect = targetCard.getBoundingClientRect(); + const position = (event.clientY - rect.top) < (rect.height / 2) ? "before" : "after"; + runtime.trackListDrag.targetTrackId = targetCard.dataset.trackId; + runtime.trackListDrag.position = position; + targetCard.classList.add(position === "before" ? "drag-before" : "drag-after"); + }); + + refs.trackList.addEventListener("drop", (event) => { + if (!runtime.trackListDrag) return; + event.preventDefault(); + + const { trackId, targetTrackId, position } = runtime.trackListDrag; + clearTrackListDragState(); + + if (targetTrackId === trackId) { + return; + } + + moveTrackToPosition(trackId, targetTrackId, position); + }); + + refs.trackList.addEventListener("dragend", () => { + clearTrackListDragState(); + }); + refs.syncSourceSelect.addEventListener("change", () => { state.transport.syncSource = refs.syncSourceSelect.value; pauseTransport(); @@ -2128,6 +2896,15 @@ refs.playButton.addEventListener("click", playTransport); refs.pauseButton.addEventListener("click", pauseTransport); refs.stopButton.addEventListener("click", () => stopTransport(true)); + refs.openSettingsButton.addEventListener("click", () => openSettings("routing")); + refs.closeSettingsButton.addEventListener("click", closeSettings); + refs.settingsBackdrop.addEventListener("click", closeSettings); + refs.settingsTabButtons.forEach((button) => { + button.addEventListener("click", () => { + state.ui.settingsTab = button.dataset.settingsTab || "routing"; + renderAll(); + }); + }); refs.midiInputSelect.addEventListener("change", () => { state.routing.midiInputId = refs.midiInputSelect.value; @@ -2140,21 +2917,31 @@ renderAll(); }); + refs.audioOutputSelect.addEventListener("change", () => { + state.routing.audioOutputId = refs.audioOutputSelect.value || ""; + void syncAudioTracks(true); + renderAll(); + }); + [ + refs.artnetAdapterSelect, refs.artnetHostInput, refs.artnetEnabledInput, refs.artnetPortInput, refs.artnetUniverseInput, refs.artnetTimecodePortInput, refs.artnetBroadcastInput, + refs.sacnAdapterSelect, refs.sacnHostInput, refs.sacnEnabledInput, refs.sacnPortInput, refs.sacnSourceNameInput, refs.sacnMulticastInput, + refs.oscAdapterSelect, refs.oscHostInput, refs.oscEnabledInput, refs.oscPortInput, + refs.mqttAdapterSelect, refs.mqttBrokerInput, refs.mqttEnabledInput, refs.mqttUsernameInput, @@ -2165,6 +2952,7 @@ refs.serialParitySelect, refs.serialModeSelect, refs.serialEnabledInput, + refs.modbusTcpAdapterSelect, refs.modbusTcpHostInput, refs.modbusTcpPortInput, refs.modbusTcpEnabledInput, @@ -2172,6 +2960,7 @@ refs.modbusRtuBaudInput, refs.modbusRtuParitySelect, refs.modbusRtuEnabledInput, + refs.knxAdapterSelect, refs.knxIpInput, refs.knxPortInput, refs.knxPhysAddrInput, @@ -2197,6 +2986,11 @@ const target = event.target; updateInspectorBinding(target.dataset.bind, target.value, target); }); + container.addEventListener("click", (event) => { + const target = event.target.closest("[data-track-command]"); + if (!target) return; + void handleInspectorCommand(target.dataset.trackCommand); + }); }); refs.timelineCanvas.addEventListener("dblclick", (event) => { @@ -2269,12 +3063,22 @@ }); window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.ui.settingsOpen) { + closeSettings(); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); saveProject(false); return; } + const activeTag = document.activeElement?.tagName; + if (["INPUT", "TEXTAREA", "SELECT"].includes(activeTag)) { + return; + } + if (event.key !== "Delete" && event.key !== "Backspace") { return; } @@ -2290,6 +3094,12 @@ }); window.addEventListener("resize", drawCanvas); + + if (navigator.mediaDevices?.addEventListener) { + navigator.mediaDevices.addEventListener("devicechange", () => { + void refreshAudioOutputs(); + }); + } } window.time4midi.artnet.onStatus((status) => { @@ -2364,6 +3174,9 @@ attachEventListeners(); setupMidi(); + void refreshAudioOutputs(); + void refreshNetworkAdapters(); + void refreshAudioTrackAssets(); configureArtNetTransport(); configureSacnTransport(); configureOscTransport(); diff --git a/src/index.html b/src/index.html index 6458ed8..e5f2653 100644 --- a/src/index.html +++ b/src/index.html @@ -11,12 +11,10 @@