From 15bd57817358f450339670992924b90a80ed4461 Mon Sep 17 00:00:00 2001 From: kunkunblueberry Date: Mon, 9 Nov 2026 18:10:27 +0800 Subject: [PATCH] [bugfix] audio: stop a stale voice from stranding a speech node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## summary - Audio settings now drop a stored voice selection when the resolved model publishes no voice library. `serviceAudioSettings` runs on the composer's read path (`getNodeGenerationSettings`), so a node reopened after its model configuration changed is no longer blocked. - Before this, only a model *switch* cleared the selection. Editing the same configuration into a plain OpenAI-compatible TTS kept the voice, and `voiceIssue` reported it as belonging to another service. The composer offers the library only while that capability exists, so there was no control left to replace it and every later edit re-applied the same stale value. - Dropping the voice is the same outcome the switch path already produces, and a model that still publishes a library keeps its selection untouched. ## validation - `tests/voiceLibrary.test.ts`: 13 passed. Four cover this change — the rules level (blocked while stale, unblocked after servicing, unchanged for a model that keeps the library) and the read boundary the composer actually uses. - Removing the `serviceAudioSettings` hunk makes the read-path test fail, so the regression is pinned. - `node --test tests/*.test.ts`: 666 tests, 658 passed, 8 failed. The same 8 fail on a clean `main` checkout here because `node_modules` is not installed locally (`ERR_MODULE_NOT_FOUND: react`); the count is identical with and without this change. - `npm run check:i18n`: 2935 keys, 0 problems. No new user-visible string. Refs: #218 --- src/backend/rules.ts | 7 +++++ tests/voiceLibrary.test.ts | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/backend/rules.ts b/src/backend/rules.ts index a3e61c6d..8dc95f63 100644 --- a/src/backend/rules.ts +++ b/src/backend/rules.ts @@ -129,10 +129,17 @@ export function serviceAudioSettings( if (settings.mode !== "audio") return settings; const music = isMusicModel(config); const seconds = settings.durationSeconds; + // A model that publishes no voice library cannot validate a stored + // connection-scoped selection, and the composer only offers the library while + // that capability exists. Keeping the selection would block generation with + // "the voice belongs to another service" and leave no control to replace it, + // so drop it here rather than waiting for a model switch to do the same. + const unusable = !config.inputRules.voiceLibrary && Boolean(settings.voiceSelection); return { ...settings, audioCreationType: music ? "music" : "audio", audioMode: "tts", + ...(unusable ? { voiceSelection: undefined, voiceName: undefined } : {}), ...(music && !(seconds >= MUSIC_SECONDS.min && seconds <= MUSIC_SECONDS.max) ? { durationSeconds: MUSIC_SECONDS.initial } : {}), diff --git a/tests/voiceLibrary.test.ts b/tests/voiceLibrary.test.ts index 231f7618..5fadef8a 100644 --- a/tests/voiceLibrary.test.ts +++ b/tests/voiceLibrary.test.ts @@ -11,6 +11,8 @@ import { copyGenerationSettings, isGenerationSettings, } from "../src/canvas/dataBoundary.ts"; +import { getNodeGenerationSettings } from "../src/canvas/generationSettings.ts"; +import { createNode } from "../src/canvas/model.ts"; test("voice registration keeps all upload data in the multipart body", async (test) => { const fields = { @@ -298,3 +300,59 @@ test("direct embeddings report the same incompatibility as the voice registry", null, ); }); + +test("a model that lost its voice library cannot strand a node on the old voice", () => { + // The operator edited this same model config into a plain OpenAI-compatible + // TTS, so the node keeps its modelId but the capability is gone. The composer + // only offers the library while that capability exists, so a stored selection + // would block generation with no control left to replace it. + const withoutLibrary = { + ...config, + inputRules: { + ...config.inputRules, + voiceLibrary: undefined, + }, + }; + assert.ok( + backendBlocker(settings, [], [withoutLibrary]), + "the raw settings are blocked on a voice the model cannot use", + ); + const serviced = serviceGenerationSettings(settings, withoutLibrary, [], false); + assert.equal(serviced.voiceSelection, undefined); + assert.equal(serviced.voiceName, undefined); + assert.equal(backendBlocker(serviced, [], [withoutLibrary]), null); +}); + +test("a model that keeps its voice library still retains the stored selection", () => { + const serviced = serviceGenerationSettings(settings, config, [], false); + assert.deepEqual(serviced.voiceSelection, selection); + assert.equal(serviced.voiceName, "Alice"); + assert.equal(backendBlocker(serviced, [], [config]), null); +}); + +/** The composer reads a node through here, so this is the boundary that decides + * what the blocker and the run both see after a model configuration changes. */ +function readSettings(models: readonly ModelConfig[]) { + const node = createNode("audio", { x: 0, y: 0 }); + return getNodeGenerationSettings( + { ...node, data: { ...node.data, settings } }, + models, + ); +} + +test("reopening a node on a model without the voice library drops the stored voice", () => { + const withoutLibrary = { + ...config, + inputRules: { ...config.inputRules, voiceLibrary: undefined }, + }; + const read = readSettings([withoutLibrary]); + assert.equal(read.voiceSelection, undefined); + assert.equal(read.voiceName, undefined); + assert.equal(voiceIssue(read.voiceSelection, undefined), null); +}); + +test("reopening a node on a model that keeps the library still reads its voice", () => { + const read = readSettings([config]); + assert.deepEqual(read.voiceSelection, selection); + assert.equal(read.voiceName, "Alice"); +});