项目文件夹

文件
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

403 行
11 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* Coverage for `FirstLineCache`, the on-disk cache of pre-synthesized first
* TTS sentences plus its voice-revision fingerprint/invalidation helpers.
* Exercises the real filesystem against a temp directory.
*/
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { FIRST_SENTENCE_SNIP_VERSION } from "@elizaos/shared";
import {
_resetVoiceRevisionMemoForTesting,
computeLocalVoiceRevision,
fingerprintVoiceSettings,
FirstLineCache,
type FirstLineCacheKey,
hashCacheKey,
} from "../src/services/voice/first-line-cache";
let tmpRoot: string;
const openCaches: FirstLineCache[] = [];
function makeCache(opts: Partial<ConstructorParameters<typeof FirstLineCache>[0]> = {}) {
const cache = new FirstLineCache({ rootDir: tmpRoot, ...opts });
openCaches.push(cache);
return cache;
}
function makeKey(over: Partial<FirstLineCacheKey> = {}): FirstLineCacheKey {
return {
algoVersion: FIRST_SENTENCE_SNIP_VERSION,
provider: "elevenlabs",
voiceId: "EXAVITQu4vr4xnSDxMaL",
voiceRevision: "rev-aaaa",
sampleRate: 44100,
codec: "mp3",
voiceSettingsFingerprint: fingerprintVoiceSettings({}),
normalizedText: "got it",
...over,
};
}
function makeBytes(len = 32, fill = 0x42): Uint8Array {
const b = new Uint8Array(len);
b.fill(fill);
return b;
}
beforeEach(() => {
tmpRoot = mkdtempSync(path.join(tmpdir(), "fl-cache-"));
});
afterEach(() => {
// Close every cache before removing its dir: an open SQLite (WAL) handle
// blocks rmSync on Windows (EBUSY/EPERM); POSIX tolerates open handles.
// close() is idempotent, so double-closing an already-closed cache is safe.
for (const cache of openCaches.splice(0)) cache.close();
rmSync(tmpRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
describe("hashCacheKey", () => {
it("is deterministic across calls", () => {
const k = makeKey();
expect(hashCacheKey(k)).toBe(hashCacheKey(k));
});
it("changes when any key field changes", () => {
const base = makeKey();
const baseHash = hashCacheKey(base);
expect(hashCacheKey({ ...base, voiceId: "other" })).not.toBe(baseHash);
expect(hashCacheKey({ ...base, voiceRevision: "rev-bbbb" })).not.toBe(baseHash);
expect(hashCacheKey({ ...base, normalizedText: "sure thing" })).not.toBe(baseHash);
expect(hashCacheKey({ ...base, sampleRate: 24000 })).not.toBe(baseHash);
expect(hashCacheKey({ ...base, codec: "opus" })).not.toBe(baseHash);
expect(hashCacheKey({ ...base, provider: "kokoro" })).not.toBe(baseHash);
expect(
hashCacheKey({
...base,
voiceSettingsFingerprint: fingerprintVoiceSettings({ stability: 0.2 }),
}),
).not.toBe(baseHash);
});
});
describe("fingerprintVoiceSettings", () => {
it("is order-independent", () => {
expect(
fingerprintVoiceSettings({ stability: 0.5, style: 0.3 }),
).toBe(fingerprintVoiceSettings({ style: 0.3, stability: 0.5 }));
});
it("differs when values differ", () => {
expect(fingerprintVoiceSettings({ stability: 0.5 })).not.toBe(
fingerprintVoiceSettings({ stability: 0.6 }),
);
});
});
describe("computeLocalVoiceRevision", () => {
it("hashes the file contents", async () => {
_resetVoiceRevisionMemoForTesting();
const fA = path.join(tmpRoot, "a.bin");
const fB = path.join(tmpRoot, "b.bin");
writeFileSync(fA, "voice-pack-bytes");
writeFileSync(fB, "model-bytes");
const rev = await computeLocalVoiceRevision([fA, fB]);
expect(rev).toMatch(/^[0-9a-f]{64}$/);
writeFileSync(fA, "voice-pack-bytes-v2");
_resetVoiceRevisionMemoForTesting();
const rev2 = await computeLocalVoiceRevision([fA, fB]);
expect(rev2).not.toBe(rev);
});
it("memoises by file list", async () => {
_resetVoiceRevisionMemoForTesting();
const fA = path.join(tmpRoot, "a.bin");
writeFileSync(fA, "x");
const rev1 = await computeLocalVoiceRevision([fA]);
// Mutate file but expect the memoized result.
writeFileSync(fA, "y");
const rev2 = await computeLocalVoiceRevision([fA]);
expect(rev2).toBe(rev1);
_resetVoiceRevisionMemoForTesting();
const rev3 = await computeLocalVoiceRevision([fA]);
expect(rev3).not.toBe(rev1);
});
});
describe("FirstLineCache — basics", () => {
it("returns null on miss", () => {
const cache = makeCache();
expect(cache.get(makeKey())).toBeNull();
});
it("put then get round-trip", () => {
const cache = makeCache();
const ok = cache.put({
...makeKey(),
bytes: makeBytes(64),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 500,
});
expect(ok).toBe(true);
const got = cache.get(makeKey());
expect(got).not.toBeNull();
expect(got?.bytes.length).toBe(64);
expect(got?.rawText).toBe("Got it.");
expect(got?.contentType).toBe("audio/mpeg");
expect(got?.durationMs).toBe(500);
expect(got?.hitCount).toBe(1);
});
it("has() reflects put/delete", () => {
const cache = makeCache();
expect(cache.has(makeKey())).toBe(false);
cache.put({
...makeKey(),
bytes: makeBytes(),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
expect(cache.has(makeKey())).toBe(true);
cache.delete(makeKey());
expect(cache.has(makeKey())).toBe(false);
});
});
describe("FirstLineCache — F3 voice-swap safety", () => {
it("Kokoro entry does NOT resolve under an ElevenLabs key", () => {
const cache = makeCache();
const kokoroKey = makeKey({
provider: "kokoro",
voiceId: "af_bella",
voiceRevision: "kokoro-rev-1",
sampleRate: 24000,
codec: "opus",
});
cache.put({
...kokoroKey,
bytes: makeBytes(48, 0xaa),
rawText: "Got it.",
contentType: "audio/opus",
durationMs: 0,
});
// Same normalized text but different provider/voice/revision → miss.
const elevenLookup = makeKey({
provider: "elevenlabs",
voiceId: "EXAVITQu4vr4xnSDxMaL",
voiceRevision: "eleven-rev-1",
});
expect(cache.get(elevenLookup)).toBeNull();
});
it("different voiceRevision invalidates the cache (re-published voice pack)", () => {
const cache = makeCache();
const k1 = makeKey({ voiceRevision: "rev-1" });
const k2 = makeKey({ voiceRevision: "rev-2" });
cache.put({
...k1,
bytes: makeBytes(48),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
expect(cache.has(k1)).toBe(true);
expect(cache.has(k2)).toBe(false);
});
it("different voiceSettingsFingerprint produces miss", () => {
const cache = makeCache();
const neutral = fingerprintVoiceSettings({});
const stylised = fingerprintVoiceSettings({ stability: 0.2 });
cache.put({
...makeKey({ voiceSettingsFingerprint: neutral }),
bytes: makeBytes(48),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
expect(cache.get(makeKey({ voiceSettingsFingerprint: stylised }))).toBeNull();
});
});
describe("FirstLineCache — safety rejections", () => {
it("rejects empty voiceRevision on put + on lookup", () => {
const cache = makeCache();
const ok = cache.put({
...makeKey({ voiceRevision: "" }),
bytes: makeBytes(48),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
expect(ok).toBe(false);
expect(cache.get(makeKey({ voiceRevision: "" }))).toBeNull();
});
it("rejects empty bytes", () => {
const cache = makeCache();
const ok = cache.put({
...makeKey(),
bytes: new Uint8Array(0),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
expect(ok).toBe(false);
});
it("rejects entries larger than maxBytesPerEntry", () => {
const cache = makeCache({ maxBytesPerEntry: 1024 });
const ok = cache.put({
...makeKey(),
bytes: makeBytes(2048),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
expect(ok).toBe(false);
});
it("rejects > 10-word phrases via implicit wordCount", () => {
const cache = makeCache();
const ok = cache.put({
...makeKey({
normalizedText: "one two three four five six seven eight nine ten eleven",
}),
bytes: makeBytes(48),
rawText: "One two three four five six seven eight nine ten eleven.",
contentType: "audio/mpeg",
durationMs: 0,
});
expect(ok).toBe(false);
});
it("noops when disabled", () => {
const cache = makeCache({ disabled: true });
expect(cache.isEnabled).toBe(false);
expect(
cache.put({
...makeKey(),
bytes: makeBytes(48),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
}),
).toBe(false);
expect(cache.get(makeKey())).toBeNull();
});
});
describe("FirstLineCache — LRU eviction", () => {
it("evicts oldest entries when over byte budget", () => {
// 5 entries × 64 bytes = 320; budget 200 → must evict at least 2.
const cache = makeCache({ maxBytes: 200, maxBytesPerEntry: 1024 });
const baseKey = makeKey();
const variants = [
{ ...baseKey, normalizedText: "okay" },
{ ...baseKey, normalizedText: "sure" },
{ ...baseKey, normalizedText: "right" },
{ ...baseKey, normalizedText: "got it" },
{ ...baseKey, normalizedText: "one sec" },
];
for (const v of variants) {
cache.put({
...v,
bytes: makeBytes(64),
rawText: v.normalizedText,
contentType: "audio/mpeg",
durationMs: 0,
});
}
const stats = cache.stats();
expect(stats.bytes).toBeLessThanOrEqual(200);
expect(stats.entries).toBeLessThanOrEqual(3);
});
});
describe("FirstLineCache — persistence across reopen", () => {
it("survives closing and re-opening the index", () => {
const cache1 = makeCache();
cache1.put({
...makeKey(),
bytes: makeBytes(48),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
cache1.close();
const cache2 = makeCache();
const got = cache2.get(makeKey());
expect(got).not.toBeNull();
expect(got?.bytes.length).toBe(48);
});
it("drops orphan rows when the blob file is missing", () => {
const cache1 = makeCache();
cache1.put({
...makeKey(),
bytes: makeBytes(48),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
const blobs = path.join(tmpRoot, "blobs");
// Find and unlink any .mp3 blob recursively (one entry).
const { readdirSync, unlinkSync } = require("node:fs") as typeof import("node:fs");
function purge(dir: string) {
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const f = path.join(dir, ent.name);
if (ent.isDirectory()) purge(f);
else if (f.endsWith(".mp3")) unlinkSync(f);
}
}
purge(blobs);
const got = cache1.get(makeKey());
expect(got).toBeNull();
});
});
describe("FirstLineCache — TTL sweep", () => {
it("removes entries older than ttlDays", () => {
const cache = makeCache({ ttlDays: 1 });
cache.put({
...makeKey(),
bytes: makeBytes(48),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
expect(cache.has(makeKey())).toBe(true);
// Sweep with a "now" 2 days in the future → entry past TTL.
const removed = cache.sweep(Date.now() + 2 * 86_400_000);
expect(removed).toBe(1);
expect(cache.has(makeKey())).toBe(false);
});
});
describe("FirstLineCache — blob layout", () => {
it("writes blobs under <root>/blobs/<provider>/<voiceId>/<voiceRevision>/", () => {
const cache = makeCache();
cache.put({
...makeKey({
provider: "elevenlabs",
voiceId: "EXAVITQu4vr4xnSDxMaL",
voiceRevision: "rev-aaaa",
}),
bytes: makeBytes(48),
rawText: "Got it.",
contentType: "audio/mpeg",
durationMs: 0,
});
const expectedDir = path.join(
tmpRoot,
"blobs",
"elevenlabs",
"EXAVITQu4vr4xnSDxMaL",
"rev-aaaa",
);
expect(statSync(expectedDir).isDirectory()).toBe(true);
});
});