nodejs-audio-visualizer 5.1.0 → 5.2.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.
Files changed (50) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -2
  3. package/dist/audio.d.ts +13 -13
  4. package/dist/audio.js +128 -128
  5. package/dist/beats.d.ts +39 -39
  6. package/dist/beats.js +220 -220
  7. package/dist/bpmEncoder.d.ts +14 -14
  8. package/dist/bpmEncoder.js +97 -97
  9. package/dist/config.d.ts +78 -77
  10. package/dist/config.js +377 -369
  11. package/dist/config.js.map +1 -1
  12. package/dist/dsp.d.ts +16 -16
  13. package/dist/dsp.js +76 -76
  14. package/dist/highlight.d.ts +34 -34
  15. package/dist/highlight.js +334 -334
  16. package/dist/image.d.ts +97 -92
  17. package/dist/image.js +545 -538
  18. package/dist/image.js.map +1 -1
  19. package/dist/index.d.ts +87 -79
  20. package/dist/index.js +597 -566
  21. package/dist/index.js.map +1 -1
  22. package/dist/subtitleConvert.d.ts +3 -3
  23. package/dist/subtitleConvert.js +61 -61
  24. package/dist/video.d.ts +150 -120
  25. package/dist/video.js +677 -582
  26. package/dist/video.js.map +1 -1
  27. package/dist/waitForEventLoop.d.ts +1 -1
  28. package/dist/waitForEventLoop.js +5 -5
  29. package/package.json +62 -62
  30. package/src/audio.ts +129 -129
  31. package/src/beats.ts +279 -279
  32. package/src/bpmEncoder.ts +97 -97
  33. package/src/config.ts +487 -476
  34. package/src/dsp.ts +95 -95
  35. package/src/highlight.ts +291 -291
  36. package/src/image.ts +707 -700
  37. package/src/index.ts +79 -37
  38. package/src/subtitleConvert.ts +63 -63
  39. package/src/tests/audio.test.ts +102 -102
  40. package/src/tests/beats.test.ts +134 -134
  41. package/src/tests/config.test.ts +534 -496
  42. package/src/tests/dsp.test.ts +24 -24
  43. package/src/tests/highlight.test.ts +178 -178
  44. package/src/tests/image.test.ts +319 -308
  45. package/src/tests/index.test.ts +180 -180
  46. package/src/tests/subtitleConvert.test.ts +23 -23
  47. package/src/tests/video.test.ts +535 -447
  48. package/src/typings.d.ts +28 -28
  49. package/src/video.ts +856 -722
  50. package/src/waitForEventLoop.ts +2 -2
package/src/beats.ts CHANGED
@@ -1,279 +1,279 @@
1
- import MusicTempo from 'music-tempo';
2
-
3
- export interface BeatInfo {
4
- isBeat: boolean;
5
- intensity: number;
6
- energy: number;
7
- threshold: number;
8
- }
9
-
10
- export interface BeatDetectorOptions {
11
- /** Number of past frames to average for adaptive threshold (~1.5s by default) */
12
- historySize?: number;
13
- /** Energy must exceed average * this multiplier to trigger a beat */
14
- thresholdMultiplier?: number;
15
- /** How fast the beat intensity fades between beats (0-1, lower = faster fade) */
16
- decayRate?: number;
17
- /** Minimum frames between consecutive beats (~150ms by default) */
18
- cooldownFrames?: number;
19
- /** How many low-frequency spectrum bands contribute to energy (sub-bass + bass) */
20
- bassEndIndex?: number;
21
- }
22
-
23
- export interface TempoEstimate {
24
- bpm: number;
25
- periodFrames: number;
26
- phaseFrame: number;
27
- /** Beat times in seconds from the start of the analyzed audio. */
28
- beatsSec?: number[];
29
- }
30
-
31
- export const MIN_TEMPO_BPM = 70;
32
- export const MAX_TEMPO_BPM = 180;
33
- export const PREFERRED_TEMPO_BPM_MIN = 90;
34
- export const PREFERRED_TEMPO_BPM_MAX = 160;
35
- export const DEFAULT_BASS_END_INDEX = 8;
36
- /** Longest PCM window passed to music-tempo (seconds), taken from the start of the audio. */
37
- export const MAX_TEMPO_ANALYSIS_SECONDS = 90;
38
- const TEMPO_TIME_STEP = 0.01;
39
-
40
- export const frameBassEnergy = (spectrum: number[], bassEndIndex = DEFAULT_BASS_END_INDEX): number => {
41
- const end = Math.min(bassEndIndex, spectrum.length);
42
- if (end <= 0) {
43
- return 0;
44
- }
45
- let sum = 0;
46
- for (let i = 0; i < end; i++) {
47
- const v = spectrum[i];
48
- sum += v * v;
49
- }
50
- return sum / end;
51
- };
52
-
53
- export const createBeatDetector = (fps: number, options?: BeatDetectorOptions) => {
54
- const historySize = options?.historySize ?? Math.round(fps * 1.5);
55
- const thresholdMultiplier = options?.thresholdMultiplier ?? 1.4;
56
- const decayRate = options?.decayRate ?? 0.85;
57
- const cooldownFrames = options?.cooldownFrames ?? Math.round(fps * 0.15);
58
- const bassEndIndex = options?.bassEndIndex ?? DEFAULT_BASS_END_INDEX;
59
-
60
- const energyHistory: number[] = [];
61
- let framesSinceLastBeat = cooldownFrames;
62
- let decayingIntensity = 0;
63
-
64
- return (spectrum: number[]): BeatInfo => {
65
- const energy = frameBassEnergy(spectrum, bassEndIndex);
66
-
67
- energyHistory.push(energy);
68
- if (energyHistory.length > historySize) {
69
- energyHistory.shift();
70
- }
71
-
72
- const avgEnergy = energyHistory.reduce((sum, e) => sum + e, 0) / energyHistory.length;
73
- const variance = energyHistory.reduce((sum, e) => sum + (e - avgEnergy) ** 2, 0) / energyHistory.length;
74
- const stdDev = Math.sqrt(variance);
75
-
76
- const threshold = avgEnergy + stdDev * thresholdMultiplier;
77
-
78
- framesSinceLastBeat++;
79
-
80
- const hasEnoughHistory = energyHistory.length >= Math.round(historySize / 3);
81
- const isAboveThreshold = energy > threshold && hasEnoughHistory;
82
- const isCooldownOver = framesSinceLastBeat >= cooldownFrames;
83
- const isBeat = isAboveThreshold && isCooldownOver;
84
-
85
- if (isBeat) {
86
- framesSinceLastBeat = 0;
87
- decayingIntensity = Math.min(1, (energy - threshold) / (stdDev + 1e-6));
88
- } else {
89
- decayingIntensity *= decayRate;
90
- }
91
-
92
- return {
93
- isBeat,
94
- intensity: decayingIntensity,
95
- energy,
96
- threshold,
97
- };
98
- };
99
- };
100
-
101
- const foldTempoBpm = (bpm: number): number => {
102
- let folded = bpm;
103
- while (folded > MAX_TEMPO_BPM && folded / 2 >= MIN_TEMPO_BPM) {
104
- folded /= 2;
105
- }
106
- while (folded < MIN_TEMPO_BPM && folded * 2 <= MAX_TEMPO_BPM) {
107
- folded *= 2;
108
- }
109
- return folded;
110
- };
111
-
112
- const samplesToArray = (samples: ArrayLike<number>, maxLength: number): number[] => {
113
- const n = Math.min(samples.length, maxLength);
114
- if (Array.isArray(samples) && n === samples.length) {
115
- return samples;
116
- }
117
- const audioData = new Array<number>(n);
118
- for (let i = 0; i < n; i++) {
119
- audioData[i] = samples[i];
120
- }
121
- return audioData;
122
- };
123
-
124
- const wrapIntoPeriod = (value: number, period: number): number => {
125
- if (!(period > 0) || !isFinite(value)) {
126
- return 0;
127
- }
128
- let wrapped = value - period * Math.floor(value / period);
129
- if (wrapped < 0) {
130
- wrapped += period;
131
- }
132
- if (wrapped >= period) {
133
- wrapped = 0;
134
- }
135
- return wrapped;
136
- };
137
-
138
- const circularMeanPhase = (frames: number[], period: number): number => {
139
- if (!(period > 0) || frames.length === 0) {
140
- return 0;
141
- }
142
- let sinSum = 0;
143
- let cosSum = 0;
144
- for (const frame of frames) {
145
- const angle = (2 * Math.PI * frame) / period;
146
- sinSum += Math.sin(angle);
147
- cosSum += Math.cos(angle);
148
- }
149
- const phase = Math.atan2(sinSum, cosSum) * period / (2 * Math.PI);
150
- return wrapIntoPeriod(phase, period);
151
- };
152
-
153
- export const estimateTempo = (
154
- samples: ArrayLike<number>,
155
- sampleRate: number,
156
- fps: number,
157
- ): TempoEstimate | null => {
158
- if (!samples || samples.length === 0 || !(sampleRate > 0) || !(fps > 0)) {
159
- return null;
160
- }
161
-
162
- const maxSamples = Math.max(1, Math.floor(sampleRate * MAX_TEMPO_ANALYSIS_SECONDS));
163
- const audioData = samplesToArray(samples, maxSamples);
164
- const hopSize = Math.max(1, Math.round(sampleRate * TEMPO_TIME_STEP));
165
-
166
- try {
167
- const mt = new MusicTempo(audioData, {
168
- hopSize,
169
- timeStep: TEMPO_TIME_STEP,
170
- minBeatInterval: 60 / MAX_TEMPO_BPM,
171
- maxBeatInterval: 60 / MIN_TEMPO_BPM,
172
- });
173
- const beatInterval = Number(mt.beatInterval);
174
- let bpm = Number(mt.tempo);
175
- if (!(bpm > 0) && beatInterval > 0) {
176
- bpm = 60 / beatInterval;
177
- }
178
- if (!(bpm > 0)) {
179
- return null;
180
- }
181
-
182
- bpm = foldTempoBpm(bpm);
183
- if (bpm < MIN_TEMPO_BPM * 0.9 || bpm > MAX_TEMPO_BPM * 1.1) {
184
- return null;
185
- }
186
-
187
- const periodFrames = fps * 60 / bpm;
188
- const beatsSec = (mt.beats ?? [])
189
- .map(t => Number(t))
190
- .filter(t => t >= 0 && isFinite(t));
191
- const beatFrames = beatsSec.map(t => t * fps);
192
- const phaseFrame = beatFrames.length > 0
193
- ? wrapIntoPeriod(circularMeanPhase(beatFrames, periodFrames), periodFrames)
194
- : 0;
195
-
196
- return {
197
- bpm: Math.round(bpm),
198
- periodFrames,
199
- phaseFrame,
200
- ...(beatsSec.length > 0 ? { beatsSec } : {}),
201
- };
202
- } catch {
203
- return null;
204
- }
205
- };
206
-
207
- export const shiftTempoPhase = (
208
- tempo: TempoEstimate,
209
- startFrame: number,
210
- ): TempoEstimate => {
211
- const period = tempo.periodFrames;
212
- if (!(period > 0) || startFrame === 0) {
213
- return tempo;
214
- }
215
- return {
216
- ...tempo,
217
- phaseFrame: wrapIntoPeriod(tempo.phaseFrame - startFrame, period),
218
- };
219
- };
220
-
221
- /** Beat-grid phase for a later audio window, using in-window beat times when available. */
222
- export const tempoForWindow = (
223
- tempo: TempoEstimate,
224
- startFrame: number,
225
- fps: number,
226
- windowFrames?: number,
227
- localOnsetFrames?: number[],
228
- ): TempoEstimate => {
229
- const period = tempo.periodFrames;
230
- if (!(period > 0) || !(fps > 0)) {
231
- return tempo;
232
- }
233
- const startSec = startFrame / fps;
234
- const endSec = windowFrames != null && windowFrames >= 0
235
- ? (startFrame + windowFrames) / fps
236
- : Number.POSITIVE_INFINITY;
237
- const inWindow = (tempo.beatsSec ?? []).filter(t => t >= startSec && t < endSec);
238
- if (inWindow.length > 0) {
239
- const localFrames = inWindow.map(t => (t - startSec) * fps);
240
- return {
241
- ...tempo,
242
- phaseFrame: wrapIntoPeriod(circularMeanPhase(localFrames, period), period),
243
- };
244
- }
245
- const onsets = (localOnsetFrames ?? []).filter(frame =>
246
- frame > 0 && (windowFrames == null || frame < windowFrames),
247
- );
248
- if (onsets.length > 0) {
249
- return {
250
- ...tempo,
251
- phaseFrame: wrapIntoPeriod(circularMeanPhase(onsets, period), period),
252
- };
253
- }
254
- return shiftTempoPhase(tempo, startFrame);
255
- };
256
-
257
- export const beatGridFrameIndices = (
258
- tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
259
- totalFrames: number,
260
- stride = 1,
261
- ): number[] => {
262
- const period = tempo.periodFrames;
263
- const step = Math.max(1, Math.round(stride));
264
- if (!(period > 0) || totalFrames <= 1) {
265
- return [];
266
- }
267
- const frames: number[] = [];
268
- const nStart = tempo.phaseFrame > 0 ? 0 : 1;
269
- for (let n = nStart; ; n += step) {
270
- const frame = Math.round(tempo.phaseFrame + n * period);
271
- if (frame >= totalFrames) {
272
- break;
273
- }
274
- if (frame > 0) {
275
- frames.push(frame);
276
- }
277
- }
278
- return frames;
279
- };
1
+ import MusicTempo from 'music-tempo';
2
+
3
+ export interface BeatInfo {
4
+ isBeat: boolean;
5
+ intensity: number;
6
+ energy: number;
7
+ threshold: number;
8
+ }
9
+
10
+ export interface BeatDetectorOptions {
11
+ /** Number of past frames to average for adaptive threshold (~1.5s by default) */
12
+ historySize?: number;
13
+ /** Energy must exceed average * this multiplier to trigger a beat */
14
+ thresholdMultiplier?: number;
15
+ /** How fast the beat intensity fades between beats (0-1, lower = faster fade) */
16
+ decayRate?: number;
17
+ /** Minimum frames between consecutive beats (~150ms by default) */
18
+ cooldownFrames?: number;
19
+ /** How many low-frequency spectrum bands contribute to energy (sub-bass + bass) */
20
+ bassEndIndex?: number;
21
+ }
22
+
23
+ export interface TempoEstimate {
24
+ bpm: number;
25
+ periodFrames: number;
26
+ phaseFrame: number;
27
+ /** Beat times in seconds from the start of the analyzed audio. */
28
+ beatsSec?: number[];
29
+ }
30
+
31
+ export const MIN_TEMPO_BPM = 70;
32
+ export const MAX_TEMPO_BPM = 180;
33
+ export const PREFERRED_TEMPO_BPM_MIN = 90;
34
+ export const PREFERRED_TEMPO_BPM_MAX = 160;
35
+ export const DEFAULT_BASS_END_INDEX = 8;
36
+ /** Longest PCM window passed to music-tempo (seconds), taken from the start of the audio. */
37
+ export const MAX_TEMPO_ANALYSIS_SECONDS = 90;
38
+ const TEMPO_TIME_STEP = 0.01;
39
+
40
+ export const frameBassEnergy = (spectrum: number[], bassEndIndex = DEFAULT_BASS_END_INDEX): number => {
41
+ const end = Math.min(bassEndIndex, spectrum.length);
42
+ if (end <= 0) {
43
+ return 0;
44
+ }
45
+ let sum = 0;
46
+ for (let i = 0; i < end; i++) {
47
+ const v = spectrum[i];
48
+ sum += v * v;
49
+ }
50
+ return sum / end;
51
+ };
52
+
53
+ export const createBeatDetector = (fps: number, options?: BeatDetectorOptions) => {
54
+ const historySize = options?.historySize ?? Math.round(fps * 1.5);
55
+ const thresholdMultiplier = options?.thresholdMultiplier ?? 1.4;
56
+ const decayRate = options?.decayRate ?? 0.85;
57
+ const cooldownFrames = options?.cooldownFrames ?? Math.round(fps * 0.15);
58
+ const bassEndIndex = options?.bassEndIndex ?? DEFAULT_BASS_END_INDEX;
59
+
60
+ const energyHistory: number[] = [];
61
+ let framesSinceLastBeat = cooldownFrames;
62
+ let decayingIntensity = 0;
63
+
64
+ return (spectrum: number[]): BeatInfo => {
65
+ const energy = frameBassEnergy(spectrum, bassEndIndex);
66
+
67
+ energyHistory.push(energy);
68
+ if (energyHistory.length > historySize) {
69
+ energyHistory.shift();
70
+ }
71
+
72
+ const avgEnergy = energyHistory.reduce((sum, e) => sum + e, 0) / energyHistory.length;
73
+ const variance = energyHistory.reduce((sum, e) => sum + (e - avgEnergy) ** 2, 0) / energyHistory.length;
74
+ const stdDev = Math.sqrt(variance);
75
+
76
+ const threshold = avgEnergy + stdDev * thresholdMultiplier;
77
+
78
+ framesSinceLastBeat++;
79
+
80
+ const hasEnoughHistory = energyHistory.length >= Math.round(historySize / 3);
81
+ const isAboveThreshold = energy > threshold && hasEnoughHistory;
82
+ const isCooldownOver = framesSinceLastBeat >= cooldownFrames;
83
+ const isBeat = isAboveThreshold && isCooldownOver;
84
+
85
+ if (isBeat) {
86
+ framesSinceLastBeat = 0;
87
+ decayingIntensity = Math.min(1, (energy - threshold) / (stdDev + 1e-6));
88
+ } else {
89
+ decayingIntensity *= decayRate;
90
+ }
91
+
92
+ return {
93
+ isBeat,
94
+ intensity: decayingIntensity,
95
+ energy,
96
+ threshold,
97
+ };
98
+ };
99
+ };
100
+
101
+ const foldTempoBpm = (bpm: number): number => {
102
+ let folded = bpm;
103
+ while (folded > MAX_TEMPO_BPM && folded / 2 >= MIN_TEMPO_BPM) {
104
+ folded /= 2;
105
+ }
106
+ while (folded < MIN_TEMPO_BPM && folded * 2 <= MAX_TEMPO_BPM) {
107
+ folded *= 2;
108
+ }
109
+ return folded;
110
+ };
111
+
112
+ const samplesToArray = (samples: ArrayLike<number>, maxLength: number): number[] => {
113
+ const n = Math.min(samples.length, maxLength);
114
+ if (Array.isArray(samples) && n === samples.length) {
115
+ return samples;
116
+ }
117
+ const audioData = new Array<number>(n);
118
+ for (let i = 0; i < n; i++) {
119
+ audioData[i] = samples[i];
120
+ }
121
+ return audioData;
122
+ };
123
+
124
+ const wrapIntoPeriod = (value: number, period: number): number => {
125
+ if (!(period > 0) || !isFinite(value)) {
126
+ return 0;
127
+ }
128
+ let wrapped = value - period * Math.floor(value / period);
129
+ if (wrapped < 0) {
130
+ wrapped += period;
131
+ }
132
+ if (wrapped >= period) {
133
+ wrapped = 0;
134
+ }
135
+ return wrapped;
136
+ };
137
+
138
+ const circularMeanPhase = (frames: number[], period: number): number => {
139
+ if (!(period > 0) || frames.length === 0) {
140
+ return 0;
141
+ }
142
+ let sinSum = 0;
143
+ let cosSum = 0;
144
+ for (const frame of frames) {
145
+ const angle = (2 * Math.PI * frame) / period;
146
+ sinSum += Math.sin(angle);
147
+ cosSum += Math.cos(angle);
148
+ }
149
+ const phase = Math.atan2(sinSum, cosSum) * period / (2 * Math.PI);
150
+ return wrapIntoPeriod(phase, period);
151
+ };
152
+
153
+ export const estimateTempo = (
154
+ samples: ArrayLike<number>,
155
+ sampleRate: number,
156
+ fps: number,
157
+ ): TempoEstimate | null => {
158
+ if (!samples || samples.length === 0 || !(sampleRate > 0) || !(fps > 0)) {
159
+ return null;
160
+ }
161
+
162
+ const maxSamples = Math.max(1, Math.floor(sampleRate * MAX_TEMPO_ANALYSIS_SECONDS));
163
+ const audioData = samplesToArray(samples, maxSamples);
164
+ const hopSize = Math.max(1, Math.round(sampleRate * TEMPO_TIME_STEP));
165
+
166
+ try {
167
+ const mt = new MusicTempo(audioData, {
168
+ hopSize,
169
+ timeStep: TEMPO_TIME_STEP,
170
+ minBeatInterval: 60 / MAX_TEMPO_BPM,
171
+ maxBeatInterval: 60 / MIN_TEMPO_BPM,
172
+ });
173
+ const beatInterval = Number(mt.beatInterval);
174
+ let bpm = Number(mt.tempo);
175
+ if (!(bpm > 0) && beatInterval > 0) {
176
+ bpm = 60 / beatInterval;
177
+ }
178
+ if (!(bpm > 0)) {
179
+ return null;
180
+ }
181
+
182
+ bpm = foldTempoBpm(bpm);
183
+ if (bpm < MIN_TEMPO_BPM * 0.9 || bpm > MAX_TEMPO_BPM * 1.1) {
184
+ return null;
185
+ }
186
+
187
+ const periodFrames = fps * 60 / bpm;
188
+ const beatsSec = (mt.beats ?? [])
189
+ .map(t => Number(t))
190
+ .filter(t => t >= 0 && isFinite(t));
191
+ const beatFrames = beatsSec.map(t => t * fps);
192
+ const phaseFrame = beatFrames.length > 0
193
+ ? wrapIntoPeriod(circularMeanPhase(beatFrames, periodFrames), periodFrames)
194
+ : 0;
195
+
196
+ return {
197
+ bpm: Math.round(bpm),
198
+ periodFrames,
199
+ phaseFrame,
200
+ ...(beatsSec.length > 0 ? { beatsSec } : {}),
201
+ };
202
+ } catch {
203
+ return null;
204
+ }
205
+ };
206
+
207
+ export const shiftTempoPhase = (
208
+ tempo: TempoEstimate,
209
+ startFrame: number,
210
+ ): TempoEstimate => {
211
+ const period = tempo.periodFrames;
212
+ if (!(period > 0) || startFrame === 0) {
213
+ return tempo;
214
+ }
215
+ return {
216
+ ...tempo,
217
+ phaseFrame: wrapIntoPeriod(tempo.phaseFrame - startFrame, period),
218
+ };
219
+ };
220
+
221
+ /** Beat-grid phase for a later audio window, using in-window beat times when available. */
222
+ export const tempoForWindow = (
223
+ tempo: TempoEstimate,
224
+ startFrame: number,
225
+ fps: number,
226
+ windowFrames?: number,
227
+ localOnsetFrames?: number[],
228
+ ): TempoEstimate => {
229
+ const period = tempo.periodFrames;
230
+ if (!(period > 0) || !(fps > 0)) {
231
+ return tempo;
232
+ }
233
+ const startSec = startFrame / fps;
234
+ const endSec = windowFrames != null && windowFrames >= 0
235
+ ? (startFrame + windowFrames) / fps
236
+ : Number.POSITIVE_INFINITY;
237
+ const inWindow = (tempo.beatsSec ?? []).filter(t => t >= startSec && t < endSec);
238
+ if (inWindow.length > 0) {
239
+ const localFrames = inWindow.map(t => (t - startSec) * fps);
240
+ return {
241
+ ...tempo,
242
+ phaseFrame: wrapIntoPeriod(circularMeanPhase(localFrames, period), period),
243
+ };
244
+ }
245
+ const onsets = (localOnsetFrames ?? []).filter(frame =>
246
+ frame > 0 && (windowFrames == null || frame < windowFrames),
247
+ );
248
+ if (onsets.length > 0) {
249
+ return {
250
+ ...tempo,
251
+ phaseFrame: wrapIntoPeriod(circularMeanPhase(onsets, period), period),
252
+ };
253
+ }
254
+ return shiftTempoPhase(tempo, startFrame);
255
+ };
256
+
257
+ export const beatGridFrameIndices = (
258
+ tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
259
+ totalFrames: number,
260
+ stride = 1,
261
+ ): number[] => {
262
+ const period = tempo.periodFrames;
263
+ const step = Math.max(1, Math.round(stride));
264
+ if (!(period > 0) || totalFrames <= 1) {
265
+ return [];
266
+ }
267
+ const frames: number[] = [];
268
+ const nStart = tempo.phaseFrame > 0 ? 0 : 1;
269
+ for (let n = nStart; ; n += step) {
270
+ const frame = Math.round(tempo.phaseFrame + n * period);
271
+ if (frame >= totalFrames) {
272
+ break;
273
+ }
274
+ if (frame > 0) {
275
+ frames.push(frame);
276
+ }
277
+ }
278
+ return frames;
279
+ };