Merge pull request #2 from Boof2015/theme-engine

Theme engine
This commit is contained in:
Boof2015
2026-03-29 03:18:16 -04:00
committed by GitHub
38 changed files with 3570 additions and 570 deletions
+79
View File
@@ -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
+7
View File
@@ -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": [
+44
View File
@@ -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)
+125
View File
@@ -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<typeof setInterval> | null = null
@@ -27,8 +32,10 @@ const popoutBoundsTimers = new Map<ScopeKind, ReturnType<typeof setTimeout>>()
const windowSettingsHeights = new Map<number, number>()
const windowSettingsBottomAnchors = new Map<number, number>()
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<void> {
mainWindow.webContents.send('profiles:external-activated', latestSnapshot)
}
async function processPendingThemeOpenPaths(): Promise<void> {
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()
}
})
}
+7 -4
View File
@@ -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<string | null>,
) {}
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<PrismProfileFileV1> {
private async readProfileFile(filePath: string): Promise<PrismProfileFile> {
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<PrismProfileFileV1>
const candidate = parsed as Partial<PrismProfileFile>
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)}.`)
}
+384
View File
@@ -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<string | null> {
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<ThemeLibrarySnapshot> {
const { entries, localState } = await this.loadLibrary()
return this.buildSnapshot(entries, localState)
}
async loadTheme(id: string): Promise<ThemeLibrarySnapshot> {
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<ThemeLibrarySnapshot> {
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<ThemeLibrarySnapshot> {
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<ThemeLibrarySnapshot> {
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<ThemeLibrarySnapshot> {
return this.getSnapshot()
}
async migrateLegacyTheme(payload: LegacyThemeMigrationPayload): Promise<LegacyThemeMigrationResult> {
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<void> {
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<ManagedThemeEntry[]> {
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<string>()
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<PrismTheme> {
const content = await readFile(filePath, 'utf8')
return parseThemeFileContent(
content,
this.buildFallbackThemeId(filePath),
basename(filePath, THEME_EXTENSION),
)
}
private async readLocalState(): Promise<PrismThemeLocalStateV1> {
try {
const raw = await readFile(this.localStatePath, 'utf8')
return normalizeThemeLocalState(JSON.parse(raw) as unknown)
} catch {
return createEmptyThemeLocalState()
}
}
private async writeLocalState(state: PrismThemeLocalStateV1): Promise<void> {
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<string> {
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<string> {
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<string, PrismTheme>)
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<boolean> {
const entries = await this.readManagedEntries()
return entries.some((entry) => entry.id === id)
}
private async pathExists(targetPath: string): Promise<boolean> {
try {
await access(targetPath)
return true
} catch {
return false
}
}
private async unlinkIfExists(targetPath: string): Promise<void> {
try {
await unlink(targetPath)
} catch {
// Ignore missing files.
}
}
}
+18
View File
@@ -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<ProfileLibrarySnapshot | null>,
revealProfilesFolder: () => ipcRenderer.invoke('profiles:reveal-folder') as Promise<void>,
migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => ipcRenderer.invoke('profiles:migrate-legacy', payload) as Promise<LegacyProfileMigrationResult>,
getThemeSnapshot: () => ipcRenderer.invoke('themes:get-snapshot') as Promise<ThemeLibrarySnapshot>,
loadTheme: (id: string) => ipcRenderer.invoke('themes:load', id) as Promise<ThemeLibrarySnapshot>,
renameTheme: (id: string, name: string) => ipcRenderer.invoke('themes:rename', id, name) as Promise<ThemeLibrarySnapshot>,
deleteTheme: (id: string) => ipcRenderer.invoke('themes:delete', id) as Promise<ThemeLibrarySnapshot>,
reloadThemes: () => ipcRenderer.invoke('themes:reload') as Promise<ThemeLibrarySnapshot>,
importThemeDialog: () => ipcRenderer.invoke('themes:import-dialog') as Promise<ThemeLibrarySnapshot | null>,
revealThemesFolder: () => ipcRenderer.invoke('themes:reveal-folder') as Promise<void>,
migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => ipcRenderer.invoke('themes:migrate-legacy', payload) as Promise<LegacyThemeMigrationResult>,
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)
+16 -4
View File
@@ -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
+33 -13
View File
@@ -90,13 +90,13 @@ interface ScopeLatencyTracker {
}
type ScopeRingMap = {
spectrum: FixedChunkRing<MonoChunkRecord>
spectrum: FixedChunkRing<StereoChunkRecord>
oscilloscope: FixedChunkRing<MonoChunkRecord>
vectorscope: FixedChunkRing<StereoChunkRecord>
spectrogram: FixedChunkRing<MonoChunkRecord>
vumeter: FixedChunkRing<StereoChunkRecord>
lufsmeter: FixedChunkRing<StereoChunkRecord>
waveform: FixedChunkRing<MonoChunkRecord>
waveform: FixedChunkRing<StereoChunkRecord>
}
class FixedChunkRing<T> {
@@ -212,13 +212,13 @@ function createScopeLatencyTracker(): ScopeLatencyTracker {
export class AudioRouter {
private readonly rings: ScopeRingMap = {
spectrum: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrum),
spectrum: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.spectrum),
oscilloscope: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.oscilloscope),
vectorscope: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vectorscope),
spectrogram: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
vumeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vumeter),
lufsmeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.lufsmeter),
waveform: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
waveform: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
}
private readonly scopeLatency: Record<ScopeKind, ScopeLatencyTracker> = {
@@ -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 {
+103 -28
View File
@@ -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<void> => {
await loadTheme(value)
setThemeId(value)
}
const handleRenameTheme = async (): Promise<void> => {
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<void> => {
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 (
<div className="bottom-bar" ref={rootRef}>
@@ -161,37 +198,75 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__section-title">Theme</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--theme">
{PRESET_IDS.map((id) => {
const preset = PRESETS[id]
const active = presetId === id && !customAccent
return (
<button
key={id}
type="button"
className={`settings-swatch ${active ? 'is-active' : ''}`.trim()}
style={{ '--swatch-color': preset.accent } as CSSProperties}
onClick={() => setPreset(id)}
title={preset.name}
aria-label={preset.name}
/>
)
})}
<input
className="settings-accent-input"
type="color"
value={accent}
onChange={(event) => setCustomAccent(event.target.value)}
title="Custom accent color"
/>
{customAccent && (
<select
className="settings-control__select"
value={activeThemeId ?? ''}
onChange={(event) => {
void handleThemeChange(event.target.value)
}}
>
{themeEntries.map(([id, theme]) => (
<option key={id} value={id}>
{theme.name}
</option>
))}
</select>
<button
type="button"
className="settings-chip"
onClick={() => {
void (async () => {
await importThemeFromDialog()
setThemeId(useThemeStore.getState().activeThemeId)
})()
}}
>
Import
</button>
<button
type="button"
className="settings-chip"
onClick={() => {
void reloadThemes()
}}
>
Reload
</button>
<button
type="button"
className="settings-chip"
onClick={() => {
void showThemesFolder()
}}
>
Folder
</button>
{activeThemeId && activeThemeId !== 'theme_default' ? (
<button
type="button"
className="settings-chip"
onClick={() => setCustomAccent(null)}
onClick={() => {
void handleRenameTheme()
}}
>
Reset
Rename
</button>
)}
) : null}
{activeThemeId && activeThemeId !== 'theme_default' ? (
<button
type="button"
className="settings-chip"
onClick={() => {
void handleDeleteTheme()
}}
>
Delete
</button>
) : null}
<div className="settings-status-pill">
<span className="settings-status-pill__dot" />
<span>{themeId ? 'Saved With Profile' : 'Not Linked'}</span>
</div>
</div>
</div>
</section>
+114 -24
View File
@@ -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<string, unknown>): void
}
/** Maps settingsStore scope settings to the visualizer's setOptions format */
function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKind], lineColor: string): Record<string, unknown> {
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<string, unknown> {
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
+14 -8
View File
@@ -20,10 +20,12 @@ function buildConsumerDemand(kind: ScopeKind): Record<ScopeKind, boolean> {
}, {} as Record<ScopeKind, boolean>)
}
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)
}
@@ -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 })}
/>
<ToggleChip
label="Side"
active={current.showSideLine}
onClick={() => onUpdate('spectrum', { showSideLine: !current.showSideLine })}
/>
</ToggleGroup>
<RangeControl
@@ -443,6 +454,19 @@ export default function ScopeSettingsSection({
const current = settings as ScopeSettings['waveform']
return (
<>
<ToggleGroup label="Mode">
<ToggleChip
label="Mono"
active={current.mode === 'mono'}
onClick={() => onUpdate('waveform', { mode: 'mono' })}
/>
<ToggleChip
label="Stereo"
active={current.mode === 'stereo'}
onClick={() => onUpdate('waveform', { mode: 'stereo' })}
/>
</ToggleGroup>
<ToggleGroup label="Bands">
<ToggleChip
label="Multiband"
-3
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import type { WindowBounds } from '../../types/popout'
import ScopeModule from './ScopeModule'
@@ -17,7 +16,6 @@ export default function Strip(): JSX.Element {
const moveDockedScope = useSettingsStore((s) => 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 {
</button>
<ScopeModule
scopeKind={kind}
lineColor={accent}
frameScheduler={frameScheduler}
/>
</div>
+14
View File
@@ -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<ProfileLibrarySnapshot | null>
revealProfilesFolder: () => Promise<void>
migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => Promise<LegacyProfileMigrationResult>
getThemeSnapshot: () => Promise<ThemeLibrarySnapshot>
loadTheme: (id: string) => Promise<ThemeLibrarySnapshot>
renameTheme: (id: string, name: string) => Promise<ThemeLibrarySnapshot>
deleteTheme: (id: string) => Promise<ThemeLibrarySnapshot>
reloadThemes: () => Promise<ThemeLibrarySnapshot>
importThemeDialog: () => Promise<ThemeLibrarySnapshot | null>
revealThemesFolder: () => Promise<void>
migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => Promise<LegacyThemeMigrationResult>
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
@@ -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 = []
+5 -4
View File
@@ -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<ScopePopoutSnapshot<ScopeKind> | 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 = <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>): void => {
@@ -215,7 +216,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
<div className="scope-popout__canvas-region">
<ScopeModule
scopeKind={scopeKind}
lineColor={effectiveAccent}
theme={effectiveScopeTheme}
settings={effectiveSettings}
frameScheduler={frameScheduler}
dataSource={dataSource}
+28
View File
@@ -19,6 +19,7 @@ import {
normalizeScopePopouts,
normalizeWidthWeights,
} from '../../shared/profileState'
import { useThemeStore } from './themeStore'
export type { ScopeSettings } from '../../types/settings'
@@ -27,6 +28,7 @@ const PROFILES_STORAGE_KEY = 'prism:profiles'
const ACTIVE_PROFILE_KEY = 'prism:activeProfile'
interface PersistedSettingsState {
themeId: string | null
scopeOrder: ScopeKind[]
hiddenScopes: ScopeKind[]
widthWeights: Record<ScopeKind, number>
@@ -35,6 +37,7 @@ interface PersistedSettingsState {
}
interface WorkingSettingsState {
themeId: string | null
scopeOrder: ScopeKind[]
hiddenScopes: Set<ScopeKind>
widthWeights: Record<ScopeKind, number>
@@ -48,6 +51,7 @@ interface SettingsState extends WorkingSettingsState {
activeProfileId: string | null
initializeProfiles: () => Promise<void>
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<PersistedSettingsState> {
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<ScopeKind>(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<Profile> {
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<ScopeKind>(normalizeHiddenScopes(stored.hiddenScopes)),
widthWeights: normalizeWidthWeights(stored.widthWeights),
@@ -266,6 +283,17 @@ export const useSettingsStore = create<SettingsState>((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)
+127 -114
View File
@@ -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<string, ThemePreset> = {
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<string, PrismTheme>
activeThemeId: string | null
activeTheme: PrismResolvedTheme
accent: string
initializeThemes: () => Promise<void>
applyExternalThemeSnapshot: (snapshot: ThemeLibrarySnapshot) => void
loadTheme: (id: string) => Promise<void>
renameTheme: (id: string, name: string) => Promise<void>
deleteTheme: (id: string) => Promise<void>
reloadThemes: () => Promise<void>
importThemeFromDialog: () => Promise<void>
showThemesFolder: () => Promise<void>
}
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<ThemeState>) => 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<ThemeState>((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 }
+72 -55
View File
@@ -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;
}
+10 -4
View File
@@ -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<Omit<LUFSMeterOptions, 'dataSource' | '
const defaultOptions: ResolvedLUFSMeterOptions = {
mode: 'bar',
lineColor: '#38bdf8',
targetColor: 'rgba(56, 189, 248, 0.25)',
scaleColor: 'rgba(255, 255, 255, 0.35)',
labelColor: 'rgba(255, 255, 255, 0.8)',
}
const defaultLUFSMeterDataSource: LUFSMeterDataSource = {
@@ -404,7 +410,7 @@ export class LUFSMeter {
// Bar label
ctx.font = `600 ${fontSize}px "Inter", system-ui, sans-serif`
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.7)`
ctx.fillStyle = this.options.labelColor
ctx.fillText(labels[i], x + barWidth / 2, padding)
// Bar background
@@ -430,14 +436,14 @@ export class LUFSMeter {
// LUFS readout below bar
const displayLufs = lufs <= METER_MIN_LUFS + 1 ? '-∞' : lufs.toFixed(1)
ctx.font = `500 ${Math.max(Math.round(8 * dpr), fontSize - Math.round(2 * dpr))}px "JetBrains Mono", "SF Mono", monospace`
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.8)`
ctx.fillStyle = this.options.labelColor
ctx.fillText(displayLufs, x + barWidth / 2, barAreaBottom + Math.round(4 * dpr))
}
// Target reference line (-14 LUFS)
const targetNorm = Math.max(0, Math.min(1, (TARGET_LUFS - METER_MIN_LUFS) / dbRange))
const targetY = Math.round(barAreaBottom - targetNorm * barAreaHeight)
ctx.strokeStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.25)`
ctx.strokeStyle = this.options.targetColor
ctx.lineWidth = Math.max(1, dpr)
ctx.setLineDash([Math.round(4 * dpr), Math.round(3 * dpr)])
ctx.beginPath()
@@ -451,7 +457,7 @@ export class LUFSMeter {
ctx.font = `400 ${scaleFont}px "JetBrains Mono", "SF Mono", monospace`
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.35)`
ctx.fillStyle = this.options.scaleColor
const tickValues = [-60, -48, -36, -24, -18, -14, -9, -6, -3, 0]
for (const tick of tickValues) {
+9 -7
View File
@@ -20,6 +20,7 @@ export interface OscilloscopeOptions {
backgroundColor?: string
showGrid?: boolean
gridColor?: string
underfillColor?: string
pitchLock?: boolean
underfillEnabled?: boolean
dataSource?: OscilloscopeDataSource
@@ -34,6 +35,7 @@ const defaultOptions: ResolvedOscilloscopeOptions = {
backgroundColor: 'transparent',
showGrid: true,
gridColor: 'rgba(255, 255, 255, 0.1)',
underfillColor: 'rgba(245, 248, 252, 0.18)',
pitchLock: true,
underfillEnabled: false,
}
@@ -255,13 +257,13 @@ export class Oscilloscope {
const shoulderAlpha = peakAlpha * 0.74
const centerlineAlpha = 0.09
const fillGradient = ctx.createLinearGradient(0, 0, 0, height)
fillGradient.addColorStop(0, highContrastUnderfillColor(options.lineColor, peakAlpha))
fillGradient.addColorStop(0.44, highContrastUnderfillColor(options.lineColor, peakAlpha * 0.94))
fillGradient.addColorStop(0.48, highContrastUnderfillColor(options.lineColor, shoulderAlpha))
fillGradient.addColorStop(0.5, highContrastUnderfillColor(options.lineColor, centerlineAlpha))
fillGradient.addColorStop(0.52, highContrastUnderfillColor(options.lineColor, shoulderAlpha))
fillGradient.addColorStop(0.56, highContrastUnderfillColor(options.lineColor, peakAlpha * 0.94))
fillGradient.addColorStop(1, highContrastUnderfillColor(options.lineColor, peakAlpha))
fillGradient.addColorStop(0, options.underfillColor || highContrastUnderfillColor(options.lineColor, peakAlpha))
fillGradient.addColorStop(0.44, options.underfillColor || highContrastUnderfillColor(options.lineColor, peakAlpha * 0.94))
fillGradient.addColorStop(0.48, options.underfillColor || highContrastUnderfillColor(options.lineColor, shoulderAlpha))
fillGradient.addColorStop(0.5, options.underfillColor || highContrastUnderfillColor(options.lineColor, centerlineAlpha))
fillGradient.addColorStop(0.52, options.underfillColor || highContrastUnderfillColor(options.lineColor, shoulderAlpha))
fillGradient.addColorStop(0.56, options.underfillColor || highContrastUnderfillColor(options.lineColor, peakAlpha * 0.94))
fillGradient.addColorStop(1, options.underfillColor || highContrastUnderfillColor(options.lineColor, peakAlpha))
ctx.fillStyle = fillGradient
ctx.fill()
}
+54 -19
View File
@@ -29,6 +29,7 @@ export interface SpectrogramOptions {
scaleMode?: SpectrogramScaleMode
colorScheme?: 'heat' | 'mono'
lineColor?: string
heatColors?: [string, string, string]
dataSource?: SpectrogramDataSource
frameScheduler?: FrameScheduler
}
@@ -52,6 +53,7 @@ const defaultOptions: ResolvedSpectrogramOptions = {
scaleMode: DEFAULT_SPECTROGRAM_SCALE_MODE,
colorScheme: 'heat',
lineColor: '#38bdf8',
heatColors: ['rgb(15, 7, 33)', 'rgb(163, 26, 121)', 'rgb(255, 241, 209)'],
}
const defaultSpectrogramDataSource: SpectrogramDataSource = {
@@ -92,6 +94,7 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial<Spe
scaleMode: resolveScaleMode(overrides.scaleMode, base.scaleMode),
colorScheme: overrides.colorScheme ?? base.colorScheme,
lineColor: overrides.lineColor ?? base.lineColor,
heatColors: overrides.heatColors ?? base.heatColors,
}
}
@@ -212,32 +215,63 @@ type ColorStop = {
color: [number, number, number]
}
const HEAT_STOPS: readonly ColorStop[] = [
{ 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 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]): 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)
+456 -137
View File
@@ -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<Omit<SpectrumAnalyzerOptions, 'dataSource' | 'frameScheduler'>>
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<number, Float32Array>()
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
}
}
+35 -22
View File
@@ -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'
+12 -2
View File
@@ -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)
+261 -106
View File
@@ -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<Omit<WaveformOptions, 'dataSource' | 'fr
const defaultOptions: ResolvedWaveformOptions = {
lineColor: '#38bdf8',
gridMajorColor: 'rgba(255, 255, 255, 0.08)',
gridMinorColor: 'rgba(255, 255, 255, 0.04)',
bandColors: {
low: '#ff4444',
mid: '#44dd44',
high: '#4488ff',
},
mode: DEFAULT_WAVEFORM_MODE,
scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED,
gainDb: DEFAULT_WAVEFORM_GAIN_DB,
multiband: false,
}
// Band colors for multiband mode — same hues as vectorscope RGB
const BAND_LOW: [number, number, number] = [255, 68, 68] // red — bass
const BAND_MID: [number, number, number] = [68, 221, 68] // green — mids
const BAND_HIGH: [number, number, number] = [68, 136, 255] // blue — highs
const MULTIBAND_WEIGHT_EMPHASIS = 2.6
const MULTIBAND_DOMINANCE_SENSITIVITY = 5
const MULTIBAND_FOCUSED_BLEND = 0.68
const MULTIBAND_FILL_ALPHA = 0.72
const MULTIBAND_EDGE_ALPHA = 1.0
const BASE_PIXELS_PER_SECOND = 64
const DISPLAY_MARGIN = 0.95
const defaultWaveformDataSource: WaveformDataSource = {
getPendingWaveformSamples: () => 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)
}
}
+21 -5
View File
@@ -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> | 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<PrismProfileFileV1>
? raw as Partial<PrismProfileFile>
: {}
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),
+922
View File
@@ -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<keyof ThemeTokens> = [
'primary',
'secondary',
'guides',
'text',
'background',
'lowBand',
'midBand',
'highBand',
'fill',
'peak',
'clip',
'target',
'heatLow',
'heatMid',
'heatHigh',
'success',
'warning',
'danger',
]
const SECTION_KEY_MAP: Record<string, ThemeSectionName> = {
all: 'all',
interface: 'interface',
spectrum: 'spectrum',
oscilloscope: 'oscilloscope',
vectorscope: 'vectorscope',
spectrogram: 'spectrogram',
vumeter: 'vumeter',
lufsmeter: 'lufsmeter',
waveform: 'waveform',
}
const TOKEN_KEY_MAP: Record<string, keyof ThemeTokens> = {
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<string, keyof ThemeTokens> = {
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<string, unknown>
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<PrismTheme>
: {}
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<PrismThemeLocalStateV1>
: {}
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<LegacyThemeMigrationPayload>
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<ThemeTokens> {
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<ThemeTokens>): 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<ThemeTokens>): 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<ThemeTokens>): 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<ThemeTokens>): 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<ThemeTokens>): 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<ThemeTokens>): 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<ThemeTokens>): 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<ThemeTokens>): 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<PrismResolvedTheme, 'interface'>): Record<string, string> {
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<PrismResolvedTheme, 'interface'>,
root: Pick<CSSStyleDeclaration, 'setProperty'>,
): 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
}
+21 -1
View File
@@ -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<K extends ScopeKind = ScopeKind> {
kind: K
label: string
accent: string
interfaceTheme: ResolvedInterfaceTheme
scopeTheme: ScopePopoutResolvedScopeTheme
settings: ScopeSettings[K]
}
+18 -2
View File
@@ -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<ScopeKind, number>
@@ -28,7 +29,7 @@ export type PrismProfileFileScopePopoutMap = Record<ScopeKind, PrismProfileFileS
export interface PrismProfileFileV1 {
format: typeof PROFILE_FILE_FORMAT
version: typeof PROFILE_FILE_VERSION
version: 1
id: string
name: string
scopeOrder: ScopeKind[]
@@ -38,6 +39,21 @@ export interface PrismProfileFileV1 {
scopePopouts: PrismProfileFileScopePopoutMap
}
export interface PrismProfileFileV2 {
format: typeof PROFILE_FILE_FORMAT
version: typeof PROFILE_FILE_VERSION
id: string
name: string
themeId: string | null
scopeOrder: ScopeKind[]
hiddenScopes: ScopeKind[]
widthWeights: Record<ScopeKind, number>
scopeSettings: ScopeSettings
scopePopouts: PrismProfileFileScopePopoutMap
}
export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2
export interface ProfileLocalMetadata {
windowBounds?: WindowBounds
scopePopoutBounds?: Partial<Record<ScopeKind, WindowBounds>>
+5 -2
View File
@@ -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 },
}
+199
View File
@@ -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<string, PrismTheme>
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
}
+3
View File
@@ -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)
+63
View File
@@ -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', () => {
+43 -2
View File
@@ -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<void>
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)
+65
View File
@@ -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()
+115
View File
@@ -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<void>
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()
}
})