mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
@@ -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
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)}.`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+14
@@ -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 = []
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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>>
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user