Watch
1
0
Fork
You've already forked souveraine
0

surface: per-agent TTS voice + turn clock + reply-handler fix

Speech resolves voice per active agent (agents[].voice_id from the list),
with the system voice as fallback — two agents on a host need not match.
Souveraine.qml carries voice_id into the agents map and adds a turn clock
(turnStartedAt/turnElapsedMs) + a 60s agent-inventory refresh. Ai.qml
declares pendingFilePath and imports qs.modules.common.functions so
StringUtils.ttsClean no longer aborts the stream-finished handler.
This commit is contained in:
Fimeg 2026-07-30 09:18:56 -04:00
commit 0ef239515f
4 changed files with 137 additions and 16 deletions

View file

@ -20,6 +20,25 @@ Item {
property var suggestionQuery: ""
property var suggestionList: []
// Turn clock formatting. A local 9B can take minutes on a cold prompt, so
// the readout has to stay legible from milliseconds to tens of minutes.
function formatTurnDuration(ms) {
if (ms < 1000)
return `${ms}ms`;
const seconds = ms / 1000;
if (seconds < 60)
return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
const rest = Math.floor(seconds % 60);
return `${minutes}m ${rest < 10 ? "0" : ""}${rest}s`;
}
function formatClockTime(msEpoch) {
if (!msEpoch)
return "—";
return new Date(msEpoch).toLocaleTimeString(Qt.locale(), "HH:mm:ss");
}
onFocusChanged: focus => {
if (focus) {
root.inputField.forceActiveFocus();
@ -369,7 +388,18 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
visible: Ai.tokenCount.total > 0
icon: "token"
statusText: Ai.tokenCount.total
description: Translation.tr("Total token count\nInput: %1\nOutput: %2").arg(Ai.tokenCount.input).arg(Ai.tokenCount.output)
description: Translation.tr("Context estimate: %1 tokens\n(Local count of the conversation — not the provider's usage numbers)").arg(Ai.tokenCount.total)
}
StatusSeparator {
visible: Souveraine.turnStartedAt > 0
}
StatusItem {
visible: Souveraine.turnStartedAt > 0
icon: Souveraine.turnActive ? "hourglass_top" : "timer"
statusText: root.formatTurnDuration(Souveraine.turnElapsedMs)
description: Souveraine.turnActive
? Translation.tr("Waiting on the agent\nSent at %1").arg(root.formatClockTime(Souveraine.turnStartedAt))
: Translation.tr("Last turn took %1\nSent at %2").arg(root.formatTurnDuration(Souveraine.turnElapsedMs)).arg(root.formatClockTime(Souveraine.turnStartedAt))
}
}
}

View file

@ -3,6 +3,11 @@ pragma ComponentBehavior: Bound
import qs
import qs.modules.common
// StringUtils.ttsClean() is called in the stream-finished handler. Without
// this import it is a ReferenceError that aborts the rest of that handler —
// postResponseHook and saveChat never run, so a turn that actually succeeded
// looks stuck. Every other service imports it unnamespaced; match that.
import qs.modules.common.functions
import Quickshell
import Quickshell.Io
import QtQuick
@ -60,6 +65,13 @@ Singleton {
property list<var> savedChats: []
property list<var> pendingFiles: []
// AttachedFileIndicator binds `filePath` straight to this. ii-base
// declares it; this override did not, so every chat panel logged
// "Unable to assign [undefined] to QString" and the chip could never
// render. Declared here even though the send path is still text-only —
// a missing property is a broken binding, not a disabled feature.
property string pendingFilePath: ""
// Tool selection is owned by the agent's sensorium; keep the UI happy.
property string currentTool: "souveraine"
property list<var> availableTools: ["souveraine"]
@ -612,7 +624,18 @@ Singleton {
}
function attachFile(filePath) {
root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet."), root.interfaceRole);
// Clearing is always honoured — AttachedFileIndicator's remove button
// calls attachFile(""), and that must not read as a failed attach.
root.pendingFilePath = "";
if (!filePath || filePath.length === 0)
return;
// The refusal is accurate, not lazy: the HTTP message API carries
// `content: String` (src/api/models.rs), so an image cannot cross the
// surface boundary even though the provider client already speaks
// OpenAI image_url parts and Kitty's model has vision. Fixing that is
// a server change, so don't show a chip for a file that will never be
// sent.
root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet — the message API carries text only."), root.interfaceRole);
}
function removePendingFile(file) {
root.pendingFiles = root.pendingFiles.filter(f => f !== file);

View file

@ -47,6 +47,21 @@ Singleton {
property string conversationId: ""
property bool turnActive: false
// ── Turn clock ───────────────────────────────────────────────────────
// Wall-clock for the request in flight. Lives here because turnActive
// does; the chat surface and the pill both read it. `turnStartedAt` is
// when we handed the request to curl (ms epoch, 0 = nothing sent yet),
// `turnElapsedMs` ticks while the turn runs and freezes at the total.
property double turnStartedAt: 0
property int turnElapsedMs: 0
Timer {
running: root.turnActive
interval: 100
repeat: true
onTriggered: root.turnElapsedMs = Date.now() - root.turnStartedAt
}
/* Raw wire events (message_type-tagged objects from the SSE stream). */
signal streamEvent(var event)
/* Stream closed (process exit). exitCode 0 = clean. */
@ -71,7 +86,10 @@ Singleton {
try {
const list = JSON.parse(text);
const map = {};
list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "" }; });
// voice_id comes from the agent's `_souveraine` block via
// the public list endpoint. None/absent = use the system
// voice. Speech reads this per active agent.
list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "", "voice_id": a.voice_id ?? "" }; });
root.agents = map;
root.agentList = Object.keys(map);
root.serverUp = true;
@ -100,6 +118,18 @@ Singleton {
getAgents.running = true;
}
// The inventory used to be fetched exactly once, at shell start, so an
// agent created afterwards stayed invisible until the whole shell was
// reloaded. One curl a minute is cheaper than that surprise. Skipped
// while a turn is in flight so a slow local model isn't competing with
// polling for the server's attention.
Timer {
interval: 60000
repeat: true
running: true
onTriggered: if (!root.turnActive) root.refreshAgents()
}
// ── Server autostart ─────────────────────────────────────────────────
// systemd user unit first (survives shell restarts, journald logging);
// bare nohup fallback for systems without it. One attempt per shell
@ -365,6 +395,8 @@ Singleton {
requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath);
requesterScriptFile.setText(scriptContent);
requester.command = ["bash", shellScriptPath];
root.turnStartedAt = Date.now();
root.turnElapsedMs = 0;
root.turnActive = true;
requester.running = true;
}
@ -385,6 +417,10 @@ Singleton {
}
}
onExited: (exitCode, exitStatus) => {
// Freeze the clock on the real total — the 100ms tick can be up to
// one interval behind when the process exits.
if (root.turnStartedAt > 0)
root.turnElapsedMs = Date.now() - root.turnStartedAt;
root.turnActive = false;
root.streamClosed(exitCode);
}

View file

@ -9,12 +9,13 @@ import QtQuick
/**
* Speech — TTS egress for the shell (the Read Aloud seam, TASK-18).
*
* Souveraine owns the who→voice mapping: VoiceConfig in the consciousness
* config carries tts_url and voice_id, served live at /v1/config. This
* service reads that mapping on every speak() and posts the text to
* VibeVoice's /audio/speech (JSON {input, voice, model}, mp3 back — the
* same contract core/voice/client.rs speaks), then plays the result. The
* shell picks no voice of its own; souveraine decides how she sounds.
* Souveraine owns the who→voice mapping. The TTS endpoint (tts_url) is a
* substrate concern, read from VoiceConfig at /v1/config. The VOICE is a
* per-agent concern: each agent's [_souveraine].voice_id rides on the public
* agent list, and on every speak()/prefetch() this service resolves it — the
* active agent's own voice wins, the system voice_id (/v1/config) is the
* fallback for an agent who hasn't set one. The shell picks no voice of its
* own; souveraine decides how each agent sounds, and they need not match.
*
* Config.options.speech.tts.enable is the user's kill switch (settings →
* Speech); Config.options.speech.tts.endpoint, when set, overrides the
@ -39,6 +40,13 @@ Singleton {
// Resolved endpoint + voice from the last /v1/config lookup, kept so a
// transient synth failure can be retried without re-resolving.
property string _synthUrl: ""
// The substrate's system voice, from /v1/config. This is the fallback —
// the voice of "no agent picked" or an agent who hasn't set her own.
property string _systemVoice: ""
// The voice actually used this turn: the active agent's own voice if she
// has one ([_souveraine].voice_id via the agent list), else the system
// voice. Recomputed at every speak()/prefetch() so switching agents
// switches voice without a re-fetch — the agent list is already local.
property string _synthVoice: ""
// Retry cap: the VibeVoice server can intermittently return 500 (concurrency
// on its shared model — now server-locked, but a collision still costs one
@ -78,9 +86,11 @@ Singleton {
root._pendingText = t;
root._synthAttempts = 0;
root._readyText = "";
// If the endpoint+voice are already resolved, skip the /v1/config
// round-trip and go straight to synthesis.
if (root._synthUrl.length > 0 && root._synthVoice.length > 0) {
// If the endpoint is already resolved, skip the /v1/config round-trip
// and go straight to synthesis. Voice is recomputed locally — the
// agent may have switched since the last turn — so it's never the gate.
if (root._synthUrl.length > 0) {
root._applyVoice();
root._fireSynth(false /*interactive*/);
} else {
voiceLookup.running = true;
@ -103,9 +113,11 @@ Singleton {
// Mark this as a background prefetch so voiceLookup chains into
// _fireSynth(prefetch=true) instead of treating it as interactive.
synthProc._prefetch = true;
// If the endpoint+voice are already resolved, go straight to synth;
// otherwise resolve via /v1/config first.
if (root._synthUrl.length > 0 && root._synthVoice.length > 0) {
// If the endpoint is already resolved, go straight to synth; voice is
// recomputed locally for the current agent. Otherwise resolve via
// /v1/config first.
if (root._synthUrl.length > 0) {
root._applyVoice();
root._fireSynth(true /*prefetch*/);
} else {
voiceLookup.running = true;
@ -119,6 +131,22 @@ Singleton {
retryTimer.running = false;
}
// The active agent's own voice, if she has one. The shell picks no voice
// of its own: souveraine owns the who→voice mapping, and that mapping is
// per-agent now — [_souveraine].voice_id rides on the public agent list.
// "" means "this agent sounds like the substrate" → system-voice fallback.
function _agentVoice() {
const a = Souveraine.agents[Souveraine.currentAgentId];
return (a && a.voice_id && a.voice_id.length > 0) ? a.voice_id : "";
}
// Recompute the turn's voice from the active agent + cached system voice.
// Called on every speak()/prefetch() (agent may have switched since last
// turn) and after a /v1/config fetch. No fetch here — both inputs are local.
function _applyVoice() {
root._synthVoice = root._agentVoice() || root._systemVoice;
}
// Play whatever is in the cache file.
function _play() {
playProc.running = true;
@ -173,7 +201,11 @@ Singleton {
return;
}
root._synthUrl = ttsUrl;
root._synthVoice = voice;
// `voice` here is the SYSTEM voice — the substrate speaking as
// itself. Stash it as the fallback, then let the active agent
// override it: she sounds like herself when she has a voice.
root._systemVoice = voice;
root._applyVoice();
// If speak() was the caller, synth+play; if prefetch() was, the
// flag is already on synthProc from _fireSynth — but voiceLookup
// can be the first hop of a prefetch, so default to interactive.