Skip to content

Commit 9d1da7c

Browse files
authored
feat: beatLamp object (#12)
Add new beatLamp object for the Max for Live device “Beat Lamps". Fix bug in webpack plugin. Add vitest testing framework. Add some unit tests. Refactor utils, put musical timing modules in util.mt. BREAKING: fix beats per bar calculation, it turns out a beat in Ableton Live sense is always a crotchet, even when the time signature is 6/8. Original function would assume beat is a quaver for 6/8 signature.
1 parent de1a1c3 commit 9d1da7c

27 files changed

+2534
-140
lines changed

.prettierrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
"proseWrap": "always"
88
}
99
}
10-
]
10+
],
11+
"plugins": ["prettier-plugin-jsdoc"]
1112
}

README.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,24 @@ yarn build
7474

7575
## Included js objects
7676

77+
### beatLamp
78+
79+
This js object is intended to be used with a Novation Launchpad Pro MK3. It
80+
helps live performers keep track of when the triggered clips will start playing.
81+
82+
Connect it to a _midiout_ object and set the MIDI output on the track with your
83+
Max for Live device to _Launchpad Pro MK3 DAW_.
84+
85+
When Live starts playing, the scene trigger buttons on the right light up to
86+
indicate how much of the current loop has elapsed. This is dependent on the
87+
global launch quantisation setting you have. If you set it to 8 bars, one of the
88+
scene buttons will light up for every bar. If you set it to 1 bar, the lights
89+
will count eighths notes.
90+
91+
The first 5 beat lamps will light up in green, the next two in yellow, and
92+
finally the 8th will light up in red, indicating that it is high time to figure
93+
out which clips to launch for the next cycle.
94+
7795
### filterMidiCC
7896

7997
This js object can receive MIDI input and filter out MIDI control change (CC)
@@ -91,13 +109,13 @@ useful for MIDI controllers.
91109

92110
### getNextTriggerTime
93111

94-
When this JS object receives a bang, it calculates the musical time in beats that
95-
a clip that has been fired will start playing.
112+
When this JS object receives a bang, it calculates the musical time in beats
113+
that a clip that has been fired will start playing.
96114

97115
### firedSlotStartOrStop
98116

99117
When this JS object receives an integer value as returned by the slot property
100-
*fired_slot_index*, it determines if that value means a clip will start playing
118+
_fired_slot_index_, it determines if that value means a clip will start playing
101119
or the track will stop playing, and issues a bang to outlet 1 or 3, accordingly.
102120

103121
If no slot has been fired, a bang is sent to outlet 2.
@@ -106,18 +124,18 @@ If no slot has been fired, a bang is sent to outlet 2.
106124

107125
When this JS object receives a bang, it checks if any slot in the device's track
108126
is playing. It sends a number to outlet 1 (0: no slot is playing, otherwise 1).
109-
It also sends a bang to outlet 1 if any slot is playing or a bang to outlet 2
110-
if no slot is playing.
127+
It also sends a bang to outlet 1 if any slot is playing or a bang to outlet 2 if
128+
no slot is playing.
111129

112130
### mkIIIDisplay
113131

114-
Write text to the display of the Novation SL MkIII MIDI keyboard. Send a message
132+
Write text to the display of the Novation SL MkIII MIDI keyboard. Send a message
115133
to the inlet like this:
116134

117135
`send [column] [message]`
118136

119-
* *column* is the column (0-7) in which to write the text (number)
120-
* *message* is text to write (list of symbols)
137+
- _column_ is the column (0-7) in which to write the text (number)
138+
- _message_ is text to write (list of symbols)
121139

122140
The text is written in the top row, above the current knob value. Keep in mind
123141
that the text needs to be short, around 9 characters, lest it is truncated with
@@ -127,7 +145,7 @@ ellipses.
127145

128146
To write the text "hi there" above knob 1:
129147

130-
`send 0 hi there`
148+
`send 0 hi there`
131149

132150
## Developing your own js objects
133151

package.json

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,28 @@
33
"version": "1.5.1",
44
"description": "Library of useful Max for Live objects",
55
"private": true,
6+
"scripts": {
7+
"build": "webpack",
8+
"watch": "webpack --watch",
9+
"test": "vitest"
10+
},
611
"dependencies": {
7-
"core-js": "^3.39.0",
8-
"dotenv": "^8.2.0"
12+
"core-js": "^3.39.0"
913
},
1014
"devDependencies": {
15+
"@types/add": "^2",
1116
"@types/core-js": "^2.5.8",
17+
"add": "^2.0.6",
18+
"dotenv": "^8.2.0",
1219
"prettier": "^3.3.3",
20+
"prettier-plugin-jsdoc": "^1.3.0",
1321
"ts-loader": "^9.5.1",
1422
"typescript": "^5.5.3",
23+
"vitest": "^2.1.8",
1524
"webpack": "^5.94.0",
1625
"webpack-cli": "^5.1.4",
17-
"webpack-sources": "^3.2.3"
18-
},
19-
"scripts": {
20-
"build": "webpack",
21-
"watch": "webpack --watch"
26+
"webpack-sources": "^3.2.3",
27+
"yarn": "^1.22.22"
2228
},
2329
"repository": {
2430
"type": "git",
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import "core-js/actual/array/includes";
2+
import type { State, StateProp, CalculationInput } from "../types";
3+
import { ApiManager, mt, log } from "../../../util";
4+
import { loggedStateProps, numberOfLamps, lampColours } from "../config";
5+
6+
const observedLiveSetProps: StateProp[] = [
7+
"clip_trigger_quantization",
8+
"signature_numerator",
9+
"signature_denominator",
10+
"is_playing",
11+
"current_song_time",
12+
];
13+
14+
export default class BeatLampManager {
15+
private _state: State = {
16+
clip_trigger_quantization: null,
17+
signature_numerator: null,
18+
signature_denominator: null,
19+
is_playing: null,
20+
is_active: null,
21+
current_song_time: null,
22+
ctq_beats: null,
23+
current_lamp: null,
24+
};
25+
26+
private _apiMan = new ApiManager();
27+
28+
private _outlet;
29+
30+
constructor(outlet: (outlet_number: number, ...args: unknown[]) => void) {
31+
this._outlet = outlet;
32+
}
33+
34+
start() {
35+
if (this._apiMan.hasNoApis) {
36+
observedLiveSetProps.forEach(this._observeLiveSet.bind(this));
37+
}
38+
this._apiMan.start();
39+
}
40+
41+
stop() {
42+
this._apiMan.stop();
43+
}
44+
45+
private _observeLiveSet(prop: StateProp) {
46+
this._observeState("live_set", prop);
47+
}
48+
49+
private _observeState(path: string, prop: StateProp) {
50+
this._apiMan.make(`${path} ${prop}`, (nextValue) => {
51+
this._doStateUpdateIfChanged(prop, nextValue);
52+
this._updateDerivedState();
53+
});
54+
}
55+
56+
private _updateDerivedState() {
57+
const isActiveHasChanged = this._updateIsActive();
58+
59+
if (isActiveHasChanged && this._state.is_active === 0) {
60+
// if quantisation is “None”, it makes no sense to have beat lamps on
61+
this._sendAllLampsOffMidiMessage();
62+
return;
63+
}
64+
65+
this._updateCtqBeats();
66+
const currentLampHasChanged = this._updateCurrentLamp();
67+
68+
if (currentLampHasChanged) {
69+
this._sendLampMidiMessage();
70+
}
71+
}
72+
73+
private _updateDerivedStateProp(
74+
prop: StateProp,
75+
requiredProps: StateProp[],
76+
calculate: (props: CalculationInput) => number,
77+
) {
78+
if (this._isMissingProps(...requiredProps)) {
79+
return false;
80+
}
81+
const propsForCalculation = requiredProps.reduce(
82+
(acc, curr) => ({
83+
...acc,
84+
[curr]: this._state[curr]!,
85+
}),
86+
{} as CalculationInput,
87+
);
88+
const nextValue = calculate(propsForCalculation);
89+
return this._doStateUpdateIfChanged(prop, nextValue);
90+
}
91+
92+
private _updateIsActive() {
93+
return this._updateDerivedStateProp(
94+
"is_active",
95+
["clip_trigger_quantization"],
96+
({ clip_trigger_quantization }) =>
97+
clip_trigger_quantization > 0 ? 1 : 0,
98+
);
99+
}
100+
101+
private _updateCtqBeats() {
102+
return this._updateDerivedStateProp(
103+
"ctq_beats",
104+
[
105+
"clip_trigger_quantization",
106+
"signature_denominator",
107+
"signature_numerator",
108+
],
109+
({
110+
clip_trigger_quantization,
111+
signature_denominator,
112+
signature_numerator,
113+
}: CalculationInput) =>
114+
mt.beatsForCTQ(
115+
clip_trigger_quantization,
116+
signature_numerator,
117+
signature_denominator,
118+
)!, // we know this can't be null because clip_trigger_quantization is validated previously
119+
);
120+
}
121+
122+
private _updateCurrentLamp() {
123+
return this._updateDerivedStateProp(
124+
"current_lamp",
125+
["ctq_beats", "current_song_time"],
126+
({ ctq_beats, current_song_time }) => {
127+
const elapsedQuantisationCycles = Math.floor(
128+
current_song_time / ctq_beats,
129+
);
130+
const currentBeatInSpan =
131+
current_song_time - elapsedQuantisationCycles * ctq_beats;
132+
const beatsPerLamp = ctq_beats / numberOfLamps;
133+
return Math.floor(currentBeatInSpan / beatsPerLamp);
134+
},
135+
);
136+
}
137+
138+
private _isMissingProps(...props: StateProp[]) {
139+
return props.some((prop) => this._state[prop] === null);
140+
}
141+
142+
private _doStateUpdateIfChanged(prop: StateProp, nextValue: number) {
143+
if (nextValue !== this._state[prop]) {
144+
this._state[prop] = nextValue;
145+
this._maybeLogStateChange(prop);
146+
return true;
147+
}
148+
return false;
149+
}
150+
151+
private _maybeLogStateChange(prop: StateProp) {
152+
if (!loggedStateProps.includes(prop)) {
153+
return;
154+
}
155+
log(`state.${prop} = ${this._state[prop]}`);
156+
}
157+
158+
private _sendAllLampsOffMidiMessage() {
159+
for (let lampIndex = 0; lampIndex < 8; lampIndex++) {
160+
const lampPosition = lampIndex * 10 + 19;
161+
outlet(0, [176, lampPosition, 0]);
162+
outlet(lampIndex + 1, 0);
163+
}
164+
}
165+
166+
// send MIDI message to Launchpad Pro MK3 to light the lamps
167+
// on the scene trigger buttons on the right
168+
private _sendLampMidiMessage() {
169+
for (let lampIndex = 0; lampIndex < 8; lampIndex++) {
170+
const isOn = lampIndex <= this._state.current_lamp!;
171+
const colourIndex = isOn ? lampColours[lampIndex] : 0;
172+
const lampPosition = lampIndex * 10 + 19;
173+
outlet(0, [176, lampPosition, colourIndex]);
174+
outlet(lampIndex + 1, isOn ? 1 : 0);
175+
}
176+
}
177+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as BeatLampManager } from "./BeatLampManager";

src/objects/beatLamp/beatLamp.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { BeatLampManager } from "./BeatLampManager";
2+
3+
autowatch = 0;
4+
inlets = 1;
5+
outlets = 9;
6+
7+
setinletassist(0, "bang: start beat lamps; 'stop': stop beat lamps");
8+
setoutletassist(0, "list: MIDI control change message, connect to midiout");
9+
setoutletassist(1, "number: lamp 1 on (1) or off (0)");
10+
setoutletassist(2, "number: lamp 2 on (1) or off (0)");
11+
setoutletassist(3, "number: lamp 3 on (1) or off (0)");
12+
setoutletassist(4, "number: lamp 4 on (1) or off (0)");
13+
setoutletassist(5, "number: lamp 5 on (1) or off (0)");
14+
setoutletassist(6, "number: lamp 6 on (1) or off (0)");
15+
setoutletassist(7, "number: lamp 7 on (1) or off (0)");
16+
setoutletassist(8, "number: lamp 8 on (1) or off (0)");
17+
18+
const beatLampMan = new BeatLampManager(outlet);
19+
20+
export function bang() {
21+
beatLampMan.start();
22+
}
23+
24+
export function stop() {
25+
beatLampMan.stop();
26+
}

src/objects/beatLamp/config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { StateProp } from "./types";
2+
3+
export const numberOfLamps = 8;
4+
5+
// comment state properties back in for debugging
6+
export const loggedStateProps: StateProp[] = [
7+
// "is_active",
8+
// "clip_trigger_quantization",
9+
// "signature_numerator",
10+
// "signature_denominator",
11+
// "is_playing",
12+
// "ctq_beats",
13+
// "current_lamp",
14+
// "current_song_time",
15+
];
16+
17+
export const lampColours = [21, 21, 21, 21, 21, 13, 13, 5];

src/objects/beatLamp/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./beatLamp";

src/objects/beatLamp/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface State {
2+
is_active: null | number;
3+
clip_trigger_quantization: null | number;
4+
signature_numerator: null | number;
5+
signature_denominator: null | number;
6+
is_playing: null | number;
7+
current_song_time: null | number;
8+
ctq_beats: null | number;
9+
current_lamp: null | number;
10+
}
11+
12+
export type StateProp = keyof State;
13+
14+
export type CalculationInput = Record<StateProp, number>;

0 commit comments

Comments
 (0)