├── .husky └── pre-commit ├── .prettierignore ├── .yarnrc.yml ├── .gitignore ├── support └── SQ-MIDI-Protocol-Issue3.pdf ├── vitest.config.ts ├── src ├── main.ts ├── utils │ ├── brand.ts │ ├── enumerate-enum.test.ts │ ├── enumerate-enum.ts │ ├── sleep.ts │ ├── promise-with-resolvers.ts │ ├── pretty.test.ts │ ├── pretty.ts │ └── socket-reader.ts ├── midi │ ├── bytes.ts │ ├── tokenize │ │ └── __tests__ │ │ │ ├── no-channel-message.test.ts │ │ │ ├── status-byte.test.ts │ │ │ ├── system-exclusive.test.ts │ │ │ ├── grab-bag.test.ts │ │ │ ├── channel-messages.test.ts │ │ │ ├── interactions.ts │ │ │ └── running-data.test.ts │ └── parse │ │ ├── __tests__ │ │ ├── scene-change.test.ts │ │ ├── pan-level.test.ts │ │ ├── fader-level.test.ts │ │ ├── mixer-replies.test.ts │ │ ├── output-pan-level.test.ts │ │ ├── mute.test.ts │ │ ├── commands.ts │ │ └── interactions.ts │ │ ├── parse-midi.ts │ │ └── channel-parser.ts ├── actions │ ├── actionid.ts │ ├── to-source-or-sink.ts │ ├── actions.ts │ ├── softkey.ts │ ├── scene.ts │ ├── level.test.ts │ ├── fading.ts │ ├── pan-balance.test.ts │ ├── assign.test.ts │ └── mute.ts ├── mixer │ ├── nrpn │ │ ├── nrpn.test.ts │ │ ├── source-to-sink.test.ts │ │ ├── nrpn.ts │ │ ├── mute.ts │ │ ├── mute.test.ts │ │ ├── output.ts │ │ └── balance.test.ts │ ├── model.test.ts │ ├── pan-balance.test.ts │ ├── pan-balance.ts │ ├── models.ts │ ├── lr.ts │ ├── level.ts │ ├── lr.test.ts │ └── model.ts ├── feedbacks │ ├── feedback-ids.ts │ └── feedbacks.ts ├── variables.ts ├── upgrades.ts ├── callback.ts ├── config.test.ts ├── config.ts ├── choices.ts ├── instance.ts ├── options.ts └── presets.ts ├── tsconfig.json ├── tsconfig.build.json ├── knip.config.ts ├── README.md ├── .github └── workflows │ ├── companion-module-checks.yaml │ └── build-and-test.yaml ├── companion ├── manifest.json └── HELP.md ├── LICENSE ├── package.json └── eslint.config.mjs /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | pkg 3 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | package-lock.json 4 | /pkg 5 | /pkg.tgz 6 | /dist 7 | /.yarn 8 | -------------------------------------------------------------------------------- /support/SQ-MIDI-Protocol-Issue3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-module-allenheath-sq/HEAD/support/SQ-MIDI-Protocol-Issue3.pdf -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: ['**/node_modules/**', '**/dist/**'], 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { runEntrypoint } from '@companion-module/base' 2 | import { sqInstance } from './instance.js' 3 | import { UpgradeScripts } from './upgrades.js' 4 | 5 | runEntrypoint(sqInstance, UpgradeScripts) 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "include": ["src/**/*.ts", "eslint.config.mjs", "knip.config.ts", "vitest.config.ts"], 4 | "exclude": ["node_modules/**"], 5 | "compilerOptions": {} 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/brand.ts: -------------------------------------------------------------------------------- 1 | declare const __brand: unique symbol 2 | 3 | type Brand = { [__brand]: B } 4 | 5 | /** 6 | * Define a "branded" version of type `T`, that is distinguishable from plain 7 | * old `T` and from other branded versions that don't share the same brand `B`. 8 | */ 9 | export type Branded = T & Brand 10 | -------------------------------------------------------------------------------- /src/utils/enumerate-enum.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { enumValues } from './enumerate-enum.js' 3 | 4 | describe('enumValues', () => { 5 | enum Foo { 6 | X = 'x-value', 7 | Y = 'y-value', 8 | } 9 | 10 | test('upon enum Foo', () => { 11 | const vals = enumValues(Foo) 12 | expect(vals).toEqual(['x-value', 'y-value']) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@companion-module/tools/tsconfig/node18/recommended", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/*test.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "paths": { 8 | "*": ["./node_modules/*"] 9 | }, 10 | "module": "es2022" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/enumerate-enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enumerate all the values of an enum `e` that has only string-valued members. 3 | * 4 | * @param e 5 | * An `enum` to iterate over. (Don't use this on values other than `enum`s.) 6 | * @returns 7 | * A well-typed array of all the values of the enum. 8 | */ 9 | export function enumValues>(e: E): E[keyof E][] { 10 | return Object.keys(e).map((name) => e[name as keyof E]) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | /** Busy-wait until `ml` milliseconds have passed. */ 2 | export function sleep(ml: number): void { 3 | const dt = Date.now() 4 | let cd = null 5 | do { 6 | cd = Date.now() 7 | } while (cd - dt < ml) 8 | } 9 | 10 | /** Return a promise that resolves after `ms` milliseconds have passed. */ 11 | export async function asyncSleep(ms: number): Promise { 12 | return new Promise((resolve) => { 13 | setTimeout(resolve, ms) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /knip.config.ts: -------------------------------------------------------------------------------- 1 | import type { KnipConfig } from 'knip' 2 | 3 | const config: KnipConfig = { 4 | entry: ['src/main.ts', 'eslint.config.ts', 'vitest.config.ts'], 5 | project: ['src/**/*.ts', '*.ts', '*.mjs'], 6 | ignoreDependencies: [ 7 | // This is used by n/* lint rules which Knip doesn't seem to be smart 8 | // enough to detect as being used and therefore requiring the plugin. 9 | 'eslint-plugin-n', 10 | ], 11 | tags: ['-allowunused'], 12 | } 13 | 14 | export default config 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # companion-module-allenheath-sq 2 | 3 | See [HELP.md](./companion/HELP.md) and [LICENSE](./LICENSE) 4 | 5 | ## Getting started 6 | 7 | Executing a `yarn` command should perform all necessary steps to develop the module, if it does not then follow the steps below. 8 | 9 | The module can be built once with `yarn build`. This should be enough to get the module to be loadable by companion. 10 | 11 | While developing the module, by using `yarn dev` the compiler will be run in watch mode to recompile the files on change. 12 | -------------------------------------------------------------------------------- /.github/workflows/companion-module-checks.yaml: -------------------------------------------------------------------------------- 1 | name: Companion Module Checks 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | check: 8 | name: Check module 9 | 10 | if: ${{ !contains(github.repository, 'companion-module-template-') }} 11 | 12 | permissions: 13 | packages: read 14 | 15 | uses: bitfocus/actions/.github/workflows/module-checks.yaml@main 16 | # with: 17 | # upload-artifact: true # uncomment this to upload the built package as an artifact to this workflow that you can download and share with others 18 | 19 | -------------------------------------------------------------------------------- /src/utils/promise-with-resolvers.ts: -------------------------------------------------------------------------------- 1 | /** Return an unresolved promise and resolve/reject functions for it. */ 2 | export function promiseWithResolvers(): { 3 | promise: Promise 4 | resolve: (value: T) => void 5 | reject: (reason?: any) => void 6 | } { 7 | let promiseResolve: (value: T) => void 8 | let promiseReject: (reason?: any) => void 9 | const promise = new Promise((resolve: (value: T) => void, reject: (reason?: any) => void) => { 10 | promiseResolve = resolve 11 | promiseReject = reject 12 | }) 13 | 14 | return { 15 | promise, 16 | resolve: promiseResolve!, 17 | reject: promiseReject!, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/midi/bytes.ts: -------------------------------------------------------------------------------- 1 | export const SysExStart = 0xf0 2 | export const SysExEnd = 0xf7 3 | 4 | export const SysCommonTimeCodeQuarterFrame = 0xf1 5 | export const SysCommonSongPosition = 0xf2 6 | export const SysCommonSongSelect = 0xf3 7 | export const SysCommonTuneRequest = 0xf6 8 | 9 | export const SysRTTimingClock = 0xf8 10 | export const SysRTContinue = 0xfb 11 | 12 | export const SysExMessage = [SysExStart, 0x00, SysExEnd] as const 13 | export const SysExMessageShortest = [SysExStart, SysExEnd] as const 14 | 15 | export const SysCommonSingleByte = [SysCommonTuneRequest] as const 16 | export const SysCommonMultiByte = [SysCommonSongPosition, 0x12, 0x34] as const 17 | 18 | export const SysCommonSongPosMessage = [SysCommonSongPosition, 0x00, 0x03] as const 19 | -------------------------------------------------------------------------------- /src/utils/pretty.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { manyPrettyBytes, prettyByte, prettyBytes } from './pretty.js' 3 | 4 | describe('prettyByte', () => { 5 | test('prettyByte(0)', () => { 6 | expect(prettyByte(0)).toBe('00') 7 | }) 8 | test('prettyByte(255)', () => { 9 | expect(prettyByte(255)).toBe('FF') 10 | }) 11 | test('prettyByte(64)', () => { 12 | expect(prettyByte(64)).toBe('40') 13 | }) 14 | }) 15 | 16 | describe('prettyBytes', () => { 17 | test('prettyBytes([0x80, 0x00])', () => { 18 | expect(prettyBytes([0x80, 0x00])).toBe('80 00') 19 | }) 20 | }) 21 | 22 | describe('manyPrettyBytes', () => { 23 | test('manyPrettyBytes([0xff], [0x12, 0x9d], [0x35])', () => { 24 | expect(manyPrettyBytes([0xff], [0x12, 0x9d], [0x35])).toBe('FF 12 9D 35') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/utils/pretty.ts: -------------------------------------------------------------------------------- 1 | /** Pretty-print a byte value. */ 2 | export function prettyByte(b: number): string { 3 | return b.toString(16).toUpperCase().padStart(2, '0') 4 | } 5 | 6 | /** Pretty-print an array of bytes. */ 7 | export function prettyBytes(message: readonly number[]): string { 8 | return message.map(prettyByte).join(' ') 9 | } 10 | 11 | /** Pretty-print multiple arrays of bytes. */ 12 | export function manyPrettyBytes(...messages: readonly (readonly number[])[]): string { 13 | return messages.map(prettyBytes).join(' ') 14 | } 15 | 16 | type Representable = 17 | | undefined 18 | | null 19 | | boolean 20 | | number 21 | | string 22 | | readonly Representable[] 23 | | { readonly [key: string]: Representable } 24 | 25 | /** Generate a debug representation of `val`. */ 26 | export function repr(val: Representable): string { 27 | if (val === undefined) { 28 | return 'undefined' 29 | } 30 | return JSON.stringify(val) 31 | } 32 | -------------------------------------------------------------------------------- /companion/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "allenheath-sq", 3 | "name": "allenheath-sq", 4 | "shortname": "SQ", 5 | "description": "Allen & Heath SQ module for Companion", 6 | "version": "0.0.0", 7 | "license": "MIT", 8 | "repository": "git+https://github.com/bitfocus/companion-module-allenheath-sq.git", 9 | "bugs": "https://github.com/bitfocus/companion-module-allenheath-sq/issues", 10 | "maintainers": [ 11 | { 12 | "name": "Max Kiusso", 13 | "email": "max@kiusso.net" 14 | }, 15 | { 16 | "name": "Joseph Adams", 17 | "email": "josephdadams@gmail.com" 18 | }, 19 | { 20 | "name": "Jeff Walden", 21 | "email": "jwalden@mit.edu" 22 | } 23 | ], 24 | "legacyIds": [], 25 | "runtime": { 26 | "type": "node18", 27 | "api": "nodejs-ipc", 28 | "apiVersion": "0.0.0", 29 | "entrypoint": "../dist/main.js" 30 | }, 31 | "manufacturer": "Allen & Heath", 32 | "products": ["SQ5", "SQ6", "SQ7"], 33 | "keywords": ["SQ5", "SQ6", "SQ7"] 34 | } 35 | -------------------------------------------------------------------------------- /src/actions/actionid.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionActionDefinition } from '@companion-module/base' 2 | import type { AssignActionId } from './assign.js' 3 | import type { LevelActionId } from './level.js' 4 | import type { MuteActionId } from './mute.js' 5 | import type { OutputLevelActionId, OutputPanBalanceActionId } from './output.js' 6 | import type { PanBalanceActionId } from './pan-balance.js' 7 | import type { SceneActionId } from './scene.js' 8 | import type { SoftKeyId } from './softkey.js' 9 | 10 | /** 11 | * The type of action definitions for all actions within the specified action 12 | * set. 13 | */ 14 | export type ActionDefinitions = { 15 | [actionId in ActionSet]: CompanionActionDefinition 16 | } 17 | 18 | /** All action IDs. */ 19 | export type ActionId = 20 | | MuteActionId 21 | | AssignActionId 22 | | SceneActionId 23 | | SoftKeyId 24 | | LevelActionId 25 | | PanBalanceActionId 26 | | OutputLevelActionId 27 | | OutputPanBalanceActionId 28 | -------------------------------------------------------------------------------- /src/mixer/nrpn/nrpn.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { calculateNRPN, makeNRPN, prettyNRPN, splitNRPN, toNRPN } from './nrpn.js' 3 | 4 | describe('NRPN', () => { 5 | const ip1ToGrp1 = makeNRPN<'assign'>(0x66, 0x74) 6 | 7 | test('makeNRPN', () => { 8 | expect(makeNRPN<'level'>(0, 7)).toBe(7) 9 | expect(makeNRPN<'panBalance'>(1, 0)).toBe(1 << 7) 10 | expect(ip1ToGrp1).toBe((0x66 << 7) + 0x74) 11 | }) 12 | 13 | const mixOutputBase = makeNRPN<'level'>(0x4f, 0x01) 14 | 15 | test('calculateNRPN', () => { 16 | expect(calculateNRPN(mixOutputBase, 4)).toBe((0x4f << 7) + 0x01 + 4) 17 | }) 18 | 19 | const ip1ToGrp1Pair = splitNRPN(ip1ToGrp1) 20 | 21 | test('splitNRPN', () => { 22 | expect(ip1ToGrp1Pair).toEqual({ MSB: 0x66, LSB: 0x74 }) 23 | expect(splitNRPN(mixOutputBase)).toEqual({ MSB: 0x4f, LSB: 0x01 }) 24 | }) 25 | 26 | test('toNRPN', () => { 27 | expect(toNRPN(ip1ToGrp1Pair)).toBe((0x66 << 7) + 0x74) 28 | }) 29 | 30 | test('prettyNRPN', () => { 31 | expect(prettyNRPN(ip1ToGrp1)).toBe('MSB=66, LSB=74') 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 Bitfocus AS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/mixer/model.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { LR } from './lr.js' 3 | import { Model } from './model.js' 4 | 5 | describe('Model.forEach', () => { 6 | const model = new Model('SQ5') 7 | 8 | // Use matrix because there are few enough to readably list them all out. 9 | test('matrix', () => { 10 | const results: [number, string, string][] = [] 11 | 12 | model.forEach('matrix', (n, label, desc) => { 13 | results.push([n, label, desc]) 14 | }) 15 | 16 | expect(results).toEqual([ 17 | [0, 'MATRIX 1', 'Matrix 1'], 18 | [1, 'MATRIX 2', 'Matrix 2'], 19 | [2, 'MATRIX 3', 'Matrix 3'], 20 | ]) 21 | }) 22 | 23 | // Test lr specially because it used to return not 0 for its one element, 24 | // but 99 (which used to be the value of `LR`). 25 | test('lr', () => { 26 | // Although `LR` is a constant string now, this shouldn't affect the 27 | // enumeration of LR signals. 28 | expect(LR).toBe('lr') 29 | 30 | const results: [number, string, string][] = [] 31 | 32 | model.forEach('lr', (n, label, desc) => { 33 | results.push([n, label, desc]) 34 | }) 35 | 36 | expect(results).toEqual([[0, 'LR', 'LR']]) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/midi/tokenize/__tests__/no-channel-message.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestMidiTokenizing } from './midi-tokenizing.js' 3 | import { SysCommonMultiByte, SysExMessage } from '../../bytes.js' 4 | import { ExpectNextMessageNotReady, MixerWriteMidiBytes } from './interactions.js' 5 | 6 | describe('parse status byte (ignored data to start)', () => { 7 | test('data bytes only', async () => { 8 | return TestMidiTokenizing([ 9 | MixerWriteMidiBytes([0x12, 0x34]), 10 | MixerWriteMidiBytes([0x56]), 11 | ExpectNextMessageNotReady(), 12 | // force separate lines 13 | ]) 14 | }) 15 | 16 | test('data bytes and partial channel message', async () => { 17 | return TestMidiTokenizing([ 18 | MixerWriteMidiBytes([0x12, 0x00, 0x56]), 19 | MixerWriteMidiBytes([0xb0, 0x12]), 20 | MixerWriteMidiBytes([0xc3]), 21 | ExpectNextMessageNotReady(), 22 | ]) 23 | }) 24 | 25 | test('data bytes and partial system exclusive', async () => { 26 | return TestMidiTokenizing([ 27 | MixerWriteMidiBytes([0x12, 0x00, 0x56]), 28 | MixerWriteMidiBytes(SysExMessage.slice(0, 2)), 29 | ExpectNextMessageNotReady(), 30 | ]) 31 | }) 32 | 33 | test('data bytes and system common (incomplete data)', async () => { 34 | return TestMidiTokenizing([ 35 | MixerWriteMidiBytes([0x12, 0x34, 0x71]), 36 | MixerWriteMidiBytes(SysCommonMultiByte.slice(0, 2)), 37 | ExpectNextMessageNotReady(), 38 | ]) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/mixer/pan-balance.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { type PanBalance, panBalanceLevelToVCVF } from './pan-balance.js' 3 | 4 | describe('pan/balance choices to VC/VF', () => { 5 | test('exact cases', () => { 6 | expect(panBalanceLevelToVCVF('L100')).toEqual([0x00, 0x00]) 7 | expect(panBalanceLevelToVCVF('CTR')).toEqual([0x3f, 0x7f]) 8 | expect(panBalanceLevelToVCVF('R100')).toEqual([0x7f, 0x7f]) 9 | }) 10 | 11 | test('various table entries', () => { 12 | const tests = [ 13 | ['L90', 0x06, 0x33], 14 | ['L80', 0x0c, 0x66], 15 | ['L70', 0x13, 0x19], 16 | ['L60', 0x19, 0x4c], 17 | ['L50', 0x1f, 0x7f], 18 | ['L40', 0x26, 0x32], 19 | ['L30', 0x2c, 0x65], 20 | ['L20', 0x33, 0x18], 21 | ['L15', 0x36, 0x32], 22 | ['L10', 0x39, 0x4b], 23 | ['L5', 0x3c, 0x65], 24 | ['R5', 0x43, 0x18], 25 | ['R10', 0x46, 0x32], 26 | ['R15', 0x49, 0x4b], 27 | ['R20', 0x4c, 0x65], 28 | ['R30', 0x53, 0x18], 29 | ['R40', 0x59, 0x4b], 30 | ['R50', 0x5f, 0x7f], 31 | ['R60', 0x66, 0x32], 32 | ['R70', 0x6c, 0x65], 33 | ['R80', 0x73, 0x18], 34 | ['R90', 0x79, 0x4b], 35 | ] satisfies [PanBalance, number, number][] 36 | 37 | for (const [level, expectedVC, expectedVF] of tests) { 38 | const [actualVC, actualVF] = panBalanceLevelToVCVF(level) 39 | expect(Math.abs(actualVC - expectedVC)).toBeLessThanOrEqual(1) 40 | expect(Math.abs(actualVF - expectedVF)).toBeLessThanOrEqual(1) 41 | } 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Test CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 5 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | # Stay consistent with package.json. 17 | node-version: [~18.12] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - name: Prepare Environment 26 | run: | 27 | corepack enable 28 | yarn install 29 | env: 30 | CI: true 31 | - name: Type-check module and test code 32 | run: | 33 | yarn check-types 34 | env: 35 | CI: true 36 | - name: Lint module code 37 | run: | 38 | yarn lint 39 | env: 40 | CI: true 41 | - name: Check for unused code 42 | run: | 43 | yarn knip 44 | - name: Compile module code 45 | run: | 46 | yarn build 47 | env: 48 | CI: true 49 | - name: Run module tests 50 | run: | 51 | yarn test 52 | env: 53 | CI: true 54 | - name: Build Companion package 55 | run: | 56 | yarn companion-module-build 57 | env: 58 | CI: true 59 | -------------------------------------------------------------------------------- /src/feedbacks/feedback-ids.ts: -------------------------------------------------------------------------------- 1 | import { type CompanionFeedbackDefinition } from '@companion-module/base' 2 | import type { InputOutputType } from '../mixer/model.js' 3 | 4 | /** 5 | * The type of feedback definitions for all feedbacks within the specified 6 | * feedback set. 7 | */ 8 | export type FeedbackDefinitions = { 9 | [actionId in FeedbackSet]: CompanionFeedbackDefinition 10 | } 11 | 12 | /** 13 | * Feedback IDs for feedbacks reacting to the mute status of particular mixer 14 | * sources/sinks. 15 | */ 16 | export enum MuteFeedbackId { 17 | MuteInputChannel = 'mute_input', 18 | MuteLR = 'mute_lr', 19 | MuteMix = 'mute_aux', 20 | MuteGroup = 'mute_group', 21 | MuteMatrix = 'mute_matrix', 22 | MuteDCA = 'mute_dca', 23 | MuteFXReturn = 'mute_fx_return', 24 | MuteFXSend = 'mute_fx_send', 25 | MuteMuteGroup = 'mute_mutegroup', 26 | } 27 | 28 | /** A map associating mutable input/output types to mute feedback IDs. */ 29 | export const typeToMuteFeedback: Record = { 30 | inputChannel: MuteFeedbackId.MuteInputChannel, 31 | group: MuteFeedbackId.MuteGroup, 32 | mix: MuteFeedbackId.MuteMix, 33 | lr: MuteFeedbackId.MuteLR, 34 | muteGroup: MuteFeedbackId.MuteMuteGroup, 35 | matrix: MuteFeedbackId.MuteMatrix, 36 | fxReturn: MuteFeedbackId.MuteFXReturn, 37 | fxSend: MuteFeedbackId.MuteFXSend, 38 | dca: MuteFeedbackId.MuteDCA, 39 | } 40 | 41 | /** All feedback IDs. */ 42 | export type FeedbackId = MuteFeedbackId 43 | -------------------------------------------------------------------------------- /src/midi/parse/__tests__/scene-change.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestParsing } from './test-parsing.js' 3 | import { 4 | ExpectNextCommandReadiness, 5 | ExpectSceneMessage, 6 | ReceiveChannelMessage, 7 | ReceiveSystemRealTimeMessage, 8 | } from './interactions.js' 9 | import { SysCommonTuneRequest } from '../../bytes.js' 10 | 11 | describe('scene changes', () => { 12 | test('basic scene', async () => { 13 | return TestParsing(0, [ 14 | ExpectNextCommandReadiness(false), 15 | ReceiveChannelMessage([0xb0, 0x00, 0x00]), 16 | ExpectNextCommandReadiness(false), 17 | ReceiveChannelMessage([0xc0, 0x05]), 18 | ExpectNextCommandReadiness(true), 19 | ExpectSceneMessage(5), 20 | ExpectNextCommandReadiness(false), 21 | ]) 22 | }) 23 | 24 | test('basic scene with extraneous CN 00 after scene change', async () => { 25 | return TestParsing(6, [ 26 | ExpectNextCommandReadiness(false), 27 | ReceiveChannelMessage([0xb6, 0x00, 0x00]), 28 | ExpectNextCommandReadiness(false), 29 | ReceiveSystemRealTimeMessage(SysCommonTuneRequest), 30 | ReceiveChannelMessage([0xc6, 0x05]), 31 | ExpectNextCommandReadiness(true), 32 | ExpectSceneMessage(5), 33 | ReceiveChannelMessage([0xc6, 0x00]), // extraneous but sent by SQ-5 34 | ExpectNextCommandReadiness(false), 35 | // in different channel, so should be ignored 36 | ReceiveChannelMessage([0xb2, 0x00, 0x00]), 37 | ReceiveChannelMessage([0xc2, 0x03]), 38 | ExpectNextCommandReadiness(false), 39 | ]) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/mixer/nrpn/source-to-sink.test.ts: -------------------------------------------------------------------------------- 1 | import type { Expect, Equal, Extends, ExpectFalse } from 'type-testing' 2 | import { describe, expect, test } from 'vitest' 3 | import { Model } from '../model.js' 4 | import { type Param, splitNRPN } from './nrpn.js' 5 | import { forEachSourceSinkLevel, type SourceSinkForNRPN, type SinkForMixAndLRInSinkForNRPN } from './source-to-sink.js' 6 | 7 | // @ts-expect-error Perform a test that *must fail* to verify testing happens. 8 | type test_VerifyThatExpectAndEqualWillErrorIfMisused = Expect> 9 | 10 | type test_MixesIntoSinkSet = Expect, 'matrix'>> 11 | 12 | type test_CantSetFXRLevelInGroup = ExpectFalse>> 13 | 14 | type test_CantSetInputLevelInGroup = ExpectFalse>> 15 | 16 | describe('forEachSourceToSinkLevel', () => { 17 | const model = new Model('SQ5') 18 | 19 | const results: [Param<'level'>, string, string][] = [] 20 | forEachSourceSinkLevel(model, (nrpn, sourceDesc, sinkDesc) => { 21 | results.push([splitNRPN(nrpn), sourceDesc, sinkDesc]) 22 | }) 23 | 24 | test('some levels gotten', () => { 25 | expect(results.length).greaterThan(0) 26 | }) 27 | 28 | // Randomly chosen, nothing special 29 | test('group 3 -> LR', () => { 30 | expect( 31 | results.findIndex(([{ MSB, LSB }, resultSourceDesc, resultSinkDesc]) => { 32 | return MSB === 0x40 && LSB === 0x32 && resultSourceDesc === 'Group 3' && resultSinkDesc === 'LR' 33 | }), 34 | ).greaterThanOrEqual(0) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/variables.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionVariableDefinition } from '@companion-module/base' 2 | import type { Model } from './mixer/model.js' 3 | import { type NRPN, splitNRPN } from './mixer/nrpn/nrpn.js' 4 | import { forEachOutputLevel } from './mixer/nrpn/output.js' 5 | import { forEachSourceSinkLevel } from './mixer/nrpn/source-to-sink.js' 6 | 7 | /** 8 | * The variable ID for the variable containing the last recalled scene 9 | * (1-indexed). 10 | */ 11 | export const CurrentSceneId = 'currentScene' 12 | 13 | /** 14 | * The variable ID for the variable updated every time a scene is recalled 15 | * intended for use in triggers. 16 | */ 17 | export const SceneRecalledTriggerId = 'sceneRecalledTrigger' 18 | 19 | export function getVariables(model: Model): CompanionVariableDefinition[] { 20 | const variables: CompanionVariableDefinition[] = [ 21 | { 22 | name: 'Scene - Scene Recalled Trigger', 23 | variableId: SceneRecalledTriggerId, 24 | }, 25 | { 26 | name: 'Scene - Current', 27 | variableId: CurrentSceneId, 28 | }, 29 | ] 30 | 31 | const addVariable = (nrpn: NRPN<'level'>, desc: string) => { 32 | const { MSB, LSB } = splitNRPN(nrpn) 33 | variables.push({ 34 | name: desc, 35 | variableId: `level_${MSB}.${LSB}`, 36 | }) 37 | } 38 | 39 | forEachSourceSinkLevel(model, (nrpn, sourceDesc, sinkDesc) => { 40 | addVariable(nrpn, `${sourceDesc} -> ${sinkDesc} Level`) 41 | }) 42 | 43 | forEachOutputLevel(model, (nrpn, outputDesc) => { 44 | addVariable(nrpn, `${outputDesc} Output Level`) 45 | }) 46 | 47 | //mute input, LR, aux, group, matrix, dca, fx return, fx send, mute group 48 | 49 | return variables 50 | } 51 | -------------------------------------------------------------------------------- /src/midi/parse/__tests__/pan-level.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestParsing } from './test-parsing.js' 3 | import { 4 | ExpectNextCommandReadiness, 5 | ExpectPanLevelMessage, 6 | ReceiveChannelMessage, 7 | ReceiveSystemCommonMessage, 8 | } from './interactions.js' 9 | import { PanLevel } from './commands.js' 10 | import { SysCommonMultiByte, SysCommonSingleByte } from '../../bytes.js' 11 | 12 | describe('pan/balance level commands', () => { 13 | test('pan/balance', async () => { 14 | return TestParsing(7, [ 15 | // Ip37 in Aux10 16 | ReceiveChannelMessage([0xb7, 0x63, 0x53]), 17 | ReceiveChannelMessage([0xb7, 0x62, 0x7d]), 18 | // Channel 1, C-1, Note on (DAW channel, i.e. 0 = one more than 7) 19 | ReceiveChannelMessage([0xc0, 0x00, 0x7f]), 20 | ReceiveChannelMessage([0xb7, 0x06, 0x00]), 21 | ExpectNextCommandReadiness(false), 22 | ReceiveChannelMessage([0xb7, 0x26, 0x00]), 23 | ReceiveSystemCommonMessage(SysCommonSingleByte), 24 | ExpectPanLevelMessage(0x53, 0x7d, 0x00, 0x00), 25 | // abortive message, discarded 26 | ReceiveChannelMessage([0xb7, 0x63, 0x55]), 27 | // Group 4 in Aux2, CTR 28 | ReceiveChannelMessage([0xb7, 0x63, 0x55]), 29 | ReceiveChannelMessage([0xb7, 0x62, 0x29]), 30 | ReceiveSystemCommonMessage(SysCommonMultiByte), 31 | ReceiveChannelMessage([0xb7, 0x06, 0x3f]), 32 | ExpectNextCommandReadiness(false), 33 | ReceiveChannelMessage([0xb7, 0x26, 0x7f]), 34 | ExpectNextCommandReadiness(true), 35 | ExpectPanLevelMessage(0x55, 0x29, 0x3f, 0x7f), 36 | ExpectNextCommandReadiness(false), 37 | // LR in Mtx1, L30% 38 | ...PanLevel(7, 0x5e, 0x24, 0x2c, 0x65).map(ReceiveChannelMessage), 39 | ExpectNextCommandReadiness(true), 40 | ExpectPanLevelMessage(0x5e, 0x24, 0x2c, 0x65), 41 | ]) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "allenheath-sq", 3 | "version": "3.0.0", 4 | "type": "module", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "postinstall": "husky", 8 | "test": "vitest --run", 9 | "check-types": "tsc -p tsconfig.json --noEmit --noUnusedLocals false", 10 | "package": "yarn build && companion-module-build", 11 | "build": "rimraf dist && yarn build:main", 12 | "build:main": "tsc -p tsconfig.build.json", 13 | "dev": "tsc -p tsconfig.build.json --watch", 14 | "lint:raw": "eslint", 15 | "lint": "yarn lint:raw .", 16 | "knip": "knip", 17 | "check": "yarn check-types && yarn lint && yarn knip && yarn build && echo PASS || echo FAIL", 18 | "bt": "yarn install && yarn check-types && yarn lint && yarn knip && yarn build && yarn test && echo PASS || echo FAIL" 19 | }, 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/bitfocus/companion-module-allenheath-sq.git" 24 | }, 25 | "prettier": "@companion-module/tools/.prettierrc.json", 26 | "lint-staged": { 27 | "*.{css,json,md,scss,yaml}": [ 28 | "prettier --write" 29 | ], 30 | "*.{mjs,ts,tsx,js,jsx}": [ 31 | "yarn lint:raw --fix" 32 | ] 33 | }, 34 | "engines": { 35 | "node": "^18.12" 36 | }, 37 | "dependencies": { 38 | "@companion-module/base": "~1.10.0", 39 | "eventemitter3": "^5.0.1" 40 | }, 41 | "devDependencies": { 42 | "@companion-module/tools": "~2.0.4", 43 | "@types/node": "^22.15.18", 44 | "eslint": "~9.22.0", 45 | "eslint-plugin-n": "^17.17.0", 46 | "husky": "^9.0.11", 47 | "knip": "^5.46.0", 48 | "lint-staged": "^15.5.0", 49 | "prettier": "^3.5.3", 50 | "rimraf": "^6.0.1", 51 | "type-testing": "^0.2.0", 52 | "typescript": "~5.8.2", 53 | "typescript-eslint": "~8.26.1", 54 | "vitest": "^3.0.9" 55 | }, 56 | "packageManager": "yarn@4.9.1" 57 | } 58 | -------------------------------------------------------------------------------- /src/midi/parse/__tests__/fader-level.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestParsing } from './test-parsing.js' 3 | import { 4 | ExpectNextCommandReadiness, 5 | ReceiveChannelMessage, 6 | ExpectFaderLevelMessage, 7 | ReceiveSystemCommonMessage, 8 | } from './interactions.js' 9 | import { FaderLevel } from './commands.js' 10 | import { SysCommonMultiByte } from '../../bytes.js' 11 | 12 | describe('fader level', () => { 13 | test('various fader level tests', async () => { 14 | return TestParsing(5, [ 15 | // Ip23 in Aux3 16 | ReceiveChannelMessage([0xb5, 0x63, 0x42]), 17 | ReceiveChannelMessage([0xb5, 0x62, 0x4e]), 18 | // Channel 6, C-1, Note on (DAW channel, i.e. 6 = one more than 5) 19 | ReceiveChannelMessage([0xc6, 0x00, 0x7f]), 20 | ReceiveChannelMessage([0xb5, 0x06, 0x7d]), 21 | ExpectNextCommandReadiness(false), 22 | ReceiveSystemCommonMessage(SysCommonMultiByte), 23 | ReceiveChannelMessage([0xb5, 0x26, 0x00]), 24 | ExpectNextCommandReadiness(true), 25 | ExpectFaderLevelMessage(0x42, 0x4e, 0x7d, 0x00), 26 | // First three NRPN messages should be discarded 27 | ReceiveChannelMessage([0xb5, 0x63, 0x42]), 28 | ReceiveChannelMessage([0xb5, 0x62, 0x4e]), 29 | ReceiveChannelMessage([0xb5, 0x06, 0x00]), 30 | ExpectNextCommandReadiness(false), 31 | // Group 1 in Aux3 32 | ReceiveChannelMessage([0xb5, 0x63, 0x45]), 33 | ReceiveChannelMessage([0xb5, 0x62, 0x06]), 34 | ReceiveChannelMessage([0xb5, 0x06, 0x00]), 35 | ReceiveChannelMessage([0xb5, 0x26, 0x00]), 36 | ExpectNextCommandReadiness(true), 37 | ExpectFaderLevelMessage(0x45, 0x06, 0x00, 0x00), 38 | ExpectNextCommandReadiness(false), 39 | // Grp3 in Aux4, 0dB (linear taper) 40 | ...FaderLevel(5, 0x45, 0x1f, 0x76, 0x5c).map(ReceiveChannelMessage), 41 | ExpectNextCommandReadiness(true), 42 | ExpectFaderLevelMessage(0x45, 0x1f, 0x76, 0x5c), 43 | ]) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/mixer/pan-balance.ts: -------------------------------------------------------------------------------- 1 | export type PanBalance = 'CTR' | `L${number}` | `R${number}` 2 | 3 | const CENTER = (0x3f << 7) | 0x7f 4 | const MAX = (0x7f << 7) + 0x7f 5 | 6 | /** 7 | * Convert a pan/balance level choice value to the MIDI NRPN encoding of that 8 | * choice. 9 | * 10 | * @param level 11 | * A pan/balance level as encoded by a pan/balance choice (see `choices.js`). 12 | * @return 13 | * A `[VC, VF]` pair that can be slotted into a MIDI NRPN data message to set 14 | * pan/balance to what `level` specifies. Except for 100%-left, 100%-right, 15 | * and center, these values are not guaranteed to be exact. 16 | */ 17 | export function panBalanceLevelToVCVF(level: PanBalance): [number, number] { 18 | // Convert L100, L95, ..., L5, CTR, R5, ... R95, R100 to 19 | // 0, 5, ..., 195, 200. 20 | let lv 21 | if (level === 'CTR') { 22 | lv = 100 23 | } else { 24 | lv = 100 + (level[0] === 'L' ? -1 : 1) * parseInt(level.slice(1), 10) 25 | } 26 | 27 | // The combined VC/VF for a pan/balance level is just a linear interpolation 28 | // over L100%=[0x00, 0x00] to CTR=[0x3F, 0x7F] to R100%=[0x7F, 0x7F]. 29 | const interpolated = Math.floor((lv / 200) * MAX) 30 | return [interpolated >> 7, interpolated & 0x7f] 31 | } 32 | 33 | /** 34 | * Convert a `VC`/`VF` pair to a human-readable pan/balance level. 35 | * 36 | * This function returns a human-readable version of what `vc`/`vf` encode. It 37 | * is _not_ guaranteed to be a valid pan/balance-level option value, as those 38 | * options are restricted to 5% increments. 39 | */ 40 | export function vcvfToReadablePanBalance(vc: number, vf: number): PanBalance { 41 | const data = (vc << 7) | vf 42 | let val = parseFloat(((data - CENTER) / 81.9).toFixed(0)) 43 | if (val > 100) { 44 | val = 100 45 | } 46 | if (val < -100) { 47 | val = -100 48 | } 49 | 50 | if (val === 0) { 51 | return 'CTR' 52 | } 53 | 54 | const amount = Math.abs(val) 55 | if (val < 0) { 56 | return `L${amount}` 57 | } 58 | return `R${amount}` 59 | } 60 | -------------------------------------------------------------------------------- /src/mixer/nrpn/nrpn.ts: -------------------------------------------------------------------------------- 1 | import type { Branded } from '../../utils/brand.js' 2 | import { prettyByte } from '../../utils/pretty.js' 3 | 4 | export type NRPNType = 'mute' | 'assign' | 'panBalance' | 'level' 5 | 6 | /** A 14-bit NRPN of specific type. */ 7 | export type NRPN = Branded 8 | 9 | /** A MIDI NRPN decomposed into 7-bit MSB and LSB. */ 10 | export type Param = T extends NRPNType 11 | ? { MSB: Branded; LSB: Branded } 12 | : never 13 | 14 | /** 15 | * An untyped MIDI NRPN decomposed into 7-bit MSB and LSB. (This is generally 16 | * only used to specify an NRPN in a literal; `Param` is used to overlay 17 | typing information upon NRPN MSB/LSB as actually used.) 18 | */ 19 | export type UnbrandedParam = { MSB: number; LSB: number } 20 | 21 | /** Compute an NRPN of the given type from a 7-bit MSB/LSB. */ 22 | export function makeNRPN(MSB: number, LSB: number): NRPN { 23 | return ((MSB << 7) + LSB) as NRPN 24 | } 25 | 26 | /** 27 | * An NRPN for the assign state of a mixer source in a sink. 28 | * @allowunused 29 | */ 30 | export type AssignParam = Param<'assign'> 31 | 32 | /** Compute the NRPN at the given offset from another NRPN. */ 33 | export function calculateNRPN(nrpn: NRPN, offset: number): NRPN { 34 | return (nrpn + offset) as NRPN 35 | } 36 | 37 | /** Split `nrpn` into its 7-bit halves. */ 38 | export function splitNRPN(nrpn: NRPN): Param { 39 | return { MSB: (nrpn >> 7) & 0b0111_1111, LSB: nrpn & 0b0111_1111 } as Param 40 | } 41 | 42 | /** Convert a 7-bit MSB/LSB pair into a 14-bit NRPN. */ 43 | export function toNRPN({ MSB, LSB }: Param): NRPN { 44 | return makeNRPN(MSB, LSB) 45 | } 46 | 47 | /** Pretty-print an NRPN into its 7-bit MSB/LSB decomposition. */ 48 | export function prettyNRPN(nrpn: NRPN): string { 49 | const { MSB, LSB } = splitNRPN(nrpn) 50 | return `MSB=${prettyByte(MSB)}, LSB=${prettyByte(LSB)}` 51 | } 52 | -------------------------------------------------------------------------------- /src/mixer/models.ts: -------------------------------------------------------------------------------- 1 | export const DefaultModel = 'SQ5' 2 | 3 | export type ModelId = 'SQ5' | 'SQ6' | 'SQ7' 4 | 5 | type ModelType = { 6 | chCount: number 7 | mixCount: number 8 | grpCount: number 9 | fxrCount: number 10 | fxsCount: number 11 | mtxCount: number 12 | dcaCount: number 13 | muteGroupCount: number 14 | softKeyCount: number 15 | RotaryKey: number 16 | sceneCount: number 17 | } 18 | 19 | export const SQModels: { [K in ModelId]: ModelType } = { 20 | SQ5: { 21 | chCount: 48, 22 | mixCount: 12, 23 | grpCount: 12, 24 | fxrCount: 8, 25 | fxsCount: 4, 26 | mtxCount: 3, 27 | dcaCount: 8, 28 | muteGroupCount: 8, 29 | softKeyCount: 8, 30 | RotaryKey: 0, 31 | sceneCount: 300, 32 | }, 33 | SQ6: { 34 | chCount: 48, 35 | mixCount: 12, 36 | grpCount: 12, 37 | fxrCount: 8, 38 | fxsCount: 4, 39 | mtxCount: 3, 40 | dcaCount: 8, 41 | muteGroupCount: 8, 42 | softKeyCount: 16, 43 | RotaryKey: 4, 44 | sceneCount: 300, 45 | }, 46 | SQ7: { 47 | chCount: 48, 48 | mixCount: 12, 49 | grpCount: 12, 50 | fxrCount: 8, 51 | fxsCount: 4, 52 | mtxCount: 3, 53 | dcaCount: 8, 54 | muteGroupCount: 8, 55 | softKeyCount: 16, 56 | RotaryKey: 8, 57 | sceneCount: 300, 58 | }, 59 | } 60 | 61 | /** 62 | * Get the value of a particular SQ model characteristic, where that 63 | * characteristic is being presumed to have the same value across all SQ mixer 64 | * models. If the characteristic in fact varies across models, this function 65 | * will throw. 66 | * 67 | * @param countType 68 | * The particular SQ characteristic to query. 69 | * @throws 70 | * A TypeError if the characteristic isn't the same across all SQ models. 71 | * @returns 72 | * The value of the characteristic. 73 | */ 74 | export function getCommonCount(countType: keyof ModelType): number { 75 | return Object.values(SQModels).reduce((count, model) => { 76 | const thisCount = model[countType] 77 | if (count === -1) { 78 | return thisCount 79 | } 80 | if (count !== thisCount) { 81 | throw new TypeError(`SQ ${countType} is not constant across models`) 82 | } 83 | return count 84 | }, -1) 85 | } 86 | -------------------------------------------------------------------------------- /src/midi/tokenize/__tests__/status-byte.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestMidiTokenizing } from './midi-tokenizing.js' 3 | import { SysRTContinue, SysExMessage, SysExEnd } from '../../bytes.js' 4 | import { 5 | ExpectNextMessageNotReady, 6 | ExpectChannelMessage, 7 | ExpectSystemExclusiveMessage, 8 | ExpectSystemRealTimeMessage, 9 | MixerWriteMidiBytes, 10 | } from './interactions.js' 11 | 12 | describe('parse status byte', () => { 13 | test('data bytes before channel status byte', async () => { 14 | return TestMidiTokenizing([ 15 | MixerWriteMidiBytes([0x12, 0x34, 0x56, 0xb0, 0x00]), 16 | ExpectNextMessageNotReady(), 17 | MixerWriteMidiBytes([0x0f]), 18 | ExpectChannelMessage([0xb0, 0x00, 0x0f]), 19 | ]) 20 | }) 21 | test('data bytes before system status byte', async () => { 22 | return TestMidiTokenizing([ 23 | MixerWriteMidiBytes([0x12, 0x34, 0x56, ...SysExMessage.slice(0, 2)]), 24 | ExpectNextMessageNotReady(), 25 | MixerWriteMidiBytes(SysExMessage.slice(2)), 26 | MixerWriteMidiBytes([0xc5, 0x17]), 27 | ExpectSystemExclusiveMessage(SysExMessage), 28 | ExpectChannelMessage([0xc5, 0x17]), 29 | ExpectNextMessageNotReady(), 30 | ]) 31 | }) 32 | test('data bytes before system status byte', async () => { 33 | return TestMidiTokenizing([ 34 | MixerWriteMidiBytes([0x12, 0x34, 0x56, ...SysExMessage.slice(0, 2)]), 35 | ExpectNextMessageNotReady(), 36 | MixerWriteMidiBytes([0xc5]), // terminate sysex 37 | ExpectSystemExclusiveMessage([...SysExMessage.slice(0, 2), SysExEnd]), 38 | MixerWriteMidiBytes([0xc5, 0x17]), 39 | ExpectChannelMessage([0xc5, 0x17]), 40 | ]) 41 | }) 42 | 43 | test('data bytes before system real time', async () => { 44 | return TestMidiTokenizing([ 45 | MixerWriteMidiBytes([0x12, 0x34, 0x56]), 46 | ExpectNextMessageNotReady(), 47 | MixerWriteMidiBytes([SysRTContinue]), 48 | ExpectSystemRealTimeMessage(SysRTContinue), 49 | MixerWriteMidiBytes(SysExMessage), 50 | MixerWriteMidiBytes([0xc5, 0x17]), 51 | ExpectSystemExclusiveMessage(SysExMessage), 52 | ExpectChannelMessage([0xc5, 0x17]), 53 | ]) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/feedbacks/feedbacks.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionFeedbackDefinition, type DropdownChoice } from '@companion-module/base' 2 | import type { Choices } from '../choices.js' 3 | import { type FeedbackDefinitions, type FeedbackId, MuteFeedbackId } from './feedback-ids.js' 4 | import type { Mixer } from '../mixer/mixer.js' 5 | import type { InputOutputType } from '../mixer/model.js' 6 | import { calculateMuteNRPN } from '../mixer/nrpn/mute.js' 7 | 8 | const WHITE = combineRgb(255, 255, 255) 9 | const CARMINE_RED = combineRgb(153, 0, 51) 10 | 11 | export function getFeedbacks(mixer: Mixer, choices: Choices): FeedbackDefinitions { 12 | function muteFeedback(label: string, type: InputOutputType, choices: DropdownChoice[]): CompanionFeedbackDefinition { 13 | return { 14 | type: 'boolean', 15 | name: `Mute ${label}`, 16 | description: 'Change colour', 17 | options: [ 18 | { 19 | type: 'dropdown', 20 | label, 21 | id: 'channel', 22 | default: 0, 23 | choices, 24 | minChoicesForSearch: 0, 25 | }, 26 | ], 27 | defaultStyle: { 28 | color: WHITE, 29 | bgcolor: CARMINE_RED, 30 | }, 31 | callback: ({ options }, _context) => { 32 | const nrpn = calculateMuteNRPN(mixer.model, type, Number(options.channel)) 33 | return mixer.muted(nrpn) 34 | }, 35 | } 36 | } 37 | 38 | return { 39 | [MuteFeedbackId.MuteInputChannel]: muteFeedback('Input', 'inputChannel', choices.inputChannels), 40 | [MuteFeedbackId.MuteLR]: muteFeedback('LR', 'lr', [{ label: `LR`, id: 0 }]), 41 | [MuteFeedbackId.MuteMix]: muteFeedback('Aux', 'mix', choices.mixes), 42 | [MuteFeedbackId.MuteGroup]: muteFeedback('Group', 'group', choices.groups), 43 | [MuteFeedbackId.MuteMatrix]: muteFeedback('Matrix', 'matrix', choices.matrixes), 44 | [MuteFeedbackId.MuteDCA]: muteFeedback('DCA', 'dca', choices.dcas), 45 | [MuteFeedbackId.MuteFXReturn]: muteFeedback('FX Return', 'fxReturn', choices.fxReturns), 46 | [MuteFeedbackId.MuteFXSend]: muteFeedback('FX Send', 'fxSend', choices.fxSends), 47 | [MuteFeedbackId.MuteMuteGroup]: muteFeedback('MuteGroup', 'muteGroup', choices.muteGroups), 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/actions/to-source-or-sink.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionOptionValues } from '@companion-module/base' 2 | import type { sqInstance } from '../instance.js' 3 | import { LR, type MixOrLR } from '../mixer/lr.js' 4 | import type { InputOutputType, Model } from '../mixer/model.js' 5 | 6 | /** The type of an option value. */ 7 | export type OptionValue = CompanionOptionValues[keyof CompanionOptionValues] 8 | 9 | /** 10 | * Given an option value `optionValue` that purports to identify a source of the 11 | * given `type`, determine whether it refers to a valid source. If it does, 12 | * return its number. If not, log an error and return null. 13 | * 14 | * `optionValue` must not refer to the LR mix if `type === 'mix'`. Use 15 | * `toMixOrLR` if you need to accept both mixes and LR. 16 | * 17 | * @param instance 18 | * The active module instance. 19 | * @param model 20 | * The mixer model. 21 | * @param optionValue 22 | * The option value identifying a source of type `type`. 23 | * @param type 24 | * The type of the source being identified. 25 | */ 26 | export function toSourceOrSink( 27 | instance: sqInstance, 28 | model: Model, 29 | optionValue: OptionValue, 30 | type: InputOutputType, 31 | ): number | null { 32 | const n = Number(optionValue) 33 | if (n < model.inputOutputCounts[type]) { 34 | return n 35 | } 36 | 37 | instance.log('error', `Invalid ${type} (${optionValue})`) 38 | return null 39 | } 40 | 41 | /** 42 | * Given an option value `optionValue` that purports to identify a mix or LR, 43 | * determine whether it refers to one. If it does, return its number. If not, 44 | * log an error and return null. 45 | * 46 | * @param instance 47 | * The active module instance. 48 | * @param model 49 | * The mixer model. 50 | * @param optionValue 51 | * The option value identifying a source of type `type`. 52 | */ 53 | export function toMixOrLR(instance: sqInstance, model: Model, optionValue: OptionValue): MixOrLR | null { 54 | if (optionValue === LR) { 55 | return LR 56 | } 57 | 58 | const n = Number(optionValue) 59 | if (n < model.inputOutputCounts.mix) { 60 | return n 61 | } 62 | 63 | instance.log('error', `Invalid mix-or-LR (${optionValue})`) 64 | return null 65 | } 66 | -------------------------------------------------------------------------------- /src/upgrades.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CompanionMigrationAction, 3 | type CompanionStaticUpgradeProps, 4 | type CompanionStaticUpgradeScript, 5 | type CompanionUpgradeContext, 6 | EmptyUpgradeScript, 7 | } from '@companion-module/base' 8 | import { tryFixFXRLevelInFXSIdTypo } from './actions/level.js' 9 | import { 10 | tryConvertOldLevelToOutputActionToSinkSpecific, 11 | tryConvertOldPanToOutputActionToSinkSpecific, 12 | } from './actions/output.js' 13 | import { tryCoalesceSceneRecallActions } from './actions/scene.js' 14 | import { 15 | type SQInstanceConfig, 16 | tryEnsureLabelInConfig, 17 | tryEnsureModelOptionInConfig, 18 | tryRemoveUnnecessaryLabelInConfig, 19 | } from './config.js' 20 | import { tryUpdateAllLRMixEncodings } from './mixer/lr.js' 21 | 22 | function ActionUpdater( 23 | tryUpdate: (action: CompanionMigrationAction) => boolean, 24 | ): CompanionStaticUpgradeScript { 25 | return ( 26 | _context: CompanionUpgradeContext, 27 | props: CompanionStaticUpgradeProps, 28 | ) => { 29 | return { 30 | updatedActions: props.actions.filter(tryUpdate), 31 | updatedConfig: null, 32 | updatedFeedbacks: [], 33 | } 34 | } 35 | } 36 | 37 | function ConfigUpdater( 38 | tryUpdate: (config: SQInstanceConfig | null) => boolean, 39 | ): CompanionStaticUpgradeScript { 40 | return ( 41 | _context: CompanionUpgradeContext, 42 | props: CompanionStaticUpgradeProps, 43 | ) => { 44 | return { 45 | updatedActions: [], 46 | updatedConfig: tryUpdate(props.config) ? props.config : null, 47 | updatedFeedbacks: [], 48 | } 49 | } 50 | } 51 | 52 | export const UpgradeScripts = [ 53 | EmptyUpgradeScript, 54 | ActionUpdater(tryCoalesceSceneRecallActions), 55 | ConfigUpdater(tryEnsureModelOptionInConfig), 56 | ConfigUpdater(tryEnsureLabelInConfig), 57 | ActionUpdater(tryConvertOldLevelToOutputActionToSinkSpecific), 58 | ActionUpdater(tryConvertOldPanToOutputActionToSinkSpecific), 59 | // ...yes, we added the `'label'` config option above because we thought it 60 | // was the only way to get the instance label, and now we're removing it 61 | // because there in fact *is* a way to get that label without requiring that 62 | // users redundantly specify it. So it goes. 63 | ConfigUpdater(tryRemoveUnnecessaryLabelInConfig), 64 | ActionUpdater(tryUpdateAllLRMixEncodings), 65 | ActionUpdater(tryFixFXRLevelInFXSIdTypo), 66 | ] 67 | -------------------------------------------------------------------------------- /src/utils/socket-reader.ts: -------------------------------------------------------------------------------- 1 | import { type TCPHelper } from '@companion-module/base' 2 | 3 | /** A class to read bytes from a socket and append them to a provided array. */ 4 | export class SocketReader { 5 | readonly #gen: AsyncGenerator 6 | 7 | /** 8 | * Create a `SocketReader` that reads from `source` and appends bytes read 9 | * to `out`. 10 | * let source: TCPHelper = someTCPHelper 11 | * const out: number[] = [] 12 | * const reader = await SocketReader.create(socket, out) 13 | * for (;;) { 14 | * if (!(await reader.read())) { 15 | * // No more data to read, stop 16 | * break 17 | * } 18 | * // `out` has fresh bytes of data appended to it 19 | * } 20 | */ 21 | static async create(source: TCPHelper, out: number[]): Promise { 22 | const reader = new SocketReader(source, out) 23 | 24 | // Skip past the implicit initial `yield` in `#createSocketReader and 25 | // (synchronously) begin waiting for more data to become available. 26 | await reader.#gen.next() 27 | 28 | return reader 29 | } 30 | 31 | /** 32 | * Read more data from the socket and append it to the array specified at 33 | * construction. Return true if more data may be readable. Return false if 34 | * no more data can be read. 35 | */ 36 | async read(): Promise { 37 | const done = Boolean((await this.#gen.next()).done) 38 | return !done 39 | } 40 | 41 | private constructor(source: TCPHelper, data: number[]) { 42 | this.#gen = SocketReader.#createReader(source, data) 43 | } 44 | 45 | static async *#createReader(source: TCPHelper, receivedData: number[]): AsyncGenerator { 46 | const socketClosed = new Promise((resolve: (more: boolean) => void, _reject: (reason: Error) => void) => { 47 | const stop = () => { 48 | resolve(false) 49 | } 50 | source.once('end', stop) 51 | source.once('error', stop) 52 | }) 53 | 54 | let dataAvailable = (async function readMore() { 55 | return new Promise((resolve: (more: boolean) => void) => { 56 | source.once('data', (data: Uint8Array) => { 57 | for (const b of data) receivedData.push(b) 58 | resolve(true) 59 | dataAvailable = readMore() 60 | }) 61 | }) 62 | })() 63 | 64 | for (;;) { 65 | const keepGoing = await Promise.race([socketClosed, dataAvailable]) 66 | if (!keepGoing) { 67 | break 68 | } 69 | 70 | yield 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/mixer/nrpn/mute.ts: -------------------------------------------------------------------------------- 1 | import type { InputOutputType, Model } from '../model.js' 2 | import { calculateNRPN, toNRPN, type NRPN, type Param, type UnbrandedParam } from './nrpn.js' 3 | 4 | type MuteParameterBaseRaw = Readonly>> 5 | 6 | /** 7 | * Base parameter MSB/LSB values for mute state of sources/sinks. Note that LR 8 | * is considered to be a special category, distinct from mixes, that consists of 9 | * only the single LR mix. 10 | * 11 | * These values are the pairs in the columns of the relevant tables in the 12 | * [SQ MIDI Protocol document](https://www.allen-heath.com/content/uploads/2023/11/SQ-MIDI-Protocol-Issue5.pdf). 13 | */ 14 | const MuteParameterBaseRaw = { 15 | inputChannel: { MSB: 0x00, LSB: 0x00 }, 16 | lr: { MSB: 0x00, LSB: 0x44 }, 17 | mix: { MSB: 0x00, LSB: 0x45 }, 18 | group: { MSB: 0x00, LSB: 0x30 }, 19 | matrix: { MSB: 0x00, LSB: 0x55 }, 20 | fxSend: { MSB: 0x00, LSB: 0x51 }, 21 | fxReturn: { MSB: 0x00, LSB: 0x3c }, 22 | dca: { MSB: 0x02, LSB: 0x00 }, 23 | muteGroup: { MSB: 0x04, LSB: 0x00 }, 24 | } as const satisfies MuteParameterBaseRaw 25 | 26 | type ApplyMuteBranding = { 27 | [NRPN in keyof T]: T[NRPN] extends UnbrandedParam ? Param<'mute'> : never 28 | } 29 | 30 | const MuteParameterBase = MuteParameterBaseRaw as ApplyMuteBranding 31 | 32 | /** 33 | * Calculate the NRPN for the mute state of the numbered input/output of the 34 | * given type. 35 | * 36 | * @param model 37 | * The mixer model for which the NRPN is computed. 38 | * @param inputOutputType 39 | * The type of the input/output, e.g. `'inputChannel'`. 40 | * @param n 41 | * The specific zero-indexed input/output instance, e.g. `7` to refer to input 42 | * channel 8. 43 | * @returns 44 | * The NRPN for the identified input/output. 45 | */ 46 | export function calculateMuteNRPN(model: Model, inputOutputType: InputOutputType, n: number): NRPN<'mute'> { 47 | if (model.inputOutputCounts[inputOutputType] <= n) { 48 | throw new Error(`${inputOutputType}=${n} is invalid`) 49 | } 50 | 51 | return calculateNRPN(toNRPN(MuteParameterBase[inputOutputType]), n) 52 | } 53 | 54 | type ForEachMuteFunctor = (nrpn: NRPN<'mute'>) => void 55 | 56 | export function forEachMute(model: Model, f: ForEachMuteFunctor): void { 57 | for (const [type, base] of Object.entries(MuteParameterBase)) { 58 | const muteType = type as InputOutputType 59 | model.forEach(muteType, (n: number) => { 60 | return f(calculateNRPN(toNRPN(base), n)) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { generateEslintConfig } from '@companion-module/tools/eslint/config.mjs' 4 | 5 | const baseConfig = await generateEslintConfig({ 6 | enableJest: true, 7 | enableTypescript: true, 8 | }) 9 | 10 | /** 11 | * @param {import('eslint').Linter.Config['files']} files 12 | * @param {readonly string[]} allowModules 13 | * @returns {import('eslint').Linter.Config} 14 | */ 15 | function permitLimitedUnpublishedImports(files, allowModules) { 16 | return { 17 | files, 18 | rules: { 19 | 'n/no-unpublished-import': [ 20 | 'error', 21 | { 22 | allowModules, 23 | }, 24 | ], 25 | }, 26 | } 27 | } 28 | 29 | const testFilePatterns = ['src/**/*spec.ts', 'src/**/*test.ts'] 30 | const testHelperPatterns = ['src/**/__tests__/*', 'src/**/__mocks__/*'] 31 | 32 | const allTestFilePatterns = [...testFilePatterns, ...testHelperPatterns] 33 | 34 | /** @type {import('eslint').Linter.Config[]} */ 35 | const customConfig = [ 36 | ...baseConfig, 37 | 38 | { 39 | ignores: ['eslint.config.*'], 40 | rules: { 41 | 'object-shorthand': 'error', 42 | 'no-useless-rename': 'error', 43 | 'n/no-missing-import': 'off', 44 | 'n/no-unpublished-import': 'error', 45 | '@typescript-eslint/strict-boolean-expressions': 'error', 46 | eqeqeq: 'error', 47 | radix: 'error', 48 | 'no-eval': 'error', 49 | 'no-implied-eval': 'error', 50 | '@typescript-eslint/consistent-type-imports': [ 51 | 'error', 52 | { 53 | fixStyle: 'inline-type-imports', 54 | }, 55 | ], 56 | }, 57 | }, 58 | 59 | { 60 | files: allTestFilePatterns, 61 | rules: { 62 | 'no-unused-vars': 'off', 63 | '@typescript-eslint/no-unused-vars': [ 64 | 'error', 65 | { 66 | vars: 'all', 67 | argsIgnorePattern: '^_', 68 | caughtErrorsIgnorePattern: '^_', 69 | // In addition to `_*' variables, allow `test_*` variables 70 | // -- specifically type variables, but this rule doesn't 71 | // support that further restriction now -- for use in tests 72 | // of types that are performed using 'type-testing' helpers. 73 | varsIgnorePattern: '^(?:test)?_', 74 | }, 75 | ], 76 | }, 77 | }, 78 | 79 | permitLimitedUnpublishedImports(allTestFilePatterns, ['type-testing', 'vitest']), 80 | permitLimitedUnpublishedImports(['eslint.config.mjs'], ['@companion-module/tools']), 81 | permitLimitedUnpublishedImports(['knip.config.ts'], ['knip']), 82 | permitLimitedUnpublishedImports(['vitest.config.ts'], ['vitest']), 83 | ] 84 | 85 | export default customConfig 86 | -------------------------------------------------------------------------------- /src/midi/tokenize/__tests__/system-exclusive.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestMidiTokenizing } from './midi-tokenizing.js' 3 | import { 4 | ExpectNextMessageNotReady, 5 | ExpectSystemExclusiveMessage, 6 | ExpectSystemRealTimeMessage, 7 | MixerWriteMidiBytes, 8 | } from './interactions.js' 9 | import { SysCommonTuneRequest, SysExEnd, SysExStart, SysRTContinue } from '../../bytes.js' 10 | 11 | describe('system exclusive', () => { 12 | test('noncanonical terminator (channel status)', async () => { 13 | return TestMidiTokenizing([ 14 | MixerWriteMidiBytes([0x33]), 15 | ExpectNextMessageNotReady(), 16 | MixerWriteMidiBytes([SysExStart]), 17 | ExpectNextMessageNotReady(), 18 | MixerWriteMidiBytes([0xc3]), 19 | ExpectSystemExclusiveMessage([SysExStart, SysExEnd]), 20 | ExpectNextMessageNotReady(), 21 | ]) 22 | }) 23 | 24 | test('noncanonical terminator, system common status', async () => { 25 | return TestMidiTokenizing([ 26 | MixerWriteMidiBytes([0x33]), 27 | ExpectNextMessageNotReady(), 28 | MixerWriteMidiBytes([SysExStart]), 29 | ExpectNextMessageNotReady(), 30 | MixerWriteMidiBytes([SysCommonTuneRequest]), 31 | ExpectSystemExclusiveMessage([SysExStart, SysExEnd]), 32 | ExpectNextMessageNotReady(), 33 | ]) 34 | }) 35 | 36 | test('shortest', async () => { 37 | return TestMidiTokenizing([ 38 | MixerWriteMidiBytes([SysExStart]), 39 | ExpectNextMessageNotReady(), 40 | MixerWriteMidiBytes([SysExEnd]), 41 | ExpectSystemExclusiveMessage([SysExStart, SysExEnd]), 42 | MixerWriteMidiBytes([SysExStart]), 43 | ExpectNextMessageNotReady(), 44 | MixerWriteMidiBytes([0x80]), 45 | ExpectSystemExclusiveMessage([SysExStart, SysExEnd]), 46 | ]) 47 | }) 48 | 49 | test('system real time cuts line', async () => { 50 | return TestMidiTokenizing([ 51 | MixerWriteMidiBytes([SysExStart]), 52 | ExpectNextMessageNotReady(), 53 | MixerWriteMidiBytes([SysRTContinue, 0x23, 0x57]), 54 | ExpectSystemRealTimeMessage(SysRTContinue), 55 | ExpectNextMessageNotReady(), 56 | MixerWriteMidiBytes([0xf5]), 57 | ExpectSystemExclusiveMessage([SysExStart, 0x23, 0x57, SysExEnd]), 58 | ]) 59 | }) 60 | 61 | test('multiple system exclusive starts', async () => { 62 | // F0 F0 F0 F0 should be interpreted as [F0 F7] [F0 F7] 63 | return TestMidiTokenizing([ 64 | MixerWriteMidiBytes([SysExStart]), 65 | ExpectNextMessageNotReady(), 66 | MixerWriteMidiBytes([SysExStart]), 67 | ExpectSystemExclusiveMessage([SysExStart, SysExEnd]), 68 | MixerWriteMidiBytes([SysExStart, SysExStart, SysExStart]), 69 | ExpectSystemExclusiveMessage([SysExStart, SysExEnd]), 70 | ExpectNextMessageNotReady(), 71 | ]) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/actions/actions.ts: -------------------------------------------------------------------------------- 1 | import { type CompanionInputFieldDropdown } from '@companion-module/base' 2 | import { type ActionDefinitions, type ActionId } from './actionid.js' 3 | import { assignActions } from './assign.js' 4 | import { type Choices } from '../choices.js' 5 | import type { sqInstance } from '../instance.js' 6 | import { levelActions } from './level.js' 7 | import { type Mixer } from '../mixer/mixer.js' 8 | import { muteActions } from './mute.js' 9 | import { outputLevelActions, outputPanBalanceActions } from './output.js' 10 | import { panBalanceActions } from './pan-balance.js' 11 | import { sceneActions } from './scene.js' 12 | import { softKeyActions } from './softkey.js' 13 | 14 | /** 15 | * Get all action definitions exposed by this module. 16 | * 17 | * @param instance 18 | * The instance for which definitions are being generated. 19 | * @param mixer 20 | * The mixer in use by the instance. 21 | * @param choices 22 | * Option choices for use in the actions. 23 | * @returns 24 | * All actions defined by this module. 25 | */ 26 | export function getActions(instance: sqInstance, mixer: Mixer, choices: Choices): ActionDefinitions { 27 | const FadingOption: CompanionInputFieldDropdown = { 28 | type: 'dropdown', 29 | label: 'Fading', 30 | id: 'fade', 31 | default: 0, 32 | choices: [ 33 | { label: `Off`, id: 0 }, 34 | { label: `1s`, id: 1 }, 35 | { label: `2s`, id: 2 }, 36 | { label: `3s`, id: 3 }, 37 | //{label: `4s`, id: 4}, //added 38 | //{label: `5s`, id: 5}, //added 39 | //{label: `10s`, id: 10}, //added 40 | ], 41 | minChoicesForSearch: 0, 42 | } 43 | 44 | const LevelOption = { 45 | type: 'dropdown', 46 | label: 'Level', 47 | id: 'leveldb', 48 | default: 0, 49 | choices: choices.levels, 50 | minChoicesForSearch: 0, 51 | } as const 52 | 53 | const PanLevelOption = { 54 | type: 'dropdown', 55 | label: 'Level', 56 | id: 'leveldb', 57 | default: 'CTR', 58 | choices: choices.panLevels, 59 | minChoicesForSearch: 0, 60 | } as const 61 | 62 | return { 63 | ...muteActions(instance, mixer, choices), 64 | ...(() => { 65 | const rotaryActions = {} 66 | if (mixer.model.rotaryKeys > 0) { 67 | // Soft Rotary 68 | } else { 69 | // No Soft Rotary 70 | } 71 | return rotaryActions 72 | })(), 73 | ...softKeyActions(instance, mixer, choices), 74 | ...assignActions(instance, mixer, choices), 75 | ...levelActions(instance, mixer, choices, LevelOption, FadingOption), 76 | ...panBalanceActions(instance, mixer, choices, PanLevelOption), 77 | ...outputLevelActions(instance, mixer, choices, LevelOption, FadingOption), 78 | ...outputPanBalanceActions(instance, mixer, choices, PanLevelOption), 79 | ...sceneActions(instance, mixer), 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/midi/parse/__tests__/mixer-replies.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestParsing } from './test-parsing.js' 3 | import { 4 | ExpectNextCommandReadiness, 5 | ExpectMuteMessage, 6 | ExpectSceneMessage, 7 | ReceiveChannelMessage, 8 | } from './interactions.js' 9 | import { MuteOff, MuteOn, SceneCommand } from './commands.js' 10 | 11 | describe('reply processing', () => { 12 | test('basic reply series', async () => { 13 | return TestParsing(0, [ 14 | ExpectNextCommandReadiness(false), 15 | ...SceneCommand(0, 129).map(ReceiveChannelMessage), 16 | ExpectSceneMessage((1 << 7) + 1), 17 | // Mute on, Ip48 18 | ...MuteOn(0, 0x00, 0x2f).map(ReceiveChannelMessage), 19 | ExpectMuteMessage(0x00, 0x2f, 0x01), 20 | // Mute off, Aux1 21 | ...MuteOff(0, 0x00, 0x45).map(ReceiveChannelMessage), 22 | ExpectMuteMessage(0x00, 0x45, 0x00), 23 | ]) 24 | }) 25 | 26 | test('basic replies with extraneous CN 00 after scene change', async () => { 27 | return TestParsing(2, [ 28 | // Scene change 29 | ReceiveChannelMessage([0xb2, 0x00, 0x01]), 30 | ExpectNextCommandReadiness(false), 31 | ReceiveChannelMessage([0xc2, 0x01]), 32 | ExpectNextCommandReadiness(true), 33 | ExpectSceneMessage((1 << 7) + 1), 34 | ReceiveChannelMessage([0xc2, 0x00]), // extraneous but sent by SQ-5 35 | ExpectNextCommandReadiness(false), 36 | // Mute on, Ip48 37 | ...MuteOn(2, 0x00, 0x2f).map(ReceiveChannelMessage), 38 | ExpectMuteMessage(0x00, 0x2f, 0x01), 39 | // Mute off, Aux1 40 | ReceiveChannelMessage([0xb2, 0x63, 0x00]), 41 | ReceiveChannelMessage([0xb2, 0x62, 0x45]), 42 | ReceiveChannelMessage([0xb2, 0x06, 0x00]), 43 | ExpectNextCommandReadiness(false), 44 | ReceiveChannelMessage([0xb2, 0x26, 0x00]), 45 | ExpectNextCommandReadiness(true), 46 | ExpectMuteMessage(0x00, 0x45, 0x00), 47 | ]) 48 | }) 49 | 50 | test('basic replies with messages in different channels interspersed', async () => { 51 | return TestParsing(0, [ 52 | // Scene change 53 | ReceiveChannelMessage([0xb0, 0x00, 0x01]), 54 | ReceiveChannelMessage([0x97, 0x3c, 0x00]), // Channel 1, C-4, Note On (velocity 0) 55 | ReceiveChannelMessage([0xc0, 0x01]), 56 | ExpectSceneMessage((1 << 7) + 1), 57 | // Mute on, Ip48 58 | ReceiveChannelMessage([0xb0, 0x63, 0x00]), 59 | ReceiveChannelMessage([0xb0, 0x62, 0x2f]), 60 | ReceiveChannelMessage([0x97, 0x00, 0x7f]), // Channel 7, C-1, Note On 61 | ReceiveChannelMessage([0xb0, 0x06, 0x00]), 62 | ReceiveChannelMessage([0xb0, 0x26, 0x01]), 63 | ExpectMuteMessage(0x00, 0x2f, 0x01), 64 | // Mute off, Aux1 65 | ReceiveChannelMessage([0xb0, 0x63, 0x00]), 66 | ReceiveChannelMessage([0xb0, 0x62, 0x45]), 67 | ReceiveChannelMessage([0xb0, 0x06, 0x00]), 68 | ReceiveChannelMessage([0xb0, 0x26, 0x00]), 69 | ExpectMuteMessage(0x00, 0x45, 0x00), 70 | ]) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/midi/parse/__tests__/output-pan-level.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { PanLevel } from './commands.js' 3 | import { ExpectPanLevelMessage, ReceiveChannelMessage } from './interactions.js' 4 | import { TestParsing } from './test-parsing.js' 5 | 6 | describe('output pan/balance mixer commands', () => { 7 | test('lr', async () => { 8 | return TestParsing(7, [ 9 | // LR, L100% 10 | ...PanLevel(0x7, 0x5f, 0x00, 0x00, 0x00).map(ReceiveChannelMessage), 11 | ExpectPanLevelMessage(0x5f, 0x00, 0x00, 0x00), 12 | // LR, R100% 13 | ...PanLevel(0x7, 0x5f, 0x00, 0x7f, 0x7f).map(ReceiveChannelMessage), 14 | ExpectPanLevelMessage(0x5f, 0x00, 0x7f, 0x7f), 15 | // LR, CTR 16 | ...PanLevel(0x7, 0x5f, 0x00, 0x3f, 0x7f).map(ReceiveChannelMessage), 17 | ExpectPanLevelMessage(0x5f, 0x00, 0x3f, 0x7f), 18 | ]) 19 | }) 20 | 21 | test('mix 1', async () => { 22 | return TestParsing(3, [ 23 | // Mix 1, L100% 24 | ...PanLevel(0x3, 0x5f, 0x01, 0x00, 0x00).map(ReceiveChannelMessage), 25 | ExpectPanLevelMessage(0x5f, 0x01, 0x00, 0x00), 26 | // Mix 1, R100% 27 | ...PanLevel(0x3, 0x5f, 0x01, 0x7f, 0x7f).map(ReceiveChannelMessage), 28 | ExpectPanLevelMessage(0x5f, 0x01, 0x7f, 0x7f), 29 | // Mix 1, CTR 30 | ...PanLevel(0x3, 0x5f, 0x01, 0x3f, 0x7f).map(ReceiveChannelMessage), 31 | ExpectPanLevelMessage(0x5f, 0x01, 0x3f, 0x7f), 32 | ]) 33 | }) 34 | test('mix 12', async () => { 35 | return TestParsing(9, [ 36 | // Mix 12, L100% 37 | ...PanLevel(0x9, 0x5f, 0x0c, 0x00, 0x00).map(ReceiveChannelMessage), 38 | ExpectPanLevelMessage(0x5f, 0x0c, 0x00, 0x00), 39 | // Mix 12, R100% 40 | ...PanLevel(0x9, 0x5f, 0x0c, 0x7f, 0x7f).map(ReceiveChannelMessage), 41 | ExpectPanLevelMessage(0x5f, 0x0c, 0x7f, 0x7f), 42 | // Mix 12, CTR 43 | ...PanLevel(0x9, 0x5f, 0x0c, 0x3f, 0x7f).map(ReceiveChannelMessage), 44 | ExpectPanLevelMessage(0x5f, 0x0c, 0x3f, 0x7f), 45 | ]) 46 | }) 47 | 48 | test('matrix 1', async () => { 49 | return TestParsing(13, [ 50 | // Matrix 1, L100% 51 | ...PanLevel(0xd, 0x5f, 0x11, 0x00, 0x00).map(ReceiveChannelMessage), 52 | ExpectPanLevelMessage(0x5f, 0x11, 0x00, 0x00), 53 | // Matrix 1, R100% 54 | ...PanLevel(0xd, 0x5f, 0x11, 0x7f, 0x7f).map(ReceiveChannelMessage), 55 | ExpectPanLevelMessage(0x5f, 0x11, 0x7f, 0x7f), 56 | // Matrix 1, CTR 57 | ...PanLevel(0xd, 0x5f, 0x11, 0x3f, 0x7f).map(ReceiveChannelMessage), 58 | ExpectPanLevelMessage(0x5f, 0x11, 0x3f, 0x7f), 59 | ]) 60 | }) 61 | test('matrix 3', async () => { 62 | return TestParsing(2, [ 63 | // Matrix 3, L100% 64 | ...PanLevel(0x2, 0x5f, 0x13, 0x00, 0x00).map(ReceiveChannelMessage), 65 | ExpectPanLevelMessage(0x5f, 0x13, 0x00, 0x00), 66 | // Matrix 3, R100% 67 | ...PanLevel(0x2, 0x5f, 0x13, 0x7f, 0x7f).map(ReceiveChannelMessage), 68 | ExpectPanLevelMessage(0x5f, 0x13, 0x7f, 0x7f), 69 | // Matrix 3, CTR 70 | ...PanLevel(0x2, 0x5f, 0x13, 0x3f, 0x7f).map(ReceiveChannelMessage), 71 | ExpectPanLevelMessage(0x5f, 0x13, 0x3f, 0x7f), 72 | ]) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/midi/parse/parse-midi.ts: -------------------------------------------------------------------------------- 1 | import type { ChannelParser } from './channel-parser.js' 2 | import { prettyByte, prettyBytes } from '../../utils/pretty.js' 3 | import type { Tokenizer } from '../tokenize/tokenizer.js' 4 | 5 | /** 6 | * An SQ mixer message parser of all MIDI messages emitted by a MIDI tokenizer, 7 | * that forwards along messages in the mixer MIDI channel to a channel-specific 8 | * mixer channel parser. 9 | */ 10 | 11 | /** 12 | * Given a MIDI tokenizer and the channel in which mixer commands will 13 | * appear, run the tokenizer until tokenizing completes, forwarding all 14 | * tokenized MIDI Messages in the mixer channel for mixer command parsing. 15 | * 16 | * @param midiChannel 17 | * The MIDI channel where mixer commands will appear. (Messages in other 18 | * MIDI channels are currently ignored.) 19 | * @param verboseLog 20 | * A function that logs the supplied message when verbose logging is 21 | * enabled. 22 | * @param tokenizer 23 | * A MIDI message tokenizer that emits MIDI messages from an underlying 24 | * raw source (such as bytes read from a socket). 25 | * @param mixerChannelParser 26 | * A parser of MIDI messages in the `midiChannel` channel, that will be 27 | * notified with each received MIDI message in that channel. 28 | */ 29 | export async function parseMidi( 30 | midiChannel: number, 31 | verboseLog: (msg: string) => void, 32 | tokenizer: Tokenizer, 33 | mixerChannelParser: ChannelParser, 34 | ): Promise { 35 | tokenizer.on('channel_message', (message: number[]) => { 36 | const channel = message[0] & 0xf 37 | if (channel === midiChannel) { 38 | mixerChannelParser.handleMessage(message) 39 | } else { 40 | // If/when mixer MIDI strip commands are supported, they will be 41 | // found when `channel === ((midiChannel + 1) % 16)`. For now 42 | // they're simply ignored like all non-`midiChannel` MIDI 43 | // messages. 44 | verboseLog(`Ignoring Ch ${channel} message ${prettyBytes(message)}`) 45 | } 46 | }) 47 | tokenizer.on('system_common', (message: number[]) => { 48 | verboseLog(`Discarding system common message ${prettyBytes(message)}`) 49 | }) 50 | tokenizer.on('system_realtime', (b: number) => { 51 | verboseLog(`Discarding system real time message ${prettyByte(b)}`) 52 | }) 53 | tokenizer.on('system_exclusive', (message: number[]) => { 54 | // Buttons in the Utility>General>MIDI UI send these System Exclusive 55 | // messages: 56 | // 57 | // Back (⏪): F0 7F 7F 06 05 F7 58 | // Stop (⏹): F0 7F 7F 06 01 F7 59 | // Play (⏵): F0 7F 7F 06 02 F7 60 | // Pause (⏸): F0 7F 7F 06 09 F7 61 | // Rec/Arm (⏺): F0 7F 7F 06 06 F7 62 | // Fwd (⏩): F0 7F 7F 06 04 F7 63 | // 64 | // It's unclear whether this module can reasonably expose these messages 65 | // to users. But it seems worth noting them here as a possible avenue 66 | // for future improvements. 67 | verboseLog(`Discarding system exclusive message ${prettyBytes(message)}`) 68 | }) 69 | 70 | return tokenizer.run() 71 | } 72 | -------------------------------------------------------------------------------- /src/midi/parse/__tests__/mute.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestParsing } from './test-parsing.js' 3 | import { 4 | ExpectNextCommandReadiness, 5 | ExpectMuteMessage, 6 | ReceiveChannelMessage, 7 | ReceiveSystemRealTimeMessage, 8 | ReceiveSystemExclusiveMessage, 9 | } from './interactions.js' 10 | import { MuteOff, MuteOn } from './commands.js' 11 | import { SysExEnd, SysExMessageShortest, SysExStart, SysRTContinue, SysRTTimingClock } from '../../bytes.js' 12 | 13 | describe('mute commands', () => { 14 | test('mute on', async () => { 15 | return TestParsing(0, [ 16 | // Mute on, Ip48 17 | ReceiveChannelMessage([0xb0, 0x63, 0x00]), 18 | ReceiveChannelMessage([0xb0, 0x62, 0x48]), 19 | // Channel 2, C-1, Note on (DAW channel, i.e. 2 = one more than 1) 20 | ReceiveChannelMessage([0xc1, 0x00, 0x7f]), 21 | ReceiveSystemRealTimeMessage(SysRTTimingClock), 22 | ReceiveChannelMessage([0xb0, 0x06, 0x00]), 23 | ExpectNextCommandReadiness(false), 24 | ReceiveChannelMessage([0xb0, 0x26, 0x01]), 25 | ExpectMuteMessage(0x00, 0x48, 0x01), 26 | // abortive message, discarded 27 | ReceiveChannelMessage([0xb0, 0x63, 0x02]), 28 | // Mute off, DCA8 29 | ReceiveChannelMessage([0xb0, 0x63, 0x02]), 30 | ReceiveSystemRealTimeMessage(SysRTContinue), 31 | ReceiveChannelMessage([0xb0, 0x62, 0x07]), 32 | ReceiveChannelMessage([0xb0, 0x06, 0x00]), 33 | ExpectNextCommandReadiness(false), 34 | ReceiveChannelMessage([0xb0, 0x26, 0x00]), 35 | ExpectNextCommandReadiness(true), 36 | ReceiveSystemExclusiveMessage(SysExMessageShortest), 37 | ExpectMuteMessage(0x02, 0x07, 0x00), 38 | // Mute off, Aux3 39 | ...MuteOff(0, 0x00, 0x47).map(ReceiveChannelMessage), 40 | ExpectMuteMessage(0x00, 0x47, 0x00), 41 | ]) 42 | }) 43 | 44 | test('mute off', async () => { 45 | return TestParsing(3, [ 46 | // Mute on, Aux4 47 | ReceiveChannelMessage([0xb3, 0x63, 0x00]), 48 | ReceiveChannelMessage([0xb3, 0x62, 0x2f]), 49 | // Channel 1, C-1, Note on (DAW channel, i.e. 1 = one more than 0) 50 | ReceiveChannelMessage([0xc4, 0x00, 0x7f]), 51 | ReceiveChannelMessage([0xb3, 0x06, 0x00]), 52 | ReceiveSystemExclusiveMessage([SysExStart, SysExEnd]), 53 | ExpectNextCommandReadiness(false), 54 | ReceiveChannelMessage([0xb3, 0x26, 0x01]), 55 | ExpectMuteMessage(0x00, 0x2f, 0x01), 56 | // abortive message, discarded 57 | ReceiveChannelMessage([0xb0, 0x63, 0x02]), 58 | ReceiveChannelMessage([0xb0, 0x62, 0x07]), 59 | // Mute off, Ip48 60 | ReceiveChannelMessage([0xb3, 0x63, 0x00]), 61 | ReceiveSystemExclusiveMessage([SysExStart, 0x33, SysExEnd]), 62 | ReceiveChannelMessage([0xb3, 0x62, 0x2f]), 63 | ReceiveSystemRealTimeMessage(SysRTTimingClock), 64 | ReceiveChannelMessage([0xb3, 0x06, 0x00]), 65 | ExpectNextCommandReadiness(false), 66 | ReceiveChannelMessage([0xb3, 0x26, 0x00]), 67 | ExpectMuteMessage(0x00, 0x2f, 0x00), 68 | // Mute on, Aux1 69 | ...MuteOn(3, 0x00, 0x45).map(ReceiveChannelMessage), 70 | ExpectMuteMessage(0x00, 0x45, 0x01), 71 | ]) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/actions/softkey.ts: -------------------------------------------------------------------------------- 1 | import { type CompanionOptionValues } from '@companion-module/base' 2 | import { type ActionDefinitions } from './actionid.js' 3 | import { type Choices } from '../choices.js' 4 | import type { sqInstance } from '../instance.js' 5 | import { type Mixer } from '../mixer/mixer.js' 6 | import { type Model } from '../mixer/model.js' 7 | import { repr } from '../utils/pretty.js' 8 | 9 | /** Action IDs for all actions that operate softkeys. */ 10 | export enum SoftKeyId { 11 | SoftKey = 'key_soft', 12 | } 13 | 14 | enum SoftKeyOp { 15 | Toggle = '0', 16 | Press = '1', 17 | Release = '2', 18 | } 19 | 20 | type SoftKeyOptions = { 21 | softKey: number 22 | op: SoftKeyOp 23 | } 24 | 25 | function getSoftKeyOptions(instance: sqInstance, model: Model, options: CompanionOptionValues): SoftKeyOptions | null { 26 | const softKey = Number(options.softKey) 27 | if (model.softKeys <= softKey) { 28 | instance.log('error', `Attempting to operate invalid softkey ${softKey}, ignoring`) 29 | return null 30 | } 31 | 32 | const option = String(options.pressedsk) 33 | let op 34 | switch (option) { 35 | case '1': 36 | op = SoftKeyOp.Press 37 | break 38 | case '2': 39 | op = SoftKeyOp.Release 40 | break 41 | case '0': 42 | op = SoftKeyOp.Toggle 43 | break 44 | default: 45 | instance.log('error', `Bad softkey option value ${repr(option)}, ignoring`) 46 | return null 47 | } 48 | 49 | return { softKey, op } 50 | } 51 | 52 | /** 53 | * Generate action definitions for operating mixer softkeys. 54 | * 55 | * @param instance 56 | * The instance for which actions are being generated. 57 | * @param mixer 58 | * The mixer object to use when executing the actions. 59 | * @param choices 60 | * Option choices for use in the actions. 61 | * @returns 62 | * The set of all softkey action definitions. 63 | */ 64 | export function softKeyActions(instance: sqInstance, mixer: Mixer, choices: Choices): ActionDefinitions { 65 | const model = mixer.model 66 | 67 | return { 68 | [SoftKeyId.SoftKey]: { 69 | name: 'Press Softkey', 70 | options: [ 71 | { 72 | type: 'dropdown', 73 | label: 'Soft Key', 74 | id: 'softKey', 75 | default: 0, 76 | choices: choices.softKeys, 77 | minChoicesForSearch: 0, 78 | }, 79 | { 80 | type: 'dropdown', 81 | label: 'Key type', 82 | id: 'pressedsk', 83 | default: SoftKeyOp.Press, 84 | choices: [ 85 | { id: SoftKeyOp.Toggle, label: 'Toggle' }, 86 | { id: SoftKeyOp.Press, label: 'Press' }, 87 | { id: SoftKeyOp.Release, label: 'Release' }, 88 | ], 89 | minChoicesForSearch: 5, 90 | }, 91 | ], 92 | callback: async ({ options }) => { 93 | const opts = getSoftKeyOptions(instance, model, options) 94 | if (opts === null) { 95 | return 96 | } 97 | 98 | const { softKey, op } = opts 99 | switch (op) { 100 | case SoftKeyOp.Toggle: 101 | // XXX This is what the module historically did, but it 102 | // isn't actually toggling. Is there actually a way to 103 | // toggle? It doesn't look like there is... 104 | // eslint-disable-next-line no-fallthrough 105 | case SoftKeyOp.Press: { 106 | mixer.pressSoftKey(softKey) 107 | break 108 | } 109 | case SoftKeyOp.Release: { 110 | mixer.releaseSoftKey(softKey) 111 | break 112 | } 113 | } 114 | }, 115 | }, 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/actions/scene.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionMigrationAction } from '@companion-module/base' 2 | import { type Model } from '../mixer/model.js' 3 | import { type Mixer } from '../mixer/mixer.js' 4 | import { type ActionDefinitions } from './actionid.js' 5 | import type { sqInstance } from '../instance.js' 6 | import { type OptionValue } from './to-source-or-sink.js' 7 | import { repr } from '../utils/pretty.js' 8 | 9 | /** Action IDs for all actions that change the mixer's current scene. */ 10 | export enum SceneActionId { 11 | SceneRecall = 'scene_recall', 12 | SceneStep = 'scene_step', 13 | } 14 | 15 | /** 16 | * The action ID of an action whose implementation was identical to that of 17 | * `SceneActionId.SceneRecall` in every way, so all uses of it are upgraded to 18 | * that action in an upgrade script. 19 | */ 20 | const ObsoleteSetCurrentSceneId = 'current_scene' 21 | 22 | /** 23 | * This module once supported 'scene_recall' and 'current_scene' actions that 24 | * were exactly identical (other than in actionId and the name for each visible 25 | * in UI). Rewrite the latter sort of action to instead encode the former. 26 | */ 27 | export function tryCoalesceSceneRecallActions(action: CompanionMigrationAction): boolean { 28 | if (action.actionId !== ObsoleteSetCurrentSceneId) { 29 | return false 30 | } 31 | 32 | action.actionId = SceneActionId.SceneRecall 33 | return true 34 | } 35 | 36 | function toScene(instance: sqInstance, model: Model, sceneOption: OptionValue): number | null { 37 | const scene = Number(sceneOption) - 1 38 | if (0 <= scene && scene < model.scenes) { 39 | return scene 40 | } 41 | 42 | instance.log('error', `Attempting to recall invalid scene ${repr(sceneOption)}, ignoring`) 43 | return null 44 | } 45 | 46 | const StepMin = -50 47 | const StepMax = 50 48 | 49 | function toSceneStep(instance: sqInstance, stepOption: OptionValue): number | null { 50 | const step = Number(stepOption) 51 | if (StepMin <= step && step <= StepMax) { 52 | return step 53 | } 54 | 55 | instance.log('error', `Attempting to step an invalid amount ${repr(stepOption)}, ignoring`) 56 | return null 57 | } 58 | 59 | /** 60 | * Generate action definitions for modifying the mixer's current scene. 61 | * 62 | * @param instance 63 | * The instance for which actions are being generated. 64 | * @param mixer 65 | * The mixer object to use when executing the actions. 66 | * @returns 67 | * The set of all scene action definitions. 68 | */ 69 | export function sceneActions(instance: sqInstance, mixer: Mixer): ActionDefinitions { 70 | const model = mixer.model 71 | 72 | return { 73 | [SceneActionId.SceneRecall]: { 74 | name: 'Scene recall', 75 | options: [ 76 | { 77 | type: 'number', 78 | label: 'Scene nr.', 79 | id: 'scene', 80 | default: 1, 81 | min: 1, 82 | max: model.scenes, 83 | required: true, 84 | }, 85 | ], 86 | callback: async ({ options }) => { 87 | const scene = toScene(instance, model, options.scene) 88 | if (scene === null) { 89 | return 90 | } 91 | mixer.setScene(scene) 92 | }, 93 | }, 94 | 95 | [SceneActionId.SceneStep]: { 96 | name: 'Scene step', 97 | options: [ 98 | { 99 | type: 'number', 100 | label: 'Scene +/-', 101 | id: 'scene', 102 | default: 1, 103 | min: StepMin, 104 | max: StepMax, 105 | required: true, 106 | }, 107 | ], 108 | callback: async ({ options }) => { 109 | const adjust = toSceneStep(instance, options.scene) 110 | if (adjust === null) { 111 | return 112 | } 113 | mixer.stepSceneBy(adjust) 114 | }, 115 | }, 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/callback.ts: -------------------------------------------------------------------------------- 1 | export type CallbackInfoType = { 2 | mute: { 3 | readonly [key: `${number}:${number}`]: readonly [string, number] 4 | } 5 | } 6 | 7 | export const CallbackInfo = { 8 | mute: { 9 | '0:0': ['mute_input', 0], 10 | '0:1': ['mute_input', 1], 11 | '0:2': ['mute_input', 2], 12 | '0:3': ['mute_input', 3], 13 | '0:4': ['mute_input', 4], 14 | '0:5': ['mute_input', 5], 15 | '0:6': ['mute_input', 6], 16 | '0:7': ['mute_input', 7], 17 | '0:8': ['mute_input', 8], 18 | '0:9': ['mute_input', 9], 19 | '0:10': ['mute_input', 10], 20 | '0:11': ['mute_input', 11], 21 | '0:12': ['mute_input', 12], 22 | '0:13': ['mute_input', 13], 23 | '0:14': ['mute_input', 14], 24 | '0:15': ['mute_input', 15], 25 | '0:16': ['mute_input', 16], 26 | '0:17': ['mute_input', 17], 27 | '0:18': ['mute_input', 18], 28 | '0:19': ['mute_input', 19], 29 | '0:20': ['mute_input', 20], 30 | '0:21': ['mute_input', 21], 31 | '0:22': ['mute_input', 22], 32 | '0:23': ['mute_input', 23], 33 | '0:24': ['mute_input', 24], 34 | '0:25': ['mute_input', 25], 35 | '0:26': ['mute_input', 26], 36 | '0:27': ['mute_input', 27], 37 | '0:28': ['mute_input', 28], 38 | '0:29': ['mute_input', 29], 39 | '0:30': ['mute_input', 30], 40 | '0:31': ['mute_input', 31], 41 | '0:32': ['mute_input', 32], 42 | '0:33': ['mute_input', 33], 43 | '0:34': ['mute_input', 34], 44 | '0:35': ['mute_input', 35], 45 | '0:36': ['mute_input', 36], 46 | '0:37': ['mute_input', 37], 47 | '0:38': ['mute_input', 38], 48 | '0:39': ['mute_input', 39], 49 | '0:40': ['mute_input', 40], 50 | '0:41': ['mute_input', 41], 51 | '0:42': ['mute_input', 42], 52 | '0:43': ['mute_input', 43], 53 | '0:44': ['mute_input', 44], 54 | '0:45': ['mute_input', 45], 55 | '0:46': ['mute_input', 46], 56 | '0:47': ['mute_input', 47], 57 | '0:48': ['mute_group', 0], 58 | '0:49': ['mute_group', 1], 59 | '0:50': ['mute_group', 2], 60 | '0:51': ['mute_group', 3], 61 | '0:52': ['mute_group', 4], 62 | '0:53': ['mute_group', 5], 63 | '0:54': ['mute_group', 6], 64 | '0:55': ['mute_group', 7], 65 | '0:56': ['mute_group', 8], 66 | '0:57': ['mute_group', 9], 67 | '0:58': ['mute_group', 10], 68 | '0:59': ['mute_group', 11], 69 | '0:60': ['mute_fx_return', 0], 70 | '0:61': ['mute_fx_return', 1], 71 | '0:62': ['mute_fx_return', 2], 72 | '0:63': ['mute_fx_return', 3], 73 | '0:64': ['mute_fx_return', 4], 74 | '0:65': ['mute_fx_return', 5], 75 | '0:66': ['mute_fx_return', 6], 76 | '0:67': ['mute_fx_return', 7], 77 | '0:68': ['mute_lr', 0], 78 | '0:69': ['mute_aux', 0], 79 | '0:70': ['mute_aux', 1], 80 | '0:71': ['mute_aux', 2], 81 | '0:72': ['mute_aux', 3], 82 | '0:73': ['mute_aux', 4], 83 | '0:74': ['mute_aux', 5], 84 | '0:75': ['mute_aux', 6], 85 | '0:76': ['mute_aux', 7], 86 | '0:77': ['mute_aux', 8], 87 | '0:78': ['mute_aux', 9], 88 | '0:79': ['mute_aux', 10], 89 | '0:80': ['mute_aux', 11], 90 | '0:81': ['mute_fx_send', 0], 91 | '0:82': ['mute_fx_send', 1], 92 | '0:83': ['mute_fx_send', 2], 93 | '0:84': ['mute_fx_send', 3], 94 | '0:85': ['mute_matrix', 0], 95 | '0:86': ['mute_matrix', 1], 96 | '0:87': ['mute_matrix', 2], 97 | '2:0': ['mute_dca', 0], 98 | '2:1': ['mute_dca', 1], 99 | '2:2': ['mute_dca', 2], 100 | '2:3': ['mute_dca', 3], 101 | '2:4': ['mute_dca', 4], 102 | '2:5': ['mute_dca', 5], 103 | '2:6': ['mute_dca', 6], 104 | '2:7': ['mute_dca', 7], 105 | '4:0': ['mute_mutegroup', 0], 106 | '4:1': ['mute_mutegroup', 1], 107 | '4:2': ['mute_mutegroup', 2], 108 | '4:3': ['mute_mutegroup', 3], 109 | '4:4': ['mute_mutegroup', 4], 110 | '4:5': ['mute_mutegroup', 5], 111 | '4:6': ['mute_mutegroup', 6], 112 | '4:7': ['mute_mutegroup', 7], 113 | }, 114 | } satisfies CallbackInfoType 115 | -------------------------------------------------------------------------------- /src/midi/tokenize/__tests__/grab-bag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestMidiTokenizing } from './midi-tokenizing.js' 3 | import { SysExStart, SysExEnd, SysRTTimingClock, SysCommonSongPosMessage, SysCommonSongPosition } from '../../bytes.js' 4 | import { 5 | ExpectChannelMessage, 6 | ExpectNextMessageNotReady, 7 | ExpectSystemCommonMessage, 8 | ExpectSystemExclusiveMessage, 9 | ExpectSystemRealTimeMessage, 10 | MixerWriteMidiBytes, 11 | } from './interactions.js' 12 | 13 | describe('parse MIDI data', () => { 14 | test('single CC', async () => { 15 | return TestMidiTokenizing([ 16 | // (comment to force to separate lines) 17 | MixerWriteMidiBytes([0xb0, 0x00, 0x0f]), 18 | ExpectChannelMessage([0xb0, 0x00, 0x0f]), 19 | ]) 20 | }) 21 | 22 | test('two CCs', async () => { 23 | return TestMidiTokenizing([ 24 | MixerWriteMidiBytes([0xb0, 0x00, 0x0f]), 25 | ExpectChannelMessage([0xb0, 0x00, 0x0f]), 26 | MixerWriteMidiBytes([0xb7, 0x22, 0x7f]), 27 | ExpectChannelMessage([0xb7, 0x22, 0x7f]), 28 | ]) 29 | }) 30 | 31 | test('scene change', async () => { 32 | return TestMidiTokenizing([ 33 | MixerWriteMidiBytes([0xb3, 0x00, 0x01, 0xc3]), 34 | ExpectChannelMessage([0xb3, 0x00, 0x01]), 35 | ExpectNextMessageNotReady(), 36 | MixerWriteMidiBytes([0x03]), 37 | ExpectChannelMessage([0xc3, 0x03]), 38 | ExpectNextMessageNotReady(), 39 | ]) 40 | }) 41 | 42 | test('scene change broken up by system common', async () => { 43 | return TestMidiTokenizing([ 44 | MixerWriteMidiBytes([0xb7, 0x00, 0x01, ...SysCommonSongPosMessage, 0xc7]), 45 | ExpectChannelMessage([0xb7, 0x00, 0x01]), 46 | ExpectSystemCommonMessage(SysCommonSongPosMessage), 47 | ExpectNextMessageNotReady(), 48 | MixerWriteMidiBytes([0x03]), 49 | ExpectChannelMessage([0xc7, 0x03]), 50 | ]) 51 | }) 52 | 53 | test('scene change broken up by incomplete system common', async () => { 54 | return TestMidiTokenizing([ 55 | MixerWriteMidiBytes([0xb7, 0x00, 0x01, SysCommonSongPosition, 0x03, 0xc7]), 56 | ExpectChannelMessage([0xb7, 0x00, 0x01]), 57 | MixerWriteMidiBytes([0x03]), 58 | ExpectChannelMessage([0xc7, 0x03]), 59 | ]) 60 | }) 61 | 62 | test('scene change w/embedded system real time', async () => { 63 | return TestMidiTokenizing([ 64 | MixerWriteMidiBytes([0xb6, 0x00, SysRTTimingClock, 0x02]), 65 | ExpectSystemRealTimeMessage(SysRTTimingClock), 66 | ExpectChannelMessage([0xb6, 0x00, 0x02]), 67 | MixerWriteMidiBytes([0xc6, 0x03]), 68 | ExpectChannelMessage([0xc6, 0x03]), 69 | ]) 70 | }) 71 | 72 | test('scene change (as sent by SQ5 with extra data byte, generating an extra message)', async () => { 73 | return TestMidiTokenizing([ 74 | MixerWriteMidiBytes([0xb2, 0x00, 0x01, 0xc2, 0x00, 0x01]), 75 | ExpectChannelMessage([0xb2, 0x00, 0x01]), 76 | ExpectChannelMessage([0xc2, 0x00]), 77 | ExpectChannelMessage([0xc2, 0x01]), 78 | ]) 79 | }) 80 | 81 | test('sysex then CC', async () => { 82 | return TestMidiTokenizing([ 83 | MixerWriteMidiBytes([SysExStart, 0x00, SysExEnd]), 84 | MixerWriteMidiBytes([0xb2, 0x00, 0x01]), 85 | ExpectSystemExclusiveMessage([SysExStart, 0x00, SysExEnd]), 86 | ExpectChannelMessage([0xb2, 0x00, 0x01]), 87 | ]) 88 | }) 89 | 90 | test("sysex terminated by status doesn't start that status", async () => { 91 | return TestMidiTokenizing([ 92 | MixerWriteMidiBytes([0xb0, 0x00]), // incomplete, ignored 93 | MixerWriteMidiBytes([0xb1, 0x12, 0x34]), // new CC 94 | MixerWriteMidiBytes([0x35]), // running status... 95 | MixerWriteMidiBytes([SysExStart, 0x00, 0xc0]), // but preempted by sysex ended by C0 96 | MixerWriteMidiBytes([0xc3, 0x03, 0x00]), // two PCs 97 | ExpectChannelMessage([0xb1, 0x12, 0x34]), 98 | ExpectSystemExclusiveMessage([SysExStart, 0x00, SysExEnd]), 99 | ExpectChannelMessage([0xc3, 0x03]), 100 | ExpectChannelMessage([0xc3, 0x00]), 101 | ]) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /src/actions/level.test.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionMigrationAction } from '@companion-module/base' 2 | import { describe, expect, test } from 'vitest' 3 | import { LevelActionId, tryUpgradeLevelMixOrLREncoding } from './level.js' 4 | 5 | function makeObsoleteLevelAction(actionId: LevelActionId, input: number, assign: number[]): CompanionMigrationAction { 6 | return { 7 | id: 'abcOdOefghiOFjBkGHlJm', 8 | controlId: '1/0/0', 9 | actionId, 10 | options: { 11 | input, 12 | assign, 13 | leveldb: 0, // 0dB 14 | fade: 1, // 1s 15 | }, 16 | } satisfies CompanionMigrationAction 17 | } 18 | 19 | describe("upgrade mix=99 to mix='lr' in level actions", () => { 20 | test('unaffected', () => { 21 | const action = makeObsoleteLevelAction(LevelActionId.InputChannelLevelInFXSend, 2, [1]) 22 | 23 | expect(tryUpgradeLevelMixOrLREncoding(action)).toBe(false) 24 | expect(action.options).toEqual({ 25 | input: 2, 26 | assign: [1], 27 | leveldb: 0, 28 | fade: 1, 29 | }) 30 | }) 31 | 32 | describe('inputChannel in mix/lr', () => { 33 | test('not lr sink', () => { 34 | const notLRSink = makeObsoleteLevelAction(LevelActionId.InputChannelLevelInMixOrLR, 3, [2]) 35 | 36 | expect(tryUpgradeLevelMixOrLREncoding(notLRSink)).toBe(false) 37 | expect(notLRSink.options).toEqual({ 38 | input: 3, 39 | assign: [2], 40 | leveldb: 0, 41 | fade: 1, 42 | }) 43 | }) 44 | 45 | test('lr sink', () => { 46 | const lrSink = makeObsoleteLevelAction(LevelActionId.InputChannelLevelInMixOrLR, 5, [6, 99]) 47 | 48 | expect(tryUpgradeLevelMixOrLREncoding(lrSink)).toBe(true) 49 | expect(lrSink.options).toEqual({ 50 | input: 5, 51 | assign: [6, 'lr'], 52 | leveldb: 0, 53 | fade: 1, 54 | }) 55 | }) 56 | }) 57 | 58 | describe('group in mix/lr', () => { 59 | test('not lr sink', () => { 60 | const notLRSink = makeObsoleteLevelAction(LevelActionId.GroupLevelInMixOrLR, 3, [2]) 61 | 62 | expect(tryUpgradeLevelMixOrLREncoding(notLRSink)).toBe(false) 63 | expect(notLRSink.options).toEqual({ 64 | input: 3, 65 | assign: [2], 66 | leveldb: 0, 67 | fade: 1, 68 | }) 69 | }) 70 | 71 | test('lr sink', () => { 72 | const lrSink = makeObsoleteLevelAction(LevelActionId.GroupLevelInMixOrLR, 2, [99, 7]) 73 | 74 | expect(tryUpgradeLevelMixOrLREncoding(lrSink)).toBe(true) 75 | expect(lrSink.options).toEqual({ 76 | input: 2, 77 | assign: ['lr', 7], 78 | leveldb: 0, 79 | fade: 1, 80 | }) 81 | }) 82 | }) 83 | 84 | describe('fxReturn in mix/lr', () => { 85 | test('not lr sink', () => { 86 | const notLRSink = makeObsoleteLevelAction(LevelActionId.FXReturnLevelInMixOrLR, 1, [5]) 87 | 88 | expect(tryUpgradeLevelMixOrLREncoding(notLRSink)).toBe(false) 89 | expect(notLRSink.options).toEqual({ 90 | input: 1, 91 | assign: [5], 92 | leveldb: 0, 93 | fade: 1, 94 | }) 95 | }) 96 | 97 | test('lr sink', () => { 98 | const lrSink = makeObsoleteLevelAction(LevelActionId.FXReturnLevelInMixOrLR, 3, [6, 99]) 99 | 100 | expect(tryUpgradeLevelMixOrLREncoding(lrSink)).toBe(true) 101 | expect(lrSink.options).toEqual({ 102 | input: 3, 103 | assign: [6, 'lr'], 104 | leveldb: 0, 105 | fade: 1, 106 | }) 107 | }) 108 | }) 109 | 110 | describe('mix/lr in matrix', () => { 111 | test('not lr source', () => { 112 | const notLRSource = makeObsoleteLevelAction(LevelActionId.MixOrLRLevelInMatrix, 1, [5]) 113 | 114 | expect(tryUpgradeLevelMixOrLREncoding(notLRSource)).toBe(false) 115 | expect(notLRSource.options).toEqual({ 116 | input: 1, 117 | assign: [5], 118 | leveldb: 0, 119 | fade: 1, 120 | }) 121 | }) 122 | 123 | test('lr source', () => { 124 | const lrSource = makeObsoleteLevelAction(LevelActionId.MixOrLRLevelInMatrix, 99, [3, 0]) 125 | 126 | expect(tryUpgradeLevelMixOrLREncoding(lrSource)).toBe(true) 127 | expect(lrSource.options).toEqual({ 128 | input: 'lr', 129 | assign: [3, 0], 130 | leveldb: 0, 131 | fade: 1, 132 | }) 133 | }) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /src/actions/fading.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionOptionValues, DropdownChoice } from '@companion-module/base' 2 | import type { sqInstance } from '../instance.js' 3 | import type { Level } from '../mixer/level.js' 4 | import { type NRPN, splitNRPN } from '../mixer/nrpn/nrpn.js' 5 | import { repr } from '../utils/pretty.js' 6 | 7 | /** Compute the set of level options for level-setting actions. */ 8 | export function createLevels(): DropdownChoice[] { 9 | const levels: DropdownChoice[] = [] 10 | levels.push( 11 | { label: `Last dB value`, id: 1000 }, 12 | { label: `Step +0.1 dB`, id: 'step+0.1' }, //added 13 | { label: `Step +1 dB`, id: 'step+1' }, 14 | { label: `Step +3 dB`, id: 'step+3' }, //added 15 | { label: `Step +6 dB`, id: 'step+6' }, //added 16 | { label: `Step -0.1 dB`, id: 'step-0.1' }, //added 17 | { label: `Step -1 dB`, id: 'step-1' }, 18 | { label: `Step -3 dB`, id: 'step-3' }, //added 19 | { label: `Step -6 dB`, id: 'step-6' }, 20 | ) 21 | for (let i = -90; i <= -40; i = i + 5) { 22 | const id = i === -90 ? '-inf' : i 23 | levels.push({ label: `${i} dB`, id }) 24 | } 25 | for (let i = -39; i <= -10; i = i + 1) { 26 | levels.push({ label: `${i} dB`, id: i }) 27 | } 28 | for (let i = -9.5; i <= 10; i = i + 0.5) { 29 | levels.push({ label: `${i} dB`, id: i }) 30 | } 31 | return levels 32 | } 33 | 34 | type FadeParameters = { 35 | start: Level 36 | end: Level 37 | fadeTimeMs: number 38 | } 39 | 40 | const MsPerSecond = 1000 41 | 42 | /** 43 | * Get the start, end, and duration of a fade of the given NRPN. 44 | * 45 | * @param instance 46 | * The instance in use. 47 | * @param options 48 | * Options specified for the action. 49 | * @param nrpn 50 | * The NRPN. 51 | * @returns 52 | * Information about the requested fade. 53 | */ 54 | export function getFadeParameters( 55 | instance: sqInstance, 56 | options: CompanionOptionValues, 57 | nrpn: NRPN<'level'>, 58 | ): FadeParameters | null { 59 | // Presets that incidentally invoke this function didn't always specify a 60 | // fade time, so treat a missing fade as zero to support them. 61 | const fade = options.fade 62 | const fadeTimeMs = fade === undefined ? 0 : Number(fade) * MsPerSecond 63 | if (!(fadeTimeMs >= 0)) { 64 | instance.log('error', `Bad fade time ${fadeTimeMs} milliseconds, aborting`) 65 | return null 66 | } 67 | 68 | // XXX It should be possible to eliminate the fallibility and range/type 69 | // errors by not storing the previous level in a barely-typed 70 | // variable. 71 | let start: Level 72 | { 73 | const { MSB, LSB } = splitNRPN(nrpn) 74 | const levelValue = instance.getVariableValue(`level_${MSB}.${LSB}`) 75 | switch (typeof levelValue) { 76 | case 'string': 77 | if (levelValue !== '-inf') { 78 | instance.log('error', `Bad start level: ${levelValue}`) 79 | return null 80 | } 81 | start = '-inf' 82 | break 83 | case 'number': 84 | if (!(-90 < levelValue && levelValue <= 10)) { 85 | instance.log('error', `Bad start level: ${levelValue}`) 86 | return null 87 | } 88 | start = levelValue 89 | break 90 | default: 91 | instance.log('error', `Bad start level`) 92 | return null 93 | } 94 | } 95 | 96 | let end: Level 97 | const levelOption = options.leveldb 98 | if (typeof levelOption === 'number' && -90 < levelOption && levelOption <= 10) { 99 | end = levelOption 100 | } else if (levelOption === '-inf') { 101 | end = '-inf' 102 | } else if (levelOption === 1000) { 103 | end = start 104 | } else if (typeof levelOption === 'string' && levelOption.startsWith('step')) { 105 | const stepAmount = Number(levelOption.slice(4)) 106 | if (Number.isNaN(stepAmount)) { 107 | instance.log('error', `Unexpected step amount: ${repr(levelOption)}`) 108 | return null 109 | } 110 | 111 | const endLevel = (start === '-inf' ? -90 : start) + stepAmount 112 | if (endLevel <= -90) { 113 | end = '-inf' 114 | } else if (10 <= endLevel) { 115 | end = 10 116 | } else { 117 | end = endLevel 118 | } 119 | } else { 120 | instance.log('error', `Bad level request: ${repr(levelOption)}`) 121 | return null 122 | } 123 | 124 | return { 125 | start, 126 | end, 127 | fadeTimeMs, 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/actions/pan-balance.test.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionMigrationAction } from '@companion-module/base' 2 | import { describe, expect, test } from 'vitest' 3 | import { PanBalanceActionId, tryUpgradePanBalanceMixOrLREncoding } from './pan-balance.js' 4 | 5 | function makeObsoletePanBalanceAction( 6 | actionId: PanBalanceActionId, 7 | source: number, 8 | sink: number, 9 | ): CompanionMigrationAction { 10 | return { 11 | id: 'abcOdOefghiOFjBkGHlJm', 12 | controlId: '1/0/0', 13 | actionId, 14 | options: { 15 | input: source, 16 | assign: sink, 17 | leveldb: 'CTR', 18 | showvar: '', 19 | }, 20 | } 21 | } 22 | 23 | describe("upgrade mix=99 to mix='lr' in pan/balance actions", () => { 24 | test('unaffected', () => { 25 | const action = makeObsoletePanBalanceAction(PanBalanceActionId.GroupPanBalanceInMatrix, 2, 1) 26 | 27 | expect(tryUpgradePanBalanceMixOrLREncoding(action)).toBe(false) 28 | expect(action.options).toEqual({ 29 | input: 2, 30 | assign: 1, 31 | leveldb: 'CTR', 32 | showvar: '', 33 | }) 34 | }) 35 | 36 | describe('inputChannel in mix/lr', () => { 37 | test('not lr sink', () => { 38 | const notLRSink = makeObsoletePanBalanceAction(PanBalanceActionId.InputChannelPanBalanceInMixOrLR, 17, 5) 39 | 40 | expect(tryUpgradePanBalanceMixOrLREncoding(notLRSink)).toBe(false) 41 | expect(notLRSink.options).toEqual({ 42 | input: 17, 43 | assign: 5, 44 | leveldb: 'CTR', 45 | showvar: '', 46 | }) 47 | }) 48 | 49 | test('lr sink', () => { 50 | const lrSink = makeObsoletePanBalanceAction(PanBalanceActionId.InputChannelPanBalanceInMixOrLR, 13, 99) 51 | 52 | expect(tryUpgradePanBalanceMixOrLREncoding(lrSink)).toBe(true) 53 | expect(lrSink.options).toEqual({ 54 | input: 13, 55 | assign: 'lr', 56 | leveldb: 'CTR', 57 | showvar: '', 58 | }) 59 | }) 60 | }) 61 | 62 | describe('group in mix/lr', () => { 63 | test('not lr sink', () => { 64 | const notLRSink = makeObsoletePanBalanceAction(PanBalanceActionId.GroupPanBalanceInMixOrLR, 1, 9) 65 | 66 | expect(tryUpgradePanBalanceMixOrLREncoding(notLRSink)).toBe(false) 67 | expect(notLRSink.options).toEqual({ 68 | input: 1, 69 | assign: 9, 70 | leveldb: 'CTR', 71 | showvar: '', 72 | }) 73 | }) 74 | 75 | test('lr sink', () => { 76 | const lrSink = makeObsoletePanBalanceAction(PanBalanceActionId.InputChannelPanBalanceInMixOrLR, 0, 99) 77 | 78 | expect(tryUpgradePanBalanceMixOrLREncoding(lrSink)).toBe(true) 79 | expect(lrSink.options).toEqual({ 80 | input: 0, 81 | assign: 'lr', 82 | leveldb: 'CTR', 83 | showvar: '', 84 | }) 85 | }) 86 | }) 87 | 88 | describe('fxr in mix/lr', () => { 89 | test('not lr sink', () => { 90 | const notLRSink = makeObsoletePanBalanceAction(PanBalanceActionId.FXReturnPanBalanceInMixOrLR, 3, 6) 91 | 92 | expect(tryUpgradePanBalanceMixOrLREncoding(notLRSink)).toBe(false) 93 | expect(notLRSink.options).toEqual({ 94 | input: 3, 95 | assign: 6, 96 | leveldb: 'CTR', 97 | showvar: '', 98 | }) 99 | }) 100 | 101 | test('lr sink', () => { 102 | const lrSink = makeObsoletePanBalanceAction(PanBalanceActionId.InputChannelPanBalanceInMixOrLR, 2, 99) 103 | 104 | expect(tryUpgradePanBalanceMixOrLREncoding(lrSink)).toBe(true) 105 | expect(lrSink.options).toEqual({ 106 | input: 2, 107 | assign: 'lr', 108 | leveldb: 'CTR', 109 | showvar: '', 110 | }) 111 | }) 112 | }) 113 | 114 | describe('mix/lr in matrix', () => { 115 | test('not lr source', () => { 116 | const notLRSource = makeObsoletePanBalanceAction(PanBalanceActionId.MixOrLRPanBalanceInMatrix, 5, 1) 117 | 118 | expect(tryUpgradePanBalanceMixOrLREncoding(notLRSource)).toBe(false) 119 | expect(notLRSource.options).toEqual({ 120 | input: 5, 121 | assign: 1, 122 | leveldb: 'CTR', 123 | showvar: '', 124 | }) 125 | }) 126 | 127 | test('lr source', () => { 128 | const lrSource = makeObsoletePanBalanceAction(PanBalanceActionId.MixOrLRPanBalanceInMatrix, 99, 2) 129 | 130 | expect(tryUpgradePanBalanceMixOrLREncoding(lrSource)).toBe(true) 131 | expect(lrSource.options).toEqual({ 132 | input: 'lr', 133 | assign: 2, 134 | leveldb: 'CTR', 135 | showvar: '', 136 | }) 137 | }) 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /src/mixer/lr.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionMigrationAction, CompanionOptionValues } from '@companion-module/base' 2 | import { tryUpgradeAssignMixOrLREncoding } from '../actions/assign.js' 3 | import { tryUpgradeLevelMixOrLREncoding } from '../actions/level.js' 4 | import { tryUpgradePanBalanceMixOrLREncoding } from '../actions/pan-balance.js' 5 | 6 | /** 7 | * The value of `LR` before it was changed to the constant string `'lr'`. This 8 | * value also identified the LR mix in any option defining a mix or LR, 9 | * requiring an upgrade script be used to convert to the more readable and 10 | * type-safe `'lr'`. 11 | */ 12 | const ObsoleteLREncoding = 99 13 | 14 | /** 15 | * The value of the LR mix, in any interface that accepts either a mix (0 16 | * through 11 if there exist mixes 1 to 12) or LR. 17 | */ 18 | export const LR = 'lr' 19 | 20 | /** A value specifying either the LR mix or a numbered mix. */ 21 | export type MixOrLR = number | typeof LR 22 | 23 | type OptionArrayElement = Extract, any[]>[0] 24 | 25 | const isLRMixAndNeedsUpgrade = (mixOrLR: OptionArrayElement) => Number(mixOrLR) === ObsoleteLREncoding 26 | 27 | /** 28 | * Try to upgrade the given action's option of `optionId` from a mix-or-LR array 29 | * containing an obsolete encoding of the LR mix as the number 99, to its 30 | * current encoding as a constant string. 31 | * 32 | * @param action 33 | * The action to potentially upgrade. 34 | * @param optionId 35 | * The id of the option on the action that might contain an obsolete LR 36 | * encoding. The option is expected to be an array of mixes (potentially 37 | * including LR), which is to say an array of numbers either `99` for the LR 38 | * mix or `[0, N)` for `N` possible mixes. 39 | * @returns 40 | */ 41 | export function tryUpgradeMixOrLRArrayEncoding(action: CompanionMigrationAction, optionId: string): boolean { 42 | const arrayOption = action.options[optionId] 43 | if (!Array.isArray(arrayOption)) { 44 | return false 45 | } 46 | 47 | const index = arrayOption.findIndex(isLRMixAndNeedsUpgrade) 48 | if (index < 0) { 49 | return false 50 | } 51 | 52 | for (let i = index; i < arrayOption.length; i++) { 53 | if (isLRMixAndNeedsUpgrade(arrayOption[i])) { 54 | arrayOption[i] = LR 55 | } 56 | } 57 | 58 | return true 59 | } 60 | 61 | /** 62 | * Try to upgrade the given action's option of `optionId` to rewrite an obsolete 63 | * encoding of the LR mix. 64 | * 65 | * @param action 66 | * The action to potentially upgrade. 67 | * @param optionId 68 | * The id of the option on the action that specifies a mix or LR. The option 69 | * is expected to convert to number 99 if identifying LR, or to `[0, N)` if 70 | * identifying a mix. 71 | * @returns 72 | * True if the mix-or-LR was rewritten. 73 | */ 74 | export function tryUpgradeMixOrLROptionEncoding(action: CompanionMigrationAction, optionId: string): boolean { 75 | const { options } = action 76 | if (Number(options[optionId]) !== ObsoleteLREncoding) { 77 | return false 78 | } 79 | 80 | options[optionId] = LR 81 | return true 82 | } 83 | 84 | /** 85 | * Historically, many actions that specified "mix or LR" as their source or sink 86 | * or standalone signal identified the LR mix using the value 99. All non-LR 87 | * mixes were identified as `[0, N)`. This made it fairly easy to confuse the 88 | * two if you weren't careful (especially before the module was converted to 89 | * TypeScript). 90 | * 91 | * To address this problem and to make "mix or LR" be a union of two types for 92 | * mixes and LR, LR was changed from `99` to `'lr'`. 93 | * 94 | * Update the encoding of LR in all actions to its new encoding. 95 | * 96 | * @param action 97 | * The action to consider rewriting. 98 | * @returns 99 | * The action if any options containing the obsolete encoding of LR were 100 | * encountered. 101 | */ 102 | export function tryUpdateAllLRMixEncodings(action: CompanionMigrationAction): boolean { 103 | // Every encoding of LR must be changed all at once, so perform the separate 104 | // partial upgrades together in one combined upgrade script. 105 | return ( 106 | tryUpgradeAssignMixOrLREncoding(action) || 107 | tryUpgradeLevelMixOrLREncoding(action) || 108 | tryUpgradePanBalanceMixOrLREncoding(action) 109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /src/mixer/nrpn/mute.test.ts: -------------------------------------------------------------------------------- 1 | import type { Equal, Expect } from 'type-testing' 2 | import { describe, expect, test } from 'vitest' 3 | import { type InputOutputType, Model } from '../model.js' 4 | import { calculateMuteNRPN } from './mute.js' 5 | import { splitNRPN, type UnbrandedParam } from './nrpn.js' 6 | 7 | type test_AllMuteTypes = Expect[1], InputOutputType>> 8 | 9 | describe('calculateMuteNRPN', () => { 10 | const model = new Model('SQ5') 11 | 12 | type InputOutputBehavior = { type: 'ok'; result: UnbrandedParam } | { type: 'error'; match: RegExp | string } 13 | 14 | type InputOutputTests = { 15 | [n: number]: InputOutputBehavior 16 | } 17 | const allInputsOutputs: Record = { 18 | inputChannel: { 19 | 0: { type: 'ok', result: { MSB: 0x00, LSB: 0x00 } }, 20 | 1: { type: 'ok', result: { MSB: 0x00, LSB: 0x01 } }, 21 | 17: { type: 'ok', result: { MSB: 0x00, LSB: 0x11 } }, 22 | 47: { type: 'ok', result: { MSB: 0x00, LSB: 0x2f } }, 23 | 48: { type: 'error', match: 'inputChannel=48 is invalid' }, 24 | }, 25 | group: { 26 | 0: { type: 'ok', result: { MSB: 0x00, LSB: 0x30 } }, 27 | 1: { type: 'ok', result: { MSB: 0x00, LSB: 0x31 } }, 28 | 5: { type: 'ok', result: { MSB: 0x00, LSB: 0x35 } }, 29 | 11: { type: 'ok', result: { MSB: 0x00, LSB: 0x3b } }, 30 | 12: { type: 'error', match: 'group=12 is invalid' }, 31 | }, 32 | mix: { 33 | 0: { type: 'ok', result: { MSB: 0x00, LSB: 0x45 } }, 34 | 1: { type: 'ok', result: { MSB: 0x00, LSB: 0x46 } }, 35 | 5: { type: 'ok', result: { MSB: 0x00, LSB: 0x4a } }, 36 | 11: { type: 'ok', result: { MSB: 0x00, LSB: 0x50 } }, 37 | 12: { type: 'error', match: 'mix=12 is invalid' }, 38 | }, 39 | lr: { 40 | 0: { type: 'ok', result: { MSB: 0x00, LSB: 0x44 } }, 41 | 1: { type: 'error', match: 'lr=1 is invalid' }, 42 | }, 43 | matrix: { 44 | 0: { type: 'ok', result: { MSB: 0x00, LSB: 0x55 } }, 45 | 1: { type: 'ok', result: { MSB: 0x00, LSB: 0x56 } }, 46 | 2: { type: 'ok', result: { MSB: 0x00, LSB: 0x57 } }, 47 | 3: { type: 'error', match: 'matrix=3 is invalid' }, 48 | }, 49 | muteGroup: { 50 | 0: { type: 'ok', result: { MSB: 0x04, LSB: 0x00 } }, 51 | 1: { type: 'ok', result: { MSB: 0x04, LSB: 0x01 } }, 52 | 5: { type: 'ok', result: { MSB: 0x04, LSB: 0x05 } }, 53 | 7: { type: 'ok', result: { MSB: 0x04, LSB: 0x07 } }, 54 | 8: { type: 'error', match: 'muteGroup=8 is invalid' }, 55 | }, 56 | fxReturn: { 57 | 0: { type: 'ok', result: { MSB: 0x00, LSB: 0x3c } }, 58 | 1: { type: 'ok', result: { MSB: 0x00, LSB: 0x3d } }, 59 | 5: { type: 'ok', result: { MSB: 0x00, LSB: 0x41 } }, 60 | 7: { type: 'ok', result: { MSB: 0x00, LSB: 0x43 } }, 61 | 8: { type: 'error', match: 'fxReturn=8 is invalid' }, 62 | }, 63 | fxSend: { 64 | 0: { type: 'ok', result: { MSB: 0x00, LSB: 0x51 } }, 65 | 1: { type: 'ok', result: { MSB: 0x00, LSB: 0x52 } }, 66 | 2: { type: 'ok', result: { MSB: 0x00, LSB: 0x53 } }, 67 | 3: { type: 'ok', result: { MSB: 0x00, LSB: 0x54 } }, 68 | 4: { type: 'error', match: 'fxSend=4 is invalid' }, 69 | }, 70 | dca: { 71 | 0: { type: 'ok', result: { MSB: 0x02, LSB: 0x00 } }, 72 | 1: { type: 'ok', result: { MSB: 0x02, LSB: 0x01 } }, 73 | 5: { type: 'ok', result: { MSB: 0x02, LSB: 0x05 } }, 74 | 7: { type: 'ok', result: { MSB: 0x02, LSB: 0x07 } }, 75 | 8: { type: 'error', match: 'dca=8 is invalid' }, 76 | }, 77 | } 78 | 79 | function* allMuteTests(): Generator<{ 80 | type: InputOutputType 81 | n: number 82 | behavior: InputOutputBehavior 83 | }> { 84 | for (const key in allInputsOutputs) { 85 | const type = key as InputOutputType 86 | const tests = allInputsOutputs[type] 87 | for (const [num, behavior] of Object.entries(tests)) { 88 | const n = Number(num) 89 | yield { type, n, behavior } 90 | } 91 | } 92 | } 93 | 94 | test.each([...allMuteTests()])('calculateMuteNRPN(model, $type, $n)', ({ type, n, behavior }) => { 95 | switch (behavior.type) { 96 | case 'ok': 97 | expect(splitNRPN(calculateMuteNRPN(model, type, n))).toEqual(behavior.result) 98 | break 99 | case 'error': 100 | expect(() => calculateMuteNRPN(model, type, n)).toThrow(behavior.match) 101 | break 102 | default: 103 | expect('missing').toBe('case') 104 | } 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /src/config.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { 3 | type SQInstanceConfig, 4 | tryEnsureLabelInConfig, 5 | tryEnsureModelOptionInConfig, 6 | tryRemoveUnnecessaryLabelInConfig, 7 | } from './config.js' 8 | 9 | describe('config upgrade to specify a missing model', () => { 10 | test('config without model', () => { 11 | const configMissingModel: SQInstanceConfig = { 12 | host: '127.0.0.1', 13 | level: 'LinearTaper', 14 | talkback: 0, 15 | midich: 0, 16 | status: 'full', 17 | label: 'SQ', 18 | verbose: false, 19 | } 20 | 21 | expect('model' in configMissingModel).toBe(false) 22 | 23 | expect(tryEnsureModelOptionInConfig(configMissingModel)).toBe(true) 24 | 25 | expect('model' in configMissingModel).toBe(true) 26 | expect(configMissingModel.model).toBe('SQ5') 27 | }) 28 | 29 | test("config with model='SQ5'", () => { 30 | const configWithModelSQ5: SQInstanceConfig = { 31 | host: '127.0.0.1', 32 | model: 'SQ5', 33 | level: 'LinearTaper', 34 | talkback: 0, 35 | midich: 0, 36 | status: 'full', 37 | label: 'SQ', 38 | verbose: false, 39 | } 40 | 41 | expect('model' in configWithModelSQ5).toBe(true) 42 | expect(configWithModelSQ5.model).toBe('SQ5') 43 | 44 | expect(tryEnsureModelOptionInConfig(configWithModelSQ5)).toBe(false) 45 | 46 | expect('model' in configWithModelSQ5).toBe(true) 47 | expect(configWithModelSQ5.model).toBe('SQ5') 48 | }) 49 | 50 | test("config with model='SQ7'", () => { 51 | const configWithModelSQ7: SQInstanceConfig = { 52 | host: '127.0.0.1', 53 | model: 'SQ7', 54 | level: 'LinearTaper', 55 | talkback: 0, 56 | midich: 0, 57 | status: 'full', 58 | label: 'SQ', 59 | verbose: false, 60 | } 61 | 62 | expect('model' in configWithModelSQ7).toBe(true) 63 | expect(configWithModelSQ7.model).toBe('SQ7') 64 | 65 | expect(tryEnsureModelOptionInConfig(configWithModelSQ7)).toBe(false) 66 | 67 | expect('model' in configWithModelSQ7).toBe(true) 68 | expect(configWithModelSQ7.model).toBe('SQ7') 69 | }) 70 | }) 71 | 72 | describe('config upgrade to specify a missing label', () => { 73 | test('config without label', () => { 74 | const configMissingLabel: SQInstanceConfig = { 75 | host: '127.0.0.1', 76 | model: 'SQ5', 77 | level: 'LinearTaper', 78 | talkback: 0, 79 | midich: 0, 80 | status: 'full', 81 | verbose: false, 82 | } 83 | 84 | expect('label' in configMissingLabel).toBe(false) 85 | 86 | expect(tryEnsureLabelInConfig(configMissingLabel)).toBe(true) 87 | 88 | expect('label' in configMissingLabel).toBe(true) 89 | expect(configMissingLabel.label).toBe('SQ') 90 | }) 91 | 92 | test("config with label='sq5'", () => { 93 | const configWithLabelSQ5: SQInstanceConfig = { 94 | host: '127.0.0.1', 95 | model: 'SQ5', 96 | level: 'LinearTaper', 97 | talkback: 0, 98 | midich: 0, 99 | status: 'full', 100 | label: 'sq5', 101 | verbose: false, 102 | } 103 | 104 | expect('label' in configWithLabelSQ5).toBe(true) 105 | expect(configWithLabelSQ5.label).toBe('sq5') 106 | 107 | expect(tryEnsureLabelInConfig(configWithLabelSQ5)).toBe(false) 108 | 109 | expect('label' in configWithLabelSQ5).toBe(true) 110 | expect(configWithLabelSQ5.label).toBe('sq5') 111 | }) 112 | }) 113 | 114 | describe('config upgrade to remove an unnecessary label', () => { 115 | test('config without label', () => { 116 | const configMissingLabel: SQInstanceConfig = { 117 | host: '127.0.0.1', 118 | model: 'SQ5', 119 | level: 'LinearTaper', 120 | talkback: 0, 121 | midich: 0, 122 | status: 'full', 123 | verbose: false, 124 | } 125 | 126 | expect('label' in configMissingLabel).toBe(false) 127 | 128 | expect(tryRemoveUnnecessaryLabelInConfig(configMissingLabel)).toBe(false) 129 | 130 | expect('label' in configMissingLabel).toBe(false) 131 | }) 132 | 133 | test('config with label', () => { 134 | const configWithLabel: SQInstanceConfig = { 135 | host: '127.0.0.1', 136 | model: 'SQ5', 137 | level: 'LinearTaper', 138 | talkback: 0, 139 | midich: 0, 140 | status: 'full', 141 | verbose: false, 142 | label: 'SQ', 143 | } 144 | 145 | expect('label' in configWithLabel).toBe(true) 146 | 147 | expect(tryRemoveUnnecessaryLabelInConfig(configWithLabel)).toBe(true) 148 | 149 | expect('label' in configWithLabel).toBe(false) 150 | }) 151 | }) 152 | -------------------------------------------------------------------------------- /src/midi/parse/__tests__/commands.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compute the sequence of MIDI messages that correspond to an SQ scene recall 3 | * command. 4 | * 5 | * @param channel 6 | * The MIDI channel the scene recall should be sent on. 7 | * @param scene 8 | * The scene to request. (Note that this is zero-indexed, so on an SQ-5 with 9 | * scenes 1-300 this will be `[0, 300)`.) 10 | * @returns 11 | * An array of the MIDI messages that constitute the requested scene recall. 12 | */ 13 | export function SceneCommand(channel: number, scene: number): [[number, number, number], [number, number]] { 14 | return [ 15 | [0xb0 | channel, 0x00, scene >> 7], 16 | [0xc0 | channel, scene & 0x7f], 17 | ] 18 | } 19 | 20 | /** 21 | * The type of an NRPN data entry sequence, consisting of an array of four MIDI 22 | * Control Change message arrays as its elements. 23 | */ 24 | type NRPNData = [[number, number, number], [number, number, number], [number, number, number], [number, number, number]] 25 | 26 | /** 27 | * Generate an NRPN data entry sequence. 28 | * 29 | * @param channel 30 | * The MIDI channel of the NRPN data entry sequence. 31 | * @param msb 32 | * The intended NRPN MSB. 33 | * @param lsb 34 | * The intended NRPN LSB. 35 | * @param vc 36 | * The velocity (coarse) byte in the message. 37 | * @param vf 38 | * The velocity (fine) byte in the message. 39 | */ 40 | function nrpnData(channel: number, msb: number, lsb: number, vc: number, vf: number): NRPNData { 41 | return [ 42 | [0xb0 | channel, 0x63, msb], 43 | [0xb0 | channel, 0x62, lsb], 44 | [0xb0 | channel, 0x06, vc], 45 | [0xb0 | channel, 0x26, vf], 46 | ] 47 | } 48 | 49 | /** 50 | * Generate a mute on/off message sequence. 51 | * 52 | * @param channel 53 | * The MIDI channel of the NRPN data entry sequence. 54 | * @param msb 55 | * The intended NRPN MSB. 56 | * @param lsb 57 | * The intended NRPN LSB. 58 | * @param on 59 | * True to turn the mute on, false to turn it off. 60 | */ 61 | function mute( 62 | channel: number, 63 | msb: number, 64 | lsb: number, 65 | on: boolean, 66 | ): [[number, number, number], [number, number, number], [number, number, number], [number, number, number]] { 67 | return nrpnData(channel, msb, lsb, 0, on ? 1 : 0) 68 | } 69 | 70 | /** 71 | * Generate a mute on message sequence. 72 | * 73 | * @param channel 74 | * The MIDI channel of the NRPN data entry sequence. 75 | * @param msb 76 | * The intended NRPN MSB. 77 | * @param lsb 78 | * The intended NRPN LSB. 79 | */ 80 | export function MuteOn( 81 | channel: number, 82 | msb: number, 83 | lsb: number, 84 | ): [[number, number, number], [number, number, number], [number, number, number], [number, number, number]] { 85 | return mute(channel, msb, lsb, true) 86 | } 87 | 88 | /** 89 | * Generate a mute off message sequence. 90 | * 91 | * @param channel 92 | * The MIDI channel of the NRPN data entry sequence. 93 | * @param msb 94 | * The intended NRPN MSB. 95 | * @param lsb 96 | * The intended NRPN LSB. 97 | */ 98 | export function MuteOff( 99 | channel: number, 100 | msb: number, 101 | lsb: number, 102 | ): [[number, number, number], [number, number, number], [number, number, number], [number, number, number]] { 103 | return mute(channel, msb, lsb, false) 104 | } 105 | 106 | /** 107 | * Generate a fader level-setting message sequence. 108 | * 109 | * @param channel 110 | * The MIDI channel of the NRPN data entry sequence. 111 | * @param msb 112 | * The intended NRPN MSB. 113 | * @param lsb 114 | * The intended NRPN LSB. 115 | * @param vc 116 | * The velocity (coarse) byte encoding half of the intended fader level. 117 | * @param vf 118 | * The velocity (fine) byte encoding half of the intended fader level. 119 | */ 120 | export function FaderLevel(channel: number, msb: number, lsb: number, vc: number, vf: number): NRPNData { 121 | return nrpnData(channel, msb, lsb, vc, vf) 122 | } 123 | 124 | /** 125 | * Generate a source/sink pan/balance-setting message sequence. 126 | * 127 | * @param channel 128 | * The MIDI channel of the NRPN data entry sequence. 129 | * @param msb 130 | * The intended NRPN MSB. 131 | * @param lsb 132 | * The intended NRPN LSB. 133 | * @param vc 134 | * The velocity (coarse) byte encoding half of the intended pan/balance level. 135 | * @param vf 136 | * The velocity (fine) byte encoding half of the intended pan/balance level. 137 | * @returns 138 | */ 139 | export function PanLevel(channel: number, msb: number, lsb: number, vc: number, vf: number): NRPNData { 140 | return nrpnData(channel, msb, lsb, vc, vf) 141 | } 142 | -------------------------------------------------------------------------------- /src/actions/assign.test.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionMigrationAction, CompanionOptionValues } from '@companion-module/base' 2 | import { describe, expect, test } from 'vitest' 3 | import { AssignActionId, tryUpgradeAssignMixOrLREncoding } from './assign.js' 4 | 5 | function makeObsoleteAssignAction(actionId: AssignActionId, options: CompanionOptionValues): CompanionMigrationAction { 6 | return { 7 | id: 'abcOdOefghiOFjBkGHlJm', 8 | controlId: '1/0/0', 9 | actionId, 10 | options, 11 | } satisfies CompanionMigrationAction 12 | } 13 | 14 | describe("upgrade mix=99 to mix='lr' in assign actions", () => { 15 | test('unaffected', () => { 16 | const action = makeObsoleteAssignAction(AssignActionId.InputChannelToFXSend, { 17 | inputChannel: 2, 18 | fxsAssign: 1, 19 | fxsActive: true, 20 | }) 21 | 22 | expect(tryUpgradeAssignMixOrLREncoding(action)).toBe(false) 23 | expect(action.options).toEqual({ 24 | inputChannel: 2, 25 | fxsAssign: 1, 26 | fxsActive: true, 27 | }) 28 | }) 29 | 30 | describe('mix/lr to matrix', () => { 31 | test('not lr source', () => { 32 | const notLRSource = makeObsoleteAssignAction(AssignActionId.MixToMatrix, { 33 | inputMix: 3, 34 | mtxAssign: [2], 35 | mtxActive: false, 36 | }) 37 | 38 | expect(tryUpgradeAssignMixOrLREncoding(notLRSource)).toBe(false) 39 | expect(notLRSource.options).toEqual({ 40 | inputMix: 3, 41 | mtxAssign: [2], 42 | mtxActive: false, 43 | }) 44 | }) 45 | 46 | test('lr source', () => { 47 | const lrSource = makeObsoleteAssignAction(AssignActionId.MixToMatrix, { 48 | inputMix: 99, 49 | mtxAssign: [2, 3], 50 | mtxActive: true, 51 | }) 52 | 53 | expect(tryUpgradeAssignMixOrLREncoding(lrSource)).toBe(true) 54 | expect(lrSource.options).toEqual({ 55 | inputMix: 'lr', 56 | mtxAssign: [2, 3], 57 | mtxActive: true, 58 | }) 59 | }) 60 | }) 61 | 62 | describe('inputChannel to mix/lr', () => { 63 | test('not lr sink', () => { 64 | const notLRSink = makeObsoleteAssignAction(AssignActionId.InputChannelToMix, { 65 | inputChannel: 3, 66 | mixAssign: [2], 67 | mixActive: false, 68 | }) 69 | 70 | expect(tryUpgradeAssignMixOrLREncoding(notLRSink)).toBe(false) 71 | expect(notLRSink.options).toEqual({ 72 | inputChannel: 3, 73 | mixAssign: [2], 74 | mixActive: false, 75 | }) 76 | }) 77 | 78 | test('lr sink', () => { 79 | const lrSink = makeObsoleteAssignAction(AssignActionId.InputChannelToMix, { 80 | inputChannel: 3, 81 | mixAssign: [2, 99, 0], 82 | mixActive: false, 83 | }) 84 | 85 | expect(tryUpgradeAssignMixOrLREncoding(lrSink)).toBe(true) 86 | expect(lrSink.options).toEqual({ 87 | inputChannel: 3, 88 | mixAssign: [2, 'lr', 0], 89 | mixActive: false, 90 | }) 91 | }) 92 | }) 93 | 94 | describe('group to mix/lr', () => { 95 | test('not lr sink', () => { 96 | const notLRSink = makeObsoleteAssignAction(AssignActionId.GroupToMix, { 97 | inputGrp: 3, 98 | mixAssign: [2], 99 | mixActive: false, 100 | }) 101 | 102 | expect(tryUpgradeAssignMixOrLREncoding(notLRSink)).toBe(false) 103 | expect(notLRSink.options).toEqual({ 104 | inputGrp: 3, 105 | mixAssign: [2], 106 | mixActive: false, 107 | }) 108 | }) 109 | 110 | test('lr sink', () => { 111 | const lrSink = makeObsoleteAssignAction(AssignActionId.GroupToMix, { 112 | inputGrp: 3, 113 | mixAssign: [2, 0, 99], 114 | mixActive: false, 115 | }) 116 | 117 | expect(tryUpgradeAssignMixOrLREncoding(lrSink)).toBe(true) 118 | expect(lrSink.options).toEqual({ 119 | inputGrp: 3, 120 | mixAssign: [2, 0, 'lr'], 121 | mixActive: false, 122 | }) 123 | }) 124 | }) 125 | 126 | describe('fxr to mix/lr', () => { 127 | test('not lr sink', () => { 128 | const notLRSink = makeObsoleteAssignAction(AssignActionId.FXReturnToMix, { 129 | inputFxr: 3, 130 | mixAssign: [2], 131 | mixActive: false, 132 | }) 133 | 134 | expect(tryUpgradeAssignMixOrLREncoding(notLRSink)).toBe(false) 135 | expect(notLRSink.options).toEqual({ 136 | inputFxr: 3, 137 | mixAssign: [2], 138 | mixActive: false, 139 | }) 140 | }) 141 | 142 | test('lr sink', () => { 143 | const lrSink = makeObsoleteAssignAction(AssignActionId.FXReturnToMix, { 144 | inputFxr: 3, 145 | mixAssign: [99, 0, 3], 146 | mixActive: true, 147 | }) 148 | 149 | expect(tryUpgradeAssignMixOrLREncoding(lrSink)).toBe(true) 150 | expect(lrSink.options).toEqual({ 151 | inputFxr: 3, 152 | mixAssign: ['lr', 0, 3], 153 | mixActive: true, 154 | }) 155 | }) 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /src/midi/parse/__tests__/interactions.ts: -------------------------------------------------------------------------------- 1 | import { makeNRPN } from '../../../mixer/nrpn/nrpn.js' 2 | 3 | type ReceiveChannel = { 4 | readonly type: 'receive-channel-message' 5 | readonly message: readonly number[] 6 | } 7 | 8 | type ReceiveSystemCommon = { 9 | readonly type: 'receive-system-common-message' 10 | readonly message: readonly number[] 11 | } 12 | 13 | type ReceiveSystemExclusive = { 14 | readonly type: 'receive-system-exclusive-message' 15 | readonly message: readonly number[] 16 | } 17 | 18 | type ReceiveSystemRealTime = { 19 | readonly type: 'receive-system-real-time' 20 | readonly message: number 21 | } 22 | 23 | type NextCommandReadiness = { 24 | readonly type: 'command-readiness' 25 | ready: boolean 26 | } 27 | 28 | type ExpectScene = { 29 | readonly type: 'expect-scene' 30 | readonly args: readonly number[] 31 | } 32 | 33 | type ExpectMute = { 34 | readonly type: 'expect-mute' 35 | readonly args: readonly number[] 36 | } 37 | 38 | type ExpectFaderLevel = { 39 | readonly type: 'expect-fader-level' 40 | readonly args: readonly number[] 41 | } 42 | 43 | type ExpectPanLevel = { 44 | readonly type: 'expect-pan-level' 45 | readonly args: readonly number[] 46 | } 47 | 48 | export type ReceiveInteraction = ReceiveChannel | ReceiveSystemCommon | ReceiveSystemExclusive | ReceiveSystemRealTime 49 | 50 | export type ExpectInteraction = ExpectScene | ExpectMute | ExpectFaderLevel | ExpectPanLevel 51 | 52 | export type Interaction = ReceiveInteraction | NextCommandReadiness | ExpectInteraction 53 | 54 | /** Receive the given MIDI channel message. */ 55 | export function ReceiveChannelMessage(message: readonly number[]): ReceiveChannel { 56 | return { type: 'receive-channel-message', message } 57 | } 58 | 59 | /** Receive the given MIDI system common message. */ 60 | export function ReceiveSystemCommonMessage(message: readonly number[]): ReceiveSystemCommon { 61 | return { type: 'receive-system-common-message', message } 62 | } 63 | 64 | /** Receive the given MIDI system exclusive message. */ 65 | export function ReceiveSystemExclusiveMessage(message: readonly number[]): ReceiveSystemExclusive { 66 | return { type: 'receive-system-exclusive-message', message } 67 | } 68 | 69 | /** Receive the given system real time single-byte message. */ 70 | export function ReceiveSystemRealTimeMessage(message: number): ReceiveSystemRealTime { 71 | return { type: 'receive-system-real-time', message } 72 | } 73 | 74 | /** Expect that the next mixer command is ready/not ready. */ 75 | export function ExpectNextCommandReadiness(ready: boolean): NextCommandReadiness { 76 | return { type: 'command-readiness', ready } 77 | } 78 | 79 | /** 80 | * Expect that the next mixer command is a mixer scene recall command specifying 81 | * the given scene. 82 | * 83 | * @param scene 84 | * The zero-indexed scene (i.e. `[0, 300)` for SQ mixer scenes 1-300). 85 | */ 86 | export function ExpectSceneMessage(scene: number): ExpectScene { 87 | return { type: 'expect-scene', args: [scene] } 88 | } 89 | 90 | /** 91 | * Expect that the next mixer command is a mixer mute on/off command. 92 | * 93 | * @param msb 94 | * The expected MSB byte. 95 | * @param lsb 96 | * The expected LSB byte. 97 | * @param vf 98 | * The velocity (fine) byte in the message. (The velocity [coarse] byte in a 99 | * mute message is always zero.) 100 | */ 101 | export function ExpectMuteMessage(msb: number, lsb: number, vf: number): ExpectMute { 102 | if (!(vf === 0 || vf === 1)) { 103 | throw new Error(`vf=${vf} in a mixer mute command must be 0 or 1`) 104 | } 105 | return { type: 'expect-mute', args: [makeNRPN(msb, lsb), vf] } 106 | } 107 | 108 | /** 109 | * Expect that the next mixer command is a fader level-setting command. 110 | * 111 | * @param msb 112 | * The expected MSB byte. 113 | * @param lsb 114 | * The expected LSB byte. 115 | * @param vc 116 | * The velocity (coarse) byte in the message. 117 | * @param vf 118 | * The velocity (fine) byte in the message. 119 | */ 120 | export function ExpectFaderLevelMessage(msb: number, lsb: number, vc: number, vf: number): ExpectFaderLevel { 121 | return { type: 'expect-fader-level', args: [makeNRPN(msb, lsb), vc, vf] } 122 | } 123 | 124 | /** 125 | * Expect that the next mixer command is a pan/balance level-setting command. 126 | * 127 | * @param msb 128 | * The expected MSB byte. 129 | * @param lsb 130 | * The expected LSB byte. 131 | * @param vc 132 | * The velocity (coarse) byte in the message. 133 | * @param vf 134 | * The velocity (fine) byte in the message. 135 | */ 136 | export function ExpectPanLevelMessage(msb: number, lsb: number, vc: number, vf: number): ExpectPanLevel { 137 | return { type: 'expect-pan-level', args: [makeNRPN(msb, lsb), vc, vf] } 138 | } 139 | -------------------------------------------------------------------------------- /src/midi/tokenize/__tests__/channel-messages.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestMidiTokenizing } from './midi-tokenizing.js' 3 | import { SysRTContinue } from '../../bytes.js' 4 | import { 5 | ExpectChannelMessage, 6 | ExpectNextMessageNotReady, 7 | ExpectSystemRealTimeMessage, 8 | MixerWriteMidiBytes, 9 | } from './interactions.js' 10 | 11 | describe('channel messages (2 data bytes)', () => { 12 | test('note off', async () => { 13 | return TestMidiTokenizing([ 14 | MixerWriteMidiBytes([0x80, 0x90]), 15 | ExpectNextMessageNotReady(), 16 | MixerWriteMidiBytes([0x80]), 17 | ExpectNextMessageNotReady(), 18 | MixerWriteMidiBytes([SysRTContinue]), 19 | ExpectSystemRealTimeMessage(SysRTContinue), 20 | MixerWriteMidiBytes([0x25]), 21 | ExpectNextMessageNotReady(), 22 | MixerWriteMidiBytes([0x37]), 23 | ExpectChannelMessage([0x80, 0x25, 0x37]), 24 | MixerWriteMidiBytes([0x22, 0x66]), 25 | ExpectChannelMessage([0x80, 0x22, 0x66]), 26 | ]) 27 | }) 28 | 29 | test('note on', async () => { 30 | return TestMidiTokenizing([ 31 | MixerWriteMidiBytes([0x93, 0x80]), 32 | ExpectNextMessageNotReady(), 33 | MixerWriteMidiBytes([0x92]), 34 | ExpectNextMessageNotReady(), 35 | MixerWriteMidiBytes([SysRTContinue]), 36 | ExpectSystemRealTimeMessage(SysRTContinue), 37 | MixerWriteMidiBytes([0x25]), 38 | ExpectNextMessageNotReady(), 39 | MixerWriteMidiBytes([0x19]), 40 | ExpectChannelMessage([0x92, 0x25, 0x19]), 41 | MixerWriteMidiBytes([0x37, 0x55]), 42 | ExpectChannelMessage([0x92, 0x37, 0x55]), 43 | ]) 44 | }) 45 | 46 | test('polyphonic pressure', async () => { 47 | return TestMidiTokenizing([ 48 | // (comment to force to separate lines) 49 | MixerWriteMidiBytes([0xa7, 0x80]), 50 | ExpectNextMessageNotReady(), 51 | MixerWriteMidiBytes([0xa6]), 52 | ExpectNextMessageNotReady(), 53 | MixerWriteMidiBytes([SysRTContinue]), 54 | ExpectSystemRealTimeMessage(SysRTContinue), 55 | MixerWriteMidiBytes([0x25]), 56 | ExpectNextMessageNotReady(), 57 | MixerWriteMidiBytes([0x19]), 58 | ExpectChannelMessage([0xa6, 0x25, 0x19]), 59 | MixerWriteMidiBytes([0x37, 0x55]), 60 | ExpectChannelMessage([0xa6, 0x37, 0x55]), 61 | ]) 62 | }) 63 | 64 | test('control change', async () => { 65 | return TestMidiTokenizing([ 66 | MixerWriteMidiBytes([0xb7, 0x80]), 67 | ExpectNextMessageNotReady(), 68 | MixerWriteMidiBytes([0xb2]), 69 | ExpectNextMessageNotReady(), 70 | MixerWriteMidiBytes([SysRTContinue]), 71 | ExpectSystemRealTimeMessage(SysRTContinue), 72 | MixerWriteMidiBytes([0x25]), 73 | ExpectNextMessageNotReady(), 74 | MixerWriteMidiBytes([0x19]), 75 | ExpectChannelMessage([0xb2, 0x25, 0x19]), 76 | MixerWriteMidiBytes([0x37, 0x55]), 77 | ExpectChannelMessage([0xb2, 0x37, 0x55]), 78 | ]) 79 | }) 80 | 81 | test('pitch bend', async () => { 82 | return TestMidiTokenizing([ 83 | MixerWriteMidiBytes([0xe3, 0x80]), 84 | ExpectNextMessageNotReady(), 85 | MixerWriteMidiBytes([0xe2]), 86 | ExpectNextMessageNotReady(), 87 | MixerWriteMidiBytes([SysRTContinue]), 88 | ExpectSystemRealTimeMessage(SysRTContinue), 89 | MixerWriteMidiBytes([0x25]), 90 | ExpectNextMessageNotReady(), 91 | MixerWriteMidiBytes([0x19]), 92 | ExpectChannelMessage([0xe2, 0x25, 0x19]), 93 | MixerWriteMidiBytes([0x37, 0x55]), 94 | ExpectChannelMessage([0xe2, 0x37, 0x55]), 95 | ]) 96 | }) 97 | }) 98 | 99 | describe('channel messages (1 data byte)', () => { 100 | test('program change', async () => { 101 | return TestMidiTokenizing([ 102 | MixerWriteMidiBytes([0xc0, 0x90]), 103 | ExpectNextMessageNotReady(), 104 | MixerWriteMidiBytes([0xc0]), 105 | ExpectNextMessageNotReady(), 106 | MixerWriteMidiBytes([SysRTContinue]), 107 | ExpectSystemRealTimeMessage(SysRTContinue), 108 | MixerWriteMidiBytes([0x25]), 109 | ExpectChannelMessage([0xc0, 0x25]), 110 | ExpectNextMessageNotReady(), 111 | MixerWriteMidiBytes([0x37]), 112 | ExpectChannelMessage([0xc0, 0x37]), 113 | MixerWriteMidiBytes([0x22, 0xc6, 0x66]), 114 | ExpectChannelMessage([0xc0, 0x22]), 115 | ExpectChannelMessage([0xc6, 0x66]), 116 | ]) 117 | }) 118 | 119 | test('channel pressure', async () => { 120 | return TestMidiTokenizing([ 121 | MixerWriteMidiBytes([0xd6, 0x90]), 122 | ExpectNextMessageNotReady(), 123 | MixerWriteMidiBytes([0xd1]), 124 | ExpectNextMessageNotReady(), 125 | MixerWriteMidiBytes([SysRTContinue]), 126 | ExpectSystemRealTimeMessage(SysRTContinue), 127 | MixerWriteMidiBytes([0x25]), 128 | ExpectChannelMessage([0xd1, 0x25]), 129 | ExpectNextMessageNotReady(), 130 | MixerWriteMidiBytes([0x37]), 131 | ExpectChannelMessage([0xd1, 0x37]), 132 | MixerWriteMidiBytes([0x22, 0xd6, 0x73]), 133 | ExpectChannelMessage([0xd1, 0x22]), 134 | ExpectChannelMessage([0xd6, 0x73]), 135 | ]) 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /src/mixer/level.ts: -------------------------------------------------------------------------------- 1 | import { assertNever } from '@companion-module/base' 2 | import { type FaderLaw } from './mixer.js' 3 | 4 | /** 5 | * A mixer fader level. `'-inf'` is -∞. Otherwise the level is in range 6 | * `(-90, 10]` directly encoding a dB value. 7 | */ 8 | export type Level = '-inf' | number 9 | 10 | /** 11 | * Convert a `VC`/`VF` data byte pair from a fader level message to a 12 | * `Level` value, interpreting `vc`/`vf` consistent with `faderLaw`. 13 | */ 14 | export function levelFromNRPNData(vc: number, vf: number, faderLaw: FaderLaw): Level { 15 | switch (faderLaw) { 16 | case 'LinearTaper': { 17 | // Linear Taper has a high-resolution `2**(7 + 7) === 16384` level 18 | // values. 19 | const data = (vc << 7) | vf 20 | 21 | // The SQ MIDI Protocol document gives example values and their 22 | // meanings that (on random spot-checking) fairly closely correspond 23 | // to this line, but it doesn't give this formula. Past revision 24 | // history offers no explanation, either -- and the formula's been 25 | // tweaked over time, e.g. the divisor used to be 119 before 26 | // 8c09283326d2db3ba46a286a5024d9a89f065b9b. 27 | // 28 | // https://community.allen-heath.com/forums/topic/db-to-linear-taper-sum 29 | // says this was invented by observation and experimentation. Given 30 | // it spot-checks, we keep using the formula while hoping an 31 | // explanation (or better formula) can be added in the future. 32 | const level = parseFloat(((data - 15196) / 118.775).toFixed(1)) 33 | 34 | return level < -89 ? '-inf' : 10 < level ? 10 : level 35 | } 36 | 37 | case 'AudioTaper': { 38 | if (!(0 <= vc && vc <= 127)) { 39 | throw new Error(`Unexpected out-of-range VC=${vc}`) 40 | } 41 | 42 | // Audio Taper has 255 possible values according to the SQ MIDI 43 | // Protocol document. That document doesn't give a tidy formula to 44 | // explain how 255 values map onto 16384 VC/VF values (or onto 128 45 | // `VC` values alone). 46 | // 47 | // The algorithm below was used before this function was introduced. 48 | // It adheres *somewhat* to the examples in the "Approximate Audio 49 | // Taper Level Values" table in the SQ MIDI Protocol document, but 50 | // it's noticeably off in places, e.g. treating dB<-65 as '-inf'. 51 | // We leave it as-is for the moment, because changing this 52 | // interpretation could result in serialization/deserialization 53 | // consistencies so changing can't be done lightly. 54 | let val 55 | if (vc > 115) { 56 | val = (10 - (127 - vc) / 3).toFixed(1) 57 | } else if (vc > 99) { 58 | val = (5 - (115 - vc) / 4).toFixed(1) 59 | } else if (vc > 79) { 60 | val = (0 - (99 - vc) / 5).toFixed(1) 61 | } else if (vc > 63) { 62 | val = (-5 - (79 - vc) / 4).toFixed(1) 63 | } else if (vc > 15) { 64 | val = (-10 - (63 - vc) / 1.778).toFixed(0) 65 | } else { 66 | val = (-40 - (15 - vc) / 0.2).toFixed(0) 67 | } 68 | 69 | val = parseFloat(val) 70 | return val < -89 ? '-inf' : val 71 | } 72 | 73 | default: 74 | assertNever(faderLaw) 75 | throw new Error(`Bad fader law: ${faderLaw}`) 76 | } 77 | } 78 | 79 | /** 80 | * Convert a fader level to a `VC`/`VF` data byte pair approximately equivalent 81 | * to it under the given `faderLaw`. 82 | */ 83 | export function nrpnDataFromLevel(level: Level, faderLaw: FaderLaw): [number, number] { 84 | // `[0, 0]` with both fader laws is -∞. 85 | if (level === '-inf') { 86 | return [0, 0] 87 | } 88 | 89 | // `level` must be a number in range (-90, 10] at this point. Enforce this 90 | // to assist understanding the operations performed below. 91 | if (!(-90 < level && level <= 10)) { 92 | throw new Error(`Unexpected out-of-range level: ${level}`) 93 | } 94 | 95 | switch (faderLaw) { 96 | case 'LinearTaper': { 97 | // As above, there is no explanation or justification for this 98 | // formula except "it seems to fit well enough...". 99 | const val = 15196 + level * 118.775 100 | const vcvf = Math.floor(val) 101 | return [(vcvf >> 7) & 0x7f, vcvf & 0x7f] 102 | } 103 | 104 | case 'AudioTaper': { 105 | // There doesn't seem to be any ready explanation of the algorithm 106 | // below, save that it's roughly consistent with example values in 107 | // the SQ MIDI Protocol document. 108 | let candidateVC 109 | if (5 < level) { 110 | candidateVC = 127 - (10 - level) * 3 111 | } else if (0 < level) { 112 | candidateVC = 115 - (5 - level) * 4 113 | } else if (-5 < level) { 114 | candidateVC = 99 + (0 - level) * 5 115 | } else if (-10 < level) { 116 | candidateVC = 79 + (5 + level) * 4 117 | } else if (-40 < level) { 118 | candidateVC = 63 + (10 + level) * 1.778 119 | } else { 120 | candidateVC = 15 + (40 + level) * 0.2 121 | } 122 | 123 | const vc = Math.floor(candidateVC) 124 | const vf = Math.floor((candidateVC - vc) * 100) 125 | return [vc, vf] 126 | } 127 | 128 | default: 129 | assertNever(faderLaw) 130 | throw new Error(`Bad fader law: ${faderLaw}`) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { type InputValue, Regex, type SomeCompanionConfigField } from '@companion-module/base' 2 | import { DefaultModel, getCommonCount } from './mixer/models.js' 3 | 4 | /** 5 | * The `TConfig` object type used to store instance configuration info. 6 | * 7 | * Nothing ensures that Companion config objects conform to the `TConfig` type 8 | * specified by a module. Therefore we leave this type underdefined, not 9 | * well-defined, so that configuration info will be defensively processed. (We 10 | * use `SQInstanceOptions` to store configuration choices as well-typed values 11 | * for the long haul. See the `options.ts:optionsFromConfig` destructuring 12 | * parameter for a list of the field/types we expect to find in config objects.) 13 | */ 14 | export interface SQInstanceConfig { 15 | [key: string]: InputValue | undefined 16 | } 17 | 18 | /** 19 | * Ensure a 'model' property is present in configs that lack it. 20 | */ 21 | export function tryEnsureModelOptionInConfig(oldConfig: SQInstanceConfig | null): boolean { 22 | if (oldConfig !== null && !('model' in oldConfig)) { 23 | oldConfig.model = DefaultModel 24 | return true 25 | } 26 | return false 27 | } 28 | 29 | /** The default label for a connection from `companion/manifest.json`. */ 30 | const DefaultConnectionLabel = 'SQ' 31 | 32 | /** 33 | * Ensure a 'label' property containing a connection label is present in configs 34 | * that lack it. 35 | */ 36 | export function tryEnsureLabelInConfig(oldConfig: SQInstanceConfig | null): boolean { 37 | if (oldConfig !== null && !('label' in oldConfig)) { 38 | oldConfig.label = DefaultConnectionLabel 39 | return true 40 | } 41 | return false 42 | } 43 | 44 | /** 45 | * This module used to have a `'label'` option (regrettably see the function 46 | * above), in which the user was expected to (re-)specify the instance label. 47 | * This label was then used in the "Learn" operation for various actions, as 48 | * well as in various preset definitions. 49 | * 50 | * But it turns out the instance label is accessible as `InstanceBase.label` 51 | * which is always up-to-date, so there's no point in having the config option. 52 | * 53 | * This function detects and, if present, removes the `'label'` option from 54 | * configs that have it. 55 | */ 56 | export function tryRemoveUnnecessaryLabelInConfig(oldConfig: SQInstanceConfig | null): boolean { 57 | if (oldConfig !== null && 'label' in oldConfig) { 58 | delete oldConfig.label 59 | return true 60 | } 61 | return false 62 | } 63 | 64 | function createDefaultTalkbackChannelOption(): SomeCompanionConfigField { 65 | // The number of input channels depends on how many input channels the 66 | // user's chosen SQ model has. Currently all SQs have the same number of 67 | // input channels, so use that count. 68 | const inputChannelCount = getCommonCount('chCount') 69 | 70 | const DefaultTalkbackInputChannelChoices = [] 71 | for (let i = 0; i < inputChannelCount; i++) { 72 | DefaultTalkbackInputChannelChoices.push({ label: `CH ${i + 1}`, id: i }) 73 | } 74 | 75 | return { 76 | type: 'dropdown', 77 | label: 'Default talkback input channel', 78 | id: 'talkback', 79 | width: 6, 80 | default: 0, 81 | choices: DefaultTalkbackInputChannelChoices, 82 | minChoicesForSearch: 0, 83 | } 84 | } 85 | 86 | /** 87 | * Get SQ module configuration fields. 88 | */ 89 | export function GetConfigFields(): SomeCompanionConfigField[] { 90 | return [ 91 | { 92 | type: 'static-text', 93 | id: 'info', 94 | width: 12, 95 | label: 'Information', 96 | value: 'This module is for Allen & Heath SQ series mixing consoles.', 97 | }, 98 | { 99 | type: 'textinput', 100 | id: 'host', 101 | label: 'Target IP', 102 | width: 6, 103 | default: '192.168.0.5', 104 | regex: Regex.IP, 105 | }, 106 | { 107 | type: 'dropdown', 108 | id: 'model', 109 | label: 'Console Type', 110 | width: 6, 111 | default: DefaultModel, 112 | choices: [ 113 | { id: 'SQ5', label: 'SQ 5' }, 114 | { id: 'SQ6', label: 'SQ 6' }, 115 | { id: 'SQ7', label: 'SQ 7' }, 116 | ], 117 | }, 118 | { 119 | type: 'dropdown', 120 | id: 'level', 121 | label: 'NRPN Fader Law', 122 | width: 6, 123 | default: 'LinearTaper', 124 | choices: [ 125 | { id: 'LinearTaper', label: 'Linear Taper' }, 126 | { id: 'AudioTaper', label: 'Audio Taper' }, 127 | ], 128 | }, 129 | createDefaultTalkbackChannelOption(), 130 | { 131 | type: 'number', 132 | id: 'midich', 133 | label: 'MIDI channel', 134 | width: 6, 135 | min: 1, 136 | max: 16, 137 | default: 1, 138 | }, 139 | { 140 | type: 'dropdown', 141 | id: 'status', 142 | label: 'Retrieve console status', 143 | width: 6, 144 | default: 'full', 145 | choices: [ 146 | { id: 'full', label: 'Fully at startup' }, 147 | { id: 'delay', label: 'Delayed at startup' }, 148 | { id: 'nosts', label: 'Not at startup' }, 149 | ], 150 | }, 151 | { 152 | type: 'checkbox', 153 | id: 'verbose', 154 | label: 'Enable Verbose Logging', 155 | width: 12, 156 | default: false, 157 | }, 158 | ] 159 | } 160 | -------------------------------------------------------------------------------- /src/midi/tokenize/__tests__/interactions.ts: -------------------------------------------------------------------------------- 1 | import { prettyByte, prettyBytes } from '../../../utils/pretty.js' 2 | 3 | type MixerWriteMidiBytes = { 4 | readonly type: 'mixer-write-midi-bytes' 5 | readonly bytes: Uint8Array 6 | } 7 | 8 | type NextMessageNotReady = { 9 | readonly type: 'next-message-not-ready' 10 | } 11 | 12 | type ExpectChannel = { 13 | readonly type: 'expect-channel-message' 14 | readonly message: readonly number[] 15 | } 16 | 17 | type ExpectSystemCommon = { 18 | readonly type: 'expect-system-common' 19 | readonly message: readonly number[] 20 | } 21 | 22 | type ExpectSystemRealTime = { 23 | readonly type: 'expect-system-real-time' 24 | readonly message: number 25 | } 26 | 27 | type ExpectSystemExclusive = { 28 | readonly type: 'expect-system-exclusive' 29 | readonly message: readonly number[] 30 | } 31 | 32 | type MixerCloseSocket = { 33 | readonly type: 'mixer-close-socket' 34 | } 35 | 36 | export type Interaction = 37 | | MixerWriteMidiBytes 38 | | NextMessageNotReady 39 | | ExpectChannel 40 | | ExpectSystemCommon 41 | | ExpectSystemRealTime 42 | | ExpectSystemExclusive 43 | | MixerCloseSocket 44 | 45 | /** 46 | * Have the "mixer" write the given MIDI bytes into its socket, to (eventually) 47 | * be passed to the tokenizer. 48 | */ 49 | export function MixerWriteMidiBytes(bytes: readonly number[]): MixerWriteMidiBytes { 50 | return { type: 'mixer-write-midi-bytes', bytes: new Uint8Array(bytes) } 51 | } 52 | 53 | /** 54 | * Expect that no tokenized MIDI message is presently ready to be examined 55 | * because the MIDI bytes scanned since the last specific MIDI message was 56 | * expected don't constitute a complete MIDI message. 57 | */ 58 | export function ExpectNextMessageNotReady(): NextMessageNotReady { 59 | // Unlike in mixer command parsing, we can only expect message-not-ready, 60 | // not message-ready. Tokenizing acts upon a TCP socket, and mixer replies 61 | // only wait for the write to have completed from the point of view of the 62 | // socket *writer*, not the socket reader. We're at the whims of the OS TCP 63 | // stack as to when the sent data is actually ready. 64 | // 65 | // If you want to expect the next message is ready, expect it directly. 66 | return { type: 'next-message-not-ready' } 67 | } 68 | 69 | /** Expect a MIDI channel message has been tokenized. */ 70 | export function ExpectChannelMessage(message: readonly number[]): ExpectChannel { 71 | if (message.length === 0) { 72 | throw new Error('forbidden zero-length message') 73 | } 74 | const first = message[0] 75 | if ((first & 0x80) === 0 || (first & 0x80) === 0xf0) { 76 | throw new Error(`bad status byte at start of channel message ${prettyBytes(message)}`) 77 | } 78 | 79 | return { type: 'expect-channel-message', message } 80 | } 81 | 82 | /** Expect a MIDI system common message has been tokenized. */ 83 | export function ExpectSystemCommonMessage(message: readonly number[]): ExpectSystemCommon { 84 | let dataBytesLen 85 | switch (message[0]) { 86 | case 0xf1: 87 | dataBytesLen = 1 88 | break 89 | case 0xf2: 90 | dataBytesLen = 2 91 | break 92 | case 0xf3: 93 | dataBytesLen = 1 94 | break 95 | case 0xf6: 96 | case 0xf7: 97 | dataBytesLen = 0 98 | break 99 | default: 100 | throw new Error(`system common message ${prettyBytes(message)} begins with a bad status byte`) 101 | } 102 | if (message.length !== dataBytesLen + 1) { 103 | throw new Error(`system common message ${prettyBytes(message)} length is incorrect for its status byte`) 104 | } 105 | 106 | return { type: 'expect-system-common', message } 107 | } 108 | 109 | /** Expect a single-byte system real time message has been tokenized. */ 110 | export function ExpectSystemRealTimeMessage(message: number): ExpectSystemRealTime { 111 | if (!(0xf8 <= message && message <= 0xff)) { 112 | throw new Error(`invalid system real time message ${prettyByte(message)}`) 113 | } 114 | return { type: 'expect-system-real-time', message } 115 | } 116 | 117 | /** 118 | * Expect a system exclusive message has been tokenized. (The expected message 119 | * will be normalized to have the standard system exclusive message end byte.) 120 | */ 121 | export function ExpectSystemExclusiveMessage(message: readonly number[]): ExpectSystemExclusive { 122 | if (message.length < 2) { 123 | throw new Error('system exclusive message too short') 124 | } 125 | if (message[0] !== 0xf0) { 126 | throw new Error('system exclusive must begin with 0xF0') 127 | } 128 | if (message[message.length - 1] !== 0xf7) { 129 | throw new Error('system exclusive must end with 0xF7') 130 | } 131 | return { type: 'expect-system-exclusive', message } 132 | } 133 | 134 | /** 135 | * Act as if the mixer closed the socket it uses to send MIDI bytes to the 136 | * tokenizer, causing the tokenizer to abruptly stop receiving data in 137 | * ungraceful fashion. 138 | * 139 | * @allowunused for the moment because connection socket handling is likely too 140 | * buggy to handle a mixer connection that flakes and requires adjustment 141 | */ 142 | export function CloseMixerSocket(): MixerCloseSocket { 143 | return { type: 'mixer-close-socket' } 144 | } 145 | -------------------------------------------------------------------------------- /src/mixer/lr.test.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionMigrationAction, CompanionOptionValues } from '@companion-module/base' 2 | import { describe, expect, test } from 'vitest' 3 | import { LR, tryUpgradeMixOrLRArrayEncoding, tryUpgradeMixOrLROptionEncoding } from './lr.js' 4 | 5 | function makeUpgradeAction(options: CompanionOptionValues): CompanionMigrationAction { 6 | return { 7 | actionId: 'foobar', 8 | controlId: '42', 9 | id: 'hello', 10 | options, 11 | } 12 | } 13 | 14 | describe('ugprade LR array encoding', () => { 15 | test('not array', () => { 16 | const action: CompanionMigrationAction = makeUpgradeAction({ 17 | bar: 42, 18 | }) 19 | 20 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'bar')).toBe(false) 21 | expect(action.options.bar).toBe(42) 22 | }) 23 | 24 | test('obsolete LR as not array', () => { 25 | const action: CompanionMigrationAction = makeUpgradeAction({ 26 | hooah: 99, 27 | }) 28 | 29 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'hooah')).toBe(false) 30 | expect(action.options.hooah).toBe(99) 31 | }) 32 | 33 | test('modern LR as not array', () => { 34 | const action: CompanionMigrationAction = makeUpgradeAction({ 35 | spatchcock: LR, 36 | }) 37 | 38 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'spatchcock')).toBe(false) 39 | expect(action.options.spatchcock).toBe('lr') 40 | }) 41 | 42 | test('empty array', () => { 43 | const action: CompanionMigrationAction = makeUpgradeAction({ 44 | baz: [], 45 | }) 46 | 47 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'baz')).toBe(false) 48 | expect(action.options.baz).toEqual([]) 49 | }) 50 | 51 | test('single element not LR array', () => { 52 | const action: CompanionMigrationAction = makeUpgradeAction({ 53 | quux: [17], 54 | }) 55 | 56 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'quux')).toBe(false) 57 | expect(action.options.quux).toEqual([17]) 58 | }) 59 | 60 | test('single element obsolete LR array', () => { 61 | const action: CompanionMigrationAction = makeUpgradeAction({ 62 | waldo: [99], 63 | }) 64 | 65 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'waldo')).toBe(true) 66 | expect(action.options.waldo).toEqual(['lr']) 67 | }) 68 | 69 | // In theory this shouldn't happen that the upgrade script is run on an 70 | // upgraded action, but let's play it safe. 71 | test('single element modern LR array', () => { 72 | const action: CompanionMigrationAction = makeUpgradeAction({ 73 | waldo: [LR], 74 | }) 75 | 76 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'waldo')).toBe(false) 77 | expect(action.options.waldo).toEqual(['lr']) 78 | }) 79 | 80 | test('multiple elements leading obsolete LR array', () => { 81 | const action: CompanionMigrationAction = makeUpgradeAction({ 82 | aight: [99, 2], 83 | }) 84 | 85 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'aight')).toBe(true) 86 | expect(action.options.aight).toEqual(['lr', 2]) 87 | }) 88 | 89 | // Again, shouldn't happen, but playing it safe. 90 | test('multiple elements leading modern LR array', () => { 91 | const action: CompanionMigrationAction = makeUpgradeAction({ 92 | kookaburra: [LR, 2], 93 | }) 94 | 95 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'kookaburra')).toBe(false) 96 | expect(action.options.kookaburra).toEqual(['lr', 2]) 97 | }) 98 | 99 | test('multiple elements multiple obsolete LR array', () => { 100 | const action: CompanionMigrationAction = makeUpgradeAction({ 101 | dorado: [3, 99, 2, 99, 6], 102 | }) 103 | 104 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'dorado')).toBe(true) 105 | expect(action.options.dorado).toEqual([3, 'lr', 2, 'lr', 6]) 106 | }) 107 | 108 | test('multiple elements last is obsolete LR array', () => { 109 | const action: CompanionMigrationAction = makeUpgradeAction({ 110 | legitimateSalvage: [9, 99], 111 | }) 112 | 113 | expect(tryUpgradeMixOrLRArrayEncoding(action, 'legitimateSalvage')).toBe(true) 114 | expect(action.options.legitimateSalvage).toEqual([9, 'lr']) 115 | }) 116 | }) 117 | 118 | describe('ugprade LR option encoding', () => { 119 | test('not obsolete LR', () => { 120 | const action: CompanionMigrationAction = makeUpgradeAction({ 121 | bar: 42, 122 | }) 123 | 124 | expect(tryUpgradeMixOrLROptionEncoding(action, 'bar')).toBe(false) 125 | expect(action.options.bar).toBe(42) 126 | }) 127 | 128 | test('obsolete LR', () => { 129 | const action: CompanionMigrationAction = makeUpgradeAction({ 130 | eit: 99, 131 | }) 132 | 133 | expect(tryUpgradeMixOrLROptionEncoding(action, 'eit')).toBe(true) 134 | expect(action.options.eit).toBe('lr') 135 | }) 136 | 137 | test('obsolete LR erroneously as string', () => { 138 | const action: CompanionMigrationAction = makeUpgradeAction({ 139 | eit: '99', 140 | }) 141 | 142 | expect(tryUpgradeMixOrLROptionEncoding(action, 'eit')).toBe(true) 143 | expect(action.options.eit).toBe('lr') 144 | }) 145 | 146 | test('modern LR', () => { 147 | const action: CompanionMigrationAction = makeUpgradeAction({ 148 | fnord: LR, 149 | }) 150 | 151 | expect(tryUpgradeMixOrLROptionEncoding(action, 'fnord')).toBe(false) 152 | expect(action.options.fnord).toBe('lr') 153 | }) 154 | }) 155 | -------------------------------------------------------------------------------- /src/choices.ts: -------------------------------------------------------------------------------- 1 | import type { DropdownChoice } from '@companion-module/base' 2 | import { createLevels } from './actions/fading.js' 3 | import { createPanLevels } from './actions/pan-balance.js' 4 | import { LR } from './mixer/lr.js' 5 | import type { Model } from './mixer/model.js' 6 | 7 | function createInputChannels(model: Model): DropdownChoice[] { 8 | const inputChannels: DropdownChoice[] = [] 9 | model.forEach('inputChannel', (channel, channelLabel) => { 10 | inputChannels.push({ label: channelLabel, id: channel }) 11 | }) 12 | return inputChannels 13 | } 14 | 15 | function createMixes(model: Model): DropdownChoice[] { 16 | const mixes: DropdownChoice[] = [] 17 | model.forEach('mix', (id, label) => { 18 | mixes.push({ label, id }) 19 | }) 20 | return mixes 21 | } 22 | 23 | function createMixesAndLR(model: Model): DropdownChoice[] { 24 | const mixesAndLR: DropdownChoice[] = [] 25 | mixesAndLR.push({ label: 'LR', id: LR }) 26 | model.forEach('mix', (id, label) => { 27 | mixesAndLR.push({ label, id }) 28 | }) 29 | return mixesAndLR 30 | } 31 | 32 | function createGroups(model: Model): DropdownChoice[] { 33 | const groups: DropdownChoice[] = [] 34 | model.forEach('group', (group, groupLabel) => { 35 | groups.push({ label: groupLabel, id: group }) 36 | }) 37 | return groups 38 | } 39 | 40 | function createMatrixes(model: Model): DropdownChoice[] { 41 | const matrixes: DropdownChoice[] = [] 42 | model.forEach('matrix', (matrix, matrixLabel) => { 43 | matrixes.push({ label: matrixLabel, id: matrix }) 44 | }) 45 | return matrixes 46 | } 47 | 48 | function createFXReturns(model: Model): DropdownChoice[] { 49 | const fxReturns: DropdownChoice[] = [] 50 | model.forEach('fxReturn', (fxr, fxrLabel) => { 51 | fxReturns.push({ label: fxrLabel, id: fxr }) 52 | }) 53 | return fxReturns 54 | } 55 | 56 | function createFXSends(model: Model): DropdownChoice[] { 57 | const fxSends: DropdownChoice[] = [] 58 | model.forEach('fxSend', (fxs, fxsLabel) => { 59 | fxSends.push({ label: fxsLabel, id: fxs }) 60 | }) 61 | return fxSends 62 | } 63 | 64 | function createDCAs(model: Model): DropdownChoice[] { 65 | const dcas: DropdownChoice[] = [] 66 | model.forEach('dca', (dca, dcaLabel) => { 67 | dcas.push({ label: dcaLabel, id: dca }) 68 | }) 69 | return dcas 70 | } 71 | 72 | function createMuteGroups(model: Model): DropdownChoice[] { 73 | const muteGroups: DropdownChoice[] = [] 74 | model.forEach('muteGroup', (muteGroup, muteGroupLabel) => { 75 | muteGroups.push({ label: muteGroupLabel, id: muteGroup }) 76 | }) 77 | return muteGroups 78 | } 79 | 80 | function createSoftKeys(model: Model): DropdownChoice[] { 81 | const softKeys: DropdownChoice[] = [] 82 | model.forEach('softKey', (softKey, softKeyLabel) => { 83 | softKeys.push({ label: softKeyLabel, id: softKey }) 84 | }) 85 | return softKeys 86 | } 87 | 88 | function createAllFaders(model: Model): DropdownChoice[] { 89 | // All fader mix choices 90 | const allFaders: DropdownChoice[] = [] 91 | allFaders.push({ label: `LR`, id: 0 }) 92 | model.forEach('mix', (mix, mixLabel) => { 93 | allFaders.push({ label: mixLabel, id: mix + 1 }) 94 | }) 95 | model.forEach('fxSend', (fxs, fxsLabel) => { 96 | allFaders.push({ label: fxsLabel, id: fxs + 1 + model.inputOutputCounts.mix }) 97 | }) 98 | model.forEach('matrix', (matrix, matrixLabel) => { 99 | allFaders.push({ 100 | label: matrixLabel, 101 | id: matrix + 1 + model.inputOutputCounts.mix + model.inputOutputCounts.fxSend, 102 | }) 103 | }) 104 | model.forEach('dca', (dca, dcaLabel) => { 105 | allFaders.push({ 106 | label: dcaLabel, 107 | id: dca + 1 + model.inputOutputCounts.mix + model.inputOutputCounts.fxSend + model.inputOutputCounts.matrix + 12, 108 | }) 109 | }) 110 | 111 | return allFaders 112 | } 113 | 114 | function createPanBalanceOutputFaders(model: Model): DropdownChoice[] { 115 | const allFaders: DropdownChoice[] = [] 116 | allFaders.push({ label: `LR`, id: 0 }) 117 | model.forEach('mix', (mix, mixLabel) => { 118 | allFaders.push({ label: mixLabel, id: 1 + mix }) 119 | }) 120 | model.forEach('matrix', (matrix, matrixLabel) => { 121 | allFaders.push({ label: matrixLabel, id: 0x11 + matrix }) 122 | }) 123 | 124 | return allFaders 125 | } 126 | 127 | export class Choices { 128 | readonly inputChannels 129 | readonly mixes 130 | readonly mixesAndLR 131 | readonly groups 132 | readonly matrixes 133 | readonly fxReturns 134 | readonly fxSends 135 | readonly dcas 136 | readonly muteGroups 137 | readonly softKeys 138 | readonly levels 139 | readonly panLevels 140 | readonly allFaders 141 | readonly panBalanceFaders 142 | 143 | constructor(model: Model) { 144 | this.inputChannels = createInputChannels(model) 145 | this.mixes = createMixes(model) 146 | this.mixesAndLR = createMixesAndLR(model) 147 | this.groups = createGroups(model) 148 | this.matrixes = createMatrixes(model) 149 | this.fxReturns = createFXReturns(model) 150 | this.fxSends = createFXSends(model) 151 | this.dcas = createDCAs(model) 152 | this.muteGroups = createMuteGroups(model) 153 | this.softKeys = createSoftKeys(model) 154 | this.levels = createLevels() 155 | this.panLevels = createPanLevels() 156 | this.allFaders = createAllFaders(model) 157 | this.panBalanceFaders = createPanBalanceOutputFaders(model) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/instance.ts: -------------------------------------------------------------------------------- 1 | // Allen & Heath SQ Series 2 | 3 | import { type CompanionVariableValue, InstanceBase, type SomeCompanionConfigField } from '@companion-module/base' 4 | import { getActions } from './actions/actions.js' 5 | import { Choices } from './choices.js' 6 | import { GetConfigFields, type SQInstanceConfig } from './config.js' 7 | import { getFeedbacks } from './feedbacks/feedbacks.js' 8 | import { Mixer } from './mixer/mixer.js' 9 | import { canUpdateOptionsWithoutRestarting, noConnectionOptions, optionsFromConfig } from './options.js' 10 | import { getPresets } from './presets.js' 11 | import { CurrentSceneId, getVariables, SceneRecalledTriggerId } from './variables.js' 12 | 13 | /** An SQ mixer connection instance. */ 14 | export class sqInstance extends InstanceBase { 15 | /** Options dictating the behavior of this instance. */ 16 | options = noConnectionOptions() 17 | 18 | /** 19 | * The mixer being manipulated by this instance if one has been identified. 20 | */ 21 | mixer: Mixer | null = null 22 | 23 | /** 24 | * The last label specified for this instance, or `null` if there wasn't a 25 | * last label. 26 | */ 27 | #lastLabel: string | null = null 28 | 29 | override async destroy(): Promise { 30 | if (this.mixer !== null) { 31 | this.mixer.stop('Module connection destroyed') 32 | this.mixer = null 33 | } 34 | } 35 | 36 | override async init(config: SQInstanceConfig, _isFirstInit: boolean): Promise { 37 | void this.configUpdated(config) 38 | } 39 | 40 | override getConfigFields(): SomeCompanionConfigField[] { 41 | return GetConfigFields() 42 | } 43 | 44 | /** 45 | * Set the value of a variable that doesn't exist when this instance is 46 | * initialized, but only is brought into existence if/when it is needed. 47 | * 48 | * @param variableId 49 | * The id of the variable, i.e. the part that appears to right of the 50 | * colon in `$(SQ:ident)`. 51 | * @param _name 52 | * A user-exposed description of the variable. 53 | * @param variableValue 54 | * The value of the variable. 55 | */ 56 | setExtraVariable(variableId: string, _name: string, variableValue: CompanionVariableValue): void { 57 | // The name of this potentially newly-defined variable is currently not 58 | // used. If we wanted to, we could redefine the entire variable set 59 | // (with this new variable included), to expose this new variable in 60 | // UI (for example, in variable autocomplete in text fields that support 61 | // variables). But that's a large amount of churn for just a single 62 | // variable, with quadratically increasing cost (define N variables, 63 | // define N + 1 variables, define N + 2 variables...). So for now we 64 | // use `disableVariableValidation` to add the variable without all that 65 | // extra support. Perhaps Companion itself will grow an API to define 66 | // individual extra variables, and then we can use the name in that API. 67 | 68 | const { instanceOptions } = this 69 | const { disableVariableValidation: oldValue } = instanceOptions 70 | try { 71 | instanceOptions.disableVariableValidation = true 72 | 73 | this.setVariableValues({ 74 | [variableId]: variableValue, 75 | }) 76 | } finally { 77 | instanceOptions.disableVariableValidation = oldValue 78 | } 79 | } 80 | 81 | /** Set variable definitions for this instance. */ 82 | initVariableDefinitions(mixer: Mixer): void { 83 | this.setVariableDefinitions(getVariables(mixer.model)) 84 | 85 | this.setVariableValues({ 86 | [SceneRecalledTriggerId]: mixer.sceneRecalledTrigger, 87 | 88 | // This value may very well be wrong, but there's no defined way to 89 | // query what the current scene is, nor to be updated if it changes 90 | // and this module didn't do it. 91 | [CurrentSceneId]: 1, 92 | }) 93 | } 94 | 95 | override async configUpdated(config: SQInstanceConfig): Promise { 96 | const oldOptions = this.options 97 | 98 | const newOptions = optionsFromConfig(config) 99 | this.options = newOptions 100 | 101 | if (this.mixer !== null) { 102 | if (canUpdateOptionsWithoutRestarting(oldOptions, newOptions)) { 103 | const label = this.label 104 | if (label !== this.#lastLabel) { 105 | // The instance label might be altered just before 106 | // `configUpdated` is called. The instance label is used in the 107 | // "Learn" operation for some actions -- and it'll always be 108 | // up-to-date in these uses. But it's also hardcoded in some 109 | // presets, so if the label changes, we must redefine presets 110 | // even if we don't have to restart the connection. 111 | this.#lastLabel = label 112 | this.setPresetDefinitions(getPresets(this, this.mixer.model)) 113 | } 114 | return 115 | } 116 | 117 | this.mixer.stop('Mixer connection restarting for configuration update...') 118 | } 119 | 120 | const mixer = new Mixer(this) 121 | this.mixer = mixer 122 | 123 | const model = mixer.model 124 | 125 | const choices = new Choices(model) 126 | 127 | this.initVariableDefinitions(mixer) 128 | this.setActionDefinitions(getActions(this, mixer, choices)) 129 | this.setFeedbackDefinitions(getFeedbacks(mixer, choices)) 130 | 131 | this.#lastLabel = this.label 132 | this.setPresetDefinitions(getPresets(this, model)) 133 | 134 | //this.checkVariables(); 135 | this.checkFeedbacks() 136 | 137 | mixer.start(newOptions.host) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/midi/tokenize/__tests__/running-data.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import { TestMidiTokenizing } from './midi-tokenizing.js' 3 | import { SysCommonMultiByte, SysRTContinue } from '../../bytes.js' 4 | import { 5 | ExpectNextMessageNotReady, 6 | ExpectChannelMessage, 7 | ExpectSystemCommonMessage, 8 | ExpectSystemRealTimeMessage, 9 | MixerWriteMidiBytes, 10 | } from './interactions.js' 11 | 12 | describe('parse status byte (ignored data to start)', () => { 13 | test('single data byte', async () => { 14 | return TestMidiTokenizing([ 15 | MixerWriteMidiBytes([0xc3, 0x01]), 16 | MixerWriteMidiBytes([0x05]), 17 | ExpectChannelMessage([0xc3, 0x01]), 18 | ExpectChannelMessage([0xc3, 0x05]), 19 | ExpectNextMessageNotReady(), 20 | MixerWriteMidiBytes([0x07]), 21 | ExpectChannelMessage([0xc3, 0x07]), 22 | ]) 23 | }) 24 | 25 | test('system real time before running data', async () => { 26 | return TestMidiTokenizing([ 27 | MixerWriteMidiBytes([0xc3]), 28 | ExpectNextMessageNotReady(), 29 | MixerWriteMidiBytes([SysRTContinue, 0x01]), 30 | ExpectSystemRealTimeMessage(SysRTContinue), 31 | ExpectChannelMessage([0xc3, 0x01]), 32 | ExpectNextMessageNotReady(), 33 | MixerWriteMidiBytes([0x05]), 34 | ExpectChannelMessage([0xc3, 0x05]), 35 | MixerWriteMidiBytes([0x07]), 36 | ExpectChannelMessage([0xc3, 0x07]), 37 | ]) 38 | }) 39 | 40 | test('system real time after first running data', async () => { 41 | return TestMidiTokenizing([ 42 | MixerWriteMidiBytes([0xc3, 0x01, SysRTContinue]), 43 | ExpectChannelMessage([0xc3, 0x01]), 44 | ExpectSystemRealTimeMessage(SysRTContinue), 45 | ExpectNextMessageNotReady(), 46 | MixerWriteMidiBytes([0x05]), 47 | ExpectChannelMessage([0xc3, 0x05]), 48 | MixerWriteMidiBytes([0x07]), 49 | ExpectChannelMessage([0xc3, 0x07]), 50 | ExpectNextMessageNotReady(), 51 | ]) 52 | }) 53 | 54 | test('system real time after second running data', async () => { 55 | return TestMidiTokenizing([ 56 | MixerWriteMidiBytes([0xc3, 0x01]), 57 | ExpectChannelMessage([0xc3, 0x01]), 58 | MixerWriteMidiBytes([0x05, SysRTContinue]), 59 | ExpectChannelMessage([0xc3, 0x05]), 60 | ExpectSystemRealTimeMessage(SysRTContinue), 61 | MixerWriteMidiBytes([0x07]), 62 | ExpectChannelMessage([0xc3, 0x07]), 63 | ExpectNextMessageNotReady(), 64 | ]) 65 | }) 66 | test('system real time end of running data', async () => { 67 | return TestMidiTokenizing([ 68 | MixerWriteMidiBytes([0xc3, 0x01]), 69 | ExpectChannelMessage([0xc3, 0x01]), 70 | MixerWriteMidiBytes([0x05]), 71 | ExpectChannelMessage([0xc3, 0x05]), 72 | MixerWriteMidiBytes([0x07, SysRTContinue, 0xb0, 0x00, 0x17]), 73 | ExpectChannelMessage([0xc3, 0x07]), 74 | ExpectSystemRealTimeMessage(SysRTContinue), 75 | ExpectChannelMessage([0xb0, 0x00, 0x17]), 76 | ]) 77 | }) 78 | 79 | test('terminated by system common', async () => { 80 | return TestMidiTokenizing([ 81 | MixerWriteMidiBytes([0xc3, 0x01]), 82 | ExpectChannelMessage([0xc3, 0x01]), 83 | MixerWriteMidiBytes(SysCommonMultiByte), 84 | MixerWriteMidiBytes([0x05]), 85 | MixerWriteMidiBytes([0xb1, 0x33, 0x77]), 86 | ExpectSystemCommonMessage(SysCommonMultiByte), 87 | ExpectChannelMessage([0xb1, 0x33, 0x77]), 88 | ]) 89 | }) 90 | 91 | test('terminated by system common after running', async () => { 92 | return TestMidiTokenizing([ 93 | MixerWriteMidiBytes([0xc3, 0x01, 0x72]), 94 | MixerWriteMidiBytes(SysCommonMultiByte.slice(0, 1)), 95 | ExpectChannelMessage([0xc3, 0x01]), 96 | ExpectChannelMessage([0xc3, 0x72]), 97 | ExpectNextMessageNotReady(), 98 | MixerWriteMidiBytes(SysCommonMultiByte.slice(1)), 99 | ExpectSystemCommonMessage(SysCommonMultiByte), 100 | MixerWriteMidiBytes([0x05]), 101 | MixerWriteMidiBytes([0xb1, 0x33, 0x77]), 102 | ExpectChannelMessage([0xb1, 0x33, 0x77]), 103 | ]) 104 | }) 105 | 106 | test('multiple data bytes, terminated by system common', async () => { 107 | return TestMidiTokenizing([ 108 | MixerWriteMidiBytes([0xb5, 0x03, 0x27]), 109 | MixerWriteMidiBytes(SysCommonMultiByte), 110 | ExpectChannelMessage([0xb5, 0x03, 0x27]), 111 | MixerWriteMidiBytes([0x05]), 112 | MixerWriteMidiBytes([0xb1, 0x33, 0x77]), 113 | ExpectSystemCommonMessage(SysCommonMultiByte), 114 | ExpectChannelMessage([0xb1, 0x33, 0x77]), 115 | ExpectNextMessageNotReady(), 116 | ]) 117 | }) 118 | 119 | test('multiple data bytes, preempted by system common', async () => { 120 | return TestMidiTokenizing([ 121 | MixerWriteMidiBytes([0xb5, ...SysCommonMultiByte]), 122 | MixerWriteMidiBytes([0x05]), 123 | MixerWriteMidiBytes([0xb1, 0x33]), 124 | ExpectSystemCommonMessage(SysCommonMultiByte), 125 | ExpectNextMessageNotReady(), 126 | MixerWriteMidiBytes([0x77]), 127 | ExpectChannelMessage([0xb1, 0x33, 0x77]), 128 | ]) 129 | }) 130 | 131 | test('multiple data bytes, interrupted by system common', async () => { 132 | return TestMidiTokenizing([ 133 | MixerWriteMidiBytes([0xb7, 0x36, 0x65]), 134 | MixerWriteMidiBytes([0x52]), 135 | MixerWriteMidiBytes(SysCommonMultiByte.slice(0, 1)), 136 | ExpectChannelMessage([0xb7, 0x36, 0x65]), 137 | MixerWriteMidiBytes(SysCommonMultiByte.slice(1)), 138 | ExpectSystemCommonMessage(SysCommonMultiByte), 139 | MixerWriteMidiBytes([0x05]), 140 | MixerWriteMidiBytes([0x06]), 141 | MixerWriteMidiBytes([0xb1, 0x33, 0x77]), 142 | ExpectChannelMessage([0xb1, 0x33, 0x77]), 143 | ExpectNextMessageNotReady(), 144 | ]) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { type ModelId, DefaultModel } from './mixer/models.js' 2 | import type { FaderLaw } from './mixer/mixer.js' 3 | import { RetrieveStatusAtStartup } from './mixer/mixer.js' 4 | import type { SQInstanceConfig } from './config.js' 5 | import { Regex } from '@companion-module/base' 6 | 7 | /** Options information controlling the operation of a mixer instance. */ 8 | type SQInstanceOptions = { 9 | /** 10 | * The TCP/IP hostname of the mixer, or null if one was invalidly specified. 11 | */ 12 | host: string | null 13 | 14 | /** The model of the mixer. */ 15 | model: ModelId 16 | 17 | /** The fader law specified in the mixer. */ 18 | faderLaw: FaderLaw 19 | 20 | /** 21 | * The channel used for talkback (zero-indexed rather than 1-indexed as it 22 | * appears in UI). 23 | */ 24 | talkbackChannel: number 25 | 26 | /** 27 | * The MIDI channel that should be used to communicate with the mixer. 28 | * (This is the encoding-level value, i.e. 0-15 rather than 1-16.) 29 | */ 30 | midiChannel: number 31 | 32 | /** 33 | * How the mixer status (signal levels, etc.) should be retrieved at 34 | * startup. 35 | */ 36 | retrieveStatusAtStartup: RetrieveStatusAtStartup 37 | 38 | /** 39 | * Log a whole bunch of extra information about ongoing operation if verbose 40 | * logging is enabled. 41 | */ 42 | verbose: boolean 43 | } 44 | 45 | function toHost(host: SQInstanceConfig['host']): string | null { 46 | if (host !== undefined) { 47 | const hostStr = String(host) 48 | if (new RegExp(Regex.IP.slice(1, -1)).test(hostStr)) { 49 | return hostStr 50 | } 51 | } 52 | 53 | return null 54 | } 55 | 56 | function toModelId(model: SQInstanceConfig['model']): ModelId { 57 | const modelStr = String(model) 58 | switch (modelStr) { 59 | case 'SQ5': 60 | case 'SQ6': 61 | case 'SQ7': 62 | return modelStr 63 | default: 64 | return DefaultModel 65 | } 66 | } 67 | 68 | function toFaderLaw(faderLawOpt: SQInstanceConfig['level']): FaderLaw { 69 | const law = String(faderLawOpt) 70 | switch (law) { 71 | case 'LinearTaper': 72 | case 'AudioTaper': 73 | return law 74 | default: 75 | return 'LinearTaper' 76 | } 77 | } 78 | 79 | function toNumberDefaultZero(v: SQInstanceConfig[string]): number { 80 | if (typeof v === 'undefined') { 81 | return 0 82 | } 83 | 84 | return Number(v) 85 | } 86 | 87 | function toTalkbackChannel(ch: SQInstanceConfig['talkback']): number { 88 | return toNumberDefaultZero(ch) 89 | } 90 | 91 | function toMidiChannel(midich: SQInstanceConfig['midich']): number { 92 | const n = toNumberDefaultZero(midich) 93 | if (1 <= n && n <= 16) { 94 | return n - 1 95 | } 96 | 97 | return 0 98 | } 99 | 100 | function toRetrieveStatusAtStartup(status: SQInstanceConfig['status']): RetrieveStatusAtStartup { 101 | const retrieveStatus = String(status) 102 | switch (retrieveStatus) { 103 | case 'delay': 104 | return RetrieveStatusAtStartup.Delayed 105 | case 'nosts': 106 | return RetrieveStatusAtStartup.None 107 | case 'full': 108 | default: 109 | return RetrieveStatusAtStartup.Fully 110 | } 111 | } 112 | 113 | function toVerbose(verbose: SQInstanceConfig['verbose']): boolean { 114 | return Boolean(verbose) 115 | } 116 | 117 | /** Compute instance options from instance configuration info. */ 118 | export function optionsFromConfig({ 119 | // Comments indicate the expected types of the various config fields. 120 | host, // string 121 | model, // string 122 | level, // string 123 | talkback, // number 124 | midich, // number 125 | status, // string 126 | verbose, // boolean 127 | }: SQInstanceConfig): SQInstanceOptions { 128 | return { 129 | host: toHost(host), 130 | model: toModelId(model), 131 | faderLaw: toFaderLaw(level), 132 | talkbackChannel: toTalkbackChannel(talkback), 133 | midiChannel: toMidiChannel(midich), 134 | retrieveStatusAtStartup: toRetrieveStatusAtStartup(status), 135 | verbose: toVerbose(verbose), 136 | } 137 | } 138 | 139 | /** 140 | * Instance options suitable for use at instance creation, before the actual 141 | * options are available. 142 | */ 143 | export function noConnectionOptions(): SQInstanceOptions { 144 | return { 145 | // Null host ensures that these options won't trigger a connection. 146 | host: null, 147 | model: DefaultModel, 148 | faderLaw: 'LinearTaper', 149 | talkbackChannel: 0, 150 | midiChannel: 0, 151 | retrieveStatusAtStartup: RetrieveStatusAtStartup.Fully, 152 | verbose: false, 153 | } 154 | } 155 | 156 | /** 157 | * For an already-started instance/connection using the given old options, 158 | * determine whether applying the new options to it requires restarting the 159 | * connection. 160 | */ 161 | 162 | export function canUpdateOptionsWithoutRestarting( 163 | oldOptions: SQInstanceOptions, 164 | newOptions: SQInstanceOptions, 165 | ): boolean { 166 | // A different host straightforwardly requires a connection restart. 167 | if (oldOptions.host !== newOptions.host) { 168 | return false 169 | } 170 | 171 | // Changing mixer model alters choices used in options. Choice generation 172 | // presently is tied to mixer connection restarting, so force a restart if 173 | // the model changes. 174 | if (oldOptions.model !== newOptions.model) { 175 | return false 176 | } 177 | 178 | // A different fader law changes the meaning of all level messages and can't 179 | // really be synced up with any messages presently in flight, so forces a 180 | // restart. 181 | if (oldOptions.faderLaw !== newOptions.faderLaw) { 182 | return false 183 | } 184 | 185 | // Talkback channel is only used in the talkback-controlling presets, which 186 | // will always reflect the latest talkback channel when added as Companion 187 | // buttons, so we don't need to restart for a change. 188 | 189 | // Changing MIDI channel could result in messages on old/new MIDI channel 190 | // being missed, so force a restart. 191 | if (oldOptions.midiChannel !== newOptions.midiChannel) { 192 | return false 193 | } 194 | 195 | // Once the mixer connection is started up, a change in status retrieval 196 | // option is irrelevant, so don't restart for such change. 197 | 198 | // Verbose logging can be flipped on and off live without restart -- and 199 | // you really want it to, because verbose logging of 26KB of startup status 200 | // retrieval is extremely slow (particularly if the instance debug log is 201 | // open.) 202 | 203 | // Otherwise we can update options without restarting. 204 | return true 205 | } 206 | -------------------------------------------------------------------------------- /src/presets.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionPresetDefinitions } from '@companion-module/base' 2 | import { AssignActionId } from './actions/assign.js' 3 | import { LevelActionId } from './actions/level.js' 4 | import { MuteActionId } from './actions/mute.js' 5 | import { MuteFeedbackId } from './feedbacks/feedback-ids.js' 6 | import type { sqInstance } from './instance.js' 7 | import { LR } from './mixer/lr.js' 8 | import type { Model } from './mixer/model.js' 9 | import { type NRPN, splitNRPN } from './mixer/nrpn/nrpn.js' 10 | import { LevelNRPNCalculator } from './mixer/nrpn/source-to-sink.js' 11 | 12 | const White = combineRgb(255, 255, 255) 13 | const Black = combineRgb(0, 0, 0) 14 | 15 | // Doesn't this lint make *no sense* for intersections? The intersection of two 16 | // types that *do not* duplicate is just `never`, which makes any such 17 | // intersection's result totally vacuous...right? 18 | // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents 19 | type MuteType = keyof typeof MuteFeedbackId & keyof typeof MuteActionId 20 | 21 | export function getPresets(instance: sqInstance, model: Model): CompanionPresetDefinitions { 22 | const presets: CompanionPresetDefinitions = {} 23 | 24 | /* MUTE */ 25 | const createtMute = (category: string, label: string, type: MuteType, count: number): void => { 26 | for (let i = 0; i < count; i++) { 27 | const suffix = count > 1 ? `_${i}` : '' 28 | const maybeSpaceNum = count > 1 ? ` ${i + 1}` : '' 29 | presets[`preset_${MuteActionId[type]}${suffix}`] = { 30 | type: 'button', 31 | category, 32 | name: `${label}${maybeSpaceNum}`, 33 | style: { 34 | text: `${label}${maybeSpaceNum}`, 35 | size: 'auto', 36 | color: White, 37 | bgcolor: Black, 38 | }, 39 | steps: [ 40 | { 41 | down: [ 42 | { 43 | actionId: MuteActionId[type], 44 | options: { 45 | strip: i, 46 | mute: 0, 47 | }, 48 | }, 49 | ], 50 | up: [], 51 | }, 52 | ], 53 | feedbacks: [ 54 | { 55 | feedbackId: MuteFeedbackId[type], 56 | options: { 57 | channel: i, 58 | }, 59 | }, 60 | ], 61 | } 62 | } 63 | } 64 | 65 | createtMute('Mute Input', 'Input channel', 'MuteInputChannel', model.inputOutputCounts.inputChannel) 66 | createtMute('Mute Mix - Group', 'LR', 'MuteLR', model.inputOutputCounts.lr) 67 | createtMute('Mute Mix - Group', 'Aux', 'MuteMix', model.inputOutputCounts.mix) 68 | createtMute('Mute Mix - Group', 'Group', 'MuteGroup', model.inputOutputCounts.group) 69 | createtMute('Mute Mix - Group', 'Matrix', 'MuteMatrix', model.inputOutputCounts.matrix) 70 | createtMute('Mute FX', 'FX Send', 'MuteFXSend', model.inputOutputCounts.fxSend) 71 | createtMute('Mute FX', 'FX Return', 'MuteFXReturn', model.inputOutputCounts.fxReturn) 72 | createtMute('Mute DCA', 'DCA', 'MuteDCA', model.inputOutputCounts.dca) 73 | createtMute('Mute MuteGroup', 'MuteGroup', 'MuteMuteGroup', model.inputOutputCounts.muteGroup) 74 | 75 | /* TALKBACK*/ 76 | model.forEach('mix', (mix, mixLabel, mixDesc) => { 77 | presets[`preset_talkback_mix${mix}`] = { 78 | type: 'button', 79 | category: 'Talkback', 80 | name: `Talk to ${mixDesc}`, 81 | style: { 82 | text: `Talk to ${mixLabel}`, 83 | size: 'auto', 84 | color: White, 85 | bgcolor: Black, 86 | }, 87 | steps: [ 88 | { 89 | down: [ 90 | { 91 | actionId: AssignActionId.InputChannelToMix, 92 | options: { 93 | inputChannel: instance.options.talkbackChannel, 94 | mixAssign: [LR], 95 | mixActive: false, 96 | }, 97 | }, 98 | { 99 | actionId: AssignActionId.InputChannelToMix, 100 | options: { 101 | inputChannel: instance.options.talkbackChannel, 102 | mixAssign: [mix], 103 | mixActive: true, 104 | }, 105 | }, 106 | { 107 | actionId: LevelActionId.InputChannelLevelInMixOrLR, 108 | options: { 109 | input: instance.options.talkbackChannel, 110 | assign: mix, 111 | level: 49, 112 | }, 113 | }, 114 | { 115 | actionId: MuteActionId.MuteInputChannel, 116 | options: { 117 | strip: instance.options.talkbackChannel, 118 | mute: 2, 119 | }, 120 | }, 121 | ], 122 | up: [ 123 | { 124 | actionId: AssignActionId.InputChannelToMix, 125 | options: { 126 | inputChannel: instance.options.talkbackChannel, 127 | mixAssign: [mix], 128 | mixActive: false, 129 | }, 130 | }, 131 | { 132 | actionId: LevelActionId.InputChannelLevelInMixOrLR, 133 | options: { 134 | input: instance.options.talkbackChannel, 135 | assign: mix, 136 | level: 0, 137 | }, 138 | }, 139 | { 140 | actionId: MuteActionId.MuteInputChannel, 141 | options: { 142 | strip: instance.options.talkbackChannel, 143 | mute: 1, 144 | }, 145 | }, 146 | ], 147 | }, 148 | ], 149 | feedbacks: [], 150 | } 151 | }) 152 | 153 | /* MUTE + FADER LEVEL */ 154 | const muteWithFaderLevel = ( 155 | nrpn: NRPN<'level'>, 156 | ch: number, 157 | channelLabel: string, 158 | mix: number | 'lr', 159 | mixLabel: string, 160 | ): void => { 161 | const { MSB, LSB } = splitNRPN(nrpn) 162 | const label = `${channelLabel}\\n${mixLabel}\\n$(${instance.label}:level_${MSB}.${LSB}) dB` 163 | 164 | const mixId = mix === 'lr' ? 'lr' : `mix${mix}` 165 | presets[`preset_mute_input${ch}_${mixId}`] = { 166 | type: 'button', 167 | category: `Mt+dB CH-${mixLabel}`, 168 | name: label, 169 | style: { 170 | text: label, 171 | size: 'auto', 172 | color: White, 173 | bgcolor: Black, 174 | }, 175 | steps: [ 176 | { 177 | down: [ 178 | { 179 | actionId: MuteActionId.MuteInputChannel, 180 | options: { 181 | strip: ch, 182 | mute: 0, 183 | }, 184 | }, 185 | ], 186 | up: [], 187 | }, 188 | ], 189 | feedbacks: [ 190 | { 191 | feedbackId: MuteFeedbackId.MuteInputChannel, 192 | options: { 193 | channel: ch, 194 | }, 195 | }, 196 | ], 197 | } 198 | } 199 | 200 | // Input -> Mix 201 | const mixCalc = LevelNRPNCalculator.get(model, ['inputChannel', 'mix']) 202 | const lrCalc = LevelNRPNCalculator.get(model, ['inputChannel', 'lr']) 203 | model.forEach('inputChannel', (channel, channelLabel) => { 204 | model.forEach('lr', (lr, lrLabel) => { 205 | const nrpn = lrCalc.calculate(channel, lr) 206 | muteWithFaderLevel(nrpn, channel, channelLabel, 'lr', lrLabel) 207 | }) 208 | 209 | model.forEach('mix', (mix, mixLabel) => { 210 | const nrpn = mixCalc.calculate(channel, mix) 211 | 212 | muteWithFaderLevel(nrpn, channel, channelLabel, mix, mixLabel) 213 | }) 214 | }) 215 | /**/ 216 | 217 | return presets 218 | } 219 | -------------------------------------------------------------------------------- /src/mixer/nrpn/output.ts: -------------------------------------------------------------------------------- 1 | import { getOutputCalculator, type InputOutputType, type Model } from '../model.js' 2 | import { calculateNRPN, type NRPN, toNRPN, type NRPNType, type Param, type UnbrandedParam } from './nrpn.js' 3 | 4 | type OutputInfo = { 5 | /** The base MSB/LSB for adjusting output level. */ 6 | readonly level: UnbrandedParam 7 | 8 | /** 9 | * The base MSB/LSB for adjusting output balance. Only stereo 10 | * outputs define this; mono outputs omit it. 11 | */ 12 | readonly panBalance?: UnbrandedParam 13 | } 14 | 15 | /** 16 | * The type of all NRPNs that apply to at least one mixer sink being used as an 17 | * output. 18 | */ 19 | export type OutputNRPN = keyof Required 20 | 21 | type OutputParametersType = Partial>>> 22 | 23 | /** 24 | * Base parameter MSB/LSB values for mixer sinks set as mixer outputs. Note 25 | * that LR is considered to be a special category, distinct from mixes, that 26 | * consists of only the single LR mix. 27 | * 28 | * These values are the pairs in the columns of the relevant tables in the 29 | * [SQ MIDI Protocol document](https://www.allen-heath.com/content/uploads/2023/11/SQ-MIDI-Protocol-Issue5.pdf). 30 | */ 31 | const OutputParameterBaseRaw = { 32 | lr: { 33 | level: { MSB: 0x4f, LSB: 0x00 }, 34 | panBalance: { MSB: 0x5f, LSB: 0x00 }, 35 | }, 36 | mix: { 37 | level: { MSB: 0x4f, LSB: 0x01 }, 38 | panBalance: { MSB: 0x5f, LSB: 0x01 }, 39 | }, 40 | // XXX Note that with 1.6.* firmware, if any matrixes have been split into 41 | // two mono matrixes, this likely mishandles stuff, as the first 1.6.* 42 | // release explicitly doesn't provide MIDI control of mono matrixes, and 43 | // this might affect stereo matrixes as well. 44 | matrix: { 45 | level: { MSB: 0x4f, LSB: 0x11 }, 46 | panBalance: { MSB: 0x5f, LSB: 0x11 }, 47 | }, 48 | fxSend: { 49 | level: { MSB: 0x4f, LSB: 0x0d }, 50 | }, 51 | dca: { 52 | level: { MSB: 0x4f, LSB: 0x20 }, 53 | }, 54 | } as const satisfies OutputParametersType 55 | 56 | type ApplyOutputBranding = { 57 | [Sink in keyof T]: T[Sink] extends OutputInfo 58 | ? { 59 | [NRPN in keyof T[Sink]]: T[Sink][NRPN] extends UnbrandedParam 60 | ? NRPN extends NRPNType 61 | ? Param 62 | : never 63 | : never 64 | } 65 | : T[Sink] extends undefined 66 | ? Sink | undefined 67 | : never 68 | } 69 | 70 | const OutputParameterBase = OutputParameterBaseRaw as ApplyOutputBranding 71 | 72 | type OutputParameterBaseType = typeof OutputParameterBase 73 | 74 | type OutputSinkMatchesNRPN< 75 | Sink extends keyof OutputParameterBaseType, 76 | NRPN extends OutputNRPN, 77 | > = Sink extends keyof OutputParameterBaseType 78 | ? [NRPN] extends [keyof OutputParameterBaseType[Sink]] 79 | ? [Sink, NRPN] 80 | : never 81 | : never 82 | 83 | /** Enumerate all `Sink` usable as output supporting the given NRPN. */ 84 | export type SinkAsOutputForNRPN = OutputSinkMatchesNRPN[0] 85 | 86 | class OutputNRPNCalculator { 87 | readonly #inputOutputCounts 88 | readonly #sinkType: SinkAsOutputForNRPN 89 | readonly #base: Param 90 | 91 | constructor(model: Model, nrpnType: T, sinkType: SinkAsOutputForNRPN) { 92 | this.#inputOutputCounts = model.inputOutputCounts 93 | this.#sinkType = sinkType 94 | 95 | // TypeScript doesn't preserve awareness of the validity of the property 96 | // walk described by `sinkType` and `nrpnType` after 97 | // `OutputSinkMatchesNRPN` does its thing. Do enough casting to make 98 | // the property access sequence type-check. 99 | const info = OutputParameterBase[sinkType] as Required 100 | this.#base = info[nrpnType] as Param 101 | } 102 | 103 | calculate(sink: number): NRPN { 104 | if (this.#inputOutputCounts[this.#sinkType] <= sink) { 105 | throw new Error(`${this.#sinkType}=${sink} is invalid`) 106 | } 107 | 108 | return calculateNRPN(toNRPN(this.#base), sink) 109 | } 110 | } 111 | 112 | export class OutputLevelNRPNCalculator extends OutputNRPNCalculator<'level'> { 113 | constructor(model: Model, sinkType: SinkAsOutputForNRPN<'level'>) { 114 | super(model, 'level', sinkType) 115 | } 116 | 117 | static get(model: Model, sinkType: SinkAsOutputForNRPN<'level'>): OutputLevelNRPNCalculator { 118 | return getOutputCalculator(model, 'level', sinkType, OutputLevelNRPNCalculator) 119 | } 120 | } 121 | 122 | export class OutputBalanceNRPNCalculator extends OutputNRPNCalculator<'panBalance'> { 123 | constructor(model: Model, sinkType: SinkAsOutputForNRPN<'panBalance'>) { 124 | super(model, 'panBalance', sinkType) 125 | } 126 | 127 | static get(model: Model, sinkType: SinkAsOutputForNRPN<'panBalance'>): OutputBalanceNRPNCalculator { 128 | return getOutputCalculator(model, 'panBalance', sinkType, OutputBalanceNRPNCalculator) 129 | } 130 | } 131 | 132 | export type OutputCalculatorForNRPN = NRPN extends 'level' 133 | ? typeof OutputLevelNRPNCalculator 134 | : NRPN extends 'panBalance' 135 | ? typeof OutputBalanceNRPNCalculator 136 | : never 137 | 138 | export type SinkToCalculator = Record< 139 | SinkAsOutputForNRPN, 140 | InstanceType> | null 141 | > 142 | 143 | export type OutputCalculatorCache = { 144 | readonly [NRPN in OutputNRPN]: SinkToCalculator 145 | } 146 | 147 | /** 148 | * The functor type passed to `forEachOutputLevel`. Each invocation will be 149 | * for a particular output level, e.g. a mix, or LR, or an FX send, etc. The 150 | * functor will be invoked with the NRPN pair for the level and a readable 151 | * description of the particular output. 152 | */ 153 | type OutputLevelFunctor = (nrpn: NRPN<'level'>, outputDesc: string) => void 154 | 155 | /** 156 | * For each sink usable as output with adjustable level, invoke the given 157 | * function. 158 | * 159 | * @param model 160 | * The SQ mixer model. 161 | * @param f 162 | * The function to invoke. For each category of output with a level, this 163 | * function is called once for each output within that category. For example, 164 | * given that there's a mix category of output and supposing a mixer model 165 | * with twelve mixes, the function is invoked 12 times for their possible 166 | * level NRPNs. 167 | */ 168 | export function forEachOutputLevel(model: Model, f: OutputLevelFunctor): void { 169 | for (const sinkType of Object.entries(OutputParameterBase).flatMap(([sinkType, params]) => { 170 | return 'level' in params ? sinkType : [] 171 | })) { 172 | const outputType = sinkType as SinkAsOutputForNRPN<'level'> 173 | 174 | const calc = new OutputLevelNRPNCalculator(model, outputType) 175 | model.forEach(outputType, (output, _outputLabel, outputDesc) => { 176 | f(calc.calculate(output), outputDesc) 177 | }) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/midi/parse/channel-parser.ts: -------------------------------------------------------------------------------- 1 | import { makeNRPN, type NRPN } from '../../mixer/nrpn/nrpn.js' 2 | import { manyPrettyBytes, prettyByte, prettyBytes } from '../../utils/pretty.js' 3 | import EventEmitter from 'eventemitter3' 4 | 5 | export interface MixerMessageEvents { 6 | // (It's unfortunate that this interface encodes NRPN MSB/LSB parameters to 7 | // this degree. But the underlying modeling of the mixer reiterates MSB/LSB 8 | // in a bunch of places -- in `CallbackInfo.mute` keys, in variable names, 9 | // in `lastValue` and `fdbState` keys, in conversions between level encoding 10 | // and level value -- so we can't really do any better.) 11 | 12 | /** A scene change to `newScene` (zero-based) occurred. */ 13 | scene: [newScene: number] 14 | 15 | /** 16 | * The input/output identified by MSB/LSB was muted (`vf=1`) or unmuted 17 | * (`vf=0`). 18 | */ 19 | mute: [nrpn: NRPN<'mute'>, vf: number] 20 | 21 | /** 22 | * The signal level identified by MSB/LSB was set to the level specified by 23 | * `vc`/`vf` with respect to the active fader law. 24 | */ 25 | fader_level: [nrpn: NRPN<'level'>, vc: number, vf: number] 26 | 27 | /** 28 | * The pan/balance of a signal in a mix identified by MSB/LSB was set to the 29 | * level specified by `vc`/`vf`. 30 | */ 31 | pan_level: [nrpn: NRPN<'panBalance'>, vc: number, vf: number] 32 | } 33 | 34 | /** 35 | * Parse MIDI replies sent by the mixer in the mixer MIDI channel, and emit 36 | * events corresponding to the received mixer messages. 37 | */ 38 | export class ChannelParser extends EventEmitter { 39 | #gen: Generator 40 | 41 | /** 42 | * Create a parser for messages sent from the mixer on the MIDI channel 43 | * defined in mixer settings. 44 | * 45 | * After the parser is created, add listeners for the various possible 46 | * message events to handle them. 47 | * 48 | * @param verboseLog 49 | * A function that writes to the log only if verbose logging was enabled. 50 | */ 51 | constructor(verboseLog: (msg: string) => void) { 52 | super() 53 | 54 | this.#gen = this.#parseMixerMessages(verboseLog) 55 | 56 | // Send `void` to `this.#gen` and advance it to the first `yield` in 57 | // `#parseMixerMessages`. The next call to `handleMessage` will supply 58 | // the message that that `yield` evaluates to, and the one after that to 59 | // the second `yield` reached in execution, and so on. 60 | this.#gen.next() 61 | } 62 | 63 | /** Pass a newly tokenized MIDI message to the parser. */ 64 | handleMessage(message: number[]): void { 65 | this.#gen.next(message) 66 | } 67 | 68 | /** 69 | * Parse mixer commands from MIDI messages in a single MIDI channel. 70 | * 71 | * To use this function, first call it to produce a generator. Then call 72 | * `.next()` on it a single time to prime it for use. Finally, repeatedly 73 | * call call `.next(message)` on it with each MIDI message received in the 74 | * channel. The generator will process messages sent into the generator, 75 | * recognize complete and coherent mixer commands comprised of them, then 76 | * emit events at `this` for each mixer command received. 77 | * 78 | * @param verboseLog 79 | * A function that writes to the log only if verbose logging was enabled. 80 | */ 81 | *#parseMixerMessages(verboseLog: (msg: string) => void): Generator { 82 | read_message: for (;;) { 83 | let first = yield 84 | 85 | parse_message: for (;;) { 86 | // [BN xx yy] ... 87 | if ((first[0] & 0xf0) === 0xb0) { 88 | // Scene change 89 | // [BN 00 aa] [CN bb] 90 | if (first[1] === 0x00) { 91 | const second = yield 92 | if ((second[0] & 0xf0) !== 0xc0) { 93 | verboseLog(`Malformed scene change ${manyPrettyBytes(first, second)}, ignoring`) 94 | first = second 95 | continue parse_message 96 | } 97 | 98 | const aa = first[2] 99 | const bb = second[1] 100 | 101 | const newScene = ((aa & 0x7f) << 7) + bb 102 | this.emit('scene', newScene) 103 | continue read_message 104 | } // [BN 00 aa] [CN bb] 105 | 106 | // NRPN data message: 107 | // [BN 63 MB] [BN 62 LB] [BN 06 VC] [BN 26 VF] 108 | if (first[1] === 0x63) { 109 | // [BN 62 LB] 110 | const second = yield 111 | if ((second[0] & 0xf0) !== 0xb0 || second[1] !== 0x62) { 112 | verboseLog(`Second message in NRPN data is malformed, ignoring: ${manyPrettyBytes(first, second)}`) 113 | first = second 114 | continue parse_message 115 | } 116 | 117 | // [BN 06 VC] 118 | const third = yield 119 | if ((third[0] & 0xf0) !== 0xb0 || third[1] !== 0x06) { 120 | verboseLog(`Third message in NRPN data is malformed, ignoring: ${manyPrettyBytes(first, second, third)}`) 121 | first = third 122 | continue parse_message 123 | } 124 | 125 | // [BN yy zz] 126 | const fourth = yield 127 | if ((fourth[0] & 0xf0) !== 0xb0 || fourth[1] !== 0x26) { 128 | verboseLog( 129 | `Fourth message in NRPN data is malformed, ignoring: ${manyPrettyBytes(first, second, third, fourth)}`, 130 | ) 131 | first = fourth 132 | continue parse_message 133 | } 134 | 135 | const [msb, lsb, vc, vf] = [first[2], second[2], third[2], fourth[2]] 136 | 137 | // Mute 138 | if (msb === 0x00 || msb === 0x02 || msb === 0x04) { 139 | if (vc === 0x00 && vf < 0x02) { 140 | this.emit('mute', makeNRPN(msb, lsb), vf) 141 | } else { 142 | verboseLog(`Malformed mute message, ignoring: ${manyPrettyBytes(first, second, third, fourth)}`) 143 | } 144 | } 145 | // Fader level 146 | else if (0x40 <= msb && msb <= 0x4f) { 147 | this.emit('fader_level', makeNRPN(msb, lsb), vc, vf) 148 | } 149 | // Pan Level 150 | else if (0x50 <= msb && msb <= 0x5f) { 151 | this.emit('pan_level', makeNRPN(msb, lsb), vc, vf) 152 | } else { 153 | verboseLog( 154 | `Unhandled MSB/LSB ${prettyByte(msb)}/${prettyByte(lsb)} in NRPN data message ${manyPrettyBytes(first, second, third, fourth)}`, 155 | ) 156 | } 157 | 158 | continue read_message 159 | } // [BN 63 MB] [BN 62 LB] [BN 06 VC] [BN 26 VF] 160 | 161 | verboseLog(`Unrecognized controller ${prettyByte(first[1])} in Control Change ${prettyBytes(first)}`) 162 | continue read_message 163 | } // [BN xx yy] ... 164 | 165 | // Ignore unrecognized messages. This is not optional: the SQ-5 166 | // sends scene change messages as [BN 00 BK | CN PG 00] rather than 167 | // [BN 00 BK | CN PG] -- which per MIDI "running status" parsing is 168 | // the same as [BN 00 BK | CN PG | CN 00], and the [CN 00] ends up 169 | // unrecognized. 170 | verboseLog(`Unrecognized channel message, ignoring: ${prettyBytes(first)}`) 171 | continue read_message 172 | } // parse_message: for(;;) 173 | } // read_message: for (;;) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/actions/mute.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CompanionOptionValues, 3 | type CompanionInputFieldDropdown, 4 | type DropdownChoice, 5 | } from '@companion-module/base' 6 | import { type ActionDefinitions } from './actionid.js' 7 | import { type Choices } from '../choices.js' 8 | import type { sqInstance } from '../instance.js' 9 | import { type Mixer } from '../mixer/mixer.js' 10 | import { type InputOutputType, type Model } from '../mixer/model.js' 11 | import { MuteOperation } from '../mixer/mixer.js' 12 | import { toSourceOrSink } from './to-source-or-sink.js' 13 | import { repr } from '../utils/pretty.js' 14 | 15 | /** 16 | * Action IDs for all actions that mute, unmute, or toggle muting of a mixer 17 | * input/output. 18 | */ 19 | export enum MuteActionId { 20 | MuteInputChannel = 'mute_input', 21 | MuteLR = 'mute_lr', 22 | MuteMix = 'mute_aux', 23 | MuteGroup = 'mute_group', 24 | MuteMatrix = 'mute_matrix', 25 | MuteFXSend = 'mute_fx_send', 26 | MuteFXReturn = 'mute_fx_return', 27 | MuteDCA = 'mute_dca', 28 | MuteMuteGroup = 'mute_mutegroup', 29 | } 30 | 31 | function StripOption(label: string, choices: DropdownChoice[]): CompanionInputFieldDropdown { 32 | return { 33 | type: 'dropdown', 34 | label, 35 | id: 'strip', 36 | default: 0, 37 | choices, 38 | minChoicesForSearch: 0, 39 | } 40 | } 41 | 42 | const MuteOption = { 43 | type: 'dropdown', 44 | label: 'Mute', 45 | id: 'mute', 46 | default: 0, 47 | choices: [ 48 | { label: 'Toggle', id: 0 }, 49 | { label: 'On', id: 1 }, 50 | { label: 'Off', id: 2 }, 51 | ], 52 | } satisfies CompanionInputFieldDropdown 53 | 54 | type MuteOptions = { 55 | strip: number 56 | op: MuteOperation 57 | } 58 | 59 | /** 60 | * Convert options for a mute action to well-typed values. 61 | * 62 | * @param instance 63 | * The active module instance. 64 | * @param model 65 | * The mixer model. 66 | * @param options 67 | * Options passed for an action callback. 68 | * @param type 69 | * The type of the strip being acted upon. 70 | * @returns 71 | * The strip and mute operation to perform if they were validly encoded. 72 | * Otherwise return null and note the failure in the log. 73 | */ 74 | function getMuteOptions( 75 | instance: sqInstance, 76 | model: Model, 77 | options: CompanionOptionValues, 78 | type: InputOutputType, 79 | ): MuteOptions | null { 80 | const strip = toSourceOrSink(instance, model, options.strip, type) 81 | if (strip === null) { 82 | return null 83 | } 84 | 85 | const muteOption = options.mute 86 | const option = Number(muteOption) 87 | let op 88 | switch (option) { 89 | case 0: 90 | op = MuteOperation.Toggle 91 | break 92 | case 1: 93 | op = MuteOperation.On 94 | break 95 | case 2: 96 | op = MuteOperation.Off 97 | break 98 | default: 99 | instance.log('error', `Mute option has invalid value, action aborted: ${repr(muteOption)}`) 100 | return null 101 | } 102 | 103 | return { strip, op } 104 | } 105 | 106 | /** 107 | * Generate action definitions for muting mixer sources and sinks: input 108 | * channels, mixes, groups, FX sends and returns, etc. 109 | * 110 | * @param instance 111 | * The instance for which actions are being generated. 112 | * @param mixer 113 | * The mixer object to use when executing the actions. 114 | * @returns 115 | * The set of all mute action definitions. 116 | */ 117 | export function muteActions(instance: sqInstance, mixer: Mixer, choices: Choices): ActionDefinitions { 118 | const model = mixer.model 119 | 120 | return { 121 | [MuteActionId.MuteInputChannel]: { 122 | name: 'Mute Input', 123 | options: [StripOption('Input Channel', choices.inputChannels), MuteOption], 124 | callback: async ({ options: opt }) => { 125 | const options = getMuteOptions(instance, model, opt, 'inputChannel') 126 | if (options === null) { 127 | return 128 | } 129 | 130 | const { strip, op } = options 131 | mixer.muteInputChannel(strip, op) 132 | }, 133 | }, 134 | 135 | [MuteActionId.MuteLR]: { 136 | name: 'Mute LR', 137 | options: [ 138 | { 139 | type: 'dropdown', 140 | label: 'LR', 141 | id: 'strip', 142 | default: 0, 143 | choices: [{ label: `LR`, id: 0 }], 144 | minChoicesForSearch: 99, 145 | }, 146 | { 147 | type: 'dropdown', 148 | label: 'Mute', 149 | id: 'mute', 150 | default: 0, 151 | choices: [ 152 | { label: 'Toggle', id: 0 }, 153 | { label: 'On', id: 1 }, 154 | { label: 'Off', id: 2 }, 155 | ], 156 | }, 157 | ], 158 | callback: async ({ options: opt }) => { 159 | const options = getMuteOptions(instance, model, opt, 'lr') 160 | if (options === null) { 161 | return 162 | } 163 | 164 | const { op } = options 165 | mixer.muteLR(op) 166 | }, 167 | }, 168 | 169 | [MuteActionId.MuteMix]: { 170 | name: 'Mute Aux', 171 | options: [StripOption('Aux', choices.mixes), MuteOption], 172 | callback: async ({ options: opt }) => { 173 | const options = getMuteOptions(instance, model, opt, 'mix') 174 | if (options === null) { 175 | return 176 | } 177 | 178 | const { strip, op } = options 179 | mixer.muteMix(strip, op) 180 | }, 181 | }, 182 | [MuteActionId.MuteGroup]: { 183 | name: 'Mute Group', 184 | options: [StripOption('Group', choices.groups), MuteOption], 185 | callback: async ({ options: opt }) => { 186 | const options = getMuteOptions(instance, model, opt, 'group') 187 | if (options === null) { 188 | return 189 | } 190 | 191 | const { strip, op } = options 192 | mixer.muteGroup(strip, op) 193 | }, 194 | }, 195 | [MuteActionId.MuteMatrix]: { 196 | name: 'Mute Matrix', 197 | options: [StripOption('Matrix', choices.matrixes), MuteOption], 198 | callback: async ({ options: opt }) => { 199 | const options = getMuteOptions(instance, model, opt, 'matrix') 200 | if (options === null) { 201 | return 202 | } 203 | 204 | const { strip, op } = options 205 | mixer.muteMatrix(strip, op) 206 | }, 207 | }, 208 | [MuteActionId.MuteFXSend]: { 209 | name: 'Mute FX Send', 210 | options: [StripOption('FX Send', choices.fxSends), MuteOption], 211 | callback: async ({ options: opt }) => { 212 | const options = getMuteOptions(instance, model, opt, 'fxSend') 213 | if (options === null) { 214 | return 215 | } 216 | 217 | const { strip, op } = options 218 | mixer.muteFXSend(strip, op) 219 | }, 220 | }, 221 | [MuteActionId.MuteFXReturn]: { 222 | name: 'Mute FX Return', 223 | options: [StripOption('FX Return', choices.fxReturns), MuteOption], 224 | callback: async ({ options: opt }) => { 225 | const options = getMuteOptions(instance, model, opt, 'fxReturn') 226 | if (options === null) { 227 | return 228 | } 229 | 230 | const { strip, op } = options 231 | mixer.muteFXReturn(strip, op) 232 | }, 233 | }, 234 | [MuteActionId.MuteDCA]: { 235 | name: 'Mute DCA', 236 | options: [StripOption('DCA', choices.dcas), MuteOption], 237 | callback: async ({ options: opt }) => { 238 | const options = getMuteOptions(instance, model, opt, 'dca') 239 | if (options === null) { 240 | return 241 | } 242 | 243 | const { strip, op } = options 244 | mixer.muteDCA(strip, op) 245 | }, 246 | }, 247 | [MuteActionId.MuteMuteGroup]: { 248 | name: 'Mute MuteGroup', 249 | options: [StripOption('MuteGroup', choices.muteGroups), MuteOption], 250 | callback: async ({ options: opt }) => { 251 | const options = getMuteOptions(instance, model, opt, 'muteGroup') 252 | if (options === null) { 253 | return 254 | } 255 | 256 | const { strip, op } = options 257 | mixer.muteMuteGroup(strip, op) 258 | }, 259 | }, 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/mixer/nrpn/balance.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { type InputOutputType, Model } from '../model.js' 3 | import { splitNRPN, type UnbrandedParam } from './nrpn.js' 4 | import { BalanceNRPNCalculator, type SourceSinkForNRPN } from './source-to-sink.js' 5 | 6 | describe('BalanceNRPNCalculator', () => { 7 | const model = new Model('SQ5') 8 | 9 | type BalanceOK = { type: 'ok'; result: UnbrandedParam } 10 | type BalanceError = { type: 'error'; match: RegExp | string } 11 | type BalanceBehavior = BalanceOK | BalanceError 12 | 13 | type BalanceTest = [number, number, BalanceBehavior] 14 | 15 | type BalanceTests = [ 16 | // top left corner always OK 17 | [0, 0, BalanceOK], 18 | BalanceTest, 19 | ...BalanceTest[], 20 | // below, below right, and right of bottom left corner are OOB 21 | [number, 0, BalanceError], 22 | [0, number, BalanceError], 23 | [number, number, BalanceError], 24 | ] 25 | 26 | type GenerateAllBalanceTests> = { 27 | [Source in SourceSink[0]]: { 28 | [Sink in (SourceSink & [Source, InputOutputType])[1]]: BalanceTests 29 | } 30 | } 31 | 32 | const tests = { 33 | inputChannel: { 34 | mix: [ 35 | [0, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x44 } }], 36 | [3, 7, { type: 'ok', result: { MSB: 0x50, LSB: 0x6f } }], 37 | [15, 0, { type: 'ok', result: { MSB: 0x51, LSB: 0x78 } }], 38 | [32, 7, { type: 'ok', result: { MSB: 0x53, LSB: 0x4b } }], 39 | [47, 11, { type: 'ok', result: { MSB: 0x55, LSB: 0x03 } }], 40 | [48, 0, { type: 'error', match: 'inputChannel=48 is invalid' }], 41 | [0, 12, { type: 'error', match: 'mix=12 is invalid' }], 42 | [48, 12, { type: 'error', match: 'inputChannel=48 is invalid' }], 43 | ], 44 | lr: [ 45 | [0, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x00 } }], 46 | [3, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x03 } }], 47 | [14, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x0e } }], 48 | [27, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x1b } }], 49 | [47, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x2f } }], 50 | [48, 0, { type: 'error', match: 'inputChannel=48 is invalid' }], 51 | [0, 1, { type: 'error', match: 'lr=1 is invalid' }], 52 | [48, 1, { type: 'error', match: 'inputChannel=48 is invalid' }], 53 | ], 54 | }, 55 | fxReturn: { 56 | mix: [ 57 | [0, 0, { type: 'ok', result: { MSB: 0x56, LSB: 0x14 } }], 58 | [1, 10, { type: 'ok', result: { MSB: 0x56, LSB: 0x2a } }], 59 | [5, 6, { type: 'ok', result: { MSB: 0x56, LSB: 0x56 } }], 60 | [7, 11, { type: 'ok', result: { MSB: 0x56, LSB: 0x73 } }], 61 | [8, 0, { type: 'error', match: 'fxReturn=8 is invalid' }], 62 | [0, 12, { type: 'error', match: 'mix=12 is invalid' }], 63 | [8, 12, { type: 'error', match: 'fxReturn=8 is invalid' }], 64 | ], 65 | lr: [ 66 | [0, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x3c } }], 67 | [5, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x41 } }], 68 | [7, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x43 } }], 69 | [8, 0, { type: 'error', match: 'fxReturn=8 is invalid' }], 70 | [0, 1, { type: 'error', match: 'lr=1 is invalid' }], 71 | [8, 1, { type: 'error', match: 'fxReturn=8 is invalid' }], 72 | ], 73 | }, 74 | group: { 75 | mix: [ 76 | [0, 0, { type: 'ok', result: { MSB: 0x55, LSB: 0x04 } }], 77 | [0, 10, { type: 'ok', result: { MSB: 0x55, LSB: 0x0e } }], 78 | [2, 7, { type: 'ok', result: { MSB: 0x55, LSB: 0x23 } }], 79 | [6, 1, { type: 'ok', result: { MSB: 0x55, LSB: 0x4d } }], 80 | [10, 0, { type: 'ok', result: { MSB: 0x55, LSB: 0x7c } }], 81 | [12, 0, { type: 'error', match: 'group=12 is invalid' }], 82 | [0, 12, { type: 'error', match: 'mix=12 is invalid' }], 83 | [12, 12, { type: 'error', match: 'group=12 is invalid' }], 84 | ], 85 | lr: [ 86 | [0, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x30 } }], 87 | [3, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x33 } }], 88 | [8, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x38 } }], 89 | [11, 0, { type: 'ok', result: { MSB: 0x50, LSB: 0x3b } }], 90 | [12, 0, { type: 'error', match: 'group=12 is invalid' }], 91 | [0, 1, { type: 'error', match: 'lr=1 is invalid' }], 92 | [12, 1, { type: 'error', match: 'group=12 is invalid' }], 93 | ], 94 | matrix: [ 95 | [0, 0, { type: 'ok', result: { MSB: 0x5e, LSB: 0x4b } }], 96 | [5, 1, { type: 'ok', result: { MSB: 0x5e, LSB: 0x5b } }], 97 | [11, 2, { type: 'ok', result: { MSB: 0x5e, LSB: 0x6e } }], 98 | [12, 0, { type: 'error', match: 'group=12 is invalid' }], 99 | [0, 3, { type: 'error', match: 'matrix=3 is invalid' }], 100 | [12, 3, { type: 'error', match: 'group=12 is invalid' }], 101 | ], 102 | }, 103 | lr: { 104 | matrix: [ 105 | [0, 0, { type: 'ok', result: { MSB: 0x5e, LSB: 0x24 } }], 106 | [0, 1, { type: 'ok', result: { MSB: 0x5e, LSB: 0x25 } }], 107 | [0, 2, { type: 'ok', result: { MSB: 0x5e, LSB: 0x26 } }], 108 | [1, 0, { type: 'error', match: 'lr=1 is invalid' }], 109 | [0, 3, { type: 'error', match: 'matrix=3 is invalid' }], 110 | [1, 3, { type: 'error', match: 'lr=1 is invalid' }], 111 | ], 112 | }, 113 | mix: { 114 | matrix: [ 115 | [0, 0, { type: 'ok', result: { MSB: 0x5e, LSB: 0x27 } }], 116 | [0, 1, { type: 'ok', result: { MSB: 0x5e, LSB: 0x28 } }], 117 | [0, 2, { type: 'ok', result: { MSB: 0x5e, LSB: 0x29 } }], 118 | [5, 1, { type: 'ok', result: { MSB: 0x5e, LSB: 0x37 } }], 119 | [11, 2, { type: 'ok', result: { MSB: 0x5e, LSB: 0x4a } }], 120 | [12, 0, { type: 'error', match: 'mix=12 is invalid' }], 121 | [0, 3, { type: 'error', match: 'matrix=3 is invalid' }], 122 | [12, 3, { type: 'error', match: 'mix=12 is invalid' }], 123 | ], 124 | }, 125 | } satisfies GenerateAllBalanceTests> 126 | 127 | function* balanceSourceSinks(): Generator<{ sourceSink: SourceSinkForNRPN<'panBalance'> }> { 128 | for (const [sourceType, sinkTests] of Object.entries(tests)) { 129 | for (const sinkType of Object.keys(sinkTests)) { 130 | yield { sourceSink: [sourceType, sinkType] as SourceSinkForNRPN<'panBalance'> } 131 | } 132 | } 133 | } 134 | 135 | test.each([...balanceSourceSinks()])( 136 | 'BalanceNRPNCalculator.get(model, [$sourceSink.0, $sourceSink.1]) === BalanceNRPNCalculator.get(model, [$sourceSink.0, $sourceSink.1])', 137 | ({ sourceSink }) => { 138 | expect(BalanceNRPNCalculator.get(model, sourceSink)).toBe(BalanceNRPNCalculator.get(model, sourceSink)) 139 | }, 140 | ) 141 | 142 | function* sourceSinkTests(): Generator<{ 143 | calc: BalanceNRPNCalculator 144 | sourceSink: SourceSinkForNRPN<'assign'> 145 | source: number 146 | sink: number 147 | behavior: BalanceBehavior 148 | }> { 149 | for (const [sourceType, sinkTests] of Object.entries(tests)) { 150 | for (const [sinkType, tests] of Object.entries(sinkTests)) { 151 | const sourceSink = [sourceType, sinkType] as SourceSinkForNRPN<'panBalance'> 152 | const calc = BalanceNRPNCalculator.get(model, sourceSink) 153 | for (const [source, sink, behavior] of tests as BalanceTests) { 154 | yield { calc, sourceSink, source, sink, behavior } 155 | } 156 | } 157 | } 158 | } 159 | 160 | test.each([...sourceSinkTests()])( 161 | 'BalanceNRPNCalculator.get(model, [$sourceSink.0, $sourceSink.1]).calculate($source, $sink)', 162 | ({ calc, source, sink, behavior }) => { 163 | switch (behavior.type) { 164 | case 'ok': 165 | expect(splitNRPN(calc.calculate(source, sink))).toEqual(behavior.result) 166 | break 167 | case 'error': 168 | expect(() => calc.calculate(source, sink)).toThrow(behavior.match) 169 | break 170 | default: 171 | expect('missing').toBe('case') 172 | } 173 | }, 174 | ) 175 | }) 176 | -------------------------------------------------------------------------------- /src/mixer/model.ts: -------------------------------------------------------------------------------- 1 | import { type ModelId, SQModels } from './models.js' 2 | import { 3 | type OutputCalculatorCache, 4 | type OutputCalculatorForNRPN, 5 | type OutputNRPN, 6 | type SinkAsOutputForNRPN, 7 | type SinkToCalculator, 8 | } from './nrpn/output.js' 9 | import { 10 | type SourceSinkCalculatorForNRPN, 11 | type SourceSinkNRPN, 12 | type SourceSinkForNRPN, 13 | type SourceToSinkCalculatorCache, 14 | } from './nrpn/source-to-sink.js' 15 | 16 | type ForEachFunctor = (n: number, label: string, desc: string) => void 17 | 18 | /** A record of the count of inputs, outputs, and soft keys on an SQ mixer. */ 19 | type MixerCounts = { 20 | inputChannel: number 21 | mix: number 22 | group: number 23 | fxReturn: number 24 | fxSend: number 25 | matrix: number 26 | dca: number 27 | muteGroup: number 28 | 29 | // LR isn't a mix in the same sense as all other mixes, because the base 30 | // NRPN for source-to-LR mappings is unrelated to the base for source-to-mix 31 | // mappings. We therefore treat LR as a separate single-element category. 32 | lr: 1 33 | 34 | softKey: number 35 | } 36 | 37 | /** The type of all inputs and outputs on an SQ mixer. */ 38 | export type InputOutputType = Exclude 39 | 40 | type LabelDesc = { 41 | readonly pairs: (readonly [string, string])[] 42 | readonly generate: (i: number) => readonly [string, string] 43 | } 44 | 45 | let outputCalculatorCache: (model: Model) => OutputCalculatorCache 46 | let sourceSinkCalculatorCache: (model: Model) => SourceToSinkCalculatorCache 47 | 48 | export class Model { 49 | /** Counts of all inputs/outputs for this mixer model. */ 50 | readonly inputOutputCounts: MixerCounts 51 | 52 | /** The number of softkeys on the mixer. */ 53 | softKeys: number 54 | 55 | /** The number of rotaries on the mixer. */ 56 | rotaryKeys: number 57 | 58 | /** The number of scenes that can be stored in the mixer. */ 59 | scenes: number 60 | 61 | /** Create a representation of a mixer identified by `modelId`. */ 62 | constructor(modelId: ModelId) { 63 | const sqModel = SQModels[modelId] 64 | 65 | this.inputOutputCounts = { 66 | inputChannel: sqModel.chCount, 67 | mix: sqModel.mixCount, 68 | group: sqModel.grpCount, 69 | fxReturn: sqModel.fxrCount, 70 | fxSend: sqModel.fxsCount, 71 | matrix: sqModel.mtxCount, 72 | dca: sqModel.dcaCount, 73 | muteGroup: sqModel.muteGroupCount, 74 | 75 | lr: 1, 76 | 77 | softKey: sqModel.softKeyCount, 78 | } 79 | 80 | this.softKeys = sqModel.softKeyCount 81 | this.rotaryKeys = sqModel.RotaryKey 82 | this.scenes = sqModel.sceneCount 83 | } 84 | 85 | readonly #labelsDescs: Record = { 86 | inputChannel: { 87 | pairs: [], 88 | generate(channel: number) { 89 | const label = `CH ${channel + 1}` 90 | return [label, label] 91 | }, 92 | }, 93 | mix: { 94 | pairs: [], 95 | generate: (mix) => [`AUX ${mix + 1}`, `Aux ${mix + 1}`], 96 | }, 97 | group: { 98 | pairs: [], 99 | generate: (group: number) => [`GROUP ${group + 1}`, `Group ${group + 1}`], 100 | }, 101 | fxReturn: { 102 | pairs: [], 103 | generate: (fxr: number) => [`FX RETURN ${fxr + 1}`, `FX Return ${fxr + 1}`], 104 | }, 105 | fxSend: { 106 | pairs: [], 107 | generate: (fxs: number) => [`FX SEND ${fxs + 1}`, `FX Send ${fxs + 1}`], 108 | }, 109 | matrix: { 110 | pairs: [], 111 | generate: (matrix: number) => [`MATRIX ${matrix + 1}`, `Matrix ${matrix + 1}`], 112 | }, 113 | dca: { 114 | pairs: [], 115 | generate: (dca: number) => { 116 | const label = `DCA ${dca + 1}` 117 | return [label, label] 118 | }, 119 | }, 120 | muteGroup: { 121 | pairs: [], 122 | generate: (muteGroup: number) => { 123 | const label = `MuteGroup ${muteGroup + 1}` 124 | return [label, label] 125 | }, 126 | }, 127 | lr: { 128 | pairs: [], 129 | generate: (_lr: number) => { 130 | // Note: `_lr === 0` here but `LR === 99`. 131 | return ['LR', 'LR'] 132 | }, 133 | }, 134 | 135 | softKey: { 136 | pairs: [], 137 | generate: (key: number) => [`SOFTKEY ${key + 1}`, `SoftKey ${key + 1}`], 138 | }, 139 | } 140 | 141 | forEach(type: InputOutputType | 'softKey', f: ForEachFunctor): void { 142 | const labelDescs = this.#labelsDescs[type] 143 | const pairs = labelDescs.pairs 144 | if (pairs.length === 0) { 145 | for (let i = 0, count = this.inputOutputCounts[type]; i < count; i++) { 146 | pairs.push(labelDescs.generate(i)) 147 | } 148 | } 149 | 150 | pairs.forEach(([label, desc], i) => { 151 | f(i, label, desc) 152 | }) 153 | } 154 | 155 | #outputCalculators: OutputCalculatorCache = { 156 | level: { 157 | lr: null, 158 | mix: null, 159 | matrix: null, 160 | fxSend: null, 161 | dca: null, 162 | }, 163 | panBalance: { 164 | lr: null, 165 | mix: null, 166 | matrix: null, 167 | }, 168 | } 169 | 170 | static { 171 | outputCalculatorCache = (model: Model) => model.#outputCalculators 172 | } 173 | 174 | #sourceSinkCalculators: SourceToSinkCalculatorCache = { 175 | assign: { 176 | inputChannel: { 177 | group: null, 178 | fxSend: null, 179 | mix: null, 180 | lr: null, 181 | }, 182 | fxReturn: { 183 | fxSend: null, 184 | mix: null, 185 | lr: null, 186 | group: null, 187 | }, 188 | group: { 189 | fxSend: null, 190 | mix: null, 191 | lr: null, 192 | matrix: null, 193 | }, 194 | lr: { 195 | matrix: null, 196 | }, 197 | mix: { 198 | matrix: null, 199 | }, 200 | }, 201 | level: { 202 | inputChannel: { 203 | fxSend: null, 204 | mix: null, 205 | lr: null, 206 | }, 207 | fxReturn: { 208 | fxSend: null, 209 | mix: null, 210 | lr: null, 211 | }, 212 | group: { 213 | fxSend: null, 214 | mix: null, 215 | lr: null, 216 | matrix: null, 217 | }, 218 | lr: { 219 | matrix: null, 220 | }, 221 | mix: { 222 | matrix: null, 223 | }, 224 | }, 225 | panBalance: { 226 | inputChannel: { 227 | mix: null, 228 | lr: null, 229 | }, 230 | fxReturn: { 231 | mix: null, 232 | lr: null, 233 | }, 234 | group: { 235 | mix: null, 236 | lr: null, 237 | matrix: null, 238 | }, 239 | lr: { 240 | matrix: null, 241 | }, 242 | mix: { 243 | matrix: null, 244 | }, 245 | }, 246 | } 247 | 248 | static { 249 | sourceSinkCalculatorCache = (model: Model) => model.#sourceSinkCalculators 250 | } 251 | } 252 | 253 | export function getOutputCalculator( 254 | model: Model, 255 | nrpnType: NRPN, 256 | sinkType: SinkAsOutputForNRPN, 257 | Calculator: OutputCalculatorForNRPN, 258 | ): InstanceType> { 259 | const cache = outputCalculatorCache(model) 260 | const calcs = cache[nrpnType] as SinkToCalculator 261 | let calc = calcs[sinkType] 262 | if (calc === null) { 263 | calc = calcs[sinkType] = new Calculator(model, sinkType as any) as any 264 | } 265 | return calc! 266 | } 267 | 268 | export function getSourceSinkCalculator( 269 | model: Model, 270 | nrpnType: NRPN, 271 | sourceSink: SourceSinkForNRPN, 272 | Calculator: SourceSinkCalculatorForNRPN, 273 | ): InstanceType> { 274 | const cache = sourceSinkCalculatorCache(model) 275 | const calcs = cache[nrpnType] as Record< 276 | InputOutputType, 277 | Record> | null> 278 | > 279 | const [sourceType, sinkType] = sourceSink 280 | const sinks = calcs[sourceType] 281 | let calc = sinks[sinkType] 282 | if (calc === null) { 283 | calc = sinks[sinkType] = new Calculator(model, sourceSink as any) as any 284 | } 285 | return calc! 286 | } 287 | -------------------------------------------------------------------------------- /companion/HELP.md: -------------------------------------------------------------------------------- 1 | # Allen & Heath SQ module 2 | 3 | Controls the Allen & Heath SQ. 4 | 5 | ## Functions: 6 | 7 | - Mute Channel, Group, Mix, FX, MuteGroup, DCA, Matrix 8 | - Fader Level 9 | - Panning, Balance Level 10 | - Soft Keys 11 | - Soft Rotary (SQ6/SQ7 - no referring in this version) 12 | - Recall Scene 13 | - Assign channel to Mix, Group, FX send 14 | - Assign group to Mix, FX send, Matrix 15 | - Assign FX return to Mix, Group, FX send 16 | - Assign Mix to Matrix 17 | 18 | ## Special Functions: 19 | 20 | - Current scene display variable 21 | - Scene recall variable (usable in triggers to respond to scene recalls) 22 | - Current dB Fader Level display variables 23 | - Current Pan level display variables 24 | - Talkback macro 25 | - Scene step increment 26 | 27 | New in v.3.0.0 28 | 29 | - Fix scene recalling if the mixer MIDI channel isn't 1 30 | - Add Step +/-0.1dB choices for setting signal levels 31 | - Permit setting signal levels between -85dB and -40dB 32 | - Make setting signal level to +10dB work 33 | - Make assigning the LR mix to matrixes work correctly 34 | - Allow setting the pan/balance of matrix 3 used as an output 35 | - Make setting the pan/balance of LR in a matrix work 36 | - Add an action to make active/inactive an FX return in LR/mixes 37 | - Define pan/balance variables on-demand as pan/balance change messages are sent by the mixer 38 | - Restart module instances in response to configuration changes only if absolutely required 39 | - Add a `sceneRecalledTrigger` variable whose value changes every time a scene is recalled, suitable for use in triggers 40 | - Ensure output pan/balance variables are appropriately changed when an output pan/balance is changed in the mixer 41 | 42 | New in v.2.0.0 (not released) 43 | 44 | - Convert to Companion v3 format 45 | 46 | New in v.1.1.0 47 | 48 | - Add listener for MIDI inbound data 49 | - Add function to auto-set button status from status of the mute button on SQ 50 | (first MIDI data from console send all mute status to Companion) 51 | 52 | New in v.1.2.0 53 | 54 | - Add feedback for all "mute" actions 55 | 56 | New in v.1.2.3 57 | 58 | - Add presets for "mute" actions and "talkback" 59 | 60 | New in v.1.2.5 61 | 62 | - Add scene step and current scene display 63 | 64 | New in v.1.2.6 65 | 66 | - Improved code 67 | - Add fader step increment 68 | - Add fader level dB get 69 | 70 | New in v.1.2.7 71 | 72 | - Improved TCP connection 73 | - Fix dB value display 74 | 75 | New in v.1.3.0 76 | 77 | - Change Mute logics 78 | - Change dB Fader Level logics 79 | - Add Current Scene variable 80 | - Add all dB Fader Level variables 81 | - New presets 82 | - Improved receiving data function 83 | - Cleaning the code 84 | 85 | New in v.1.3.1 86 | 87 | - Beautify code 88 | - Fix level variables 89 | 90 | New in v.1.3.2 91 | 92 | - Fix DCA level output 93 | 94 | New in v.1.3.3 95 | 96 | - Add "Last dB value" 97 | - Add fading option 98 | 99 | New in v.1.3.4 100 | 101 | - Improve fader level 102 | - Improve pan level 103 | - Improve fading 104 | - Add Pan step increment 105 | - Add Pan level variables 106 | 107 | New in v.1.3.5 108 | 109 | - Improve dB level 110 | - Add MIDI channel configuration 111 | - Add Retrieve status configuration 112 | 113 | New in v.1.3.6 114 | 115 | - Converted code to new upgrade script 116 | 117 | New in v.1.3.7 118 | 119 | - Corrected sqconfig.json 120 | - Corrected status request 121 | - Add missing upgrade script for 1.3.5 122 | 123 | New in v.1.3.8 124 | 125 | - Fix issue #25 126 | 127 | New in v.1.3.9 128 | 129 | - Fix issue #28 130 | - Fix issue #31 131 | - Corrected some stuff 132 | 133 | Created by referring to all controls in the "SQ Midi Protocol Issue 3 - Firmware v. 1.5.0 or later" manual. 134 | 135 | Last update (d/m/y): 05/10/2021 136 | 137 | Current version: 1.3.9 138 | 139 | ## Configuring: 140 | 141 | ### New instance 142 | 143 | First step after adding SQ instance is to setting it up: 144 | 145 | - Name: the name you want 146 | - Target IP: IP to reach your SQ (needs on the same net) 147 | - Model: your SQ model 148 | - NRPN Fader Law: same as your MIDI configuration on console !! IMPORTANT !! 149 | - Default talkback...: channel number where is connected your talkback microphone 150 | - MIDI channel: MIDI channel used to communicate with SQ !! IMPORTANT !! 151 | - Retrieve console...: Modality to retrieve console status 152 | 153 | ### Soft keys 9 - 16 on SQ5 154 | 155 | To configure soft keys 9 - 16 on SQ5 you have to use MixPad application. After initially configuration, all settings will be stored in SQ memory until next configuration or console reset. To avoid deleting the soft key configuration when change scene, set "block" in Scenes Global Filter. 156 | 157 | ## How to: 158 | 159 | ### Scene step and current scene display 160 | 161 | "Scene step" admits a value between -50 and 50 in order to create forward and rewind scene call. 162 | "Current scene" set the current scene of your console to Companion into a variable (from version 1.3.0) then you'll be able to use it on any button text field. The value of the variable will be updating on first scene change performed by SQ or Companion. If your console starts with a scene other than 1, set the number in the option of the button, the press that button to setting up your starts current scene. To recall the variable on a button, type on button text field $( and Companion show you a list of SQ variables, then select "Scene - Current" (or digit $(SQ:currentScene)). 163 | 164 | ### Fader step increment 165 | 166 | There are two specific values on level drop-down menu (at the top) when you configuring fader level. 167 | 168 | ### Displaying fader level (dB Level) 169 | 170 | From version 1.3.0 fader dB level being a variable. Like the current scene variable, you you'll be able to use it on any button text field and the value will be updating on every level change on the console (or via Companion). To use the variable, start typing $( on button text field and Companion show you a list of SQ variables, then select a level dB you want. If you want to use multi-line text, using \n for a new line. 171 | 172 | Example: Showing up a dB fader level (-2) from channel 1 (Mic) to LR 173 | 174 | Mic\n$(SQ:level_64_0) dB 175 | 176 | the button show on display: 177 | 178 | Mic 179 | -2 dB 180 | 181 | On same button, if you want, you can attach a Mute function for channel 1. 182 | 183 | ### Last dB value 184 | 185 | This option set at the release action of a Fader Level allows you to return to the last dB value level of that fader. 186 | Example: If the fader level was +2 dB and you want to momentarily set it to -20 dB, set the Press / On action fader level option to "-20 dB", then the Release / Off action of the fader level option to "Last dB value". When the button is pressed the level drops to -20 dB and when the button is released the level returns to +2 dB. 187 | 188 | ### Fading 189 | 190 | This option available on any fader level allow you to reach set dB fader level using fading in/out mode. The speed of the fader route is selectable between 1 and 3 seconds. 191 | 192 | ### Pan display level 193 | 194 | When configuring a button with one of the Pan functions appear an only read option that shows name of variable to use on button text to show up value of Pan/Balance. After select option for input and destination you need to press configuring button once to refresh variable value before use it. 195 | 196 | ## Presets: 197 | 198 | ### Talkback 199 | 200 | This macro preset simulate the native function talkback of SQ, but it works with "channel assign to mix" function in console routing screen. With this preset you'll be able to talk to one specific AUX channels by pressing a button. This preset works with talkback input channel you set up on instance configuration. Initially, you have to remove the talkback input channel from mix assign on the console. 201 | --------------------------------------------------------------------------------