From 718dca722babe499d62ad3eed9a93d2562934d51 Mon Sep 17 00:00:00 2001 From: Konstantin Baltsat Date: Mon, 27 Jul 2026 11:03:21 +0800 Subject: [PATCH 1/2] feat: add safe prepared-song import backend Validate and copy prepared chart folders, preserve manual artwork, ingest embedded or explicitly permitted covers, and normalize AI provenance without deleting colliding library data. User-Request: add a reversible SightKick import foundation with cover and provenance repair | codex:019fa139-029e-7613-9be1-6a8ef299c61f --- package.json | 1 + src/main/AppState.ts | 3 + src/main/ipc/importSong.test.ts | 200 ++++++++++++++++++++++++++++++++ src/main/ipc/importSong.ts | 199 +++++++++++++++++++++++++++++++ src/main/songCover.test.ts | 93 +++++++++++++++ src/main/songCover.ts | 144 +++++++++++++++++++++++ src/main/util.test.ts | 39 +++++++ src/main/util.ts | 20 +++- src/preload/index.ts | 2 + src/types.ts | 34 ++++++ yarn.lock | 110 ++++++++++++++++++ 11 files changed, 844 insertions(+), 1 deletion(-) create mode 100644 src/main/ipc/importSong.test.ts create mode 100644 src/main/ipc/importSong.ts create mode 100644 src/main/songCover.test.ts create mode 100644 src/main/songCover.ts diff --git a/package.json b/package.json index 9c3b8010..f48fa2a9 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "es-toolkit": "^1.47.1", "fuse.js": "^7.0.0", "ini": "^4.1.1", + "music-metadata": "^11.14.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.18.1", diff --git a/src/main/AppState.ts b/src/main/AppState.ts index 0adcf5f0..c66e02b7 100644 --- a/src/main/AppState.ts +++ b/src/main/AppState.ts @@ -24,6 +24,7 @@ import { listenMidi, loadMidiDeviceList, stopListenMidi } from './ipc/midi'; import { updateSong } from './ipc/updateSong'; import { rescanSongs } from './ipc/rescanSongs'; import { exportPdf } from './ipc/exportPdf'; +import { importSong, selectImportSong } from './ipc/importSong'; class AppState { private static instance: AppState; @@ -100,6 +101,8 @@ class AppState { ipcMain.on('load-song-list', loadSongList); ipcMain.on('rescan-songs', rescanSongs); ipcMain.on('download-song', downloadSong); + ipcMain.on('select-import-song', selectImportSong); + ipcMain.on('import-song', importSong); ipcMain.on('check-stem-tools', checkStemTools); ipcMain.on('check-stem-tools-update', checkStemToolsUpdate); diff --git a/src/main/ipc/importSong.test.ts b/src/main/ipc/importSong.test.ts new file mode 100644 index 00000000..3e2fa2c7 --- /dev/null +++ b/src/main/ipc/importSong.test.ts @@ -0,0 +1,200 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeStore, lastReply, makeEvent, makeStore } from './test-support'; + +const storeHolder = vi.hoisted(() => ({ + current: undefined as FakeStore | undefined, +})); +const dialogHolder = vi.hoisted(() => ({ + sourceDir: '', + canceled: false, +})); +const coverHolder = vi.hoisted(() => ({ + preview: { + dataUrl: 'data:image/jpeg;base64,cHJldmlldw==', + source: 'embedded' as const, + }, +})); + +vi.mock('../AppState', () => ({ + appState: { + store: { + get: (key: string) => storeHolder.current!.get(key), + set: (key: string, value: unknown) => + storeHolder.current!.set(key, value), + }, + }, +})); + +vi.mock('electron', () => ({ + dialog: { + showOpenDialog: vi.fn(async () => ({ + canceled: dialogHolder.canceled, + filePaths: dialogHolder.canceled ? [] : [dialogHolder.sourceDir], + })), + }, +})); + +vi.mock('../songCover', () => ({ + previewSongCover: vi.fn(async () => coverHolder.preview), + ingestSongCover: vi.fn(async () => coverHolder.preview.source), +})); + +const { importSong, selectImportSong } = await import('./importSong'); +const CHART = `[Song] +{ + Resolution = 192 +} +[SyncTrack] +{ + 0 = TS 4 + 0 = B 120000 +} +[ExpertDrums] +{ + 0 = N 0 0 +} +`; + +describe('local song import', () => { + let root: string; + let library: string; + let sourceDir: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'import-song-')); + library = path.join(root, 'library'); + sourceDir = path.join(root, 'source'); + fs.mkdirSync(library); + fs.mkdirSync(sourceDir); + fs.writeFileSync( + path.join(sourceDir, 'song.ini'), + [ + '[song]', + 'name = Raging', + 'artist = Kygo feat. Kodaline', + 'album = Cloud Nine', + 'auto_chart = True', + 'auto_chart_tool = STRUM (OCTAVE AI auto-charter)', + 'charter = STRUM', + 'diff_drums = 2', + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(sourceDir, 'notes.chart'), CHART); + fs.writeFileSync(path.join(sourceDir, 'song.mp3'), 'audio'); + storeHolder.current = makeStore({ lastOpenedPath: library, songs: {} }); + dialogHolder.sourceDir = sourceDir; + dialogHolder.canceled = false; + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('validates a selected chart folder and returns a metadata preview', async () => { + const event = makeEvent(); + + await selectImportSong(event as never); + + expect(lastReply(event, 'select-import-song')!.args[0]).toMatchObject({ + preview: { + sourceDir, + name: 'Raging', + artist: 'Kygo feat. Kodaline', + album: 'Cloud Nine', + charter: '', + autoChartTool: 'STRUM (OCTAVE AI auto-charter)', + chartFormat: 'chart', + audioCount: 1, + coverSource: 'embedded', + }, + }); + }); + + it('copies the confirmed folder, ingests its cover and persists the song', async () => { + const event = makeEvent(); + + await importSong(event as never, { + sourceDir, + artworkUrl: 'https://example.com/permitted-cover.jpg', + }); + + const reply = lastReply(event, 'import-song')!.args[0] as { + success: boolean; + song: { id: string; dir: string; charter: string }; + }; + + expect(reply.success).toBe(true); + expect(reply.song.charter).toBe(''); + expect(reply.song.dir.startsWith(library)).toBe(true); + expect(fs.existsSync(path.join(reply.song.dir, 'song.ini'))).toBe(true); + expect(fs.existsSync(path.join(reply.song.dir, '.sightkick'))).toBe(true); + expect( + fs.readFileSync(path.join(reply.song.dir, 'song.ini'), 'utf-8'), + ).toMatch(/^charter\s*=\s*$/m); + expect(storeHolder.current.get(`songs.${reply.song.id}`)).toMatchObject({ + charter: '', + auto_chart_tool: 'STRUM (OCTAVE AI auto-charter)', + }); + }); + + it('rejects a folder that has no playable audio', async () => { + fs.rmSync(path.join(sourceDir, 'song.mp3')); + + const event = makeEvent(); + + await selectImportSong(event as never); + + expect(lastReply(event, 'select-import-song')!.args[0]).toMatchObject({ + error: 'This folder has no playable audio file', + }); + }); + + it('never deletes an existing library folder when the name collides', async () => { + const existing = path.join(library, 'Kygo feat. Kodaline - Raging'); + const sentinel = path.join(existing, 'keep.txt'); + + fs.mkdirSync(existing); + fs.writeFileSync(sentinel, 'manual library data'); + + const event = makeEvent(); + + await importSong(event as never, { sourceDir }); + + expect(lastReply(event, 'import-song')!.args[0]).toMatchObject({ + success: false, + error: + 'A library folder named "Kygo feat. Kodaline - Raging" already exists', + }); + expect(fs.readFileSync(sentinel, 'utf-8')).toBe('manual library data'); + }); + + it('rejects a source folder that contains the selected library', async () => { + sourceDir = root; + fs.renameSync( + path.join(root, 'source', 'song.ini'), + path.join(root, 'song.ini'), + ); + fs.renameSync( + path.join(root, 'source', 'notes.chart'), + path.join(root, 'notes.chart'), + ); + fs.renameSync( + path.join(root, 'source', 'song.mp3'), + path.join(root, 'song.mp3'), + ); + + const event = makeEvent(); + + await importSong(event as never, { sourceDir }); + + expect(lastReply(event, 'import-song')!.args[0]).toMatchObject({ + success: false, + error: 'The selected song folder cannot contain the library', + }); + expect(fs.existsSync(library)).toBe(true); + }); +}); diff --git a/src/main/ipc/importSong.ts b/src/main/ipc/importSong.ts new file mode 100644 index 00000000..99d08091 --- /dev/null +++ b/src/main/ipc/importSong.ts @@ -0,0 +1,199 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { dialog, IpcMainEvent } from 'electron'; +import { + IpcImportSongRequest, + IpcImportSongResponse, + IpcSelectImportSongResponse, + SongData, + StorageSchema, +} from '../../types'; +import { appState } from '../AppState'; +import { ingestSongCover, previewSongCover } from '../songCover'; +import { + buildSongFromDir, + hasDuplicatedAutoCharter, + isUnderDirectory, + toSong, + writeSongIdFile, +} from '../util'; + +function validateSongDir(dir: string): SongData { + const song = buildSongFromDir(dir); + + if (!song) { + throw new Error( + 'Choose a folder with song.ini and notes.mid or notes.chart', + ); + } + + if (song.audio.length === 0) { + throw new Error('This folder has no playable audio file'); + } + + if (!song.drumDifficulties?.length) { + throw new Error('This chart has no playable drum difficulty'); + } + + return song; +} + +function destinationName(song: SongData, sourceDir: string): string { + const sourceName = path.basename(sourceDir); + const base = + [song.artist, song.name].filter(Boolean).join(' - ') || sourceName; + const safe = base.replace(/[\\/:*?"<>|]/g, '').trim(); + + return safe.slice(0, 180) || 'Imported song'; +} + +function copySongDirectory(sourceDir: string, destinationDir: string): void { + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const source = path.join(sourceDir, entry.name); + const destination = path.join(destinationDir, entry.name); + + if (entry.isSymbolicLink()) { + throw new Error('Song folders with symbolic links are not supported'); + } + + if (entry.isDirectory()) { + fs.mkdirSync(destination); + copySongDirectory(source, destination); + } else if (entry.isFile()) { + fs.copyFileSync(source, destination); + } + } +} + +function normalizeImportedProvenance(dir: string, song: SongData): void { + if (!hasDuplicatedAutoCharter(song)) { + return; + } + + const iniPath = path.join(dir, 'song.ini'); + const original = fs.readFileSync(iniPath, 'utf-8'); + const normalized = original.replace(/^(\s*charter\s*=\s*).*$/im, '$1'); + + fs.writeFileSync(iniPath, normalized); +} + +export async function selectImportSong(event: IpcMainEvent): Promise { + try { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Choose a prepared Clone Hero song folder', + message: 'Choose a folder containing song.ini, a chart and audio', + }); + + if (result.canceled || !result.filePaths[0]) { + event.reply('select-import-song', { cancelled: true }); + + return; + } + + const sourceDir = result.filePaths[0]; + const stored = validateSongDir(sourceDir); + const song = toSong(stored); + const cover = await previewSongCover(sourceDir); + const response: IpcSelectImportSongResponse = { + preview: { + sourceDir, + name: song.name, + artist: song.artist, + album: song.album, + charter: song.charter, + autoChartTool: song.autoChartTool, + chartFormat: song.format, + audioCount: song.audio.length, + drumDifficulties: song.drumDifficulties ?? [], + albumCoverDataUrl: cover.dataUrl, + coverSource: cover.source, + }, + }; + + event.reply('select-import-song', response); + } catch (error) { + event.reply('select-import-song', { + error: error instanceof Error ? error.message : String(error), + } satisfies IpcSelectImportSongResponse); + } +} + +export async function importSong( + event: IpcMainEvent, + { sourceDir, artworkUrl }: IpcImportSongRequest, +): Promise { + let outputDir: string | undefined; + let outputCreated = false; + + try { + const libraryRoot = appState.store.get('lastOpenedPath') as + | string + | undefined; + + if (!libraryRoot) { + throw new Error('Select a library folder before importing'); + } + + if (isUnderDirectory(sourceDir, libraryRoot)) { + throw new Error('This song is already inside the selected library'); + } + + if (isUnderDirectory(libraryRoot, sourceDir)) { + throw new Error('The selected song folder cannot contain the library'); + } + + const sourceSong = validateSongDir(sourceDir); + const folderName = destinationName(sourceSong, sourceDir); + + outputDir = path.join(libraryRoot, folderName); + + if (!isUnderDirectory(outputDir, libraryRoot)) { + throw new Error('Invalid import destination'); + } + + if (fs.existsSync(outputDir)) { + throw new Error(`A library folder named "${folderName}" already exists`); + } + + fs.mkdirSync(outputDir); + outputCreated = true; + copySongDirectory(sourceDir, outputDir); + normalizeImportedProvenance(outputDir, sourceSong); + await ingestSongCover(outputDir, artworkUrl); + + const id = randomUUID(); + + writeSongIdFile(outputDir, id); + + const songData = buildSongFromDir(outputDir, { id }); + + if (!songData) { + throw new Error('Imported files could not be read as a song'); + } + + const songs = (appState.store.get('songs') as StorageSchema['songs']) ?? {}; + + appState.store.set('songs', { ...songs, [id]: songData }); + + const response: IpcImportSongResponse = { + success: true, + song: toSong({ + ...songData, + updatedAt: fs.statSync(outputDir).mtime.toISOString(), + }), + }; + + event.reply('import-song', response); + } catch (error) { + if (outputCreated && outputDir && fs.existsSync(outputDir)) { + fs.rmSync(outputDir, { recursive: true, force: true }); + } + + event.reply('import-song', { + success: false, + error: error instanceof Error ? error.message : String(error), + } satisfies IpcImportSongResponse); + } +} diff --git a/src/main/songCover.test.ts b/src/main/songCover.test.ts new file mode 100644 index 00000000..006ef9d1 --- /dev/null +++ b/src/main/songCover.test.ts @@ -0,0 +1,93 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const metadataHolder = vi.hoisted(() => ({ + picture: undefined as number[] | undefined, +})); + +vi.mock('music-metadata', () => ({ + parseFile: vi.fn(async () => ({ + common: { + picture: metadataHolder.picture + ? [{ data: Uint8Array.from(metadataHolder.picture) }] + : [], + }, + })), +})); + +vi.mock('electron', () => ({ + nativeImage: { + createFromBuffer: vi.fn(() => ({ + isEmpty: () => false, + toJPEG: () => Buffer.from('jpeg'), + toDataURL: () => 'data:image/jpeg;base64,cHJldmlldw==', + })), + }, +})); + +const { ingestSongCover, previewSongCover } = await import('./songCover'); + +describe('song cover ingestion', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-')); + metadataHolder.picture = undefined; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('preserves an existing manual cover', async () => { + fs.writeFileSync(path.join(dir, 'album.png'), 'manual'); + fs.writeFileSync(path.join(dir, 'song.mp3'), ''); + + const result = await ingestSongCover(dir, 'https://example.com/remote.jpg'); + + expect(result).toBe('existing'); + expect(fs.readFileSync(path.join(dir, 'album.png'), 'utf-8')).toBe( + 'manual', + ); + expect(fs.existsSync(path.join(dir, 'album.jpg'))).toBe(false); + }); + + it('normalizes embedded artwork to album.jpg', async () => { + metadataHolder.picture = [1, 2, 3]; + fs.writeFileSync(path.join(dir, 'song.mp3'), ''); + + expect(await ingestSongCover(dir)).toBe('embedded'); + expect(fs.readFileSync(path.join(dir, 'album.jpg'), 'utf-8')).toBe('jpeg'); + }); + + it('uses an explicit remote image only when local artwork is absent', async () => { + fs.writeFileSync(path.join(dir, 'song.ogg'), ''); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + headers: new Headers({ 'content-type': 'image/jpeg' }), + arrayBuffer: async () => Uint8Array.from([4, 5, 6]).buffer, + })), + ); + + expect( + await ingestSongCover(dir, 'https://example.com/permitted-cover.jpg'), + ).toBe('remote'); + expect(fs.readFileSync(path.join(dir, 'album.jpg'), 'utf-8')).toBe('jpeg'); + }); + + it('previews embedded artwork without writing into the source folder', async () => { + metadataHolder.picture = [1, 2, 3]; + fs.writeFileSync(path.join(dir, 'song.mp3'), ''); + + expect(await previewSongCover(dir)).toEqual({ + dataUrl: 'data:image/jpeg;base64,cHJldmlldw==', + source: 'embedded', + }); + expect(fs.existsSync(path.join(dir, 'album.jpg'))).toBe(false); + }); +}); diff --git a/src/main/songCover.ts b/src/main/songCover.ts new file mode 100644 index 00000000..35ed7c7a --- /dev/null +++ b/src/main/songCover.ts @@ -0,0 +1,144 @@ +import fs from 'fs'; +import path from 'path'; +import { nativeImage } from 'electron'; +import { parseFile } from 'music-metadata'; + +export type SongCoverSource = 'existing' | 'embedded' | 'remote' | 'none'; + +const COVER_EXTENSIONS = ['png', 'jpg', 'jpeg']; +const AUDIO_EXTENSIONS = new Set(['.mp3', '.ogg', '.opus']); +const MAX_REMOTE_IMAGE_BYTES = 10_000_000; + +function existingCoverPath(dir: string): string | undefined { + return COVER_EXTENSIONS.map((extension) => + path.join(dir, `album.${extension}`), + ).find((file) => fs.existsSync(file)); +} + +function imageDataUrl(data: Uint8Array): string | undefined { + const image = nativeImage.createFromBuffer(Buffer.from(data)); + + return image.isEmpty() ? undefined : image.toDataURL(); +} + +function jpegData(data: Uint8Array): Buffer { + const image = nativeImage.createFromBuffer(Buffer.from(data)); + + if (image.isEmpty()) { + throw new Error('Artwork is not a supported image'); + } + + return image.toJPEG(90); +} + +function audioFiles(dir: string): string[] { + return fs + .readdirSync(dir) + .filter((file) => AUDIO_EXTENSIONS.has(path.extname(file).toLowerCase())) + .sort((a, b) => { + const aSong = path.parse(a).name === 'song' ? 0 : 1; + const bSong = path.parse(b).name === 'song' ? 0 : 1; + + return aSong - bSong || a.localeCompare(b); + }) + .map((file) => path.join(dir, file)); +} + +async function embeddedArtwork(dir: string): Promise { + for (const file of audioFiles(dir)) { + try { + const metadata = await parseFile(file, { duration: false }); + const picture = metadata.common.picture?.[0]; + + if (picture?.data?.length) { + return picture.data; + } + } catch { + continue; + } + } + + return undefined; +} + +async function remoteArtwork(url: string): Promise { + const parsed = new URL(url); + + if (parsed.protocol !== 'https:') { + throw new Error('Artwork URL must use HTTPS'); + } + + const response = await fetch(parsed); + + if (!response.ok) { + throw new Error(`Artwork download failed: ${response.status}`); + } + + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.toLowerCase().startsWith('image/')) { + throw new Error('Artwork URL did not return an image'); + } + + const contentLength = Number(response.headers.get('content-length') ?? 0); + + if (contentLength > MAX_REMOTE_IMAGE_BYTES) { + throw new Error('Artwork image is larger than 10 MB'); + } + + const data = new Uint8Array(await response.arrayBuffer()); + + if (data.byteLength > MAX_REMOTE_IMAGE_BYTES) { + throw new Error('Artwork image is larger than 10 MB'); + } + + return data; +} + +export async function previewSongCover( + dir: string, +): Promise<{ dataUrl?: string; source: SongCoverSource }> { + const existing = existingCoverPath(dir); + + if (existing) { + return { + dataUrl: imageDataUrl(fs.readFileSync(existing)), + source: 'existing', + }; + } + + const embedded = await embeddedArtwork(dir); + + if (embedded) { + return { dataUrl: imageDataUrl(embedded), source: 'embedded' }; + } + + return { source: 'none' }; +} + +export async function ingestSongCover( + dir: string, + artworkUrl?: string, +): Promise { + if (existingCoverPath(dir)) { + return 'existing'; + } + + const embedded = await embeddedArtwork(dir); + + if (embedded) { + fs.writeFileSync(path.join(dir, 'album.jpg'), jpegData(embedded)); + + return 'embedded'; + } + + if (artworkUrl?.trim()) { + const remote = await remoteArtwork(artworkUrl.trim()); + + fs.writeFileSync(path.join(dir, 'album.jpg'), jpegData(remote)); + + return 'remote'; + } + + return 'none'; +} diff --git a/src/main/util.test.ts b/src/main/util.test.ts index 6d96cc64..ffe4f03d 100644 --- a/src/main/util.test.ts +++ b/src/main/util.test.ts @@ -277,6 +277,45 @@ describe('toSong', () => { expect(song.drumDifficulties).toEqual(['hard', 'expert']); expect(song.scoreData).toEqual(scoreData); }); + + it('keeps auto-chart provenance separate from a human charter', () => { + const song = toSong( + stored({ + auto_chart: 'True', + auto_chart_tool: 'STRUM (OCTAVE AI auto-charter)', + charter: 'Jane Doe', + }), + ); + + expect(song.charter).toBe('Jane Doe'); + expect(song.autoChartTool).toBe('STRUM (OCTAVE AI auto-charter)'); + }); + + it('hides a duplicated AI engine from the human charter field', () => { + const song = toSong( + stored({ + auto_chart: 'True', + auto_chart_tool: 'STRUM (OCTAVE AI auto-charter)', + charter: 'STRUM', + }), + ); + + expect(song.charter).toBe(''); + expect(song.autoChartTool).toBe('STRUM (OCTAVE AI auto-charter)'); + }); + + it('hides a parenthetical AI label from the human charter field', () => { + const song = toSong( + stored({ + auto_chart: 'True', + auto_chart_tool: 'STRUM (OCTAVE AI auto-charter)', + charter: 'STRUM (AI auto-charted)', + }), + ); + + expect(song.charter).toBe(''); + expect(song.autoChartTool).toBe('STRUM (OCTAVE AI auto-charter)'); + }); }); describe('chartGlobPattern', () => { diff --git a/src/main/util.ts b/src/main/util.ts index 69e7dbb2..b623ea09 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -26,6 +26,8 @@ function readSongIdFile(dir: string): string | undefined { export function toSong(stored: SongData): Song { const rating = parseInt(stored.diff_drums ?? '', 10); + const autoChartTool = stored.auto_chart_tool?.trim(); + const charter = stored.charter?.trim() ?? ''; return { id: stored.id, @@ -34,7 +36,8 @@ export function toSong(stored: SongData): Song { name: stored.name ?? '', artist: stored.artist ?? '', album: stored.album ?? '', - charter: stored.charter ?? '', + charter: hasDuplicatedAutoCharter(stored) ? '' : charter, + autoChartTool: autoChartTool || undefined, genre: stored.genre ?? '', year: stored.year ?? '', fiveLaneDrums: stored.five_lane_drums === 'True', @@ -50,6 +53,21 @@ export function toSong(stored: SongData): Song { }; } +export function hasDuplicatedAutoCharter( + stored: Pick, +): boolean { + const autoChartToolName = stored.auto_chart_tool?.split('(')[0].trim() ?? ''; + const charterName = stored.charter?.split('(')[0].trim() ?? ''; + + return ( + Boolean(autoChartToolName) && + stored.auto_chart?.toLowerCase() === 'true' && + charterName.localeCompare(autoChartToolName, undefined, { + sensitivity: 'accent', + }) === 0 + ); +} + function readDrumDifficulties( dir: string, format: 'mid' | 'chart', diff --git a/src/preload/index.ts b/src/preload/index.ts index e6da6a33..99a52ab0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,8 @@ export type Channels = | 'resume-sleep' | 'check-dev' | 'download-song' + | 'select-import-song' + | 'import-song' | 'check-stem-tools' | 'check-stem-tools-update' | 'download-stem-tools' diff --git a/src/types.ts b/src/types.ts index 36e558dc..de7d7586 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ export interface SongData { album: string; album_track: string; artist: string; + auto_chart: string; + auto_chart_tool: string; banner_link_a: string; banner_link_b: string; charter: string; @@ -67,6 +69,7 @@ export interface Song { artist: string; album: string; charter: string; + autoChartTool?: string; genre: string; year: string; fiveLaneDrums: boolean; @@ -229,6 +232,37 @@ export interface IpcScanProgressResponse { total: number; } +export interface IpcImportSongPreview { + sourceDir: string; + name: string; + artist: string; + album: string; + charter: string; + autoChartTool?: string; + chartFormat: 'mid' | 'chart'; + audioCount: number; + drumDifficulties: Difficulty[]; + albumCoverDataUrl?: string; + coverSource: 'existing' | 'embedded' | 'remote' | 'none'; +} + +export interface IpcSelectImportSongResponse { + preview?: IpcImportSongPreview; + cancelled?: boolean; + error?: string; +} + +export interface IpcImportSongRequest { + sourceDir: string; + artworkUrl?: string; +} + +export interface IpcImportSongResponse { + success: boolean; + song?: Song; + error?: string; +} + export interface StorageSchema { songs: { [key: string]: SongData; diff --git a/yarn.lock b/yarn.lock index f7efe84e..be056657 100644 --- a/yarn.lock +++ b/yarn.lock @@ -373,6 +373,13 @@ __metadata: languageName: node linkType: hard +"@borewit/text-codec@npm:^0.2.1, @borewit/text-codec@npm:^0.2.2": + version: 0.2.2 + resolution: "@borewit/text-codec@npm:0.2.2" + checksum: 10c0/2d3fb132bc6a132914a8fbf8e9ff2fa1ead210ecc395b28bb7355bd7719548a5e351ffe39f21c3bee8048f6cabd99eabd404bb5cc809cad9cba25abed19d271f + languageName: node + linkType: hard + "@bramus/specificity@npm:^2.4.2": version: 2.4.2 resolution: "@bramus/specificity@npm:2.4.2" @@ -3314,6 +3321,23 @@ __metadata: languageName: node linkType: hard +"@tokenizer/inflate@npm:^0.4.1": + version: 0.4.1 + resolution: "@tokenizer/inflate@npm:0.4.1" + dependencies: + debug: "npm:^4.4.3" + token-types: "npm:^6.1.1" + checksum: 10c0/9817516efe21d1ce3bdfb80a1f94efc8981064ce3873448ba79f4d81d96c0694c484c289bd042d346ae5536cf77f5aa9a367d39c3df700eb610761b7c306b4de + languageName: node + linkType: hard + +"@tokenizer/token@npm:^0.3.0": + version: 0.3.0 + resolution: "@tokenizer/token@npm:0.3.0" + checksum: 10c0/7ab9a822d4b5ff3f5bca7f7d14d46bdd8432528e028db4a52be7fbf90c7f495cc1af1324691dda2813c6af8dc4b8eb29de3107d4508165f9aa5b53e7d501f155 + languageName: node + linkType: hard + "@tsconfig/node10@npm:^1.0.7": version: 1.0.9 resolution: "@tsconfig/node10@npm:1.0.9" @@ -5400,6 +5424,13 @@ __metadata: languageName: node linkType: hard +"content-type@npm:^2.0.0": + version: 2.0.0 + resolution: "content-type@npm:2.0.0" + checksum: 10c0/491539fff707d7594b0ca4fabcc084bef2a31ffa754ff0a4f80c4377e3963cff0394317f9271c24087596c97fa675bc123d61fa34ffe65b4904e7d3d3098de72 + languageName: node + linkType: hard + "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -7488,6 +7519,18 @@ __metadata: languageName: node linkType: hard +"file-type@npm:^21.3.4": + version: 21.3.4 + resolution: "file-type@npm:21.3.4" + dependencies: + "@tokenizer/inflate": "npm:^0.4.1" + strtok3: "npm:^10.3.4" + token-types: "npm:^6.1.1" + uint8array-extras: "npm:^1.4.0" + checksum: 10c0/6f15e7538c5d73f9308d2e897365d253a6647a6751bb1b0d85c78aebc02b8976afb7c6c9b3759687a064b1b3d60246e5504746b8f11e38b0d5a1b339087e00d2 + languageName: node + linkType: hard + "filelist@npm:^1.0.4": version: 1.0.4 resolution: "filelist@npm:1.0.4" @@ -8192,6 +8235,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "ignore@npm:^5.2.0, ignore@npm:^5.2.4": version: 5.2.4 resolution: "ignore@npm:5.2.4" @@ -9495,6 +9545,13 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:^2.0.0": + version: 2.0.0 + resolution: "media-typer@npm:2.0.0" + checksum: 10c0/8b404e5e16dff1d83a211b23811425be7b33b32fc8ae0fdae757572ba54a05e7e3f6074af283a29d5f2b55920c4c321d54d7fa36703cd460f392e98c68cf09ff + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -9684,6 +9741,24 @@ __metadata: languageName: node linkType: hard +"music-metadata@npm:^11.14.0": + version: 11.14.0 + resolution: "music-metadata@npm:11.14.0" + dependencies: + "@borewit/text-codec": "npm:^0.2.2" + "@tokenizer/token": "npm:^0.3.0" + content-type: "npm:^2.0.0" + debug: "npm:^4.4.3" + file-type: "npm:^21.3.4" + media-typer: "npm:^2.0.0" + strtok3: "npm:^10.3.5" + token-types: "npm:^6.1.2" + uint8array-extras: "npm:^1.5.0" + win-guid: "npm:^0.2.1" + checksum: 10c0/f86d3d3a0d9561bd4a771bff17c7b3fa9f4e5ecbdb49b5df8726ce4a90bab2d32e3d357c59eb4309aba3abc79abad2e93549601e8eaecc16691b6d7d5fd87e51 + languageName: node + linkType: hard + "nanoid@npm:^3.3.16": version: 3.3.16 resolution: "nanoid@npm:3.3.16" @@ -11642,6 +11717,7 @@ __metadata: identity-obj-proxy: "npm:^3.0.0" ini: "npm:^4.1.1" jsdom: "npm:^29.1.1" + music-metadata: "npm:^11.14.0" prettier: "npm:^3.0.3" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" @@ -12016,6 +12092,15 @@ __metadata: languageName: node linkType: hard +"strtok3@npm:^10.3.4, strtok3@npm:^10.3.5": + version: 10.3.5 + resolution: "strtok3@npm:10.3.5" + dependencies: + "@tokenizer/token": "npm:^0.3.0" + checksum: 10c0/8d2477b239054c9f1f5b14a65d531147ca158ab9887fdc2d0938e77b7ec8891fb683b58254c7643afd5d98a421a59207534d491762b111f58c795071ecbe9fd1 + languageName: node + linkType: hard + "stylis@npm:^4.3.4": version: 4.4.0 resolution: "stylis@npm:4.4.0" @@ -12250,6 +12335,17 @@ __metadata: languageName: node linkType: hard +"token-types@npm:^6.1.1, token-types@npm:^6.1.2": + version: 6.1.2 + resolution: "token-types@npm:6.1.2" + dependencies: + "@borewit/text-codec": "npm:^0.2.1" + "@tokenizer/token": "npm:^0.3.0" + ieee754: "npm:^1.2.1" + checksum: 10c0/8786e28e3cb65b9e890bc3c38def98e6dfe4565538237f8c0e47dbe549ed8f5f00de8dc464717868308abb4729f1958f78f69e1c4c3deebbb685729113a6fee8 + languageName: node + linkType: hard + "tough-cookie@npm:^6.0.1": version: 6.0.1 resolution: "tough-cookie@npm:6.0.1" @@ -12550,6 +12646,13 @@ __metadata: languageName: node linkType: hard +"uint8array-extras@npm:^1.4.0, uint8array-extras@npm:^1.5.0": + version: 1.5.0 + resolution: "uint8array-extras@npm:1.5.0" + checksum: 10c0/0e74641ac7dadb02eadefc1ccdadba6010e007757bda824960de3c72bbe2b04e6d3af75648441f412148c4103261d54fcb60be45a2863beb76643a55fddba3bd + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" @@ -13217,6 +13320,13 @@ __metadata: languageName: node linkType: hard +"win-guid@npm:^0.2.1": + version: 0.2.1 + resolution: "win-guid@npm:0.2.1" + checksum: 10c0/4ef31b8553f9f6c775f71f8cda399b6089444657c8dec9cf3ffb4c4f074fb6a11806d7e7bcc4a1fa92015bcb54f1085ae860e9b9a9dca83129bdb3cd90f8c54a + languageName: node + linkType: hard + "workerpool@npm:^6.4.0": version: 6.5.1 resolution: "workerpool@npm:6.5.1" From c4ecaf54db7aef2e67b161f6c4f0dc9f0ef9f40f Mon Sep 17 00:00:00 2001 From: Konstantin Baltsat Date: Mon, 27 Jul 2026 11:03:31 +0800 Subject: [PATCH 2/2] feat: add import review and metadata truth UI Show prepared-song validation before import, separate auto-chart provenance from human authorship, explain score stars, expand normalized local search, and rank exact online matches ahead of fuzzy results. User-Request: make SightKick import, provenance, score stars, and search understandable | codex:019fa139-029e-7613-9be1-6a8ef299c61f --- e2e/smoke.e2e.ts | 50 +++++ e2e/support.ts | 76 ++++--- .../components/SongImport/SongImport.tsx | 202 ++++++++++++++++++ src/renderer/components/SongImport/index.ts | 1 + .../SongListItem/SongListItem.stories.tsx | 14 ++ .../components/SongListItem/SongListItem.tsx | 50 ++++- .../components/StemTools/StemTools.tsx | 4 + src/renderer/hooks/useSongFilter.ts | 19 +- src/renderer/songSearch.test.ts | 79 +++++++ src/renderer/songSearch.ts | 104 +++++++++ .../views/SongListView/SongListView.test.tsx | 166 ++++++++++++++ .../views/SongListView/SongListView.tsx | 75 +++++-- 12 files changed, 777 insertions(+), 63 deletions(-) create mode 100644 src/renderer/components/SongImport/SongImport.tsx create mode 100644 src/renderer/components/SongImport/index.ts create mode 100644 src/renderer/songSearch.test.ts create mode 100644 src/renderer/songSearch.ts diff --git a/e2e/smoke.e2e.ts b/e2e/smoke.e2e.ts index 74856d13..4bffc084 100644 --- a/e2e/smoke.e2e.ts +++ b/e2e/smoke.e2e.ts @@ -1,4 +1,5 @@ import path from 'path'; +import { existsSync } from 'fs'; import { test, expect, Page } from '@playwright/test'; import { launchApp, Harness } from './support'; import { toAssetUrl } from '../src/main/util'; @@ -29,6 +30,55 @@ test.describe('first run', () => { }); test.describe('seeded library', () => { + test('previews and imports a prepared local auto-chart', async () => { + harness = await launchApp({ seedLibrary: true }); + await harness.app.evaluate(({ dialog }, importDir) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [importDir], + }); + }, harness.importDir); + page = await harness.app.firstWindow(); + + await page.getByRole('button', { name: 'Import song' }).click(); + await expect(page.getByText('Review song import')).toBeVisible(); + await expect(page.getByText('Auto-charted with STRUM')).toBeVisible(); + await expect( + page.getByText('Existing album artwork will be preserved.'), + ).toBeVisible(); + + if (process.env.SIGHTKICK_IMPORT_PREVIEW_PROOF) { + await page.screenshot({ + path: process.env.SIGHTKICK_IMPORT_PREVIEW_PROOF, + }); + } + + await page.getByRole('button', { name: 'Add to library' }).click(); + + const row = page.getByTestId(/song-item-/).filter({ hasText: 'Raging' }); + + await expect(row).toBeVisible(); + await expect(row.getByText('Auto-charted with STRUM')).toBeVisible(); + await expect(row.getByText('play once to earn stars')).toBeVisible(); + + await page.getByTestId('song-search').fill('STRUM'); + await expect(row).toBeVisible(); + + const importedDir = path.join( + harness.libraryDir, + 'Kygo feat. Kodaline - Raging', + ); + + expect(existsSync(path.join(importedDir, 'album.png'))).toBe(true); + expect(existsSync(path.join(importedDir, '.sightkick'))).toBe(true); + + if (process.env.SIGHTKICK_IMPORT_AFTER_PROOF) { + await page.screenshot({ + path: process.env.SIGHTKICK_IMPORT_AFTER_PROOF, + }); + } + }); + test('scans the folder, lists the song, and renders real sheet music', async () => { harness = await launchApp({ seedLibrary: true }); page = await harness.app.firstWindow(); diff --git a/e2e/support.ts b/e2e/support.ts index 41aa044c..926efd56 100644 --- a/e2e/support.ts +++ b/e2e/support.ts @@ -7,6 +7,7 @@ const MAIN_ENTRY = path.join(__dirname, '..', 'out', 'main', 'index.js'); export interface Harness { app: ElectronApplication; + importDir: string; libraryDir: string; } @@ -14,6 +15,30 @@ const ALBUM_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64', ); +const EXPERT_DRUM_CHART = [ + '[Song]', + '{', + ' Resolution = 480', + '}', + '[SyncTrack]', + '{', + ' 0 = TS 4', + ' 0 = B 120000', + '}', + '[ExpertDrums]', + '{', + ' 0 = N 0 0', + ' 480 = N 1 0', + ' 960 = N 0 0', + ' 1440 = N 1 0', + ' 1920 = N 0 0', + ' 2400 = N 2 0', + ' 2400 = N 66 0', + ' 2880 = N 0 0', + ' 3360 = N 1 0', + '}', + '', +].join('\n'); function writeFixtureLibrary(): string { const libraryDir = mkdtempSync(path.join(tmpdir(), 'sightkick-library-')); @@ -37,35 +62,37 @@ function writeFixtureLibrary(): string { ].join('\n'), ); + writeFileSync(path.join(songDir, 'notes.chart'), EXPERT_DRUM_CHART); + + return libraryDir; +} + +function writeImportFixture(): string { + const root = mkdtempSync(path.join(tmpdir(), 'sightkick-import-')); + const songDir = path.join(root, 'prepared-song'); + + mkdirSync(songDir); + writeFileSync(path.join(songDir, 'album.png'), ALBUM_PNG); + writeFileSync(path.join(songDir, 'notes.chart'), EXPERT_DRUM_CHART); + writeFileSync(path.join(songDir, 'song.mp3'), 'test audio'); writeFileSync( - path.join(songDir, 'notes.chart'), + path.join(songDir, 'song.ini'), [ - '[Song]', - '{', - ' Resolution = 480', - '}', - '[SyncTrack]', - '{', - ' 0 = TS 4', - ' 0 = B 120000', - '}', - '[ExpertDrums]', - '{', - ' 0 = N 0 0', - ' 480 = N 1 0', - ' 960 = N 0 0', - ' 1440 = N 1 0', - ' 1920 = N 0 0', - ' 2400 = N 2 0', - ' 2400 = N 66 0', - ' 2880 = N 0 0', - ' 3360 = N 1 0', - '}', + '[song]', + 'name = Raging', + 'artist = Kygo feat. Kodaline', + 'album = Cloud Nine', + 'auto_chart = True', + 'auto_chart_tool = STRUM (OCTAVE AI auto-charter)', + 'charter = STRUM', + 'pro_drums = True', + 'five_lane_drums = False', + 'diff_drums = 2', '', ].join('\n'), ); - return libraryDir; + return songDir; } function seedUserData(seed: Record): string { @@ -83,6 +110,7 @@ export async function launchApp( options: { seedLibrary?: boolean } = {}, ): Promise { const libraryDir = writeFixtureLibrary(); + const importDir = writeImportFixture(); const userDataDir = seedUserData( options.seedLibrary ? { lastOpenedPath: libraryDir } : {}, ); @@ -95,5 +123,5 @@ export async function launchApp( }, }); - return { app, libraryDir }; + return { app, importDir, libraryDir }; } diff --git a/src/renderer/components/SongImport/SongImport.tsx b/src/renderer/components/SongImport/SongImport.tsx new file mode 100644 index 00000000..60dc9206 --- /dev/null +++ b/src/renderer/components/SongImport/SongImport.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from 'react'; +import { faFileImport } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { App, Button, Input, Modal, Tag, Tooltip } from 'antd'; +import appIcon from '../../../../assets/icon.png'; +import { + IpcImportSongPreview, + IpcImportSongResponse, + IpcSelectImportSongResponse, + Song, +} from '../../../types'; + +interface Props { + disabled: boolean; + onImported: (song: Song) => void; +} + +function autoChartToolName(value?: string): string | undefined { + return value?.split('(')[0].trim() || undefined; +} + +function coverMessage(preview: IpcImportSongPreview): string { + switch (preview.coverSource) { + case 'existing': + return 'Existing album artwork will be preserved.'; + + case 'embedded': + return 'Embedded artwork found. It will be cached as album.jpg.'; + + default: + return 'No local cover found. Add an allowed image URL below if you have one.'; + } +} + +export function SongImport({ disabled, onImported }: Props) { + const { notification } = App.useApp(); + const [selecting, setSelecting] = useState(false); + const [importing, setImporting] = useState(false); + const [preview, setPreview] = useState(); + const [artworkUrl, setArtworkUrl] = useState(''); + + useEffect(() => { + const stopSelect = + window.electron.ipcRenderer.on( + 'select-import-song', + (response) => { + setSelecting(false); + + if (response.error) { + notification.error({ + title: "Couldn't import this folder", + description: response.error, + placement: 'bottomRight', + }); + + return; + } + + if (response.preview) { + setArtworkUrl(''); + setPreview(response.preview); + } + }, + ); + const stopImport = window.electron.ipcRenderer.on( + 'import-song', + (response) => { + setImporting(false); + + if (!response.success || !response.song) { + notification.error({ + title: 'Import failed', + description: response.error, + placement: 'bottomRight', + }); + + return; + } + + setPreview(undefined); + setArtworkUrl(''); + onImported(response.song); + notification.success({ + title: `"${response.song.name}" added to your library`, + placement: 'bottomRight', + }); + }, + ); + + return () => { + stopSelect(); + stopImport(); + }; + }, [notification, onImported]); + + const selectSong = () => { + setSelecting(true); + window.electron.ipcRenderer.sendMessage('select-import-song'); + }; + const confirmImport = () => { + if (!preview) { + return; + } + + setImporting(true); + window.electron.ipcRenderer.sendMessage('import-song', { + sourceDir: preview.sourceDir, + ...(artworkUrl.trim() ? { artworkUrl: artworkUrl.trim() } : {}), + }); + }; + const toolName = autoChartToolName(preview?.autoChartTool); + + return ( + <> + + + + + { + if (!importing) { + setPreview(undefined); + setArtworkUrl(''); + } + }} + > + {preview && ( +
+
+ {preview.albumCoverDataUrl +
+
+ {preview.name} +
+
{preview.artist}
+ {preview.album && ( +
{preview.album}
+ )} + {preview.charter && ( +
+ Chart by {preview.charter} +
+ )} + {toolName && ( + Auto-charted with {toolName} + )} +
+
+ +
+ {preview.chartFormat.toUpperCase()} chart · {preview.audioCount}{' '} + audio file{preview.audioCount === 1 ? '' : 's'} ·{' '} + {preview.drumDifficulties.join(', ')} +
+ +
+ {coverMessage(preview)} +
+ + setArtworkUrl(event.target.value)} + placeholder="Allowed direct cover image URL (optional fallback)" + /> + +
+ Only use artwork you own or are allowed to cache. This import + accepts prepared local charts and does not bypass streaming, + paywall or DRM restrictions. +
+
+ )} +
+ + ); +} diff --git a/src/renderer/components/SongImport/index.ts b/src/renderer/components/SongImport/index.ts new file mode 100644 index 00000000..ead77753 --- /dev/null +++ b/src/renderer/components/SongImport/index.ts @@ -0,0 +1 @@ +export * from './SongImport'; diff --git a/src/renderer/components/SongListItem/SongListItem.stories.tsx b/src/renderer/components/SongListItem/SongListItem.stories.tsx index 5235f61d..fa38bc8b 100644 --- a/src/renderer/components/SongListItem/SongListItem.stories.tsx +++ b/src/renderer/components/SongListItem/SongListItem.stories.tsx @@ -56,6 +56,20 @@ export const Liked: Story = { args: { songData: { ...songData, liked: true } }, }; +export const Unplayed: Story = { + args: { songData: { ...songData, scoreData: undefined } }, +}; + +export const AutoCharted: Story = { + args: { + songData: { + ...songData, + charter: '', + autoChartTool: 'STRUM (OCTAVE AI auto-charter)', + }, + }, +}; + export const Focused: Story = { args: { focused: true } }; export const Online: Story = { args: { songData: onlineSongData } }; diff --git a/src/renderer/components/SongListItem/SongListItem.tsx b/src/renderer/components/SongListItem/SongListItem.tsx index 7862d162..7a7fa145 100644 --- a/src/renderer/components/SongListItem/SongListItem.tsx +++ b/src/renderer/components/SongListItem/SongListItem.tsx @@ -9,7 +9,7 @@ import { faHeart } from '@fortawesome/free-regular-svg-icons'; import appIcon from '../../../../assets/icon.png'; import { Song } from '../../../types'; import { cn } from '../../cn'; -import { Button, Tooltip } from 'antd'; +import { Button, Tag, Tooltip } from 'antd'; import { useMemo } from 'react'; import { SongMenu } from '../SongMenu'; import { Stars } from '../Stars'; @@ -48,6 +48,9 @@ export function SongListItem({ }: SongListItemProps) { const local = 'source' in songData ? undefined : songData; const { albumCover, id, name, artist, charter, drumDifficulty } = songData; + const autoChartTool = + 'autoChartTool' in songData ? songData.autoChartTool : undefined; + const autoChartToolName = autoChartTool?.split('(')[0].trim(); const score = useMemo(() => { const result = local?.scoreData?.[difficulty]; @@ -152,6 +155,7 @@ export function SongListItem({
{albumCover { e.currentTarget.src = appIcon; }} @@ -176,16 +180,48 @@ export function SongListItem({
)} + {autoChartToolName && ( + Auto-charted with {autoChartToolName} + )} + {local && (
{difficulty}
- + +
+ {score ? ( + + ) : ( + 'play once to earn stars' + )} +
+
)} diff --git a/src/renderer/components/StemTools/StemTools.tsx b/src/renderer/components/StemTools/StemTools.tsx index b38b4771..e32fef20 100644 --- a/src/renderer/components/StemTools/StemTools.tsx +++ b/src/renderer/components/StemTools/StemTools.tsx @@ -140,6 +140,10 @@ export function StemTools({ onClick={onDeleteStemTools} > +
+ Open ⋮ on a local song with one mixed audio file, then choose Split + stems. +
{updateAvailable && (