Skip to content

Commit 0ac564d

Browse files
authored
Add NBS v6 upload support (#93)
2 parents f47e80a + 5924852 commit 0ac564d

20 files changed

Lines changed: 390 additions & 26 deletions

File tree

apps/backend/src/song/song-upload/song-upload.service.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,12 @@ describe('SongUploadService', () => {
386386

387387
const buffer = songTest.toArrayBuffer();
388388

389-
const song = songUploadService.getSongObject(buffer); //TODO: For some reason the song is always empty
389+
const song = songUploadService.getSongObject(buffer);
390390

391391
expect(song).toBeInstanceOf(Song);
392+
expect(song.meta.name).toBe('Cool Test Song');
393+
expect(song.length).toBe(16);
394+
expect(song.layers).toHaveLength(3);
392395
});
393396

394397
it('should throw an error if the array buffer is invalid', () => {

apps/backend/src/song/song-upload/song-upload.service.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Song, fromArrayBuffer, toArrayBuffer } from '@encode42/nbs.js';
1+
import { Song, toArrayBuffer } from '@encode42/nbs.js';
22
import {
33
HttpException,
44
HttpStatus,
@@ -19,7 +19,9 @@ import {
1919
import {
2020
NoteQuadTree,
2121
SongStatsGenerator,
22+
UnsupportedNbsVersionError,
2223
injectSongFileMetadata,
24+
loadNbsFromBuffer,
2325
obfuscateAndPackSong,
2426
} from '@nbw/song';
2527
import { drawToImage } from '@nbw/thumbnail/node';
@@ -361,6 +363,7 @@ export class SongUploadService {
361363

362364
const thumbBuffer = await drawToImage({
363365
notes: quadTree,
366+
defaultInstrumentCount: nbsSong.instruments.firstCustomIndex,
364367
startTick: startTick,
365368
startLayer: startLayer,
366369
zoomLevel: zoomLevel,
@@ -439,7 +442,24 @@ export class SongUploadService {
439442
}
440443

441444
public getSongObject(loadedArrayBuffer: ArrayBuffer): Song {
442-
const nbsSong = fromArrayBuffer(loadedArrayBuffer);
445+
let nbsSong: Song;
446+
447+
try {
448+
nbsSong = loadNbsFromBuffer(loadedArrayBuffer);
449+
} catch (error) {
450+
if (error instanceof UnsupportedNbsVersionError) {
451+
throw new HttpException(
452+
{
453+
error: {
454+
file: error.message,
455+
},
456+
},
457+
HttpStatus.BAD_REQUEST,
458+
);
459+
}
460+
461+
throw error;
462+
}
443463

444464
// If the above operation fails, it will return an empty song
445465
if (nbsSong.length === 0) {

apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { toast } from 'react-hot-toast';
1212
import { create } from 'zustand';
1313

1414
import { BG_COLORS, THUMBNAIL_CONSTANTS, UPLOAD_CONSTANTS } from '@nbw/config';
15-
import { parseSongFromBuffer, type SongFileType } from '@nbw/song';
15+
import {
16+
parseSongFromBuffer,
17+
UnsupportedNbsVersionError,
18+
type SongFileType,
19+
} from '@nbw/song';
1620
import axiosInstance from '@web/lib/axios';
1721
import { InvalidTokenError, getTokenLocal } from '@web/lib/axios/token.utils';
1822
import {
@@ -250,7 +254,15 @@ export const UploadSongProvider = ({
250254
parsedSong = await parseSongFromBuffer(await file.arrayBuffer());
251255
} catch (e) {
252256
console.error('Error parsing song file', e);
253-
toast.error('Invalid song file! Please try again with a different song.');
257+
if (e instanceof UnsupportedNbsVersionError) {
258+
toast.error(
259+
'Unsupported NBS version! Please save this file in an older version.',
260+
);
261+
} else {
262+
toast.error(
263+
'Invalid song file! Please try again with a different song.',
264+
);
265+
}
254266
setSong(null);
255267
return;
256268
}

apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,12 @@ export const SongThumbnailInput: React.FC<SongThumbnailInputProps> = ({
149149
}: SongThumbnailInputProps) => {
150150
const { song, formMethods } = useSongProvider(type);
151151

152-
const [notes, maxTick, maxLayer] = useMemo(() => {
153-
if (!song) return [null, 0, 0];
154-
const notes = song.notes;
152+
const [maxTick, maxLayer] = useMemo(() => {
153+
if (!song) return [0, 0];
155154
const maxTick = song.length;
156155
const maxLayer = song.height;
157156

158-
return [notes, maxTick, maxLayer];
157+
return [maxTick, maxLayer];
159158
}, [song]);
160159

161160
return (
@@ -167,8 +166,8 @@ export const SongThumbnailInput: React.FC<SongThumbnailInputProps> = ({
167166
maxLayer={maxLayer}
168167
/>
169168

170-
{song && notes && (
171-
<ThumbnailRendererCanvas notes={notes} formMethods={formMethods} />
169+
{song && (
170+
<ThumbnailRendererCanvas song={song} formMethods={formMethods} />
172171
)}
173172

174173
{/* Background Color */}

apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@ import { useEffect, useRef, useState } from 'react';
44
import { UseFormReturn } from 'react-hook-form';
55

66
import { THUMBNAIL_CONSTANTS } from '@nbw/config';
7-
import { NoteQuadTree } from '@nbw/song';
7+
import { SongFileType } from '@nbw/song';
88
import { drawNotesOffscreen, swap } from '@nbw/thumbnail/browser';
99

1010
import { UploadSongFormInput } from './SongForm.zod';
1111

1212
type ThumbnailRendererCanvasProps = {
13-
notes: NoteQuadTree;
13+
song: SongFileType;
1414
formMethods: UseFormReturn<UploadSongFormInput>;
1515
};
1616

1717
export const ThumbnailRendererCanvas = ({
18-
notes,
18+
song,
1919
formMethods,
2020
}: ThumbnailRendererCanvasProps) => {
2121
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -31,6 +31,9 @@ export const ThumbnailRendererCanvas = ({
3131
],
3232
);
3333

34+
const notes = song.notes;
35+
const defaultInstrumentCount = song.defaultInstrumentCount;
36+
3437
useEffect(() => {
3538
const canvas = canvasRef.current;
3639
if (!canvas) return;
@@ -61,6 +64,7 @@ export const ThumbnailRendererCanvas = ({
6164
try {
6265
const output = (await drawNotesOffscreen({
6366
notes,
67+
defaultInstrumentCount,
6468
startTick: startTick ?? THUMBNAIL_CONSTANTS.startTick.default,
6569
startLayer: startLayer ?? THUMBNAIL_CONSTANTS.startLayer.default,
6670
zoomLevel: zoomLevel ?? THUMBNAIL_CONSTANTS.zoomLevel.default,
@@ -78,7 +82,7 @@ export const ThumbnailRendererCanvas = ({
7882
setLoading(false);
7983
}
8084
});
81-
}, [notes, startTick, startLayer, zoomLevel, backgroundColor]);
85+
}, [startTick, startLayer, zoomLevel, backgroundColor]);
8286

8387
return (
8488
<div className='relative w-full'>

packages/song/scripts/build.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ async function buildNode() {
1717
entrypoints: [join(packageRoot, 'src', 'index.ts')],
1818
outdir: join(packageRoot, 'dist'),
1919
target: 'node',
20+
// Keep a single @encode42/nbs.js instance for consumers (e.g. backend instanceof checks).
21+
external: ['@encode42/nbs.js'],
2022
});
2123

2224
if (!result.success) {

packages/song/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './nbsCompat';
12
export * from './injectMetadata';
23
export * from './notes';
34
export * from './obfuscate';

packages/song/src/injectMetadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Song } from '@encode42/nbs.js';
22
import unidecode from 'unidecode';
33

4+
/** Expects a song from {@link loadNbsFromBuffer} (normalized v5/v6 instrument layout). */
45
export function injectSongFileMetadata(
56
nbsSong: Song,
67
title: string,

packages/song/src/nbsCompat.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import {
2+
fromArrayBuffer,
3+
Instrument,
4+
Song,
5+
type FromArrayBufferOptions,
6+
} from '@encode42/nbs.js';
7+
8+
/** Default instruments 0–15 (NBS v5 and below). */
9+
export const NBS_V5_FIRST_CUSTOM = 16;
10+
11+
/** First custom instrument index in NBS v6. */
12+
export const NBS_V6_FIRST_CUSTOM = 20;
13+
14+
export const MAX_SUPPORTED_NBS_VERSION = 6;
15+
16+
export class UnsupportedNbsVersionError extends Error {
17+
constructor(public readonly version: number) {
18+
super(
19+
`Unsupported NBS version: ${version}. Maximum supported version is ${MAX_SUPPORTED_NBS_VERSION}.`,
20+
);
21+
this.name = 'UnsupportedNbsVersionError';
22+
}
23+
}
24+
25+
// TODO: TEMP: Remove when @encode42/nbs.js ships v6 built-in instruments in Instrument.builtIn.
26+
const NBS_V6_BUILTIN_INSTRUMENTS: Instrument[] = [
27+
new Instrument(16, {
28+
name: 'Trumpet',
29+
soundFile: 'trumpet.ogg',
30+
builtIn: true,
31+
key: 45,
32+
}),
33+
new Instrument(17, {
34+
name: 'Exposed Trumpet',
35+
soundFile: 'exposed_trumpet.ogg',
36+
builtIn: true,
37+
key: 45,
38+
}),
39+
new Instrument(18, {
40+
name: 'Weathered Trumpet',
41+
soundFile: 'weathered_trumpet.ogg',
42+
builtIn: true,
43+
key: 45,
44+
}),
45+
new Instrument(19, {
46+
name: 'Oxidized Trumpet',
47+
soundFile: 'oxidized_trumpet.ogg',
48+
builtIn: true,
49+
key: 45,
50+
}),
51+
];
52+
53+
export function getNbsFormatVersion(song: Song): 5 | 6 {
54+
return song.nbsVersion >= 6 ? 6 : 5;
55+
}
56+
57+
export function isNbsV6(song: Song): boolean {
58+
return getNbsFormatVersion(song) === 6;
59+
}
60+
61+
function findInstrumentById(song: Song, id: number): Instrument | undefined {
62+
const { loaded } = song.instruments;
63+
64+
if (loaded[id]?.id === id) {
65+
return loaded[id];
66+
}
67+
68+
return loaded.find((inst) => inst?.id === id);
69+
}
70+
71+
function cloneBuiltinInstrument(
72+
source: Instrument | undefined,
73+
fallback: Instrument,
74+
id: number,
75+
): Instrument {
76+
const base = source ?? fallback;
77+
78+
return new Instrument(id, {
79+
name: base.meta.name,
80+
soundFile: base.meta.soundFile,
81+
key: base.key,
82+
pressKey: base.pressKey,
83+
builtIn: true,
84+
});
85+
}
86+
87+
function getDefaultBuiltinInstrument(id: number): Instrument {
88+
if (id < Instrument.builtIn.length) {
89+
return Instrument.builtIn[id]!;
90+
}
91+
92+
return NBS_V6_BUILTIN_INSTRUMENTS[id - NBS_V5_FIRST_CUSTOM]!;
93+
}
94+
95+
/**
96+
* Rebuilds `instruments.loaded` so array indices match note instrument IDs.
97+
* nbs.js 5.0.2 leaves gaps at 16–19 for v6 files and may place customs at wrong indices.
98+
*
99+
* // TODO: TEMP: Remove when @encode42/nbs.js natively models v6.
100+
*/
101+
export function normalizeNbsSong(song: Song): Song {
102+
if (song.nbsVersion > MAX_SUPPORTED_NBS_VERSION) {
103+
throw new UnsupportedNbsVersionError(song.nbsVersion);
104+
}
105+
106+
if (!isNbsV6(song)) {
107+
return song;
108+
}
109+
110+
const firstCustom = song.instruments.firstCustomIndex;
111+
const newLoaded: Instrument[] = [];
112+
113+
for (let id = 0; id < firstCustom; id++) {
114+
newLoaded[id] = cloneBuiltinInstrument(
115+
findInstrumentById(song, id),
116+
getDefaultBuiltinInstrument(id),
117+
id,
118+
);
119+
}
120+
121+
const customs = song.instruments.loaded.filter(
122+
(inst): inst is Instrument => Boolean(inst) && !inst.builtIn,
123+
);
124+
125+
customs.sort((a, b) => a.id - b.id);
126+
127+
for (const inst of customs) {
128+
const targetId =
129+
inst.id >= firstCustom ? inst.id : firstCustom + customs.indexOf(inst);
130+
131+
newLoaded[targetId] = inst;
132+
}
133+
134+
song.instruments.loaded = newLoaded;
135+
136+
return song;
137+
}
138+
139+
/**
140+
* Seeds obfuscated output with the source song's format version and built-in instruments.
141+
*
142+
* TODO: TEMP: Remove when @encode42/nbs.js creates v6 songs from `new Song()`.
143+
*/
144+
export function seedOutputBuiltinInstruments(source: Song, output: Song): void {
145+
output.nbsVersion = getNbsFormatVersion(source);
146+
output.instruments.firstCustomIndex = source.instruments.firstCustomIndex;
147+
148+
const firstCustom = source.instruments.firstCustomIndex;
149+
const builtins: Instrument[] = [];
150+
151+
for (let id = 0; id < firstCustom; id++) {
152+
builtins[id] = cloneBuiltinInstrument(
153+
findInstrumentById(source, id),
154+
getDefaultBuiltinInstrument(id),
155+
id,
156+
);
157+
}
158+
159+
output.instruments.loaded = builtins;
160+
}
161+
162+
export function loadNbsFromBuffer(
163+
buffer: ArrayBuffer,
164+
options?: FromArrayBufferOptions,
165+
): Song {
166+
const song = fromArrayBuffer(buffer, options);
167+
168+
if (song.nbsVersion > MAX_SUPPORTED_NBS_VERSION) {
169+
throw new UnsupportedNbsVersionError(song.nbsVersion);
170+
}
171+
172+
return normalizeNbsSong(song);
173+
}

packages/song/src/obfuscate.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Instrument, Layer, Note, Song } from '@encode42/nbs.js';
22

3+
import { seedOutputBuiltinInstruments } from './nbsCompat';
34
import { getInstrumentNoteCounts, getTempoChangerInstrumentIds } from './util';
45

56
export class SongObfuscator {
@@ -21,6 +22,9 @@ export class SongObfuscator {
2122
const song = this.song;
2223
const output = new Song();
2324

25+
// TODO: TEMP: preserve v5/v6 format until nbs.js writes v6 from `new Song()`.
26+
seedOutputBuiltinInstruments(song, output);
27+
2428
// ✅ Clear work stats
2529
// ✅ Copy: title, author, description, loop info, time signature
2630
this.copyMetaAndStats(song, output);

0 commit comments

Comments
 (0)