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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared AudioContext manager for multi-instance support.
3
+ *
4
+ * Browsers limit the number of AudioContexts that can be created (typically ~6).
5
+ * By sharing a single AudioContext across all ChartPreview instances, we can
6
+ * support many more simultaneous players.
7
+ *
8
+ * Each player instance gets its own GainNode connected to the shared context,
9
+ * allowing independent volume control while sharing the same audio destination.
10
+ */
11
+
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ const AudioContextClass =
14
+ typeof window !== "undefined"
15
+ ? window.AudioContext || (window as any).webkitAudioContext
16
+ : null;
17
+
18
+ let sharedContext: AudioContext | null = null;
19
+ let activeInstanceCount = 0;
20
+
21
+ /**
22
+ * Gets or creates the shared AudioContext.
23
+ * The context is lazily created on first use.
24
+ *
25
+ * @returns The shared AudioContext, or null if AudioContext is not supported
26
+ */
27
+ export function getSharedAudioContext(): AudioContext | null {
28
+ if (!AudioContextClass) {
29
+ return null;
30
+ }
31
+
32
+ if (!sharedContext || sharedContext.state === "closed") {
33
+ sharedContext = new AudioContextClass();
34
+ }
35
+
36
+ return sharedContext;
37
+ }
38
+
39
+ /**
40
+ * Registers a new instance that will use the shared AudioContext.
41
+ * Call this when a player starts using the context.
42
+ */
43
+ export function registerInstance(): void {
44
+ activeInstanceCount++;
45
+ }
46
+
47
+ /**
48
+ * Unregisters an instance from the shared AudioContext.
49
+ * When all instances are unregistered, the context can be closed.
50
+ *
51
+ * Note: We don't actually close the context here because:
52
+ * 1. Creating a new context is expensive
53
+ * 2. The user might create new players soon
54
+ * 3. The context uses minimal resources when not playing
55
+ */
56
+ export function unregisterInstance(): void {
57
+ activeInstanceCount = Math.max(0, activeInstanceCount - 1);
58
+ }
59
+
60
+ /**
61
+ * Gets the current number of active instances using the shared context.
62
+ */
63
+ export function getActiveInstanceCount(): number {
64
+ return activeInstanceCount;
65
+ }
66
+
67
+ /**
68
+ * Checks if AudioContext is supported in the current environment.
69
+ */
70
+ export function isAudioContextSupported(): boolean {
71
+ return AudioContextClass !== null;
72
+ }
73
+
74
+ /**
75
+ * Resumes the shared AudioContext if it's suspended.
76
+ * This is needed because browsers require user interaction before playing audio.
77
+ */
78
+ export async function resumeSharedContext(): Promise<void> {
79
+ const ctx = getSharedAudioContext();
80
+ if (ctx && ctx.state === "suspended") {
81
+ await ctx.resume();
82
+ }
83
+ }
@@ -0,0 +1,330 @@
1
+ import { SngStream } from "parse-sng";
2
+ import {
3
+ scanChartFolder,
4
+ parseChartFile,
5
+ getInstrumentType,
6
+ type Difficulty,
7
+ type Instrument,
8
+ type ScannedChart,
9
+ } from "scan-chart";
10
+ import { ChartPreview, type ParsedChart } from "./ChartPreview";
11
+
12
+ /**
13
+ * Audio file extensions supported by the chart preview.
14
+ */
15
+ const AUDIO_EXTENSIONS = [".ogg", ".mp3", ".wav", ".opus"];
16
+
17
+ /**
18
+ * Audio file names to exclude from playback (preview stems, crowd noise).
19
+ */
20
+ const EXCLUDED_AUDIO_NAMES = ["preview", "crowd"];
21
+
22
+ /**
23
+ * Configuration for loading a chart from a URL.
24
+ */
25
+ export interface LoadFromUrlConfig {
26
+ /** URL to the .sng file */
27
+ url: string;
28
+ /** The instrument to display */
29
+ instrument: Instrument;
30
+ /** The difficulty level to display */
31
+ difficulty: Difficulty;
32
+ /** Initial seek position as a percentage (0-1). Defaults to 0. */
33
+ initialSeekPercent?: number;
34
+ /** AbortSignal to cancel the fetch operation */
35
+ signal?: AbortSignal;
36
+ }
37
+
38
+ /**
39
+ * Configuration for loading a chart from raw .sng file data.
40
+ */
41
+ export interface LoadFromSngFileConfig {
42
+ /** Raw .sng file data */
43
+ sngFile: Uint8Array;
44
+ /** The instrument to display */
45
+ instrument: Instrument;
46
+ /** The difficulty level to display */
47
+ difficulty: Difficulty;
48
+ /** Initial seek position as a percentage (0-1). Defaults to 0. */
49
+ initialSeekPercent?: number;
50
+ }
51
+
52
+ /**
53
+ * Configuration for loading a chart from individual chart and audio files.
54
+ */
55
+ export interface LoadFromChartFilesConfig {
56
+ /** Array of files with their names and data */
57
+ files: { fileName: string; data: Uint8Array }[];
58
+ /** The instrument to display */
59
+ instrument: Instrument;
60
+ /** The difficulty level to display */
61
+ difficulty: Difficulty;
62
+ /** Initial seek position as a percentage (0-1). Defaults to 0. */
63
+ initialSeekPercent?: number;
64
+ }
65
+
66
+ /**
67
+ * Result of preparing chart data for playback.
68
+ */
69
+ export interface PreparedChartData {
70
+ parsedChart: ParsedChart;
71
+ textures: Awaited<ReturnType<typeof ChartPreview.loadTextures>>;
72
+ audioFiles: Uint8Array[];
73
+ instrument: Instrument;
74
+ difficulty: Difficulty;
75
+ startDelayMs: number;
76
+ audioLengthMs: number;
77
+ initialSeekPercent: number;
78
+ }
79
+
80
+ /**
81
+ * Extracts files from a .sng archive.
82
+ *
83
+ * @param data - Raw .sng file data
84
+ * @returns Array of extracted files with their names and data
85
+ */
86
+ export async function extractSngFile(
87
+ data: Uint8Array
88
+ ): Promise<{ fileName: string; data: Uint8Array }[]> {
89
+ return new Promise((resolve, reject) => {
90
+ const files: { fileName: string; data: Uint8Array }[] = [];
91
+
92
+ // Convert Uint8Array to ReadableStream
93
+ const stream = new ReadableStream<Uint8Array>({
94
+ start(controller) {
95
+ controller.enqueue(data);
96
+ controller.close();
97
+ },
98
+ });
99
+
100
+ const sngStream = new SngStream(stream, { generateSongIni: true });
101
+
102
+ sngStream.on("error", (error) => {
103
+ reject(error instanceof Error ? error : new Error(String(error)));
104
+ });
105
+
106
+ sngStream.on("file", async (fileName, fileStream, nextFile) => {
107
+ try {
108
+ // Read the entire file stream into a Uint8Array
109
+ const chunks: Uint8Array[] = [];
110
+ const reader = fileStream.getReader();
111
+
112
+ while (true) {
113
+ const { done, value } = await reader.read();
114
+ if (done) break;
115
+ chunks.push(value);
116
+ }
117
+
118
+ // Combine chunks into a single Uint8Array
119
+ const totalLength = chunks.reduce(
120
+ (sum, chunk) => sum + chunk.length,
121
+ 0
122
+ );
123
+ const fileData = new Uint8Array(totalLength);
124
+ let offset = 0;
125
+ for (const chunk of chunks) {
126
+ fileData.set(chunk, offset);
127
+ offset += chunk.length;
128
+ }
129
+
130
+ files.push({ fileName, data: fileData });
131
+
132
+ if (nextFile) {
133
+ nextFile();
134
+ } else {
135
+ resolve(files);
136
+ }
137
+ } catch (error) {
138
+ reject(error instanceof Error ? error : new Error(String(error)));
139
+ }
140
+ });
141
+
142
+ sngStream.start();
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Finds the chart file (.chart or .mid) from an array of files.
148
+ * Prefers .mid over .chart when both exist.
149
+ *
150
+ * @param files - Array of files to search
151
+ * @returns The chart file, or null if not found
152
+ */
153
+ export function findChartFile(
154
+ files: { fileName: string; data: Uint8Array }[]
155
+ ): { fileName: string; data: Uint8Array; format: "chart" | "mid" } | null {
156
+ // Look for .mid first (preferred)
157
+ const midFile = files.find((f) => f.fileName.toLowerCase().endsWith(".mid"));
158
+ if (midFile) {
159
+ return { ...midFile, format: "mid" };
160
+ }
161
+
162
+ // Fall back to .chart
163
+ const chartFile = files.find((f) =>
164
+ f.fileName.toLowerCase().endsWith(".chart")
165
+ );
166
+ if (chartFile) {
167
+ return { ...chartFile, format: "chart" };
168
+ }
169
+
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Finds audio files from an array of files, excluding preview and crowd stems.
175
+ *
176
+ * @param files - Array of files to search
177
+ * @returns Array of audio file data
178
+ */
179
+ export function findAudioFiles(
180
+ files: { fileName: string; data: Uint8Array }[]
181
+ ): Uint8Array[] {
182
+ return files
183
+ .filter((file) => {
184
+ const ext = file.fileName
185
+ .toLowerCase()
186
+ .slice(file.fileName.lastIndexOf("."));
187
+ if (!AUDIO_EXTENSIONS.includes(ext)) {
188
+ return false;
189
+ }
190
+
191
+ // Exclude preview and crowd audio files
192
+ const baseName =
193
+ file.fileName
194
+ .toLowerCase()
195
+ .replace(/\.[^/.]+$/, "")
196
+ .split("/")
197
+ .pop() || "";
198
+ return !EXCLUDED_AUDIO_NAMES.some((excluded) =>
199
+ baseName.includes(excluded)
200
+ );
201
+ })
202
+ .map((file) => file.data);
203
+ }
204
+
205
+ /**
206
+ * Checks if a filename is a video file (to be excluded from .sng extraction).
207
+ */
208
+ export function isVideoFile(fileName: string): boolean {
209
+ const videoExtensions = [".mp4", ".avi", ".webm", ".ogv", ".mpeg"];
210
+ const ext = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
211
+ return videoExtensions.includes(ext);
212
+ }
213
+
214
+ /**
215
+ * Prepares chart data for playback by extracting, parsing, and loading all resources.
216
+ *
217
+ * @param files - Array of extracted files
218
+ * @param instrument - The instrument to display
219
+ * @param difficulty - The difficulty level to display
220
+ * @param initialSeekPercent - Initial seek position (0-1)
221
+ * @returns Prepared chart data ready for the player
222
+ */
223
+ export async function prepareChartData(
224
+ files: { fileName: string; data: Uint8Array }[],
225
+ instrument: Instrument,
226
+ difficulty: Difficulty,
227
+ initialSeekPercent = 0
228
+ ): Promise<PreparedChartData> {
229
+ // Filter out video files
230
+ const filteredFiles = files.filter((f) => !isVideoFile(f.fileName));
231
+
232
+ // Scan the chart folder to get metadata and modifiers
233
+ const scannedChart: ScannedChart = scanChartFolder(filteredFiles);
234
+
235
+ if (!scannedChart.playable || !scannedChart.notesData) {
236
+ throw new Error("This chart has no playable tracks.");
237
+ }
238
+
239
+ // Find the chart file
240
+ const chartFile = findChartFile(filteredFiles);
241
+ if (!chartFile) {
242
+ throw new Error("No .chart or .mid file found in the chart.");
243
+ }
244
+
245
+ // Build chart modifiers from scanned chart
246
+ const modifiers = {
247
+ song_length: scannedChart.song_length || 0,
248
+ hopo_frequency: scannedChart.hopo_frequency || 0,
249
+ eighthnote_hopo: scannedChart.eighthnote_hopo || false,
250
+ multiplier_note: scannedChart.multiplier_note || 0,
251
+ sustain_cutoff_threshold: scannedChart.sustain_cutoff_threshold ?? -1,
252
+ chord_snap_threshold: scannedChart.chord_snap_threshold || 0,
253
+ five_lane_drums: scannedChart.five_lane_drums || false,
254
+ pro_drums: scannedChart.pro_drums || false,
255
+ };
256
+
257
+ // Parse the chart
258
+ const parsedChart = parseChartFile(
259
+ chartFile.data,
260
+ chartFile.format,
261
+ modifiers
262
+ );
263
+
264
+ // Verify the requested track exists
265
+ const track = parsedChart.trackData.find(
266
+ (t) => t.instrument === instrument && t.difficulty === difficulty
267
+ );
268
+ if (!track) {
269
+ throw new Error(
270
+ `No ${difficulty} ${instrument} track found in this chart.`
271
+ );
272
+ }
273
+
274
+ // Find audio files
275
+ const audioFiles = findAudioFiles(filteredFiles);
276
+
277
+ // Load textures for the instrument type
278
+ const instrumentType = getInstrumentType(instrument);
279
+ const textures = await ChartPreview.loadTextures(instrumentType);
280
+
281
+ // Calculate audio length from the last note or from metadata
282
+ const lastNote = parsedChart.trackData
283
+ .flatMap((t) => t.noteEventGroups.flat())
284
+ .reduce((max, note) => Math.max(max, note.msTime + note.msLength), 0);
285
+ const audioLengthMs = scannedChart.song_length || lastNote + 5000;
286
+
287
+ // Calculate start delay from chart metadata
288
+ const startDelayMs =
289
+ (scannedChart.delay || 0) + (scannedChart.chart_offset || 0) * 1000;
290
+
291
+ // Calculate initial seek position from preview_start_time if not explicitly provided
292
+ const seekPercent =
293
+ initialSeekPercent > 0
294
+ ? initialSeekPercent
295
+ : scannedChart.preview_start_time
296
+ ? scannedChart.preview_start_time / audioLengthMs
297
+ : 0;
298
+
299
+ return {
300
+ parsedChart,
301
+ textures,
302
+ audioFiles,
303
+ instrument,
304
+ difficulty,
305
+ startDelayMs,
306
+ audioLengthMs,
307
+ initialSeekPercent: seekPercent,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Fetches a .sng file from a URL.
313
+ *
314
+ * @param url - URL to fetch the .sng file from
315
+ * @param signal - Optional AbortSignal to cancel the fetch
316
+ * @returns Raw .sng file data
317
+ */
318
+ export async function fetchSngFile(
319
+ url: string,
320
+ signal?: AbortSignal
321
+ ): Promise<Uint8Array> {
322
+ const response = await fetch(url, { signal });
323
+ if (!response.ok) {
324
+ throw new Error(
325
+ `Failed to fetch chart: ${response.status} ${response.statusText}`
326
+ );
327
+ }
328
+ const arrayBuffer = await response.arrayBuffer();
329
+ return new Uint8Array(arrayBuffer);
330
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Core classes
2
+ export {
3
+ ChartPreview,
4
+ type ChartPreviewConfig,
5
+ type ChartPreviewEvents,
6
+ type ParsedChart,
7
+ } from "./ChartPreview";
8
+
9
+ // Web Component player
10
+ export {
11
+ ChartPreviewPlayer,
12
+ type ChartPreviewPlayerConfig,
13
+ type PlayerState,
14
+ } from "./ChartPreviewPlayer";
15
+
16
+ // Loader utilities and types (for advanced use cases)
17
+ export {
18
+ extractSngFile,
19
+ fetchSngFile,
20
+ prepareChartData,
21
+ findChartFile,
22
+ findAudioFiles,
23
+ isVideoFile,
24
+ type LoadFromUrlConfig,
25
+ type LoadFromSngFileConfig,
26
+ type LoadFromChartFilesConfig,
27
+ type PreparedChartData,
28
+ } from "./SngLoader";
29
+
30
+ // Re-export commonly used types from scan-chart for convenience
31
+ export {
32
+ type Difficulty,
33
+ type Instrument,
34
+ type InstrumentType,
35
+ getInstrumentType,
36
+ instrumentTypes,
37
+ } from "scan-chart";
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "declaration": true,
8
+ "emitDeclarationOnly": false,
9
+ "allowSyntheticDefaultImports": true,
10
+ "sourceMap": true,
11
+ "outDir": "./dist",
12
+ "baseUrl": "./",
13
+ "skipLibCheck": true,
14
+ "strictNullChecks": true,
15
+ "noImplicitAny": true,
16
+ "downlevelIteration": true,
17
+ "strictBindCallApply": true,
18
+ "forceConsistentCasingInFileNames": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "noErrorTruncation": true,
21
+ "noEmit": true,
22
+ "lib": ["ES2020", "DOM", "DOM.Iterable"]
23
+ },
24
+ "include": ["src/**/*"],
25
+ "exclude": ["node_modules", "dist", "dev"]
26
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "vite";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ export default defineConfig({
8
+ root: "dev",
9
+ server: {
10
+ port: 3000,
11
+ open: true,
12
+ },
13
+ resolve: {
14
+ alias: {
15
+ "chart-preview": path.resolve(__dirname, "src/index.ts"),
16
+ },
17
+ },
18
+ });