みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Electron + node-dmx + ディマーでLED電球を調光する 💡

2月に「Webフロントエンド技術で作る体験型コンテンツ勉強会」たるものに参加させていただき、DMXの便利さを痛感してから、ずーっと欲しかった、DMXコントローラー調光ユニット(ディマー)を、ついに手に入れることができたので、LED電球を調光できるElectronアプリをつくってみました。

starryworks.notion.site

ハード構成

PCとDMXコントローラーはUSBで接続。
DMXコントローラーとディマーはDMXケーブルで接続。

と、とてもシンプルな構成です。

アプリ構成

いつも通り、Next.jsのElectronテンプレートを使ってElectronアプリを作りました。

github.com

いまだ、この問題が解決していないため、

blog.kimizuka.org

electron-next自分のリポジトリのものを使っています。

Node.jsからのDMXを出力する部分は、node-dmxを使いました。
node-dmxは内部でnode-serialportを使っているため、Electronと合わせて使うためには、electron-rebuildを使って、バージョンの整合性をとる必要があります。

参考URL

blog.kimizuka.org

追記

electron-rebuildが廃止予定だったので、@electron/rebuildを検証し、乗り換えました。

blog.kimizuka.org

ソースコード(抜粋)

package.json

{
  "private": true,
  "main": "main/index.js",
  "productName": "ElectronDMX",
  "scripts": {
    "clean": "rimraf dist main renderer/out renderer/.next",
    "dev": "npm run build-electron && electron .",
    "build-renderer": "next build renderer",
    "build-electron": "tsc -p electron-src",
    "build": "npm run build-renderer && npm run build-electron",
    "pack-app": "npm run build && electron-builder --dir",
    "dist": "npm run build && electron-builder",
    "type-check": "tsc -p ./renderer/tsconfig.json && tsc -p ./electron-src/tsconfig.json",
    "postinstall": "electron-rebuild -f -w serialport"
  },
  "dependencies": {
    "dmx": "^0.2.5",
    "electron-is-dev": "^1.2.0",
    "electron-next": "git+https://github.com/kimizuka/electron-next#master",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@electron/rebuild": "^3.6.0",
    "@types/node": "^14.18.63",
    "@types/react": "^16.14.52",
    "@types/react-dom": "^16.9.24",
    "electron": "^27.1.2",
    "electron-builder": "^24.9.1",
    "next": "latest",
    "rimraf": "^3.0.2",
    "sass": "^1.77.1",
    "typescript": "^4.9.5"
  },
  "build": {
    "asar": true,
    "files": [
      "main",
      "renderer/out"
    ]
  }
}

electron-src/index.ts

// Native
import { join } from 'path';
import { format } from 'url';

// Packages
import { BrowserWindow, app, ipcMain, IpcMainEvent } from 'electron';
import isDev from 'electron-is-dev';
import prepareNext from 'electron-next';

const port = '/dev/tty.usbserial-XXXXXXXX'; // ls -l /dev/tty.usb* などでコントローラのポートを調べる
const DMX = require('dmx');
const dmx = new DMX();
const universe = dmx.addUniverse('dmx', 'enttec-usb-dmx-pro', port);

// Prepare the renderer once the app is ready
app.on('ready', async () => {
  await prepareNext('./renderer');

  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: join(__dirname, 'preload.js'),
    },
  });

  const url = isDev
    ? 'http://localhost:8000/'
    : format({
        pathname: join(__dirname, '../renderer/out/index.html'),
        protocol: 'file:',
        slashes: true,
      });

  mainWindow.loadURL(url);
});

// Quit the app once all windows are closed
app.on('window-all-closed', app.quit);

// listen the channel `message` and resend the received message to the renderer process
ipcMain.on('sendValue', (_evt: IpcMainEvent, value: number) => {
  universe.update({ 2: value }); // チャンネルを合わせる必要あり
});

electron-src/preload.ts

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electron', {
  sendValue: (value: number) => ipcRenderer.send('sendValue', value)
});

renderer/pages/index.tsx

import style from './index.module.scss';
import { useEffect, useState } from 'react';

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  interface Window {
    electron: {
      sendValue: (value: number) => void;
    };
  }
}

export default function IndexPage() {
  const [ value, setValue ] = useState(0);

  useEffect(() => {
    window.electron.sendValue(value);
  }, [value]);

  return (
    <main className={ style.wrapper }>
      <label>
        <input
          type="range"
          min={ 0 }
          max={ 100 }
          step={ 1 }
          value={ value }
          onChange={ (evt) => setValue(Number(evt.target.value)) }
        />
        <input
          type="text"
          readOnly={ true }
          value={ value }
        />
      </label>
    </main>
  );
};

renderer/pages/index.module.scss

.wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
  position: fixed;
  inset: 0;

  label {
    display: flex;
    align-items: center;
    justify-content: center;
    transform: scale(2);

    > * {
      + * {
        margin-left: 8px;
      }
    }

    [type='range'] {
      cursor: pointer;
    }

    [type='text'] {
      width: 40px;
      text-align: center;
    }
  }
}

ざざざっと書くとこんな感じです。
アプリ上のスライダーの値をIPC通信でメインプロセスに渡して、その値をDMXコントローラに渡しています。
その際、僕の使っているディマーのスタートチャンネルが2だったため、1チャンネルに接続しているLED電球の調光のために2チャンネルに値を渡していますが、

universe.update({ 2: value });

を、

universe.updateAll(value);

にすれば、すべてのチャンネルを一括で調光できます。

DEMO

アプリ上のスライダーでLED電球を調光できています。
今回は以上です。

リポジトリ

github.com