chart-preview 1.0.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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +38 -0
- package/.vscode/extensions.json +6 -0
- package/.vscode/settings.json +8 -0
- package/README.md +541 -0
- package/dev/index.html +299 -0
- package/dev/main.ts +525 -0
- package/dev/tsconfig.json +16 -0
- package/dist/index.d.ts +496 -0
- package/dist/index.js +2158 -0
- package/dist/index.mjs +2125 -0
- package/package.json +57 -0
- package/src/ChartPreview.ts +1346 -0
- package/src/ChartPreviewPlayer.ts +1163 -0
- package/src/SharedAudioContext.ts +83 -0
- package/src/SngLoader.ts +330 -0
- package/src/index.ts +37 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
import { EventEmitter } from "eventemitter3";
|
|
2
|
+
import {
|
|
3
|
+
Difficulty,
|
|
4
|
+
getInstrumentType,
|
|
5
|
+
Instrument,
|
|
6
|
+
InstrumentType,
|
|
7
|
+
instrumentTypes,
|
|
8
|
+
NoteEvent,
|
|
9
|
+
noteFlags,
|
|
10
|
+
NoteType,
|
|
11
|
+
noteTypes,
|
|
12
|
+
parseChartFile,
|
|
13
|
+
} from "scan-chart";
|
|
14
|
+
import * as THREE from "three";
|
|
15
|
+
import {
|
|
16
|
+
getSharedAudioContext,
|
|
17
|
+
registerInstance,
|
|
18
|
+
unregisterInstance,
|
|
19
|
+
isAudioContextSupported,
|
|
20
|
+
} from "./SharedAudioContext";
|
|
21
|
+
|
|
22
|
+
export type ParsedChart = ReturnType<typeof parseChartFile>;
|
|
23
|
+
|
|
24
|
+
const HIGHWAY_DURATION_MS = 1500;
|
|
25
|
+
const SCALE = 0.105;
|
|
26
|
+
const NOTE_SPAN_WIDTH = 0.95;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Custom note types for 6-fret barre notes.
|
|
30
|
+
* These are the only sprites without a dedicated NoteType from scan-chart.
|
|
31
|
+
*/
|
|
32
|
+
const BARRE1_TYPE = 99991 as const;
|
|
33
|
+
const BARRE2_TYPE = 99992 as const;
|
|
34
|
+
const BARRE3_TYPE = 99993 as const;
|
|
35
|
+
type BarreType = typeof BARRE1_TYPE | typeof BARRE2_TYPE | typeof BARRE3_TYPE;
|
|
36
|
+
const barreTypes: readonly BarreType[] = [
|
|
37
|
+
BARRE1_TYPE,
|
|
38
|
+
BARRE2_TYPE,
|
|
39
|
+
BARRE3_TYPE,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Extended note type that includes custom barre types */
|
|
43
|
+
type ExtendedNoteType = NoteType | BarreType;
|
|
44
|
+
|
|
45
|
+
// Sprites for star power versions are the only sprites without a dedicated NoteFlag
|
|
46
|
+
const SP_FLAG = 2147483648;
|
|
47
|
+
|
|
48
|
+
/** Default interval between progress events in milliseconds */
|
|
49
|
+
const DEFAULT_PROGRESS_INTERVAL_MS = 50;
|
|
50
|
+
|
|
51
|
+
export interface ChartPreviewEvents {
|
|
52
|
+
progress: (percentComplete: number) => void;
|
|
53
|
+
end: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ChartPreviewConfig {
|
|
57
|
+
/** The parsed chart data from scan-chart's parseChartFile function */
|
|
58
|
+
parsedChart: ParsedChart;
|
|
59
|
+
/** Pre-loaded textures from ChartPreview.loadTextures() */
|
|
60
|
+
textures: Awaited<ReturnType<typeof loadTextures>>;
|
|
61
|
+
/** Array of audio file data (Uint8Array) to be played in sync with the preview */
|
|
62
|
+
audioFiles: Uint8Array[];
|
|
63
|
+
/** The instrument to display */
|
|
64
|
+
instrument: Instrument;
|
|
65
|
+
/** The difficulty level to display */
|
|
66
|
+
difficulty: Difficulty;
|
|
67
|
+
/** The amount of time to delay the start of the audio in milliseconds (can be negative) */
|
|
68
|
+
startDelayMs: number;
|
|
69
|
+
/** The length of the longest audio file stem in milliseconds */
|
|
70
|
+
audioLengthMs: number;
|
|
71
|
+
/** The HTML div element where the preview should be rendered */
|
|
72
|
+
container: HTMLDivElement;
|
|
73
|
+
/** Minimum interval between progress events in milliseconds. Defaults to 50ms. */
|
|
74
|
+
progressIntervalMs?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Renders a chart preview inside a container element, and plays audio files in sync with the render.
|
|
79
|
+
* Works like a video player but generates 3D visualization of the chart in real-time.
|
|
80
|
+
*/
|
|
81
|
+
export class ChartPreview {
|
|
82
|
+
private eventEmitter = new EventEmitter<ChartPreviewEvents>();
|
|
83
|
+
|
|
84
|
+
public instrumentType: InstrumentType;
|
|
85
|
+
private paused = true;
|
|
86
|
+
private scene = new THREE.Scene();
|
|
87
|
+
private highwayTexture: THREE.Texture;
|
|
88
|
+
private camera: ChartCamera;
|
|
89
|
+
private renderer: ChartRenderer;
|
|
90
|
+
private audioManager: AudioManager | SilentAudioManager;
|
|
91
|
+
private notesManager: NotesManager;
|
|
92
|
+
private progressIntervalMs: number;
|
|
93
|
+
private lastProgressEmitTime = 0;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Loads textures required for the chart preview.
|
|
97
|
+
* Call this once per instrument type and cache the result for reuse.
|
|
98
|
+
*/
|
|
99
|
+
static loadTextures = loadTextures;
|
|
100
|
+
|
|
101
|
+
private constructor() {}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Adds an event listener.
|
|
105
|
+
*
|
|
106
|
+
* Available events:
|
|
107
|
+
* - `progress`: called during playback with the current progress (0-1), throttled by progressIntervalMs.
|
|
108
|
+
* - `end`: called when the chart preview ends.
|
|
109
|
+
*/
|
|
110
|
+
on<T extends keyof ChartPreviewEvents>(
|
|
111
|
+
event: T,
|
|
112
|
+
listener: ChartPreviewEvents[T]
|
|
113
|
+
) {
|
|
114
|
+
this.eventEmitter.on(event, listener as (...args: unknown[]) => void);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Removes an event listener.
|
|
119
|
+
*/
|
|
120
|
+
off<T extends keyof ChartPreviewEvents>(
|
|
121
|
+
event: T,
|
|
122
|
+
listener: ChartPreviewEvents[T]
|
|
123
|
+
) {
|
|
124
|
+
this.eventEmitter.off(event, listener as (...args: unknown[]) => void);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates a new ChartPreview instance.
|
|
129
|
+
*
|
|
130
|
+
* @param config - Configuration object for the chart preview
|
|
131
|
+
*
|
|
132
|
+
* Will throw an exception if textures fail to load or if audio decoding fails.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* ```ts
|
|
136
|
+
* import { ChartPreview, ParsedChart } from 'chart-preview'
|
|
137
|
+
* import { parseChartFile, getInstrumentType } from 'scan-chart'
|
|
138
|
+
*
|
|
139
|
+
* // Parse your chart file first
|
|
140
|
+
* const parsedChart = parseChartFile(chartData, format, modifiers)
|
|
141
|
+
*
|
|
142
|
+
* // Load textures for the instrument type
|
|
143
|
+
* const textures = await ChartPreview.loadTextures(getInstrumentType('guitar'))
|
|
144
|
+
*
|
|
145
|
+
* // Create the preview
|
|
146
|
+
* const preview = await ChartPreview.create({
|
|
147
|
+
* parsedChart,
|
|
148
|
+
* textures,
|
|
149
|
+
* audioFiles: [audioData],
|
|
150
|
+
* instrument: 'guitar',
|
|
151
|
+
* difficulty: 'expert',
|
|
152
|
+
* startDelayMs: 0,
|
|
153
|
+
* audioLengthMs: 180000,
|
|
154
|
+
* container: document.getElementById('preview-container')
|
|
155
|
+
* })
|
|
156
|
+
*
|
|
157
|
+
* // Control playback
|
|
158
|
+
* await preview.togglePaused()
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
static async create(config: ChartPreviewConfig) {
|
|
162
|
+
const chartPreview = new ChartPreview();
|
|
163
|
+
chartPreview.instrumentType = getInstrumentType(config.instrument);
|
|
164
|
+
chartPreview.highwayTexture = config.textures.highwayTexture;
|
|
165
|
+
chartPreview.progressIntervalMs =
|
|
166
|
+
config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
|
|
167
|
+
chartPreview.camera = new ChartCamera(config.container);
|
|
168
|
+
chartPreview.renderer = new ChartRenderer(config.container);
|
|
169
|
+
chartPreview.audioManager = await (isAudioContextSupported()
|
|
170
|
+
? AudioManager.create(config.audioFiles, config.startDelayMs)
|
|
171
|
+
: SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
|
|
172
|
+
chartPreview.audioManager.on("end", () =>
|
|
173
|
+
chartPreview.eventEmitter.emit("end")
|
|
174
|
+
);
|
|
175
|
+
chartPreview.notesManager = new NotesManager(
|
|
176
|
+
config.parsedChart,
|
|
177
|
+
config.instrument,
|
|
178
|
+
config.difficulty,
|
|
179
|
+
chartPreview.scene,
|
|
180
|
+
config.textures.noteTextures
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
chartPreview.addHighwayToScene(config.textures.highwayTexture);
|
|
184
|
+
chartPreview.addStrikelineToScene(config.textures.strikelineTexture);
|
|
185
|
+
config.container.firstChild?.remove();
|
|
186
|
+
config.container.appendChild(chartPreview.renderer.domElement);
|
|
187
|
+
|
|
188
|
+
return chartPreview;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Toggles between playing and paused states.
|
|
193
|
+
*/
|
|
194
|
+
async togglePaused() {
|
|
195
|
+
if (this.paused) {
|
|
196
|
+
await this.audioManager.play();
|
|
197
|
+
this.renderer.setAnimationLoop(() => this.animateFrame());
|
|
198
|
+
} else {
|
|
199
|
+
await this.audioManager.pause();
|
|
200
|
+
this.renderer.setAnimationLoop(null);
|
|
201
|
+
}
|
|
202
|
+
this.paused = !this.paused;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Returns whether the preview is currently paused.
|
|
207
|
+
*/
|
|
208
|
+
get isPaused() {
|
|
209
|
+
return this.paused;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Moves the playback time to `percentComplete` of the way through the preview.
|
|
214
|
+
* @param percentComplete A number between 0 and 1 (inclusive)
|
|
215
|
+
*/
|
|
216
|
+
async seek(percentComplete: number) {
|
|
217
|
+
await this.audioManager.seek(percentComplete);
|
|
218
|
+
this.animateFrame(false);
|
|
219
|
+
this.renderer.setAnimationLoop(null);
|
|
220
|
+
this.paused = true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Gets or sets the volume (0 to 1).
|
|
225
|
+
* Returns `null` if audio isn't loaded (e.g., using SilentAudioManager).
|
|
226
|
+
*/
|
|
227
|
+
set volume(volume: number | null) {
|
|
228
|
+
this.audioManager.volume = volume;
|
|
229
|
+
}
|
|
230
|
+
get volume() {
|
|
231
|
+
return this.audioManager.volume;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Current playback position in milliseconds */
|
|
235
|
+
get chartCurrentTimeMs() {
|
|
236
|
+
return this.audioManager.chartCurrentTimeMs;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Total duration in milliseconds */
|
|
240
|
+
get chartEndTimeMs() {
|
|
241
|
+
return this.audioManager.chartEndTimeMs;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Cleans up all resources. Call this when discarding the preview.
|
|
246
|
+
* The preview should not be used after calling dispose().
|
|
247
|
+
*/
|
|
248
|
+
dispose() {
|
|
249
|
+
this.eventEmitter.removeAllListeners();
|
|
250
|
+
this.camera.dispose();
|
|
251
|
+
this.renderer.setAnimationLoop(null);
|
|
252
|
+
this.renderer.renderLists.dispose();
|
|
253
|
+
this.renderer.dispose();
|
|
254
|
+
this.renderer.forceContextLoss();
|
|
255
|
+
this.audioManager.closeAudio();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private addHighwayToScene(highwayTexture: THREE.Texture) {
|
|
259
|
+
const mat = new THREE.MeshBasicMaterial({ map: highwayTexture });
|
|
260
|
+
|
|
261
|
+
const geometry = new THREE.PlaneGeometry(
|
|
262
|
+
this.instrumentType === instrumentTypes.drums
|
|
263
|
+
? 0.9
|
|
264
|
+
: this.instrumentType === instrumentTypes.sixFret
|
|
265
|
+
? 0.7
|
|
266
|
+
: 1,
|
|
267
|
+
2
|
|
268
|
+
);
|
|
269
|
+
const plane = new THREE.Mesh(geometry, mat);
|
|
270
|
+
plane.position.y = -0.1;
|
|
271
|
+
plane.renderOrder = 1;
|
|
272
|
+
|
|
273
|
+
this.scene.add(plane);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private addStrikelineToScene(strikelineTexture: THREE.Texture) {
|
|
277
|
+
const material = new THREE.SpriteMaterial({
|
|
278
|
+
map: strikelineTexture,
|
|
279
|
+
sizeAttenuation: true,
|
|
280
|
+
transparent: true,
|
|
281
|
+
depthTest: false,
|
|
282
|
+
});
|
|
283
|
+
const aspectRatio =
|
|
284
|
+
strikelineTexture.image.width / strikelineTexture.image.height;
|
|
285
|
+
const scale =
|
|
286
|
+
this.instrumentType === instrumentTypes.sixFret ? 0.141 : 0.19;
|
|
287
|
+
const sprite = new THREE.Sprite(material);
|
|
288
|
+
if (aspectRatio > 1) {
|
|
289
|
+
// Texture is wider than it is tall
|
|
290
|
+
sprite.scale.set(aspectRatio * scale, 1 * scale, 1);
|
|
291
|
+
} else {
|
|
292
|
+
// Texture is taller than it is wide or square
|
|
293
|
+
sprite.scale.set(1 * scale, (1 / aspectRatio) * scale, 1);
|
|
294
|
+
}
|
|
295
|
+
sprite.position.y = -1;
|
|
296
|
+
sprite.renderOrder = 3;
|
|
297
|
+
|
|
298
|
+
this.scene.add(sprite);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private animateFrame(emit = true) {
|
|
302
|
+
this.notesManager.updateDisplayedNotes(
|
|
303
|
+
this.audioManager.chartCurrentTimeMs
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Shift highway position
|
|
307
|
+
const scrollPosition =
|
|
308
|
+
-0.9 *
|
|
309
|
+
(this.audioManager.chartCurrentTimeMs / 1000) *
|
|
310
|
+
(HIGHWAY_DURATION_MS / 1000);
|
|
311
|
+
this.highwayTexture.offset.y = -1 * scrollPosition;
|
|
312
|
+
// Y position goes from -0.1 to 2-0.1
|
|
313
|
+
|
|
314
|
+
this.renderer.render(this.scene, this.camera);
|
|
315
|
+
|
|
316
|
+
// Throttle progress events to reduce overhead
|
|
317
|
+
if (emit) {
|
|
318
|
+
const now = performance.now();
|
|
319
|
+
if (now - this.lastProgressEmitTime >= this.progressIntervalMs) {
|
|
320
|
+
this.lastProgressEmitTime = now;
|
|
321
|
+
this.eventEmitter.emit(
|
|
322
|
+
"progress",
|
|
323
|
+
this.audioManager.chartCurrentTimeMs /
|
|
324
|
+
this.audioManager.chartEndTimeMs
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
class ChartCamera extends THREE.PerspectiveCamera {
|
|
332
|
+
constructor(private divContainer: HTMLDivElement) {
|
|
333
|
+
super(90, 1 / 1, 0.01, 10);
|
|
334
|
+
this.position.z = 0.8;
|
|
335
|
+
this.position.y = -1.3;
|
|
336
|
+
this.rotation.x = THREE.MathUtils.degToRad(60);
|
|
337
|
+
this.onResize();
|
|
338
|
+
window.addEventListener("resize", this.resizeListener);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private resizeListener = () => this.onResize();
|
|
342
|
+
private onResize() {
|
|
343
|
+
const width = this.divContainer.offsetWidth ?? window.innerWidth;
|
|
344
|
+
const height = this.divContainer.offsetHeight ?? window.innerHeight;
|
|
345
|
+
this.aspect = width / height;
|
|
346
|
+
this.updateProjectionMatrix();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
dispose() {
|
|
350
|
+
window.removeEventListener("resize", this.resizeListener);
|
|
351
|
+
this.clear();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
class ChartRenderer extends THREE.WebGLRenderer {
|
|
356
|
+
constructor(private divContainer: HTMLDivElement) {
|
|
357
|
+
super({
|
|
358
|
+
antialias: true,
|
|
359
|
+
});
|
|
360
|
+
this.localClippingEnabled = true;
|
|
361
|
+
this.outputColorSpace = THREE.LinearSRGBColorSpace;
|
|
362
|
+
this.onResize();
|
|
363
|
+
window.addEventListener("resize", this.resizeListener);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private resizeListener = () => this.onResize();
|
|
367
|
+
private onResize() {
|
|
368
|
+
const width = this.divContainer.offsetWidth ?? window.innerWidth;
|
|
369
|
+
const height = this.divContainer.offsetHeight ?? window.innerHeight;
|
|
370
|
+
this.setSize(width, height);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
override dispose() {
|
|
374
|
+
window.removeEventListener("resize", this.resizeListener);
|
|
375
|
+
super.dispose();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
interface AudioManagerEvents {
|
|
380
|
+
end: () => void;
|
|
381
|
+
}
|
|
382
|
+
class AudioManager {
|
|
383
|
+
private eventEmitter = new EventEmitter();
|
|
384
|
+
|
|
385
|
+
private audioCtx: AudioContext;
|
|
386
|
+
private gainNode: GainNode | null = null;
|
|
387
|
+
private audioSources: AudioBufferSourceNode[] = [];
|
|
388
|
+
private _volume = 0.5;
|
|
389
|
+
private lastSeekChartTimeMs = 0;
|
|
390
|
+
private lastAudioCtxCurrentTime = 0; // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
|
|
391
|
+
private audioLengthMs: number = 0;
|
|
392
|
+
|
|
393
|
+
private constructor(
|
|
394
|
+
private audioFiles: Uint8Array[],
|
|
395
|
+
private startDelayMs: number
|
|
396
|
+
) {
|
|
397
|
+
const ctx = getSharedAudioContext();
|
|
398
|
+
if (!ctx) {
|
|
399
|
+
throw new Error("AudioContext is not supported in this environment");
|
|
400
|
+
}
|
|
401
|
+
this.audioCtx = ctx;
|
|
402
|
+
registerInstance();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* @param audioFiles The `Uint8Array[]` of the audio files to be played.
|
|
407
|
+
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
|
|
408
|
+
*/
|
|
409
|
+
static async create(audioFiles: Uint8Array[], startDelayMs: number) {
|
|
410
|
+
const audioManager = new AudioManager(audioFiles, startDelayMs);
|
|
411
|
+
await audioManager.initAudio();
|
|
412
|
+
return audioManager;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Available events:
|
|
417
|
+
* - `end`: called when the audio playback ends.
|
|
418
|
+
*/
|
|
419
|
+
on<T extends keyof AudioManagerEvents>(
|
|
420
|
+
event: T,
|
|
421
|
+
listener: AudioManagerEvents[T]
|
|
422
|
+
) {
|
|
423
|
+
this.eventEmitter.on(event, listener);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** `volume` is a number between 0 and 1 (inclusive). Values outside this range are clamped. */
|
|
427
|
+
set volume(volume: number) {
|
|
428
|
+
const clamped = Math.max(0, Math.min(1, volume));
|
|
429
|
+
this._volume = clamped * clamped;
|
|
430
|
+
if (this.gainNode) {
|
|
431
|
+
this.gainNode.gain.value = this._volume;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** `volume` is a number between 0 and 1 (inclusive) */
|
|
435
|
+
get volume() {
|
|
436
|
+
return Math.sqrt(this._volume);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
|
|
440
|
+
get chartCurrentTimeMs() {
|
|
441
|
+
const isPaused = this.audioCtx.state === "suspended";
|
|
442
|
+
// outputLatency is not implemented in safari
|
|
443
|
+
const audioLatency =
|
|
444
|
+
(this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1000;
|
|
445
|
+
const audioTimeSinceLastSeekMs =
|
|
446
|
+
(this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1000;
|
|
447
|
+
// Note: when paused, the queued audio during the latency period is skipped and never heard.
|
|
448
|
+
// The solution here is to represent that visually by jumping ahead slightly by ignoring latency when paused.
|
|
449
|
+
// If this is a more significant problem, it can be fixed by seeking backward by `audioLatency`.
|
|
450
|
+
return (
|
|
451
|
+
this.lastSeekChartTimeMs +
|
|
452
|
+
audioTimeSinceLastSeekMs -
|
|
453
|
+
(isPaused ? 0 : audioLatency)
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
|
458
|
+
get chartEndTimeMs() {
|
|
459
|
+
return Math.max(this.startDelayMs + this.audioLengthMs, 0);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async play() {
|
|
463
|
+
// Reinitialize audio if needed (e.g., after seeking)
|
|
464
|
+
if (this.gainNode === null) {
|
|
465
|
+
await this.initAudio();
|
|
466
|
+
}
|
|
467
|
+
// Resume context if suspended
|
|
468
|
+
if (this.audioCtx.state === "suspended") {
|
|
469
|
+
await this.audioCtx.resume();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async pause() {
|
|
473
|
+
if (this.audioCtx.state === "running") {
|
|
474
|
+
await this.audioCtx.suspend();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
closeAudio() {
|
|
479
|
+
this.eventEmitter.removeAllListeners();
|
|
480
|
+
// Stop all audio sources
|
|
481
|
+
for (const source of this.audioSources) {
|
|
482
|
+
// Remove onended handler before stopping to prevent false "end" events
|
|
483
|
+
source.onended = null;
|
|
484
|
+
try {
|
|
485
|
+
source.stop();
|
|
486
|
+
source.disconnect();
|
|
487
|
+
} catch {
|
|
488
|
+
// Source may already be stopped
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
this.audioSources = [];
|
|
492
|
+
this.gainNode?.disconnect();
|
|
493
|
+
this.gainNode = null;
|
|
494
|
+
// Don't close the shared context - just unregister this instance
|
|
495
|
+
unregisterInstance();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async initAudio() {
|
|
499
|
+
// Stop any existing sources before creating new ones
|
|
500
|
+
for (const source of this.audioSources) {
|
|
501
|
+
// Remove onended handler before stopping to prevent false "end" events
|
|
502
|
+
source.onended = null;
|
|
503
|
+
try {
|
|
504
|
+
source.stop();
|
|
505
|
+
source.disconnect();
|
|
506
|
+
} catch {
|
|
507
|
+
// Source may already be stopped
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
this.audioSources = [];
|
|
511
|
+
|
|
512
|
+
const audioBuffers = await Promise.all(
|
|
513
|
+
// Must be recreated on each seek because seek is not supported by the Web Audio API
|
|
514
|
+
this.audioFiles.map((file) =>
|
|
515
|
+
this.audioCtx.decodeAudioData(file.slice(0).buffer)
|
|
516
|
+
)
|
|
517
|
+
);
|
|
518
|
+
this.audioLengthMs =
|
|
519
|
+
Math.max(...audioBuffers.map((b) => b.duration)) * 1000;
|
|
520
|
+
|
|
521
|
+
this.gainNode = this.audioCtx.createGain();
|
|
522
|
+
this.gainNode.gain.value = this._volume;
|
|
523
|
+
this.gainNode.connect(this.audioCtx.destination);
|
|
524
|
+
|
|
525
|
+
let endedCount = 0;
|
|
526
|
+
const audioStartOffsetSeconds =
|
|
527
|
+
(this.lastSeekChartTimeMs - this.startDelayMs) / 1000;
|
|
528
|
+
for (const audioBuffer of audioBuffers) {
|
|
529
|
+
const source = this.audioCtx.createBufferSource();
|
|
530
|
+
source.buffer = audioBuffer;
|
|
531
|
+
source.onended = () => {
|
|
532
|
+
endedCount++;
|
|
533
|
+
if (endedCount === audioBuffers.length) {
|
|
534
|
+
this.eventEmitter.emit("end");
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
source.connect(this.gainNode!);
|
|
538
|
+
// When using a shared AudioContext, we need to schedule relative to the current time,
|
|
539
|
+
// not absolute time 0, since the context may have been running for a while
|
|
540
|
+
const delaySeconds = Math.abs(Math.min(audioStartOffsetSeconds, 0));
|
|
541
|
+
const when = this.audioCtx.currentTime + delaySeconds;
|
|
542
|
+
const offset = Math.max(audioStartOffsetSeconds, 0);
|
|
543
|
+
source.start(when, offset);
|
|
544
|
+
this.audioSources.push(source);
|
|
545
|
+
}
|
|
546
|
+
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
547
|
+
this.pause();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @param percentComplete The progress between the start and end of the preview.
|
|
552
|
+
*/
|
|
553
|
+
async seek(percentComplete: number) {
|
|
554
|
+
// Suspend context first to prevent time drift during seek
|
|
555
|
+
// (chartCurrentTimeMs is calculated from audioCtx.currentTime)
|
|
556
|
+
if (this.audioCtx.state === "running") {
|
|
557
|
+
await this.audioCtx.suspend();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Stop existing audio sources
|
|
561
|
+
for (const source of this.audioSources) {
|
|
562
|
+
// Remove onended handler before stopping to prevent false "end" events
|
|
563
|
+
source.onended = null;
|
|
564
|
+
try {
|
|
565
|
+
source.stop();
|
|
566
|
+
source.disconnect();
|
|
567
|
+
} catch {
|
|
568
|
+
// Source may already be stopped
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
this.audioSources = [];
|
|
572
|
+
this.gainNode?.disconnect();
|
|
573
|
+
this.gainNode = null;
|
|
574
|
+
const chartSeekTimeMs = percentComplete * this.chartEndTimeMs;
|
|
575
|
+
this.lastSeekChartTimeMs = chartSeekTimeMs;
|
|
576
|
+
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Used if window.AudioContext || window.webkitAudioContext is undefined. */
|
|
581
|
+
class SilentAudioManager {
|
|
582
|
+
private eventEmitter = new EventEmitter();
|
|
583
|
+
private endEventTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
584
|
+
|
|
585
|
+
private isPaused = true;
|
|
586
|
+
private lastResumeChartTimeMs: number;
|
|
587
|
+
private lastResumeClockTimeMs: number;
|
|
588
|
+
|
|
589
|
+
private constructor(
|
|
590
|
+
private startDelayMs: number,
|
|
591
|
+
private audioLengthMs: number
|
|
592
|
+
) {}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
|
|
596
|
+
* @param audioLengthMs The length of the longest audio file stem.
|
|
597
|
+
*/
|
|
598
|
+
static async create(startDelayMs: number, audioLengthMs: number) {
|
|
599
|
+
const audioManager = new SilentAudioManager(startDelayMs, audioLengthMs);
|
|
600
|
+
await audioManager.seek(0);
|
|
601
|
+
return audioManager;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Available events:
|
|
606
|
+
* - `end`: called when the playback ends.
|
|
607
|
+
*/
|
|
608
|
+
on<T extends keyof AudioManagerEvents>(
|
|
609
|
+
event: T,
|
|
610
|
+
listener: AudioManagerEvents[T]
|
|
611
|
+
) {
|
|
612
|
+
this.eventEmitter.on(event, listener);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** `volume` is invalid for silent playback. */
|
|
616
|
+
set volume(_null: number | null) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
/** `volume` is invalid for silent playback. */
|
|
620
|
+
get volume() {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
|
|
625
|
+
get chartCurrentTimeMs() {
|
|
626
|
+
if (this.isPaused) {
|
|
627
|
+
return this.lastResumeChartTimeMs;
|
|
628
|
+
} else {
|
|
629
|
+
return (
|
|
630
|
+
this.lastResumeChartTimeMs +
|
|
631
|
+
performance.now() -
|
|
632
|
+
this.lastResumeClockTimeMs
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
|
637
|
+
get chartEndTimeMs() {
|
|
638
|
+
return Math.max(this.startDelayMs + this.audioLengthMs, 0);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async play() {
|
|
642
|
+
if (this.lastResumeChartTimeMs >= this.chartEndTimeMs - 2) {
|
|
643
|
+
this.lastResumeChartTimeMs = 0; // Restart at the end
|
|
644
|
+
}
|
|
645
|
+
this.lastResumeClockTimeMs = performance.now();
|
|
646
|
+
this.endEventTimeout = setTimeout(() => {
|
|
647
|
+
this.pause();
|
|
648
|
+
this.eventEmitter.emit("end");
|
|
649
|
+
}, this.chartEndTimeMs - this.lastResumeChartTimeMs);
|
|
650
|
+
this.isPaused = false;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async pause() {
|
|
654
|
+
this.lastResumeChartTimeMs = this.chartCurrentTimeMs;
|
|
655
|
+
if (this.endEventTimeout) {
|
|
656
|
+
clearTimeout(this.endEventTimeout);
|
|
657
|
+
}
|
|
658
|
+
this.isPaused = true;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
closeAudio() {
|
|
662
|
+
this.eventEmitter.removeAllListeners();
|
|
663
|
+
if (this.endEventTimeout) {
|
|
664
|
+
clearTimeout(this.endEventTimeout);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* @param percentComplete The progress between the start and end of the preview.
|
|
670
|
+
*/
|
|
671
|
+
async seek(percentComplete: number) {
|
|
672
|
+
this.lastResumeChartTimeMs = this.chartEndTimeMs * percentComplete;
|
|
673
|
+
|
|
674
|
+
if (!this.isPaused) {
|
|
675
|
+
this.play();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Handles adding/removing/moving the notes in `scene` at the given `chartCurrentTimeMs` value.
|
|
682
|
+
*/
|
|
683
|
+
class NotesManager {
|
|
684
|
+
private noteMaterials = new Map<
|
|
685
|
+
ExtendedNoteType,
|
|
686
|
+
Map<number, THREE.SpriteMaterial>
|
|
687
|
+
>();
|
|
688
|
+
private clippingPlanes = [
|
|
689
|
+
new THREE.Plane(new THREE.Vector3(0, 1, 0), 1),
|
|
690
|
+
new THREE.Plane(new THREE.Vector3(0, -1, 0), 0.9),
|
|
691
|
+
];
|
|
692
|
+
|
|
693
|
+
private instrumentType: InstrumentType;
|
|
694
|
+
private noteEvents: NoteEvent[];
|
|
695
|
+
private notes: EventSequence<
|
|
696
|
+
ParsedChart["trackData"][number]["noteEventGroups"][number][number]
|
|
697
|
+
>;
|
|
698
|
+
|
|
699
|
+
// TODO: These will be used in a future release to render visual indicators for:
|
|
700
|
+
// - Solo sections (glowing effect or highlighted highway)
|
|
701
|
+
// - Flex lanes (dynamic lane highlighting)
|
|
702
|
+
// - Drum freestyle sections (open note zone indicators)
|
|
703
|
+
private soloSections: EventSequence<
|
|
704
|
+
ParsedChart["trackData"][number]["soloSections"][number]
|
|
705
|
+
>;
|
|
706
|
+
private flexLanes: EventSequence<
|
|
707
|
+
ParsedChart["trackData"][number]["flexLanes"][number]
|
|
708
|
+
>;
|
|
709
|
+
private drumFreestyleSections: EventSequence<
|
|
710
|
+
ParsedChart["trackData"][number]["drumFreestyleSections"][number]
|
|
711
|
+
>;
|
|
712
|
+
|
|
713
|
+
private noteGroups = new Map<number, THREE.Group<THREE.Object3DEventMap>>();
|
|
714
|
+
|
|
715
|
+
constructor(
|
|
716
|
+
private chartData: ParsedChart,
|
|
717
|
+
private instrument: Instrument,
|
|
718
|
+
private difficulty: Difficulty,
|
|
719
|
+
private scene: THREE.Scene,
|
|
720
|
+
noteTextures: Map<ExtendedNoteType, Map<number, THREE.Texture>>
|
|
721
|
+
) {
|
|
722
|
+
adjustParsedChart(chartData, instrument, difficulty);
|
|
723
|
+
Object.values(noteTypes).forEach((noteType) =>
|
|
724
|
+
this.noteMaterials.set(noteType, new Map())
|
|
725
|
+
);
|
|
726
|
+
barreTypes.forEach((barreType) =>
|
|
727
|
+
this.noteMaterials.set(barreType, new Map())
|
|
728
|
+
);
|
|
729
|
+
noteTextures.forEach((flagTextures, noteType) => {
|
|
730
|
+
flagTextures.forEach((texture, noteFlagsValue) => {
|
|
731
|
+
this.noteMaterials
|
|
732
|
+
.get(noteType)!
|
|
733
|
+
.set(noteFlagsValue, new THREE.SpriteMaterial({ map: texture }));
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const track = chartData.trackData.find(
|
|
738
|
+
(t) => t.instrument === instrument && t.difficulty === difficulty
|
|
739
|
+
);
|
|
740
|
+
if (!track) {
|
|
741
|
+
throw new Error(
|
|
742
|
+
`Track not found for instrument "${instrument}" at difficulty "${difficulty}"`
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
this.instrumentType = getInstrumentType(instrument);
|
|
747
|
+
this.noteEvents = track.noteEventGroups.flat();
|
|
748
|
+
this.notes = new EventSequence(this.noteEvents);
|
|
749
|
+
this.soloSections = new EventSequence(track.soloSections);
|
|
750
|
+
this.flexLanes = new EventSequence(track.flexLanes);
|
|
751
|
+
this.drumFreestyleSections = new EventSequence(track.drumFreestyleSections);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
updateDisplayedNotes(chartCurrentTimeMs: number) {
|
|
755
|
+
const noteStartIndex =
|
|
756
|
+
this.notes.getEarliestActiveEventIndex(chartCurrentTimeMs);
|
|
757
|
+
|
|
758
|
+
const renderEndTimeMs = chartCurrentTimeMs + HIGHWAY_DURATION_MS;
|
|
759
|
+
let maxNoteEventIndex = noteStartIndex - 1;
|
|
760
|
+
for (const [noteEventIndex, sprite] of this.noteGroups) {
|
|
761
|
+
if (
|
|
762
|
+
noteEventIndex < noteStartIndex ||
|
|
763
|
+
this.noteEvents[noteEventIndex].msTime > renderEndTimeMs
|
|
764
|
+
) {
|
|
765
|
+
this.scene.remove(sprite);
|
|
766
|
+
this.noteGroups.delete(noteEventIndex);
|
|
767
|
+
} else {
|
|
768
|
+
sprite.position.y = interpolate(
|
|
769
|
+
this.noteEvents[noteEventIndex].msTime,
|
|
770
|
+
chartCurrentTimeMs,
|
|
771
|
+
renderEndTimeMs,
|
|
772
|
+
-1,
|
|
773
|
+
1
|
|
774
|
+
);
|
|
775
|
+
if (noteEventIndex > maxNoteEventIndex) {
|
|
776
|
+
maxNoteEventIndex = noteEventIndex;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
for (
|
|
782
|
+
let i = maxNoteEventIndex + 1;
|
|
783
|
+
this.noteEvents[i] && this.noteEvents[i].msTime < renderEndTimeMs;
|
|
784
|
+
i++
|
|
785
|
+
) {
|
|
786
|
+
const note = this.noteEvents[i];
|
|
787
|
+
const noteGroup = new THREE.Group();
|
|
788
|
+
const scale =
|
|
789
|
+
note.type === noteTypes.kick
|
|
790
|
+
? 0.045
|
|
791
|
+
: note.type === noteTypes.open &&
|
|
792
|
+
this.instrumentType === instrumentTypes.sixFret
|
|
793
|
+
? 0.04
|
|
794
|
+
: SCALE;
|
|
795
|
+
const sprite = new THREE.Sprite(
|
|
796
|
+
this.noteMaterials.get(note.type)!.get(note.flags)!
|
|
797
|
+
);
|
|
798
|
+
noteGroup.add(sprite);
|
|
799
|
+
sprite.center = new THREE.Vector2(
|
|
800
|
+
note.type === noteTypes.kick ? 0.62 : 0.5,
|
|
801
|
+
note.type === noteTypes.kick ? -0.5 : 0
|
|
802
|
+
);
|
|
803
|
+
const aspectRatio =
|
|
804
|
+
sprite.material.map!.image.width / sprite.material.map!.image.height;
|
|
805
|
+
sprite.scale.set(scale * aspectRatio, scale, scale);
|
|
806
|
+
noteGroup.position.x = calculateNoteXOffset(
|
|
807
|
+
this.instrumentType,
|
|
808
|
+
note.type
|
|
809
|
+
);
|
|
810
|
+
noteGroup.position.y = interpolate(
|
|
811
|
+
note.msTime,
|
|
812
|
+
chartCurrentTimeMs,
|
|
813
|
+
renderEndTimeMs,
|
|
814
|
+
-1,
|
|
815
|
+
1
|
|
816
|
+
);
|
|
817
|
+
noteGroup.position.z = 0;
|
|
818
|
+
sprite.material.clippingPlanes = this.clippingPlanes;
|
|
819
|
+
sprite.material.depthTest = false;
|
|
820
|
+
sprite.material.transparent = true;
|
|
821
|
+
sprite.renderOrder = note.type === noteTypes.kick ? 1 : 4;
|
|
822
|
+
|
|
823
|
+
if (note.msLength > 0) {
|
|
824
|
+
const mat = new THREE.MeshBasicMaterial({
|
|
825
|
+
color: calculateColor(note.type),
|
|
826
|
+
side: THREE.DoubleSide,
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
mat.clippingPlanes = this.clippingPlanes;
|
|
830
|
+
mat.depthTest = false;
|
|
831
|
+
mat.transparent = true;
|
|
832
|
+
const geometry = new THREE.PlaneGeometry(
|
|
833
|
+
SCALE * (note.type === noteTypes.open ? 5 : 0.3),
|
|
834
|
+
2 * (note.msLength / HIGHWAY_DURATION_MS)
|
|
835
|
+
);
|
|
836
|
+
const plane = new THREE.Mesh(geometry, mat);
|
|
837
|
+
plane.position.y = 0.03 + note.msLength / HIGHWAY_DURATION_MS;
|
|
838
|
+
plane.renderOrder = 2;
|
|
839
|
+
|
|
840
|
+
noteGroup.add(plane);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
this.noteGroups.set(i, noteGroup);
|
|
844
|
+
this.scene.add(noteGroup);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
class EventSequence<
|
|
850
|
+
T extends { msTime: number; msLength: number; type?: NoteType }
|
|
851
|
+
> {
|
|
852
|
+
/** Contains the closest events before msTime, grouped by type */
|
|
853
|
+
private lastPrecedingEventIndexesOfType = new Map<
|
|
854
|
+
NoteType | undefined,
|
|
855
|
+
number
|
|
856
|
+
>();
|
|
857
|
+
private lastPrecedingEventIndex = -1;
|
|
858
|
+
|
|
859
|
+
/** Assumes `events` are already sorted in `msTime` order. */
|
|
860
|
+
constructor(private events: T[]) {}
|
|
861
|
+
|
|
862
|
+
getEarliestActiveEventIndex(startMs: number) {
|
|
863
|
+
if (
|
|
864
|
+
this.lastPrecedingEventIndex !== -1 &&
|
|
865
|
+
startMs < this.events[this.lastPrecedingEventIndex].msTime
|
|
866
|
+
) {
|
|
867
|
+
this.lastPrecedingEventIndexesOfType = new Map<
|
|
868
|
+
NoteType | undefined,
|
|
869
|
+
number
|
|
870
|
+
>();
|
|
871
|
+
this.lastPrecedingEventIndex = -1;
|
|
872
|
+
}
|
|
873
|
+
while (
|
|
874
|
+
this.events[this.lastPrecedingEventIndex + 1] &&
|
|
875
|
+
this.events[this.lastPrecedingEventIndex + 1].msTime < startMs
|
|
876
|
+
) {
|
|
877
|
+
this.lastPrecedingEventIndexesOfType.set(
|
|
878
|
+
this.events[this.lastPrecedingEventIndex + 1].type,
|
|
879
|
+
this.lastPrecedingEventIndex + 1
|
|
880
|
+
);
|
|
881
|
+
this.lastPrecedingEventIndex++;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
let earliestActiveEventIndex: number | null = null;
|
|
885
|
+
for (const [, index] of this.lastPrecedingEventIndexesOfType) {
|
|
886
|
+
if (this.events[index].msTime + this.events[index].msLength > startMs) {
|
|
887
|
+
if (
|
|
888
|
+
earliestActiveEventIndex === null ||
|
|
889
|
+
earliestActiveEventIndex > index
|
|
890
|
+
) {
|
|
891
|
+
earliestActiveEventIndex = index;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return earliestActiveEventIndex === null
|
|
897
|
+
? this.lastPrecedingEventIndex + 1
|
|
898
|
+
: earliestActiveEventIndex;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Loads all required textures for the chart preview.
|
|
904
|
+
* @param instrumentType The type of instrument to load textures for
|
|
905
|
+
* @returns An object containing highway, strikeline, and note textures
|
|
906
|
+
*/
|
|
907
|
+
async function loadTextures(instrumentType: InstrumentType) {
|
|
908
|
+
const textureLoader = new THREE.TextureLoader();
|
|
909
|
+
const load = (path: string) =>
|
|
910
|
+
textureLoader.loadAsync("https://static.enchor.us/" + path);
|
|
911
|
+
|
|
912
|
+
const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
|
|
913
|
+
(async () => {
|
|
914
|
+
const texture = await load("preview-highway.png");
|
|
915
|
+
|
|
916
|
+
texture.wrapS = THREE.RepeatWrapping;
|
|
917
|
+
texture.wrapT = THREE.RepeatWrapping;
|
|
918
|
+
|
|
919
|
+
texture.repeat.set(1, 2);
|
|
920
|
+
return texture;
|
|
921
|
+
})(),
|
|
922
|
+
(async () => {
|
|
923
|
+
switch (instrumentType) {
|
|
924
|
+
case instrumentTypes.drums:
|
|
925
|
+
return await load("preview-drums-strikeline.png");
|
|
926
|
+
case instrumentTypes.sixFret:
|
|
927
|
+
return await load("preview-6fret-strikeline.png");
|
|
928
|
+
case instrumentTypes.fiveFret:
|
|
929
|
+
return await load("preview-5fret-strikeline.png");
|
|
930
|
+
}
|
|
931
|
+
})(),
|
|
932
|
+
(async () => {
|
|
933
|
+
const texturePromises: {
|
|
934
|
+
type: ExtendedNoteType;
|
|
935
|
+
flags: number;
|
|
936
|
+
texture: Promise<THREE.Texture>;
|
|
937
|
+
}[] = [];
|
|
938
|
+
const addTexture = (
|
|
939
|
+
type: ExtendedNoteType,
|
|
940
|
+
flags: number,
|
|
941
|
+
path: string
|
|
942
|
+
) => {
|
|
943
|
+
const texture = textureLoader.loadAsync(
|
|
944
|
+
`https://static.enchor.us/preview-${path}.webp`
|
|
945
|
+
);
|
|
946
|
+
texturePromises.push({ type, flags, texture });
|
|
947
|
+
return texture;
|
|
948
|
+
};
|
|
949
|
+
const reuseTexture = (
|
|
950
|
+
type: ExtendedNoteType,
|
|
951
|
+
flags: number,
|
|
952
|
+
texture: Promise<THREE.Texture>
|
|
953
|
+
) => {
|
|
954
|
+
texturePromises.push({ type, flags, texture });
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
if (instrumentType === instrumentTypes.drums) {
|
|
958
|
+
const colors = new Map([
|
|
959
|
+
[noteTypes.redDrum, "red"],
|
|
960
|
+
[noteTypes.yellowDrum, "yellow"],
|
|
961
|
+
[noteTypes.blueDrum, "blue"],
|
|
962
|
+
[noteTypes.greenDrum, "green"],
|
|
963
|
+
]);
|
|
964
|
+
const dynamicFlags = new Map([
|
|
965
|
+
[noteFlags.none, ""],
|
|
966
|
+
[noteFlags.ghost, "-ghost"],
|
|
967
|
+
[noteFlags.accent, "-accent"],
|
|
968
|
+
]);
|
|
969
|
+
const spFlags = new Map([
|
|
970
|
+
[noteFlags.none, ""],
|
|
971
|
+
[SP_FLAG, "-sp"],
|
|
972
|
+
]);
|
|
973
|
+
|
|
974
|
+
addTexture(noteTypes.kick, noteFlags.none, "drums-kick");
|
|
975
|
+
addTexture(noteTypes.kick, noteFlags.doubleKick, "drums-kick");
|
|
976
|
+
addTexture(noteTypes.kick, noteFlags.none | SP_FLAG, "drums-kick-sp");
|
|
977
|
+
addTexture(
|
|
978
|
+
noteTypes.kick,
|
|
979
|
+
noteFlags.doubleKick | SP_FLAG,
|
|
980
|
+
"drums-kick-sp"
|
|
981
|
+
);
|
|
982
|
+
for (const [colorKey, colorName] of colors) {
|
|
983
|
+
for (const [dynamicFlagKey, dynamicFlagName] of dynamicFlags) {
|
|
984
|
+
for (const [spFlagKey, spFlagName] of spFlags) {
|
|
985
|
+
addTexture(
|
|
986
|
+
colorKey,
|
|
987
|
+
spFlagKey | dynamicFlagKey | noteFlags.tom,
|
|
988
|
+
`drums-${colorName}-tom${dynamicFlagName}${spFlagName}`
|
|
989
|
+
);
|
|
990
|
+
if (colorKey !== noteTypes.redDrum) {
|
|
991
|
+
addTexture(
|
|
992
|
+
colorKey,
|
|
993
|
+
spFlagKey | dynamicFlagKey | noteFlags.cymbal,
|
|
994
|
+
`drums-${colorName}-cymbal${dynamicFlagName}${spFlagName}`
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
} else if (instrumentType === instrumentTypes.sixFret) {
|
|
1001
|
+
const lanes = new Map<ExtendedNoteType, string>([
|
|
1002
|
+
[noteTypes.open, "open"],
|
|
1003
|
+
[noteTypes.black1, "black"],
|
|
1004
|
+
[noteTypes.white1, "white"],
|
|
1005
|
+
[BARRE1_TYPE, "barre"],
|
|
1006
|
+
]);
|
|
1007
|
+
const modifiers = new Map([
|
|
1008
|
+
[noteFlags.strum, "-strum"],
|
|
1009
|
+
[noteFlags.hopo, "-hopo"],
|
|
1010
|
+
[noteFlags.tap, "-tap"],
|
|
1011
|
+
]);
|
|
1012
|
+
const spFlags = new Map([
|
|
1013
|
+
[noteFlags.none, ""],
|
|
1014
|
+
[SP_FLAG, "-sp"],
|
|
1015
|
+
]);
|
|
1016
|
+
|
|
1017
|
+
for (const [laneKey, laneName] of lanes) {
|
|
1018
|
+
for (const [modifierKey, modifierName] of modifiers) {
|
|
1019
|
+
for (const [spFlagKey, spFlagName] of spFlags) {
|
|
1020
|
+
const texturePromise = addTexture(
|
|
1021
|
+
laneKey,
|
|
1022
|
+
modifierKey | spFlagKey,
|
|
1023
|
+
`6fret-${laneName}${modifierName}${spFlagName}`
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
// Same texture used for all three lanes
|
|
1027
|
+
if (laneKey === noteTypes.black1) {
|
|
1028
|
+
reuseTexture(
|
|
1029
|
+
noteTypes.black2,
|
|
1030
|
+
modifierKey | spFlagKey,
|
|
1031
|
+
texturePromise
|
|
1032
|
+
);
|
|
1033
|
+
reuseTexture(
|
|
1034
|
+
noteTypes.black3,
|
|
1035
|
+
modifierKey | spFlagKey,
|
|
1036
|
+
texturePromise
|
|
1037
|
+
);
|
|
1038
|
+
} else if (laneKey === noteTypes.white1) {
|
|
1039
|
+
reuseTexture(
|
|
1040
|
+
noteTypes.white2,
|
|
1041
|
+
modifierKey | spFlagKey,
|
|
1042
|
+
texturePromise
|
|
1043
|
+
);
|
|
1044
|
+
reuseTexture(
|
|
1045
|
+
noteTypes.white3,
|
|
1046
|
+
modifierKey | spFlagKey,
|
|
1047
|
+
texturePromise
|
|
1048
|
+
);
|
|
1049
|
+
} else if (laneKey === BARRE1_TYPE) {
|
|
1050
|
+
reuseTexture(
|
|
1051
|
+
BARRE2_TYPE,
|
|
1052
|
+
modifierKey | spFlagKey,
|
|
1053
|
+
texturePromise
|
|
1054
|
+
);
|
|
1055
|
+
reuseTexture(
|
|
1056
|
+
BARRE3_TYPE,
|
|
1057
|
+
modifierKey | spFlagKey,
|
|
1058
|
+
texturePromise
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
} else if (instrumentType === instrumentTypes.fiveFret) {
|
|
1065
|
+
const lanes = new Map([
|
|
1066
|
+
[noteTypes.open, "open"],
|
|
1067
|
+
[noteTypes.green, "green"],
|
|
1068
|
+
[noteTypes.red, "red"],
|
|
1069
|
+
[noteTypes.yellow, "yellow"],
|
|
1070
|
+
[noteTypes.blue, "blue"],
|
|
1071
|
+
[noteTypes.orange, "orange"],
|
|
1072
|
+
]);
|
|
1073
|
+
const modifiers = new Map([
|
|
1074
|
+
[noteFlags.strum, "-strum"],
|
|
1075
|
+
[noteFlags.hopo, "-hopo"],
|
|
1076
|
+
[noteFlags.tap, "-tap"],
|
|
1077
|
+
]);
|
|
1078
|
+
const spFlags = new Map([
|
|
1079
|
+
[noteFlags.none, ""],
|
|
1080
|
+
[SP_FLAG, "-sp"],
|
|
1081
|
+
]);
|
|
1082
|
+
|
|
1083
|
+
for (const [laneKey, laneName] of lanes) {
|
|
1084
|
+
for (let [modifierKey, modifierName] of modifiers) {
|
|
1085
|
+
for (const [spFlagKey, spFlagName] of spFlags) {
|
|
1086
|
+
if (laneKey === noteTypes.open && modifierKey === noteFlags.tap) {
|
|
1087
|
+
modifierName = "-hopo";
|
|
1088
|
+
modifierKey = noteFlags.hopo;
|
|
1089
|
+
}
|
|
1090
|
+
addTexture(
|
|
1091
|
+
laneKey,
|
|
1092
|
+
modifierKey | spFlagKey,
|
|
1093
|
+
`5fret-${laneName}${modifierName}${spFlagName}`
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const textures = await Promise.all(
|
|
1101
|
+
texturePromises.map(async (t) => ({
|
|
1102
|
+
type: t.type,
|
|
1103
|
+
flags: t.flags,
|
|
1104
|
+
texture: await t.texture,
|
|
1105
|
+
}))
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const textureMap = new Map<
|
|
1109
|
+
ExtendedNoteType,
|
|
1110
|
+
Map<number, THREE.Texture>
|
|
1111
|
+
>();
|
|
1112
|
+
Object.values(noteTypes).forEach((noteType) =>
|
|
1113
|
+
textureMap.set(noteType, new Map())
|
|
1114
|
+
);
|
|
1115
|
+
barreTypes.forEach((barreType) => textureMap.set(barreType, new Map()));
|
|
1116
|
+
for (const texture of textures) {
|
|
1117
|
+
textureMap.get(texture.type)!.set(texture.flags, texture.texture);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return textureMap;
|
|
1121
|
+
})(),
|
|
1122
|
+
]);
|
|
1123
|
+
|
|
1124
|
+
return {
|
|
1125
|
+
highwayTexture,
|
|
1126
|
+
strikelineTexture,
|
|
1127
|
+
noteTextures,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function adjustParsedChart(
|
|
1132
|
+
parsedChart: ParsedChart,
|
|
1133
|
+
instrument: Instrument,
|
|
1134
|
+
difficulty: Difficulty
|
|
1135
|
+
) {
|
|
1136
|
+
const track = parsedChart.trackData.find(
|
|
1137
|
+
(t) => t.instrument === instrument && t.difficulty === difficulty
|
|
1138
|
+
)!;
|
|
1139
|
+
const starPower = track.starPowerSections;
|
|
1140
|
+
|
|
1141
|
+
if (starPower.length > 0) {
|
|
1142
|
+
let starPowerIndex = 0;
|
|
1143
|
+
for (const noteGroup of track.noteEventGroups) {
|
|
1144
|
+
while (
|
|
1145
|
+
starPowerIndex < starPower.length &&
|
|
1146
|
+
starPower[starPowerIndex].tick + starPower[starPowerIndex].length <
|
|
1147
|
+
noteGroup[0].tick
|
|
1148
|
+
) {
|
|
1149
|
+
starPowerIndex++;
|
|
1150
|
+
}
|
|
1151
|
+
if (starPowerIndex === starPower.length) {
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
if (
|
|
1155
|
+
noteGroup[0].tick >= starPower[starPowerIndex].tick &&
|
|
1156
|
+
noteGroup[0].tick <
|
|
1157
|
+
starPower[starPowerIndex].tick + starPower[starPowerIndex].length
|
|
1158
|
+
) {
|
|
1159
|
+
for (const note of noteGroup) {
|
|
1160
|
+
note.flags |= SP_FLAG;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (getInstrumentType(instrument) === instrumentTypes.sixFret) {
|
|
1167
|
+
for (const noteGroup of track.noteEventGroups) {
|
|
1168
|
+
let oneCount = 0;
|
|
1169
|
+
let twoCount = 0;
|
|
1170
|
+
let threeCount = 0;
|
|
1171
|
+
for (const note of noteGroup) {
|
|
1172
|
+
switch (note.type) {
|
|
1173
|
+
case noteTypes.black1:
|
|
1174
|
+
case noteTypes.white1:
|
|
1175
|
+
oneCount++;
|
|
1176
|
+
break;
|
|
1177
|
+
case noteTypes.black2:
|
|
1178
|
+
case noteTypes.white2:
|
|
1179
|
+
twoCount++;
|
|
1180
|
+
break;
|
|
1181
|
+
case noteTypes.black3:
|
|
1182
|
+
case noteTypes.white3:
|
|
1183
|
+
threeCount++;
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (oneCount > 1) {
|
|
1188
|
+
const removed = removeFromArray(
|
|
1189
|
+
noteGroup,
|
|
1190
|
+
(n) => n.type === noteTypes.black1 || n.type === noteTypes.white1
|
|
1191
|
+
);
|
|
1192
|
+
// Cast to allow assigning custom barre type
|
|
1193
|
+
(removed[0] as { type: ExtendedNoteType }).type = BARRE1_TYPE;
|
|
1194
|
+
noteGroup.push(removed[0]);
|
|
1195
|
+
}
|
|
1196
|
+
if (twoCount > 1) {
|
|
1197
|
+
const removed = removeFromArray(
|
|
1198
|
+
noteGroup,
|
|
1199
|
+
(n) => n.type === noteTypes.black2 || n.type === noteTypes.white2
|
|
1200
|
+
);
|
|
1201
|
+
(removed[0] as { type: ExtendedNoteType }).type = BARRE2_TYPE;
|
|
1202
|
+
noteGroup.push(removed[0]);
|
|
1203
|
+
}
|
|
1204
|
+
if (threeCount > 1) {
|
|
1205
|
+
const removed = removeFromArray(
|
|
1206
|
+
noteGroup,
|
|
1207
|
+
(n) => n.type === noteTypes.black3 || n.type === noteTypes.white3
|
|
1208
|
+
);
|
|
1209
|
+
(removed[0] as { type: ExtendedNoteType }).type = BARRE3_TYPE;
|
|
1210
|
+
noteGroup.push(removed[0]);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
} else if (getInstrumentType(instrument) === instrumentTypes.drums) {
|
|
1214
|
+
for (const noteGroup of track.noteEventGroups) {
|
|
1215
|
+
for (const note of noteGroup) {
|
|
1216
|
+
if (note.flags & noteFlags.discoNoflip) {
|
|
1217
|
+
note.flags &= ~noteFlags.discoNoflip;
|
|
1218
|
+
}
|
|
1219
|
+
if (note.flags & noteFlags.disco) {
|
|
1220
|
+
note.flags &= ~noteFlags.disco;
|
|
1221
|
+
switch (note.type) {
|
|
1222
|
+
case noteTypes.redDrum:
|
|
1223
|
+
note.type = noteTypes.yellowDrum;
|
|
1224
|
+
note.flags &= ~noteFlags.tom;
|
|
1225
|
+
note.flags |= noteFlags.cymbal;
|
|
1226
|
+
break;
|
|
1227
|
+
case noteTypes.yellowDrum:
|
|
1228
|
+
note.type = noteTypes.redDrum;
|
|
1229
|
+
note.flags &= ~noteFlags.cymbal;
|
|
1230
|
+
note.flags |= noteFlags.tom;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return parsedChart;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function calculateNoteXOffset(
|
|
1241
|
+
instrumentType: InstrumentType,
|
|
1242
|
+
noteType: NoteType
|
|
1243
|
+
) {
|
|
1244
|
+
const lane = calculateLane(noteType);
|
|
1245
|
+
const leftOffset =
|
|
1246
|
+
instrumentType === instrumentTypes.drums
|
|
1247
|
+
? 0.135
|
|
1248
|
+
: instrumentType === instrumentTypes.sixFret &&
|
|
1249
|
+
noteType !== noteTypes.open
|
|
1250
|
+
? 0.2
|
|
1251
|
+
: instrumentType === instrumentTypes.sixFret &&
|
|
1252
|
+
noteType === noteTypes.open
|
|
1253
|
+
? 0.035
|
|
1254
|
+
: 0.035;
|
|
1255
|
+
|
|
1256
|
+
return (
|
|
1257
|
+
leftOffset +
|
|
1258
|
+
-(NOTE_SPAN_WIDTH / 2) +
|
|
1259
|
+
SCALE +
|
|
1260
|
+
((NOTE_SPAN_WIDTH - SCALE) / 5) * lane
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function calculateLane(noteType: NoteType) {
|
|
1265
|
+
switch (noteType) {
|
|
1266
|
+
case noteTypes.green:
|
|
1267
|
+
case noteTypes.redDrum:
|
|
1268
|
+
case noteTypes.black1:
|
|
1269
|
+
case noteTypes.white1:
|
|
1270
|
+
case BARRE1_TYPE as NoteType:
|
|
1271
|
+
return 0;
|
|
1272
|
+
case noteTypes.red:
|
|
1273
|
+
case noteTypes.yellowDrum:
|
|
1274
|
+
case noteTypes.black2:
|
|
1275
|
+
case noteTypes.white2:
|
|
1276
|
+
case BARRE2_TYPE as NoteType:
|
|
1277
|
+
return 1;
|
|
1278
|
+
case noteTypes.yellow:
|
|
1279
|
+
case noteTypes.blueDrum:
|
|
1280
|
+
case noteTypes.open:
|
|
1281
|
+
case noteTypes.kick:
|
|
1282
|
+
case noteTypes.black3:
|
|
1283
|
+
case noteTypes.white3:
|
|
1284
|
+
case BARRE3_TYPE as NoteType:
|
|
1285
|
+
return 2;
|
|
1286
|
+
case noteTypes.blue:
|
|
1287
|
+
case noteTypes.greenDrum:
|
|
1288
|
+
return 3;
|
|
1289
|
+
case noteTypes.orange:
|
|
1290
|
+
return 4;
|
|
1291
|
+
default:
|
|
1292
|
+
return 0;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function calculateColor(noteType: NoteType) {
|
|
1297
|
+
switch (noteType) {
|
|
1298
|
+
case noteTypes.green:
|
|
1299
|
+
case noteTypes.greenDrum:
|
|
1300
|
+
return "#01B11A";
|
|
1301
|
+
case noteTypes.red:
|
|
1302
|
+
case noteTypes.redDrum:
|
|
1303
|
+
return "#DD2214";
|
|
1304
|
+
case noteTypes.yellow:
|
|
1305
|
+
case noteTypes.yellowDrum:
|
|
1306
|
+
return "#DEEB52";
|
|
1307
|
+
case noteTypes.blue:
|
|
1308
|
+
case noteTypes.blueDrum:
|
|
1309
|
+
return "#006CAF";
|
|
1310
|
+
case noteTypes.open:
|
|
1311
|
+
return "#8A0BB5";
|
|
1312
|
+
case noteTypes.orange:
|
|
1313
|
+
return "#F8B272";
|
|
1314
|
+
default:
|
|
1315
|
+
return "#FFFFFF";
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Removes all elements from an array that satisfy the predicate and returns the removed elements.
|
|
1321
|
+
* Modifies the array in place (similar to lodash's _.remove).
|
|
1322
|
+
*/
|
|
1323
|
+
function removeFromArray<T>(array: T[], predicate: (item: T) => boolean): T[] {
|
|
1324
|
+
const removed: T[] = [];
|
|
1325
|
+
for (let i = array.length - 1; i >= 0; i--) {
|
|
1326
|
+
if (predicate(array[i])) {
|
|
1327
|
+
removed.unshift(array.splice(i, 1)[0]);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return removed;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Converts `val` from the range (`fromStart`, `fromEnd`) to the range (`toStart`, `toEnd`).
|
|
1335
|
+
*/
|
|
1336
|
+
function interpolate(
|
|
1337
|
+
val: number,
|
|
1338
|
+
fromStart: number,
|
|
1339
|
+
fromEnd: number,
|
|
1340
|
+
toStart: number,
|
|
1341
|
+
toEnd: number
|
|
1342
|
+
) {
|
|
1343
|
+
return (
|
|
1344
|
+
((val - fromStart) / (fromEnd - fromStart)) * (toEnd - toStart) + toStart
|
|
1345
|
+
);
|
|
1346
|
+
}
|