feat(tts): speech toggles warm up and unload local TTS engines (#100881)
Desktop "Read replies aloud" / voice conversation, TUI and CLI /voice tts now hold a lease on the TTS engine. Acquiring pre-loads the configured provider (piper/kittentts model into the same LRU slot synthesis reads; lazily-installed cloud SDKs), so the first spoken reply no longer pays the model load as dead air. Releasing the last lease across surfaces unloads resident local models. - tools/tts_tool.py: warm_tts_provider / release_tts_provider / acquire_tts_lease / release_tts_lease over a _LOCAL_TTS_MODEL_CACHES registry; piper/kittentts loaders extracted so warm-up and synthesis share one resolution path. - web_server: POST /api/audio/tts-lease (profile-scoped, off-loop, failures reported in body never as HTTP errors). - tui_gateway voice.toggle + cli.py /voice tts|on|off wire the lease. - desktop: lib/tts-lease.ts (dedupe, per-lease serialization, latest intent wins) driven from useComposerVoice; setTtsLease API client. - docs: features/tts.md section. Live (real piper, isolated HERMES_HOME): first synthesis 988ms cold → 92ms after the toggle warmed the engine; release drops the model.
This commit is contained in:
@@ -3,6 +3,7 @@ import type {
|
|||||||
ActionStatusResponse,
|
ActionStatusResponse,
|
||||||
AudioSpeakResponse,
|
AudioSpeakResponse,
|
||||||
AudioTranscriptionResponse,
|
AudioTranscriptionResponse,
|
||||||
|
AudioTtsLeaseResponse,
|
||||||
BackendUpdateCheckResponse,
|
BackendUpdateCheckResponse,
|
||||||
CuratorStatusResponse,
|
CuratorStatusResponse,
|
||||||
DebugShareResponse,
|
DebugShareResponse,
|
||||||
@@ -196,6 +197,26 @@ export function speakText(text: string): Promise<AudioSpeakResponse> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acquiring a lease pre-loads the configured TTS engine. For local engines
|
||||||
|
// that is a model load and, on a fresh install, a voice download — well past
|
||||||
|
// the default 15s Electron backend timeout.
|
||||||
|
export const AUDIO_TTS_LEASE_REQUEST_TIMEOUT_MS = 180_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the backend a speech-output toggle flipped so it can warm the TTS engine
|
||||||
|
* (`active: true`) or release it once no surface needs it (`active: false`).
|
||||||
|
* `lease` names the toggle — `desktop:read-aloud`, `desktop:conversation`.
|
||||||
|
*/
|
||||||
|
export function setTtsLease(lease: string, active: boolean): Promise<AudioTtsLeaseResponse> {
|
||||||
|
return hermesApi<AudioTtsLeaseResponse>({
|
||||||
|
...profileScoped(),
|
||||||
|
path: '/api/audio/tts-lease',
|
||||||
|
method: 'POST',
|
||||||
|
body: { active, lease },
|
||||||
|
timeoutMs: AUDIO_TTS_LEASE_REQUEST_TIMEOUT_MS
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getElevenLabsVoices(profile?: null | string): Promise<ElevenLabsVoicesResponse> {
|
export function getElevenLabsVoices(profile?: null | string): Promise<ElevenLabsVoicesResponse> {
|
||||||
return hermesApi<ElevenLabsVoicesResponse>({
|
return hermesApi<ElevenLabsVoicesResponse>({
|
||||||
path: '/api/audio/elevenlabs/voices',
|
path: '/api/audio/elevenlabs/voices',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from '@/i18n'
|
|||||||
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
|
import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
|
||||||
|
import { CONVERSATION_LEASE, READ_ALOUD_LEASE, syncTtsLease } from '@/lib/tts-lease'
|
||||||
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
|
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
|
||||||
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
|
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
|
||||||
import { resetBrowseState } from '@/store/composer-input-history'
|
import { resetBrowseState } from '@/store/composer-input-history'
|
||||||
@@ -265,6 +266,26 @@ export function useComposerVoice({
|
|||||||
|
|
||||||
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
|
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
|
||||||
|
|
||||||
|
// Speech-output toggles are TTS warm-up / release signals. Entering a voice
|
||||||
|
// conversation acquires this window's lease (pre-loads the engine so the
|
||||||
|
// first spoken reply doesn't start with dead air); ending it releases the
|
||||||
|
// lease, and the backend unloads resident local models once no surface holds
|
||||||
|
// one. Fire-and-forget — the toggle never waits on or fails from this.
|
||||||
|
useEffect(() => {
|
||||||
|
void syncTtsLease(CONVERSATION_LEASE, voiceConversationActive)
|
||||||
|
}, [voiceConversationActive])
|
||||||
|
|
||||||
|
useEffect(() => () => void syncTtsLease(CONVERSATION_LEASE, false), [])
|
||||||
|
|
||||||
|
// "Read replies aloud" is the same signal, held for as long as the toggle is
|
||||||
|
// on (it mirrors voice.auto_tts, so this also warms at startup when the
|
||||||
|
// preference is already set).
|
||||||
|
const autoSpeakReplies = useStore($autoSpeakReplies)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void syncTtsLease(READ_ALOUD_LEASE, autoSpeakReplies)
|
||||||
|
}, [autoSpeakReplies])
|
||||||
|
|
||||||
// Explicit start/end for the on-screen conversation controls (the hotkey uses
|
// Explicit start/end for the on-screen conversation controls (the hotkey uses
|
||||||
// the gated toggle above).
|
// the gated toggle above).
|
||||||
const startConversation = useCallback(() => setVoiceConversationActive(true), [])
|
const startConversation = useCallback(() => setVoiceConversationActive(true), [])
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type {
|
|||||||
AnalyticsTotals,
|
AnalyticsTotals,
|
||||||
AudioSpeakResponse,
|
AudioSpeakResponse,
|
||||||
AudioTranscriptionResponse,
|
AudioTranscriptionResponse,
|
||||||
|
AudioTtsLeaseResponse,
|
||||||
AutomationBlueprint,
|
AutomationBlueprint,
|
||||||
AutomationBlueprintField,
|
AutomationBlueprintField,
|
||||||
AuxiliaryModelsResponse,
|
AuxiliaryModelsResponse,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const setTtsLease = vi.fn(async (_lease: string, _active: boolean) => ({ ok: true }))
|
||||||
|
|
||||||
|
vi.mock('@/hermes', () => ({
|
||||||
|
setTtsLease: (lease: string, active: boolean) => setTtsLease(lease, active)
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { CONVERSATION_LEASE, READ_ALOUD_LEASE, resetTtsLeasesForTests, syncTtsLease } from './tts-lease'
|
||||||
|
|
||||||
|
describe('syncTtsLease', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTtsLeasesForTests()
|
||||||
|
setTtsLease.mockReset()
|
||||||
|
setTtsLease.mockImplementation(async () => ({ ok: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetTtsLeasesForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('acquires on the first on and releases on off', async () => {
|
||||||
|
await syncTtsLease(READ_ALOUD_LEASE, true)
|
||||||
|
await syncTtsLease(READ_ALOUD_LEASE, false)
|
||||||
|
|
||||||
|
expect(setTtsLease.mock.calls).toEqual([
|
||||||
|
[READ_ALOUD_LEASE, true],
|
||||||
|
[READ_ALOUD_LEASE, false]
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips an initial off — never releases a lease it did not hold', async () => {
|
||||||
|
await syncTtsLease(CONVERSATION_LEASE, false)
|
||||||
|
|
||||||
|
expect(setTtsLease).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes a repeat of the last sent state', async () => {
|
||||||
|
await syncTtsLease(READ_ALOUD_LEASE, true)
|
||||||
|
await syncTtsLease(READ_ALOUD_LEASE, true)
|
||||||
|
await syncTtsLease(READ_ALOUD_LEASE, true)
|
||||||
|
|
||||||
|
expect(setTtsLease).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues an off behind an in-flight on so the wire never sees them reordered', async () => {
|
||||||
|
let finishAcquire: () => void = () => undefined
|
||||||
|
setTtsLease.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
finishAcquire = () => resolve({ ok: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const on = syncTtsLease(CONVERSATION_LEASE, true)
|
||||||
|
// Let the acquire actually go out (it runs on a microtask).
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(setTtsLease.mock.calls).toEqual([[CONVERSATION_LEASE, true]])
|
||||||
|
|
||||||
|
const off = syncTtsLease(CONVERSATION_LEASE, false)
|
||||||
|
await Promise.resolve()
|
||||||
|
// Still only the acquire — the release waits for it to finish.
|
||||||
|
expect(setTtsLease).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
finishAcquire()
|
||||||
|
await Promise.all([on, off])
|
||||||
|
|
||||||
|
expect(setTtsLease.mock.calls).toEqual([
|
||||||
|
[CONVERSATION_LEASE, true],
|
||||||
|
[CONVERSATION_LEASE, false]
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coalesces a flip that reverses before its call went out — latest intent wins', async () => {
|
||||||
|
const on = syncTtsLease(CONVERSATION_LEASE, true)
|
||||||
|
const off = syncTtsLease(CONVERSATION_LEASE, false)
|
||||||
|
await Promise.all([on, off])
|
||||||
|
|
||||||
|
// The acquire never had a chance to go out; only the terminal state is sent
|
||||||
|
// (a release of a never-held lease is a backend no-op).
|
||||||
|
expect(setTtsLease.mock.calls).toEqual([[CONVERSATION_LEASE, false]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forgets the sent state on failure so the next flip retries', async () => {
|
||||||
|
setTtsLease.mockImplementationOnce(async () => {
|
||||||
|
throw new Error('backend not ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(syncTtsLease(READ_ALOUD_LEASE, true)).resolves.toBeUndefined()
|
||||||
|
await syncTtsLease(READ_ALOUD_LEASE, true)
|
||||||
|
|
||||||
|
expect(setTtsLease).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conversation lease is per renderer, read-aloud lease is shared', () => {
|
||||||
|
expect(CONVERSATION_LEASE).toMatch(/^desktop:conversation:[a-z0-9]+$/)
|
||||||
|
expect(READ_ALOUD_LEASE).toBe('desktop:read-aloud')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { setTtsLease } from '@/hermes'
|
||||||
|
|
||||||
|
// The desktop's speech-output toggles — "Read replies aloud" and voice
|
||||||
|
// conversation mode — are the user telling us TTS is about to be needed (or no
|
||||||
|
// longer is). The backend turns that into engine lifecycle: acquiring a lease
|
||||||
|
// pre-loads the configured provider (a local piper/kittentts model, a lazily
|
||||||
|
// installed SDK) so the first spoken reply starts hot instead of paying the load
|
||||||
|
// as dead air; releasing the last lease unloads resident local models.
|
||||||
|
//
|
||||||
|
// This module is the renderer's single choke point for that signal. It dedupes
|
||||||
|
// (several composers/tiles observe the same toggle), serializes per lease so a
|
||||||
|
// fast on→off→on can't be reordered on the wire, and never surfaces failures —
|
||||||
|
// warm-up is an optimization; the toggle itself must not depend on it.
|
||||||
|
|
||||||
|
// Per-renderer id so two windows in conversation mode hold DISTINCT leases —
|
||||||
|
// window A ending its conversation must not release the engine window B is
|
||||||
|
// still speaking through. Read-aloud mirrors one config key shared by every
|
||||||
|
// window, so it deliberately uses one shared lease name.
|
||||||
|
const RENDERER_ID = Math.random().toString(36).slice(2, 10)
|
||||||
|
|
||||||
|
export const READ_ALOUD_LEASE = 'desktop:read-aloud'
|
||||||
|
export const CONVERSATION_LEASE = `desktop:conversation:${RENDERER_ID}`
|
||||||
|
|
||||||
|
const sent = new Map<string, boolean>()
|
||||||
|
const inFlight = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the backend's view of `lease` in line with `active`. Idempotent: a
|
||||||
|
* repeat of the last sent state is a no-op. The initial `false` (nothing was
|
||||||
|
* ever acquired) is also skipped — releasing a lease we never held would only
|
||||||
|
* churn the backend on app start.
|
||||||
|
*/
|
||||||
|
export function syncTtsLease(lease: string, active: boolean): Promise<void> {
|
||||||
|
const last = sent.get(lease)
|
||||||
|
|
||||||
|
if (last === active || (last === undefined && !active)) {
|
||||||
|
return inFlight.get(lease) ?? Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
sent.set(lease, active)
|
||||||
|
|
||||||
|
const previous = inFlight.get(lease) ?? Promise.resolve()
|
||||||
|
|
||||||
|
const next = previous
|
||||||
|
.then(async () => {
|
||||||
|
// Latest intent wins: if the toggle flipped again while we were queued,
|
||||||
|
// the newer call sends its own state and this one has nothing to say.
|
||||||
|
if (sent.get(lease) !== active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await setTtsLease(lease, active)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Backend not up yet / older backend without the endpoint / warm-up
|
||||||
|
// failure: forget what we "sent" so the next flip retries honestly.
|
||||||
|
if (sent.get(lease) === active) {
|
||||||
|
sent.delete(lease)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (inFlight.get(lease) === next) {
|
||||||
|
inFlight.delete(lease)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
inFlight.set(lease, next)
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam — forget every sent state. */
|
||||||
|
export function resetTtsLeasesForTests() {
|
||||||
|
sent.clear()
|
||||||
|
inFlight.clear()
|
||||||
|
}
|
||||||
@@ -29,6 +29,21 @@ export interface AudioSpeakResponse {
|
|||||||
provider?: string
|
provider?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `POST /api/audio/tts-lease` — TTS engine warm-up / release driven by speech toggles. */
|
||||||
|
export interface AudioTtsLeaseResponse {
|
||||||
|
ok: boolean
|
||||||
|
lease: string
|
||||||
|
active: boolean
|
||||||
|
/** Live lease holders after this call (null when the backend call itself failed). */
|
||||||
|
leases: null | number
|
||||||
|
/** Warm-up outcome: `loaded` | `cached` | `installed` | `noop` | `error`. */
|
||||||
|
action?: string
|
||||||
|
provider?: string
|
||||||
|
/** Resident local models dropped (release path). */
|
||||||
|
released?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElevenLabsVoice {
|
export interface ElevenLabsVoice {
|
||||||
label: string
|
label: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -15765,6 +15765,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||||||
# _voice_message_prefix property and its usage in _process_message().
|
# _voice_message_prefix property and its usage in _process_message().
|
||||||
|
|
||||||
tts_status = " (TTS enabled)" if self._voice_tts else ""
|
tts_status = " (TTS enabled)" if self._voice_tts else ""
|
||||||
|
if self._voice_tts:
|
||||||
|
# Speech output is on from the start — warm the engine now so the
|
||||||
|
# first spoken reply doesn't pay the model load as dead air.
|
||||||
|
self._tts_lease_async(True)
|
||||||
# Use the startup-pinned cache so the advertised shortcut always
|
# Use the startup-pinned cache so the advertised shortcut always
|
||||||
# matches the live prompt_toolkit binding — reading live config
|
# matches the live prompt_toolkit binding — reading live config
|
||||||
# here would drift after a mid-session config edit (Copilot
|
# here would drift after a mid-session config edit (Copilot
|
||||||
@@ -15823,6 +15827,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||||||
self._voice_tts = False
|
self._voice_tts = False
|
||||||
self._voice_continuous = False
|
self._voice_continuous = False
|
||||||
|
|
||||||
|
# Speech output is off with the mode — release the TTS engine lease so
|
||||||
|
# a resident local model (piper/kittentts) is freed once nothing else
|
||||||
|
# in this process still needs it.
|
||||||
|
self._tts_lease_async(False)
|
||||||
|
|
||||||
# Shut down the persistent audio stream in background
|
# Shut down the persistent audio stream in background
|
||||||
if recorder is not None:
|
if recorder is not None:
|
||||||
def _bg_shutdown(rec=recorder):
|
def _bg_shutdown(rec=recorder):
|
||||||
@@ -16070,6 +16079,29 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||||||
if not owned:
|
if not owned:
|
||||||
_cprint(f" {_DIM}Enable with /wake on{_RST}")
|
_cprint(f" {_DIM}Enable with /wake on{_RST}")
|
||||||
|
|
||||||
|
def _tts_lease_async(self, active: bool) -> None:
|
||||||
|
"""Acquire/release this CLI's TTS engine lease in the background.
|
||||||
|
|
||||||
|
The /voice tts toggle (and voice-mode on/off with speech output set)
|
||||||
|
is the "TTS is about to be needed / no longer needed" signal:
|
||||||
|
acquiring pre-loads the configured provider so the first reply starts
|
||||||
|
hot; releasing lets the last-holder path unload resident local models.
|
||||||
|
Never blocks the toggle and never fails it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
from tools.tts_tool import acquire_tts_lease, release_tts_lease
|
||||||
|
|
||||||
|
if active:
|
||||||
|
acquire_tts_lease("cli:voice-tts")
|
||||||
|
else:
|
||||||
|
release_tts_lease("cli:voice-tts")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("voice: tts lease active=%s failed: %s", active, e)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, name="tts-lease-cli", daemon=True).start()
|
||||||
|
|
||||||
def _toggle_voice_tts(self):
|
def _toggle_voice_tts(self):
|
||||||
"""Toggle TTS output for voice mode."""
|
"""Toggle TTS output for voice mode."""
|
||||||
if not self._voice_mode:
|
if not self._voice_mode:
|
||||||
@@ -16085,6 +16117,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||||||
if not check_tts_requirements():
|
if not check_tts_requirements():
|
||||||
_cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
|
_cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
|
||||||
|
|
||||||
|
# Toggle = warm-up / release signal for the TTS engine (see
|
||||||
|
# tools.tts_tool.acquire_tts_lease).
|
||||||
|
self._tts_lease_async(self._voice_tts)
|
||||||
|
|
||||||
_cprint(f"{_ACCENT}Voice TTS {status}.{_RST}")
|
_cprint(f"{_ACCENT}Voice TTS {status}.{_RST}")
|
||||||
|
|
||||||
def _show_voice_status(self):
|
def _show_voice_status(self):
|
||||||
|
|||||||
@@ -306,6 +306,17 @@ class TTSSpeakRequest(BaseModel):
|
|||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class TTSLeaseRequest(BaseModel):
|
||||||
|
"""Body for ``POST /api/audio/tts-lease``.
|
||||||
|
|
||||||
|
``lease`` names the toggle/surface holding the lease (``desktop:read-aloud``,
|
||||||
|
``desktop:conversation``); ``active`` True acquires + warms, False releases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lease: str
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
|
||||||
# --- from web_server.py (originally lines 11549-11551) ---
|
# --- from web_server.py (originally lines 11549-11551) ---
|
||||||
|
|
||||||
class OAuthSubmitBody(BaseModel):
|
class OAuthSubmitBody(BaseModel):
|
||||||
|
|||||||
@@ -1836,6 +1836,7 @@ from hermes_cli.web_models import ( # noqa: F401
|
|||||||
LearningNodeEdit,
|
LearningNodeEdit,
|
||||||
DebugShareRequest,
|
DebugShareRequest,
|
||||||
TTSSpeakRequest,
|
TTSSpeakRequest,
|
||||||
|
TTSLeaseRequest,
|
||||||
OAuthSubmitBody,
|
OAuthSubmitBody,
|
||||||
BulkDeleteSessions,
|
BulkDeleteSessions,
|
||||||
SessionImport,
|
SessionImport,
|
||||||
@@ -5665,6 +5666,43 @@ async def speak_text(payload: TTSSpeakRequest, profile: Optional[str] = None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/audio/tts-lease")
|
||||||
|
async def tts_lease(payload: TTSLeaseRequest, profile: Optional[str] = None):
|
||||||
|
"""Desktop TTS-output toggles as warm-up / release signals.
|
||||||
|
|
||||||
|
"Read replies aloud" and voice-conversation mode are explicit "speech is
|
||||||
|
about to be needed" gestures. ``active: true`` registers the toggle as a
|
||||||
|
lease on the TTS engine and pre-loads the configured provider (local
|
||||||
|
piper/kittentts model, lazily-installed SDK) so the first spoken reply
|
||||||
|
doesn't pay the load as dead air; ``active: false`` drops the lease and,
|
||||||
|
once no surface holds one, unloads resident local models.
|
||||||
|
|
||||||
|
Blocking work (model load, voice download) runs off the event loop.
|
||||||
|
Warm-up failures are reported in the body, never as an HTTP error — the
|
||||||
|
toggle must succeed even when the engine can't preload.
|
||||||
|
"""
|
||||||
|
lease = (payload.lease or "").strip()
|
||||||
|
if not lease:
|
||||||
|
raise HTTPException(status_code=400, detail="lease is required")
|
||||||
|
|
||||||
|
def _apply():
|
||||||
|
from tools.tts_tool import acquire_tts_lease, release_tts_lease
|
||||||
|
|
||||||
|
if payload.active:
|
||||||
|
with _config_profile_scope(profile):
|
||||||
|
return acquire_tts_lease(lease)
|
||||||
|
return release_tts_lease(lease)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.get_running_loop().run_in_executor(None, _apply)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
_log.warning("TTS lease %s (%s) failed: %s", lease, payload.active, exc)
|
||||||
|
result = {"leases": None, "action": "error", "error": str(exc)}
|
||||||
|
return {"ok": True, "lease": lease, "active": payload.active, **result}
|
||||||
|
|
||||||
|
|
||||||
def _split_text_for_speak_stream(text: str, cap: int) -> list:
|
def _split_text_for_speak_stream(text: str, cap: int) -> list:
|
||||||
"""Split *text* into provider-cap-sized pieces on sentence boundaries.
|
"""Split *text* into provider-cap-sized pieces on sentence boundaries.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""``POST /api/audio/tts-lease`` — desktop speech toggles as TTS warm-up/release.
|
||||||
|
|
||||||
|
The desktop's "Read replies aloud" and voice-conversation toggles call this so
|
||||||
|
the backend can pre-load the configured TTS engine when speech is about to be
|
||||||
|
needed and unload resident local models once no surface holds a lease.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
from hermes_cli import profiles
|
||||||
|
|
||||||
|
default_home = get_hermes_home()
|
||||||
|
profiles_root = default_home / "profiles"
|
||||||
|
worker_home = profiles_root / "worker_beta"
|
||||||
|
for home in (default_home, worker_home):
|
||||||
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
|
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||||
|
(worker_home / ".env").write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||||
|
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||||
|
return {"default": default_home, "worker_beta": worker_home}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, isolated_profiles):
|
||||||
|
try:
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("fastapi/starlette not installed")
|
||||||
|
|
||||||
|
import hermes_state
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||||
|
|
||||||
|
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||||
|
c = TestClient(app)
|
||||||
|
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_leases():
|
||||||
|
from tools import tts_tool
|
||||||
|
|
||||||
|
tts_tool._reset_tts_leases_for_tests()
|
||||||
|
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||||
|
cache.clear()
|
||||||
|
yield
|
||||||
|
tts_tool._reset_tts_leases_for_tests()
|
||||||
|
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_acquires_and_warms(client, monkeypatch):
|
||||||
|
from tools import tts_tool
|
||||||
|
|
||||||
|
warmed = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tts_tool,
|
||||||
|
"warm_tts_provider",
|
||||||
|
lambda cfg=None, provider=None: warmed.append(1) or {"provider": "piper", "warmed": True, "action": "loaded"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["lease"] == "desktop:read-aloud"
|
||||||
|
assert body["active"] is True
|
||||||
|
assert body["leases"] == 1
|
||||||
|
assert body["action"] == "loaded"
|
||||||
|
assert warmed == [1]
|
||||||
|
assert tts_tool.tts_lease_holders() == ["desktop:read-aloud"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactive_releases_and_unloads_when_last(client, monkeypatch):
|
||||||
|
from tools import tts_tool
|
||||||
|
|
||||||
|
monkeypatch.setattr(tts_tool, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "piper"})
|
||||||
|
client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True})
|
||||||
|
client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": True})
|
||||||
|
tts_tool._piper_voice_cache["voice"] = object()
|
||||||
|
|
||||||
|
first = client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": False}).json()
|
||||||
|
assert first["leases"] == 1
|
||||||
|
assert first["released"] == 0
|
||||||
|
assert len(tts_tool._piper_voice_cache) == 1
|
||||||
|
|
||||||
|
last = client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": False}).json()
|
||||||
|
assert last["leases"] == 0
|
||||||
|
assert last["released"] == 1
|
||||||
|
assert tts_tool._piper_voice_cache == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_failure_is_reported_not_an_http_error(client, monkeypatch):
|
||||||
|
from tools import tts_tool
|
||||||
|
|
||||||
|
def _boom(cfg=None, provider=None):
|
||||||
|
raise RuntimeError("engine exploded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tts_tool, "warm_tts_provider", _boom)
|
||||||
|
resp = client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["action"] == "error"
|
||||||
|
assert "engine exploded" in body["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_lease_rejected(client):
|
||||||
|
resp = client.post("/api/audio/tts-lease", json={"lease": " ", "active": True})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_default_true(client, monkeypatch):
|
||||||
|
from tools import tts_tool
|
||||||
|
|
||||||
|
monkeypatch.setattr(tts_tool, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "x"})
|
||||||
|
resp = client.post("/api/audio/tts-lease", json={"lease": "tui:x"})
|
||||||
|
assert resp.json()["active"] is True
|
||||||
|
assert tts_tool.tts_lease_holders() == ["tui:x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquire_resolves_provider_inside_target_profile(client, isolated_profiles, monkeypatch):
|
||||||
|
"""Warm-up must read the REQUESTING profile's tts config, like /api/audio/speak."""
|
||||||
|
import yaml
|
||||||
|
from tools import tts_tool
|
||||||
|
|
||||||
|
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||||
|
yaml.safe_dump({"tts": {"provider": "kittentts"}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _fake_warm(cfg=None, provider=None):
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
seen["home"] = str(get_hermes_home())
|
||||||
|
seen["provider"] = tts_tool._get_provider(tts_tool._load_tts_config())
|
||||||
|
return {"action": "noop", "warmed": False, "provider": seen["provider"]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(tts_tool, "warm_tts_provider", _fake_warm)
|
||||||
|
resp = client.post("/api/audio/tts-lease?profile=worker_beta", json={"lease": "desktop:read-aloud", "active": True})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert seen["home"] == str(isolated_profiles["worker_beta"])
|
||||||
|
assert seen["provider"] == "kittentts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_profile_404(client):
|
||||||
|
resp = client.post("/api/audio/tts-lease?profile=ghost", json={"lease": "desktop:read-aloud", "active": True})
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""TTS engine lifecycle driven by speech-output toggles (issue #100881).
|
||||||
|
|
||||||
|
Local engines load lazily on first synthesis, so the first spoken reply after
|
||||||
|
"read replies aloud" / voice conversation turns on pays the model load as dead
|
||||||
|
air. The toggles now hold *leases*: acquiring warms the configured provider
|
||||||
|
into the SAME cache slot synthesis reads; releasing the last lease unloads
|
||||||
|
resident local models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools import tts_tool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_lifecycle(monkeypatch):
|
||||||
|
tts_tool._reset_tts_leases_for_tests()
|
||||||
|
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||||
|
cache.clear()
|
||||||
|
yield
|
||||||
|
tts_tool._reset_tts_leases_for_tests()
|
||||||
|
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePiperVoice:
|
||||||
|
loads = 0
|
||||||
|
synthesized: list = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, model_path, use_cuda=False):
|
||||||
|
cls.loads += 1
|
||||||
|
inst = cls()
|
||||||
|
inst.model_path = model_path
|
||||||
|
return inst
|
||||||
|
|
||||||
|
def synthesize_wav(self, text, wav_file, syn_config=None):
|
||||||
|
type(self).synthesized.append(text)
|
||||||
|
wav_file.setnchannels(1)
|
||||||
|
wav_file.setsampwidth(2)
|
||||||
|
wav_file.setframerate(16000)
|
||||||
|
wav_file.writeframes(b"\x00\x00" * 160)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_piper(monkeypatch, tmp_path):
|
||||||
|
_FakePiperVoice.loads = 0
|
||||||
|
_FakePiperVoice.synthesized = []
|
||||||
|
monkeypatch.setattr(tts_tool, "_import_piper", lambda: _FakePiperVoice)
|
||||||
|
# Pretend the voice is already on disk so no download subprocess runs.
|
||||||
|
voices_dir = tmp_path / "voices"
|
||||||
|
voices_dir.mkdir()
|
||||||
|
(voices_dir / "en_US-test-medium.onnx").write_bytes(b"onnx")
|
||||||
|
(voices_dir / "en_US-test-medium.onnx.json").write_text("{}")
|
||||||
|
cfg = {"provider": "piper", "piper": {"voice": "en_US-test-medium", "voices_dir": str(voices_dir)}}
|
||||||
|
monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# warm_tts_provider: warm-up populates the exact slot synthesis reads
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_loads_piper_into_synthesis_cache(fake_piper, tmp_path):
|
||||||
|
result = tts_tool.warm_tts_provider(fake_piper)
|
||||||
|
|
||||||
|
assert result["warmed"] is True
|
||||||
|
assert result["action"] == "loaded"
|
||||||
|
assert result["provider"] == "piper"
|
||||||
|
assert _FakePiperVoice.loads == 1
|
||||||
|
assert len(tts_tool._piper_voice_cache) == 1
|
||||||
|
|
||||||
|
# The load that would have happened on the first reply is already done:
|
||||||
|
# synthesis reuses the warmed instance without loading again.
|
||||||
|
out = tts_tool._generate_piper_tts("hello", str(tmp_path / "out.wav"), fake_piper)
|
||||||
|
assert out.endswith(".wav")
|
||||||
|
assert _FakePiperVoice.loads == 1
|
||||||
|
assert _FakePiperVoice.synthesized == ["hello"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_twice_is_a_cache_hit(fake_piper):
|
||||||
|
tts_tool.warm_tts_provider(fake_piper)
|
||||||
|
second = tts_tool.warm_tts_provider(fake_piper)
|
||||||
|
|
||||||
|
assert second["action"] == "cached"
|
||||||
|
assert _FakePiperVoice.loads == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_reads_configured_provider_when_none_given(fake_piper):
|
||||||
|
result = tts_tool.warm_tts_provider()
|
||||||
|
assert result["provider"] == "piper"
|
||||||
|
assert result["action"] == "loaded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_never_raises_on_engine_failure(monkeypatch):
|
||||||
|
def _boom():
|
||||||
|
raise ImportError("No module named 'piper'")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tts_tool, "_import_piper", _boom)
|
||||||
|
result = tts_tool.warm_tts_provider({"provider": "piper"})
|
||||||
|
|
||||||
|
assert result["warmed"] is False
|
||||||
|
assert result["action"] == "error"
|
||||||
|
assert "piper" in result["error"]
|
||||||
|
assert tts_tool._piper_voice_cache == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_is_noop_for_cloud_provider_without_lazy_sdk(monkeypatch):
|
||||||
|
result = tts_tool.warm_tts_provider({"provider": "openai"})
|
||||||
|
assert result == {"provider": "openai", "warmed": False, "action": "noop"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_lazy_sdk_provider_reports_cached_when_installed(monkeypatch):
|
||||||
|
import types
|
||||||
|
|
||||||
|
fake = types.SimpleNamespace(
|
||||||
|
is_available=lambda feature: feature == "tts.edge",
|
||||||
|
ensure=lambda *a, **k: pytest.fail("ensure must not run when the SDK is present"),
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(__import__("sys").modules, "tools.lazy_deps", fake)
|
||||||
|
result = tts_tool.warm_tts_provider({"provider": "edge"})
|
||||||
|
assert result["warmed"] is True
|
||||||
|
assert result["action"] == "cached"
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_lazy_sdk_provider_installs_when_missing(monkeypatch):
|
||||||
|
import types
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
fake = types.SimpleNamespace(
|
||||||
|
is_available=lambda feature: False,
|
||||||
|
ensure=lambda feature, prompt: calls.append((feature, prompt)),
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(__import__("sys").modules, "tools.lazy_deps", fake)
|
||||||
|
result = tts_tool.warm_tts_provider({"provider": "edge"})
|
||||||
|
assert result["action"] == "installed"
|
||||||
|
assert calls == [("tts.edge", False)]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# release_tts_provider
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_drops_every_local_cache(fake_piper):
|
||||||
|
tts_tool.warm_tts_provider(fake_piper)
|
||||||
|
tts_tool._kittentts_model_cache["m"] = object()
|
||||||
|
|
||||||
|
assert tts_tool.release_tts_provider() == {"released": 2}
|
||||||
|
assert tts_tool._piper_voice_cache == {}
|
||||||
|
assert tts_tool._kittentts_model_cache == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_scoped_to_one_provider(fake_piper):
|
||||||
|
tts_tool.warm_tts_provider(fake_piper)
|
||||||
|
tts_tool._kittentts_model_cache["m"] = object()
|
||||||
|
|
||||||
|
assert tts_tool.release_tts_provider("kittentts") == {"released": 1}
|
||||||
|
assert len(tts_tool._piper_voice_cache) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_with_nothing_resident_is_zero():
|
||||||
|
assert tts_tool.release_tts_provider() == {"released": 0}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Leases: warm on acquire, unload only when the LAST holder releases
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquire_warms_and_counts(fake_piper):
|
||||||
|
result = tts_tool.acquire_tts_lease("desktop:read-aloud")
|
||||||
|
assert result["leases"] == 1
|
||||||
|
assert result["action"] == "loaded"
|
||||||
|
assert tts_tool.tts_lease_holders() == ["desktop:read-aloud"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_release_unloads_but_earlier_release_does_not(fake_piper):
|
||||||
|
tts_tool.acquire_tts_lease("desktop:read-aloud")
|
||||||
|
tts_tool.acquire_tts_lease("tui:voice-tts")
|
||||||
|
assert len(tts_tool._piper_voice_cache) == 1
|
||||||
|
|
||||||
|
# One surface turning speech off must not pull the model from under the
|
||||||
|
# other surface that still speaks through this process.
|
||||||
|
first = tts_tool.release_tts_lease("desktop:read-aloud")
|
||||||
|
assert first == {"leases": 1, "released": 0}
|
||||||
|
assert len(tts_tool._piper_voice_cache) == 1
|
||||||
|
|
||||||
|
last = tts_tool.release_tts_lease("tui:voice-tts")
|
||||||
|
assert last == {"leases": 0, "released": 1}
|
||||||
|
assert tts_tool._piper_voice_cache == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reacquire_is_idempotent_and_reheals_cache(fake_piper):
|
||||||
|
tts_tool.acquire_tts_lease("cli:voice-tts")
|
||||||
|
tts_tool.release_tts_provider() # something else dropped the model
|
||||||
|
result = tts_tool.acquire_tts_lease("cli:voice-tts")
|
||||||
|
|
||||||
|
assert result["leases"] == 1
|
||||||
|
assert result["action"] == "loaded"
|
||||||
|
assert _FakePiperVoice.loads == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_unknown_lease_is_noop(fake_piper):
|
||||||
|
tts_tool.acquire_tts_lease("a")
|
||||||
|
assert tts_tool.release_tts_lease("never-acquired") == {"leases": 1, "released": 0}
|
||||||
|
assert len(tts_tool._piper_voice_cache) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquire_failure_still_registers_lease(monkeypatch):
|
||||||
|
def _boom():
|
||||||
|
raise RuntimeError("engine missing")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tts_tool, "_import_piper", _boom)
|
||||||
|
result = tts_tool.acquire_tts_lease("desktop:conversation", {"provider": "piper"})
|
||||||
|
assert result["action"] == "error"
|
||||||
|
assert result["leases"] == 1
|
||||||
|
assert tts_tool.tts_lease_holders() == ["desktop:conversation"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Registry invariant: every local engine cache is release-able
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_local_warmer_has_a_registered_cache():
|
||||||
|
warmers = tts_tool._local_tts_warmers()
|
||||||
|
assert set(warmers) == set(tts_tool._LOCAL_TTS_MODEL_CACHES)
|
||||||
|
assert tts_tool._LOCAL_TTS_MODEL_CACHES["piper"] is tts_tool._piper_voice_cache
|
||||||
|
assert tts_tool._LOCAL_TTS_MODEL_CACHES["kittentts"] is tts_tool._kittentts_model_cache
|
||||||
+224
-27
@@ -2893,10 +2893,182 @@ def _tts_cache_get_or_load(cache: Dict[str, Any], key: str, load: Callable[[], A
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Local-engine lifecycle: warm-up / release driven by TTS-output toggles
|
||||||
|
# ===========================================================================
|
||||||
|
#
|
||||||
|
# Local engines (Piper, KittenTTS) load their model lazily on the first
|
||||||
|
# synthesis call, so the first spoken reply after a user turns on "read
|
||||||
|
# replies aloud" / a voice conversation pays the whole load (plus a voice
|
||||||
|
# download on a fresh install) as dead air before the first word. And once
|
||||||
|
# loaded, the model stays resident for the process lifetime even after every
|
||||||
|
# TTS-output toggle is off again.
|
||||||
|
#
|
||||||
|
# The toggles ARE the intent signal. Every surface that flips speech output
|
||||||
|
# on holds a *lease* here (warming the configured engine as a side effect);
|
||||||
|
# flipping it off releases the lease, and when the last lease is gone the
|
||||||
|
# local model caches are dropped. Lease-counting instead of a bare
|
||||||
|
# on/off keeps one surface's "off" from unloading a model another surface
|
||||||
|
# (TUI /voice tts, desktop read-aloud, desktop conversation) still needs —
|
||||||
|
# they share this process's caches.
|
||||||
|
#
|
||||||
|
# Cloud providers have no resident model; warming them is a no-op beyond
|
||||||
|
# making sure the lazily-installed SDK is importable (edge-tts), which is
|
||||||
|
# also first-use latency users see as silence.
|
||||||
|
|
||||||
|
# Provider name → local model cache it populates. The single registry both
|
||||||
|
# warm_tts_provider() and the release path consult — a new local engine adds
|
||||||
|
# one row here (at its cache declaration) plus a loader in
|
||||||
|
# _local_tts_warmers() and gets warm/release for free.
|
||||||
|
_LOCAL_TTS_MODEL_CACHES: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _local_tts_warmers() -> Dict[str, Callable[[Dict[str, Any]], Any]]:
|
||||||
|
# Resolved lazily: the loader functions are defined later in this module.
|
||||||
|
return {
|
||||||
|
"piper": lambda cfg: _load_piper_voice_for_config(cfg)[0],
|
||||||
|
"kittentts": lambda cfg: _load_kittentts_model_for_config(cfg)[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lazy_sdk_feature_for_provider(provider: str) -> Optional[str]:
|
||||||
|
"""tools.lazy_deps feature key for providers whose SDK installs on first use."""
|
||||||
|
return {
|
||||||
|
"edge": "tts.edge",
|
||||||
|
"elevenlabs": "tts.elevenlabs",
|
||||||
|
"mistral": "tts.mistral",
|
||||||
|
}.get(provider)
|
||||||
|
|
||||||
|
|
||||||
|
_tts_lease_lock = threading.Lock()
|
||||||
|
_tts_leases: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def warm_tts_provider(
|
||||||
|
tts_config: Optional[Dict[str, Any]] = None,
|
||||||
|
provider: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Pre-load the configured TTS provider so the next synthesis starts hot.
|
||||||
|
|
||||||
|
* Local engines (Piper, KittenTTS): resolve the configured voice/model
|
||||||
|
exactly as synthesis would (including first-use voice download) and
|
||||||
|
load it into the same LRU cache slot synthesis reads.
|
||||||
|
* Lazily-installed cloud SDKs (edge-tts, ElevenLabs, Mistral): make sure
|
||||||
|
the SDK is importable, installing it if lazy installs are allowed.
|
||||||
|
* Everything else: nothing to warm — reported as ``action: "noop"``.
|
||||||
|
|
||||||
|
Never raises; the result dict carries ``warmed`` / ``action`` / ``error``
|
||||||
|
so callers on a toggle path can log and move on. Blocking — callers on a
|
||||||
|
UI thread should run it in the background.
|
||||||
|
"""
|
||||||
|
if tts_config is None:
|
||||||
|
tts_config = _load_tts_config()
|
||||||
|
name = (provider or _get_provider(tts_config) or "").lower().strip()
|
||||||
|
result: Dict[str, Any] = {"provider": name, "warmed": False, "action": "noop"}
|
||||||
|
|
||||||
|
warmer = _local_tts_warmers().get(name)
|
||||||
|
if warmer is not None:
|
||||||
|
cache = _LOCAL_TTS_MODEL_CACHES.get(name)
|
||||||
|
before = len(cache) if cache is not None else 0
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
warmer(tts_config)
|
||||||
|
except Exception as exc: # engine missing, download failed, bad voice…
|
||||||
|
logger.warning("[TTS] warm-up for %s failed: %s", name, exc)
|
||||||
|
result.update(action="error", error=str(exc))
|
||||||
|
return result
|
||||||
|
after = len(cache) if cache is not None else 0
|
||||||
|
result.update(
|
||||||
|
warmed=True,
|
||||||
|
action="loaded" if after > before else "cached",
|
||||||
|
elapsed_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
)
|
||||||
|
logger.info("[TTS] warm-up %s: %s in %dms", name, result["action"], result["elapsed_ms"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
feature = _lazy_sdk_feature_for_provider(name)
|
||||||
|
if feature is not None:
|
||||||
|
try:
|
||||||
|
from tools.lazy_deps import ensure, is_available
|
||||||
|
|
||||||
|
if is_available(feature):
|
||||||
|
result.update(warmed=True, action="cached")
|
||||||
|
else:
|
||||||
|
ensure(feature, prompt=False)
|
||||||
|
result.update(warmed=True, action="installed")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("[TTS] SDK warm-up for %s skipped: %s", name, exc)
|
||||||
|
result.update(action="error", error=str(exc))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def release_tts_provider(provider: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Drop resident local TTS models so their memory is returned.
|
||||||
|
|
||||||
|
With ``provider`` given, only that engine's cache is cleared; otherwise
|
||||||
|
every local engine cache is. Cloud providers hold nothing to release.
|
||||||
|
Returns ``{"released": <number of model instances dropped>}``. The next
|
||||||
|
synthesis simply reloads (or a warm-up does it ahead of time).
|
||||||
|
"""
|
||||||
|
name = (provider or "").lower().strip()
|
||||||
|
released = 0
|
||||||
|
for cache_name, cache in _LOCAL_TTS_MODEL_CACHES.items():
|
||||||
|
if name and cache_name != name:
|
||||||
|
continue
|
||||||
|
released += len(cache)
|
||||||
|
cache.clear()
|
||||||
|
if released:
|
||||||
|
logger.info("[TTS] released %d resident local model(s)", released)
|
||||||
|
return {"released": released}
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_tts_lease(lease: str, tts_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
"""Register ``lease`` as a live TTS-output consumer and warm the provider.
|
||||||
|
|
||||||
|
``lease`` names the surface/toggle (e.g. ``"desktop:read-aloud"``,
|
||||||
|
``"tui:voice-tts"``). Re-acquiring an existing lease is idempotent (still
|
||||||
|
re-warms — cheap on a cache hit, and heals a cache cleared elsewhere).
|
||||||
|
"""
|
||||||
|
with _tts_lease_lock:
|
||||||
|
_tts_leases.add(lease)
|
||||||
|
holders = len(_tts_leases)
|
||||||
|
result = warm_tts_provider(tts_config)
|
||||||
|
result["leases"] = holders
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def release_tts_lease(lease: str) -> Dict[str, Any]:
|
||||||
|
"""Drop ``lease``; when it was the last one, unload resident local models.
|
||||||
|
|
||||||
|
Releasing a lease that was never acquired is a no-op (still reports the
|
||||||
|
live holder count) so surfaces can call it unconditionally on their
|
||||||
|
"off" path.
|
||||||
|
"""
|
||||||
|
with _tts_lease_lock:
|
||||||
|
_tts_leases.discard(lease)
|
||||||
|
holders = len(_tts_leases)
|
||||||
|
result: Dict[str, Any] = {"leases": holders, "released": 0}
|
||||||
|
if holders == 0:
|
||||||
|
result["released"] = release_tts_provider()["released"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def tts_lease_holders() -> List[str]:
|
||||||
|
"""Snapshot of live lease names (diagnostics / tests)."""
|
||||||
|
with _tts_lease_lock:
|
||||||
|
return sorted(_tts_leases)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_tts_leases_for_tests() -> None:
|
||||||
|
with _tts_lease_lock:
|
||||||
|
_tts_leases.clear()
|
||||||
|
|
||||||
|
|
||||||
# Module-level cache for Piper voice instances. Voices are keyed on their
|
# Module-level cache for Piper voice instances. Voices are keyed on their
|
||||||
# absolute .onnx model path so switching voices doesn't invalidate older
|
# absolute .onnx model path so switching voices doesn't invalidate older
|
||||||
# cached voices.
|
# cached voices.
|
||||||
_piper_voice_cache: Dict[str, Any] = {}
|
_piper_voice_cache: Dict[str, Any] = {}
|
||||||
|
_LOCAL_TTS_MODEL_CACHES["piper"] = _piper_voice_cache
|
||||||
|
|
||||||
|
|
||||||
def _check_piper_available() -> bool:
|
def _check_piper_available() -> bool:
|
||||||
@@ -2973,15 +3145,16 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
|
|||||||
return str(cached)
|
return str(cached)
|
||||||
|
|
||||||
|
|
||||||
def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[str, Any]]:
|
||||||
"""Generate speech using the local Piper engine.
|
"""Resolve + load (or fetch from cache) the Piper voice ``tts_config`` selects.
|
||||||
|
|
||||||
Loads the voice model once per process (cached by absolute path) and
|
Shared by synthesis and :func:`warm_tts_provider` so a warm-up populates
|
||||||
writes a WAV file. Caller is responsible for converting to MP3/Opus
|
exactly the cache slot the next synthesis call will hit — same voice
|
||||||
via ffmpeg when a different output format is required.
|
resolution, same download-on-first-use, same cache key.
|
||||||
|
|
||||||
|
Returns ``(voice, piper_config)``.
|
||||||
"""
|
"""
|
||||||
PiperVoice = _import_piper()
|
PiperVoice = _import_piper()
|
||||||
import wave
|
|
||||||
|
|
||||||
piper_config = tts_config.get("piper") or {} if isinstance(tts_config, dict) else {}
|
piper_config = tts_config.get("piper") or {} if isinstance(tts_config, dict) else {}
|
||||||
voice_name = piper_config.get("voice") or DEFAULT_PIPER_VOICE
|
voice_name = piper_config.get("voice") or DEFAULT_PIPER_VOICE
|
||||||
@@ -2991,15 +3164,6 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
|
|||||||
|
|
||||||
model_path = _resolve_piper_voice_path(voice_name, download_dir)
|
model_path = _resolve_piper_voice_path(voice_name, download_dir)
|
||||||
|
|
||||||
# Tolerant speaker_id parse: drop bad input (non-int strings, lists, dicts)
|
|
||||||
# to 0 (Piper's own default). Booleans are rejected outright — True/False
|
|
||||||
# would silently coerce to 1/0 and hide a config mistake.
|
|
||||||
_raw_speaker = piper_config.get("speaker_id", 0)
|
|
||||||
if isinstance(_raw_speaker, bool) or not isinstance(_raw_speaker, int):
|
|
||||||
speaker_id = 0
|
|
||||||
else:
|
|
||||||
speaker_id = _raw_speaker
|
|
||||||
|
|
||||||
# speaker_id is applied per-call via syn_config.speaker_id — the same
|
# speaker_id is applied per-call via syn_config.speaker_id — the same
|
||||||
# PiperVoice instance serves all speakers, so it stays out of the cache
|
# PiperVoice instance serves all speakers, so it stays out of the cache
|
||||||
# key. Multi-speaker workflows share one model load.
|
# key. Multi-speaker workflows share one model load.
|
||||||
@@ -3012,6 +3176,28 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
voice = _tts_cache_get_or_load(_piper_voice_cache, cache_key, _load_piper_voice)
|
voice = _tts_cache_get_or_load(_piper_voice_cache, cache_key, _load_piper_voice)
|
||||||
|
return voice, piper_config
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||||
|
"""Generate speech using the local Piper engine.
|
||||||
|
|
||||||
|
Loads the voice model once per process (cached by absolute path) and
|
||||||
|
writes a WAV file. Caller is responsible for converting to MP3/Opus
|
||||||
|
via ffmpeg when a different output format is required.
|
||||||
|
"""
|
||||||
|
import wave
|
||||||
|
|
||||||
|
voice, piper_config = _load_piper_voice_for_config(tts_config)
|
||||||
|
|
||||||
|
# Tolerant speaker_id parse: drop bad input (non-int strings, lists, dicts)
|
||||||
|
# to 0 (Piper's own default). Booleans are rejected outright — True/False
|
||||||
|
# would silently coerce to 1/0 and hide a config mistake.
|
||||||
|
_raw_speaker = piper_config.get("speaker_id", 0)
|
||||||
|
if isinstance(_raw_speaker, bool) or not isinstance(_raw_speaker, int):
|
||||||
|
speaker_id = 0
|
||||||
|
else:
|
||||||
|
speaker_id = _raw_speaker
|
||||||
|
|
||||||
# Optional synthesis knobs — only pass a SynthesisConfig when at least
|
# Optional synthesis knobs — only pass a SynthesisConfig when at least
|
||||||
# one advanced knob is configured, so we don't depend on a newer Piper
|
# one advanced knob is configured, so we don't depend on a newer Piper
|
||||||
@@ -3079,6 +3265,28 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
|
|||||||
|
|
||||||
# Module-level cache for KittenTTS model instance
|
# Module-level cache for KittenTTS model instance
|
||||||
_kittentts_model_cache: Dict[str, Any] = {}
|
_kittentts_model_cache: Dict[str, Any] = {}
|
||||||
|
_LOCAL_TTS_MODEL_CACHES["kittentts"] = _kittentts_model_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _load_kittentts_model_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[str, Any]]:
|
||||||
|
"""Load (or fetch from cache) the KittenTTS model ``tts_config`` selects.
|
||||||
|
|
||||||
|
Shared by synthesis and :func:`warm_tts_provider` — same model name,
|
||||||
|
same cache key. Returns ``(model, kittentts_config)``.
|
||||||
|
"""
|
||||||
|
KittenTTS = _import_kittentts()
|
||||||
|
kt_config = tts_config.get("kittentts", {}) if isinstance(tts_config, dict) else {}
|
||||||
|
kt_config = kt_config or {}
|
||||||
|
model_name = kt_config.get("model", DEFAULT_KITTENTTS_MODEL)
|
||||||
|
|
||||||
|
def _load_kittentts_model():
|
||||||
|
logger.info("[KittenTTS] Loading model: %s", model_name)
|
||||||
|
m = KittenTTS(model_name)
|
||||||
|
logger.info("[KittenTTS] Model loaded successfully")
|
||||||
|
return m
|
||||||
|
|
||||||
|
model = _tts_cache_get_or_load(_kittentts_model_cache, model_name, _load_kittentts_model)
|
||||||
|
return model, kt_config
|
||||||
|
|
||||||
|
|
||||||
def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||||
@@ -3095,22 +3303,11 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any])
|
|||||||
Returns:
|
Returns:
|
||||||
Path to the saved audio file.
|
Path to the saved audio file.
|
||||||
"""
|
"""
|
||||||
KittenTTS = _import_kittentts()
|
model, kt_config = _load_kittentts_model_for_config(tts_config)
|
||||||
kt_config = tts_config.get("kittentts", {})
|
|
||||||
model_name = kt_config.get("model", DEFAULT_KITTENTTS_MODEL)
|
|
||||||
voice = kt_config.get("voice", DEFAULT_KITTENTTS_VOICE)
|
voice = kt_config.get("voice", DEFAULT_KITTENTTS_VOICE)
|
||||||
speed = kt_config.get("speed", 1.0)
|
speed = kt_config.get("speed", 1.0)
|
||||||
clean_text = kt_config.get("clean_text", True)
|
clean_text = kt_config.get("clean_text", True)
|
||||||
|
|
||||||
# Use cached model instance if available
|
|
||||||
def _load_kittentts_model():
|
|
||||||
logger.info("[KittenTTS] Loading model: %s", model_name)
|
|
||||||
m = KittenTTS(model_name)
|
|
||||||
logger.info("[KittenTTS] Model loaded successfully")
|
|
||||||
return m
|
|
||||||
|
|
||||||
model = _tts_cache_get_or_load(_kittentts_model_cache, model_name, _load_kittentts_model)
|
|
||||||
|
|
||||||
# Generate audio (returns numpy array at 24kHz)
|
# Generate audio (returns numpy array at 24kHz)
|
||||||
audio = model.generate(text, voice=voice, speed=speed, clean_text=clean_text)
|
audio = model.generate(text, voice=voice, speed=speed, clean_text=clean_text)
|
||||||
|
|
||||||
|
|||||||
@@ -16702,6 +16702,30 @@ def _voice_tts_enabled() -> bool:
|
|||||||
return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
|
return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _tts_lease_async(lease: str, active: bool) -> None:
|
||||||
|
"""Acquire/release a TTS engine lease off the RPC thread.
|
||||||
|
|
||||||
|
Speech-output toggles are the signal that TTS is about to be needed (or
|
||||||
|
no longer is). Acquiring warms the configured provider — for local
|
||||||
|
engines that is a model load, possibly a voice download — so it must not
|
||||||
|
block the toggle's RPC reply. Release is cheap but rides the same thread
|
||||||
|
for symmetry. Best-effort: a failure here never affects the toggle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
from tools.tts_tool import acquire_tts_lease, release_tts_lease
|
||||||
|
|
||||||
|
if active:
|
||||||
|
acquire_tts_lease(lease)
|
||||||
|
else:
|
||||||
|
release_tts_lease(lease)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("voice: tts lease %s active=%s failed: %s", lease, active, e)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, name=f"tts-lease-{lease}", daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _any_session_running() -> bool:
|
def _any_session_running() -> bool:
|
||||||
"""True while any session's agent turn is in flight.
|
"""True while any session's agent turn is in flight.
|
||||||
|
|
||||||
@@ -17551,6 +17575,12 @@ def _(rid, params: dict) -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
stop_hint = ""
|
stop_hint = ""
|
||||||
|
|
||||||
|
# Voice mode with speech output already on (voice.auto_tts /
|
||||||
|
# prior /voice tts) means replies will be spoken — warm the
|
||||||
|
# engine now rather than on the first reply.
|
||||||
|
if _voice_tts_enabled():
|
||||||
|
_tts_lease_async("tui:voice-tts", True)
|
||||||
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
# Disabling the mode must tear the continuous loop down; the
|
# Disabling the mode must tear the continuous loop down; the
|
||||||
# loop holds the microphone and would otherwise keep running.
|
# loop holds the microphone and would otherwise keep running.
|
||||||
@@ -17567,6 +17597,7 @@ def _(rid, params: dict) -> dict:
|
|||||||
# and silence any in-flight streaming speech.
|
# and silence any in-flight streaming speech.
|
||||||
os.environ["HERMES_VOICE_TTS"] = "0"
|
os.environ["HERMES_VOICE_TTS"] = "0"
|
||||||
_tts_stream_stop(user_barge=False)
|
_tts_stream_stop(user_barge=False)
|
||||||
|
_tts_lease_async("tui:voice-tts", False)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
rid,
|
rid,
|
||||||
@@ -17586,6 +17617,10 @@ def _(rid, params: dict) -> dict:
|
|||||||
os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0"
|
os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0"
|
||||||
if not new_value:
|
if not new_value:
|
||||||
_tts_stream_stop(user_barge=False)
|
_tts_stream_stop(user_barge=False)
|
||||||
|
# The TTS toggle is the "speech is about to be needed" signal: on →
|
||||||
|
# pre-load the configured engine so the first reply starts hot; off →
|
||||||
|
# release the lease (last holder gone = resident local model freed).
|
||||||
|
_tts_lease_async("tui:voice-tts", new_value)
|
||||||
# Include ``record_key`` on every branch so a /voice tts toggle
|
# Include ``record_key`` on every branch so a /voice tts toggle
|
||||||
# doesn't reset the TUI's cached shortcut to the default when a
|
# doesn't reset the TUI's cached shortcut to the default when a
|
||||||
# user has a custom binding configured (Copilot review, round 2
|
# user has a custom binding configured (Copilot review, round 2
|
||||||
|
|||||||
@@ -256,6 +256,17 @@ tts:
|
|||||||
|
|
||||||
**Advanced knobs** (`tts.piper.length_scale` / `noise_scale` / `noise_w_scale` / `volume` / `normalize_audio`, `use_cuda`) correspond 1:1 to Piper's `SynthesisConfig`. They're ignored on older `piper-tts` versions.
|
**Advanced knobs** (`tts.piper.length_scale` / `noise_scale` / `noise_w_scale` / `volume` / `normalize_audio`, `use_cuda`) correspond 1:1 to Piper's `SynthesisConfig`. They're ignored on older `piper-tts` versions.
|
||||||
|
|
||||||
|
### Warm-up and unload via speech toggles (local engines)
|
||||||
|
|
||||||
|
Local engines (Piper, KittenTTS) load their model lazily, so without help the *first* spoken reply after you turn speech on pays the whole model load — and on a fresh install the voice download — as silence before the first word. Hermes treats the speech-output toggles as the signal that TTS is about to be needed:
|
||||||
|
|
||||||
|
- **Desktop** — turning on **Read replies aloud**, or starting a **voice conversation**, pre-loads the configured engine in the background right away. Turning both off again unloads the resident model (a Piper voice is tens of MB; KittenTTS up to ~80MB) so it isn't parked in RAM for nothing.
|
||||||
|
- **CLI / TUI** — `/voice tts` (and `/voice on` when `voice.auto_tts` is set) do the same; `/voice off` releases.
|
||||||
|
|
||||||
|
Each toggle holds a *lease* on the engine; the model is only unloaded when the last lease across surfaces is released, so switching off read-aloud in one Desktop window never pulls the voice out from under a conversation running in another. For cloud providers there is no model to hold — the toggle only makes sure a lazily-installed SDK (edge-tts, ElevenLabs, Mistral) is present. Warm-up is best-effort: if the engine can't load, the toggle still succeeds and the first reply falls back to loading on demand as before.
|
||||||
|
|
||||||
|
The Desktop calls `POST /api/audio/tts-lease` with `{"lease": "<name>", "active": true|false}`; other frontends can use the same endpoint.
|
||||||
|
|
||||||
### Custom command providers
|
### Custom command providers
|
||||||
|
|
||||||
If a TTS engine you want isn't natively supported (VoxCPM, MLX-Kokoro, XTTS CLI, a voice-cloning script, anything else that exposes a CLI), you can wire it in as a **command-type provider** without writing any Python. Hermes writes the input text to a temp UTF-8 file, runs your shell command, and reads the audio file the command produced.
|
If a TTS engine you want isn't natively supported (VoxCPM, MLX-Kokoro, XTTS CLI, a voice-cloning script, anything else that exposes a CLI), you can wire it in as a **command-type provider** without writing any Python. Hermes writes the input text to a temp UTF-8 file, runs your shell command, and reads the audio file the command produced.
|
||||||
|
|||||||
Reference in New Issue
Block a user