diff --git a/JHArtworks2023_wh.png b/JHArtworks2023_wh.png new file mode 100644 index 0000000..cdc0ff5 Binary files /dev/null and b/JHArtworks2023_wh.png differ 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 = `