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
20 changes: 20 additions & 0 deletions .toneforge/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# ToneForge category classifier configuration.
# Maps recipe-name prefixes (before the first '-') to canonical category labels.
# Edit this file to add or rename categories without modifying TypeScript source.
# Schema: prefixToCategory is a flat map of string -> string.

prefixToCategory:
weapon: weapon
footstep: footstep
ui: ui
ambient: ambient
character: character
creature: creature
vehicle: vehicle
impact: impact
slam: impact
rumble: impact
debris: impact
rattle: impact
resonance: impact
card: card-game
51 changes: 39 additions & 12 deletions package-lock.json

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

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"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",
Expand All @@ -44,6 +45,7 @@
"yargs": "^17.7.2"
},
"devDependencies": {
"@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();
});
});
Loading