From d2858b17801bc7a12af3868cdb7a6bf184f55225 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:44:28 +0000 Subject: [PATCH 1/4] feat(scripts): let an entry carry every binary it ships `componentIdentifiers` lists further identifiers per key, resolved with the same precedence as `identifiers`. The build writes the primary and every component into software_format_identifiers (schema migration 24, additive). E402 fails an identifier claimed twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UsMUqfW3Tnod96zTZJfkfT --- .changeset/component-identifiers.md | 31 +++++ CLAUDE.md | 41 ++++++- docs/SCHEMA_VERSIONS.md | 20 ++- docs/VALIDATION_ERRORS.md | 17 +++ scripts/__tests__/identifier-fallback.test.ts | 116 +++++++++++++++++- scripts/__tests__/identifier-writer.test.ts | 27 ++++ scripts/build-sqlite.ts | 16 ++- scripts/generate-json-schemas.ts | 6 + scripts/lib/error-codes.ts | 5 + scripts/lib/identifier-fallback.ts | 27 ++++ scripts/lib/identifier-validation.ts | 66 ++++++++++ scripts/lib/identifier-writer.ts | 24 +++- scripts/lib/types.ts | 6 + scripts/schema.sql | 19 ++- scripts/validate.ts | 77 ++++++++++-- 15 files changed, 480 insertions(+), 18 deletions(-) create mode 100644 .changeset/component-identifiers.md diff --git a/.changeset/component-identifiers.md b/.changeset/component-identifiers.md new file mode 100644 index 000000000..89179aeb3 --- /dev/null +++ b/.changeset/component-identifiers.md @@ -0,0 +1,31 @@ +--- +"catalog": minor +--- + +Match every plugin a collection installs, and add the Pultec, StudioVerse, ROLI and sonible entries Studio kept reporting + +A UAD collection installs one plugin per member, each with its own +bundle id, but an entry could hold one identifier per format, so Studio +listed the 1176 Rev A, the LA-2 or InTrigger Live as unknown plugins on +every scan. `componentIdentifiers` takes the same keys and precedence as +`identifiers` with a list per key, and the build writes the primary and +every component into the new `software_format_identifiers` table (schema +migration 24, additive, no `schema_version` bump). `software_formats` is +unchanged for Studio builds that predate the table. + +`pnpm validate` now fails an identifier two entries claim, or one +repeated within an entry (E402), because the matcher resolves an +identifier to one entry. None existed on main. + +Data: + +- Component ids for the 1176, Fairchild, LA-2A, Manley Massive Passive, + Topline and Hitsville entries, and InTrigger Live. The 1176 collection + also gains the formats it lacked, without which it matched nothing. +- The Nx Germano and StudioVerse Audio Effects identifiers. StudioRack is + renamed StudioVerse Audio Effects, its name since V15. +- New entries: Pultec Passive EQ Collection, StudioVerse Instruments, + ROLI Studio Player, and the discontinued smart:reverb (v1) and + smart:comp 2, each superseded by the entry the catalog already carried. +- Add moves from Mesa/Boogie to its real developer, Mesa+, as a + discontinued Windows VST. diff --git a/CLAUDE.md b/CLAUDE.md index 95b7ef59e..0199dd2e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -747,9 +747,44 @@ store numbers) feeds nothing, has no pattern, and stays where it is. `vst3` holds either the macOS bundle id or the 32-digit hex class id from `moduleinfo.json`, because Studio reads the first on macOS and the second on Windows and Linux, and a value the scanner cannot produce matches -nothing. One value per format is the schema's limit today, so an entry -matched on both platforms needs a second key and a second -`software_formats` row, which is additive and not yet done. +nothing. An entry matched on both platforms keeps one of the two in +`identifiers.vst3` and the other in `componentIdentifiers.vst3` (below). + +**`componentIdentifiers` holds every further binary one entry ships.** It +takes the same keys and the same precedence as `identifiers`, with a list +per key, and exists because `identifiers` holds one value per key while a +UAD collection installs one binary per member: the 1176 Classic Limiter +Collection is `U3CM` plus Rev A (`U39V`), LN Rev E (`U39X`) and AE +(`U39Z`), and Studio saw each member as an unmatched plugin until it could +resolve all four. + +```yaml +identifiers: + default: com.uaudio.effects.U3CM +componentIdentifiers: + default: + - com.uaudio.effects.U39V + - com.uaudio.effects.U39X + - com.uaudio.effects.U39Z +``` + +- **The primary stays in `identifiers`.** `software_formats.identifier` is + what every Studio build that predates schema migration 24 reads, so the + value that goes there is unchanged. The build writes the primary and + every component into `software_format_identifiers` (`is_primary` tells + them apart), which is what the matcher reads when the table exists. +- **A component is a binary of this product, never a sibling product.** A + plugin sold on its own gets its own entry (IR-Live beside IR-1), and a + component bundled with its parent goes here (InTrigger Live, which ships + with every InTrigger licence and has no page of its own). +- **An identifier belongs to one entry.** `pnpm validate` fails an + identifier claimed by two entries, repeated in one list, or repeated + from `identifiers` under the same key (E402), because the matcher + resolves a value to one entry and would otherwise pick one silently. +- **The writer treats a listed component as agreement.** A telemetry row + naming a value already in `componentIdentifiers` reports `same`, not the + conflict it would be against the primary alone. Adding a component is a + reviewed hand edit, the same as any other identifier the writer refuses. **Every observed identifier is written through one writer**, `scripts/lib/identifier-writer.ts`, whichever lane observed it: diff --git a/docs/SCHEMA_VERSIONS.md b/docs/SCHEMA_VERSIONS.md index 544f3dd71..3ec33c485 100644 --- a/docs/SCHEMA_VERSIONS.md +++ b/docs/SCHEMA_VERSIONS.md @@ -22,7 +22,25 @@ ORDER BY version; ## Version History -### Version 23 (Current) +### Version 24 (Current) + +**Description:** Added software_format_identifiers table for every identifier a format resolves to + +**Breaking:** No + +**Changes:** + +- Added `software_format_identifiers` (`software_id`, `format`, `identifier`, + `is_primary`), one row per identifier a listed format resolves to +- `is_primary = 1` is the value `software_formats.identifier` carries; each + YAML `componentIdentifiers` value adds an `is_primary = 0` row, so a + collection whose members ship their own binaries (UAD's 1176 Classic + Limiter Collection) is reachable from every member's bundle id +- Indexed on `identifier` for the plugin matcher's lookup +- Additive: `software_formats` is unchanged, a reader that predates the table + never sees it, and `schema_version` in `catalog_meta` stays at 1 + +### Version 23 **Description:** Added nullable hp column to hardware for Eurorack panel width diff --git a/docs/VALIDATION_ERRORS.md b/docs/VALIDATION_ERRORS.md index 2bd4af6c0..adc0f9b6d 100644 --- a/docs/VALIDATION_ERRORS.md +++ b/docs/VALIDATION_ERRORS.md @@ -723,6 +723,23 @@ carries. See `scripts/lib/identifier-fallback.ts` and CLAUDE.md --- +### E402: Duplicate Identifier + +An identifier is claimed twice: by two software entries, twice within one +entry's `componentIdentifiers`, or in `componentIdentifiers` under the same +key that already holds it in `identifiers`. + +Studio's plugin matcher resolves an identifier to one entry, so a value two +entries share matches whichever the query happens to return. `productId` is +exempt, since it feeds nothing. + +**Fix:** Keep the identifier on the one entry the binary belongs to. A +collection's member plugins go in that collection's `componentIdentifiers`, +not in a second entry, and a value already in `identifiers` needs no +repeat. + +--- + ## Running Validation To validate your entries: diff --git a/scripts/__tests__/identifier-fallback.test.ts b/scripts/__tests__/identifier-fallback.test.ts index 7d588c643..9a525bfcd 100644 --- a/scripts/__tests__/identifier-fallback.test.ts +++ b/scripts/__tests__/identifier-fallback.test.ts @@ -5,7 +5,14 @@ import Database from "better-sqlite3"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { buildDatabase } from "../build-sqlite.js"; import { insertStatements, reflectCollection } from "../generate-patch.js"; -import { resolveFormatIdentifier } from "../lib/identifier-fallback.js"; +import { + resolveFormatComponentIdentifiers, + resolveFormatIdentifier, +} from "../lib/identifier-fallback.js"; +import { + findComponentIdentifierRepeats, + findSharedIdentifiers, +} from "../lib/identifier-validation.js"; import { getYamlFiles } from "../lib/utils.js"; import { COLLECTION_SCHEMAS, validateFile } from "../validate.js"; @@ -38,12 +45,54 @@ describe("resolveFormatIdentifier", () => { }); }); +describe("resolveFormatComponentIdentifiers", () => { + it("applies the same precedence as the primary", () => { + const ids = { default: ["com.acme.Member"], vst3: ["com.acme.Member.vst3"] }; + expect(resolveFormatComponentIdentifiers(ids, "vst3")).toEqual(["com.acme.Member.vst3"]); + expect(resolveFormatComponentIdentifiers(ids, "au")).toEqual(["com.acme.Member"]); + expect(resolveFormatComponentIdentifiers({ bundle: ["com.acme.Helper"] }, "vst3")).toEqual([]); + expect(resolveFormatComponentIdentifiers({ bundle: ["com.acme.Helper"] }, "au")).toEqual([ + "com.acme.Helper", + ]); + expect(resolveFormatComponentIdentifiers(undefined, "au")).toEqual([]); + }); +}); + +describe("identifier uniqueness", () => { + it("flags a component repeated or already the primary under its key", () => { + expect( + findComponentIdentifierRepeats( + { default: "com.acme.A" }, + { default: ["com.acme.A", "com.acme.B", "com.acme.B"] } + ) + ).toEqual([ + { key: "default", value: "com.acme.A", reason: "already the primary identifiers.default" }, + { key: "default", value: "com.acme.B", reason: "listed twice" }, + ]); + // A different key is a different claim, so it is not a repeat. + expect(findComponentIdentifierRepeats({ vst3: "com.acme.A" }, { au: ["com.acme.A"] })).toEqual( + [] + ); + }); + + it("flags an identifier two entries claim, whichever field holds it", () => { + const shared = findSharedIdentifiers([ + { slug: "one", identifiers: { default: "com.acme.A", productId: "1" } }, + { slug: "two", componentIdentifiers: { default: ["com.acme.A"] } }, + { slug: "three", identifiers: { productId: "1" } }, + { slug: "four", identifiers: { default: "com.acme.B" } }, + ]); + expect([...shared]).toEqual([["com.acme.A", ["one", "two"]]]); + }); +}); + describe("buildDatabase with fallback identifiers", () => { const ids = { acme: "MFR00000000000000acme", shared: "SW0000000000000shared", app: "SW00000000000000000app", mixed: "SW00000000000000mixed", + collection: "SW00000000collection", }; let tmp: string; @@ -129,6 +178,30 @@ describe("buildDatabase with fallback identifiers", () => { ].join("\n") ); + // A collection whose members ship their own binaries: the 1176 shape. + write( + "software/acme-collection.yaml", + [ + `id: ${ids.collection}`, + "name: Acme Collection", + "manufacturer: acme-audio", + "primaryCategory: compressor", + "formats:", + " - au", + " - vst3", + "platforms:", + " - mac", + "identifiers:", + " default: com.acme.Collection", + "componentIdentifiers:", + " default:", + " - com.acme.MemberA", + " - com.acme.MemberB", + "description: One entry, three binaries.", + "", + ].join("\n") + ); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { @@ -179,6 +252,47 @@ describe("buildDatabase with fallback identifiers", () => { ]); }); + it("keeps software_formats at the primary and lists every binary in software_format_identifiers", () => { + expect(rows(ids.collection)).toEqual([ + { format: "au", identifier: "com.acme.Collection" }, + { format: "vst3", identifier: "com.acme.Collection" }, + ]); + const all = db + .prepare( + "SELECT format, identifier, is_primary FROM software_format_identifiers WHERE software_id = ? ORDER BY format, is_primary DESC, identifier" + ) + .all(ids.collection); + expect(all).toEqual([ + { format: "au", identifier: "com.acme.Collection", is_primary: 1 }, + { format: "au", identifier: "com.acme.MemberA", is_primary: 0 }, + { format: "au", identifier: "com.acme.MemberB", is_primary: 0 }, + { format: "vst3", identifier: "com.acme.Collection", is_primary: 1 }, + { format: "vst3", identifier: "com.acme.MemberA", is_primary: 0 }, + { format: "vst3", identifier: "com.acme.MemberB", is_primary: 0 }, + ]); + }); + + it("carries the primary alone into software_format_identifiers for a plain entry", () => { + const all = db + .prepare( + "SELECT format, identifier, is_primary FROM software_format_identifiers WHERE software_id = ? ORDER BY format" + ) + .all(ids.app); + // vst3 resolves no identifier, so it has no row here at all. + expect(all).toEqual([ + { format: "au", identifier: "com.acme.App", is_primary: 1 }, + { format: "standalone", identifier: "com.acme.App", is_primary: 1 }, + ]); + }); + + it("reaches software_format_identifiers in the incremental patch through reflection", () => { + const schema = reflectCollection(db, "software"); + const statements = insertStatements(db, schema, ids.collection) ?? []; + const rows = statements.filter((s) => s.includes('INSERT INTO "software_format_identifiers"')); + expect(rows).toHaveLength(6); + expect(rows.some((s) => s.includes("'com.acme.MemberB'"))).toBe(true); + }); + it("reaches the incremental patch through reflection, with no hand-written block", () => { // generate-patch reads rows back out of the built database, so the // resolved identifier rides the existing software_formats reflection. diff --git a/scripts/__tests__/identifier-writer.test.ts b/scripts/__tests__/identifier-writer.test.ts index 6e84345d0..fbfd6fcfa 100644 --- a/scripts/__tests__/identifier-writer.test.ts +++ b/scripts/__tests__/identifier-writer.test.ts @@ -178,6 +178,33 @@ describe("applyIdentifierRows", () => { expect(readEntry("fabfilter-pro-q-3")).toContain("vst3: com.fabfilter.Pro-Q-3.vst3"); }); + it("reports a collection member already listed as a component as same, not a conflict", () => { + writeEntry( + "fabfilter-pro-q-3", + "name: Pro-Q 3\nmanufacturer: fabfilter\nformats:\n - vst3\nidentifiers:\n default: com.fabfilter.Pro-Q-3\ncomponentIdentifiers:\n default:\n - com.fabfilter.Pro-Q-3-Mono\n" + ); + const summary = applyIdentifierRows( + [{ target: "fabfilter-pro-q-3", format: "vst3", identifier: "com.fabfilter.Pro-Q-3-Mono" }], + { write: true, dataDir } + ); + expect(summary.outcomes[0].kind).toBe("same"); + expect(summary.outcomes[0].detail).toContain("as a component"); + expect(summary.filesChanged).toBe(0); + }); + + it("never promotes a component to the primary on an entry with no primary for the format", () => { + writeEntry( + "fabfilter-pro-q-3", + "name: Pro-Q 3\nmanufacturer: fabfilter\nformats:\n - vst3\ncomponentIdentifiers:\n vst3:\n - com.fabfilter.Pro-Q-3-Mono\n" + ); + const summary = applyIdentifierRows( + [{ target: "fabfilter-pro-q-3", format: "vst3", identifier: "com.fabfilter.Pro-Q-3-Mono" }], + { write: true, dataDir } + ); + expect(summary.outcomes[0].kind).toBe("same"); + expect(readEntry("fabfilter-pro-q-3")).not.toContain("identifiers:\n vst3"); + }); + it("refuses a vendor segment naming another maker, which is the mis-match signal", () => { const summary = applyIdentifierRows( [{ target: "fabfilter-pro-q-3", format: "vst3", identifier: "com.waves.Renaissance-EQ" }], diff --git a/scripts/build-sqlite.ts b/scripts/build-sqlite.ts index 1b5aed7e0..a841cbf4a 100644 --- a/scripts/build-sqlite.ts +++ b/scripts/build-sqlite.ts @@ -79,7 +79,10 @@ export function normalizeCategory(category: string): string { return CATEGORY_ALIASES.get(category) ?? category; } -import { resolveFormatIdentifier } from "./lib/identifier-fallback.js"; +import { + resolveFormatComponentIdentifiers, + resolveFormatIdentifier, +} from "./lib/identifier-fallback.js"; import { IO_KEY_PATTERN } from "./lib/io-keys.js"; // Load IO position and connection aliases for normalization import { loadSchemaContext } from "./lib/schema-loader.js"; @@ -355,6 +358,10 @@ function populateDatabase(db: Database.Database, dataDir: string, version: strin INSERT INTO software_formats (software_id, format, identifier) VALUES (?, ?, ?) `); + const insertFormatIdentifier = db.prepare(` + INSERT OR IGNORE INTO software_format_identifiers (software_id, format, identifier, is_primary) + VALUES (?, ?, ?, ?) + `); const insertPlatform = db.prepare(` INSERT INTO software_platforms (software_id, platform) VALUES (?, ?) @@ -468,6 +475,13 @@ function populateDatabase(db: Database.Database, dataDir: string, version: strin for (const format of data.formats) { const identifier = resolveFormatIdentifier(data.identifiers, format); insertFormat.run(id, format, identifier); + if (identifier) insertFormatIdentifier.run(id, format, identifier, 1); + for (const component of resolveFormatComponentIdentifiers( + data.componentIdentifiers, + format + )) { + insertFormatIdentifier.run(id, format, component, 0); + } } } diff --git a/scripts/generate-json-schemas.ts b/scripts/generate-json-schemas.ts index cf5db7724..fe356337b 100644 --- a/scripts/generate-json-schemas.ts +++ b/scripts/generate-json-schemas.ts @@ -376,6 +376,12 @@ const schemas: Record = { description: "Plugin identifiers keyed by format (e.g., au, vst3). `default` covers every listed format without its own key; `bundle` covers au and standalone.", }, + componentIdentifiers: { + type: "object", + additionalProperties: { type: "array", items: { type: "string" }, minItems: 1 }, + description: + "Further identifiers for a product that ships more than one binary per format (a collection's members), keyed and resolved like `identifiers`, which keeps the primary.", + }, compatibleWith: { type: "array", items: { type: "string" }, diff --git a/scripts/lib/error-codes.ts b/scripts/lib/error-codes.ts index 408ba2915..048dd19c0 100644 --- a/scripts/lib/error-codes.ts +++ b/scripts/lib/error-codes.ts @@ -67,6 +67,7 @@ export enum ValidationErrorCode { // Identifier errors (E4xx) E400_INVALID_IDENTIFIER_FORMAT = "E400", E401_MISSING_IDENTIFIER = "E401", + E402_DUPLICATE_IDENTIFIER = "E402", // Advisory warnings (W1xx) — non-blocking W120_UNKNOWN_IO_TYPE = "W120", @@ -305,6 +306,10 @@ const ERROR_INFO: Record = { title: "Missing identifier", anchor: "e401-missing-identifier", }, + [ValidationErrorCode.E402_DUPLICATE_IDENTIFIER]: { + title: "Duplicate identifier", + anchor: "e402-duplicate-identifier", + }, // Advisory warnings [ValidationErrorCode.W120_UNKNOWN_IO_TYPE]: { diff --git a/scripts/lib/identifier-fallback.ts b/scripts/lib/identifier-fallback.ts index b29acaeb9..41c3e1126 100644 --- a/scripts/lib/identifier-fallback.ts +++ b/scripts/lib/identifier-fallback.ts @@ -43,3 +43,30 @@ export function resolveFormatIdentifier( if (identifiers.bundle && BUNDLE_FALLBACK_FORMATS.has(format)) return identifiers.bundle; return null; } + +/** + * The extra identifiers one listed format carries through + * `componentIdentifiers`, under the same precedence as the primary. + * + * `identifiers` holds one value per key, which is right for a plugin that + * ships one binary. A UAD collection ships one binary per member (the 1176 + * Classic Limiter Collection installs Rev A, LN Rev E and AE, each with its + * own bundle id), and a product can ship more than one component (Manley + * Massive Passive and its MST variant). Studio sees each binary separately, + * so every one of them has to resolve to the entry or the scan leaves it + * unmatched. The primary stays in `identifiers` because Studio builds that + * predate `software_format_identifiers` read `software_formats.identifier` + * alone. + */ +export function resolveFormatComponentIdentifiers( + componentIdentifiers: Record | undefined, + format: string +): string[] { + if (!componentIdentifiers) return []; + if (componentIdentifiers[format]) return componentIdentifiers[format]; + if (componentIdentifiers.default) return componentIdentifiers.default; + if (componentIdentifiers.bundle && BUNDLE_FALLBACK_FORMATS.has(format)) { + return componentIdentifiers.bundle; + } + return []; +} diff --git a/scripts/lib/identifier-validation.ts b/scripts/lib/identifier-validation.ts index 2fe56fb80..b1571e3d4 100644 --- a/scripts/lib/identifier-validation.ts +++ b/scripts/lib/identifier-validation.ts @@ -133,3 +133,69 @@ export function getKnownFormats(): string[] { export function hasValidationPattern(format: string): boolean { return format in IDENTIFIER_PATTERNS; } + +// ============================================================================= +// COMPONENT IDENTIFIERS AND UNIQUENESS +// ============================================================================= + +/** + * Problems within one entry's `componentIdentifiers`: a value repeated under + * one key, or a value that is already the primary under the same key in + * `identifiers`. Either would write the same `software_format_identifiers` + * row twice and reads as a mistake rather than a second binary. + */ +export function findComponentIdentifierRepeats( + identifiers: Record | undefined, + componentIdentifiers: Record +): { key: string; value: string; reason: string }[] { + const problems: { key: string; value: string; reason: string }[] = []; + for (const [key, values] of Object.entries(componentIdentifiers)) { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + problems.push({ key, value, reason: "listed twice" }); + } else if (identifiers?.[key] === value) { + problems.push({ key, value, reason: `already the primary identifiers.${key}` }); + } + seen.add(value); + } + } + return problems; +} + +export interface IdentifierOwner { + slug: string; + identifiers?: Record; + componentIdentifiers?: Record; +} + +/** + * Identifiers claimed by more than one software entry. Studio's matcher maps + * an identifier to one entry, so a value two entries share matches whichever + * the query returns last, silently. `productId` is exempt: it is a store + * number that feeds nothing. + */ +export function findSharedIdentifiers(entries: IdentifierOwner[]): Map { + const owners = new Map>(); + const claim = (value: string, slug: string) => { + const set = owners.get(value) ?? new Set(); + set.add(slug); + owners.set(value, set); + }; + for (const entry of entries) { + for (const [key, value] of Object.entries(entry.identifiers ?? {})) { + if (key !== "productId" && typeof value === "string") claim(value, entry.slug); + } + for (const values of Object.values(entry.componentIdentifiers ?? {})) { + if (!Array.isArray(values)) continue; + for (const value of values) { + if (typeof value === "string") claim(value, entry.slug); + } + } + } + const shared = new Map(); + for (const [value, slugs] of owners) { + if (slugs.size > 1) shared.set(value, [...slugs].sort()); + } + return shared; +} diff --git a/scripts/lib/identifier-writer.ts b/scripts/lib/identifier-writer.ts index 1595b6e6a..6e5131bb7 100644 --- a/scripts/lib/identifier-writer.ts +++ b/scripts/lib/identifier-writer.ts @@ -32,7 +32,10 @@ import fs from "node:fs"; import path from "node:path"; import { parseDocument } from "yaml"; -import { resolveFormatIdentifier } from "./identifier-fallback.js"; +import { + resolveFormatComponentIdentifiers, + resolveFormatIdentifier, +} from "./identifier-fallback.js"; import { validateIdentifier } from "./identifier-validation.js"; import { isValidFormat } from "./schema-loader.js"; import { DATA_DIR, getYamlFiles } from "./utils.js"; @@ -227,6 +230,7 @@ interface EntryShape { manufacturer?: string; formats?: string[]; identifiers?: Record; + componentIdentifiers?: Record; versions?: VersionRecord[]; } @@ -307,7 +311,21 @@ export function applyIdentifierRows( identifier === undefined ? `${row.format} already listed` : `${row.format} already resolves to ${existing ?? identifier}`; - if (identifier !== undefined && existing && !sameIdentifier(existing, identifier)) { + // A collection member's binary is already on the entry when the + // observed id is one of its componentIdentifiers: that is agreement, + // not the mis-match the conflict below exists to catch. + const isComponent = + identifier !== undefined && + resolveFormatComponentIdentifiers(entry.componentIdentifiers, row.format).some((c) => + sameIdentifier(c, identifier) + ); + if (isComponent) detail = `${row.format} already carries ${identifier} as a component`; + if ( + identifier !== undefined && + existing && + !isComponent && + !sameIdentifier(existing, identifier) + ) { outcomes.push({ row, kind: "conflict", @@ -319,7 +337,7 @@ export function applyIdentifierRows( let formatAdded = false; let versionAdded = false; - if (identifier !== undefined && !existing) { + if (identifier !== undefined && !existing && !isComponent) { const identifiers = { ...(entry.identifiers ?? {}), [row.format]: identifier }; doc.set("identifiers", identifiers); kind = "written"; diff --git a/scripts/lib/types.ts b/scripts/lib/types.ts index 1c3d9e535..c90f19f57 100644 --- a/scripts/lib/types.ts +++ b/scripts/lib/types.ts @@ -133,6 +133,12 @@ export interface Software { formats?: string[]; platforms?: string[]; identifiers?: Record; + /** + * Further identifiers per key, for a product that ships more than one + * binary per format (a collection's members). Same keys and precedence + * as `identifiers`, which keeps the primary. + */ + componentIdentifiers?: Record; url?: string; releaseDate?: string; releaseDateYearOnly?: boolean; diff --git a/scripts/schema.sql b/scripts/schema.sql index 889ce20f2..a4cf0c950 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -80,6 +80,22 @@ CREATE TABLE IF NOT EXISTS software_formats ( CREATE INDEX idx_software_formats_format ON software_formats(format); +-- Every identifier a listed format resolves to: the primary from +-- `identifiers` (is_primary = 1, the same value software_formats carries) +-- plus each `componentIdentifiers` value (is_primary = 0). A collection or a +-- multi-component product ships one binary per member, and Studio's matcher +-- needs each binary's id to reach the entry. Additive: software_formats is +-- unchanged for readers that predate this table. +CREATE TABLE IF NOT EXISTS software_format_identifiers ( + software_id TEXT NOT NULL REFERENCES software(id) ON DELETE CASCADE, + format TEXT NOT NULL, + identifier TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (software_id, format, identifier) +); + +CREATE INDEX idx_software_format_identifiers_identifier ON software_format_identifiers(identifier); + -- Software Platforms (many-to-many) CREATE TABLE IF NOT EXISTS software_platforms ( software_id TEXT NOT NULL REFERENCES software(id) ON DELETE CASCADE, @@ -835,7 +851,8 @@ INSERT OR REPLACE INTO schema_migrations (version, description, breaking_change) (20, 'Added search_terms column to all FTS5 tables; changed tokenizer from porter unicode61 to unicode61', 1), (21, 'Added stable port_key column to hardware_io', 0), (22, 'Added optional term column to every prices table', 0), - (23, 'Added nullable hp column to hardware for Eurorack panel width', 0); + (23, 'Added nullable hp column to hardware for Eurorack panel width', 0), + (24, 'Added software_format_identifiers table for every identifier a format resolves to', 0); -- Insert initial metadata (version comes from build script) INSERT OR REPLACE INTO catalog_meta (key, value) VALUES diff --git a/scripts/validate.ts b/scripts/validate.ts index 4cf4da9ee..c09e70a9e 100644 --- a/scripts/validate.ts +++ b/scripts/validate.ts @@ -14,7 +14,12 @@ import { z } from "zod"; import { looksLikeAcronymName } from "./lib/acronym-exclusions.js"; import { findControlCharacters, formatControlCharacterPath } from "./lib/control-characters.js"; import { getDocsUrl, ValidationErrorCode } from "./lib/error-codes.js"; -import { validateIdentifier } from "./lib/identifier-validation.js"; +import { + findComponentIdentifierRepeats, + findSharedIdentifiers, + type IdentifierOwner, + validateIdentifier, +} from "./lib/identifier-validation.js"; import { isIoCombineCandidate, STORAGE_MEDIA_SLOT } from "./lib/io-heuristics.js"; import { findHintFindings, isPositiveInteger } from "./lib/io-hints.js"; import { findDuplicateIoKeys, IO_KEY_PATTERN } from "./lib/io-keys.js"; @@ -790,6 +795,7 @@ const SoftwareSchema = z }), platforms: createPlatformArrayValidator(), identifiers: z.record(z.string(), z.string()).optional(), + componentIdentifiers: z.record(z.string(), z.array(z.string()).min(1)).optional(), compatibleWith: z.array(z.string()).optional(), url: z.url().optional(), releaseDate: z.string().optional(), @@ -1054,6 +1060,7 @@ interface DataWithOptionalFields { categories?: string[]; supersedes?: string; identifiers?: Record; + componentIdentifiers?: Record; description?: string | string[]; details?: string | string[]; specs?: string | string[]; @@ -1323,26 +1330,58 @@ export function validateFile( // value under either key was inert; now it reaches every format row, and // this is the only gate in front of `software_formats.identifier`. A key // without a pattern (`productId`) is accepted as before. - if (data.identifiers && typeof data.identifiers === "object") { + // `componentIdentifiers` values ship to software_format_identifiers the + // same way, so each one passes the pattern of the key it sits under. + if ( + (data.identifiers && typeof data.identifiers === "object") || + (data.componentIdentifiers && typeof data.componentIdentifiers === "object") + ) { const identifierErrors: string[] = []; const identifierDetails: ValidationErrorDetail[] = []; const errorCode = ValidationErrorCode.E400_INVALID_IDENTIFIER_FORMAT; - for (const [key, value] of Object.entries(data.identifiers)) { + const checks: { key: string; value: string; path: (string | number)[] }[] = []; + for (const [key, value] of Object.entries(data.identifiers ?? {})) { + checks.push({ key, value, path: ["identifiers", key] }); + } + for (const [key, values] of Object.entries(data.componentIdentifiers ?? {})) { + values.forEach((value, index) => { + checks.push({ key, value, path: ["componentIdentifiers", key, index] }); + }); + } + for (const { key, value, path: yamlPath } of checks) { const result = validateIdentifier(key, value); if (result.valid || !result.error) continue; const message = result.suggestion ? `${result.error}. Expected: ${result.suggestion}` : result.error; - const line = getLineForPath(document, lineCounter, ["identifiers", key]); - identifierErrors.push(`identifiers.${key}: ${message}`); + const line = getLineForPath(document, lineCounter, yamlPath); + identifierErrors.push(`${yamlPath.join(".")}: ${message}`); identifierDetails.push({ code: errorCode, message, - path: `identifiers.${key}`, + path: yamlPath.join("."), line: line ?? undefined, docsUrl: getDocsUrl(errorCode), }); } + if (data.componentIdentifiers) { + const repeatCode = ValidationErrorCode.E402_DUPLICATE_IDENTIFIER; + for (const { key, value, reason } of findComponentIdentifierRepeats( + data.identifiers, + data.componentIdentifiers + )) { + const message = `'${value}' is ${reason}`; + const line = getLineForPath(document, lineCounter, ["componentIdentifiers", key]); + identifierErrors.push(`componentIdentifiers.${key}: ${message}`); + identifierDetails.push({ + code: repeatCode, + message, + path: `componentIdentifiers.${key}`, + line: line ?? undefined, + docsUrl: getDocsUrl(repeatCode), + }); + } + } if (identifierErrors.length > 0) { return { file: path.relative(process.cwd(), filePath), @@ -1838,12 +1877,22 @@ function validate(): ValidationResult { allHardwareSlugs.add(path.basename(file, path.extname(file))); } - // Collect software IDs and supersedes relationships + // Collect software IDs and supersedes relationships, and every entry's + // identifiers for the cross-file E402 check below + const identifierOwners: IdentifierOwner[] = []; for (const file of softwareFiles) { const slug = path.basename(file, path.extname(file)); try { const content = fs.readFileSync(file, "utf-8"); - const data = parseYaml(content) as { id?: string; supersedes?: string }; + const data = parseYaml(content) as { id?: string; supersedes?: string } & Omit< + IdentifierOwner, + "slug" + >; + identifierOwners.push({ + slug, + identifiers: data.identifiers, + componentIdentifiers: data.componentIdentifiers, + }); if (data.id) { softwareIds.add(data.id); softwareIdToSlug.set(data.id, slug); @@ -1947,6 +1996,18 @@ function validate(): ValidationResult { } } + // E402 across entries: Studio's matcher resolves an identifier to one + // entry, so two entries claiming one value is a silent mis-match. + for (const [value, slugs] of findSharedIdentifiers(identifierOwners)) { + const errorCode = ValidationErrorCode.E402_DUPLICATE_IDENTIFIER; + const message = `identifier '${value}' is claimed by ${slugs.join(", ")}; an identifier resolves to one entry`; + errors.push({ + file: slugs.map((slug) => path.join("data", "software", `${slug}.yaml`)).join(", "), + errors: [message], + details: [{ code: errorCode, message, path: "identifiers", docsUrl: getDocsUrl(errorCode) }], + }); + } + // Validate content (supersedes must reference valid content ID) for (const file of contentFiles) { const error = validateFile(file, ContentSchema, allManufacturers, contentIds); From f933b4ee40b7d859cd627dbffdb62d630e4b5ec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:45:06 +0000 Subject: [PATCH 2/4] fix(data): resolve the plugins studio keeps reporting Adds the held UAD collection and InTrigger Live component ids, the Nx Germano and StudioVerse Audio Effects identifiers, and entries for the Pultec Passive EQ Collection, StudioVerse Instruments, ROLI Studio Player, smart:reverb v1 and smart:comp 2. Add moves to Mesa+. Settles catalog-submissions 104, and the identifier items in 12, 35 and 94. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UsMUqfW3Tnod96zTZJfkfT --- data/manufacturers/mesa-plus.yaml | 9 +++ data/manufacturers/roli.yaml | 2 +- ...esa-boogie-add.yaml => mesa-plus-add.yaml} | 27 ++++++-- data/software/roli-studio-player.yaml | 49 +++++++++++++++ data/software/sonible-smartcomp-2.yaml | 60 ++++++++++++++++++ data/software/sonible-smartcomp-3.yaml | 1 + data/software/sonible-smartreverb-2.yaml | 1 + data/software/sonible-smartreverb.yaml | 57 +++++++++++++++++ ...audio-1176-classic-limiter-collection.yaml | 9 +++ ...dio-fairchild-tube-limiter-collection.yaml | 3 + ...ersal-audio-hitsville-reverb-chambers.yaml | 3 + ...niversal-audio-manley-massive-passive.yaml | 3 + ...al-audio-pultec-passive-eq-collection.yaml | 61 +++++++++++++++++++ ...udio-teletronix-la-2a-tube-compressor.yaml | 6 ++ .../universal-audio-topline-vocal-suite.yaml | 3 + .../waves-intrigger-drum-replacer.yaml | 3 + .../waves-nx-germano-studios-new-york.yaml | 2 + data/software/waves-studiorack.yaml | 16 +++-- .../waves-studioverse-instruments.yaml | 27 ++++++++ .../2026-09-identifier-backfill-telemetry.tsv | 22 ++++--- 20 files changed, 343 insertions(+), 21 deletions(-) create mode 100644 data/manufacturers/mesa-plus.yaml rename data/software/{mesa-boogie-add.yaml => mesa-plus-add.yaml} (76%) create mode 100644 data/software/roli-studio-player.yaml create mode 100644 data/software/sonible-smartcomp-2.yaml create mode 100644 data/software/sonible-smartreverb.yaml create mode 100644 data/software/universal-audio-pultec-passive-eq-collection.yaml create mode 100644 data/software/waves-studioverse-instruments.yaml diff --git a/data/manufacturers/mesa-plus.yaml b/data/manufacturers/mesa-plus.yaml new file mode 100644 index 000000000..f118cb591 --- /dev/null +++ b/data/manufacturers/mesa-plus.yaml @@ -0,0 +1,9 @@ +id: QnN2y9cQ8OHf2I11i206n +name: Mesa+ +url: https://www.kvraudio.com/developer/mesaand +defunct: true +description: Mesa+ was a software developer behind the Add phrase sampler and + Wave Drum Kits and Waveformer content for Ableton Live, active from 2006 to + 2009. +searchTerms: + - Mesa Plus diff --git a/data/manufacturers/roli.yaml b/data/manufacturers/roli.yaml index 775d9e745..2fd976e93 100644 --- a/data/manufacturers/roli.yaml +++ b/data/manufacturers/roli.yaml @@ -1,3 +1,3 @@ id: bASprMAMkzynfkk9J7qID -name: Roli +name: ROLI url: https://roli.com/us diff --git a/data/software/mesa-boogie-add.yaml b/data/software/mesa-plus-add.yaml similarity index 76% rename from data/software/mesa-boogie-add.yaml rename to data/software/mesa-plus-add.yaml index 1725c98f8..b7282429b 100644 --- a/data/software/mesa-boogie-add.yaml +++ b/data/software/mesa-plus-add.yaml @@ -1,12 +1,14 @@ id: XlnjygJ67thR0210h5XVc name: Add -manufacturer: mesa-boogie +manufacturer: mesa-plus primaryCategory: sampler categories: - sequencer - plugin + - discontinued +formats: + - vst2 platforms: - - mac - windows description: Add is a multi purpose phrase-sampler that combines a powerful sampler (sound engine) with a realtime phrase sequencer (rps). @@ -17,7 +19,7 @@ details: |- An editor maps sequences within MIDI notes ranges, ready to be triggered via MIDI in several different modes. MIDI effects such as quantize-swing, arpeggiator and humanization have been deeply studied in order to match MIDI sequences in any type of musical styles. - Add comes with its own unique sound library, which provides sequences and sound-patches. These will be constantly updated. + Add comes with its own sound library, which provides sequences and sound-patches. specs: |- - 8-part multi-timbral phrase sampler - 4 sound layers per part with independent synth-engine @@ -45,6 +47,21 @@ specs: |- - Integrated MIDI humanizer effect - Advance swing-quantizer functions prices: - - amount: 599 - currency: USD + - amount: 139 + currency: EUR + asOf: 2006-01-25 + source: https://www.kvraudio.com/news/mesaand_releases_add_v1_0_phrase_sampler_4713 url: https://www.kvraudio.com/product/add-by-mesa-and +searchTerms: + - Mesa Plus +versions: + - name: "1.0" + releaseDate: 2006-01-25 + url: https://www.kvraudio.com/news/mesaand_releases_add_v1_0_phrase_sampler_4713 + - name: "1.2" + releaseDate: 2006-11-27 + url: https://www.kvraudio.com/news/mesaand_updates_add_to_v1_2_6288 +links: + - url: https://www.musicradar.com/reviews/tech/mesa-add-22320 + type: review + title: MusicRadar review diff --git a/data/software/roli-studio-player.yaml b/data/software/roli-studio-player.yaml new file mode 100644 index 000000000..078fe8244 --- /dev/null +++ b/data/software/roli-studio-player.yaml @@ -0,0 +1,49 @@ +id: jYdmh8GgbGPnNeXQp2alK +name: Studio Player +manufacturer: roli +primaryCategory: synthesizer +categories: + - virtual-instrument + - plugin + - arpeggiator +formats: + - au + - vst2 + - vst3 + - standalone +platforms: + - mac + - windows +identifiers: + default: com.roli.StudioPlayer +url: https://roli.com/us/product/roli-studio +searchTerms: + - ROLI Studio Player + - ROLI Studio +description: ROLI Studio Player is a preset-based instrument plug-in built on + ROLI's Equator2, Cypher2 and Strobe2 synth engines, with a curated set of + melodic presets, reorderable effects, Smart Chords and a multi-layered MPE + arpeggiator. +details: |- + ROLI Studio Player puts a curated library of melodic presets drawn from ROLI's premium synth engines, Equator2, Cypher2 and Strobe2, behind a single interface where each sound can be twisted and tweaked through macros, effects, harmonies and arpeggios. Many of the presets are designed for MPE controllers such as the Seaboard and Piano M, and they also play from any standard MIDI keyboard. + + Smart Chords turn a single finger into a full chord, and the arpeggiator stacks up to three layers into evolving sequences that respond to MPE gestures. A chain of built-in effects can be reordered and adjusted per preset. + + Studio Player is one of the two plug-ins in ROLI Studio, alongside ROLI Studio Drums. In the VST3 version its audio engine can be disabled so that its arpeggiator and Smart Chords drive other instruments in the DAW; the AU version cannot do this. +specs: |- + - Melodic presets from the Equator2, Cypher2 and Strobe2 synth engines + - MPE support, compatible with standard MIDI controllers + - Multi-layered arpeggiator (up to three layers) + - Smart Chords + - Reorderable built-in effects and macros + - Formats: standalone, AU, VST, VST3 + - macOS 11 or later, Windows 10 (x64) or later + - Intel Core, AMD Ryzen or Apple M1 processor + - 4 GB RAM (8 GB recommended), 2 GB disk space + - Internet connection required for registration and content download +versions: + - name: 1.4.1 +links: + - url: https://support.roli.com/en/support/solutions/articles/36000375168-using-roli-studio-player-to-control-other-plugins + type: support + title: Using ROLI Studio Player to control other plugins diff --git a/data/software/sonible-smartcomp-2.yaml b/data/software/sonible-smartcomp-2.yaml new file mode 100644 index 000000000..594c44f7a --- /dev/null +++ b/data/software/sonible-smartcomp-2.yaml @@ -0,0 +1,60 @@ +id: F4mzxciNJOj-c1e0ryjT9 +name: smart:comp 2 +manufacturer: sonible +primaryCategory: compressor +categories: + - plugin + - dynamics + - effect + - discontinued +formats: + - au + - vst + - vst3 + - aax +platforms: + - mac + - windows +identifiers: + default: com.sonible.smartcomp2 +url: https://www.sonible.com/blog/new-in-smartcomp3/ +prices: + - amount: 129 + currency: EUR + asOf: 2022-08-05 + source: https://musictech.com/reviews/plug-ins/sonible-smartcomp-2-review-a-flexible-spectro-dynamic-compressor-with-ai-and-new-added-features/ +description: smart:comp 2 is a spectro-dynamic compressor plug-in that learns + from the input signal to set its parameters and blends broadband compression + with Spectral Compression across more than 2000 bands. It has been replaced by + smart:comp 3. +details: |- + smart:comp 2 analyzes the incoming audio during a learning pass and computes ratio, threshold, attack and release for a well-balanced, transparent result, with instrument, bus and mix profiles to prime it for different sources. Spectral Compression then works like an ultra-high-resolution multiband compressor across more than 2000 bands, applying gain reduction only where the spectrum needs it; set to zero, the plug-in behaves as an ordinary broadband compressor. + + Sound-shaping tools go beyond the first smart:comp. The Attack and Release Shaper adds hold and curve shape to both time constants, with tempo-synced release, and a Free-form Transfer Function with a level histogram allows shapes such as expansion below the threshold. A Style control moves the character from clean to dirty. + + In external sidechain mode, Spectral Ducking carves space in the main signal only where the sidechain source sits. Mid/side and 5.1 surround processing, input riding, a soft-clip limiter, a sidechain EQ and eight states complete the feature set. sonible released smart:comp 2 in July 2022 and replaced it with smart:comp 3 in December 2025. +specs: |- + - AI-driven learning with instrument, bus and mix profiles + - Spectral Compression across more than 2000 bands + - Spectral Ducking in external sidechain mode + - Attack and Release Shaper with hold, shape and tempo sync + - Free-form Transfer Function with level histogram + - Style control from clean to dirty + - Mid/side and 5.1 surround processing + - Input riding and soft-clip limiter + - Sidechain EQ + - Eight states + - Plug-in formats: AU, VST, VST3, AAX + - Authorization: machine-based or iLok + - Windows 10 (64 bit) or later, macOS 10.12 or later + - Intel Core i5 or Apple M1, 4 GB RAM, OpenGL 3.2 or later +links: + - url: https://www.sonible.com/wp-content/uploads/2023/01/manual-smartComp2_v10.pdf + type: resource + title: Manual + - url: https://www.soundonsound.com/reviews/sonible-smartcomp-2 + type: review + title: Sound On Sound review + - url: https://musictech.com/reviews/plug-ins/sonible-smartcomp-2-review-a-flexible-spectro-dynamic-compressor-with-ai-and-new-added-features/ + type: review + title: MusicTech review diff --git a/data/software/sonible-smartcomp-3.yaml b/data/software/sonible-smartcomp-3.yaml index 8166411bc..ea41d9cd6 100644 --- a/data/software/sonible-smartcomp-3.yaml +++ b/data/software/sonible-smartcomp-3.yaml @@ -2,6 +2,7 @@ id: cNbm41wohDNrzauclzMj3 name: smart:comp 3 manufacturer: sonible primaryCategory: compressor +supersedes: F4mzxciNJOj-c1e0ryjT9 categories: - plugin - dynamics diff --git a/data/software/sonible-smartreverb-2.yaml b/data/software/sonible-smartreverb-2.yaml index b66aa8b54..bc1170268 100644 --- a/data/software/sonible-smartreverb-2.yaml +++ b/data/software/sonible-smartreverb-2.yaml @@ -2,6 +2,7 @@ id: JYch0IZbzwqb90qqYtQJc name: smart:reverb 2 manufacturer: sonible primaryCategory: reverb +supersedes: xOQbszoXqmyTv32FgeRZc categories: - plugin - effect diff --git a/data/software/sonible-smartreverb.yaml b/data/software/sonible-smartreverb.yaml new file mode 100644 index 000000000..f37778b04 --- /dev/null +++ b/data/software/sonible-smartreverb.yaml @@ -0,0 +1,57 @@ +id: xOQbszoXqmyTv32FgeRZc +name: smart:reverb +manufacturer: sonible +primaryCategory: reverb +categories: + - plugin + - effect + - discontinued +formats: + - vst + - vst3 + - au + - aax +platforms: + - mac + - windows +identifiers: + default: com.sonible.smartreverb +url: https://www.sonible.com/blog/smartreverb-user-guide/ +prices: + - amount: 129 + currency: EUR + asOf: 2020-08-10 + source: https://www.kvraudio.com/news/sonible-releases-smart-reverb---ai-powered-reverb-plug-in-49361 +description: smart:reverb is a source-adaptive reverb plug-in that learns from + the incoming audio and builds a custom-tailored reverb around it, with a + Reverb Matrix XY pad for browsing reverb styles. It has been replaced by + smart:reverb 2. +details: |- + smart:reverb adjusts its processing to the individual characteristics of the input material. After a profile is chosen and the learning process has analyzed the playing track, the plug-in sets its learned parameters, fits the Reverb Matrix to the signal and enables its detailed shaping controls. The Reverb Matrix is a parameter space covering different reverb styles, navigated as an XY pad guided by semantic descriptors. + + The Particle Display shows the reverb's anatomy and activity across frequency bands, and its Spectral Shaper Grid lets the decay rate be adjusted per band. The Temporal Shaper shows and edits how decay, spread and density evolve over time. Global Width, Color and Clarity controls fine-tune the result, with Clarity unmasking direct signal components to keep the reverb transparent. + + Reverb time, pre-delay and dry/wet sit beside Freeze, which captures the current tail as a layered sound, and Infinite, which holds the reverb indefinitely. A pre-filter EQ shapes the signal before it reaches the reverb. sonible released smart:reverb in August 2020 and replaced it with smart:reverb 2 in 2025. +specs: |- + - Source-adaptive reverb with profile-based learning + - Reverb Matrix XY pad across reverb styles + - Particle Display with Spectral Shaper Grid for per-band decay + - Temporal Shaper for decay, spread and density over time + - Width, Color and Clarity controls + - Reverb time, pre-delay and dry/wet + - Freeze and Infinite modes + - Pre-filter EQ + - States for A/B comparison and presets + - Plug-in formats: AU, VST, VST3, AAX + - Authorization: machine-based or iLok + - Windows 10 (64 bit) or macOS + - Intel Core Duo or i5 CPU, 2 GB RAM +versions: + - name: 1.1.2 +links: + - url: https://www.sonible.com/wp-content/uploads/2020/08/manual-smartreverb.pdf + type: resource + title: Manual + - url: https://musictech.com/reviews/plug-ins/sonible-smartreverb/ + type: review + title: MusicTech review diff --git a/data/software/universal-audio-1176-classic-limiter-collection.yaml b/data/software/universal-audio-1176-classic-limiter-collection.yaml index 3e24dedad..627e8ede4 100644 --- a/data/software/universal-audio-1176-classic-limiter-collection.yaml +++ b/data/software/universal-audio-1176-classic-limiter-collection.yaml @@ -5,12 +5,21 @@ primaryCategory: compressor categories: - limiter - dynamics +formats: + - vst3 + - au + - aax platforms: - mac - windows url: https://www.uaudio.com/products/1176-collection identifiers: default: com.uaudio.effects.U3CM +componentIdentifiers: + default: + - com.uaudio.effects.U39V + - com.uaudio.effects.U39X + - com.uaudio.effects.U39Z description: The definitive collection of the world's most famous compressors. The original Universal Audio 1176 was designed by ua founder m.t. "bill" putnam, and represented a major breakthrough in limiter technology. The first diff --git a/data/software/universal-audio-fairchild-tube-limiter-collection.yaml b/data/software/universal-audio-fairchild-tube-limiter-collection.yaml index e3e8f8dac..a63069b5c 100644 --- a/data/software/universal-audio-fairchild-tube-limiter-collection.yaml +++ b/data/software/universal-audio-fairchild-tube-limiter-collection.yaml @@ -15,6 +15,9 @@ platforms: url: https://www.uaudio.com/products/fairchild-tube-limiter-collection identifiers: default: com.uaudio.effects.U3AX +componentIdentifiers: + default: + - com.uaudio.effects.U3AV prices: - amount: 49 currency: USD diff --git a/data/software/universal-audio-hitsville-reverb-chambers.yaml b/data/software/universal-audio-hitsville-reverb-chambers.yaml index c3b19c29f..09f37da53 100644 --- a/data/software/universal-audio-hitsville-reverb-chambers.yaml +++ b/data/software/universal-audio-hitsville-reverb-chambers.yaml @@ -12,6 +12,9 @@ platforms: url: https://www.uaudio.com/products/hitsville-reverb-chambers identifiers: default: com.uaudio.effects.U3D5 +componentIdentifiers: + default: + - com.uaudio.effects.U3D7 prices: - amount: 199.0 currency: USD diff --git a/data/software/universal-audio-manley-massive-passive.yaml b/data/software/universal-audio-manley-massive-passive.yaml index 404e10003..3829c6911 100644 --- a/data/software/universal-audio-manley-massive-passive.yaml +++ b/data/software/universal-audio-manley-massive-passive.yaml @@ -12,6 +12,9 @@ platforms: url: https://www.uaudio.com/products/manley-massive-passive identifiers: default: com.uaudio.effects.U393 +componentIdentifiers: + default: + - com.uaudio.effects.U391 prices: - amount: 149.0 currency: USD diff --git a/data/software/universal-audio-pultec-passive-eq-collection.yaml b/data/software/universal-audio-pultec-passive-eq-collection.yaml new file mode 100644 index 000000000..7b5145ca9 --- /dev/null +++ b/data/software/universal-audio-pultec-passive-eq-collection.yaml @@ -0,0 +1,61 @@ +id: 0aA-dMN7rAaBv4KpOtAoo +name: Pultec Passive EQ Collection +manufacturer: universal-audio +primaryCategory: equalizer +categories: + - filter +formats: + - vst3 + - au + - aax +platforms: + - mac + - windows +url: https://www.uaudio.com/products/pultec-passive-eq-collection +identifiers: + default: com.uaudio.effects.U3AN +componentIdentifiers: + default: + - com.uaudio.effects.U3AP +prices: + - amount: 149 + currency: USD + asOf: 2026-09-26 + source: https://www.uaudio.com/products/pultec-passive-eq-collection +description: Circuit-modeled emulations of three Pultec passive tube equalizers, + the EQP-1A Program EQ, MEQ-5 Mid-Range EQ and HLF-3C filter set, modeling + their tube amplifiers and transformers. +details: |- + Get the "must-have" tube EQs that have shaped over 70 years of popular music. + The collection models the entire electronic path of three Pultec equalizers, + including their tube amplifiers and transformers. + + Sculpt fat and punchy bass, synths, and drums with the EQP-1A. Experience rich + midrange textures on guitars, vocals, and keys with the MEQ-5. Create retro + filter effects or reduce unwanted frequencies with surgical precision using + the HLF-3C. + + First released for UAD-2 and Apollo in 2013, succeeding UA's legacy Pultec + and Pultec Pro plugins, and available natively (UADx) since 2022 with no UA hardware + required. A purchase includes both the native and the UAD-2/Apollo versions. +specs: |- + - EQP-1A Program EQ + - MEQ-5 Mid-Range EQ + - HLF-3C filter set + - Tube amplifier and transformer modeling + - Native UADx and UAD-2/Apollo versions +searchTerms: + - Pultec + - Pultec EQP-1A + - EQP-1A + - EQP1A + - MEQ-5 + - MEQ5 + - HLF-3C + - HLF3C +links: + - type: support + url: https://help.uaudio.com/hc/en-us/articles/7183176266260-Pultec-Passive-EQ-Collection-Manual + title: Pultec Passive EQ Collection Manual +versions: + - name: "1.2.16" diff --git a/data/software/universal-audio-teletronix-la-2a-tube-compressor.yaml b/data/software/universal-audio-teletronix-la-2a-tube-compressor.yaml index 76afdb518..f27524737 100644 --- a/data/software/universal-audio-teletronix-la-2a-tube-compressor.yaml +++ b/data/software/universal-audio-teletronix-la-2a-tube-compressor.yaml @@ -12,6 +12,12 @@ platforms: - mac - windows url: https://www.uaudio.com/products/teletronix-la-2a-tube-compressor +identifiers: + default: com.uaudio.effects.U3A7 +componentIdentifiers: + default: + - com.uaudio.effects.U3A5 + - com.uaudio.effects.U3A9 prices: - amount: 49.0 currency: USD diff --git a/data/software/universal-audio-topline-vocal-suite.yaml b/data/software/universal-audio-topline-vocal-suite.yaml index bd4cc4298..fbb8c84a7 100644 --- a/data/software/universal-audio-topline-vocal-suite.yaml +++ b/data/software/universal-audio-topline-vocal-suite.yaml @@ -14,6 +14,9 @@ platforms: url: https://www.uaudio.com/products/topline-vocal-suite identifiers: default: com.uaudio.effects.UI13 +componentIdentifiers: + default: + - com.uaudio.effects.UI14 prices: - amount: 199.0 currency: USD diff --git a/data/software/waves-intrigger-drum-replacer.yaml b/data/software/waves-intrigger-drum-replacer.yaml index 2432af00b..4e99646f3 100644 --- a/data/software/waves-intrigger-drum-replacer.yaml +++ b/data/software/waves-intrigger-drum-replacer.yaml @@ -15,6 +15,9 @@ platforms: url: https://www.waves.com/plugins/intrigger-drum-replacer identifiers: default: com.WavesAudio.InTrigger +componentIdentifiers: + default: + - com.WavesAudio.InTriggerLive description: Drum trigger and replacement plugin for enhancing or replacing drum sounds in your mix. versions: diff --git a/data/software/waves-nx-germano-studios-new-york.yaml b/data/software/waves-nx-germano-studios-new-york.yaml index 269f5298e..97e427ca6 100644 --- a/data/software/waves-nx-germano-studios-new-york.yaml +++ b/data/software/waves-nx-germano-studios-new-york.yaml @@ -13,6 +13,8 @@ platforms: - mac - windows url: https://www.waves.com/plugins/nx-germano-studios-new-york +identifiers: + default: com.WavesAudio.NxGermanoStudios description: Nx Germano brings the A-list acoustics of the legendary NYC studio (formerly The Hit Factory) to your headphones. Monitor your mixes in a precise model of the iconic control room, so you can create better mixes anywhere, diff --git a/data/software/waves-studiorack.yaml b/data/software/waves-studiorack.yaml index 68d894892..9970e401b 100644 --- a/data/software/waves-studiorack.yaml +++ b/data/software/waves-studiorack.yaml @@ -1,5 +1,5 @@ id: Sc3zp1cg2yizqLNCjxOBh -name: StudioRack +name: StudioVerse Audio Effects manufacturer: waves primaryCategory: utility categories: @@ -12,9 +12,15 @@ formats: platforms: - mac - windows -url: https://www.waves.com/plugins/studiorack -description: "Mix more easily with StudioRack, the smart plugin chainer, now - supercharged with the StudioVerse plugin chain community: Thousands of - mix-ready chains, available inside your DAW, with powerful Musical AI search." +url: https://www.waves.com/plugins/studioverse-audio-effects +identifiers: + default: com.WavesAudio.StudioVerseAudioEffects +description: "StudioVerse Audio Effects, the plugin chainer formerly called + StudioRack (renamed at V15), loads thousands of mix-ready chains from the + StudioVerse community inside your DAW, with Musical AI search. It opens old + StudioRack presets and sessions." +searchTerms: + - StudioRack + - Studio Rack versions: - name: 16.7.33 diff --git a/data/software/waves-studioverse-instruments.yaml b/data/software/waves-studioverse-instruments.yaml new file mode 100644 index 000000000..c67ee197f --- /dev/null +++ b/data/software/waves-studioverse-instruments.yaml @@ -0,0 +1,27 @@ +id: 8sZNZtfgbDxevUi_dx6XN +name: StudioVerse Instruments +manufacturer: waves +primaryCategory: utility +categories: + - routing + - virtual-instrument +formats: + - au + - vst3 + - aax +platforms: + - mac + - windows +url: https://www.waves.com/plugins/studioverse-instruments +identifiers: + default: com.WavesAudio.StudioVerseInstruments +description: A free virtual instrument rack that plays, browses and auditions + thousands of instrument chains from the StudioVerse library inside your DAW, + combining layered instruments, MIDI effects and audio effects. +prices: + - amount: 0 + currency: USD + asOf: 2026-09-26 + source: https://www.waves.com/support/how-to-get-studioverse +versions: + - name: 16.7.33 diff --git a/docs/reviews/2026-09-identifier-backfill-telemetry.tsv b/docs/reviews/2026-09-identifier-backfill-telemetry.tsv index b26386cae..2c57df4fc 100644 --- a/docs/reviews/2026-09-identifier-backfill-telemetry.tsv +++ b/docs/reviews/2026-09-identifier-backfill-telemetry.tsv @@ -16,18 +16,20 @@ # Rejected, and why a probe cannot take them: # sonible-smartreverb-2 com.sonible.smartreverb — scan is smart:reverb 1 (v1.1.2), only v2 is in the catalog # sonible-smartcomp-3 com.sonible.smartcomp2 — scan is smart:comp 2, only v3 is in the catalog -# waves-intrigger-drum-replacer com.WavesAudio.InTriggerLive — "InTrigger Live" may be a separate component, entry holds com.WavesAudio.InTrigger -# waves-nx-germano-studios-new-york com.WavesAudio.NxGermanoStudios — `waves-nx` scores equally, needs a human # -# Collection-vs-member, all left for a person: UAD ships one binary per member -# of a "Collection" entry and `identifiers` holds one value per format, so the -# catalog cannot express them yet. Affected scans: +# Settled on review 2026-09-26 and written by hand: +# waves-intrigger-drum-replacer com.WavesAudio.InTriggerLive — a component every InTrigger licence ships, now in componentIdentifiers +# waves-nx-germano-studios-new-york com.WavesAudio.NxGermanoStudios — the Germano room, sold on its own; `waves-nx` is a different product +# +# Collection-vs-member: UAD ships one binary per member of a "Collection" +# entry and `identifiers` holds one value per format. Resolved 2026-09-26 by +# `componentIdentifiers` (CLAUDE.md "Identifiers"), written by hand: # universal-audio-1176-classic-limiter-collection U39V / U39X / U39Z (Rev A, LN Rev E, AE) -# universal-audio-fairchild-tube-limiter-collection U3AV (670) vs U3AX already held -# universal-audio-teletronix-la-2a-tube-compressor U3A5 / U3A7 / U3A9 (LA-2, Gray, Silver) -# universal-audio-manley-massive-passive holds U393 (the MST), scan says U391 is the base EQ -# universal-audio-topline-vocal-suite holds UI13, scan says UI14 -# universal-audio-hitsville-reverb-chambers holds U3D5, scan says U3D7 +# universal-audio-fairchild-tube-limiter-collection U3AV (670) beside U3AX +# universal-audio-teletronix-la-2a-tube-compressor U3A7 primary (Gray), U3A5 / U3A9 (LA-2, Silver) +# universal-audio-manley-massive-passive U391 (base EQ) beside U393 (MST) +# universal-audio-topline-vocal-suite UI14 beside UI13 +# universal-audio-hitsville-reverb-chambers U3D7 beside U3D5 sonible-smartgate vst3 com.sonible.smartgate studio-telemetry 2026-09-09 sonible-truebalance vst3 com.sonible.truebalance studio-telemetry 2026-09-09 From d8adb90c03011ed2d772894b53f4ae83b316078d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:00:05 +0000 Subject: [PATCH 3/4] chore(schema): regenerate json schemas Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UsMUqfW3Tnod96zTZJfkfT --- schema/json/software.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/schema/json/software.json b/schema/json/software.json index d54c89bff..bb24e46e1 100644 --- a/schema/json/software.json +++ b/schema/json/software.json @@ -2339,6 +2339,17 @@ }, "description": "Plugin identifiers keyed by format (e.g., au, vst3). `default` covers every listed format without its own key; `bundle` covers au and standalone." }, + "componentIdentifiers": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "description": "Further identifiers for a product that ships more than one binary per format (a collection's members), keyed and resolved like `identifiers`, which keeps the primary." + }, "compatibleWith": { "type": "array", "items": { From 15f7f874c2b246d9f858160b47ebcee1e318e565 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:18:25 +0000 Subject: [PATCH 4/4] fix(scripts): compare class ids without case, stop patches at migrations E402 now compares a VST3 class id case-blind, the way the writer's sameIdentifier does. pnpm patch refuses when schema migrations landed since the source tag, since a patch carries no DDL. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UsMUqfW3Tnod96zTZJfkfT --- CLAUDE.md | 6 +++ scripts/__tests__/generate-patch.test.ts | 20 ++++++++ scripts/__tests__/identifier-fallback.test.ts | 14 ++++++ scripts/generate-patch.ts | 46 +++++++++++++++++++ scripts/lib/identifier-validation.ts | 23 ++++++++-- 5 files changed, 104 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0199dd2e1..ee907a5de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,12 @@ second time, and that is the whole point of its shape. does not have, which is what makes the other failures hard to diagnose after the fact. +- **A patch refuses to cross a schema migration.** It carries rows, never + DDL, so a database at the source tag has none of the tables or columns a + newer migration adds, and the first insert naming one would fail halfway. + `pnpm patch` compares the migrations `scripts/schema.sql` recorded at that + tag with the built database's and stops when any are new. + A patch only rewrites entries whose YAML changed, so renaming a manufacturer leaves the denormalized `manufacturer_name` stale in the FTS rows of its unchanged products. Ship a full database when manufacturer names move. diff --git a/scripts/__tests__/generate-patch.test.ts b/scripts/__tests__/generate-patch.test.ts index 9171144c5..8469d5faf 100644 --- a/scripts/__tests__/generate-patch.test.ts +++ b/scripts/__tests__/generate-patch.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { type CollectionSchema, deleteStatements, + highestMigration, insertStatements, reflectCollection, } from "../generate-patch.js"; @@ -307,3 +308,22 @@ describe("insertStatements", () => { }); }); }); + +describe("highestMigration", () => { + it("reads the highest schema_migrations row, and 0 from a schema without one", () => { + const sql = [ + "CREATE TABLE foo (id TEXT);", + "INSERT OR REPLACE INTO schema_migrations (version, description, breaking_change) VALUES", + " (1, 'First; with a semicolon', 0),", + " (24, 'Added software_format_identifiers', 0),", + " (3, 'Third', 1);", + ].join("\n"); + expect(highestMigration(sql)).toBe(24); + expect(highestMigration("CREATE TABLE foo (id TEXT);")).toBe(0); + }); + + it("matches the schema this repo builds", () => { + const sql = fs.readFileSync(path.join(import.meta.dirname, "../schema.sql"), "utf-8"); + expect(highestMigration(sql)).toBe(24); + }); +}); diff --git a/scripts/__tests__/identifier-fallback.test.ts b/scripts/__tests__/identifier-fallback.test.ts index 9a525bfcd..032f938d6 100644 --- a/scripts/__tests__/identifier-fallback.test.ts +++ b/scripts/__tests__/identifier-fallback.test.ts @@ -84,6 +84,20 @@ describe("identifier uniqueness", () => { ]); expect([...shared]).toEqual([["com.acme.A", ["one", "two"]]]); }); + + it("compares a VST3 class id without case, and a bundle id with it", () => { + const upper = "ABCDEF0123456789ABCDEF0123456789"; + const shared = findSharedIdentifiers([ + { slug: "one", identifiers: { vst3: upper } }, + { slug: "two", componentIdentifiers: { vst3: [upper.toLowerCase()] } }, + { slug: "three", identifiers: { default: "com.acme.Verb" } }, + { slug: "four", identifiers: { default: "com.acme.verb" } }, + ]); + expect([...shared.values()]).toEqual([["one", "two"]]); + expect( + findComponentIdentifierRepeats({ vst3: upper }, { vst3: [upper.toLowerCase()] }) + ).toHaveLength(1); + }); }); describe("buildDatabase with fallback identifiers", () => { diff --git a/scripts/generate-patch.ts b/scripts/generate-patch.ts index 982c6b820..1d6182eee 100644 --- a/scripts/generate-patch.ts +++ b/scripts/generate-patch.ts @@ -341,6 +341,40 @@ function getDeletedEntryId(since: string, filePath: string): string | null { * Paths outside the five collections, and non-YAML files, are skipped rather * than guessed at: they carry no entry for a patch to rewrite. */ +/** + * The highest `schema_migrations` version a `scripts/schema.sql` text + * records, or 0 when it records none. + */ +export function highestMigration(schemaSql: string): number { + const start = schemaSql.indexOf("INSERT OR REPLACE INTO schema_migrations"); + if (start === -1) return 0; + let highest = 0; + for (const match of schemaSql.slice(start).matchAll(/\(\s*(\d+)\s*,\s*'/g)) { + highest = Math.max(highest, Number(match[1])); + } + return highest; +} + +/** + * Migrations HEAD's schema adds over the one at `since`. A patch carries rows, + * never DDL, so a database at `since` has none of the tables or columns these + * add, and the first insert that names one fails halfway through the patch. + */ +function migrationsSince(since: string, db: Database.Database): number[] { + let before = 0; + try { + before = highestMigration( + execFileSync("git", ["show", `${since}:scripts/schema.sql`], { encoding: "utf-8" }) + ); + } catch { + // No schema.sql at that tag: every migration is new to it. + } + const rows = db + .prepare("SELECT version FROM schema_migrations WHERE version > ? ORDER BY version") + .all(before) as { version: number }[]; + return rows.map((row) => row.version); +} + function getChangedFiles(since: string): Change[] { const changes: Change[] = []; const output = execFileSync("git", ["diff", "--name-status", since, "HEAD", "--", "data/"], { @@ -525,6 +559,18 @@ function generatePatch(fromTag: string, toVersion: string, dbPath: string): void console.log(` Found ${changes.length} changes\n`); const db = new Database(dbPath, { readonly: true }); + const newMigrations = migrationsSince(fromTag, db); + if (newMigrations.length > 0) { + // The same rule as a manufacturer rename: a database the schema moved + // under is shipped whole, never patched. + console.error( + `\n❌ Refusing to write a patch: schema migration(s) ${newMigrations.join(", ")} ` + + `landed since ${fromTag}, and a patch carries no DDL. Ship a full database.` + ); + process.exitCode = 1; + db.close(); + return; + } const schemas = new Map( COLLECTION_ORDER.map((category) => [category, reflectCollection(db, category)]) ); diff --git a/scripts/lib/identifier-validation.ts b/scripts/lib/identifier-validation.ts index b1571e3d4..a6ef4402b 100644 --- a/scripts/lib/identifier-validation.ts +++ b/scripts/lib/identifier-validation.ts @@ -144,6 +144,16 @@ export function hasValidationPattern(format: string): boolean { * `identifiers`. Either would write the same `software_format_identifiers` * row twice and reads as a mistake rather than a second binary. */ +/** + * The form two identifiers are compared in. A VST3 class id is hex, so its + * case carries no meaning (the writer's `sameIdentifier` agrees); a + * reverse-domain id keeps its case, because macOS bundle ids are + * case-sensitive. + */ +export function identifierKey(value: string): string { + return /^[0-9A-Fa-f]{32}$/.test(value) ? value.toLowerCase() : value; +} + export function findComponentIdentifierRepeats( identifiers: Record | undefined, componentIdentifiers: Record @@ -151,13 +161,15 @@ export function findComponentIdentifierRepeats( const problems: { key: string; value: string; reason: string }[] = []; for (const [key, values] of Object.entries(componentIdentifiers)) { const seen = new Set(); + const primary = identifiers?.[key]; for (const value of values) { - if (seen.has(value)) { + const normalized = identifierKey(value); + if (seen.has(normalized)) { problems.push({ key, value, reason: "listed twice" }); - } else if (identifiers?.[key] === value) { + } else if (primary !== undefined && identifierKey(primary) === normalized) { problems.push({ key, value, reason: `already the primary identifiers.${key}` }); } - seen.add(value); + seen.add(normalized); } } return problems; @@ -178,9 +190,10 @@ export interface IdentifierOwner { export function findSharedIdentifiers(entries: IdentifierOwner[]): Map { const owners = new Map>(); const claim = (value: string, slug: string) => { - const set = owners.get(value) ?? new Set(); + const normalized = identifierKey(value); + const set = owners.get(normalized) ?? new Set(); set.add(slug); - owners.set(value, set); + owners.set(normalized, set); }; for (const entry of entries) { for (const [key, value] of Object.entries(entry.identifiers ?? {})) {