Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .toneforge/config.yaml

This file was deleted.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@
"buffer": "^6.0.3",
"fft.js": "^4.0.4",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0",
"node-web-audio-api": "^1.0.8",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"js-yaml": "^4.1.0",
"tone": "^15.1.22",
"unified": "^11.0.5",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/js-yaml": "^4.0.5",
"@types/js-yaml": "^4.0.9",
"@types/marked-terminal": "^6.1.1",
"@types/yargs": "^17.0.35",
"concurrently": "^9.2.1",
Expand Down
129 changes: 128 additions & 1 deletion src/classify/__tests__/category.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import { writeFileSync, mkdirSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { CategoryClassifier } from "../dimensions/category.js";
import type { AnalysisResult } from "../../analyze/types.js";
import type { RecipeContext } from "../types.js";
Expand Down Expand Up @@ -155,3 +158,127 @@ describe("CategoryClassifier", () => {
}
});
});

describe("CategoryClassifier — config loading", () => {
let tmpDir: string;

afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
vi.restoreAllMocks();
});

function makeTmpConfig(content: string): string {
tmpDir = join(tmpdir(), `toneforge-cat-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
const configPath = join(tmpDir, "config.yaml");
writeFileSync(configPath, content, "utf-8");
return configPath;
}

function makeAnalysis(): AnalysisResult {
return {
analysisVersion: "1.0",
sampleRate: 44100,
sampleCount: 44100,
metrics: {
time: { duration: 1.0, peak: 0.5, rms: 0.2, crestFactor: 2.5 },
quality: { clipping: false, silence: false },
envelope: { attackTime: 10 },
spectral: { spectralCentroid: 2000 },
},
};
}

it("loads prefix-to-category mappings from a valid YAML config", () => {
const configPath = makeTmpConfig(`
prefixToCategory:
explosion: explosion
weapon: weapon
`);
const classifier = new CategoryClassifier(configPath);
const analysis = makeAnalysis();

expect(classifier.classify(analysis, { name: "explosion-large", category: "" }).category).toBe("explosion");
expect(classifier.classify(analysis, { name: "weapon-shotgun", category: "" }).category).toBe("weapon");
});

it("config mappings override built-in defaults when present", () => {
const configPath = makeTmpConfig(`
prefixToCategory:
weapon: custom-weapon
`);
const classifier = new CategoryClassifier(configPath);
const analysis = makeAnalysis();
// The config only has "weapon"; "card" is not in config so falls through to metric heuristics
expect(classifier.classify(analysis, { name: "weapon-laser", category: "" }).category).toBe("custom-weapon");
});

it("falls back to built-in defaults and warns when config file is missing", () => {
const missingPath = join(tmpdir(), `nonexistent-${Date.now()}`, "config.yaml");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);

const classifier = new CategoryClassifier(missingPath);
const analysis = makeAnalysis();

// Should use built-in defaults (card -> card-game)
expect(classifier.classify(analysis, { name: "card-flip", category: "" }).category).toBe("card-game");
expect(warnSpy).toHaveBeenCalledOnce();
const warnMessage = warnSpy.mock.calls[0]![0] as string;
expect(warnMessage).toContain("[ToneForge]");
expect(warnMessage).toContain("built-in category mappings");
});

it("caches mappings — warning is only emitted once per classifier instance", () => {
const missingPath = join(tmpdir(), `nonexistent-${Date.now()}`, "config.yaml");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);

const classifier = new CategoryClassifier(missingPath);
const analysis = makeAnalysis();

classifier.classify(analysis, { name: "card-flip", category: "" });
classifier.classify(analysis, { name: "weapon-gun", category: "" });

expect(warnSpy).toHaveBeenCalledOnce();
});

it("throws on malformed YAML (not a top-level object)", () => {
const configPath = makeTmpConfig(`- item1\n- item2\n`);
const classifier = new CategoryClassifier(configPath);
const analysis = makeAnalysis();
expect(() => classifier.classify(analysis, { name: "weapon-gun", category: "" })).toThrow(
/Invalid config/,
);
});

it("throws when prefixToCategory key is missing", () => {
const configPath = makeTmpConfig(`someOtherKey:\n weapon: weapon\n`);
const classifier = new CategoryClassifier(configPath);
const analysis = makeAnalysis();
expect(() => classifier.classify(analysis, { name: "weapon-gun", category: "" })).toThrow(
/prefixToCategory/,
);
});

it("throws when a prefixToCategory value is not a string", () => {
const configPath = makeTmpConfig(`prefixToCategory:\n weapon: 42\n`);
const classifier = new CategoryClassifier(configPath);
const analysis = makeAnalysis();
expect(() => classifier.classify(analysis, { name: "weapon-gun", category: "" })).toThrow(
/must be a string/,
);
});

it("config load does not trigger when context.category is set", () => {
const missingPath = join(tmpdir(), `nonexistent-${Date.now()}`, "config.yaml");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);

const classifier = new CategoryClassifier(missingPath);
const analysis = makeAnalysis();

// context.category is set, so config should not be loaded
classifier.classify(analysis, { name: "weapon-gun", category: "weapon" });
expect(warnSpy).not.toHaveBeenCalled();
});
});
98 changes: 96 additions & 2 deletions src/classify/dimensions/category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
* Categories are always lowercase strings matching the vocabulary:
* weapon, footstep, ui, ambient, character, creature, vehicle, impact, card-game.
*
* Prefix-to-category mappings are loaded lazily from `.toneforge/config.yaml`
* (relative to the current working directory) on the first name-based lookup.
* If the config file is absent, the built-in defaults below are used and a
* warning is emitted. If the file exists but is malformed, an error is thrown
* so CI surfaces the problem immediately.
*
* Reference: docs/prd/CLASSIFY_PRD.md
*/

Expand All @@ -18,7 +24,7 @@ import type { AnalysisResult } from "../../analyze/types.js";
import type { DimensionClassifier, DimensionResult, RecipeContext } from "../types.js";

/**
* Known category prefixes extracted from recipe names.
* Built-in prefix-to-category defaults, used when `.toneforge/config.yaml` is absent.
*
* When a recipe name starts with one of these prefixes (before the first `-`
* or as a known multi-segment prefix), the corresponding category is assigned.
Expand Down Expand Up @@ -169,10 +175,98 @@ function inferCategoryFromMetrics(analysis: AnalysisResult): string {
*
* Uses recipe metadata as the primary signal for category, with
* analysis-metric-based fallback for unknown sources.
*
* Prefix-to-category mappings are lazy-loaded from the YAML config on the
* first name-based lookup. Pass a custom `configPath` in the constructor to
* override the default location (useful for testing).
*/
export class CategoryClassifier implements DimensionClassifier {
readonly name = "category";

private readonly configPath: string | undefined;
private mappings: Record<string, string> | null = null;
private loaded = false;

/**
* @param configPath - Optional override for the config file path. When omitted,
* the path is resolved from `process.cwd()` at the time of the first name-based
* classify call, so it reflects the working directory at usage time.
*/
constructor(configPath?: string) {
this.configPath = configPath;
}

/**
* Lazily load prefix-to-category mappings from `.toneforge/config.yaml`.
*
* - If the file is absent: emits a console warning and returns built-in defaults.
* - If the file is present but malformed: throws an error to fail fast.
* - On subsequent calls: returns the cached result.
*/
private loadMappings(): Record<string, string> {
if (this.loaded) {
return this.mappings ?? DEFAULT_RECIPE_NAME_CATEGORY_MAP;
}
this.loaded = true;

// If an instance-specific configPath was provided, prefer that and
// perform instance-scoped loading and warnings (so tests can spy on
// console.warn per-instance). Otherwise delegate to the module loader.
if (this.configPath) {
try {
if (fs.existsSync(this.configPath)) {
const raw = fs.readFileSync(this.configPath, "utf8");
const parsed = yaml.load(raw);

if (Array.isArray(parsed) || !parsed || typeof parsed !== "object") {
throw new Error(`Invalid config: expected a top-level mapping`);
}

const root = parsed as Record<string, any>;
const candidate = (root.prefixToCategory && typeof root.prefixToCategory === "object")
? root.prefixToCategory
: root;

if (Array.isArray(candidate) || typeof candidate !== "object") {
throw new Error(`Invalid config: expected mapping of prefix->category`);
}

const map: Record<string, string> = {};
for (const [k, v] of Object.entries(candidate as Record<string, any>)) {
if (typeof v !== "string") {
throw new Error(`prefixToCategory['${k}'] must be a string, got ${typeof v}`);
}
map[String(k).toLowerCase()] = String(v).toLowerCase().replace(/\s+/g, "-");
}

const normalizedDefaults: Record<string, string> = {};
for (const [k, v] of Object.entries(DEFAULT_RECIPE_NAME_CATEGORY_MAP)) {
normalizedDefaults[String(k).toLowerCase()] = String(v).toLowerCase().replace(/\s+/g, "-");
}

this.mappings = { ...normalizedDefaults, ...map };
return this.mappings;
}

// Missing file: emit a per-instance warning and fall back to defaults
// eslint-disable-next-line no-console
console.warn(`[ToneForge] No ${this.configPath} found; using built-in category mappings.`);
const normalizedDefaults: Record<string, string> = {};
for (const [k, v] of Object.entries(DEFAULT_RECIPE_NAME_CATEGORY_MAP)) {
normalizedDefaults[String(k).toLowerCase()] = String(v).toLowerCase().replace(/\s+/g, "-");
}
this.mappings = { ...normalizedDefaults };
return this.mappings;
} catch (err) {
throw new Error(`Error loading ${this.configPath}: ${(err as Error).message}`);
}
}

// No instance path — use module-level loader which caches globally
this.mappings = loadConfigMap();
return this.mappings;
}

classify(analysis: AnalysisResult, context?: RecipeContext): DimensionResult {
// Primary signal: recipe metadata
// Normalize to lowercase hyphenated form (e.g. "Card Game" -> "card-game")
Expand All @@ -184,7 +278,7 @@ export class CategoryClassifier implements DimensionClassifier {
// Secondary signal: recipe name parsing (use config if available)
if (context?.name) {
const firstSegment = context.name.split("-")[0]!.toLowerCase();
const map = loadConfigMap();
const map = this.loadMappings();
const mapped = map[firstSegment];
if (mapped) {
return { category: mapped };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "tableau_coin_collect",
"seed": 42,
"sampleRate": 44100,
"totalDuration": 0.15,
"totalDuration_ms": 150,
"events": [
{
"time_ms": 0,
"sampleOffset": 0,
"event": "card-coin-collect",
"seedOffset": 0,
"eventSeed": 42,
"gain": 1,
"repetition": 0
},
{
"time_ms": 150,
"sampleOffset": 6615,
"event": "card-chip-stack",
"seedOffset": 1,
"eventSeed": 43,
"gain": 0.8,
"repetition": 0
}
]
}