Skip to content

[bugfix] audio: stop a stale voice from stranding a speech node - #242

Merged
kunkunblueberry merged 1 commit into
mainfrom
fix/stale-voice-selection
Nov 9, 2026
Merged

kunkunblueberry merged 1 commit into
mainfrom
fix/stale-voice-selection

Conversation

@kunkunblueberry

Copy link
Copy Markdown
Collaborator

A speech node could stay blocked on a voice its model can no longer use.

What happened

The composer offers the voice library only while the resolved model publishes
that capability (NodeComposer.tsx:110 reads inputRules.voiceLibrary). A node's
stored voiceSelection was cleared only inside the model-switch branch
(rules.ts:431 if (modelChanged)), which runs with modelChanged = true from
the picker and from newNodeGenerationSettings.

So after an operator edited the same model configuration into a plain
OpenAI-compatible TTS — the node keeps its modelConfigId, so nothing counts as
a switch — the stored voice survived, voiceIssue(selection, undefined) reported
voiceLibrary.connectionChanged, and backendBlocker returned "该音色属于另一服务或账户。
请从当前音色库重新选择。"
while there was no library left to re-select from. Every
later composer edit re-applied the same value through
serviceGenerationSettings(..., modelChanged = false), so the node stayed
blocked until the user switched models.

Measured before the change, on 63100975:

[raw]  blocker   = 该音色属于另一服务或账户。请从当前音色库重新选择。
[read] selection = {connectionId: "service-a", voiceId: "Alice", ...}
[read] voiceName = "Alice"

What changed

serviceAudioSettings now drops the selection when the model publishes no voice
library. That function is on the read path the composer actually uses —
getNodeGenerationSettings (generationSettings.ts:76) — so the drop happens
when the node is read, not only after a switch:

const unusable = !config.inputRules.voiceLibrary && Boolean(settings.voiceSelection);
...
...(unusable ? { voiceSelection: undefined, voiceName: undefined } : {}),

voiceName is cleared with it because rules.ts:522 requires
voiceName === voiceSelection.voiceId, so leaving the id behind would trade one
blocker for "select a voice". This is the same outcome the model-switch branch
already produced. A model that still publishes a library is untouched, and a
connection whose connectionId changed is untouched too — that case keeps its
deliberate block because the library is still there to re-select from.

Validation

node --test tests/voiceLibrary.test.ts   → 13 passed
node --test tests/*.test.ts              → 666 tests, 658 pass, 8 fail
npm run check:i18n                       → 2935 keys, 0 problems

Four tests cover this change:

  • a model that lost its voice library cannot strand a node on the old voice —
    asserts the raw settings are blocked, then that servicing clears both fields
    and the blocker.
  • a model that keeps its voice library still retains the stored selection —
    guards against over-clearing.
  • reopening a node on a model without the voice library drops the stored voice
    and reopening a node on a model that keeps the library still reads its voice
    — the same two cases through getNodeGenerationSettings, which is the boundary
    the composer and the run share.

Removing the serviceAudioSettings hunk makes the read-path test fail, so the
regression is pinned rather than asserted after the fact.

The 8 failures in the full run are identical on a clean main checkout here:
node_modules is not installed locally, so anything importing react fails with
ERR_MODULE_NOT_FOUND. The set and count are the same with and without this
change. tsc --noEmit could not run locally for the same reason; the Release
checks lane covers it.

Not covered

The composer was not exercised in a browser. The claim rests on
getNodeGenerationSettings being the single read boundary for node settings,
which the two read-path tests do exercise.

## 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
@kunkunblueberry
kunkunblueberry merged commit f06e22f into main Nov 9, 2026
1 check passed
@FayeSpica FayeSpica added area:provider Model providers and generation protocols type:bug Something is broken labels Nov 10, 2026 — with ChatGPT Codex Connector
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider Model providers and generation protocols type:bug Something is broken

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants