diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5fb0e3e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Build Release + +on: + workflow_dispatch: + inputs: + artifact_label: + description: Optional label appended to the artifact name + required: false + default: '' + +permissions: + contents: read + +jobs: + build: + name: Build on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + dist_cmd: dist:mac + artifact_name: prism-macos + - os: windows-latest + dist_cmd: dist:win + artifact_name: prism-windows + + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + ELECTRON_CACHE: ${{ runner.temp }}/electron-cache + ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder-cache + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + - name: Cache Electron + uses: actions/cache@v4 + with: + path: | + ${{ env.ELECTRON_CACHE }} + ${{ env.ELECTRON_BUILDER_CACHE }} + key: ${{ runner.os }}-electron-cache-${{ hashFiles('package-lock.json') }} + + - name: Install dependencies + run: npm ci + + - name: Build native module + run: npm run rebuild:native + + - name: Verify native module exists + shell: bash + run: | + if [ ! -f native/build/Release/visualizer_dsp.node ]; then + echo "ERROR: native DSP module was not built" + exit 1 + fi + echo "Native module built successfully:" + ls -lh native/build/Release/visualizer_dsp.node + + - name: Build distributable + run: npm run ${{ matrix.dist_cmd }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }}${{ inputs.artifact_label && format('-{0}', inputs.artifact_label) || '' }} + path: | + dist/*.dmg + dist/*.zip + dist/*.exe + if-no-files-found: error + retention-days: 30 diff --git a/package.json b/package.json index 895e895..77ace5d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit", "test:audio-router": "node scripts/run-audio-router-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs", + "test:themes": "node scripts/run-theme-library-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "build:native": "cd native && node-gyp rebuild", "rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"", @@ -64,6 +65,12 @@ "name": "Prism Profile", "description": "Prism shareable profile", "role": "Editor" + }, + { + "ext": "iro", + "name": "Prism Theme", + "description": "Prism shareable theme", + "role": "Editor" } ], "files": [ diff --git a/scripts/run-theme-library-tests.mjs b/scripts/run-theme-library-tests.mjs new file mode 100644 index 0000000..40bfbf9 --- /dev/null +++ b/scripts/run-theme-library-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-theme-library-tests-')) +const bundledTestPath = join(tempDir, 'theme-library.test.mjs') +const entryPoint = join(rootDir, 'test', 'theme-library.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index 5b140ca..489f01f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,8 +12,13 @@ import type { import type { ProfileMenuRequest } from '../types/profileMenu' import type { LegacyProfileMigrationPayload, Profile, ProfileLibrarySnapshot } from '../types/profile' import { SCOPE_KINDS, type ScopeKind } from '../types/scope' +import type { + LegacyThemeMigrationPayload, + ThemeLibrarySnapshot, +} from '../types/theme' import { normalizeProfile } from '../shared/profileState' import { FileBackedProfileLibrary } from './profileLibrary' +import { FileBackedThemeLibrary } from './themeLibrary' let mainWindow: BrowserWindow | null = null let moveInterval: ReturnType | null = null @@ -27,8 +32,10 @@ const popoutBoundsTimers = new Map>() const windowSettingsHeights = new Map() const windowSettingsBottomAnchors = new Map() const pendingProfileOpenPaths: string[] = [] +const pendingThemeOpenPaths: string[] = [] let profileLibrary: FileBackedProfileLibrary | null = null +let themeLibrary: FileBackedThemeLibrary | null = null const WINDOW_DEFAULTS = { width: 900, @@ -49,12 +56,24 @@ function getProfileLibrary(): FileBackedProfileLibrary { profileLibrary = new FileBackedProfileLibrary( join(app.getPath('documents'), 'Prism Profiles'), join(app.getPath('userData'), 'profile-state.json'), + async () => getThemeLibrary().getActiveThemeId(), ) } return profileLibrary } +function getThemeLibrary(): FileBackedThemeLibrary { + if (!themeLibrary) { + themeLibrary = new FileBackedThemeLibrary( + join(app.getPath('documents'), 'Prism Themes'), + join(app.getPath('userData'), 'theme-state.json'), + ) + } + + return themeLibrary +} + function queueProfileOpenPath(filePath: string): void { if (extname(filePath).toLowerCase() !== '.prsm') return @@ -70,12 +89,33 @@ function queueProfileOpenPaths(paths: string[]): void { } } +function queueThemeOpenPath(filePath: string): void { + if (extname(filePath).toLowerCase() !== '.iro') return + + const resolvedPath = resolve(filePath) + if (!pendingThemeOpenPaths.includes(resolvedPath)) { + pendingThemeOpenPaths.push(resolvedPath) + } +} + +function queueThemeOpenPaths(paths: string[]): void { + for (const filePath of paths) { + queueThemeOpenPath(filePath) + } +} + function extractProfilePathsFromArgv(argv: string[]): string[] { return argv .filter((value) => extname(value).toLowerCase() === '.prsm') .map((value) => resolve(value)) } +function extractThemePathsFromArgv(argv: string[]): string[] { + return argv + .filter((value) => extname(value).toLowerCase() === '.iro') + .map((value) => resolve(value)) +} + function focusMainWindow(): void { if (!mainWindow) return if (mainWindow.isMinimized()) { @@ -116,6 +156,31 @@ async function processPendingProfileOpenPaths(): Promise { mainWindow.webContents.send('profiles:external-activated', latestSnapshot) } +async function processPendingThemeOpenPaths(): Promise { + if (pendingThemeOpenPaths.length === 0) return + + const paths = [...pendingThemeOpenPaths] + pendingThemeOpenPaths.length = 0 + + let latestSnapshot: ThemeLibrarySnapshot | null = null + + for (const filePath of paths) { + try { + latestSnapshot = await getThemeLibrary().importThemeFromPath(filePath) + } catch (error) { + dialog.showErrorBox( + 'Could Not Open Theme', + getErrorMessage(error, `Prism could not open ${filePath}.`), + ) + } + } + + if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return + + focusMainWindow() + mainWindow.webContents.send('themes:external-activated', latestSnapshot) +} + function scheduleMainWindowBoundsSave(window: BrowserWindow): void { if (!isMainRendererWindow(window)) return @@ -752,6 +817,60 @@ function setupIPC(): void { return getProfileLibrary().migrateLegacyProfiles(payload) }) + ipcMain.handle('themes:get-snapshot', async () => { + return getThemeLibrary().getSnapshot() + }) + + ipcMain.handle('themes:load', async (_event, id: string) => { + return getThemeLibrary().loadTheme(id) + }) + + ipcMain.handle('themes:rename', async (_event, id: string, name: string) => { + return getThemeLibrary().renameTheme(id, name) + }) + + ipcMain.handle('themes:delete', async (_event, id: string) => { + return getThemeLibrary().deleteTheme(id) + }) + + ipcMain.handle('themes:reload', async () => { + return getThemeLibrary().reloadThemes() + }) + + ipcMain.handle('themes:import-dialog', async () => { + const targetWindow = mainWindow ?? BrowserWindow.getFocusedWindow() ?? undefined + const dialogOptions: OpenDialogOptions = { + properties: ['openFile'], + filters: [ + { + name: 'Prism Themes', + extensions: ['iro'], + }, + ], + } + const result = targetWindow + ? await dialog.showOpenDialog(targetWindow, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) + + if (result.canceled || result.filePaths.length === 0) { + return null + } + + return getThemeLibrary().importThemeFromPath(result.filePaths[0]) + }) + + ipcMain.handle('themes:reveal-folder', async () => { + const folderPath = getThemeLibrary().getThemesDirectory() + const openResult = await shell.openPath(folderPath) + if (openResult) { + throw new Error(openResult) + } + }) + + ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => { + return getThemeLibrary().migrateLegacyTheme(payload) + }) + ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => { const request = normalizeProfileMenuRequest(rawRequest) if (!request) return @@ -914,22 +1033,28 @@ if (!hasSingleInstanceLock) { createMainWindow() setupShortcuts() queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv)) + queueThemeOpenPaths(extractThemePathsFromArgv(process.argv)) void processPendingProfileOpenPaths() + void processPendingThemeOpenPaths() }) app.on('open-file', (event, filePath) => { event.preventDefault() queueProfileOpenPath(filePath) + queueThemeOpenPath(filePath) if (app.isReady()) { void processPendingProfileOpenPaths() + void processPendingThemeOpenPaths() } }) app.on('second-instance', (_event, argv) => { queueProfileOpenPaths(extractProfilePathsFromArgv(argv)) + queueThemeOpenPaths(extractThemePathsFromArgv(argv)) if (app.isReady()) { focusMainWindow() void processPendingProfileOpenPaths() + void processPendingThemeOpenPaths() } }) } diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts index 70324a2..4a3aef1 100644 --- a/src/main/profileLibrary.ts +++ b/src/main/profileLibrary.ts @@ -9,8 +9,8 @@ import { PROFILE_FILE_VERSION, type LegacyProfileMigrationPayload, type Profile, + type PrismProfileFile, type ProfileLibrarySnapshot, - type PrismProfileFileV1, type PrismProfileLocalStateV1, } from '../types/profile' import type { ScopeKind } from '../types/scope' @@ -45,6 +45,7 @@ export class FileBackedProfileLibrary { constructor( private readonly profilesDir: string, private readonly localStatePath: string, + private readonly resolveDefaultThemeId?: () => Promise, ) {} getProfilesDirectory(): string { @@ -270,7 +271,9 @@ export class FileBackedProfileLibrary { let entries = await this.readManagedEntries(localState) if (!entries.some((entry) => entry.id === DEFAULT_PROFILE_ID)) { + const defaultThemeId = await this.resolveDefaultThemeId?.() ?? null const defaultProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) + defaultProfile.themeId = defaultThemeId const defaultPath = await this.writeManagedProfile(entries, DEFAULT_PROFILE_ID, defaultProfile) entries = await this.readManagedEntries({ ...localState, @@ -333,7 +336,7 @@ export class FileBackedProfileLibrary { return entries } - private async readProfileFile(filePath: string): Promise { + private async readProfileFile(filePath: string): Promise { let parsed: unknown try { @@ -346,12 +349,12 @@ export class FileBackedProfileLibrary { throw new Error(`Profile file ${basename(filePath)} must contain an object.`) } - const candidate = parsed as Partial + const candidate = parsed as Partial if (candidate.format !== PROFILE_FILE_FORMAT) { throw new Error(`Unsupported profile format in ${basename(filePath)}.`) } - if (candidate.version !== PROFILE_FILE_VERSION) { + if (candidate.version !== 1 && candidate.version !== PROFILE_FILE_VERSION) { throw new Error(`Unsupported profile version in ${basename(filePath)}.`) } diff --git a/src/main/themeLibrary.ts b/src/main/themeLibrary.ts new file mode 100644 index 0000000..74a8130 --- /dev/null +++ b/src/main/themeLibrary.ts @@ -0,0 +1,384 @@ +import { access, mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises' +import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + DEFAULT_THEME_ID, + DEFAULT_THEME_NAME, + LEGACY_THEME_MIGRATION_VERSION, + type LegacyThemeMigrationPayload, + type LegacyThemeMigrationResult, + type PrismTheme, + type PrismThemeLocalStateV1, + type ThemeLibrarySnapshot, +} from '../types/theme' +import { + createBundledThemes, + createDefaultTheme, + createEmptyThemeLocalState, + createMigratedAccentTheme, + createTemplateThemeFile, + getDefaultThemeIdForLocalState, + normalizeLegacyThemePayload, + normalizeThemeLocalState, + parseThemeFileContent, + resolveLegacyThemeToPresetId, + serializeThemeFile, +} from '../shared/themeState' + +const THEME_EXTENSION = '.iro' +const TEMPLATE_THEME_FILE_NAME = '_TEMPLATE.iro' + +interface ManagedThemeEntry { + id: string + path: string + theme: PrismTheme +} + +export class FileBackedThemeLibrary { + constructor( + private readonly themesDir: string, + private readonly localStatePath: string, + ) {} + + getThemesDirectory(): string { + return this.themesDir + } + + async getActiveThemeId(): Promise { + const { entries, localState } = await this.loadLibrary() + return localState.activeThemeId && entries.some((entry) => entry.id === localState.activeThemeId) + ? localState.activeThemeId + : (entries[0]?.id ?? null) + } + + async getSnapshot(): Promise { + const { entries, localState } = await this.loadLibrary() + return this.buildSnapshot(entries, localState) + } + + async loadTheme(id: string): Promise { + const { entries, localState } = await this.loadLibrary() + this.findEntry(entries, id) + localState.activeThemeId = id + await this.writeLocalState(localState) + return this.buildSnapshot(entries, localState) + } + + async importThemeFromPath(sourcePath: string): Promise { + const { entries, localState } = await this.loadLibrary() + const resolvedSourcePath = resolve(sourcePath) + const theme = await this.readThemeFile(resolvedSourcePath) + const existingEntry = entries.find((entry) => entry.id === theme.id) ?? null + const insideManagedDirectory = this.isPathInsideDirectory(resolvedSourcePath, this.themesDir) + const currentPath = existingEntry?.path ?? (insideManagedDirectory ? resolvedSourcePath : undefined) + const targetPath = await this.writeManagedTheme(entries, theme.id, theme, currentPath) + + if (insideManagedDirectory && resolvedSourcePath !== targetPath) { + await this.unlinkIfExists(resolvedSourcePath) + } + + localState.activeThemeId = theme.id + await this.writeLocalState(localState) + return this.getSnapshot() + } + + async renameTheme(id: string, name: string): Promise { + if (id === DEFAULT_THEME_ID) { + throw new Error('The default theme cannot be renamed.') + } + + const { entries, localState } = await this.loadLibrary() + const entry = this.findEntry(entries, id) + const normalized = { + ...entry.theme, + name: name.trim() || entry.theme.name, + } + await this.writeManagedTheme(entries, id, normalized, entry.path) + return this.buildSnapshot(await this.readManagedEntries(), localState) + } + + async deleteTheme(id: string): Promise { + if (id === DEFAULT_THEME_ID) { + throw new Error('The default theme cannot be deleted.') + } + + const { entries, localState } = await this.loadLibrary() + const entry = this.findEntry(entries, id) + await unlink(entry.path) + if (localState.activeThemeId === id) { + localState.activeThemeId = DEFAULT_THEME_ID + } + await this.writeLocalState(localState) + return this.getSnapshot() + } + + async reloadThemes(): Promise { + return this.getSnapshot() + } + + async migrateLegacyTheme(payload: LegacyThemeMigrationPayload): Promise { + const { entries, localState } = await this.loadLibrary() + if (localState.migrationVersion >= LEGACY_THEME_MIGRATION_VERSION) { + return { + didMigrate: false, + snapshot: this.buildSnapshot(entries, localState), + } + } + + const normalizedPayload = normalizeLegacyThemePayload(payload) + let nextActiveThemeId = resolveLegacyThemeToPresetId(normalizedPayload) + let didMigrate = false + + if (normalizedPayload.customAccent) { + const migratedTheme = createMigratedAccentTheme(normalizedPayload.customAccent) + if (migratedTheme) { + await this.writeManagedTheme(entries, migratedTheme.id, migratedTheme) + nextActiveThemeId = migratedTheme.id + didMigrate = true + } + } else if (nextActiveThemeId) { + didMigrate = true + } + + localState.migrationVersion = LEGACY_THEME_MIGRATION_VERSION + localState.activeThemeId = nextActiveThemeId && (await this.themeExists(nextActiveThemeId)) + ? nextActiveThemeId + : getDefaultThemeIdForLocalState() + await this.writeLocalState(localState) + + return { + didMigrate, + snapshot: await this.getSnapshot(), + } + } + + private async loadLibrary(): Promise<{ + entries: ManagedThemeEntry[] + localState: PrismThemeLocalStateV1 + }> { + await mkdir(this.themesDir, { recursive: true }) + let localState = await this.readLocalState() + let entries = await this.readManagedEntries() + + if (entries.length === 0) { + for (const theme of createBundledThemes()) { + await this.writeManagedTheme(entries, theme.id, theme) + } + entries = await this.readManagedEntries() + } + + if (!entries.some((entry) => entry.id === DEFAULT_THEME_ID)) { + await this.writeManagedTheme(entries, DEFAULT_THEME_ID, createDefaultTheme()) + entries = await this.readManagedEntries() + } + + await this.ensureTemplateFile() + + if (!localState.activeThemeId || !entries.some((entry) => entry.id === localState.activeThemeId)) { + localState = { + ...localState, + activeThemeId: entries.find((entry) => entry.id === DEFAULT_THEME_ID)?.id ?? entries[0]?.id ?? null, + } + await this.writeLocalState(localState) + } + + return { + entries: this.sortEntries(entries), + localState, + } + } + + private async ensureTemplateFile(): Promise { + const targetPath = resolve(join(this.themesDir, TEMPLATE_THEME_FILE_NAME)) + if (await this.pathExists(targetPath)) { + return + } + await writeFile(targetPath, createTemplateThemeFile(), 'utf8') + } + + private async readManagedEntries(): Promise { + const dirEntries = await readdir(this.themesDir, { withFileTypes: true }) + const themePaths = dirEntries + .filter((entry) => { + if (!entry.isFile()) return false + if (entry.name.startsWith('_')) return false + return extname(entry.name).toLowerCase() === THEME_EXTENSION + }) + .map((entry) => resolve(join(this.themesDir, entry.name))) + .sort((left, right) => left.localeCompare(right)) + + const entries: ManagedThemeEntry[] = [] + const seenIds = new Set() + + for (const filePath of themePaths) { + try { + const theme = await this.readThemeFile(filePath) + if (seenIds.has(theme.id)) { + console.warn(`Skipping duplicate theme id "${theme.id}" in ${basename(filePath)}.`) + continue + } + seenIds.add(theme.id) + entries.push({ + id: theme.id, + path: filePath, + theme, + }) + } catch (error) { + console.warn(`Skipping invalid theme file at ${filePath}:`, error) + } + } + + return entries + } + + private async readThemeFile(filePath: string): Promise { + const content = await readFile(filePath, 'utf8') + return parseThemeFileContent( + content, + this.buildFallbackThemeId(filePath), + basename(filePath, THEME_EXTENSION), + ) + } + + private async readLocalState(): Promise { + try { + const raw = await readFile(this.localStatePath, 'utf8') + return normalizeThemeLocalState(JSON.parse(raw) as unknown) + } catch { + return createEmptyThemeLocalState() + } + } + + private async writeLocalState(state: PrismThemeLocalStateV1): Promise { + await mkdir(dirname(this.localStatePath), { recursive: true }) + await writeFile(this.localStatePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8') + } + + private async writeManagedTheme( + entries: ManagedThemeEntry[], + id: string, + theme: PrismTheme, + currentPath?: string, + ): Promise { + const nextPath = await this.getManagedThemePath(entries, id, theme.name, currentPath) + const existingPath = currentPath ? resolve(currentPath) : null + + await mkdir(dirname(nextPath), { recursive: true }) + await writeFile(nextPath, serializeThemeFile(theme), 'utf8') + + if (existingPath && existingPath !== nextPath) { + await this.unlinkIfExists(existingPath) + } + + return nextPath + } + + private async getManagedThemePath( + entries: ManagedThemeEntry[], + id: string, + name: string, + currentPath?: string, + ): Promise { + if (id === DEFAULT_THEME_ID) { + const defaultPath = resolve(join(this.themesDir, `${DEFAULT_THEME_NAME}${THEME_EXTENSION}`)) + if (!currentPath || resolve(currentPath) === defaultPath) { + return defaultPath + } + const occupiedByOtherEntry = entries.some((entry) => entry.id !== id && entry.path === defaultPath) + return occupiedByOtherEntry ? resolve(currentPath) : defaultPath + } + + const preferredCurrentPath = currentPath ? resolve(currentPath) : null + const occupiedPaths = new Set(entries.filter((entry) => entry.id !== id).map((entry) => entry.path)) + const baseStem = this.sanitizeFileStem(name) + + let attempt = 0 + while (true) { + const suffix = attempt === 0 ? '' : ` (${attempt + 1})` + const candidatePath = resolve(join(this.themesDir, `${baseStem}${suffix}${THEME_EXTENSION}`)) + if (preferredCurrentPath === candidatePath) { + return candidatePath + } + if (occupiedPaths.has(candidatePath) || await this.pathExists(candidatePath)) { + attempt += 1 + continue + } + return candidatePath + } + } + + private sortEntries(entries: ManagedThemeEntry[]): ManagedThemeEntry[] { + return [...entries].sort((left, right) => { + if (left.id === DEFAULT_THEME_ID) return -1 + if (right.id === DEFAULT_THEME_ID) return 1 + return left.theme.name.localeCompare(right.theme.name) + }) + } + + private buildSnapshot(entries: ManagedThemeEntry[], localState: PrismThemeLocalStateV1): ThemeLibrarySnapshot { + const themes = this.sortEntries(entries).reduce((acc, entry) => { + acc[entry.id] = entry.theme + return acc + }, {} as Record) + + return { + themes, + activeThemeId: localState.activeThemeId && themes[localState.activeThemeId] + ? localState.activeThemeId + : (themes[DEFAULT_THEME_ID] ? DEFAULT_THEME_ID : Object.keys(themes)[0] ?? null), + } + } + + private findEntry(entries: ManagedThemeEntry[], id: string): ManagedThemeEntry { + const entry = entries.find((candidate) => candidate.id === id) + if (!entry) { + throw new Error(`Theme "${id}" was not found.`) + } + return entry + } + + private sanitizeFileStem(name: string): string { + const sanitized = name + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + return sanitized || 'Theme' + } + + private buildFallbackThemeId(filePath: string): string { + const stem = basename(filePath, THEME_EXTENSION) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + return stem ? `theme_${stem}` : `theme_${randomUUID().replace(/-/g, '')}` + } + + private isPathInsideDirectory(candidatePath: string, directoryPath: string): boolean { + const relativePath = relative(resolve(directoryPath), resolve(candidatePath)) + return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) + } + + private async themeExists(id: string): Promise { + const entries = await this.readManagedEntries() + return entries.some((entry) => entry.id === id) + } + + private async pathExists(targetPath: string): Promise { + try { + await access(targetPath) + return true + } catch { + return false + } + } + + private async unlinkIfExists(targetPath: string): Promise { + try { + await unlink(targetPath) + } catch { + // Ignore missing files. + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 851cce2..53645df 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,6 +16,11 @@ import type { ProfileLibrarySnapshot, } from '../types/profile' import type { ScopeKind } from '../types/scope' +import type { + LegacyThemeMigrationPayload, + LegacyThemeMigrationResult, + ThemeLibrarySnapshot, +} from '../types/theme' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI @@ -49,6 +54,14 @@ contextBridge.exposeInMainWorld('electronAPI', { importProfileDialog: () => ipcRenderer.invoke('profiles:import-dialog') as Promise, revealProfilesFolder: () => ipcRenderer.invoke('profiles:reveal-folder') as Promise, migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => ipcRenderer.invoke('profiles:migrate-legacy', payload) as Promise, + getThemeSnapshot: () => ipcRenderer.invoke('themes:get-snapshot') as Promise, + loadTheme: (id: string) => ipcRenderer.invoke('themes:load', id) as Promise, + renameTheme: (id: string, name: string) => ipcRenderer.invoke('themes:rename', id, name) as Promise, + deleteTheme: (id: string) => ipcRenderer.invoke('themes:delete', id) as Promise, + reloadThemes: () => ipcRenderer.invoke('themes:reload') as Promise, + importThemeDialog: () => ipcRenderer.invoke('themes:import-dialog') as Promise, + revealThemesFolder: () => ipcRenderer.invoke('themes:reveal-folder') as Promise, + migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => ipcRenderer.invoke('themes:migrate-legacy', payload) as Promise, expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight), collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight), setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight), @@ -125,6 +138,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('profiles:external-activated', handler) return () => ipcRenderer.removeListener('profiles:external-activated', handler) }, + onExternalThemeActivated: (callback: (snapshot: ThemeLibrarySnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, snapshot: ThemeLibrarySnapshot): void => callback(snapshot) + ipcRenderer.on('themes:external-activated', handler) + return () => ipcRenderer.removeListener('themes:external-activated', handler) + }, onScopePopoutReady: (callback: (kind: ScopeKind) => void) => { const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind): void => callback(kind) ipcRenderer.on('scope-popout:ready', handler) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3f4257c..f888b55 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,6 +6,7 @@ import BottomBar from './components/BottomBar' import ScopePopoutBridge from './components/ScopePopoutBridge' import { useSettingsStore } from './stores/settingsStore' import { useAudioStore } from './stores/audioStore' +import { useThemeStore } from './stores/themeStore' import { SCOPE_KINDS } from '../types/scope' const DEFAULT_SETTINGS_HEIGHT = 400 @@ -20,6 +21,8 @@ export default function App(): JSX.Element { const toggleScope = useSettingsStore((s) => s.toggleScope) const initializeProfiles = useSettingsStore((s) => s.initializeProfiles) const applyExternalProfileSnapshot = useSettingsStore((s) => s.applyExternalProfileSnapshot) + const initializeThemes = useThemeStore((s) => s.initializeThemes) + const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) // Auto-capture on launch useEffect(() => { @@ -30,14 +33,23 @@ export default function App(): JSX.Element { }, []) useEffect(() => { - void initializeProfiles() + void (async () => { + await initializeThemes() + await initializeProfiles() + })() - const unsubscribe = window.electronAPI.onExternalProfileActivated((snapshot) => { + const unsubscribeProfile = window.electronAPI.onExternalProfileActivated((snapshot) => { applyExternalProfileSnapshot(snapshot) }) + const unsubscribeTheme = window.electronAPI.onExternalThemeActivated((snapshot) => { + applyExternalThemeSnapshot(snapshot) + }) - return unsubscribe - }, [applyExternalProfileSnapshot, initializeProfiles]) + return () => { + unsubscribeProfile() + unsubscribeTheme() + } + }, [applyExternalProfileSnapshot, applyExternalThemeSnapshot, initializeProfiles, initializeThemes]) const measuredSettingsHeight = settingsPanelHeight > 0 && bottomBarHeight > 0 ? settingsPanelHeight + bottomBarHeight diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts index b62f119..b50b33e 100644 --- a/src/renderer/audio/AudioRouter.ts +++ b/src/renderer/audio/AudioRouter.ts @@ -90,13 +90,13 @@ interface ScopeLatencyTracker { } type ScopeRingMap = { - spectrum: FixedChunkRing + spectrum: FixedChunkRing oscilloscope: FixedChunkRing vectorscope: FixedChunkRing spectrogram: FixedChunkRing vumeter: FixedChunkRing lufsmeter: FixedChunkRing - waveform: FixedChunkRing + waveform: FixedChunkRing } class FixedChunkRing { @@ -212,13 +212,13 @@ function createScopeLatencyTracker(): ScopeLatencyTracker { export class AudioRouter { private readonly rings: ScopeRingMap = { - spectrum: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrum), + spectrum: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrum), oscilloscope: new FixedChunkRing(SCOPE_RING_CAPACITY.oscilloscope), vectorscope: new FixedChunkRing(SCOPE_RING_CAPACITY.vectorscope), spectrogram: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrogram), vumeter: new FixedChunkRing(SCOPE_RING_CAPACITY.vumeter), lufsmeter: new FixedChunkRing(SCOPE_RING_CAPACITY.lufsmeter), - waveform: new FixedChunkRing(SCOPE_RING_CAPACITY.waveform), + waveform: new FixedChunkRing(SCOPE_RING_CAPACITY.waveform), } private readonly scopeLatency: Record = { @@ -362,11 +362,12 @@ export class AudioRouter { if (len === 0) return const activeDemand = this.getActiveDemand() - const needsMono = Boolean(activeDemand.spectrum || activeDemand.spectrogram) - const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter) - const needsLeft = Boolean(activeDemand.oscilloscope || activeDemand.waveform) + const needsSpectrum = Boolean(activeDemand.spectrum) + const needsMono = Boolean(activeDemand.spectrogram) + const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform) + const needsLeft = Boolean(activeDemand.oscilloscope) - if (!needsMono && !needsStereo && !needsLeft) { + if (!needsSpectrum && !needsMono && !needsStereo && !needsLeft) { this.undemandedChunks += 1 return } @@ -388,8 +389,8 @@ export class AudioRouter { this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence }) } - if (activeDemand.spectrum && mono) { - this.rings.spectrum.push({ samples: mono, capturedAt, sequence }) + if (activeDemand.spectrum) { + this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) } if (activeDemand.spectrogram && mono) { @@ -409,7 +410,7 @@ export class AudioRouter { } if (activeDemand.waveform) { - this.rings.waveform.push({ samples: leftSamples, capturedAt, sequence }) + this.rings.waveform.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) } } @@ -422,7 +423,20 @@ export class AudioRouter { flushPendingSpectrumSamples(): Float32Array[] { const records = this.rings.spectrum.drain() this.recordScopeDrain('spectrum', records) - return records.map((record) => record.samples) + return records.map((record) => { + const length = Math.min(record.left.length, record.right.length) + const mono = new Float32Array(length) + for (let index = 0; index < length; index += 1) { + mono[index] = (record.left[index] + record.right[index]) * 0.5 + } + return mono + }) + } + + flushPendingSpectrumStereoSamples(): { left: Float32Array; right: Float32Array }[] { + const records = this.rings.spectrum.drain() + this.recordScopeDrain('spectrum', records) + return records.map((record) => ({ left: record.left, right: record.right })) } flushPendingSpectrogramSamples(): Float32Array[] { @@ -452,7 +466,13 @@ export class AudioRouter { flushPendingWaveformSamples(): Float32Array[] { const records = this.rings.waveform.drain() this.recordScopeDrain('waveform', records) - return records.map((record) => record.samples) + return records.map((record) => record.left) + } + + flushPendingWaveformStereoSamples(): { left: Float32Array; right: Float32Array }[] { + const records = this.rings.waveform.drain() + this.recordScopeDrain('waveform', records) + return records.map((record) => ({ left: record.left, right: record.right })) } getDiagnosticsSnapshot(): AudioRouterDiagnostics { diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index bc8a95c..2e4cedd 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX } from import { useAudioStore } from '../stores/audioStore' import { usePerformanceStore } from '../stores/performanceStore' import { useSettingsStore } from '../stores/settingsStore' -import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore' +import { useThemeStore } from '../stores/themeStore' import type { ScopeKind } from '../../types/scope' import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance' import { SCOPE_KINDS } from '../../types/scope' @@ -39,7 +39,18 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) - const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore() + const themeId = useSettingsStore((s) => s.themeId) + const setThemeId = useSettingsStore((s) => s.setThemeId) + const { + themes, + activeThemeId, + loadTheme, + renameTheme, + deleteTheme, + reloadThemes, + importThemeFromDialog, + showThemesFolder, + } = useThemeStore() const { systemSources, @@ -128,6 +139,32 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) + const themeEntries = Object.entries(themes) + + const handleThemeChange = async (value: string): Promise => { + await loadTheme(value) + setThemeId(value) + } + + const handleRenameTheme = async (): Promise => { + if (!activeThemeId || activeThemeId === 'theme_default') return + const activeTheme = themes[activeThemeId] + if (!activeTheme) return + + const nextName = window.prompt('Rename theme', activeTheme.name)?.trim() + if (!nextName) return + await renameTheme(activeThemeId, nextName) + } + + const handleDeleteTheme = async (): Promise => { + if (!activeThemeId || activeThemeId === 'theme_default') return + const activeTheme = themes[activeThemeId] + if (!activeTheme) return + if (!window.confirm(`Delete "${activeTheme.name}"?`)) return + + await deleteTheme(activeThemeId) + setThemeId(useThemeStore.getState().activeThemeId) + } return (
@@ -161,37 +198,75 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
Theme
- {PRESET_IDS.map((id) => { - const preset = PRESETS[id] - const active = presetId === id && !customAccent - return ( - + + + {activeThemeId && activeThemeId !== 'theme_default' ? ( - )} + ) : null} + {activeThemeId && activeThemeId !== 'theme_default' ? ( + + ) : null} +
+ + {themeId ? 'Saved With Profile' : 'Not Linked'} +
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 9185b50..9286a6b 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -1,7 +1,18 @@ import { useEffect, useRef, type JSX } from 'react' import type { ScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import type { + PrismResolvedTheme, + ResolvedLUFSMeterTheme, + ResolvedOscilloscopeTheme, + ResolvedSpectrogramTheme, + ResolvedSpectrumTheme, + ResolvedVectorscopeTheme, + ResolvedVUMeterTheme, + ResolvedWaveformTheme, +} from '../../types/theme' import { useSettingsStore } from '../stores/settingsStore' +import { useThemeStore } from '../stores/themeStore' import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer' import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope' import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope' @@ -11,9 +22,18 @@ import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter' import { Waveform, type WaveformDataSource } from '../visualizers/Waveform' import type { FrameScheduler } from '../visualizers/frameScheduler' +type ScopeModuleTheme = + | ResolvedSpectrumTheme + | ResolvedOscilloscopeTheme + | ResolvedVectorscopeTheme + | ResolvedSpectrogramTheme + | ResolvedVUMeterTheme + | ResolvedLUFSMeterTheme + | ResolvedWaveformTheme + interface ScopeModuleProps { scopeKind: ScopeKind - lineColor?: string + theme?: ScopeModuleTheme settings?: ScopeSettings[ScopeKind] frameScheduler?: FrameScheduler dataSource?: @@ -34,14 +54,26 @@ interface Visualizer { setOptions(options: Record): void } -/** Maps settingsStore scope settings to the visualizer's setOptions format */ -function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKind], lineColor: string): Record { - const base = { lineColor } +function getScopeTheme(theme: PrismResolvedTheme, kind: ScopeKind): ScopeModuleTheme { + return theme[kind] as ScopeModuleTheme +} + +export function scopeSettingsToOptions( + kind: ScopeKind, + settings: ScopeSettings[ScopeKind], + theme: ScopeModuleTheme, +): Record { switch (kind) { case 'spectrum': { const s = settings as ScopeSettings['spectrum'] + const t = theme as ResolvedSpectrumTheme return { - ...base, + lineColor: t.primary, + secondaryLineColor: t.secondary, + gradientColors: t.fillGradient, + heatColors: t.heatColors, + backgroundColor: t.background, + gridColor: t.guides, fftSize: s.fftSize, tiltDbPerOctave: s.tiltDbPerOctave, heatmapFill: s.heatmap, @@ -49,16 +81,35 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi showGrid: s.showGrid, fillGradient: s.fillGradient, smoothing: s.smoothing, + showSideLine: s.showSideLine, } } case 'oscilloscope': { const s = settings as ScopeSettings['oscilloscope'] - return { ...base, pitchLock: s.pitchLock, underfillEnabled: s.underfillEnabled, showGrid: s.showGrid, lineWidth: s.lineWidth } + const t = theme as ResolvedOscilloscopeTheme + return { + lineColor: t.primary, + backgroundColor: t.background, + gridColor: t.guides, + underfillColor: t.fill, + pitchLock: s.pitchLock, + underfillEnabled: s.underfillEnabled, + showGrid: s.showGrid, + lineWidth: s.lineWidth, + } } case 'vectorscope': { const s = settings as ScopeSettings['vectorscope'] + const t = theme as ResolvedVectorscopeTheme return { - ...base, + lineColor: t.primary, + backgroundColor: t.background, + gridColor: t.guides, + bandColors: { + low: t.lowBand, + mid: t.midBand, + high: t.highBand, + }, mode: s.mode, multiband: s.multiband, showGrid: s.showGrid, @@ -68,22 +119,61 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi } case 'spectrogram': { const s = settings as ScopeSettings['spectrogram'] - return { ...base, fftSize: s.fftSize, scrollSpeed: s.scrollSpeed, clarityMode: s.clarityMode, scaleMode: s.scaleMode, colorScheme: s.colorScheme } + const t = theme as ResolvedSpectrogramTheme + return { + lineColor: t.primary, + heatColors: t.heatColors, + fftSize: s.fftSize, + scrollSpeed: s.scrollSpeed, + clarityMode: s.clarityMode, + scaleMode: s.scaleMode, + colorScheme: s.colorScheme, + } } case 'vumeter': { const s = settings as ScopeSettings['vumeter'] - return { ...base, mode: s.mode, orientation: s.orientation } + const t = theme as ResolvedVUMeterTheme + return { + lineColor: t.primary, + peakColor: t.peak, + clipColor: t.clip, + scaleColor: t.guides, + labelColor: t.text, + mode: s.mode, + orientation: s.orientation, + } } case 'lufsmeter': { const s = settings as ScopeSettings['lufsmeter'] - return { ...base, mode: s.mode } + const t = theme as ResolvedLUFSMeterTheme + return { + lineColor: t.primary, + targetColor: t.target, + scaleColor: t.guides, + labelColor: t.text, + mode: s.mode, + } } case 'waveform': { const s = settings as ScopeSettings['waveform'] - return { ...base, scrollSpeed: s.scrollSpeed, gainDb: s.gainDb, multiband: s.multiband } + const t = theme as ResolvedWaveformTheme + return { + lineColor: t.primary, + gridMajorColor: t.guides, + gridMinorColor: t.guides, + bandColors: { + low: t.lowBand, + mid: t.midBand, + high: t.highBand, + }, + mode: s.mode, + scrollSpeed: s.scrollSpeed, + gainDb: s.gainDb, + multiband: s.multiband, + } } default: - return base + return {} } } @@ -91,11 +181,11 @@ function createVisualizer( scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], - lineColor: string, + theme: ScopeModuleTheme, frameScheduler?: FrameScheduler, dataSource?: ScopeModuleProps['dataSource'], ): Visualizer | null { - const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), frameScheduler } + const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, theme), frameScheduler } switch (scopeKind) { case 'spectrum': return new SpectrumAnalyzer(canvas, { @@ -139,7 +229,7 @@ function createVisualizer( export default function ScopeModule({ scopeKind, - lineColor = '#38bdf8', + theme, settings, frameScheduler, dataSource, @@ -150,42 +240,42 @@ export default function ScopeModule({ const initializedRef = useRef(false) const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind]) + const activeTheme = useThemeStore((s) => s.activeTheme) const mySettings = settings ?? storeSettings + const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind) - // Initialize visualizer useEffect(() => { const canvas = canvasRef.current if (!canvas) return initializedRef.current = false - const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, frameScheduler, dataSource) + const viz = createVisualizer(scopeKind, canvas, mySettings, myTheme, frameScheduler, dataSource) if (!viz) return visualizerRef.current = viz viz.start() - // Mark as initialized after a frame so the settings effect skips the first run - requestAnimationFrame(() => { initializedRef.current = true }) + requestAnimationFrame(() => { + initializedRef.current = true + }) return () => { viz.dispose() visualizerRef.current = null initializedRef.current = false } - }, [dataSource, frameScheduler, scopeKind]) + }, [dataSource, frameScheduler, myTheme, mySettings, scopeKind]) - // Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it) useEffect(() => { if (!visualizerRef.current || !initializedRef.current) return const opts = { - ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), + ...scopeSettingsToOptions(scopeKind, mySettings, myTheme), frameScheduler, ...(dataSource ? { dataSource } : {}), } visualizerRef.current.setOptions(opts) - }, [dataSource, frameScheduler, lineColor, mySettings, scopeKind]) + }, [dataSource, frameScheduler, mySettings, myTheme, scopeKind]) - // ResizeObserver for DPI-aware canvas sizing useEffect(() => { const container = containerRef.current const canvas = canvasRef.current diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index d9fcba4..15db3e2 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -20,10 +20,12 @@ function buildConsumerDemand(kind: ScopeKind): Record { }, {} as Record) } -function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch { +function flushScopeAudioBatch(kind: ScopeKind, scopeSettings: ScopeSettings): ScopePopoutAudioBatch { switch (kind) { case 'spectrum': - return audioRouter.flushPendingSpectrumSamples() + return scopeSettings.spectrum.showSideLine + ? audioRouter.flushPendingSpectrumStereoSamples() + : audioRouter.flushPendingSpectrumSamples() case 'oscilloscope': return audioRouter.flushPendingOscilloscopeSamples() case 'vectorscope': @@ -35,7 +37,9 @@ function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch { case 'lufsmeter': return audioRouter.flushPendingLUFSMeterSamples() case 'waveform': - return audioRouter.flushPendingWaveformSamples() + return scopeSettings.waveform.mode === 'stereo' + ? audioRouter.flushPendingWaveformStereoSamples() + : audioRouter.flushPendingWaveformSamples() } } @@ -60,7 +64,7 @@ export default function ScopePopoutBridge(): null { const popInScope = useSettingsStore((s) => s.popInScope) const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds) const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings) - const accent = useThemeStore((s) => s.accent) + const activeTheme = useThemeStore((s) => s.activeTheme) const frameTarget = usePerformanceStore((s) => s.frameTarget) const activePopoutKinds = useMemo( @@ -96,12 +100,13 @@ export default function ScopePopoutBridge(): null { const snapshot: ScopePopoutSnapshot = { kind, label: SCOPE_LABELS[kind], - accent, + interfaceTheme: activeTheme.interface, + scopeTheme: activeTheme[kind], settings: scopeSettings[kind], } window.electronAPI.sendScopePopoutSnapshot(snapshot) } - }, [accent, activePopoutKinds, scopeSettings]) + }, [activePopoutKinds, activeTheme, scopeSettings]) useEffect(() => { const sessionState = toPopoutSessionState(audioRouter.getSessionState()) @@ -129,7 +134,8 @@ export default function ScopePopoutBridge(): null { window.electronAPI.sendScopePopoutSnapshot({ kind, label: SCOPE_LABELS[kind], - accent: useThemeStore.getState().accent, + interfaceTheme: useThemeStore.getState().activeTheme.interface, + scopeTheme: useThemeStore.getState().activeTheme[kind], settings: useSettingsStore.getState().scopeSettings[kind], }) window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState())) @@ -169,7 +175,7 @@ export default function ScopePopoutBridge(): null { } for (const kind of activePopoutKindsRef.current) { - const batch = flushScopeAudioBatch(kind) + const batch = flushScopeAudioBatch(kind, useSettingsStore.getState().scopeSettings) if (batch.length > 0) { window.electronAPI.sendScopePopoutAudio(kind, batch) } diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 5949d28..4f26767 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -22,7 +22,8 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] switch (kind) { case 'spectrum': { const scopeSettings = settings as ScopeSettings['spectrum'] - return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` + const summary = `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` + return scopeSettings.showSideLine ? `${summary} · Side` : summary } case 'oscilloscope': { const scopeSettings = settings as ScopeSettings['oscilloscope'] @@ -47,9 +48,14 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] return 'Bar Meter' case 'waveform': { const scopeSettings = settings as ScopeSettings['waveform'] - return scopeSettings.multiband - ? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB` - : `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB` + const summary = [`${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`] + if (scopeSettings.mode === 'stereo') { + summary.push('Stereo') + } + if (scopeSettings.multiband) { + summary.push('RGB') + } + return summary.join(' · ') } } } @@ -213,6 +219,11 @@ export default function ScopeSettingsSection({ active={current.showGrid} onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })} /> + onUpdate('spectrum', { showSideLine: !current.showSideLine })} + /> + + onUpdate('waveform', { mode: 'mono' })} + /> + onUpdate('waveform', { mode: 'stereo' })} + /> + + s.moveDockedScope) const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight) const popOutScope = useSettingsStore((s) => s.popOutScope) - const accent = useThemeStore((s) => s.accent) const frameTarget = usePerformanceStore((s) => s.frameTarget) const setDockedRenderFps = usePerformanceStore((s) => s.setDockedRenderFps) const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), []) @@ -262,7 +260,6 @@ export default function Strip(): JSX.Element {
diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 71b6c9a..b3acaca 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -18,6 +18,11 @@ import type { ProfileLibrarySnapshot, } from '../types/profile' import type { ScopeKind } from '../types/scope' +import type { + LegacyThemeMigrationPayload, + LegacyThemeMigrationResult, + ThemeLibrarySnapshot, +} from '../types/theme' declare global { interface Window { @@ -45,6 +50,14 @@ declare global { importProfileDialog: () => Promise revealProfilesFolder: () => Promise migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => Promise + getThemeSnapshot: () => Promise + loadTheme: (id: string) => Promise + renameTheme: (id: string, name: string) => Promise + deleteTheme: (id: string) => Promise + reloadThemes: () => Promise + importThemeDialog: () => Promise + revealThemesFolder: () => Promise + migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => Promise expandSettings: (panelHeight: number) => void collapseSettings: (panelHeight: number) => void setSettingsHeight: (panelHeight: number) => void @@ -69,6 +82,7 @@ declare global { onProfileMenuImport: (callback: () => void) => () => void onProfileMenuShowFolder: (callback: () => void) => () => void onExternalProfileActivated: (callback: (snapshot: ProfileLibrarySnapshot) => void) => () => void + onExternalThemeActivated: (callback: (snapshot: ThemeLibrarySnapshot) => void) => () => void onScopePopoutReady: (callback: (kind: ScopeKind) => void) => () => void onScopePopoutCloseRequested: (callback: (kind: ScopeKind) => void) => () => void onScopePopoutBoundsChanged: (callback: (kind: ScopeKind, bounds: WindowBounds) => void) => () => void diff --git a/src/renderer/popouts/ScopePopoutDataSource.ts b/src/renderer/popouts/ScopePopoutDataSource.ts index 6a9c1ce..3880dec 100644 --- a/src/renderer/popouts/ScopePopoutDataSource.ts +++ b/src/renderer/popouts/ScopePopoutDataSource.ts @@ -67,6 +67,34 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { return } + if (this.scopeKind === 'spectrum' || this.scopeKind === 'waveform') { + if (isStereoBatch(batch)) { + this.monoQueue = [] + this.stereoQueue.push(...batch) + if (this.scopeKind === 'spectrum') { + for (const chunk of batch) { + this.nativeVisualizerTransport.handleChunk(chunk.left, chunk.right, { + sessionId: this.sessionState.sessionId, + channelCount: this.sessionState.channelCount, + }) + } + } + return + } + + this.stereoQueue = [] + this.monoQueue.push(...batch) + if (this.scopeKind === 'spectrum') { + for (const chunk of batch) { + this.nativeVisualizerTransport.handleChunk(chunk, chunk, { + sessionId: this.sessionState.sessionId, + channelCount: 1, + }) + } + } + return + } + if (isStereoBatch(batch)) return this.monoQueue.push(...batch) for (const chunk of batch) { @@ -116,6 +144,12 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { return this.scopeKind === 'spectrum' ? batch : [] } + getPendingSpectrumStereoSamples(): ScopePopoutStereoBatch { + const batch = this.stereoQueue + this.stereoQueue = [] + return this.scopeKind === 'spectrum' ? batch : [] + } + getPendingOscilloscopeSamples(): Float32Array[] { const batch = this.monoQueue this.monoQueue = [] @@ -134,6 +168,12 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { return this.scopeKind === 'waveform' ? batch : [] } + getPendingWaveformStereoSamples(): ScopePopoutStereoBatch { + const batch = this.stereoQueue + this.stereoQueue = [] + return this.scopeKind === 'waveform' ? batch : [] + } + getPendingVectorscopeSamples(): ScopePopoutStereoBatch { const batch = this.stereoQueue this.stereoQueue = [] diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index 252a935..6e454e7 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -2,10 +2,10 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ import type { ScopePopoutSnapshot } from '../../types/popout' import { SCOPE_LABELS, type ScopeKind } from '../../types/scope' import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings' +import { applyResolvedThemeToDocument, createDefaultTheme, resolveTheme } from '../../shared/themeState' import ScopeModule from '../components/ScopeModule' import ScopeSettingsSection from '../components/ScopeSettingsSection' import { usePerformanceStore } from '../stores/performanceStore' -import { applyAccentToDOM } from '../stores/themeStore' import { ScopePopoutDataSource } from './ScopePopoutDataSource' import { FrameScheduler } from '../visualizers/frameScheduler' @@ -44,6 +44,7 @@ interface ScopePopoutWindowProps { } const POPOUT_SETTINGS_EXPAND_HEIGHT = 260 +const defaultTheme = resolveTheme(createDefaultTheme()) export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element { const [snapshot, setSnapshot] = useState | null>(null) @@ -61,7 +62,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => { if (nextSnapshot.kind !== scopeKind) return setSnapshot(nextSnapshot) - applyAccentToDOM(nextSnapshot.accent) + applyResolvedThemeToDocument({ interface: nextSnapshot.interfaceTheme }, document.documentElement.style) }) const unsubscribeAudio = window.electronAPI.onScopePopoutAudio((kind, batch) => { if (kind !== scopeKind) return @@ -81,8 +82,8 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) } }, [dataSource, scopeKind]) - const effectiveAccent = snapshot?.accent ?? '#38bdf8' const effectiveSettings = (snapshot?.settings ?? DEFAULT_SCOPE_SETTINGS[scopeKind]) as ScopeSettings[ScopeKind] + const effectiveScopeTheme = snapshot?.scopeTheme ?? defaultTheme[scopeKind] const settingsHeight = miniSettingsOpen ? POPOUT_SETTINGS_EXPAND_HEIGHT : 0 const handleUpdateScopeSettings = (kind: K, partial: Partial): void => { @@ -215,7 +216,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
@@ -35,6 +37,7 @@ interface PersistedSettingsState { } interface WorkingSettingsState { + themeId: string | null scopeOrder: ScopeKind[] hiddenScopes: Set widthWeights: Record @@ -48,6 +51,7 @@ interface SettingsState extends WorkingSettingsState { activeProfileId: string | null initializeProfiles: () => Promise applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => void + setThemeId: (themeId: string | null) => void toggleScope: (kind: ScopeKind) => void moveDockedScope: (kind: ScopeKind, direction: 'left' | 'right') => void setScopeWidthWeight: (kind: ScopeKind, weight: number) => void @@ -80,6 +84,7 @@ function loadFromStorage(): Partial { function saveToStorage(state: WorkingSettingsState): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ + themeId: state.themeId, scopeOrder: state.scopeOrder, hiddenScopes: Array.from(state.hiddenScopes), widthWeights: state.widthWeights, @@ -121,6 +126,7 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState { const normalizedProfile = normalizeProfile(profile, profile.name) return { + themeId: normalizedProfile.themeId, scopeOrder: normalizeScopeOrder(normalizedProfile.scopeOrder), hiddenScopes: new Set(normalizeHiddenScopes(normalizedProfile.hiddenScopes)), widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights), @@ -147,6 +153,9 @@ function applyProfileSnapshot( } const nextState = createWorkingStateFromProfile(activeProfile) + if (!nextState.themeId && snapshot.activeProfileId === DEFAULT_PROFILE_ID) { + nextState.themeId = useThemeStore.getState().activeThemeId + } saveToStorage(nextState) set({ ...nextState, @@ -154,6 +163,10 @@ function applyProfileSnapshot( activeProfileId: snapshot.activeProfileId, }) + if (activeProfile.themeId && useThemeStore.getState().themes[activeProfile.themeId]) { + void useThemeStore.getState().loadTheme(activeProfile.themeId) + } + if (activeProfile.windowBounds && canUseElectronAPI()) { window.electronAPI.setWindowBounds(activeProfile.windowBounds) } @@ -162,6 +175,7 @@ function applyProfileSnapshot( async function buildProfileFromState(state: SettingsState, name: string): Promise { const profile = normalizeProfile({ name, + themeId: state.themeId ?? useThemeStore.getState().activeThemeId, scopeOrder: [...state.scopeOrder], hiddenScopes: Array.from(state.hiddenScopes), widthWeights: { ...state.widthWeights }, @@ -226,6 +240,9 @@ export function moveDockedScopeOrder( const stored = loadFromStorage() const initialWorkingState: WorkingSettingsState = { + themeId: typeof stored.themeId === 'string' && stored.themeId.trim() + ? stored.themeId.trim() + : null, scopeOrder: normalizeScopeOrder(stored.scopeOrder), hiddenScopes: new Set(normalizeHiddenScopes(stored.hiddenScopes)), widthWeights: normalizeWidthWeights(stored.widthWeights), @@ -266,6 +283,17 @@ export const useSettingsStore = create((set, get) => ({ applyProfileSnapshot(set, snapshot, { loadActiveProfile: true }) }, + setThemeId: (themeId: string | null) => { + set((state) => { + const nextState = { + ...state, + themeId, + } + saveToStorage(nextState) + return nextState + }) + }, + toggleScope: (kind: ScopeKind) => { set((state) => { const next = new Set(state.hiddenScopes) diff --git a/src/renderer/stores/themeStore.ts b/src/renderer/stores/themeStore.ts index 350fa3e..ffc4aaf 100644 --- a/src/renderer/stores/themeStore.ts +++ b/src/renderer/stores/themeStore.ts @@ -1,133 +1,146 @@ import { create } from 'zustand' +import type { + LegacyThemeMigrationPayload, + PrismResolvedTheme, + PrismTheme, + ThemeLibrarySnapshot, +} from '../../types/theme' +import { + applyResolvedThemeToDocument, + createDefaultTheme, + normalizeLegacyThemePayload, + resolveTheme, +} from '../../shared/themeState' -export interface ThemePreset { - name: string - accent: string - accentHover: string - accentGlow: string - accentRgb: string -} - -const PRESETS: Record = { - default: { - name: 'Cyan', - accent: '#38bdf8', - accentHover: '#7dd3fc', - accentGlow: 'rgba(56, 189, 248, 0.3)', - accentRgb: '56, 189, 248', - }, - graphite: { - name: 'Graphite', - accent: '#4fc3f7', - accentHover: '#81d4fa', - accentGlow: 'rgba(79, 195, 247, 0.3)', - accentRgb: '79, 195, 247', - }, - midnight: { - name: 'Midnight', - accent: '#4f9bff', - accentHover: '#7eb8ff', - accentGlow: 'rgba(79, 155, 255, 0.3)', - accentRgb: '79, 155, 255', - }, - green: { - name: 'Green', - accent: '#4ade80', - accentHover: '#86efac', - accentGlow: 'rgba(74, 222, 128, 0.3)', - accentRgb: '74, 222, 128', - }, - purple: { - name: 'Purple', - accent: '#a78bfa', - accentHover: '#c4b5fd', - accentGlow: 'rgba(167, 139, 250, 0.3)', - accentRgb: '167, 139, 250', - }, - rose: { - name: 'Rose', - accent: '#fb7185', - accentHover: '#fda4af', - accentGlow: 'rgba(251, 113, 133, 0.3)', - accentRgb: '251, 113, 133', - }, -} - -export const PRESET_IDS = Object.keys(PRESETS) - -const STORAGE_KEY = 'prism:theme' - -function hexToRgb(hex: string): string { - const h = hex.replace('#', '') - const r = parseInt(h.substring(0, 2), 16) - const g = parseInt(h.substring(2, 4), 16) - const b = parseInt(h.substring(4, 6), 16) - return `${r}, ${g}, ${b}` -} - -function lightenHex(hex: string, amount: number): string { - const h = hex.replace('#', '') - const r = Math.min(255, parseInt(h.substring(0, 2), 16) + amount) - const g = Math.min(255, parseInt(h.substring(2, 4), 16) + amount) - const b = Math.min(255, parseInt(h.substring(4, 6), 16) + amount) - return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` -} +const LEGACY_STORAGE_KEY = 'prism:theme' interface ThemeState { - presetId: string - customAccent: string | null // null = use preset accent - accent: string // resolved accent hex - - setPreset: (id: string) => void - setCustomAccent: (hex: string | null) => void + themes: Record + activeThemeId: string | null + activeTheme: PrismResolvedTheme + accent: string + initializeThemes: () => Promise + applyExternalThemeSnapshot: (snapshot: ThemeLibrarySnapshot) => void + loadTheme: (id: string) => Promise + renameTheme: (id: string, name: string) => Promise + deleteTheme: (id: string) => Promise + reloadThemes: () => Promise + importThemeFromDialog: () => Promise + showThemesFolder: () => Promise } -function loadTheme(): { presetId: string; customAccent: string | null } { +function canUseElectronAPI(): boolean { + return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined' +} + +function loadLegacyThemeMigrationPayload(): LegacyThemeMigrationPayload | null { try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw) return JSON.parse(raw) - } catch { /* ignore */ } - return { presetId: 'default', customAccent: null } + const raw = localStorage.getItem(LEGACY_STORAGE_KEY) + if (!raw) return null + return normalizeLegacyThemePayload(JSON.parse(raw) as unknown) + } catch { + return null + } } -export function applyAccentToDOM(accent: string): void { - const rgb = hexToRgb(accent) - const root = document.documentElement - root.style.setProperty('--accent', accent) - root.style.setProperty('--accent-hover', lightenHex(accent, 50)) - root.style.setProperty('--accent-glow', `rgba(${rgb}, 0.3)`) - root.style.setProperty('--accent-rgb', rgb) +function clearLegacyThemeStorage(): void { + try { + localStorage.removeItem(LEGACY_STORAGE_KEY) + } catch { + // Ignore localStorage failures. + } } -const stored = loadTheme() -const initialPreset = PRESETS[stored.presetId] ?? PRESETS.default -const initialAccent = stored.customAccent ?? initialPreset.accent +function applyThemeToDOM(theme: PrismResolvedTheme): void { + if (typeof document === 'undefined') return + applyResolvedThemeToDocument(theme, document.documentElement.style) +} -// Apply immediately on load -applyAccentToDOM(initialAccent) +function resolveActiveTheme(snapshot: ThemeLibrarySnapshot): PrismResolvedTheme { + const theme = snapshot.activeThemeId + ? snapshot.themes[snapshot.activeThemeId] ?? null + : null + return resolveTheme(theme ?? createDefaultTheme()) +} + +function applyThemeSnapshot( + set: (partial: Partial) => void, + snapshot: ThemeLibrarySnapshot, +): void { + const activeTheme = resolveActiveTheme(snapshot) + applyThemeToDOM(activeTheme) + set({ + themes: snapshot.themes, + activeThemeId: snapshot.activeThemeId, + activeTheme, + accent: activeTheme.interface.accent, + }) +} + +const fallbackTheme = resolveTheme(createDefaultTheme()) +applyThemeToDOM(fallbackTheme) export const useThemeStore = create((set) => ({ - presetId: stored.presetId, - customAccent: stored.customAccent, - accent: initialAccent, + themes: { + [fallbackTheme.id]: createDefaultTheme(), + }, + activeThemeId: fallbackTheme.id, + activeTheme: fallbackTheme, + accent: fallbackTheme.interface.accent, - setPreset: (id: string) => { - const preset = PRESETS[id] ?? PRESETS.default - applyAccentToDOM(preset.accent) - const state = { presetId: id, customAccent: null, accent: preset.accent } - localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: id, customAccent: null })) - set(state) + initializeThemes: async () => { + if (!canUseElectronAPI()) return + + let snapshot = await window.electronAPI.getThemeSnapshot() + const legacyPayload = loadLegacyThemeMigrationPayload() + if (legacyPayload) { + const migration = await window.electronAPI.migrateLegacyTheme(legacyPayload) + if (migration.didMigrate) { + snapshot = migration.snapshot + } + clearLegacyThemeStorage() + } + + applyThemeSnapshot(set, snapshot) }, - setCustomAccent: (hex: string | null) => { - set((prev) => { - const preset = PRESETS[prev.presetId] ?? PRESETS.default - const accent = hex ?? preset.accent - applyAccentToDOM(accent) - localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: prev.presetId, customAccent: hex })) - return { ...prev, customAccent: hex, accent } - }) + applyExternalThemeSnapshot: (snapshot) => { + applyThemeSnapshot(set, snapshot) + }, + + loadTheme: async (id: string) => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.loadTheme(id) + applyThemeSnapshot(set, snapshot) + }, + + renameTheme: async (id: string, name: string) => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.renameTheme(id, name) + applyThemeSnapshot(set, snapshot) + }, + + deleteTheme: async (id: string) => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.deleteTheme(id) + applyThemeSnapshot(set, snapshot) + }, + + reloadThemes: async () => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.reloadThemes() + applyThemeSnapshot(set, snapshot) + }, + + importThemeFromDialog: async () => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.importThemeDialog() + if (!snapshot) return + applyThemeSnapshot(set, snapshot) + }, + + showThemesFolder: async () => { + if (!canUseElectronAPI()) return + await window.electronAPI.revealThemesFolder() }, })) - -export { PRESETS } diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index eee5aa3..140d516 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -19,12 +19,29 @@ --text-tertiary: rgba(255, 255, 255, 0.42); --text-muted: rgba(255, 255, 255, 0.3); --danger: #f87171; + --warning: #ffbf00; --success: #22c55e; --accent: #38bdf8; --accent-hover: #7dd3fc; --accent-glow: rgba(56, 189, 248, 0.3); --accent-rgb: 56, 189, 248; + --toolbar-bg: rgba(0, 0, 0, 0.74); + --settings-bg-top: rgba(8, 10, 14, 0.94); + --settings-bg-bottom: rgba(4, 6, 9, 0.98); + --bottom-bar-bg: rgba(2, 4, 7, 0.98); + --menu-bg: rgba(8, 11, 16, 0.96); + --menu-border: rgba(255, 255, 255, 0.1); + --control-bg: rgba(255, 255, 255, 0.03); + --control-bg-hover: rgba(255, 255, 255, 0.06); + --control-bg-active: rgba(var(--accent-rgb), 0.12); + --control-border: rgba(255, 255, 255, 0.08); + --control-border-active: rgba(var(--accent-rgb), 0.28); + --input-bg: rgba(10, 14, 20, 0.96); + --input-bg-focus: rgba(12, 17, 24, 0.98); + --input-border: rgba(255, 255, 255, 0.09); + --input-border-focus: rgba(var(--accent-rgb), 0.26); + --divider: rgba(255, 255, 255, 0.08); } * { @@ -86,9 +103,9 @@ select { flex-direction: column; overflow: hidden; background: - linear-gradient(180deg, rgba(8, 10, 14, 0.94), rgba(4, 6, 9, 0.98)), + linear-gradient(180deg, var(--settings-bg-top), var(--settings-bg-bottom)), rgba(0, 0, 0, 0.96); - border-top: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid var(--divider); box-shadow: 0 -16px 34px rgba(0, 0, 0, 0.34); } @@ -99,7 +116,7 @@ select { min-height: 38px; padding: 4px 10px 0; gap: 10px; - background: rgba(0, 0, 0, 0.74); + background: var(--toolbar-bg); backdrop-filter: blur(16px) saturate(1.05); -webkit-backdrop-filter: blur(16px) saturate(1.05); } @@ -113,7 +130,7 @@ select { padding: 0; border: 0; background: transparent; - color: rgba(255, 255, 255, 0.3); + color: var(--text-muted); cursor: grab; flex-shrink: 0; -webkit-app-region: no-drag; @@ -136,8 +153,8 @@ select { min-height: 28px; padding: 0 10px; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.025); + border: 1px solid var(--control-border); + background: var(--glass-bg); } .toolbar__brand-mark { @@ -164,7 +181,7 @@ select { } .toolbar__brand-text { - color: rgba(255, 255, 255, 0.72); + color: var(--text-secondary); font-size: 10px; } @@ -181,7 +198,7 @@ select { min-height: 28px; padding: 0 10px; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--control-border); background: transparent; color: var(--text-secondary); font-family: 'JetBrains Mono', monospace; @@ -195,8 +212,8 @@ select { .toolbar__profile-button:hover, .toolbar__profile-button.is-active { - color: rgba(255, 255, 255, 0.78); - border-color: rgba(255, 255, 255, 0.14); + color: var(--text-primary); + border-color: var(--control-border-active); } .toolbar__profile-name { @@ -224,8 +241,8 @@ select { margin-top: 4px; display: flex; flex-direction: column; - background: rgba(8, 11, 16, 0.96); - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--menu-bg); + border: 1px solid var(--menu-border); border-radius: 8px; overflow: hidden; z-index: 20; @@ -248,7 +265,7 @@ select { .toolbar__reposition-option:hover { color: var(--text-primary); - background: rgba(255, 255, 255, 0.06); + background: var(--control-bg-hover); } .toolbar__chip { @@ -578,12 +595,12 @@ select { align-items: center; justify-content: space-between; gap: 10px; - color: rgba(255, 255, 255, 0.56); + color: var(--text-secondary); font-size: 9px; } .settings-control__value { - color: rgba(255, 255, 255, 0.82); + color: var(--text-primary); letter-spacing: 0.08em; } @@ -592,10 +609,10 @@ select { min-height: 34px; padding: 0 12px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.09); - background: linear-gradient(180deg, rgba(10, 14, 20, 0.96), rgba(5, 8, 12, 0.96)); + border: 1px solid var(--input-border); + background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.9); + color: var(--text-primary); font-size: 11px; outline: none; transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease; @@ -603,8 +620,8 @@ select { .settings-control__select:hover, .settings-control__select:focus { - border-color: rgba(var(--accent-rgb), 0.26); - background: linear-gradient(180deg, rgba(12, 17, 24, 0.98), rgba(7, 10, 15, 0.98)); + border-color: var(--input-border-focus); + background: linear-gradient(180deg, var(--input-bg-focus), var(--settings-bg-bottom)); color: var(--text-primary); } @@ -718,8 +735,8 @@ select { padding: 0 11px; border-radius: 9px; border: 1px solid transparent; - background: rgba(255, 255, 255, 0.015); - color: rgba(255, 255, 255, 0.5); + background: var(--control-bg); + color: var(--text-secondary); font-size: 10px; white-space: nowrap; cursor: pointer; @@ -730,9 +747,9 @@ select { } .settings-chip:hover { - color: rgba(255, 255, 255, 0.86); - border-color: rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.04); + color: var(--text-primary); + border-color: var(--control-border); + background: var(--control-bg-hover); } .settings-chip.is-active { @@ -749,9 +766,9 @@ select { min-height: 32px; padding: 0 10px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: linear-gradient(180deg, rgba(10, 14, 19, 0.94), rgba(7, 10, 14, 0.94)); - color: rgba(255, 255, 255, 0.62); + border: 1px solid var(--control-border); + background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom)); + color: var(--text-secondary); font-size: 10px; white-space: nowrap; } @@ -760,7 +777,7 @@ select { width: 6px; height: 6px; border-radius: 999px; - background: rgba(255, 255, 255, 0.26); + background: var(--text-muted); } .settings-status-pill.is-connecting .settings-status-pill__dot { @@ -780,7 +797,7 @@ select { .settings-error-text { margin-top: 8px; - color: rgba(248, 113, 113, 0.88); + color: var(--danger); font-size: 11px; line-height: 1.4; } @@ -840,9 +857,9 @@ select { min-height: 34px; padding: 0 14px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: linear-gradient(180deg, rgba(10, 14, 20, 0.96), rgba(5, 8, 12, 0.96)); - color: rgba(255, 255, 255, 0.66); + border: 1px solid var(--control-border); + background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom)); + color: var(--text-secondary); font-size: 9px; cursor: pointer; transition: @@ -852,9 +869,9 @@ select { } .settings-panel__close:hover { - color: rgba(255, 255, 255, 0.88); - border-color: rgba(var(--accent-rgb), 0.24); - background: linear-gradient(180deg, rgba(12, 17, 24, 0.98), rgba(7, 10, 15, 0.98)); + color: var(--text-primary); + border-color: var(--control-border-active); + background: linear-gradient(180deg, var(--input-bg-focus), var(--settings-bg-bottom)); } .bottom-bar { @@ -863,8 +880,8 @@ select { gap: 0; min-height: 92px; padding: 0; - background: rgba(2, 4, 7, 0.98); - border-top: 1px solid rgba(255, 255, 255, 0.06); + background: var(--bottom-bar-bg); + border-top: 1px solid var(--divider); flex-shrink: 0; } @@ -925,7 +942,7 @@ select { font-family: 'JetBrains Mono', monospace; letter-spacing: 0.12em; text-transform: uppercase; - color: rgba(255, 255, 255, 0.54); + color: var(--text-secondary); font-size: 9px; white-space: nowrap; } @@ -942,8 +959,8 @@ select { gap: 4px; padding: 4px; border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.025); + border: 1px solid var(--control-border); + background: var(--glass-bg); } .bottom-bar__inline--theme { @@ -958,14 +975,14 @@ select { width: 1px; align-self: stretch; margin: 0 16px 0 0; - background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.14) 18%, rgba(255, 255, 255, 0.04) 82%, transparent); + background: linear-gradient(180deg, transparent, var(--divider) 18%, rgba(255, 255, 255, 0.04) 82%, transparent); flex-shrink: 0; } .bottom-bar__trim-value { font-family: 'JetBrains Mono', monospace; font-size: 10px; - color: rgba(255, 255, 255, 0.88); + color: var(--text-primary); letter-spacing: 0.08em; min-width: 56px; text-align: right; @@ -985,7 +1002,7 @@ select { .bottom-bar__fps-pill { min-width: 84px; justify-content: center; - color: rgba(255, 255, 255, 0.84); + color: var(--text-primary); font-family: 'JetBrains Mono', monospace; letter-spacing: 0.08em; } @@ -1000,8 +1017,8 @@ select { align-items: center; flex: 0 0 auto; padding: 10px 14px 10px 16px; - border-left: 1px solid rgba(255, 255, 255, 0.08); - background: linear-gradient(90deg, rgba(2, 4, 7, 0.76), rgba(2, 4, 7, 0.98) 24%, rgba(2, 4, 7, 1)); + border-left: 1px solid var(--divider); + background: linear-gradient(90deg, rgba(2, 4, 7, 0.76), var(--bottom-bar-bg) 24%, var(--bottom-bar-bg)); } .bottom-bar__close { @@ -1015,7 +1032,7 @@ select { flex-direction: column; position: relative; overflow: hidden; - background: #000000; + background: var(--bg-primary); color: var(--text-primary); } @@ -1055,7 +1072,7 @@ select { gap: 10px; min-height: 42px; padding: 8px 10px; - background: rgba(7, 10, 14, 0.92); + background: var(--toolbar-bg); } .scope-popout__drag { @@ -1092,7 +1109,7 @@ select { display: inline-flex; align-items: center; justify-content: center; - color: rgba(255, 255, 255, 0.32); + color: var(--text-muted); } .scope-popout__drag-icon svg { @@ -1134,9 +1151,9 @@ select { align-items: center; justify-content: center; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.03); - color: rgba(255, 255, 255, 0.64); + border: 1px solid var(--control-border); + background: var(--control-bg); + color: var(--text-secondary); cursor: pointer; transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease, transform 120ms ease; } @@ -1149,8 +1166,8 @@ select { .scope-popout__button:hover, .scope-popout__button.is-active { color: var(--accent); - border-color: rgba(var(--accent-rgb), 0.28); - background: rgba(var(--accent-rgb), 0.1); + border-color: var(--control-border-active); + background: var(--control-bg-active); transform: translateY(-1px); } @@ -1158,7 +1175,7 @@ select { height: 100%; overflow-y: auto; overflow-x: hidden; - background: rgba(5, 7, 10, 0.98); + background: var(--settings-bg-bottom); padding: 12px 0 10px; } diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index 652ce3b..3ead42c 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -12,6 +12,9 @@ export interface LUFSMeterDataSource extends VisualizerSessionSource { export interface LUFSMeterOptions { mode?: LUFSMeterMode lineColor?: string + targetColor?: string + scaleColor?: string + labelColor?: string dataSource?: LUFSMeterDataSource frameScheduler?: FrameScheduler } @@ -21,6 +24,9 @@ type ResolvedLUFSMeterOptions = Required { + const left = resolveColorToRgb(color) + const right = resolveColorToRgb(LEGACY_DEFAULT_HEAT_COLORS[index]) + return left.r === right.r && left.g === right.g && left.b === right.b + }) +} + +function buildHeatStops(colors: [string, string, string]): ColorStop[] { + if (isLegacyDefaultHeatColors(colors)) { + return [ + { at: 0, color: [0, 0, 0] }, + { at: 0.14, color: [15, 7, 33] }, + { at: 0.32, color: [61, 11, 94] }, + { at: 0.54, color: [163, 26, 121] }, + { at: 0.74, color: [255, 82, 87] }, + { at: 0.9, color: [255, 166, 63] }, + { at: 1, color: [255, 241, 209] }, + ] + } + + const low = resolveColorToRgb(colors[0]) + const mid = resolveColorToRgb(colors[1]) + const high = resolveColorToRgb(colors[2]) + + return [ + { at: 0, color: [0, 0, 0] }, + { at: 0.2, color: [Math.round(low.r * 0.5), Math.round(low.g * 0.5), Math.round(low.b * 0.5)] }, + { at: 0.48, color: [low.r, low.g, low.b] }, + { at: 0.76, color: [mid.r, mid.g, mid.b] }, + { at: 1, color: [high.r, high.g, high.b] }, + ] +} + function lerpChannel(start: number, end: number, amount: number): number { return Math.round(start + ((end - start) * amount)) } -function buildHeatLUT(): Uint8Array { +function buildHeatLUT(colors: [string, string, string]): Uint8Array { + const heatStops = buildHeatStops(colors) const lut = new Uint8Array(256 * 3) for (let index = 0; index < 256; index += 1) { const t = index / 255 - let start = HEAT_STOPS[0] - let end = HEAT_STOPS[HEAT_STOPS.length - 1] + let start = heatStops[0] + let end = heatStops[heatStops.length - 1] - for (let stopIndex = 0; stopIndex < HEAT_STOPS.length - 1; stopIndex += 1) { - const nextStop = HEAT_STOPS[stopIndex + 1] + for (let stopIndex = 0; stopIndex < heatStops.length - 1; stopIndex += 1) { + const nextStop = heatStops[stopIndex + 1] if (t <= nextStop.at) { - start = HEAT_STOPS[stopIndex] + start = heatStops[stopIndex] end = nextStop break } @@ -253,8 +287,6 @@ function buildHeatLUT(): Uint8Array { return lut } -const HEAT_LUT = buildHeatLUT() - // Zero-pad FFT for finer frequency resolution (visual interpolation) const FFT_PAD_FACTOR = 4 @@ -279,6 +311,7 @@ export class Spectrogram { private columnValues = new Float32Array(0) private rawColumnValues = new Float32Array(0) private columnImageData: ImageData | null = null + private heatLut: Uint8Array private lastWidth = 0 private lastHeight = 0 @@ -298,6 +331,7 @@ export class Spectrogram { const { dataSource, frameScheduler, ...optionOverrides } = options this.options = resolveOptions(defaultOptions, optionOverrides) this.dataSource = dataSource ?? defaultSpectrogramDataSource + this.heatLut = buildHeatLUT(this.options.heatColors) this.frameLoop = new VisualizerFrameLoop({ frameScheduler, shouldRun: () => this.dataSource.isPlaying(), @@ -342,6 +376,7 @@ export class Spectrogram { const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options const previousOptions = this.options this.options = resolveOptions(previousOptions, optionUpdates) + this.heatLut = buildHeatLUT(this.options.heatColors) if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource @@ -525,9 +560,9 @@ export class Spectrogram { const dataIndex = row * 4 if (this.options.colorScheme === 'heat') { - imageData[dataIndex] = HEAT_LUT[lutIndex * 3] - imageData[dataIndex + 1] = HEAT_LUT[(lutIndex * 3) + 1] - imageData[dataIndex + 2] = HEAT_LUT[(lutIndex * 3) + 2] + imageData[dataIndex] = this.heatLut[lutIndex * 3] + imageData[dataIndex + 1] = this.heatLut[(lutIndex * 3) + 1] + imageData[dataIndex + 2] = this.heatLut[(lutIndex * 3) + 2] } else { imageData[dataIndex] = Math.round(tintR * intensity) imageData[dataIndex + 1] = Math.round(tintG * intensity) diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index f844b9a..3f35f76 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -3,6 +3,7 @@ import { spectrum as nativeSpectrum, isNativeAvailable } from '../audio/native' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' +import { resolveColorToRgb } from '../utils/color' import { DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE, DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, @@ -10,16 +11,30 @@ import { clampSpectrumHeatmapTiltDbPerOctave, } from '../../types/spectrum' +type SpectrumStereoChunk = { + left: Float32Array + right: Float32Array +} + +type SpectrumPoint = { + x: number + y: number + heatmapIntensity: number +} + export interface SpectrumAnalyzerDataSource extends VisualizerSessionSource { getPendingSpectrumSamples: () => Float32Array[] + getPendingSpectrumStereoSamples: () => SpectrumStereoChunk[] } export interface SpectrumAnalyzerOptions { lineColor?: string + secondaryLineColor?: string lineWidth?: number fillGradient?: boolean heatmapFill?: boolean gradientColors?: string[] + heatColors?: [string, string, string] backgroundColor?: string showGrid?: boolean gridColor?: string @@ -33,6 +48,7 @@ export interface SpectrumAnalyzerOptions { heatmapTiltDbPerOctave?: number tiltReferenceHz?: number fftSize?: number + showSideLine?: boolean dataSource?: SpectrumAnalyzerDataSource frameScheduler?: FrameScheduler } @@ -40,46 +56,154 @@ export interface SpectrumAnalyzerOptions { type ResolvedSpectrumAnalyzerOptions = Required> type HeatStop = { at: number; color: [number, number, number] } -const HEAT_STOPS: readonly HeatStop[] = [ - { at: 0, color: [0, 0, 0] }, - { at: 0.14, color: [15, 7, 33] }, - { at: 0.32, color: [61, 11, 94] }, - { at: 0.54, color: [163, 26, 121] }, - { at: 0.74, color: [255, 82, 87] }, - { at: 0.9, color: [255, 166, 63] }, - { at: 1, color: [255, 241, 209] }, + +const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [ + 'rgb(15, 7, 33)', + 'rgb(163, 26, 121)', + 'rgb(255, 241, 209)', ] -function buildHeatLUT(): Uint8Array { +const HEATMAP_GAMMA = 1.4 +const FFT_SILENCE_DB = -100 +const SPECTRUM_DB_FLOOR = -120 +const SPECTRUM_DB_CEILING = 12 +const SIDE_LINE_WIDTH_RATIO = 0.75 + +const hannWindowCache = new Map() + +function getHannWindow(size: number): Float32Array { + let window = hannWindowCache.get(size) + if (window) return window + + window = new Float32Array(size) + for (let index = 0; index < size; index += 1) { + window[index] = 0.5 * (1 - Math.cos((2 * Math.PI * index) / (size - 1))) + } + + hannWindowCache.set(size, window) + return window +} + +function fft(re: Float32Array, im: Float32Array): void { + const size = re.length + if (size <= 1) return + + let j = 0 + for (let i = 1; i < size; i += 1) { + let bit = size >> 1 + while (j & bit) { + j ^= bit + bit >>= 1 + } + j ^= bit + + if (i < j) { + let tmp = re[i] + re[i] = re[j] + re[j] = tmp + tmp = im[i] + im[i] = im[j] + im[j] = tmp + } + } + + for (let len = 2; len <= size; len <<= 1) { + const halfLen = len >> 1 + const angle = -2 * Math.PI / len + const wRe = Math.cos(angle) + const wIm = Math.sin(angle) + + for (let i = 0; i < size; i += len) { + let curRe = 1 + let curIm = 0 + + for (let k = 0; k < halfLen; k += 1) { + const evenIndex = i + k + const oddIndex = i + k + halfLen + + const tRe = curRe * re[oddIndex] - curIm * im[oddIndex] + const tIm = curRe * im[oddIndex] + curIm * re[oddIndex] + + re[oddIndex] = re[evenIndex] - tRe + im[oddIndex] = im[evenIndex] - tIm + re[evenIndex] += tRe + im[evenIndex] += tIm + + const nextRe = curRe * wRe - curIm * wIm + curIm = curRe * wIm + curIm * wRe + curRe = nextRe + } + } + } +} + +function isLegacyDefaultHeatColors(colors: [string, string, string]): boolean { + return colors.every((color, index) => { + const left = resolveColorToRgb(color) + const right = resolveColorToRgb(LEGACY_DEFAULT_HEAT_COLORS[index]) + return left.r === right.r && left.g === right.g && left.b === right.b + }) +} + +function buildHeatStops(colors: [string, string, string]): HeatStop[] { + if (isLegacyDefaultHeatColors(colors)) { + // Preserve Prism's original default spectrum heatmap instead of flattening it + // into the generic themed stop builder. + return [ + { at: 0, color: [0, 0, 0] }, + { at: 0.14, color: [15, 7, 33] }, + { at: 0.32, color: [61, 11, 94] }, + { at: 0.54, color: [163, 26, 121] }, + { at: 0.74, color: [255, 82, 87] }, + { at: 0.9, color: [255, 166, 63] }, + { at: 1, color: [255, 241, 209] }, + ] + } + + const low = resolveColorToRgb(colors[0]) + const mid = resolveColorToRgb(colors[1]) + const high = resolveColorToRgb(colors[2]) + + return [ + { at: 0, color: [0, 0, 0] }, + { at: 0.2, color: [Math.round(low.r * 0.5), Math.round(low.g * 0.5), Math.round(low.b * 0.5)] }, + { at: 0.48, color: [low.r, low.g, low.b] }, + { at: 0.76, color: [mid.r, mid.g, mid.b] }, + { at: 1, color: [high.r, high.g, high.b] }, + ] +} + +function buildHeatLUT(colors: [string, string, string]): Uint8Array { + const heatStops = buildHeatStops(colors) const lut = new Uint8Array(256 * 3) - for (let i = 0; i < 256; i++) { + for (let i = 0; i < 256; i += 1) { const t = i / 255 - let s = HEAT_STOPS[0] - let e = HEAT_STOPS[HEAT_STOPS.length - 1] - for (let si = 0; si < HEAT_STOPS.length - 1; si++) { - if (t <= HEAT_STOPS[si + 1].at) { - s = HEAT_STOPS[si] - e = HEAT_STOPS[si + 1] + let start = heatStops[0] + let end = heatStops[heatStops.length - 1] + for (let stopIndex = 0; stopIndex < heatStops.length - 1; stopIndex += 1) { + if (t <= heatStops[stopIndex + 1].at) { + start = heatStops[stopIndex] + end = heatStops[stopIndex + 1] break } } - const a = Math.max(0, Math.min(1, (t - s.at) / Math.max(1e-6, e.at - s.at))) - lut[i * 3] = Math.round(s.color[0] + (e.color[0] - s.color[0]) * a) - lut[i * 3 + 1] = Math.round(s.color[1] + (e.color[1] - s.color[1]) * a) - lut[i * 3 + 2] = Math.round(s.color[2] + (e.color[2] - s.color[2]) * a) + + const amount = Math.max(0, Math.min(1, (t - start.at) / Math.max(1e-6, end.at - start.at))) + lut[i * 3] = Math.round(start.color[0] + (end.color[0] - start.color[0]) * amount) + lut[i * 3 + 1] = Math.round(start.color[1] + (end.color[1] - start.color[1]) * amount) + lut[i * 3 + 2] = Math.round(start.color[2] + (end.color[2] - start.color[2]) * amount) } return lut } -const HEAT_LUT = buildHeatLUT() -const HEATMAP_GAMMA = 1.4 - const defaultOptions: ResolvedSpectrumAnalyzerOptions = { lineColor: '#00ffff', + secondaryLineColor: 'rgba(0, 255, 255, 0.5)', lineWidth: 2, fillGradient: true, heatmapFill: false, gradientColors: ['rgba(0, 255, 255, 0)', 'rgba(0, 255, 255, 0.3)', 'rgba(138, 43, 226, 0.5)'], + heatColors: [...LEGACY_DEFAULT_HEAT_COLORS], backgroundColor: 'transparent', showGrid: true, gridColor: 'rgba(255, 255, 255, 0.1)', @@ -93,10 +217,12 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = { heatmapTiltDbPerOctave: DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, tiltReferenceHz: 1000, fftSize: 2048, + showSideLine: false, } const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = { getPendingSpectrumSamples: () => audioRouter.flushPendingSpectrumSamples(), + getPendingSpectrumStereoSamples: () => audioRouter.flushPendingSpectrumStereoSamples(), ...defaultVisualizerSessionSource, } @@ -109,11 +235,21 @@ export class SpectrumAnalyzer { private nativeInitialized = false private sampleRate = 48000 private lastSampleRate = 0 + private heatLut: Uint8Array private staticLayerCanvas: HTMLCanvasElement private staticLayerCtx: CanvasRenderingContext2D private staticLayerKey = '' private unsubscribeSessionChange: (() => void) | null = null + private jsMidHistory = new Float32Array(defaultOptions.fftSize) + private jsSideHistory = new Float32Array(defaultOptions.fftSize) + private jsMidMagnitudes = new Float32Array(defaultOptions.fftSize / 2) + private jsSideMagnitudes = new Float32Array(defaultOptions.fftSize / 2) + private jsFftRe = new Float32Array(defaultOptions.fftSize) + private jsFftIm = new Float32Array(defaultOptions.fftSize) + private jsBufferedSamples = 0 + private jsHasSpectrumData = false + constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) { this.canvas = canvas const ctx = canvas.getContext('2d') @@ -132,6 +268,7 @@ export class SpectrumAnalyzer { ), } this.dataSource = dataSource ?? defaultSpectrumDataSource + this.heatLut = buildHeatLUT(this.options.heatColors) this.frameLoop = new VisualizerFrameLoop({ frameScheduler, shouldRun: () => this.dataSource.isPlaying(), @@ -142,6 +279,7 @@ export class SpectrumAnalyzer { if (!staticLayerCtx) throw new Error('Could not get offscreen 2D context') this.staticLayerCtx = staticLayerCtx + this.resetJsState() this.initNative() this.subscribeToSessionChanges() } @@ -150,32 +288,61 @@ export class SpectrumAnalyzer { if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() } + this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetState() }) } private initNative(): void { + this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) + this.lastSampleRate = 0 + if (isNativeAvailable() && !this.nativeInitialized) { - this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) - this.lastSampleRate = 0 nativeSpectrum.setFFTSize(this.options.fftSize) nativeSpectrum.setSampleRate(this.sampleRate) nativeSpectrum.setSmoothing(this.getNativeSmoothing()) this.nativeInitialized = true console.log(`SpectrumAnalyzer: Using native DSP (${this.sampleRate}Hz)`) - } else if (!isNativeAvailable()) { + } else if (!isNativeAvailable() && !this.options.showSideLine) { console.error('SpectrumAnalyzer: Native DSP not available!') } } + private ensureJsStateSize(): void { + const { fftSize } = this.options + if (this.jsMidHistory.length === fftSize) { + return + } + + this.jsMidHistory = new Float32Array(fftSize) + this.jsSideHistory = new Float32Array(fftSize) + this.jsMidMagnitudes = new Float32Array(fftSize / 2) + this.jsSideMagnitudes = new Float32Array(fftSize / 2) + this.jsFftRe = new Float32Array(fftSize) + this.jsFftIm = new Float32Array(fftSize) + } + + private resetJsState(): void { + this.ensureJsStateSize() + this.jsMidHistory.fill(0) + this.jsSideHistory.fill(0) + this.jsMidMagnitudes.fill(FFT_SILENCE_DB) + this.jsSideMagnitudes.fill(FFT_SILENCE_DB) + this.jsFftRe.fill(0) + this.jsFftIm.fill(0) + this.jsBufferedSamples = 0 + this.jsHasSpectrumData = false + } + private updateSampleRateIfNeeded(): void { - if (!isNativeAvailable()) return const currentRate = Math.max(1, this.dataSource.getSampleRate()) if (currentRate !== this.lastSampleRate && currentRate > 0) { this.sampleRate = currentRate this.lastSampleRate = currentRate - nativeSpectrum.setSampleRate(currentRate) + if (isNativeAvailable()) { + nativeSpectrum.setSampleRate(currentRate) + } console.log(`SpectrumAnalyzer: Sample rate updated to ${currentRate}Hz`) } } @@ -190,6 +357,7 @@ export class SpectrumAnalyzer { if (isNativeAvailable()) { nativeSpectrum.reset() } + this.resetJsState() this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) this.lastSampleRate = 0 this.invalidate() @@ -204,11 +372,22 @@ export class SpectrumAnalyzer { if (optionUpdates.heatmapTiltDbPerOctave !== undefined) { nextOptions.heatmapTiltDbPerOctave = clampSpectrumHeatmapTiltDbPerOctave(optionUpdates.heatmapTiltDbPerOctave) } + + const shouldResetForOptions = ( + optionUpdates.fftSize !== undefined + || optionUpdates.smoothing !== undefined + || optionUpdates.showSideLine !== undefined + ) + this.options = nextOptions + this.heatLut = buildHeatLUT(this.options.heatColors) + let didReset = false + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() this.resetState() + didReset = true } if (isNativeAvailable()) { @@ -220,8 +399,15 @@ export class SpectrumAnalyzer { } } + if (shouldResetForOptions && !didReset) { + this.resetState() + didReset = true + } + this.staticLayerKey = '' - this.invalidate() + if (!didReset) { + this.invalidate() + } } start(): void { @@ -272,7 +458,7 @@ export class SpectrumAnalyzer { } let peak = -Infinity - for (let i = lo; i <= hi; i++) { + for (let i = lo; i <= hi; i += 1) { peak = Math.max(peak, data[i]) } @@ -295,7 +481,9 @@ export class SpectrumAnalyzer { if (pendingSpectrum.length === 1) return pendingSpectrum[0] let totalLength = 0 - for (const chunk of pendingSpectrum) totalLength += chunk.length + for (const chunk of pendingSpectrum) { + totalLength += chunk.length + } const monoData = new Float32Array(totalLength) let offset = 0 @@ -307,8 +495,196 @@ export class SpectrumAnalyzer { return monoData } + private clearPendingSpectrumQueues(): void { + this.dataSource.getPendingSpectrumSamples() + this.dataSource.getPendingSpectrumStereoSamples() + } + + private pushJsSpectrumHistory(left: Float32Array, right: Float32Array, length: number): void { + const fftSize = this.options.fftSize + if (length >= fftSize) { + const start = length - fftSize + for (let index = 0; index < fftSize; index += 1) { + const leftValue = left[start + index] ?? 0 + const rightValue = right[start + index] ?? leftValue + this.jsMidHistory[index] = (leftValue + rightValue) * 0.5 + this.jsSideHistory[index] = (leftValue - rightValue) * 0.5 + } + this.jsBufferedSamples = fftSize + return + } + + this.jsMidHistory.copyWithin(0, length) + this.jsSideHistory.copyWithin(0, length) + const writeStart = fftSize - length + for (let index = 0; index < length; index += 1) { + const leftValue = left[index] ?? 0 + const rightValue = right[index] ?? leftValue + this.jsMidHistory[writeStart + index] = (leftValue + rightValue) * 0.5 + this.jsSideHistory[writeStart + index] = (leftValue - rightValue) * 0.5 + } + this.jsBufferedSamples = Math.min(fftSize, this.jsBufferedSamples + length) + } + + private updateJsMagnitudes(history: Float32Array, smoothedMagnitudes: Float32Array): void { + const fftSize = this.options.fftSize + const window = getHannWindow(fftSize) + + for (let index = 0; index < fftSize; index += 1) { + this.jsFftRe[index] = history[index] * window[index] + this.jsFftIm[index] = 0 + } + + fft(this.jsFftRe, this.jsFftIm) + + const scale = 2 / fftSize + const smoothing = Math.min(0.99, Math.max(0, this.options.smoothing)) + for (let index = 0; index < smoothedMagnitudes.length; index += 1) { + const magnitude = Math.hypot(this.jsFftRe[index], this.jsFftIm[index]) * scale + let db = 20 * Math.log10(Math.max(magnitude, 1e-10)) + db += 6 + db = Math.min(SPECTRUM_DB_CEILING, Math.max(SPECTRUM_DB_FLOOR, db)) + + if (this.jsBufferedSamples < fftSize) { + smoothedMagnitudes[index] = db + continue + } + + smoothedMagnitudes[index] = smoothing * smoothedMagnitudes[index] + (1 - smoothing) * db + if (!Number.isFinite(smoothedMagnitudes[index])) { + smoothedMagnitudes[index] = FFT_SILENCE_DB + } + } + } + + private processJsSpectrumChunks(pendingSpectrum: SpectrumStereoChunk[]): void { + let didReceiveAudio = false + for (const chunk of pendingSpectrum) { + const length = Math.min(chunk.left.length, chunk.right.length) + if (length <= 0) { + continue + } + + this.pushJsSpectrumHistory(chunk.left, chunk.right, length) + didReceiveAudio = true + } + + if (!didReceiveAudio) { + return + } + + this.updateJsMagnitudes(this.jsMidHistory, this.jsMidMagnitudes) + this.updateJsMagnitudes(this.jsSideHistory, this.jsSideMagnitudes) + this.jsHasSpectrumData = true + } + + private buildSpectrumPoints( + frequencyData: Float32Array, + width: number, + height: number, + minFrequency: number, + maxFrequency: number, + nyquist: number, + ): SpectrumPoint[] { + const bufferLength = frequencyData.length + const binWidth = nyquist / bufferLength + const points: SpectrumPoint[] = [] + const numPoints = Math.max(2, Math.floor(width)) + + for (let index = 0; index < numPoints; index += 1) { + const t0 = index / (numPoints - 1) + const t1 = Math.min(1, (index + 1) / (numPoints - 1)) + const x = t0 * width + + const frequency0 = this.frequencyAtPosition(t0, minFrequency, maxFrequency) + const frequency1 = this.frequencyAtPosition(t1, minFrequency, maxFrequency) + const centerFrequency = (frequency0 + frequency1) * 0.5 + const bin0 = frequency0 / binWidth + const bin1 = frequency1 / binWidth + const centerBin = (bin0 + bin1) * 0.5 + const binSpan = Math.abs(bin1 - bin0) + const rawDb = binSpan <= 1 + ? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1)) + : this.getPeakInRange(frequencyData, bin0, bin1) + const db = this.applyTilt(rawDb, centerFrequency) + const heatmapDb = this.applyTilt(rawDb, centerFrequency, this.options.heatmapTiltDbPerOctave) + + const normalized = (db - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) + const heatmapNormalized = (heatmapDb - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) + + points.push({ + x, + y: height - Math.max(0, Math.min(1, normalized)) * height, + heatmapIntensity: Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA), + }) + } + + return points + } + + private renderHeatmap(points: SpectrumPoint[], width: number, height: number): void { + for (let index = 0; index < points.length; index += 1) { + const x = Math.floor(points[index].x) + const y = points[index].y + const nextX = index < points.length - 1 ? Math.floor(points[index + 1].x) : width + const columnWidth = Math.max(1, nextX - x) + const fillHeight = height - y + if (fillHeight <= 0) { + continue + } + + const lutIndex = Math.round(points[index].heatmapIntensity * 255) + const r = this.heatLut[lutIndex * 3] + const g = this.heatLut[lutIndex * 3 + 1] + const b = this.heatLut[lutIndex * 3 + 2] + + this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)` + this.ctx.fillRect(x, Math.floor(y), columnWidth, Math.ceil(fillHeight)) + } + } + + private renderGradientFill(points: SpectrumPoint[], width: number, height: number): void { + this.ctx.beginPath() + this.ctx.moveTo(points[0].x, points[0].y) + + for (let index = 1; index < points.length; index += 1) { + this.ctx.lineTo(points[index].x, points[index].y) + } + + this.ctx.lineTo(width, height) + this.ctx.lineTo(0, height) + this.ctx.closePath() + + const gradient = this.ctx.createLinearGradient(0, height, 0, 0) + const colors = this.options.gradientColors + for (let index = 0; index < colors.length; index += 1) { + gradient.addColorStop(index / (colors.length - 1), colors[index]) + } + + this.ctx.fillStyle = gradient + this.ctx.fill() + } + + private renderStroke(points: SpectrumPoint[], color: string, lineWidth: number): void { + if (points.length === 0) { + return + } + + this.ctx.beginPath() + this.ctx.moveTo(points[0].x, points[0].y) + for (let index = 1; index < points.length; index += 1) { + this.ctx.lineTo(points[index].x, points[index].y) + } + + this.ctx.lineWidth = lineWidth + this.ctx.strokeStyle = color + this.ctx.lineCap = 'round' + this.ctx.lineJoin = 'round' + this.ctx.stroke() + } + private drawFrame = (): void => { - const { canvas, ctx, options } = this + const { canvas, options } = this const width = canvas.width const height = canvas.height const dpr = window.devicePixelRatio || 1 @@ -316,11 +692,6 @@ export class SpectrumAnalyzer { return } - if (!isNativeAvailable()) { - console.error('SpectrumAnalyzer: Native DSP required') - return - } - this.updateSampleRateIfNeeded() const nyquist = this.sampleRate / 2 @@ -328,119 +699,66 @@ export class SpectrumAnalyzer { const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist)) if (!this.dataSource.isPlaying()) { - this.dataSource.getPendingSpectrumSamples() - nativeSpectrum.reset() - this.renderStaticLayer(minFrequency, maxFrequency) - return - } - - const nativeTransport = this.dataSource.getNativeVisualizerTransport?.() ?? null - const pendingSpectrum = this.dataSource.getPendingSpectrumSamples() - if (!nativeTransport) { - const monoData = this.mergePendingSpectrumChunks(pendingSpectrum) - if (monoData) { - nativeSpectrum.pushSamples(monoData) + this.clearPendingSpectrumQueues() + if (isNativeAvailable()) { + nativeSpectrum.reset() } - } - - const frequencyData = nativeTransport - ? nativeTransport.getLatestSpectrumMagnitudes() - : nativeSpectrum.getMagnitudes() - if (!frequencyData) { + this.resetJsState() this.renderStaticLayer(minFrequency, maxFrequency) return } - const bufferLength = frequencyData.length - if (bufferLength === 0) { + let primaryData: Float32Array | null = null + let secondaryData: Float32Array | null = null + + if (options.showSideLine) { + this.processJsSpectrumChunks(this.dataSource.getPendingSpectrumStereoSamples()) + primaryData = this.jsHasSpectrumData ? this.jsMidMagnitudes : null + secondaryData = this.jsHasSpectrumData ? this.jsSideMagnitudes : null + } else { + if (!isNativeAvailable()) { + console.error('SpectrumAnalyzer: Native DSP required') + this.renderStaticLayer(minFrequency, maxFrequency) + return + } + + const nativeTransport = this.dataSource.getNativeVisualizerTransport?.() ?? null + const pendingSpectrum = this.dataSource.getPendingSpectrumSamples() + if (!nativeTransport) { + const monoData = this.mergePendingSpectrumChunks(pendingSpectrum) + if (monoData) { + nativeSpectrum.pushSamples(monoData) + } + } + + primaryData = nativeTransport + ? nativeTransport.getLatestSpectrumMagnitudes() + : nativeSpectrum.getMagnitudes() + } + + if (!primaryData || primaryData.length === 0) { + this.renderStaticLayer(minFrequency, maxFrequency) return } + const primaryPoints = this.buildSpectrumPoints(primaryData, width, height, minFrequency, maxFrequency, nyquist) + const secondaryPoints = secondaryData && secondaryData.length > 0 + ? this.buildSpectrumPoints(secondaryData, width, height, minFrequency, maxFrequency, nyquist) + : null + this.renderStaticLayer(minFrequency, maxFrequency) - const binWidth = nyquist / bufferLength - const points: { x: number; y: number; heatmapIntensity: number }[] = [] - const numPoints = Math.max(2, Math.floor(width)) - - for (let i = 0; i < numPoints; i++) { - const t0 = i / (numPoints - 1) - const t1 = Math.min(1, (i + 1) / (numPoints - 1)) - const x = t0 * width - - const frequency0 = this.frequencyAtPosition(t0, minFrequency, maxFrequency) - const frequency1 = this.frequencyAtPosition(t1, minFrequency, maxFrequency) - const centerFrequency = (frequency0 + frequency1) * 0.5 - const bin0 = frequency0 / binWidth - const bin1 = frequency1 / binWidth - - const centerBin = (bin0 + bin1) * 0.5 - const binSpan = Math.abs(bin1 - bin0) - const rawDb = binSpan <= 1 - ? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1)) - : this.getPeakInRange(frequencyData, bin0, bin1) - const db = this.applyTilt(rawDb, centerFrequency) - const heatmapDb = this.applyTilt(rawDb, centerFrequency, options.heatmapTiltDbPerOctave) - - const normalized = (db - options.minDecibels) / (options.maxDecibels - options.minDecibels) - const heatmapNormalized = (heatmapDb - options.minDecibels) / (options.maxDecibels - options.minDecibels) - const y = height - Math.max(0, Math.min(1, normalized)) * height - const heatmapIntensity = Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA) - - points.push({ x, y, heatmapIntensity }) + if (options.heatmapFill && primaryPoints.length > 0) { + this.renderHeatmap(primaryPoints, width, height) + } else if (options.fillGradient && primaryPoints.length > 0) { + this.renderGradientFill(primaryPoints, width, height) } - if (options.heatmapFill && points.length > 0) { - for (let i = 0; i < points.length; i++) { - const x = Math.floor(points[i].x) - const y = points[i].y - const nextX = i < points.length - 1 ? Math.floor(points[i + 1].x) : width - const colWidth = Math.max(1, nextX - x) - const fillHeight = height - y - if (fillHeight <= 0) continue - - const intensity = points[i].heatmapIntensity - const li = Math.round(intensity * 255) - const r = HEAT_LUT[li * 3] - const g = HEAT_LUT[li * 3 + 1] - const b = HEAT_LUT[li * 3 + 2] - - ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)` - ctx.fillRect(x, Math.floor(y), colWidth, Math.ceil(fillHeight)) - } - } else if (options.fillGradient && points.length > 0) { - ctx.beginPath() - ctx.moveTo(points[0].x, points[0].y) - - for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i].x, points[i].y) - } - - ctx.lineTo(width, height) - ctx.lineTo(0, height) - ctx.closePath() - - const gradient = ctx.createLinearGradient(0, height, 0, 0) - const colors = options.gradientColors - for (let i = 0; i < colors.length; i++) { - gradient.addColorStop(i / (colors.length - 1), colors[i]) - } - - ctx.fillStyle = gradient - ctx.fill() + this.renderStroke(primaryPoints, options.lineColor, options.lineWidth * dpr) + if (secondaryPoints && secondaryPoints.length > 0) { + const secondaryLineWidth = Math.max(dpr, options.lineWidth * SIDE_LINE_WIDTH_RATIO * dpr) + this.renderStroke(secondaryPoints, options.secondaryLineColor, secondaryLineWidth) } - - ctx.beginPath() - ctx.moveTo(points[0].x, points[0].y) - - for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i].x, points[i].y) - } - - ctx.lineWidth = options.lineWidth * dpr - ctx.strokeStyle = options.lineColor - ctx.lineCap = 'round' - ctx.lineJoin = 'round' - ctx.stroke() } private renderStaticLayer(minFrequency: number, maxFrequency: number): void { @@ -547,6 +865,7 @@ export class SpectrumAnalyzer { if (isNativeAvailable()) { nativeSpectrum.reset() } + this.resetJsState() this.lastSampleRate = 0 } } diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index ec237db..e170ea6 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -23,6 +23,10 @@ export interface VUMeterOptions { mode?: VUMeterMode orientation?: VUMeterOrientation lineColor?: string + peakColor?: string + clipColor?: string + scaleColor?: string + labelColor?: string dataSource?: VUMeterDataSource frameScheduler?: FrameScheduler } @@ -33,6 +37,10 @@ const defaultOptions: ResolvedVUMeterOptions = { mode: 'bar', orientation: DEFAULT_VU_METER_ORIENTATION, lineColor: '#38bdf8', + peakColor: 'rgb(255, 127, 0)', + clipColor: 'rgba(255, 120, 80, 0.9)', + scaleColor: 'rgba(255, 255, 255, 0.12)', + labelColor: 'rgba(255, 255, 255, 0.5)', } const defaultVUMeterDataSource: VUMeterDataSource = { @@ -44,6 +52,11 @@ function colorWithAlpha(r: number, g: number, b: number, a: number): string { return `rgba(${r}, ${g}, ${b}, ${a})` } +function alphaColor(color: string, alpha: number): string { + const { r, g, b } = resolveColorToRgb(color) + return colorWithAlpha(r, g, b, alpha) +} + // ---- VU Meter class ---- export class VUMeter { @@ -245,7 +258,7 @@ export class VUMeter { const hotThreshold = this.dbToNormalized(-6) * w // Background track - ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' + ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25) ctx.fillRect(x, y, w, h) // Main level bar @@ -272,13 +285,13 @@ export class VUMeter { const peakX = x + peakNorm * w const peakInHot = peakDb > -6 ctx.fillStyle = peakInHot - ? 'rgba(255, 120, 80, 0.9)' - : colorWithAlpha(cr, cg, cb, 0.9) + ? this.options.clipColor + : this.options.peakColor ctx.fillRect(peakX - 1, y, 2, h) } // Scale ticks - ctx.fillStyle = 'rgba(255, 255, 255, 0.12)' + ctx.fillStyle = this.options.scaleColor const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0] for (const db of tickDbs) { const tickX = x + this.dbToNormalized(db) * w @@ -297,7 +310,7 @@ export class VUMeter { const levelHeight = levelNorm * h const hotThreshold = this.dbToNormalized(-6) * h - ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' + ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25) ctx.fillRect(x, y, w, h) if (levelHeight > 0) { @@ -321,12 +334,12 @@ export class VUMeter { const peakY = y + h - peakNorm * h const peakInHot = peakDb > -6 ctx.fillStyle = peakInHot - ? 'rgba(255, 120, 80, 0.9)' - : colorWithAlpha(cr, cg, cb, 0.9) + ? this.options.clipColor + : this.options.peakColor ctx.fillRect(x, peakY - 1, w, 2) } - ctx.fillStyle = 'rgba(255, 255, 255, 0.1)' + ctx.fillStyle = alphaColor(this.options.scaleColor, 0.84) const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0] for (const db of tickDbs) { const tickY = y + h - this.dbToNormalized(db) * h @@ -339,7 +352,7 @@ export class VUMeter { x: number, y: number, w: number, h: number, label: string ): void { - ctx.fillStyle = 'rgba(255, 255, 255, 0.5)' + ctx.fillStyle = this.options.labelColor ctx.font = `${Math.min(22, Math.max(10, h * 0.65))}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'middle' @@ -353,7 +366,7 @@ export class VUMeter { ): void { const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, db)) const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` - ctx.fillStyle = 'rgba(255, 255, 255, 0.4)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.8) ctx.font = `${Math.min(20, Math.max(9, h * 0.55))}px "JetBrains Mono", monospace` ctx.textAlign = 'left' ctx.textBaseline = 'middle' @@ -367,7 +380,7 @@ export class VUMeter { ): void { const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, db)) const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` - ctx.fillStyle = 'rgba(255, 255, 255, 0.4)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.8) ctx.font = `${Math.min(16, Math.max(8, h * 0.5))}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'middle' @@ -383,11 +396,11 @@ export class VUMeter { const corr = Math.max(-1, Math.min(1, this.correlation)) // Background track - ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' + ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25) ctx.fillRect(x, y, w, h) // Center line - ctx.fillStyle = 'rgba(255, 255, 255, 0.12)' + ctx.fillStyle = this.options.scaleColor ctx.fillRect(centerX - 0.5, y, 1, h) // Correlation indicator @@ -399,7 +412,7 @@ export class VUMeter { ctx.fillRect(centerX, y, indicatorWidth, h) } else { // Negative correlation: draw leftward from center (out of phase) - ctx.fillStyle = 'rgba(255, 120, 80, 0.6)' + ctx.fillStyle = alphaColor(this.options.clipColor, 0.6) ctx.fillRect(centerX - indicatorWidth, y, indicatorWidth, h) } } @@ -408,7 +421,7 @@ export class VUMeter { const fontSize = Math.min(18, Math.max(8, h * 0.55)) ctx.font = `${fontSize}px "JetBrains Mono", monospace` ctx.textBaseline = 'middle' - ctx.fillStyle = 'rgba(255, 255, 255, 0.3)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.6) ctx.textAlign = 'left' ctx.fillText('-1', x + 2, y + h / 2) ctx.textAlign = 'center' @@ -455,7 +468,7 @@ export class VUMeter { const endAngle = Math.PI * 1.75 // 315° (bottom-right) // Scale arc - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)' + ctx.strokeStyle = alphaColor(this.options.scaleColor, 0.66) ctx.lineWidth = 2 ctx.beginPath() ctx.arc(centerX, arcCenterY, arcRadius, startAngle, endAngle) @@ -470,8 +483,8 @@ export class VUMeter { const outerR = arcRadius + 2 ctx.strokeStyle = db >= -6 - ? 'rgba(255, 120, 80, 0.3)' - : 'rgba(255, 255, 255, 0.15)' + ? alphaColor(this.options.clipColor, 0.3) + : alphaColor(this.options.scaleColor, 0.9) ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(centerX + Math.cos(angle) * innerR, arcCenterY + Math.sin(angle) * innerR) @@ -507,8 +520,8 @@ export class VUMeter { const peakAngle = startAngle + peakNorm * (endAngle - startAngle) const peakInHot = peakDb > -6 ctx.fillStyle = peakInHot - ? 'rgba(255, 120, 80, 0.8)' - : colorWithAlpha(cr, cg, cb, 0.8) + ? alphaColor(this.options.clipColor, 0.8) + : alphaColor(this.options.peakColor, 0.8) ctx.beginPath() ctx.arc( centerX + Math.cos(peakAngle) * arcRadius, @@ -520,7 +533,7 @@ export class VUMeter { // Channel label const fontSize = Math.min(22, Math.max(10, h * 0.1)) - ctx.fillStyle = 'rgba(255, 255, 255, 0.45)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.9) ctx.font = `${fontSize}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'top' @@ -529,7 +542,7 @@ export class VUMeter { // dB readout const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, rmsDb)) const dbText = displayDb <= VU_METER_MIN_DB + 1 ? '-∞ dB' : `${displayDb.toFixed(1)} dB` - ctx.fillStyle = 'rgba(255, 255, 255, 0.35)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.7) ctx.font = `${Math.max(9, fontSize - 1)}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'bottom' diff --git a/src/renderer/visualizers/Vectorscope.ts b/src/renderer/visualizers/Vectorscope.ts index 30bf7be..be8692a 100644 --- a/src/renderer/visualizers/Vectorscope.ts +++ b/src/renderer/visualizers/Vectorscope.ts @@ -1,7 +1,7 @@ import { audioRouter } from '../audio/AudioRouter' import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/native' import { transformPoint, drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids' -import { MultibandSplitter, MultibandBuffer, BAND_COLORS } from './multibandSplitter' +import { MultibandSplitter, MultibandBuffer } from './multibandSplitter' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' @@ -18,6 +18,11 @@ export interface VectorscopeOptions { backgroundColor?: string showGrid?: boolean gridColor?: string + bandColors?: { + low: string + mid: string + high: string + } persistence?: number displayPoints?: number mode?: VectorscopeMode @@ -34,6 +39,11 @@ const defaultOptions: ResolvedVectorscopeOptions = { backgroundColor: 'transparent', showGrid: true, gridColor: 'rgba(255, 255, 255, 0.1)', + bandColors: { + low: '#ff4444', + mid: '#44dd44', + high: '#4488ff', + }, persistence: 0.10, displayPoints: 4096, mode: 'lissajous', @@ -370,7 +380,7 @@ export class Vectorscope { for (const band of BAND_ORDER) { const bandData = result.bands[band] - ctx.fillStyle = BAND_COLORS[band] + ctx.fillStyle = options.bandColors[band] for (let i = startIdx; i < endIdx; i++) { const point = transformPoint(bandData.left[i], bandData.right[i], mode) diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 216386e..7c6692a 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -5,18 +5,34 @@ import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' import { DEFAULT_WAVEFORM_GAIN_DB, + DEFAULT_WAVEFORM_MODE, DEFAULT_WAVEFORM_SCROLL_SPEED, clampWaveformGainDb, clampWaveformScrollSpeed, + type WaveformMode, } from '../../types/waveform' import { MultibandSplitter } from './multibandSplitter' +export interface WaveformStereoChunk { + left: Float32Array + right: Float32Array +} + export interface WaveformDataSource extends VisualizerSessionSource { getPendingWaveformSamples: () => Float32Array[] + getPendingWaveformStereoSamples: () => WaveformStereoChunk[] } export interface WaveformOptions { lineColor?: string + gridMajorColor?: string + gridMinorColor?: string + bandColors?: { + low: string + mid: string + high: string + } + mode?: WaveformMode scrollSpeed?: number gainDb?: number multiband?: boolean @@ -28,30 +44,33 @@ type ResolvedWaveformOptions = Required audioRouter.flushPendingWaveformSamples(), + getPendingWaveformStereoSamples: () => audioRouter.flushPendingWaveformStereoSamples(), ...defaultVisualizerSessionSource, } -// Calibrate 1.0x to the prior 8s window at roughly 512px wide, -// while keeping scroll speed independent from panel width. -const BASE_PIXELS_PER_SECOND = 64 - export class Waveform { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D @@ -59,24 +78,25 @@ export class Waveform { private dataSource: WaveformDataSource private frameLoop: VisualizerFrameLoop - // Offscreen canvas for scrolling content private waterfallCanvas: HTMLCanvasElement private waterfallCtx: CanvasRenderingContext2D private staticLayerCanvas: HTMLCanvasElement private staticLayerCtx: CanvasRenderingContext2D private staticLayerKey = '' - // Sample accumulator for current pixel column - private columnAccumulator: Float32Array = new Float32Array(0) + private leftColumnAccumulator: Float32Array = new Float32Array(0) + private rightColumnAccumulator: Float32Array = new Float32Array(0) private columnAccumulatorPos = 0 private samplesPerColumn = 0 private lastSampleRate = 0 - // Multiband analysis private splitter = new MultibandSplitter() - private bandLowAcc: Float32Array = new Float32Array(0) - private bandMidAcc: Float32Array = new Float32Array(0) - private bandHighAcc: Float32Array = new Float32Array(0) + private leftBandLowAcc: Float32Array = new Float32Array(0) + private leftBandMidAcc: Float32Array = new Float32Array(0) + private leftBandHighAcc: Float32Array = new Float32Array(0) + private rightBandLowAcc: Float32Array = new Float32Array(0) + private rightBandMidAcc: Float32Array = new Float32Array(0) + private rightBandHighAcc: Float32Array = new Float32Array(0) private unsubscribeSessionChange: (() => void) | null = null constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) { @@ -90,6 +110,7 @@ export class Waveform { this.options = { ...defaultOptions, ...optionOverrides, + mode: optionOverrides.mode ?? defaultOptions.mode, scrollSpeed: clampWaveformScrollSpeed(optionOverrides.scrollSpeed ?? defaultOptions.scrollSpeed), gainDb: clampWaveformGainDb(optionOverrides.gainDb ?? defaultOptions.gainDb), multiband: optionOverrides.multiband ?? defaultOptions.multiband, @@ -108,6 +129,7 @@ export class Waveform { if (!waterfallCtx) throw new Error('Could not get waterfall 2D context') this.waterfallCtx = waterfallCtx this.waterfallCtx.imageSmoothingEnabled = false + this.staticLayerCanvas = document.createElement('canvas') const staticLayerCtx = this.staticLayerCanvas.getContext('2d') if (!staticLayerCtx) throw new Error('Could not get static 2D context') @@ -139,10 +161,14 @@ export class Waveform { const next = Math.max(1, Math.round(sampleRate / pixelsPerSecond)) if (next !== this.samplesPerColumn) { this.samplesPerColumn = next - this.columnAccumulator = new Float32Array(next) - this.bandLowAcc = new Float32Array(next) - this.bandMidAcc = new Float32Array(next) - this.bandHighAcc = new Float32Array(next) + this.leftColumnAccumulator = new Float32Array(next) + this.rightColumnAccumulator = new Float32Array(next) + this.leftBandLowAcc = new Float32Array(next) + this.leftBandMidAcc = new Float32Array(next) + this.leftBandHighAcc = new Float32Array(next) + this.rightBandLowAcc = new Float32Array(next) + this.rightBandMidAcc = new Float32Array(next) + this.rightBandHighAcc = new Float32Array(next) this.columnAccumulatorPos = 0 } this.lastSampleRate = sampleRate @@ -154,6 +180,7 @@ export class Waveform { const nextOptions: ResolvedWaveformOptions = { ...this.options, ...optionUpdates, + mode: optionUpdates.mode ?? this.options.mode, lineColor: optionUpdates.lineColor ?? this.options.lineColor, scrollSpeed: clampWaveformScrollSpeed(optionUpdates.scrollSpeed ?? this.options.scrollSpeed), gainDb: clampWaveformGainDb(optionUpdates.gainDb ?? this.options.gainDb), @@ -161,20 +188,26 @@ export class Waveform { } const speedChanged = nextOptions.scrollSpeed !== this.options.scrollSpeed const multibandChanged = nextOptions.multiband !== this.options.multiband + const modeChanged = nextOptions.mode !== this.options.mode + const dataSourceChanged = Boolean(dataSource && dataSource !== this.dataSource) this.options = nextOptions - if (dataSource && dataSource !== this.dataSource) { + + if (dataSourceChanged && dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() - this.recomputeSamplesPerColumn() - this.resetDisplay() } - if (speedChanged) { + + if (dataSourceChanged || speedChanged) { this.recomputeSamplesPerColumn() - this.resetDisplay() } - if (multibandChanged) { + + if (multibandChanged || modeChanged) { this.splitter.reset() + } + + this.staticLayerKey = '' + if (dataSourceChanged || speedChanged || multibandChanged || modeChanged) { this.resetDisplay() } @@ -194,37 +227,46 @@ export class Waveform { } resize(): void { - // Resize handled in draw loop this.staticLayerKey = '' this.invalidate() } - private computeMinMax(): { min: number; max: number } { - let min = this.columnAccumulator[0] - let max = this.columnAccumulator[0] + private computeMinMax(samples: Float32Array): { min: number; max: number } { + if (this.columnAccumulatorPos === 0) { + return { min: 0, max: 0 } + } + + let min = samples[0] + let max = samples[0] for (let i = 1; i < this.columnAccumulatorPos; i++) { - const s = this.columnAccumulator[i] - if (s < min) min = s - if (s > max) max = s + const sample = samples[i] + if (sample < min) min = sample + if (sample > max) max = sample } return { min, max } } - private computeBandColor(): [number, number, number] { + private computeBandColor( + lowBandSamples: Float32Array, + midBandSamples: Float32Array, + highBandSamples: Float32Array, + ): [number, number, number] { + const lowBand = this.toBandColorTuple(this.options.bandColors.low) + const midBand = this.toBandColorTuple(this.options.bandColors.mid) + const highBand = this.toBandColorTuple(this.options.bandColors.high) const n = this.columnAccumulatorPos - if (n === 0) return BAND_MID + if (n === 0) return midBand - // Compute RMS energy for each band let lowSum = 0 let midSum = 0 let highSum = 0 for (let i = 0; i < n; i++) { - const l = this.bandLowAcc[i] - const m = this.bandMidAcc[i] - const h = this.bandHighAcc[i] - lowSum += l * l - midSum += m * m - highSum += h * h + const low = lowBandSamples[i] + const mid = midBandSamples[i] + const high = highBandSamples[i] + lowSum += low * low + midSum += mid * mid + highSum += high * high } const lowRms = Math.sqrt(lowSum / n) @@ -232,7 +274,7 @@ export class Waveform { const highRms = Math.sqrt(highSum / n) const total = lowRms + midRms + highRms - if (total < 1e-10) return BAND_MID + if (total < 1e-10) return midBand const emphasizedWeights = [ Math.pow(lowRms / total, MULTIBAND_WEIGHT_EMPHASIS), @@ -240,12 +282,12 @@ export class Waveform { Math.pow(highRms / total, MULTIBAND_WEIGHT_EMPHASIS), ] as const const emphasizedTotal = emphasizedWeights[0] + emphasizedWeights[1] + emphasizedWeights[2] - if (emphasizedTotal < 1e-10) return BAND_MID + if (emphasizedTotal < 1e-10) return midBand const normalizedBands = [ - { color: BAND_LOW, weight: emphasizedWeights[0] / emphasizedTotal }, - { color: BAND_MID, weight: emphasizedWeights[1] / emphasizedTotal }, - { color: BAND_HIGH, weight: emphasizedWeights[2] / emphasizedTotal }, + { color: lowBand, weight: emphasizedWeights[0] / emphasizedTotal }, + { color: midBand, weight: emphasizedWeights[1] / emphasizedTotal }, + { color: highBand, weight: emphasizedWeights[2] / emphasizedTotal }, ] as const const blended: [number, number, number] = [ @@ -272,40 +314,54 @@ export class Waveform { ] } - private shiftAndPaintColumn(min: number, max: number, width: number, height: number): void { - // Shift existing content left by 1 pixel — use 'copy' to avoid - // alpha accumulation from source-over compositing on semi-transparent pixels + private toBandColorTuple(color: string): [number, number, number] { + const { r, g, b } = resolveColorToRgb(color) + return [r, g, b] + } + + private resolveColumnColor( + lowBandSamples: Float32Array, + midBandSamples: Float32Array, + highBandSamples: Float32Array, + ): [number, number, number] { + if (this.options.multiband) { + return this.computeBandColor(lowBandSamples, midBandSamples, highBandSamples) + } + + const lineColor = resolveColorToRgb(this.options.lineColor) + return [lineColor.r, lineColor.g, lineColor.b] + } + + private shiftWaterfall(): void { this.waterfallCtx.globalCompositeOperation = 'copy' this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0) this.waterfallCtx.globalCompositeOperation = 'source-over' + } - const centerY = height / 2 + private paintColumn( + min: number, + max: number, + width: number, + laneTop: number, + laneHeight: number, + color: [number, number, number], + ): void { const amplitudeGain = Math.pow(10, this.options.gainDb / 20) const scaledMin = Math.max(-1, Math.min(1, min * amplitudeGain)) const scaledMax = Math.max(-1, Math.min(1, max * amplitudeGain)) - const displayMargin = 0.95 // slight margin so full-scale doesn't clip at edge - const yTop = Math.round(centerY - scaledMax * centerY * displayMargin) - const yBottom = Math.round(centerY - scaledMin * centerY * displayMargin) + const centerY = laneTop + (laneHeight / 2) + const displayHalfHeight = (laneHeight / 2) * DISPLAY_MARGIN + const yTop = Math.round(centerY - scaledMax * displayHalfHeight) + const yBottom = Math.round(centerY - scaledMin * displayHalfHeight) const lineHeight = Math.max(1, yBottom - yTop) - let r: number, g: number, b: number - if (this.options.multiband) { - ;[r, g, b] = this.computeBandColor() - } else { - const lineColor = resolveColorToRgb(this.options.lineColor) - r = lineColor.r - g = lineColor.g - b = lineColor.b - } - const fillAlpha = this.options.multiband ? MULTIBAND_FILL_ALPHA : 0.55 const edgeAlpha = this.options.multiband ? MULTIBAND_EDGE_ALPHA : 0.9 + const [r, g, b] = color - // Draw the amplitude column — brighter at the edges, dimmer in the middle this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${fillAlpha})` this.waterfallCtx.fillRect(width - 1, yTop, 1, lineHeight) - // Bright edge pixels at min/max this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${edgeAlpha})` this.waterfallCtx.fillRect(width - 1, yTop, 1, 1) if (lineHeight > 1) { @@ -320,7 +376,7 @@ export class Waveform { } private ensureStaticLayer(width: number, height: number): void { - const key = `${width}:${height}` + const key = `${width}:${height}:${this.options.mode}` if (this.staticLayerKey === key) { return } @@ -333,18 +389,25 @@ export class Waveform { } private drawGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void { + if (this.options.mode === 'stereo') { + this.drawStereoGrid(ctx, width, height) + return + } + + this.drawMonoGrid(ctx, width, height) + } + + private drawMonoGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void { const centerY = height / 2 - // Center line (zero crossing) - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)' + ctx.strokeStyle = this.options.gridMajorColor ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(0, centerY) ctx.lineTo(width, centerY) ctx.stroke() - // ±0.5 guide lines - ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)' + ctx.strokeStyle = this.options.gridMinorColor const quarterY = centerY * 0.5 ctx.beginPath() ctx.moveTo(0, quarterY) @@ -354,6 +417,124 @@ export class Waveform { ctx.stroke() } + private drawStereoGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void { + const laneHeight = height / 2 + + ctx.strokeStyle = this.options.gridMajorColor + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(0, laneHeight * 0.5) + ctx.lineTo(width, laneHeight * 0.5) + ctx.moveTo(0, laneHeight) + ctx.lineTo(width, laneHeight) + ctx.moveTo(0, laneHeight * 1.5) + ctx.lineTo(width, laneHeight * 1.5) + ctx.stroke() + + ctx.strokeStyle = this.options.gridMinorColor + ctx.beginPath() + ctx.moveTo(0, laneHeight * 0.25) + ctx.lineTo(width, laneHeight * 0.25) + ctx.moveTo(0, laneHeight * 0.75) + ctx.lineTo(width, laneHeight * 0.75) + ctx.moveTo(0, laneHeight * 1.25) + ctx.lineTo(width, laneHeight * 1.25) + ctx.moveTo(0, laneHeight * 1.75) + ctx.lineTo(width, laneHeight * 1.75) + ctx.stroke() + } + + private drainPendingSamples(): void { + if (this.options.mode === 'stereo') { + this.dataSource.getPendingWaveformStereoSamples() + return + } + + this.dataSource.getPendingWaveformSamples() + } + + private processMonoChunk(chunk: Float32Array, width: number, height: number): void { + let lowBand: Float32Array | null = null + let midBand: Float32Array | null = null + let highBand: Float32Array | null = null + if (this.options.multiband) { + const bands = this.splitter.split(chunk, chunk) + lowBand = bands.low.left + midBand = bands.mid.left + highBand = bands.high.left + } + + for (let i = 0; i < chunk.length; i++) { + this.leftColumnAccumulator[this.columnAccumulatorPos] = chunk[i] + if (lowBand && midBand && highBand) { + this.leftBandLowAcc[this.columnAccumulatorPos] = lowBand[i] + this.leftBandMidAcc[this.columnAccumulatorPos] = midBand[i] + this.leftBandHighAcc[this.columnAccumulatorPos] = highBand[i] + } + this.columnAccumulatorPos += 1 + + if (this.columnAccumulatorPos >= this.samplesPerColumn) { + const { min, max } = this.computeMinMax(this.leftColumnAccumulator) + const color = this.resolveColumnColor(this.leftBandLowAcc, this.leftBandMidAcc, this.leftBandHighAcc) + this.shiftWaterfall() + this.paintColumn(min, max, width, 0, height, color) + this.columnAccumulatorPos = 0 + } + } + } + + private processStereoChunk(chunk: WaveformStereoChunk, width: number, height: number): void { + const length = Math.min(chunk.left.length, chunk.right.length) + if (length === 0) { + return + } + + const leftSamples = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length) + const rightSamples = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length) + + let lowLeft: Float32Array | null = null + let midLeft: Float32Array | null = null + let highLeft: Float32Array | null = null + let lowRight: Float32Array | null = null + let midRight: Float32Array | null = null + let highRight: Float32Array | null = null + if (this.options.multiband) { + const bands = this.splitter.split(leftSamples, rightSamples) + lowLeft = bands.low.left + midLeft = bands.mid.left + highLeft = bands.high.left + lowRight = bands.low.right + midRight = bands.mid.right + highRight = bands.high.right + } + + const laneHeight = height / 2 + for (let i = 0; i < length; i++) { + this.leftColumnAccumulator[this.columnAccumulatorPos] = leftSamples[i] + this.rightColumnAccumulator[this.columnAccumulatorPos] = rightSamples[i] + if (lowLeft && midLeft && highLeft && lowRight && midRight && highRight) { + this.leftBandLowAcc[this.columnAccumulatorPos] = lowLeft[i] + this.leftBandMidAcc[this.columnAccumulatorPos] = midLeft[i] + this.leftBandHighAcc[this.columnAccumulatorPos] = highLeft[i] + this.rightBandLowAcc[this.columnAccumulatorPos] = lowRight[i] + this.rightBandMidAcc[this.columnAccumulatorPos] = midRight[i] + this.rightBandHighAcc[this.columnAccumulatorPos] = highRight[i] + } + this.columnAccumulatorPos += 1 + + if (this.columnAccumulatorPos >= this.samplesPerColumn) { + const leftMinMax = this.computeMinMax(this.leftColumnAccumulator) + const rightMinMax = this.computeMinMax(this.rightColumnAccumulator) + const leftColor = this.resolveColumnColor(this.leftBandLowAcc, this.leftBandMidAcc, this.leftBandHighAcc) + const rightColor = this.resolveColumnColor(this.rightBandLowAcc, this.rightBandMidAcc, this.rightBandHighAcc) + this.shiftWaterfall() + this.paintColumn(leftMinMax.min, leftMinMax.max, width, 0, laneHeight, leftColor) + this.paintColumn(rightMinMax.min, rightMinMax.max, width, laneHeight, laneHeight, rightColor) + this.columnAccumulatorPos = 0 + } + } + } + private drawFrame = (): void => { const width = this.canvas.width const height = this.canvas.height @@ -364,7 +545,6 @@ export class Waveform { this.ctx.imageSmoothingEnabled = false - // Handle resize: preserve existing content anchored to right edge if (this.waterfallCanvas.width !== width || this.waterfallCanvas.height !== height) { const previousCanvas = document.createElement('canvas') previousCanvas.width = this.waterfallCanvas.width @@ -385,7 +565,7 @@ export class Waveform { this.waterfallCtx.drawImage( previousCanvas, srcX, 0, srcW, previousCanvas.height, - dstX, 0, srcW, height + dstX, 0, srcW, height, ) } @@ -393,52 +573,27 @@ export class Waveform { this.staticLayerKey = '' } - // Handle sample rate changes const sampleRate = this.dataSource.getSampleRate() if (Math.abs(sampleRate - this.lastSampleRate) > 100) { this.recomputeSamplesPerColumn() } if (!this.dataSource.isPlaying()) { - this.dataSource.getPendingWaveformSamples() // drain - // Freeze display — show last waveform + this.drainPendingSamples() this.renderStaticLayer(width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) return } - const pending = this.dataSource.getPendingWaveformSamples() - const samplesPerCol = this.samplesPerColumn - const multiband = this.options.multiband - - if (samplesPerCol > 0) { + if (this.options.mode === 'stereo') { + const pending = this.dataSource.getPendingWaveformStereoSamples() for (const chunk of pending) { - // When multiband is enabled, split each chunk through the crossover filters - let lowBand: Float32Array | null = null - let midBand: Float32Array | null = null - let highBand: Float32Array | null = null - if (multiband) { - const bands = this.splitter.split(chunk, chunk) - lowBand = bands.low.left - midBand = bands.mid.left - highBand = bands.high.left - } - - for (let i = 0; i < chunk.length; i++) { - this.columnAccumulator[this.columnAccumulatorPos] = chunk[i] - if (multiband && lowBand && midBand && highBand) { - this.bandLowAcc[this.columnAccumulatorPos] = lowBand[i] - this.bandMidAcc[this.columnAccumulatorPos] = midBand[i] - this.bandHighAcc[this.columnAccumulatorPos] = highBand[i] - } - this.columnAccumulatorPos++ - - if (this.columnAccumulatorPos >= samplesPerCol) { - const { min, max } = this.computeMinMax() - this.shiftAndPaintColumn(min, max, width, height) - this.columnAccumulatorPos = 0 - } - } + this.processStereoChunk(chunk, width, height) + } + } else { + const pending = this.dataSource.getPendingWaveformSamples() + for (const chunk of pending) { + this.processMonoChunk(chunk, width, height) } } diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 2334019..234cd7e 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -7,8 +7,9 @@ import { PROFILE_LOCAL_STATE_VERSION, type Profile, type ProfileLocalMetadata, + type PrismProfileFile, type PrismProfileFileScopePopoutMap, - type PrismProfileFileV1, + type PrismProfileFileV2, type PrismProfileLocalStateV1, } from '../types/profile' import { SCOPE_KINDS, type ScopeKind } from '../types/scope' @@ -152,6 +153,7 @@ export function normalizeProfileName(value: unknown, fallback = DEFAULT_PROFILE_ export function createDefaultProfile(name = DEFAULT_PROFILE_NAME): Profile { return { name, + themeId: null, scopeOrder: [...SCOPE_KINDS], hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)), widthWeights: { ...DEFAULT_SCOPE_WIDTH_WEIGHTS }, @@ -167,6 +169,9 @@ export function normalizeProfile(raw: unknown, fallbackName = DEFAULT_PROFILE_NA return { name: normalizeProfileName(parsed.name, fallbackName), + themeId: typeof parsed.themeId === 'string' && parsed.themeId.trim() + ? parsed.themeId.trim() + : null, scopeOrder: normalizeScopeOrder(parsed.scopeOrder), hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), widthWeights: normalizeWidthWeights(parsed.widthWeights), @@ -187,13 +192,21 @@ export function normalizeProfileFileScopePopouts(raw: unknown): PrismProfileFile }, {} as PrismProfileFileScopePopoutMap) } +function readProfileFileThemeId(file: Partial | PrismProfileFile): string | null { + if (!('themeId' in file)) return null + const { themeId } = file + return typeof themeId === 'string' && themeId.trim() + ? themeId.trim() + : null +} + export function normalizeProfileFile( raw: unknown, fallbackId: string, fallbackName = DEFAULT_PROFILE_NAME, -): PrismProfileFileV1 { +) : PrismProfileFileV2 { const parsed = typeof raw === 'object' && raw !== null - ? raw as Partial + ? raw as Partial : {} const id = typeof parsed.id === 'string' && parsed.id.trim() @@ -207,6 +220,7 @@ export function normalizeProfileFile( version: PROFILE_FILE_VERSION, id, name, + themeId: readProfileFileThemeId(parsed), scopeOrder: normalizeScopeOrder(parsed.scopeOrder), hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), widthWeights: normalizeWidthWeights(parsed.widthWeights), @@ -215,7 +229,7 @@ export function normalizeProfileFile( } } -export function profileToFileData(id: string, profile: Profile): PrismProfileFileV1 { +export function profileToFileData(id: string, profile: Profile): PrismProfileFileV2 { const normalized = normalizeProfile(profile, profile.name) return { @@ -223,6 +237,7 @@ export function profileToFileData(id: string, profile: Profile): PrismProfileFil version: PROFILE_FILE_VERSION, id, name: normalized.name, + themeId: normalized.themeId, scopeOrder: [...normalized.scopeOrder], hiddenScopes: [...normalized.hiddenScopes], widthWeights: { ...normalized.widthWeights }, @@ -308,13 +323,14 @@ export function extractLocalProfileMetadata(profile: Profile): ProfileLocalMetad } export function profileFileToProfile( - file: PrismProfileFileV1, + file: PrismProfileFile, localMetadata?: ProfileLocalMetadata, ): Profile { const metadata = normalizeProfileLocalMetadata(localMetadata) return { name: normalizeProfileName(file.name, DEFAULT_PROFILE_NAME), + themeId: readProfileFileThemeId(file), scopeOrder: normalizeScopeOrder(file.scopeOrder), hiddenScopes: normalizeHiddenScopes(file.hiddenScopes), widthWeights: normalizeWidthWeights(file.widthWeights), diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts new file mode 100644 index 0000000..818eaa0 --- /dev/null +++ b/src/shared/themeState.ts @@ -0,0 +1,922 @@ +import { + DEFAULT_THEME_ID, + DEFAULT_THEME_NAME, + LEGACY_THEME_MIGRATION_VERSION, + THEME_FILE_FORMAT, + THEME_FILE_VERSION, + THEME_LOCAL_STATE_FORMAT, + THEME_LOCAL_STATE_VERSION, + type LegacyThemeMigrationPayload, + type PrismResolvedTheme, + type PrismTheme, + type PrismThemeLocalStateV1, + type ResolvedInterfaceTheme, + type ResolvedLUFSMeterTheme, + type ResolvedOscilloscopeTheme, + type ResolvedSpectrogramTheme, + type ResolvedSpectrumTheme, + type ResolvedVectorscopeTheme, + type ResolvedVUMeterTheme, + type ResolvedWaveformTheme, + type ThemeSectionName, + type ThemeTokens, +} from '../types/theme' + +const DEFAULT_BAND_LOW = '#ff4444' +const DEFAULT_BAND_MID = '#44dd44' +const DEFAULT_BAND_HIGH = '#4488ff' +const DEFAULT_WARNING = 'rgb(255, 191, 0)' +const DEFAULT_SUCCESS = '#22c55e' +const DEFAULT_DANGER = '#f87171' + +const MODULE_SECTION_ORDER: ThemeSectionName[] = [ + 'all', + 'interface', + 'spectrum', + 'oscilloscope', + 'vectorscope', + 'spectrogram', + 'vumeter', + 'lufsmeter', + 'waveform', +] + +const COLOR_KEY_ORDER: Array = [ + 'primary', + 'secondary', + 'guides', + 'text', + 'background', + 'lowBand', + 'midBand', + 'highBand', + 'fill', + 'peak', + 'clip', + 'target', + 'heatLow', + 'heatMid', + 'heatHigh', + 'success', + 'warning', + 'danger', +] + +const SECTION_KEY_MAP: Record = { + all: 'all', + interface: 'interface', + spectrum: 'spectrum', + oscilloscope: 'oscilloscope', + vectorscope: 'vectorscope', + spectrogram: 'spectrogram', + vumeter: 'vumeter', + lufsmeter: 'lufsmeter', + waveform: 'waveform', +} + +const TOKEN_KEY_MAP: Record = { + primary: 'primary', + secondary: 'secondary', + guides: 'guides', + text: 'text', + background: 'background', + low_band: 'lowBand', + mid_band: 'midBand', + high_band: 'highBand', + fill: 'fill', + peak: 'peak', + clip: 'clip', + target: 'target', + heat_low: 'heatLow', + heat_mid: 'heatMid', + heat_high: 'heatHigh', + success: 'success', + warning: 'warning', + danger: 'danger', +} + +const TOKEN_PROPERTY_KEY_MAP: Record = { + primary: 'primary', + secondary: 'secondary', + guides: 'guides', + text: 'text', + background: 'background', + lowBand: 'lowBand', + midBand: 'midBand', + highBand: 'highBand', + fill: 'fill', + peak: 'peak', + clip: 'clip', + target: 'target', + heatLow: 'heatLow', + heatMid: 'heatMid', + heatHigh: 'heatHigh', + success: 'success', + warning: 'warning', + danger: 'danger', +} + +interface RgbaColor { + r: number + g: number + b: number + a: number +} + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))) +} + +function clampAlpha(value: number): number { + return Math.max(0, Math.min(1, value)) +} + +function normalizeKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^\w]+/g, '_') + .replace(/^_+|_+$/g, '') +} + +function parseByte(token: string): number | null { + const value = Number.parseFloat(token.trim()) + if (!Number.isFinite(value)) return null + return clampByte(value) +} + +function parseThemeChannelColor(value: string): RgbaColor | null { + const parts = value.split(',').map((token) => token.trim()).filter(Boolean) + if (parts.length < 3 || parts.length > 4) return null + + const r = parseByte(parts[0]) + const g = parseByte(parts[1]) + const b = parseByte(parts[2]) + if (r === null || g === null || b === null) return null + + const a = parts.length === 4 ? parseByte(parts[3]) : 255 + if (a === null) return null + + return { + r, + g, + b, + a: clampAlpha(a / 255), + } +} + +function parseCssToken(token: string): number | null { + const trimmed = token.trim() + if (!trimmed) return null + if (trimmed.endsWith('%')) { + const percent = Number.parseFloat(trimmed.slice(0, -1)) + if (!Number.isFinite(percent)) return null + return clampByte((percent / 100) * 255) + } + const value = Number.parseFloat(trimmed) + if (!Number.isFinite(value)) return null + return clampByte(value) +} + +function parseCssAlpha(token: string): number | null { + const trimmed = token.trim() + if (!trimmed) return null + if (trimmed.endsWith('%')) { + const percent = Number.parseFloat(trimmed.slice(0, -1)) + if (!Number.isFinite(percent)) return null + return clampAlpha(percent / 100) + } + const value = Number.parseFloat(trimmed) + if (!Number.isFinite(value)) return null + return value > 1 ? clampAlpha(value / 255) : clampAlpha(value) +} + +function parseCssColor(value: string): RgbaColor | null { + const normalized = value.trim() + if (!normalized) return null + + if (normalized.startsWith('#')) { + const raw = normalized.slice(1) + const expanded = raw.length === 3 || raw.length === 4 + ? raw.split('').map((part) => `${part}${part}`).join('') + : raw + + if (expanded.length !== 6 && expanded.length !== 8) return null + + const r = Number.parseInt(expanded.slice(0, 2), 16) + const g = Number.parseInt(expanded.slice(2, 4), 16) + const b = Number.parseInt(expanded.slice(4, 6), 16) + const a = expanded.length === 8 + ? Number.parseInt(expanded.slice(6, 8), 16) / 255 + : 1 + + if ([r, g, b].some((channel) => Number.isNaN(channel))) { + return null + } + + return { r, g, b, a: clampAlpha(a) } + } + + const match = /^rgba?\((.*)\)$/i.exec(normalized) + if (!match) return null + + const body = match[1]?.trim() ?? '' + if (!body) return null + + const [colorPart, alphaPart] = body.includes('/') + ? body.split('/', 2) + : [body, undefined] + + const colorTokens = colorPart.includes(',') + ? colorPart.split(',').map((token) => token.trim()) + : colorPart.split(/\s+/).filter(Boolean) + + if (colorTokens.length < 3) return null + + const r = parseCssToken(colorTokens[0]) + const g = parseCssToken(colorTokens[1]) + const b = parseCssToken(colorTokens[2]) + if (r === null || g === null || b === null) return null + + const rawAlpha = alphaPart ?? colorTokens[3] + const a = rawAlpha ? parseCssAlpha(rawAlpha) : 1 + if (a === null) return null + + return { r, g, b, a } +} + +function toCssColor(color: RgbaColor): string { + if (Math.abs(color.a - 1) < 0.001) { + return `rgb(${color.r}, ${color.g}, ${color.b})` + } + return `rgba(${color.r}, ${color.g}, ${color.b}, ${Number(color.a.toFixed(3))})` +} + +function quantizeThemeColor(color: RgbaColor): RgbaColor { + return { + r: clampByte(color.r), + g: clampByte(color.g), + b: clampByte(color.b), + a: clampAlpha(clampByte(color.a * 255) / 255), + } +} + +function toThemeChannels(color: string): string { + const parsed = parseCssColor(color) + if (!parsed) return '0, 0, 0' + if (Math.abs(parsed.a - 1) < 0.001) { + return `${parsed.r}, ${parsed.g}, ${parsed.b}` + } + return `${parsed.r}, ${parsed.g}, ${parsed.b}, ${clampByte(parsed.a * 255)}` +} + +function withAlpha(color: string, alpha: number): string { + const parsed = parseCssColor(color) + if (!parsed) return color + return toCssColor({ ...parsed, a: clampAlpha(alpha) }) +} + +function multiplyAlpha(color: string, factor: number): string { + const parsed = parseCssColor(color) + if (!parsed) return color + return toCssColor({ ...parsed, a: clampAlpha(parsed.a * factor) }) +} + +function mixColors(left: string, right: string, amount: number): string { + const leftColor = parseCssColor(left) + const rightColor = parseCssColor(right) + if (!leftColor) return right + if (!rightColor) return left + + const t = clampAlpha(amount) + return toCssColor({ + r: clampByte(leftColor.r + (rightColor.r - leftColor.r) * t), + g: clampByte(leftColor.g + (rightColor.g - leftColor.g) * t), + b: clampByte(leftColor.b + (rightColor.b - leftColor.b) * t), + a: clampAlpha(leftColor.a + (rightColor.a - leftColor.a) * t), + }) +} + +function lighten(color: string, amount: number): string { + return mixColors(color, 'rgb(255, 255, 255)', amount) +} + +function darken(color: string, amount: number): string { + return mixColors(color, 'rgb(0, 0, 0)', amount) +} + +function colorToRgbChannels(color: string): string { + const parsed = parseCssColor(color) + if (!parsed) return '0, 0, 0' + return `${parsed.r}, ${parsed.g}, ${parsed.b}` +} + +function createEmptyThemeTokens(): ThemeTokens { + return {} +} + +function createEmptyTheme(): PrismTheme { + return { + id: DEFAULT_THEME_ID, + name: DEFAULT_THEME_NAME, + all: createEmptyThemeTokens(), + interface: createEmptyThemeTokens(), + spectrum: createEmptyThemeTokens(), + oscilloscope: createEmptyThemeTokens(), + vectorscope: createEmptyThemeTokens(), + spectrogram: createEmptyThemeTokens(), + vumeter: createEmptyThemeTokens(), + lufsmeter: createEmptyThemeTokens(), + waveform: createEmptyThemeTokens(), + } +} + +function mergeThemeTokens(base: ThemeTokens, overrides?: ThemeTokens): ThemeTokens { + return { + ...base, + ...(overrides ?? {}), + } +} + +function normalizeTokens(raw: unknown): ThemeTokens { + if (typeof raw !== 'object' || raw === null) { + return createEmptyThemeTokens() + } + + const parsed = raw as Record + const next: ThemeTokens = {} + + for (const [rawKey, rawValue] of Object.entries(parsed)) { + if (typeof rawValue !== 'string') continue + const key = TOKEN_PROPERTY_KEY_MAP[rawKey] ?? TOKEN_KEY_MAP[normalizeKey(rawKey)] + if (!key) continue + const parsedColor = parseCssColor(rawValue) ?? parseThemeChannelColor(rawValue) + if (!parsedColor) continue + next[key] = toCssColor(quantizeThemeColor(parsedColor)) + } + + return next +} + +export function createDefaultTheme(): PrismTheme { + return normalizeTheme({ + id: DEFAULT_THEME_ID, + name: DEFAULT_THEME_NAME, + credit: 'Prism', + all: { + primary: '#38bdf8', + secondary: 'rgb(172, 192, 222)', + guides: 'rgba(255, 255, 255, 0.1)', + text: 'rgb(255, 255, 255)', + background: 'rgb(0, 0, 0)', + lowBand: DEFAULT_BAND_LOW, + midBand: DEFAULT_BAND_MID, + highBand: DEFAULT_BAND_HIGH, + success: DEFAULT_SUCCESS, + warning: DEFAULT_WARNING, + danger: DEFAULT_DANGER, + }, + interface: { + secondary: 'rgba(8, 11, 16, 0.92)', + guides: 'rgba(255, 255, 255, 0.09)', + background: 'rgb(0, 0, 0)', + }, + spectrum: { + secondary: 'rgba(56, 189, 248, 0.5)', + heatLow: 'rgb(15, 7, 33)', + heatMid: 'rgb(163, 26, 121)', + heatHigh: 'rgb(255, 241, 209)', + }, + oscilloscope: { + fill: 'rgba(245, 248, 252, 0.18)', + }, + spectrogram: { + heatLow: 'rgb(15, 7, 33)', + heatMid: 'rgb(163, 26, 121)', + heatHigh: 'rgb(255, 241, 209)', + }, + vumeter: { + peak: 'rgb(255, 127, 0)', + clip: 'rgba(255, 120, 80, 0.9)', + }, + lufsmeter: { + target: 'rgba(56, 189, 248, 0.25)', + }, + }, DEFAULT_THEME_ID, DEFAULT_THEME_NAME) +} + +function cloneTheme(theme: PrismTheme): PrismTheme { + return JSON.parse(JSON.stringify(theme)) as PrismTheme +} + +function createPresetTheme(id: string, name: string, primary: string): PrismTheme { + const base = cloneTheme(createDefaultTheme()) + base.id = id + base.name = name + base.all.primary = primary + base.spectrum.secondary = multiplyAlpha(primary, 0.6) + base.lufsmeter.target = withAlpha(primary, 0.25) + return normalizeTheme(base, id, name) +} + +export function createBundledThemes(): PrismTheme[] { + return [ + createDefaultTheme(), + createPresetTheme('theme_graphite', 'Graphite', '#4fc3f7'), + createPresetTheme('theme_midnight', 'Midnight', '#4f9bff'), + createPresetTheme('theme_green', 'Green', '#4ade80'), + createPresetTheme('theme_purple', 'Purple', '#a78bfa'), + createPresetTheme('theme_rose', 'Rose', '#fb7185'), + ] +} + +export function normalizeTheme( + raw: unknown, + fallbackId = DEFAULT_THEME_ID, + fallbackName = DEFAULT_THEME_NAME, +): PrismTheme { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial + : {} + + const id = typeof parsed.id === 'string' && parsed.id.trim() + ? parsed.id.trim() + : fallbackId + + const name = typeof parsed.name === 'string' && parsed.name.trim() + ? parsed.name.trim() + : fallbackName + + const normalized = createEmptyTheme() + normalized.id = id + normalized.name = name + normalized.credit = typeof parsed.credit === 'string' && parsed.credit.trim() + ? parsed.credit.trim() + : undefined + normalized.website = typeof parsed.website === 'string' && parsed.website.trim() + ? parsed.website.trim() + : undefined + normalized.description = typeof parsed.description === 'string' && parsed.description.trim() + ? parsed.description.trim() + : undefined + normalized.all = normalizeTokens(parsed.all) + normalized.interface = normalizeTokens(parsed.interface) + normalized.spectrum = normalizeTokens(parsed.spectrum) + normalized.oscilloscope = normalizeTokens(parsed.oscilloscope) + normalized.vectorscope = normalizeTokens(parsed.vectorscope) + normalized.spectrogram = normalizeTokens(parsed.spectrogram) + normalized.vumeter = normalizeTokens(parsed.vumeter) + normalized.lufsmeter = normalizeTokens(parsed.lufsmeter) + normalized.waveform = normalizeTokens(parsed.waveform) + return normalized +} + +export function createEmptyThemeLocalState(): PrismThemeLocalStateV1 { + return { + format: THEME_LOCAL_STATE_FORMAT, + version: THEME_LOCAL_STATE_VERSION, + migrationVersion: 0, + activeThemeId: null, + } +} + +export function normalizeThemeLocalState(raw: unknown): PrismThemeLocalStateV1 { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial + : {} + + return { + format: THEME_LOCAL_STATE_FORMAT, + version: THEME_LOCAL_STATE_VERSION, + migrationVersion: typeof parsed.migrationVersion === 'number' && Number.isFinite(parsed.migrationVersion) + ? Math.max(0, Math.trunc(parsed.migrationVersion)) + : 0, + activeThemeId: typeof parsed.activeThemeId === 'string' + ? parsed.activeThemeId + : null, + } +} + +export function normalizeLegacyThemePayload(raw: unknown): LegacyThemeMigrationPayload { + if (typeof raw !== 'object' || raw === null) { + return { presetId: null, customAccent: null } + } + + const parsed = raw as Partial + return { + presetId: typeof parsed.presetId === 'string' ? parsed.presetId : null, + customAccent: typeof parsed.customAccent === 'string' ? parsed.customAccent : null, + } +} + +function parseThemeContent(content: string, fallbackId: string, fallbackName: string): PrismTheme { + const nextTheme = createEmptyTheme() + nextTheme.id = fallbackId + nextTheme.name = fallbackName + + let currentSection: ThemeSectionName | 'theme' | null = null + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#') || line.startsWith(';')) { + continue + } + + const sectionMatch = /^\[(.+)\]$/.exec(line) + if (sectionMatch) { + const sectionKey = normalizeKey(sectionMatch[1] ?? '') + currentSection = sectionKey === 'theme' + ? 'theme' + : (SECTION_KEY_MAP[sectionKey] ?? null) + continue + } + + const equalsIndex = line.indexOf('=') + if (equalsIndex === -1 || !currentSection) { + continue + } + + const key = normalizeKey(line.slice(0, equalsIndex)) + const value = line.slice(equalsIndex + 1).trim() + if (!value) continue + + if (currentSection === 'theme') { + switch (key) { + case 'format': + if (value !== THEME_FILE_FORMAT) { + throw new Error(`Unsupported theme format "${value}".`) + } + break + case 'version': { + const version = Number.parseInt(value, 10) + if (version !== THEME_FILE_VERSION) { + throw new Error(`Unsupported theme version "${value}".`) + } + break + } + case 'id': + nextTheme.id = value + break + case 'name': + nextTheme.name = value + break + case 'credit': + nextTheme.credit = value + break + case 'website': + nextTheme.website = value + break + case 'description': + nextTheme.description = value + break + default: + break + } + continue + } + + const tokenKey = TOKEN_KEY_MAP[key] + if (!tokenKey) continue + + const parsedColor = parseThemeChannelColor(value) + if (!parsedColor) continue + nextTheme[currentSection][tokenKey] = toCssColor(parsedColor) + } + + return normalizeTheme(nextTheme, fallbackId, fallbackName) +} + +export function parseThemeFileContent( + content: string, + fallbackId: string, + fallbackName = DEFAULT_THEME_NAME, +): PrismTheme { + return parseThemeContent(content, fallbackId, fallbackName) +} + +function serializeSection(sectionName: string, tokens: ThemeTokens): string[] { + const lines: string[] = [`[${sectionName}]`] + + for (const key of COLOR_KEY_ORDER) { + const value = tokens[key] + if (!value) continue + const serializedKey = key + .replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`) + lines.push(`${serializedKey} = ${toThemeChannels(value)}`) + } + + return lines +} + +export function serializeThemeFile(theme: PrismTheme): string { + const normalized = normalizeTheme(theme, theme.id, theme.name) + const sections: string[] = [ + '[Theme]', + `format = ${THEME_FILE_FORMAT}`, + `version = ${THEME_FILE_VERSION}`, + `id = ${normalized.id}`, + `name = ${normalized.name}`, + ] + + if (normalized.credit) sections.push(`credit = ${normalized.credit}`) + if (normalized.website) sections.push(`website = ${normalized.website}`) + if (normalized.description) sections.push(`description = ${normalized.description}`) + + const output = [sections.join('\n')] + + for (const section of MODULE_SECTION_ORDER) { + const tokens = normalized[section] + if (!Object.values(tokens).some(Boolean)) continue + const label = section === 'interface' + ? 'Interface' + : section === 'all' + ? 'All' + : section === 'vumeter' + ? 'VUMeter' + : section === 'lufsmeter' + ? 'LUFSMeter' + : `${section.charAt(0).toUpperCase()}${section.slice(1)}` + output.push(serializeSection(label, tokens).join('\n')) + } + + return `${output.join('\n\n')}\n` +} + +export function createTemplateThemeFile(): string { + return `# Prism theme template\n#\n# Authoring rules:\n# - Colors use R, G, B or R, G, B, A (0-255)\n# - Omit sections or keys you do not want to override\n# - [All] sets the defaults for everything else\n# - [Interface] overrides the app window, controls, and menus\n# - Module sections only need the colors that should differ from [All]\n\n[Theme]\nformat = ${THEME_FILE_FORMAT}\nversion = ${THEME_FILE_VERSION}\nid = theme_template\nname = Template Theme\ncredit = Your Name\nwebsite = https://example.com\n\n[All]\nprimary = 56, 189, 248\nsecondary = 172, 192, 222\nguides = 255, 255, 255, 26\ntext = 255, 255, 255\nbackground = 0, 0, 0\nlow_band = 255, 68, 68\nmid_band = 68, 221, 68\nhigh_band = 68, 136, 255\nsuccess = 34, 197, 94\nwarning = 255, 191, 0\ndanger = 248, 113, 113\n\n[Interface]\nsecondary = 8, 11, 16, 235\nguides = 255, 255, 255, 23\nbackground = 0, 0, 0\n\n[Spectrum]\nsecondary = 56, 189, 248, 127\nheat_low = 15, 7, 33\nheat_mid = 163, 26, 121\nheat_high = 255, 241, 209\n\n[Oscilloscope]\nfill = 245, 248, 252, 46\n\n[VUMeter]\npeak = 255, 127, 0\nclip = 255, 120, 80, 230\n\n[LUFSMeter]\ntarget = 56, 189, 248, 64\n` +} + +function getThemeFallbackSection(base: ThemeTokens): Required { + const primary = base.primary ?? '#38bdf8' + return { + primary, + secondary: base.secondary ?? lighten(primary, 0.22), + guides: base.guides ?? 'rgba(255, 255, 255, 0.1)', + text: base.text ?? 'rgb(255, 255, 255)', + background: base.background ?? 'transparent', + lowBand: base.lowBand ?? DEFAULT_BAND_LOW, + midBand: base.midBand ?? DEFAULT_BAND_MID, + highBand: base.highBand ?? DEFAULT_BAND_HIGH, + fill: base.fill ?? withAlpha(primary, 0.18), + peak: base.peak ?? 'rgb(255, 127, 0)', + clip: base.clip ?? 'rgba(255, 120, 80, 0.9)', + target: base.target ?? withAlpha(primary, 0.25), + heatLow: base.heatLow ?? 'rgb(15, 7, 33)', + heatMid: base.heatMid ?? 'rgb(163, 26, 121)', + heatHigh: base.heatHigh ?? 'rgb(255, 241, 209)', + success: base.success ?? DEFAULT_SUCCESS, + warning: base.warning ?? DEFAULT_WARNING, + danger: base.danger ?? DEFAULT_DANGER, + } +} + +function resolveInterfaceTheme(theme: PrismTheme, all: Required): ResolvedInterfaceTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.interface)) + const background = section.background + const surface = theme.interface.secondary ?? mixColors(background, section.text, 0.06) + const guides = theme.interface.guides ?? all.guides + const primary = section.primary + const text = section.text + + return { + primary, + secondary: section.secondary, + guides, + text, + background, + accent: primary, + accentHover: lighten(primary, 0.2), + accentGlow: withAlpha(primary, 0.3), + accentRgb: colorToRgbChannels(primary), + bgPrimary: background, + bgSecondary: darken(background, 0.04), + bgTertiary: darken(background, 0.08), + panelSurface: surface, + panelSurfaceSoft: multiplyAlpha(surface, 0.92), + panelOutline: withAlpha(guides, 0.5), + panelOutlineStrong: withAlpha(guides, 0.9), + glassBg: withAlpha(surface, 0.18), + glassBorder: withAlpha(guides, 0.7), + glassHighlight: withAlpha(text, 0.05), + textPrimary: text, + textSecondary: withAlpha(text, 0.62), + textTertiary: withAlpha(text, 0.42), + textMuted: withAlpha(text, 0.3), + toolbarBg: withAlpha(background, 0.74), + settingsBgTop: multiplyAlpha(surface, 0.98), + settingsBgBottom: withAlpha(darken(background, 0.2), 0.98), + bottomBarBg: withAlpha(darken(background, 0.08), 0.98), + menuBg: withAlpha(surface, 0.96), + menuBorder: withAlpha(guides, 0.75), + controlBg: withAlpha(section.secondary, 0.08), + controlBgHover: withAlpha(lighten(section.secondary, 0.08), 0.12), + controlBgActive: withAlpha(primary, 0.12), + controlBorder: withAlpha(guides, 0.7), + controlBorderActive: withAlpha(primary, 0.34), + inputBg: withAlpha(surface, 0.98), + inputBgFocus: withAlpha(lighten(surface, 0.05), 0.98), + inputBorder: withAlpha(guides, 0.8), + inputBorderFocus: withAlpha(primary, 0.7), + divider: withAlpha(guides, 0.55), + success: all.success, + warning: all.warning, + danger: all.danger, + } +} + +function resolveSpectrumTheme(theme: PrismTheme, all: Required): ResolvedSpectrumTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.spectrum)) + return { + primary: section.primary, + secondary: section.secondary, + guides: section.guides, + background: theme.spectrum.background ?? 'transparent', + fillGradient: [ + withAlpha(section.primary, 0), + withAlpha(section.primary, 0.3), + withAlpha(section.secondary, 0.5), + ], + heatColors: [section.heatLow, section.heatMid, section.heatHigh], + } +} + +function resolveOscilloscopeTheme(theme: PrismTheme, all: Required): ResolvedOscilloscopeTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.oscilloscope)) + return { + primary: section.primary, + guides: section.guides, + background: theme.oscilloscope.background ?? 'transparent', + fill: section.fill, + } +} + +function resolveVectorscopeTheme(theme: PrismTheme, all: Required): ResolvedVectorscopeTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.vectorscope)) + return { + primary: section.primary, + guides: section.guides, + background: theme.vectorscope.background ?? 'transparent', + lowBand: section.lowBand, + midBand: section.midBand, + highBand: section.highBand, + } +} + +function resolveSpectrogramTheme(theme: PrismTheme, all: Required): ResolvedSpectrogramTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.spectrogram)) + return { + primary: section.primary, + guides: section.guides, + background: theme.spectrogram.background ?? 'transparent', + heatColors: [section.heatLow, section.heatMid, section.heatHigh], + } +} + +function resolveVUMeterTheme(theme: PrismTheme, all: Required): ResolvedVUMeterTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.vumeter)) + return { + primary: section.primary, + peak: section.peak, + clip: section.clip, + guides: section.guides, + text: section.text, + background: theme.vumeter.background ?? 'transparent', + } +} + +function resolveLUFSMeterTheme(theme: PrismTheme, all: Required): ResolvedLUFSMeterTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.lufsmeter)) + return { + primary: section.primary, + target: section.target, + guides: section.guides, + text: section.text, + background: theme.lufsmeter.background ?? 'transparent', + } +} + +function resolveWaveformTheme(theme: PrismTheme, all: Required): ResolvedWaveformTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.waveform)) + return { + primary: section.primary, + guides: section.guides, + background: theme.waveform.background ?? 'transparent', + lowBand: section.lowBand, + midBand: section.midBand, + highBand: section.highBand, + } +} + +export function resolveTheme(theme: PrismTheme): PrismResolvedTheme { + const normalized = normalizeTheme(theme, theme.id, theme.name) + const baseAll = getThemeFallbackSection(mergeThemeTokens(createDefaultTheme().all, normalized.all)) + + return { + id: normalized.id, + name: normalized.name, + credit: normalized.credit, + website: normalized.website, + description: normalized.description, + interface: resolveInterfaceTheme(normalized, baseAll), + spectrum: resolveSpectrumTheme(normalized, baseAll), + oscilloscope: resolveOscilloscopeTheme(normalized, baseAll), + vectorscope: resolveVectorscopeTheme(normalized, baseAll), + spectrogram: resolveSpectrogramTheme(normalized, baseAll), + vumeter: resolveVUMeterTheme(normalized, baseAll), + lufsmeter: resolveLUFSMeterTheme(normalized, baseAll), + waveform: resolveWaveformTheme(normalized, baseAll), + } +} + +export function resolveLegacyThemeToPresetId(payload: LegacyThemeMigrationPayload): string | null { + switch (payload.presetId) { + case 'default': + return DEFAULT_THEME_ID + case 'graphite': + return 'theme_graphite' + case 'midnight': + return 'theme_midnight' + case 'green': + return 'theme_green' + case 'purple': + return 'theme_purple' + case 'rose': + return 'theme_rose' + default: + return null + } +} + +export function createMigratedAccentTheme(accent: string): PrismTheme | null { + const parsed = parseCssColor(accent) + if (!parsed) return null + + const base = cloneTheme(createDefaultTheme()) + base.id = 'theme_migrated_accent' + base.name = 'Migrated Accent' + base.all.primary = toCssColor(parsed) + base.spectrum.secondary = withAlpha(base.all.primary, 0.5) + base.lufsmeter.target = withAlpha(base.all.primary, 0.25) + return normalizeTheme(base, base.id, base.name) +} + +export function themeToCssVariables(theme: Pick): Record { + const ui = theme.interface + return { + '--bg-primary': ui.bgPrimary, + '--bg-secondary': ui.bgSecondary, + '--bg-tertiary': ui.bgTertiary, + '--panel-surface': ui.panelSurface, + '--panel-surface-soft': ui.panelSurfaceSoft, + '--panel-outline': ui.panelOutline, + '--panel-outline-strong': ui.panelOutlineStrong, + '--glass-bg': ui.glassBg, + '--glass-border': ui.glassBorder, + '--glass-highlight': ui.glassHighlight, + '--text-primary': ui.textPrimary, + '--text-secondary': ui.textSecondary, + '--text-tertiary': ui.textTertiary, + '--text-muted': ui.textMuted, + '--danger': ui.danger, + '--warning': ui.warning, + '--success': ui.success, + '--accent': ui.accent, + '--accent-hover': ui.accentHover, + '--accent-glow': ui.accentGlow, + '--accent-rgb': ui.accentRgb, + '--toolbar-bg': ui.toolbarBg, + '--settings-bg-top': ui.settingsBgTop, + '--settings-bg-bottom': ui.settingsBgBottom, + '--bottom-bar-bg': ui.bottomBarBg, + '--menu-bg': ui.menuBg, + '--menu-border': ui.menuBorder, + '--control-bg': ui.controlBg, + '--control-bg-hover': ui.controlBgHover, + '--control-bg-active': ui.controlBgActive, + '--control-border': ui.controlBorder, + '--control-border-active': ui.controlBorderActive, + '--input-bg': ui.inputBg, + '--input-bg-focus': ui.inputBgFocus, + '--input-border': ui.inputBorder, + '--input-border-focus': ui.inputBorderFocus, + '--divider': ui.divider, + } +} + +export function applyResolvedThemeToDocument( + theme: Pick, + root: Pick, +): void { + const variables = themeToCssVariables(theme) + for (const [name, value] of Object.entries(variables)) { + root.setProperty(name, value) + } +} + +export function getDefaultThemeIdForLocalState(): string { + return DEFAULT_THEME_ID +} + +export function getLegacyThemeMigrationVersion(): number { + return LEGACY_THEME_MIGRATION_VERSION +} diff --git a/src/types/popout.ts b/src/types/popout.ts index 1eeacf4..9814231 100644 --- a/src/types/popout.ts +++ b/src/types/popout.ts @@ -1,6 +1,16 @@ import type { CaptureBackendKind } from './capture' import type { ScopeKind } from './scope' import type { ScopeSettings } from './settings' +import type { + ResolvedInterfaceTheme, + ResolvedLUFSMeterTheme, + ResolvedOscilloscopeTheme, + ResolvedSpectrogramTheme, + ResolvedSpectrumTheme, + ResolvedVectorscopeTheme, + ResolvedVUMeterTheme, + ResolvedWaveformTheme, +} from './theme' export interface WindowBounds { x: number @@ -40,9 +50,19 @@ export type ScopePopoutMonoBatch = Float32Array[] export type ScopePopoutStereoBatch = ScopePopoutStereoChunk[] export type ScopePopoutAudioBatch = ScopePopoutMonoBatch | ScopePopoutStereoBatch +export type ScopePopoutResolvedScopeTheme = + | ResolvedSpectrumTheme + | ResolvedOscilloscopeTheme + | ResolvedVectorscopeTheme + | ResolvedSpectrogramTheme + | ResolvedVUMeterTheme + | ResolvedLUFSMeterTheme + | ResolvedWaveformTheme + export interface ScopePopoutSnapshot { kind: K label: string - accent: string + interfaceTheme: ResolvedInterfaceTheme + scopeTheme: ScopePopoutResolvedScopeTheme settings: ScopeSettings[K] } diff --git a/src/types/profile.ts b/src/types/profile.ts index 9f59a9a..0bbba51 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -3,7 +3,7 @@ import type { ScopeKind } from './scope' import type { ScopeSettings } from './settings' export const PROFILE_FILE_FORMAT = 'prism-profile' -export const PROFILE_FILE_VERSION = 1 +export const PROFILE_FILE_VERSION = 2 export const PROFILE_LOCAL_STATE_FORMAT = 'prism-profile-local' export const PROFILE_LOCAL_STATE_VERSION = 1 export const LEGACY_PROFILE_MIGRATION_VERSION = 1 @@ -12,6 +12,7 @@ export const DEFAULT_PROFILE_NAME = 'Default' export interface Profile { name: string + themeId: string | null scopeOrder: ScopeKind[] hiddenScopes: ScopeKind[] widthWeights: Record @@ -28,7 +29,7 @@ export type PrismProfileFileScopePopoutMap = Record + scopeSettings: ScopeSettings + scopePopouts: PrismProfileFileScopePopoutMap +} + +export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 + export interface ProfileLocalMetadata { windowBounds?: WindowBounds scopePopoutBounds?: Partial> diff --git a/src/types/settings.ts b/src/types/settings.ts index f35826a..e8af65c 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -2,6 +2,7 @@ import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope' import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram' import type { VUMeterMode, VUMeterOrientation } from './vumeter' import type { LUFSMeterMode } from './lufsmeter' +import { DEFAULT_WAVEFORM_MODE, type WaveformMode } from './waveform' export interface ScopeSettings { spectrum: { @@ -12,6 +13,7 @@ export interface ScopeSettings { showGrid: boolean smoothing: number fillGradient: boolean + showSideLine: boolean } oscilloscope: { pitchLock: boolean @@ -41,6 +43,7 @@ export interface ScopeSettings { mode: LUFSMeterMode } waveform: { + mode: WaveformMode scrollSpeed: number gainDb: number multiband: boolean @@ -48,11 +51,11 @@ export interface ScopeSettings { } export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { - spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true }, + spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false }, oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' }, vumeter: { mode: 'bar', orientation: 'horizontal' }, lufsmeter: { mode: 'bar' }, - waveform: { scrollSpeed: 1, gainDb: 0, multiband: false }, + waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, gainDb: 0, multiband: false }, } diff --git a/src/types/theme.ts b/src/types/theme.ts new file mode 100644 index 0000000..1db887f --- /dev/null +++ b/src/types/theme.ts @@ -0,0 +1,199 @@ +import type { ScopeKind } from './scope' + +export const THEME_FILE_FORMAT = 'prism-theme' +export const THEME_FILE_VERSION = 1 +export const THEME_LOCAL_STATE_FORMAT = 'prism-theme-local' +export const THEME_LOCAL_STATE_VERSION = 1 +export const LEGACY_THEME_MIGRATION_VERSION = 1 +export const DEFAULT_THEME_ID = 'theme_default' +export const DEFAULT_THEME_NAME = 'Default' + +export type ThemeSectionName = + | 'all' + | 'interface' + | ScopeKind + +export interface ThemeTokens { + primary?: string + secondary?: string + guides?: string + text?: string + background?: string + lowBand?: string + midBand?: string + highBand?: string + fill?: string + peak?: string + clip?: string + target?: string + heatLow?: string + heatMid?: string + heatHigh?: string + success?: string + warning?: string + danger?: string +} + +export interface PrismTheme { + id: string + name: string + credit?: string + website?: string + description?: string + all: ThemeTokens + interface: ThemeTokens + spectrum: ThemeTokens + oscilloscope: ThemeTokens + vectorscope: ThemeTokens + spectrogram: ThemeTokens + vumeter: ThemeTokens + lufsmeter: ThemeTokens + waveform: ThemeTokens +} + +export interface PrismThemeLocalStateV1 { + format: typeof THEME_LOCAL_STATE_FORMAT + version: typeof THEME_LOCAL_STATE_VERSION + migrationVersion: number + activeThemeId: string | null +} + +export interface ThemeSummary { + id: string + name: string + isDefault: boolean +} + +export interface ThemeLibrarySnapshot { + themes: Record + activeThemeId: string | null +} + +export interface LegacyThemeMigrationPayload { + presetId: string | null + customAccent: string | null +} + +export interface LegacyThemeMigrationResult { + didMigrate: boolean + snapshot: ThemeLibrarySnapshot +} + +export interface ResolvedInterfaceTheme { + primary: string + secondary: string + guides: string + text: string + background: string + accent: string + accentHover: string + accentGlow: string + accentRgb: string + bgPrimary: string + bgSecondary: string + bgTertiary: string + panelSurface: string + panelSurfaceSoft: string + panelOutline: string + panelOutlineStrong: string + glassBg: string + glassBorder: string + glassHighlight: string + textPrimary: string + textSecondary: string + textTertiary: string + textMuted: string + toolbarBg: string + settingsBgTop: string + settingsBgBottom: string + bottomBarBg: string + menuBg: string + menuBorder: string + controlBg: string + controlBgHover: string + controlBgActive: string + controlBorder: string + controlBorderActive: string + inputBg: string + inputBgFocus: string + inputBorder: string + inputBorderFocus: string + divider: string + success: string + warning: string + danger: string +} + +export interface ResolvedSpectrumTheme { + primary: string + secondary: string + guides: string + background: string + fillGradient: [string, string, string] + heatColors: [string, string, string] +} + +export interface ResolvedOscilloscopeTheme { + primary: string + guides: string + background: string + fill: string +} + +export interface ResolvedVectorscopeTheme { + primary: string + guides: string + background: string + lowBand: string + midBand: string + highBand: string +} + +export interface ResolvedSpectrogramTheme { + primary: string + guides: string + background: string + heatColors: [string, string, string] +} + +export interface ResolvedVUMeterTheme { + primary: string + peak: string + clip: string + guides: string + text: string + background: string +} + +export interface ResolvedLUFSMeterTheme { + primary: string + target: string + guides: string + text: string + background: string +} + +export interface ResolvedWaveformTheme { + primary: string + guides: string + background: string + lowBand: string + midBand: string + highBand: string +} + +export interface PrismResolvedTheme { + id: string + name: string + credit?: string + website?: string + description?: string + interface: ResolvedInterfaceTheme + spectrum: ResolvedSpectrumTheme + oscilloscope: ResolvedOscilloscopeTheme + vectorscope: ResolvedVectorscopeTheme + spectrogram: ResolvedSpectrogramTheme + vumeter: ResolvedVUMeterTheme + lufsmeter: ResolvedLUFSMeterTheme + waveform: ResolvedWaveformTheme +} diff --git a/src/types/waveform.ts b/src/types/waveform.ts index 252832b..944149b 100644 --- a/src/types/waveform.ts +++ b/src/types/waveform.ts @@ -1,3 +1,5 @@ +export type WaveformMode = 'mono' | 'stereo' + export const MIN_WAVEFORM_SCROLL_SPEED = 0.5 export const MAX_WAVEFORM_SCROLL_SPEED = 8 export const WAVEFORM_SCROLL_SPEED_STEP = 0.5 @@ -6,6 +8,7 @@ export const MIN_WAVEFORM_GAIN_DB = -12 export const MAX_WAVEFORM_GAIN_DB = 18 export const WAVEFORM_GAIN_DB_STEP = 0.5 export const DEFAULT_WAVEFORM_GAIN_DB = 0 +export const DEFAULT_WAVEFORM_MODE: WaveformMode = 'mono' export function clampWaveformScrollSpeed(value: unknown): number { const numeric = Number(value) diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index c4a4468..8690eab 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -47,6 +47,69 @@ test('routes chunks only to demanded scopes and prunes queues when demand is rem assert.equal(router.flushPendingSpectrumSamples().length, 0) assert.equal(router.flushPendingWaveformSamples().length, 0) + assert.equal(router.flushPendingWaveformStereoSamples().length, 0) +}) + +test('spectrum keeps stereo chunks for the side overlay path and still exposes mono downmixes', () => { + const router = new AudioRouter() + const sessionId = router.beginSession(48000, 2, 'electron-system') + router.setVisualizerConsumerDemand('test-consumer', { spectrum: true }) + + router.ingestChunk(createChunk(2), createChunk(4), { + sessionId, + channelCount: 2, + sequence: 1, + capturedAt: performance.now() - 5, + }) + + const stereoChunks = router.flushPendingSpectrumStereoSamples() + assert.equal(stereoChunks.length, 1) + assert.deepEqual(Array.from(stereoChunks[0]?.left ?? []), [2, 2, 2, 2]) + assert.deepEqual(Array.from(stereoChunks[0]?.right ?? []), [4, 4, 4, 4]) + assert.equal(router.flushPendingSpectrumSamples().length, 0) + + router.ingestChunk(createChunk(6), createChunk(10), { + sessionId, + channelCount: 2, + sequence: 2, + capturedAt: performance.now() - 5, + }) + + const monoChunks = router.flushPendingSpectrumSamples() + assert.equal(monoChunks.length, 1) + assert.deepEqual(Array.from(monoChunks[0] ?? []), [8, 8, 8, 8]) + assert.equal(router.flushPendingSpectrumStereoSamples().length, 0) +}) + +test('waveform keeps stereo chunks for stereo mode while mono flushes still expose the left channel', () => { + const router = new AudioRouter() + const sessionId = router.beginSession(48000, 2, 'electron-system') + router.setVisualizerConsumerDemand('test-consumer', { waveform: true }) + + router.ingestChunk(createChunk(2), createChunk(4), { + sessionId, + channelCount: 2, + sequence: 1, + capturedAt: performance.now() - 5, + }) + + const stereoChunks = router.flushPendingWaveformStereoSamples() + assert.equal(stereoChunks.length, 1) + assert.deepEqual(Array.from(stereoChunks[0]?.left ?? []), [2, 2, 2, 2]) + assert.deepEqual(Array.from(stereoChunks[0]?.right ?? []), [4, 4, 4, 4]) + assert.equal(router.flushPendingWaveformSamples().length, 0) + + router.ingestChunk(createChunk(6), createChunk(10), { + sessionId, + channelCount: 2, + sequence: 2, + capturedAt: performance.now() - 5, + }) + + const monoChunks = router.flushPendingWaveformSamples() + assert.equal(monoChunks.length, 1) + assert.deepEqual(Array.from(monoChunks[0] ?? []), [6, 6, 6, 6]) + assert.equal(router.flushPendingWaveformStereoSamples().length, 0) }) test('keeps the newest chunks when a fixed-capacity ring overflows', () => { diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index e2dee73..ea24cb1 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -24,6 +24,18 @@ async function createHarness(): Promise<{ localStatePath: string profilesDir: string rootDir: string +}> { + return createHarnessWithOptions() +} + +async function createHarnessWithOptions(options?: { + defaultThemeId?: string | null +}): Promise<{ + cleanup: () => Promise + library: FileBackedProfileLibrary + localStatePath: string + profilesDir: string + rootDir: string }> { const rootDir = await mkdtemp(join(tmpdir(), 'prism-profile-library-')) const profilesDir = join(rootDir, 'Documents', 'Prism Profiles') @@ -31,7 +43,13 @@ async function createHarness(): Promise<{ return { cleanup: () => rm(rootDir, { recursive: true, force: true }), - library: new FileBackedProfileLibrary(profilesDir, localStatePath), + library: new FileBackedProfileLibrary( + profilesDir, + localStatePath, + options && 'defaultThemeId' in options + ? async () => options.defaultThemeId ?? null + : undefined, + ), localStatePath, profilesDir, rootDir, @@ -40,11 +58,13 @@ async function createHarness(): Promise<{ function createProfile(name: string): Profile { const profile = createDefaultProfile(name) + profile.themeId = 'theme_default' profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 120, y: 40, width: 420, height: 240 }, } profile.windowBounds = { x: 10, y: 20, width: 840, height: 180 } + profile.scopeSettings.spectrum.showSideLine = true profile.scopeSettings.spectrogram.colorScheme = 'mono' return profile } @@ -55,6 +75,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.format, PROFILE_FILE_FORMAT) assert.equal(file.version, PROFILE_FILE_VERSION) + assert.equal(file.themeId, 'theme_default') assert.equal(JSON.stringify(file).includes('windowBounds'), false) assert.equal(JSON.stringify(file).includes('frameTarget'), false) assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) @@ -62,6 +83,7 @@ test('profile file serialization excludes geometry and round-trips with local me const restored = profileFileToProfile(file, extractLocalProfileMetadata(profile)) assert.deepEqual(restored.windowBounds, profile.windowBounds) assert.deepEqual(restored.scopePopouts.spectrum.windowBounds, profile.scopePopouts.spectrum.windowBounds) + assert.equal(restored.scopeSettings.spectrum.showSideLine, true) assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono') }) @@ -99,6 +121,24 @@ test('library saves, renames, deletes, and resolves filename collisions', async } }) +test('default profile seeds with the current active theme when available', async () => { + const harness = await createHarnessWithOptions({ defaultThemeId: 'theme_midnight' }) + + try { + const snapshot = await harness.library.getSnapshot() + assert.equal(snapshot.profiles[DEFAULT_PROFILE_ID]?.themeId, 'theme_midnight') + + const defaultFile = JSON.parse( + await readFile(join(harness.profilesDir, 'Default.prsm'), 'utf8'), + ) as { + themeId?: string | null + } + assert.equal(defaultFile.themeId, 'theme_midnight') + } finally { + await harness.cleanup() + } +}) + test('importing the same embedded id replaces the managed profile instead of duplicating it', async () => { const harness = await createHarness() @@ -148,7 +188,7 @@ test('partial files normalize, unsupported versions fail, and import does not ch const partialPath = join(harness.rootDir, 'partial.prsm') await writeFile(partialPath, `${JSON.stringify({ format: PROFILE_FILE_FORMAT, - version: PROFILE_FILE_VERSION, + version: 1, id: 'profile_partial', name: 'Partial', scopeOrder: ['spectrogram'], @@ -157,6 +197,7 @@ test('partial files normalize, unsupported versions fail, and import does not ch const partialSnapshot = await harness.library.importProfileFromPath(partialPath) assert.equal(partialSnapshot.activeProfileId, 'profile_partial') + assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.showSideLine, false) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.colorScheme, 'heat') assert.equal(partialSnapshot.profiles.profile_partial.scopePopouts.spectrogram.poppedOut, true) assert.equal(partialSnapshot.profiles.profile_partial.widthWeights.spectrum, 1) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 0659c7e..bb3c4af 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -9,17 +9,21 @@ import { import { createDefaultProfile, } from '../src/shared/profileState' +import { createDefaultTheme, resolveTheme } from '../src/shared/themeState' import { usePerformanceStore } from '../src/renderer/stores/performanceStore' import { moveDockedScopeOrder, useSettingsStore, } from '../src/renderer/stores/settingsStore' +import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule' +import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection' import { applyInputGainToStereoSamples, inputGainDbToLinear, } from '../src/renderer/audio/inputGain' import { SCOPE_KINDS, type ScopeKind } from '../src/types/scope' import type { ScopePopoutStateMap } from '../src/types/popout' +import { ScopePopoutDataSource } from '../src/renderer/popouts/ScopePopoutDataSource' import { VUMeterBallistics, VU_INTEGRATION_WINDOW_MS, @@ -499,6 +503,67 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked ]) }) +test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => { + const profile = createDefaultProfile('Default') + profile.scopeSettings.spectrum.showSideLine = true + const theme = resolveTheme(createDefaultTheme()) + + const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum) + + assert.equal(options.showSideLine, true) + assert.equal(options.secondaryLineColor, theme.spectrum.secondary) + assert.equal(options.lineColor, theme.spectrum.primary) +}) + +test('scopeSettingsToOptions wires waveform stereo mode into analyzer options', () => { + const profile = createDefaultProfile('Default') + profile.scopeSettings.waveform.mode = 'stereo' + profile.scopeSettings.waveform.multiband = true + const theme = resolveTheme(createDefaultTheme()) + + const options = scopeSettingsToOptions('waveform', profile.scopeSettings.waveform, theme.waveform) + + assert.equal(options.mode, 'stereo') + assert.equal(options.multiband, true) + assert.equal(options.lineColor, theme.waveform.primary) +}) + +test('scopeSummary includes Stereo for waveform only when stereo mode is enabled', () => { + const profile = createDefaultProfile('Default') + profile.scopeSettings.waveform.gainDb = 6 + + assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB') + + profile.scopeSettings.waveform.mode = 'stereo' + assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo') + + profile.scopeSettings.waveform.multiband = true + assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo · RGB') +}) + +test('ScopePopoutDataSource switches waveform batches between mono and stereo queues', () => { + const dataSource = new ScopePopoutDataSource('waveform') + const monoChunk = new Float32Array([0.1, 0.2, 0.3]) + const stereoLeft = new Float32Array([0.4, 0.5]) + const stereoRight = new Float32Array([0.6, 0.7]) + const nextMonoChunk = new Float32Array([0.8]) + + dataSource.pushAudioBatch([monoChunk]) + assert.equal(dataSource.getPendingWaveformSamples()[0], monoChunk) + assert.equal(dataSource.getPendingWaveformStereoSamples().length, 0) + + dataSource.pushAudioBatch([{ left: stereoLeft, right: stereoRight }]) + assert.equal(dataSource.getPendingWaveformSamples().length, 0) + const stereoBatch = dataSource.getPendingWaveformStereoSamples() + assert.equal(stereoBatch.length, 1) + assert.equal(stereoBatch[0]?.left, stereoLeft) + assert.equal(stereoBatch[0]?.right, stereoRight) + + dataSource.pushAudioBatch([nextMonoChunk]) + assert.equal(dataSource.getPendingWaveformStereoSamples().length, 0) + assert.equal(dataSource.getPendingWaveformSamples()[0], nextMonoChunk) +}) + test('applying a profile snapshot does not change the machine-local frame target', () => { const previousPerformanceState = usePerformanceStore.getState() const previousSettingsState = useSettingsStore.getState() diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts new file mode 100644 index 0000000..2a77246 --- /dev/null +++ b/test/theme-library.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { FileBackedThemeLibrary } from '../src/main/themeLibrary' +import { + createDefaultTheme, + parseThemeFileContent, + serializeThemeFile, +} from '../src/shared/themeState' +import { + DEFAULT_THEME_ID, + DEFAULT_THEME_NAME, +} from '../src/types/theme' + +async function createHarness(): Promise<{ + cleanup: () => Promise + library: FileBackedThemeLibrary + localStatePath: string + themesDir: string + rootDir: string +}> { + const rootDir = await mkdtemp(join(tmpdir(), 'prism-theme-library-')) + const themesDir = join(rootDir, 'Documents', 'Prism Themes') + const localStatePath = join(rootDir, 'userData', 'theme-state.json') + + return { + cleanup: () => rm(rootDir, { recursive: true, force: true }), + library: new FileBackedThemeLibrary(themesDir, localStatePath), + localStatePath, + themesDir, + rootDir, + } +} + +test('theme files round-trip and keep grouped sections intact', () => { + const theme = createDefaultTheme() + theme.spectrum.heatMid = 'rgb(200, 50, 120)' + + const serialized = serializeThemeFile(theme) + const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_ID, DEFAULT_THEME_NAME) + + assert.equal(parsed.id, DEFAULT_THEME_ID) + assert.equal(parsed.name, DEFAULT_THEME_NAME) + assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)') + assert.equal(parsed.interface.secondary, theme.interface.secondary) +}) + +test('library seeds default themes and template file', async () => { + const harness = await createHarness() + + try { + const snapshot = await harness.library.getSnapshot() + assert.ok(snapshot.themes[DEFAULT_THEME_ID]) + assert.equal(snapshot.activeThemeId, DEFAULT_THEME_ID) + + const fileNames = (await readdir(harness.themesDir)).sort() + assert.ok(fileNames.includes('Default.iro')) + assert.ok(fileNames.includes('_TEMPLATE.iro')) + } finally { + await harness.cleanup() + } +}) + +test('importing the same embedded theme id replaces the managed theme', async () => { + const harness = await createHarness() + + try { + const theme = createDefaultTheme() + theme.id = 'theme_shared' + theme.name = 'Shared' + const externalPath = join(harness.rootDir, 'shared.iro') + await writeFile(externalPath, serializeThemeFile(theme), 'utf8') + + const firstSnapshot = await harness.library.importThemeFromPath(externalPath) + assert.equal(firstSnapshot.activeThemeId, 'theme_shared') + + theme.name = 'Shared Updated' + theme.all.primary = 'rgb(74, 222, 128)' + const updatedPath = join(harness.rootDir, 'shared-updated.iro') + await writeFile(updatedPath, serializeThemeFile(theme), 'utf8') + + const secondSnapshot = await harness.library.importThemeFromPath(updatedPath) + assert.equal(secondSnapshot.activeThemeId, 'theme_shared') + assert.equal(secondSnapshot.themes.theme_shared.name, 'Shared Updated') + assert.equal(secondSnapshot.themes.theme_shared.all.primary, 'rgb(74, 222, 128)') + } finally { + await harness.cleanup() + } +}) + +test('legacy migration can create an accent theme and make it active', async () => { + const harness = await createHarness() + + try { + const migration = await harness.library.migrateLegacyTheme({ + presetId: 'default', + customAccent: '#4ade80', + }) + + assert.equal(migration.didMigrate, true) + assert.equal(migration.snapshot.activeThemeId, 'theme_migrated_accent') + assert.ok(migration.snapshot.themes.theme_migrated_accent) + + const localState = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as { + activeThemeId: string | null + migrationVersion: number + } + assert.equal(localState.activeThemeId, 'theme_migrated_accent') + assert.equal(localState.migrationVersion, 1) + } finally { + await harness.cleanup() + } +})