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 = `
@@ -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 @@