diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 215ce497..f7ce5c8b 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { EmailLoginModule } from './email-login/email-login.module'; import { FileModule } from './file/file.module'; import { ParseTokenPipe } from './lib/parseToken'; import { MailingModule } from './mailing/mailing.module'; +import { MigrationModule } from './migration/migration.module'; import { SeedModule } from './seed/seed.module'; import { SongModule } from './song/song.module'; import { UserModule } from './user/user.module'; @@ -78,6 +79,7 @@ import { UserModule } from './user/user.module'; SeedModule.forRoot(), EmailLoginModule, MailingModule, + MigrationModule, ], controllers: [], providers: [ diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 2fd6a1ed..e3d72e26 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -5,6 +5,7 @@ import * as express from 'express'; import { AppModule } from './app.module'; import { initializeSwagger } from './lib/initializeSwagger'; import { ParseTokenPipe } from './lib/parseToken'; +import { MigrationService } from './migration/migration.service'; const logger: Logger = new Logger('main.ts'); @@ -47,6 +48,10 @@ async function bootstrap() { app.use('/v1', express.static('public')); + const migrationService = app.get(MigrationService); + const migrationResults = await migrationService.runAllPendingMigrations(); + logger.log(`Migrations complete: ${JSON.stringify(migrationResults)}`); + const port = process.env.PORT || '4000'; logger.log('Note Block World API Backend 🎢🌎🌍🌏 '); diff --git a/apps/backend/src/migration/migration.module.ts b/apps/backend/src/migration/migration.module.ts new file mode 100644 index 00000000..14148f1b --- /dev/null +++ b/apps/backend/src/migration/migration.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { Song, SongSchema } from '@nbw/database'; + +import { MigrationService } from './migration.service'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Song.name, schema: SongSchema }]), + ], + providers: [MigrationService], + exports: [MigrationService], +}) +export class MigrationModule {} diff --git a/apps/backend/src/migration/migration.service.ts b/apps/backend/src/migration/migration.service.ts new file mode 100644 index 00000000..9f82cc5d --- /dev/null +++ b/apps/backend/src/migration/migration.service.ts @@ -0,0 +1,184 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { + Song, + SONG_MIGRATION_REGISTRY, + SONG_PENDING_QUERY_CLAUSES, + buildPendingQuery, + isFullyMigrated, + migrateDocument, + type MigrationRegistry, +} from '@nbw/database'; + +export interface MigrationBatchResult { + collection: string; + matched: number; + migrated: number; + errors: string[]; +} + +export interface MigrationStatus { + collection: string; + currentVersion: number; + pendingCount: number; +} + +const DEFAULT_BATCH_SIZE = 500; + +@Injectable() +export class MigrationService { + private readonly logger = new Logger(MigrationService.name); + + private readonly registries = new Map>([ + [SONG_MIGRATION_REGISTRY.collection, SONG_MIGRATION_REGISTRY], + ]); + + constructor( + @InjectModel(Song.name) + private readonly songModel: Model, + ) {} + + async getMigrationStatus(collection: string): Promise { + const registry = this.getRegistry(collection); + const model = this.getModel(collection); + const pendingQuery = this.buildRegistryPendingQuery(collection); + + const pendingCount = await model.countDocuments(pendingQuery); + + return { + collection, + currentVersion: registry.currentVersion, + pendingCount, + }; + } + + async runAllPendingMigrations(options?: { + batchSize?: number; + }): Promise { + const results: MigrationBatchResult[] = []; + + for (const collection of this.registries.keys()) { + results.push(await this.runBulkMigration(collection, options)); + } + + return results; + } + + async runBulkMigration( + collection: string, + options?: { batchSize?: number }, + ): Promise { + const registry = this.getRegistry(collection); + const model = this.getModel(collection); + const batchSize = options?.batchSize ?? DEFAULT_BATCH_SIZE; + const pendingQuery = this.buildRegistryPendingQuery(collection); + + const matched = await model.countDocuments(pendingQuery); + + if (matched === 0) { + this.logger.log( + `No pending migrations for ${collection} (current version ${registry.currentVersion})`, + ); + + return { + collection, + matched: 0, + migrated: 0, + errors: [], + }; + } + + this.logger.log( + `Migrating ${matched} ${collection} document(s) to schema version ${registry.currentVersion}`, + ); + + let migrated = 0; + const errors: string[] = []; + let lastId: unknown; + + while (true) { + const query = lastId + ? { ...pendingQuery, _id: { $gt: lastId } } + : pendingQuery; + + const batch = await model + .find(query) + .sort({ _id: 1 }) + .limit(batchSize) + .exec(); + + if (batch.length === 0) { + break; + } + + for (const doc of batch) { + try { + if (isFullyMigrated(registry, doc)) { + lastId = doc._id; + continue; + } + + const updated = migrateDocument(registry, doc.toObject()); + + doc.set(updated); + await doc.save(); + migrated++; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + errors.push(`${doc._id}: ${message}`); + } + + lastId = doc._id; + } + } + + if (errors.length > 0) { + throw new Error( + `Migration failed for ${collection}: ${errors.join('; ')}`, + ); + } + + this.logger.log( + `Migrated ${migrated}/${matched} ${collection} document(s)`, + ); + + return { + collection, + matched, + migrated, + errors, + }; + } + + private getRegistry(collection: string): MigrationRegistry { + const registry = this.registries.get(collection); + + if (!registry) { + throw new Error(`No migration registry registered for ${collection}`); + } + + return registry; + } + + private getModel(collection: string): Model { + if (collection === SONG_MIGRATION_REGISTRY.collection) { + return this.songModel; + } + + throw new Error(`No model registered for ${collection}`); + } + + private buildRegistryPendingQuery(collection: string) { + if (collection === SONG_MIGRATION_REGISTRY.collection) { + return buildPendingQuery( + SONG_MIGRATION_REGISTRY, + SONG_PENDING_QUERY_CLAUSES, + ); + } + + return buildPendingQuery(this.getRegistry(collection)); + } +} diff --git a/apps/backend/src/song/song-upload/song-upload.service.spec.ts b/apps/backend/src/song/song-upload/song-upload.service.spec.ts index c58c0d22..db3968a6 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.spec.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.spec.ts @@ -386,9 +386,12 @@ describe('SongUploadService', () => { const buffer = songTest.toArrayBuffer(); - const song = songUploadService.getSongObject(buffer); //TODO: For some reason the song is always empty + const song = songUploadService.getSongObject(buffer); expect(song).toBeInstanceOf(Song); + expect(song.meta.name).toBe('Cool Test Song'); + expect(song.length).toBe(16); + expect(song.layers).toHaveLength(3); }); it('should throw an error if the array buffer is invalid', () => { diff --git a/apps/backend/src/song/song-upload/song-upload.service.ts b/apps/backend/src/song/song-upload/song-upload.service.ts index b9ed8871..8e7b3ce4 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.ts @@ -1,4 +1,4 @@ -import { Song, fromArrayBuffer, toArrayBuffer } from '@encode42/nbs.js'; +import { Song, toArrayBuffer } from '@encode42/nbs.js'; import { HttpException, HttpStatus, @@ -9,6 +9,7 @@ import { import { Types } from 'mongoose'; import { + CURRENT_SONG_SCHEMA_VERSION, SongDocument, Song as SongEntity, SongStats, @@ -19,7 +20,9 @@ import { import { NoteQuadTree, SongStatsGenerator, + UnsupportedNbsVersionError, injectSongFileMetadata, + loadNbsFromBuffer, obfuscateAndPackSong, } from '@nbw/song'; import { drawToImage } from '@nbw/thumbnail/node'; @@ -109,6 +112,7 @@ export class SongUploadService { file: Express.Multer.File, ): Promise { const song = new SongEntity(); + song.schemaVersion = CURRENT_SONG_SCHEMA_VERSION; song.uploader = await this.validateUploader(user); song.publicId = publicId; song.title = removeExtraSpaces(body.title); @@ -361,6 +365,7 @@ export class SongUploadService { const thumbBuffer = await drawToImage({ notes: quadTree, + defaultInstrumentCount: nbsSong.instruments.firstCustomIndex, startTick: startTick, startLayer: startLayer, zoomLevel: zoomLevel, @@ -439,7 +444,24 @@ export class SongUploadService { } public getSongObject(loadedArrayBuffer: ArrayBuffer): Song { - const nbsSong = fromArrayBuffer(loadedArrayBuffer); + let nbsSong: Song; + + try { + nbsSong = loadNbsFromBuffer(loadedArrayBuffer); + } catch (error) { + if (error instanceof UnsupportedNbsVersionError) { + throw new HttpException( + { + error: { + file: error.message, + }, + }, + HttpStatus.BAD_REQUEST, + ); + } + + throw error; + } // If the above operation fails, it will return an empty song if (nbsSong.length === 0) { diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index edf8dfb6..8bcc2851 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -1,20 +1,19 @@ -import type { UserDocument } from '@nbw/database'; +import { HttpException } from '@nestjs/common'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import mongoose, { Model } from 'mongoose'; + import { SongDocument, Song as SongEntity, SongPreviewDto, - SongSchema, SongStats, SongViewDto, SongWithUser, UploadSongDto, UploadSongResponseDto, } from '@nbw/database'; -import { HttpException } from '@nestjs/common'; -import { getModelToken } from '@nestjs/mongoose'; -import { Test, TestingModule } from '@nestjs/testing'; -import mongoose, { Model } from 'mongoose'; - +import type { UserDocument } from '@nbw/database'; import { FileService } from '@server/file/file.service'; import { SongUploadService } from './song-upload/song-upload.service'; @@ -132,7 +131,8 @@ describe('SongService', () => { minutesSpent: 10, vanillaInstrumentCount: 10, customInstrumentCount: 0, - firstCustomInstrumentIndex: 0, + firstCustomInstrumentIndex: 16, + nbsVersion: 5, outOfRangeNoteCount: 0, detunedNoteCount: 0, customInstrumentNoteCount: 0, @@ -350,7 +350,8 @@ describe('SongService', () => { minutesSpent: 10, vanillaInstrumentCount: 10, customInstrumentCount: 0, - firstCustomInstrumentIndex: 0, + firstCustomInstrumentIndex: 16, + nbsVersion: 5, outOfRangeNoteCount: 0, detunedNoteCount: 0, customInstrumentNoteCount: 0, diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 82a61e0d..3b6a39e3 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -57,6 +57,7 @@ "react-loading-skeleton": "^3.5.0", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", "remove-markdown": "^0.6.3", "schema-dts": "^1.1.5", "sharp": "^0.34.5", diff --git a/apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md b/apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md index 1d5e3758..a98071df 100644 --- a/apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md +++ b/apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md @@ -7,6 +7,10 @@ image: '/img/blog/summit26/banner.png' tags: ['events'] --- +**Update (2026-06-17):** We've just added support for songs that use the four new [**trumpet**](https://minecraft.wiki/w/Java_Edition_26.1#Blocks_2) instruments added to Minecraft 26.1! To be fair to everyone, the submission deadline has been extended to **June 23rd**. + +--- + It's time for our third major community event! The in-game Minecraft data pack convention, [**Smithed Summit 2026**](https://smithed.net/summit), is coming this summer! We'll be holding a **booth** and a **live performance** at the event, and, just like last time, we're opening submissions for note block songs to be played on the server, around the convention grounds, to all attendees. Our goal is to include as many submissions as possible. No matter your skill level or experience creating note block music, everybody is welcome! Use this jam as an opportunity to **try new things**, and as motivation to experiment with interesting techniques. We encourage everybody to embrace their unique creativity to create original note block compositions or stylized covers. After all, we've got three great "prompts" to pique your imagination this time around. @@ -41,7 +45,7 @@ For a more detailed look into each region, check the [Summit announcement video] ## πŸ•› Deadline -Submissions will be cut off on **June 16th, 2026**. You must submit your songs before then, but we can manually accept submissions up to **July 1st, 2026** on a case-by-case basis. +Submissions will be cut off on ~~June 16th~~ **June 23th, 2026**. You must submit your songs before then, but we can manually accept submissions up to **July 1st, 2026** on a case-by-case basis. ## βœ… What's allowed? diff --git a/apps/frontend/src/app/robots.ts b/apps/frontend/src/app/robots.ts new file mode 100644 index 00000000..ceb96f91 --- /dev/null +++ b/apps/frontend/src/app/robots.ts @@ -0,0 +1,25 @@ +import { MetadataRoute } from 'next'; + +export default function robots(): MetadataRoute.Robots { + // Check if the current deployment is Production + const isProd = process.env.NODE_ENV === 'production'; + + if (isProd) { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: ['/api/', '/admin/'], // Block search engines from sensitive paths + }, + sitemap: 'https://noteblock.world', + }; + } + + // Block ALL crawling on Preview, Staging, and Local environments + return { + rules: { + userAgent: '*', + disallow: '/', + }, + }; +} diff --git a/apps/frontend/src/modules/browse/EventBanner.tsx b/apps/frontend/src/modules/browse/EventBanner.tsx index 0daceed3..f57b339b 100644 --- a/apps/frontend/src/modules/browse/EventBanner.tsx +++ b/apps/frontend/src/modules/browse/EventBanner.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; export const EventBanner = () => { - const targetDate = Date.UTC(2026, 5, 16, 17, 0, 0); // June is 5 (0-indexed) + const targetDate = Date.UTC(2026, 5, 24, 12, 0, 0); // June is 5 (0-indexed) const [timeLeft, setTimeLeft] = useState(() => Math.max(0, targetDate - Date.now()), diff --git a/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx b/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx index 9b156819..684d35fe 100644 --- a/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx +++ b/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import type { JSX } from 'react'; import Markdown, { ExtraProps } from 'react-markdown'; import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; const YOUTUBE_EMBED_PREFIX = 'https://www.youtube.com/embed/'; @@ -12,6 +13,7 @@ export const CustomMarkdown = ({ }) => { return ( {MarkdownContent} @@ -167,6 +170,10 @@ const iframe = ({ ); }; +const del = ({ node, ...props }: JSX.IntrinsicElements['del'] & ExtraProps) => { + return ; +}; + export { p, h1, @@ -185,4 +192,5 @@ export { a, img, iframe, + del, }; diff --git a/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx b/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx index 12ccb606..f85f7355 100644 --- a/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx +++ b/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx @@ -12,7 +12,11 @@ import { toast } from 'react-hot-toast'; import { create } from 'zustand'; import { BG_COLORS, THUMBNAIL_CONSTANTS, UPLOAD_CONSTANTS } from '@nbw/config'; -import { parseSongFromBuffer, type SongFileType } from '@nbw/song'; +import { + parseSongFromBuffer, + UnsupportedNbsVersionError, + type SongFileType, +} from '@nbw/song'; import axiosInstance from '@web/lib/axios'; import { InvalidTokenError, getTokenLocal } from '@web/lib/axios/token.utils'; import { @@ -250,7 +254,15 @@ export const UploadSongProvider = ({ parsedSong = await parseSongFromBuffer(await file.arrayBuffer()); } catch (e) { console.error('Error parsing song file', e); - toast.error('Invalid song file! Please try again with a different song.'); + if (e instanceof UnsupportedNbsVersionError) { + toast.error( + 'Unsupported NBS version! Please save this file in an older version.', + ); + } else { + toast.error( + 'Invalid song file! Please try again with a different song.', + ); + } setSong(null); return; } diff --git a/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx b/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx index 868b7b03..ec079e32 100644 --- a/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx +++ b/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx @@ -149,13 +149,12 @@ export const SongThumbnailInput: React.FC = ({ }: SongThumbnailInputProps) => { const { song, formMethods } = useSongProvider(type); - const [notes, maxTick, maxLayer] = useMemo(() => { - if (!song) return [null, 0, 0]; - const notes = song.notes; + const [maxTick, maxLayer] = useMemo(() => { + if (!song) return [0, 0]; const maxTick = song.length; const maxLayer = song.height; - return [notes, maxTick, maxLayer]; + return [maxTick, maxLayer]; }, [song]); return ( @@ -167,8 +166,8 @@ export const SongThumbnailInput: React.FC = ({ maxLayer={maxLayer} /> - {song && notes && ( - + {song && ( + )} {/* Background Color */} diff --git a/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx b/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx index c7d77afa..6d6404a5 100644 --- a/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx +++ b/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx @@ -4,18 +4,18 @@ import { useEffect, useRef, useState } from 'react'; import { UseFormReturn } from 'react-hook-form'; import { THUMBNAIL_CONSTANTS } from '@nbw/config'; -import { NoteQuadTree } from '@nbw/song'; +import { SongFileType } from '@nbw/song'; import { drawNotesOffscreen, swap } from '@nbw/thumbnail/browser'; import { UploadSongFormInput } from './SongForm.zod'; type ThumbnailRendererCanvasProps = { - notes: NoteQuadTree; + song: SongFileType; formMethods: UseFormReturn; }; export const ThumbnailRendererCanvas = ({ - notes, + song, formMethods, }: ThumbnailRendererCanvasProps) => { const canvasRef = useRef(null); @@ -31,6 +31,9 @@ export const ThumbnailRendererCanvas = ({ ], ); + const notes = song.notes; + const defaultInstrumentCount = song.defaultInstrumentCount; + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -61,6 +64,7 @@ export const ThumbnailRendererCanvas = ({ try { const output = (await drawNotesOffscreen({ notes, + defaultInstrumentCount, startTick: startTick ?? THUMBNAIL_CONSTANTS.startTick.default, startLayer: startLayer ?? THUMBNAIL_CONSTANTS.startLayer.default, zoomLevel: zoomLevel ?? THUMBNAIL_CONSTANTS.zoomLevel.default, @@ -78,7 +82,7 @@ export const ThumbnailRendererCanvas = ({ setLoading(false); } }); - }, [notes, startTick, startLayer, zoomLevel, backgroundColor]); + }, [startTick, startLayer, zoomLevel, backgroundColor]); return (
diff --git a/bun.lock b/bun.lock index 48458e6d..c914e8a9 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,7 @@ "react-loading-skeleton": "^3.5.0", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", "remove-markdown": "^0.6.3", "schema-dts": "^1.1.5", "sharp": "^0.34.5", @@ -2278,10 +2279,26 @@ "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -2318,6 +2335,20 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], @@ -2778,6 +2809,8 @@ "relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -3596,6 +3629,8 @@ "make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "mjml-cli/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index a9e1cc0a..072c43f0 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -15,6 +15,8 @@ export * from './song/dto/UploadSongResponseDto.dto'; export * from './song/dto/types'; export * from './song/entity/song.entity'; +export * from './migration'; + export * from './user/dto/CreateUser.dto'; export * from './user/dto/GetUser.dto'; export * from './user/dto/Login.dto copy'; diff --git a/packages/database/src/migration/collections/song.migrations.ts b/packages/database/src/migration/collections/song.migrations.ts new file mode 100644 index 00000000..3c2499de --- /dev/null +++ b/packages/database/src/migration/collections/song.migrations.ts @@ -0,0 +1,40 @@ +import type { Song } from '../../song/entity/song.entity'; +import type { MigrationRegistry } from '../types'; + +export const CURRENT_SONG_SCHEMA_VERSION = 1; + +type SongMigrationDoc = Pick; + +function songNeedsMigration1(doc: SongMigrationDoc): boolean { + const schemaVersion = doc.schemaVersion ?? 0; + + return schemaVersion < 1 || doc.stats?.nbsVersion == null; +} + +function applySongMigration1(doc: SongMigrationDoc): SongMigrationDoc { + return { + ...doc, + stats: { + ...doc.stats, + nbsVersion: doc.stats?.nbsVersion ?? 5, + }, + }; +} + +export const SONG_MIGRATION_REGISTRY: MigrationRegistry = { + collection: 'songs', + currentVersion: CURRENT_SONG_SCHEMA_VERSION, + migrations: [ + { + version: 1, + name: 'add-nbs-version-stats', + collection: 'songs', + needsMigration: songNeedsMigration1, + apply: applySongMigration1, + }, + ], +}; + +export const SONG_PENDING_QUERY_CLAUSES = [ + { 'stats.nbsVersion': { $exists: false } }, +]; diff --git a/packages/database/src/migration/index.ts b/packages/database/src/migration/index.ts new file mode 100644 index 00000000..feaedcce --- /dev/null +++ b/packages/database/src/migration/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './runner'; +export * from './collections/song.migrations'; diff --git a/packages/database/src/migration/runner.ts b/packages/database/src/migration/runner.ts new file mode 100644 index 00000000..6ad2b1f8 --- /dev/null +++ b/packages/database/src/migration/runner.ts @@ -0,0 +1,74 @@ +import type { + DocumentMigration, + MigrationRegistry, + PendingQueryClause, + SchemaVersionedDocument, +} from './types'; + +export function getDocumentSchemaVersion(doc: SchemaVersionedDocument): number { + return doc.schemaVersion ?? 0; +} + +export function getPendingMigrations( + registry: MigrationRegistry, + doc: TDoc, +): DocumentMigration[] { + const schemaVersion = getDocumentSchemaVersion(doc); + + return registry.migrations.filter( + (migration) => + schemaVersion < migration.version || migration.needsMigration(doc), + ); +} + +export function migrateDocument( + registry: MigrationRegistry, + doc: TDoc, +): TDoc { + let result = doc; + const originalSchemaVersion = getDocumentSchemaVersion(doc); + + for (const migration of registry.migrations) { + if ( + originalSchemaVersion < migration.version || + migration.needsMigration(result) + ) { + result = migration.apply(result); + + // Never downgrade schemaVersion if an older migration is applied via needsMigration(). + result.schemaVersion = Math.max( + getDocumentSchemaVersion(result), + originalSchemaVersion, + migration.version, + ); + } + } + + return result; +} + +export function isFullyMigrated( + registry: MigrationRegistry, + doc: TDoc, +): boolean { + if (getDocumentSchemaVersion(doc) < registry.currentVersion) { + return false; + } + + return !registry.migrations.some((migration) => + migration.needsMigration(doc), + ); +} + +export function buildPendingQuery( + registry: MigrationRegistry, + extraClauses: PendingQueryClause[] = [], +): { $or: PendingQueryClause[] } { + const clauses: PendingQueryClause[] = [ + { schemaVersion: { $exists: false } }, + { schemaVersion: { $lt: registry.currentVersion } }, + ...extraClauses, + ]; + + return { $or: clauses }; +} diff --git a/packages/database/src/migration/types.ts b/packages/database/src/migration/types.ts new file mode 100644 index 00000000..badfb495 --- /dev/null +++ b/packages/database/src/migration/types.ts @@ -0,0 +1,21 @@ +export interface SchemaVersionedDocument { + schemaVersion?: number; +} + +export interface DocumentMigration { + version: number; + name: string; + collection: string; + needsMigration(doc: TDoc): boolean; + apply(doc: TDoc): TDoc; +} + +export interface MigrationRegistry { + collection: string; + currentVersion: number; + migrations: DocumentMigration[]; +} + +export interface PendingQueryClause { + [key: string]: unknown; +} diff --git a/packages/database/src/song/dto/SongStats.ts b/packages/database/src/song/dto/SongStats.ts index 49cb712b..6c1cce69 100644 --- a/packages/database/src/song/dto/SongStats.ts +++ b/packages/database/src/song/dto/SongStats.ts @@ -1,5 +1,6 @@ import { IsBoolean, + IsIn, IsInt, IsNumber, IsString, @@ -48,8 +49,13 @@ export class SongStats { customInstrumentCount: number; @IsInt() + @IsIn([16, 20]) firstCustomInstrumentIndex: number; + @IsInt() + @IsIn([5, 6]) + nbsVersion: number; + @IsInt() outOfRangeNoteCount: number; diff --git a/packages/database/src/song/entity/song.entity.ts b/packages/database/src/song/entity/song.entity.ts index 29d7bd41..1bbc4935 100644 --- a/packages/database/src/song/entity/song.entity.ts +++ b/packages/database/src/song/entity/song.entity.ts @@ -17,6 +17,9 @@ import type { CategoryType, LicenseType, VisibilityType } from '../dto/types'; }, }) export class Song { + @Prop({ type: Number, required: true, default: 0 }) + schemaVersion: number; + @Prop({ type: String, required: true, unique: true }) publicId: string; diff --git a/packages/database/tests/migration/runner.spec.ts b/packages/database/tests/migration/runner.spec.ts new file mode 100644 index 00000000..83fdfc58 --- /dev/null +++ b/packages/database/tests/migration/runner.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'bun:test'; + +import { + CURRENT_SONG_SCHEMA_VERSION, + SONG_MIGRATION_REGISTRY, + SONG_PENDING_QUERY_CLAUSES, +} from '../../src/migration/collections/song.migrations'; +import { + buildPendingQuery, + getPendingMigrations, + isFullyMigrated, + migrateDocument, +} from '../../src/migration/runner'; + +describe('migration runner', () => { + it('returns no pending migrations for a fully migrated song document', () => { + const doc = { + schemaVersion: CURRENT_SONG_SCHEMA_VERSION, + stats: { + nbsVersion: 5, + firstCustomInstrumentIndex: 16, + }, + }; + + expect(getPendingMigrations(SONG_MIGRATION_REGISTRY, doc)).toEqual([]); + expect(isFullyMigrated(SONG_MIGRATION_REGISTRY, doc)).toBe(true); + }); + + it('applies song migration 1 to legacy documents', () => { + const doc = { + schemaVersion: 0, + stats: { + noteCount: 10, + }, + }; + + const migrated = migrateDocument(SONG_MIGRATION_REGISTRY, doc); + + expect(migrated.schemaVersion).toBe(1); + expect(migrated.stats.nbsVersion).toBe(5); + expect(migrated.stats.noteCount).toBe(10); + }); + + it('stamps schemaVersion when stats fields exist but version is missing', () => { + const doc = { + stats: { + nbsVersion: 5, + firstCustomInstrumentIndex: 16, + }, + }; + + const migrated = migrateDocument(SONG_MIGRATION_REGISTRY, doc); + + expect(migrated.schemaVersion).toBe(1); + expect(migrated.stats.nbsVersion).toBe(5); + }); + + it('builds a pending query with schema and field guards', () => { + expect( + buildPendingQuery(SONG_MIGRATION_REGISTRY, SONG_PENDING_QUERY_CLAUSES), + ).toEqual({ + $or: [ + { schemaVersion: { $exists: false } }, + { schemaVersion: { $lt: CURRENT_SONG_SCHEMA_VERSION } }, + { 'stats.nbsVersion': { $exists: false } }, + ], + }); + }); +}); diff --git a/packages/song/scripts/build.ts b/packages/song/scripts/build.ts index c3eb47a9..4a0625aa 100644 --- a/packages/song/scripts/build.ts +++ b/packages/song/scripts/build.ts @@ -17,6 +17,8 @@ async function buildNode() { entrypoints: [join(packageRoot, 'src', 'index.ts')], outdir: join(packageRoot, 'dist'), target: 'node', + // Keep a single @encode42/nbs.js instance for consumers (e.g. backend instanceof checks). + external: ['@encode42/nbs.js'], }); if (!result.success) { diff --git a/packages/song/src/index.ts b/packages/song/src/index.ts index 1faff3fa..a3acce9e 100644 --- a/packages/song/src/index.ts +++ b/packages/song/src/index.ts @@ -1,3 +1,4 @@ +export * from './nbsCompat'; export * from './injectMetadata'; export * from './notes'; export * from './obfuscate'; diff --git a/packages/song/src/injectMetadata.ts b/packages/song/src/injectMetadata.ts index 0b755a8d..6f018e83 100644 --- a/packages/song/src/injectMetadata.ts +++ b/packages/song/src/injectMetadata.ts @@ -1,6 +1,7 @@ import { Song } from '@encode42/nbs.js'; import unidecode from 'unidecode'; +/** Expects a song from {@link loadNbsFromBuffer} (normalized v5/v6 instrument layout). */ export function injectSongFileMetadata( nbsSong: Song, title: string, diff --git a/packages/song/src/nbsCompat.ts b/packages/song/src/nbsCompat.ts new file mode 100644 index 00000000..73667973 --- /dev/null +++ b/packages/song/src/nbsCompat.ts @@ -0,0 +1,173 @@ +import { + fromArrayBuffer, + Instrument, + Song, + type FromArrayBufferOptions, +} from '@encode42/nbs.js'; + +/** Default instruments 0–15 (NBS v5 and below). */ +export const NBS_V5_FIRST_CUSTOM = 16; + +/** First custom instrument index in NBS v6. */ +export const NBS_V6_FIRST_CUSTOM = 20; + +export const MAX_SUPPORTED_NBS_VERSION = 6; + +export class UnsupportedNbsVersionError extends Error { + constructor(public readonly version: number) { + super( + `Unsupported NBS version: ${version}. Maximum supported version is ${MAX_SUPPORTED_NBS_VERSION}.`, + ); + this.name = 'UnsupportedNbsVersionError'; + } +} + +// TODO: TEMP: Remove when @encode42/nbs.js ships v6 built-in instruments in Instrument.builtIn. +const NBS_V6_BUILTIN_INSTRUMENTS: Instrument[] = [ + new Instrument(16, { + name: 'Trumpet', + soundFile: 'trumpet.ogg', + builtIn: true, + key: 45, + }), + new Instrument(17, { + name: 'Exposed Trumpet', + soundFile: 'exposed_trumpet.ogg', + builtIn: true, + key: 45, + }), + new Instrument(18, { + name: 'Weathered Trumpet', + soundFile: 'weathered_trumpet.ogg', + builtIn: true, + key: 45, + }), + new Instrument(19, { + name: 'Oxidized Trumpet', + soundFile: 'oxidized_trumpet.ogg', + builtIn: true, + key: 45, + }), +]; + +export function getNbsFormatVersion(song: Song): 5 | 6 { + return song.nbsVersion >= 6 ? 6 : 5; +} + +export function isNbsV6(song: Song): boolean { + return getNbsFormatVersion(song) === 6; +} + +function findInstrumentById(song: Song, id: number): Instrument | undefined { + const { loaded } = song.instruments; + + if (loaded[id]?.id === id) { + return loaded[id]; + } + + return loaded.find((inst) => inst?.id === id); +} + +function cloneBuiltinInstrument( + source: Instrument | undefined, + fallback: Instrument, + id: number, +): Instrument { + const base = source ?? fallback; + + return new Instrument(id, { + name: base.meta.name, + soundFile: base.meta.soundFile, + key: base.key, + pressKey: base.pressKey, + builtIn: true, + }); +} + +function getDefaultBuiltinInstrument(id: number): Instrument { + if (id < Instrument.builtIn.length) { + return Instrument.builtIn[id]!; + } + + return NBS_V6_BUILTIN_INSTRUMENTS[id - NBS_V5_FIRST_CUSTOM]!; +} + +/** + * Rebuilds `instruments.loaded` so array indices match note instrument IDs. + * nbs.js 5.0.2 leaves gaps at 16–19 for v6 files and may place customs at wrong indices. + * + * // TODO: TEMP: Remove when @encode42/nbs.js natively models v6. + */ +export function normalizeNbsSong(song: Song): Song { + if (song.nbsVersion > MAX_SUPPORTED_NBS_VERSION) { + throw new UnsupportedNbsVersionError(song.nbsVersion); + } + + if (!isNbsV6(song)) { + return song; + } + + const firstCustom = song.instruments.firstCustomIndex; + const newLoaded: Instrument[] = []; + + for (let id = 0; id < firstCustom; id++) { + newLoaded[id] = cloneBuiltinInstrument( + findInstrumentById(song, id), + getDefaultBuiltinInstrument(id), + id, + ); + } + + const customs = song.instruments.loaded.filter( + (inst): inst is Instrument => Boolean(inst) && !inst.builtIn, + ); + + customs.sort((a, b) => a.id - b.id); + + for (const inst of customs) { + const targetId = + inst.id >= firstCustom ? inst.id : firstCustom + customs.indexOf(inst); + + newLoaded[targetId] = inst; + } + + song.instruments.loaded = newLoaded; + + return song; +} + +/** + * Seeds obfuscated output with the source song's format version and built-in instruments. + * + * TODO: TEMP: Remove when @encode42/nbs.js creates v6 songs from `new Song()`. + */ +export function seedOutputBuiltinInstruments(source: Song, output: Song): void { + output.nbsVersion = getNbsFormatVersion(source); + output.instruments.firstCustomIndex = source.instruments.firstCustomIndex; + + const firstCustom = source.instruments.firstCustomIndex; + const builtins: Instrument[] = []; + + for (let id = 0; id < firstCustom; id++) { + builtins[id] = cloneBuiltinInstrument( + findInstrumentById(source, id), + getDefaultBuiltinInstrument(id), + id, + ); + } + + output.instruments.loaded = builtins; +} + +export function loadNbsFromBuffer( + buffer: ArrayBuffer, + options?: FromArrayBufferOptions, +): Song { + const song = fromArrayBuffer(buffer, options); + + if (song.nbsVersion > MAX_SUPPORTED_NBS_VERSION) { + throw new UnsupportedNbsVersionError(song.nbsVersion); + } + + return normalizeNbsSong(song); +} diff --git a/packages/song/src/obfuscate.ts b/packages/song/src/obfuscate.ts index 490bcb80..25576bfc 100644 --- a/packages/song/src/obfuscate.ts +++ b/packages/song/src/obfuscate.ts @@ -1,5 +1,6 @@ import { Instrument, Layer, Note, Song } from '@encode42/nbs.js'; +import { seedOutputBuiltinInstruments } from './nbsCompat'; import { getInstrumentNoteCounts, getTempoChangerInstrumentIds } from './util'; export class SongObfuscator { @@ -21,6 +22,9 @@ export class SongObfuscator { const song = this.song; const output = new Song(); + // TODO: TEMP: preserve v5/v6 format until nbs.js writes v6 from `new Song()`. + seedOutputBuiltinInstruments(song, output); + // βœ… Clear work stats // βœ… Copy: title, author, description, loop info, time signature this.copyMetaAndStats(song, output); diff --git a/packages/song/src/parse.ts b/packages/song/src/parse.ts index 202daf8d..7213d8b8 100644 --- a/packages/song/src/parse.ts +++ b/packages/song/src/parse.ts @@ -1,5 +1,6 @@ -import { Song, fromArrayBuffer } from '@encode42/nbs.js'; +import type { Song } from '@encode42/nbs.js'; +import { loadNbsFromBuffer } from './nbsCompat'; import { NoteQuadTree } from './notes'; import type { InstrumentArray, SongFileType } from './types'; import { getInstrumentNoteCounts } from './util'; @@ -20,7 +21,7 @@ async function getVanillaSoundList() { export async function parseSongFromBuffer( buffer: ArrayBuffer, ): Promise { - const song = fromArrayBuffer(buffer); + const song = loadNbsFromBuffer(buffer); if (song.length === 0) { throw new Error('Invalid song'); @@ -40,6 +41,7 @@ export async function parseSongFromBuffer( arrayBuffer: buffer, notes: quadTree, instruments: getInstruments(song, vanillaSoundList), + defaultInstrumentCount: song.instruments.firstCustomIndex, }; } diff --git a/packages/song/src/stats.ts b/packages/song/src/stats.ts index 9b585ad9..9c20b1f6 100644 --- a/packages/song/src/stats.ts +++ b/packages/song/src/stats.ts @@ -1,5 +1,6 @@ import { Song } from '@encode42/nbs.js'; +import { getNbsFormatVersion } from './nbsCompat'; import type { SongStatsType } from './types'; import { getTempoChangerInstrumentIds } from './util'; @@ -49,6 +50,7 @@ export class SongStatsGenerator { ); const firstCustomInstrumentIndex = this.getFirstCustomInstrumentIndex(); + const nbsVersion = getNbsFormatVersion(this.song); const compatible = incompatibleNoteCount === 0; @@ -67,6 +69,7 @@ export class SongStatsGenerator { vanillaInstrumentCount, customInstrumentCount, firstCustomInstrumentIndex, + nbsVersion, instrumentNoteCounts, customInstrumentNoteCount, outOfRangeNoteCount, @@ -102,8 +105,10 @@ export class SongStatsGenerator { let customInstrumentNoteCount = 0; let incompatibleNoteCount = 0; + // At least one slot per default instrument (16 for v5, 20 for v6). + const minInstrumentSlots = this.song.instruments.firstCustomIndex; const instrumentNoteCounts = Array( - this.song.instruments.loaded.length, + Math.max(minInstrumentSlots, this.song.instruments.loaded.length), ).fill(0); for (const [layerId, layer] of this.song.layers.entries()) { diff --git a/packages/song/src/types.ts b/packages/song/src/types.ts index 19dc4bbe..47a2becc 100644 --- a/packages/song/src/types.ts +++ b/packages/song/src/types.ts @@ -10,6 +10,7 @@ export type SongFileType = { arrayBuffer: ArrayBuffer; notes: NoteQuadTree; instruments: InstrumentArray; + defaultInstrumentCount: number; }; export type InstrumentArray = Instrument[]; @@ -45,6 +46,7 @@ export type SongStatsType = { vanillaInstrumentCount: number; customInstrumentCount: number; firstCustomInstrumentIndex: number; + nbsVersion: number; outOfRangeNoteCount: number; detunedNoteCount: number; customInstrumentNoteCount: number; diff --git a/packages/song/tests/song/files/testV6Trumpets.nbs b/packages/song/tests/song/files/testV6Trumpets.nbs new file mode 100644 index 00000000..0a633722 Binary files /dev/null and b/packages/song/tests/song/files/testV6Trumpets.nbs differ diff --git a/packages/song/tests/song/index.spec.ts b/packages/song/tests/song/index.spec.ts index 08b8d48c..c7bf233f 100644 --- a/packages/song/tests/song/index.spec.ts +++ b/packages/song/tests/song/index.spec.ts @@ -52,6 +52,7 @@ describe('SongStatsGenerator', () => { assert(stats.vanillaInstrumentCount === 5); assert(stats.customInstrumentCount === 0); assert(stats.firstCustomInstrumentIndex === 16); + assert(stats.nbsVersion === 5); assert(stats.customInstrumentNoteCount === 0); assert(stats.outOfRangeNoteCount === 0); assert(stats.detunedNoteCount === 0); diff --git a/packages/song/tests/song/nbsCompat.spec.ts b/packages/song/tests/song/nbsCompat.spec.ts new file mode 100644 index 00000000..9ecc0c54 --- /dev/null +++ b/packages/song/tests/song/nbsCompat.spec.ts @@ -0,0 +1,135 @@ +import assert from 'assert'; +import { readFileSync } from 'fs'; +import { join, resolve } from 'path'; + +import { Instrument, toArrayBuffer } from '@encode42/nbs.js'; + +import { + NBS_V6_FIRST_CUSTOM, + UnsupportedNbsVersionError, + getNbsFormatVersion, + loadNbsFromBuffer, + normalizeNbsSong, +} from '../../src/nbsCompat'; +import { SongObfuscator } from '../../src/obfuscate'; +import { SongStatsGenerator } from '../../src/stats'; + +import { asArrayBuffer, openSongFromPath } from './util'; + +const fixturesDir = join(resolve(import.meta.dir), 'files'); + +function createV6TestSong() { + const song = openSongFromPath('files/testSimple.nbs'); + + song.nbsVersion = 6; + song.instruments.firstCustomIndex = NBS_V6_FIRST_CUSTOM; + normalizeNbsSong(song); + + const layer = song.layers[0]; + song.addNote(layer, 0, 16, { key: 45 }); + song.addNote(layer, 4, 17, { key: 50 }); + song.addNote(layer, 8, 18, { key: 55 }); + song.addNote(layer, 12, 19, { key: 60 }); + + song.instruments.loaded[NBS_V6_FIRST_CUSTOM] = new Instrument( + NBS_V6_FIRST_CUSTOM, + { + name: 'Test Custom', + soundFile: 'custom.ogg', + key: 45, + }, + ); + song.addNote(layer, 16, NBS_V6_FIRST_CUSTOM, { key: 45 }); + + return song; +} + +describe('nbsCompat', () => { + it('leaves v5 songs unchanged', () => { + const song = openSongFromPath('files/testSimple.nbs'); + + assert.strictEqual(song.nbsVersion, 5); + assert.strictEqual(song.instruments.firstCustomIndex, 16); + assert.strictEqual(song.instruments.loaded.length, 16); + }); + + it('pads v5 instrumentNoteCounts to at least 16 slots', () => { + const stats = SongStatsGenerator.getSongStats( + openSongFromPath('files/testSimple.nbs'), + ); + + assert(stats.instrumentNoteCounts.length >= 16); + assert.strictEqual(stats.firstCustomInstrumentIndex, 16); + assert.strictEqual(stats.nbsVersion, 5); + }); + + it('normalizes v6 built-in instruments at indices 16–19', () => { + const song = createV6TestSong(); + + assert.strictEqual(song.instruments.firstCustomIndex, 20); + assert.strictEqual(song.instruments.loaded[16]?.builtIn, true); + assert.strictEqual(song.instruments.loaded[16]?.meta.name, 'Trumpet'); + assert.strictEqual(song.instruments.loaded[19]?.builtIn, true); + assert.strictEqual( + song.instruments.loaded[NBS_V6_FIRST_CUSTOM]?.meta.name, + 'Test Custom', + ); + }); + + it('pads v6 instrumentNoteCounts to at least 20 slots', () => { + const stats = SongStatsGenerator.getSongStats(createV6TestSong()); + + assert(stats.instrumentNoteCounts.length >= 20); + assert.strictEqual(stats.firstCustomInstrumentIndex, 20); + assert.strictEqual(stats.nbsVersion, 6); + assert.strictEqual(stats.instrumentNoteCounts[16], 1); + assert.strictEqual(stats.instrumentNoteCounts[19], 1); + assert.strictEqual(stats.instrumentNoteCounts[20], 1); + }); + + it('preserves v6 format when obfuscating', () => { + const song = createV6TestSong(); + const obfuscated = SongObfuscator.obfuscateSong(song, ['customhash']); + + assert.strictEqual(getNbsFormatVersion(obfuscated), 6); + assert.strictEqual(obfuscated.nbsVersion, 6); + assert.strictEqual(obfuscated.instruments.firstCustomIndex, 20); + assert.strictEqual(obfuscated.instruments.loaded[16]?.builtIn, true); + assert.strictEqual( + obfuscated.instruments.loaded[20]?.meta.soundFile, + 'customhash', + ); + }); + + it('round-trips v6 through toArrayBuffer', () => { + const buffer = toArrayBuffer(createV6TestSong()); + const reloaded = loadNbsFromBuffer(buffer); + + assert.strictEqual(reloaded.nbsVersion, 6); + assert.strictEqual(reloaded.instruments.firstCustomIndex, 20); + assert.strictEqual(reloaded.instruments.loaded[16]?.builtIn, true); + assert.strictEqual( + reloaded.instruments.loaded[NBS_V6_FIRST_CUSTOM]?.meta.name, + 'Test Custom', + ); + }); + + it('loads v6 fixture file', () => { + const fixturePath = join(fixturesDir, 'testV6Trumpets.nbs'); + const buffer = asArrayBuffer(readFileSync(fixturePath)); + const song = loadNbsFromBuffer(buffer); + + assert.strictEqual(song.nbsVersion, 6); + assert.strictEqual(song.instruments.firstCustomIndex, 20); + assert.strictEqual(song.instruments.loaded[16]?.builtIn, true); + }); + + it('throws an error if the NBS version is too high', () => { + const song = createV6TestSong(); + song.nbsVersion = 7; + assert.throws( + () => loadNbsFromBuffer(toArrayBuffer(song)), + UnsupportedNbsVersionError, + ); + }); +}); diff --git a/packages/song/tests/song/util.ts b/packages/song/tests/song/util.ts index 40698920..c12c5f04 100644 --- a/packages/song/tests/song/util.ts +++ b/packages/song/tests/song/util.ts @@ -1,7 +1,9 @@ import { readFileSync } from 'fs'; import { join, resolve } from 'path'; -import { Song, fromArrayBuffer } from '@encode42/nbs.js'; +import type { Song } from '@encode42/nbs.js'; + +import { loadNbsFromBuffer } from '../../src/nbsCompat'; export function openSongFromPath(path: string): Song { // Specify the relative path to the file @@ -10,12 +12,10 @@ export function openSongFromPath(path: string): Song { // Read the file and get its array buffer const buffer = asArrayBuffer(readFileSync(filePath)); - const song = fromArrayBuffer(buffer); - - return song; + return loadNbsFromBuffer(buffer); } -function asArrayBuffer(buffer: Buffer): ArrayBuffer { +export function asArrayBuffer(buffer: Buffer): ArrayBuffer { const arrayBuffer = new ArrayBuffer(buffer.length); const view = new Uint8Array(arrayBuffer); diff --git a/packages/thumbnail/src/shared/drawCore.ts b/packages/thumbnail/src/shared/drawCore.ts index 30f2f230..aa4d4942 100644 --- a/packages/thumbnail/src/shared/drawCore.ts +++ b/packages/thumbnail/src/shared/drawCore.ts @@ -9,6 +9,7 @@ export async function drawNotes( ) { const { notes, + defaultInstrumentCount, startTick, startLayer, zoomLevel, @@ -78,7 +79,8 @@ export async function drawNotes( // Calculate position const x = (note.tick - startTick) * 8 * zoomFactor; const y = (note.layer - startLayer) * 8 * zoomFactor; - const overlayColor = instrumentColors[note.instrument % 16] ?? '#FF00FF'; + const overlayColor = + instrumentColors[note.instrument % defaultInstrumentCount] ?? '#FF00FF'; if (!loadedNoteBlockImage) { throw new Error('Note block image not loaded'); diff --git a/packages/thumbnail/src/shared/types.ts b/packages/thumbnail/src/shared/types.ts index 33506f22..397e7fe8 100644 --- a/packages/thumbnail/src/shared/types.ts +++ b/packages/thumbnail/src/shared/types.ts @@ -2,6 +2,7 @@ import type { NoteQuadTree } from '@nbw/song'; export interface DrawParams { notes: NoteQuadTree; + defaultInstrumentCount: number; startTick: number; startLayer: number; zoomLevel: number; diff --git a/packages/thumbnail/src/shared/utils.spec.ts b/packages/thumbnail/src/shared/utils.spec.ts index 13928a00..a5435a5d 100644 --- a/packages/thumbnail/src/shared/utils.spec.ts +++ b/packages/thumbnail/src/shared/utils.spec.ts @@ -1,8 +1,8 @@ import { getKeyText, instrumentColors, isDarkColor } from './utils.js'; describe('instrumentColors', () => { - it('should contain 16 color codes', () => { - expect(instrumentColors).toHaveLength(16); + it('should contain 20 color codes', () => { + expect(instrumentColors).toHaveLength(20); instrumentColors.forEach((color) => { expect(color).toMatch(/^#[0-9a-f]{6}$/); diff --git a/packages/thumbnail/src/shared/utils.ts b/packages/thumbnail/src/shared/utils.ts index bd96978b..787a9292 100644 --- a/packages/thumbnail/src/shared/utils.ts +++ b/packages/thumbnail/src/shared/utils.ts @@ -22,6 +22,10 @@ export const instrumentColors = [ '#19be19', '#be1957', '#575757', + '#d26b50', + '#c38969', + '#78a07a', + '#5ca087', ]; const tintedImages: Record = {};