From a1c1bb344fd7dc0cdb3340a363fd3db2538a8e5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 02:20:00 +0000 Subject: [PATCH 1/2] =?UTF-8?q?WS4:=20patchSmith=20=E2=80=94=20downloadabl?= =?UTF-8?q?e=20Vital=20(.vital)=20presets=20with=20cited=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns measured synthesis character into a loadable Vital preset where every parameter cites the Phase 1 measurement behind it (PURPOSE.md invariant #2) and weak/absent evidence is disclosed rather than guessed (invariant #4). apps/ui/src/services/patchSmith.ts (pure, deterministic): - buildPatch(phase1) maps measurements -> Vital controls, each with a PatchParameterCitation {label, vitalParam, value, display, phase1Fields, rationale, confidence}: * supersawDetail.voiceCount/avgDetuneCents -> osc_1 unison voices + detune * spectralBalance.subBass (strong) -> octave-down osc_2 sub layer * acidDetail.* + spectralBalance brightness -> resonant filter + cutoff * bassDetail.averageDecayMs/type -> amp envelope decay/sustain No measurement -> no bound parameter. Low-confidence/absent sources are skipped or defaulted with an explicit hedge; overall confidence = worst band. - serializeVital(): override-only settings JSON. Confirmed against Vital's load_save.cpp that missing wavetables/modulations/lfos arrays load on init defaults (no fragile base64 wavetable embedding). Unit mappers encode Vital's real ValueDetails (env seconds = value^4; cutoff semitone = 69+12log2(f/440)). apps/ui/src/components/PatchSmithPanel.tsx: a DeviceRack "Generate Vital patch" panel (mounted in AnalysisResults) rendering the cited-parameter manifest, per-param + overall confidence pills, hedges, and the .vital download. Tests (14): deterministic mapping, citation provenance, hedging on low-confidence/empty inputs, the unit mappers, and a .vital re-parse asserting the JSON shape + in-range control values (the machine-verifiable gate). https://claude.ai/code/session_01Wq1vE1D7o3W9xZKSHXz43Q --- apps/ui/src/components/AnalysisResults.tsx | 2 + apps/ui/src/components/PatchSmithPanel.tsx | 169 +++++++++ apps/ui/src/services/patchSmith.ts | 404 +++++++++++++++++++++ apps/ui/tests/services/patchSmith.test.ts | 230 ++++++++++++ 4 files changed, 805 insertions(+) create mode 100644 apps/ui/src/components/PatchSmithPanel.tsx create mode 100644 apps/ui/src/services/patchSmith.ts create mode 100644 apps/ui/tests/services/patchSmith.test.ts diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 0ade057d..6c719aa4 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -31,6 +31,7 @@ import { INTERPRETATION_LABEL } from '../services/phaseLabels'; import type { ValidationReport } from '../services/phase2Validator'; import { Phase2ConsistencyReport } from './Phase2ConsistencyReport'; import { MeasurementDashboard } from './MeasurementDashboard'; +import { PatchSmithPanel } from './PatchSmithPanel'; import { SamplePlayback } from './SamplePlayback'; import { SessionMusicianPanel } from './SessionMusicianPanel'; import { TranscriptionPianorollBlock } from './TranscriptionPianorollBlock'; @@ -2552,6 +2553,7 @@ export function AnalysisResults({ runId={runId} /> + {phase1 && } {apiBaseUrl && runId && ( = { + HIGH: "success", + MED: "accent", + LOW: "neutral", +}; + +const CONFIDENCE_LABEL: Record = { + HIGH: "High confidence", + MED: "Medium confidence", + LOW: "Low confidence", +}; + +function rackStatus(result: PatchSmithResult): RackStatus { + if (result.manifest.citations.length === 0) return "idle"; + switch (result.manifest.overallConfidence) { + case "HIGH": + return "success"; + case "MED": + return "active"; + default: + return "warning"; + } +} + +function downloadVital(result: PatchSmithResult): void { + const blob = new Blob([serializeVital(result.preset)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = patchFileName(result.manifest.presetName); + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + +export interface PatchSmithPanelProps { + phase1: Phase1Result; + className?: string; +} + +/** + * Client-side Vital (`.vital`) patch generator. patchSmith derives the preset + * deterministically from Phase 1 synthesis measurements; this panel surfaces the + * cited-parameter manifest + hedges (invariants #2/#4) and offers the download. + * Gated behind an explicit "Generate" press so untracked tracks don't auto-fill + * the results surface. + */ +export function PatchSmithPanel({ phase1, className }: PatchSmithPanelProps) { + const result = useMemo(() => buildPatch(phase1), [phase1]); + const [generated, setGenerated] = useState(false); + + const { manifest } = result; + const hasCues = manifest.citations.length > 0; + const citationCount = manifest.citations.length; + + return ( + downloadVital(result)}> + Download .vital + + ) + } + > +
+

+ A Vital preset derived from this track's measured synthesis character — + every parameter cites the Phase 1 measurement behind it. A starting point + to load and play, not an exact reconstruction. +

+ + {!generated && ( + + )} + + {generated && ( +
+
+ {manifest.presetName} + + {CONFIDENCE_LABEL[manifest.overallConfidence]} + + + {citationCount} cited parameter{citationCount === 1 ? "" : "s"} + +
+ + {hasCues ? ( +
    + {manifest.citations.map((c) => ( +
  • +
    + {c.label} + {c.display} + + {c.confidence} + +
    +

    + {c.rationale} +

    +

    + cites {c.phase1Fields.join(", ")} +

    +
  • + ))} +
+ ) : ( +

+ No strong synthesis cues were measured — the preset is a neutral + single-oscillator starting point. +

+ )} + + {manifest.hedges.length > 0 && ( +
    + {manifest.hedges.map((hedge, index) => ( +
  • + ⚠ {hedge} +
  • + ))} +
+ )} + + +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/services/patchSmith.ts b/apps/ui/src/services/patchSmith.ts new file mode 100644 index 00000000..04a288ff --- /dev/null +++ b/apps/ui/src/services/patchSmith.ts @@ -0,0 +1,404 @@ +/** + * patchSmith — downloadable Vital (`.vital`) synth presets derived from Phase 1 + * synthesis measurements. + * + * Why this exists (PURPOSE.md): ASA answers "how do I make something that sounds + * like this?". A citable, downloadable preset turns measured synthesis character + * into a concrete starting point the producer can load and play. + * + * Two invariants shape every line here: + * #2 (chain of custody): every parameter we set cites the exact Phase 1 + * measurement(s) that justify it. No measurement → no bound parameter. + * #4 (honest uncertainty): SynthesisCharacter carries no confidence and several + * source fields are nullable, so a parameter whose evidence is weak or + * missing is either skipped or set to a conservative default *and disclosed* + * as a hedge. A preset is binary; the manifest is where the honesty lives. + * + * Format: a `.vital` file is JSON. Vital's loader (`load_save.cpp jsonToState`) + * applies the `settings` control map and *retains init defaults* for any absent + * `wavetables` / `modulations` / `lfos` / `sample` arrays — so an override-only + * settings object loads cleanly on Vital's init wavetables. We therefore shape + * the synthesis *character* via core controls (unison, filter, amp envelope, sub + * layer) rather than embedding custom wavetables; wavetable authoring from + * odd/even + inharmonicity is a documented v2 follow-up. + * + * Vital control units (from synth_parameters.cpp ValueDetails) are encoded in the + * mapping helpers below: env times use seconds = value^4; filter cutoff is in + * semitones where freq = 440·2^((s−69)/12). + */ + +import type { Phase1Result } from "../types"; + +export type PatchConfidence = "HIGH" | "MED" | "LOW"; + +/** One Vital control we set, with its measurement provenance (invariant #2). */ +export interface PatchParameterCitation { + /** Human-readable label, e.g. "Unison voices". */ + label: string; + /** Vital control name written to `settings`, e.g. "osc_1_unison_voices". */ + vitalParam: string; + /** Value written to the preset, in Vital's units. */ + value: number; + /** Musically-meaningful display of that value, e.g. "7 voices", "180 ms". */ + display: string; + /** Dotted Phase 1 paths this value is derived from. */ + phase1Fields: string[]; + /** Why this value, citing the measurement. */ + rationale: string; + /** Honest confidence of the derivation (invariant #4). */ + confidence: PatchConfidence; +} + +export interface PatchManifest { + presetName: string; + /** Parameters set with measurement provenance. */ + citations: PatchParameterCitation[]; + /** Honest-uncertainty disclosures for low-confidence / defaulted derivations. */ + hedges: string[]; + /** Worst confidence across cited params; drives the overall badge. */ + overallConfidence: PatchConfidence; +} + +/** The serializable `.vital` preset shape (a subset Vital fills out on load). */ +export interface VitalPreset { + synth_version: string; + preset_name: string; + author: string; + comments: string; + preset_style: string; + macro1: string; + macro2: string; + macro3: string; + macro4: string; + settings: Record; +} + +export interface PatchSmithResult { + manifest: PatchManifest; + preset: VitalPreset; +} + +// We declare the version Vital's loader is tolerant against; it only gates a +// soft compatibility note inside Vital, never a load failure. +const SYNTH_VERSION = "1.5.5"; +const PRESET_AUTHOR = "ASA patchSmith"; + +/** + * Baseline init controls. Vital resets to init before applying a preset, but we + * write the full signal path we touch so the patch is deterministic regardless + * of host load order. Values match Vital's documented ValueDetails defaults. + */ +const VITAL_INIT_SETTINGS: Readonly> = { + osc_1_on: 1.0, + osc_1_level: 0.707, + osc_1_transpose: 0.0, + osc_1_unison_voices: 1.0, + osc_1_unison_detune: 4.472, + osc_2_on: 0.0, + osc_2_level: 0.707, + osc_2_transpose: 0.0, + env_1_attack: 0.15, + env_1_decay: 1.0, + env_1_sustain: 1.0, + env_1_release: 0.548, + filter_1_on: 0.0, + filter_1_cutoff: 60.0, + filter_1_resonance: 0.5, + filter_1_mix: 1.0, + volume: 5473.04, + polyphony: 8.0, +}; + +// ── Unit mappers (Vital ValueDetails) ───────────────────────────────────────── + +function clamp(value: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, value)); +} + +function round(value: number, places = 4): number { + const f = 10 ** places; + return Math.round(value * f) / f; +} + +/** Vital env time control: displayed seconds = value^4 (range 0…2.378 ≈ 32 s). */ +export function secondsToEnvValue(seconds: number): number { + if (!Number.isFinite(seconds) || seconds <= 0) return 0; + return round(clamp(seconds ** 0.25, 0, 2.378)); +} + +/** Vital filter cutoff is a semitone value; freq = 440·2^((s−69)/12), range 8…136. */ +export function hzToCutoffSemitone(hz: number): number { + if (!Number.isFinite(hz) || hz <= 0) return 60.0; + const semitone = 69 + 12 * Math.log2(hz / 440); + return round(clamp(semitone, 8, 136), 2); +} + +/** + * Map a measured average detune in cents to Vital's 0…10 unison_detune control. + * The control's calibration is non-linear (kQuadratic); this is a monotonic + * approximation that lands typical supersaws (≈25–55 cents) in the musical 3.5–8 + * band. The cited measurement (avgDetuneCents) is the exact, honest part. + */ +export function detuneCentsToVital(cents: number): number { + if (!Number.isFinite(cents) || cents <= 0) return 0; + return round(clamp(cents / 7.0, 0, 10), 3); +} + +// ── Mapping engine ───────────────────────────────────────────────────────── + +const CONF_RANK: Record = { HIGH: 2, MED: 1, LOW: 0 }; + +function worstConfidence(citations: PatchParameterCitation[]): PatchConfidence { + if (citations.length === 0) return "LOW"; + return citations.reduce( + (worst, c) => (CONF_RANK[c.confidence] < CONF_RANK[worst] ? c.confidence : worst), + "HIGH", + ); +} + +/** Confidence band from a detector's 0–1 confidence score. */ +function bandFromScore(score: number): PatchConfidence { + if (score >= 0.7) return "HIGH"; + if (score >= 0.5) return "MED"; + return "LOW"; +} + +interface PatchAccumulator { + citations: PatchParameterCitation[]; + hedges: string[]; + settings: Record; +} + +function applyCitation(acc: PatchAccumulator, citation: PatchParameterCitation): void { + acc.settings[citation.vitalParam] = citation.value; + acc.citations.push(citation); +} + +/** Supersaw → unison voices + detune. The single most iconic synthesis mapping. */ +function mapSupersaw(phase1: Phase1Result, acc: PatchAccumulator): void { + const ss = phase1.supersawDetail; + if (!ss || ss.voiceCount <= 1) return; + + const detected = ss.isSupersaw && ss.confidence >= 0.5; + const confidence = detected ? bandFromScore(ss.confidence) : "LOW"; + const voices = clamp(Math.round(ss.voiceCount), 2, 16); + const detune = detuneCentsToVital(ss.avgDetuneCents); + + applyCitation(acc, { + label: "Unison voices", + vitalParam: "osc_1_unison_voices", + value: voices, + display: `${voices} voices`, + phase1Fields: ["supersawDetail.voiceCount", "supersawDetail.isSupersaw"], + rationale: `Detected ${ss.voiceCount} stacked voices (supersaw confidence ${(ss.confidence * 100).toFixed(0)}%).`, + confidence, + }); + applyCitation(acc, { + label: "Unison detune", + vitalParam: "osc_1_unison_detune", + value: detune, + display: `${ss.avgDetuneCents.toFixed(0)}¢ spread`, + phase1Fields: ["supersawDetail.avgDetuneCents"], + rationale: `Average voice detune measured at ${ss.avgDetuneCents.toFixed(0)} cents.`, + confidence, + }); + + if (!detected) { + acc.hedges.push( + `Unison is derived from a low-confidence supersaw reading (${(ss.confidence * 100).toFixed(0)}%): a starting point, not a guarantee. Dial voices/detune by ear.`, + ); + } +} + +/** Strong measured sub energy → a sub-octave second oscillator. */ +function mapSubLayer(phase1: Phase1Result, acc: PatchAccumulator): void { + const subBass = phase1.spectralBalance?.subBass; + if (typeof subBass !== "number" || subBass <= 1.0) return; + + applyCitation(acc, { + label: "Sub oscillator", + vitalParam: "osc_2_on", + value: 1.0, + display: "on (−1 oct)", + phase1Fields: ["spectralBalance.subBass"], + rationale: `Sub band sits ${subBass.toFixed(1)} dB above the mix average — add an octave-down oscillator for that weight.`, + confidence: "MED", + }); + acc.settings.osc_2_transpose = -12.0; + acc.settings.osc_2_level = 0.5; +} + +/** Acid character → resonant filter; spectral brightness places its cutoff. */ +function mapAcidFilter(phase1: Phase1Result, acc: PatchAccumulator): void { + const acid = phase1.acidDetail; + if (!acid || !acid.isAcid || acid.confidence < 0.5) return; + + const confidence = bandFromScore(acid.confidence); + const resonance = clamp(acid.resonanceLevel, 0.3, 0.95); + + applyCitation(acc, { + label: "Filter", + vitalParam: "filter_1_on", + value: 1.0, + display: "on (low-pass)", + phase1Fields: ["acidDetail.isAcid"], + rationale: `Acid character detected (confidence ${(acid.confidence * 100).toFixed(0)}%) — the resonant low-pass is the defining move.`, + confidence, + }); + applyCitation(acc, { + label: "Filter resonance", + vitalParam: "filter_1_resonance", + value: round(resonance, 3), + display: `${(resonance * 100).toFixed(0)}%`, + phase1Fields: ["acidDetail.resonanceLevel"], + rationale: `Measured resonance level ${acid.resonanceLevel.toFixed(2)} drives the filter emphasis.`, + confidence, + }); + + // Place the cutoff from spectral brightness (always-present measurement). + const sb = phase1.spectralBalance; + if (sb && typeof sb.highs === "number" && typeof sb.brilliance === "number") { + const brightness = (sb.highs + sb.brilliance) / 2; // dB above/below average + const cutoffHz = clamp(1200 * 2 ** (brightness / 6), 200, 16000); + applyCitation(acc, { + label: "Filter cutoff", + vitalParam: "filter_1_cutoff", + value: hzToCutoffSemitone(cutoffHz), + display: `${Math.round(cutoffHz)} Hz`, + phase1Fields: ["spectralBalance.highs", "spectralBalance.brilliance"], + rationale: `High/brilliance balance (${brightness >= 0 ? "+" : ""}${brightness.toFixed(1)} dB) sets the cutoff at ≈${Math.round(cutoffHz)} Hz.`, + confidence: "MED", + }); + } +} + +/** Bass decay character → amp envelope (env 1 is hardwired to amplitude). */ +function mapAmpEnvelope(phase1: Phase1Result, acc: PatchAccumulator): void { + const bass = phase1.bassDetail; + if (!bass || !Number.isFinite(bass.averageDecayMs)) return; + + const decaySec = bass.averageDecayMs / 1000; + const sustainByType: Record = { + punchy: 0.15, + medium: 0.45, + rolling: 0.6, + sustained: 0.85, + }; + const sustain = sustainByType[bass.type] ?? 0.5; + + applyCitation(acc, { + label: "Amp decay", + vitalParam: "env_1_decay", + value: secondsToEnvValue(decaySec), + display: `${Math.round(bass.averageDecayMs)} ms`, + phase1Fields: ["bassDetail.averageDecayMs"], + rationale: `Bass notes decay over ≈${Math.round(bass.averageDecayMs)} ms; the amp envelope follows.`, + confidence: "MED", + }); + applyCitation(acc, { + label: "Amp sustain", + vitalParam: "env_1_sustain", + value: round(sustain, 3), + display: `${(sustain * 100).toFixed(0)}%`, + phase1Fields: ["bassDetail.type"], + rationale: `A "${bass.type}" bass envelope ${sustain < 0.3 ? "drops fast" : sustain > 0.7 ? "holds" : "settles mid"} after the transient.`, + confidence: "MED", + }); +} + +/** Build the human-facing preset name from genre + key when available. */ +function buildPresetName(phase1: Phase1Result): string { + const genre = phase1.genreDetail?.genre; + const key = typeof phase1.key === "string" ? phase1.key : null; + const parts = ["ASA", genre ?? "Patch", key ?? ""].filter(Boolean); + return parts.join(" ").trim(); +} + +/** Build the `comments` block — measured context that didn't bind a hard param. */ +function buildComments(phase1: Phase1Result): string { + const notes: string[] = ["Generated by ASA patchSmith from Phase 1 measurements."]; + const synth = phase1.synthesisCharacter; + if (synth) { + const bits: string[] = []; + if (typeof synth.oddToEvenRatio === "number") + bits.push(`odd/even harmonic ratio ${synth.oddToEvenRatio.toFixed(2)}`); + if (typeof synth.inharmonicity === "number") + bits.push(`inharmonicity ${synth.inharmonicity.toFixed(3)}`); + if (typeof synth.analogLike === "boolean") + bits.push(synth.analogLike ? "analog-like" : "digital-like"); + if (bits.length) notes.push(`Spectral character: ${bits.join(", ")}.`); + } + const tex = phase1.textureCharacter; + if (tex) { + notes.push( + `Texture: score ${tex.textureScore.toFixed(2)}, high-band flatness ${tex.highBandFlatness.toFixed(2)}.`, + ); + } + notes.push( + "Synthesis character is approximated via core controls; wavetable shaping from odd/even + inharmonicity is a planned follow-up.", + ); + return notes.join(" "); +} + +export interface BuildPatchOptions { + /** Override the preset name (defaults to genre + key). */ + presetName?: string; +} + +/** + * Derive a Vital preset + a fully-cited manifest from Phase 1 measurements. + * Pure and deterministic: the same Phase 1 input always yields the same output. + */ +export function buildPatch(phase1: Phase1Result, options: BuildPatchOptions = {}): PatchSmithResult { + const acc: PatchAccumulator = { + citations: [], + hedges: [], + settings: { ...VITAL_INIT_SETTINGS }, + }; + + mapSupersaw(phase1, acc); + mapSubLayer(phase1, acc); + mapAcidFilter(phase1, acc); + mapAmpEnvelope(phase1, acc); + + if (acc.citations.length === 0) { + acc.hedges.push( + "Few strong synthesis cues were measured in this track, so this is a neutral single-oscillator starting patch rather than a reconstruction.", + ); + } + + const presetName = options.presetName?.trim() || buildPresetName(phase1); + + const preset: VitalPreset = { + synth_version: SYNTH_VERSION, + preset_name: presetName, + author: PRESET_AUTHOR, + comments: buildComments(phase1), + preset_style: phase1.genreDetail?.genre ?? "", + macro1: "MACRO 1", + macro2: "MACRO 2", + macro3: "MACRO 3", + macro4: "MACRO 4", + settings: acc.settings, + }; + + const manifest: PatchManifest = { + presetName, + citations: acc.citations, + hedges: acc.hedges, + overallConfidence: worstConfidence(acc.citations), + }; + + return { manifest, preset }; +} + +/** Serialize a preset to `.vital` JSON text (re-parseable; Vital reads standard JSON). */ +export function serializeVital(preset: VitalPreset): string { + return JSON.stringify(preset, null, 2); +} + +/** Convenience: filesystem-safe filename for the preset download. */ +export function patchFileName(presetName: string): string { + const safe = presetName.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, ""); + return `${safe || "ASA_Patch"}.vital`; +} diff --git a/apps/ui/tests/services/patchSmith.test.ts b/apps/ui/tests/services/patchSmith.test.ts new file mode 100644 index 00000000..11c4f39c --- /dev/null +++ b/apps/ui/tests/services/patchSmith.test.ts @@ -0,0 +1,230 @@ +/** + * patchSmith: measurement → Vital preset mapping, citation provenance, + * honest-uncertainty hedging, and .vital re-parse (the machine-verifiable gate). + */ +import { describe, expect, it } from "vitest"; +import { + buildPatch, + serializeVital, + patchFileName, + secondsToEnvValue, + hzToCutoffSemitone, + detuneCentsToVital, + type PatchParameterCitation, +} from "../../src/services/patchSmith"; +import type { Phase1Result } from "../../src/types"; + +// Minimal Phase1 shape; buildPatch only reads the synthesis-relevant fields. +function makePhase1(overrides: Record = {}): Phase1Result { + return { + bpm: 128, + key: "F minor", + spectralBalance: { + subBass: 0.0, + lowBass: 0.0, + lowMids: 0.0, + mids: 0.0, + upperMids: 0.0, + highs: 0.0, + brilliance: 0.0, + }, + ...overrides, + } as unknown as Phase1Result; +} + +const richSupersaw = makePhase1({ + genreDetail: { genre: "Trance", confidence: 0.8 }, + supersawDetail: { + isSupersaw: true, + confidence: 0.82, + voiceCount: 7, + avgDetuneCents: 35, + spectralComplexity: 0.7, + }, + spectralBalance: { + subBass: 2.0, // strong sub → osc 2 + lowBass: 1.0, + lowMids: 0.0, + mids: -0.5, + upperMids: 0.5, + highs: 2.0, + brilliance: 1.0, + }, +}); + +describe("unit mappers", () => { + it("secondsToEnvValue inverts Vital's seconds = value^4", () => { + expect(secondsToEnvValue(1)).toBeCloseTo(1.0, 3); // default decay 1.0 == 1 s + expect(secondsToEnvValue(0.2)).toBeCloseTo(0.2 ** 0.25, 3); + expect(secondsToEnvValue(100)).toBeCloseTo(2.378, 3); // clamped to 32 s max + expect(secondsToEnvValue(0)).toBe(0); + expect(secondsToEnvValue(-1)).toBe(0); + }); + + it("hzToCutoffSemitone maps Hz to Vital's semitone range", () => { + expect(hzToCutoffSemitone(261.63)).toBeCloseTo(60.0, 1); // middle C ≈ 60 + expect(hzToCutoffSemitone(20000)).toBeLessThanOrEqual(136); + expect(hzToCutoffSemitone(20)).toBeGreaterThanOrEqual(8); + expect(hzToCutoffSemitone(0)).toBe(60.0); // invalid → default + }); + + it("detuneCentsToVital is monotonic and clamped to 0..10", () => { + expect(detuneCentsToVital(0)).toBe(0); + expect(detuneCentsToVital(35)).toBeGreaterThan(detuneCentsToVital(20)); + expect(detuneCentsToVital(1000)).toBeLessThanOrEqual(10); + }); +}); + +function citation(result: ReturnType, vitalParam: string): PatchParameterCitation | undefined { + return result.manifest.citations.find((c) => c.vitalParam === vitalParam); +} + +describe("buildPatch — mapping & citations", () => { + it("maps a confident supersaw to unison voices + detune, each cited", () => { + const result = buildPatch(richSupersaw); + const voices = citation(result, "osc_1_unison_voices"); + const detune = citation(result, "osc_1_unison_detune"); + + expect(voices?.value).toBe(7); + expect(voices?.confidence).toBe("HIGH"); + expect(voices?.phase1Fields).toContain("supersawDetail.voiceCount"); + expect(detune?.phase1Fields).toContain("supersawDetail.avgDetuneCents"); + // Settings actually carry the value. + expect(result.preset.settings.osc_1_unison_voices).toBe(7); + }); + + it("enables a sub oscillator when measured sub energy is strong, citing subBass", () => { + const result = buildPatch(richSupersaw); + const sub = citation(result, "osc_2_on"); + expect(sub?.value).toBe(1.0); + expect(sub?.phase1Fields).toEqual(["spectralBalance.subBass"]); + expect(result.preset.settings.osc_2_transpose).toBe(-12.0); + }); + + it("maps acid character to a resonant filter with a spectral-placed cutoff", () => { + const result = buildPatch( + makePhase1({ + acidDetail: { + isAcid: true, + confidence: 0.75, + resonanceLevel: 0.8, + centroidOscillationHz: 5, + bassRhythmDensity: 0.6, + }, + spectralBalance: { + subBass: 0, + lowBass: 0, + lowMids: 0, + mids: 0, + upperMids: 0, + highs: 3.0, + brilliance: 2.0, + }, + }), + ); + expect(citation(result, "filter_1_on")?.value).toBe(1.0); + const res = citation(result, "filter_1_resonance"); + expect(res?.value).toBeGreaterThan(0.5); + expect(res?.phase1Fields).toContain("acidDetail.resonanceLevel"); + const cutoff = citation(result, "filter_1_cutoff"); + expect(cutoff?.phase1Fields).toContain("spectralBalance.highs"); + expect(cutoff?.value).toBeGreaterThanOrEqual(8); + expect(cutoff?.value).toBeLessThanOrEqual(136); + }); + + it("maps bass decay to the amp envelope, cited from averageDecayMs", () => { + const result = buildPatch( + makePhase1({ + bassDetail: { + averageDecayMs: 180, + type: "punchy", + transientRatio: 0.6, + fundamentalHz: 55, + transientCount: 32, + swingPercent: 0, + grooveType: "straight", + }, + }), + ); + const decay = citation(result, "env_1_decay"); + expect(decay?.phase1Fields).toEqual(["bassDetail.averageDecayMs"]); + expect(decay?.value).toBeCloseTo(secondsToEnvValue(0.18), 4); + // punchy → low sustain + expect(citation(result, "env_1_sustain")?.value).toBeLessThan(0.3); + }); + + it("every cited parameter carries at least one Phase 1 field (invariant #2)", () => { + const result = buildPatch(richSupersaw); + expect(result.manifest.citations.length).toBeGreaterThan(0); + for (const c of result.manifest.citations) { + expect(c.phase1Fields.length).toBeGreaterThan(0); + } + }); +}); + +describe("buildPatch — honest uncertainty (invariant #4)", () => { + it("hedges a low-confidence supersaw and marks the params LOW", () => { + const result = buildPatch( + makePhase1({ + supersawDetail: { + isSupersaw: false, + confidence: 0.3, + voiceCount: 5, + avgDetuneCents: 20, + spectralComplexity: 0.4, + }, + }), + ); + expect(citation(result, "osc_1_unison_voices")?.confidence).toBe("LOW"); + expect(result.manifest.hedges.join(" ")).toMatch(/low-confidence supersaw/i); + expect(result.manifest.overallConfidence).toBe("LOW"); + }); + + it("skips the sub oscillator when sub energy is not elevated", () => { + const result = buildPatch(makePhase1()); // flat spectral balance + expect(citation(result, "osc_2_on")).toBeUndefined(); + expect(result.preset.settings.osc_2_on).toBe(0.0); // stays at init default + }); + + it("emits no citations and a neutral hedge when nothing is detected", () => { + const result = buildPatch(makePhase1()); + expect(result.manifest.citations).toHaveLength(0); + expect(result.manifest.overallConfidence).toBe("LOW"); + expect(result.manifest.hedges.join(" ")).toMatch(/neutral.*starting patch/i); + }); + + it("is deterministic: same input yields identical output", () => { + expect(buildPatch(richSupersaw)).toEqual(buildPatch(richSupersaw)); + }); +}); + +describe("serializeVital — .vital re-parse gate", () => { + it("produces JSON that re-parses with the expected Vital shape and in-range values", () => { + const { preset } = buildPatch(richSupersaw); + const text = serializeVital(preset); + + const parsed = JSON.parse(text); + expect(parsed.synth_version).toBe("1.5.5"); + expect(typeof parsed.preset_name).toBe("string"); + expect(parsed.author).toBe("ASA patchSmith"); + expect(typeof parsed.settings).toBe("object"); + + // Every settings value is a finite number (Vital reads a float control map). + for (const [key, value] of Object.entries(parsed.settings)) { + expect(typeof value, key).toBe("number"); + expect(Number.isFinite(value as number), key).toBe(true); + } + // Spot-check the controls we set are within Vital's documented ranges. + expect(parsed.settings.osc_1_unison_voices).toBeGreaterThanOrEqual(1); + expect(parsed.settings.osc_1_unison_voices).toBeLessThanOrEqual(16); + expect(parsed.settings.osc_1_unison_detune).toBeGreaterThanOrEqual(0); + expect(parsed.settings.osc_1_unison_detune).toBeLessThanOrEqual(10); + expect(parsed.settings.osc_1_level).toBeGreaterThanOrEqual(0); + expect(parsed.settings.osc_1_level).toBeLessThanOrEqual(1); + }); + + it("derives a filesystem-safe .vital filename", () => { + expect(patchFileName("ASA Trance F minor")).toBe("ASA_Trance_F_minor.vital"); + expect(patchFileName("")).toBe("ASA_Patch.vital"); + }); +}); From 582f01de579ce45c71d7a6b0ee8403a1dd2128c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 02:25:42 +0000 Subject: [PATCH 2/2] patchSmith review: cite sub-osc values, drop redundant download button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR #130 review: - mapSubLayer now routes osc_2_transpose (−1 octave) and osc_2_level through applyCitation with spectralBalance.subBass provenance, so every osc_2 value in the preset appears in the manifest (invariant #2 completeness) rather than only osc_2_on. Test asserts the new citations. - Removed the redundant header "Download .vital" link from PatchSmithPanel; the body primary CTA is the single download affordance. https://claude.ai/code/session_01Wq1vE1D7o3W9xZKSHXz43Q --- apps/ui/src/components/PatchSmithPanel.tsx | 7 ------- apps/ui/src/services/patchSmith.ts | 24 +++++++++++++++++++--- apps/ui/tests/services/patchSmith.test.ts | 5 +++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/ui/src/components/PatchSmithPanel.tsx b/apps/ui/src/components/PatchSmithPanel.tsx index ccce0c7f..d9a108ba 100644 --- a/apps/ui/src/components/PatchSmithPanel.tsx +++ b/apps/ui/src/components/PatchSmithPanel.tsx @@ -75,13 +75,6 @@ export function PatchSmithPanel({ phase1, className }: PatchSmithPanelProps) { status={generated ? rackStatus(result) : "idle"} aria-label="Vital synth patch generator" className={className} - action={ - generated && ( - - ) - } >

diff --git a/apps/ui/src/services/patchSmith.ts b/apps/ui/src/services/patchSmith.ts index 04a288ff..99b4889c 100644 --- a/apps/ui/src/services/patchSmith.ts +++ b/apps/ui/src/services/patchSmith.ts @@ -219,13 +219,31 @@ function mapSubLayer(phase1: Phase1Result, acc: PatchAccumulator): void { label: "Sub oscillator", vitalParam: "osc_2_on", value: 1.0, - display: "on (−1 oct)", + display: "on", phase1Fields: ["spectralBalance.subBass"], rationale: `Sub band sits ${subBass.toFixed(1)} dB above the mix average — add an octave-down oscillator for that weight.`, confidence: "MED", }); - acc.settings.osc_2_transpose = -12.0; - acc.settings.osc_2_level = 0.5; + // The octave-down transpose and balance are what "sub layer" means; cite them + // too so every value in the preset is traceable in the manifest (invariant #2). + applyCitation(acc, { + label: "Sub oscillator octave", + vitalParam: "osc_2_transpose", + value: -12.0, + display: "−1 octave", + phase1Fields: ["spectralBalance.subBass"], + rationale: "An octave below the main oscillator carries the measured sub weight.", + confidence: "MED", + }); + applyCitation(acc, { + label: "Sub oscillator level", + vitalParam: "osc_2_level", + value: 0.5, + display: "50%", + phase1Fields: ["spectralBalance.subBass"], + rationale: "Mixed under the main oscillator so the sub supports rather than dominates.", + confidence: "MED", + }); } /** Acid character → resonant filter; spectral brightness places its cutoff. */ diff --git a/apps/ui/tests/services/patchSmith.test.ts b/apps/ui/tests/services/patchSmith.test.ts index 11c4f39c..86b79555 100644 --- a/apps/ui/tests/services/patchSmith.test.ts +++ b/apps/ui/tests/services/patchSmith.test.ts @@ -99,6 +99,11 @@ describe("buildPatch — mapping & citations", () => { expect(sub?.value).toBe(1.0); expect(sub?.phase1Fields).toEqual(["spectralBalance.subBass"]); expect(result.preset.settings.osc_2_transpose).toBe(-12.0); + // The octave + level are cited too, so the manifest accounts for every + // osc_2 value the preset carries (review follow-up). + expect(citation(result, "osc_2_transpose")?.value).toBe(-12.0); + expect(citation(result, "osc_2_transpose")?.phase1Fields).toEqual(["spectralBalance.subBass"]); + expect(citation(result, "osc_2_level")?.value).toBe(0.5); }); it("maps acid character to a resonant filter with a spectral-placed cutoff", () => {