@blibliki/transport 0.5.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  export { Transport, TransportState } from "./Transport";
2
- export type { TransportEvent } from "./Transport";
2
+ export type {
3
+ TransportEvent,
4
+ TransportProperty,
5
+ TransportPropertyChangeCallback,
6
+ } from "./Transport";
3
7
  export { Position } from "./Position";
8
+ export {
9
+ TPB,
10
+ divisionToTicks,
11
+ divisionToFrequency,
12
+ divisionToMilliseconds,
13
+ } from "./utils";
14
+ export type { Division } from "./utils";
4
15
  export type {
5
16
  Seconds,
6
17
  Ticks,
@@ -9,3 +20,16 @@ export type {
9
20
  ClockTime,
10
21
  TimeSignature,
11
22
  } from "./types";
23
+ export {
24
+ StepSequencerSource,
25
+ Resolution,
26
+ PlaybackMode,
27
+ } from "./sources/StepSequencerSource";
28
+ export type {
29
+ StepSequencerSourceEvent,
30
+ IStep,
31
+ IStepNote,
32
+ IStepCC,
33
+ IPage,
34
+ IPattern,
35
+ } from "./sources/StepSequencerSource";
@@ -0,0 +1,70 @@
1
+ import { uuidv4 } from "@blibliki/utils";
2
+ import { Transport, TransportEvent } from "@/Transport";
3
+ import { Ticks } from "@/types";
4
+
5
+ export interface SourceEvent extends TransportEvent {
6
+ eventSourceId: string;
7
+ }
8
+
9
+ export interface IBaseSource<T extends SourceEvent> {
10
+ id: string;
11
+
12
+ generator: (start: Ticks, end: Ticks) => readonly Readonly<T>[];
13
+ consumer: (event: Readonly<T>) => void;
14
+
15
+ // Optional lifecycle hooks
16
+ onStart: (ticks: Ticks) => void;
17
+ onStop: (ticks: Ticks) => void;
18
+ onJump: (ticks: Ticks) => void;
19
+ onSilence: (ticks: Ticks) => void;
20
+ }
21
+
22
+ export abstract class BaseSource<
23
+ T extends SourceEvent,
24
+ > implements IBaseSource<T> {
25
+ readonly id: string;
26
+
27
+ protected transport: Transport;
28
+ protected startedAt?: Ticks;
29
+ protected stoppedAt?: Ticks;
30
+ protected lastGeneratedTick?: Ticks;
31
+
32
+ constructor(transport: Transport) {
33
+ this.id = uuidv4();
34
+ this.transport = transport;
35
+ }
36
+
37
+ abstract generator(start: Ticks, end: Ticks): readonly Readonly<T>[];
38
+ abstract consumer(event: Readonly<T>): void;
39
+
40
+ onStart(ticks: Ticks) {
41
+ this.startedAt = ticks;
42
+ this.stoppedAt = undefined;
43
+ this.lastGeneratedTick = undefined;
44
+ }
45
+
46
+ onStop(ticks: Ticks) {
47
+ this.stoppedAt = ticks;
48
+ }
49
+
50
+ onJump(ticks: Ticks) {
51
+ this.lastGeneratedTick = ticks;
52
+ }
53
+
54
+ onSilence(_ticks: Ticks) {
55
+ // Not implemented yet
56
+ }
57
+
58
+ protected isPlaying(start: Ticks, end: Ticks) {
59
+ const isStarted = this.startedAt !== undefined && this.startedAt <= start;
60
+ if (!isStarted) return false;
61
+
62
+ return this.stoppedAt === undefined || this.stoppedAt >= end;
63
+ }
64
+
65
+ protected shouldGenerate(eventTick: Ticks): boolean {
66
+ if (this.lastGeneratedTick === undefined) return true;
67
+
68
+ return eventTick > this.lastGeneratedTick;
69
+ }
70
+ }
@@ -0,0 +1,54 @@
1
+ import { Ticks } from "@/types";
2
+ import type { BaseSource, SourceEvent } from "./BaseSource";
3
+
4
+ export class SourceManager {
5
+ private activeSources = new Map<string, BaseSource<SourceEvent>>();
6
+
7
+ addSource<T extends SourceEvent>(source: BaseSource<T>) {
8
+ this.activeSources.set(source.id, source);
9
+ }
10
+
11
+ removeSource(id: string) {
12
+ this.activeSources.delete(id);
13
+ }
14
+
15
+ generator(start: Ticks, end: Ticks): readonly SourceEvent[] {
16
+ const events: SourceEvent[] = [];
17
+
18
+ this.activeSources.forEach((source) => {
19
+ events.push(...(source.generator(start, end) as SourceEvent[]));
20
+ });
21
+
22
+ return events;
23
+ }
24
+
25
+ consumer(events: readonly SourceEvent[]) {
26
+ events.forEach((event) => {
27
+ this.activeSources.get(event.eventSourceId)?.consumer(event);
28
+ });
29
+ }
30
+
31
+ onStart(ticks: Ticks) {
32
+ this.activeSources.forEach((source) => {
33
+ source.onStart(ticks);
34
+ });
35
+ }
36
+
37
+ onStop(ticks: Ticks) {
38
+ this.activeSources.forEach((source) => {
39
+ source.onStop(ticks);
40
+ });
41
+ }
42
+
43
+ onJump(ticks: Ticks) {
44
+ this.activeSources.forEach((source) => {
45
+ source.onJump(ticks);
46
+ });
47
+ }
48
+
49
+ onSilence(ticks: Ticks) {
50
+ this.activeSources.forEach((source) => {
51
+ source.onSilence(ticks);
52
+ });
53
+ }
54
+ }
@@ -0,0 +1,310 @@
1
+ import { Transport } from "@/Transport";
2
+ import { Ticks } from "@/types";
3
+ import { Division, TPB } from "@/utils";
4
+ import { BaseSource } from "./BaseSource";
5
+ import type { SourceEvent } from "./BaseSource";
6
+
7
+ export interface StepSequencerSourceEvent extends SourceEvent {
8
+ stepNo: number;
9
+ pageNo: number;
10
+ patternNo: number;
11
+ step: IStep;
12
+ }
13
+
14
+ export type IStepNote = {
15
+ note: string; // "C4", "E4", "G4"
16
+ velocity: number; // 0-127
17
+ };
18
+
19
+ export type IStepCC = {
20
+ cc: number;
21
+ value: number;
22
+ };
23
+
24
+ // Individual step
25
+ export type IStep = {
26
+ active: boolean; // Whether step is enabled/muted
27
+ notes: IStepNote[]; // Multiple notes for chords
28
+ ccMessages: IStepCC[]; // Multiple CC messages per step
29
+ probability: number; // 0-100% chance to trigger
30
+ microtimeOffset: number; // -50 to +50 ticks offset
31
+ duration: Division;
32
+ };
33
+
34
+ // Page contains multiple steps
35
+ export type IPage = {
36
+ name: string;
37
+ steps: IStep[];
38
+ };
39
+
40
+ // Pattern contains multiple pages
41
+ export type IPattern = {
42
+ name: string;
43
+ pages: IPage[];
44
+ };
45
+
46
+ export enum Resolution {
47
+ thirtysecond = "1/32",
48
+ sixteenth = "1/16",
49
+ eighth = "1/8",
50
+ quarter = "1/4",
51
+ }
52
+
53
+ export enum PlaybackMode {
54
+ loop = "loop",
55
+ oneShot = "oneShot",
56
+ }
57
+
58
+ interface StepSequencerSourceProps {
59
+ onEvent: (event: StepSequencerSourceEvent) => void;
60
+ patterns: IPattern[];
61
+ stepsPerPage: number; // 1-16 steps per page
62
+ resolution: Resolution;
63
+ playbackMode: PlaybackMode;
64
+ patternSequence: string; // Pattern sequence notation (e.g., "2A4B2AC")
65
+ enableSequence: boolean; // Toggle to enable/disable sequence mode
66
+ }
67
+
68
+ const RESOLUTION_TO_TICKS: Record<Resolution, number> = {
69
+ [Resolution.thirtysecond]: TPB / 8, // 1920 ticks
70
+ [Resolution.sixteenth]: TPB / 4, // 3840 ticks
71
+ [Resolution.eighth]: TPB / 2, // 7680 ticks
72
+ [Resolution.quarter]: TPB, // 15360 ticks
73
+ };
74
+
75
+ function expandPatternSequence(input: string): string[] {
76
+ const result: string[] = [];
77
+ let num = "";
78
+
79
+ for (const ch of input) {
80
+ if (ch >= "0" && ch <= "9") {
81
+ num += ch;
82
+ } else {
83
+ const count = Number(num);
84
+ for (let i = 0; i < count; i++) {
85
+ result.push(ch);
86
+ }
87
+ num = "";
88
+ }
89
+ }
90
+
91
+ return result.map((v) => v.toUpperCase());
92
+ }
93
+
94
+ export class StepSequencerSource extends BaseSource<StepSequencerSourceEvent> {
95
+ props: StepSequencerSourceProps;
96
+ private expandedSequence: string[] = [];
97
+ private pageMapping: { patternNo: number; pageNo: number }[] = [];
98
+
99
+ constructor(transport: Transport, props: StepSequencerSourceProps) {
100
+ super(transport);
101
+
102
+ this.props = props;
103
+ this.expandedSequence = expandPatternSequence(props.patternSequence);
104
+ this.pageMapping = this.buildPageMapping();
105
+ }
106
+
107
+ /**
108
+ * Build a mapping of absolute pages to (patternNo, pageNo) for one full sequence cycle
109
+ * Example: sequence "2A1B" with A having 2 pages, B having 1 page produces:
110
+ * [A0, A1, A0, A1, B0]
111
+ */
112
+ private buildPageMapping(): { patternNo: number; pageNo: number }[] {
113
+ const mapping: { patternNo: number; pageNo: number }[] = [];
114
+
115
+ if (this.props.enableSequence && this.expandedSequence.length > 0) {
116
+ // For each pattern letter in the expanded sequence
117
+ for (const patternLetter of this.expandedSequence) {
118
+ // Find the pattern by name
119
+ const patternNo = this.props.patterns.findIndex(
120
+ (p) => p.name.toUpperCase() === patternLetter,
121
+ );
122
+
123
+ if (patternNo === -1) continue;
124
+
125
+ const pattern = this.props.patterns[patternNo];
126
+ if (!pattern) continue;
127
+
128
+ // Add all pages of this pattern to the mapping
129
+ for (let pageNo = 0; pageNo < pattern.pages.length; pageNo++) {
130
+ mapping.push({ patternNo, pageNo });
131
+ }
132
+ }
133
+ } else {
134
+ // No sequence mode - build mapping for all patterns sequentially
135
+ for (
136
+ let patternNo = 0;
137
+ patternNo < this.props.patterns.length;
138
+ patternNo++
139
+ ) {
140
+ const pattern = this.props.patterns[patternNo];
141
+ if (!pattern) continue;
142
+
143
+ for (let pageNo = 0; pageNo < pattern.pages.length; pageNo++) {
144
+ mapping.push({ patternNo, pageNo });
145
+ }
146
+ }
147
+ }
148
+
149
+ return mapping;
150
+ }
151
+
152
+ get stepResolution(): Ticks {
153
+ return RESOLUTION_TO_TICKS[this.props.resolution];
154
+ }
155
+
156
+ onStart(ticks: Ticks) {
157
+ // Quantize to the start of the next bar
158
+ const timeSignature = this.transport.timeSignature;
159
+ const ticksPerBar = TPB * timeSignature[0];
160
+
161
+ if (ticks % ticksPerBar === 0) {
162
+ super.onStart(ticks);
163
+ return;
164
+ }
165
+
166
+ // Calculate which bar we're in and round up to the next bar
167
+ const currentBar = Math.floor(ticks / ticksPerBar);
168
+ const nextBarTicks = (currentBar + 1) * ticksPerBar;
169
+
170
+ super.onStart(nextBarTicks);
171
+ }
172
+
173
+ extractStepsTicks(start: Ticks, end: Ticks): Ticks[] {
174
+ const result: number[] = [];
175
+ const stepResolution = this.stepResolution;
176
+
177
+ // Calculate which step we should be at, then convert back to absolute ticks
178
+ const stepsSinceStart = Math.floor(
179
+ (start - this.startedAt!) / stepResolution,
180
+ );
181
+ const actualStart = this.startedAt! + stepsSinceStart * stepResolution;
182
+
183
+ for (let value = actualStart; value <= end; value += stepResolution) {
184
+ if (!this.shouldGenerate(value)) continue;
185
+
186
+ result.push(value);
187
+ this.lastGeneratedTick = value;
188
+ }
189
+
190
+ return result;
191
+ }
192
+
193
+ private getStep(patternNo: number, pageNo: number, stepNo: number): IStep {
194
+ const pattern = this.props.patterns[patternNo];
195
+ if (!pattern) throw Error("Pattern not found");
196
+
197
+ const page = pattern.pages[pageNo];
198
+ if (!page) throw Error("Page not found");
199
+
200
+ const step = page.steps[stepNo];
201
+ if (!step) throw Error("Step not found");
202
+
203
+ return step;
204
+ }
205
+
206
+ /**
207
+ * Calculate ticks per page based on step resolution and steps per page
208
+ */
209
+ get ticksPerPage(): Ticks {
210
+ return this.stepResolution * this.props.stepsPerPage;
211
+ }
212
+
213
+ /**
214
+ * Calculate absolute page number from tick position
215
+ */
216
+ private getAbsolutePageFromTicks(ticks: Ticks): number {
217
+ if (this.startedAt === undefined) return 0;
218
+
219
+ const ticksSinceStart = ticks - this.startedAt;
220
+ return Math.floor(ticksSinceStart / this.ticksPerPage);
221
+ }
222
+
223
+ /**
224
+ * Calculate step number within the current page from tick position
225
+ */
226
+ private getStepNoInPage(ticks: Ticks): number {
227
+ if (this.startedAt === undefined) return 0;
228
+
229
+ const ticksSinceStart = ticks - this.startedAt;
230
+ const ticksIntoPage = ticksSinceStart % this.ticksPerPage;
231
+ return Math.floor(ticksIntoPage / this.stepResolution);
232
+ }
233
+
234
+ /**
235
+ * Map absolute page number to actual pattern and page indices
236
+ * Takes into account sequence mode and playback mode (loop vs oneShot)
237
+ */
238
+ private getPatternAndPageFromAbsolutePage(absolutePage: number): {
239
+ patternNo: number;
240
+ pageNo: number;
241
+ } {
242
+ if (this.pageMapping.length === 0) {
243
+ return { patternNo: 0, pageNo: 0 };
244
+ }
245
+
246
+ let index: number;
247
+ if (this.props.playbackMode === PlaybackMode.loop) {
248
+ index = absolutePage % this.pageMapping.length;
249
+ } else {
250
+ // oneShot mode - stop at the last page
251
+ index = Math.min(absolutePage, this.pageMapping.length - 1);
252
+ }
253
+
254
+ return this.pageMapping[index] ?? { patternNo: 0, pageNo: 0 };
255
+ }
256
+
257
+ generator(
258
+ start: Ticks,
259
+ end: Ticks,
260
+ ): readonly Readonly<StepSequencerSourceEvent>[] {
261
+ if (!this.isPlaying(start, end) || this.startedAt === undefined) return [];
262
+
263
+ const stepTicks = this.extractStepsTicks(start, end);
264
+
265
+ // Check if we should stop in oneShot mode
266
+ if (
267
+ this.props.playbackMode === PlaybackMode.oneShot &&
268
+ stepTicks.length > 0
269
+ ) {
270
+ const lastTick = stepTicks[stepTicks.length - 1];
271
+ if (lastTick !== undefined) {
272
+ const absolutePage = this.getAbsolutePageFromTicks(lastTick);
273
+ const stepNo = this.getStepNoInPage(lastTick);
274
+
275
+ // If we've reached or passed the last page and last step, stop the source
276
+ if (
277
+ absolutePage >= this.pageMapping.length - 1 &&
278
+ stepNo >= this.props.stepsPerPage - 1
279
+ ) {
280
+ // Stop after this final step
281
+ this.onStop(lastTick + this.stepResolution);
282
+ }
283
+ }
284
+ }
285
+
286
+ return stepTicks.map((ticks) => {
287
+ const absolutePage = this.getAbsolutePageFromTicks(ticks);
288
+ const { patternNo, pageNo } =
289
+ this.getPatternAndPageFromAbsolutePage(absolutePage);
290
+ const stepNo = this.getStepNoInPage(ticks);
291
+
292
+ const step = this.getStep(patternNo, pageNo, stepNo);
293
+
294
+ return {
295
+ ticks,
296
+ time: 0,
297
+ contextTime: 0,
298
+ eventSourceId: this.id,
299
+ stepNo,
300
+ pageNo,
301
+ patternNo,
302
+ step,
303
+ };
304
+ });
305
+ }
306
+
307
+ consumer(event: Readonly<StepSequencerSourceEvent>) {
308
+ this.props.onEvent(event);
309
+ }
310
+ }
File without changes
package/src/utils.ts CHANGED
@@ -104,3 +104,69 @@ export function swing(time: Ticks, amount: NormalRange): Ticks {
104
104
  // Map transformed time back into ticks
105
105
  return Math.floor((t8i + tn) * 7680);
106
106
  }
107
+
108
+ // We will update the type to be more flexible when there is a need for it
109
+ export type Division =
110
+ | "1/64"
111
+ | "1/48"
112
+ | "1/32"
113
+ | "1/24"
114
+ | "1/16"
115
+ | "1/12"
116
+ | "1/8"
117
+ | "1/6"
118
+ | "3/16"
119
+ | "1/4"
120
+ | "5/16"
121
+ | "1/3"
122
+ | "3/8"
123
+ | "1/2"
124
+ | "3/4"
125
+ | "1"
126
+ | "1.5"
127
+ | "2"
128
+ | "3"
129
+ | "4"
130
+ | "6"
131
+ | "8"
132
+ | "16"
133
+ | "32"
134
+ | "infinity";
135
+
136
+ export function divisionToTicks(division: Division): Ticks {
137
+ // Defensive: handle invalid input (runtime safety for old data)
138
+ if (typeof division !== "string") {
139
+ console.error(`Invalid duration value, using default "8n."`);
140
+ division = "1/8";
141
+ }
142
+
143
+ let ticks: Ticks;
144
+
145
+ if (division === "infinity") return Infinity;
146
+
147
+ if (division.match(/\d\/\d/)) {
148
+ const [num, den] = division.split("/").map(Number);
149
+ if (!num || !den)
150
+ throw Error(`Note duration parsing error for value: ${division}`);
151
+
152
+ ticks = (TPB * num) / (den / 4);
153
+ } else {
154
+ ticks = TPB * Number(division) * 4;
155
+ }
156
+
157
+ return Math.round(ticks);
158
+ }
159
+
160
+ export function divisionToFrequency(division: Division, bpm: BPM): number {
161
+ const ticks = divisionToTicks(division);
162
+ const beatsPerDivision = ticks / TPB;
163
+ const secondsPerDivision = beatsPerDivision * (60 / bpm);
164
+ return 1 / secondsPerDivision;
165
+ }
166
+
167
+ export function divisionToMilliseconds(division: Division, bpm: BPM): number {
168
+ const ticksPerDivision = divisionToTicks(division);
169
+ const beatsPerDivision = ticksPerDivision / TPB;
170
+ const secondsPerDivision = beatsPerDivision * (60 / bpm);
171
+ return secondsPerDivision * 1000;
172
+ }